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/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
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
|