From 6a01070c9bdc732563401bd0308f61bd0aaf0288 Mon Sep 17 00:00:00 2001 From: BGwill-OUTLOOK Date: Fri, 4 Sep 2026 00:31:10 -0400 Subject: [PATCH 1/7] fix(runtime): preserve custom provider request overrides --- hermes_cli/cli_agent_setup_mixin.py | 25 +++- hermes_cli/oneshot.py | 1 + .../test_cli_custom_provider_vision.py | 18 +++ ...test_oneshot_provider_request_overrides.py | 107 ++++++++++++++++++ 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_oneshot_provider_request_overrides.py diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 46eae97b0d049..ca78cf9e02695 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -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 @@ -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 @@ -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..extra_body on the normal tier. + route["request_overrides"] = provider_overrides or None return route try: @@ -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: diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index e2778d67d7c1b..b05fe5431f054 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -501,6 +501,7 @@ def _run_agent( platform="cli", session_db=session_db, credential_pool=runtime.get("credential_pool"), + request_overrides=runtime.get("request_overrides"), fallback_model=_fb or None, ephemeral_system_prompt=skills_prompt, # Interactive callbacks are intentionally NOT wired beyond this diff --git a/tests/hermes_cli/test_cli_custom_provider_vision.py b/tests/hermes_cli/test_cli_custom_provider_vision.py index b722667ecaeda..f210285d7222d 100644 --- a/tests/hermes_cli/test_cli_custom_provider_vision.py +++ b/tests/hermes_cli/test_cli_custom_provider_vision.py @@ -47,6 +47,10 @@ def _write_profile_config(hermes_home) -> None: qwen-token-plan: base_url: https://qwen-token-plan.example/v1 api_key: test-key + extra_body: + reasoning: + enabled: true + effort: medium models: qwen3.8-max-preview: supports_vision: true @@ -98,6 +102,20 @@ def test_real_cli_args_keep_transport_and_capability_identities_separate(): assert cli.agent is sentinel_agent +def test_normal_tier_preserves_named_custom_provider_request_overrides(): + """A normal-tier turn must not drop providers..extra_body.""" + _cli, route = _resolve_cli_route() + + assert route["request_overrides"] == { + "extra_body": { + "reasoning": { + "enabled": True, + "effort": "medium", + } + } + } + + def test_named_identity_reaches_agent_and_vision_tool_native_gates(): from agent.auxiliary_client import reset_runtime_main, set_runtime_main from run_agent import AIAgent diff --git a/tests/hermes_cli/test_oneshot_provider_request_overrides.py b/tests/hermes_cli/test_oneshot_provider_request_overrides.py new file mode 100644 index 0000000000000..bad3169e8e566 --- /dev/null +++ b/tests/hermes_cli/test_oneshot_provider_request_overrides.py @@ -0,0 +1,107 @@ +"""Regression coverage for named custom-provider request overrides in -z mode.""" + +from __future__ import annotations + +import sys +import types + + +def _module(name: str, **attrs): + module = types.ModuleType(name) + for key, value in attrs.items(): + setattr(module, key, value) + return module + + +def test_oneshot_passes_named_provider_request_overrides_to_agent(monkeypatch): + """The one-shot constructor must receive the resolver's extra_body.""" + from hermes_cli.oneshot import _run_agent + + captured = {} + + class FakeAgent: + def __init__(self, **kwargs): + captured.update(kwargs) + self.suppress_status_output = False + self.stream_delta_callback = object() + self.tool_gen_callback = object() + + def run_conversation(self, _prompt, **_kwargs): + return {"final_response": "ok", "failed": False, "partial": False} + + def shutdown_memory_provider(self, *_args, **_kwargs): + return None + + def close(self): + return None + + class FakeSessionDB: + def close(self): + return None + + monkeypatch.setitem(sys.modules, "run_agent", _module("run_agent", AIAgent=FakeAgent)) + monkeypatch.setitem( + sys.modules, + "hermes_state", + _module("hermes_state", SessionDB=FakeSessionDB), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.config", + _module( + "hermes_cli.config", + load_config=lambda: {"model": {"default": "gpt-5.6-sol"}}, + ), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.models", + _module( + "hermes_cli.models", + detect_provider_for_model=lambda *_args, **_kwargs: None, + ), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.runtime_provider", + _module( + "hermes_cli.runtime_provider", + resolve_runtime_provider=lambda **_kwargs: { + "api_key": "test-key", + "base_url": "https://gateway.example/v1", + "provider": "custom", + "requested_provider": "custom:gateway", + "api_mode": "chat_completions", + "credential_pool": None, + "request_overrides": { + "extra_body": { + "reasoning": { + "enabled": True, + "effort": "medium", + } + } + }, + }, + ), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.tools_config", + _module( + "hermes_cli.tools_config", + _get_platform_tools=lambda *_args, **_kwargs: set(), + ), + ) + + text, result = _run_agent("hello") + + assert text == "ok" + assert not result.get("failed") + assert captured["request_overrides"] == { + "extra_body": { + "reasoning": { + "enabled": True, + "effort": "medium", + } + } + } From ce0c1c1d3e59bd639ef2842143ceec1a1f4cd17a Mon Sep 17 00:00:00 2001 From: BGwill-OUTLOOK Date: Fri, 4 Sep 2026 00:51:07 -0400 Subject: [PATCH 2/7] fix(aux): preserve dynamic credentials in async clients --- agent/auxiliary_client.py | 18 ++++++++++++- .../test_auxiliary_named_custom_providers.py | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index b7b482e91843c..8b55e5862f9f8 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -44,6 +44,7 @@ their OpenRouter balance but has Codex OAuth or another provider available. """ +import asyncio import contextlib import contextvars import copy @@ -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) diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 40336b720c4d9..fc87b6c842551 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -156,6 +156,32 @@ def test_providers_dict_uses_durable_pool_when_no_inline_key(self, tmp_path): assert "api.b.ai" in str(client.base_url) assert client.api_key == "sk-real-b-ai-pool-key-12345" + def test_async_named_custom_key_cmd_preserves_token_provider(self, tmp_path): + """Vision/async auxiliary clients must not drop a key_cmd credential.""" + _write_config(tmp_path, { + "providers": { + "relay": { + "name": "relay", + "base_url": "https://relay.example.test/v1", + "key_cmd": "printf 'sk-dynamic-test-token'", + "default_model": "vision-model", + }, + }, + }) + + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client( + "relay", "vision-model", async_mode=True, is_vision=True + ) + + assert client is not None + assert model == "vision-model" + assert callable(getattr(client, "_api_key_provider", None)) + + import asyncio + assert asyncio.run(client._api_key_provider()) == "sk-dynamic-test-token" + class TestResolveProviderClientModelNormalization: """Direct-provider auxiliary routing should normalize models like main runtime.""" From bdc8d45ca4731d91244da37a60242ac83ce4ee76 Mon Sep 17 00:00:00 2001 From: BGwill-OUTLOOK Date: Fri, 4 Sep 2026 01:00:12 -0400 Subject: [PATCH 3/7] fix(oneshot): honor explicit and configured reasoning --- hermes_cli/main.py | 5 +++++ hermes_cli/oneshot.py | 17 +++++++++++++++++ .../test_oneshot_provider_request_overrides.py | 10 +++++++--- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8281cbdd63d8f..c2578f39b683a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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, @@ -218,6 +219,7 @@ def _run_and_exit_oneshot( prompt, model=model, provider=provider, + reasoning=reasoning, toolsets=toolsets, skills=skills, usage_file=usage_file, @@ -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), @@ -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), @@ -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), diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index b05fe5431f054..0353f48727dff 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -203,6 +203,7 @@ def run_oneshot( prompt: str, model: Optional[str] = None, provider: Optional[str] = None, + reasoning: Optional[str] = None, toolsets: object = None, skills: object = None, usage_file: Optional[str] = None, @@ -215,6 +216,7 @@ def run_oneshot( env var, then config.yaml's model.default / model.model. provider: Optional provider override. Falls back to config.yaml's model.provider, then "auto". + reasoning: Optional reasoning-effort override for this invocation. toolsets: Optional comma-separated string or iterable of toolsets. skills: Optional repeated/comma-separated skill identifiers to preload. usage_file: Optional path; when set, a JSON usage report (estimated @@ -280,6 +282,7 @@ def run_oneshot( prompt, model=model, provider=provider, + reasoning=reasoning, toolsets=explicit_toolsets, use_config_toolsets=use_config_toolsets, skills=skills, @@ -358,6 +361,7 @@ def _run_agent( prompt: str, model: Optional[str] = None, provider: Optional[str] = None, + reasoning: Optional[str] = None, toolsets: object = None, use_config_toolsets: bool = True, skills: object = None, @@ -454,6 +458,18 @@ def _run_agent( explicit_api_key=explicit_api_key_from_alias, ) + # Match interactive/TUI semantics: the per-invocation CLI flag wins over + # per-model and global config without persisting anything. Previously the + # top-level parser accepted --reasoning for -z but oneshot silently dropped + # it before constructing AIAgent. + from hermes_constants import parse_reasoning_effort, resolve_reasoning_config + + reasoning_config = resolve_reasoning_config(cfg, effective_model) + if reasoning is not None and str(reasoning).strip(): + explicit_reasoning = parse_reasoning_effort(reasoning) + if explicit_reasoning is not None: + reasoning_config = explicit_reasoning + # Pull in explicit toolsets when provided; otherwise use whatever the user # has enabled for "cli". sorted() gives stable ordering for config-derived # sets; explicit values preserve user order. @@ -502,6 +518,7 @@ def _run_agent( session_db=session_db, credential_pool=runtime.get("credential_pool"), request_overrides=runtime.get("request_overrides"), + reasoning_config=reasoning_config, fallback_model=_fb or None, ephemeral_system_prompt=skills_prompt, # Interactive callbacks are intentionally NOT wired beyond this diff --git a/tests/hermes_cli/test_oneshot_provider_request_overrides.py b/tests/hermes_cli/test_oneshot_provider_request_overrides.py index bad3169e8e566..a6a2ed4792179 100644 --- a/tests/hermes_cli/test_oneshot_provider_request_overrides.py +++ b/tests/hermes_cli/test_oneshot_provider_request_overrides.py @@ -13,8 +13,8 @@ def _module(name: str, **attrs): return module -def test_oneshot_passes_named_provider_request_overrides_to_agent(monkeypatch): - """The one-shot constructor must receive the resolver's extra_body.""" +def test_oneshot_passes_named_provider_request_overrides_and_reasoning_to_agent(monkeypatch): + """The one-shot constructor must receive provider and CLI request controls.""" from hermes_cli.oneshot import _run_agent captured = {} @@ -93,7 +93,7 @@ def close(self): ), ) - text, result = _run_agent("hello") + text, result = _run_agent("hello", reasoning="max") assert text == "ok" assert not result.get("failed") @@ -105,3 +105,7 @@ def close(self): } } } + assert captured["reasoning_config"] == { + "enabled": True, + "effort": "max", + } From cd677c35958bed68ad6a7deba2848725cc14c9a2 Mon Sep 17 00:00:00 2001 From: BGwill-OUTLOOK Date: Fri, 4 Sep 2026 01:05:43 -0400 Subject: [PATCH 4/7] fix(reasoning): honor route-specific custom provider effort --- agent/agent_init.py | 34 ++++++++++++++++ agent/agent_runtime_helpers.py | 3 ++ agent/chat_completion_helpers.py | 28 ++++++++++--- .../agent/test_custom_provider_extra_body.py | 37 +++++++++++++++++- tests/run_agent/test_provider_fallback.py | 39 +++++++++++++++++++ 5 files changed, 134 insertions(+), 7 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index a64fdfd8540c6..6d6bb7ba9a96d 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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. @@ -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: diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 1880698e81b13..3ebf1bbe0b689 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -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( diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 4f91ec1c366eb..1f4027e3a0a3e 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -3073,10 +3073,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", @@ -3102,6 +3106,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, ) @@ -3131,9 +3136,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, diff --git a/tests/agent/test_custom_provider_extra_body.py b/tests/agent/test_custom_provider_extra_body.py index ea94c104f2a21..4c5880ad21e86 100644 --- a/tests/agent/test_custom_provider_extra_body.py +++ b/tests/agent/test_custom_provider_extra_body.py @@ -1,6 +1,9 @@ from types import SimpleNamespace -from agent.agent_init import _merge_custom_provider_extra_body +from agent.agent_init import ( + _apply_reasoning_config_to_request_overrides, + _merge_custom_provider_extra_body, +) @@ -71,3 +74,35 @@ def test_named_custom_provider_extra_body_matches_provider_key(): ) assert agent.request_overrides == {"extra_body": {"enable_thinking": False}} + + +def test_runtime_reasoning_overrides_provider_wide_reasoning_default(): + agent = SimpleNamespace( + reasoning_config={"enabled": True, "effort": "max"}, + request_overrides={ + "extra_body": { + "reasoning": {"enabled": True, "effort": "medium"}, + "provider_only": True, + }, + }, + ) + + _apply_reasoning_config_to_request_overrides(agent) + + assert agent.request_overrides == { + "extra_body": { + "reasoning": {"enabled": True, "effort": "max"}, + "provider_only": True, + }, + } + + +def test_runtime_reasoning_does_not_invent_an_unsupported_wire_field(): + agent = SimpleNamespace( + reasoning_config={"enabled": True, "effort": "max"}, + request_overrides={"extra_body": {"provider_only": True}}, + ) + + _apply_reasoning_config_to_request_overrides(agent) + + assert agent.request_overrides == {"extra_body": {"provider_only": True}} diff --git a/tests/run_agent/test_provider_fallback.py b/tests/run_agent/test_provider_fallback.py index cf6c4f5475dd6..600b684be966a 100644 --- a/tests/run_agent/test_provider_fallback.py +++ b/tests/run_agent/test_provider_fallback.py @@ -502,3 +502,42 @@ def test_non_extra_body_overrides_untouched(self): agent.request_overrides["temperature"] = 0.2 self._activate(agent) assert agent.request_overrides.get("temperature") == 0.2 + + def test_fallback_entry_reasoning_controls_override_provider_default(self): + """A fallback's explicit effort/body must beat the provider-wide default.""" + agent = _make_agent( + fallback_model={ + "provider": "custom:fbprov", + "model": "gpt-5.6-terra", + "base_url": self.FB_URL, + "reasoning_effort": "max", + "extra_body": { + "reasoning": {"enabled": True, "effort": "max"}, + }, + }, + ) + agent.provider = "custom" + agent.model = "gpt-5.6-sol" + agent.base_url = self.OLD_URL + agent.request_overrides = { + "extra_body": { + "reasoning": {"enabled": True, "effort": "medium"}, + }, + } + agent._custom_providers = [ + { + "provider_key": "fbprov", + "base_url": self.FB_URL, + "extra_body": { + "reasoning": {"enabled": True, "effort": "medium"}, + }, + }, + ] + + self._activate(agent) + + assert agent.reasoning_config == {"enabled": True, "effort": "max"} + assert agent.request_overrides["extra_body"]["reasoning"] == { + "enabled": True, + "effort": "max", + } From 70fa95937dace1f0b2c5e853193f1cd4461dc50b Mon Sep 17 00:00:00 2001 From: BGwill-OUTLOOK Date: Fri, 4 Sep 2026 01:10:54 -0400 Subject: [PATCH 5/7] fix(fallback): preserve named custom transport --- agent/chat_completion_helpers.py | 21 ++++++++++++++++++++- tests/run_agent/test_provider_fallback.py | 2 ++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 1f4027e3a0a3e..e1920695de8e7 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -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 @@ -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"}: diff --git a/tests/run_agent/test_provider_fallback.py b/tests/run_agent/test_provider_fallback.py index 600b684be966a..4e18c69667c69 100644 --- a/tests/run_agent/test_provider_fallback.py +++ b/tests/run_agent/test_provider_fallback.py @@ -528,6 +528,7 @@ def test_fallback_entry_reasoning_controls_override_provider_default(self): { "provider_key": "fbprov", "base_url": self.FB_URL, + "api_mode": "chat_completions", "extra_body": { "reasoning": {"enabled": True, "effort": "medium"}, }, @@ -536,6 +537,7 @@ def test_fallback_entry_reasoning_controls_override_provider_default(self): self._activate(agent) + assert agent.api_mode == "chat_completions" assert agent.reasoning_config == {"enabled": True, "effort": "max"} assert agent.request_overrides["extra_body"]["reasoning"] == { "enabled": True, From 6cb3b9eac37d5d37311c36625649b141fe63ddd0 Mon Sep 17 00:00:00 2001 From: BGwill-OUTLOOK Date: Fri, 4 Sep 2026 01:13:13 -0400 Subject: [PATCH 6/7] fix(fallback): preserve dynamic credentials during activation --- agent/chat_completion_helpers.py | 16 ++++++++++++--- tests/run_agent/test_provider_fallback.py | 25 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index e1920695de8e7..e900d44f33460 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2996,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. @@ -3004,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 @@ -3016,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 @@ -3030,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 {}), } diff --git a/tests/run_agent/test_provider_fallback.py b/tests/run_agent/test_provider_fallback.py index 4e18c69667c69..29f234d97696c 100644 --- a/tests/run_agent/test_provider_fallback.py +++ b/tests/run_agent/test_provider_fallback.py @@ -37,6 +37,7 @@ def _mock_client(base_url="https://openrouter.ai/api/v1", api_key="fb-key"): mock = MagicMock() mock.base_url = base_url mock.api_key = api_key + mock._api_key_provider = None return mock @@ -214,6 +215,30 @@ def test_resolves_key_env_for_fallback_provider(self): assert agent._try_activate_fallback() is True assert mock_rpc.call_args.kwargs["explicit_api_key"] == "env-secret" + def test_callable_fallback_credential_survives_client_swap(self): + """key_cmd/Entra callables must not collapse to client.api_key=''.""" + token_provider = lambda: "fresh-token" + client = _mock_client( + base_url="https://fallback.example/v1", + api_key="", + ) + client._api_key_provider = token_provider + agent = _make_agent( + fallback_model={ + "provider": "custom:relay", + "model": "fallback-model", + }, + ) + + with patch( + "agent.auxiliary_client.resolve_provider_client", + return_value=(client, "fallback-model"), + ): + assert agent._try_activate_fallback() is True + + assert agent.api_key is token_provider + assert agent._client_kwargs["api_key"] is token_provider + def test_nous_anthropic_fallback_uses_the_messages_wire(self): """Portal Claude fallbacks must not stay on chat_completions. From 30dbe6120fa487576bc412ea5fb64be7f35479e1 Mon Sep 17 00:00:00 2001 From: BGwill-OUTLOOK Date: Fri, 4 Sep 2026 01:33:13 -0400 Subject: [PATCH 7/7] fix(doctor): recognize command-minted provider credentials --- hermes_cli/doctor.py | 35 +++++++++++++++++++++++++++++++-- tests/hermes_cli/test_doctor.py | 17 +++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 91c26092c8ecc..8a98b6f6aca24 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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: @@ -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") diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 267388f369169..89789ba24742d 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -14,7 +14,10 @@ import hermes_cli.doctor as doctor import hermes_cli.gateway as gateway_cli from hermes_cli import doctor as doctor_mod -from hermes_cli.doctor import _has_provider_env_config +from hermes_cli.doctor import ( + _has_provider_config_key_command, + _has_provider_env_config, +) class TestDoctorPlatformHints: @@ -65,6 +68,18 @@ def test_returns_false_when_no_provider_settings(self): content = "TERMINAL_ENV=local\n" assert not _has_provider_env_config(content) + def test_detects_command_minted_provider_credential(self, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + "providers:\n" + " relay:\n" + " api: https://gateway.example/v1\n" + " key_cmd: credential-helper print-token\n", + encoding="utf-8", + ) + + assert _has_provider_config_key_command(config_path) + class TestDoctorToolAvailabilitySummary: def test_missing_api_key_summary_ignores_disabled_toolsets(self, monkeypatch):