turnloop 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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Anthropic Messages API adapter.
|
|
2
|
+
|
|
3
|
+
The internal message model was designed to match this API, so conversion is
|
|
4
|
+
close to identity. What is *not* identity is prompt caching: cache breakpoints
|
|
5
|
+
have to be placed deliberately, and placing them wrong silently costs money
|
|
6
|
+
rather than failing loudly.
|
|
7
|
+
|
|
8
|
+
Breakpoint strategy (max 4 allowed by the API, we use 3):
|
|
9
|
+
1. last system segment — stable across the whole session
|
|
10
|
+
2. last tool definition — stable unless the tool set changes
|
|
11
|
+
3. last block of the second-to-last user turn — the moving frontier, so the
|
|
12
|
+
current turn's new content is the only uncached part
|
|
13
|
+
|
|
14
|
+
Placing one on the *last* user turn instead would invalidate on every request,
|
|
15
|
+
which is the classic way to pay cache-write prices for zero cache reads.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import AsyncIterator
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from turnloop.core.events import (
|
|
26
|
+
MessageDone,
|
|
27
|
+
StreamEvent,
|
|
28
|
+
TextDelta,
|
|
29
|
+
ThinkingDelta,
|
|
30
|
+
ToolUseArgsDelta,
|
|
31
|
+
ToolUseStart,
|
|
32
|
+
)
|
|
33
|
+
from turnloop.core.messages import (
|
|
34
|
+
ContentBlock,
|
|
35
|
+
Message,
|
|
36
|
+
TextBlock,
|
|
37
|
+
ThinkingBlock,
|
|
38
|
+
ToolResultBlock,
|
|
39
|
+
ToolUseBlock,
|
|
40
|
+
Usage,
|
|
41
|
+
)
|
|
42
|
+
from turnloop.providers.base import (
|
|
43
|
+
CompletionRequest,
|
|
44
|
+
ErrorClass,
|
|
45
|
+
Provider,
|
|
46
|
+
classify_error,
|
|
47
|
+
raise_for_class,
|
|
48
|
+
)
|
|
49
|
+
from turnloop.providers.openai_compat import _brief, _parse_args
|
|
50
|
+
from turnloop.providers.sse import iter_sse
|
|
51
|
+
|
|
52
|
+
API_VERSION = "2023-06-01"
|
|
53
|
+
|
|
54
|
+
_STOP_REASONS = {
|
|
55
|
+
"end_turn": "end_turn",
|
|
56
|
+
"tool_use": "tool_use",
|
|
57
|
+
"max_tokens": "max_tokens",
|
|
58
|
+
"stop_sequence": "stop_sequence",
|
|
59
|
+
"refusal": "refusal",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
_EPHEMERAL = {"cache_control": {"type": "ephemeral"}}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AnthropicProvider(Provider):
|
|
66
|
+
kind = "anthropic"
|
|
67
|
+
|
|
68
|
+
def __init__(self, *args, beta_headers: list[str] | None = None, **kwargs):
|
|
69
|
+
super().__init__(*args, **kwargs)
|
|
70
|
+
self.base_url = self.base_url or "https://api.anthropic.com/v1"
|
|
71
|
+
self.beta_headers = beta_headers or []
|
|
72
|
+
|
|
73
|
+
def headers(self) -> dict[str, str]:
|
|
74
|
+
h = super().headers()
|
|
75
|
+
h["anthropic-version"] = API_VERSION
|
|
76
|
+
if self.api_key:
|
|
77
|
+
h["x-api-key"] = self.api_key
|
|
78
|
+
if self.beta_headers:
|
|
79
|
+
h["anthropic-beta"] = ",".join(self.beta_headers)
|
|
80
|
+
return h
|
|
81
|
+
|
|
82
|
+
# --- request construction ---------------------------------------------
|
|
83
|
+
|
|
84
|
+
def build_payload(self, req: CompletionRequest) -> dict:
|
|
85
|
+
payload: dict = {
|
|
86
|
+
"model": self.model,
|
|
87
|
+
"max_tokens": req.max_tokens or self.caps.max_output,
|
|
88
|
+
"messages": self.to_wire_messages(req.messages),
|
|
89
|
+
"stream": True,
|
|
90
|
+
}
|
|
91
|
+
if req.system:
|
|
92
|
+
payload["system"] = self._system_blocks(req.system)
|
|
93
|
+
if req.tools:
|
|
94
|
+
tools = [t.to_anthropic() for t in req.tools]
|
|
95
|
+
if self.caps.supports_prompt_caching and tools:
|
|
96
|
+
tools[-1] = {**tools[-1], **_EPHEMERAL}
|
|
97
|
+
payload["tools"] = tools
|
|
98
|
+
if self.caps.supports_tool_choice and req.tool_choice != "auto":
|
|
99
|
+
payload["tool_choice"] = {
|
|
100
|
+
"none": {"type": "none"},
|
|
101
|
+
"required": {"type": "any"},
|
|
102
|
+
}[req.tool_choice]
|
|
103
|
+
|
|
104
|
+
if req.thinking_tokens and self.caps.native_thinking:
|
|
105
|
+
payload["thinking"] = {"type": "enabled", "budget_tokens": req.thinking_tokens}
|
|
106
|
+
# Extended thinking requires an unconstrained sampler.
|
|
107
|
+
payload.pop("temperature", None)
|
|
108
|
+
elif req.temperature is not None and self.caps.supports_temperature:
|
|
109
|
+
payload["temperature"] = req.temperature
|
|
110
|
+
|
|
111
|
+
if req.stop_sequences:
|
|
112
|
+
payload["stop_sequences"] = req.stop_sequences
|
|
113
|
+
return payload
|
|
114
|
+
|
|
115
|
+
def _system_blocks(self, segments: list[str]) -> list[dict]:
|
|
116
|
+
blocks: list[dict] = [{"type": "text", "text": s} for s in segments if s.strip()]
|
|
117
|
+
if blocks and self.caps.supports_prompt_caching:
|
|
118
|
+
blocks[-1] = {**blocks[-1], **_EPHEMERAL}
|
|
119
|
+
return blocks
|
|
120
|
+
|
|
121
|
+
def to_wire_messages(self, messages: list[Message]) -> list[dict]:
|
|
122
|
+
wire: list[dict] = []
|
|
123
|
+
for msg in messages:
|
|
124
|
+
blocks = [b for b in (self._block_to_wire(b) for b in msg.content) if b]
|
|
125
|
+
if not blocks:
|
|
126
|
+
continue
|
|
127
|
+
wire.append({"role": msg.role, "content": blocks})
|
|
128
|
+
|
|
129
|
+
if self.caps.supports_prompt_caching:
|
|
130
|
+
self._mark_cache_frontier(wire)
|
|
131
|
+
return wire
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _block_to_wire(block: ContentBlock) -> dict | None:
|
|
135
|
+
if isinstance(block, TextBlock):
|
|
136
|
+
return {"type": "text", "text": block.text} if block.text else None
|
|
137
|
+
if isinstance(block, ThinkingBlock):
|
|
138
|
+
# Only signed thinking may be replayed; unsigned reasoning from
|
|
139
|
+
# another provider would be rejected as an invalid signature.
|
|
140
|
+
if not block.resendable:
|
|
141
|
+
return None
|
|
142
|
+
return {"type": "thinking", "thinking": block.text, "signature": block.signature}
|
|
143
|
+
if isinstance(block, ToolUseBlock):
|
|
144
|
+
return {"type": "tool_use", "id": block.id, "name": block.name, "input": block.args}
|
|
145
|
+
if isinstance(block, ToolResultBlock):
|
|
146
|
+
return {
|
|
147
|
+
"type": "tool_result",
|
|
148
|
+
"tool_use_id": block.tool_use_id,
|
|
149
|
+
"content": block.content or "(no output)",
|
|
150
|
+
"is_error": block.is_error,
|
|
151
|
+
}
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _mark_cache_frontier(wire: list[dict]) -> None:
|
|
156
|
+
"""Cache up to the second-to-last user turn — the stable prefix."""
|
|
157
|
+
user_indices = [i for i, m in enumerate(wire) if m["role"] == "user"]
|
|
158
|
+
if len(user_indices) < 2:
|
|
159
|
+
return
|
|
160
|
+
target = wire[user_indices[-2]]
|
|
161
|
+
if target["content"]:
|
|
162
|
+
target["content"][-1] = {**target["content"][-1], **_EPHEMERAL}
|
|
163
|
+
|
|
164
|
+
# --- streaming --------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
async def _stream_once(self, req: CompletionRequest) -> AsyncIterator[StreamEvent]:
|
|
167
|
+
url = f"{self.base_url}/messages"
|
|
168
|
+
payload = self.build_payload(req)
|
|
169
|
+
bytes_seen = 0
|
|
170
|
+
acc = _Accumulator()
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
async with self.client.stream(
|
|
174
|
+
"POST", url, json=payload, headers=self.headers()
|
|
175
|
+
) as resp:
|
|
176
|
+
if resp.status_code >= 400:
|
|
177
|
+
body = (await resp.aread()).decode("utf-8", errors="replace")
|
|
178
|
+
raise_for_class(
|
|
179
|
+
classify_error(None, resp),
|
|
180
|
+
f"{self.name}: HTTP {resp.status_code} {_brief(body)}",
|
|
181
|
+
status=resp.status_code,
|
|
182
|
+
body=body,
|
|
183
|
+
)
|
|
184
|
+
async for frame in iter_sse(resp.aiter_lines()):
|
|
185
|
+
bytes_seen += len(frame.data)
|
|
186
|
+
chunk = frame.json()
|
|
187
|
+
if chunk is None:
|
|
188
|
+
continue
|
|
189
|
+
if chunk.get("type") == "error":
|
|
190
|
+
err = chunk.get("error", {})
|
|
191
|
+
# An overloaded_error mid-stream is transient; anything else
|
|
192
|
+
# arriving as a stream error will fail the same way on retry.
|
|
193
|
+
in_stream: ErrorClass = (
|
|
194
|
+
"retryable" if err.get("type") == "overloaded_error" else "fatal"
|
|
195
|
+
)
|
|
196
|
+
raise_for_class(in_stream, f"{self.name}: {err.get('message', err)}")
|
|
197
|
+
for event in acc.feed(chunk):
|
|
198
|
+
yield event
|
|
199
|
+
except (httpx.HTTPError, httpx.StreamError) as exc:
|
|
200
|
+
kind = classify_error(exc, None, bytes_seen)
|
|
201
|
+
raise_for_class(
|
|
202
|
+
"fatal" if kind == "fatal" else "retryable",
|
|
203
|
+
f"{self.name}: {type(exc).__name__}: {exc}",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
yield acc.finish(self.name, self.model)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _Accumulator:
|
|
210
|
+
"""Assembles Anthropic's block-oriented event stream."""
|
|
211
|
+
|
|
212
|
+
def __init__(self):
|
|
213
|
+
self.blocks: dict[int, dict] = {}
|
|
214
|
+
self.stop_reason: str | None = None
|
|
215
|
+
self.usage = Usage()
|
|
216
|
+
|
|
217
|
+
def feed(self, chunk: dict) -> list[StreamEvent]:
|
|
218
|
+
kind = chunk.get("type")
|
|
219
|
+
events: list[StreamEvent] = []
|
|
220
|
+
|
|
221
|
+
if kind == "message_start":
|
|
222
|
+
self.usage = _usage_from((chunk.get("message") or {}).get("usage") or {})
|
|
223
|
+
|
|
224
|
+
elif kind == "content_block_start":
|
|
225
|
+
index = chunk["index"]
|
|
226
|
+
block = chunk.get("content_block") or {}
|
|
227
|
+
btype = block.get("type")
|
|
228
|
+
self.blocks[index] = {"type": btype, "text": "", "args": "", **block}
|
|
229
|
+
if btype == "tool_use":
|
|
230
|
+
events.append(
|
|
231
|
+
ToolUseStart(index=index, id=block.get("id", ""), name=block.get("name", ""))
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
elif kind == "content_block_delta":
|
|
235
|
+
index = chunk["index"]
|
|
236
|
+
delta = chunk.get("delta") or {}
|
|
237
|
+
slot = self.blocks.setdefault(index, {"type": "text", "text": "", "args": ""})
|
|
238
|
+
dtype = delta.get("type")
|
|
239
|
+
if dtype == "text_delta":
|
|
240
|
+
slot["text"] += delta.get("text", "")
|
|
241
|
+
events.append(TextDelta(delta.get("text", "")))
|
|
242
|
+
elif dtype == "thinking_delta":
|
|
243
|
+
slot["text"] += delta.get("thinking", "")
|
|
244
|
+
events.append(ThinkingDelta(delta.get("thinking", "")))
|
|
245
|
+
elif dtype == "signature_delta":
|
|
246
|
+
slot["signature"] = slot.get("signature", "") + delta.get("signature", "")
|
|
247
|
+
elif dtype == "input_json_delta":
|
|
248
|
+
fragment = delta.get("partial_json", "")
|
|
249
|
+
slot["args"] += fragment
|
|
250
|
+
if fragment:
|
|
251
|
+
events.append(ToolUseArgsDelta(index=index, fragment=fragment))
|
|
252
|
+
|
|
253
|
+
elif kind == "message_delta":
|
|
254
|
+
delta = chunk.get("delta") or {}
|
|
255
|
+
if sr := delta.get("stop_reason"):
|
|
256
|
+
self.stop_reason = _STOP_REASONS.get(sr, "end_turn")
|
|
257
|
+
if usage := chunk.get("usage"):
|
|
258
|
+
merged = _usage_from(usage)
|
|
259
|
+
# message_delta reports cumulative output tokens only.
|
|
260
|
+
self.usage = Usage(
|
|
261
|
+
input_tokens=self.usage.input_tokens or merged.input_tokens,
|
|
262
|
+
output_tokens=merged.output_tokens or self.usage.output_tokens,
|
|
263
|
+
cache_read_tokens=self.usage.cache_read_tokens or merged.cache_read_tokens,
|
|
264
|
+
cache_write_tokens=self.usage.cache_write_tokens or merged.cache_write_tokens,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return events
|
|
268
|
+
|
|
269
|
+
def finish(self, provider: str, model: str) -> MessageDone:
|
|
270
|
+
content: list[ContentBlock] = []
|
|
271
|
+
for index in sorted(self.blocks):
|
|
272
|
+
slot = self.blocks[index]
|
|
273
|
+
btype = slot.get("type")
|
|
274
|
+
if btype == "text" and slot["text"]:
|
|
275
|
+
content.append(TextBlock(text=slot["text"]))
|
|
276
|
+
elif btype == "thinking" and slot["text"]:
|
|
277
|
+
content.append(
|
|
278
|
+
ThinkingBlock(
|
|
279
|
+
text=slot["text"],
|
|
280
|
+
signature=slot.get("signature"),
|
|
281
|
+
provider=f"{provider}:thinking",
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
elif btype == "redacted_thinking":
|
|
285
|
+
content.append(
|
|
286
|
+
ThinkingBlock(text="[redacted]", provider=f"{provider}:redacted")
|
|
287
|
+
)
|
|
288
|
+
elif btype == "tool_use":
|
|
289
|
+
content.append(
|
|
290
|
+
ToolUseBlock(
|
|
291
|
+
id=slot.get("id", ""),
|
|
292
|
+
name=slot.get("name", ""),
|
|
293
|
+
args=slot.get("input") or _parse_args(slot.get("args", "")),
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
stop = self.stop_reason or "end_turn"
|
|
298
|
+
msg = Message(
|
|
299
|
+
role="assistant",
|
|
300
|
+
content=content,
|
|
301
|
+
usage=self.usage,
|
|
302
|
+
stop_reason=stop, # type: ignore[arg-type]
|
|
303
|
+
meta={"provider": provider, "model": model},
|
|
304
|
+
)
|
|
305
|
+
return MessageDone(message=msg, usage=self.usage, stop_reason=stop)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _usage_from(u: dict) -> Usage:
|
|
309
|
+
return Usage(
|
|
310
|
+
input_tokens=u.get("input_tokens", 0) or 0,
|
|
311
|
+
output_tokens=u.get("output_tokens", 0) or 0,
|
|
312
|
+
cache_read_tokens=u.get("cache_read_input_tokens", 0) or 0,
|
|
313
|
+
cache_write_tokens=u.get("cache_creation_input_tokens", 0) or 0,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _dumps(obj) -> str: # pragma: no cover - debugging aid
|
|
318
|
+
return json.dumps(obj, indent=2, default=str)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Provider abstraction.
|
|
2
|
+
|
|
3
|
+
Design note. There is one seam in this project between "the agent loop" and "an
|
|
4
|
+
LLM", and it is `Provider.stream()`. Everything provider-specific — wire format,
|
|
5
|
+
auth, caching, reasoning fields, error taxonomy — lives behind it. Nothing above
|
|
6
|
+
it may import a provider module directly; the registry resolves names.
|
|
7
|
+
|
|
8
|
+
Capability degradation is deliberately *not* handled here. Adapters report what
|
|
9
|
+
they can do via `Capabilities`; the loop decides how to cope. Otherwise every
|
|
10
|
+
adapter grows its own copy of the fallback logic.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from collections.abc import AsyncIterator
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
from turnloop.core.events import ProviderStatus, StreamEvent
|
|
25
|
+
from turnloop.core.messages import Message, ToolSpec
|
|
26
|
+
from turnloop.core.tokens import TokenEstimator
|
|
27
|
+
from turnloop.errors import (
|
|
28
|
+
ColdBootTimeout,
|
|
29
|
+
FatalProviderError,
|
|
30
|
+
RetryableProviderError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ErrorClass = Literal["cold_boot", "retryable", "fatal"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Capabilities(BaseModel):
|
|
37
|
+
"""What a model/endpoint can do. Consulted by the loop, not by adapters."""
|
|
38
|
+
|
|
39
|
+
max_context: int = 200_000
|
|
40
|
+
max_output: int = 8_192
|
|
41
|
+
|
|
42
|
+
supports_prompt_caching: bool = False
|
|
43
|
+
supports_parallel_tool_calls: bool = True
|
|
44
|
+
supports_reasoning_field: bool = False # separate reasoning_content on the wire
|
|
45
|
+
native_thinking: bool = False # request a thinking budget (Anthropic)
|
|
46
|
+
supports_system_role: bool = True
|
|
47
|
+
supports_tool_choice: bool = True
|
|
48
|
+
supports_temperature: bool = True
|
|
49
|
+
|
|
50
|
+
# Per-million-token pricing. Zero means "unknown/free".
|
|
51
|
+
price_in_per_mtok: float = 0.0
|
|
52
|
+
price_out_per_mtok: float = 0.0
|
|
53
|
+
price_cache_read_per_mtok: float = 0.0
|
|
54
|
+
price_cache_write_per_mtok: float = 0.0
|
|
55
|
+
|
|
56
|
+
# Self-hosted endpoints bill wall clock, not tokens. GLM-5.2 on 4xH200 is
|
|
57
|
+
# $18.16/hr, which dwarfs any token cost and must be surfaced differently.
|
|
58
|
+
cost_per_hour: float = 0.0
|
|
59
|
+
|
|
60
|
+
# vLLM's --max-num-seqs, or an API's concurrency ceiling. The loop builds one
|
|
61
|
+
# CapacityLimiter from this, shared process-wide, so subagent fan-out cannot
|
|
62
|
+
# queue behind itself.
|
|
63
|
+
max_concurrent_requests: int = 8
|
|
64
|
+
|
|
65
|
+
def cost_for(self, input_tokens: int, output_tokens: int, cache_read: int = 0,
|
|
66
|
+
cache_write: int = 0) -> float:
|
|
67
|
+
m = 1_000_000
|
|
68
|
+
return (
|
|
69
|
+
input_tokens * self.price_in_per_mtok / m
|
|
70
|
+
+ output_tokens * self.price_out_per_mtok / m
|
|
71
|
+
+ cache_read * self.price_cache_read_per_mtok / m
|
|
72
|
+
+ cache_write * self.price_cache_write_per_mtok / m
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True)
|
|
77
|
+
class CompletionRequest:
|
|
78
|
+
messages: list[Message]
|
|
79
|
+
system: list[str] = field(default_factory=list) # segments, cacheable in order
|
|
80
|
+
tools: list[ToolSpec] = field(default_factory=list)
|
|
81
|
+
max_tokens: int | None = None
|
|
82
|
+
temperature: float | None = 0.0
|
|
83
|
+
thinking_tokens: int | None = None # only honored when caps.native_thinking
|
|
84
|
+
tool_choice: Literal["auto", "none", "required"] = "auto"
|
|
85
|
+
stop_sequences: list[str] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def classify_error(exc: BaseException | None, response: httpx.Response | None,
|
|
89
|
+
bytes_received: int = 0, body: str | None = None) -> ErrorClass:
|
|
90
|
+
"""Decide whether a failure means "still booting", "try again", or "give up".
|
|
91
|
+
|
|
92
|
+
Getting this wrong is expensive in both directions: treat a 400 as a cold
|
|
93
|
+
boot and you retry a malformed tool schema for 55 minutes; treat a cold boot
|
|
94
|
+
as fatal and the GLM endpoint looks permanently broken for its first ~29
|
|
95
|
+
minutes.
|
|
96
|
+
|
|
97
|
+
The discriminator for timeouts is whether any bytes arrived. Zero bytes means
|
|
98
|
+
the server never answered — consistent with vLLM not yet bound to its port.
|
|
99
|
+
A timeout mid-stream is a different failure and is merely retryable.
|
|
100
|
+
"""
|
|
101
|
+
if response is not None:
|
|
102
|
+
status = response.status_code
|
|
103
|
+
if status in (502, 503, 504):
|
|
104
|
+
return "cold_boot" # Modal's edge answers before the container does
|
|
105
|
+
if status in (408, 409, 425, 429, 500, 529):
|
|
106
|
+
return "retryable"
|
|
107
|
+
# A per-minute token quota can surface as 413 rather than 429 — Groq's free
|
|
108
|
+
# tier does exactly this. The status alone says "your request is too big
|
|
109
|
+
# forever"; the body says "too big *this minute*", which is worth waiting out.
|
|
110
|
+
if body and "rate_limit" in body and status in (400, 413):
|
|
111
|
+
return "retryable"
|
|
112
|
+
if 400 <= status < 500:
|
|
113
|
+
return "fatal"
|
|
114
|
+
if status >= 500:
|
|
115
|
+
return "retryable"
|
|
116
|
+
return "retryable"
|
|
117
|
+
|
|
118
|
+
if isinstance(exc, httpx.ConnectError | httpx.ConnectTimeout):
|
|
119
|
+
return "cold_boot"
|
|
120
|
+
if isinstance(exc, httpx.ReadTimeout | httpx.PoolTimeout):
|
|
121
|
+
return "cold_boot" if bytes_received == 0 else "retryable"
|
|
122
|
+
if isinstance(exc, httpx.RemoteProtocolError | httpx.ReadError | httpx.WriteError):
|
|
123
|
+
return "retryable"
|
|
124
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
125
|
+
return "cold_boot" if bytes_received == 0 else "retryable"
|
|
126
|
+
return "fatal"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def raise_for_class(kind: ErrorClass, message: str, *, status: int | None = None,
|
|
130
|
+
body: str | None = None, retry_after: float | None = None) -> None:
|
|
131
|
+
if kind == "fatal":
|
|
132
|
+
raise FatalProviderError(message, status=status, body=body, retry_after=retry_after)
|
|
133
|
+
raise RetryableProviderError(message, status=status, body=body, retry_after=retry_after)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_retry_after(response: httpx.Response | None) -> float | None:
|
|
137
|
+
"""Seconds from a `Retry-After` header, when the server sent one."""
|
|
138
|
+
if response is None:
|
|
139
|
+
return None
|
|
140
|
+
raw = response.headers.get("retry-after") or response.headers.get("x-ratelimit-reset-tokens")
|
|
141
|
+
if not raw:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return max(0.0, float(str(raw).rstrip("s")))
|
|
145
|
+
except ValueError:
|
|
146
|
+
return None # HTTP-date form; the default backoff is good enough
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Provider(ABC):
|
|
150
|
+
"""Base class for every adapter.
|
|
151
|
+
|
|
152
|
+
Subclasses implement `_stream_once` (one attempt, no retries) and the loop
|
|
153
|
+
around it — health gating, cold-boot patience, backoff — is inherited.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
kind: str = "abstract"
|
|
157
|
+
|
|
158
|
+
def __init__(self, *, name: str, model: str, caps: Capabilities,
|
|
159
|
+
base_url: str | None = None, api_key: str = "", headers: dict | None = None,
|
|
160
|
+
timeout_s: float | None = None, health_url: str | None = None,
|
|
161
|
+
cold_boot_budget_s: float = 55 * 60, cold_boot_poll_s: float = 10.0,
|
|
162
|
+
max_retries: int = 3):
|
|
163
|
+
self.name = name
|
|
164
|
+
self.model = model
|
|
165
|
+
self.caps = caps
|
|
166
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
167
|
+
self.api_key = api_key
|
|
168
|
+
self.extra_headers = headers or {}
|
|
169
|
+
self.timeout_s = timeout_s
|
|
170
|
+
self.health_url = health_url
|
|
171
|
+
self.cold_boot_budget_s = cold_boot_budget_s
|
|
172
|
+
self.cold_boot_poll_s = cold_boot_poll_s
|
|
173
|
+
self.max_retries = max_retries
|
|
174
|
+
|
|
175
|
+
self.estimator = TokenEstimator()
|
|
176
|
+
self._client: httpx.AsyncClient | None = None
|
|
177
|
+
self._healthy_until: float = 0.0
|
|
178
|
+
# Wall clock for cost_per_hour providers, from first successful request.
|
|
179
|
+
self.first_request_at: float | None = None
|
|
180
|
+
self.last_request_at: float | None = None
|
|
181
|
+
|
|
182
|
+
# --- HTTP plumbing -----------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def _timeout(self) -> httpx.Timeout:
|
|
185
|
+
"""Short connect, unbounded read.
|
|
186
|
+
|
|
187
|
+
Connect timing is our cold-boot signal, so it stays tight. Read must be
|
|
188
|
+
unbounded: time-to-first-token on a 4xH200 serving a 744B model can be
|
|
189
|
+
minutes, and this is exactly where the openai SDK's 600s default breaks.
|
|
190
|
+
"""
|
|
191
|
+
if self.timeout_s is None:
|
|
192
|
+
return httpx.Timeout(connect=10.0, read=None, write=30.0, pool=None)
|
|
193
|
+
return httpx.Timeout(self.timeout_s, connect=min(10.0, self.timeout_s))
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def client(self) -> httpx.AsyncClient:
|
|
197
|
+
if self._client is None:
|
|
198
|
+
self._client = httpx.AsyncClient(timeout=self._timeout(), follow_redirects=True)
|
|
199
|
+
return self._client
|
|
200
|
+
|
|
201
|
+
async def aclose(self) -> None:
|
|
202
|
+
if self._client is not None:
|
|
203
|
+
await self._client.aclose()
|
|
204
|
+
self._client = None
|
|
205
|
+
|
|
206
|
+
def headers(self) -> dict[str, str]:
|
|
207
|
+
return {"content-type": "application/json", **self.extra_headers}
|
|
208
|
+
|
|
209
|
+
# --- readiness ---------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
async def preflight(self) -> AsyncIterator[ProviderStatus]:
|
|
212
|
+
"""Poll a health endpoint until it answers, reporting progress.
|
|
213
|
+
|
|
214
|
+
Only self-hosted endpoints configure `health_url`. For hosted APIs this
|
|
215
|
+
yields nothing and returns immediately.
|
|
216
|
+
"""
|
|
217
|
+
if not self.health_url:
|
|
218
|
+
return
|
|
219
|
+
if time.monotonic() < self._healthy_until:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
started = time.monotonic()
|
|
223
|
+
attempt = 0
|
|
224
|
+
while True:
|
|
225
|
+
elapsed = time.monotonic() - started
|
|
226
|
+
try:
|
|
227
|
+
resp = await self.client.get(
|
|
228
|
+
self.health_url, timeout=httpx.Timeout(15.0, connect=10.0)
|
|
229
|
+
)
|
|
230
|
+
if resp.status_code < 400:
|
|
231
|
+
# Trust health for a while; re-checking every turn wastes a
|
|
232
|
+
# round trip and, on Modal, counts as billable traffic.
|
|
233
|
+
self._healthy_until = time.monotonic() + 120
|
|
234
|
+
yield ProviderStatus("endpoint ready", phase="ready", elapsed_s=elapsed)
|
|
235
|
+
return
|
|
236
|
+
except Exception: # noqa: BLE001 - any transport failure means "not up"
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
if elapsed > self.cold_boot_budget_s:
|
|
240
|
+
raise ColdBootTimeout(self._timeout_message(elapsed))
|
|
241
|
+
|
|
242
|
+
attempt += 1
|
|
243
|
+
yield ProviderStatus(self._boot_hint(elapsed), phase="cold_boot", elapsed_s=elapsed)
|
|
244
|
+
await _sleep(self.cold_boot_poll_s)
|
|
245
|
+
|
|
246
|
+
def _boot_hint(self, elapsed: float) -> str:
|
|
247
|
+
mins, secs = divmod(int(elapsed), 60)
|
|
248
|
+
budget_mins = self.cold_boot_budget_s / 60
|
|
249
|
+
base = f"cold boot: {mins}m{secs:02d}s elapsed, budget {budget_mins:.0f}m"
|
|
250
|
+
if self.caps.cost_per_hour:
|
|
251
|
+
base += " (first boot ~29 min, ~13 min with a warm compile cache)"
|
|
252
|
+
return base
|
|
253
|
+
|
|
254
|
+
def _timeout_message(self, elapsed: float) -> str:
|
|
255
|
+
"""An error a tired human can act on, not just "it timed out".
|
|
256
|
+
|
|
257
|
+
The endpoint accepted every connection and never answered — which is
|
|
258
|
+
exactly what a stopped Modal app also looks like from here (see
|
|
259
|
+
`classify_error`'s docstring for why the two cannot be told apart on
|
|
260
|
+
the wire). Say that plainly instead of implying the wait was simply
|
|
261
|
+
too short.
|
|
262
|
+
"""
|
|
263
|
+
msg = (
|
|
264
|
+
f"{self.name}: endpoint accepted connections for "
|
|
265
|
+
f"{self.cold_boot_budget_s / 60:.0f} min but never became healthy. "
|
|
266
|
+
"This is indistinguishable from a stopped deployment — Modal's edge "
|
|
267
|
+
"accepts the TCP connection either way."
|
|
268
|
+
)
|
|
269
|
+
if self.caps.cost_per_hour:
|
|
270
|
+
msg += " If this is Modal-hosted, verify the app is actually deployed (not stopped)."
|
|
271
|
+
return msg
|
|
272
|
+
|
|
273
|
+
# --- the streaming loop -----------------------------------------------
|
|
274
|
+
|
|
275
|
+
@abstractmethod
|
|
276
|
+
def _stream_once(self, req: CompletionRequest) -> AsyncIterator[StreamEvent]:
|
|
277
|
+
"""One attempt. Raise Fatal/Retryable errors; do not retry internally."""
|
|
278
|
+
raise NotImplementedError
|
|
279
|
+
|
|
280
|
+
async def stream(self, req: CompletionRequest) -> AsyncIterator[StreamEvent]:
|
|
281
|
+
"""Health-gate, then attempt with backoff, surfacing status as events."""
|
|
282
|
+
async for status in self.preflight():
|
|
283
|
+
yield status
|
|
284
|
+
|
|
285
|
+
attempt = 0
|
|
286
|
+
while True:
|
|
287
|
+
try:
|
|
288
|
+
async for event in self._stream_once(req):
|
|
289
|
+
yield event
|
|
290
|
+
self.last_request_at = time.monotonic()
|
|
291
|
+
if self.first_request_at is None:
|
|
292
|
+
self.first_request_at = self.last_request_at
|
|
293
|
+
return
|
|
294
|
+
except FatalProviderError:
|
|
295
|
+
raise
|
|
296
|
+
except RetryableProviderError as exc:
|
|
297
|
+
attempt += 1
|
|
298
|
+
if attempt > self.max_retries:
|
|
299
|
+
raise
|
|
300
|
+
delay = exc.retry_after if exc.retry_after else min(2 ** attempt, 30)
|
|
301
|
+
delay = min(delay, 90) # never park a turn for longer than this
|
|
302
|
+
yield ProviderStatus(
|
|
303
|
+
f"{exc} — retry {attempt}/{self.max_retries} in {delay}s", phase="retrying"
|
|
304
|
+
)
|
|
305
|
+
await _sleep(delay)
|
|
306
|
+
except ColdBootTimeout:
|
|
307
|
+
raise
|
|
308
|
+
|
|
309
|
+
# --- accounting -------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def gpu_seconds(self) -> float | None:
|
|
312
|
+
"""Wall clock a self-hosted endpoint has been in use this session."""
|
|
313
|
+
if not self.caps.cost_per_hour or self.first_request_at is None:
|
|
314
|
+
return None
|
|
315
|
+
return time.monotonic() - self.first_request_at
|
|
316
|
+
|
|
317
|
+
def estimate_tokens(self, messages: list[Message], system: list[str],
|
|
318
|
+
tools: list[ToolSpec]) -> int:
|
|
319
|
+
return self.estimator.estimate_request(messages, system, tools)
|
|
320
|
+
|
|
321
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
322
|
+
return f"<{type(self).__name__} {self.name}:{self.model}>"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def _sleep(seconds: float) -> None:
|
|
326
|
+
# Indirected so tests can patch a single symbol instead of anyio internals.
|
|
327
|
+
import anyio
|
|
328
|
+
|
|
329
|
+
await anyio.sleep(seconds)
|