aid-runtime-python 0.1.39__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. aid_runtime/__init__.py +500 -0
  2. aid_runtime/agents/__init__.py +94 -0
  3. aid_runtime/agents/chat_model.py +351 -0
  4. aid_runtime/agents/deep_agents.py +600 -0
  5. aid_runtime/agents/engine.py +102 -0
  6. aid_runtime/agents/guardrails.py +519 -0
  7. aid_runtime/agents/ir.py +622 -0
  8. aid_runtime/agents/loop.py +1308 -0
  9. aid_runtime/agents/memory.py +241 -0
  10. aid_runtime/agents/policy.py +163 -0
  11. aid_runtime/agents/state.py +182 -0
  12. aid_runtime/cache.py +185 -0
  13. aid_runtime/concurrency.py +70 -0
  14. aid_runtime/control.py +329 -0
  15. aid_runtime/cost.py +168 -0
  16. aid_runtime/errors.py +337 -0
  17. aid_runtime/evals/__init__.py +72 -0
  18. aid_runtime/evals/dataset.py +77 -0
  19. aid_runtime/evals/gate.py +430 -0
  20. aid_runtime/evals/metrics.py +335 -0
  21. aid_runtime/evals/predicate.py +211 -0
  22. aid_runtime/evals/runner.py +224 -0
  23. aid_runtime/gateway.py +473 -0
  24. aid_runtime/pipeline.py +168 -0
  25. aid_runtime/prompts.py +185 -0
  26. aid_runtime/providers/__init__.py +20 -0
  27. aid_runtime/providers/base.py +57 -0
  28. aid_runtime/providers/fake.py +256 -0
  29. aid_runtime/providers/http.py +344 -0
  30. aid_runtime/py.typed +0 -0
  31. aid_runtime/retrieval/__init__.py +178 -0
  32. aid_runtime/retrieval/chunking.py +222 -0
  33. aid_runtime/retrieval/citations.py +131 -0
  34. aid_runtime/retrieval/connectors.py +131 -0
  35. aid_runtime/retrieval/embedding.py +293 -0
  36. aid_runtime/retrieval/gate.py +120 -0
  37. aid_runtime/retrieval/hybrid.py +164 -0
  38. aid_runtime/retrieval/metrics.py +213 -0
  39. aid_runtime/retrieval/pgvector.py +258 -0
  40. aid_runtime/retrieval/rerank.py +133 -0
  41. aid_runtime/retrieval/retriever.py +335 -0
  42. aid_runtime/retrieval/stores.py +161 -0
  43. aid_runtime/retrieval/types.py +433 -0
  44. aid_runtime/steps.py +284 -0
  45. aid_runtime/structured.py +150 -0
  46. aid_runtime/tools.py +271 -0
  47. aid_runtime/tracing.py +241 -0
  48. aid_runtime/types.py +332 -0
  49. aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
  50. aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
  51. aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
  52. aid_runtime_python-0.1.39.dist-info/top_level.txt +1 -0
