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,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
|