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
44providers in its `models.json`, each speaking the API dialect best suited to
55that 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
1112Per-provider `compat` flags work around fields the gateway translators reject:
1213
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
2434The bearer token is baked into the file and refreshed by a background thread
2535while the session runs (same pattern as OpenCode/Copilot).
3343import threading
3444
3545from ucode .agent_updates import available_npm_package_update
46+ from ucode .agents import _mlflow_proxy
3647from ucode .config_io import (
3748 APP_DIR ,
3849 ToolSpec ,
4556 TOKEN_REFRESH_INTERVAL_SECONDS ,
4657 build_pi_base_urls ,
4758 get_databricks_token ,
59+ model_is_reasoning ,
60+ model_token_limits ,
4861)
4962from ucode .state import mark_tool_managed , save_state
5063from ucode .telemetry import agent_version , ucode_version
6881 "databricks-claude" ,
6982 "databricks-openai" ,
7083 "databricks-gemini" ,
84+ "databricks-mlflow" ,
7185)
7286
7387PROVIDER_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+
103138def 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
221276def _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+
244320def 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
0 commit comments