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,357 @@
1
+ """OpenAI Chat Completions adapter.
2
+
3
+ Covers OpenAI itself, Groq, Together, Ollama, LM Studio, and any vLLM server —
4
+ including the self-hosted GLM-5.2 this project targets. The GLM specifics are a
5
+ constructor flag rather than a subclass, because there is exactly one behavioral
6
+ difference on the wire: reasoning arrives in `reasoning_content` instead of
7
+ `content`, courtesy of vLLM's `--reasoning-parser glm45`.
8
+
9
+ Two wire-format facts drive the code below.
10
+
11
+ 1. `is_error` has no representation in this API. A tool result is just a string
12
+ in a `role: "tool"` message. So an error result is prefixed with "Error: " —
13
+ that text is the *only* signal the model receives, and dropping it makes
14
+ models retry blindly.
15
+
16
+ 2. Tool arguments stream as fragments of a JSON string across many deltas,
17
+ keyed by `index`, and `id`/`name` appear only on the first fragment. Nothing
18
+ is parseable until the choice finishes.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from collections.abc import AsyncIterator
25
+
26
+ import httpx
27
+
28
+ from turnloop.core.events import (
29
+ MessageDone,
30
+ ProviderStatus,
31
+ StreamEvent,
32
+ TextDelta,
33
+ ThinkingDelta,
34
+ ToolUseArgsDelta,
35
+ ToolUseStart,
36
+ )
37
+ from turnloop.core.ids import new_id
38
+ from turnloop.core.messages import (
39
+ ContentBlock,
40
+ Message,
41
+ TextBlock,
42
+ ThinkingBlock,
43
+ ToolResultBlock,
44
+ ToolUseBlock,
45
+ Usage,
46
+ )
47
+ from turnloop.providers.base import (
48
+ CompletionRequest,
49
+ Provider,
50
+ classify_error,
51
+ parse_retry_after,
52
+ raise_for_class,
53
+ )
54
+ from turnloop.providers.sse import iter_sse
55
+
56
+ _STOP_REASONS = {
57
+ "stop": "end_turn",
58
+ "length": "max_tokens",
59
+ "tool_calls": "tool_use",
60
+ "function_call": "tool_use",
61
+ "content_filter": "refusal",
62
+ }
63
+
64
+
65
+ class OpenAICompatProvider(Provider):
66
+ kind = "openai_compat"
67
+
68
+ def __init__(self, *args, glm_reasoning: bool = False, extra_body: dict | None = None,
69
+ **kwargs):
70
+ super().__init__(*args, **kwargs)
71
+ self.glm_reasoning = glm_reasoning
72
+ self.extra_body = extra_body or {}
73
+
74
+ def headers(self) -> dict[str, str]:
75
+ h = super().headers()
76
+ # vLLM on Modal is unauthenticated, but sending a bearer token is
77
+ # harmless and keeps one code path for every OpenAI-compatible endpoint.
78
+ if self.api_key:
79
+ h["authorization"] = f"Bearer {self.api_key}"
80
+ return h
81
+
82
+ # --- request construction ---------------------------------------------
83
+
84
+ def build_payload(self, req: CompletionRequest) -> dict:
85
+ payload: dict = {
86
+ "model": self.model,
87
+ "messages": self.to_wire_messages(req),
88
+ "stream": True,
89
+ # Ask for usage on the final chunk. Standard OpenAI, honored by vLLM.
90
+ "stream_options": {"include_usage": True},
91
+ }
92
+ if req.max_tokens or self.caps.max_output:
93
+ payload["max_tokens"] = req.max_tokens or self.caps.max_output
94
+ if req.temperature is not None and self.caps.supports_temperature:
95
+ payload["temperature"] = req.temperature
96
+ if req.stop_sequences:
97
+ payload["stop"] = req.stop_sequences
98
+ if req.tools:
99
+ payload["tools"] = [t.to_openai() for t in req.tools]
100
+ if self.caps.supports_tool_choice:
101
+ payload["tool_choice"] = (
102
+ "required" if req.tool_choice == "required" else req.tool_choice
103
+ )
104
+ payload.update(self.extra_body)
105
+ return payload
106
+
107
+ def to_wire_messages(self, req: CompletionRequest) -> list[dict]:
108
+ out: list[dict] = []
109
+ system_text = "\n\n".join(s for s in req.system if s.strip())
110
+ if system_text:
111
+ role = "system" if self.caps.supports_system_role else "user"
112
+ out.append({"role": role, "content": system_text})
113
+
114
+ for msg in req.messages:
115
+ if msg.role == "user":
116
+ out.extend(self._user_to_wire(msg))
117
+ else:
118
+ wire = self._assistant_to_wire(msg)
119
+ if wire:
120
+ out.append(wire)
121
+ return out
122
+
123
+ def _user_to_wire(self, msg: Message) -> list[dict]:
124
+ """Split a user message into tool results plus any remaining text.
125
+
126
+ Tool results must each become their own `role: "tool"` message, and they
127
+ have to precede any user text in the same turn or providers complain
128
+ about a tool_call without a matching response.
129
+ """
130
+ tool_msgs = [
131
+ {
132
+ "role": "tool",
133
+ "tool_call_id": b.tool_use_id,
134
+ "content": (f"Error: {b.content}" if b.is_error else b.content) or "(no output)",
135
+ }
136
+ for b in msg.content
137
+ if isinstance(b, ToolResultBlock)
138
+ ]
139
+ text = "".join(b.text for b in msg.content if isinstance(b, TextBlock)).strip()
140
+ if text:
141
+ tool_msgs.append({"role": "user", "content": text})
142
+ return tool_msgs
143
+
144
+ def _assistant_to_wire(self, msg: Message) -> dict | None:
145
+ text = "".join(b.text for b in msg.content if isinstance(b, TextBlock))
146
+ calls = [
147
+ {
148
+ "id": b.id,
149
+ "type": "function",
150
+ "function": {"name": b.name, "arguments": json.dumps(b.args)},
151
+ }
152
+ for b in msg.content
153
+ if isinstance(b, ToolUseBlock)
154
+ ]
155
+ # Thinking is never sent back. There is no signature to verify, the API
156
+ # has no field for it, and on a 65k window it is pure overhead.
157
+ if not text and not calls:
158
+ return None
159
+ wire: dict = {"role": "assistant", "content": text or None}
160
+ if calls:
161
+ wire["tool_calls"] = calls
162
+ return wire
163
+
164
+ # --- streaming --------------------------------------------------------
165
+
166
+ async def _stream_once(self, req: CompletionRequest) -> AsyncIterator[StreamEvent]:
167
+ url = f"{self.base_url}/chat/completions"
168
+ payload = self.build_payload(req)
169
+ bytes_seen = 0
170
+
171
+ acc = _Accumulator(glm_reasoning=self.glm_reasoning)
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, body=body),
180
+ f"{self.name}: HTTP {resp.status_code} {_brief(body)}",
181
+ status=resp.status_code,
182
+ body=body,
183
+ retry_after=parse_retry_after(resp),
184
+ )
185
+
186
+ async for frame in iter_sse(resp.aiter_lines()):
187
+ bytes_seen += len(frame.data)
188
+ if frame.is_done:
189
+ break
190
+ chunk = frame.json()
191
+ if chunk is None:
192
+ continue
193
+ if err := chunk.get("error"):
194
+ raise_for_class("fatal", f"{self.name}: {err}", body=json.dumps(err))
195
+ for event in acc.feed(chunk):
196
+ yield event
197
+ except (httpx.HTTPError, httpx.StreamError) as exc:
198
+ kind = classify_error(exc, None, bytes_seen)
199
+ if kind == "cold_boot":
200
+ # Surface as retryable so Provider.stream backs off and the next
201
+ # attempt re-runs preflight, which is what actually waits it out.
202
+ yield ProviderStatus(f"{self.name}: endpoint not answering", phase="cold_boot")
203
+ raise_for_class(
204
+ "fatal" if kind == "fatal" else "retryable", f"{self.name}: {type(exc).__name__}: {exc}"
205
+ )
206
+
207
+ yield acc.finish(self.name, self.model)
208
+
209
+
210
+ class _Accumulator:
211
+ """Turns a sequence of chat-completion chunks into events plus a Message."""
212
+
213
+ def __init__(self, glm_reasoning: bool = False):
214
+ self.glm_reasoning = glm_reasoning
215
+ self.text: list[str] = []
216
+ self.thinking: list[str] = []
217
+ # index -> {"id", "name", "args"}
218
+ self.calls: dict[int, dict] = {}
219
+ self.stop_reason: str | None = None
220
+ self.usage = Usage()
221
+ self._started: set[int] = set()
222
+
223
+ def feed(self, chunk: dict) -> list[StreamEvent]:
224
+ events: list[StreamEvent] = []
225
+
226
+ if usage := chunk.get("usage"):
227
+ self.usage = _usage_from(usage)
228
+
229
+ for choice in chunk.get("choices") or []:
230
+ delta = choice.get("delta") or {}
231
+
232
+ # Reasoning first: vLLM's glm45 parser emits it as a sibling of
233
+ # `content`, and a chunk can legitimately carry both.
234
+ reasoning = delta.get("reasoning_content") or delta.get("reasoning")
235
+ if reasoning:
236
+ self.thinking.append(reasoning)
237
+ events.append(ThinkingDelta(reasoning))
238
+
239
+ if content := delta.get("content"):
240
+ self.text.append(content)
241
+ events.append(TextDelta(content))
242
+
243
+ # NVIDIA NIM's z-ai/glm-5.2 sends id, function.name, and the full
244
+ # function.arguments string fully formed in a single delta,
245
+ # immediately followed by finish_reason: "tool_calls" — unlike
246
+ # self-hosted vLLM's glm45 parser, which fragments arguments
247
+ # across chunks. No special casing needed: accumulating one
248
+ # fragment yields the same string as accumulating many.
249
+ for tc in delta.get("tool_calls") or []:
250
+ events.extend(self._feed_tool_call(tc))
251
+
252
+ if fr := choice.get("finish_reason"):
253
+ self.stop_reason = _STOP_REASONS.get(fr, "end_turn")
254
+
255
+ return events
256
+
257
+ def _feed_tool_call(self, tc: dict) -> list[StreamEvent]:
258
+ index = tc.get("index", 0)
259
+ slot = self.calls.setdefault(index, {"id": "", "name": "", "args": ""})
260
+
261
+ if tc.get("id"):
262
+ slot["id"] = tc["id"]
263
+ fn = tc.get("function") or {}
264
+ if fn.get("name"):
265
+ slot["name"] = fn["name"]
266
+
267
+ events: list[StreamEvent] = []
268
+ if index not in self._started and slot["name"]:
269
+ self._started.add(index)
270
+ if not slot["id"]:
271
+ # Some servers omit ids entirely; the loop needs one to pair the
272
+ # result back, so synthesize a stable one.
273
+ slot["id"] = new_id("call")
274
+ events.append(ToolUseStart(index=index, id=slot["id"], name=slot["name"]))
275
+
276
+ if (fragment := fn.get("arguments")) is not None:
277
+ slot["args"] += fragment
278
+ if fragment:
279
+ events.append(ToolUseArgsDelta(index=index, fragment=fragment))
280
+ return events
281
+
282
+ def finish(self, provider: str, model: str) -> MessageDone:
283
+ blocks: list[ContentBlock] = []
284
+ if thinking := "".join(self.thinking):
285
+ blocks.append(
286
+ ThinkingBlock(text=thinking, provider=f"{provider}:reasoning_content")
287
+ )
288
+ if text := "".join(self.text):
289
+ blocks.append(TextBlock(text=text))
290
+
291
+ for index in sorted(self.calls):
292
+ slot = self.calls[index]
293
+ if not slot["name"]:
294
+ continue
295
+ blocks.append(
296
+ ToolUseBlock(
297
+ id=slot["id"] or new_id("call"),
298
+ name=slot["name"],
299
+ args=_parse_args(slot["args"]),
300
+ )
301
+ )
302
+
303
+ stop = self.stop_reason or ("tool_use" if self.calls else "end_turn")
304
+ # A model that emits tool calls but finish_reason "stop" (seen on some
305
+ # vLLM builds) still needs the loop to run the tools.
306
+ if self.calls and stop == "end_turn":
307
+ stop = "tool_use"
308
+
309
+ msg = Message(
310
+ role="assistant",
311
+ content=blocks,
312
+ usage=self.usage,
313
+ stop_reason=stop, # type: ignore[arg-type]
314
+ meta={"provider": provider, "model": model},
315
+ )
316
+ return MessageDone(message=msg, usage=self.usage, stop_reason=stop)
317
+
318
+
319
+ def _parse_args(raw: str) -> dict:
320
+ """Parse accumulated argument JSON, tolerating the ways models get it wrong.
321
+
322
+ A failure here is not an exception: it becomes `{}` plus a `_raw` field, and
323
+ schema validation in ToolRunner produces an error result the model can read
324
+ and correct. Counting these is precisely the tool-calling-reliability metric.
325
+ """
326
+ raw = (raw or "").strip()
327
+ if not raw:
328
+ return {}
329
+ try:
330
+ parsed = json.loads(raw)
331
+ except json.JSONDecodeError:
332
+ # A truncated stream often leaves valid JSON one brace short.
333
+ for suffix in ('"}', "}", "]}", '"}]}'):
334
+ try:
335
+ parsed = json.loads(raw + suffix)
336
+ break
337
+ except json.JSONDecodeError:
338
+ continue
339
+ else:
340
+ return {"_raw": raw, "_parse_error": "arguments were not valid JSON"}
341
+ if isinstance(parsed, dict):
342
+ return parsed
343
+ return {"_raw": raw, "_parse_error": "arguments were not a JSON object"}
344
+
345
+
346
+ def _usage_from(u: dict) -> Usage:
347
+ details = u.get("prompt_tokens_details") or {}
348
+ return Usage(
349
+ input_tokens=u.get("prompt_tokens", 0) or 0,
350
+ output_tokens=u.get("completion_tokens", 0) or 0,
351
+ cache_read_tokens=details.get("cached_tokens", 0) or 0,
352
+ )
353
+
354
+
355
+ def _brief(body: str, limit: int = 400) -> str:
356
+ body = " ".join(body.split())
357
+ return body if len(body) <= limit else body[:limit] + "…"
@@ -0,0 +1,148 @@
1
+ """Capability and price presets per model.
2
+
3
+ Prices are USD per million tokens, as published at the time of writing. They are
4
+ data, not truth: `settings.json` can override any field, and a wrong number here
5
+ only skews the cost column, never behavior.
6
+
7
+ `glm-5.2` is the interesting entry. It has no token price at all — it is
8
+ self-hosted on 4xH200 and billed by wall clock, so `cost_per_hour` carries the
9
+ cost and `max_concurrent_requests` mirrors the server's `--max-num-seqs 16`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from turnloop.providers.base import Capabilities
15
+
16
+ _CLAUDE_COMMON = {
17
+ "max_context": 200_000,
18
+ "supports_prompt_caching": True,
19
+ "supports_parallel_tool_calls": True,
20
+ "native_thinking": True,
21
+ "price_cache_read_per_mtok": 0.0, # filled per model below
22
+ }
23
+
24
+ PRESETS: dict[str, Capabilities] = {
25
+ # --- Anthropic ------------------------------------------------------
26
+ "claude-opus-4-5": Capabilities(
27
+ max_context=200_000, max_output=64_000, supports_prompt_caching=True,
28
+ native_thinking=True, price_in_per_mtok=5.0, price_out_per_mtok=25.0,
29
+ price_cache_read_per_mtok=0.5, price_cache_write_per_mtok=6.25,
30
+ max_concurrent_requests=8,
31
+ ),
32
+ "claude-sonnet-4-5": Capabilities(
33
+ max_context=200_000, max_output=64_000, supports_prompt_caching=True,
34
+ native_thinking=True, price_in_per_mtok=3.0, price_out_per_mtok=15.0,
35
+ price_cache_read_per_mtok=0.3, price_cache_write_per_mtok=3.75,
36
+ max_concurrent_requests=8,
37
+ ),
38
+ "claude-haiku-4-5": Capabilities(
39
+ max_context=200_000, max_output=64_000, supports_prompt_caching=True,
40
+ native_thinking=True, price_in_per_mtok=1.0, price_out_per_mtok=5.0,
41
+ price_cache_read_per_mtok=0.1, price_cache_write_per_mtok=1.25,
42
+ max_concurrent_requests=8,
43
+ ),
44
+ # --- self-hosted GLM-5.2 on Modal -----------------------------------
45
+ "glm-5.2": Capabilities(
46
+ max_context=65_536,
47
+ max_output=8_192,
48
+ supports_prompt_caching=False,
49
+ supports_parallel_tool_calls=True,
50
+ supports_reasoning_field=True, # vLLM --reasoning-parser glm45
51
+ native_thinking=False,
52
+ cost_per_hour=18.16, # 4xH200
53
+ max_concurrent_requests=16, # vLLM --max-num-seqs 16
54
+ ),
55
+ # --- NVIDIA NIM -------------------------------------------------------
56
+ #
57
+ # NVIDIA hosts Z.ai's GLM-5.2 on NIM at 200k context — nothing like the
58
+ # 65k self-hosted deployment above. This key must be an exact model id
59
+ # ("z-ai/glm-5.2"), not a substring of it: `preset_for`'s fallback does
60
+ # `key in model`, and the self-hosted key "glm-5.2" is a substring of
61
+ # this one. Without an explicit exact entry, this model would silently
62
+ # inherit the self-hosted preset's 65k window and $18.16/hr cost.
63
+ #
64
+ # No published per-token price exists for this credit-based developer
65
+ # endpoint, so both prices are honestly 0.0 rather than fabricated.
66
+ "z-ai/glm-5.2": Capabilities(
67
+ max_context=200_000,
68
+ max_output=32_768,
69
+ supports_parallel_tool_calls=True,
70
+ supports_reasoning_field=True, # reasoning_content, opt-in via extra_body
71
+ native_thinking=False,
72
+ price_in_per_mtok=0.0,
73
+ price_out_per_mtok=0.0,
74
+ # Measured, not guessed: an 84-run sweep triggered HTTP 429 almost
75
+ # immediately, and minutes later a single sequential request still
76
+ # got 429'd. No Retry-After or x-ratelimit-* headers are ever present,
77
+ # so there is nothing to back off against. That a lone request failed
78
+ # well after the burst suggests the free/credit tier's limit is a
79
+ # longer-window quota rather than a per-minute throttle, but the
80
+ # actual reset window is unknown — better to say so than invent one.
81
+ max_concurrent_requests=1,
82
+ ),
83
+ # --- OpenAI ---------------------------------------------------------
84
+ "gpt-4.1": Capabilities(
85
+ max_context=1_047_576, max_output=32_768, price_in_per_mtok=2.0,
86
+ price_out_per_mtok=8.0, price_cache_read_per_mtok=0.5,
87
+ ),
88
+ "gpt-4.1-mini": Capabilities(
89
+ max_context=1_047_576, max_output=32_768, price_in_per_mtok=0.4,
90
+ price_out_per_mtok=1.6, price_cache_read_per_mtok=0.1,
91
+ ),
92
+ # --- Blaxel (OpenAI-compatible sandbox) ------------------------------
93
+ #
94
+ # Blaxel's sandbox endpoint is pinned to gpt-4o-mini server-side — the
95
+ # `model` field in the request is accepted but ignored — so this preset
96
+ # exists to price and size the window for that one fixed model. Prices
97
+ # are OpenAI's published gpt-4o-mini list rates; Blaxel does not publish
98
+ # its own sandbox billing, so what this actually costs on Blaxel is
99
+ # unknown, not zero. `max_concurrent_requests=4` is a modest default, not
100
+ # a measured limit: ten consecutive requests totalling 390k input tokens
101
+ # drew zero 429s, so nothing here is throttling on purpose.
102
+ "gpt-4o-mini": Capabilities(
103
+ max_context=128_000, max_output=16_384, price_in_per_mtok=0.15,
104
+ price_out_per_mtok=0.60, max_concurrent_requests=4,
105
+ ),
106
+ # --- Groq -----------------------------------------------------------
107
+ #
108
+ # These numbers are the *free-tier rate limit*, not the model's capability.
109
+ # Groq's on-demand tier caps tokens-per-minute at 8,000 and — this is the part
110
+ # that surprises — counts the requested `max_tokens` against it. Asking for the
111
+ # model's real 32,768-token output with a 4k prompt is a 35k request and a hard
112
+ # 413 before a single token is generated.
113
+ #
114
+ # `max_context` is therefore set to what we may actually send, which is what the
115
+ # budget and compaction machinery consumes it as. Raise both on a paid tier.
116
+ "llama-3.3-70b-versatile": Capabilities(
117
+ max_context=7_000, max_output=1_500, price_in_per_mtok=0.59,
118
+ price_out_per_mtok=0.79, max_concurrent_requests=1,
119
+ ),
120
+ "openai/gpt-oss-120b": Capabilities(
121
+ max_context=7_000, max_output=1_500, supports_reasoning_field=True,
122
+ price_in_per_mtok=0.15, price_out_per_mtok=0.75, max_concurrent_requests=1,
123
+ ),
124
+ # --- Gemini ---------------------------------------------------------
125
+ "gemini-2.5-pro": Capabilities(
126
+ max_context=1_048_576, max_output=65_536, price_in_per_mtok=1.25,
127
+ price_out_per_mtok=10.0, price_cache_read_per_mtok=0.31,
128
+ ),
129
+ "gemini-2.5-flash": Capabilities(
130
+ max_context=1_048_576, max_output=65_536, price_in_per_mtok=0.30,
131
+ price_out_per_mtok=2.50, price_cache_read_per_mtok=0.075,
132
+ ),
133
+ }
134
+
135
+
136
+ def preset_for(model: str) -> Capabilities:
137
+ """Best-effort capability lookup: exact match, then prefix, then a default.
138
+
139
+ A conservative default (128k context, no caching) is safer than guessing
140
+ generously — under-estimating the window costs an extra compaction, while
141
+ over-estimating costs a hard 400 mid-session.
142
+ """
143
+ if model in PRESETS:
144
+ return PRESETS[model].model_copy(deep=True)
145
+ for key, caps in PRESETS.items():
146
+ if model.startswith(key) or key in model:
147
+ return caps.model_copy(deep=True)
148
+ return Capabilities(max_context=128_000, max_output=8_192)
@@ -0,0 +1,75 @@
1
+ """Provider construction from configuration.
2
+
3
+ The only module that imports every adapter. Everything above the provider layer
4
+ resolves a provider by name through here, so adding a new backend touches one
5
+ dict and one file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from turnloop.errors import ConfigError
13
+ from turnloop.providers.anthropic import AnthropicProvider
14
+ from turnloop.providers.base import Provider
15
+ from turnloop.providers.gemini import GeminiProvider
16
+ from turnloop.providers.mock import MockProvider
17
+ from turnloop.providers.openai_compat import OpenAICompatProvider
18
+
19
+ _KINDS = {
20
+ "anthropic": AnthropicProvider,
21
+ "openai_compat": OpenAICompatProvider,
22
+ "gemini": GeminiProvider,
23
+ "mock": MockProvider,
24
+ }
25
+
26
+
27
+ def build_provider(name: str, cfg) -> Provider:
28
+ """Instantiate a provider from a ProviderConfig.
29
+
30
+ `cfg` is typed loosely to keep config.py from importing this module and back.
31
+ """
32
+ cls = _KINDS.get(cfg.kind)
33
+ if cls is None:
34
+ raise ConfigError(
35
+ f"provider '{name}': unknown kind '{cfg.kind}' "
36
+ f"(expected one of {', '.join(sorted(_KINDS))})"
37
+ )
38
+
39
+ api_key = cfg.api_key or ""
40
+ if cfg.api_key_env:
41
+ api_key = os.environ.get(cfg.api_key_env, "") or api_key
42
+ if not api_key and cfg.kind != "openai_compat":
43
+ # An OpenAI-compatible endpoint may legitimately be unauthenticated
44
+ # (vLLM on Modal is), so only hosted APIs hard-fail here.
45
+ raise ConfigError(
46
+ f"provider '{name}': environment variable {cfg.api_key_env} is not set"
47
+ )
48
+
49
+ kwargs: dict = {
50
+ "name": name,
51
+ "model": cfg.model,
52
+ "caps": cfg.caps,
53
+ "base_url": cfg.base_url,
54
+ "api_key": api_key,
55
+ "headers": dict(cfg.headers or {}),
56
+ "timeout_s": cfg.timeout_s,
57
+ "health_url": cfg.health_url,
58
+ }
59
+ if cfg.cold_boot_budget_s is not None:
60
+ kwargs["cold_boot_budget_s"] = cfg.cold_boot_budget_s
61
+
62
+ if cfg.kind == "openai_compat":
63
+ kwargs["glm_reasoning"] = cfg.glm_reasoning
64
+ kwargs["extra_body"] = dict(cfg.extra_body or {})
65
+ elif cfg.kind == "mock":
66
+ kwargs["mode"] = cfg.mock_mode
67
+ kwargs["seed"] = cfg.mock_seed
68
+ if cfg.mock_replay_path:
69
+ kwargs["replay_path"] = cfg.mock_replay_path
70
+
71
+ return cls(**kwargs)
72
+
73
+
74
+ def available_kinds() -> list[str]:
75
+ return sorted(_KINDS)
@@ -0,0 +1,114 @@
1
+ """Server-sent-events parsing.
2
+
3
+ This file is the entire reason the project needs no vendor SDK. Every endpoint we
4
+ speak to (Anthropic messages, OpenAI-compatible chat completions, Gemini
5
+ streamGenerateContent) emits `event:`/`data:` framed text, and parsing it here
6
+ means the raw JSON dict reaches the adapter untouched — which is how GLM's
7
+ `reasoning_content` arrives without fighting a typed SDK model that has no field
8
+ for it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import AsyncIterator
15
+ from dataclasses import dataclass
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class SSEEvent:
20
+ event: str | None
21
+ data: str
22
+
23
+ def json(self) -> dict | None:
24
+ if not self.data or self.data == "[DONE]":
25
+ return None
26
+ try:
27
+ parsed = json.loads(self.data)
28
+ except json.JSONDecodeError:
29
+ return None
30
+ return parsed if isinstance(parsed, dict) else None
31
+
32
+ @property
33
+ def is_done(self) -> bool:
34
+ return self.data.strip() == "[DONE]"
35
+
36
+
37
+ async def iter_sse(lines: AsyncIterator[str]) -> AsyncIterator[SSEEvent]:
38
+ """Yield one SSEEvent per blank-line-terminated frame.
39
+
40
+ Multi-line `data:` fields are joined with newlines per the SSE spec. A frame
41
+ is also flushed at end-of-stream even without a trailing blank line, because
42
+ some proxies drop it.
43
+ """
44
+ event_name: str | None = None
45
+ data_lines: list[str] = []
46
+
47
+ async for raw in lines:
48
+ line = raw.rstrip("\r\n")
49
+
50
+ if not line:
51
+ if data_lines or event_name:
52
+ yield SSEEvent(event=event_name, data="\n".join(data_lines))
53
+ event_name, data_lines = None, []
54
+ continue
55
+
56
+ if line.startswith(":"): # comment / keepalive
57
+ continue
58
+
59
+ field, _, value = line.partition(":")
60
+ if value.startswith(" "):
61
+ value = value[1:]
62
+
63
+ if field == "event":
64
+ event_name = value
65
+ elif field == "data":
66
+ data_lines.append(value)
67
+ # `id` and `retry` are unused by any provider we target.
68
+
69
+ if data_lines or event_name:
70
+ yield SSEEvent(event=event_name, data="\n".join(data_lines))
71
+
72
+
73
+ async def iter_json_array(chunks: AsyncIterator[bytes]) -> AsyncIterator[dict]:
74
+ """Stream objects out of a top-level JSON array as they complete.
75
+
76
+ Gemini's `streamGenerateContent` without `alt=sse` returns one growing JSON
77
+ array rather than SSE frames. Tracks brace depth outside of string literals.
78
+ """
79
+ buf = ""
80
+ depth = 0
81
+ start = -1
82
+ in_string = False
83
+ escape = False
84
+
85
+ async for chunk in chunks:
86
+ buf += chunk.decode("utf-8", errors="replace")
87
+ i = 0
88
+ while i < len(buf):
89
+ ch = buf[i]
90
+ if in_string:
91
+ if escape:
92
+ escape = False
93
+ elif ch == "\\":
94
+ escape = True
95
+ elif ch == '"':
96
+ in_string = False
97
+ elif ch == '"':
98
+ in_string = True
99
+ elif ch == "{":
100
+ if depth == 0:
101
+ start = i
102
+ depth += 1
103
+ elif ch == "}":
104
+ depth -= 1
105
+ if depth == 0 and start >= 0:
106
+ try:
107
+ obj = json.loads(buf[start : i + 1])
108
+ except json.JSONDecodeError:
109
+ obj = None
110
+ if isinstance(obj, dict):
111
+ yield obj
112
+ buf = buf[i + 1 :]
113
+ i, start = -1, -1
114
+ i += 1
File without changes