Skip to content

Commit a0a0d1c

Browse files
Add Kimi model support for opencode (#178)
Add OSS model support for OpenCode Co-authored-by: Lilly <lilly.gray@tecton.ai>
1 parent 47fa18a commit a0a0d1c

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
@@ -344,7 +344,7 @@ def check_gateway_endpoint(state: dict, tool: str) -> bool:
344344

345345
_TOOL_DISCOVERY_SOURCES: dict[str, tuple[str, ...]] = {
346346
"claude": ("claude",),
347-
"opencode": ("claude", "gemini"),
347+
"opencode": ("claude", "gemini", "oss"),
348348
"codex": ("codex",),
349349
"gemini": ("gemini",),
350350
"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
@@ -90,6 +90,7 @@
9090
"claude": ("claude", "opencode", "copilot", "pi"),
9191
"codex": ("codex", "copilot", "pi"),
9292
"gemini": ("gemini", "opencode", "pi"),
93+
"oss": ("opencode",),
9394
}
9495

9596

@@ -99,7 +100,12 @@ def _print_discovery_diagnostics(state: dict) -> None:
99100
reasons = state.get("_discovery_reasons") or {}
100101
if not reasons:
101102
return
102-
labels = {"claude": "Claude models", "codex": "Codex models", "gemini": "Gemini models"}
103+
labels = {
104+
"claude": "Claude models",
105+
"codex": "Codex models",
106+
"gemini": "Gemini models",
107+
"oss": "OSS models",
108+
}
103109
for source, reason in reasons.items():
104110
consumers = ", ".join(_DISCOVERY_CONSUMERS.get(source, ()))
105111
label = labels.get(source, source)
@@ -267,13 +273,16 @@ def configure_shared_state(
267273
)
268274
want_gemini = fetch_all or "gemini" in tools or "opencode" in tools or "pi" in tools
269275
want_codex = fetch_all or "codex" in tools or "copilot" in tools or "pi" in tools
276+
want_oss = fetch_all or "opencode" in tools
270277

271278
claude_reason: str | None = None
272279
gemini_reason: str | None = None
273280
codex_reason: str | None = None
281+
oss_reason: str | None = None
274282
claude_models = {}
275283
gemini_models = []
276284
codex_models = []
285+
oss_models = []
277286
web_search_model: str | None = None
278287
if skip_model_discovery:
279288
# Provider mode: the agent routes through a Model Provider Service and
@@ -291,7 +300,9 @@ def configure_shared_state(
291300
# empty (workspace without UC model-services, or the listing failed), fall
292301
# back to the per-family AI Gateway listing for that family only.
293302
with spinner("Fetching available models..."):
294-
ms_claude, ms_codex, ms_gemini, ms_reason = discover_model_services(workspace, token)
303+
ms_claude, ms_codex, ms_gemini, ms_oss, ms_reason = discover_model_services(
304+
workspace, token
305+
)
295306
if want_claude:
296307
claude_models, claude_reason = ms_claude, ms_reason
297308
if not claude_models:
@@ -304,11 +315,15 @@ def configure_shared_state(
304315
codex_models, codex_reason = ms_codex, ms_reason
305316
if not codex_models:
306317
codex_models, codex_reason = discover_codex_models(workspace, token)
318+
if want_oss:
319+
oss_models, oss_reason = ms_oss, ms_reason
307320
opencode_models: dict[str, list[str]] = {}
308321
if claude_models:
309322
opencode_models["anthropic"] = list(claude_models.values())
310323
if gemini_models:
311324
opencode_models["gemini"] = gemini_models
325+
if oss_models:
326+
opencode_models["oss"] = oss_models
312327

313328
# Merge into existing workspace state so prior tool configs are preserved.
314329
state = load_state()
@@ -339,6 +354,8 @@ def configure_shared_state(
339354
state["gemini_models"] = gemini_models
340355
if want_codex:
341356
state["codex_models"] = codex_models
357+
if want_oss:
358+
state["oss_models"] = oss_models
342359
if fetch_all or "opencode" in tools:
343360
state["opencode_models"] = opencode_models
344361
save_state(state)
@@ -352,6 +369,7 @@ def configure_shared_state(
352369
"claude": claude_reason,
353370
"gemini": gemini_reason,
354371
"codex": codex_reason,
372+
"oss": oss_reason,
355373
}
356374
return state
357375

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) -----------------------------
@@ -2081,6 +2084,7 @@ def build_opencode_base_urls(workspace: str) -> dict[str, str]:
20812084
return {
20822085
"anthropic": build_tool_base_url("claude", workspace) + "/v1",
20832086
"gemini": build_tool_base_url("gemini", workspace) + "/v1beta",
2087+
"oss": f"{workspace}/ai-gateway/mlflow/v1",
20842088
}
20852089

20862090

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
@@ -1045,7 +1045,7 @@ def _stub_deps(monkeypatch, *, pat_token, existing_state=None):
10451045
monkeypatch.setattr(cli_mod, "find_profile_name_for_host", lambda w: None)
10461046
monkeypatch.setattr(cli_mod, "get_databricks_token", lambda w, p: "token")
10471047
monkeypatch.setattr(cli_mod, "ensure_ai_gateway_v2", lambda w, t: None)
1048-
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], None))
1048+
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], None))
10491049
monkeypatch.setattr(cli_mod, "discover_claude_models", lambda w, t: ({}, None))
10501050
monkeypatch.setattr(cli_mod, "discover_gemini_models", lambda w, t: ([], None))
10511051
monkeypatch.setattr(cli_mod, "discover_codex_models", lambda w, t: ([], None))
@@ -1117,7 +1117,13 @@ def test_uc_models_used_without_legacy_fallback(self, monkeypatch):
11171117
monkeypatch.setattr(
11181118
cli_mod,
11191119
"discover_model_services",
1120-
lambda w, t: ({"opus": "system.ai.claude-opus-4-8"}, ["system.ai.gpt-5"], [], None),
1120+
lambda w, t: (
1121+
{"opus": "system.ai.claude-opus-4-8"},
1122+
["system.ai.gpt-5"],
1123+
[],
1124+
[],
1125+
None,
1126+
),
11211127
)
11221128
legacy_called: list[str] = []
11231129
monkeypatch.setattr(
@@ -1137,7 +1143,7 @@ def test_falls_back_to_legacy_when_uc_empty(self, monkeypatch):
11371143
# No UC model-services: each family falls back to the legacy listing.
11381144
cli_mod, *_ = self._stub_deps(monkeypatch, pat_token="dapi-pat")
11391145
monkeypatch.setattr(
1140-
cli_mod, "discover_model_services", lambda w, t: ({}, [], [], "no model services")
1146+
cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], "no model services")
11411147
)
11421148
monkeypatch.setattr(
11431149
cli_mod,
@@ -1215,7 +1221,7 @@ def _stub_external_deps(monkeypatch):
12151221
monkeypatch.setattr(cli_mod, "find_profile_name_for_host", lambda w: None)
12161222
monkeypatch.setattr(cli_mod, "get_databricks_token", lambda w, p: "token")
12171223
monkeypatch.setattr(cli_mod, "ensure_ai_gateway_v2", lambda w, t: None)
1218-
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], None))
1224+
monkeypatch.setattr(cli_mod, "discover_model_services", lambda w, t: ({}, [], [], [], None))
12191225
monkeypatch.setattr(cli_mod, "discover_claude_models", lambda w, t: ({}, None))
12201226
monkeypatch.setattr(cli_mod, "discover_gemini_models", lambda w, t: ([], None))
12211227
monkeypatch.setattr(cli_mod, "discover_codex_models", lambda w, t: ([], None))

0 commit comments

Comments
 (0)