From 998f614c7f8e066728dd6c8e399916f44eb4f238 Mon Sep 17 00:00:00 2001 From: Ritesh Patel <60716910+DECRUX9812@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:09:31 -0600 Subject: [PATCH] feat(providers): remove the keyless opencode-free tier OpenCode's free tier now returns HTTP 403 for anonymous traffic outside the OpenCode client, so the built-in keyless provider is dead weight: - drop the opencode-free provider row, aliases (free/opencode_free), model catalog, keyless runtime ladder rung, header wiring, and cached slugs - delete the model-providers/opencode-free plugin - update tests and the compat manifest for the removed symbols - keep a migration hint in auth.py so users who had it configured see a clear error naming the removal Existing opencode-free configs can move to opencode-zen (pay-as-you-go) or opencode-go (flat subscription). --- COMPAT_MANIFEST.md | 2 - agent/agent_init.py | 8 - agent/agent_runtime_helpers.py | 8 - agent/auxiliary_client.py | 24 +- agent/models_dev.py | 3 - agent/opencode_affinity.py | 4 +- agent/reasoning_effort.py | 4 +- compat_manifest.json | 12 - hermes_cli/auth.py | 11 +- hermes_cli/main_provider_setup.py | 2 +- hermes_cli/model_setup_flows.py | 23 +- hermes_cli/model_switch_providers.py | 2 +- hermes_cli/models.py | 151 +--------- hermes_cli/models_catalog_static.py | 22 +- hermes_cli/provider_catalog.py | 2 +- hermes_cli/providers.py | 6 +- hermes_cli/runtime_provider.py | 11 - .../model-providers/opencode-free/__init__.py | 51 ---- .../model-providers/opencode-free/plugin.yaml | 5 - .../test_fallback_api_mode_preservation.py | 3 +- tests/agent/test_model_metadata.py | 1 - tests/agent/test_models_dev_meta_mapping.py | 7 +- .../test_opencode_free_client_headers.py | 127 --------- tests/agent/test_opencode_free_provider.py | 103 ------- tests/agent/test_opencode_session_affinity.py | 1 - tests/hermes_cli/test_model_validation.py | 2 - .../test_opencode_free_live_catalog.py | 269 ------------------ .../test_opencode_zen_free_keyless.py | 143 ---------- tests/hermes_cli/test_provider_catalog.py | 4 +- tests/hermes_cli/test_provider_parity.py | 2 +- .../test_runtime_provider_resolution.py | 55 ---- .../test_opencode_go_profile.py | 24 -- .../test_thinking_toggle_parity.py | 13 +- 33 files changed, 45 insertions(+), 1060 deletions(-) delete mode 100644 plugins/model-providers/opencode-free/__init__.py delete mode 100644 plugins/model-providers/opencode-free/plugin.yaml delete mode 100644 tests/agent/test_opencode_free_client_headers.py delete mode 100644 tests/agent/test_opencode_free_provider.py delete mode 100644 tests/hermes_cli/test_opencode_free_live_catalog.py delete mode 100644 tests/hermes_cli/test_opencode_zen_free_keyless.py diff --git a/COMPAT_MANIFEST.md b/COMPAT_MANIFEST.md index 4589374cc4..992a02050d 100644 --- a/COMPAT_MANIFEST.md +++ b/COMPAT_MANIFEST.md @@ -1475,7 +1475,6 @@ to the public equivalent or the new module. Test monkeypatch seams are likewise | `NamedTuple` | import | `typing` | | `PROVIDER_GROUPS` | moved-lazy | `hermes_cli.models_catalog_static` | | `ProviderEntry` | moved-lazy | `hermes_cli.models_catalog_static` | -| `_OPENCODE_KEYLESS_EXTRA_SLUGS` | restored-helper | `(deleted; restored as a dependency of is_opencode_zen_free_model)` | | `atomic_json_write` | moved-lazy | `utils` | | `base_url_host_matches` | moved-lazy | `utils` | | `compute_sale_discount` | moved-lazy | `hermes_cli.models_pricing` | @@ -1490,7 +1489,6 @@ to the public equivalent or the new module. Test monkeypatch seams are likewise | `group_providers` | moved-lazy | `hermes_cli.models_catalog_static` | | `http` | import | `http.client` | | `is_nous_free_tier` | restored-def | `(deleted; BASE body restored)` | -| `is_opencode_zen_free_model` | restored-def | `(deleted; BASE body restored)` | | `lmstudio_model_reasoning_options` | moved-lazy | `hermes_cli.models_local` | | `nous_catalog_url` | moved-lazy | `hermes_cli.models_reasoning_caps` | | `nous_model_reasoning_capabilities` | moved-lazy | `hermes_cli.models_reasoning_caps` | diff --git a/agent/agent_init.py b/agent/agent_init.py index 1783a60b16..800f216782 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -795,14 +795,6 @@ def _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) -> Dict if agent.provider == "copilot-acp": client_kwargs["command"] = agent.acp_command client_kwargs["args"] = agent.acp_args - # OpenCode Zen free tier is served ANONYMOUSLY and 401s any bearer (incl. our keyless - # placeholder): send an empty Authorization header to override the SDK's "Bearer ". - with suppress(Exception): - from hermes_cli.models import ( - OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, opencode_zen_free_headers - ) - if api_key == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER: - client_kwargs["default_headers"] = opencode_zen_free_headers() _headers_for = _host_default_headers_factory(base_url) if _headers_for is not None: client_kwargs["default_headers"] = _headers_for(api_key, base_url) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 90f3078c32..a38c6edb13 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1777,14 +1777,6 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo # keeps SDK retries because it is NOT wrapped by the conversation loop. client_kwargs.setdefault("max_retries", 0) _ensure_copilot_headers(client_kwargs) - # OpenCode Free is served anonymously: any unrecognized bearer is a 401, so an empty - # Authorization default_header overrides the SDK's "Bearer ". Key on the keyless - # placeholder as well as the provider: a free slug picked under the paid ``opencode`` profile - # resolves to the placeholder too, and shipping it as a bearer 401s every request with an - # empty pool to rotate (#110831). - from hermes_cli.models import OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, opencode_zen_free_headers - if agent.provider == "opencode-free" or client_kwargs.get("api_key") == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER: - client_kwargs["default_headers"] = {**(client_kwargs.get("default_headers") or {}), **opencode_zen_free_headers()} # All primary construction and recovery paths must identify Hermes to the official Codex # endpoint, including snapshots with custom header overrides. from agent.codex_headers import apply_required_codex_headers diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index ccf72855ba..450d1d3337 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -174,12 +174,6 @@ def _create_openai_client(*, api_key: str, base_url: str, **kwargs: Any) -> Any: # Availability probe: resolved credentials/base_url are the answer. return _AuxProbeClientStub(api_key=api_key, base_url=base_url) kwargs = {**_openai_http_client_kwargs(base_url), **kwargs} - # OpenCode Zen free tier: the keyless placeholder must never hit the wire (relay 401s any - # unrecognized bearer) — blank the Authorization header. - with contextlib.suppress(Exception): - from hermes_cli.models import OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, opencode_zen_free_headers - if api_key == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER: - kwargs["default_headers"] = {**(kwargs.get("default_headers") or {}), **opencode_zen_free_headers()} _apply_required_codex_headers(kwargs, access_token=api_key, base_url=base_url) # Hermes owns aux retry/fallback policy; the SDK default (max_retries=2) would triple # wall time on a hung endpoint before Hermes sees one failure. @@ -4585,12 +4579,6 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): except Exception: inferred = "" headers = _endpoint_default_headers(sync_base_url, inferred, is_vision=is_vision, xai=True) - # Headers are rebuilt from scratch here, so re-apply the OpenCode keyless policy from - # _create_openai_client: the placeholder must never ship as a bearer (see #110831). - with contextlib.suppress(Exception): - from hermes_cli.models import OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, opencode_zen_free_headers - if sync_client.api_key == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER: - headers = {**(headers or {}), **opencode_zen_free_headers()} headers = {**(headers or {}), **configured_default_headers(sync_client)} if headers: async_kwargs["default_headers"] = headers @@ -5116,16 +5104,8 @@ def _resolve_api_key_branch(req: _ResolveRequest, pconfig: Any, resolve_creds: C raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url if req.explicit_base_url: raw_base_url = req.explicit_base_url.strip().rstrip("/") - # OpenCode Zen free tier (*-free slugs) is served anonymously on the Zen relay only; - # any bearer (even a Go subscription key) is rejected, so route keyless regardless of creds. - try: - from hermes_cli.models import opencode_zen_free_runtime as _oc_free_rt - _free_rt = _oc_free_rt(provider, req.model) - except Exception: - _free_rt = None - if _free_rt is not None: - api_key = _free_rt["api_key"] - raw_base_url = str(_free_rt["base_url"]).rstrip("/") + # Keyless OpenCode free-tier routing was removed (OpenCode 403s anonymous traffic, so -free + # slugs now resolve with the caller's own Zen/Go credentials). if provider == "actual": with contextlib.suppress(Exception): from hermes_cli.auth import ( diff --git a/agent/models_dev.py b/agent/models_dev.py index 4f35b40d16..6f5becea77 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -117,9 +117,6 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "alibaba": "alibaba", "qwen-oauth": "alibaba", "copilot": "github-copilot", "ai-gateway": "vercel", "opencode-zen": "opencode", "opencode-go": "opencode-go", - # opencode-free is Zen-hosted (hermes_cli/models.py) and models.dev's "opencode" catalog lists - # its *-contributor-free SKUs; without this alias every opencode-free lookup missed models.dev. - "opencode-free": "opencode", "kilocode": "kilo", "fireworks": "fireworks-ai", "huggingface": "huggingface", "gemini": "google", "google": "google", "xai": "xai", diff --git a/agent/opencode_affinity.py b/agent/opencode_affinity.py index be355eb121..c4a1628e7c 100644 --- a/agent/opencode_affinity.py +++ b/agent/opencode_affinity.py @@ -1,6 +1,6 @@ """``x-opencode-session`` — OpenCode relay session-affinity header. -OpenCode (opencode.ai Zen/Go/free relay) pins requests that share an +OpenCode (opencode.ai Zen/Go relay) pins requests that share an ``x-opencode-session`` value to the same upstream backend, which is what keeps its prompt cache warm across the turns of one conversation. The value only has to be opaque and consistent per conversation, so it is derived the @@ -25,7 +25,7 @@ OPENCODE_SESSION_HEADER = "x-opencode-session" def is_opencode_target(provider: Optional[str], base_url: Optional[str]) -> bool: """True when *provider* or *base_url* addresses the OpenCode relay. - Matches the built-in opencode-zen/go/free providers, custom + Matches the built-in opencode-zen/go providers, custom ``opencode--*`` providers, and any base_url hosted on opencode.ai. """ try: diff --git a/agent/reasoning_effort.py b/agent/reasoning_effort.py index 53e5393ce8..bd252fde32 100644 --- a/agent/reasoning_effort.py +++ b/agent/reasoning_effort.py @@ -197,8 +197,8 @@ def thinking_toggle_extras( def ox_alpha_reasoning_extras(reasoning_config: Optional[dict], model: Optional[str]) -> tuple[dict, dict]: - """Ox Alpha (``x-preview-f-free``) ``reasoning_effort`` translation, shared by the - opencode-zen and opencode-free profiles (low/high/max only; anything else 400s).""" + """Ox Alpha (``x-preview-f-free``) ``reasoning_effort`` translation for the + opencode-zen profile (low/high/max only; anything else 400s).""" if (model or "").strip().rsplit("/", 1)[-1].lower() != "x-preview-f-free": return {}, {} effort = requested_effort(reasoning_config) diff --git a/compat_manifest.json b/compat_manifest.json index 9c3a37ca0c..b214cfbc85 100644 --- a/compat_manifest.json +++ b/compat_manifest.json @@ -4064,18 +4064,6 @@ "kind": "restored-def", "target": "(deleted; BASE body restored)" }, - { - "facade": "hermes_cli.models", - "name": "_OPENCODE_KEYLESS_EXTRA_SLUGS", - "kind": "restored-helper", - "target": "(deleted; restored as a dependency of is_opencode_zen_free_model)" - }, - { - "facade": "hermes_cli.models", - "name": "is_opencode_zen_free_model", - "kind": "restored-def", - "target": "(deleted; BASE body restored)" - }, { "facade": "hermes_cli.models", "name": "lmstudio_model_reasoning_options", diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index a2491062f2..2afdf803c3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -230,9 +230,6 @@ _REGISTRY_ROWS: Tuple[Any, ...] = ( # Qwen 3.7: Anthropic Messages under /v1/messages). Keep the base at /v1; api_mode is per-model. ("opencode-go", "OpenCode Go", "https://opencode.ai/zen/go/v1", ("OPENCODE_GO_API_KEY",), "OPENCODE_GO_BASE_URL"), - # Deliberately NO api_key_env_vars: the free tier is served anonymously (any unrecognized bearer - # is a 401), so there is no secret to configure. Select via `hermes model` / `/model free`. - ("opencode-free", "OpenCode Free", "https://opencode.ai/zen/v1", ()), ("kilocode", "Kilo Code", "https://api.kilo.ai/api/gateway", ("KILOCODE_API_KEY",), "KILOCODE_BASE_URL"), ("huggingface", "Hugging Face", "https://router.huggingface.co/v1", ("HF_TOKEN",), "HF_BASE_URL"), ("xiaomi", "Xiaomi MiMo", "https://api.xiaomimimo.com/v1", ("XIAOMI_API_KEY",), "XIAOMI_BASE_URL"), @@ -1110,6 +1107,11 @@ def deactivate_provider() -> None: def _get_config_hint_for_unknown_provider(provider_name: str) -> str: """Return a helpful hint string when provider resolution fails.""" + if str(provider_name or "").strip().lower() in {"opencode-free", "free", "opencode_free"}: + return ("OpenCode discontinued anonymous free-tier access outside its own client " + "(relay 403s FreeTierError), so the keyless 'opencode-free' provider was removed. " + "Switch to 'opencode-zen' (pay-as-you-go, OPENCODE_ZEN_API_KEY) or 'opencode-go' " + "($10/mo subscription, OPENCODE_GO_API_KEY) via 'hermes model'.") try: from hermes_cli.config import validate_config_structure issues = validate_config_structure() @@ -1172,7 +1174,6 @@ _PROVIDER_ALIASES: Dict[str, str] = { "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", "opencode": "opencode-zen", "zen": "opencode-zen", - "free": "opencode-free", "opencode_free": "opencode-free", "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", "mimo": "xiaomi", "xiaomi-mimo": "xiaomi", @@ -1758,7 +1759,7 @@ def get_api_key_provider_status(provider_id: str) -> Dict[str, Any]: "configured": True, "provider": provider_id, "name": pconfig.name, "key_source": "keyless", "base_url": pconfig.inference_base_url, "logged_in": True} if _provider_is_keyless(provider_id): - # Keyless providers (opencode-free) are served anonymously: every install counts as + # Keyless providers are served anonymously: every install counts as # configured. return status diff --git a/hermes_cli/main_provider_setup.py b/hermes_cli/main_provider_setup.py index 2e43ed76f4..a1878344d6 100644 --- a/hermes_cli/main_provider_setup.py +++ b/hermes_cli/main_provider_setup.py @@ -24,7 +24,7 @@ def _is_profile_api_key_provider(provider_id: str) -> bool: _GENERIC_API_KEY_PROVIDERS = frozenset({ "openai-api", "gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", - "opencode-free", "alibaba", "huggingface", "xiaomi", "arcee", "gmi", + "alibaba", "huggingface", "xiaomi", "arcee", "gmi", "nvidia", "ollama-cloud", "tencent-tokenhub", "tencent-tokenplan", "lmstudio"}) diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index adb6867044..7dc6c2d916 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -843,14 +843,6 @@ def _ollama_cloud_models(pconfig, curated, api_key, base_url): return model_list -def _opencode_free_models(pconfig, curated, api_key, base_url): - """Keyless tier: the curated list is synced against anonymous live probes (models.dev's - cost.input==0 filter lags reality).""" - if curated: - print(f' Showing {len(curated)} keyless free models — use "Enter custom model name" for others.') - return curated - - def _novita_models(pconfig, curated, api_key, base_url): """Novita: live first, then models.dev, then curated.""" from hermes_cli.models import fetch_api_models @@ -870,7 +862,6 @@ def _novita_models(pconfig, curated, api_key, base_url): _SPECIAL_MODEL_LISTS = { "lmstudio": _lmstudio_models, "ollama-cloud": _ollama_cloud_models, - "opencode-free": _opencode_free_models, "novita": _novita_models} @@ -912,17 +903,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): pconfig = PROVIDER_REGISTRY[provider_id] key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" base_url_env = pconfig.base_url_env_var or "" - is_opencode = provider_id in {"opencode-zen", "opencode-go", "opencode-free"} + is_opencode = provider_id in {"opencode-zen", "opencode-go"} - # OpenCode Free is keyless — the tier is served anonymously and any unrecognized - # bearer 401s, so there is no key to prompt for. - if provider_id == "opencode-free": - print(" OpenCode Free is keyless — no API key or account needed.") - existing_key = "" - else: - _, existing_key, abort = _ensure_flow_api_key(provider_id, pconfig) - if abort: - return + _, existing_key, abort = _ensure_flow_api_key(provider_id, pconfig) + if abort: + return if provider_id == "gemini" and existing_key and not _gemini_tier_ok(existing_key, pconfig, base_url_env): return diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index d5915738dd..4ce12a3f2c 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -804,7 +804,7 @@ def _overlay_has_creds(b: _PickerBuild, pid: str, hermes_slug: str, overlay) -> """Section-2 credential ladder: env/SDK, external-process executable, auth store, pool, anthropic's external credential files.""" if overlay.keyless: - return True # served anonymously (opencode-free) + return True # served anonymously — no credential exists to configure if overlay.auth_type == "aws_sdk": has_creds = _has_aws_sdk_creds_for_listing(hermes_slug, b.current_provider) else: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 3ea77a3415..f8a1682d86 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1412,12 +1412,6 @@ def _bedrock_catalog(normalized: str, force_refresh: bool) -> Optional[list[str] return None -def _opencode_free_catalog(normalized: str, force_refresh: bool) -> list[str]: - # Live keyless catalog filtered to the anonymous-servable `*-free` tier ourselves (models.dev's - # cost.input==0 lags reality); the curated floor applies only when the live fetch fails/is empty. - return _fetch_opencode_free_models(force_refresh=force_refresh) or list(_PROVIDER_MODELS.get(normalized, [])) - - # Per-provider catalog sources tried before the generic profile fetch. A fetcher returning None # falls through to the profile/curated path; a list is returned as-is (even empty). _PROVIDER_CATALOG_FETCHERS: dict[str, Any] = { @@ -1437,8 +1431,7 @@ _PROVIDER_CATALOG_FETCHERS: dict[str, Any] = { "openai": _openai_catalog, "openai-api": _openai_catalog, "custom": _custom_catalog, - "bedrock": _bedrock_catalog, - "opencode-free": _opencode_free_catalog} + "bedrock": _bedrock_catalog} def _profile_live_catalog(normalized: str) -> Optional[list[str]]: @@ -2103,7 +2096,7 @@ def azure_foundry_model_api_mode(model_name: Optional[str]) -> Optional[str]: return "codex_responses" if raw and raw.startswith(tuple(_AZURE_FOUNDRY_RESPONSES_PREFIXES)) else None -_OPENCODE_FAMILIES = ("opencode-free", "opencode-go", "opencode-zen") +_OPENCODE_FAMILIES = ("opencode-go", "opencode-zen") def opencode_provider_family(provider_id: Optional[str]) -> Optional[str]: @@ -2136,123 +2129,6 @@ def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[s return current -# OpenCode Zen free-tier models (``*-free`` slugs plus unsuffixed ones like big-pickle) are -# served ANONYMOUSLY on the Zen relay: no Authorization header succeeds, while ANY unrecognized -# non-empty bearer — including our placeholder and OpenCode GO subscription keys — is 401'd (the -# Go relay doesn't serve the free tier at all). -OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER = "opencode-zen-free-keyless" -_OPENCODE_ZEN_FREE_BASE_URL = "https://opencode.ai/zen/v1" - -# ``-free``-suffixed slugs the live list may carry that the keyless catalog must NOT offer: -# - KEYED (Go-subscription) twins, not anonymous-servable despite the suffix (ox-alpha-free is -# Ox Alpha's Go twin; the Go relay delisted it 2026-09-09 — the exclusion stays so a stale live -# list can never route it into the keyless catalog). -# - Delisted ids the relay still LISTS but no longer serves: deepseek-v4-flash-free (promo ended; -# gone from opencode.ai/docs/zen by 2026-09-15 yet still in GET /zen/v1/models, and every POST -# 400s "Model is unavailable"). Offering it lets a first-turn 400 drive a fallback switch that -# strands the whole session (#111749). -_OPENCODE_FREE_EXCLUDED_MODELS = frozenset({"ox-alpha-free", "deepseek-v4-flash-free"}) - -# In-process memo for _fetch_opencode_free_models(): (fetched_at, ids-or-None). Validation and -# healing call provider_model_ids("opencode-free") several times per resolution; failures are -# memoized too so an unreachable relay doesn't stall every call for `timeout` seconds. -_opencode_free_live_memo: Optional[tuple[float, Optional[list[str]]]] = None -_OPENCODE_FREE_LIVE_MEMO_TTL = 300.0 # 5 min; SWR disk cache handles the rest - - -def opencode_zen_free_headers() -> dict: - """Client default_headers for anonymous Zen free-tier requests. ``Authorization: ""`` overrides the - OpenAI SDK's ``Bearer `` so the placeholder never reaches the wire (the relay 401s any - unknown bearer). Attribution headers mirror the opencode provider profile.""" - try: - from hermes_cli import __version__ as _v - except Exception: - _v = "0" - return { - "Authorization": "", - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Title": "Hermes Agent", - "User-Agent": f"HermesAgent/{_v}"} - - -def _fetch_opencode_free_models( - timeout: float = 8.0, *, force_refresh: bool = False) -> Optional[list[str]]: - """Live keyless OpenCode Free catalog from the Zen relay, filtered to the anonymous-servable - ``*-free`` tier minus ``_OPENCODE_FREE_EXCLUDED_MODELS`` (keyed twins and listed-but-dead ids) — - the same membership criterion ``opencode_zen_free_runtime`` routes on.""" - from hermes_cli.urllib_security import open_credentialed_url - - now = time.time() - memo = _opencode_free_live_memo - if not force_refresh and memo is not None and now - memo[0] < _OPENCODE_FREE_LIVE_MEMO_TTL: - return list(memo[1]) if memo[1] else None - - req = urllib.request.Request(f"{_OPENCODE_ZEN_FREE_BASE_URL.rstrip('/')}/models") - 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: - _set_opencode_free_live_memo(None) - return None - live_free = [ - m["id"] for m in items - if isinstance(m, dict) and isinstance(m.get("id"), str) - and m["id"].lower().endswith("-free") and m["id"].lower() not in _OPENCODE_FREE_EXCLUDED_MODELS - ] - result = live_free or None - _set_opencode_free_live_memo(result) - return result - - -def _set_opencode_free_live_memo(ids: Optional[list[str]]) -> None: - global _opencode_free_live_memo - _opencode_free_live_memo = (time.time(), list(ids) if ids else None) - - -def _opencode_free_known_model_slugs() -> set[str]: - """Lowercased keyless free-tier slugs known right now WITHOUT network I/O: static floor ∪ live - memo ∪ SWR disk-cache entry. The ``opencode_zen_free_runtime`` healing path runs during model - resolution and must never block on a fetch.""" - known = {m.lower() for m in _PROVIDER_MODELS.get("opencode-free", [])} - memo = _opencode_free_live_memo - if memo is not None and memo[1]: - known.update(m.lower() for m in memo[1]) - try: - entry = _load_provider_models_cache().get("opencode-free") or {} - known.update(str(m).lower() for m in entry.get("models", []) or []) - except Exception: - pass - return known - - -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. Fires when ``provider_id`` - is ``opencode-free`` (EVERY model on it routes anonymously) or when any other OpenCode-family - provider selected a model in the known keyless catalog (static floor ∪ cached live catalog — - never a blocking fetch), healing a free-model pick made under Zen/Go whose keys the free tier - rejects.""" - family = opencode_provider_family(provider_id) - if family is None: - return None - normalized = normalize_opencode_model_id(provider_id, model_id) - if family != "opencode-free" and normalized.strip().lower() not in _opencode_free_known_model_slugs(): - return None - api_mode = opencode_model_api_mode("opencode-zen", normalized) - base_url = normalize_opencode_base_url("opencode-zen", api_mode, _OPENCODE_ZEN_FREE_BASE_URL) - return { - "provider": family, - "api_mode": api_mode, - "base_url": base_url, - "api_key": OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, - "default_headers": opencode_zen_free_headers(), - "source": "opencode-zen-free-keyless"} - - # Per-family (model-id prefix → api_mode) routing from OpenCode's published Zen/Go endpoint # tables, checked in order. GPT/Codex/Grok and Muse Spark use /v1/responses (Muse Spark 503s on # chat/completions); Claude (Zen), MiniMax (Go), Union Alpha, and Qwen use /v1/messages; @@ -2269,8 +2145,6 @@ _OPENCODE_API_MODE_PREFIXES: dict[str, tuple[tuple[tuple[str, ...], str], ...]] def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) -> str: """Determine the API mode for an OpenCode Zen / Go model (see ``_OPENCODE_API_MODE_PREFIXES``).""" family = opencode_provider_family(provider_id) - if family == "opencode-free": # the free tier lives on the Zen relay → Zen's routing - family = "opencode-zen" normalized = normalize_opencode_model_id(provider_id, model_id).lower() if normalized: for prefixes, mode in _OPENCODE_API_MODE_PREFIXES.get(family or "", ()): @@ -2279,8 +2153,8 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) return "chat_completions" -# Relay path per OpenCode family on opencode.ai hosts. The free tier is served by the Zen relay. -_OPENCODE_FAMILY_PATHS = {"opencode-zen": "/zen", "opencode-free": "/zen", "opencode-go": "/zen/go"} +# Relay path per OpenCode family on opencode.ai hosts. +_OPENCODE_FAMILY_PATHS = {"opencode-zen": "/zen", "opencode-go": "/zen/go"} def normalize_opencode_base_url( @@ -2725,23 +2599,6 @@ def is_nous_free_tier(account_info: dict[str, Any]) -> bool: except (TypeError, ValueError): return False -_OPENCODE_KEYLESS_EXTRA_SLUGS = frozenset({"big-pickle"}) - -def is_opencode_zen_free_model(model_id: Optional[str]) -> bool: - """True when ``model_id`` is an OpenCode Zen free-tier slug. - - Matches the ``*-free`` suffix plus the known unsuffixed free slugs - (``big-pickle``). Tolerates provider-prefixed ids - (``opencode-zen/x-preview-f-free``). The Go catalog serves no free - models (verified 2026-08-21), so this identifies the Zen free tier - across the OpenCode family. - """ - bare = str(model_id or "").strip().rsplit("/", 1)[-1].lower() - if not bare: - return False - return bare.endswith("-free") or bare in _OPENCODE_KEYLESS_EXTRA_SLUGS - - _PLUGIN_COMPAT_LAZY = { 'LMStudioLoadResult': ('hermes_cli.models_local', 'LMStudioLoadResult'), 'PROVIDER_GROUPS': ('hermes_cli.models_catalog_static', 'PROVIDER_GROUPS'), diff --git a/hermes_cli/models_catalog_static.py b/hermes_cli/models_catalog_static.py index 3a01aef8a1..80082227a8 100644 --- a/hermes_cli/models_catalog_static.py +++ b/hermes_cli/models_catalog_static.py @@ -235,17 +235,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "nemotron-3-ultra-free", "nemotron-3.5-lightning-free", "muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free", ], - # OpenCode keyless free tier — OFFLINE FLOOR only. provider_model_ids("opencode-free") - # revalidates live against GET /zen/v1/models and filters to the anonymous tier, so this list - # may lag the relay (intentional). Known-delisted models are REMOVED (the offline fallback must - # not offer a model that 401s; x-preview-f-free delisted 2026-08-26, hy3-free and - # laguna-s-2.1-free delisted 2026-09-09, and deepseek-v4-flash-free delisted - # 2026-09-15 — all removed from this offline floor after their relay delisting). - "opencode-free": [ - "mimo-v2.5-free", - "nemotron-3-ultra-free", "nemotron-3.5-lightning-free", "muse-spark-1.2-contributor-free", - "muse-spark-1.3-contributor-free", - ], # Synced against opencode.ai/docs/go + live GET /zen/go/v1/models. Known-delisted models are # REMOVED (the live-first merge would otherwise keep offering a model that 401s): "ox-alpha-free" # — the Go-subscription twin of Zen's keyless Ox Alpha — was delisted 2026-09-09. @@ -394,7 +383,7 @@ PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = { "google": ("Google Gemini", "Google AI Studio (API key)", ["gemini"]), "openai": ("OpenAI", "ChatGPT/Codex subscription or direct OpenAI API", ["openai-codex", "openai-api"]), "qwen": ("Qwen", "Qwen Cloud / DashScope, Coding Plan, Token Plan & Qwen CLI OAuth", ["alibaba", "alibaba-cn", "alibaba-coding-plan", "alibaba-coding-plan-cn", "alibaba-token-plan", "alibaba-token-plan-cn", "qwen-oauth"]), - "opencode": ("OpenCode", "Zen pay-as-you-go, Go subscription, or free tier", ["opencode-zen", "opencode-go", "opencode-free"]), + "opencode": ("OpenCode", "Zen pay-as-you-go or Go subscription", ["opencode-zen", "opencode-go"]), "copilot": ("GitHub Copilot", "GitHub token API or copilot --acp process", ["copilot", "copilot-acp"]), "tencent": ("Tencent Hy", "Hy4 / Hy3 via TokenHub & TokenPlan", ["tencent-tokenhub", "tencent-tokenplan"]), } @@ -462,8 +451,7 @@ _PROVIDER_ALIASES = dict(( ("minimax-china", "minimax-cn"), ("minimax_cn", "minimax-cn"), ("minimax-portal", "minimax-oauth"), ("minimax-global", "minimax-oauth"), ("minimax_oauth", "minimax-oauth"), ("claude", "anthropic"), ("claude-code", "anthropic"), ("deep-seek", "deepseek"), ("opencode", "opencode-zen"), ("zen", "opencode-zen"), - ("go", "opencode-go"), ("opencode-go-sub", "opencode-go"), ("free", "opencode-free"), - ("opencode_free", "opencode-free"), ("aigateway", "ai-gateway"), ("vercel", "ai-gateway"), + ("go", "opencode-go"), ("opencode-go-sub", "opencode-go"), ("aigateway", "ai-gateway"), ("vercel", "ai-gateway"), ("vercel-ai-gateway", "ai-gateway"), ("kilo", "kilocode"), ("kilo-code", "kilocode"), ("kilo-gateway", "kilocode"), ("dashscope", "alibaba"), ("aliyun", "alibaba"), ("qwen", "alibaba"), ("alibaba-cloud", "alibaba"), ("qwen-portal", "qwen-oauth"), ("hf", "huggingface"), @@ -545,9 +533,9 @@ _MODELS_DEV_PREFERRED: frozenset[str] = frozenset({ # Providers whose catalog is served with NO credential get a constant credential fingerprint in -# the disk cache: the anonymous opencode-free catalog's freshness comes from TTL revalidation, -# so folding in unrelated auth.json mtimes would only bust the SWR cache needlessly. -_KEYLESS_STABLE_CACHE_PROVIDERS = frozenset({"opencode-free"}) +# the disk cache, so folding in unrelated auth.json mtimes would only bust the SWR cache needlessly. +# (Empty since the keyless OpenCode free tier was removed; kept as the extension point.) +_KEYLESS_STABLE_CACHE_PROVIDERS = frozenset() # OpenRouter-style ids -> Copilot ids. Dash-notation Claude ids are accepted too: Hermes' default diff --git a/hermes_cli/provider_catalog.py b/hermes_cli/provider_catalog.py index 8f381e6a05..25c31ab092 100644 --- a/hermes_cli/provider_catalog.py +++ b/hermes_cli/provider_catalog.py @@ -104,7 +104,7 @@ def provider_catalog() -> list[ProviderDescriptor]: slug=slug, label=label, description=(prof.description if prof else "") or entry.tui_desc or label, auth_type=auth_type, tab=tab_for_auth_type(auth_type), api_key_env_vars=api_key_vars, base_url_env_var=base_url_var, signup_url=signup_url, order=order, - # Keyless providers (opencode-free) are served anonymously: no key card in the GUI, + # Keyless providers are served anonymously: no key card in the GUI, # and contract tests exempt them. keyless=bool(overlay.keyless) if overlay else False, ) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 54380eb718..55b1eef0f8 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -59,7 +59,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { "vercel": HermesOverlay(is_aggregator=True), "opencode": HermesOverlay(is_aggregator=True, base_url_env_var="OPENCODE_ZEN_BASE_URL"), "opencode-go": HermesOverlay(is_aggregator=True, base_url_env_var="OPENCODE_GO_BASE_URL"), - "opencode-free": HermesOverlay(is_aggregator=True, base_url_override="https://opencode.ai/zen/v1", keyless=True), "kilo": HermesOverlay(is_aggregator=True, base_url_env_var="KILOCODE_BASE_URL"), "huggingface": HermesOverlay(is_aggregator=True, base_url_env_var="HF_BASE_URL"), "novita": HermesOverlay(is_aggregator=True, base_url_env_var="NOVITA_BASE_URL"), @@ -122,8 +121,7 @@ _ALIAS_GROUPS: Dict[str, Tuple[str, ...]] = { "stepfun": ("step", "stepfun-coding-plan"), "minimax-cn": ("minimax-china", "minimax_cn"), "anthropic": ("claude", "claude-code"), "github-copilot": ("copilot", "github"), "copilot-acp": ("github-copilot-acp",), "vercel": ("ai-gateway", "aigateway", "vercel-ai-gateway"), - "opencode": ("opencode-zen", "zen"), "opencode-go": ("go", "opencode-go-sub"), - "opencode-free": ("free", "opencode_free"), "kilo": ("kilocode", "kilo-code", "kilo-gateway"), + "opencode": ("opencode-zen", "zen"), "opencode-go": ("go", "opencode-go-sub"), "kilo": ("kilocode", "kilo-code", "kilo-gateway"), "deepseek": ("deep-seek",), "alibaba": ("dashscope", "aliyun", "qwen", "alibaba-cloud"), "alibaba-coding-plan": ("alibaba_coding", "alibaba-coding", "alibaba_coding_plan"), "huggingface": ("hf", "hugging-face", "huggingface-hub"), "novita": ("novita-ai", "novitaai"), @@ -147,7 +145,7 @@ _LABEL_OVERRIDES: Dict[str, str] = { "upstage": "Upstage Solar", "actual": "Actual Computer", "tencent-tokenhub": "Tencent TokenHub", "nebius-token-factory": "Nebius Token Factory", "tencent-tokenplan": "Tencent TokenPlan", "lmstudio": "LM Studio", "local": "Local endpoint", "bedrock": "AWS Bedrock", "vertex": "Google Vertex AI", "ollama-cloud": "Ollama Cloud", - "xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)", "opencode-free": "OpenCode Free", + "xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)", } diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index a4c2988abd..35da50a91d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -838,16 +838,6 @@ def _openrouter_fallback(requested_provider, explicit_api_key, explicit_base_url explicit_base_url=explicit_base_url), requested_provider) -def _opencode_free_runtime(provider, requested_provider, model_cfg, target_model) -> Optional[Dict[str, Any]]: - """OpenCode Zen free tier (*-free slugs) is served ANONYMOUSLY on the Zen relay only: unknown - bearers 401 and the Go relay rejects free models, so free slugs route through the keyless Zen - runtime BEFORE the pool / explicit / api_key paths.""" - if _models.opencode_provider_family(provider) is None: - return None - model = str(target_model or model_cfg.get("default") or model_cfg.get("model") or "").strip() - return _tag(_models.opencode_zen_free_runtime(provider, model), requested_provider) - - def resolve_runtime_provider(*, requested: Optional[str] = None, explicit_api_key: Optional[str] = None, explicit_base_url: Optional[str] = None, target_model: Optional[str] = None) -> Dict[str, Any]: """Resolve runtime provider credentials for agent execution. Ladder (order is behavior — each @@ -904,7 +894,6 @@ def _ladder_rungs(requested_provider, explicit_api_key, explicit_base_url, targe yield _local_endpoint_bypass(requested_provider, explicit_api_key, explicit_base_url) provider = resolve_provider(requested_provider, explicit_api_key=explicit_api_key, explicit_base_url=explicit_base_url) model_cfg = _get_model_config() - yield _opencode_free_runtime(provider, requested_provider, model_cfg, target_model) yield _resolve_explicit_runtime(provider=provider, requested_provider=requested_provider, model_cfg=model_cfg, explicit_api_key=explicit_api_key, explicit_base_url=explicit_base_url, target_model=target_model) diff --git a/plugins/model-providers/opencode-free/__init__.py b/plugins/model-providers/opencode-free/__init__.py deleted file mode 100644 index e0c40ac540..0000000000 --- a/plugins/model-providers/opencode-free/__init__.py +++ /dev/null @@ -1,51 +0,0 @@ -"""OpenCode Free provider profile: the free tier on the Zen relay (https://opencode.ai/zen/v1). - -KEYLESS: the relay serves free-tier models anonymously and 401s any bearer it -doesn't recognize, so this provider never sends a credential (the runtime -resolver pins the keyless placeholder and an empty Authorization header; see -hermes_cli.models.opencode_zen_free_runtime). Select via ``/model free``. -""" - -from typing import Any - -from agent.reasoning_effort import ox_alpha_reasoning_extras -from hermes_cli import __version__ as _HERMES_VERSION -from providers import register_provider -from providers.base import ProviderProfile - - -class OpenCodeFreeProfile(ProviderProfile): - """OpenCode Free — keyless, with Ox Alpha reasoning controls. - - Ox Alpha (x-preview-f-free) is also reachable via opencode-zen with the same wire - contract; both profiles call ``agent.reasoning_effort.ox_alpha_reasoning_extras``. - """ - - def build_api_kwargs_extras( - self, *, reasoning_config: dict | None = None, model: str | None = None, **context - ) -> tuple[dict[str, Any], dict[str, Any]]: - return ox_alpha_reasoning_extras(reasoning_config, model) - - -opencode_free = OpenCodeFreeProfile( - name="opencode-free", aliases=("free", "opencode_free"), - env_vars=(), # keyless — nothing to configure - base_url="https://opencode.ai/zen/v1", display_name="OpenCode Free", - description="OpenCode free models — keyless, no account needed", - # Attribution headers (same values as opencode-zen/go) plus the empty Authorization - # override that keeps the SDK's "Bearer " off the wire (free tier 401s it). - default_headers={ - "Authorization": "", - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Title": "Hermes Agent", - "User-Agent": f"HermesAgent/{_HERMES_VERSION}", - }, - # laguna-s-2.1-free was delisted by the relay 2026-09-09 (anon 401). Of the - # surviving anonymous models mimo-v2.5-free is the only one that answers - # promptly (200 in 2-4s on every probe, 2026-09-13); nemotron-3.5-lightning-free - # hung >90s with no bytes on 4/4 probes and nemotron-3-ultra-free took ~40s. - # big-pickle 429s every client except the opencode CLI's own User-Agent. - default_aux_model="mimo-v2.5-free", -) - -register_provider(opencode_free) diff --git a/plugins/model-providers/opencode-free/plugin.yaml b/plugins/model-providers/opencode-free/plugin.yaml deleted file mode 100644 index 41c33d4ea2..0000000000 --- a/plugins/model-providers/opencode-free/plugin.yaml +++ /dev/null @@ -1,5 +0,0 @@ -name: opencode-free-provider -kind: model-provider -version: 1.0.0 -description: OpenCode Free Models -author: bilboquet diff --git a/tests/agent/test_fallback_api_mode_preservation.py b/tests/agent/test_fallback_api_mode_preservation.py index 71e651d5d0..6a6a330f4d 100644 --- a/tests/agent/test_fallback_api_mode_preservation.py +++ b/tests/agent/test_fallback_api_mode_preservation.py @@ -201,7 +201,7 @@ class TestPlainFallbackUnchanged: class TestOpenCodeFamilyPerModelWire: - """OpenCode Zen/Go/free serve Responses-only, Anthropic-wire and chat-completions models behind + """OpenCode Zen/Go serve Responses-only, Anthropic-wire and chat-completions models behind one provider; a fallback entry must land on the same wire the primary /model path picks (#102148: muse-spark on opencode-go was sent to /chat/completions → deterministic 500).""" @@ -209,7 +209,6 @@ class TestOpenCodeFamilyPerModelWire: ("entry", "resolved_base_url", "expected_mode"), [ ({"provider": "opencode-go", "model": "muse-spark-1.3-contributor"}, "https://opencode.ai/zen/go/v1", "codex_responses"), - ({"provider": "opencode-free", "model": "muse-spark-1.3-contributor-free"}, "https://opencode.ai/zen/v1", "codex_responses"), ({"provider": "opencode-go", "model": "minimax-m2.7"}, "https://opencode.ai/zen/go/v1", "anthropic_messages"), ({"provider": "custom", "model": "muse-spark-1.3-contributor", "base_url": "https://opencode.ai/zen/go/v1", "api_key": "k"}, "https://opencode.ai/zen/go/v1", "codex_responses"), diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 9372cca14e..2076c57759 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -304,7 +304,6 @@ class TestDefaultContextLengths: ) == 1_048_576 @pytest.mark.parametrize("model, provider, base_url", [ - ("muse-spark-1.3-contributor-free", "opencode-free", "https://opencode.ai/zen/v1"), ("muse-spark-1.3-contributor", "opencode-go", "https://opencode.ai/zen/go/v1"), ("muse-spark-1.3", "meta-ai", "https://api.meta.ai/v1"), ("meta/muse-spark-1.3", "commandcode", "https://api.commandcode.ai/provider/v1"), diff --git a/tests/agent/test_models_dev_meta_mapping.py b/tests/agent/test_models_dev_meta_mapping.py index a4c110cf5a..328c5adb3e 100644 --- a/tests/agent/test_models_dev_meta_mapping.py +++ b/tests/agent/test_models_dev_meta_mapping.py @@ -8,6 +8,7 @@ def test_meta_ai_maps_to_meta(): assert PROVIDER_TO_MODELS_DEV.get("meta") == "meta" -def test_opencode_free_maps_to_zen_catalog(): - # The free tier is served by the Zen relay, whose models.dev id is "opencode". - assert PROVIDER_TO_MODELS_DEV.get("opencode-free") == "opencode" +def test_opencode_free_is_no_longer_mapped(): + # The keyless OpenCode free tier was removed (OpenCode 403s anonymous access outside its + # own client), so `opencode-free` must not claim a models.dev catalog anymore. + assert "opencode-free" not in PROVIDER_TO_MODELS_DEV diff --git a/tests/agent/test_opencode_free_client_headers.py b/tests/agent/test_opencode_free_client_headers.py deleted file mode 100644 index d75ab1824e..0000000000 --- a/tests/agent/test_opencode_free_client_headers.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Regression guard: opencode-free client keyless header handling. - -OpenCode's free tier at ``https://opencode.ai/zen/v1`` is served ANONYMOUSLY: -requests with no recognizable Authorization bearer succeed, while any bearer -the relay doesn't recognize — placeholders included — is rejected with 401 -"Invalid API key" (verified live 2026-08-21). - -The client therefore must ship an EMPTY ``Authorization`` default header for -every opencode-free build, which overrides the OpenAI SDK's always-injected -``Authorization: Bearer `` so no credential-shaped value ever -reaches the wire. -""" -from unittest.mock import MagicMock, patch - -from agent.agent_runtime_helpers import create_openai_client - -ZEN_V1 = "https://opencode.ai/zen/v1" - - -class _FakeAgent: - def __init__(self, api_key): - self.provider = "opencode-free" - self.base_url = ZEN_V1 - self.api_key = api_key - self.model = "x-preview-f-free" - self.api_mode = "chat_completions" - - def _client_log_context(self): - return {} - - def _build_keepalive_http_client(self, base_url, verify=True): - return None - - -def _zen_call_headers(mock_openai): - matching = [ - c for c in mock_openai.call_args_list - if c.kwargs.get("base_url") == ZEN_V1 - ] - assert matching, "OpenAI was never constructed with the zen base_url" - return dict(matching[-1].kwargs.get("default_headers") or {}) - - -@patch("agent.process_bootstrap.OpenAI") -def test_opencode_free_blanks_authorization_header(mock_openai): - """Whatever api_key value reaches the client build (placeholder, stale - key, empty), the Authorization default header must be blanked so the - SDK's Bearer never hits the wire.""" - mock_openai.return_value = MagicMock() - for key in ("opencode-zen-free-keyless", "no-key-required", "", "sk-stale"): - mock_openai.reset_mock() - create_openai_client( - _FakeAgent(api_key=key), - {"api_key": key, "base_url": ZEN_V1}, - reason="test", - shared=False, - ) - headers = _zen_call_headers(mock_openai) - assert headers.get("Authorization") == "", ( - f"opencode-free with api_key={key!r} must blank Authorization; " - f"got {headers!r}" - ) - - -@patch("agent.process_bootstrap.OpenAI") -def test_opencode_free_sends_hermes_attribution(mock_openai): - """Keyless requests still identify as Hermes (attribution headers match - the opencode zen/go profiles).""" - mock_openai.return_value = MagicMock() - create_openai_client( - _FakeAgent(api_key="opencode-zen-free-keyless"), - {"api_key": "opencode-zen-free-keyless", "base_url": ZEN_V1}, - reason="test", - shared=False, - ) - headers = _zen_call_headers(mock_openai) - assert headers.get("X-Title") == "Hermes Agent" - assert str(headers.get("User-Agent", "")).startswith("HermesAgent/") - - -@patch("agent.process_bootstrap.OpenAI") -def test_other_providers_unaffected(mock_openai): - """The opencode-free header policy must not leak to other providers.""" - mock_openai.return_value = MagicMock() - agent = _FakeAgent(api_key="sk-real") - agent.provider = "opencode-zen" - create_openai_client( - agent, - {"api_key": "sk-real", "base_url": ZEN_V1}, - reason="test", - shared=False, - ) - headers = _zen_call_headers(mock_openai) - assert "Authorization" not in headers, ( - "opencode-zen (keyed) must not have its Authorization header blanked" - ) - - -@patch("agent.process_bootstrap.OpenAI") -def test_keyless_placeholder_blanks_authorization_under_paid_opencode_profile(mock_openai): - """A free slug selected under the paid ``opencode`` profile resolves to the keyless - placeholder; it must be blanked exactly like under ``opencode-free``, or every request - 401s with nothing in the pool to rotate (#110831).""" - mock_openai.return_value = MagicMock() - agent = _FakeAgent(api_key="opencode-zen-free-keyless") - agent.provider = "opencode" - create_openai_client( - agent, - {"api_key": "opencode-zen-free-keyless", "base_url": ZEN_V1}, - reason="test", - shared=False, - ) - assert _zen_call_headers(mock_openai).get("Authorization") == "" - - -def test_async_aux_wrapper_keeps_keyless_authorization_blank(): - """``_to_async_client`` rebuilds default_headers; the keyless placeholder must stay - blanked on the async twin too, or every async aux call ships the placeholder bearer.""" - import openai - import agent.auxiliary_client as aux - - sync_client = aux._create_openai_client(api_key="opencode-zen-free-keyless", base_url=ZEN_V1) - async_client, _ = aux._to_async_client(sync_client, "x-preview-f-free") - request = async_client._build_request( - openai._models.FinalRequestOptions.construct(method="post", url="/chat/completions", json_data={}) - ) - assert request.headers.get("authorization") == "" diff --git a/tests/agent/test_opencode_free_provider.py b/tests/agent/test_opencode_free_provider.py deleted file mode 100644 index 87dbbd52f0..0000000000 --- a/tests/agent/test_opencode_free_provider.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Tests for OpenCode Free provider — registration, keyless contract, aliases. - -The provider is KEYLESS: OpenCode's free tier is served anonymously and -rejects any unrecognized Authorization bearer with 401, so the provider -declares no env vars and every request goes out with an empty Authorization -header (see hermes_cli.models.opencode_zen_free_runtime). -""" - -import os -from unittest.mock import patch - - -class TestOpenCodeFreeProviderRegistration: - """Verify the opencode-free provider registers correctly.""" - - def test_provider_is_registered(self): - from providers import get_provider_profile - profile = get_provider_profile("opencode-free") - assert profile is not None - assert profile.name == "opencode-free" - - def test_provider_has_correct_base_url(self): - from providers import get_provider_profile - profile = get_provider_profile("opencode-free") - assert profile.base_url == "https://opencode.ai/zen/v1" - - def test_provider_is_keyless(self): - """No env vars declared — the free tier requires no credential.""" - from providers import get_provider_profile - profile = get_provider_profile("opencode-free") - assert profile.env_vars == () - - def test_provider_headers_override_sdk_bearer(self): - """The profile's default headers blank Authorization so the SDK's - Bearer never reaches the wire (the free tier 401s unknown bearers).""" - from providers import get_provider_profile - profile = get_provider_profile("opencode-free") - assert profile.default_headers.get("Authorization") == "" - - def test_provider_uses_chat_completions_mode(self): - from providers import get_provider_profile - profile = get_provider_profile("opencode-free") - assert profile.api_mode == "chat_completions" - - -class TestOpenCodeFreeAliases: - """Verify alias resolution for the opencode-free provider.""" - - def test_alias_free(self): - from providers import get_provider_profile - profile = get_provider_profile("free") - assert profile is not None - assert profile.name == "opencode-free" - - def test_alias_opencode_free(self): - from providers import get_provider_profile - profile = get_provider_profile("opencode_free") - assert profile is not None - assert profile.name == "opencode-free" - - -class TestOpenCodeFreeAuthAlias: - """Verify the hardcoded alias in auth.py resolve_provider().""" - - def test_resolve_provider_free_alias(self): - from hermes_cli.auth import resolve_provider - # "free" should resolve to "opencode-free" without any credential - result = resolve_provider("free") - assert result == "opencode-free" - - -class TestOpenCodeFreeModelLists: - """Curated keyless model lists exist and stay in sync.""" - - def test_delisted_ox_alpha_not_in_floor(self): - """x-preview-f-free was delisted by the relay 2026-08-26 (401s keyless); - the offline floor must not offer it (#95914).""" - from hermes_cli.models import _PROVIDER_MODELS - assert "x-preview-f-free" not in _PROVIDER_MODELS["opencode-free"] - - -class TestOpenCodeFreeRuntimeKeyless: - """The runtime resolver pins every opencode-free model keyless.""" - - def test_free_provider_any_model_routes_keyless(self): - from hermes_cli.models import ( - OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, - opencode_zen_free_runtime, - ) - rt = opencode_zen_free_runtime("opencode-free", "big-pickle") - assert rt is not None - assert rt["api_key"] == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER - assert rt["base_url"] == "https://opencode.ai/zen/v1" - assert rt["default_headers"]["Authorization"] == "" - - def test_free_provider_muse_routes_responses(self): - """opencode-free inherits Zen's per-model endpoint routing.""" - from hermes_cli.models import opencode_zen_free_runtime - rt = opencode_zen_free_runtime( - "opencode-free", "muse-spark-1.2-contributor-free" - ) - assert rt is not None - assert rt["api_mode"] == "codex_responses" diff --git a/tests/agent/test_opencode_session_affinity.py b/tests/agent/test_opencode_session_affinity.py index f0fb4ad8bf..ee7877816f 100644 --- a/tests/agent/test_opencode_session_affinity.py +++ b/tests/agent/test_opencode_session_affinity.py @@ -38,7 +38,6 @@ def _agent(provider, model, base_url, api_mode=None): ("opencode-go", "glm-5", "https://opencode.ai/zen/go/v1", None), # chat_completions ("opencode-go", "gpt-5.6-luna", "https://opencode.ai/zen/go/v1", None), # codex_responses ("opencode-go", "minimax-m2.7", "https://opencode.ai/zen/go/v1", "anthropic_messages"), - ("opencode-free", "nemotron-3.5-lightning-free", "https://opencode.ai/zen/v1", None), ("custom", "glm-5", "https://opencode.ai/zen/go/v1", None), # URL-only detection ], ) diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 127b05d280..777bc0b7ac 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -348,8 +348,6 @@ class TestNormalizeOpencodeBaseUrlFamilyPath: @pytest.mark.parametrize("provider, api_mode, url, expected", [ ("opencode-go", "chat_completions", "https://opencode.ai/zen/v1", "https://opencode.ai/zen/go/v1"), ("opencode-zen", "chat_completions", "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/v1"), - # opencode-free is served on the Zen relay, so it maps back to /zen (not /zen/go). - ("opencode-free", "chat_completions", "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/v1"), # Family healed first, then the /v1 strip for the Anthropic SDK — both apply. ("opencode-go", "anthropic_messages", "https://opencode.ai/zen/v1", "https://opencode.ai/zen/go"), ("opencode-zen", "anthropic_messages", "https://opencode.ai/zen/go", "https://opencode.ai/zen"), diff --git a/tests/hermes_cli/test_opencode_free_live_catalog.py b/tests/hermes_cli/test_opencode_free_live_catalog.py deleted file mode 100644 index cb226c1c2b..0000000000 --- a/tests/hermes_cli/test_opencode_free_live_catalog.py +++ /dev/null @@ -1,269 +0,0 @@ -"""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; -# hy3-free and laguna-s-2.1-free were DELISTED 2026-09-09 (gone from live -# /models, anon 401 "Model … is not supported"); deepseek-v4-flash-free was -# DELISTED 2026-09-15 (still LISTED by /models, every POST 400s "Model is unavailable"). -_LIVE_FREE_MODELS = [ - "mimo-v2.5-free", - "nemotron-3-ultra-free", - "nemotron-3.5-lightning-free", - "muse-spark-1.2-contributor-free", - "muse-spark-1.3-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) - "deepseek-v4-flash-free", # delisted but still listed; POST → 400 "Model is unavailable" -] - - -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 "nemotron-3-ultra-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) - - -class TestOpencodeFreeFollowUps: - """Follow-up hardening on top of the salvaged live-catalog fix (#95943).""" - - def _reset_memo(self, mod): - mod._opencode_free_live_memo = None - - def test_fetch_memoizes_success_in_process(self): - """Direct provider_model_ids() callers must not each pay a network - round-trip: the second call within the memo TTL is served in-process.""" - import hermes_cli.models as mod - - self._reset_memo(mod) - calls = {"n": 0} - - def fake_open(req, timeout): - calls["n"] += 1 - import io, json as _json - - class _Resp(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - return _Resp( - _json.dumps({"data": [{"id": m} for m in _LIVE_FREE_MODELS]}).encode() - ) - - with patch("hermes_cli.urllib_security.open_credentialed_url", fake_open): - first = mod._fetch_opencode_free_models() - second = mod._fetch_opencode_free_models() - assert first == second == list(_LIVE_FREE_MODELS) - assert calls["n"] == 1 # memo served the second call - self._reset_memo(mod) - - def test_fetch_memoizes_failure_negative_cache(self): - """An unreachable relay is memoized too — repeated validations must not - each block for the full network timeout.""" - import hermes_cli.models as mod - - self._reset_memo(mod) - calls = {"n": 0} - - def fake_open(req, timeout): - calls["n"] += 1 - raise OSError("relay down") - - with patch("hermes_cli.urllib_security.open_credentialed_url", fake_open): - assert mod._fetch_opencode_free_models() is None - assert mod._fetch_opencode_free_models() is None - assert calls["n"] == 1 - self._reset_memo(mod) - - def test_fetch_drops_listed_but_delisted_model(self): - """A delisted id the relay still LISTS (deepseek-v4-flash-free: docs/zen dropped it, - every POST 400s "Model is unavailable") must not reach the keyless picker; offering it - lets a first-turn 400 drive a fallback switch that strands the session (#111749).""" - import hermes_cli.models as mod - - self._reset_memo(mod) - - def fake_open(req, timeout): - import io, json as _json - - class _Resp(io.BytesIO): - def __enter__(self): - return self - - def __exit__(self, *a): - return False - - return _Resp(_json.dumps({"data": [{"id": m} for m in _LIVE_RAW_IDS]}).encode()) - - try: - with patch("hermes_cli.urllib_security.open_credentialed_url", fake_open): - live = mod._fetch_opencode_free_models(force_refresh=True) - finally: - self._reset_memo(mod) - assert live is not None - assert "deepseek-v4-flash-free" not in live - assert "ox-alpha-free" not in live - assert "mimo-v2.5-free" in live # control: a servable free model survives the filter - - def test_keyed_zen_live_first_picker_drops_delisted_model(self, monkeypatch): - """The keyed opencode-zen picker is live-first over GET /zen/v1/models, which still lists - deepseek-v4-flash-free; it must take the same exclusion as the keyless catalog (#111749).""" - import hermes_cli.models as mod - from providers import get_provider_profile - - prof = get_provider_profile("opencode-zen") - monkeypatch.setenv("OPENCODE_ZEN_API_KEY", "sk-zen-fake") - with patch.object(type(prof), "fetch_models", lambda self, **kw: list(_LIVE_RAW_IDS)): - zen = mod._profile_live_catalog("opencode-zen") - assert zen is not None - assert "deepseek-v4-flash-free" not in zen - assert "mimo-v2.5-free" in zen # control: a servable live model still leads - - def test_heal_union_includes_live_only_model(self): - """A newly-live free model absent from the static floor must still heal - opencode-go/zen selections to the keyless Zen relay (sibling-site widen: - opencode_zen_free_runtime used to check the floor only).""" - import hermes_cli.models as mod - - live_only = "ling-3.0-flash-fin-free" - assert live_only not in {m.lower() for m in _PROVIDER_MODELS["opencode-free"]} - - self._reset_memo(mod) - with patch.object(mod, "_load_provider_models_cache", return_value={}): - assert mod.opencode_zen_free_runtime("opencode-go", live_only) is None - - mod._set_opencode_free_live_memo(_LIVE_FREE_MODELS + [live_only]) - try: - with patch.object(mod, "_load_provider_models_cache", return_value={}): - rt = mod.opencode_zen_free_runtime("opencode-go", live_only) - assert rt is not None and rt["source"] == "opencode-zen-free-keyless" - finally: - self._reset_memo(mod) - - def test_heal_union_reads_swr_disk_cache(self): - """A fresh process (empty memo) still heals live-only models via the - SWR disk-cache entry — no blocking fetch on the resolution hot path.""" - import hermes_cli.models as mod - - live_only = "ling-3.0-flash-fin-free" - self._reset_memo(mod) - entry = {"opencode-free": {"fp": "keyless:opencode-free", "at": 0.0, "models": [live_only]}} - with patch.object(mod, "_load_provider_models_cache", return_value=entry): - rt = mod.opencode_zen_free_runtime("opencode-zen", live_only) - assert rt is not None - - def test_static_floor_excludes_delisted_model(self): - """The offline floor must not offer a model known to 401 (#95914).""" - assert "x-preview-f-free" not in _PROVIDER_MODELS["opencode-free"] - assert "hy3-free" not in _PROVIDER_MODELS["opencode-free"] - assert "laguna-s-2.1-free" not in _PROVIDER_MODELS["opencode-free"] - assert "deepseek-v4-flash-free" not in _PROVIDER_MODELS["opencode-free"] diff --git a/tests/hermes_cli/test_opencode_zen_free_keyless.py b/tests/hermes_cli/test_opencode_zen_free_keyless.py deleted file mode 100644 index f49427816b..0000000000 --- a/tests/hermes_cli/test_opencode_zen_free_keyless.py +++ /dev/null @@ -1,143 +0,0 @@ -"""OpenCode Zen free-tier keyless routing (x-preview-f-free / "Ox Alpha"). - -The Zen relay serves ``*-free`` models ANONYMOUSLY: a request with no -Authorization header succeeds, while any non-empty bearer the relay doesn't -recognize — including our historical "no-key-required" placeholder and valid -OpenCode GO subscription keys — is rejected with 401 "Invalid API key". -The Go relay doesn't serve the free tier at all ("Model x is not supported"). - -These tests pin the keyless routing added for the community report where the -free Ox Alpha model failed under an OpenCode subscription: - -1. ``opencode_zen_free_runtime`` pins free slugs to the Zen relay with the - keyless placeholder + empty-Authorization headers, for BOTH family - providers (Go selections heal to Zen). -2. ``resolve_runtime_provider`` routes free slugs keylessly with no - OPENCODE_* credential present, and still fails closed for paid models. -3. The keyless placeholder never reaches the wire: client default_headers - carry ``Authorization: ""`` overriding the SDK bearer. -""" - -import os -from unittest import mock - -import pytest - -from hermes_cli.models import ( - OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, - opencode_zen_free_headers, - opencode_zen_free_runtime, -) - - -class TestFreeRuntime: - def test_zen_provider_free_model(self): - rt = opencode_zen_free_runtime("opencode-zen", "nemotron-3.5-lightning-free") - assert rt is not None - assert rt["base_url"] == "https://opencode.ai/zen/v1" - assert rt["api_key"] == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER - assert rt["api_mode"] == "chat_completions" - assert rt["default_headers"]["Authorization"] == "" - - def test_go_provider_heals_to_zen(self): - # Free slugs only exist on the Zen relay; a Go selection must be - # routed to Zen (the Go relay rejects the model outright). - rt = opencode_zen_free_runtime("opencode-go", "nemotron-3.5-lightning-free") - assert rt is not None - assert rt["base_url"] == "https://opencode.ai/zen/v1" - - def test_go_ox_alpha_free_does_not_heal_to_zen(self): - """ox-alpha-free is a KEYED Go-subscription model despite its -free - suffix (Zen doesn't serve it; Go 401s anonymous). Membership in the - verified keyless catalog — not the suffix — gates the heal.""" - assert opencode_zen_free_runtime("opencode-go", "ox-alpha-free") is None - assert opencode_zen_free_runtime("opencode-zen", "ox-alpha-free") is None - - def test_paid_model_returns_none(self): - assert opencode_zen_free_runtime("opencode-zen", "claude-sonnet-5") is None - - def test_non_opencode_provider_returns_none(self): - assert opencode_zen_free_runtime("openrouter", "x-preview-f-free") is None - assert opencode_zen_free_runtime(None, "x-preview-f-free") is None - - def test_headers_override_sdk_bearer(self): - headers = opencode_zen_free_headers() - assert headers["Authorization"] == "" - assert headers["X-Title"] == "Hermes Agent" - - -class TestRuntimeProviderKeylessRouting: - @pytest.fixture(autouse=True) - def _no_opencode_creds(self, monkeypatch): - for var in ("OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY"): - monkeypatch.delenv(var, raising=False) - - def _resolve(self, provider, model): - from hermes_cli.runtime_provider import resolve_runtime_provider - - with mock.patch( - "hermes_cli.runtime_provider._get_model_config", - return_value={"provider": provider, "model": model, "default": model}, - ): - return resolve_runtime_provider(requested=provider, target_model=model) - - def test_zen_free_model_resolves_keyless(self): - rt = self._resolve("opencode-zen", "nemotron-3.5-lightning-free") - assert rt["api_key"] == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER - assert rt["base_url"] == "https://opencode.ai/zen/v1" - assert rt["api_mode"] == "chat_completions" - - def test_go_free_model_resolves_keyless_on_zen(self): - rt = self._resolve("opencode-go", "nemotron-3.5-lightning-free") - assert rt["api_key"] == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER - assert rt["base_url"] == "https://opencode.ai/zen/v1" - - def test_paid_model_still_fails_closed_without_key(self): - from hermes_cli.auth import AuthError - - with pytest.raises(AuthError): - self._resolve("opencode-zen", "claude-sonnet-5") - - -class TestKeylessProviderAlwaysAuthenticated: - """opencode-free counts as authenticated everywhere, with zero keys. - - The provider is keyless: there is no credential to configure, so every - surface that gates on auth (get_auth_status, provider:model listing, - the /model picker source, the desktop explicit-only filter) must treat - every install as logged in. - """ - - @pytest.fixture(autouse=True) - def _no_creds(self, monkeypatch): - for var in ("OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY"): - monkeypatch.delenv(var, raising=False) - - def test_auth_status_logged_in(self): - from hermes_cli.auth import get_auth_status - - st = get_auth_status("opencode-free") - assert st["logged_in"] is True - assert st["configured"] is True - assert st["key_source"] == "keyless" - - def test_list_available_providers_authenticated(self): - from hermes_cli.models import list_available_providers - - rows = {r["id"]: r["authenticated"] for r in list_available_providers()} - assert rows.get("opencode-free") is True - - def test_picker_source_includes_provider_with_models(self): - import model_tools # noqa: F401 — plugin discovery - from hermes_cli.model_switch import list_authenticated_providers - - provs = list_authenticated_providers(for_picker=True) - free = [p for p in provs if p["slug"] == "opencode-free"] - assert free, "opencode-free must appear in the picker with zero keys" - assert free[0]["models"], "picker row must carry the curated models" - - def test_explicit_only_filter_keeps_keyless(self): - from hermes_cli.inventory import _provider_is_keyless - - assert _provider_is_keyless("opencode-free") is True - assert _provider_is_keyless("opencode-zen") is False diff --git a/tests/hermes_cli/test_provider_catalog.py b/tests/hermes_cli/test_provider_catalog.py index 89fe2a31f6..1ecff0947c 100644 --- a/tests/hermes_cli/test_provider_catalog.py +++ b/tests/hermes_cli/test_provider_catalog.py @@ -59,8 +59,8 @@ def test_api_key_providers_expose_a_credential_env_var(): Exemptions: ``aws_sdk`` (bedrock — uses AWS_REGION/AWS_PROFILE), the ``custom`` bring-your-own-endpoint pseudo-provider (configured inline via - the local-endpoint flow), and keyless providers (``d.keyless`` — e.g. - opencode-free, served anonymously: there is no credential to write). + the ``local-endpoint`` flow), and keyless providers (``d.keyless`` — + served anonymously: there is no credential to write). """ exempt = {"custom"} for d in provider_catalog(): diff --git a/tests/hermes_cli/test_provider_parity.py b/tests/hermes_cli/test_provider_parity.py index f5961184cd..1fb6a56c29 100644 --- a/tests/hermes_cli/test_provider_parity.py +++ b/tests/hermes_cli/test_provider_parity.py @@ -31,7 +31,7 @@ HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN} # derived from the catalog so any future virtual provider is covered without a # hardcoded slug. _VIRTUAL = {d.slug for d in provider_catalog() if d.auth_type == "virtual"} -# Keyless providers (opencode-free) are served anonymously: no credential +# Keyless providers are served anonymously: no credential # exists, so there is nothing to configure on either Providers tab. Derived # from the catalog flag so any future keyless provider is covered. _KEYLESS = {d.slug for d in provider_catalog() if d.keyless} diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index d09c6b280c..e3ff5a5972 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -1842,61 +1842,6 @@ def test_resolve_named_custom_runtime_pool_result_includes_extra_headers(monkeyp assert resolved["requested_provider"] == "custom:lmstudio" -def test_resolve_runtime_provider_opencode_free_keyless_despite_exhausted_pool(monkeypatch): - """OpenCode Free is keyless: an exhausted credential pool must not raise - a missing-credential error. The provider resolves with the keyless - placeholder + empty-Authorization headers so the request goes out - anonymously.""" - class _ExhaustedPool: - def has_credentials(self): - return True - - def select(self, **_kwargs): - return None - - monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-free") - monkeypatch.setattr( - rp, - "_get_model_config", - lambda: {"provider": "opencode-free", "default": "x-preview-f-free"}, - ) - monkeypatch.setattr(rp, "load_pool", lambda provider: _ExhaustedPool()) - - resolved = rp.resolve_runtime_provider( - requested="opencode-free", target_model="x-preview-f-free" - ) - - assert resolved["provider"] == "opencode-free" - assert resolved["api_key"] == "opencode-zen-free-keyless" - assert resolved["base_url"] == "https://opencode.ai/zen/v1" - assert resolved["api_mode"] == "chat_completions" - assert resolved["default_headers"]["Authorization"] == "" - - -def test_resolve_runtime_provider_opencode_free_missing_env_still_resolves(monkeypatch): - """OpenCode Free resolves keylessly with no env var configured at all — - the provider declares no credentials.""" - class _NoPool: - def has_credentials(self): - return False - - monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "opencode-free") - monkeypatch.setattr( - rp, - "_get_model_config", - lambda: {"provider": "opencode-free", "default": "x-preview-f-free"}, - ) - monkeypatch.setattr(rp, "load_pool", lambda provider: _NoPool()) - - resolved = rp.resolve_runtime_provider( - requested="opencode-free", target_model="x-preview-f-free" - ) - - assert resolved["provider"] == "opencode-free" - assert resolved["api_key"] == "opencode-zen-free-keyless" - assert resolved["base_url"] == "https://opencode.ai/zen/v1" - - def test_custom_provider_explicit_target_model_wins(monkeypatch): """An explicit target_model must not be silently replaced by the custom provider's configured default model (regression: auxiliary slots such as diff --git a/tests/plugins/model_providers/test_opencode_go_profile.py b/tests/plugins/model_providers/test_opencode_go_profile.py index f10eb87902..0540472c1b 100644 --- a/tests/plugins/model_providers/test_opencode_go_profile.py +++ b/tests/plugins/model_providers/test_opencode_go_profile.py @@ -81,30 +81,6 @@ class TestOpenCodeZenOxReasoning: ) assert top_level == {"reasoning_effort": expected}, requested - def test_opencode_free_profile_shares_the_translation(self): - """Ox Alpha is reachable via the keyless opencode-free provider too; - its profile must emit the identical clamped reasoning_effort.""" - import model_tools # noqa: F401 - import providers - from providers.base import ProviderProfile - - profile = providers.get_provider_profile("opencode-free") - assert profile is not None - assert ( - type(profile).build_api_kwargs_extras - is not ProviderProfile.build_api_kwargs_extras - ), "opencode-free must override build_api_kwargs_extras (aux gate)" - _, top_level = profile.build_api_kwargs_extras( - reasoning_config={"enabled": True, "effort": "medium"}, - model="x-preview-f-free", - ) - assert top_level == {"reasoning_effort": "low"} - _, other = profile.build_api_kwargs_extras( - reasoning_config={"enabled": True, "effort": "max"}, - model="big-pickle", - ) - assert other == {} - class TestOpenCodeGoKimiReasoning: """Kimi K2 models use Moonshot's thinking + reasoning_effort shape on OpenCode Go.""" diff --git a/tests/plugins/model_providers/test_thinking_toggle_parity.py b/tests/plugins/model_providers/test_thinking_toggle_parity.py index 3c3041b4c6..7000afa8ae 100644 --- a/tests/plugins/model_providers/test_thinking_toggle_parity.py +++ b/tests/plugins/model_providers/test_thinking_toggle_parity.py @@ -35,11 +35,12 @@ def test_thinking_toggle_and_effort_never_both_on_moonshot_wire(reasoning_config @pytest.mark.parametrize("reasoning_config", REASONING_MATRIX, ids=str) -def test_ox_alpha_translation_identical_on_zen_and_free(reasoning_config): - zen = get_provider_profile("opencode-zen").build_api_kwargs_extras( +def test_ox_alpha_translation_on_zen(reasoning_config): + extra_body, top_level = get_provider_profile("opencode-zen").build_api_kwargs_extras( reasoning_config=reasoning_config, model="x-preview-f-free" ) - free = get_provider_profile("opencode-free").build_api_kwargs_extras( - reasoning_config=reasoning_config, model="x-preview-f-free" - ) - assert zen == free + # Ox Alpha's wire carries reasoning_effort at top level — never the thinking + # toggle — and every emitted effort is inside the wire vocabulary. + assert "thinking" not in extra_body + effort = top_level.get("reasoning_effort") + assert effort in (None, "low", "high", "max"), (reasoning_config, effort)