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
34 changes: 34 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,39 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An
agent.request_overrides = overrides


def _apply_reasoning_config_to_request_overrides(agent) -> None:
"""Apply the active reasoning level to an existing custom wire field.

A named custom provider may declare ``extra_body.reasoning`` to certify
that its endpoint accepts that wire shape. The active per-model/session
``reasoning_config`` is more specific than that provider-wide default and
must therefore win. Do not invent the field for providers that did not
declare it: their reasoning vocabulary may be different or unsupported.
"""
reasoning_config = getattr(agent, "reasoning_config", None)
if not isinstance(reasoning_config, dict):
return

overrides = dict(getattr(agent, "request_overrides", {}) or {})
extra_body = overrides.get("extra_body")
if not isinstance(extra_body, dict) or not isinstance(extra_body.get("reasoning"), dict):
return

merged_reasoning = dict(extra_body["reasoning"])
effort = str(reasoning_config.get("effort") or "").strip().lower()
if reasoning_config.get("enabled") is False or effort == "none":
merged_reasoning.update({"enabled": False, "effort": "none"})
elif effort:
merged_reasoning.update({"enabled": True, "effort": effort})
else:
return

merged_extra_body = dict(extra_body)
merged_extra_body["reasoning"] = merged_reasoning
overrides["extra_body"] = merged_extra_body
agent.request_overrides = overrides


def _normalize_run_budget_seconds(value) -> Optional[float]:
"""Normalize a wall-clock run budget value to a positive float or None.

Expand Down Expand Up @@ -2654,6 +2687,7 @@ def _parse_prune_int(raw, default):
# compression model context-length detection needs the same list).
agent._custom_providers = _custom_providers
_merge_custom_provider_extra_body(agent, _custom_providers)
_apply_reasoning_config_to_request_overrides(agent)

# Check custom_providers per-model context_length
if _config_context_length is None and _custom_providers:
Expand Down
3 changes: 3 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2996,6 +2996,9 @@ def _apply_switched_provider_request_overrides(agent, new_provider):
if new_extra_body:
overrides["extra_body"] = dict(new_extra_body)
agent.request_overrides = overrides
from agent.agent_init import _apply_reasoning_config_to_request_overrides

_apply_reasoning_config_to_request_overrides(agent)


def switch_model(
Expand Down
18 changes: 17 additions & 1 deletion agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
their OpenRouter balance but has Codex OAuth or another provider available.
"""

import asyncio
import contextlib
import contextvars
import copy
Expand Down Expand Up @@ -6728,8 +6729,23 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
if _client_declares(sync_client, "HERMES_SKIP_ASYNC_WRAP"):
return sync_client, model

# OpenAI's sync client stores callable credentials in the private
# ``_api_key_provider`` slot and leaves ``api_key`` as an empty string.
# Copying only ``sync_client.api_key`` therefore strips key_cmd / Entra
# credentials when vision and other auxiliary work switch to AsyncOpenAI.
# AsyncOpenAI expects an async token provider, so adapt the sync callable
# through a worker thread; a key helper may perform bounded local I/O.
sync_key_provider = getattr(sync_client, "_api_key_provider", None)
if callable(sync_key_provider):
async def _async_key_provider() -> str:
return await asyncio.to_thread(sync_key_provider)

async_api_key: Any = _async_key_provider
else:
async_api_key = sync_client.api_key

async_kwargs = {
"api_key": sync_client.api_key,
"api_key": async_api_key,
"base_url": str(sync_client.base_url),
}
sync_base_url = str(sync_client.base_url)
Expand Down
65 changes: 55 additions & 10 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2825,9 +2825,28 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# An explicit ``api_mode`` on the fallback entry always wins — including
# an explicit "chat_completions" — and suppresses all re-detection below.
fb_api_mode_explicit = bool(str(fb.get("api_mode") or "").strip())
fb_named_api_mode = ""
if str(fb_provider or "").strip().lower().startswith("custom:"):
_fb_provider_key = str(fb_provider).split(":", 1)[1].strip().lower()
for _cp in getattr(agent, "_custom_providers", None) or []:
if not isinstance(_cp, dict):
continue
_cp_keys = {
str(_cp.get("provider_key") or "").strip().lower(),
str(_cp.get("name") or "").strip().lower(),
}
if _fb_provider_key in _cp_keys:
fb_named_api_mode = str(
_cp.get("api_mode") or _cp.get("transport") or ""
).strip()
break
fb_api_mode = "chat_completions"
if fb_api_mode_explicit:
fb_api_mode = str(fb.get("api_mode")).strip()
elif fb_named_api_mode:
# A named custom provider's declared transport is authoritative.
# Do not infer Responses solely from a GPT-looking model name.
fb_api_mode = fb_named_api_mode
elif fb_provider == "anthropic":
# Provider-name check must not be gated on fb_base_url_hint:
# an entry that names provider: anthropic without an explicit
Expand Down Expand Up @@ -2877,7 +2896,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_base_url = str(fb_client.base_url)
_fb_is_azure = agent._is_azure_openai_url(fb_base_url)

