diff --git a/hermes_cli/runtime_provider_backends.py b/hermes_cli/runtime_provider_backends.py index d9c662f200..f9ddf0afc6 100644 --- a/hermes_cli/runtime_provider_backends.py +++ b/hermes_cli/runtime_provider_backends.py @@ -1,9 +1,9 @@ -"""Provider-specific runtime builders for :mod:`hermes_cli.runtime_provider`. +"""Provider-specific runtime builders for :mod:`hermes_cli.runtime_provider`: Azure Foundry, the +OpenRouter / bare-custom fallback resolver, Bedrock, and external-process providers. -Azure Foundry, the OpenRouter / bare-custom fallback resolver, Bedrock, and external-process -providers. Origin-internal collaborators are resolved on the origin module at call time via -:func:`_rp` so test patches on ``hermes_cli.runtime_provider.*`` (``_get_model_config``, -``load_config``, ``has_usable_secret``, ``_try_resolve_from_custom_pool``, …) still apply. +Origin-internal collaborators are resolved on the origin module at call time via :func:`_rp` so +test patches on ``hermes_cli.runtime_provider.*`` (``_get_model_config``, ``load_config``, +``has_usable_secret``, ``_try_resolve_from_custom_pool``, …) still apply. """ from __future__ import annotations @@ -22,11 +22,6 @@ def _rp(): return origin -def _strip_v1(base_url: str) -> str: - """Anthropic SDK appends /v1/messages itself — drop an inherited trailing /v1.""" - return re.sub(r"/v1/?$", "", base_url) - - # ── Azure Foundry ────────────────────────────────────────────────────────────────────────── @@ -35,11 +30,7 @@ def _azure_entra_credentials(cfg_entra: Dict[str, Any]) -> Any: ``build_anthropic_client`` injects the bearer via an httpx hook).""" AuthError = _rp().AuthError try: - from agent.azure_identity_adapter import ( - SCOPE_AI_AZURE_DEFAULT, - EntraIdentityConfig, - build_token_provider, - ) + from agent.azure_identity_adapter import SCOPE_AI_AZURE_DEFAULT, EntraIdentityConfig, build_token_provider except Exception as exc: raise AuthError( "Azure Foundry Entra ID auth requires the 'azure-identity' " @@ -53,6 +44,27 @@ def _azure_entra_credentials(cfg_entra: Dict[str, Any]) -> Any: raise AuthError(str(exc)) from exc +def _azure_foundry_api_key(rp, explicit_api_key: str) -> str: + if explicit_api_key: + return explicit_api_key + try: + from hermes_cli.config import get_env_value + + api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or "" + except Exception: + api_key = "" + api_key = api_key or rp._getenv("AZURE_FOUNDRY_API_KEY", "").strip() + if not api_key: + raise rp.AuthError( + "Azure Foundry requires an API key. Set AZURE_FOUNDRY_API_KEY in " + "~/.hermes/.env or run 'hermes model' to configure. To use " + "keyless Microsoft Entra ID auth instead, set " + "model.auth_mode: entra_id in config.yaml (or pick " + "'Microsoft Entra ID' in 'hermes model')." + ) + return api_key + + def _resolve_azure_foundry_runtime( *, requested_provider: str, @@ -63,7 +75,7 @@ def _resolve_azure_foundry_runtime( ) -> Dict[str, Any]: """Azure Foundry: ``model.base_url`` + ``model.api_mode`` (or explicit overrides), API key from ``.env``/env or a per-request Entra ID token, trailing ``/v1`` stripped for Anthropic-style - endpoints.""" + endpoints (the Anthropic SDK appends /v1/messages itself).""" rp = _rp() explicit_api_key = str(explicit_api_key or "").strip() explicit_base_url_clean = str(explicit_base_url or "").strip().rstrip("/") @@ -88,7 +100,7 @@ def _resolve_azure_foundry_runtime( "the AZURE_FOUNDRY_BASE_URL environment variable." ) if cfg_api_mode == "anthropic_messages": - base_url = _strip_v1(base_url) + base_url = re.sub(r"/v1/?$", "", base_url) if cfg_auth_mode == "entra_id": if explicit_api_key: @@ -105,28 +117,9 @@ def _resolve_azure_foundry_runtime( "azure-foundry", cfg_api_mode, base_url, api_key, auth_mode=auth_mode, entra=clean_entra, source=source, requested_provider=requested_provider, ) - - api_key = explicit_api_key - if not api_key: - try: - from hermes_cli.config import get_env_value - - api_key = get_env_value("AZURE_FOUNDRY_API_KEY") or "" - except Exception: - api_key = "" - api_key = api_key or rp._getenv("AZURE_FOUNDRY_API_KEY", "").strip() - if not api_key: - raise rp.AuthError( - "Azure Foundry requires an API key. Set AZURE_FOUNDRY_API_KEY in " - "~/.hermes/.env or run 'hermes model' to configure. To use " - "keyless Microsoft Entra ID auth instead, set " - "model.auth_mode: entra_id in config.yaml (or pick " - "'Microsoft Entra ID' in 'hermes model')." - ) return rp._runtime( - "azure-foundry", cfg_api_mode, base_url, api_key, - auth_mode="api_key", - source="explicit" if (explicit_api_key or explicit_base_url) else "config", + "azure-foundry", cfg_api_mode, base_url, _azure_foundry_api_key(rp, explicit_api_key), + auth_mode="api_key", source="explicit" if (explicit_api_key or explicit_base_url) else "config", requested_provider=requested_provider, ) @@ -135,10 +128,7 @@ def _resolve_azure_foundry_runtime( def _resolve_openrouter_runtime( - *, - requested_provider: str, - explicit_api_key: Optional[str] = None, - explicit_base_url: Optional[str] = None, + *, requested_provider: str, explicit_api_key: Optional[str] = None, explicit_base_url: Optional[str] = None ) -> Dict[str, Any]: """Terminal resolver: OpenRouter, or a bare/aliased ``custom`` endpoint. @@ -152,8 +142,7 @@ def _resolve_openrouter_runtime( cfg_base_url = model_cfg.get("base_url") if isinstance(model_cfg.get("base_url"), str) else "" cfg_provider = model_cfg.get("provider") if isinstance(model_cfg.get("provider"), str) else "" cfg_api_key = next( - (v.strip() for v in (model_cfg.get("api_key"), model_cfg.get("api")) if isinstance(v, str) and v.strip()), - "", + (v.strip() for v in (model_cfg.get("api_key"), model_cfg.get("api")) if isinstance(v, str) and v.strip()), "" ) requested_norm = (requested_provider or "").strip().lower() cfg_provider = cfg_provider.strip().lower() @@ -163,7 +152,6 @@ def _resolve_openrouter_runtime( env_openrouter_base_url = rp._getenv("OPENROUTER_BASE_URL", "").strip() env_custom_base_url = rp._getenv("CUSTOM_BASE_URL", "").strip() - use_config_base_url = bool(cfg_base_url.strip()) and not explicit_base_url and ( (requested_norm == "auto" and cfg_provider in ("", "auto")) or (requested_norm == "custom" and rp._config_base_url_trustworthy_for_bare_custom(cfg_base_url, cfg_provider)) @@ -194,20 +182,19 @@ def _resolve_openrouter_runtime( ] api_key = next((str(c or "").strip() for c in candidates if rp.has_usable_secret(c)), "") source = "explicit" if (explicit_api_key or explicit_base_url) else "env/config" + cfg_api_mode = rp._parse_api_mode(model_cfg.get("api_mode")) # Explicit "custom" stays "custom" rather than relabeling to "openrouter". if requested_norm != "custom": return rp._runtime( - "openrouter", - rp._parse_api_mode(model_cfg.get("api_mode")) or rp._detect_api_mode_for_url(base_url) or "chat_completions", + "openrouter", cfg_api_mode or rp._detect_api_mode_for_url(base_url) or "chat_completions", base_url, api_key, source=source, ) if base_url: # provider_name makes pool lookup prefer name match over base_url (fixes credential # mix-ups when multiple custom providers share a base_url). pool_result = rp._try_resolve_from_custom_pool( - base_url, "custom", rp._parse_api_mode(model_cfg.get("api_mode")), - provider_name=requested_provider if requested_norm != "custom" else None, + base_url, "custom", cfg_api_mode, provider_name=requested_provider if requested_norm != "custom" else None ) if pool_result: return pool_result @@ -274,12 +261,8 @@ def _resolve_bedrock_runtime(requested_provider: str, model_cfg: Dict[str, Any], if is_openai_bedrock_model(current_model): bearer = resolve_bedrock_bearer_token() runtime.update( - api_mode="codex_responses", - base_url=bedrock_openai_base_url(region), - api_key=bearer or "aws-sdk", - source="AWS_BEARER_TOKEN_BEDROCK" if bearer else auth_source, - model=current_model, - bedrock_openai=True, + api_mode="codex_responses", base_url=bedrock_openai_base_url(region), api_key=bearer or "aws-sdk", + source="AWS_BEARER_TOKEN_BEDROCK" if bearer else auth_source, model=current_model, bedrock_openai=True, ) elif is_anthropic_bedrock_model(current_model) and not has_bearer_token: runtime.update(api_mode="anthropic_messages", bedrock_anthropic=True) diff --git a/hermes_cli/runtime_provider_custom.py b/hermes_cli/runtime_provider_custom.py index 1de8c6771d..51be816abe 100644 --- a/hermes_cli/runtime_provider_custom.py +++ b/hermes_cli/runtime_provider_custom.py @@ -1,11 +1,11 @@ """Custom-provider resolution: ``providers:`` / ``custom_providers:`` lookup, identity recovery, custom credential pools, and the named-custom runtime builder. -Extracted from :mod:`hermes_cli.runtime_provider`; every public/private name here is -re-exported there. Origin-internal collaborators (``load_config``, ``_get_model_config``, -``load_pool``, ``has_usable_secret``, ``custom_provider_pool_key_candidates``, …) are looked up -on the origin module AT CALL TIME via :func:`_rp` so ``monkeypatch.setattr(runtime_provider, -name, …)`` in tests keeps working for moved bodies. +Extracted from :mod:`hermes_cli.runtime_provider`; every name here is re-exported there. +Origin-internal collaborators (``load_config``, ``_get_model_config``, ``load_pool``, +``has_usable_secret``, ``custom_provider_pool_key_candidates``, …) are looked up on the origin +module AT CALL TIME via :func:`_rp` so ``monkeypatch.setattr(runtime_provider, name, …)`` keeps +working for moved bodies. """ from __future__ import annotations @@ -19,6 +19,8 @@ from utils import base_url_hostname logger = logging.getLogger("hermes_cli.runtime_provider") +_LLAMACPP_ALIASES = ("llamacpp", "llama.cpp", "llama-cpp") + def _rp(): """Origin module, late-bound so test patches on ``hermes_cli.runtime_provider.*`` apply.""" @@ -65,11 +67,9 @@ def _lift_model_capabilities(entry: Dict[str, Any], model: Optional[str], result def _lift_max_output_tokens(entry: Dict[str, Any], result: Dict[str, Any]) -> None: - """``max_output_tokens`` or ``max_tokens`` on a provider entry pins its own output limit. - - Gateway/CLI map it onto ``AIAgent.max_tokens`` only when top-level ``model.max_tokens`` is - unset, so the documented global key still wins. - """ + """``max_output_tokens`` or ``max_tokens`` on a provider entry pins its own output limit; + gateway/CLI map it onto ``AIAgent.max_tokens`` only when top-level ``model.max_tokens`` is + unset, so the documented global key still wins.""" for key in ("max_output_tokens", "max_tokens"): value = entry.get(key) if isinstance(value, int) and value > 0: @@ -85,12 +85,7 @@ def _lift_extra_headers(entry: Dict[str, Any], result: Dict[str, Any]) -> None: def _lift_common_custom_fields( - entry: Dict[str, Any], - result: Dict[str, Any], - *, - provider_key: str, - key_env: str, - api_mode: Optional[str], + entry: Dict[str, Any], result: Dict[str, Any], *, provider_key: str, key_env: str, api_mode: Optional[str] ) -> None: """Copy the optional fields shared by ``providers:`` and legacy ``custom_providers:`` entries.""" if key_env: @@ -115,11 +110,11 @@ def _lift_common_custom_fields( def _shadowed_by_builtin(requested_norm: str) -> bool: """Raw names map to custom providers only when they are not canonical built-ins. - Explicit ``custom:`` keys always target the saved entry, and bare ``custom`` is - exempt: a user may literally name a ``providers:`` entry "custom" (returning None before - the config scan made such cron jobs fail with ``auth_unavailable``). Defer to the built-in - only when the raw name IS the canonical provider (``nous``); an entry matching merely an - alias (``kimi`` → ``kimi-coding``) is the user's target. + Explicit ``custom:`` keys always target the saved entry, and bare ``custom`` is exempt: a + user may literally name a ``providers:`` entry "custom" (returning None before the config scan + made such cron jobs fail with ``auth_unavailable``). Defer to the built-in only when the raw + name IS the canonical provider (``nous``); an entry matching merely an alias (``kimi`` → + ``kimi-coding``) is the user's target. """ if requested_norm == "custom" or requested_norm.startswith("custom:"): return False @@ -162,9 +157,7 @@ def _match_new_style_provider(requested_norm: str, providers: Dict[str, Any]) -> # v12 migration writes ``transport``; hand-edited configs may still use ``api_mode``. # Accept both or migrated configs silently downgrade to chat_completions. _lift_common_custom_fields( - entry, result, - provider_key=_clean(ep_name), - key_env=key_env, + entry, result, provider_key=_clean(ep_name), key_env=key_env, api_mode=rp._parse_api_mode(entry.get("api_mode") or entry.get("transport")), ) return result @@ -187,9 +180,7 @@ def _match_legacy_custom_provider(requested_norm: str, custom_providers) -> Opti if model_name: result["model"] = model_name _lift_common_custom_fields( - entry, result, - provider_key=provider_key, - key_env=_clean(entry.get("key_env", "")), + entry, result, provider_key=provider_key, key_env=_clean(entry.get("key_env", "")), api_mode=_rp()._parse_api_mode(entry.get("api_mode")), ) return result @@ -200,7 +191,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An requested_norm = _normalize_custom_provider_name(requested_provider or "") if not requested_norm or requested_norm == "auto" or _shadowed_by_builtin(requested_norm): return None - rp = _rp() config = rp.load_config() providers = config.get("providers") @@ -208,7 +198,6 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An found = _match_new_style_provider(requested_norm, providers) if found: return found - if isinstance(config.get("custom_providers"), dict): logger.warning( "custom_providers in config.yaml is a dict, not a list. " @@ -223,10 +212,8 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An def has_named_custom_provider(requested_provider: str) -> bool: - """True when config defines a ``providers:`` / ``custom_providers:`` entry matching the request. - - Public wrapper so other modules (e.g. the cronjob tool) need not reach into a private helper. - """ + """True when config defines a ``providers:`` / ``custom_providers:`` entry matching the request + (public wrapper so e.g. the cronjob tool need not reach into a private helper).""" try: return _rp()._get_named_custom_provider(requested_provider) is not None except Exception: @@ -244,13 +231,11 @@ def _find_custom_identity(matches: Callable[[Dict[str, Any]], bool]) -> Optional config = rp.load_config() except Exception: return None - providers = config.get("providers") if isinstance(providers, dict): for ep_name, entry in providers.items(): if isinstance(entry, dict) and matches(entry): return custom_provider_slug(str(ep_name), str(ep_name)) - try: custom_providers = rp.get_compatible_custom_providers(config) except Exception: @@ -265,64 +250,54 @@ def _find_custom_identity(matches: Callable[[Dict[str, Any]], bool]) -> Optional def find_custom_provider_identity(base_url: str) -> Optional[str]: - """Map an endpoint URL back to its canonical ``custom:`` menu key. - - Session persistence stores the agent's *resolved* provider, and for every named custom - endpoint that is the literal string ``"custom"`` — the entry name is lost, and the api_key is - deliberately never persisted. - """ + """Map an endpoint URL back to its canonical ``custom:`` menu key. Session persistence + stores the agent's *resolved* provider, which for every named custom endpoint is the literal + string ``"custom"`` — the entry name is lost, and the api_key is deliberately never persisted.""" target = _normalize_base_url_for_match(base_url) if not target: return None return _find_custom_identity(lambda entry: _normalize_base_url_for_match(_entry_url(entry)) == target) -def find_custom_provider_identity_by_model(model: str) -> Optional[str]: - """Map a model id back to the ``custom:`` entry that serves it. +def _model_id_matches(value: Any, target: str) -> bool: + return isinstance(value, str) and value.strip().lower() == target - Companion to :func:`find_custom_provider_identity` for persistence paths where no base_url - survived the round-trip: the session row always stores the model name. - """ + +def find_custom_provider_identity_by_model(model: str) -> Optional[str]: + """Map a model id back to the ``custom:`` entry that serves it — companion to + :func:`find_custom_provider_identity` for persistence paths where no base_url survived the + round-trip (the session row always stores the model name).""" target = str(model or "").strip().lower() if not target: return None def _entry_serves_model(entry: Dict[str, Any]) -> bool: - for key in ("model", "default_model"): - value = entry.get(key) - if isinstance(value, str) and value.strip().lower() == target: - return True + if any(_model_id_matches(entry.get(key), target) for key in ("model", "default_model")): + return True models = entry.get("models") if isinstance(models, dict): return any(str(mid).strip().lower() == target for mid in models) if isinstance(models, list): - for item in models: - if isinstance(item, str) and item.strip().lower() == target: - return True - if isinstance(item, dict): - mid = item.get("id") or item.get("name") - if isinstance(mid, str) and mid.strip().lower() == target: - return True + return any( + _model_id_matches(item.get("id") or item.get("name") if isinstance(item, dict) else item, target) + for item in models + ) return False return _find_custom_identity(_entry_serves_model) def canonical_custom_identity( - *, - base_url: Optional[str] = None, - config_provider: Optional[str] = None, - model: Optional[str] = None, + *, base_url: Optional[str] = None, config_provider: Optional[str] = None, model: Optional[str] = None ) -> Optional[str]: """Recover a routable ``custom:`` identity for a bare custom provider. Every path that persists or restores a session's provider override must run the resolved - provider through this so a bare ``"custom"`` is upgraded back to its durable - ``custom:`` menu key. Recovery sources, in priority order: (1) ``base_url`` reverse - lookup — the one fact that always survives the round-trip when a URL was recorded; (2) - ``model`` reverse lookup (``model``/``default_model``/``models`` catalog); (3) the configured - provider (arg, then ``model.provider``, then ``HERMES_INFERENCE_PROVIDER``) when it names a - real entry. + provider through this so a bare ``"custom"`` is upgraded back to its durable ``custom:`` + menu key. Sources in priority order: (1) ``base_url`` reverse lookup — the one fact that always + survives the round-trip when a URL was recorded; (2) ``model`` reverse lookup + (``model``/``default_model``/``models`` catalog); (3) the configured provider (arg, then + ``model.provider``, then ``HERMES_INFERENCE_PROVIDER``) when it names a real entry. """ rp = _rp() if base_url: @@ -333,7 +308,6 @@ def canonical_custom_identity( identity = find_custom_provider_identity_by_model(model) if identity: return identity - candidate = str(config_provider or "").strip() if not candidate: try: @@ -342,7 +316,6 @@ def canonical_custom_identity( candidate = "" if not candidate: candidate = os.environ.get("HERMES_INFERENCE_PROVIDER", "").strip() - candidate_norm = _normalize_custom_provider_name(candidate) # A bare/non-routable candidate cannot heal a bare custom override. if not candidate_norm or candidate_norm in {"custom", "auto", "openrouter"}: @@ -367,11 +340,11 @@ def canonical_custom_identity( def is_routable_provider(provider: Optional[str]) -> bool: """Whether a provider name currently resolves to a routable route. - Empty/None/``auto`` is vacuously routable (agent build falls back to the configured - default). Bare ``custom`` is the resolved billing class shared by every named entry — not a - routable identity; restore paths must heal it (:func:`canonical_custom_identity`) or fall - back. Anything else is routable iff the full chain (built-in -> ``providers:`` -> - ``custom_providers:`` -> models.dev) resolves it. + Empty/None/``auto`` is vacuously routable (agent build falls back to the configured default). + Bare ``custom`` is the resolved billing class shared by every named entry — not a routable + identity; restore paths must heal it (:func:`canonical_custom_identity`) or fall back. Anything + else is routable iff the full chain (built-in -> ``providers:`` -> ``custom_providers:`` -> + models.dev) resolves it. """ name = str(provider or "").strip() if not name or name.lower() == "auto": @@ -383,9 +356,7 @@ def is_routable_provider(provider: Optional[str]) -> bool: rp = _rp() config = rp.load_config() - return resolve_provider_full( - name, config.get("providers"), rp.get_compatible_custom_providers(config) - ) is not None + return resolve_provider_full(name, config.get("providers"), rp.get_compatible_custom_providers(config)) is not None except Exception: return False @@ -394,10 +365,7 @@ def is_routable_provider(provider: Optional[str]) -> bool: def _try_resolve_from_custom_pool( - base_url: str, - provider_label: str, - api_mode_override: Optional[str] = None, - provider_name: Optional[str] = None, + base_url: str, provider_label: str, api_mode_override: Optional[str] = None, provider_name: Optional[str] = None ) -> Optional[Dict[str, Any]]: """Runtime dict from the first credential pool that owns this custom endpoint, else None.""" rp = _rp() @@ -424,12 +392,8 @@ def _try_resolve_from_custom_pool( # substitutes "no-key-required" for a loopback endpoint — this was the one gap. pool_api_key = "no-key-required" return rp._runtime( - provider_label, - api_mode_override or rp._detect_api_mode_for_url(base_url) or "chat_completions", - base_url, - pool_api_key, - source=f"pool:{pool_key}", - credential_pool=pool, + provider_label, api_mode_override or rp._detect_api_mode_for_url(base_url) or "chat_completions", + base_url, pool_api_key, source=f"pool:{pool_key}", credential_pool=pool, ) except Exception: continue @@ -447,12 +411,9 @@ def _apply_custom_provider_extras( custom_provider: Dict[str, Any], target_model: Optional[str], result: Dict[str, Any] ) -> None: """Copy model / capabilities / max_output_tokens / extra_headers / request_overrides onto a - resolved custom runtime. - - An explicit ``target_model`` wins over the provider's configured default (auxiliary slots / - background-review resolve a concrete model and must not fall back to ``default_model``). - ``extra_headers`` may carry credentials — NEVER log them. - """ + resolved custom runtime. An explicit ``target_model`` wins over the provider's configured + default (auxiliary slots / background-review resolve a concrete model and must not fall back to + ``default_model``). ``extra_headers`` may carry credentials — NEVER log them.""" model_name = target_model or custom_provider.get("model") if model_name: result["model"] = model_name @@ -482,12 +443,9 @@ def _resolve_llamacpp_runtime(requested_provider: str, explicit_api_key: Optiona endpoint = None if endpoint: return rp._runtime( - "custom", - "chat_completions", - endpoint["base_url"], + "custom", "chat_completions", endpoint["base_url"], (explicit_api_key or "").strip() or endpoint["api_key"] or "no-key-required", - source="local-runtime", - requested_provider=requested_provider, + source="local-runtime", requested_provider=requested_provider, ) try: enabled = bool((rp.load_config().get("local_runtime") or {}).get("enabled")) @@ -506,6 +464,14 @@ def _resolve_llamacpp_runtime(requested_provider: str, explicit_api_key: Optiona ) +def _custom_runtime(rp, base_url: str, api_key: Any, api_mode: Optional[str], **extra: Any) -> Dict[str, Any]: + """``custom`` runtime dict with URL-detected api_mode fallback and the no-auth placeholder.""" + return rp._runtime( + "custom", api_mode or rp._detect_api_mode_for_url(base_url) or "chat_completions", base_url, + api_key or "no-key-required", **extra, + ) + + def _resolve_direct_alias_runtime( requested_provider: str, explicit_api_key: Optional[str], explicit_base_url: str ) -> Dict[str, Any]: @@ -521,15 +487,8 @@ def _resolve_direct_alias_runtime( # OLLAMA_API_KEY gets its own gate here: without it a `model_aliases:` entry pointing at # Ollama Cloud resolved no key at all. candidates = [(explicit_api_key or "").strip(), *rp._host_gated_env_key_candidates(base_url, ollama=True)] - api_key = next((c for c in candidates if rp.has_usable_secret(c)), "") or "no-key-required" - return rp._runtime( - "custom", - rp._detect_api_mode_for_url(base_url) or "chat_completions", - base_url, - api_key, - source="direct-alias", - requested_provider=requested_provider, - ) + api_key = next((c for c in candidates if rp.has_usable_secret(c)), "") + return _custom_runtime(rp, base_url, api_key, None, source="direct-alias", requested_provider=requested_provider) def _opencode_family_for_custom(requested_provider: str, base_url: str) -> Optional[str]: @@ -562,7 +521,7 @@ def _resolve_named_custom_runtime( """ rp = _rp() requested_norm = (requested_provider or "").strip().lower() - if requested_norm in ("llamacpp", "llama.cpp", "llama-cpp") and not explicit_base_url: + if requested_norm in _LLAMACPP_ALIASES and not explicit_base_url: return _resolve_llamacpp_runtime(requested_provider, explicit_api_key) if requested_norm and requested_norm != "custom" and rp._resolves_to_custom(requested_norm): requested_norm = "custom" @@ -577,9 +536,7 @@ def _resolve_named_custom_runtime( return None pool_result = rp._try_resolve_from_custom_pool( - base_url, - "custom", - custom_provider.get("api_mode"), + base_url, "custom", custom_provider.get("api_mode"), provider_name=custom_provider.get("provider_key") or custom_provider.get("name"), ) if pool_result: @@ -587,8 +544,9 @@ def _resolve_named_custom_runtime( _apply_custom_provider_extras(custom_provider, target_model, pool_result) return pool_result + explicit_key = (explicit_api_key or "").strip() candidates = [ - (explicit_api_key or "").strip(), + explicit_key, _clean(custom_provider.get("api_key", "")), rp._getenv(_clean(custom_provider.get("key_env", "")), "").strip(), *rp._host_gated_env_key_candidates(base_url, ollama=False), @@ -599,7 +557,7 @@ def _resolve_named_custom_runtime( # mid-session); both wire clients accept a callable api_key (the Entra ID contract). An # explicit --api-key still wins as the one-off recovery escape hatch. key_cmd = _clean(custom_provider.get("key_cmd", "")) - if key_cmd and not rp.has_usable_secret((explicit_api_key or "").strip()): + if key_cmd and not rp.has_usable_secret(explicit_key): from agent.command_token_source import build_command_token_provider token_provider = build_command_token_provider( @@ -608,13 +566,9 @@ def _resolve_named_custom_runtime( if token_provider is not None: api_key = token_provider - result = rp._runtime( - "custom", - custom_provider.get("api_mode") or rp._detect_api_mode_for_url(base_url) or "chat_completions", - base_url, - api_key or "no-key-required", - source=f"custom_provider:{custom_provider.get('name', requested_provider)}", - requested_provider=requested_provider, + result = _custom_runtime( + rp, base_url, api_key, custom_provider.get("api_mode"), + source=f"custom_provider:{custom_provider.get('name', requested_provider)}", requested_provider=requested_provider, ) _apply_custom_provider_extras(custom_provider, target_model, result)