Skip to content

Commit f5e37d9

Browse files
committed
fix(opencode): wire databricks-openai provider for GPT-5 / Codex
Configure the third Databricks model family for OpenCode. Mirrors the Pi adapter's pattern (which already supports all three families): @ai-sdk/openai against /ai-gateway/codex/v1 with per-model useResponsesApi=true. Before: ucode configure --agents opencode wrote only databricks-anthropic and databricks-google. databricks-gpt-5-5 / databricks-gpt-codex were unreachable from OpenCode. After: opencode.json includes databricks-openai with per-model useResponsesApi=true; default_model() falls back to codex before gemini. Fixes #97
1 parent 444e74f commit f5e37d9

5 files changed

Lines changed: 158 additions & 2 deletions

File tree

src/ucode/agents/opencode.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
PROVIDER_KEYS: list[list[str]] = [
4242
["provider", "databricks-anthropic"],
4343
["provider", "databricks-google"],
44+
["provider", "databricks-openai"],
4445
]
4546

4647

@@ -50,7 +51,11 @@ def is_update_available() -> tuple[str, str] | None:
5051

5152
def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -> str:
5253
"""Return an OpenCode model selector in provider/model form when possible."""
53-
if model.startswith("databricks-anthropic/") or model.startswith("databricks-google/"):
54+
if (
55+
model.startswith("databricks-anthropic/")
56+
or model.startswith("databricks-google/")
57+
or model.startswith("databricks-openai/")
58+
):
5459
return model
5560

5661
anthropic_models = opencode_models.get("anthropic") or []
@@ -61,6 +66,10 @@ def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -
6166
if model in gemini_models:
6267
return f"databricks-google/{model}"
6368

69+
openai_models = opencode_models.get("openai") or []
70+
if model in openai_models:
71+
return f"databricks-openai/{model}"
72+
6473
return model
6574

6675

