From d9d6112aa76237ef4a72f58c94d3dc596ca75bc5 Mon Sep 17 00:00:00 2001 From: Jackal991 Date: Thu, 27 Aug 2026 01:17:17 +0100 Subject: [PATCH] fix(opencode): revalidate keyless opencode-free catalog live against the Zen relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode-free (keyless) models were served exclusively from a hardcoded in-repo snapshot (_PROVIDER_MODELS["opencode-free"]). The SWR disk cache only revalidated AUTHED providers — its entries were keyed by a credential fingerprint, which keyless providers have none of — so the catalog never refreshed against GET /zen/v1/models. When the relay delisted a free model (e.g. x-preview-f-free, 2026-08-26) the picker kept offering it and selecting it 401'd: "Model x-preview-f-free is not supported". Now provider_model_ids("opencode-free") fetches the live /zen/v1/models catalog anonymously, filters it to the anonymous-servable free tier (excluding KEYED suffix-fakes like Go's ox-alpha-free), and falls back to the curated static floor only when the live fetch fails or is empty. The keyless provider gets a stable disk-cache fingerprint so the picker's SWR path serves stale immediately while refreshing off-thread — the same behavior authed providers already get. Regression tests prove the fix: the delisted/newly-live model assertions fail when the live-fetch wiring is reverted. Closes #95914 --- hermes_cli/models.py | 97 +++++++++++-- .../test_opencode_free_live_catalog.py | 129 ++++++++++++++++++ 2 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 tests/hermes_cli/test_opencode_free_live_catalog.py diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 4c587f01ee..59e193a2d9 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -566,16 +566,20 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "nemotron-3.5-lightning-free", "muse-spark-1.2-contributor-free", ], - # OpenCode free tier — keyless (no OpenCode account needed). Synced - # against live GET /zen/v1/models + anonymous probes (2026-08-21); - # deepseek-v4-flash-free delisted (promo ended, now 401s). - # big-pickle + mimo-v2.5-free delisted (UA-gated: the relay 429s - # FreeUsageLimitError for every client except User-Agent - # "opencode/latest"; we send honest Hermes attribution and don't - # impersonate other clients — verified 2026-08-21). + # OpenCode free tier — keyless (no OpenCode account needed). This is the + # OFFLINE FLOOR only: provider_model_ids("opencode-free") revalidates live + # against GET /zen/v1/models (keyless) and filters to the anonymous free + # tier, so a relay-delisted model stops appearing in the picker and a + # newly-live one becomes selectable without a release. This floor keeps the + # picker populated when the relay is unreachable. Note: this floor may lag + # the live relay (e.g. x-preview-f-free was delisted 2026-08-26) — that is + # intentional; the live revalidation is the source of truth when reachable. + # deepseek-v4-flash-free and mimo-v2.5-free are back on the live list. "opencode-free": [ "x-preview-f-free", # "Ox Alpha" stealth model — free, 1M ctx, ZDR + "deepseek-v4-flash-free", "hy3-free", + "mimo-v2.5-free", "laguna-s-2.1-free", "nemotron-3-ultra-free", "nemotron-3.5-lightning-free", @@ -4100,12 +4104,16 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) except Exception: pass - # OpenCode Free: curated keyless list only. models.dev's cost.input==0 - # filter lags reality (deepseek-v4-flash-free stayed "free" there after - # its promo ended and the relay began 401ing keyless requests), so the - # curated list — synced against anonymous live probes — is authoritative. + # OpenCode Free: keyless live catalog, revalidated against the Zen relay + # every TTL. models.dev's cost.input==0 filter lags reality + # (deepseek-v4-flash-free stayed "free" there after its promo ended and the + # relay began 401ing keyless requests), so we filter the live /zen/v1/models + # dump to the anonymous-servable `*-free` tier ourselves and fall back to + # the curated _PROVIDER_MODELS floor only when the live fetch fails or is + # empty. This is what keeps a relay-delisted model (e.g. x-preview-f-free) + # from lingering in the picker until a release re-syncs the snapshot. if normalized == "opencode-free": - return list(_PROVIDER_MODELS.get(normalized, [])) + return _fetch_opencode_free_models() or list(_PROVIDER_MODELS.get(normalized, [])) # ── Profile-based generic live fetch (all simple api-key providers) ── # Handles any provider registered in providers/ with auth_type="api_key". @@ -4196,6 +4204,12 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) # to a live fetch — the picker keeps working. _PROVIDER_MODELS_CACHE_TTL = 3600 # 1h +# Providers whose catalog is served with NO credential and therefore gets a +# stable (constant) credential fingerprint in the disk cache. The opencode-free +# catalog is anonymous — its freshness comes from TTL revalidation, not from +# user-rotatable credentials — so folding in unrelated auth.json mtimes would +# only needlessly bust the SWR cache. +_KEYLESS_STABLE_CACHE_PROVIDERS = frozenset({"opencode-free"}) # Stale-while-revalidate window: an expired-but-same-credentials entry is # served IMMEDIATELY (picker opens stay instant) while a background daemon # thread re-fetches the live catalog and rewrites the disk cache for the @@ -4296,6 +4310,14 @@ def _credential_fingerprint(provider: str) -> str: parts: list[str] = [] + # Keyless providers have no credential to fingerprint: the catalog is + # served anonymously, so nothing the user rotates (env vars, auth files, + # base URLs) should invalidate the cached entry. A stable fingerprint keeps + # the SWR disk cache alive across unrelated re-auths and only busts on TTL + # expiry — matching how the live catalog genuinely changes. + if (provider or "").strip().lower() in _KEYLESS_STABLE_CACHE_PROVIDERS: + return "keyless:" + (provider or "").strip().lower() + # Env vars from PROVIDER_REGISTRY for this slug try: from hermes_cli.auth import PROVIDER_REGISTRY @@ -5447,6 +5469,12 @@ _OPENCODE_ZEN_FREE_BASE_URL = "https://opencode.ai/zen/v1" # (big-pickle is OpenCode's rotating free stealth slot.) _OPENCODE_KEYLESS_EXTRA_SLUGS = frozenset({"big-pickle"}) +# Models whose slug carries ``-free`` but are NOT anonymous-servable: they are +# KEYED (Go-subscription) models and must be excluded from the keyless free +# catalog even though the suffix looks free. ox-alpha-free is the Go relay's +# subscription twin of the Zen keyless Ox Alpha (verified 2026-08-21). +_OPENCODE_FREE_KEYED_SUFFIX_MODELS = frozenset({"ox-alpha-free"}) + def is_opencode_zen_free_model(model_id: Optional[str]) -> bool: """True when ``model_id`` is an OpenCode Zen free-tier slug. @@ -5483,6 +5511,51 @@ def opencode_zen_free_headers() -> dict: } +def _fetch_opencode_free_models(timeout: float = 8.0) -> Optional[list[str]]: + """Fetch the live keyless OpenCode Free catalog from the Zen relay. + + GETs ``{_OPENCODE_ZEN_FREE_BASE_URL}/models`` ANONYMOUSLY (the free tier + rejects any unrecognized Authorization bearer with 401) and filters the + dump to the anonymous-servable ``*-free`` tier. Returns ``None`` on any + network/auth/parse failure so callers fall back to the curated + ``_PROVIDER_MODELS["opencode-free"]`` floor; an empty filtered result is + treated as a failure for the same reason (a relay with zero free models is + not worth trusting over the floor). + + The Zen ``/models`` dump also lists paid/subscription IDs (e.g. Go + ``ox-alpha-free`` is KEYED despite the suffix), so a bare ``*-free`` suffix + filter is not safe on its own — this mirrors the existing + ``opencode_zen_free_runtime`` contract, which uses membership in the + verified keyless catalog as the routing criterion. + """ + import urllib.request + + from hermes_cli.urllib_security import open_credentialed_url + + url = f"{_OPENCODE_ZEN_FREE_BASE_URL.rstrip('/')}/models" + req = urllib.request.Request(url) + req.add_header("Accept", "application/json") + for k, v in opencode_zen_free_headers().items(): + if k.lower() != "authorization": # never send a bearer keylessly + req.add_header(k, v) + try: + with open_credentialed_url(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode()) + items = data if isinstance(data, list) else data.get("data", []) + except Exception: + return None + ids = [m["id"] for m in items if isinstance(m, dict) and isinstance(m.get("id"), str)] + # Filter to the anonymous-servable free tier. The Zen dump can contain + # keyed/Go IDs; only the verified free set belongs in the keyless picker. + live_free = [ + mid + for mid in ids + if mid.lower().endswith("-free") + and mid.lower() not in _OPENCODE_FREE_KEYED_SUFFIX_MODELS + ] + return live_free if live_free else None + + def opencode_zen_free_runtime(provider_id: Optional[str], model_id: Optional[str]) -> Optional[dict]: """Keyless runtime entry for an OpenCode Zen free-tier model, or None. diff --git a/tests/hermes_cli/test_opencode_free_live_catalog.py b/tests/hermes_cli/test_opencode_free_live_catalog.py new file mode 100644 index 0000000000..207354a5ad --- /dev/null +++ b/tests/hermes_cli/test_opencode_free_live_catalog.py @@ -0,0 +1,129 @@ +"""Regression tests for #95914 — keyless opencode-free catalog live revalidation. + +The opencode-free (keyless) model catalog used to be served exclusively from a +hardcoded in-repo snapshot (_PROVIDER_MODELS["opencode-free"]). When the OpenCode +Zen relay delisted a free model (e.g. x-preview-f-free, 2026-08-26), the picker +kept offering it and selecting it failed with a non-retryable HTTP 401 +("Model x-preview-f-free is not supported"). The SWR disk cache only refreshed +AUTHED providers (its entries were keyed by a credential fingerprint, which +keyless providers have none of), so the keyless catalog never revalidated. + +The fix makes provider_model_ids("opencode-free") revalidate LIVE against +GET /zen/v1/models (anonymous, filtered to the free tier) and gives the keyless +provider a stable disk-cache fingerprint so the picker's SWR path serves stale +immediately while refreshing off-thread — the same behavior authed providers +already get. + +These tests PROVE the fix: reverting the live-fetch wiring makes the +delisted-model / newly-live-model assertions fail (the catalog reverts to the +static snapshot only). +""" + +from unittest.mock import patch + +from hermes_cli.models import ( + _KEYLESS_STABLE_CACHE_PROVIDERS, + _PROVIDER_MODELS, + cached_provider_model_ids, + provider_model_ids, +) + +# Static floor (may lag the live relay by design — it is only the offline fallback). +_STATIC_FLOOR = list(_PROVIDER_MODELS["opencode-free"]) + +# The live relay's current free tier. x-preview-f-free was DELISTED 2026-08-26; +# deepseek-v4-flash-free + mimo-v2.5-free are back on the live list. +_LIVE_FREE_MODELS = [ + "deepseek-v4-flash-free", + "hy3-free", + "mimo-v2.5-free", + "laguna-s-2.1-free", + "nemotron-3-ultra-free", + "nemotron-3.5-lightning-free", + "muse-spark-1.2-contributor-free", +] + +# The raw live /zen/v1/models dump also lists paid/subscription + KEYED-free IDs +# (e.g. ox-alpha-free is a Go-subscription model despite the suffix). The filter +# must keep only anonymous-servable free models. +_LIVE_RAW_IDS = _LIVE_FREE_MODELS + [ + "claude-sonnet-5", # paid + "gpt-5.6-sol", # paid + "ox-alpha-free", # KEYED Go-subscription (suffix looks free) +] + + +class TestProviderModelIdsOpencodeFree: + def test_live_catalog_revalidation_excludes_delisted(self): + """The delisted model must NOT appear when the live relay no longer lists it.""" + with patch( + "hermes_cli.models._fetch_opencode_free_models", + return_value=list(_LIVE_FREE_MODELS), + ): + result = provider_model_ids("opencode-free") + + assert "x-preview-f-free" not in result # delisted — REVERT-PROOF + assert "deepseek-v4-flash-free" in result # newly-live — REVERT-PROOF + assert "mimo-v2.5-free" in result # newly-live — REVERT-PROOF + + def test_live_catalog_filters_out_keyed_free_suffix_model(self): + """ox-alpha-free (KEYED Go-subscription) must never enter the keyless picker.""" + with patch( + "hermes_cli.models._fetch_opencode_free_models", + return_value=list(_LIVE_FREE_MODELS), + ): + result = provider_model_ids("opencode-free") + assert "ox-alpha-free" not in result + + def test_falls_back_to_static_floor_when_live_fetch_fails(self): + """On live-fetch failure/empty, the static floor keeps the picker populated.""" + with patch("hermes_cli.models._fetch_opencode_free_models", return_value=None): + result = provider_model_ids("opencode-free") + assert result == _STATIC_FLOOR + assert result # never empty on a transient outage + + def test_empty_live_result_falls_back_to_static_floor(self): + """An empty live result (no free models) is not trusted over the floor.""" + with patch("hermes_cli.models._fetch_opencode_free_models", return_value=[]): + result = provider_model_ids("opencode-free") + assert result == _STATIC_FLOOR + + +class TestOpencodeFreeCacheFingerprint: + def test_keyless_provider_has_stable_fingerprint(self): + """opencode-free is in the stable-fingerprint set (no credential to rotate).""" + assert "opencode-free" in _KEYLESS_STABLE_CACHE_PROVIDERS + + import hermes_cli.models as mod + + fp1 = mod._credential_fingerprint("opencode-free") + fp2 = mod._credential_fingerprint("opencode-free") + assert fp1 == fp2 + assert fp1.startswith("keyless:opencode-free") + + def test_cached_picker_path_revalidates_live(self): + """cached_provider_model_ids('opencode-free') serves the live catalog and + persists it under a stable fingerprint (SWR cache path the picker uses).""" + import time + + import hermes_cli.models as mod + + with ( + patch.object(mod, "_load_provider_models_cache", return_value={}), + patch.object( + mod, + "_fetch_opencode_free_models", + return_value=list(_LIVE_FREE_MODELS), + ) as fetch, + patch.object(mod, "_save_provider_models_cache") as save, + ): + out = cached_provider_model_ids("opencode-free") + + assert out == list(_LIVE_FREE_MODELS) + fetch.assert_called_once() + # The persisted entry carries the stable keyless fingerprint so future + # SWR lookups match and don't re-fetch on unrelated auth changes. + written = save.call_args[0][0] + entry = written["opencode-free"] + assert entry["fp"].startswith("keyless:opencode-free") + assert isinstance(entry["at"], float) and not isinstance(entry["at"], bool)