Skip to content

Commit f45180b

Browse files
committed
Add OSS model support for OpenCode
1 parent 1051b1a commit f45180b

8 files changed

Lines changed: 141 additions & 32 deletions

File tree

src/ucode/agents/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
304304

305305
_TOOL_DISCOVERY_SOURCES: dict[str, tuple[str, ...]] = {
306306
"claude": ("claude",),
307-
"opencode": ("claude", "gemini"),
307+
"opencode": ("claude", "gemini", "oss"),
308308
"codex": ("codex",),
309309
"gemini": ("gemini",),
310310
"copilot": ("claude", "codex"),

src/ucode/agents/opencode.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""OpenCode agent: writes opencode.json with two Databricks-backed providers."""
1+
"""OpenCode agent: writes opencode.json with Databricks-backed providers."""
22

33
from __future__ import annotations
44

@@ -41,6 +41,7 @@
4141
PROVIDER_KEYS: list[list[str]] = [
4242
["provider", "databricks-anthropic"],
4343
["provider", "databricks-google"],
44+
["provider", "databricks-oss"],
4445
]
4546

4647

@@ -50,7 +51,7 @@ 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 model.startswith(("databricks-anthropic/", "databricks-google/", "databricks-oss/")):
5455
return model
5556

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

65+
oss_models = opencode_models.get("oss") or []
66+
if model in oss_models:
67+
return f"databricks-oss/{model}"
68+
6469
return model
6570

6671

@@ -82,6 +87,7 @@ def render_overlay(
8287

8388
anthropic_models = opencode_models.get("anthropic") or []
8489
gemini_models = opencode_models.get("gemini") or []
90+
oss_models = opencode_models.get("oss") or []
8591

8692
providers: dict = {}
8793
keys: list[list[str]] = [["model"]]
@@ -116,6 +122,17 @@ def render_overlay(
116122
"models": {m: {"headers": ua_header} for m in gemini_models},
117123
}
118124
keys.append(["provider", "databricks-google"])
125+
if oss_models:
126+
providers["databricks-oss"] = {
127+
"npm": "@ai-sdk/openai",
128+
"options": {
129+
"baseURL": opencode_base_urls["oss"],
130+
"apiKey": token,
131+
"headers": auth_headers,
132+
},
133+
"models": {m: {"headers": ua_header} for m in oss_models},
134+
}
135+
keys.append(["provider", "databricks-oss"])
119136

120137
overlay: dict = {"model": _resolve_model_selector(model, opencode_models)}
121138
if providers:
@@ -147,7 +164,12 @@ def write_tool_config(
147164
existing = read_json_safe(OPENCODE_CONFIG_PATH)
148165
providers = existing.get("provider")
149166
if isinstance(providers, dict):
150-
for stale in ("databricks-anthropic", "databricks-google", "databricks-openai"):
167+
for stale in (
168+
"databricks-anthropic",
169+
"databricks-google",
170+
"databricks-openai",
171+
"databricks-oss",
172+
):
151173
providers.pop(stale, None)
152174
merged = deep_merge_dict(existing, overlay)
153175
write_json_file(OPENCODE_CONFIG_PATH, merged)
@@ -197,7 +219,10 @@ def default_model(state: dict) -> str | None:
197219
if anthropic:
198220
return anthropic[0]
199221
gemini = opencode_models.get("gemini") or []
200-
return gemini[0] if gemini else None
222+
if gemini:
223+
return gemini[0]
224+
oss = opencode_models.get("oss") or []
225+
return oss[0] if oss else None
201226

202227

203228
def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str:

src/ucode/cli.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"claude": ("claude", "opencode", "copilot", "pi"),
7777
"codex": ("codex", "copilot", "pi"),
7878
"gemini": ("gemini", "opencode", "pi"),
79+
"oss": ("opencode",),
7980
}
8081

8182

@@ -85,7 +86,12 @@ def _print_discovery_diagnostics(state: dict) -> None:
8586
reasons = state.get("_discovery_reasons") or {}
8687
if not reasons:
8788
return
88-
labels = {"claude": "Claude models", "codex": "Codex models", "gemini": "Gemini models"}
89+
labels = {
90+
"claude": "Claude models",
91+
"codex": "Codex models",
92+
"gemini": "Gemini models",
93+
"oss": "OSS models",
94+
}
8995
for source, reason in reasons.items():
9096
consumers = ", ".join(_DISCOVERY_CONSUMERS.get(source, ()))
9197
label = labels.get(source, source)
@@ -252,19 +258,24 @@ def configure_shared_state(
252258
)
253259
want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools
254260
want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools
261+
want_oss = fetch_all or "opencode" in tools
255262

256263
claude_reason: str | None = None
257264
gemini_reason: str | None = None
258265
codex_reason: str | None = None
266+
oss_reason: str | None = None
259267
claude_models = {}
260268
gemini_models = []
261269
codex_models = []
270+
oss_models = []
262271
# UC-first, best-effort: one UC model-services call yields all families as
263272
# `system.ai.<model-name>` ids, bucketed by name. If a family comes back
264273
# empty (workspace without UC model-services, or the listing failed), fall
265274
# back to the per-family AI Gateway listing for that family only.
266275
with spinner("Fetching available models..."):
267-
ms_claude, ms_codex, ms_gemini, ms_reason = discover_model_services(workspace, token)
276+
ms_claude, ms_codex, ms_gemini, ms_oss, ms_reason = discover_model_services(
277+
workspace, token
278+
)
268279
if want_claude:
269280
claude_models, claude_reason = ms_claude, ms_reason
270281
if not claude_models:
@@ -277,11 +288,15 @@ def configure_shared_state(
277288
codex_models, codex_reason = ms_codex, ms_reason
278289
if not codex_models:
279290
codex_models, codex_reason = discover_codex_models(workspace, token)
291+
if want_oss:
292+
oss_models, oss_reason = ms_oss, ms_reason
280293
opencode_models: dict[str, list[str]] = {}
281294
if claude_models:
282295
opencode_models["anthropic"] = list(claude_models.values())
283296
if gemini_models:
284297
opencode_models["gemini"] = gemini_models
298+
if oss_models:
299+
opencode_models["oss"] = oss_models
285300

286301
# Merge into existing workspace state so prior tool configs are preserved.
287302
state = load_state()
@@ -305,6 +320,8 @@ def configure_shared_state(
305320
state["gemini_models"] = gemini_models
306321
if want_codex:
307322
state["codex_models"] = codex_models
323+
if want_oss:
324+
state["oss_models"] = oss_models
308325
if fetch_all or "opencode" in tools:
309326
state["opencode_models"] = opencode_models
310327
save_state(state)
@@ -318,6 +335,7 @@ def configure_shared_state(
318335
"claude": claude_reason,
319336
"gemini": gemini_reason,
320337
"codex": codex_reason,
338+
"oss": oss_reason,
321339
}
322340
return state
323341

src/ucode/databricks.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,23 +1193,24 @@ def list_model_services(
11931193

11941194
def discover_model_services(
11951195
workspace: str, token: str
1196-
) -> tuple[dict[str, str], list[str], list[str], str | None]:
1196+
) -> tuple[dict[str, str], list[str], list[str], list[str], str | None]:
11971197
"""Discover models via UC model-services and bucket them by family name.
11981198
1199-
Returns (claude_models, codex_models, gemini_models, reason):
1199+
Returns (claude_models, codex_models, gemini_models, oss_models, reason):
12001200
12011201
- ``claude_models`` maps ``opus``/``sonnet``/``haiku`` to the newest
12021202
matching ``system.ai.claude-*`` id (mirrors ``discover_claude_models``).
12031203
- ``codex_models`` is the list of ``system.ai.*gpt-*`` ids.
12041204
- ``gemini_models`` is the list of ``system.ai.*gemini-*`` ids, newest first.
1205+
- ``oss_models`` is the list of OSS-model ``system.ai.*`` ids.
12051206
12061207
``reason`` is None on success, else explains why nothing was found. Family
12071208
bucketing is by name substring because the model-services API does not
12081209
expose per-model API dialects.
12091210
"""
12101211
ids, reason = list_model_services(workspace, token)
12111212
if not ids:
1212-
return {}, [], [], reason
1213+
return {}, [], [], [], reason
12131214

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

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

1226-
if not (claude_models or codex_models or gemini_models):
1228+
if not (claude_models or codex_models or gemini_models or oss_models):
12271229
sample = ", ".join(ids[:5])
12281230
return (
12291231
{},
12301232
[],
12311233
[],
1234+
[],
12321235
(
12331236
"model-services returned model ids but none matched "
1234-
f"claude/gpt/gemini families (got: {sample})"
1237+
f"claude/gpt/gemini/oss families (got: {sample})"
12351238
),
12361239
)
1237-
return claude_models, codex_models, gemini_models, None
1240+
return claude_models, codex_models, gemini_models, oss_models, None
12381241

12391242

12401243
# --- MCP services (parallel to model services) -----------------------------
@@ -1879,6 +1882,7 @@ def build_opencode_base_urls(workspace: str) -> dict[str, str]:
18791882
return {
18801883
"anthropic": build_tool_base_url("claude", workspace) + "/v1",
18811884
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
1885+
"oss": f"{workspace}/ai-gateway/mlflow/v1",
18821886
}
18831887

18841888

tests/test_agent_opencode.py

Lines changed: 45 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+
"oss": f"{WS}/ai-gateway/mlflow/v1",
1718
}
1819

1920

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

52+
def test_oss_provider_added_when_models_present(self):
53+
models = {"oss": ["system.ai.kimi-k2-7-code"]}
54+
overlay, _ = opencode.render_overlay(
55+
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
56+
)
57+
assert "databricks-oss" in overlay["provider"]
58+
59+
def test_oss_provider_uses_ai_sdk_openai_package(self):
60+
models = {"oss": ["system.ai.kimi-k2-7-code"]}
61+
overlay, _ = opencode.render_overlay(
62+
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
63+
)
64+
assert overlay["provider"]["databricks-oss"]["npm"] == "@ai-sdk/openai"
65+
5166
def test_both_providers_when_both_present(self):
5267
models = {"anthropic": ["claude-sonnet"], "gemini": ["gemini-2"]}
5368
overlay, _ = opencode.render_overlay("claude-sonnet", "tok", _base_urls(), models)
@@ -70,6 +85,14 @@ def test_gemini_base_url(self):
7085
options = overlay["provider"]["databricks-google"]["options"]
7186
assert options["baseURL"] == f"{WS}/ai-gateway/gemini/v1beta"
7287

88+
def test_oss_base_url(self):
89+
models = {"oss": ["system.ai.kimi-k2-7-code"]}
90+
overlay, _ = opencode.render_overlay(
91+
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
92+
)
93+
options = overlay["provider"]["databricks-oss"]["options"]
94+
assert options["baseURL"] == f"{WS}/ai-gateway/mlflow/v1"
95+
7396
def test_token_in_api_key(self):
7497
models = {"anthropic": ["claude-sonnet"]}
7598
overlay, _ = opencode.render_overlay("claude-sonnet", "mytoken", _base_urls(), models)
@@ -134,6 +157,11 @@ def test_managed_keys_include_gemini_provider(self):
134157
_, keys = opencode.render_overlay("gemini-2", "tok", _base_urls(), models)
135158
assert ["provider", "databricks-google"] in keys
136159

160+
def test_managed_keys_include_oss_provider(self):
161+
models = {"oss": ["system.ai.kimi-k2-7-code"]}
162+
_, keys = opencode.render_overlay("system.ai.kimi-k2-7-code", "tok", _base_urls(), models)
163+
assert ["provider", "databricks-oss"] in keys
164+
137165
def test_anthropic_models_listed(self):
138166
models = {"anthropic": ["claude-sonnet", "claude-haiku"]}
139167
overlay, _ = opencode.render_overlay("claude-sonnet", "tok", _base_urls(), models)
@@ -151,6 +179,13 @@ def test_prefixes_gemini_model_with_provider_id(self):
151179
overlay, _ = opencode.render_overlay("gemini-2", "tok", _base_urls(), models)
152180
assert overlay["model"] == "databricks-google/gemini-2"
153181

182+
def test_prefixes_oss_model_with_provider_id(self):
183+
models = {"oss": ["system.ai.kimi-k2-7-code"]}
184+
overlay, _ = opencode.render_overlay(
185+
"system.ai.kimi-k2-7-code", "tok", _base_urls(), models
186+
)
187+
assert overlay["model"] == "databricks-oss/system.ai.kimi-k2-7-code"
188+
154189

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

306+
def test_falls_back_to_oss(self):
307+
state = {
308+
"opencode_models": {
309+
"anthropic": [],
310+
"gemini": [],
311+
"oss": ["system.ai.kimi-k2-7-code"],
312+
}
313+
}
314+
assert opencode.default_model(state) == "system.ai.kimi-k2-7-code"
315+
271316
def test_returns_none_when_empty(self):
272317
assert opencode.default_model({}) is None
273318
assert opencode.default_model({"opencode_models": {}}) is None

tests/test_cli.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,7 @@ def _stub_deps(monkeypatch, *, pat_token, existing_state=None):
10121012
monkeypatch.setattr(cli_mod, "find_profile_name_for_host", lambda w: None)
10131013
monkeypatch.setattr(cli_mod, "get_databricks_token", lambda w, p: "token")
10141014
monkeypatch.setattr(cli_mod, "ensure_ai_gateway_v2", lambda w, t: None)
1015-
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], None))
1015+
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], None))
10161016
monkeypatch.setattr(cli_mod, "discover_claude_models", lambda w, t: ({}, None))
10171017
monkeypatch.setattr(cli_mod, "discover_gemini_models", lambda w, t: ([], None))
10181018
monkeypatch.setattr(cli_mod, "discover_codex_models", lambda w, t: ([], None))
@@ -1084,7 +1084,13 @@ def test_uc_models_used_without_legacy_fallback(self, monkeypatch):
10841084
monkeypatch.setattr(
10851085
cli_mod,
10861086
"discover_model_services",
1087-
lambda w, t: ({"opus": "system.ai.claude-opus-4-8"}, ["system.ai.gpt-5"], [], None),
1087+
lambda w, t: (
1088+
{"opus": "system.ai.claude-opus-4-8"},
1089+
["system.ai.gpt-5"],
1090+
[],
1091+
[],
1092+
None,
1093+
),
10881094
)
10891095
legacy_called: list[str] = []
10901096
monkeypatch.setattr(
@@ -1104,7 +1110,7 @@ def test_falls_back_to_legacy_when_uc_empty(self, monkeypatch):
11041110
# No UC model-services: each family falls back to the legacy listing.
11051111
cli_mod, *_ = self._stub_deps(monkeypatch, pat_token="dapi-pat")
11061112
monkeypatch.setattr(
1107-
cli_mod, "discover_model_services", lambda w, t: ({}, [], [], "no model services")
1113+
cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], "no model services")
11081114
)
11091115
monkeypatch.setattr(
11101116
cli_mod,
@@ -1182,7 +1188,7 @@ def _stub_external_deps(monkeypatch):
11821188
monkeypatch.setattr(cli_mod, "find_profile_name_for_host", lambda w: None)
11831189
monkeypatch.setattr(cli_mod, "get_databricks_token", lambda w, p: "token")
11841190
monkeypatch.setattr(cli_mod, "ensure_ai_gateway_v2", lambda w, t: None)
1185-
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], None))
1191+
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], None))
11861192
monkeypatch.setattr(cli_mod, "discover_claude_models", lambda w, t: ({}, None))
11871193
monkeypatch.setattr(cli_mod, "discover_gemini_models", lambda w, t: ([], None))
11881194
monkeypatch.setattr(cli_mod, "discover_codex_models", lambda w, t: ([], None))

0 commit comments

Comments
 (0)