if not fb_api_mode_explicit and fb_api_mode == "chat_completions":
if not (fb_api_mode_explicit or fb_named_api_mode) and fb_api_mode == "chat_completions":
if fb_provider == "openai-codex":
fb_api_mode = "codex_responses"
elif fb_provider in {"nous", "nous-portal", "nousresearch"}:
Expand Down Expand Up @@ -2977,6 +2996,16 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
fb_provider, fb_model, exc,
)

# OpenAI stores callable credentials (key_cmd, Entra ID) in a private
# provider slot while exposing ``client.api_key`` as an empty string.
# Preserve the callable across the client swap or the first fallback
# request is sent without Authorization and incorrectly skips to the
# next provider.
_fb_key_provider = getattr(fb_client, "_api_key_provider", None)
_fb_runtime_api_key = (
_fb_key_provider if callable(_fb_key_provider) else fb_client.api_key
)

# Honor per-provider / per-model request_timeout_seconds for the
# fallback target (same knob the primary client uses). None = use
# SDK default.
Expand All @@ -2985,7 +3014,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
if fb_api_mode == "anthropic_messages":
# Build native Anthropic client instead of using OpenAI client
from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token, _is_oauth_token
effective_key = (fb_client.api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (fb_client.api_key or "")
effective_key = (_fb_runtime_api_key or resolve_anthropic_token() or "") if fb_provider == "anthropic" else (_fb_runtime_api_key or "")
agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = fb_base_url
Expand All @@ -2997,7 +3026,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
agent._client_kwargs = {}
else:
# Swap OpenAI client and config in-place
agent.api_key = fb_client.api_key
agent.api_key = _fb_runtime_api_key
agent.client = fb_client
# Preserve provider-specific headers that
# resolve_provider_client() may have baked into
Expand All @@ -3011,7 +3040,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
if not fb_headers:
fb_headers = getattr(fb_client, "default_headers", None)
agent._client_kwargs = {
"api_key": fb_client.api_key,
"api_key": _fb_runtime_api_key,
"base_url": fb_base_url,
**({"default_headers": dict(fb_headers)} if fb_headers else {}),
}
Expand Down Expand Up @@ -3073,10 +3102,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# config load failure must not kill the swap.
try:
from hermes_cli.config import load_config
from hermes_constants import resolve_reasoning_config

agent.reasoning_config = resolve_reasoning_config(
load_config() or {}, agent.model
from hermes_constants import parse_reasoning_effort, resolve_reasoning_config

fallback_effort = fb.get("reasoning_effort")
fallback_reasoning = parse_reasoning_effort(fallback_effort)
agent.reasoning_config = (
fallback_reasoning
if fallback_effort is not None and fallback_reasoning is not None
else resolve_reasoning_config(load_config() or {}, agent.model)
)
logger.info(
"Fallback %s: reasoning_config resolved: %s",
Expand All @@ -3102,6 +3135,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
# swap untouched.
try:
from agent.agent_init import (
_apply_reasoning_config_to_request_overrides,
_custom_provider_extra_body_for_agent,
_merge_custom_provider_extra_body,
)
Expand Down Expand Up @@ -3131,9 +3165,20 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
else:
_overrides.pop("extra_body", None)
agent.request_overrides = _overrides
# Merge in the fallback provider's own extra_body (existing
# caller-provided keys win on conflict inside the merge helper).
# Merge in the fallback provider's own extra_body, then layer the
# fallback entry itself on top because it is the most specific
# route configuration.
_merge_custom_provider_extra_body(agent, _custom_providers)
# The fallback entry is more specific than the named provider and
# may carry its own request controls.
_fallback_extra_body = fb.get("extra_body")
if isinstance(_fallback_extra_body, dict) and _fallback_extra_body:
_overrides = dict(getattr(agent, "request_overrides", {}) or {})
_merged_fb_body = dict(_overrides.get("extra_body") or {})
_merged_fb_body.update(_fallback_extra_body)
_overrides["extra_body"] = _merged_fb_body
agent.request_overrides = _overrides
_apply_reasoning_config_to_request_overrides(agent)
logger.info(
"Fallback %s: extra_body resolved: %s",
agent.model,
Expand Down
25 changes: 22 additions & 3 deletions hermes_cli/cli_agent_setup_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def _ensure_runtime_credentials(self) -> bool:
resolved_acp_command = runtime.get("command")
resolved_acp_args = list(runtime.get("args") or [])
resolved_credential_pool = runtime.get("credential_pool")
resolved_request_overrides = dict(runtime.get("request_overrides") or {})
# A callable api_key is a bearer-token provider (Azure Foundry
# Entra ID — ``azure_identity_adapter.build_token_provider``).
# The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and
Expand Down Expand Up @@ -164,12 +165,15 @@ def _ensure_runtime_credentials(self) -> bool:
or resolved_api_mode != self.api_mode
or resolved_acp_command != self.acp_command
or resolved_acp_args != self.acp_args
or resolved_request_overrides
!= dict(getattr(self, "_provider_request_overrides", {}) or {})
)
self.provider = resolved_provider
self.api_mode = resolved_api_mode
self.acp_command = resolved_acp_command
self.acp_args = resolved_acp_args
self._credential_pool = resolved_credential_pool
self._provider_request_overrides = resolved_request_overrides
self._provider_source = runtime.get("source")
self.api_key = api_key
self.base_url = base_url
Expand Down Expand Up @@ -352,11 +356,16 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict:
),
}

provider_overrides = dict(
getattr(self, "_provider_request_overrides", {}) or {}
)

service_tier = getattr(self, "service_tier", None)
if service_tier != "priority":
# None (normal) or auto/cold — the bounded window is applied per
# request by agent.fast_mode, not pinned into request_overrides.
route["request_overrides"] = None
# request by agent.fast_mode. Preserve provider-level overrides
# such as providers.<name>.extra_body on the normal tier.
route["request_overrides"] = provider_overrides or None
return route

try:
Expand All @@ -367,7 +376,17 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict:
)
except Exception:
overrides = None
route["request_overrides"] = overrides
merged_overrides = provider_overrides
if overrides:
merged_overrides = {**provider_overrides, **overrides}
provider_extra = provider_overrides.get("extra_body")
fast_extra = overrides.get("extra_body")
if isinstance(provider_extra, dict) and isinstance(fast_extra, dict):
merged_overrides["extra_body"] = {
**provider_extra,
**fast_extra,
}
route["request_overrides"] = merged_overrides or None
return route

