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.
- aid_runtime/__init__.py +500 -0
- aid_runtime/agents/__init__.py +94 -0
- aid_runtime/agents/chat_model.py +351 -0
- aid_runtime/agents/deep_agents.py +600 -0
- aid_runtime/agents/engine.py +102 -0
- aid_runtime/agents/guardrails.py +519 -0
- aid_runtime/agents/ir.py +622 -0
- aid_runtime/agents/loop.py +1308 -0
- aid_runtime/agents/memory.py +241 -0
- aid_runtime/agents/policy.py +163 -0
- aid_runtime/agents/state.py +182 -0
- aid_runtime/cache.py +185 -0
- aid_runtime/concurrency.py +70 -0
- aid_runtime/control.py +329 -0
- aid_runtime/cost.py +168 -0
- aid_runtime/errors.py +337 -0
- aid_runtime/evals/__init__.py +72 -0
- aid_runtime/evals/dataset.py +77 -0
- aid_runtime/evals/gate.py +430 -0
- aid_runtime/evals/metrics.py +335 -0
- aid_runtime/evals/predicate.py +211 -0
- aid_runtime/evals/runner.py +224 -0
- aid_runtime/gateway.py +473 -0
- aid_runtime/pipeline.py +168 -0
- aid_runtime/prompts.py +185 -0
- aid_runtime/providers/__init__.py +20 -0
- aid_runtime/providers/base.py +57 -0
- aid_runtime/providers/fake.py +256 -0
- aid_runtime/providers/http.py +344 -0
- aid_runtime/py.typed +0 -0
- aid_runtime/retrieval/__init__.py +178 -0
- aid_runtime/retrieval/chunking.py +222 -0
- aid_runtime/retrieval/citations.py +131 -0
- aid_runtime/retrieval/connectors.py +131 -0
- aid_runtime/retrieval/embedding.py +293 -0
- aid_runtime/retrieval/gate.py +120 -0
- aid_runtime/retrieval/hybrid.py +164 -0
- aid_runtime/retrieval/metrics.py +213 -0
- aid_runtime/retrieval/pgvector.py +258 -0
- aid_runtime/retrieval/rerank.py +133 -0
- aid_runtime/retrieval/retriever.py +335 -0
- aid_runtime/retrieval/stores.py +161 -0
- aid_runtime/retrieval/types.py +433 -0
- aid_runtime/steps.py +284 -0
- aid_runtime/structured.py +150 -0
- aid_runtime/tools.py +271 -0
- aid_runtime/tracing.py +241 -0
- aid_runtime/types.py +332 -0
- aid_runtime_python-0.1.39.dist-info/METADATA +268 -0
- aid_runtime_python-0.1.39.dist-info/RECORD +52 -0
- aid_runtime_python-0.1.39.dist-info/WHEEL +5 -0
- 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)
|