fix(opencode): revalidate keyless opencode-free catalog live against the Zen relay
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
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
129
tests/hermes_cli/test_opencode_free_live_catalog.py
Normal file
129
tests/hermes_cli/test_opencode_free_live_catalog.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user