Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
@anthropic-ai/claude-code
@openai/codex
@google/gemini-cli
opencode-ai
opencode-ai@1
@github/copilot
@earendil-works/pi-coding-agent
- run: uv lock
Expand Down
180 changes: 148 additions & 32 deletions src/ucode/agents/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

from __future__ import annotations

import json
import os
import re
import signal
import subprocess
import threading

from ucode.agent_updates import available_npm_package_update
from ucode.config_io import (
Expand All @@ -15,9 +16,10 @@
deep_merge_dict,
read_json_safe,
write_json_file,
write_text_file,
)
from ucode.databricks import (
TOKEN_REFRESH_INTERVAL_SECONDS,
build_auth_token_argv,
build_opencode_base_urls,
get_databricks_token,
model_token_limits,
Expand All @@ -29,10 +31,16 @@
OPENCODE_CONFIG_DIR = OPENCODE_XDG_CONFIG_HOME / "opencode"
OPENCODE_CONFIG_PATH = OPENCODE_CONFIG_DIR / "opencode.json"
OPENCODE_BACKUP_PATH = APP_DIR / "opencode-config.backup.json"
OPENCODE_AUTH_PLUGIN_PATH = OPENCODE_CONFIG_DIR / "plugin" / "ucode-auth.js"
OPENCODE_NPM_PACKAGE = "opencode-ai"
MINIMUM_OPENCODE_VERSION = (1, 0, 220)
MINIMUM_OPENCODE_VERSION_TEXT = "1.0.220"

SPEC: ToolSpec = {
"binary": "opencode",
"package": "opencode-ai",
# npm's `latest` tag currently points at an old 0.0.0 beta. Stay on the
# stable v1 line for both fresh installs and updates.
"package": f"{OPENCODE_NPM_PACKAGE}@1",
"display": "OpenCode",
"config_path": OPENCODE_CONFIG_PATH,
"backup_path": OPENCODE_BACKUP_PATH,
Expand All @@ -44,9 +52,139 @@
["provider", "databricks-oss"],
]

_AUTH_PLUGIN_TEMPLATE = """// Generated by ucode. Keep Databricks auth fresh for model requests.
import { execFile } from "node:child_process"
import { promisify } from "node:util"

const DATABRICKS_PROVIDERS = new Set([
"databricks-anthropic",
"databricks-google",
"databricks-oss",
])
const AUTH_COMMAND = __AUTH_COMMAND__
const REFRESH_SKEW_MS = 120_000
const run = promisify(execFile)

let accessToken
let expiresAt = 0
let refreshPromise

function cacheToken(value) {
accessToken = value
expiresAt = Infinity
try {
const claims = JSON.parse(Buffer.from(value.split(".")[1], "base64url").toString())
if (typeof claims.exp === "number") expiresAt = claims.exp * 1000
} catch {}
}

async function mintToken() {
try {
const { stdout } = await run(AUTH_COMMAND[0], AUTH_COMMAND.slice(1), { encoding: "utf8" })
const token = stdout.trim()
if (!token) throw new Error("returned an empty token")
cacheToken(token)
} catch (error) {
const detail = String(error.stderr || error.message || "").trim()
throw new Error("ucode auth-token failed" + (detail ? ": " + detail : ""))
}
}

function refreshToken() {
if (!refreshPromise) refreshPromise = mintToken().finally(() => { refreshPromise = undefined })
return refreshPromise
}

function requestWithToken(input, init, token) {
const headers = new Headers(input instanceof Request ? input.headers : undefined)
new Headers(init?.headers).forEach((value, key) => headers.set(key, value))
headers.set("Authorization", "Bearer " + token)
return { ...init, headers }
}

async function databricksFetch(input, init) {
if (!accessToken || expiresAt <= Date.now() + REFRESH_SKEW_MS) await refreshToken()
const attemptedToken = accessToken
const response = await fetch(input, requestWithToken(input, init, attemptedToken))
if (response.status !== 401) return response

// Another concurrent request may already have refreshed this token.
if (accessToken === attemptedToken) await refreshToken()
return fetch(input, requestWithToken(input, init, accessToken))
}

export const UcodeDatabricksAuth = async () => ({
config: async (config) => {
for (const providerID of DATABRICKS_PROVIDERS) {
const options = config.provider?.[providerID]?.options
if (!options) continue
if (!accessToken && typeof options.apiKey === "string") cacheToken(options.apiKey)
options.fetch = databricksFetch
}
},
})
"""


def _parse_version(value: str) -> tuple[int, int, int] | None:
match = re.search(r"(\d+)\.(\d+)\.(\d+)", value)
if match is None:
return None
major, minor, patch = match.groups()
return int(major), int(minor), int(patch)


def _minimum_version_message() -> str | None:
installed = agent_version(SPEC["binary"])
parsed = _parse_version(installed)
if parsed is None or parsed >= MINIMUM_OPENCODE_VERSION:
return None
return (
f"OpenCode {installed} is too old. ucode requires OpenCode "
f"{MINIMUM_OPENCODE_VERSION_TEXT} or newer for renewable Databricks authentication."
)


def required_update_message() -> str | None:
return _minimum_version_message()


def minimum_version_error() -> str | None:
message = _minimum_version_message()
if message is None:
return None
return f"{message} Update it with `npm install -g {SPEC['package']}`."


def is_update_available() -> tuple[str, str] | None:
return available_npm_package_update(SPEC["package"])
"""Offer only stable OpenCode v1 updates, never npm's beta `latest` tag."""
update = available_npm_package_update(OPENCODE_NPM_PACKAGE)
if update is None:
return None
_, target = update
parsed = _parse_version(target)
if "-" in target or parsed is None or parsed[0] != 1:
return None
return update


def render_auth_plugin(state: dict) -> str:
"""Render the local OpenCode plugin that refreshes Databricks auth on demand."""
argv = build_auth_token_argv(
state["workspace"],
state.get("profile"),
use_pat=bool(state.get("use_pat")),
)
# A 401 must not return the same still-unexpired cached credential.
argv.append("--force-refresh")
return _AUTH_PLUGIN_TEMPLATE.replace("__AUTH_COMMAND__", json.dumps(argv))


def write_auth_plugin(state: dict) -> None:
"""Install the auto-discovered hook in ucode's isolated OpenCode config."""
# Derive the path so tests that redirect OPENCODE_CONFIG_PATH stay isolated.
path = OPENCODE_CONFIG_PATH.parent / "plugin" / OPENCODE_AUTH_PLUGIN_PATH.name
write_text_file(path, render_auth_plugin(state))


def _resolve_model_selector(model: str, opencode_models: dict[str, list[str]]) -> str:
Expand Down Expand Up @@ -158,14 +296,10 @@ def write_tool_config(
state: dict,
model: str,
token: str | None = None,
*,
force_refresh: bool = False,
) -> tuple[dict, str]:
backup_existing_file(OPENCODE_CONFIG_PATH, OPENCODE_BACKUP_PATH)
if token is None:
token = get_databricks_token(
state["workspace"], state.get("profile"), force_refresh=force_refresh
)
token = get_databricks_token(state["workspace"], state.get("profile"))
opencode_base_urls = state.get("base_urls", {}).get("opencode") or build_opencode_base_urls(
state["workspace"]
)
Expand All @@ -176,6 +310,7 @@ def write_tool_config(
state.get("opencode_models") or {},
)
existing = read_json_safe(OPENCODE_CONFIG_PATH)
write_auth_plugin(state)
providers = existing.get("provider")
if isinstance(providers, dict):
for stale in (
Expand Down Expand Up @@ -241,22 +376,14 @@ def default_model(state: dict) -> str | None:
return oss[0] if oss else None


def _refresh_token_once(state: dict, *, force_refresh: bool = False) -> str:
def _configure_launch(state: dict) -> str:
model = default_model(state)
if not model:
raise RuntimeError("No OpenCode model is configured.")
_, token = write_tool_config(state, model, force_refresh=force_refresh)
_, token = write_tool_config(state, model)
return token


def _refresh_forever(state: dict, stop_event: threading.Event) -> None:
while not stop_event.wait(TOKEN_REFRESH_INTERVAL_SECONDS):
try:
_refresh_token_once(state, force_refresh=True)
except RuntimeError:
continue


def build_runtime_env(token: str, state: dict | None = None) -> dict[str, str]:
env = os.environ.copy()
env["OAUTH_TOKEN"] = token
Expand All @@ -265,27 +392,16 @@ def build_runtime_env(token: str, state: dict | None = None) -> dict[str, str]:


def launch(state: dict, tool_args: list[str]) -> None:
"""Launch opencode with background token refresh (same pattern as Gemini)."""
token = _refresh_token_once(state)
"""Launch OpenCode with on-demand token refresh from its local plugin."""
token = _configure_launch(state)
env = build_runtime_env(token, state)

stop_event = threading.Event()
refresher = threading.Thread(
target=_refresh_forever,
args=(state, stop_event),
daemon=True,
)
refresher.start()

proc = subprocess.Popen([SPEC["binary"], *tool_args], env=env)
try:
returncode = proc.wait()
except KeyboardInterrupt:
proc.send_signal(signal.SIGINT)
returncode = proc.wait()
finally:
stop_event.set()
refresher.join(timeout=1)

raise SystemExit(returncode)

Expand Down
6 changes: 5 additions & 1 deletion src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,10 @@ def auth_token_cmd(
use_pat: Annotated[
bool, typer.Option("--use-pat", help="Read the profile's static PAT instead of OAuth.")
] = False,
force_refresh: Annotated[
bool,
typer.Option("--force-refresh", help="Force the Databricks CLI to mint a new token."),
] = False,
) -> None:
"""Print a Databricks bearer token to stdout, then exit.

Expand Down Expand Up @@ -1210,7 +1214,7 @@ def auth_token_cmd(
)
raise typer.Exit(1)
try:
token = get_databricks_token(workspace, profile)
token = get_databricks_token(workspace, profile, force_refresh=force_refresh)
except RuntimeError as exc:
print_err(str(exc))
raise typer.Exit(1) from None
Expand Down
80 changes: 79 additions & 1 deletion tests/test_agent_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def test_binary(self):
assert opencode.SPEC["binary"] == "opencode"

def test_package(self):
assert opencode.SPEC["package"] == "opencode-ai"
assert opencode.SPEC["package"] == "opencode-ai@1"

def test_display(self):
assert opencode.SPEC["display"] == "OpenCode"
Expand All @@ -33,6 +33,80 @@ def test_config_path_is_under_ucode_xdg_home(self):
opencode.OPENCODE_XDG_CONFIG_HOME / "opencode" / "opencode.json"
)

def test_update_check_uses_latest_stable_v1(self, monkeypatch):
monkeypatch.setattr(
opencode,
"available_npm_package_update",
lambda _package: ("1.18.15", "1.18.16"),
)

assert opencode.is_update_available() == ("1.18.15", "1.18.16")

def test_update_check_ignores_npm_beta(self, monkeypatch):
monkeypatch.setattr(
opencode,
"available_npm_package_update",
lambda _package: ("1.18.16", "0.0.0-beta-202605152242"),
)

assert opencode.is_update_available() is None

def test_requires_version_with_custom_provider_fetch(self, monkeypatch):
monkeypatch.setattr(opencode, "agent_version", lambda _binary: "1.0.219")

message = opencode.minimum_version_error()

assert message is not None
assert "requires OpenCode 1.0.220 or newer" in message
assert "npm install -g opencode-ai@1" in message

def test_supported_version_needs_no_required_update(self, monkeypatch):
monkeypatch.setattr(opencode, "agent_version", lambda _binary: "1.0.220")

assert opencode.required_update_message() is None
assert opencode.minimum_version_error() is None


class TestAuthPlugin:
def test_calls_cross_platform_auth_token_helper_only_when_refreshing(self, monkeypatch):
monkeypatch.setattr(
opencode,
"build_auth_token_argv",
lambda workspace, profile, use_pat=False: [
"/opt/ucode",
"auth-token",
"--host",
workspace,
"--profile",
profile,
],
)

plugin = opencode.render_auth_plugin({"workspace": WS, "profile": "my profile"})

assert (
'const AUTH_COMMAND = ["/opt/ucode", "auth-token", "--host", '
f'"{WS}", "--profile", "my profile", "--force-refresh"]'
) in plugin
assert "run(AUTH_COMMAND[0], AUTH_COMMAND.slice(1)" in plugin
assert '"chat.headers"' not in plugin

def test_installs_cached_refreshing_fetch_on_databricks_providers(self):
plugin = opencode.render_auth_plugin({"workspace": WS})

assert "config: async (config)" in plugin
assert "options.fetch = databricksFetch" in plugin
assert "expiresAt <= Date.now() + REFRESH_SKEW_MS" in plugin
assert 'headers.set("Authorization", "Bearer " + token)' in plugin
assert "if (response.status !== 401) return response" in plugin
assert "return fetch(input, requestWithToken(input, init, accessToken))" in plugin

def test_refresh_is_single_flighted(self):
plugin = opencode.render_auth_plugin({"workspace": WS})

assert "if (!refreshPromise)" in plugin
assert "mintToken().finally(() => { refreshPromise = undefined })" in plugin


class TestRenderOverlay:
def test_sets_model(self):
Expand Down Expand Up @@ -397,6 +471,10 @@ def test_stale_providers_removed_before_merge(self, tmp_path, monkeypatch):
assert providers.get("databricks-anthropic") != {"old": True}
# unmanaged provider entry survives
assert providers.get("other-provider") == {"keep": True}
# OpenCode 1.0.0 discovers `plugin/`; plural `plugins/` came later.
plugin = config_file.parent / "plugin" / opencode.OPENCODE_AUTH_PLUGIN_PATH.name
assert plugin.exists()
assert "options.fetch = databricksFetch" in plugin.read_text()

def test_config_written_with_correct_model(self, tmp_path, monkeypatch):
import ucode.agents.opencode as oc_mod
Expand Down
Loading
Loading