Skip to content

Commit 3f1c227

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 636c3b6 commit 3f1c227

8 files changed

Lines changed: 438 additions & 20 deletions

File tree

src/ucode/agents/opencode.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
TOKEN_REFRESH_INTERVAL_SECONDS,
2121
build_opencode_base_urls,
2222
get_databricks_token,
23+
gpt_model_token_limits,
2324
model_token_limits,
25+
preferred_gpt_model,
2426
)
2527
from ucode.state import mark_tool_managed, save_state
2628
from ucode.telemetry import agent_version, ucode_version
@@ -42,6 +44,7 @@
4244
PROVIDER_KEYS: list[list[str]] = [
4345
["provider", "databricks-anthropic"],
4446
["provider", "databricks-google"],
47+
["provider", "databricks-openai"],
4548
["provider", "databricks-oss"],
4649
]
4750

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

5356
def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -> str:
5457
"""Return an OpenCode model selector in provider/model form when possible."""
55-
if model.startswith(("databricks-anthropic/", "databricks-google/", "databricks-oss/")):
58+
if model.startswith(
59+
(
60+
"databricks-anthropic/",
61+
"databricks-google/",
62+
"databricks-openai/",
63+
"databricks-oss/",
64+
)
65+
):
5666
return model
5767

5868
anthropic_models = opencode_models.get("anthropic") or []
@@ -63,6 +73,10 @@ def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -
6373
if model in gemini_models:
6474
return f"databricks-google/{model}"
6575

76+
openai_models = opencode_models.get("openai") or []
77+
if model in openai_models:
78+
return f"databricks-openai/{model}"
79+
6680
oss_models = opencode_models.get("oss") or []
6781
if model in oss_models:
6882
return f"databricks-oss/{model}"
@@ -84,6 +98,15 @@ def _oss_model_overlay(model: str, ua_header: dict[str, str]) -> dict:
8498
return overlay
8599

86100

