Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 123 additions & 73 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,120 @@ def init_agent(
# Claude uses its own timeout path and is not covered here.
_provider_timeout = get_provider_request_timeout(agent.provider, agent.model)

# Resolve the OpenAI-wire primary before selecting the transport-specific
# initialization branch. If its credentials are unavailable, a fallback
# entry may select a different transport (notably anthropic_messages), so
# that selection must happen before the branch below rather than inside
# the OpenAI client setup block.
_pre_resolved_client = None
if (
agent.api_mode not in {"anthropic_messages", "bedrock_converse"}
and agent.provider != "moa"
and not (api_key and base_url)
):
from agent.auxiliary_client import resolve_provider_client

_pre_resolved_client, _ = resolve_provider_client(
agent.provider or "auto",
model=agent.model,
raw_codex=True,
)
if _pre_resolved_client is None:
_explicit = (agent.provider or "").strip().lower()
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
_env_hint = f"{_explicit.upper()}_API_KEY"
try:
from hermes_cli.auth import PROVIDER_REGISTRY

_pcfg = PROVIDER_REGISTRY.get(_explicit)
if _pcfg and _pcfg.api_key_env_vars:
_env_hint = _pcfg.api_key_env_vars[0]
except Exception:
pass

if isinstance(fallback_model, list):
_fb_entries = [
entry
for entry in fallback_model
if isinstance(entry, dict)
and entry.get("provider")
and entry.get("model")
]
elif (
isinstance(fallback_model, dict)
and fallback_model.get("provider")
and fallback_model.get("model")
):
_fb_entries = [fallback_model]
else:
_fb_entries = []

from hermes_cli.fallback_config import (
resolve_fallback_client,
resolve_fallback_transport,
)

for _fb in _fb_entries:
_fb_client, _fb_model, _fb_api_mode = (
resolve_fallback_client(_fb, raw_codex=True)
)
if _fb_client is None:
continue

agent.provider = str(_fb["provider"]).strip().lower()
agent.model = _fb_model or str(_fb["model"]).strip()
agent.base_url = str(_fb_client.base_url)
agent.api_mode = resolve_fallback_transport(
validated_api_mode=_fb_api_mode,
provider=agent.provider,
model_requires_responses=(
agent._provider_model_requires_responses_api(
agent.model,
provider=agent.provider,
)
),
base_url=agent.base_url,
is_azure=agent._is_azure_openai_url(agent.base_url),
)
# The initial policy was computed for the unavailable
# primary. Re-evaluate it before any request so a native
# Anthropic fallback keeps the same cache-control contract
# as a fallback activated during a live conversation.
(
agent._use_prompt_caching,
agent._use_native_cache_layout,
) = agent._anthropic_prompt_cache_policy(
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
model=agent.model,
)
agent._fallback_activated = True
_provider_timeout = get_provider_request_timeout(
agent.provider,
agent.model,
)
_pre_resolved_client = _fb_client

# Native transports initialize below from the selected
# fallback's credentials and endpoint. OpenAI-wire modes
# reuse the already-resolved client in the implicit-auth
# branch so provider-specific headers survive.
if agent.api_mode in {
"anthropic_messages",
"bedrock_converse",
}:
api_key = _fb_client.api_key
base_url = str(_fb_client.base_url)
break

if _pre_resolved_client is None:
raise RuntimeError(
f"Provider '{_explicit}' is set in config.yaml but no API key "
f"was found. Set the {_env_hint} environment "
f"variable, or switch to a different provider with `hermes model`."
)

if agent.api_mode == "anthropic_messages":
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token
# Bedrock + Claude β†’ use AnthropicBedrock SDK for full feature parity
Expand Down Expand Up @@ -983,10 +1097,9 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
except Exception:
pass
else:
# No explicit creds β€” use the centralized provider router
from agent.auxiliary_client import resolve_provider_client
_routed_client, _ = resolve_provider_client(
agent.provider or "auto", model=agent.model, raw_codex=True)
# No explicit creds β€” reuse the preflight router result. Fallback
# selection already happened before transport branching above.
_routed_client = _pre_resolved_client
if _routed_client is not None:
client_kwargs = {
"api_key": _routed_client.api_key,
Expand All @@ -1006,75 +1119,12 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
if _routed_headers:
client_kwargs["default_headers"] = dict(_routed_headers)
else:
# When the user explicitly chose a non-OpenRouter provider
# but no credentials were found, fail fast with a clear
# message instead of silently routing through OpenRouter.
_explicit = (agent.provider or "").strip().lower()
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
# Look up the actual env var name from the provider
# config β€” some providers use non-standard names
# (e.g. alibaba β†’ DASHSCOPE_API_KEY, not ALIBABA_API_KEY).
_env_hint = f"{_explicit.upper()}_API_KEY"
try:
from hermes_cli.auth import PROVIDER_REGISTRY
_pcfg = PROVIDER_REGISTRY.get(_explicit)
if _pcfg and _pcfg.api_key_env_vars:
_env_hint = _pcfg.api_key_env_vars[0]
except Exception:
pass
# --- Init-time fallback (#17929) ---
_fb_entries = []
if isinstance(fallback_model, list):
_fb_entries = [
f for f in fallback_model
if isinstance(f, dict) and f.get("provider") and f.get("model")
]
elif isinstance(fallback_model, dict) and fallback_model.get("provider") and fallback_model.get("model"):
_fb_entries = [fallback_model]
_fb_resolved = False
for _fb in _fb_entries:
_fb_explicit_key = (_fb.get("api_key") or "").strip() or None
if not _fb_explicit_key:
_fb_key_env = (_fb.get("key_env") or _fb.get("api_key_env") or "").strip()
if _fb_key_env:
_fb_explicit_key = os.getenv(_fb_key_env, "").strip() or None
_fb_client, _fb_model = resolve_provider_client(
_fb["provider"], model=_fb["model"], raw_codex=True,
explicit_base_url=_fb.get("base_url"),
explicit_api_key=_fb_explicit_key,
)
if _fb_client is not None:
agent.provider = _fb["provider"]
agent.model = _fb_model or _fb["model"]
agent._fallback_activated = True
client_kwargs = {
"api_key": _fb_client.api_key,
"base_url": str(_fb_client.base_url),
}
if _provider_timeout is not None:
client_kwargs["timeout"] = _provider_timeout
_fb_headers = getattr(_fb_client, "_custom_headers", None)
if not _fb_headers:
_fb_headers = getattr(_fb_client, "default_headers", None)
if not _fb_headers:
_fb_headers = getattr(_fb_client, "_default_headers", None)
if _fb_headers:
client_kwargs["default_headers"] = dict(_fb_headers)
_fb_resolved = True
break
if not _fb_resolved:
raise RuntimeError(
f"Provider '{_explicit}' is set in config.yaml but no API key "
f"was found. Set the {_env_hint} environment "
f"variable, or switch to a different provider with `hermes model`."
)
if not getattr(agent, "_fallback_activated", False):
# No provider configured β€” reject with a clear message.
raise RuntimeError(
"No LLM provider configured. Run `hermes model` to "
"select a provider, or run `hermes setup` for first-time "
"configuration."
)
# No provider configured β€” reject with a clear message.
raise RuntimeError(
"No LLM provider configured. Run `hermes model` to "
"select a provider, or run `hermes setup` for first-time "
"configuration."
)

agent._client_kwargs = client_kwargs # stored for rebuilding after interrupt

Expand Down
77 changes: 21 additions & 56 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
_repair_tool_call_arguments,
)
from tools.terminal_tool import is_persistent_env
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
from utils import base_url_host_matches, env_float, env_int

logger = logging.getLogger(__name__)
_OPENROUTER_PROVIDER_SORT_VALUES = {"throughput", "latency", "price"}
Expand Down Expand Up @@ -1421,27 +1421,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# raw_codex=True because the main agent needs direct responses.stream()
# access for Codex providers.
try:
from agent.auxiliary_client import resolve_provider_client
# Pass base_url and api_key from fallback config so custom
# endpoints (e.g. Ollama Cloud) resolve correctly instead of
# falling through to OpenRouter defaults.
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
fb_api_key_hint = (fb.get("api_key") or "").strip() or None
if not fb_api_key_hint:
# key_env and api_key_env are both documented aliases (see
# _normalize_custom_provider_entry in hermes_cli/config.py).
fb_key_env = (fb.get("key_env") or fb.get("api_key_env") or "").strip()
if fb_key_env:
fb_api_key_hint = os.getenv(fb_key_env, "").strip() or None
# For Ollama Cloud endpoints, pull OLLAMA_API_KEY from env
# when no explicit key is in the fallback config. Host match
# (not substring) β€” see GHSA-76xc-57q6-vm5m.
if fb_base_url_hint and base_url_host_matches(fb_base_url_hint, "ollama.com") and not fb_api_key_hint:
fb_api_key_hint = os.getenv("OLLAMA_API_KEY") or None
fb_client, _resolved_fb_model = resolve_provider_client(
fb_provider, model=fb_model, raw_codex=True,
explicit_base_url=fb_base_url_hint,
explicit_api_key=fb_api_key_hint)
from hermes_cli.fallback_config import (
resolve_fallback_client,
resolve_fallback_transport,
)

fb_client, _resolved_fb_model, fb_api_mode_hint = (
resolve_fallback_client(fb, raw_codex=True)
)
if fb_client is None:
logger.warning(
"Fallback to %s failed: provider not configured",
Expand All @@ -1458,43 +1445,21 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_model, fb_provider, _norm_err,
)

# Determine api_mode from provider / base URL / model
fb_api_mode = "chat_completions"
# The validated entry hint wins. With no valid hint, reuse the same
# provider/URL transport resolver as initial runtime setup, followed by
# the agent's model-specific Responses API rule.
fb_base_url = str(fb_client.base_url)
_fb_is_azure = agent._is_azure_openai_url(fb_base_url)
if fb_provider == "openai-codex":
fb_api_mode = "codex_responses"
elif (
fb_provider == "anthropic"
or fb_base_url.rstrip("/").lower().endswith("/anthropic")
or base_url_hostname(fb_base_url) == "api.anthropic.com"
):
# Custom providers (e.g. cron-anthropic) point at the native
# api.anthropic.com host with no "/anthropic" path suffix, so the
# name/suffix checks above miss them and they default to
# chat_completions β†’ POST /v1/chat/completions β†’ 404. Match the
# host the same way determine_api_mode() and _detect_api_mode_for_url()
# do on the primary path. (#32243, #49247)
fb_api_mode = "anthropic_messages"
elif _fb_is_azure:
# Azure OpenAI serves gpt-5.x on /chat/completions β€” does NOT
# support the Responses API. Stay on chat_completions.
fb_api_mode = "chat_completions"
elif agent._is_direct_openai_url(fb_base_url):
fb_api_mode = "codex_responses"
elif agent._provider_model_requires_responses_api(
fb_model,
fb_api_mode = resolve_fallback_transport(
validated_api_mode=fb_api_mode_hint,
provider=fb_provider,
):
# GPT-5.x models usually need Responses API, but keep
# provider-specific exceptions like Copilot gpt-5-mini on
# chat completions.
fb_api_mode = "codex_responses"
elif fb_provider == "bedrock" or (
base_url_hostname(fb_base_url).startswith("bedrock-runtime.")
and base_url_host_matches(fb_base_url, "amazonaws.com")
):
fb_api_mode = "bedrock_converse"
model_requires_responses=agent._provider_model_requires_responses_api(
fb_model,
provider=fb_provider,
),
base_url=fb_base_url,
is_azure=_fb_is_azure,
)

old_model = agent.model
old_provider = agent.provider
Expand Down
8 changes: 2 additions & 6 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3030,6 +3030,7 @@ def run_job(
resolve_runtime_provider,
format_runtime_provider_error,
)
from hermes_cli.fallback_config import resolve_fallback_runtime
from hermes_cli.auth import AuthError

# F8 runtime backstop: never resolve a stored provider/base_url pair that
Expand Down Expand Up @@ -3059,12 +3060,7 @@ def run_job(
runtime = None
for entry in fb_list:
try:
fb_kwargs = {"requested": entry.get("provider")}
if entry.get("base_url"):
fb_kwargs["explicit_base_url"] = entry["base_url"]
if entry.get("api_key"):
fb_kwargs["explicit_api_key"] = entry["api_key"]
runtime = resolve_runtime_provider(**fb_kwargs)
runtime = resolve_fallback_runtime(entry)
logger.info("Job '%s': fallback resolved to %s", job_id, runtime.get("provider"))
break
except Exception as fb_exc:
Expand Down
15 changes: 2 additions & 13 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1972,7 +1972,7 @@ def _credential_pool_for_provider(provider: Optional[str]):

def _try_resolve_fallback_provider() -> dict | None:
"""Attempt to resolve credentials from the fallback_model/fallback_providers config."""
from hermes_cli.runtime_provider import resolve_runtime_provider
from hermes_cli.fallback_config import resolve_fallback_runtime
try:
import yaml as _y
cfg_path = _hermes_home / "config.yaml"
Expand All @@ -1985,18 +1985,7 @@ def _try_resolve_fallback_provider() -> dict | None:
return None
for entry in fb_list:
try:
explicit_api_key = entry.get("api_key")
if not explicit_api_key:
key_env = str(
entry.get("key_env") or entry.get("api_key_env") or ""
).strip()
if key_env:
explicit_api_key = os.getenv(key_env, "").strip() or None
runtime = resolve_runtime_provider(
requested=entry.get("provider"),
explicit_base_url=entry.get("base_url"),
explicit_api_key=explicit_api_key,
)
runtime = resolve_fallback_runtime(entry)
# Log the literal `provider` key from config, not the resolved
# runtime category β€” an Ollama fallback resolves through the
# OpenAI-compatible path and would otherwise be logged as
Expand Down
3 changes: 2 additions & 1 deletion hermes_cli/cli_agent_setup_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _ensure_runtime_credentials(self) -> bool:
# Primary provider auth failed β€” try fallback providers before giving up.
if runtime is None and _primary_exc is not None:
from hermes_cli.auth import AuthError
from hermes_cli.fallback_config import resolve_fallback_runtime
if isinstance(_primary_exc, AuthError):
_fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
for _fb in _fb_chain:
Expand All @@ -57,7 +58,7 @@ def _ensure_runtime_credentials(self) -> bool:
if not _fb_provider or not _fb_model:
continue
try:
runtime = resolve_runtime_provider(requested=_fb_provider)
runtime = resolve_fallback_runtime(_fb)
logger.warning(
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
_primary_exc, _fb_provider, _fb_model,
Expand Down
Loading
Loading