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,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)