Skip to content
Draft
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
20 changes: 3 additions & 17 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from ucode.databricks import (
build_auth_shell_command,
build_tool_base_url,
claude_model_supports_1m,
get_databricks_token,
)
from ucode.launcher import exec_or_spawn
Expand Down Expand Up @@ -165,11 +166,6 @@ def _resolve_web_search_model(state: dict) -> str | None:


WEB_SEARCH_MCP_NAME = "web_search"
# Matches both the AI Gateway form (`databricks-claude-opus-4-8`) and the UC
# model-services form (`system.ai.claude-opus-4-8`).
_CLAUDE_MODEL_RE = re.compile(
r"^(?:system\.ai\.)?(?:databricks-)?claude-(opus|sonnet)-(\d+)(?:-(\d+))?(.*)$"
)

# Env keys the MLflow Stop hook reads to route traces. Written into the
# settings `env` block alongside the hook itself.
Expand Down Expand Up @@ -485,19 +481,9 @@ def render_overlay(


def _maybe_add_1m_suffix(model: str) -> str:
if model.endswith("[1m]"):
return model
match = _CLAUDE_MODEL_RE.match(model)
if not match:
if model.endswith("[1m]") or not claude_model_supports_1m(model):
return model

family, major_raw, minor_raw, _ = match.groups()
major = int(major_raw)
minor = int(minor_raw or 0)
should_suffix = (family == "opus" and (major, minor) >= (4, 6)) or (
family == "sonnet" and (major, minor) >= (4, 6)
)
return f"{model}[1m]" if should_suffix else model
return f"{model}[1m]"


def _register_web_search_mcp(workspace: str, search_model: str, profile: str | None = None) -> bool:
Expand Down
77 changes: 67 additions & 10 deletions src/ucode/agents/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import signal
import subprocess
import threading
from typing import cast

from ucode.config_io import (
APP_DIR,
Expand Down Expand Up @@ -64,17 +65,67 @@ def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -
return model


def _oss_model_overlay(model: str, ua_header: dict[str, str]) -> dict:
"""Per-model overlay for an OSS model entry.
_OSS_SAFE_LIMITS = {"context": 128_000, "output": 8_192}

All OSS models carry the User-Agent header; models with known token limits
also pin `limit` (context + output) so OpenCode clamps `max_tokens` to a
value the gateway accepts. OpenCode's schema requires both fields together,
so the limits table always supplies both."""

def _positive_int(value: object) -> int | None:
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None


def _oss_specs_by_id(raw_specs: object) -> dict[str, dict[str, object]]:
if not isinstance(raw_specs, list):
return {}
specs: dict[str, dict[str, object]] = {}
for raw_spec in raw_specs:
if not isinstance(raw_spec, dict):
continue
typed_spec = cast(dict[str, object], raw_spec)
model_id = typed_spec.get("id")
reasoning = typed_spec.get("reasoning")
context = typed_spec.get("context_window")
output = typed_spec.get("max_tokens")
valid_limits = all(
value is None or _positive_int(value) is not None for value in (context, output)
)
if (
isinstance(model_id, str)
and model_id
and isinstance(reasoning, bool)
and "context_window" in typed_spec
and "max_tokens" in typed_spec
and valid_limits
and model_id not in specs
):
specs[model_id] = typed_spec
return specs


def _oss_model_overlay(
model: str, ua_header: dict[str, str], spec: dict[str, object] | None = None
) -> dict:
"""Per-model OSS overlay from discovered or static capabilities.

OpenCode requires context and output limits together. Every discovered spec
therefore receives a complete conservative pair. Missing specs retain
static GLM/Kimi/DeepSeek metadata, and unknown no-spec models remain uncapped.
"""
overlay: dict = {"headers": ua_header}
limits = model_token_limits(model)
if limits is not None:
overlay["limit"] = limits
static_limits = model_token_limits(model)
context = _positive_int(spec.get("context_window")) if isinstance(spec, dict) else None
output = _positive_int(spec.get("max_tokens")) if isinstance(spec, dict) else None
if isinstance(spec, dict):
overlay["limit"] = {
"context": context
or (static_limits.get("context") if static_limits else _OSS_SAFE_LIMITS["context"]),
"output": output
or (static_limits.get("output") if static_limits else _OSS_SAFE_LIMITS["output"]),
}
elif static_limits is not None:
overlay["limit"] = static_limits

reasoning = spec.get("reasoning") if isinstance(spec, dict) else None
if isinstance(reasoning, bool):
overlay["reasoning"] = reasoning
return overlay


Expand All @@ -83,6 +134,7 @@ def render_overlay(
token: str,
opencode_base_urls: dict[str, str],
opencode_models: dict[str, list[str]],
oss_specs: list[dict] | None = None,
) -> tuple[dict, list[list[str]]]:
"""Return (overlay, managed_key_paths) for opencode.json."""
auth_headers = {"Authorization": f"Bearer {token}"}
Expand Down Expand Up @@ -132,14 +184,18 @@ def render_overlay(
}
keys.append(["provider", "databricks-google"])
if oss_models:
specs_by_id = _oss_specs_by_id(oss_specs)
providers["databricks-oss"] = {
"npm": "@ai-sdk/openai",
"options": {
"baseURL": opencode_base_urls["oss"],
"apiKey": token,
"headers": auth_headers,
# OpenCode otherwise adds `prompt_cache_key`, which the MLflow
# chat-completions gateway rejects as an unknown field.
"setCacheKey": False,
},
"models": {m: _oss_model_overlay(m, ua_header) for m in oss_models},
"models": {m: _oss_model_overlay(m, ua_header, specs_by_id.get(m)) for m in oss_models},
}
keys.append(["provider", "databricks-oss"])

Expand Down Expand Up @@ -169,6 +225,7 @@ def write_tool_config(
token,
opencode_base_urls,
state.get("opencode_models") or {},
state.get("oss_model_specs") or [],
)
existing = read_json_safe(OPENCODE_CONFIG_PATH)
providers = existing.get("provider")
Expand Down
Loading
Loading