Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion src/ucode/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:

_TOOL_DISCOVERY_SOURCES: dict[str, tuple[str, ...]] = {
"claude": ("claude",),
"opencode": ("claude", "gemini"),
"opencode": ("claude", "gemini", "oss"),
"codex": ("codex",),
"gemini": ("gemini",),
"copilot": ("claude", "codex"),
Expand Down
33 changes: 29 additions & 4 deletions src/ucode/agents/opencode.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""OpenCode agent: writes opencode.json with two Databricks-backed providers."""
"""OpenCode agent: writes opencode.json with Databricks-backed providers."""

from __future__ import annotations

Expand Down Expand Up @@ -41,6 +41,7 @@
PROVIDER_KEYS: list[list[str]] = [
["provider", "databricks-anthropic"],
["provider", "databricks-google"],
["provider", "databricks-oss"],
]


Expand All @@ -50,7 +51,7 @@ 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/", "databricks-google/", "databricks-oss/")):
return model

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

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

return model


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

anthropic_models = opencode_models.get("anthropic") or []
gemini_models = opencode_models.get("gemini") or []
oss_models = opencode_models.get("oss") or []

providers: dict = {}
keys: list[list[str]] = [["model"]]
Expand Down Expand Up @@ -116,6 +122,17 @@ def render_overlay(
"models": {m: {"headers": ua_header} for m in gemini_models},
}
keys.append(["provider", "databricks-google"])
if oss_models:
providers["databricks-oss"] = {
"npm": "@ai-sdk/openai",
"options": {
"baseURL": opencode_base_urls["oss"],
"apiKey": token,
"headers": auth_headers,
},
"models": {m: {"headers": ua_header} for m in oss_models},
}
keys.append(["provider", "databricks-oss"])

overlay: dict = {"model": _resolve_model_selector(model, opencode_models)}
if providers:
Expand Down Expand Up @@ -147,7 +164,12 @@ def write_tool_config(
existing = read_json_safe(OPENCODE_CONFIG_PATH)
providers = existing.get("provider")
if isinstance(providers, dict):
for stale in ("databricks-anthropic", "databricks-google", "databricks-openai"):
for stale in (
"databricks-anthropic",
"databricks-google",
"databricks-openai",
"databricks-oss",
):
providers.pop(stale, None)
merged = deep_merge_dict(existing, overlay)
write_json_file(OPENCODE_CONFIG_PATH, merged)
Expand Down Expand Up @@ -197,7 +219,10 @@ def default_model(state: dict) -> str | None:
if anthropic:
return anthropic[0]
gemini = opencode_models.get("gemini") or []
return gemini[0] if gemini else None
if gemini:
return gemini[0]
oss = opencode_models.get("oss") or []
return oss[0] if oss else None


def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str:
Expand Down
22 changes: 20 additions & 2 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"claude": ("claude", "opencode", "copilot", "pi"),
"codex": ("codex", "copilot", "pi"),
"gemini": ("gemini", "opencode", "pi"),
"oss": ("opencode",),
}


Expand All @@ -85,7 +86,12 @@ def _print_discovery_diagnostics(state: dict) -> None:
reasons = state.get("_discovery_reasons") or {}
if not reasons:
return
labels = {"claude": "Claude models", "codex": "Codex models", "gemini": "Gemini models"}
labels = {
"claude": "Claude models",
"codex": "Codex models",
"gemini": "Gemini models",
"oss": "OSS models",
}
for source, reason in reasons.items():
consumers = ", ".join(_DISCOVERY_CONSUMERS.get(source, ()))
label = labels.get(source, source)
Expand Down Expand Up @@ -252,19 +258,24 @@ def configure_shared_state(
)
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_oss = fetch_all or "opencode" in tools

claude_reason: str | None = None
gemini_reason: str | None = None
codex_reason: str | None = None
oss_reason: str | None = None
claude_models = {}
gemini_models = []
codex_models = []
oss_models = []
# UC-first, best-effort: one UC model-services call yields all families as
# `system.ai.<model-name>` ids, bucketed by name. If a family comes back
# empty (workspace without UC model-services, or the listing failed), fall
# back to the per-family AI Gateway listing for that family only.
with spinner("Fetching available models..."):
ms_claude, ms_codex, ms_gemini, ms_reason = discover_model_services(workspace, token)
ms_claude, ms_codex, ms_gemini, ms_oss, ms_reason = discover_model_services(
workspace, token
)
if want_claude:
claude_models, claude_reason = ms_claude, ms_reason
if not claude_models:
Expand All @@ -277,11 +288,15 @@ def configure_shared_state(
codex_models, codex_reason = ms_codex, ms_reason
if not codex_models:
codex_models, codex_reason = discover_codex_models(workspace, token)
if want_oss:
oss_models, oss_reason = ms_oss, ms_reason
opencode_models: dict[str, list[str]] = {}
if claude_models:
opencode_models["anthropic"] = list(claude_models.values())
if gemini_models:
opencode_models["gemini"] = gemini_models
if oss_models:
opencode_models["oss"] = oss_models

# Merge into existing workspace state so prior tool configs are preserved.
state = load_state()
Expand All @@ -305,6 +320,8 @@ def configure_shared_state(
state["gemini_models"] = gemini_models
if want_codex:
state["codex_models"] = codex_models
if want_oss:
state["oss_models"] = oss_models
if fetch_all or "opencode" in tools:
state["opencode_models"] = opencode_models
save_state(state)
Expand All @@ -318,6 +335,7 @@ def configure_shared_state(
"claude": claude_reason,
"gemini": gemini_reason,
"codex": codex_reason,
"oss": oss_reason,
}
return state

Expand Down
16 changes: 10 additions & 6 deletions src/ucode/databricks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1193,23 +1193,24 @@ def list_model_services(

def discover_model_services(
workspace: str, token: str
) -> tuple[dict[str, str], list[str], list[str], str | None]:
) -> tuple[dict[str, str], list[str], list[str], list[str], str | None]:
"""Discover models via UC model-services and bucket them by family name.

Returns (claude_models, codex_models, gemini_models, reason):
Returns (claude_models, codex_models, gemini_models, oss_models, reason):

- ``claude_models`` maps ``opus``/``sonnet``/``haiku`` to the newest
matching ``system.ai.claude-*`` id (mirrors ``discover_claude_models``).
- ``codex_models`` is the list of ``system.ai.*gpt-*`` ids.
- ``gemini_models`` is the list of ``system.ai.*gemini-*`` ids, newest first.
- ``oss_models`` is the list of OSS-model ``system.ai.*`` ids.

``reason`` is None on success, else explains why nothing was found. Family
bucketing is by name substring because the model-services API does not
expose per-model API dialects.
"""
ids, reason = list_model_services(workspace, token)
if not ids:
return {}, [], [], reason
return {}, [], [], [], reason

claude_models: dict[str, str] = {}
for family in ("opus", "sonnet", "haiku"):
Expand All @@ -1222,19 +1223,21 @@ def discover_model_services(

codex_models = [m for m in ids if "gpt-" in m]
gemini_models = sorted([m for m in ids if "gemini-" in m], key=model_version_sort_key)
oss_models = [m for m in ids if "kimi-" in m]

if not (claude_models or codex_models or gemini_models):
if not (claude_models or codex_models or gemini_models or oss_models):
sample = ", ".join(ids[:5])
return (
{},
[],
[],
[],
(
"model-services returned model ids but none matched "
f"claude/gpt/gemini families (got: {sample})"
f"claude/gpt/gemini/oss families (got: {sample})"
),
)
return claude_models, codex_models, gemini_models, None
return claude_models, codex_models, gemini_models, oss_models, None


# --- MCP services (parallel to model services) -----------------------------
Expand Down Expand Up @@ -1879,6 +1882,7 @@ 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",
"oss": f"{workspace}/ai-gateway/mlflow/v1",
}


Expand Down
45 changes: 45 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",
"oss": f"{WS}/ai-gateway/mlflow/v1",
}


Expand Down Expand Up @@ -48,6 +49,20 @@ def test_gemini_provider_added_when_models_present(self):
overlay, _ = opencode.render_overlay("gemini-2", "tok", _base_urls(), models)
assert "databricks-google" in overlay["provider"]

def test_oss_provider_added_when_models_present(self):
models = {"oss": ["system.ai.kimi-k2-7-code"]}
overlay, _ = opencode.render_overlay(
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
)
assert "databricks-oss" in overlay["provider"]

def test_oss_provider_uses_ai_sdk_openai_package(self):
models = {"oss": ["system.ai.kimi-k2-7-code"]}
overlay, _ = opencode.render_overlay(
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
)
assert overlay["provider"]["databricks-oss"]["npm"] == "@ai-sdk/openai"

def test_both_providers_when_both_present(self):
models = {"anthropic": ["claude-sonnet"], "gemini": ["gemini-2"]}
overlay, _ = opencode.render_overlay("claude-sonnet", "tok", _base_urls(), models)
Expand All @@ -70,6 +85,14 @@ def test_gemini_base_url(self):
options = overlay["provider"]["databricks-google"]["options"]
assert options["baseURL"] == f"{WS}/ai-gateway/gemini/v1beta"

def test_oss_base_url(self):
models = {"oss": ["system.ai.kimi-k2-7-code"]}
overlay, _ = opencode.render_overlay(
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
)
options = overlay["provider"]["databricks-oss"]["options"]
assert options["baseURL"] == f"{WS}/ai-gateway/mlflow/v1"

def test_token_in_api_key(self):
models = {"anthropic": ["claude-sonnet"]}
overlay, _ = opencode.render_overlay("claude-sonnet", "mytoken", _base_urls(), models)
Expand Down Expand Up @@ -134,6 +157,11 @@ def test_managed_keys_include_gemini_provider(self):
_, keys = opencode.render_overlay("gemini-2", "tok", _base_urls(), models)
assert ["provider", "databricks-google"] in keys

def test_managed_keys_include_oss_provider(self):
models = {"oss": ["system.ai.kimi-k2-7-code"]}
_, keys = opencode.render_overlay("system.ai.kimi-k2-7-code", "tok", _base_urls(), models)
assert ["provider", "databricks-oss"] in keys

def test_anthropic_models_listed(self):
models = {"anthropic": ["claude-sonnet", "claude-haiku"]}
overlay, _ = opencode.render_overlay("claude-sonnet", "tok", _base_urls(), models)
Expand All @@ -151,6 +179,13 @@ def test_prefixes_gemini_model_with_provider_id(self):
overlay, _ = opencode.render_overlay("gemini-2", "tok", _base_urls(), models)
assert overlay["model"] == "databricks-google/gemini-2"

def test_prefixes_oss_model_with_provider_id(self):
models = {"oss": ["system.ai.kimi-k2-7-code"]}
overlay, _ = opencode.render_overlay(
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
)
assert overlay["model"] == "databricks-oss/system.ai.kimi-k2-7-code"


class TestMcpServerConfig:
def test_builds_remote_server_entry_with_oauth_token_env_header(self):
Expand Down Expand Up @@ -268,6 +303,16 @@ def test_falls_back_to_gemini(self):
state = {"opencode_models": {"anthropic": [], "gemini": ["gemini-2"]}}
assert opencode.default_model(state) == "gemini-2"

def test_falls_back_to_oss(self):
state = {
"opencode_models": {
"anthropic": [],
"gemini": [],
"oss": ["system.ai.kimi-k2-7-code"],
}
}
assert opencode.default_model(state) == "system.ai.kimi-k2-7-code"

def test_returns_none_when_empty(self):
assert opencode.default_model({}) is None
assert opencode.default_model({"opencode_models": {}}) is None
Expand Down
14 changes: 10 additions & 4 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ def _stub_deps(monkeypatch, *, pat_token, existing_state=None):
monkeypatch.setattr(cli_mod, "find_profile_name_for_host", lambda w: None)
monkeypatch.setattr(cli_mod, "get_databricks_token", lambda w, p: "token")
monkeypatch.setattr(cli_mod, "ensure_ai_gateway_v2", lambda w, t: None)
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], None))
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], None))
monkeypatch.setattr(cli_mod, "discover_claude_models", lambda w, t: ({}, None))
monkeypatch.setattr(cli_mod, "discover_gemini_models", lambda w, t: ([], None))
monkeypatch.setattr(cli_mod, "discover_codex_models", lambda w, t: ([], None))
Expand Down Expand Up @@ -1084,7 +1084,13 @@ def test_uc_models_used_without_legacy_fallback(self, monkeypatch):
monkeypatch.setattr(
cli_mod,
"discover_model_services",
lambda w, t: ({"opus": "system.ai.claude-opus-4-8"}, ["system.ai.gpt-5"], [], None),
lambda w, t: (
{"opus": "system.ai.claude-opus-4-8"},
["system.ai.gpt-5"],
[],
[],
None,
),
)
legacy_called: list[str] = []
monkeypatch.setattr(
Expand All @@ -1104,7 +1110,7 @@ def test_falls_back_to_legacy_when_uc_empty(self, monkeypatch):
# No UC model-services: each family falls back to the legacy listing.
cli_mod, *_ = self._stub_deps(monkeypatch, pat_token="dapi-pat")
monkeypatch.setattr(
cli_mod, "discover_model_services", lambda w, t: ({}, [], [], "no model services")
cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], "no model services")
)
monkeypatch.setattr(
cli_mod,
Expand Down Expand Up @@ -1182,7 +1188,7 @@ def _stub_external_deps(monkeypatch):
monkeypatch.setattr(cli_mod, "find_profile_name_for_host", lambda w: None)
monkeypatch.setattr(cli_mod, "get_databricks_token", lambda w, p: "token")
monkeypatch.setattr(cli_mod, "ensure_ai_gateway_v2", lambda w, t: None)
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], None))
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], None))
monkeypatch.setattr(cli_mod, "discover_claude_models", lambda w, t: ({}, None))
monkeypatch.setattr(cli_mod, "discover_gemini_models", lambda w, t: ([], None))
monkeypatch.setattr(cli_mod, "discover_codex_models", lambda w, t: ([], None))
Expand Down
Loading
Loading