diff --git a/src/ucode/agents/opencode.py b/src/ucode/agents/opencode.py index 7215388..895ac01 100644 --- a/src/ucode/agents/opencode.py +++ b/src/ucode/agents/opencode.py @@ -41,6 +41,7 @@ PROVIDER_KEYS: list[list[str]] = [ ["provider", "databricks-anthropic"], ["provider", "databricks-google"], + ["provider", "databricks-openai"], ] @@ -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 [] @@ -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 @@ -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"]] @@ -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..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: @@ -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 diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 6bc3f93..0df75f4 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -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 @@ -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() diff --git a/src/ucode/databricks.py b/src/ucode/databricks.py index 35c7f31..6a20e55 100644 --- a/src/ucode/databricks.py +++ b/src/ucode/databricks.py @@ -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), } diff --git a/tests/test_agent_opencode.py b/tests/test_agent_opencode.py index 0f32f4d..d004256 100644 --- a/tests/test_agent_opencode.py +++ b/tests/test_agent_opencode.py @@ -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", } @@ -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..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") @@ -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" diff --git a/tests/test_databricks.py b/tests/test_databricks.py index dfcf17c..6f3f89a 100644 --- a/tests/test_databricks.py +++ b/tests/test_databricks.py @@ -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):