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
aid_runtime/pipeline.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""A plain-async pipeline engine.
|
|
2
|
+
|
|
3
|
+
This is the Phase 0 answer to "the loop". It is a sequential reducer over an
|
|
4
|
+
immutable context: each step receives the context and returns the context,
|
|
5
|
+
optionally enriched. No graph, no scheduler, no orchestration dependency.
|
|
6
|
+
|
|
7
|
+
That is a deliberate ceiling. When Phase 2 needs branching, retries and
|
|
8
|
+
human-in-the-loop interrupts an orchestration library is the right tool -- but
|
|
9
|
+
adopting one before those requirements exist would place an unobservable graph
|
|
10
|
+
between the user and their own trace.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass, field, replace
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from aid_runtime.errors import PipelineError
|
|
21
|
+
from aid_runtime.tracing import get_tracer, record_exception
|
|
22
|
+
|
|
23
|
+
__all__ = ["STEPS_KEY", "Pipeline", "PipelineContext", "Step", "StopPipeline"]
|
|
24
|
+
|
|
25
|
+
#: Context key under which :meth:`Pipeline.run` records the steps that ran, in order.
|
|
26
|
+
STEPS_KEY = "steps"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StopPipeline(Exception): # noqa: N818 - control-flow signal, not an error condition
|
|
30
|
+
"""Raise from a step to end the pipeline successfully and early."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, reason: str = "") -> None:
|
|
33
|
+
super().__init__(reason or "pipeline stopped")
|
|
34
|
+
self.reason = reason
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class PipelineContext:
|
|
39
|
+
"""The value threaded through a pipeline.
|
|
40
|
+
|
|
41
|
+
Frozen, so a step cannot mutate what an earlier step observed; it returns a
|
|
42
|
+
new context instead. That keeps a failed run's state inspectable and makes
|
|
43
|
+
replaying a pipeline reproducible.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
values: Mapping[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
49
|
+
return self.values.get(key, default)
|
|
50
|
+
|
|
51
|
+
def require(self, key: str) -> Any:
|
|
52
|
+
try:
|
|
53
|
+
return self.values[key]
|
|
54
|
+
except KeyError:
|
|
55
|
+
raise PipelineError(f"pipeline context has no {key!r}") from None
|
|
56
|
+
|
|
57
|
+
def with_values(self, **values: Any) -> PipelineContext:
|
|
58
|
+
return replace(self, values={**self.values, **values})
|
|
59
|
+
|
|
60
|
+
def without(self, *keys: str) -> PipelineContext:
|
|
61
|
+
return replace(self, values={k: v for k, v in self.values.items() if k not in keys})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: What a step may produce: a new context, a mapping to merge in, or nothing.
|
|
65
|
+
StepResult = PipelineContext | Mapping[str, Any] | None
|
|
66
|
+
|
|
67
|
+
#: A step: an async (or plain) callable from context to a step result.
|
|
68
|
+
Step = Callable[[PipelineContext], StepResult | Awaitable[StepResult]]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Pipeline:
|
|
72
|
+
"""Runs named steps in order, tracing each one."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
steps: Sequence[tuple[str, Step]],
|
|
77
|
+
*,
|
|
78
|
+
tracer_name: str = "aid_runtime",
|
|
79
|
+
record_steps: bool = True,
|
|
80
|
+
) -> None:
|
|
81
|
+
if not steps:
|
|
82
|
+
raise PipelineError("a pipeline needs at least one step")
|
|
83
|
+
seen: set[str] = set()
|
|
84
|
+
for name, _ in steps:
|
|
85
|
+
if name in seen:
|
|
86
|
+
raise PipelineError(f"duplicate step name {name!r}")
|
|
87
|
+
seen.add(name)
|
|
88
|
+
self._steps = tuple(steps)
|
|
89
|
+
self._tracer_name = tracer_name
|
|
90
|
+
self._record_steps = record_steps
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def step_names(self) -> tuple[str, ...]:
|
|
94
|
+
return tuple(name for name, _ in self._steps)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def of(cls, *steps: Step, tracer_name: str = "aid_runtime") -> Pipeline:
|
|
98
|
+
"""Build a pipeline from bare callables, naming each by its function."""
|
|
99
|
+
return cls([(_step_name(step), step) for step in steps], tracer_name=tracer_name)
|
|
100
|
+
|
|
101
|
+
def then(self, name: str, step: Step) -> Pipeline:
|
|
102
|
+
"""A new pipeline with ``step`` appended. This instance is unchanged."""
|
|
103
|
+
return Pipeline(
|
|
104
|
+
[*self._steps, (name, step)],
|
|
105
|
+
tracer_name=self._tracer_name,
|
|
106
|
+
record_steps=self._record_steps,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def run(
|
|
110
|
+
self, initial: PipelineContext | Mapping[str, Any] | None = None
|
|
111
|
+
) -> PipelineContext:
|
|
112
|
+
"""Execute every step, stopping early if one raises :class:`StopPipeline`.
|
|
113
|
+
|
|
114
|
+
A step that raises anything else aborts the run and the original exception
|
|
115
|
+
is chained, so the traceback shows both the pipeline coordinate and the
|
|
116
|
+
real cause.
|
|
117
|
+
"""
|
|
118
|
+
context = _as_context(initial)
|
|
119
|
+
ran: list[str] = []
|
|
120
|
+
tracer = get_tracer(self._tracer_name)
|
|
121
|
+
|
|
122
|
+
for name, step in self._steps:
|
|
123
|
+
with tracer.start_as_current_span(
|
|
124
|
+
f"pipeline.{name}",
|
|
125
|
+
# The failing step's own exception is recorded below; the automatic
|
|
126
|
+
# recording would add a second event for the PipelineError wrapper
|
|
127
|
+
# and would hide the original exception type.
|
|
128
|
+
record_exception=False,
|
|
129
|
+
set_status_on_exception=False,
|
|
130
|
+
) as span:
|
|
131
|
+
span.set_attribute("aid.pipeline.step", name)
|
|
132
|
+
try:
|
|
133
|
+
context = _as_context(await _resolve(step(context)))
|
|
134
|
+
except StopPipeline as stop:
|
|
135
|
+
ran.append(name)
|
|
136
|
+
if stop.reason:
|
|
137
|
+
span.set_attribute("aid.pipeline.stop_reason", stop.reason)
|
|
138
|
+
return self._finish(context, ran)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
record_exception(span, exc)
|
|
141
|
+
raise PipelineError(f"step {name!r} failed: {exc}") from exc
|
|
142
|
+
ran.append(name)
|
|
143
|
+
|
|
144
|
+
return self._finish(context, ran)
|
|
145
|
+
|
|
146
|
+
def _finish(self, context: PipelineContext, ran: Sequence[str]) -> PipelineContext:
|
|
147
|
+
return context.with_values(**{STEPS_KEY: tuple(ran)}) if self._record_steps else context
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _resolve(value: StepResult | Awaitable[StepResult]) -> StepResult:
|
|
151
|
+
return await value if inspect.isawaitable(value) else value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _as_context(value: Any) -> PipelineContext:
|
|
155
|
+
if isinstance(value, PipelineContext):
|
|
156
|
+
return value
|
|
157
|
+
if value is None:
|
|
158
|
+
return PipelineContext()
|
|
159
|
+
if isinstance(value, Mapping):
|
|
160
|
+
return PipelineContext(values=dict(value))
|
|
161
|
+
raise PipelineError(
|
|
162
|
+
f"a pipeline step must return a PipelineContext, a mapping or None, "
|
|
163
|
+
f"got {type(value).__name__}"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _step_name(step: Step) -> str:
|
|
168
|
+
return getattr(step, "__name__", None) or type(step).__name__
|
aid_runtime/prompts.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Versioned prompt registry with strict typed rendering.
|
|
2
|
+
|
|
3
|
+
Rendering is *strict*: a template that references a variable nobody supplied, or
|
|
4
|
+
a caller that supplies a variable the template never uses, is an error. In a
|
|
5
|
+
framework whose promise is that regenerating an app is safe, a silently-blank
|
|
6
|
+
prompt variable is exactly the class of bug that must not survive to runtime.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import string
|
|
12
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
13
|
+
from typing import Self
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from aid_runtime.errors import PromptNotFoundError, PromptRenderError
|
|
18
|
+
|
|
19
|
+
__all__ = ["Example", "PromptRegistry", "PromptTemplate", "RenderedPrompt"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Example(BaseModel):
|
|
23
|
+
"""One few-shot example. Rendered into the prompt, not interpolated as text."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
26
|
+
|
|
27
|
+
input: str
|
|
28
|
+
output: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PromptTemplate(BaseModel):
|
|
32
|
+
"""A versioned prompt with declared variables and optional few-shot examples."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
35
|
+
|
|
36
|
+
name: str = Field(min_length=1)
|
|
37
|
+
version: str = Field(min_length=1)
|
|
38
|
+
template: str = Field(min_length=1)
|
|
39
|
+
description: str = ""
|
|
40
|
+
examples: tuple[Example, ...] = ()
|
|
41
|
+
#: Variable names that must be supplied even when the template body omits them.
|
|
42
|
+
#: Empty by default: the template body is the source of truth.
|
|
43
|
+
required: tuple[str, ...] = ()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def variables(self) -> frozenset[str]:
|
|
47
|
+
"""Every field name the template body interpolates."""
|
|
48
|
+
try:
|
|
49
|
+
parsed = string.Formatter().parse(self.template)
|
|
50
|
+
return frozenset(field_name for _, field_name, _, _ in parsed if field_name is not None)
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
# A brace the formatter cannot parse is a broken template, not a bad
|
|
53
|
+
# variable, so it is reported as the render error the caller handles
|
|
54
|
+
# rather than escaping as a bare ValueError.
|
|
55
|
+
raise PromptRenderError(
|
|
56
|
+
f"prompt {self.name!r} v{self.version} has a malformed template: {exc}"
|
|
57
|
+
) from exc
|
|
58
|
+
|
|
59
|
+
def render(self, **values: str) -> RenderedPrompt:
|
|
60
|
+
"""Interpolate ``values``, rejecting missing and unused variables."""
|
|
61
|
+
expected = self.variables
|
|
62
|
+
required = expected | frozenset(self.required)
|
|
63
|
+
supplied = set(values)
|
|
64
|
+
|
|
65
|
+
missing = sorted(name for name in required if name not in supplied)
|
|
66
|
+
if missing:
|
|
67
|
+
raise PromptRenderError(
|
|
68
|
+
f"prompt {self.name!r} v{self.version} is missing variables: {', '.join(missing)}"
|
|
69
|
+
)
|
|
70
|
+
# ``required`` is subtracted here as well as added above: a variable the
|
|
71
|
+
# caller was told to supply without an interpolation site is supplied
|
|
72
|
+
# deliberately, not "unused".
|
|
73
|
+
unused = sorted(supplied - required)
|
|
74
|
+
if unused:
|
|
75
|
+
raise PromptRenderError(
|
|
76
|
+
f"prompt {self.name!r} v{self.version} received unused variables: "
|
|
77
|
+
f"{', '.join(unused)}; template uses {', '.join(sorted(required)) or '<none>'}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
body = self.template.format(**values)
|
|
82
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
83
|
+
raise PromptRenderError(
|
|
84
|
+
f"prompt {self.name!r} v{self.version} failed to render: {exc}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
return RenderedPrompt(
|
|
88
|
+
name=self.name,
|
|
89
|
+
version=self.version,
|
|
90
|
+
text=body,
|
|
91
|
+
examples=self.examples,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RenderedPrompt(BaseModel):
|
|
96
|
+
"""A prompt ready to send, carrying the version that produced it.
|
|
97
|
+
|
|
98
|
+
The version travels with the text so a trace can answer "which prompt was
|
|
99
|
+
this?" -- without it, a baseline diff cannot distinguish a model change from
|
|
100
|
+
a prompt change.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
version: str
|
|
107
|
+
text: str
|
|
108
|
+
examples: tuple[Example, ...] = ()
|
|
109
|
+
|
|
110
|
+
def as_messages(self, *, system: str | None = None) -> tuple[tuple[str, str], ...]:
|
|
111
|
+
"""Flatten to ``(role, content)`` pairs, examples inlined as few-shot turns."""
|
|
112
|
+
messages: list[tuple[str, str]] = []
|
|
113
|
+
if system is not None:
|
|
114
|
+
messages.append(("system", system))
|
|
115
|
+
for example in self.examples:
|
|
116
|
+
messages.append(("user", example.input))
|
|
117
|
+
messages.append(("assistant", example.output))
|
|
118
|
+
messages.append(("user", self.text))
|
|
119
|
+
return tuple(messages)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class PromptRegistry:
|
|
123
|
+
"""A registry keyed by prompt name, with per-name version pinning."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, templates: Iterable[PromptTemplate] = ()) -> None:
|
|
126
|
+
self._by_name: dict[str, dict[str, PromptTemplate]] = {}
|
|
127
|
+
for template in templates:
|
|
128
|
+
self.register(template)
|
|
129
|
+
|
|
130
|
+
def register(self, template: PromptTemplate) -> Self:
|
|
131
|
+
"""Add a template. Re-registering the same name+version is an error."""
|
|
132
|
+
versions = self._by_name.setdefault(template.name, {})
|
|
133
|
+
if template.version in versions:
|
|
134
|
+
raise PromptRenderError(
|
|
135
|
+
f"prompt {template.name!r} version {template.version!r} is already registered"
|
|
136
|
+
)
|
|
137
|
+
versions[template.version] = template
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def names(self) -> tuple[str, ...]:
|
|
141
|
+
return tuple(sorted(self._by_name))
|
|
142
|
+
|
|
143
|
+
def versions(self, name: str) -> tuple[str, ...]:
|
|
144
|
+
return tuple(sorted(self._by_name.get(name, {})))
|
|
145
|
+
|
|
146
|
+
def latest(self, name: str) -> PromptTemplate:
|
|
147
|
+
"""The highest registered version, compared numerically per segment."""
|
|
148
|
+
versions = ordered_versions(self.versions(name))
|
|
149
|
+
if not versions:
|
|
150
|
+
raise PromptNotFoundError(name, None, self.names())
|
|
151
|
+
return self._by_name[name][versions[-1]]
|
|
152
|
+
|
|
153
|
+
def get(self, name: str, version: str | None = None) -> PromptTemplate:
|
|
154
|
+
if version is None:
|
|
155
|
+
return self.latest(name)
|
|
156
|
+
try:
|
|
157
|
+
return self._by_name[name][version]
|
|
158
|
+
except KeyError:
|
|
159
|
+
raise PromptNotFoundError(name, version, self.versions(name)) from None
|
|
160
|
+
|
|
161
|
+
def render(
|
|
162
|
+
self,
|
|
163
|
+
name: str,
|
|
164
|
+
*,
|
|
165
|
+
version: str | None = None,
|
|
166
|
+
values: Mapping[str, str] | None = None,
|
|
167
|
+
) -> RenderedPrompt:
|
|
168
|
+
return self.get(name, version).render(**(dict(values) if values else {}))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def ordered_versions(versions: Sequence[str]) -> tuple[str, ...]:
|
|
172
|
+
"""Sort version strings numerically per dotted segment.
|
|
173
|
+
|
|
174
|
+
String sorting puts ``v10`` before ``v9``; a prompt registry that silently
|
|
175
|
+
resolves "latest" to the wrong version is worse than one that refuses.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def key(version: str) -> tuple[tuple[int, int, str], ...]:
|
|
179
|
+
segments: list[tuple[int, int, str]] = []
|
|
180
|
+
for segment in version.lstrip("v").split("."):
|
|
181
|
+
digits = "".join(char for char in segment if char.isdigit())
|
|
182
|
+
segments.append((0, int(digits), "") if digits else (1, 0, segment))
|
|
183
|
+
return tuple(segments)
|
|
184
|
+
|
|
185
|
+
return tuple(sorted(versions, key=key))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Provider adapters.
|
|
2
|
+
|
|
3
|
+
Exactly two ship in Phase 0: one real HTTP adapter and one deterministic fake for
|
|
4
|
+
tests and the eval-gate fixture. Provider breadth (Anthropic, Azure, Bedrock) is
|
|
5
|
+
deliberately out of scope.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from aid_runtime.providers.base import Provider
|
|
11
|
+
from aid_runtime.providers.fake import FakeProvider, ScriptedTurn
|
|
12
|
+
from aid_runtime.providers.http import DEFAULT_BASE_URL, OpenAICompatibleProvider
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_BASE_URL",
|
|
16
|
+
"FakeProvider",
|
|
17
|
+
"OpenAICompatibleProvider",
|
|
18
|
+
"Provider",
|
|
19
|
+
"ScriptedTurn",
|
|
20
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Provider adapter contract.
|
|
2
|
+
|
|
3
|
+
A provider translates the runtime's shapes into one wire protocol. It knows
|
|
4
|
+
nothing about aliases, budgets, or tracing -- those live in the gateway, so an
|
|
5
|
+
adapter stays a thin, independently testable translation layer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from aid_runtime.types import (
|
|
14
|
+
Capability,
|
|
15
|
+
CompletionRequest,
|
|
16
|
+
CompletionResponse,
|
|
17
|
+
ModelSpec,
|
|
18
|
+
StreamChunk,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = ["Provider"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class Provider(Protocol):
|
|
26
|
+
"""A single upstream model vendor endpoint."""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
"""Identifier referenced by :attr:`ModelSpec.provider`."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def capabilities(self) -> frozenset[Capability]:
|
|
35
|
+
"""What this endpoint can do at all, before per-model negotiation."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def complete(self, request: CompletionRequest, *, model: ModelSpec) -> CompletionResponse:
|
|
39
|
+
"""Perform one request/response call.
|
|
40
|
+
|
|
41
|
+
Implementations must not retry in a way that hides which model answered:
|
|
42
|
+
the gateway owns fallback and reports every attempt it made.
|
|
43
|
+
"""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def stream(self, request: CompletionRequest, *, model: ModelSpec) -> AsyncIterator[StreamChunk]:
|
|
47
|
+
"""Stream one call as text deltas, ending with a usage-bearing chunk.
|
|
48
|
+
|
|
49
|
+
Only adapters that advertise :attr:`Capability.STREAMING` implement this.
|
|
50
|
+
The first delta must be yielded before the call completes, and the final
|
|
51
|
+
chunk carries ``finish_reason`` and ``usage`` so the gateway can account
|
|
52
|
+
for the stream exactly as it accounts for :meth:`complete`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
async def aclose(self) -> None:
|
|
56
|
+
"""Release transport resources. Must be idempotent."""
|
|
57
|
+
...
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""A deterministic provider for tests.
|
|
2
|
+
|
|
3
|
+
This is not a mock in the "returns a canned value" sense. It is a *seeded
|
|
4
|
+
simulation* of a probabilistic model: given the same seed and the same request it
|
|
5
|
+
produces byte-identical output, but it deliberately answers correctly only a
|
|
6
|
+
configurable fraction of the time. That is the whole point -- it lets the eval
|
|
7
|
+
gate be tested against genuine non-determinism, so a test can prove that run-to-run
|
|
8
|
+
variation is tolerated while a real regression is caught.
|
|
9
|
+
|
|
10
|
+
A *script* is the second way to be deterministic, and the one an agent loop needs:
|
|
11
|
+
a real model's tool calls are the part of its output that a canned text answer
|
|
12
|
+
cannot express, so :class:`ScriptedTurn` scripts them turn by turn. An off-by-one
|
|
13
|
+
in a loop's step accounting is invisible against a provider that can only answer.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import random
|
|
20
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from aid_runtime.errors import ProviderError
|
|
25
|
+
from aid_runtime.types import (
|
|
26
|
+
Capability,
|
|
27
|
+
CompletionRequest,
|
|
28
|
+
CompletionResponse,
|
|
29
|
+
FinishReason,
|
|
30
|
+
ModelSpec,
|
|
31
|
+
StreamChunk,
|
|
32
|
+
TokenUsage,
|
|
33
|
+
ToolCall,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = ["FakeProvider", "ScriptedTurn"]
|
|
37
|
+
|
|
38
|
+
#: The fixed chunk size :meth:`FakeProvider.stream` splits text into, so a test can
|
|
39
|
+
#: assert that a long answer streams as several deltas rather than one blob.
|
|
40
|
+
_STREAM_CHUNK = 4
|
|
41
|
+
|
|
42
|
+
# A responder sees the request and returns the model's text. Receiving the index
|
|
43
|
+
# of the call lets a responder model "the third call fails" without mutable state.
|
|
44
|
+
Responder = Callable[[CompletionRequest, int], str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class ScriptedTurn:
|
|
49
|
+
"""One scripted model turn: some text, some tool calls, or both.
|
|
50
|
+
|
|
51
|
+
``calls`` is ``(tool name, arguments)`` pairs rather than :class:`ToolCall`
|
|
52
|
+
because a script should not have to invent unique call ids -- the provider
|
|
53
|
+
derives ``call_<turn>_<position>`` from the turn's position in the script, so
|
|
54
|
+
two turns calling the same tool still produce distinguishable ids.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
text: str = ""
|
|
58
|
+
calls: tuple[tuple[str, Mapping[str, Any]], ...] = ()
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def answer(cls, text: str) -> ScriptedTurn:
|
|
62
|
+
return cls(text=text)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def tool(cls, name: str, arguments: Mapping[str, Any] | None = None) -> ScriptedTurn:
|
|
66
|
+
return cls(calls=((name, dict(arguments or {})),))
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def tools(cls, *calls: tuple[str, Mapping[str, Any]]) -> ScriptedTurn:
|
|
70
|
+
return cls(calls=tuple(calls))
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def finish_reason(self) -> FinishReason:
|
|
74
|
+
return "tool-calls" if self.calls else "stop"
|
|
75
|
+
|
|
76
|
+
def tool_calls(self, turn: int) -> tuple[ToolCall, ...]:
|
|
77
|
+
return tuple(
|
|
78
|
+
ToolCall(id=f"call_{turn}_{position}", name=name, arguments=dict(arguments))
|
|
79
|
+
for position, (name, arguments) in enumerate(self.calls)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FakeProvider:
|
|
84
|
+
"""Deterministic, seedable, and optionally inaccurate."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
responder: Responder | None = None,
|
|
90
|
+
answers: Mapping[str, str] | None = None,
|
|
91
|
+
script: Sequence[ScriptedTurn] = (),
|
|
92
|
+
accuracy: float = 1.0,
|
|
93
|
+
seed: int = 0,
|
|
94
|
+
name: str = "fake",
|
|
95
|
+
fail_with: Sequence[str] = (),
|
|
96
|
+
tokens_per_char: float = 0.25,
|
|
97
|
+
overhead_tokens: int = 8,
|
|
98
|
+
) -> None:
|
|
99
|
+
if not 0.0 <= accuracy <= 1.0:
|
|
100
|
+
raise ValueError("accuracy must be between 0.0 and 1.0")
|
|
101
|
+
self._responder = responder
|
|
102
|
+
# `answers` maps the *user message* to the ideal response, which lets a
|
|
103
|
+
# test wire a dataset to a provider without writing a callable.
|
|
104
|
+
self._answers = dict(answers or {})
|
|
105
|
+
# A script is consumed one turn per call, then the provider behaves
|
|
106
|
+
# normally -- so a scripted loop that overshoots its script is visible
|
|
107
|
+
# rather than silently answered.
|
|
108
|
+
self._script = tuple(script)
|
|
109
|
+
self._accuracy = accuracy
|
|
110
|
+
self._seed = seed
|
|
111
|
+
self._name = name
|
|
112
|
+
# Each entry is a message for a successive call. Once exhausted the
|
|
113
|
+
# provider behaves normally, so a test can prove retry-then-succeed.
|
|
114
|
+
self._failures = list(fail_with)
|
|
115
|
+
self._served = 0
|
|
116
|
+
self._tokens_per_char = tokens_per_char
|
|
117
|
+
self._overhead_tokens = overhead_tokens
|
|
118
|
+
self.calls: list[CompletionRequest] = []
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def name(self) -> str:
|
|
122
|
+
return self._name
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def capabilities(self) -> frozenset[Capability]:
|
|
126
|
+
return frozenset(
|
|
127
|
+
{
|
|
128
|
+
Capability.TOOL_CALLING,
|
|
129
|
+
Capability.STRUCTURED_OUTPUT,
|
|
130
|
+
Capability.LONG_CONTEXT,
|
|
131
|
+
Capability.STREAMING,
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
async def complete(self, request: CompletionRequest, *, model: ModelSpec) -> CompletionResponse:
|
|
136
|
+
self.calls.append(request)
|
|
137
|
+
index = len(self.calls) - 1
|
|
138
|
+
|
|
139
|
+
if self._failures:
|
|
140
|
+
raise ProviderError(str(self._failures.pop(0)))
|
|
141
|
+
|
|
142
|
+
input_tokens = self._count_tokens(_prompt_text(request))
|
|
143
|
+
if self._served < len(self._script):
|
|
144
|
+
turn = self._script[self._served]
|
|
145
|
+
self._served += 1
|
|
146
|
+
return CompletionResponse(
|
|
147
|
+
text=turn.text,
|
|
148
|
+
model=model.name,
|
|
149
|
+
provider=self._name,
|
|
150
|
+
usage=TokenUsage(
|
|
151
|
+
input_tokens=input_tokens,
|
|
152
|
+
output_tokens=self._count_tokens(turn.text),
|
|
153
|
+
),
|
|
154
|
+
tool_calls=turn.tool_calls(self._served - 1),
|
|
155
|
+
finish_reason=turn.finish_reason,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
text = self._respond_to(request, index)
|
|
159
|
+
return CompletionResponse(
|
|
160
|
+
text=text,
|
|
161
|
+
model=model.name,
|
|
162
|
+
provider=self._name,
|
|
163
|
+
usage=TokenUsage(
|
|
164
|
+
input_tokens=input_tokens,
|
|
165
|
+
output_tokens=self._count_tokens(text),
|
|
166
|
+
),
|
|
167
|
+
finish_reason="stop",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def stream(
|
|
171
|
+
self, request: CompletionRequest, *, model: ModelSpec
|
|
172
|
+
) -> AsyncIterator[StreamChunk]:
|
|
173
|
+
"""Stream the same answer :meth:`complete` would return, in fixed chunks.
|
|
174
|
+
|
|
175
|
+
The script and failure queue are consumed identically to ``complete``, so a
|
|
176
|
+
streaming call and a request/response call observe the same deterministic
|
|
177
|
+
sequence.
|
|
178
|
+
"""
|
|
179
|
+
self.calls.append(request)
|
|
180
|
+
index = len(self.calls) - 1
|
|
181
|
+
|
|
182
|
+
if self._failures:
|
|
183
|
+
raise ProviderError(str(self._failures.pop(0)))
|
|
184
|
+
|
|
185
|
+
input_tokens = self._count_tokens(_prompt_text(request))
|
|
186
|
+
if self._served < len(self._script):
|
|
187
|
+
turn = self._script[self._served]
|
|
188
|
+
self._served += 1
|
|
189
|
+
text = turn.text
|
|
190
|
+
finish_reason = turn.finish_reason
|
|
191
|
+
else:
|
|
192
|
+
text = self._respond_to(request, index)
|
|
193
|
+
finish_reason = "stop"
|
|
194
|
+
|
|
195
|
+
usage = TokenUsage(
|
|
196
|
+
input_tokens=input_tokens,
|
|
197
|
+
output_tokens=self._count_tokens(text),
|
|
198
|
+
)
|
|
199
|
+
for start in range(0, len(text), _STREAM_CHUNK):
|
|
200
|
+
yield StreamChunk(delta=text[start : start + _STREAM_CHUNK])
|
|
201
|
+
yield StreamChunk(finish_reason=finish_reason, usage=usage)
|
|
202
|
+
|
|
203
|
+
async def aclose(self) -> None:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def _respond_to(self, request: CompletionRequest, index: int) -> str:
|
|
207
|
+
if self._responder is not None:
|
|
208
|
+
return self._responder(request, index)
|
|
209
|
+
|
|
210
|
+
prompt = _prompt_text(request)
|
|
211
|
+
ideal = self._answers.get(prompt)
|
|
212
|
+
if ideal is None:
|
|
213
|
+
for key, value in self._answers.items():
|
|
214
|
+
if key in prompt:
|
|
215
|
+
ideal = value
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
if ideal is None:
|
|
219
|
+
# No expectation to hit, so accuracy does not apply: echo deterministically.
|
|
220
|
+
return f"echo:{_digest(prompt, self._seed)}"
|
|
221
|
+
|
|
222
|
+
if self._accuracy >= 1.0:
|
|
223
|
+
return ideal
|
|
224
|
+
if self._draw(prompt, index) < self._accuracy:
|
|
225
|
+
return ideal
|
|
226
|
+
# A wrong answer must still be deterministic and visibly wrong.
|
|
227
|
+
return f"{ideal}#{_digest(prompt, self._seed + index)}"
|
|
228
|
+
|
|
229
|
+
def _draw(self, prompt: str, index: int) -> float:
|
|
230
|
+
"""A stable pseudo-random draw for this (seed, call index, prompt) triple.
|
|
231
|
+
|
|
232
|
+
Deriving the draw from a hash rather than a sequential PRNG stream means a
|
|
233
|
+
recorded baseline reproduces exactly: the same call in the same position
|
|
234
|
+
always draws the same number. It is deliberately *not* stable across
|
|
235
|
+
positions -- the call index is part of the input -- so re-running a dataset
|
|
236
|
+
re-draws every case and an N-run sample genuinely varies. That variation is
|
|
237
|
+
what the eval gate has to tolerate, so it has to exist here.
|
|
238
|
+
"""
|
|
239
|
+
rng = random.Random(f"{self._seed}:{index}:{prompt}")
|
|
240
|
+
return rng.random()
|
|
241
|
+
|
|
242
|
+
def _count_tokens(self, text: str) -> int:
|
|
243
|
+
return int(len(text) * self._tokens_per_char) + self._overhead_tokens
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _prompt_text(request: CompletionRequest) -> str:
|
|
247
|
+
return "\n".join(message.content for message in request.messages)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _digest(prompt: str, seed: int) -> str:
|
|
251
|
+
return hashlib.sha256(f"{seed}:{prompt}".encode()).hexdigest()[:8]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def with_accuracy(accuracy: float, *, seed: int = 0) -> FakeProvider:
|
|
255
|
+
"""Convenience factory used by the eval-gate tests."""
|
|
256
|
+
return FakeProvider(accuracy=accuracy, seed=seed)
|