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,344 @@
1
+ """A single OpenAI-compatible HTTP provider adapter.
2
+
3
+ One adapter, deliberately. Anthropic, Azure, Bedrock and friends all speak
4
+ slightly different dialects, and the framework's value is the IR seam and the
5
+ eval gate, not the breadth of vendor plumbing. Anything OpenAI-compatible --
6
+ vLLM, Ollama's compatibility endpoint, LiteLLM, OpenAI itself -- works through
7
+ this one class via ``base_url``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ from collections.abc import AsyncIterator
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from aid_runtime.errors import ProviderError, ProviderResponseError
20
+ from aid_runtime.types import (
21
+ Capability,
22
+ CompletionRequest,
23
+ CompletionResponse,
24
+ FinishReason,
25
+ Message,
26
+ ModelSpec,
27
+ StreamChunk,
28
+ TokenUsage,
29
+ ToolCall,
30
+ )
31
+
32
+ __all__ = ["OpenAICompatibleProvider"]
33
+
34
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
35
+
36
+ # 429 and 5xx are the retryable classes: the request was well-formed but the
37
+ # endpoint declined for a reason that may not repeat.
38
+ _RETRYABLE_STATUS = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
39
+
40
+
41
+ class OpenAICompatibleProvider:
42
+ """Calls ``POST {base_url}/chat/completions`` and maps the response back."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ base_url: str = DEFAULT_BASE_URL,
48
+ api_key: str | None = None,
49
+ name: str = "openai-compatible",
50
+ timeout_seconds: float = 60.0,
51
+ max_attempts: int = 3,
52
+ backoff_seconds: float = 0.5,
53
+ client: httpx.AsyncClient | None = None,
54
+ extra_headers: dict[str, str] | None = None,
55
+ ) -> None:
56
+ if not base_url.startswith(("http://", "https://")):
57
+ raise ValueError(f"base_url must be an absolute http(s) URL, got {base_url!r}")
58
+ if max_attempts < 1:
59
+ raise ValueError("max_attempts must be at least 1")
60
+ self._name = name
61
+ self._base_url = base_url.rstrip("/")
62
+ self._api_key = api_key
63
+ self._timeout = timeout_seconds
64
+ self._max_attempts = max_attempts
65
+ self._backoff = backoff_seconds
66
+ self._extra_headers = dict(extra_headers or {})
67
+ self._owns_client = client is None
68
+ self._client = client or httpx.AsyncClient(timeout=timeout_seconds)
69
+ self._closed = False
70
+
71
+ @property
72
+ def name(self) -> str:
73
+ return self._name
74
+
75
+ @property
76
+ def capabilities(self) -> frozenset[Capability]:
77
+ return frozenset(
78
+ {
79
+ Capability.TOOL_CALLING,
80
+ Capability.STRUCTURED_OUTPUT,
81
+ Capability.STREAMING,
82
+ }
83
+ )
84
+
85
+ async def complete(self, request: CompletionRequest, *, model: ModelSpec) -> CompletionResponse:
86
+ payload = self._build_payload(request, model)
87
+ body = await self._post("/chat/completions", payload)
88
+ return self._parse(body, model)
89
+
90
+ async def stream(
91
+ self, request: CompletionRequest, *, model: ModelSpec
92
+ ) -> AsyncIterator[StreamChunk]:
93
+ """Stream one call as OpenAI-style SSE, yielding one chunk per delta.
94
+
95
+ Streaming is a single attempt, not the retry loop ``complete`` uses: a
96
+ stream that has already emitted a token cannot be safely replayed, so a
97
+ transport or status error is raised once rather than retried mid-stream.
98
+ """
99
+ payload = self._build_payload(request, model)
100
+ payload["stream"] = True
101
+ payload["stream_options"] = {"include_usage": True}
102
+ url = f"{self._base_url}/chat/completions"
103
+ try:
104
+ async with self._client.stream(
105
+ "POST", url, json=payload, headers=self._headers()
106
+ ) as response:
107
+ if response.status_code >= 400:
108
+ await response.aread()
109
+ raise ProviderError(
110
+ f"{self._name} returned {response.status_code}: {response.text[:500]}"
111
+ )
112
+ async for line in response.aiter_lines():
113
+ if not line.startswith("data:"):
114
+ continue
115
+ data = line[len("data:") :].strip()
116
+ if data == "[DONE]":
117
+ continue
118
+ try:
119
+ decoded = json.loads(data)
120
+ except json.JSONDecodeError as exc:
121
+ raise ProviderResponseError(
122
+ f"{self._name} streamed non-JSON", data
123
+ ) from exc
124
+ if not isinstance(decoded, dict):
125
+ raise ProviderResponseError(
126
+ f"{self._name} streamed a non-object body", data
127
+ )
128
+ yield self._parse_chunk(decoded, model)
129
+ except httpx.TransportError as exc:
130
+ raise ProviderError(f"{self._name} stream failed: {exc}") from exc
131
+
132
+ async def aclose(self) -> None:
133
+ if self._closed:
134
+ return
135
+ self._closed = True
136
+ if self._owns_client:
137
+ await self._client.aclose()
138
+
139
+ def _headers(self) -> dict[str, str]:
140
+ headers = {"content-type": "application/json", **self._extra_headers}
141
+ if self._api_key:
142
+ headers["authorization"] = f"Bearer {self._api_key}"
143
+ return headers
144
+
145
+ def _build_payload(self, request: CompletionRequest, model: ModelSpec) -> dict[str, Any]:
146
+ payload: dict[str, Any] = {
147
+ "model": model.name,
148
+ "messages": [_message_payload(message) for message in request.messages],
149
+ }
150
+ if request.temperature is not None:
151
+ payload["temperature"] = request.temperature
152
+ if request.max_output_tokens is not None:
153
+ payload["max_tokens"] = min(request.max_output_tokens, model.max_output_tokens)
154
+ if request.tools:
155
+ payload["tools"] = [tool.as_openai_tool() for tool in request.tools]
156
+ if request.stop:
157
+ payload["stop"] = list(request.stop)
158
+ return payload
159
+
160
+ async def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
161
+ url = f"{self._base_url}{path}"
162
+ last_error: Exception | None = None
163
+ for attempt in range(1, self._max_attempts + 1):
164
+ try:
165
+ response = await self._client.post(url, json=payload, headers=self._headers())
166
+ except httpx.TransportError as exc:
167
+ last_error = exc
168
+ if attempt == self._max_attempts:
169
+ break
170
+ await asyncio.sleep(self._backoff * 2 ** (attempt - 1))
171
+ continue
172
+
173
+ if response.status_code in _RETRYABLE_STATUS and attempt < self._max_attempts:
174
+ last_error = ProviderError(f"{self._name} returned {response.status_code}")
175
+ await asyncio.sleep(self._backoff * 2 ** (attempt - 1))
176
+ continue
177
+
178
+ if response.status_code >= 400:
179
+ raise ProviderError(
180
+ f"{self._name} returned {response.status_code}: {response.text[:500]}"
181
+ )
182
+
183
+ try:
184
+ decoded = response.json()
185
+ except json.JSONDecodeError as exc:
186
+ raise ProviderResponseError(
187
+ f"{self._name} returned non-JSON", response.text
188
+ ) from exc
189
+ if not isinstance(decoded, dict):
190
+ raise ProviderResponseError(
191
+ f"{self._name} returned a non-object body", response.text
192
+ )
193
+ return decoded
194
+
195
+ raise ProviderError(
196
+ f"{self._name} failed after {self._max_attempts} attempt(s): {last_error}"
197
+ ) from last_error
198
+
199
+ def _parse(self, body: dict[str, Any], model: ModelSpec) -> CompletionResponse:
200
+ choices = body.get("choices")
201
+ if not isinstance(choices, list) or not choices:
202
+ raise ProviderResponseError(
203
+ f"{self._name} returned no choices", json.dumps(body, sort_keys=True)
204
+ )
205
+ first = choices[0]
206
+ if not isinstance(first, dict):
207
+ raise ProviderResponseError(f"{self._name} returned a malformed choice")
208
+ message = first.get("message")
209
+ if not isinstance(message, dict):
210
+ raise ProviderResponseError(
211
+ f"{self._name} returned a choice without a message",
212
+ json.dumps(first, sort_keys=True),
213
+ )
214
+
215
+ text = message.get("content")
216
+ if text is None:
217
+ text = ""
218
+ if not isinstance(text, str):
219
+ raise ProviderResponseError(f"{self._name} returned non-string content")
220
+
221
+ tool_calls = self._parse_tool_calls(message.get("tool_calls"))
222
+
223
+ raw_usage = body.get("usage")
224
+ usage = TokenUsage(input_tokens=0, output_tokens=0)
225
+ if isinstance(raw_usage, dict):
226
+ usage = TokenUsage(
227
+ input_tokens=int(raw_usage.get("prompt_tokens") or 0),
228
+ output_tokens=int(raw_usage.get("completion_tokens") or 0),
229
+ )
230
+
231
+ return CompletionResponse(
232
+ text=text,
233
+ model=str(body.get("model") or model.name),
234
+ provider=self._name,
235
+ usage=usage,
236
+ tool_calls=tool_calls,
237
+ finish_reason=_finish_reason(first.get("finish_reason")),
238
+ )
239
+
240
+ def _parse_chunk(self, body: dict[str, Any], model: ModelSpec) -> StreamChunk:
241
+ """Map one streamed SSE ``data`` payload to a :class:`StreamChunk`."""
242
+ choices = body.get("choices")
243
+ delta = ""
244
+ finish_reason: FinishReason | None = None
245
+ if isinstance(choices, list) and choices:
246
+ first = choices[0]
247
+ if isinstance(first, dict):
248
+ delta_obj = first.get("delta")
249
+ if isinstance(delta_obj, dict):
250
+ content = delta_obj.get("content")
251
+ if isinstance(content, str):
252
+ delta = content
253
+ raw_reason = first.get("finish_reason")
254
+ if isinstance(raw_reason, str) and raw_reason:
255
+ finish_reason = _finish_reason(raw_reason)
256
+
257
+ usage: TokenUsage | None = None
258
+ raw_usage = body.get("usage")
259
+ if isinstance(raw_usage, dict):
260
+ usage = TokenUsage(
261
+ input_tokens=int(raw_usage.get("prompt_tokens") or 0),
262
+ output_tokens=int(raw_usage.get("completion_tokens") or 0),
263
+ )
264
+ return StreamChunk(delta=delta, finish_reason=finish_reason, usage=usage)
265
+
266
+ def _parse_tool_calls(self, raw: Any) -> tuple[ToolCall, ...]:
267
+ if not isinstance(raw, list):
268
+ return ()
269
+ calls: list[ToolCall] = []
270
+ for index, item in enumerate(raw):
271
+ if not isinstance(item, dict):
272
+ continue
273
+ function = item.get("function")
274
+ if not isinstance(function, dict):
275
+ continue
276
+ name = function.get("name")
277
+ if not isinstance(name, str) or not name:
278
+ continue
279
+ calls.append(
280
+ ToolCall(
281
+ id=str(item.get("id") or f"call_{index}"),
282
+ name=name,
283
+ arguments=_decode_arguments(function.get("arguments")),
284
+ )
285
+ )
286
+ return tuple(calls)
287
+
288
+
289
+ _FINISH_REASONS: dict[str, FinishReason] = {
290
+ "stop": "stop",
291
+ "length": "length",
292
+ "tool_calls": "tool-calls",
293
+ "content_filter": "content-filter",
294
+ "error": "error",
295
+ }
296
+
297
+
298
+ def _finish_reason(raw: Any) -> FinishReason:
299
+ return _FINISH_REASONS.get(raw, "stop") if isinstance(raw, str) else "stop"
300
+
301
+
302
+ def _decode_arguments(raw: Any) -> dict[str, Any]:
303
+ """Tool arguments arrive as a JSON *string*; a malformed one is an empty dict.
304
+
305
+ Returning ``{}`` rather than raising keeps the failure in the tool executor,
306
+ where it is reported to the model as a retryable tool error instead of
307
+ aborting the whole turn.
308
+ """
309
+ if isinstance(raw, dict):
310
+ return raw
311
+ if not isinstance(raw, str) or not raw.strip():
312
+ return {}
313
+ try:
314
+ decoded = json.loads(raw)
315
+ except json.JSONDecodeError:
316
+ return {}
317
+ return decoded if isinstance(decoded, dict) else {}
318
+
319
+
320
+ def _message_payload(message: Message) -> dict[str, Any]:
321
+ """One OpenAI chat message, omitting fields the transcript did not set.
322
+
323
+ Single-turn requests must keep the exact ``{"role", "content"}`` shape they had
324
+ before tool bookkeeping existed, so absent fields are omitted rather than sent
325
+ as ``null``/``[]``.
326
+ """
327
+ payload: dict[str, Any] = {"role": message.role, "content": message.content}
328
+ if message.tool_calls:
329
+ payload["tool_calls"] = [
330
+ {
331
+ "id": call.id,
332
+ "type": "function",
333
+ "function": {
334
+ "name": call.name,
335
+ "arguments": json.dumps(call.arguments, sort_keys=True),
336
+ },
337
+ }
338
+ for call in message.tool_calls
339
+ ]
340
+ if message.tool_call_id is not None:
341
+ payload["tool_call_id"] = message.tool_call_id
342
+ if message.name is not None:
343
+ payload["name"] = message.name
344
+ return payload
aid_runtime/py.typed ADDED
File without changes
@@ -0,0 +1,178 @@
1
+ """The retrieval runtime: connectors, chunkers, embedders, stores, fusion, citations.
2
+
3
+ The package is one pipeline in source order. A connector yields documents, a chunker
4
+ splits them, an embedder vectorizes them, a store holds them, and retrieval goes back
5
+ out through the store, optionally fusing, optionally reranking, and always attaching
6
+ citations.
7
+
8
+ Every seam is a protocol with a deterministic implementation and, where the plan
9
+ allows one, a hosted implementation. Phase 1 ships exactly one hosted adapter --
10
+ :class:`OpenAICompatibleEmbedder` -- alongside :class:`FakeDeterministicEmbedder`,
11
+ which is what lets a gate run offline. There is no hosted reranker: the only
12
+ reranker is the deterministic :class:`LexicalReranker`.
13
+
14
+ Import safety is deliberate. :class:`PgVectorStore` is exported from here, but the
15
+ ``psycopg`` driver is imported inside its connect path, never at module scope, so
16
+ ``import aid_runtime`` works without the ``pgvector`` extra installed -- which is the
17
+ only state CI is ever in, since CI installs the ``dev`` extra and ``pgvector`` is not
18
+ part of it. Reaching for the driver without the extra raises ``ConfigurationError``
19
+ naming the extra, rather than ``ImportError`` from three frames down.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from aid_runtime.retrieval.chunking import (
25
+ Chunker,
26
+ CodeChunker,
27
+ FixedChunker,
28
+ RecursiveChunker,
29
+ chunk_document,
30
+ chunker_for,
31
+ )
32
+ from aid_runtime.retrieval.citations import (
33
+ Citation,
34
+ RetrievalResult,
35
+ RetrievedItem,
36
+ assemble_context,
37
+ cite,
38
+ )
39
+ from aid_runtime.retrieval.connectors import (
40
+ Connector,
41
+ FileConnector,
42
+ SourceDocument,
43
+ open_connector,
44
+ )
45
+ from aid_runtime.retrieval.embedding import (
46
+ Embedder,
47
+ EmbeddingBatch,
48
+ FakeDeterministicEmbedder,
49
+ OpenAICompatibleEmbedder,
50
+ embedder_for,
51
+ estimate_tokens,
52
+ )
53
+ from aid_runtime.retrieval.gate import RetrievalGate
54
+ from aid_runtime.retrieval.hybrid import (
55
+ RankedId,
56
+ bm25_scores,
57
+ fuse,
58
+ lexical_ranking,
59
+ rrf_score,
60
+ tokenize,
61
+ )
62
+ from aid_runtime.retrieval.metrics import (
63
+ CitationGrounding,
64
+ MeanReciprocalRank,
65
+ RecallAtK,
66
+ RetrievalCase,
67
+ RetrievalMetric,
68
+ retrieval_metrics,
69
+ )
70
+ from aid_runtime.retrieval.pgvector import PgVectorStore, resolve_uri
71
+ from aid_runtime.retrieval.rerank import LexicalReranker, Reranker, lexical_score, reranker_for
72
+ from aid_runtime.retrieval.retriever import DEFAULT_CANDIDATE_FACTOR, IngestReport, Retriever
73
+ from aid_runtime.retrieval.stores import (
74
+ InMemoryVectorStore,
75
+ SearchHit,
76
+ StoreRecord,
77
+ VectorStore,
78
+ create_store,
79
+ similarity,
80
+ )
81
+ from aid_runtime.retrieval.types import (
82
+ Chunk,
83
+ ChunkingSpec,
84
+ ChunkingStrategy,
85
+ DistanceMetric,
86
+ EmbeddingCostProfile,
87
+ EmbeddingSpec,
88
+ IndexSpec,
89
+ PinnedEmbedding,
90
+ RefreshMode,
91
+ RefreshSpec,
92
+ RerankSpec,
93
+ RetrievalMetricSpec,
94
+ RetrieverBinding,
95
+ RetrieverSpec,
96
+ ScoredChunk,
97
+ SourceKind,
98
+ SourceSpec,
99
+ StoreKind,
100
+ VectorStoreSpec,
101
+ binding_from_spec,
102
+ chunk_id_for,
103
+ pinned_embedding,
104
+ )
105
+
106
+ __all__ = [
107
+ "DEFAULT_CANDIDATE_FACTOR",
108
+ "Chunk",
109
+ "Chunker",
110
+ "ChunkingSpec",
111
+ "ChunkingStrategy",
112
+ "Citation",
113
+ "CitationGrounding",
114
+ "CodeChunker",
115
+ "Connector",
116
+ "DistanceMetric",
117
+ "Embedder",
118
+ "EmbeddingBatch",
119
+ "EmbeddingCostProfile",
120
+ "EmbeddingSpec",
121
+ "FakeDeterministicEmbedder",
122
+ "FileConnector",
123
+ "FixedChunker",
124
+ "InMemoryVectorStore",
125
+ "IndexSpec",
126
+ "IngestReport",
127
+ "LexicalReranker",
128
+ "MeanReciprocalRank",
129
+ "OpenAICompatibleEmbedder",
130
+ "PgVectorStore",
131
+ "PinnedEmbedding",
132
+ "RankedId",
133
+ "RecallAtK",
134
+ "RecursiveChunker",
135
+ "RefreshMode",
136
+ "RefreshSpec",
137
+ "RerankSpec",
138
+ "Reranker",
139
+ "RetrievalCase",
140
+ "RetrievalGate",
141
+ "RetrievalMetric",
142
+ "RetrievalMetricSpec",
143
+ "RetrievalResult",
144
+ "RetrievedItem",
145
+ "Retriever",
146
+ "RetrieverBinding",
147
+ "RetrieverSpec",
148
+ "ScoredChunk",
149
+ "SearchHit",
150
+ "SourceDocument",
151
+ "SourceKind",
152
+ "SourceSpec",
153
+ "StoreKind",
154
+ "StoreRecord",
155
+ "VectorStore",
156
+ "VectorStoreSpec",
157
+ "assemble_context",
158
+ "binding_from_spec",
159
+ "bm25_scores",
160
+ "chunk_document",
161
+ "chunk_id_for",
162
+ "chunker_for",
163
+ "cite",
164
+ "create_store",
165
+ "embedder_for",
166
+ "estimate_tokens",
167
+ "fuse",
168
+ "lexical_ranking",
169
+ "lexical_score",
170
+ "open_connector",
171
+ "pinned_embedding",
172
+ "reranker_for",
173
+ "resolve_uri",
174
+ "retrieval_metrics",
175
+ "rrf_score",
176
+ "similarity",
177
+ "tokenize",
178
+ ]