Skip to content

Commit fe18889

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 41ec3f9 commit fe18889

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
@@ -363,6 +363,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
363363
bool(state.get("claude_models"))
364364
or bool(state.get("codex_models"))
365365
or bool(state.get("gemini_models"))
366+
or bool(state.get("oss_models"))
366367
)
367368
return False
368369

@@ -373,7 +374,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
373374
"codex": ("codex",),
374375
"gemini": ("gemini",),
375376
"copilot": ("claude", "codex"),
376-
"pi": ("claude", "codex", "gemini"),
377+
"pi": ("claude", "codex", "gemini", "oss"),
377378
}
378379

379380

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
@@ -97,7 +97,7 @@
9797
"claude": ("claude", "opencode", "copilot", "pi"),
9898
"codex": ("codex", "copilot", "pi"),
9999
"gemini": ("gemini", "opencode", "pi"),
100-
"oss": ("opencode",),
100+
"oss": ("opencode", "pi"),
101101
}
102102

103103

@@ -371,7 +371,7 @@ def configure_shared_state(
371371
)
372372
want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools
373373
want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools
374-
want_oss = fetch_all or "opencode" in tools
374+
want_oss = fetch_all or "opencode" in tools or "pi" in tools
375375

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

src/ucode/databricks.py

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

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

1203+
# Non-chat services must never be offered to a chat agent if a future supported
1204+
# family also uses one of these substrings.
1205+
_OSS_NON_CHAT_SUBSTRINGS = ("embedding", "embed", "rerank")
1206+
1207+
1208+
def _is_oss_chat_model(model_id: str) -> bool:
1209+
"""True if the id matches an OSS chat family and isn't a non-chat service."""
1210+
if any(bad in model_id for bad in _OSS_NON_CHAT_SUBSTRINGS):
1211+
return False
1212+
return any(family in model_id for family in _OSS_MODEL_FAMILIES)
1213+
1214+
12011215
# Claude model families ucode buckets, newest tier first. Each maps to a
12021216
# Claude Code family alias (ANTHROPIC_DEFAULT_<FAMILY>_MODEL). Add an entry to
12031217
# support a new family in both discovery paths (`claude-<family>-*` via the
@@ -1211,21 +1225,46 @@ def build_auth_shell_command(
12111225
# config dialect. Both fields are provided because agents like OpenCode require
12121226
# context and output together. Keyed by family substring; add an entry to bound
12131227
# a new model.
1228+
#
1229+
# Output caps probed from the gateway 2026-07-16 (it 400s with "max_tokens (N)
1230+
# cannot exceed <cap>"); context windows from each model's docs/description
1231+
# (conservative when unstated). If the gateway raises a cap or ships a new
1232+
# model, update this table.
12141233
_MODEL_TOKEN_LIMITS: dict[str, dict[str, int]] = {
1215-
# GLM-4.6: 200k context, but the gateway caps output well below the model's
1216-
# native 128k — pin 25k so requests aren't rejected.
1234+
# Keep the version-specific entry before the family fallback: GLM 5.2 has
1235+
# materially higher probed gateway limits than earlier/unknown variants.
1236+
"glm-5-2": {"context": 1_000_000, "output": 65_536},
12171237
"glm": {"context": 200_000, "output": 25_000},
1238+
"kimi": {"context": 128_000, "output": 65_536},
12181239
}
12191240

1241+
# Conservative fallback for a future variant that matches a validated family
1242+
# but has no specific entry. Pinning a low output ceiling risks truncation, not
1243+
# a gateway 400, so it is the safe failure direction.
1244+
_OSS_FALLBACK_LIMITS = {"context": 128_000, "output": 8_192}
1245+
1246+
# Validated families that emit reasoning. Pi renders their streamed
1247+
# reasoning_content as thinking when the model entry sets reasoning:true.
1248+
_OSS_REASONING_FAMILIES = ("glm", "kimi")
1249+
1250+
1251+
def model_is_reasoning(model_id: str) -> bool:
1252+
"""True if the OSS model reports reasoning output (family-matched)."""
1253+
return any(family in model_id for family in _OSS_REASONING_FAMILIES)
1254+
12201255

12211256
def model_token_limits(model_id: str) -> dict[str, int] | None:
12221257
"""Return ``{"context": ..., "output": ...}`` limits for ``model_id``, or None.
12231258
1224-
Matches by family substring (e.g. any ``*glm*`` id). None means the model
1225-
has no known limits and the agent should not pin any."""
1259+
Prefers a specific `_MODEL_TOKEN_LIMITS` family entry (e.g. any ``*glm*``
1260+
id). Any other OSS chat model falls back to a conservative floor so it is
1261+
never offered uncapped (which would 400). None only for non-OSS ids, where
1262+
the agent should not pin any limit."""
12261263
for family, limits in _MODEL_TOKEN_LIMITS.items():
12271264
if family in model_id:
12281265
return dict(limits)
1266+
if _is_oss_chat_model(model_id):
1267+
return dict(_OSS_FALLBACK_LIMITS)
12291268
return None
12301269

12311270

@@ -1354,10 +1393,13 @@ def discover_model_services(
13541393
if candidates:
13551394
claude_models[family] = candidates[0]
13561395

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

1360-
oss_models = [m for m in ids if any(family in m for family in _OSS_MODEL_FAMILIES)]
1402+
oss_models = [m for m in ids if _is_oss_chat_model(m)]
13611403

13621404
if not (claude_models or codex_models or gemini_models or oss_models):
13631405
sample = ", ".join(ids[:5])
@@ -2391,6 +2433,7 @@ def build_pi_base_urls(workspace: str) -> dict[str, str]:
23912433
"claude": build_tool_base_url("claude", workspace),
23922434
"openai": build_tool_base_url("codex", workspace),
23932435
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
2436+
"oss": f"{workspace}/ai-gateway/mlflow/v1",
23942437
}
23952438

23962439

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)