@@ -82,6 +91,7 @@ def render_overlay(
8291

8392
anthropic_models = opencode_models.get("anthropic") or []
8493
gemini_models = opencode_models.get("gemini") or []
94+
openai_models = opencode_models.get("openai") or []
8595

8696
providers: dict = {}
8797
keys: list[list[str]] = [["model"]]
@@ -116,6 +126,28 @@ def render_overlay(
116126
"models": {m: {"headers": ua_header} for m in gemini_models},
117127
}
118128
keys.append(["provider", "databricks-google"])
129+
if openai_models:
130+
# @ai-sdk/openai supports both the Responses API and the legacy
131+
# chat-completions API. Databricks GPT-5 / Codex models are
132+
# Responses-only on /ai-gateway/codex/v1, so the per-model flag
133+
# `useResponsesApi: true` lives in models.<m>.options where opencode
134+
# reads it (provider-level options is read by the SDK only).
135+
providers["databricks-openai"] = {
136+
"npm": "@ai-sdk/openai",
137+
"options": {
138+
"baseURL": opencode_base_urls["openai"],
139+
"apiKey": token,
140+
"headers": auth_headers,
141+
},
142+
"models": {
143+
m: {
144+
"headers": ua_header,
145+
"options": {"useResponsesApi": True},
146+
}
147+
for m in openai_models
148+
},
149+
}
150+
keys.append(["provider", "databricks-openai"])
119151

120152
overlay: dict = {"model": _resolve_model_selector(model, opencode_models)}
121153
if providers:
@@ -196,6 +228,9 @@ def default_model(state: dict) -> str | None:
196228
anthropic = opencode_models.get("anthropic") or []
197229
if anthropic:
198230
return anthropic[0]
231+
openai = opencode_models.get("openai") or []
232+
if openai:
233+
return openai[0]
199234
gemini = opencode_models.get("gemini") or []
200235
return gemini[0] if gemini else None
201236

src/ucode/cli.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ def configure_shared_state(
176176
fetch_all or "claude" in tools or "opencode" in tools or "copilot" in tools or "pi" in tools
177177
)
178178
want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools
179-
want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools
179+
want_codex = (
180+
fetch_all or "codex" in tools or "copilot" in tools or "opencode" in tools or "pi" in tools
181+
)
180182

181183
claude_reason: str | None = None
182184
gemini_reason: str | None = None
@@ -199,6 +201,8 @@ def configure_shared_state(
199201
opencode_models["anthropic"] = list(claude_models.values())
200202
if gemini_models:
201203
opencode_models["gemini"] = gemini_models
204+
if codex_models:
205+
opencode_models["openai"] = codex_models
202206

203207
# Merge into existing workspace state so prior tool configs are preserved.
204208
state = load_state()

src/ucode/databricks.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,9 @@ def build_opencode_base_urls(workspace: str) -> dict[str, str]:
11041104
return {
11051105
"anthropic": build_tool_base_url("claude", workspace) + "/v1",
11061106
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
1107+
# @ai-sdk/openai appends "/responses" (or "/chat/completions") to baseURL,
1108+
# so stop just before that — matches the Pi adapter's build_pi_base_urls.
1109+
"openai": build_tool_base_url("codex", workspace),
11071110
}
11081111

11091112

tests/test_agent_opencode.py

Lines changed: 108 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
}
1819

1920

@@ -152,6 +153,101 @@ def test_prefixes_gemini_model_with_provider_id(self):
152153
assert overlay["model"] == "databricks-google/gemini-2"
153154

154155

156+
class TestRenderOverlayCodex:
157+
"""Regression coverage for #97: the GPT-5 / Codex (Responses) family must
158+
land in opencode.json alongside Anthropic and Gemini. Before the fix, only
159+
two providers were written and `databricks-gpt-5-5` could not be reached
160+
from OpenCode at all."""
161+
162+
def test_openai_provider_added_when_codex_models_present(self):
163+
models = {"openai": ["databricks-gpt-5-5"]}
164+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
165+
assert "databricks-openai" in overlay["provider"]
166+
167+
def test_openai_provider_uses_ai_sdk_openai_npm(self):
168+
models = {"openai": ["databricks-gpt-5-5"]}
169+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
170+
assert overlay["provider"]["databricks-openai"]["npm"] == "@ai-sdk/openai"
171+
172+
def test_openai_base_url_points_at_codex_gateway(self):
173+
models = {"openai": ["databricks-gpt-5-5"]}
174+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
175+
options = overlay["provider"]["databricks-openai"]["options"]
176+
assert options["baseURL"] == f"{WS}/ai-gateway/codex/v1"
177+
178+
def test_use_responses_api_flag_set_per_model(self):
179+
# Databricks GPT-5 / Codex models are Responses-only on
180+
# /ai-gateway/codex/v1. The per-model `useResponsesApi: true` lives in
181+
# `models.<m>.options` where opencode reads it.
182+
models = {"openai": ["databricks-gpt-5-5"]}
183+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
184+
model_entry = overlay["provider"]["databricks-openai"]["models"]["databricks-gpt-5-5"]
185+
assert model_entry["options"]["useResponsesApi"] is True
186+
187+
def test_use_responses_api_set_on_every_codex_model(self):
188+
models = {"openai": ["databricks-gpt-5-5", "databricks-gpt-codex"]}
189+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
190+
provider_models = overlay["provider"]["databricks-openai"]["models"]
191+
for m in ("databricks-gpt-5-5", "databricks-gpt-codex"):
192+
assert provider_models[m]["options"]["useResponsesApi"] is True
193+
194+
def test_openai_token_in_api_key(self):
195+
models = {"openai": ["databricks-gpt-5-5"]}
196+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "mytoken", _base_urls(), models)
197+
assert overlay["provider"]["databricks-openai"]["options"]["apiKey"] == "mytoken"
198+
199+
def test_openai_authorization_header(self):
200+
models = {"openai": ["databricks-gpt-5-5"]}
201+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
202+
headers = overlay["provider"]["databricks-openai"]["options"]["headers"]
203+
assert headers["Authorization"] == "Bearer tok"
204+
205+
def test_user_agent_header_codex(self, monkeypatch):
206+
monkeypatch.setattr(opencode, "ucode_version", lambda: "0.1.0")
207+
monkeypatch.setattr(opencode, "agent_version", lambda binary: "0.74.0")
208+
models = {"openai": ["databricks-gpt-5-5"]}
209+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
210+
model_headers = overlay["provider"]["databricks-openai"]["models"]["databricks-gpt-5-5"][
211+
"headers"
212+
]
213+
assert model_headers["User-Agent"] == "ucode/0.1.0 opencode/0.74.0"
214+
215+
def test_managed_keys_include_openai_provider(self):
216+
models = {"openai": ["databricks-gpt-5-5"]}
217+
_, keys = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
218+
assert ["provider", "databricks-openai"] in keys
219+
220+
def test_prefixes_openai_model_with_provider_id(self):
221+
models = {"openai": ["databricks-gpt-5-5"]}
222+
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
223+
assert overlay["model"] == "databricks-openai/databricks-gpt-5-5"
224+
225+
def test_already_prefixed_codex_model_is_preserved(self):
226+
models = {"openai": ["databricks-gpt-5-5"]}
227+
overlay, _ = opencode.render_overlay(
228+
"databricks-openai/databricks-gpt-5-5", "tok", _base_urls(), models
229+
)
230+
assert overlay["model"] == "databricks-openai/databricks-gpt-5-5"
231+
232+
def test_all_three_providers_when_all_present(self):
233+
models = {
234+
"anthropic": ["claude-sonnet"],
235+
"gemini": ["gemini-2"],
236+
"openai": ["databricks-gpt-5-5"],
237+
}
238+
overlay, _ = opencode.render_overlay("claude-sonnet", "tok", _base_urls(), models)
239+
assert set(overlay["provider"].keys()) == {
240+
"databricks-anthropic",
241+
"databricks-google",
242+
"databricks-openai",
243+
}
244+
245+
def test_provider_keys_listed_in_module(self):
246+
# `PROVIDER_KEYS` drives the stale-config cleanup. The codex provider
247+
# must be in this list or stale entries would leak across configures.
248+
assert ["provider", "databricks-openai"] in opencode.PROVIDER_KEYS
249+
250+
155251
class TestMcpServerConfig:
156252
def test_builds_remote_server_entry_with_oauth_token_env_header(self):
157253
entry = opencode.build_mcp_server_entry(f"{WS}/api/2.0/mcp/external/github")
@@ -264,6 +360,18 @@ def test_prefers_anthropic(self):
264360
state = {"opencode_models": {"anthropic": ["claude-sonnet"], "gemini": ["gemini-2"]}}
265361
assert opencode.default_model(state) == "claude-sonnet"
266362

