pretensor 0.1.0__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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""LLM client adapters for the L3 NL-to-SQL agent.
|
|
2
|
+
|
|
3
|
+
The OSS core has no LLM dependency (``docs/contracts/architecture.md``
|
|
4
|
+
Invariant #6). This module talks to provider HTTP endpoints directly via
|
|
5
|
+
``httpx`` (already a top-level dep), so ``import pretensor`` never pulls
|
|
6
|
+
an LLM SDK. Instantiation is lazy: callers build an :class:`AnthropicHttpClient`
|
|
7
|
+
or :class:`OpenAIHttpClient` inside the runner — module load does not call
|
|
8
|
+
``os.environ`` or open any sockets.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from pretensor.benchmark.l3.agent import (
|
|
21
|
+
AgentMessage,
|
|
22
|
+
AgentStep,
|
|
23
|
+
AgentTool,
|
|
24
|
+
AgentToolCall,
|
|
25
|
+
)
|
|
26
|
+
from pretensor.errors import PretensorError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AnthropicHttpClient",
|
|
30
|
+
"LlmCallError",
|
|
31
|
+
"LlmClient",
|
|
32
|
+
"LlmResponse",
|
|
33
|
+
"OpenAIHttpClient",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_DEFAULT_TIMEOUT_S = 60.0
|
|
38
|
+
_DEFAULT_MAX_TOKENS = 4096
|
|
39
|
+
_ANTHROPIC_API = "https://api.anthropic.com/v1/messages"
|
|
40
|
+
_ANTHROPIC_VERSION = "2023-06-01"
|
|
41
|
+
_OPENAI_API = "https://api.openai.com/v1/chat/completions"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class LlmResponse:
|
|
46
|
+
"""One LLM completion plus the runner's per-call telemetry.
|
|
47
|
+
|
|
48
|
+
``text`` is the raw model output (no fence stripping — that's the
|
|
49
|
+
runner's job). ``prompt_tokens`` / ``completion_tokens`` are
|
|
50
|
+
optional; some providers omit them. ``latency_ms`` is wall-clock
|
|
51
|
+
time around the HTTP request, captured by the client itself so
|
|
52
|
+
the runner does not need to reach into provider-specific timing.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
text: str
|
|
56
|
+
prompt_tokens: int | None
|
|
57
|
+
completion_tokens: int | None
|
|
58
|
+
latency_ms: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LlmCallError(PretensorError, RuntimeError):
|
|
62
|
+
"""Raised on transport failure, non-2xx response, or unparseable body."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@runtime_checkable
|
|
66
|
+
class LlmClient(Protocol):
|
|
67
|
+
"""Sync NL-to-SQL completion surface.
|
|
68
|
+
|
|
69
|
+
A single ``complete`` call is one prompt → one completion. Sync because
|
|
70
|
+
L3 evaluates one question at a time and the LLM call is the dominant
|
|
71
|
+
latency — async would only buy concurrency the runner does not use.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def complete(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
system: str,
|
|
78
|
+
user: str,
|
|
79
|
+
model: str,
|
|
80
|
+
temperature: float,
|
|
81
|
+
) -> LlmResponse: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AnthropicHttpClient:
|
|
85
|
+
"""Default Anthropic Messages API client.
|
|
86
|
+
|
|
87
|
+
Reads ``ANTHROPIC_API_KEY`` from env at construction; raises
|
|
88
|
+
:class:`LlmCallError` if absent. Uses an internally-owned
|
|
89
|
+
:class:`httpx.Client` so the runner does not need to manage the
|
|
90
|
+
transport.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
api_key: str | None = None,
|
|
97
|
+
client: httpx.Client | None = None,
|
|
98
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
99
|
+
) -> None:
|
|
100
|
+
resolved_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
101
|
+
if not resolved_key:
|
|
102
|
+
raise LlmCallError(
|
|
103
|
+
"ANTHROPIC_API_KEY is not set; cannot run the Anthropic client."
|
|
104
|
+
)
|
|
105
|
+
self._api_key = resolved_key
|
|
106
|
+
self._client = client or httpx.Client(timeout=_DEFAULT_TIMEOUT_S)
|
|
107
|
+
self._max_tokens = max_tokens
|
|
108
|
+
|
|
109
|
+
def complete(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
system: str,
|
|
113
|
+
user: str,
|
|
114
|
+
model: str,
|
|
115
|
+
temperature: float,
|
|
116
|
+
) -> LlmResponse:
|
|
117
|
+
body: dict[str, Any] = {
|
|
118
|
+
"model": model,
|
|
119
|
+
"max_tokens": self._max_tokens,
|
|
120
|
+
"temperature": temperature,
|
|
121
|
+
"system": system,
|
|
122
|
+
"messages": [{"role": "user", "content": user}],
|
|
123
|
+
}
|
|
124
|
+
payload, latency_ms = self._post(body)
|
|
125
|
+
text = _extract_anthropic_text(payload)
|
|
126
|
+
usage = payload.get("usage") or {}
|
|
127
|
+
return LlmResponse(
|
|
128
|
+
text=text,
|
|
129
|
+
prompt_tokens=usage.get("input_tokens"),
|
|
130
|
+
completion_tokens=usage.get("output_tokens"),
|
|
131
|
+
latency_ms=latency_ms,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def agent_complete(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
system: str,
|
|
138
|
+
messages: list[AgentMessage],
|
|
139
|
+
tools: list[AgentTool],
|
|
140
|
+
model: str,
|
|
141
|
+
temperature: float,
|
|
142
|
+
) -> AgentStep:
|
|
143
|
+
"""One tool-use step against the Anthropic Messages API.
|
|
144
|
+
|
|
145
|
+
Translates :class:`AgentMessage` history to Anthropic's
|
|
146
|
+
content-block wire format, sends the request with the supplied
|
|
147
|
+
``tools`` array, and decodes the response into a provider-agnostic
|
|
148
|
+
:class:`AgentStep`. ``stop_reason="tool_use"`` becomes
|
|
149
|
+
``tool_calls`` populated; ``stop_reason="end_turn"`` (or
|
|
150
|
+
``"stop_sequence"``) becomes a final ``text``.
|
|
151
|
+
"""
|
|
152
|
+
body: dict[str, Any] = {
|
|
153
|
+
"model": model,
|
|
154
|
+
"max_tokens": self._max_tokens,
|
|
155
|
+
"temperature": temperature,
|
|
156
|
+
"system": system,
|
|
157
|
+
"messages": [_anthropic_message(m) for m in messages],
|
|
158
|
+
"tools": [_anthropic_tool(t) for t in tools],
|
|
159
|
+
}
|
|
160
|
+
payload, latency_ms = self._post(body)
|
|
161
|
+
text, tool_calls = _decode_anthropic_step(payload)
|
|
162
|
+
usage = payload.get("usage") or {}
|
|
163
|
+
return AgentStep(
|
|
164
|
+
text=text,
|
|
165
|
+
tool_calls=tool_calls,
|
|
166
|
+
prompt_tokens=usage.get("input_tokens"),
|
|
167
|
+
completion_tokens=usage.get("output_tokens"),
|
|
168
|
+
latency_ms=latency_ms,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _post(self, body: dict[str, Any]) -> tuple[dict[str, Any], int]:
|
|
172
|
+
"""POST ``body`` to Anthropic, returning ``(payload, latency_ms)``.
|
|
173
|
+
|
|
174
|
+
Centralises the transport / error / timing path shared between
|
|
175
|
+
``complete`` and ``agent_complete`` so the two stay byte-for-byte
|
|
176
|
+
consistent on retry semantics, header set, and timeout.
|
|
177
|
+
"""
|
|
178
|
+
headers = {
|
|
179
|
+
"x-api-key": self._api_key,
|
|
180
|
+
"anthropic-version": _ANTHROPIC_VERSION,
|
|
181
|
+
"content-type": "application/json",
|
|
182
|
+
}
|
|
183
|
+
t0 = time.perf_counter()
|
|
184
|
+
try:
|
|
185
|
+
response = self._client.post(_ANTHROPIC_API, json=body, headers=headers)
|
|
186
|
+
except httpx.HTTPError as exc:
|
|
187
|
+
raise LlmCallError(f"Anthropic transport error: {exc}") from exc
|
|
188
|
+
latency_ms = int((time.perf_counter() - t0) * 1000)
|
|
189
|
+
if response.status_code >= 400:
|
|
190
|
+
raise LlmCallError(
|
|
191
|
+
f"Anthropic returned HTTP {response.status_code}: {response.text[:500]}"
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
payload = response.json()
|
|
195
|
+
except ValueError as exc:
|
|
196
|
+
raise LlmCallError(f"Anthropic response was not JSON: {exc}") from exc
|
|
197
|
+
return payload, latency_ms
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class OpenAIHttpClient:
|
|
201
|
+
"""OpenAI chat-completions client.
|
|
202
|
+
|
|
203
|
+
Reads ``OPENAI_API_KEY`` from env at construction. Same shape as the
|
|
204
|
+
Anthropic client so the runner can swap providers behind the
|
|
205
|
+
:class:`LlmClient` Protocol.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
*,
|
|
211
|
+
api_key: str | None = None,
|
|
212
|
+
client: httpx.Client | None = None,
|
|
213
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
214
|
+
) -> None:
|
|
215
|
+
resolved_key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
216
|
+
if not resolved_key:
|
|
217
|
+
raise LlmCallError(
|
|
218
|
+
"OPENAI_API_KEY is not set; cannot run the OpenAI client."
|
|
219
|
+
)
|
|
220
|
+
self._api_key = resolved_key
|
|
221
|
+
self._client = client or httpx.Client(timeout=_DEFAULT_TIMEOUT_S)
|
|
222
|
+
self._max_tokens = max_tokens
|
|
223
|
+
|
|
224
|
+
def complete(
|
|
225
|
+
self,
|
|
226
|
+
*,
|
|
227
|
+
system: str,
|
|
228
|
+
user: str,
|
|
229
|
+
model: str,
|
|
230
|
+
temperature: float,
|
|
231
|
+
) -> LlmResponse:
|
|
232
|
+
body: dict[str, Any] = {
|
|
233
|
+
"model": model,
|
|
234
|
+
"max_tokens": self._max_tokens,
|
|
235
|
+
"temperature": temperature,
|
|
236
|
+
"messages": [
|
|
237
|
+
{"role": "system", "content": system},
|
|
238
|
+
{"role": "user", "content": user},
|
|
239
|
+
],
|
|
240
|
+
}
|
|
241
|
+
headers = {
|
|
242
|
+
"authorization": f"Bearer {self._api_key}",
|
|
243
|
+
"content-type": "application/json",
|
|
244
|
+
}
|
|
245
|
+
t0 = time.perf_counter()
|
|
246
|
+
try:
|
|
247
|
+
response = self._client.post(_OPENAI_API, json=body, headers=headers)
|
|
248
|
+
except httpx.HTTPError as exc:
|
|
249
|
+
raise LlmCallError(f"OpenAI transport error: {exc}") from exc
|
|
250
|
+
latency_ms = int((time.perf_counter() - t0) * 1000)
|
|
251
|
+
if response.status_code >= 400:
|
|
252
|
+
raise LlmCallError(
|
|
253
|
+
f"OpenAI returned HTTP {response.status_code}: {response.text[:500]}"
|
|
254
|
+
)
|
|
255
|
+
try:
|
|
256
|
+
payload = response.json()
|
|
257
|
+
except ValueError as exc:
|
|
258
|
+
raise LlmCallError(f"OpenAI response was not JSON: {exc}") from exc
|
|
259
|
+
text = _extract_openai_text(payload)
|
|
260
|
+
usage = payload.get("usage") or {}
|
|
261
|
+
return LlmResponse(
|
|
262
|
+
text=text,
|
|
263
|
+
prompt_tokens=usage.get("prompt_tokens"),
|
|
264
|
+
completion_tokens=usage.get("completion_tokens"),
|
|
265
|
+
latency_ms=latency_ms,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extract_anthropic_text(payload: dict[str, Any]) -> str:
|
|
270
|
+
"""Concatenate every ``text`` block from an Anthropic Messages response.
|
|
271
|
+
|
|
272
|
+
Tool-use blocks (which the L3 baseline does not request) are skipped.
|
|
273
|
+
"""
|
|
274
|
+
content = payload.get("content")
|
|
275
|
+
if not isinstance(content, list):
|
|
276
|
+
raise LlmCallError("Anthropic response missing 'content' array.")
|
|
277
|
+
parts: list[str] = []
|
|
278
|
+
for block in content:
|
|
279
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
280
|
+
text_part = block.get("text")
|
|
281
|
+
if isinstance(text_part, str):
|
|
282
|
+
parts.append(text_part)
|
|
283
|
+
if not parts:
|
|
284
|
+
raise LlmCallError("Anthropic response had no text blocks.")
|
|
285
|
+
return "".join(parts)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _extract_openai_text(payload: dict[str, Any]) -> str:
|
|
289
|
+
choices = payload.get("choices")
|
|
290
|
+
if not isinstance(choices, list) or not choices:
|
|
291
|
+
raise LlmCallError("OpenAI response missing 'choices' array.")
|
|
292
|
+
first = choices[0]
|
|
293
|
+
if not isinstance(first, dict):
|
|
294
|
+
raise LlmCallError("OpenAI 'choices[0]' is not an object.")
|
|
295
|
+
message = first.get("message")
|
|
296
|
+
if not isinstance(message, dict):
|
|
297
|
+
raise LlmCallError("OpenAI 'choices[0].message' is missing.")
|
|
298
|
+
content = message.get("content")
|
|
299
|
+
if not isinstance(content, str):
|
|
300
|
+
raise LlmCallError("OpenAI 'choices[0].message.content' is not a string.")
|
|
301
|
+
return content
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _anthropic_tool(tool: AgentTool) -> dict[str, Any]:
|
|
305
|
+
"""Render an :class:`AgentTool` as Anthropic's tool descriptor."""
|
|
306
|
+
return {
|
|
307
|
+
"name": tool.name,
|
|
308
|
+
"description": tool.description,
|
|
309
|
+
"input_schema": tool.input_schema,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _anthropic_message(message: AgentMessage) -> dict[str, Any]:
|
|
314
|
+
"""Render an :class:`AgentMessage` as one Anthropic ``messages[]`` entry.
|
|
315
|
+
|
|
316
|
+
A ``user`` turn with tool results becomes a content array of
|
|
317
|
+
``tool_result`` blocks; an ``assistant`` turn merges any text +
|
|
318
|
+
``tool_use`` blocks. Plain user prompts stay as a string so the wire
|
|
319
|
+
payload matches the format the simple ``complete`` path emits.
|
|
320
|
+
"""
|
|
321
|
+
if message.role == "user":
|
|
322
|
+
if message.tool_results:
|
|
323
|
+
return {
|
|
324
|
+
"role": "user",
|
|
325
|
+
"content": [
|
|
326
|
+
{
|
|
327
|
+
"type": "tool_result",
|
|
328
|
+
"tool_use_id": r.id,
|
|
329
|
+
"content": r.content,
|
|
330
|
+
**({"is_error": True} if r.is_error else {}),
|
|
331
|
+
}
|
|
332
|
+
for r in message.tool_results
|
|
333
|
+
],
|
|
334
|
+
}
|
|
335
|
+
return {"role": "user", "content": message.text or ""}
|
|
336
|
+
|
|
337
|
+
blocks: list[dict[str, Any]] = []
|
|
338
|
+
if message.text:
|
|
339
|
+
blocks.append({"type": "text", "text": message.text})
|
|
340
|
+
for call in message.tool_calls:
|
|
341
|
+
blocks.append(
|
|
342
|
+
{
|
|
343
|
+
"type": "tool_use",
|
|
344
|
+
"id": call.id,
|
|
345
|
+
"name": call.name,
|
|
346
|
+
"input": call.arguments,
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
return {"role": "assistant", "content": blocks}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _decode_anthropic_step(
|
|
353
|
+
payload: dict[str, Any],
|
|
354
|
+
) -> tuple[str | None, tuple[AgentToolCall, ...]]:
|
|
355
|
+
"""Pull text and tool calls out of an Anthropic Messages response.
|
|
356
|
+
|
|
357
|
+
Returns ``(text, tool_calls)``. ``text`` is the concatenated text
|
|
358
|
+
blocks (may be empty when the model only emitted tool calls);
|
|
359
|
+
``tool_calls`` is empty when the response is purely text.
|
|
360
|
+
"""
|
|
361
|
+
content = payload.get("content")
|
|
362
|
+
if not isinstance(content, list):
|
|
363
|
+
raise LlmCallError("Anthropic response missing 'content' array.")
|
|
364
|
+
text_parts: list[str] = []
|
|
365
|
+
tool_calls: list[AgentToolCall] = []
|
|
366
|
+
for block in content:
|
|
367
|
+
if not isinstance(block, dict):
|
|
368
|
+
continue
|
|
369
|
+
block_type = block.get("type")
|
|
370
|
+
if block_type == "text":
|
|
371
|
+
text_part = block.get("text")
|
|
372
|
+
if isinstance(text_part, str):
|
|
373
|
+
text_parts.append(text_part)
|
|
374
|
+
elif block_type == "tool_use":
|
|
375
|
+
call_id = block.get("id")
|
|
376
|
+
name = block.get("name")
|
|
377
|
+
args = block.get("input")
|
|
378
|
+
if not isinstance(call_id, str) or not isinstance(name, str):
|
|
379
|
+
raise LlmCallError("Anthropic tool_use block missing 'id' or 'name'.")
|
|
380
|
+
# ``input`` is contractually a JSON object on Anthropic's API
|
|
381
|
+
# (no-arg tools still emit ``{}``). A missing or non-dict
|
|
382
|
+
# value is a provider-side regression — silently coercing
|
|
383
|
+
# it to ``{}`` would forward an empty-args tool call and
|
|
384
|
+
# mask the bug. Fail loud so callers can record the
|
|
385
|
+
# underlying provider response.
|
|
386
|
+
if not isinstance(args, dict):
|
|
387
|
+
raise LlmCallError(
|
|
388
|
+
f"Anthropic tool_use block 'input' for tool {name!r} "
|
|
389
|
+
f"is not a JSON object (got {type(args).__name__})."
|
|
390
|
+
)
|
|
391
|
+
tool_calls.append(
|
|
392
|
+
AgentToolCall(id=call_id, name=name, arguments=dict(args))
|
|
393
|
+
)
|
|
394
|
+
text = "".join(text_parts) if text_parts else None
|
|
395
|
+
return text, tuple(tool_calls)
|