feat(learn): metered embeddings and the cache (decision-engine 6.2)

plane/models/openrouter-embedding.yaml: openai/text-embedding-3-small via
OpenRouter (embeddings only), input_per_m 0.02, cache/output 0, egress
openrouter.ai; `openrouter` joins the provider extensions.

plane/budget.py MeteredEmbeddingClient: prompt_tokens priced from the
Model manifest, usd/calls ceilings stop the next call, a priced call that
reports no tokens raises UnmeteredUsage (a silent zero is an instrument
failure). OpenRouterEmbeddingClient keeps one usage row per call
(parse_embedding_payload); its api key is out of repr.

learn/embeddings.py + `ousast learn memory embed`: excerpts embedded in
batches into embeddings/<model-slug>/<excerpt_sha>.json (float32 base64),
cached by content hash so a rerun makes no call; the declared dimension is
checked; --dry-run estimates tokens and cost without a call; a paid run
needs --budget-usd and exits 3 (unfinished) at its ceiling. Not run against
the provider: the store embedding waits for its budget go-ahead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-10-01 09:56:52 +02:00
co-authored by Claude Opus 5.5
parent 7b81c9ac4e
commit 98fa4ec8cd
11 changed files with 498 additions and 11 deletions
+85 -2
View File
@@ -10,7 +10,7 @@ failure is `AccountError` (the task is `failed` and the run starts nothing furth
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from typing import Any, Protocol
from ..model.endpoint import Prices
from ..tool_hunter import ChatClient, ChatResponse
@@ -160,4 +160,87 @@ def _tokens(value: object) -> int:
return int(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0
__all__ = ["AccountError", "BudgetExhausted", "MeteredClient", "cost_of", "is_account_error", "prices_from"]
class EmbeddingClient(Protocol):
def embed(self, *, model: str, inputs: list[str], timeout_seconds: int = 60) -> list[list[float]]: ...
class UnmeteredUsage(RuntimeError):
"""A priced embedding call whose provider reported no tokens: a working-looking zero, never a free call."""
class MeteredEmbeddingClient:
"""An embedding client metered like :class:`MeteredClient`: the wrapped client records one usage dict per call
in a ``usage`` list (``OpenRouterEmbeddingClient`` does), ``prompt_tokens`` are priced at the Model manifest's
``input_per_m``, and the usd/calls ceilings stop the next call. A priced call that reports no tokens raises
:class:`UnmeteredUsage` (the 2026-09 lesson: a silent zero is an instrument failure)."""
def __init__(
self,
inner: EmbeddingClient,
*,
prices: Mapping[str, object] | Prices | None = None,
budget_usd: float | None = None,
budget_calls: int | None = None,
require_usage: bool = True,
) -> None:
self.inner = inner
self.prices = prices_from(prices)
if budget_usd is not None and self.prices is None:
raise ValueError("unpriced model cannot run under a usd budget")
self.budget_usd, self.budget_calls, self.require_usage = budget_usd, budget_calls, require_usage
self.calls = 0
self.tokens = 0
self._usd = 0.0
self._seen_rows = len(self._inner_rows())
@property
def usd(self) -> float | None:
return self._usd if self.prices is not None else None
def check(self) -> None:
if self.budget_usd is not None and self._usd >= self.budget_usd:
raise BudgetExhausted(f"usd budget exhausted: spent {self._usd:.6f} of {self.budget_usd:.6f}", usd=self._usd, calls=self.calls)
if self.budget_calls is not None and self.calls >= self.budget_calls:
raise BudgetExhausted(f"calls budget exhausted: {self.calls} of {self.budget_calls}", usd=self.usd, calls=self.calls)
def embed(self, *, model: str, inputs: list[str], timeout_seconds: int = 60) -> list[list[float]]:
self.check()
try:
vectors = self.inner.embed(model=model, inputs=inputs, timeout_seconds=timeout_seconds)
except Exception as exc:
self._absorb_rows()
if is_account_error(exc):
raise AccountError(str(exc)) from exc
raise
self.calls += 1
tokens = self._absorb_rows()
if self.require_usage and self.prices is not None and tokens == 0 and inputs:
raise UnmeteredUsage(
f"embedding call {self.calls} reported no prompt tokens for {len(inputs)} inputs: the meter cannot price it"
)
return vectors
def summary(self) -> dict[str, Any]:
return {"usd": self.usd, "calls": self.calls, "usage": {"prompt_tokens": self.tokens}, "priced": self.prices is not None}
def _inner_rows(self) -> list[Any]:
rows = getattr(self.inner, "usage", None)
return rows if isinstance(rows, list) else []
def _absorb_rows(self) -> int:
rows = self._inner_rows()
tokens = 0
for row in rows[self._seen_rows :]:
if isinstance(row, Mapping):
tokens += _tokens(row.get("prompt_tokens") or row.get("total_tokens"))
self._seen_rows = len(rows)
self.tokens += tokens
if self.prices is not None:
self._usd += tokens * self.prices.input_per_m / 1_000_000
return tokens
__all__ = [
"AccountError", "BudgetExhausted", "EmbeddingClient", "MeteredClient", "MeteredEmbeddingClient", "UnmeteredUsage", "cost_of",
"is_account_error", "prices_from",
] # fmt: skip
+1 -1
View File
@@ -59,7 +59,7 @@ GIT_COMMITS_ANNOTATION = "openultrasast.io/git-commits"
EGRESS_HOSTS_ANNOTATION = "openultrasast.io/egress-hosts" # the Model API hosts a bound task may reach
_DNS_NAME = re.compile(r"^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$")
AX_PROVIDERS = frozenset({"google", "anthropic"})
EXTENSION_PROVIDERS = frozenset({"deepseek"})
EXTENSION_PROVIDERS = frozenset({"deepseek", "openrouter"}) # openrouter: embeddings only (decision engine)
_API_VERSIONS = {"Task": AX_API_VERSION, "Workspace": AX_API_VERSION, "Model": AX_API_VERSION, "Run": RUN_API_VERSION}