101+
def _openai_model_overlay(model: str, ua_header: dict[str, str]) -> dict:
102+
"""Per-model Responses API options and explicit GPT token limits."""
103+
return {
104+
"headers": ua_header,
105+
"limit": gpt_model_token_limits(model),
106+
"options": {"useResponsesApi": True},
107+
}
108+
109+
87110
def render_overlay(
88111
model: str,
89112
token: str,
@@ -102,6 +125,7 @@ def render_overlay(
102125

103126
anthropic_models = opencode_models.get("anthropic") or []
104127
gemini_models = opencode_models.get("gemini") or []
128+
openai_models = opencode_models.get("openai") or []
105129
oss_models = opencode_models.get("oss") or []
106130

107131
providers: dict = {}
@@ -137,6 +161,22 @@ def render_overlay(
137161
"models": {m: {"headers": ua_header} for m in gemini_models},
138162
}
139163
keys.append(["provider", "databricks-google"])
164+
if openai_models:
165+
# @ai-sdk/openai supports both the Responses API and the legacy
166+
# chat-completions API. Databricks GPT-5 / GPT-5.6 / Codex models are
167+
# Responses-only on /ai-gateway/codex/v1, so the per-model flag
168+
# `useResponsesApi: true` lives in models.<m>.options where opencode
169+
# reads it (provider-level options is read by the SDK only).
170+
providers["databricks-openai"] = {
171+
"npm": "@ai-sdk/openai",
172+
"options": {
173+
"baseURL": opencode_base_urls["openai"],
174+
"apiKey": token,
175+
"headers": auth_headers,
176+
},
177+
"models": {m: _openai_model_overlay(m, ua_header) for m in openai_models},
178+
}
179+
keys.append(["provider", "databricks-openai"])
140180
if oss_models:
141181
providers["databricks-oss"] = {
142182
"npm": "@ai-sdk/openai",
@@ -233,6 +273,9 @@ def default_model(state: dict) -> str | None:
233273
anthropic = opencode_models.get("anthropic") or []
234274
if anthropic:
235275
return anthropic[0]
276+
openai = preferred_gpt_model(opencode_models.get("openai") or [])
277+
if openai:
278+
return openai
236279
gemini = opencode_models.get("gemini") or []
237280
if gemini:
238281
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: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2128,27 +2128,53 @@ def discover_endpoints_with_api_type(
21282128
return [], reason
21292129

21302130
data = cast(dict, payload) if isinstance(payload, dict) else {}
2131-
endpoints = data.get("endpoints", [])
2131+
raw_endpoints = data.get("endpoints", [])
2132+
endpoints = raw_endpoints if isinstance(raw_endpoints, list) else []
21322133
out: list[str] = []
21332134
saw_endpoint_without_v2 = False
2135+
saw_malformed = not isinstance(raw_endpoints, list)
21342136
for ep in endpoints:
2135-
name = ep.get("name", "")
2136-
entities = ep.get("config", {}).get("served_entities", [])
2137+
if not isinstance(ep, dict):
2138+
saw_malformed = True
2139+
continue
2140+
name = ep.get("name")
2141+
config = ep.get("config")
2142+
if not isinstance(name, str) or not name or not isinstance(config, dict):
2143+
saw_malformed = True
2144+
continue
2145+
raw_entities = config.get("served_entities", [])
2146+
if not isinstance(raw_entities, list):
2147+
saw_malformed = True
2148+
continue
21372149
api_types: set[str] = set()
21382150
any_v2 = False
2139-
for se in entities:
2140-
fm = se.get("foundation_model", {})
2151+
for se in raw_entities:
2152+
if not isinstance(se, dict):
2153+
saw_malformed = True
2154+
continue
2155+
fm = se.get("foundation_model")
2156+
if not isinstance(fm, dict):
2157+
saw_malformed = True
2158+
continue
21412159
if fm.get("ai_gateway_v2_supported") is True:
21422160
any_v2 = True
2143-
api_types.update(fm.get("api_types", []))
2144-
if not any_v2 and entities:
2161+
raw_api_types = fm.get("api_types", [])
2162+
if isinstance(raw_api_types, list):
2163+
api_types.update(value for value in raw_api_types if isinstance(value, str))
2164+
else:
2165+
saw_malformed = True
2166+
if not any_v2 and raw_entities:
21452167
saw_endpoint_without_v2 = True
21462168
if api_type in api_types:
21472169
out.append(name)
21482170
if out:
21492171
return sorted(out, key=sort_key), None
21502172
if not endpoints:
2173+
if saw_malformed:
2174+
return [], "foundation-models listing returned malformed `endpoints`"
21512175
return [], "foundation-models listing returned no endpoints"
2176+
if saw_malformed:
2177+
return [], "foundation-models listing contained no valid matching endpoints"
21522178
if saw_endpoint_without_v2:
21532179
return [], (
21542180
f"no endpoint exposes api_type `{api_type}` with "
@@ -2175,6 +2201,34 @@ def discover_codex_models(workspace: str, token: str) -> tuple[list[str], str |
21752201
return discover_endpoints_with_api_type(workspace, token, "openai/v1/responses")
21762202

21772203

2204+
def discover_oss_models(workspace: str, token: str) -> tuple[list[str], str | None]:
2205+
"""Discover OSS chat models served as AI Gateway foundation-model endpoints.
2206+
2207+
Fallback for workspaces that don't register OSS foundation models as
2208+
`system.ai.*` UC model-services (see `discover_model_services`): those
2209+
workspaces expose the same models as regular `databricks-*` serving
2210+
endpoints instead. Lists every endpoint advertising the
2211+
`mlflow/v1/chat/completions` dialect, then keeps only the OSS chat families
2212+
(`_is_oss_chat_model`) — on some workspaces the Claude/Gemini endpoints also
2213+
advertise that dialect, so the family filter is what separates the OSS
2214+
cohort from them. Mirrors the AI-Gateway fallback the other families use
2215+
when the UC model-services listing is empty.
2216+
"""
2217+
endpoints, reason = discover_endpoints_with_api_type(
2218+
workspace, token, "mlflow/v1/chat/completions"
2219+
)
2220+
if not endpoints:
2221+
return [], reason
2222+
oss = [e for e in endpoints if _is_oss_chat_model(e)]
2223+
if oss:
2224+
return oss, None
2225+
sample = ", ".join(endpoints[:5])
2226+
return [], (
2227+
"foundation-models exposing `mlflow/v1/chat/completions` matched no OSS "
2228+
f"chat family (got: {sample})"
2229+
)
2230+
2231+
21782232
def fetch_gemini_models(workspace: str, token: str) -> list[str]:
21792233
models, _ = discover_gemini_models(workspace, token)
21802234
return models
@@ -2372,6 +2426,9 @@ def build_opencode_base_urls(workspace: str) -> dict[str, str]:
23722426
return {
23732427
"anthropic": build_tool_base_url("claude", workspace) + "/v1",
23742428
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
2429+
# @ai-sdk/openai appends "/responses" (or "/chat/completions") to baseURL,
2430+
# so stop just before that — matches the Pi adapter's build_pi_base_urls.
2431+
"openai": build_tool_base_url("codex", workspace),
23752432
"oss": f"{workspace}/ai-gateway/mlflow/v1",
23762433
}
23772434

tests/conftest.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
from ucode.databricks import (
1010
build_shared_base_urls,
11-
fetch_ai_gateway_claude_models,
12-
fetch_codex_models,
13-
fetch_gemini_models,
11+
discover_claude_models,
12+
discover_codex_models,
13+
discover_gemini_models,
14+
discover_model_services,
15+
discover_oss_models,
1416
get_databricks_token,
1517
)
1618
from ucode.ui import normalize_workspace_url
@@ -52,22 +54,38 @@ def e2e_token(e2e_workspace):
5254

5355
@pytest.fixture(scope="session")
5456
def e2e_state(e2e_workspace, e2e_token):
55-
"""Full state dict mirroring what configure_shared_state produces."""
56-
claude_models = fetch_ai_gateway_claude_models(e2e_workspace, e2e_token)
57-
gemini_models = fetch_gemini_models(e2e_workspace, e2e_token)
58-
codex_models = fetch_codex_models(e2e_workspace, e2e_token)
57+
"""Full state dict mirroring configure's UC-first family discovery."""
58+
claude_models, codex_models, gemini_models, oss_models, _ = discover_model_services(
59+
e2e_workspace, e2e_token
60+
)
61+
if not claude_models:
62+
claude_models, _ = discover_claude_models(e2e_workspace, e2e_token)
63+
if not gemini_models:
64+
gemini_models, _ = discover_gemini_models(e2e_workspace, e2e_token)
65+
if not codex_models:
66+
codex_models, _ = discover_codex_models(e2e_workspace, e2e_token)
67+
if not oss_models:
68+
oss_models, _ = discover_oss_models(e2e_workspace, e2e_token)
69+
70+
# E2E mirrors configure's default (Fable is premium and opt-in).
71+
claude_models.pop("fable", None)
5972

6073
opencode_models: dict = {}
6174
if claude_models:
6275
opencode_models["anthropic"] = list(claude_models.values())
6376
if gemini_models:
6477
opencode_models["gemini"] = gemini_models
78+
if codex_models:
79+
opencode_models["openai"] = codex_models
80+
if oss_models:
81+
opencode_models["oss"] = oss_models
6582

6683
return {
6784
"workspace": e2e_workspace,
6885
"claude_models": claude_models,
6986
"gemini_models": gemini_models,
7087
"codex_models": codex_models,
88+
"oss_models": oss_models,
7189
"opencode_models": opencode_models,
7290
"base_urls": build_shared_base_urls(e2e_workspace),
7391
"managed_configs": {},

0 commit comments

Comments
 (0)