Skip to content

Commit b53813f

Browse files
committed
fix(opencode): route GPT models and discover OSS fallbacks
Configure the Databricks OpenAI Responses provider alongside the validated GLM/Kimi provider, and fall back to foundation-model serving endpoints when UC model services are unavailable.
1 parent f2fbb83 commit b53813f

5 files changed

Lines changed: 237 additions & 3 deletions

File tree

src/ucode/agents/opencode.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
PROVIDER_KEYS: list[list[str]] = [
4343
["provider", "databricks-anthropic"],
4444
["provider", "databricks-google"],
45+
["provider", "databricks-openai"],
4546
["provider", "databricks-oss"],
4647
]
4748

@@ -52,7 +53,14 @@ def is_update_available() -> tuple[str, str] | None:
5253

5354
def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -> str:
5455
"""Return an OpenCode model selector in provider/model form when possible."""
55-
if model.startswith(("databricks-anthropic/", "databricks-google/", "databricks-oss/")):
56+
if model.startswith(
57+
(
58+
"databricks-anthropic/",
59+
"databricks-google/",
60+
"databricks-openai/",
61+
"databricks-oss/",
62+
)
63+
):
5664
return model
5765

5866
anthropic_models = opencode_models.get("anthropic") or []
@@ -63,6 +71,10 @@ def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -
6371
if model in gemini_models:
6472
return f"databricks-google/{model}"
6573

