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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. 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)