@@ -0,0 +1,224 @@
1
+ """Running a dataset through a gateway, once or N times.
2
+
3
+ The split with :mod:`aid_runtime.evals.gate` is deliberate: this module *produces*
4
+ numbers, the gate *judges* them. Keeping them apart is what lets the sampling
5
+ loop stay boring and the decision logic stay testable without a provider.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import statistics
11
+ from collections.abc import Awaitable, Callable, Sequence
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ from aid_runtime.errors import EvalError
16
+ from aid_runtime.evals.dataset import Dataset, EvalCase
17
+ from aid_runtime.evals.gate import EvalRun, MetricRun
18
+ from aid_runtime.evals.metrics import Metric, MetricResult
19
+ from aid_runtime.gateway import ModelGateway
20
+ from aid_runtime.types import CompletionRequest, Message
21
+
22
+ __all__ = [
23
+ "CaseOutcome",
24
+ "DatasetRun",
25
+ "GatewayRunner",
26
+ "Runner",
27
+ "evaluate_once",
28
+ "gateway_judge",
29
+ "run_evaluation",
30
+ ]
31
+
32
+ #: Anything that turns a case into a prediction.
33
+ Runner = Callable[[EvalCase], Awaitable[str]]
34
+
35
+
36
+ class CaseOutcome(BaseModel):
37
+ """One case's prediction and its per-metric scores, for inspection."""
38
+
39
+ model_config = ConfigDict(frozen=True, extra="forbid")
40
+
41
+ case_id: str
42
+ prediction: str
43
+ scores: tuple[MetricResult, ...] = ()
44
+
45
+ @property
46
+ def passed(self) -> bool:
47
+ return all(score.passed for score in self.scores)
48
+
49
+
50
+ class DatasetRun(BaseModel):
51
+ """A single pass over a dataset, keeping per-case detail.
52
+
53
+ ``run_evaluation`` only needs the means, but a failing gate is useless without
54
+ knowing *which* cases moved, so the detail is kept here and summarised there.
55
+ """
56
+
57
+ model_config = ConfigDict(frozen=True, extra="forbid")
58
+
59
+ dataset: str
60
+ dataset_version: str
61
+ outcomes: tuple[CaseOutcome, ...] = Field(min_length=1)
62
+
63
+ def means(self) -> dict[str, float]:
64
+ """Mean score per metric across the cases of this pass."""
65
+ collected: dict[str, list[float]] = {}
66
+ for outcome in self.outcomes:
67
+ for score in outcome.scores:
68
+ collected.setdefault(score.metric, []).append(score.score)
69
+ return {name: statistics.fmean(values) for name, values in collected.items()}
70
+
71
+ def failures(self) -> tuple[CaseOutcome, ...]:
72
+ return tuple(outcome for outcome in self.outcomes if not outcome.passed)
73
+
74
+ def outcome(self, case_id: str) -> CaseOutcome:
75
+ for outcome in self.outcomes:
76
+ if outcome.case_id == case_id:
77
+ return outcome
78
+ raise EvalError(f"no outcome recorded for case {case_id!r}")
79
+
80
+ def summary(self) -> str:
81
+ lines = [
82
+ f"{self.dataset}@{self.dataset_version}: "
83
+ f"{len(self.outcomes) - len(self.failures())}/{len(self.outcomes)} cases passed"
84
+ ]
85
+ for name, mean in sorted(self.means().items()):
86
+ lines.append(f" {name}: {mean:.4f}")
87
+ for outcome in self.failures():
88
+ failed = [score for score in outcome.scores if not score.passed]
89
+ detail = "; ".join(f"{score.metric}: {score.detail}" for score in failed)
90
+ lines.append(f" FAIL {outcome.case_id}: {detail}")
91
+ return "\n".join(lines)
92
+
93
+
94
+ class GatewayRunner:
95
+ """Adapts a :class:`ModelGateway` into a :data:`Runner`.
96
+
97
+ ``request_for`` exists so a caller can supply a prompt-registry-backed request
98
+ (with its own system message and version) instead of the default
99
+ one-message-for-``case.input`` shape. Keeping it a parameter rather than a
100
+ subclass is enough: the only thing that varies is how a case becomes a
101
+ request.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ gateway: ModelGateway,
107
+ alias: str,
108
+ *,
109
+ system: str | None = None,
110
+ temperature: float | None = None,
111
+ max_output_tokens: int | None = None,
112
+ request_for: Callable[[EvalCase], CompletionRequest] | None = None,
113
+ fallbacks: Sequence[str] = (),
114
+ ) -> None:
115
+ gateway.resolve(alias)
116
+ self._gateway = gateway
117
+ self._alias = alias
118
+ self._fallbacks = tuple(fallbacks)
119
+ self._temperature = temperature
120
+ self._max_output_tokens = max_output_tokens
121
+ self._system = None if request_for is not None else system
122
+ self._request_for: Callable[[EvalCase], CompletionRequest] = (
123
+ self._default_request if request_for is None else request_for
124
+ )
125
+
126
+ @property
127
+ def alias(self) -> str:
128
+ return self._alias
129
+
130
+ @property
131
+ def cost_usd(self) -> float:
132
+ return self._gateway.ledger.cost_usd
133
+
134
+ async def __call__(self, case: EvalCase) -> str:
135
+ request = self._request_for(case)
136
+ # Evals are cache-off by default: a cached prediction is a false pass, not
137
+ # a measurement of the model. The ``use_cache`` opt-out exists precisely so
138
+ # a runner can insist on a live call without the caller knowing the cache
139
+ # is there at all.
140
+ result = await self._gateway.generate(
141
+ self._alias, request, fallbacks=self._fallbacks, use_cache=False
142
+ )
143
+ return result.text
144
+
145
+ def _default_request(self, case: EvalCase) -> CompletionRequest:
146
+ messages: list[Message] = []
147
+ if self._system is not None:
148
+ messages.append(Message(role="system", content=self._system))
149
+ messages.append(Message(role="user", content=case.input))
150
+ return CompletionRequest(
151
+ messages=tuple(messages),
152
+ temperature=self._temperature,
153
+ max_output_tokens=self._max_output_tokens,
154
+ )
155
+
156
+
157
+ async def gateway_judge(
158
+ gateway: ModelGateway,
159
+ alias: str,
160
+ *,
161
+ system: str | None = None,
162
+ ) -> Callable[[str], Awaitable[str]]:
163
+ """A :data:`JudgeCall` backed by the gateway, for :class:`LlmJudge`.
164
+
165
+ Note what this does *not* do: it does not make the judge deterministic. It is
166
+ still unfit for a CI threshold.
167
+ """
168
+
169
+ async def call(prompt: str) -> str:
170
+ request = CompletionRequest.prompt(prompt, system=system)
171
+ result = await gateway.generate(alias, request, use_cache=False)
172
+ return result.text
173
+
174
+ return call
175
+
176
+
177
+ async def evaluate_once(dataset: Dataset, runner: Runner, metrics: Sequence[Metric]) -> DatasetRun:
178
+ """One pass over ``dataset``, scoring every prediction with every metric."""
179
+ if not metrics:
180
+ raise ValueError("at least one metric is required")
181
+ outcomes: list[CaseOutcome] = []
182
+ for case in dataset.cases:
183
+ prediction = await runner(case)
184
+ scores = tuple([await metric.score(case, prediction) for metric in metrics])
185
+ outcomes.append(CaseOutcome(case_id=case.id, prediction=prediction, scores=scores))
186
+ return DatasetRun(
187
+ dataset=dataset.name, dataset_version=dataset.version, outcomes=tuple(outcomes)
188
+ )
189
+
190
+
191
+ async def run_evaluation(
192
+ dataset: Dataset,
193
+ runner: Runner,
194
+ metrics: Sequence[Metric],
195
+ *,
196
+ runs: int = 5,
197
+ ) -> EvalRun:
198
+ """Sample ``dataset`` ``runs`` times, recording one mean per metric per run.
199
+
200
+ Every run makes fresh calls, which is the point: the spread across runs is the
201
+ measurement of how much this system varies when nothing has changed. Without
202
+ it there is nothing to compare a "regression" against.
203
+ """
204
+ if runs < 1:
205
+ raise ValueError("runs must be at least 1")
206
+ if not metrics:
207
+ raise ValueError("at least one metric is required")
208
+
209
+ collected: dict[str, list[float]] = {metric.name: [] for metric in metrics}
210
+ for _ in range(runs):
211
+ pass_result = await evaluate_once(dataset, runner, metrics)
212
+ means = pass_result.means()
213
+ for metric in metrics:
214
+ collected[metric.name].append(means[metric.name])
215
+
216
+ return EvalRun(
217
+ dataset=dataset.name,
218
+ dataset_version=dataset.version,
219
+ runs=runs,
220
+ metrics=tuple(
221
+ MetricRun(metric=metric.name, values=tuple(collected[metric.name]))
222
+ for metric in metrics
223
+ ),
224
+ )
aid_runtime/gateway.py ADDED
@@ -0,0 +1,473 @@
1
+ """The model gateway: alias resolution, capability negotiation, cost accounting.
2
+
3
+ Three responsibilities, in this order:
4
+
5
+ 1. **Pinning.** An alias resolves to an exact provider model id. Unknown aliases
6
+ are an error; there is no fall-through to a provider default, because a
7
+ provider default is precisely the floating version a stored eval baseline
8
+ cannot survive.
9
+ 2. **Ordered fallback.** A caller may name fallback aliases. They are tried in the
10
+ order given, and the aliases actually attempted are reported on the result.
11
+ Escalation routing -- "use the strong model if the cheap one is unsure" -- is
12
+ *not* implemented: "unsure" needs an agent loop to define, which is Phase 2.
13
+ 3. **Budgets and tracing.** Every charged call opens a GenAI span and updates the
14
+ ledger.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from collections.abc import AsyncIterator, Callable, Iterable, Mapping, Sequence
21
+ from contextlib import asynccontextmanager
22
+ from typing import Any
23
+
24
+ from aid_runtime.cache import CompletionCache, cache_key_for
25
+ from aid_runtime.concurrency import ConcurrencyLimiter
26
+ from aid_runtime.cost import Budget, CostLedger, LedgerEntry, estimate_prompt_cost_usd
27
+ from aid_runtime.errors import (
28
+ BudgetExceededError,
29
+ CapabilityMismatchError,
30
+ ConfigurationError,
31
+ ProviderError,
32
+ UnknownModelAliasError,
33
+ )
34
+ from aid_runtime.providers.base import Provider
35
+ from aid_runtime.tracing import ATTRIBUTES, get_tracer, llm_span, record_cost, record_usage
36
+ from aid_runtime.types import (
37
+ Capability,
38
+ CompletionRequest,
39
+ CompletionResponse,
40
+ FinishReason,
41
+ GenerationResult,
42
+ ModelSpec,
43
+ PinnedModel,
44
+ TokenUsage,
45
+ )
46
+
47
+ __all__ = ["ModelGateway", "estimate_usage"]
48
+
49
+ #: The fixed chunk size a cached stream is re-chunked into, so a cache hit still
50
+ #: streams as several deltas rather than one blob.
51
+ _CACHE_CHUNK = 16
52
+
53
+ #: Callback after every successfully charged call. Lets a generated app log or
54
+ #: forward spend without the gateway knowing anything about logging.
55
+ CallHook = Callable[[LedgerEntry], None]
56
+
57
+
58
+ def _sse(payload: dict[str, Any]) -> bytes:
59
+ """Encode one Server-Sent Event ``data`` frame as UTF-8 bytes."""
60
+ return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n".encode()
61
+
62
+
63
+ class ModelGateway:
64
+ """Resolves aliases to pinned models and executes calls within a budget."""
65
+
66
+ def __init__(
67
+ self,
68
+ providers: Mapping[str, Provider],
69
+ aliases: Mapping[str, ModelSpec | PinnedModel],
70
+ *,
71
+ ledger: CostLedger | None = None,
72
+ budget: Budget | None = None,
73
+ on_call: Iterable[CallHook] = (),
74
+ tracer_name: str = "aid_runtime",
75
+ limiter: ConcurrencyLimiter | None = None,
76
+ ) -> None:
77
+ if not providers:
78
+ raise ConfigurationError("the gateway needs at least one provider")
79
+ if not aliases:
80
+ raise ConfigurationError("the gateway needs at least one pinned alias")
81
+
82
+ self._providers = dict(providers)
83
+ self._aliases: dict[str, ModelSpec] = {}
84
+ for alias, value in aliases.items():
85
+ spec = value.spec if isinstance(value, PinnedModel) else value
86
+ if spec.provider not in self._providers:
87
+ raise ConfigurationError(
88
+ f"alias {alias!r} names provider {spec.provider!r}, "
89
+ f"which is not among {sorted(self._providers)}"
90
+ )
91
+ self._aliases[alias] = spec
92
+
93
+ self._ledger = ledger if ledger is not None else CostLedger()
94
+ self._budget = budget
95
+ self._hooks = tuple(on_call)
96
+ self._tracer_name = tracer_name
97
+ self._cache = CompletionCache()
98
+ self._limiter = limiter
99
+
100
+ @property
101
+ def aliases(self) -> tuple[str, ...]:
102
+ return tuple(sorted(self._aliases))
103
+
104
+ @property
105
+ def ledger(self) -> CostLedger:
106
+ return self._ledger
107
+
108
+ @property
109
+ def budget(self) -> Budget | None:
110
+ return self._budget
111
+
112
+ def resolve(self, alias: str) -> PinnedModel:
113
+ """Resolve an alias to its pinned model, or fail loudly."""
114
+ try:
115
+ spec = self._aliases[alias]
116
+ except KeyError:
117
+ raise UnknownModelAliasError(alias, self.aliases) from None
118
+ return PinnedModel(alias=alias, spec=spec)
119
+
120
+ def provider_for(self, alias: str) -> Provider:
121
+ """The adapter that serves ``alias``. Raises for an unknown alias."""
122
+ return self._providers[self.resolve(alias).spec.provider]
123
+
124
+ def require(self, alias: str, capabilities: Iterable[Capability]) -> PinnedModel:
125
+ """Assert a pinned model *and* its adapter advertise every capability.
126
+
127
+ Both sides are checked: a model that supports tool calling behind an
128
+ adapter that does not serialise tool definitions cannot tool-call.
129
+ """
130
+ pinned = self.resolve(alias)
131
+ required = frozenset(capabilities)
132
+ missing = required - pinned.spec.capabilities
133
+ missing |= required - self._providers[pinned.spec.provider].capabilities
134
+ if missing:
135
+ raise CapabilityMismatchError(alias, sorted(c.value for c in missing))
136
+ return pinned
137
+
138
+ async def generate(
139
+ self,
140
+ alias: str,
141
+ request: CompletionRequest,
142
+ *,
143
+ fallbacks: Sequence[str] = (),
144
+ budget: Budget | None = None,
145
+ use_cache: bool = True,
146
+ ) -> GenerationResult:
147
+ """Run one call, falling back through ``fallbacks`` in order.
148
+
149
+ A fallback is only tried for a failure another model could plausibly
150
+ survive: a provider error or a missing capability. A
151
+ :class:`~aid_runtime.errors.BudgetExceededError` propagates immediately --
152
+ retrying a different model cannot make an exhausted budget affordable.
153
+
154
+ ``use_cache`` opts out of the cache (default: on). It exists for callers
155
+ whose result must reflect a live call -- evals in particular, where a
156
+ cached answer would be a false pass.
157
+ """
158
+ chain = [alias, *fallbacks]
159
+ errors: list[str] = []
160
+ active_budget = budget if budget is not None else self._budget
161
+
162
+ for index, candidate in enumerate(chain):
163
+ pinned = self.resolve(candidate)
164
+ try:
165
+ return await self._attempt(
166
+ pinned, request, active_budget, chain[: index + 1], use_cache
167
+ )
168
+ except (ProviderError, CapabilityMismatchError) as exc:
169
+ errors.append(f"{candidate}: {exc}")
170
+ if index == len(chain) - 1:
171
+ raise ProviderError(
172
+ f"every candidate failed ({len(chain)} tried): {'; '.join(errors)}"
173
+ ) from exc
174
+
175
+ raise ProviderError(f"no candidate produced a response: {'; '.join(errors)}")
176
+
177
+ def projected_cost_usd(self, alias: str, request: CompletionRequest) -> float:
178
+ """Spend so far plus the prompt price of ``request`` on ``alias``.
179
+
180
+ This is the *same* estimate :meth:`_preflight` refuses on, exposed so an
181
+ agent loop can decide to route to a cheaper alias before the gateway would
182
+ refuse the call. One estimate, one accounting path.
183
+ """
184
+ spec = self.resolve(alias).spec
185
+ return self._ledger.cost_usd + sum(
186
+ estimate_prompt_cost_usd(spec, message.content) for message in request.messages
187
+ )
188
+
189
+ def fits(self, alias: str, request: CompletionRequest, budget: Budget | None) -> bool:
190
+ """Whether ``request`` on ``alias`` still fits ``budget``'s dollar ceiling.
191
+
192
+ Only dollars are projected: switching *alias* changes price, never token
193
+ count, so token ceilings cannot be improved by a cheaper model. A budget
194
+ with only a token ceiling therefore always "fits" here and is enforced by
195
+ :meth:`CostLedger.charge`, exactly as the gateway does it.
196
+ """
197
+ if budget is None or budget.max_usd is None:
198
+ return True
199
+ if self._ledger.cost_usd > budget.max_usd:
200
+ return False
201
+ return self.projected_cost_usd(alias, request) <= budget.max_usd
202
+
203
+ async def stream(
204
+ self,
205
+ alias: str,
206
+ request: CompletionRequest,
207
+ *,
208
+ budget: Budget | None = None,
209
+ use_cache: bool = True,
210
+ ) -> AsyncIterator[bytes]:
211
+ """Stream one call as Server-Sent Event ``data`` frames, accounting exactly
212
+ like :meth:`generate`.
213
+
214
+ Each frame is ``data: <json>\\n\\n``: one frame per text delta (payload
215
+ ``{"delta": "..."}``) so the first frame reaches the wire before the call
216
+ completes, then a terminal frame carrying ``done``, the finish reason, the
217
+ served model/provider and the usage. A cache hit re-chunks the cached text
218
+ and charges nothing. Cancellation propagates from the consumer and closes
219
+ the stream, releasing the shared concurrency slot.
220
+
221
+ Only models *and* adapters advertising ``streaming`` are served; anything
222
+ else raises :class:`CapabilityMismatchError` rather than silently falling
223
+ back to a non-streaming response.
224
+ """
225
+ pinned = self.require(alias, (Capability.STREAMING,))
226
+ spec = pinned.spec
227
+ provider = self._providers[spec.provider]
228
+ active_budget = budget if budget is not None else self._budget
229
+
230
+ if active_budget is not None:
231
+ self._ledger.check(active_budget)
232
+ self._preflight(pinned, request, active_budget)
233
+
234
+ cached = self._cache_get(spec, request) if use_cache else None
235
+ if cached is not None:
236
+ async for frame in self._stream_cached(pinned, cached):
237
+ yield frame
238
+ return
239
+
240
+ text_parts: list[str] = []
241
+ usage = TokenUsage(input_tokens=0, output_tokens=0)
242
+ finish_reason: FinishReason = "stop"
243
+
244
+ async with self._limit():
245
+ with llm_span(
246
+ model=spec.name,
247
+ provider=spec.provider,
248
+ alias=pinned.alias,
249
+ temperature=request.temperature,
250
+ max_tokens=request.max_output_tokens,
251
+ tracer=get_tracer(self._tracer_name),
252
+ ) as span:
253
+ async for chunk in provider.stream(request, model=spec):
254
+ if chunk.delta:
255
+ text_parts.append(chunk.delta)
256
+ yield _sse({"delta": chunk.delta})
257
+ if chunk.usage is not None:
258
+ usage = chunk.usage
259
+ if chunk.finish_reason is not None:
260
+ finish_reason = chunk.finish_reason
261
+
262
+ text = "".join(text_parts)
263
+ if usage.total_tokens == 0:
264
+ usage = TokenUsage(
265
+ input_tokens=sum(
266
+ (len(message.content) + 3) // 4 for message in request.messages
267
+ ),
268
+ output_tokens=(len(text) + 3) // 4,
269
+ )
270
+ cost = spec.cost.cost_usd(usage)
271
+ record_usage(span, usage)
272
+ record_cost(span, cost, alias=pinned.alias)
273
+
274
+ entry = LedgerEntry(
275
+ alias=pinned.alias,
276
+ model=spec.name,
277
+ provider=spec.provider,
278
+ usage=usage,
279
+ cost_usd=cost,
280
+ )
281
+ self._ledger.charge(active_budget, entry)
282
+ for hook in self._hooks:
283
+ hook(entry)
284
+
285
+ if use_cache:
286
+ self._cache_put(
287
+ spec,
288
+ request,
289
+ CompletionResponse(
290
+ text=text,
291
+ model=spec.name,
292
+ provider=spec.provider,
293
+ usage=usage,
294
+ finish_reason=finish_reason,
295
+ ),
296
+ )
297
+
298
+ yield _sse(
299
+ {
300
+ "done": True,
301
+ "finish_reason": finish_reason,
302
+ "model": spec.name,
303
+ "provider": spec.provider,
304
+ }
305
+ )
306
+
307
+ async def aclose(self) -> None:
308
+ for provider in self._providers.values():
309
+ await provider.aclose()
310
+
311
+ async def _attempt(
312
+ self,
313
+ pinned: PinnedModel,
314
+ request: CompletionRequest,
315
+ budget: Budget | None,
316
+ attempted: Sequence[str],
317
+ use_cache: bool,
318
+ ) -> GenerationResult:
319
+ if budget is not None:
320
+ self._ledger.check(budget)
321
+ self._preflight(pinned, request, budget)
322
+
323
+ spec = pinned.spec
324
+ provider = self._providers[spec.provider]
325
+
326
+ if use_cache:
327
+ cached = self._cache_get(spec, request)
328
+ if cached is not None:
329
+ return self._cache_result(pinned, cached, attempted)
330
+
331
+ async with self._limit():
332
+ with llm_span(
333
+ model=spec.name,
334
+ provider=spec.provider,
335
+ alias=pinned.alias,
336
+ temperature=request.temperature,
337
+ max_tokens=request.max_output_tokens,
338
+ tracer=get_tracer(self._tracer_name),
339
+ ) as span:
340
+ response = await provider.complete(request, model=spec)
341
+ cost = spec.cost.cost_usd(response.usage)
342
+ record_usage(span, response.usage)
343
+ record_cost(span, cost, alias=pinned.alias)
344
+ if len(attempted) > 1:
345
+ span.set_attribute(ATTRIBUTES["attempts"], list(attempted))
346
+
347
+ entry = LedgerEntry(
348
+ alias=pinned.alias,
349
+ model=response.model,
350
+ provider=response.provider,
351
+ usage=response.usage,
352
+ cost_usd=cost,
353
+ )
354
+ self._ledger.charge(budget, entry)
355
+ for hook in self._hooks:
356
+ hook(entry)
357
+
358
+ if use_cache:
359
+ self._cache_put(spec, request, response)
360
+
361
+ return GenerationResult(
362
+ response=response,
363
+ alias=pinned.alias,
364
+ cost_usd=cost,
365
+ attempts=tuple(attempted),
366
+ )
367
+
368
+ @asynccontextmanager
369
+ async def _limit(self) -> AsyncIterator[None]:
370
+ """Acquire the shared concurrency slot, releasing it on cancel or error.
371
+
372
+ A gateway constructed without a limiter is unbounded (no-op), which keeps
373
+ plain request/response usage free of concurrency surprises.
374
+ """
375
+ if self._limiter is None:
376
+ yield
377
+ return
378
+ async with self._limiter.run():
379
+ yield
380
+
381
+ def _cache_get(self, spec: ModelSpec, request: CompletionRequest) -> CompletionResponse | None:
382
+ cfg = spec.cache
383
+ if cfg is None:
384
+ return None
385
+ if cfg.prompt:
386
+ exact = self._cache.get(cache_key_for(spec.name, request))
387
+ if exact is not None:
388
+ return exact
389
+ if cfg.semantic is not None:
390
+ return self._cache.semantic_get(
391
+ spec,
392
+ request,
393
+ threshold=cfg.semantic.threshold,
394
+ ttl_seconds=cfg.semantic.ttl_seconds,
395
+ )
396
+ return None
397
+
398
+ def _cache_put(
399
+ self, spec: ModelSpec, request: CompletionRequest, response: CompletionResponse
400
+ ) -> None:
401
+ cfg = spec.cache
402
+ if cfg is None or (not cfg.prompt and cfg.semantic is None):
403
+ return
404
+ self._cache.put(cache_key_for(spec.name, request), response, request)
405
+
406
+ def _cache_result(
407
+ self, pinned: PinnedModel, response: CompletionResponse, attempted: Sequence[str]
408
+ ) -> GenerationResult:
409
+ """Build a cache-hit result: trace-visible, zero cost, no budget charge."""
410
+ with llm_span(
411
+ model=response.model,
412
+ provider=response.provider,
413
+ alias=pinned.alias,
414
+ tracer=get_tracer(self._tracer_name),
415
+ ) as span:
416
+ span.set_attribute("aid.cache.hit", True)
417
+ if len(attempted) > 1:
418
+ span.set_attribute(ATTRIBUTES["attempts"], list(attempted))
419
+ return GenerationResult(
420
+ response=response,
421
+ alias=pinned.alias,
422
+ cost_usd=0.0,
423
+ attempts=tuple(attempted),
424
+ cache_hit=True,
425
+ )
426
+
427
+ async def _stream_cached(
428
+ self, pinned: PinnedModel, response: CompletionResponse
429
+ ) -> AsyncIterator[bytes]:
430
+ with llm_span(
431
+ model=response.model,
432
+ provider=response.provider,
433
+ alias=pinned.alias,
434
+ tracer=get_tracer(self._tracer_name),
435
+ ) as span:
436
+ span.set_attribute("aid.cache.hit", True)
437
+ text = response.text
438
+ for start in range(0, len(text), _CACHE_CHUNK):
439
+ yield _sse({"delta": text[start : start + _CACHE_CHUNK]})
440
+ yield _sse(
441
+ {
442
+ "done": True,
443
+ "finish_reason": response.finish_reason,
444
+ "model": response.model,
445
+ "provider": response.provider,
446
+ }
447
+ )
448
+
449
+ def clear_cache(self) -> None:
450
+ """Empty the prompt and semantic caches.
451
+
452
+ Exposed for callers -- tests and evals in particular -- that need a known
453
+ cold cache rather than an accidental hit from an earlier call.
454
+ """
455
+ self._cache.clear()
456
+
457
+ def _preflight(self, pinned: PinnedModel, request: CompletionRequest, budget: Budget) -> None:
458
+ """Refuse a call whose *prompt* alone already breaches the budget.
459
+
460
+ Only the prompt is priced, and deliberately over-estimated, because the
461
+ response is unknowable before the call. This catches gross overruns early;
462
+ the post-call charge is what enforces the ceiling exactly.
463
+ """
464
+ if budget.max_usd is None:
465
+ return
466
+ projected = self.projected_cost_usd(pinned.alias, request)
467
+ if projected > budget.max_usd:
468
+ raise BudgetExceededError(budget.scope, budget.max_usd, projected)
469
+
470
+
471
+ def estimate_usage(texts: Sequence[str]) -> TokenUsage:
472
+ """Token estimate for a set of strings, at the usual four characters per token."""
473
+ return TokenUsage(input_tokens=sum((len(text) + 3) // 4 for text in texts), output_tokens=0)