Skip to content

Commit e302713

Browse files
committed
fix(pi): address code-review findings in mlflow proxy + OSS limits
Proxy (_mlflow_proxy.py): - Close the connection at stream end (self.close_connection = True) so HTTP/1.1 clients detect the message boundary instead of hanging after [DONE] (the proxy sends no Content-Length/chunking). [critical] - Only repair text/event-stream responses; relay non-streaming and error bodies verbatim with their real Content-Type instead of forcing SSE. [high] - Catch URLError/OSError around urlopen and return a clean 502 instead of a dead socket on connection-level failures. [high] - Restrict forwarded paths to /ai-gateway/mlflow/ (404 otherwise) so the localhost relay can't be used as an authenticated arbitrary-path workspace client. [security] - Move terminator-repair writes inside the OSError guard so a client disconnect doesn't traceback on the daemon thread. [medium] - Parse SSE data lines with or without the optional space after the colon. [low] Discovery (databricks.py): - OSS chat models matching a broad family (qwen, llama-) but lacking a specific _MODEL_TOKEN_LIMITS entry now fall back to a conservative floor (128k/8192) so they're never offered uncapped (which 400s). Non-OSS and embedding ids still return None. Verified live: glm-5-2 streams content (42) + finish_reason + [DONE] through the proxy; non-stream/error/404/502 paths covered by tests. 898 passed.
1 parent b1f2dd4 commit e302713

4 files changed

Lines changed: 193 additions & 43 deletions

File tree

src/ucode/agents/_mlflow_proxy.py

Lines changed: 79 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,13 @@
77
with "Stream ended without finish_reason", so the model is unusable.
88
99
This proxy sits between Pi and the gateway. It forwards every request verbatim
10-
and passes every response byte through unchanged, except that it repairs the
11-
stream terminator: when a streaming response ends — cleanly at ``[DONE]`` OR
12-
abnormally (an upstream drop / mid-stream 429, which otherwise reaches Pi as
13-
"Stream ended without finish_reason") — after some data but without a
14-
``finish_reason``, it synthesizes a ``finish_reason: "stop"`` chunk (and a
15-
``[DONE]`` if the upstream never sent one). Models that already terminate
16-
correctly (glm, llama, qwen, ...) never trigger the injection, so the proxy is
17-
a no-op for them.
10+
and, for streaming (``text/event-stream``) responses, repairs the terminator:
11+
when the stream ends — cleanly at ``[DONE]`` OR abnormally (an upstream drop /
12+
mid-stream 429) — after some data but without a ``finish_reason``, it
13+
synthesizes a ``finish_reason: "stop"`` chunk (and a ``[DONE]`` if the upstream
14+
never sent one). Non-streaming and error responses are relayed byte-for-byte
15+
with their original ``Content-Type``. Well-behaved streams never trigger the
16+
injection, so the proxy is a no-op for them.
1817
1918
Tracked upstream as the gateway bug that should make this unnecessary; once the
2019
gateway emits ``finish_reason`` this module can be deleted.
@@ -32,28 +31,56 @@
3231
# Request headers we must not forward: hop-by-hop or ones urllib recomputes.
3332
_SKIP_REQUEST_HEADERS = frozenset({"host", "content-length", "accept-encoding", "connection"})
3433

34+
# The proxy only exists to front the mlflow chat-completions route. Refuse any
35+
# other path so a co-located process can't turn the localhost relay into an
36+
# arbitrary authenticated workspace client (SSRF-to-workspace) using the token
37+
# we forward.
38+
_ALLOWED_PATH_PREFIX = "/ai-gateway/mlflow/"
39+
3540

3641
def _make_handler(upstream: str) -> type[BaseHTTPRequestHandler]:
3742
class _Handler(BaseHTTPRequestHandler):
3843
protocol_version = "HTTP/1.1"
3944

