Skip to content

Commit 23d71a5

Browse files
committed
feat(pi): add databricks-mlflow OSS provider + broaden OSS cohort with reasoning/limits
Builds on the merged OpenCode OSS support (kimi/glm) to close the remaining gaps: - Pi had NO OSS provider. Add databricks-mlflow (api openai-completions -> /ai-gateway/mlflow/v1) so Pi can select OSS chat models, with a local SSE-repair proxy (_mlflow_proxy) for models like inkling whose gateway stream omits finish_reason (Pi's parser rejects that; OpenCode's @ai-sdk/openai tolerates it, so it needs no proxy). - Broaden _OSS_MODEL_FAMILIES from kimi/glm to the full chat-completions cohort (inkling, llama, qwen, gpt-oss, gemma), excluding non-chat services (embeddings/rerankers) that share a family substring (e.g. qwen3-embedding). - Correct + extend _MODEL_TOKEN_LIMITS from probed gateway caps: glm is 1M context / 65536 output (was 200k/25k); per-family output ceilings for the cohort. Add model_is_reasoning() from probed openai_reasoning capability; Pi sets reasoning:true so it renders streamed reasoning_content as thinking. Both agents read the shared model_token_limits/model_is_reasoning tables. Verified live: glm-5-2 answers through Pi (6*7 -> 42).
1 parent d29d9f3 commit 23d71a5

8 files changed

Lines changed: 553 additions & 31 deletions

File tree

src/ucode/agents/_mlflow_proxy.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Local SSE-repair proxy for the MLflow chat-completions gateway.
2+
3+
Some MLflow-served models (notably ``databricks-inkling``) stream a
4+
spec-incomplete response: on natural completion the terminal chunk omits the
5+
OpenAI-required ``finish_reason`` and the stream ends with only ``data: [DONE]``.
6+
Strict clients — Pi's ``openai-completions`` parser among them — reject this
7+
with "Stream ended without finish_reason", so the model is unusable.
8+
9+
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.
18+
19+
Tracked upstream as the gateway bug that should make this unnecessary; once the
20+
gateway emits ``finish_reason`` this module can be deleted.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
import threading
27+
import urllib.error
28+
import urllib.request
29+
from http.server import BaseHTTPRequestHandler, HTTPServer
30+
from socketserver import ThreadingMixIn
31+
32+
# Request headers we must not forward: hop-by-hop or ones urllib recomputes.
33+
_SKIP_REQUEST_HEADERS = frozenset({"host", "content-length", "accept-encoding", "connection"})
34+
35+
36+
def _make_handler(upstream: str) -> type[BaseHTTPRequestHandler]:
37+
class _Handler(BaseHTTPRequestHandler):
38+
protocol_version = "HTTP/1.1"
39+
40+
def do_POST(self) -> None: # noqa: N802 (BaseHTTPRequestHandler API)
41+
length = int(self.headers.get("Content-Length", 0))
42+
body = self.rfile.read(length)
43+
req = urllib.request.Request(upstream + self.path, data=body, method="POST")
44+
for key, value in self.headers.items():
45+
if key.lower() not in _SKIP_REQUEST_HEADERS:
46+
req.add_header(key, value)
47+
try:
48+
upstream_resp = urllib.request.urlopen(req) # noqa: S310 (trusted upstream)
49+
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)
55+
return
56+
57+
self.send_response(upstream_resp.status)
58+
self.send_header("Content-Type", "text/event-stream")
59+
self.send_header("Cache-Control", "no-cache")
60+
self.send_header("Connection", "close")
61+
self.end_headers()
62+
self._relay_stream(upstream_resp)
63+
64+
def _relay_stream(self, upstream_resp) -> None:
65+
saw_finish = False
66+
saw_done = False
67+
saw_data = False
68+
last_id: str | None = None
69+
try:
70+
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]":
73+
saw_data = True
74+
try:
75+
obj = json.loads(stripped[6:])
76+
last_id = obj.get("id", last_id)
77+
choice = (obj.get("choices") or [{}])[0]
78+
if choice.get("finish_reason"):
79+
saw_finish = True
80+
except (ValueError, AttributeError, IndexError):
81+
pass
82+
self._write(raw)
83+
elif stripped == b"data: [DONE]":
84+
if not saw_finish:
85+
self._write(b"data: " + _finish_chunk(last_id) + b"\n\n")
86+
saw_finish = True
87+
self._write(raw)
88+
saw_done = True
89+
else:
90+
self._write(raw)
91+
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.
95+
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")
104+
105+
def _write(self, data: bytes) -> None:
106+
self.wfile.write(data)
107+
self.wfile.flush()
108+
109+
def log_message(self, format: str, *args) -> None: # silence default stderr logging
110+
pass
111+
112+
return _Handler
113+
114+
115+
def _finish_chunk(chunk_id: str | None) -> bytes:
116+
return json.dumps(
117+
{
118+
"id": chunk_id,
119+
"object": "chat.completion.chunk",
120+
"choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}],
121+
}
122+
).encode()
123+
124+
125+
class _Server(ThreadingMixIn, HTTPServer):
126+
daemon_threads = True
127+
allow_reuse_address = True
128+
129+
130+
def start(upstream: str) -> tuple[str, threading.Event]:
131+
"""Start the repair proxy on a free localhost port.
132+
133+
:param upstream: gateway origin to forward to, e.g.
134+
``https://workspace.databricks.com``.
135+
:returns: ``(base_url, stop_event)`` where ``base_url`` is the local origin
136+
Pi should target (``http://127.0.0.1:<port>``) and setting
137+
``stop_event`` shuts the server down.
138+
"""
139+
server = _Server(("127.0.0.1", 0), _make_handler(upstream.rstrip("/")))
140+
port = server.server_address[1]
141+
stop_event = threading.Event()
142+
143+
threading.Thread(target=server.serve_forever, daemon=True).start()
144+
145+
def _watch_stop() -> None:
146+
stop_event.wait()
147+
server.shutdown()
148+
149+
threading.Thread(target=_watch_stop, daemon=True).start()
150+
return f"http://127.0.0.1:{port}", stop_event

