Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion src/ucode/agents/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
PROVIDER_KEYS: list[list[str]] = [
["provider", "databricks-anthropic"],
["provider", "databricks-google"],
["provider", "databricks-openai"],
]


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

def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -> str:
"""Return an OpenCode model selector in provider/model form when possible."""
if model.startswith("databricks-anthropic/") or model.startswith("databricks-google/"):
if (
model.startswith("databricks-anthropic/")
or model.startswith("databricks-google/")
or model.startswith("databricks-openai/")
):
return model

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

openai_models = opencode_models.get("openai") or []
if model in openai_models:
return f"databricks-openai/{model}"

return model


Expand All @@ -82,6 +91,7 @@ def render_overlay(

anthropic_models = opencode_models.get("anthropic") or []
gemini_models = opencode_models.get("gemini") or []
openai_models = opencode_models.get("openai") or []

providers: dict = {}
keys: list[list[str]] = [["model"]]
Expand Down Expand Up @@ -116,6 +126,28 @@ def render_overlay(
"models": {m: {"headers": ua_header} for m in gemini_models},
}
keys.append(["provider", "databricks-google"])
if openai_models:
# @ai-sdk/openai supports both the Responses API and the legacy
# chat-completions API. Databricks GPT-5 / Codex models are
# Responses-only on /ai-gateway/codex/v1, so the per-model flag
# `useResponsesApi: true` lives in models.<m>.options where opencode
# reads it (provider-level options is read by the SDK only).
providers["databricks-openai"] = {
"npm": "@ai-sdk/openai",
"options": {
"baseURL": opencode_base_urls["openai"],
"apiKey": token,
"headers": auth_headers,
},
"models": {
m: {
"headers": ua_header,
"options": {"useResponsesApi": True},
}
for m in openai_models
},
}
keys.append(["provider", "databricks-openai"])

overlay: dict = {"model": _resolve_model_selector(model, opencode_models)}
if providers:
Expand Down Expand Up @@ -194,6 +226,9 @@ def default_model(state: dict) -> str | None:
anthropic = opencode_models.get("anthropic") or []
if anthropic:
return anthropic[0]
openai = opencode_models.get("openai") or []
if openai:
return openai[0]
gemini = opencode_models.get("gemini") or []
return gemini[0] if gemini else None

Expand Down
6 changes: 5 additions & 1 deletion src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ def configure_shared_state(
fetch_all or "claude" in tools or "opencode" in tools or "copilot" in tools or "pi" in tools
)
want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools
want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools
want_codex = (
fetch_all or "codex" in tools or "copilot" in tools or "opencode" in tools or "pi" in tools
)

claude_reason: str | None = None
gemini_reason: str | None = None
Expand All @@ -172,6 +174,8 @@ def configure_shared_state(
opencode_models["anthropic"] = list(claude_models.values())
if gemini_models:
opencode_models["gemini"] = gemini_models
if codex_models:
opencode_models["openai"] = codex_models

# Merge into existing workspace state so prior tool configs are preserved.
state = load_state()
Expand Down
3 changes: 3 additions & 0 deletions src/ucode/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,9 @@ def build_opencode_base_urls(workspace: str) -> dict[str, str]:
return {
"anthropic": build_tool_base_url("claude", workspace) + "/v1",
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
# @ai-sdk/openai appends "/responses" (or "/chat/completions") to baseURL,
# so stop just before that — matches the Pi adapter's build_pi_base_urls.
"openai": build_tool_base_url("codex", workspace),
}


Expand Down
108 changes: 108 additions & 0 deletions tests/test_agent_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def _base_urls() -> dict[str, str]:
return {
"anthropic": f"{WS}/ai-gateway/anthropic/v1",
"gemini": f"{WS}/ai-gateway/gemini/v1beta",
"openai": f"{WS}/ai-gateway/codex/v1",
}


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


class TestRenderOverlayCodex:
"""Regression coverage for #97: the GPT-5 / Codex (Responses) family must
land in opencode.json alongside Anthropic and Gemini. Before the fix, only
two providers were written and `databricks-gpt-5-5` could not be reached
from OpenCode at all."""

def test_openai_provider_added_when_codex_models_present(self):
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
assert "databricks-openai" in overlay["provider"]

def test_openai_provider_uses_ai_sdk_openai_npm(self):
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
assert overlay["provider"]["databricks-openai"]["npm"] == "@ai-sdk/openai"

def test_openai_base_url_points_at_codex_gateway(self):
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
options = overlay["provider"]["databricks-openai"]["options"]
assert options["baseURL"] == f"{WS}/ai-gateway/codex/v1"

def test_use_responses_api_flag_set_per_model(self):
# Databricks GPT-5 / Codex models are Responses-only on
# /ai-gateway/codex/v1. The per-model `useResponsesApi: true` lives in
# `models.<m>.options` where opencode reads it.
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
model_entry = overlay["provider"]["databricks-openai"]["models"]["databricks-gpt-5-5"]
assert model_entry["options"]["useResponsesApi"] is True

def test_use_responses_api_set_on_every_codex_model(self):
models = {"openai": ["databricks-gpt-5-5", "databricks-gpt-codex"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
provider_models = overlay["provider"]["databricks-openai"]["models"]
for m in ("databricks-gpt-5-5", "databricks-gpt-codex"):
assert provider_models[m]["options"]["useResponsesApi"] is True

def test_openai_token_in_api_key(self):
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "mytoken", _base_urls(), models)
assert overlay["provider"]["databricks-openai"]["options"]["apiKey"] == "mytoken"

def test_openai_authorization_header(self):
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
headers = overlay["provider"]["databricks-openai"]["options"]["headers"]
assert headers["Authorization"] == "Bearer tok"

def test_user_agent_header_codex(self, monkeypatch):
monkeypatch.setattr(opencode, "ucode_version", lambda: "0.1.0")
monkeypatch.setattr(opencode, "agent_version", lambda binary: "0.74.0")
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
model_headers = overlay["provider"]["databricks-openai"]["models"]["databricks-gpt-5-5"][
"headers"
]
assert model_headers["User-Agent"] == "ucode/0.1.0 opencode/0.74.0"

def test_managed_keys_include_openai_provider(self):
models = {"openai": ["databricks-gpt-5-5"]}
_, keys = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
assert ["provider", "databricks-openai"] in keys

def test_prefixes_openai_model_with_provider_id(self):
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay("databricks-gpt-5-5", "tok", _base_urls(), models)
assert overlay["model"] == "databricks-openai/databricks-gpt-5-5"

def test_already_prefixed_codex_model_is_preserved(self):
models = {"openai": ["databricks-gpt-5-5"]}
overlay, _ = opencode.render_overlay(
"databricks-openai/databricks-gpt-5-5", "tok", _base_urls(), models
)
assert overlay["model"] == "databricks-openai/databricks-gpt-5-5"

def test_all_three_providers_when_all_present(self):
models = {
"anthropic": ["claude-sonnet"],
"gemini": ["gemini-2"],
"openai": ["databricks-gpt-5-5"],
}
overlay, _ = opencode.render_overlay("claude-sonnet", "tok", _base_urls(), models)
assert set(overlay["provider"].keys()) == {
"databricks-anthropic",
"databricks-google",
"databricks-openai",
}

def test_provider_keys_listed_in_module(self):
# `PROVIDER_KEYS` drives the stale-config cleanup. The codex provider
# must be in this list or stale entries would leak across configures.
assert ["provider", "databricks-openai"] in opencode.PROVIDER_KEYS


class TestMcpServerConfig:
def test_builds_remote_server_entry_with_oauth_token_env_header(self):
entry = opencode.build_mcp_server_entry(f"{WS}/api/2.0/mcp/external/github")
Expand Down Expand Up @@ -264,6 +360,18 @@ def test_prefers_anthropic(self):
state = {"opencode_models": {"anthropic": ["claude-sonnet"], "gemini": ["gemini-2"]}}
assert opencode.default_model(state) == "claude-sonnet"

def test_falls_back_to_openai_before_gemini(self):
# Codex/GPT-5 ranks above Gemini in the fallback order — these are the
# primary code models on Databricks once the codex provider is wired.
state = {
"opencode_models": {
"anthropic": [],
"openai": ["databricks-gpt-5-5"],
"gemini": ["gemini-2"],
}
}
assert opencode.default_model(state) == "databricks-gpt-5-5"

def test_falls_back_to_gemini(self):
state = {"opencode_models": {"anthropic": [], "gemini": ["gemini-2"]}}
assert opencode.default_model(state) == "gemini-2"
Expand Down
6 changes: 6 additions & 0 deletions tests/test_databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ def test_returns_anthropic_and_gemini(self):
assert urls["anthropic"] == f"{WS}/ai-gateway/anthropic/v1"
assert urls["gemini"] == f"{WS}/ai-gateway/gemini/v1beta"

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


class TestBuildSharedBaseUrls:
def test_contains_all_tools(self):
Expand Down