4045
def do_POST(self) -> None: # noqa: N802 (BaseHTTPRequestHandler API)
46+
# Read the body regardless so the socket is drained before we close.
4147
length = int(self.headers.get("Content-Length", 0))
4248
body = self.rfile.read(length)
49+
50+
if not self.path.startswith(_ALLOWED_PATH_PREFIX):
51+
self._send_bytes(404, b'{"error":"path not allowed"}', "application/json")
52+
return
53+
4354
req = urllib.request.Request(upstream + self.path, data=body, method="POST")
4455
for key, value in self.headers.items():
4556
if key.lower() not in _SKIP_REQUEST_HEADERS:
4657
req.add_header(key, value)
4758
try:
4859
upstream_resp = urllib.request.urlopen(req) # noqa: S310 (trusted upstream)
4960
except urllib.error.HTTPError as exc:
50-
payload = exc.read()
51-
self.send_response(exc.code)
52-
self.send_header("Content-Length", str(len(payload)))
53-
self.end_headers()
54-
self.wfile.write(payload)
61+
# Relay the gateway's own error verbatim (status + body).
62+
payload = exc.read() if exc.fp else b""
63+
content_type = exc.headers.get("Content-Type", "application/json")
64+
self._send_bytes(exc.code, payload, content_type)
65+
return
66+
except (urllib.error.URLError, OSError) as exc:
67+
# Connection-level failure (DNS/TLS/timeout/reset): give the
68+
# client a clean 502 instead of a dead socket.
69+
msg = json.dumps({"error": f"upstream unreachable: {exc}"}).encode()
70+
self._send_bytes(502, msg, "application/json")
71+
return
72+
73+
content_type = upstream_resp.headers.get("Content-Type", "")
74+
if "text/event-stream" not in content_type:
75+
# Non-streaming (or error-envelope) 200: relay unchanged.
76+
self._send_bytes(
77+
upstream_resp.status, upstream_resp.read(), content_type or "application/json"
78+
)
5579
return
5680

81+
# Streaming: close the connection at end-of-stream so an HTTP/1.1
82+
# client can detect the message boundary (we send no length/chunking).
83+
self.close_connection = True
5784
self.send_response(upstream_resp.status)
5885
self.send_header("Content-Type", "text/event-stream")
5986
self.send_header("Cache-Control", "no-cache")
@@ -68,39 +95,51 @@ def _relay_stream(self, upstream_resp) -> None:
6895
last_id: str | None = None
6996
try:
7097
for raw in upstream_resp: # yields line-by-line as bytes arrive
71-
stripped = raw.strip()
72-
if stripped.startswith(b"data: ") and stripped[6:] != b"[DONE]":
98+
payload = _sse_data_payload(raw)
99+
if payload is not None and payload != b"[DONE]":
73100
saw_data = True
74101
try:
75-
obj = json.loads(stripped[6:])
102+
obj = json.loads(payload)
76103
last_id = obj.get("id", last_id)
77104
choice = (obj.get("choices") or [{}])[0]
78105
if choice.get("finish_reason"):
79106
saw_finish = True
80107
except (ValueError, AttributeError, IndexError):
81108
pass
82109
self._write(raw)
83-
elif stripped == b"data: [DONE]":
110+
elif payload == b"[DONE]":
84111
if not saw_finish:
85112
self._write(b"data: " + _finish_chunk(last_id) + b"\n\n")
86113
saw_finish = True
87114
self._write(raw)
88115
saw_done = True
89116
else:
90117
self._write(raw)
118+
# Clean or abnormal end after data but without a terminator:
119+
# synthesize one so strict clients don't error on a truncated
120+
# stream (a mid-stream 429 is the common trigger).
121+
if saw_data and not saw_finish:
122+
self._write(b"data: " + _finish_chunk(last_id) + b"\n\n")
123+
if saw_data and not saw_done:
124+
self._write(b"data: [DONE]\n\n")
125+
except OSError:
126+
# Upstream dropped, or the client disconnected mid-write. Either
127+
# way the socket is gone; swallow so the daemon thread exits
128+
# quietly rather than tracebacking.
129+
pass
130+
131+
def _send_bytes(self, status: int, data: bytes, content_type: str) -> None:
132+
self.close_connection = True
133+
try:
134+
self.send_response(status)
135+
self.send_header("Content-Type", content_type)
136+
self.send_header("Content-Length", str(len(data)))
137+
self.send_header("Connection", "close")
138+
self.end_headers()
139+
self.wfile.write(data)
140+
self.wfile.flush()
91141
except OSError:
92-
# Upstream stream dropped mid-flight (throttle/reset). Fall
93-
# through to the terminator repair below so the client still
94-
# sees a well-formed end instead of a truncated stream.
95142
pass
96-
# If the stream ended (cleanly or abnormally) after some data but
97-
# without a finish_reason and/or [DONE], synthesize the terminator
98-
# so strict clients (Pi) don't error on an incomplete stream. A
99-
# gateway 429 mid-stream is the common trigger.
100-
if saw_data and not saw_finish:
101-
self._write(b"data: " + _finish_chunk(last_id) + b"\n\n")
102-
if saw_data and not saw_done:
103-
self._write(b"data: [DONE]\n\n")
104143