src/ucode/agents/pi.py

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Pi coding agent: writes ~/.pi/agent/models.json with Databricks-backed providers.
22
3-
Pi (https://pi.dev) is a multi-provider coding agent. We register three
3+
Pi (https://pi.dev) is a multi-provider coding agent. We register four
44
providers in its `models.json`, each speaking the API dialect best suited to
55
that family's gateway path:
66
77
- `databricks-claude` (api: anthropic-messages) → /ai-gateway/anthropic
88
- `databricks-openai` (api: openai-responses) → /ai-gateway/codex/v1
99
- `databricks-gemini` (api: google-generative-ai) → /ai-gateway/gemini/v1beta
10+
- `databricks-mlflow` (api: openai-completions) → /ai-gateway/mlflow/v1
1011
1112
Per-provider `compat` flags work around fields the gateway translators reject:
1213
@@ -15,11 +16,20 @@
1516
pi uses for every request. With this flag pi omits the per-tool field and
1617
sends the legacy `anthropic-beta: fine-grained-tool-streaming-...` header
1718
instead, which the gateway accepts.
19+
- mlflow: `supportsStore: false` and `supportsStrictMode: false` — the MLflow
20+
chat-completions gateway rejects OpenAI's `store` field and
21+
`tools[].function.strict`.
1822
19-
OSS / Databricks-foundation models (Llama, Qwen, etc.) are not exposed via
20-
pi today — they live behind /ai-gateway/mlflow/v1 with per-model
21-
`max_tokens` caps that pi has no global way to honor without per-model
22-
config we don't currently maintain.
23+
The `databricks-mlflow` provider carries the OSS chat-completions-only
24+
foundation models (Llama, Qwen, GLM, inkling, ...) discovered upstream. Per
25+
model it sets `contextWindow`/`maxTokens` from `databricks.model_token_limits`
26+
and `reasoning` from `databricks.model_is_reasoning` (so Pi renders the
27+
gateway's streamed reasoning_content as thinking).
28+
29+
At launch the mlflow provider is routed through a local SSE-repair proxy (see
30+
`_mlflow_proxy`): some OSS models (inkling) omit the `finish_reason` on the
31+
streaming terminal chunk, which Pi's parser rejects. The proxy injects it and
32+
is a no-op for well-behaved models. Removable once the gateway is fixed.
2333
2434
The bearer token is baked into the file and refreshed by a background thread
2535
while the session runs (same pattern as OpenCode/Copilot).
@@ -33,6 +43,7 @@
3343
import threading
3444

3545
from ucode.agent_updates import available_npm_package_update
46+
from ucode.agents import _mlflow_proxy
3647
from ucode.config_io import (
3748
APP_DIR,
3849
ToolSpec,
@@ -45,6 +56,8 @@
4556
TOKEN_REFRESH_INTERVAL_SECONDS,
4657
build_pi_base_urls,
4758
get_databricks_token,
59+
model_is_reasoning,
60+
model_token_limits,
4861
)
4962
from ucode.state import mark_tool_managed, save_state
5063
from ucode.telemetry import agent_version, ucode_version
@@ -68,6 +81,7 @@
6881
"databricks-claude",
6982
"databricks-openai",
7083
"databricks-gemini",
84+
"databricks-mlflow",
7185
)
7286

7387
PROVIDER_KEYS: list[list[str]] = [["providers", name] for name in PROVIDER_NAMES]
@@ -86,6 +100,7 @@ def _resolve_model_selector(
86100
claude_models: dict[str, str],
87101
codex_models: list[str],
88102
gemini_models: list[str],
103+
oss_models: list[str],
89104
) -> str:
90105
"""Return a Pi model selector in `<provider>/<model>` form when possible."""
91106
for name in PROVIDER_NAMES:
@@ -97,16 +112,37 @@ def _resolve_model_selector(
97112
return f"databricks-openai/{model}"
98113
if model in gemini_models:
99114
return f"databricks-gemini/{model}"
115+
if model in oss_models:
116+
return f"databricks-mlflow/{model}"
100117
return model
101118

102119

120+
def _pi_oss_model_entry(model_id: str) -> dict:
121+
"""Build a Pi mlflow model entry enriched from the shared limits/reasoning
122+
tables: `reasoning:true` for reasoning models (Pi renders their streamed
123+
reasoning_content as thinking), and `contextWindow`/`maxTokens` from
124+
`model_token_limits`. Fields are omitted when unknown so Pi keeps its
125+
default."""
126+
entry: dict = {"id": model_id}
127+
if model_is_reasoning(model_id):
128+
entry["reasoning"] = True
129+
limits = model_token_limits(model_id)
130+
if limits:
131+
if limits.get("context"):
132+
entry["contextWindow"] = limits["context"]
133+
if limits.get("output"):
134+
entry["maxTokens"] = limits["output"]
135+
return entry
136+
137+
103138
def render_overlay(
104139
model: str,
105140
token: str,
106141
pi_base_urls: dict[str, str],
107142
claude_models: dict[str, str],
108143
codex_models: list[str],
109144
gemini_models: list[str],
145+
oss_models: list[str],
110146
) -> tuple[dict, list[list[str]]]:
111147
"""Return (overlay, managed_key_paths) for ~/.pi/agent/models.json."""
112148
providers: dict = {}
@@ -150,8 +186,23 @@ def render_overlay(
150186
"models": [{"id": m} for m in gemini_models],
151187
}
152188
keys.append(["providers", "databricks-gemini"])
189+
if oss_models:
190+
providers["databricks-mlflow"] = {
191+
"baseUrl": pi_base_urls["oss"],
192+
"api": "openai-completions",
193+
"apiKey": token,
194+
"authHeader": True,
195+
# MLflow chat-completions gateway rejects OpenAI's `store` field
196+
# and per-tool `strict`. Pi omits both when these are false.
197+
"compat": {"supportsStore": False, "supportsStrictMode": False},
198+
"headers": ua_headers,
199+
"models": [_pi_oss_model_entry(m) for m in oss_models],
200+
}
201+
keys.append(["providers", "databricks-mlflow"])
153202
overlay: dict = {
154-
"model": _resolve_model_selector(model, claude_models, codex_models, gemini_models),
203+
"model": _resolve_model_selector(
204+
model, claude_models, codex_models, gemini_models, oss_models
205+
),
155206
}
156207
if providers:
157208
overlay["providers"] = providers
@@ -178,6 +229,7 @@ def write_tool_config(
178229
state.get("claude_models") or {},
179230
state.get("codex_models") or [],
180231
state.get("gemini_models") or [],
232+
state.get("oss_models") or [],
181233
)
182234
existing = read_json_safe(PI_CONFIG_PATH)
183235
providers = existing.get("providers")
@@ -215,7 +267,10 @@ def default_model(state: dict) -> str | None:
215267
if codex_models:
216268
return codex_models[0]
217269
gemini_models = state.get("gemini_models") or []
218-
return gemini_models[0] if gemini_models else None
270+
if gemini_models:
271+
return gemini_models[0]
272+
oss_models = state.get("oss_models") or []
273+
return oss_models[0] if oss_models else None
219274

220275

221276
def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str:
@@ -241,7 +296,29 @@ def build_runtime_env(token: str) -> dict[str, str]:
241296
return env
242297

243298

299+
def _start_oss_proxy(state: dict) -> threading.Event | None:
300+
"""Route the mlflow provider through the local SSE-repair proxy.
301+
302+
Rewrites ``state["base_urls"]["pi"]["oss"]`` in-memory to the proxy origin
303+
so both the initial config write and every token-refresh rewrite point Pi
304+
at the proxy. The gateway origin is always re-derived from the workspace
305+
(never the persisted `oss` URL, which may hold a dead proxy port from a
306+
prior session). Returns the proxy's stop event, or None when no OSS models
307+
are configured. See `_mlflow_proxy`.
308+
"""
309+
if not (state.get("oss_models") or []):
310+
return None
311+
pi_urls = state.setdefault("base_urls", {}).setdefault(
312+
"pi", build_pi_base_urls(state["workspace"])
313+
)
314+
origin = build_pi_base_urls(state["workspace"])["oss"].split("/ai-gateway/", 1)[0]
315+
proxy_base, stop_event = _mlflow_proxy.start(origin)
316+
pi_urls["oss"] = f"{proxy_base}/ai-gateway/mlflow/v1"
317+
return stop_event
318+
319+
244320
def launch(state: dict, tool_args: list[str]) -> None:
321+
proxy_stop = _start_oss_proxy(state)
245322
token = _refresh_token_once(state)
246323
env = build_runtime_env(token)
247324

@@ -262,6 +339,8 @@ def launch(state: dict, tool_args: list[str]) -> None:
262339
finally:
263340
stop_event.set()
264341
refresher.join(timeout=1)
342+
if proxy_stop is not None:
343+
proxy_stop.set()
265344

266345
raise SystemExit(returncode)
267346

src/ucode/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ def configure_shared_state(
324324
)
325325
want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools
326326
want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools
327-
want_oss = fetch_all or "opencode" in tools
327+
want_oss = fetch_all or "opencode" in tools or "pi" in tools
328328

329329
claude_reason: str | None = None
330330
gemini_reason: str | None = None

0 commit comments

Comments
 (0)