Skip to content
Merged
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
92 changes: 92 additions & 0 deletions src/pythinker_code/auth/opencode_go.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
OPENCODE_GO_ANTHROPIC_BASE_URL = "https://opencode.ai/zen/go"
OPENCODE_GO_OPENAI_PROVIDER_KEY = "managed:opencode-go-openai"
OPENCODE_GO_ANTHROPIC_PROVIDER_KEY = "managed:opencode-go-anthropic"
OPENCODE_GO_PROVIDER_KEYS = (
OPENCODE_GO_OPENAI_PROVIDER_KEY,
OPENCODE_GO_ANTHROPIC_PROVIDER_KEY,
)
OPENCODE_GO_DEFAULT_MODEL_ALIAS = "opencode-go/kimi-k2.6"
OPENCODE_GO_DEFAULT_CONTEXT = 262_000

Expand Down Expand Up @@ -297,6 +301,94 @@ async def _discover_opencode_go_models(api_key: str) -> tuple[OpenCodeGoModel, .
return _build_models(model_ids, metadata)


def _opencode_go_api_key(config: Config) -> str | None:
"""The saved OpenCode Go key (both providers share it), or None if absent."""
for provider_key in OPENCODE_GO_PROVIDER_KEYS:
provider = config.providers.get(provider_key)
if provider is None:
continue
value = provider.api_key.get_secret_value().strip()
if value:
return value
return None


def apply_opencode_go_models(config: Config, models: tuple[OpenCodeGoModel, ...]) -> bool:
"""Upsert discovered OpenCode Go models and prune ones no longer offered,
across both shape providers.

Unlike ``_apply_opencode_go_config`` (the login path), this preserves the
user's ``default_model`` and ``default_thinking`` — it only reassigns the
default when the currently selected model was pruned. This mirrors
``platforms._apply_models`` for the two-provider OpenCode Go split, so the
background refresh can keep the catalog current without resetting choices.

Returns True if ``config.models`` (or the default) changed.
"""
changed = False
aliases: list[str] = []
for model in models:
alias = model.alias
aliases.append(alias)
existing = config.models.get(alias)
if existing is None:
config.models[alias] = LLMModel(
provider=model.provider_key,
model=model.model_id,
max_context_size=model.max_context_size,
display_name=model.display_name,
)
changed = True
continue
if existing.provider != model.provider_key:
existing.provider = model.provider_key
changed = True
if existing.model != model.model_id:
existing.model = model.model_id
changed = True
if existing.max_context_size != model.max_context_size:
existing.max_context_size = model.max_context_size
changed = True
if existing.display_name != model.display_name:
existing.display_name = model.display_name
changed = True

alias_set = set(aliases)
removed_default = False
for alias, model_cfg in list(config.models.items()):
if model_cfg.provider not in OPENCODE_GO_PROVIDER_KEYS:
continue
if alias in alias_set:
continue
del config.models[alias]
if config.default_model == alias:
removed_default = True
changed = True

if removed_default:
config.default_model = aliases[0] if aliases else next(iter(config.models), "")
changed = True
elif config.default_model and config.default_model not in config.models:
config.default_model = next(iter(config.models), "")
changed = True
return changed


async def refresh_opencode_go_models(config: Config) -> tuple[OpenCodeGoModel, ...] | None:
"""Re-discover the live OpenCode Go model list for the startup managed-model
refresh, reusing the saved API key.

Returns the discovered models to apply, or None when OpenCode Go isn't
configured or discovery yields nothing (so callers leave the saved list
untouched). Network/HTTP errors propagate to the caller.
"""
api_key = _opencode_go_api_key(config)
if api_key is None:
return None
discovered = await _discover_opencode_go_models(api_key)
return discovered or None


async def login_opencode_go_api_key(
config: Config, api_key: str | None = None
) -> AsyncIterator[OAuthEvent]:
Expand Down
22 changes: 22 additions & 0 deletions src/pythinker_code/auth/platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ async def refresh_managed_models(config: Config) -> bool:
if not config.is_from_default_location:
return False

from pythinker_code.auth.opencode_go import (
OPENCODE_GO_PROVIDER_KEYS,
OpenCodeGoModel,
apply_opencode_go_models,
refresh_opencode_go_models,
)

managed_providers = {
key: provider for key, provider in config.providers.items() if is_managed_provider_key(key)
}
Expand All @@ -222,6 +229,11 @@ async def refresh_managed_models(config: Config) -> bool:
updates: list[tuple[str, str, list[ModelInfo]]] = []
oauth_manager = None
for provider_key, provider in managed_providers.items():
# OpenCode Go uses a two-shape (OpenAI- + Anthropic-compatible) provider
# split that the generic single-provider path below can't express, so it
# is refreshed via its own discovery after this loop.
if provider_key in OPENCODE_GO_PROVIDER_KEYS:
continue
platform_id = parse_managed_provider_key(provider_key)
if not platform_id:
continue
Expand Down Expand Up @@ -341,12 +353,22 @@ async def refresh_managed_models(config: Config) -> bool:
if _apply_models(config, provider_key, platform_id, models):
changed = True

opencode_go_models: tuple[OpenCodeGoModel, ...] | None = None
try:
opencode_go_models = await refresh_opencode_go_models(config)
except (aiohttp.ClientError, TimeoutError, ValueError) as exc:
logger.warning("Failed to refresh OpenCode Go models: {error}", error=exc)
if opencode_go_models and apply_opencode_go_models(config, opencode_go_models):
changed = True

if changed:
config_for_save = load_config()
save_changed = False
for provider_key, platform_id, models in updates:
if _apply_models(config_for_save, provider_key, platform_id, models):
save_changed = True
if opencode_go_models and apply_opencode_go_models(config_for_save, opencode_go_models):
save_changed = True
if save_changed:
save_config(config_for_save)
return changed
Expand Down
156 changes: 155 additions & 1 deletion tests/auth/test_opencode_go_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pydantic import SecretStr
from yarl import URL

from pythinker_code.config import Config
from pythinker_code.config import Config, LLMModel, LLMProvider


def _request_info(url: str) -> aiohttp.RequestInfo:
Expand Down Expand Up @@ -446,3 +446,157 @@ async def test_logout_opencode_go_rejects_non_default_config_location():
# No mutation must have occurred.
assert config.providers == {}
assert config.models == {}


# ── refresh-on-update: apply_opencode_go_models / refresh_opencode_go_models ──


def _stale_opencode_go_config() -> Config:
"""A config resembling an OpenCode Go login from an older binary: Qwen on the
wrong (OpenAI) provider, no qwen3.7-max, kimi as the default, and the user's
own thinking preference set to True."""
from pythinker_code.auth.opencode_go import (
OPENCODE_GO_OPENAI_PROVIDER_KEY,
OpenCodeGoModel,
_apply_opencode_go_config,
)

stale_models = (
OpenCodeGoModel("kimi-k2.6", "Kimi K2.6", OPENCODE_GO_OPENAI_PROVIDER_KEY),
# Pre-fix login put Qwen on the OpenAI-shaped provider.
OpenCodeGoModel("qwen3.5-plus", "Qwen3.5 Plus", OPENCODE_GO_OPENAI_PROVIDER_KEY, 262_000),
)
config = Config(is_from_default_location=True)
_apply_opencode_go_config(config, SecretStr("ocgo-test"), models=stale_models)
# User's own preferences that a refresh must not clobber.
config.default_model = "opencode-go/kimi-k2.6"
config.default_thinking = True
return config


def test_apply_opencode_go_models_adds_new_and_corrects_shape_preserving_user_prefs():
from pythinker_code.auth.opencode_go import (
OPENCODE_GO_ANTHROPIC_PROVIDER_KEY,
OPENCODE_GO_OPENAI_PROVIDER_KEY,
OpenCodeGoModel,
apply_opencode_go_models,
)

config = _stale_opencode_go_config()
assert "opencode-go/qwen3.7-max" not in config.models

discovered = (
OpenCodeGoModel("kimi-k2.6", "Kimi K2.6", OPENCODE_GO_OPENAI_PROVIDER_KEY),
OpenCodeGoModel(
"qwen3.5-plus", "Qwen3.5 Plus", OPENCODE_GO_ANTHROPIC_PROVIDER_KEY, 262_000
),
OpenCodeGoModel(
"qwen3.7-max", "Qwen3.7 Max", OPENCODE_GO_ANTHROPIC_PROVIDER_KEY, 1_000_000
),
)

changed = apply_opencode_go_models(config, discovered)

assert changed is True
# New model now present on the Anthropic-shaped provider.
assert config.models["opencode-go/qwen3.7-max"].provider == OPENCODE_GO_ANTHROPIC_PROVIDER_KEY
assert config.models["opencode-go/qwen3.7-max"].max_context_size == 1_000_000
# Existing Qwen corrected from OpenAI → Anthropic shape.
assert config.models["opencode-go/qwen3.5-plus"].provider == OPENCODE_GO_ANTHROPIC_PROVIDER_KEY
# User preferences untouched.
assert config.default_model == "opencode-go/kimi-k2.6"
assert config.default_thinking is True


def test_apply_opencode_go_models_prunes_stale_only_and_reassigns_default_when_removed():
from pythinker_code.auth.opencode_go import (
OPENCODE_GO_ANTHROPIC_PROVIDER_KEY,
OPENCODE_GO_OPENAI_PROVIDER_KEY,
OpenCodeGoModel,
apply_opencode_go_models,
)

config = _stale_opencode_go_config()
# A non-OpenCode-Go model the refresh must never touch.
config.providers["managed:other"] = LLMProvider(
type="openai_legacy", base_url="https://x/v1", api_key=SecretStr("k")
)
config.models["other/keep-me"] = LLMModel(
provider="managed:other", model="keep-me", max_context_size=100_000
)
# Default points at a model that discovery will no longer return.
config.default_model = "opencode-go/qwen3.5-plus"

discovered = (
OpenCodeGoModel("kimi-k2.6", "Kimi K2.6", OPENCODE_GO_OPENAI_PROVIDER_KEY),
OpenCodeGoModel(
"qwen3.7-max", "Qwen3.7 Max", OPENCODE_GO_ANTHROPIC_PROVIDER_KEY, 1_000_000
),
)

changed = apply_opencode_go_models(config, discovered)

assert changed is True
# Stale OpenCode Go model pruned.
assert "opencode-go/qwen3.5-plus" not in config.models
# Unrelated provider's model preserved.
assert "other/keep-me" in config.models
# Default was pruned → reassigned to a still-present OpenCode Go model.
assert config.default_model in {"opencode-go/kimi-k2.6", "opencode-go/qwen3.7-max"}


def test_apply_opencode_go_models_no_change_returns_false():
from pythinker_code.auth.opencode_go import (
OPENCODE_GO_ANTHROPIC_PROVIDER_KEY,
OPENCODE_GO_OPENAI_PROVIDER_KEY,
OpenCodeGoModel,
apply_opencode_go_models,
)

config = Config(is_from_default_location=True)
from pythinker_code.auth.opencode_go import _apply_opencode_go_config

current = (
OpenCodeGoModel("kimi-k2.6", "Kimi K2.6", OPENCODE_GO_OPENAI_PROVIDER_KEY),
OpenCodeGoModel(
"qwen3.7-max", "Qwen3.7 Max", OPENCODE_GO_ANTHROPIC_PROVIDER_KEY, 1_000_000
),
)
_apply_opencode_go_config(config, SecretStr("ocgo-test"), models=current)

# Applying the identical discovered set is a no-op.
assert apply_opencode_go_models(config, current) is False


@pytest.mark.asyncio
async def test_refresh_opencode_go_models_returns_none_when_not_configured():
from pythinker_code.auth.opencode_go import refresh_opencode_go_models

config = Config(is_from_default_location=True)
assert await refresh_opencode_go_models(config) is None


@pytest.mark.asyncio
async def test_refresh_opencode_go_models_discovers_using_saved_key(monkeypatch):
from pythinker_code.auth.opencode_go import (
OPENCODE_GO_OPENAI_PROVIDER_KEY,
OpenCodeGoModel,
refresh_opencode_go_models,
)

config = _stale_opencode_go_config()
seen_keys: list[str] = []

async def fake_discover(api_key):
seen_keys.append(api_key)
return (OpenCodeGoModel("kimi-k2.6", "Kimi K2.6", OPENCODE_GO_OPENAI_PROVIDER_KEY),)

monkeypatch.setattr(
"pythinker_code.auth.opencode_go._discover_opencode_go_models", fake_discover
)

result = await refresh_opencode_go_models(config)

assert seen_keys == ["ocgo-test"]
assert result is not None
assert result[0].model_id == "kimi-k2.6"
Loading
Loading