def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
Expand Down
35 changes: 33 additions & 2 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,35 @@ def _has_provider_env_config(content: str) -> bool:
return any(key in content for key in _PROVIDER_ENV_HINTS)


def _has_provider_config_key_command(config_path: Path) -> bool:
"""Return True when config declares a command-minted provider token.

``key_cmd`` is a first-class credential source and intentionally keeps the
token out of ``.env``. Doctor must not tell a correctly configured user to
run setup merely because that safer credential source lives in YAML.
"""
try:
from hermes_cli.config import read_user_config_raw

config = read_user_config_raw(config_path)
except Exception:
return False

providers = config.get("providers")
if isinstance(providers, dict):
for entry in providers.values():
if isinstance(entry, dict) and str(entry.get("key_cmd") or "").strip():
return True

legacy = config.get("custom_providers")
if isinstance(legacy, list):
return any(
isinstance(entry, dict) and str(entry.get("key_cmd") or "").strip()
for entry in legacy
)
return False


def _honcho_is_configured_for_doctor() -> bool:
"""Return True when Honcho is configured, even if this process has no active session."""
try:
Expand Down Expand Up @@ -1490,8 +1519,10 @@ def run_doctor(args):
content = env_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = env_path.read_text(encoding="latin-1")
if _has_provider_env_config(content):
check_ok("API key or custom endpoint configured")
if _has_provider_env_config(content) or _has_provider_config_key_command(
HERMES_HOME / "config.yaml"
):
check_ok("API key, key command, or custom endpoint configured")
else:
check_warn(f"No API key found in {_DHH}/.env")
issues.append("Run 'hermes setup' to configure API keys")
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ def _run_and_exit_oneshot(
*,
model: object = None,
provider: object = None,
reasoning: object = None,
toolsets: object = None,
skills: object = None,
usage_file: object = None,
Expand All @@ -218,6 +219,7 @@ def _run_and_exit_oneshot(
prompt,
model=model,
provider=provider,
reasoning=reasoning,
toolsets=toolsets,
skills=skills,
usage_file=usage_file,
Expand Down Expand Up @@ -3462,6 +3464,7 @@ def _skills_sync_bg() -> None:
tui_dev=getattr(args, "tui_dev", False),
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
reasoning=getattr(args, "reasoning", None),
toolsets=getattr(args, "toolsets", None),
skills=getattr(args, "skills", None),
verbose=getattr(args, "verbose", None),
Expand Down Expand Up @@ -13098,6 +13101,7 @@ def _try_fast_chat_launch() -> bool:
args.oneshot,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
reasoning=getattr(args, "reasoning", None),
toolsets=getattr(args, "toolsets", None),
skills=getattr(args, "skills", None),
usage_file=getattr(args, "usage_file", None),
Expand Down Expand Up @@ -13156,6 +13160,7 @@ def _try_termux_fast_cli_launch() -> bool:
args.oneshot,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
reasoning=getattr(args, "reasoning", None),
toolsets=getattr(args, "toolsets", None),
skills=getattr(args, "skills", None),
usage_file=getattr(args, "usage_file", None),
Expand Down
Loading