diff --git a/src/pythinker_code/auth/opencode_go.py b/src/pythinker_code/auth/opencode_go.py index 50bdfcf6..813f5282 100644 --- a/src/pythinker_code/auth/opencode_go.py +++ b/src/pythinker_code/auth/opencode_go.py @@ -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 @@ -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]: diff --git a/src/pythinker_code/auth/platforms.py b/src/pythinker_code/auth/platforms.py index cedc5b0c..f7f1174f 100644 --- a/src/pythinker_code/auth/platforms.py +++ b/src/pythinker_code/auth/platforms.py @@ -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) } @@ -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 @@ -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 diff --git a/tests/auth/test_opencode_go_auth.py b/tests/auth/test_opencode_go_auth.py index b8c2a3e6..7b93d87b 100644 --- a/tests/auth/test_opencode_go_auth.py +++ b/tests/auth/test_opencode_go_auth.py @@ -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: @@ -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" diff --git a/tests/auth/test_platforms.py b/tests/auth/test_platforms.py index a95b568e..c896f9d5 100644 --- a/tests/auth/test_platforms.py +++ b/tests/auth/test_platforms.py @@ -584,3 +584,161 @@ async def _should_not_be_called(*args, **kwargs): assert config.models["lm-studio/qwen"].max_context_size == 262144 assert config.models["lm-studio/qwen"].display_name == "qwen3 (Q4_K_M)" assert config.models["ollama/llama3.1:8b"].max_context_size == 131072 + + +def _make_opencode_go_config() -> Config: + """A config with OpenCode Go configured but a stale model list: Qwen on the + wrong (OpenAI) provider and no qwen3.7-max, as written by an older binary.""" + from pythinker_code.auth.opencode_go import ( + OPENCODE_GO_ANTHROPIC_BASE_URL, + OPENCODE_GO_ANTHROPIC_PROVIDER_KEY, + OPENCODE_GO_BASE_URL, + OPENCODE_GO_OPENAI_PROVIDER_KEY, + ) + + config = Config( + default_model="opencode-go/kimi-k2.6", + default_thinking=True, + providers={ + OPENCODE_GO_OPENAI_PROVIDER_KEY: LLMProvider( + type="openai_legacy", + base_url=OPENCODE_GO_BASE_URL, + api_key=SecretStr("ocgo-test"), + ), + OPENCODE_GO_ANTHROPIC_PROVIDER_KEY: LLMProvider( + type="anthropic", + base_url=OPENCODE_GO_ANTHROPIC_BASE_URL, + api_key=SecretStr("ocgo-test"), + ), + }, + models={ + "opencode-go/kimi-k2.6": LLMModel( + provider=OPENCODE_GO_OPENAI_PROVIDER_KEY, + model="kimi-k2.6", + max_context_size=262_000, + ), + "opencode-go/qwen3.5-plus": LLMModel( + provider=OPENCODE_GO_OPENAI_PROVIDER_KEY, + model="qwen3.5-plus", + max_context_size=262_000, + ), + }, + services=Services(), + ) + config.is_from_default_location = True + return config + + +@pytest.mark.asyncio +async def test_refresh_managed_models_refreshes_opencode_go_without_relogin(): + """The every-startup refresh must update OpenCode Go's two-provider catalog + via its own discovery — surfacing new models (qwen3.7-max) and correcting + Qwen's shape — without a manual re-login and without resetting user prefs.""" + from pythinker_code.auth.opencode_go import ( + OPENCODE_GO_ANTHROPIC_PROVIDER_KEY, + OPENCODE_GO_OPENAI_PROVIDER_KEY, + OpenCodeGoModel, + ) + + config = _make_opencode_go_config() + discovered = ( + OpenCodeGoModel("kimi-k2.6", "Kimi K2.6", OPENCODE_GO_OPENAI_PROVIDER_KEY, 262_000), + 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 + ), + ) + saved: list[Config] = [] + + with ( + patch( + "pythinker_code.auth.opencode_go._discover_opencode_go_models", + new=AsyncMock(return_value=discovered), + ), + patch("pythinker_code.auth.platforms.list_models", new=AsyncMock()) as list_models_mock, + patch( + "pythinker_code.auth.platforms.load_config", + return_value=_make_opencode_go_config(), + ), + patch( + "pythinker_code.auth.platforms.save_config", + side_effect=lambda cfg, *a, **k: saved.append(cfg), + ), + ): + changed = await refresh_managed_models(config) + + assert changed is True + # OpenCode Go must not be routed through the generic single-provider path. + assert list_models_mock.await_count == 0 + # New model now selectable on the Anthropic-shaped provider, no re-login. + assert config.models["opencode-go/qwen3.7-max"].provider == OPENCODE_GO_ANTHROPIC_PROVIDER_KEY + # Existing Qwen's shape corrected OpenAI → Anthropic. + assert config.models["opencode-go/qwen3.5-plus"].provider == OPENCODE_GO_ANTHROPIC_PROVIDER_KEY + # User preferences preserved across the refresh. + assert config.default_model == "opencode-go/kimi-k2.6" + assert config.default_thinking is True + # Persisted to disk for subsequent launches. + assert len(saved) == 1 + assert "opencode-go/qwen3.7-max" in saved[0].models + + +@pytest.mark.asyncio +async def test_refresh_managed_models_isolates_opencode_go_discovery_failure(): + """An OpenCode Go discovery failure must not abort other providers' refresh + or mangle the saved OpenCode Go list.""" + from pythinker_code.auth.opencode_go import OPENCODE_GO_OPENAI_PROVIDER_KEY + + def _config_with_generic_provider() -> Config: + cfg = _make_opencode_go_config() + cfg.providers["managed:pythinker-code"] = LLMProvider( + type="pythinker", base_url="https://api.test/v1", api_key=SecretStr("k") + ) + cfg.models["pythinker-code/pythinker-for-coding"] = LLMModel( + provider="managed:pythinker-code", + model="pythinker-for-coding", + max_context_size=100_000, + ) + return cfg + + config = _config_with_generic_provider() + generic_models = [ + ModelInfo( + id="pythinker-for-coding", + context_length=200_000, # differs from saved 100_000 → real update + supports_reasoning=False, + supports_image_in=False, + supports_video_in=False, + display_name=None, + ) + ] + saved: list[Config] = [] + + with ( + patch( + "pythinker_code.auth.opencode_go._discover_opencode_go_models", + new=AsyncMock(side_effect=aiohttp.ClientConnectionError("offline")), + ), + patch( + "pythinker_code.auth.platforms.list_models", + new=AsyncMock(return_value=generic_models), + ), + patch( + "pythinker_code.auth.platforms.load_config", + side_effect=_config_with_generic_provider, + ), + patch( + "pythinker_code.auth.platforms.save_config", + side_effect=lambda cfg, *a, **k: saved.append(cfg), + ), + ): + changed = await refresh_managed_models(config) + + # The generic provider's refresh still succeeds and persists. + assert changed is True + assert len(saved) == 1 + assert saved[0].models["pythinker-code/pythinker-for-coding"].max_context_size == 200_000 + # OpenCode Go list is left exactly as-is (not wiped, not half-applied). + assert "opencode-go/qwen3.7-max" not in config.models + assert config.models["opencode-go/qwen3.5-plus"].provider == OPENCODE_GO_OPENAI_PROVIDER_KEY