363+
def test_falls_back_to_openai_before_gemini(self):
364+
# Codex/GPT-5 ranks above Gemini in the fallback order — these are the
365+
# primary code models on Databricks once the codex provider is wired.
366+
state = {
367+
"opencode_models": {
368+
"anthropic": [],
369+
"openai": ["databricks-gpt-5-5"],
370+
"gemini": ["gemini-2"],
371+
}
372+
}
373+
assert opencode.default_model(state) == "databricks-gpt-5-5"
374+
267375
def test_falls_back_to_gemini(self):
268376
state = {"opencode_models": {"anthropic": [], "gemini": ["gemini-2"]}}
269377
assert opencode.default_model(state) == "gemini-2"

tests/test_databricks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ def test_returns_anthropic_and_gemini(self):
9696
assert urls["anthropic"] == f"{WS}/ai-gateway/anthropic/v1"
9797
assert urls["gemini"] == f"{WS}/ai-gateway/gemini/v1beta"
9898

99+
def test_returns_openai_codex_gateway(self):
100+
# @ai-sdk/openai appends /responses (Responses API) or /chat/completions
101+
# to baseURL, so stop just before that suffix. Mirrors build_pi_base_urls.
102+
urls = build_opencode_base_urls(WS)
103+
assert urls["openai"] == f"{WS}/ai-gateway/codex/v1"
104+
99105

100106
class TestBuildSharedBaseUrls:
101107
def test_contains_all_tools(self):

0 commit comments

Comments
 (0)