chad-code 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.
chad/mcp_oauth.py ADDED
@@ -0,0 +1,329 @@
1
+ """OAuth client support for hosted HTTP MCP servers (plan 017).
2
+
3
+ Hosted connectors that matter to the operator (Linear, Slack, Atlassian, most SaaS)
4
+ require **OAuth**, not a static bearer token. Plan 016 added HTTP transport with
5
+ bearer/PAT auth; this module adds the OAuth path. It is built on the official `mcp`
6
+ SDK's OAuth machinery and contributes the two chad-specific pieces the SDK leaves to
7
+ the embedder:
8
+
9
+ 1. **Token persistence** — an `mcp.client.auth.TokenStorage` implementation that
10
+ keeps per-server tokens + dynamic-client-registration info in
11
+ `~/.chad/mcp_tokens.json`, created mode 0600 (never world-readable, never logged).
12
+ 2. **The interactive browser/loopback flow** — a one-shot localhost HTTP server that
13
+ catches the `?code=...&state=...` redirect, plus a redirect handler that opens
14
+ the browser (and prints the URL as a headless fallback). Bounded by a timeout so
15
+ an abandoned or headless login degrades gracefully instead of hanging.
16
+
17
+ Everything here is gated by the `CHAD_MCP_OAUTH` feature flag (see `oauth_enabled`).
18
+ With the flag off, an `auth: oauth` server is skipped with a warning and the
19
+ stdio/bearer/HTTP paths from plan 016 are unchanged — none of this code runs.
20
+
21
+ SDK API confirmed against **mcp==1.28.1** (`mcp.__version__` does not exist; use
22
+ `importlib.metadata.version("mcp")`):
23
+ - `from mcp.client.auth import OAuthClientProvider, TokenStorage`
24
+ - `OAuthClientProvider(server_url, client_metadata, storage, redirect_handler,
25
+ callback_handler, timeout=300.0, client_metadata_url=None)`
26
+ - `TokenStorage` is a 4-method **async** protocol:
27
+ get_tokens() -> OAuthToken | None
28
+ set_tokens(tokens) -> None
29
+ get_client_info() -> OAuthClientInformationFull | None
30
+ set_client_info(client_info) -> None
31
+ - models live in `mcp.shared.auth`: `OAuthClientMetadata` (only `redirect_uris`
32
+ is required), `OAuthToken` (`access_token` required), `OAuthClientInformationFull`.
33
+ - `OAuthClientProvider` is an `httpx.Auth`; Dynamic Client Registration is performed
34
+ internally during its auth flow when the server advertises a registration endpoint
35
+ and no client_info is stored (so we never hand-roll DCR).
36
+ - `streamablehttp_client(url, headers=..., auth=<provider>)` accepts the provider —
37
+ this is exactly why plan 016 keeps the deprecated `streamablehttp_client` (the
38
+ newer `streamable_http_client` has no `auth=`).
39
+ No divergence from the plan's assumed API was found.
40
+
41
+ NOTE / e2e gap: a full 3-legged OAuth handshake against a real authorization server is
42
+ NOT exercised in tests (there is no real IdP in CI). The seams are tested — token-store
43
+ round-trip + 0600 perms, the loopback callback parsing a fired redirect, the flag-off
44
+ skip, the needs-login state, and headless/no-browser degradation. Do not fake a green
45
+ end-to-end OAuth test.
46
+ """
47
+
48
+ import json
49
+ import os
50
+ import threading
51
+ import time
52
+ from http.server import BaseHTTPRequestHandler, HTTPServer
53
+ from urllib.parse import parse_qs, urlparse
54
+
55
+ import anyio
56
+ from mcp.client.auth import OAuthClientProvider
57
+ from mcp.shared.auth import (
58
+ OAuthClientInformationFull,
59
+ OAuthClientMetadata,
60
+ OAuthToken,
61
+ )
62
+
63
+ from .diag import log
64
+
65
+ # How long the interactive login waits for the human to approve in the browser and the
66
+ # loopback redirect to land, before giving up. Bounds the flow so an abandoned or
67
+ # headless login degrades (no tools + a clear message) instead of hanging the agent.
68
+ _LOGIN_TIMEOUT = 300.0
69
+ # Poll granularity for the one-shot callback server's blocking accept loop.
70
+ _POLL = 1.0
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Feature flag — ALL OAuth code is gated here. Off by default.
75
+ # ---------------------------------------------------------------------------
76
+
77
+ def oauth_enabled() -> bool:
78
+ """True only when the operator has opted into OAuth via `CHAD_MCP_OAUTH`. With this
79
+ off, no OAuth code runs and the stdio/bearer/HTTP paths behave exactly as plan 016."""
80
+ val = os.environ.get("CHAD_MCP_OAUTH", "")
81
+ return val.strip().lower() not in ("", "0", "false", "no", "off")
82
+
83
+
84
+ def is_oauth(spec: dict) -> bool:
85
+ """Whether a server spec opts into OAuth (an `auth: oauth` marker on an http
86
+ server). Independent of the feature flag — the flag decides whether we act on it."""
87
+ return isinstance(spec, dict) and str(spec.get("auth", "")).lower() == "oauth"
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Token storage — ~/.chad/mcp_tokens.json, mode 0600, keyed per server
92
+ # ---------------------------------------------------------------------------
93
+ #
94
+ # The file maps a server key -> {"tokens": <OAuthToken>, "client_info":
95
+ # <OAuthClientInformationFull>}. Both are pydantic models persisted via model_dump and
96
+ # rehydrated via model_validate. The file is created 0600 from the first write so a
97
+ # token is never briefly world-readable, mirroring plan 015's trust store. Token VALUES
98
+ # are never logged — only the server key and field name ever appear in diagnostics.
99
+
100
+ _io_lock = threading.Lock()
101
+
102
+
103
+ def tokens_path() -> str:
104
+ # Uses the same os.path.expanduser indirection the rest of mcp.py relies on so tests
105
+ # isolate HOME by monkeypatching expanduser.
106
+ return os.path.join(os.path.expanduser("~"), ".chad", "mcp_tokens.json")
107
+
108
+
109
+ def _read_all() -> dict:
110
+ path = tokens_path()
111
+ try:
112
+ with open(path, "r", errors="replace") as f:
113
+ data = json.load(f)
114
+ return data if isinstance(data, dict) else {}
115
+ except (OSError, ValueError):
116
+ return {}
117
+
118
+
119
+ def _write_all(data: dict) -> None:
120
+ path = tokens_path()
121
+ os.makedirs(os.path.dirname(path), exist_ok=True)
122
+ # Create with 0600 from the start so credentials are never briefly world-readable.
123
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
124
+ with os.fdopen(fd, "w") as f:
125
+ json.dump(data, f)
126
+ os.chmod(path, 0o600)
127
+
128
+
129
+ def has_tokens(server_key: str) -> bool:
130
+ """Sync helper for the connect-time 'needs login?' decision: does the store hold an
131
+ access token for this server? (Presence only — never inspects/logs the value.)"""
132
+ entry = _read_all().get(server_key)
133
+ return bool(isinstance(entry, dict) and (entry.get("tokens") or {}).get("access_token"))
134
+
135
+
136
+ class FileTokenStorage:
137
+ """`mcp.client.auth.TokenStorage` backed by ~/.chad/mcp_tokens.json (0600).
138
+
139
+ The SDK calls these on the portal's event loop, hence async; the file I/O itself is
140
+ plain sync inside (it's small and local). Refreshed tokens flow back through
141
+ `set_tokens` so the SDK's silent refresh is persisted for the next run."""
142
+
143
+ def __init__(self, server_key: str):
144
+ self._key = server_key
145
+
146
+ async def get_tokens(self) -> "OAuthToken | None":
147
+ entry = _read_all().get(self._key) or {}
148
+ raw = entry.get("tokens")
149
+ if not isinstance(raw, dict):
150
+ return None
151
+ try:
152
+ return OAuthToken.model_validate(raw)
153
+ except Exception as e: # noqa: BLE001 — corrupt entry shouldn't crash connect
154
+ log.warning("mcp: %s stored tokens unreadable (%s)", self._key, type(e).__name__)
155
+ return None
156
+
157
+ async def set_tokens(self, tokens: "OAuthToken") -> None:
158
+ with _io_lock:
159
+ data = _read_all()
160
+ entry = data.get(self._key)
161
+ if not isinstance(entry, dict):
162
+ entry = {}
163
+ entry["tokens"] = tokens.model_dump(mode="json", exclude_none=True)
164
+ data[self._key] = entry
165
+ _write_all(data)
166
+
167
+ async def get_client_info(self) -> "OAuthClientInformationFull | None":
168
+ entry = _read_all().get(self._key) or {}
169
+ raw = entry.get("client_info")
170
+ if not isinstance(raw, dict):
171
+ return None
172
+ try:
173
+ return OAuthClientInformationFull.model_validate(raw)
174
+ except Exception as e: # noqa: BLE001
175
+ log.warning("mcp: %s stored client_info unreadable (%s)", self._key, type(e).__name__)
176
+ return None
177
+
178
+ async def set_client_info(self, client_info: "OAuthClientInformationFull") -> None:
179
+ with _io_lock:
180
+ data = _read_all()
181
+ entry = data.get(self._key)
182
+ if not isinstance(entry, dict):
183
+ entry = {}
184
+ entry["client_info"] = client_info.model_dump(mode="json", exclude_none=True)
185
+ data[self._key] = entry
186
+ _write_all(data)
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Loopback callback server — catches the OAuth redirect on 127.0.0.1
191
+ # ---------------------------------------------------------------------------
192
+
193
+ _CLOSE_PAGE = (
194
+ b"<!doctype html><html><body style='font-family:sans-serif'>"
195
+ b"<h3>chad: login complete</h3><p>You can close this tab.</p></body></html>"
196
+ )
197
+
198
+
199
+ class _CallbackHandler(BaseHTTPRequestHandler):
200
+ """One-shot handler: records the redirect's code/state onto the server object and
201
+ shows a 'you can close this tab' page. Silenced so it never spews to the console."""
202
+
203
+ def do_GET(self): # noqa: N802 — http.server API
204
+ qs = parse_qs(urlparse(self.path).query)
205
+ self.server.code = (qs.get("code") or [None])[0]
206
+ self.server.state = (qs.get("state") or [None])[0]
207
+ self.server.error = (qs.get("error") or [None])[0]
208
+ self.server.received = True
209
+ body = _CLOSE_PAGE
210
+ if self.server.error:
211
+ body = (b"<!doctype html><html><body style='font-family:sans-serif'>"
212
+ b"<h3>chad: login failed</h3><p>You can close this tab.</p></body></html>")
213
+ self.send_response(200)
214
+ self.send_header("Content-Type", "text/html")
215
+ self.send_header("Content-Length", str(len(body)))
216
+ self.end_headers()
217
+ self.wfile.write(body)
218
+
219
+ def log_message(self, *args): # noqa: A002 — silence default request logging
220
+ pass
221
+
222
+
223
+ class LoopbackServer:
224
+ """A localhost HTTP listener bound to an ephemeral port. The bound port is known
225
+ *before* the flow starts, so the redirect URI registered with the provider matches
226
+ exactly what we listen on (no port race). `wait()` blocks for one redirect, bounded
227
+ by a timeout so an abandoned login can't hang forever."""
228
+
229
+ def __init__(self):
230
+ self._httpd = HTTPServer(("127.0.0.1", 0), _CallbackHandler)
231
+ self._httpd.code = None
232
+ self._httpd.state = None
233
+ self._httpd.error = None
234
+ self._httpd.received = False
235
+ self._httpd.timeout = _POLL
236
+ self.port = self._httpd.server_address[1]
237
+ self.redirect_uri = f"http://127.0.0.1:{self.port}/callback"
238
+
239
+ def wait(self, timeout: float):
240
+ """Block until one redirect lands; return (code, state) or None on timeout.
241
+ Sync (run via anyio.to_thread.run_sync off the portal loop)."""
242
+ deadline = time.monotonic() + timeout
243
+ while time.monotonic() < deadline:
244
+ # handle_request returns after one request OR after self.timeout seconds
245
+ # (handle_timeout), so this loop stays responsive to the deadline.
246
+ self._httpd.handle_request()
247
+ if self._httpd.received:
248
+ return self._httpd.code, self._httpd.state
249
+ return None
250
+
251
+ def close(self):
252
+ try:
253
+ self._httpd.server_close()
254
+ except Exception: # noqa: BLE001 — best-effort
255
+ pass
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # OAuthClientProvider builders
260
+ # ---------------------------------------------------------------------------
261
+
262
+ def _client_metadata(redirect_uri: str, scope: str | None) -> "OAuthClientMetadata":
263
+ return OAuthClientMetadata(
264
+ # pydantic coerces the str to AnyUrl at construction; the annotation is stricter.
265
+ redirect_uris=[redirect_uri], # type: ignore[list-item]
266
+ client_name="chad",
267
+ grant_types=["authorization_code", "refresh_token"],
268
+ response_types=["code"],
269
+ token_endpoint_auth_method="client_secret_post",
270
+ scope=scope or None,
271
+ )
272
+
273
+
274
+ def make_noninteractive_provider(server_url: str, storage: "FileTokenStorage",
275
+ scope: str | None = None) -> "OAuthClientProvider":
276
+ """Provider for AUTO-CONNECT when tokens already exist: the SDK uses/refreshes the
277
+ stored token silently. The handlers deliberately RAISE — if a refresh fails and the
278
+ SDK would otherwise open a browser, we degrade (connect errors, no tools) instead of
279
+ hanging an agent turn or eval. The operator re-runs `/mcp login` to re-authorize."""
280
+
281
+ async def _no_interaction(*_a, **_k):
282
+ raise RuntimeError("interactive OAuth login required — run /mcp login <server>")
283
+
284
+ return OAuthClientProvider(
285
+ server_url=server_url,
286
+ client_metadata=_client_metadata("http://127.0.0.1/callback", scope),
287
+ storage=storage,
288
+ redirect_handler=_no_interaction,
289
+ callback_handler=_no_interaction,
290
+ )
291
+
292
+
293
+ def make_login_provider(server_url: str, storage: "FileTokenStorage",
294
+ loopback: "LoopbackServer", scope: str | None = None,
295
+ emit=None, timeout: float = _LOGIN_TIMEOUT) -> "OAuthClientProvider":
296
+ """Provider for the INTERACTIVE `/mcp login` flow: opens the browser (printing the
297
+ URL as a headless fallback) and catches the redirect on the loopback server. Bounded
298
+ by `timeout` so an abandoned/headless login fails cleanly instead of hanging."""
299
+ say = emit or (lambda _m: None)
300
+
301
+ async def redirect_handler(auth_url: str) -> None:
302
+ opened = False
303
+ try:
304
+ import webbrowser
305
+ opened = bool(webbrowser.open(auth_url))
306
+ except Exception: # noqa: BLE001 — no display / no browser
307
+ opened = False
308
+ if opened:
309
+ say("Opened your browser to approve access. If it didn't open, visit:")
310
+ else:
311
+ say("No browser available. Open this URL to approve access:")
312
+ say(auth_url)
313
+
314
+ async def callback_handler() -> "tuple[str, str | None]":
315
+ res = await anyio.to_thread.run_sync(loopback.wait, timeout)
316
+ if res is None:
317
+ raise TimeoutError(f"OAuth login timed out after {int(timeout)}s")
318
+ code, state = res
319
+ if not code:
320
+ raise RuntimeError("OAuth login did not return an authorization code")
321
+ return code, state
322
+
323
+ return OAuthClientProvider(
324
+ server_url=server_url,
325
+ client_metadata=_client_metadata(loopback.redirect_uri, scope),
326
+ storage=storage,
327
+ redirect_handler=redirect_handler,
328
+ callback_handler=callback_handler,
329
+ )
chad/openai_engine.py ADDED
@@ -0,0 +1,252 @@
1
+ """OpenAI-compatible backend adapter (plan 046 SPIKE — behind `--backend openai`).
2
+
3
+ WHAT THIS IS, AND ISN'T
4
+ -----------------------
5
+ This is an **architecture spike**, not a production backend. It exists to answer one
6
+ research question — *how much of chad's result is the harness vs the in-process engine?* —
7
+ by letting the exact same agent loop (guardrails, validate/repair, symbolic tools, edit
8
+ cascade) drive **any** OpenAI-compatible `/v1/chat/completions` endpoint (mlx_lm.server,
9
+ llama.cpp, LM Studio, a cloud model). It is a `BaseEngine` (see base_engine.py), so it
10
+ drops into the `Agent` slot where `engine.Engine` normally sits. The default MLX path is
11
+ UNTOUCHED; you only get here with `chad --backend openai …`.
12
+
13
+ The recorded verdict (README, plan 046) is that the in-process engine is the moat: the
14
+ stateless HTTP boundary throws away everything that makes chad fast. This adapter makes
15
+ that cost *measurable* rather than merely asserted — and it necessarily reproduces the
16
+ loss. The degradations below are not bugs to fix; they are the finding.
17
+
18
+ HONEST DEGRADATIONS (loud, on purpose)
19
+ --------------------------------------
20
+ 1. DETOKENIZE — the agent hands the engine rendered *token ids* (`prompt_ids`); a chat
21
+ endpoint wants *messages*. We pick **decode-the-ids**: `tok.decode(prompt_ids)` and
22
+ ship the result verbatim as a single user message. WHY this over re-rendering a
23
+ message list: the ids ARE the seam the agent provides, and decoding reproduces chad's
24
+ exact rendered prompt (system prompt, tool schemas, `<think>` scaffolding, every prior
25
+ turn) byte-for-byte — so the ablation measures *the real harness context over a
26
+ server*, not a lossily re-derived one. The re-render alternative isn't even available
27
+ at this seam without changing `generate`'s contract (the adapter never receives the
28
+ structured `messages`), and chasing it would grow model-specific templating past the
29
+ plan's 50-line stop line. COST: the server re-applies ITS OWN chat template around our
30
+ already-templated text (a double chat-template), and multi-role structure flattens to
31
+ one user turn. Acceptable for a TTFT/pass-rate spike; not for production chat fidelity.
32
+ 2. `cached_tokens` is UNKNOWABLE across the boundary — the server owns its cache and
33
+ doesn't report reuse. We report `cached_tokens=0` and set `GenStats.approximate=True`
34
+ so callers know the prefill/throughput numbers are estimates, not the exact accounting
35
+ the MLX engine gives.
36
+ 3. NO PREFILL PROGRESS — the server prefills opaquely; `on_prefill` fires once (best-effort
37
+ size, 0 cached) and `on_prefill_progress` is never called. The status line can't show
38
+ an advancing %; there is nothing to advance.
39
+ 4. INTERRUPTS = DROPPING THE STREAM — there is no server-side cancel; honoring
40
+ `should_stop` means we stop reading and close the HTTP response mid-generation. Tokens
41
+ already produced server-side are simply discarded.
42
+
43
+ There is also no persistent prefix KV cache, no warm-prefix disk checkpoint, and no
44
+ cache quarantine — those require owning the cache object, which a stateless endpoint
45
+ forbids. `warm_prefix`/`push_cache`/`pop_cache` are therefore no-ops here (satisfying the
46
+ protocol without pretending to do work).
47
+ """
48
+
49
+ import json
50
+ import time
51
+ import urllib.request
52
+ from typing import Any, Callable, Iterator, Optional
53
+
54
+ from .base_engine import GenStats
55
+
56
+
57
+ def build_chat_body(model_id: str, prompt_text: str, max_tokens: int, temp: float,
58
+ stream: bool = True) -> dict:
59
+ """Build the `/v1/chat/completions` request body (pure — no network, unit-tested).
60
+
61
+ The decoded chad prompt goes in as a SINGLE user message (see the module docstring's
62
+ DETOKENIZE note). `stream_options.include_usage` asks the server to append a final
63
+ usage chunk so we can report real token counts when it obliges."""
64
+ return {
65
+ "model": model_id,
66
+ "messages": [{"role": "user", "content": prompt_text}],
67
+ "max_tokens": max_tokens,
68
+ "temperature": temp,
69
+ "stream": stream,
70
+ "stream_options": {"include_usage": True},
71
+ }
72
+
73
+
74
+ def parse_sse_chunk(line: str) -> Optional[dict]:
75
+ """Parse one Server-Sent-Events line from a streamed chat completion (pure).
76
+
77
+ Returns the decoded JSON payload for a `data: {…}` line, or None for blanks,
78
+ comments, and the terminal `data: [DONE]` sentinel. Raises nothing on a normal
79
+ stream; malformed JSON propagates so the caller can decide (we treat it as end)."""
80
+ line = line.strip()
81
+ if not line or line.startswith(":"):
82
+ return None
83
+ if not line.startswith("data:"):
84
+ return None
85
+ payload = line[len("data:"):].strip()
86
+ if payload == "[DONE]":
87
+ return None
88
+ return json.loads(payload)
89
+
90
+
91
+ def delta_text(chunk: dict) -> str:
92
+ """Extract the incremental assistant text from a streamed chat-completion chunk
93
+ (pure). Tolerates the usage-only final chunk (empty choices) by returning ''."""
94
+ choices = chunk.get("choices") or []
95
+ if not choices:
96
+ return ""
97
+ return choices[0].get("delta", {}).get("content") or ""
98
+
99
+
100
+ class OpenAIEngine:
101
+ """A `BaseEngine` that talks to an OpenAI-compatible `/v1/chat/completions` endpoint.
102
+
103
+ Constructed by cli.py under `--backend openai`. Loads only a *tokenizer* (no weights)
104
+ so the agent can render/decode prompts; all generation happens over HTTP. See the
105
+ module docstring for the degradations this backend deliberately embodies."""
106
+
107
+ def __init__(self, model_id: str, base_url: str, api_key: str = "",
108
+ tokenizer_id: Optional[str] = None, effective_ctx: int = 32768,
109
+ temp: float = 0.0, timeout: float = 600.0):
110
+ self.model_id = model_id
111
+ # Normalize to the completions URL. Accept a base like "http://host:8080/v1" or
112
+ # the full ".../v1/chat/completions"; be forgiving about the trailing slash.
113
+ base = base_url.rstrip("/")
114
+ self.url = base if base.endswith("/chat/completions") else base + "/chat/completions"
115
+ self.api_key = api_key
116
+ self.temp = temp
117
+ self.timeout = timeout
118
+ self.effective_ctx = effective_ctx
119
+ # tokenizer_id lets you point at HF tokenizer files when the served model_id isn't
120
+ # a resolvable repo (e.g. an mlx_lm.server alias); defaults to model_id.
121
+ self._tokenizer_id = tokenizer_id or model_id
122
+ # --- BaseEngine / drop-in data members ---
123
+ self.tok: Any = None # loaded in load()
124
+ self.cache_dir: Optional[str] = None # no on-disk KV across a stateless boundary
125
+ self._cached_ids: list = [] # never populated; kept for seam compatibility
126
+ self.draft = None # no draft model (repl status line reads this)
127
+ self.kv_bytes_per_token: float = 0.0 # cli's RAM-aware ctx sizing reads this
128
+
129
+ # -- lifecycle --------------------------------------------------------
130
+
131
+ def load(self) -> float:
132
+ """Load ONLY the tokenizer (no weights) so `Agent._render` can template prompts and
133
+ we can decode ids back to text. Returns elapsed seconds (mirrors Engine.load)."""
134
+ t0 = time.time()
135
+ from transformers import AutoTokenizer # heavy import; defer to actual use
136
+ self.tok = AutoTokenizer.from_pretrained(self._tokenizer_id)
137
+ # Prefer the tokenizer's documented window if it's sane and we weren't told one.
138
+ mml = getattr(self.tok, "model_max_length", None)
139
+ if isinstance(mml, int) and 0 < mml < 10_000_000:
140
+ self.effective_ctx = min(self.effective_ctx, mml) if self.effective_ctx else mml
141
+ return time.time() - t0
142
+
143
+ def reset(self) -> None:
144
+ """No conversational state to drop (the server is stateless); clear the vestigial
145
+ id list so the seam behaves identically to a fresh engine."""
146
+ self._cached_ids = []
147
+
148
+ _reset_cache = reset # cli/bench call the private spelling; keep it working
149
+
150
+ def warm_prefix(self, prefix_ids: list,
151
+ should_stop: Optional[Callable[[], bool]] = None) -> tuple[str, int]:
152
+ """No disk KV warm-start across a stateless boundary — the server owns any cache
153
+ and won't hand us its state. Always 'skip'."""
154
+ return ("skip", 0)
155
+
156
+ def push_cache(self) -> None:
157
+ """No cache to quarantine (stateless); a sub-agent just shares the same endpoint."""
158
+ return None
159
+
160
+ def pop_cache(self) -> None:
161
+ return None
162
+
163
+ # -- HTTP (isolated so tests can stub it; no network in tests) ---------
164
+
165
+ def _stream_completion(self, body: dict) -> Iterator[str]:
166
+ """POST the chat-completion request and yield raw SSE lines. The ONLY network code
167
+ in this file; unit tests monkeypatch it with a canned line generator so the pure
168
+ request-build / SSE-parse / generate-accounting logic is exercised offline."""
169
+ data = json.dumps(body).encode("utf-8")
170
+ headers = {"Content-Type": "application/json"}
171
+ if self.api_key:
172
+ headers["Authorization"] = f"Bearer {self.api_key}"
173
+ req = urllib.request.Request(self.url, data=data, headers=headers, method="POST")
174
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
175
+ for raw in resp:
176
+ yield raw.decode("utf-8", "replace")
177
+
178
+ # -- generation -------------------------------------------------------
179
+
180
+ def generate(
181
+ self,
182
+ prompt_ids: list,
183
+ max_tokens: int = 2048,
184
+ on_token: Optional[Callable[[str], None]] = None,
185
+ stop_texts: Optional[list] = None,
186
+ should_stop: Optional[Callable[[], bool]] = None,
187
+ on_prefill: Optional[Callable[[int, int], None]] = None,
188
+ on_prefill_progress: Optional[Callable[[int, int], None]] = None,
189
+ stop_condition: Optional[Callable[[str, int], bool]] = None,
190
+ ) -> tuple[str, GenStats]:
191
+ """Stream a completion from the endpoint. See the module docstring for the honest
192
+ degradations baked into this path (detokenize, cached_tokens=0/approximate, no
193
+ prefill progress, interrupt = drop the stream)."""
194
+ # DETOKENIZE (degradation #1): decode chad's rendered ids back to text, keeping the
195
+ # special role/think markers so the server receives chad's exact prompt verbatim.
196
+ prompt_text = self.tok.decode(prompt_ids, skip_special_tokens=False)
197
+ stats = GenStats(prompt_tokens=len(prompt_ids), cached_tokens=0, approximate=True)
198
+ # on_prefill fires ONCE with a best-effort size; there is no cached prefix (0) and
199
+ # no progress stream to advance (on_prefill_progress is intentionally never called).
200
+ if on_prefill:
201
+ on_prefill(stats.prompt_tokens, 0)
202
+
203
+ body = build_chat_body(self.model_id, prompt_text, max_tokens, self.temp)
204
+ text = ""
205
+ n_out = 0
206
+ t0 = time.time()
207
+ first_at: Optional[float] = None
208
+ usage: Optional[dict] = None
209
+ stream = self._stream_completion(body)
210
+ try:
211
+ for raw in stream:
212
+ # INTERRUPT (degradation #4): no server-side cancel — stop reading and let
213
+ # the `finally` close the response, discarding whatever it kept generating.
214
+ if should_stop and should_stop():
215
+ break
216
+ for sub in raw.splitlines(): # a read may carry >1 SSE line
217
+ chunk = parse_sse_chunk(sub)
218
+ if chunk is None:
219
+ continue
220
+ if chunk.get("usage"): # final include_usage chunk
221
+ usage = chunk["usage"]
222
+ seg = delta_text(chunk)
223
+ if not seg:
224
+ continue
225
+ if first_at is None:
226
+ first_at = time.time()
227
+ stats.prefill_s = first_at - t0
228
+ text += seg
229
+ n_out += 1
230
+ if on_token:
231
+ on_token(seg)
232
+ if stop_texts and any(s in text for s in stop_texts):
233
+ should_stop = lambda: True # noqa: E731 — bail on next outer read
234
+ break
235
+ if stop_condition is not None and stop_condition(text, n_out):
236
+ stats.stop_condition_fired = True
237
+ should_stop = lambda: True # noqa: E731
238
+ break
239
+ finally:
240
+ close = getattr(stream, "close", None)
241
+ if close:
242
+ close()
243
+
244
+ stats.gen_s = time.time() - (first_at or t0)
245
+ # Prefer the server's real token counts when it sent a usage chunk; otherwise the
246
+ # streamed-chunk count is our (approximate) generated-token estimate.
247
+ if usage:
248
+ stats.generated_tokens = int(usage.get("completion_tokens", n_out))
249
+ stats.prompt_tokens = int(usage.get("prompt_tokens", stats.prompt_tokens))
250
+ else:
251
+ stats.generated_tokens = n_out
252
+ return text, stats