74+
openai_models = opencode_models.get("openai") or []
75+
if model in openai_models:
76+
return f"databricks-openai/{model}"
77+
6678
oss_models = opencode_models.get("oss") or []
6779
if model in oss_models:
6880
return f"databricks-oss/{model}"
@@ -102,6 +114,7 @@ def render_overlay(
102114

103115
anthropic_models = opencode_models.get("anthropic") or []
104116
gemini_models = opencode_models.get("gemini") or []
117+
openai_models = opencode_models.get("openai") or []
105118
oss_models = opencode_models.get("oss") or []
106119

107120
providers: dict = {}
@@ -137,6 +150,28 @@ def render_overlay(
137150
"models": {m: {"headers": ua_header} for m in gemini_models},
138151
}
139152
keys.append(["provider", "databricks-google"])
153+
if openai_models:
154+
# @ai-sdk/openai supports both the Responses API and the legacy
155+
# chat-completions API. Databricks GPT-5 / GPT-5.6 / Codex models are
156+
# Responses-only on /ai-gateway/codex/v1, so the per-model flag
157+
# `useResponsesApi: true` lives in models.<m>.options where opencode
158+
# reads it (provider-level options is read by the SDK only).
159+
providers["databricks-openai"] = {
160+
"npm": "@ai-sdk/openai",
161+
"options": {
162+
"baseURL": opencode_base_urls["openai"],
163+
"apiKey": token,
164+
"headers": auth_headers,
165+
},
166+
"models": {
167+
m: {
168+
"headers": ua_header,
169+
"options": {"useResponsesApi": True},
170+
}
171+
for m in openai_models
172+
},
173+
}
174+
keys.append(["provider", "databricks-openai"])
140175
if oss_models:
141176
providers["databricks-oss"] = {
142177
"npm": "@ai-sdk/openai",
@@ -233,6 +268,9 @@ def default_model(state: dict) -> str | None:
233268
anthropic = opencode_models.get("anthropic") or []
234269
if anthropic:
235270
return anthropic[0]
271+
openai = opencode_models.get("openai") or []
272+
if openai:
273+
return openai[0]
236274
gemini = opencode_models.get("gemini") or []
237275
if gemini:
238276
return gemini[0]

src/ucode/cli.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
discover_codex_models,
3838
discover_gemini_models,
3939
discover_model_services,
40+
discover_oss_models,
4041
ensure_ai_gateway_v2,
4142
ensure_databricks_auth,
4243
ensure_pat_bearer,
@@ -92,7 +93,7 @@
9293

9394
_DISCOVERY_CONSUMERS: dict[str, tuple[str, ...]] = {
9495
"claude": ("claude", "opencode", "copilot", "pi"),
95-
"codex": ("codex", "copilot", "pi"),
96+
"codex": ("codex", "copilot", "opencode", "pi"),
9697
"gemini": ("gemini", "opencode", "pi"),
9798
"oss": ("opencode", "pi"),
9899
}
@@ -371,7 +372,9 @@ def configure_shared_state(
371372
fetch_all or "claude" in tools or "opencode" in tools or "copilot" in tools or "pi" in tools
372373
)
373374
want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools
374-
want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools
375+
want_codex = (
376+
fetch_all or "codex" in tools or "copilot" in tools or "opencode" in tools or "pi" in tools
377+
)
375378
want_oss = fetch_all or "opencode" in tools or "pi" in tools
376379

377380
claude_reason: str | None = None
@@ -424,10 +427,14 @@ def configure_shared_state(
424427
codex_models, codex_reason = discover_codex_models(workspace, token)
425428
if want_oss:
426429
oss_models, oss_reason = ms_oss, ms_reason
430+
if not oss_models:
431+
oss_models, oss_reason = discover_oss_models(workspace, token)
427432
if claude_models:
428433
opencode_models["anthropic"] = list(claude_models.values())
429434
if gemini_models:
430435
opencode_models["gemini"] = gemini_models
436+
if codex_models:
437+
opencode_models["openai"] = codex_models
431438
if oss_models:
432439
opencode_models["oss"] = oss_models
433440

src/ucode/databricks.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2142,6 +2142,34 @@ def discover_codex_models(workspace: str, token: str) -> tuple[list[str], str |
21422142
return discover_endpoints_with_api_type(workspace, token, "openai/v1/responses")
21432143

21442144

2145+
def discover_oss_models(workspace: str, token: str) -> tuple[list[str], str | None]:
2146+
"""Discover OSS chat models served as AI Gateway foundation-model endpoints.
2147+
2148+
Fallback for workspaces that don't register OSS foundation models as
2149+
`system.ai.*` UC model-services (see `discover_model_services`): those
2150+
workspaces expose the same models as regular `databricks-*` serving
2151+
endpoints instead. Lists every endpoint advertising the
2152+
`mlflow/v1/chat/completions` dialect, then keeps only the OSS chat families
2153+
(`_is_oss_chat_model`) — on some workspaces the Claude/Gemini endpoints also
2154+
advertise that dialect, so the family filter is what separates the OSS
2155+
cohort from them. Mirrors the AI-Gateway fallback the other families use
2156+
when the UC model-services listing is empty.
2157+
"""
2158+
endpoints, reason = discover_endpoints_with_api_type(
2159+
workspace, token, "mlflow/v1/chat/completions"
2160+
)
2161+
if not endpoints:
2162+
return [], reason
2163+
oss = [e for e in endpoints if _is_oss_chat_model(e)]
2164+
if oss:
2165+
return oss, None
2166+
sample = ", ".join(endpoints[:5])
2167+
return [], (
2168+
"foundation-models exposing `mlflow/v1/chat/completions` matched no OSS "
2169+
f"chat family (got: {sample})"
2170+
)
2171+
2172+
21452173
def fetch_gemini_models(workspace: str, token: str) -> list[str]:
21462174
models, _ = discover_gemini_models(workspace, token)
21472175
return models
@@ -2339,6 +2367,9 @@ def build_opencode_base_urls(workspace: str) -> dict[str, str]:
23392367
return {
23402368
"anthropic": build_tool_base_url("claude", workspace) + "/v1",
23412369
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
2370+
# @ai-sdk/openai appends "/responses" (or "/chat/completions") to baseURL,
2371+
# so stop just before that — matches the Pi adapter's build_pi_base_urls.
2372+
"openai": build_tool_base_url("codex", workspace),
23422373
"oss": f"{workspace}/ai-gateway/mlflow/v1",
23432374
}
23442375

tests/test_agent_opencode.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def _base_urls() -> dict[str, str]:
1414
return {
1515
"anthropic": f"{WS}/ai-gateway/anthropic/v1",
1616
"gemini": f"{WS}/ai-gateway/gemini/v1beta",
17+
"openai": f"{WS}/ai-gateway/codex/v1",
1718
"oss": f"{WS}/ai-gateway/mlflow/v1",
1819
}
1920

@@ -211,6 +212,76 @@ def test_prefixes_oss_model_with_provider_id(self):
211212
assert overlay["model"] == "databricks-oss/system.ai.kimi-k2-7-code"
212213

213214

215+
class TestOpenAIProvider:
216+
"""OpenCode reaches Databricks GPT-5 / GPT-5.6 / Codex models through the
217+
databricks-openai provider (@ai-sdk/openai against /ai-gateway/codex/v1).
218+
Without this wiring the codex/openai family is unreachable from OpenCode."""
219+
220+
def test_openai_provider_added_when_codex_models_present(self):
221+
models = {"openai": ["databricks-gpt-5-6-sol"]}
222+
overlay, _ = opencode.render_overlay("databricks-gpt-5-6-sol", "tok", _base_urls(), models)
223+
assert "databricks-openai" in overlay["provider"]
224+
225+
def test_openai_provider_uses_ai_sdk_openai_npm(self):
226+
models = {"openai": ["databricks-gpt-5-6-sol"]}
227+
overlay, _ = opencode.render_overlay("databricks-gpt-5-6-sol", "tok", _base_urls(), models)
228+
assert overlay["provider"]["databricks-openai"]["npm"] == "@ai-sdk/openai"
229+
230+
def test_openai_base_url_points_at_codex_gateway(self):
231+
models = {"openai": ["databricks-gpt-5-6-sol"]}
232+
overlay, _ = opencode.render_overlay("databricks-gpt-5-6-sol", "tok", _base_urls(), models)
233+
options = overlay["provider"]["databricks-openai"]["options"]
234+
assert options["baseURL"] == f"{WS}/ai-gateway/codex/v1"
235+
236+
def test_use_responses_api_set_on_every_codex_model(self):
237+
models = {"openai": ["databricks-gpt-5-6-sol", "databricks-gpt-codex"]}
238+
overlay, _ = opencode.render_overlay("databricks-gpt-5-6-sol", "tok", _base_urls(), models)
239+
provider_models = overlay["provider"]["databricks-openai"]["models"]
240+
for m in ("databricks-gpt-5-6-sol", "databricks-gpt-codex"):
241+
assert provider_models[m]["options"]["useResponsesApi"] is True
242+
243+
def test_openai_authorization_header(self):
244+
models = {"openai": ["databricks-gpt-5-6-sol"]}
245+
overlay, _ = opencode.render_overlay("databricks-gpt-5-6-sol", "tok", _base_urls(), models)
246+
headers = overlay["provider"]["databricks-openai"]["options"]["headers"]
247+
assert headers["Authorization"] == "Bearer tok"
248+
249+
def test_managed_keys_include_openai_provider(self):
250+
models = {"openai": ["databricks-gpt-5-6-sol"]}
251+
_, keys = opencode.render_overlay("databricks-gpt-5-6-sol", "tok", _base_urls(), models)
252+
assert ["provider", "databricks-openai"] in keys
253+
254+
def test_prefixes_openai_model_with_provider_id(self):
255+
models = {"openai": ["databricks-gpt-5-6-sol"]}
256+
overlay, _ = opencode.render_overlay("databricks-gpt-5-6-sol", "tok", _base_urls(), models)
257+
assert overlay["model"] == "databricks-openai/databricks-gpt-5-6-sol"
258+
259+
def test_already_prefixed_openai_model_is_preserved(self):
260+
models = {"openai": ["databricks-gpt-5-6-sol"]}
261+
overlay, _ = opencode.render_overlay(
262+
"databricks-openai/databricks-gpt-5-6-sol", "tok", _base_urls(), models
263+
)
264+
assert overlay["model"] == "databricks-openai/databricks-gpt-5-6-sol"
265+
266+
def test_all_four_providers_when_all_present(self):
267+
models = {
268+
"anthropic": ["claude-sonnet"],
269+
"gemini": ["gemini-2"],
270+
"openai": ["databricks-gpt-5-6-sol"],
271+
"oss": ["system.ai.kimi-k2-7-code"],
272+
}
273+
overlay, _ = opencode.render_overlay("claude-sonnet", "tok", _base_urls(), models)
274+
assert set(overlay["provider"].keys()) == {
275+
"databricks-anthropic",
276+
"databricks-google",
277+
"databricks-openai",
278+
"databricks-oss",
279+
}
280+
281+
def test_provider_keys_listed_in_module(self):
282+
assert ["provider", "databricks-openai"] in opencode.PROVIDER_KEYS
283+
284+
214285
class TestMcpServerConfig:
215286
def test_builds_remote_server_entry_with_oauth_token_env_header(self):
216287
entry = opencode.build_mcp_server_entry(f"{WS}/api/2.0/mcp/external/github")
@@ -323,6 +394,16 @@ def test_prefers_anthropic(self):
323394
state = {"opencode_models": {"anthropic": ["claude-sonnet"], "gemini": ["gemini-2"]}}
324395
assert opencode.default_model(state) == "claude-sonnet"
325396

397+
def test_falls_back_to_openai_before_gemini(self):
398+
state = {
399+
"opencode_models": {
400+
"anthropic": [],
401+
"openai": ["databricks-gpt-5-6-sol"],
402+
"gemini": ["gemini-2"],
403+
}
404+
}
405+
assert opencode.default_model(state) == "databricks-gpt-5-6-sol"
406+
326407
def test_falls_back_to_gemini(self):
327408
state = {"opencode_models": {"anthropic": [], "gemini": ["gemini-2"]}}
328409
assert opencode.default_model(state) == "gemini-2"

tests/test_databricks.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ def test_returns_anthropic_gemini_and_oss(self):
102102
assert urls["gemini"] == f"{WS}/ai-gateway/gemini/v1beta"
103103
assert urls["oss"] == f"{WS}/ai-gateway/mlflow/v1"
104104

105+
def test_returns_openai_codex_gateway(self):
106+
# @ai-sdk/openai appends /responses (Responses API) or /chat/completions
107+
# to baseURL, so stop just before that suffix. Mirrors build_pi_base_urls.
108+
urls = build_opencode_base_urls(WS)
109+
assert urls["openai"] == f"{WS}/ai-gateway/codex/v1"
110+
105111

106112
class TestBuildSharedBaseUrls:
107113
def test_contains_all_tools(self):
@@ -919,6 +925,77 @@ def test_codex_discovery_keeps_alphabetical_order(self, monkeypatch):
919925
assert models == ["databricks-gpt-4-1", "databricks-gpt-5-2-codex"]
920926

921927

928+
def _mlflow_chat_payload(names, *, api_type="mlflow/v1/chat/completions", v2=True):
929+
return {
930+
"endpoints": [
931+
{
932+
"name": name,
933+
"config": {
934+
"served_entities": [
935+
{
936+
"foundation_model": {
937+
"ai_gateway_v2_supported": v2,
938+
"api_types": [api_type],
939+
}
940+
}
941+
]
942+
},
943+
}
944+
for name in names
945+
]
946+
}
947+
948+
949+
class TestDiscoverOssModels:
950+
def test_finds_oss_endpoints_via_foundation_models(self, monkeypatch):
951+
# Mirrors a workspace with no system.ai UC model-services: OSS models are
952+
# plain databricks-* serving endpoints under the mlflow chat dialect.
953+
payload = _mlflow_chat_payload(
954+
[
955+
"databricks-glm-5-2",
956+
"databricks-kimi-k2-7-code",
957+
"databricks-inkling",
958+
"databricks-qwen35-122b-a10b",
959+
"databricks-gemma-3-12b",
960+
]
961+
)
962+
monkeypatch.setattr(db_mod, "_http_get_json", lambda url, token: (payload, None))
963+
964+
models, reason = db_mod.discover_oss_models(WS, "token")
965+
966+
assert reason is None
967+
assert models == ["databricks-glm-5-2", "databricks-kimi-k2-7-code"]
968+
969+
def test_excludes_claude_and_gemini_sharing_the_mlflow_dialect(self, monkeypatch):
970+
# On some workspaces every foundation model advertises the mlflow chat
971+
# dialect, so the api_type filter alone is too broad — the OSS family
972+
# filter must drop Claude/Gemini and keep only the OSS cohort.
973+
payload = _mlflow_chat_payload(
974+
[
975+
"databricks-claude-opus-4-8",
976+
"databricks-gemini-2-5-pro",
977+
"databricks-glm-5-2",
978+
"databricks-qwen3-embedding-0-6b",
979+
]
980+
)
981+
monkeypatch.setattr(db_mod, "_http_get_json", lambda url, token: (payload, None))
982+
983+
models, reason = db_mod.discover_oss_models(WS, "token")
984+
985+
assert reason is None
986+
assert models == ["databricks-glm-5-2"]
987+
988+
def test_reports_reason_when_no_oss_family_matches(self, monkeypatch):
989+
payload = _mlflow_chat_payload(["databricks-claude-opus-4-8"])
990+
monkeypatch.setattr(db_mod, "_http_get_json", lambda url, token: (payload, None))
991+
992+
models, reason = db_mod.discover_oss_models(WS, "token")
993+
994+
assert models == []
995+
assert reason is not None
996+
assert "no OSS" in reason
997+
998+
922999
class TestResolvePatToken:
9231000
def test_reads_pat_profile_token_from_cfg(self, monkeypatch, tmp_path):
9241001
cfg = tmp_path / "databrickscfg"

0 commit comments

Comments
 (0)