Skip to content

Commit 65b4609

Browse files
committed
feat(pi): add validated MLflow OSS provider
Expose the GLM and Kimi coding-model cohort through Pi and OpenCode with shared token limits and reasoning metadata. Keep unsupported chat models out of discovery, including Inkling until gateway issue #215 is fixed, and retain the GPT-OSS Responses API routing guard.
1 parent 3a4087b commit 65b4609

9 files changed

Lines changed: 282 additions & 38 deletions

File tree

src/ucode/agents/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
356356
bool(state.get("claude_models"))
357357
or bool(state.get("codex_models"))
358358
or bool(state.get("gemini_models"))
359+
or bool(state.get("oss_models"))
359360
)
360361
return False
361362

@@ -366,7 +367,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
366367
"codex": ("codex",),
367368
"gemini": ("gemini",),
368369
"copilot": ("claude", "codex"),
369-
"pi": ("claude", "codex", "gemini"),
370+
"pi": ("claude", "codex", "gemini", "oss"),
370371
}
371372

372373

src/ucode/agents/pi.py

Lines changed: 60 additions & 9 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,17 @@
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.
18-
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.
19+
- mlflow: `supportsStore: false` and `supportsStrictMode: false` — the MLflow
20+
chat-completions gateway rejects OpenAI's `store` field and
21+
`tools[].function.strict`.
22+
23+
The `databricks-mlflow` provider carries the validated OSS coding models
24+
(GLM and Kimi) discovered upstream. Per model it sets
25+
`contextWindow`/`maxTokens` from `databricks.model_token_limits` and
26+
`reasoning` from `databricks.model_is_reasoning` (so Pi renders the gateway's
27+
streamed reasoning_content as thinking). Inkling is intentionally not offered
28+
until the gateway emits a terminal `finish_reason` on natural completion
29+
(issue #215).
2330
2431
The bearer token is baked into the file and refreshed by a background thread
2532
while the session runs (same pattern as OpenCode/Copilot).
@@ -45,6 +52,8 @@
4552
TOKEN_REFRESH_INTERVAL_SECONDS,
4653
build_pi_base_urls,
4754
get_databricks_token,
55+
model_is_reasoning,
56+
model_token_limits,
4857
)
4958
from ucode.state import mark_tool_managed, save_state
5059
from ucode.telemetry import agent_version, ucode_version
@@ -68,6 +77,7 @@
6877
"databricks-claude",
6978
"databricks-openai",
7079
"databricks-gemini",
80+
"databricks-mlflow",
7181
)
7282

7383
PROVIDER_KEYS: list[list[str]] = [["providers", name] for name in PROVIDER_NAMES]
@@ -86,6 +96,7 @@ def _resolve_model_selector(
8696
claude_models: dict[str, str],
8797
codex_models: list[str],
8898
gemini_models: list[str],
99+
oss_models: list[str],
89100
) -> str:
90101
"""Return a Pi model selector in `<provider>/<model>` form when possible."""
91102
for name in PROVIDER_NAMES:
@@ -97,16 +108,37 @@ def _resolve_model_selector(
97108
return f"databricks-openai/{model}"
98109
if model in gemini_models:
99110
return f"databricks-gemini/{model}"
111+
if model in oss_models:
112+
return f"databricks-mlflow/{model}"
100113
return model
101114

102115

116+
def _pi_oss_model_entry(model_id: str) -> dict:
117+
"""Build a Pi mlflow model entry enriched from the shared limits/reasoning
118+
tables: `reasoning:true` for reasoning models (Pi renders their streamed
119+
reasoning_content as thinking), and `contextWindow`/`maxTokens` from
120+
`model_token_limits`. Fields are omitted when unknown so Pi keeps its
121+
default."""
122+
entry: dict = {"id": model_id}
123+
if model_is_reasoning(model_id):
124+
entry["reasoning"] = True
125+
limits = model_token_limits(model_id)
126+
if limits:
127+
if limits.get("context"):
128+
entry["contextWindow"] = limits["context"]
129+
if limits.get("output"):
130+
entry["maxTokens"] = limits["output"]
131+
return entry
132+
133+
103134
def render_overlay(
104135
model: str,
105136
token: str,
106137
pi_base_urls: dict[str, str],
107138
claude_models: dict[str, str],
108139
codex_models: list[str],
109140
gemini_models: list[str],
141+
oss_models: list[str],
110142
) -> tuple[dict, list[list[str]]]:
111143
"""Return (overlay, managed_key_paths) for ~/.pi/agent/models.json."""
112144
providers: dict = {}
@@ -150,8 +182,23 @@ def render_overlay(
150182
"models": [{"id": m} for m in gemini_models],
151183
}
152184
keys.append(["providers", "databricks-gemini"])
185+
if oss_models:
186+
providers["databricks-mlflow"] = {
187+
"baseUrl": pi_base_urls["oss"],
188+
"api": "openai-completions",
189+
"apiKey": token,
190+
"authHeader": True,
191+
# MLflow chat-completions gateway rejects OpenAI's `store` field
192+
# and per-tool `strict`. Pi omits both when these are false.
193+
"compat": {"supportsStore": False, "supportsStrictMode": False},
194+
"headers": ua_headers,
195+
"models": [_pi_oss_model_entry(m) for m in oss_models],
196+
}
197+
keys.append(["providers", "databricks-mlflow"])
153198
overlay: dict = {
154-
"model": _resolve_model_selector(model, claude_models, codex_models, gemini_models),
199+
"model": _resolve_model_selector(
200+
model, claude_models, codex_models, gemini_models, oss_models
201+
),
155202
}
156203
if providers:
157204
overlay["providers"] = providers
@@ -178,6 +225,7 @@ def write_tool_config(
178225
state.get("claude_models") or {},
179226
state.get("codex_models") or [],
180227
state.get("gemini_models") or [],
228+
state.get("oss_models") or [],
181229
)
182230
existing = read_json_safe(PI_CONFIG_PATH)
183231
providers = existing.get("providers")
@@ -206,7 +254,7 @@ def _write_settings(model_selector: str) -> None:
206254

207255

208256
def default_model(state: dict) -> str | None:
209-
"""Prefer Claude opus → sonnet → haiku; fall back to codex, gemini."""
257+
"""Prefer Claude opus → sonnet → haiku; fall back to codex, Gemini, then OSS."""
210258
claude_models = state.get("claude_models") or {}
211259
for family in ("opus", "sonnet", "haiku"):
212260
if claude_models.get(family):
@@ -215,7 +263,10 @@ def default_model(state: dict) -> str | None:
215263
if codex_models:
216264
return codex_models[0]
217265
gemini_models = state.get("gemini_models") or []
218-
return gemini_models[0] if gemini_models else None
266+
if gemini_models:
267+
return gemini_models[0]
268+
oss_models = state.get("oss_models") or []
269+
return oss_models[0] if oss_models else None
219270

220271

221272
def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str:

src/ucode/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
"claude": ("claude", "opencode", "copilot", "pi"),
9595
"codex": ("codex", "copilot", "pi"),
9696
"gemini": ("gemini", "opencode", "pi"),
97-
"oss": ("opencode",),
97+
"oss": ("opencode", "pi"),
9898
}
9999