105144
def _write(self, data: bytes) -> None:
106145
self.wfile.write(data)
@@ -112,6 +151,18 @@ def log_message(self, format: str, *args) -> None: # silence default stderr log
112151
return _Handler
113152

114153

154+
def _sse_data_payload(raw: bytes) -> bytes | None:
155+
"""Return the payload of an SSE ``data:`` line (the spec allows an optional
156+
single space after the colon), or None if the line isn't a data line."""
157+
stripped = raw.strip()
158+
if not stripped.startswith(b"data:"):
159+
return None
160+
payload = stripped[len(b"data:") :]
161+
if payload.startswith(b" "):
162+
payload = payload[1:]
163+
return payload
164+
165+
115166
def _finish_chunk(chunk_id: str | None) -> bytes:
116167
return json.dumps(
117168
{

src/ucode/databricks.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,16 @@ def _is_oss_chat_model(model_id: str) -> bool:
11891189
"gemma": {"context": 128_000, "output": 8_192},
11901190
}
11911191

1192+
# Conservative fallback for a model that IS an OSS chat model (bucketed by
1193+
# `_is_oss_chat_model`) but has no specific `_MODEL_TOKEN_LIMITS` entry — e.g. a
1194+
# `qwen3-coder` or future `llama-5`. The discovery families are intentionally
1195+
# broader than the limits table, so without a floor such a model would be
1196+
# offered with no output cap and 400 on a default-size request. 8192 is the
1197+
# lowest cap observed across the cohort, so every mlflow chat model accepts it;
1198+
# pinning it only risks truncating a longer response (the safe failure
1199+
# direction), never a hard 400.
1200+
_OSS_FALLBACK_LIMITS = {"context": 128_000, "output": 8_192}
1201+
11921202
# OSS families that emit reasoning (openai_reasoning capability, verified
11931203
# 2026-07-16). Pi renders their streamed reasoning_content as thinking when the
11941204
# model entry sets reasoning:true; agents whose SDK surfaces reasoning natively
@@ -1204,11 +1214,15 @@ def model_is_reasoning(model_id: str) -> bool:
12041214
def model_token_limits(model_id: str) -> dict[str, int] | None:
12051215
"""Return ``{"context": ..., "output": ...}`` limits for ``model_id``, or None.
12061216
1207-
Matches by family substring (e.g. any ``*glm*`` id). None means the model
1208-
has no known limits and the agent should not pin any."""
1217+
Prefers a specific `_MODEL_TOKEN_LIMITS` family entry (e.g. any ``*glm*``
1218+
id). Any other OSS chat model falls back to a conservative floor so it is
1219+
never offered uncapped (which would 400). None only for non-OSS ids, where
1220+
the agent should not pin any limit."""
12091221
for family, limits in _MODEL_TOKEN_LIMITS.items():
12101222
if family in model_id:
12111223
return dict(limits)
1224+
if _is_oss_chat_model(model_id):
1225+
return dict(_OSS_FALLBACK_LIMITS)
12121226
return None
12131227

12141228

tests/test_databricks.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,22 @@ def test_qwen_versions_distinct_caps(self):
185185
db_mod.model_token_limits("system.ai.qwen3-next-80b-a3b-instruct")["output"] == 10_000
186186
)
187187

188-
def test_uncapped_model_returns_none(self):
188+
def test_oss_chat_model_without_specific_entry_uses_fallback(self):
189+
# qwen3-coder is an OSS chat model (matches the `qwen` family) but has
190+
# no specific limits entry — it must get the conservative floor, never
191+
# None (which would leave it uncapped and 400 on a default request).
192+
limits = db_mod.model_token_limits("system.ai.qwen3-coder-480b")
193+
assert limits == {"context": 128_000, "output": 8_192}
194+
195+
def test_non_oss_model_returns_none(self):
196+
# deepseek is outside every family (not an OSS chat model) -> no cap.
189197
assert db_mod.model_token_limits("system.ai.deepseek-v3") is None
190198

199+
def test_embedding_model_returns_none_not_fallback(self):
200+
# An embeddings model is excluded from OSS chat, so it must NOT get the
201+
# chat fallback limits.
202+
assert db_mod.model_token_limits("system.ai.qwen3-embedding-0-6b") is None
203+
191204

192205
class TestModelIsReasoning:
193206
def test_reasoning_families(self):

tests/test_mlflow_proxy.py

Lines changed: 84 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
from __future__ import annotations
44

5+
import json
56
import threading
7+
import urllib.error
68
import urllib.request
79
from http.server import BaseHTTPRequestHandler, HTTPServer
810

@@ -21,15 +23,23 @@
2123
# An abnormally-terminated stream: content delta, then the upstream just stops —
2224
# no finish_reason AND no [DONE] (what a mid-stream 429/drop looks like).
2325
_STREAM_ABRUPT_END = b'data: {"id":"c3","choices":[{"delta":{"content":"ok"},"index":0}]}\n\n'
26+
# SSE data line with NO space after the colon (spec-legal) and no finish_reason.
27+
_STREAM_NO_SPACE = (
28+
b'data:{"id":"c4","choices":[{"delta":{"content":"ok"},"index":0}]}\n\ndata:[DONE]\n\n'
29+
)
30+
# A non-streaming JSON response (some models / non-stream requests).
31+
_JSON_BODY = b'{"id":"c5","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}'
2432

2533

26-
def _make_fake_gateway(payload: bytes) -> tuple[str, HTTPServer]:
34+
def _make_fake_gateway(
35+
payload: bytes, content_type: str = "text/event-stream", status: int = 200
36+
) -> tuple[str, HTTPServer]:
2737
class _Fake(BaseHTTPRequestHandler):
2838
protocol_version = "HTTP/1.1"
2939

3040
def do_POST(self): # noqa: N802
31-
self.send_response(200)
32-
self.send_header("Content-Type", "text/event-stream")
41+
self.send_response(status)
42+
self.send_header("Content-Type", content_type)
3343
self.send_header("Content-Length", str(len(payload)))
3444
self.end_headers()
3545
self.wfile.write(payload)
@@ -42,26 +52,35 @@ def log_message(self, format: str, *args):
4252
return f"http://127.0.0.1:{server.server_address[1]}", server
4353

4454

45-
def _post_through_proxy(upstream: str) -> str:
55+
def _post_through_proxy(upstream: str, path: str = "/ai-gateway/mlflow/v1/chat/completions"):
56+
"""POST through the proxy; returns (status, body, content_type)."""
4657
base, stop = _mlflow_proxy.start(upstream)
4758
try:
4859
req = urllib.request.Request(
49-
f"{base}/ai-gateway/mlflow/v1/chat/completions",
60+
f"{base}{path}",
5061
data=b'{"model":"databricks-inkling","stream":true}',
5162
headers={"Content-Type": "application/json"},
5263
method="POST",
5364
)
54-
with urllib.request.urlopen(req, timeout=5) as resp:
55-
return resp.read().decode("utf-8")
65+
try:
66+
with urllib.request.urlopen(req, timeout=5) as resp:
67+
return resp.status, resp.read().decode("utf-8"), resp.headers.get("Content-Type")
68+
except urllib.error.HTTPError as exc:
69+
return exc.code, exc.read().decode("utf-8"), exc.headers.get("Content-Type")
5670
finally:
5771
stop.set()
5872

5973

74+
def _body(upstream: str) -> str:
75+
"""Convenience: body text of a normal streaming POST through the proxy."""
76+
return _post_through_proxy(upstream)[1]
77+
78+
6079
class TestFinishReasonInjection:
6180
def test_injects_finish_reason_when_absent(self):
6281
upstream, server = _make_fake_gateway(_STREAM_NO_FINISH)
6382
try:
64-
out = _post_through_proxy(upstream)
83+
out = _body(upstream)
6584
finally:
6685
server.shutdown()
6786
assert '"finish_reason": "stop"' in out or '"finish_reason":"stop"' in out
@@ -72,7 +91,7 @@ def test_injects_finish_reason_when_absent(self):
7291
def test_passes_through_when_finish_reason_present(self):
7392
upstream, server = _make_fake_gateway(_STREAM_WITH_FINISH)
7493
try:
75-
out = _post_through_proxy(upstream)
94+
out = _body(upstream)
7695
finally:
7796
server.shutdown()
7897
# Exactly one finish_reason — the proxy did not add a second.
@@ -81,7 +100,7 @@ def test_passes_through_when_finish_reason_present(self):
81100
def test_forwards_content_unchanged(self):
82101
upstream, server = _make_fake_gateway(_STREAM_NO_FINISH)
83102
try:
84-
out = _post_through_proxy(upstream)
103+
out = _body(upstream)
85104
finally:
86105
server.shutdown()
87106
assert '"content":"ok"' in out
@@ -92,17 +111,70 @@ def test_repairs_abrupt_end_with_finish_and_done(self):
92111
# doesn't error on a truncated stream.
93112
upstream, server = _make_fake_gateway(_STREAM_ABRUPT_END)
94113
try:
95-
out = _post_through_proxy(upstream)
114+
out = _body(upstream)
96115
finally:
97116
server.shutdown()
98117
assert "finish_reason" in out
99118
assert out.rstrip().endswith("[DONE]")
100119
assert '"content":"ok"' in out
101120

121+
def test_parses_data_line_without_space(self):
122+
# SSE allows `data:{...}` (no space). The already-present finish_reason
123+
# must be detected so no duplicate is injected.
124+
upstream, server = _make_fake_gateway(_STREAM_NO_SPACE)
125+
try:
126+
out = _body(upstream)
127+
finally:
128+
server.shutdown()
129+
# One synthetic finish (none was present) — not two.
130+
assert out.count("finish_reason") == 1
131+
132+
133+
class TestNonStreamingAndErrors:
134+
def test_non_streaming_json_relayed_unchanged(self):
135+
# A non-SSE 200 must pass through verbatim with its content-type — no
136+
# finish_reason injection, no SSE relabeling.
137+
upstream, server = _make_fake_gateway(_JSON_BODY, content_type="application/json")
138+
try:
139+
status, body, ctype = _post_through_proxy(upstream)
140+
finally:
141+
server.shutdown()
142+
assert status == 200
143+
assert "application/json" in ctype
144+
assert body == _JSON_BODY.decode()
145+
assert "chat.completion.chunk" not in body # no injected SSE chunk
146+
147+
def test_gateway_error_relayed_with_status(self):
148+
# A gateway 400 (e.g. max_tokens exceeded) is relayed with its status.
149+
err = b'{"error":"max_tokens cannot exceed 8192"}'
150+
upstream, server = _make_fake_gateway(err, content_type="application/json", status=400)
151+
try:
152+
status, body, _ = _post_through_proxy(upstream)
153+
finally:
154+
server.shutdown()
155+
assert status == 400
156+
assert "max_tokens" in body
157+
158+
def test_connection_failure_returns_502(self):
159+
# Point the proxy at a dead port: urlopen raises URLError, and the proxy
160+
# must return a clean 502, not a dead socket.
161+
status, body, _ = _post_through_proxy("http://127.0.0.1:1")
162+
assert status == 502
163+
assert "upstream unreachable" in body
164+
165+
def test_disallowed_path_returns_404(self):
166+
# Only the mlflow route may be forwarded (SSRF guard).
167+
upstream, server = _make_fake_gateway(_JSON_BODY, content_type="application/json")
168+
try:
169+
status, body, _ = _post_through_proxy(upstream, path="/api/2.0/secrets/get")
170+
finally:
171+
server.shutdown()
172+
assert status == 404
173+
assert "not allowed" in body
174+
102175

103176
class TestFinishChunk:
104177
def test_shape(self):
105-
import json
106178

107179
chunk = json.loads(_mlflow_proxy._finish_chunk("abc"))
108180
assert chunk["id"] == "abc"

0 commit comments

Comments
 (0)