diff --git a/acp_adapter/session.py b/acp_adapter/session.py index 63717fc01d..dbee83263a 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -400,7 +400,8 @@ class SessionManager: "cwd": cwd, } try: - runtime = resolve_runtime_provider(requested=requested_provider or config_provider) + runtime = resolve_runtime_provider( + requested=requested_provider or config_provider, target_model=(model or default_model) or None) kwargs.update({ "provider": runtime.get("provider"), "api_mode": api_mode or runtime.get("api_mode"), "base_url": base_url or runtime.get("base_url"), "api_key": runtime.get("api_key"), diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index bdb7843627..df5725c915 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -2006,7 +2006,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter): except Exception as exc: with suppress(Exception): from gateway.run import _resolve_runtime_agent_kwargs_for_provider - return _resolve_runtime_agent_kwargs_for_provider(provider_name) + return _resolve_runtime_agent_kwargs_for_provider(provider_name, target_model=target_model or None) if required: raise _ProviderAuthResolutionError(str(exc)) from exc logger.debug( diff --git a/gateway/run.py b/gateway/run.py index 75238b5928..31e9fa352a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2381,11 +2381,15 @@ def _resolve_gateway_model_context( context_length=context_length, context_source=context_source) -def _resolve_runtime_agent_kwargs_for_provider(provider: str) -> dict: - """Resolve runtime credentials for a specific provider (e.g. from channel override).""" +def _resolve_runtime_agent_kwargs_for_provider(provider: str, target_model: Optional[str] = None) -> dict: + """Resolve runtime credentials for a specific provider (e.g. from channel override). + + ``target_model`` is the model the override will actually send: the ladder's model-keyed rungs + (OpenCode free tier, Zen/Go relay + api_mode) must see it rather than config's ``default``, + or a ``*-free`` default routes a Go-only override to the keyless Zen relay (#112600).""" from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error try: - runtime = resolve_runtime_provider(requested=provider) + runtime = resolve_runtime_provider(requested=provider, target_model=target_model or None) except Exception as exc: raise RuntimeError(format_runtime_provider_error(exc)) from exc return { @@ -2430,7 +2434,7 @@ def _try_resolve_fallback_provider() -> dict | None: from hermes_cli.fallback_config import effective_runtime_provider, resolve_entry_api_key runtime = resolve_runtime_provider( requested=entry.get("provider"), explicit_base_url=entry.get("base_url"), - explicit_api_key=resolve_entry_api_key(entry)) + explicit_api_key=resolve_entry_api_key(entry), target_model=entry.get("model") or None) # Named custom entries resolve to the bare "custom" billing class; persist the configured # identity so UI/billing rows match the manual-switch path (#98739). runtime["provider"] = effective_runtime_provider(entry, runtime) diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index 04ee5b6b02..f228602ff9 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -161,7 +161,7 @@ class GatewayAgentCacheMixin: # since the switch) keep the credential-less override — _resolve_session_agent_runtime # falls back to env resolution and layers model/provider. try: - runtime = _resolve_runtime_agent_kwargs_for_provider(provider) + runtime = _resolve_runtime_agent_kwargs_for_provider(provider, target_model=persisted.get("model") or None) for k in ("api_key", "api_mode", "credential_pool", "requested_provider", "max_tokens"): override[k] = runtime.get(k) override["request_overrides"] = dict(runtime.get("request_overrides") or {}) diff --git a/gateway/run_turn.py b/gateway/run_turn.py index 144c782f45..2c01323b32 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -181,7 +181,7 @@ class GatewayTurnMixin: if ch.model: model = ch.model if ch.provider: - runtime_kwargs = _resolve_runtime_agent_kwargs_for_provider(ch.provider) + runtime_kwargs = _resolve_runtime_agent_kwargs_for_provider(ch.provider, target_model=model or None) ch_runtime_model = runtime_kwargs.pop("model", None) # Adopt the provider's bundled model only when the override named none. if ch_runtime_model and not ch.model: diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 8ef87db4f3..717da2d6b7 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -308,7 +308,9 @@ class CLIAgentSetupMixin: continue try: from hermes_cli.fallback_config import resolve_entry_api_key - _fb_kwargs = {"requested": _fb_provider} + # target_model: the fallback entry names the model that will be sent; without it the + # ladder keys off config `default` (see _ensure_runtime_credentials, #112600). + _fb_kwargs = {"requested": _fb_provider, "target_model": _fb_model} if _fb.get("base_url"): _fb_kwargs["explicit_base_url"] = _fb["base_url"] _fb_api_key = resolve_entry_api_key(_fb) diff --git a/hermes_cli/cli_model_switch_mixin.py b/hermes_cli/cli_model_switch_mixin.py index 7e4e642267..03a73745dc 100644 --- a/hermes_cli/cli_model_switch_mixin.py +++ b/hermes_cli/cli_model_switch_mixin.py @@ -436,7 +436,7 @@ class CLIModelSwitchMixin: self._explicit_base_url = stored_base_url try: from hermes_cli.runtime_provider import resolve_runtime_provider - resolved = resolve_runtime_provider(requested=stored_provider) + resolved = resolve_runtime_provider(requested=stored_provider, target_model=self.model or None) if resolved.get("api_key"): self.api_key = resolved["api_key"] self._credential_pool = resolved.get("credential_pool") diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 9f8b7a635c..3ea77a3415 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2290,23 +2290,25 @@ def normalize_opencode_base_url( and chat/codex modes heal it by re-adding ``/v1`` — but only on opencode.ai hosts, so custom ``OPENCODE_*_BASE_URL`` proxies are left alone. On those hosts the relay path segment follows the resolved family too (``/zen`` vs ``/zen/go``): the two relays serve different model sets, - so a ``model.base_url`` carried over from the other family 401s ("Model ... is not supported").""" + so a ``model.base_url`` carried over from the other family 401s ("Model ... is not supported"). + The family heal applies to the BUILT-IN providers only: a custom provider merely named after a + family (``opencode-go-bridge``) declared its relay path explicitly in ``providers:`` and keeps it. + Only the path is edited, so a port, userinfo, query or fragment round-trips untouched.""" url = str(base_url or "").strip().rstrip("/") family = opencode_provider_family(provider_id) if not url or family is None: return url - try: - parsed = urllib.parse.urlparse(url) - except Exception: - parsed = None - official = parsed is not None and (parsed.netloc.lower() == "opencode.ai" or parsed.netloc.lower().endswith(".opencode.ai")) - if official and re.fullmatch(r"/zen(/go)?(/v1)?", parsed.path): - url = f"{parsed.scheme}://{parsed.netloc}{_OPENCODE_FAMILY_PATHS[family]}{'/v1' if parsed.path.endswith('/v1') else ''}" + parsed = urllib.parse.urlparse(url) + host = (parsed.hostname or "").lower() + official = host == "opencode.ai" or host.endswith(".opencode.ai") + path = parsed.path.rstrip("/") + if official and normalize_provider(provider_id) in _OPENCODE_FAMILIES and re.fullmatch(r"/zen(/go)?(/v1)?", path): + path = _OPENCODE_FAMILY_PATHS[family] + ("/v1" if path.endswith("/v1") else "") if api_mode == "anthropic_messages": - return re.sub(r"/v1$", "", url) - if url.endswith("/v1"): - return url - return url + "/v1" if official else url + path = re.sub(r"/v1$", "", path) + elif official and not path.endswith("/v1"): + path += "/v1" + return urllib.parse.urlunparse(parsed._replace(path=path)) def github_model_reasoning_efforts( diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 87c7810425..f1dc90c740 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -2686,7 +2686,7 @@ class TestModelRoutesAgentCreation: _patch_create_agent_runtime(monkeypatch, captured, FakeAgent) monkeypatch.setattr( "gateway.run._resolve_runtime_agent_kwargs_for_provider", - lambda provider: { + lambda provider, target_model=None: { "provider": provider, "api_key": f"sk-{provider}", "base_url": f"https://{provider}.example/v1", diff --git a/tests/gateway/test_runtime_provider_override_target_model.py b/tests/gateway/test_runtime_provider_override_target_model.py new file mode 100644 index 0000000000..efccf5d9c7 --- /dev/null +++ b/tests/gateway/test_runtime_provider_override_target_model.py @@ -0,0 +1,37 @@ +"""Gateway provider-override credential resolution keys off the override's MODEL (#112600). + +Channel overrides, persisted ``/model`` switches and API-server provider refreshes all resolve +credentials through ``gateway.run._resolve_runtime_agent_kwargs_for_provider``; without the model +the ladder keys off config's ``default`` and a ``*-free`` default sends a Go-only model to the +keyless Zen relay ("Model ... is not supported").""" + +import pytest + + +@pytest.fixture() +def _zen_free_default_home(monkeypatch, tmp_path): + home = tmp_path / "hermes" + home.mkdir() + (home / "config.yaml").write_text( + "model:\n default: mimo-v2.5-free\n provider: opencode\n base_url: https://opencode.ai/zen/v1\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test-go") + + +def test_provider_override_runtime_uses_the_override_model(_zen_free_default_home): + from gateway.run import _resolve_runtime_agent_kwargs_for_provider + + runtime = _resolve_runtime_agent_kwargs_for_provider("opencode-go", target_model="mimo-v2.5") + assert runtime["provider"] == "opencode-go" + assert runtime["base_url"] == "https://opencode.ai/zen/go/v1" + + +def test_fallback_chain_runtime_uses_the_entry_model(_zen_free_default_home, monkeypatch): + import gateway.run as gateway_run + + monkeypatch.setattr(gateway_run, "_load_gateway_config", + lambda: {"fallback_model": [{"provider": "opencode-go", "model": "mimo-v2.5"}]}) + fb = gateway_run._try_resolve_fallback_provider() + assert fb is not None + assert fb["model"] == "mimo-v2.5" + assert fb["base_url"] == "https://opencode.ai/zen/go/v1" diff --git a/tests/gateway/test_session_api.py b/tests/gateway/test_session_api.py index e263d74c95..e78d428a52 100644 --- a/tests/gateway/test_session_api.py +++ b/tests/gateway/test_session_api.py @@ -540,7 +540,7 @@ def _patch_api_server_runtime(monkeypatch): monkeypatch.setattr("hermes_cli.tools_config._get_platform_tools", lambda *_: set()) monkeypatch.setattr( "gateway.run._resolve_runtime_agent_kwargs_for_provider", - lambda provider: { + lambda provider, target_model=None: { "provider": provider, "api_key": f"sk-{provider}", "base_url": f"https://{provider}.example/v1", diff --git a/tests/hermes_cli/test_cli_provider_resolution.py b/tests/hermes_cli/test_cli_provider_resolution.py index fb206c77a2..017ddd684e 100644 --- a/tests/hermes_cli/test_cli_provider_resolution.py +++ b/tests/hermes_cli/test_cli_provider_resolution.py @@ -289,6 +289,30 @@ def test_ensure_runtime_credentials_passes_cli_model_as_target_model(monkeypatch +def test_fallback_runtime_resolves_the_fallback_entry_model(monkeypatch, tmp_path): + """The auth-fallback rung must resolve credentials for the ENTRY's model, exactly like the + primary path does for `-m`: with a `*-free` config default and no target_model, the OpenCode + free-tier rung wins and a Go-only fallback entry is built against the Zen relay (#112600).""" + from hermes_cli.auth import AuthError + from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin + + home = tmp_path / "hermes" + home.mkdir() + (home / "config.yaml").write_text( + "model:\n default: mimo-v2.5-free\n provider: opencode\n base_url: https://opencode.ai/zen/v1\n") + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("OPENCODE_GO_API_KEY", "sk-test-go") + monkeypatch.setattr("cli._cprint", lambda *a, **k: None, raising=False) + + shell = CLIAgentSetupMixin.__new__(CLIAgentSetupMixin) + shell._fallback_model = [{"provider": "opencode-go", "model": "mimo-v2.5"}] + runtime = shell._resolve_fallback_runtime(AuthError("no key", provider="opencode-zen", code="missing_api_key")) + + assert runtime is not None + assert shell.model == "mimo-v2.5" + assert runtime["base_url"] == "https://opencode.ai/zen/go/v1" + + def test_cli_turn_routing_uses_primary_when_disabled(monkeypatch): cli = _import_cli() shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1) diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 5af4e11198..127b05d280 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -357,6 +357,13 @@ class TestNormalizeOpencodeBaseUrlFamilyPath: ("opencode-go", "chat_completions", "https://gateway.internal.example/zen/v1", "https://gateway.internal.example/zen/v1"), # Non-/zen paths on the real host keep the pre-existing /v1 behaviour. ("opencode-go", "chat_completions", "https://opencode.ai/api", "https://opencode.ai/api/v1"), + # A custom provider merely NAMED after a family declared its relay explicitly: no family + # heal, but still the family's /v1 handling. + ("opencode-zen-bridge", "chat_completions", "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/go/v1"), + ("opencode-go-bridge", "chat_completions", "https://opencode.ai/zen/go", "https://opencode.ai/zen/go/v1"), + # The host check is on the hostname, so a port does not defeat the heal; query survives. + ("opencode-go", "chat_completions", "https://opencode.ai:443/zen/v1", "https://opencode.ai:443/zen/go/v1"), + ("opencode-go", "anthropic_messages", "https://opencode.ai/zen/v1?x=1", "https://opencode.ai/zen/go?x=1"), ]) def test_family_path_follows_the_resolved_provider(self, provider, api_mode, url, expected): from hermes_cli.models import normalize_opencode_base_url diff --git a/tui_gateway/model_switch.py b/tui_gateway/model_switch.py index 9139bf2f25..0bf80e54c9 100644 --- a/tui_gateway/model_switch.py +++ b/tui_gateway/model_switch.py @@ -177,7 +177,7 @@ def _current_model_runtime(agent, explicit_provider: str) -> tuple: if explicit_provider: return explicit_provider.strip(), current_model, "", "" from hermes_cli.runtime_provider import resolve_runtime_provider - runtime = resolve_runtime_provider(requested=None) + runtime = resolve_runtime_provider(requested=None, target_model=current_model or None) # Keep a callable api_key (Azure Entra bearer) unchanged: ``str()`` would # yield "" and poison switch_model validation. key = runtime.get("api_key", "") diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index a37c8c79ab..0fc23c8a92 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -63,6 +63,8 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro All three OpenCode providers send an opaque, per-conversation `x-opencode-session` header on every request (main turns on every transport plus auxiliary calls such as compression, titles, approval checks, skills-hub lookups and `/btw` side questions — including the ones that run in the background after the turn has ended; headless Kanban `specify`/`decompose` and dashboard estimate calls use a per-task key). OpenCode uses it to pin a conversation to one backend so its prompt cache stays warm; the value is derived from the Hermes session id (or the Kanban task id) and carries no personal data. +The three built-in OpenCode providers each pin their own relay on `opencode.ai` (`opencode-zen` and `opencode-free` → `/zen/v1`, `opencode-go` → `/zen/go/v1`). A `model.base_url` left behind by the other relay is healed to the selected provider's relay, and the model you pick (`-m`, `/model`, a fallback entry or a channel override) decides which relay is used — so switching from a Zen model to a Go-only one never sends the request to Zen. A custom provider you define under `providers:` whose name extends a family slug (for example `opencode-go-bridge`) still gets the family's per-model API-mode routing and `/v1` handling, but its `base_url` is taken as declared: name it after the relay it actually points at. + For the official API-key path, see the dedicated [Google Gemini guide](/guides/google-gemini). :::tip Model key alias