100100

@@ -372,7 +372,7 @@ def configure_shared_state(
372372
)
373373
want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools
374374
want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools
375-
want_oss = fetch_all or "opencode" in tools
375+
want_oss = fetch_all or "opencode" in tools or "pi" in tools
376376

377377
claude_reason: str | None = None
378378
gemini_reason: str | None = None

src/ucode/databricks.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,10 +1161,24 @@ def build_auth_shell_command(
11611161
# Databricks-managed foundation models under `system.ai`.
11621162
_MODEL_SERVICE_REQUIRED_PREFIX = "system.ai."
11631163

1164-
# Supported OSS chat families, matched by name substring. Add an entry to
1165-
# support a new family.
1164+
# OSS families validated as coding models in ucode, matched by name substring.
1165+
# Keep this as an explicit product allowlist rather than exposing every model on
1166+
# the chat-completions route. Inkling remains excluded until gateway issue #215
1167+
# is fixed; other families require coding-harness validation before inclusion.
11661168
_OSS_MODEL_FAMILIES = ("kimi-", "glm-")
11671169

1170+
# Non-chat services must never be offered to a chat agent if a future supported
1171+
# family also uses one of these substrings.
1172+
_OSS_NON_CHAT_SUBSTRINGS = ("embedding", "embed", "rerank")
1173+
1174+
1175+
def _is_oss_chat_model(model_id: str) -> bool:
1176+
"""True if the id matches an OSS chat family and isn't a non-chat service."""
1177+
if any(bad in model_id for bad in _OSS_NON_CHAT_SUBSTRINGS):
1178+
return False
1179+
return any(family in model_id for family in _OSS_MODEL_FAMILIES)
1180+
1181+
11681182
# Claude model families ucode buckets, newest tier first. Each maps to a
11691183
# Claude Code family alias (ANTHROPIC_DEFAULT_<FAMILY>_MODEL). Add an entry to
11701184
# support a new family in both discovery paths (`claude-<family>-*` via the
@@ -1178,21 +1192,46 @@ def build_auth_shell_command(
11781192
# config dialect. Both fields are provided because agents like OpenCode require
11791193
# context and output together. Keyed by family substring; add an entry to bound
11801194
# a new model.
1195+
#
1196+
# Output caps probed from the gateway 2026-07-16 (it 400s with "max_tokens (N)
1197+
# cannot exceed <cap>"); context windows from each model's docs/description
1198+
# (conservative when unstated). If the gateway raises a cap or ships a new
1199+
# model, update this table.
11811200
_MODEL_TOKEN_LIMITS: dict[str, dict[str, int]] = {
1182-
# GLM-4.6: 200k context, but the gateway caps output well below the model's
1183-
# native 128k — pin 25k so requests aren't rejected.
1201+
# Keep the version-specific entry before the family fallback: GLM 5.2 has
1202+
# materially higher probed gateway limits than earlier/unknown variants.
1203+
"glm-5-2": {"context": 1_000_000, "output": 65_536},
11841204
"glm": {"context": 200_000, "output": 25_000},
1205+
"kimi": {"context": 128_000, "output": 65_536},
11851206
}
11861207

1208+
# Conservative fallback for a future variant that matches a validated family
1209+
# but has no specific entry. Pinning a low output ceiling risks truncation, not
1210+
# a gateway 400, so it is the safe failure direction.
1211+
_OSS_FALLBACK_LIMITS = {"context": 128_000, "output": 8_192}
1212+
1213+
# Validated families that emit reasoning. Pi renders their streamed
1214+
# reasoning_content as thinking when the model entry sets reasoning:true.
1215+
_OSS_REASONING_FAMILIES = ("glm", "kimi")
1216+
1217+
1218+
def model_is_reasoning(model_id: str) -> bool:
1219+
"""True if the OSS model reports reasoning output (family-matched)."""
1220+
return any(family in model_id for family in _OSS_REASONING_FAMILIES)
1221+
11871222

11881223
def model_token_limits(model_id: str) -> dict[str, int] | None:
11891224
"""Return ``{"context": ..., "output": ...}`` limits for ``model_id``, or None.
11901225
1191-
Matches by family substring (e.g. any ``*glm*`` id). None means the model
1192-
has no known limits and the agent should not pin any."""
1226+
Prefers a specific `_MODEL_TOKEN_LIMITS` family entry (e.g. any ``*glm*``
1227+
id). Any other OSS chat model falls back to a conservative floor so it is
1228+
never offered uncapped (which would 400). None only for non-OSS ids, where
1229+
the agent should not pin any limit."""
11931230
for family, limits in _MODEL_TOKEN_LIMITS.items():
11941231
if family in model_id:
11951232
return dict(limits)
1233+
if _is_oss_chat_model(model_id):
1234+
return dict(_OSS_FALLBACK_LIMITS)
11961235
return None
11971236

11981237

@@ -1321,10 +1360,13 @@ def discover_model_services(
13211360
if candidates:
13221361
claude_models[family] = candidates[0]
13231362

1324-
codex_models = [m for m in ids if "gpt-" in m]
1363+
# `gpt-oss-*` also contains "gpt-" but is a chat-completions-only OSS model
1364+
# (served via /ai-gateway/mlflow/v1), NOT an openai-responses codex model —
1365+
# exclude it here so it isn't offered under the codex provider (which 400s).
1366+
codex_models = [m for m in ids if "gpt-" in m and "gpt-oss" not in m]
13251367
gemini_models = sorted([m for m in ids if "gemini-" in m], key=model_version_sort_key)
13261368

1327-
oss_models = [m for m in ids if any(family in m for family in _OSS_MODEL_FAMILIES)]
1369+
oss_models = [m for m in ids if _is_oss_chat_model(m)]
13281370

13291371
if not (claude_models or codex_models or gemini_models or oss_models):
13301372
sample = ", ".join(ids[:5])
@@ -2219,6 +2261,7 @@ def build_pi_base_urls(workspace: str) -> dict[str, str]:
22192261
"claude": build_tool_base_url("claude", workspace),
22202262
"openai": build_tool_base_url("codex", workspace),
22212263
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
2264+
"oss": f"{workspace}/ai-gateway/mlflow/v1",
22222265
}
22232266

22242267

tests/test_agent_opencode.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,24 @@ def test_glm_gets_token_limits(self):
9898
overlay, _ = opencode.render_overlay("system.ai.glm-5-2", "tok", _base_urls(), models)
9999
glm = overlay["provider"]["databricks-oss"]["models"]["system.ai.glm-5-2"]
100100
# OpenCode's schema requires both context and output on `limit`.
101-
assert glm["limit"] == {"context": 200000, "output": 25000}
101+
# Probed 2026-07-16: glm-5-2 is 1M context / 65536 output.
102+
assert glm["limit"] == {"context": 1_000_000, "output": 65_536}
102103

103-
def test_non_glm_oss_model_has_no_output_cap(self):
104+
def test_kimi_gets_token_limits(self):
105+
# kimi is now a capped OSS family (128k context / 65536 output).
104106
models = {"oss": ["system.ai.kimi-k2-7-code"]}
105107
overlay, _ = opencode.render_overlay(
106108
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
107109
)
108110
kimi = overlay["provider"]["databricks-oss"]["models"]["system.ai.kimi-k2-7-code"]
109-
assert "limit" not in kimi
111+
assert kimi["limit"] == {"context": 128_000, "output": 65_536}
112+
113+
def test_uncapped_oss_model_has_no_limit(self):
114+
# A model outside the limits table gets no `limit` (client default).
115+
models = {"oss": ["system.ai.mystery-7b"]}
116+
overlay, _ = opencode.render_overlay("system.ai.mystery-7b", "tok", _base_urls(), models)
117+
entry = overlay["provider"]["databricks-oss"]["models"]["system.ai.mystery-7b"]
118+
assert "limit" not in entry
110119

111120
def test_token_in_api_key(self):
112121
models = {"anthropic": ["claude-sonnet"]}

0 commit comments

Comments
 (0)