From 2bd9721f053c537ae0b056ec5f71d592066adb50 Mon Sep 17 00:00:00 2001 From: bookerwei Date: Tue, 21 Jul 2026 15:31:40 +0800 Subject: [PATCH 1/3] fix(minimax): preserve native M3 thinking on Anthropic routes Enable MiniMax-M3 on https://api.minimaxi.com/anthropic (and the global MiniMax endpoint) to use the official adaptive thinking contract (thinking={"type":"adaptive"} or {"type":"disabled"}) instead of the Anthropic-style enabled + budget_tokens form. M2.x and any non-canonical M3 slug fall through to the existing manual thinking branch. Fixes the regression where MiniMax-M3 thinking and the model response were indistinguishable on the /anthropic route (the upstream MiniMax profile only adapted the /v1 path). - Endpoint + canonical-slug gate: _is_minimax_anthropic_endpoint() AND _is_minimax_m3() prevent over-matching (vendor/minimax-m3-preview, minimax-m3-128k) and false-positive matches on non-MiniMax Anthropic-compatible endpoints (Azure, Foundry, Palantir). - AnthropicCompletionsAdapter forwards the underlying SDK clients base_url so the auxiliary/MoA call path reaches the same gate. - _manage_thinking_signatures preserves the full thinking/text/tool_use sequence for intact turns and drops the now-invalid thinking blocks on orphan-mutated turns without leaking the internal _thinking_signature_invalidated flag onto the wire. - New regression tests cover: canonical-slug exact match (positive + negative), raw SDK response -> normalize_response -> persist -> round-trip, Codex->MiniMax fallback with reasoning_content history, redacted_thinking preservation, M3 orphan-then-text tool_use fixture, orphan flag cleanup across merged assistant turns, M3 on non-MiniMax endpoints (manual), M2.x on MiniMax endpoints (manual), and the auxiliary adapters _client.base_url=None fallback. Validated on latest origin/main (f4df260f26c93f15694698869f3ea8e965eea301) with 604 focused tests passing and a live MiniMax CN /anthropic smoke returning distinct thinking and text blocks under the adaptive contract. --- agent/anthropic_adapter.py | 79 +- agent/auxiliary_client.py | 1359 ++------------------------ tests/agent/test_auxiliary_client.py | 93 +- tests/agent/test_minimax_provider.py | 393 +++++++- 4 files changed, 646 insertions(+), 1278 deletions(-) diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 0d59d94c9c32..5c87aaf496e0 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -612,6 +612,32 @@ def _base_url_needs_context_1m_beta(base_url: str | None) -> bool: return "azure.com" in normalized +_MINIMAX_M3_CANONICAL_SLUGS = frozenset({ + "minimax-m3", + "minimax/minimax-m3", +}) + + +def _is_minimax_m3(model: str | None) -> bool: + """Return True for canonical MiniMax-M3 model slugs only. + + MiniMax-M3 follows a distinct Anthropic-compatible thinking contract + (adaptive/disabled, no ``budget_tokens`` / ``output_config``) on MiniMax's + own Anthropic-compatible endpoints. Match the canonical slugs exactly so + future third-party slugs that merely contain the substring — e.g. + ``some-vendor/minimax-m3-preview`` — fall through to the existing manual + thinking branch. + """ + if not isinstance(model, str): + return False + normalized = model.strip().lower() + if not normalized: + return False + if "/" in normalized: + normalized = normalized.rsplit("/", 1)[-1] + return normalized in _MINIMAX_M3_CANONICAL_SLUGS + + def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool: """Return True for MiniMax's Anthropic-compatible endpoints. @@ -2455,13 +2481,15 @@ def _manage_thinking_signatures( stripping, message merging) invalidates the signature, causing HTTP 400 "Invalid signature in thinking block". - Signatures are Anthropic-proprietary. Third-party endpoints (MiniMax, - Azure AI Foundry, AWS Bedrock, self-hosted proxies) cannot validate them - and will reject them outright. Kimi's /coding and DeepSeek's /anthropic - endpoints speak the Anthropic protocol upstream but require unsigned - thinking blocks (synthesised from ``reasoning_content``) to round-trip on - replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and - hermes-agent#16748 (DeepSeek). + Signatures are Anthropic-proprietary. Third-party endpoints (Azure AI + Foundry, AWS Bedrock, self-hosted proxies) cannot validate them and will + reject them outright. MiniMax-M3's Anthropic-compatible endpoints are a + documented exception: they require their complete thinking/text/tool_use + content blocks to be returned verbatim in later tool-call turns. Kimi's + /coding and DeepSeek's /anthropic endpoints also require unsigned thinking + blocks (synthesised from ``reasoning_content``) to round-trip on replayed + assistant tool-call messages. See MiniMax's Anthropic API compatibility + documentation, hermes-agent#13848 (Kimi), and hermes-agent#16748 (DeepSeek). Nous Portal's ``/v1/messages`` route is the exception among third-party hosts: it proxies Claude to Anthropic/Vertex/Bedrock and validates the @@ -2490,7 +2518,24 @@ def _manage_thinking_signatures( if m.get("role") != "assistant" or not isinstance(m.get("content"), list): continue - if _is_kimi_family_endpoint(base_url, model): + is_minimax_m3 = _is_minimax_anthropic_endpoint(base_url) and _is_minimax_m3(model) + if is_minimax_m3: + # MiniMax-M3 requires complete content blocks (thinking, text, and + # tool_use) to round-trip unchanged across function-call turns. + # Its thinking blocks are not Anthropic signatures, so do not send + # this documented provider through the generic third-party strip. + # + # If orphan cleanup already removed a tool_use from this turn, the + # original content is no longer complete and must not be replayed + # as MiniMax thinking. Drop only the now-invalid thinking blocks; + # preserved text/tool_use still form a valid recovery turn. + if m.get("_thinking_signature_invalidated"): + m["content"] = [ + b + for b in m["content"] + if not (isinstance(b, dict) and b.get("type") in _THINKING_TYPES) + ] or [{"type": "text", "text": "(thinking elided)"}] + elif _is_kimi_family_endpoint(base_url, model): # Kimi does not enforce thinking signatures — replay as-is # (shared cleanup below still strips cache markers + the internal flag). pass @@ -2847,9 +2892,10 @@ def _to_oauth_wire_name(name: str) -> str: # Map reasoning_config to Anthropic's thinking parameter. # Claude 4.6+ models use adaptive thinking + output_config.effort. - # Older models use manual thinking with budget_tokens. - # MiniMax Anthropic-compat endpoints support thinking (manual mode only, - # not adaptive). Haiku does NOT support extended thinking — skip entirely. + # Older models use manual thinking with budget_tokens. MiniMax-M3 on its + # Anthropic-compatible endpoints uses a distinct adaptive contract with no + # output_config or budget_tokens; it returns separate thinking/text blocks. + # Haiku does NOT support extended thinking — skip entirely. # # Kimi / Moonshot models also use adaptive thinking: their # Anthropic-compatible endpoints (api.moonshot.cn/anthropic, @@ -2864,7 +2910,16 @@ def _to_oauth_wire_name(name: str) -> str: # request "summarized" so the reasoning blocks stay populated — matching # 4.6 behavior and preserving the activity-feed UX during long tool runs. if reasoning_config and isinstance(reasoning_config, dict): - if reasoning_config.get("enabled") is not False and "haiku" not in model.lower(): + is_minimax_m3 = _is_minimax_anthropic_endpoint(base_url) and _is_minimax_m3(model) + if is_minimax_m3: + # MiniMax documents only adaptive/disabled for M3. Do not send + # Anthropic's enabled + budget_tokens form: it is not MiniMax's + # structured-thinking contract. + if reasoning_config.get("enabled") is False: + kwargs["thinking"] = {"type": "disabled"} + else: + kwargs["thinking"] = {"type": "adaptive"} + elif reasoning_config.get("enabled") is not False and "haiku" not in model.lower(): effort = str(reasoning_config.get("effort", "medium")).lower() budget = THINKING_BUDGET.get(effort, 8000) if _supports_adaptive_thinking(model): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 426479560427..c3e3783fd367 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -42,7 +42,6 @@ import contextlib import contextvars -import functools import hashlib import inspect import json @@ -51,10 +50,9 @@ import re import threading import time -import uuid from pathlib import Path # noqa: F401 — used by test mocks from types import SimpleNamespace -from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING from urllib.parse import urlparse, parse_qs, urlunparse # NOTE: `from openai import OpenAI` is deliberately NOT at module top — the @@ -249,50 +247,6 @@ def aux_interrupt_protection(active: bool = True): _aux_interrupt_protection.active = prev -# ── Forward-progress hook for streamed auxiliary calls ─────────────────── -# Long auxiliary calls (context compression is the prime case) are watched by -# wall-clock deadlines in their hosts (gateway session hygiene). A fixed -# deadline punishes SLOW summary models exactly as hard as HUNG ones: a -# reasoning model happily streaming a large summary is killed mid-generation. -# This thread-local hook lets the host observe liveness instead: the wire -# consumers below tick it on every streamed token/SSE event, and the host -# extends its deadline while tokens are moving (see gateway/run.py session -# hygiene + CompressionCommitFence.touch_progress). Thread-local matches the -# call topology — the aux call and its stream consumption run synchronously -# on the thread that installed the hook. -_aux_progress = threading.local() - - -def _notify_aux_progress() -> None: - """Tick the installed forward-progress hook, if any. Never raises.""" - hook = getattr(_aux_progress, "hook", None) - if hook is None: - return - try: - hook() - except Exception: - logger.debug("aux progress hook failed", exc_info=True) - - -def _aux_progress_active() -> bool: - return getattr(_aux_progress, "hook", None) is not None - - -@contextlib.contextmanager -def aux_progress_hook(hook): - """Install *hook* as the current thread's aux forward-progress callback. - - ``hook=None`` is a no-op passthrough so callers can wire it - unconditionally. Re-entrant-safe: restores the previous hook on exit. - """ - prev = getattr(_aux_progress, "hook", None) - _aux_progress.hook = hook if callable(hook) else prev - try: - yield - finally: - _aux_progress.hook = prev - - def _safe_isinstance(obj: Any, maybe_type: Any) -> bool: """Return False instead of raising when a patched symbol is not a type.""" try: @@ -536,7 +490,7 @@ def _get_aux_model_for_provider(provider_id: str) -> str: # plus providers we intentionally keep pinned here (e.g. Anthropic predates # profiles). New providers should set default_aux_model on their profile instead. _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = { - "gemini": "gemini-3.6-flash", + "gemini": "gemini-3-flash-preview", "zai": "glm-4.5-flash", "kimi-coding": "kimi-k2-turbo-preview", "stepfun": "step-3.5-flash", @@ -545,7 +499,7 @@ def _get_aux_model_for_provider(provider_id: str) -> str: "anthropic": "claude-haiku-4-5-20251001", "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", - "kilocode": "google/gemini-3.6-flash", + "kilocode": "google/gemini-3-flash-preview", "ollama-cloud": "nemotron-3-nano:30b", "tencent-tokenhub": "hy3-preview", # NB: no "deepinfra" entry — its aux model lives on the ProviderProfile @@ -760,8 +714,8 @@ def _nous_extra_body() -> dict: auxiliary_is_nous: bool = False # Default auxiliary models per provider -_OPENROUTER_MODEL = "google/gemini-3.6-flash" -_NOUS_MODEL = "google/gemini-3.6-flash" +_OPENROUTER_MODEL = "google/gemini-3-flash-preview" +_NOUS_MODEL = "google/gemini-3-flash-preview" _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" _ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com" _AUTH_JSON_PATH = get_hermes_home() / "auth.json" @@ -1104,29 +1058,16 @@ def create(self, **kwargs) -> Any: # key in extra_body (not top-level) and GitHub/Copilot Responses opts # out of cache-key routing entirely — for those hosts, skip it here. try: - from agent.transports.codex import ( - _content_cache_key, - _default_prompt_cache_retention_for_request, - ) + from agent.transports.codex import _content_cache_key from utils import base_url_host_matches _host_src = str(getattr(self._client, "base_url", "") or "") _is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai") - _is_github = ( - base_url_host_matches(_host_src, "githubcopilot.com") - or base_url_host_matches(_host_src, "models.github.ai") - ) + _is_github = base_url_host_matches(_host_src, "githubcopilot.com") if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs: _cache_key = _content_cache_key(instructions, resp_kwargs.get("tools")) if _cache_key: resp_kwargs["prompt_cache_key"] = _cache_key - if "prompt_cache_retention" not in resp_kwargs: - _cache_retention = _default_prompt_cache_retention_for_request( - model, - _host_src, - ) - if _cache_retention: - resp_kwargs["prompt_cache_retention"] = _cache_retention except Exception: logger.debug( "Codex auxiliary: prompt_cache_key derivation skipped", exc_info=True @@ -1208,10 +1149,6 @@ def _check_cancelled() -> None: def _on_each_event(_event: Any) -> None: # Re-check timeout/cancellation per event, matching the # cadence the old in-line ``_check_cancelled()`` used. - # Each SSE event is also forward progress for hosts watching - # a progress hook (gateway session hygiene): a reasoning - # model streaming a long summary must not look hung. - _notify_aux_progress() _check_cancelled() event_stream = self._client.responses.create(**stream_kwargs) @@ -1364,32 +1301,10 @@ def __init__(self, sync_wrapper: "CodexAuxiliaryClient"): class _AnthropicCompletionsAdapter: """OpenAI-client-compatible adapter for Anthropic Messages API.""" - def __init__( - self, - real_client: Any, - model: str, - is_oauth: bool = False, - base_url: str | None = None, - ): + def __init__(self, real_client: Any, model: str, is_oauth: bool = False): self._client = real_client self._model = model self._is_oauth = is_oauth - # Prefer the caller-supplied URL (AnthropicAuxiliaryClient keeps the - # pre-strip Portal ``.../v1`` form). Only fall back to the SDK - # client's host for Nous Portal — a blanket fallback would flip - # MiniMax/Zhipu/etc. aux adapters from "unknown host = native - # Anthropic" to third-party (stripping thinking signatures). - self._base_url = base_url or None - if not self._base_url: - candidate = str(getattr(real_client, "base_url", "") or "") or None - if candidate: - try: - from agent.anthropic_adapter import _is_nous_portal_endpoint - - if _is_nous_portal_endpoint(candidate): - self._base_url = candidate - except Exception: - pass def create(self, **kwargs) -> Any: from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message @@ -1400,6 +1315,7 @@ def create(self, **kwargs) -> Any: tools = kwargs.get("tools") tool_choice = kwargs.get("tool_choice") reasoning_config = kwargs.get("_reasoning_config") + base_url = str(getattr(self._client, "base_url", "") or "") or None # ZAI's Anthropic-compatible endpoint rejects max_tokens on vision # models (glm-4v-flash etc.) with error code 1210. When the caller # signals this by setting _skip_zai_max_tokens in kwargs, omit it. @@ -1441,11 +1357,7 @@ def create(self, **kwargs) -> Any: reasoning_config=_reasoning_cfg, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, - # Portal routes on ``anthropic/`` catalog ids and replays - # signed thinking like native Anthropic; both carve-outs key off - # base_url. Omitting it normalizes the id to a bare Anthropic - # slug and the Portal Messages route cannot resolve it. - base_url=self._base_url, + base_url=base_url, ) # Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set # temperature for models that still accept it. build_anthropic_kwargs @@ -1480,18 +1392,7 @@ def create(self, **kwargs) -> Any: existing = {} anthropic_kwargs["extra_body"] = {**existing, **passthrough} - response = create_anthropic_message( - self._client, - anthropic_kwargs, - # Tick the aux forward-progress hook per streamed event so hosts - # watching liveness (gateway session hygiene) don't kill a - # slow-but-generating summary model. No-op when no hook is - # installed (None keeps the fast get_final_message path). - on_stream_event=( - (lambda _event: _notify_aux_progress()) - if _aux_progress_active() else None - ), - ) + response = create_anthropic_message(self._client, anthropic_kwargs) _transport = get_transport("anthropic_messages") _nr = _transport.normalize_response( response, strip_tool_prefix=self._is_oauth @@ -1539,9 +1440,7 @@ class AnthropicAuxiliaryClient: def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False): self._real_client = real_client - adapter = _AnthropicCompletionsAdapter( - real_client, model, is_oauth=is_oauth, base_url=base_url, - ) + adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth) self.chat = _AnthropicChatShim(adapter) self.api_key = api_key self.base_url = base_url @@ -1806,7 +1705,7 @@ def _read_nous_auth() -> Optional[dict]: try: if not _AUTH_JSON_PATH.is_file(): return None - data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8")) + data = json.loads(_AUTH_JSON_PATH.read_text()) if data.get("active_provider") != "nous": return None provider = data.get("providers", {}).get("nous", {}) @@ -2406,62 +2305,6 @@ def _read_main_base_url() -> str: return "" -def _resolve_moa_aggregator(preset_name: Optional[str]) -> Tuple[Optional[str], Optional[str]]: - """Resolve a MoA preset to its aggregator (provider, model) pair. - - "moa" is a virtual provider — the acting model of a preset is its - aggregator slot, and there is no real "moa" HTTP endpoint. Auxiliary - tasks (title generation, compression, vision, commit messages, …) don't - need the reference fan-out, so every aux resolution layer maps - provider="moa"/model= to the aggregator's real provider+model - through this single helper (shared by ``_resolve_auto``, - ``_resolve_task_provider_model``, and ``resolve_provider_client`` so the - preset lookup and validation cannot drift between paths). - - Args: - preset_name: The MoA preset name (usually carried in the "model" - field), or None/"" to resolve the user's default preset. - - Returns: - (aggregator_provider, aggregator_model), or (None, None) when the - preset cannot be resolved (missing config, renamed/deleted preset, - or a malformed aggregator slot). - """ - try: - from hermes_cli.config import load_config - from hermes_cli.moa_config import resolve_moa_preset - - preset = resolve_moa_preset(load_config().get("moa") or {}, preset_name or None) - agg = preset.get("aggregator") or {} - agg_provider = str(agg.get("provider") or "").strip() - agg_model = str(agg.get("model") or "").strip() - if agg_provider and agg_model and agg_provider.lower() != "moa": - return agg_provider, agg_model - except Exception: - logger.debug( - "MoA aggregator resolution failed for preset %r", preset_name, exc_info=True - ) - return None, None - - -def _read_main_model_for_aux() -> str: - """Main model with MoA presets unwrapped to the aggregator's model. - - When the main provider is ``moa``, ``_read_main_model()`` returns a MoA - *preset name* (e.g. "opus-gpt") — never a valid wire model id on any - provider. Auxiliary fallback chains that pre-fill a missing model from - the main model must use this reader instead, so unset aux models default - to the preset's acting (aggregator) model. Returns "" when the main - provider is moa but the preset cannot be resolved — sending nothing is - strictly better than sending a preset name that 400s. - """ - model = _read_main_model() - if (_read_main_provider() or "").strip().lower() == "moa": - _, agg_model = _resolve_moa_aggregator(model) - return agg_model or "" - return model - - def _read_main_api_key_if_same_host(aux_base_url: str) -> str: """Return the main api_key only when *aux_base_url* points at the same host as the main model's base_url. @@ -2494,167 +2337,6 @@ def _read_main_api_key_if_same_host(aux_base_url: str) -> str: _RUNTIME_MAIN_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = ( contextvars.ContextVar("auxiliary_runtime_main", default=None) ) - -_RELAY_AUX_CALL_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = ( - contextvars.ContextVar("auxiliary_relay_call", default=None) -) - - -def _relay_auxiliary_call(callback): - """Give every physical retry in one auxiliary call a shared Relay identity.""" - - @functools.wraps(callback) - def wrapped(*args, **kwargs): - task = args[0] if args else kwargs.get("task") - token = _RELAY_AUX_CALL_CONTEXT.set({ - "task": str(task or "unknown"), - "request_id": f"aux-{uuid.uuid4().hex}", - "attempt_count": 0, - "provider": "", - "model": "", - "api_mode": "chat_completions", - }) - try: - return callback(*args, **kwargs) - except BaseException: - _fail_relay_auxiliary_call() - raise - finally: - _RELAY_AUX_CALL_CONTEXT.reset(token) - - return wrapped - - -def _relay_auxiliary_call_async(callback): - """Async counterpart to :func:`_relay_auxiliary_call`.""" - - @functools.wraps(callback) - async def wrapped(*args, **kwargs): - task = args[0] if args else kwargs.get("task") - token = _RELAY_AUX_CALL_CONTEXT.set({ - "task": str(task or "unknown"), - "request_id": f"aux-{uuid.uuid4().hex}", - "attempt_count": 0, - "provider": "", - "model": "", - "api_mode": "chat_completions", - }) - try: - return await callback(*args, **kwargs) - except BaseException: - _fail_relay_auxiliary_call() - raise - finally: - _RELAY_AUX_CALL_CONTEXT.reset(token) - - return wrapped - - -def _set_relay_auxiliary_route( - provider: str | None, - model: str | None, - api_mode: str | None, -) -> None: - context = _RELAY_AUX_CALL_CONTEXT.get() - if context is None: - return - context["provider"] = str(provider or "auxiliary") - context["model"] = str(model or "unknown") - context["api_mode"] = str(api_mode or "chat_completions") - - -def _relay_auxiliary_metadata( - *, - provider: str | None = None, - api_mode: str | None = None, -) -> tuple[str, str, dict[str, Any]] | None: - context = _RELAY_AUX_CALL_CONTEXT.get() - if context is None: - return None - attempt_count = int(context.get("attempt_count") or 0) - context["attempt_count"] = attempt_count + 1 - provider_name = str(provider or context.get("provider") or "auxiliary") - model_name = str(context.get("model") or "unknown") - return provider_name, model_name, { - "api_mode": str(api_mode or context.get("api_mode") or "chat_completions"), - "api_request_id": str(context["request_id"]), - "call_role": f"auxiliary:{context['task']}", - "retry_count": attempt_count, - "auxiliary_task": str(context["task"]), - } - - -def _relay_sync_completion( - client: Any, - kwargs: dict[str, Any], - *, - provider: str | None = None, - api_mode: str | None = None, - create: Callable[[dict[str, Any]], Any] | None = None, -) -> Any: - callback = create or (lambda request: client.chat.completions.create(**request)) - route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode) - if route is None: - return callback(kwargs) - provider_name, fallback_model, metadata = route - from agent import relay_llm - - return relay_llm.execute_current( - kwargs, - callback, - name=provider_name, - model_name=str(kwargs.get("model") or fallback_model), - metadata=metadata, - defer_logical_completion=True, - ) - - -async def _relay_async_completion( - client: Any, - kwargs: dict[str, Any], - *, - provider: str | None = None, - api_mode: str | None = None, - create: Callable[[dict[str, Any]], Any] | None = None, -) -> Any: - callback = create or (lambda request: client.chat.completions.create(**request)) - route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode) - if route is None: - return await callback(kwargs) - provider_name, fallback_model, metadata = route - from agent import relay_llm - - return await relay_llm.execute_current_async( - kwargs, - callback, - name=provider_name, - model_name=str(kwargs.get("model") or fallback_model), - metadata=metadata, - defer_logical_completion=True, - ) - - -def _relay_sync_stream( - client: Any, - kwargs: dict[str, Any], - *, - provider: str | None = None, - api_mode: str | None = None, -) -> Any: - route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode) - if route is None: - return client.chat.completions.create(**kwargs) - provider_name, fallback_model, metadata = route - from agent import relay_llm - - return relay_llm.stream_current( - kwargs, - lambda request: client.chat.completions.create(**request), - name=provider_name, - model_name=str(kwargs.get("model") or fallback_model), - finalizer=dict, - metadata=metadata, - ) _RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = ("", "", "", "", "", "") _RUNTIME_MAIN_COMPAT_LOCK = threading.Lock() @@ -2698,7 +2380,6 @@ def set_runtime_main( provider: str, model: str, *, - requested_provider: str = "", base_url: str = "", api_key: Any = "", api_mode: str = "", @@ -2714,7 +2395,6 @@ def set_runtime_main( global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT runtime = { "provider": (provider or "").strip().lower(), - "requested_provider": (requested_provider or "").strip().lower(), "model": (model or "").strip(), "base_url": (base_url or "").strip(), "api_key": ( @@ -2895,7 +2575,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]: return None, None if custom_base.lower().startswith(_CODEX_AUX_BASE_URL.lower()): return None, None - model = _read_main_model_for_aux() or "gpt-4o-mini" + model = _read_main_model() or "gpt-4o-mini" logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions") _clean_base, _dq = _extract_url_query_params(custom_base) _extra = {"default_query": _dq} if _dq else {} @@ -2957,13 +2637,7 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str return None, None api_key, base_url = resolved logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model) - from tools.xai_http import hermes_xai_default_headers - - real_client = _create_openai_client( - api_key=api_key, - base_url=base_url, - default_headers=hermes_xai_default_headers(), - ) + real_client = _create_openai_client(api_key=api_key, base_url=base_url) return CodexAuxiliaryClient(real_client, model), model @@ -3193,7 +2867,6 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona } _MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode") -_MAIN_RUNTIME_CONTEXT_FIELDS = _MAIN_RUNTIME_FIELDS + ("requested_provider",) def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]: @@ -3216,7 +2889,7 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, if not isinstance(main_runtime, dict): return {} normalized: Dict[str, Any] = {} - for field in _MAIN_RUNTIME_CONTEXT_FIELDS: + for field in _MAIN_RUNTIME_FIELDS: value = main_runtime.get(field) # Preserve a callable api_key (Entra ID bearer provider) unchanged. if field == "api_key" and callable(value) and not isinstance(value, str): @@ -3224,10 +2897,9 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, continue if isinstance(value, str) and value.strip(): normalized[field] = value.strip() - for identity_field in ("provider", "requested_provider"): - identity = normalized.get(identity_field) - if isinstance(identity, str): - normalized[identity_field] = identity.lower() + provider = normalized.get("provider") + if isinstance(provider, str): + normalized["provider"] = provider.lower() return normalized @@ -3918,7 +3590,6 @@ def _retry_same_provider_sync( effective_timeout: float, effective_extra_body: dict, reasoning_config: Optional[dict], - extra_headers: Optional[Dict[str, str]] = None, ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3954,23 +3625,11 @@ def _retry_same_provider_sync( extra_body=effective_extra_body, reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, - task=task, ) - # Preserve per-request attribution headers (e.g. Copilot's - # ``x-initiator: user``) across the rebuilt-client retry — dropping them - # here would let a recovery retry silently lose capability gating (#60293). - if extra_headers: - retry_kwargs["extra_headers"] = dict(extra_headers) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) return _validate_llm_response( - _relay_sync_completion( - retry_client, - retry_kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), - task, + retry_client.chat.completions.create(**retry_kwargs), task, ) @@ -3990,7 +3649,6 @@ async def _retry_same_provider_async( effective_timeout: float, effective_extra_body: dict, reasoning_config: Optional[dict], - extra_headers: Optional[Dict[str, str]] = None, ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -4026,22 +3684,11 @@ async def _retry_same_provider_async( extra_body=effective_extra_body, reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, - task=task, ) - # Preserve per-request attribution headers across the rebuilt-client - # retry — see the sync variant above (#60293). - if extra_headers: - retry_kwargs["extra_headers"] = dict(extra_headers) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) return _validate_llm_response( - await _relay_async_completion( - retry_client, - retry_kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), - task, + await retry_client.chat.completions.create(**retry_kwargs), task, ) @@ -4112,24 +3759,6 @@ def _refresh_provider_credentials(provider: str) -> bool: return False _evict_cached_clients(normalized) return True - if normalized == "vertex": - # Mirrors run_agent.py's _try_refresh_vertex_client_credentials - # for the main conversation loop. Without this branch, an - # auxiliary Vertex client (vision, title generation, reflection, - # context compression, ...) that 401s on its ~1h token expiry - # falls through to the final `return False` below: the stale - # client is never evicted from _client_cache (whose cache key - # ignores the rotating bearer token), so every subsequent - # auxiliary Vertex call keeps 401ing until process restart. - from agent.vertex_adapter import get_vertex_config - - token, base_url = get_vertex_config() - if not isinstance(token, str) or not token.strip(): - return False - if not isinstance(base_url, str) or not base_url.strip(): - return False - _evict_cached_clients(normalized) - return True except Exception as exc: logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc) return False @@ -4243,10 +3872,10 @@ def _call_fallback_candidate_sync( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=fb_base, task=task) + base_url=fb_base) try: return _validate_llm_response( - _relay_sync_completion(fb_client, fb_kwargs, provider=fb_label), task) + fb_client.chat.completions.create(**fb_kwargs), task) except Exception as fb_err: if not _is_auth_error(fb_err): raise @@ -4260,16 +3889,10 @@ def _call_fallback_candidate_sync( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task) + base_url=str(getattr(retry_client, "base_url", "") or fb_base)) try: return _validate_llm_response( - _relay_sync_completion( - retry_client, - retry_kwargs, - provider=fb_provider, - ), - task, - ) + retry_client.chat.completions.create(**retry_kwargs), task) except Exception as retry_err: if not _is_auth_error(retry_err): raise @@ -4315,16 +3938,10 @@ async def _call_fallback_candidate_async( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=fb_base, task=task) + base_url=fb_base) try: return _validate_llm_response( - await _relay_async_completion( - fb_client, - fb_kwargs, - provider=fb_label, - ), - task, - ) + await fb_client.chat.completions.create(**fb_kwargs), task) except Exception as fb_err: if not _is_auth_error(fb_err): raise @@ -4339,16 +3956,10 @@ async def _call_fallback_candidate_async( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task) + base_url=str(getattr(retry_client, "base_url", "") or fb_base)) try: return _validate_llm_response( - await _relay_async_completion( - retry_client, - retry_kwargs, - provider=fb_provider, - ), - task, - ) + await retry_client.chat.completions.create(**retry_kwargs), task) except Exception as retry_err: if not _is_auth_error(retry_err): raise @@ -4416,7 +4027,6 @@ def _try_main_agent_model_fallback( failed_provider: str, task: str = None, reason: str = "error", - failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Last-resort fallback to the user's main agent provider + model. @@ -4425,56 +4035,20 @@ def _try_main_agent_model_fallback( layer: if nothing the user asked for can serve the request, try the main chat model before giving up. - ``failed_model`` narrows the same-provider skip to the exact - (provider, model) pair that just failed, mirroring - :func:`_try_configured_fallback_chain`. This matters for self-hosted / - custom endpoints serving several models behind one provider label: the - aux compression model timing out says nothing about the health of the - main agent model deployed on the same URL (real incident: aux - ``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the - identical endpoint was serving 448K-token turns fine — the - provider-label skip discarded the one fallback that would have worked). - - - Model-specific runtime failures (timeout, connection, rate limit, - model-incompatible, invalid response) pass ``failed_model``: skip the - main model only when it IS the exact model that failed. - - Provider-wide failures (auth 401, payment 402) and legacy callers - leave ``failed_model`` as None, keeping the whole-provider skip — - the shared credentials/account are broken, so the main model on the - same provider cannot help either. + Skips when the failed provider already IS the main provider (no point + retrying the same backend that just failed). Returns: (client, model, provider_label) or (None, None, "") if no fallback. """ main_provider = (_read_main_provider() or "").strip() main_model = (_read_main_model() or "").strip() - if main_provider.lower() == "moa": - # MoA virtual provider: fall back to the preset's aggregator — the - # acting model — instead of the unreachable "moa"/ pair. - _agg_provider, _agg_model = _resolve_moa_aggregator(main_model) - if not _agg_provider or not _agg_model: - return None, None, "" - main_provider, main_model = _agg_provider, _agg_model if not main_provider or not main_model or main_provider.lower() in {"auto", ""}: return None, None, "" - # Identity + scope semantics owned by agent.backend_identity (#72468): - # model-scoped failures skip only the exact deployment that failed; - # provider-wide failures (no failed_model) skip the credential surface. - from agent.backend_identity import ( - BackendIdentity, - FailureScope, - should_skip_candidate, - ) - - skip_model = (failed_model or "").strip().lower() or None - if should_skip_candidate( - BackendIdentity.build(provider=main_provider, model=main_model), - BackendIdentity.build(provider=failed_provider, model=skip_model), - FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL, - ): - # The thing that failed IS the main model (or the failure was - # provider-wide) — nothing to fall back to. + skip = (failed_provider or "").lower().strip() + if main_provider.lower() == skip: + # The thing that failed IS the main model — nothing to fall back to. return None, None, "" if _is_provider_unhealthy(main_provider): _log_skip_unhealthy(main_provider, task) @@ -4584,7 +4158,6 @@ def _try_configured_fallback_chain( task: str, failed_provider: str, reason: str = "error", - failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Try user-configured fallback_chain for a specific auxiliary task. @@ -4592,25 +4165,6 @@ def _try_configured_fallback_chain( entry in order. Each entry must have at least ``provider``; ``model``, ``base_url``, and ``api_key`` are optional. - ``failed_model`` narrows the skip check to the exact (provider, model) - pair that just failed, rather than the whole provider. Without it every - entry sharing the failed provider is skipped (the original behaviour). - Callers pass it only when a sibling model on the same provider could - plausibly recover: - - - Model-specific runtime failures (timeout, connection, rate limit, - model-incompatible, invalid response) pass ``failed_model`` so a - chain that intentionally lists several models under the same provider - — e.g. two more NVIDIA NIM models after the primary NIM model times - out — is not skipped wholesale. Only the exact model that failed is - skipped; the siblings still run instead of jumping straight to the - main-agent-model safety net. - - Provider-wide failures (auth 401, payment 402) and "no client could - be built" callers leave ``failed_model`` as None, keeping the whole - provider skipped — the shared credentials/account behind every model - on that provider are broken, so a sibling can't help and the - main-agent-model safety net should be reached instead. - Returns: (client, model, provider_label) or (None, None, "") if no fallback. """ @@ -4622,24 +4176,7 @@ def _try_configured_fallback_chain( if not chain or not isinstance(chain, list): return None, None, "" - skip_model = (failed_model or "").strip().lower() or None - # Identity + scope semantics owned by agent.backend_identity (#59561, - # #72468): a failed_model means the failure was model-scoped (timeout / - # connection / rate limit) — only the exact deployment is skipped; no - # failed_model means provider-wide (auth/payment) — the whole credential - # surface is skipped. - from agent.backend_identity import ( - BackendIdentity, - FailureScope, - should_skip_candidate, - ) - - failed_ident = BackendIdentity.build( - provider=failed_provider, model=skip_model, - ) - failure_scope = ( - FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL - ) + skip = failed_provider.lower().strip() tried = [] min_ctx = _task_minimum_context_length(task) @@ -4647,20 +4184,9 @@ def _try_configured_fallback_chain( if not isinstance(entry, dict): continue fb_provider = str(entry.get("provider", "")).strip() - if not fb_provider: - continue - fb_model_raw = str(entry.get("model", "")).strip() - if should_skip_candidate( - BackendIdentity.build( - provider=fb_provider, - model=fb_model_raw, - base_url=str(entry.get("base_url") or ""), - ), - failed_ident, - failure_scope, - ): + if not fb_provider or fb_provider.lower() == skip: continue - fb_model = fb_model_raw or None + fb_model = str(entry.get("model", "")).strip() or None label = f"fallback_chain[{i}]({fb_provider})" @@ -4917,17 +4443,26 @@ def _resolve_auto( # model. Resolve the MoA preset to its aggregator slot and continue Step 1 # with that real provider+model. Mirrors the MoA context-length resolution. if main_provider == "moa": - _agg_provider, _agg_model = _resolve_moa_aggregator(main_model) - if _agg_provider and _agg_model: - main_provider = _agg_provider - main_model = _agg_model - # The MoA virtual runtime carries a non-HTTP base_url - # ("moa://local") and a placeholder api_key; they belong to the - # facade, not the aggregator's real provider. Drop them so the - # aggregator resolves through its own provider credentials. - runtime_base_url = "" - runtime_api_key = "" - runtime_api_mode = "" + try: + from hermes_cli.config import load_config + from hermes_cli.moa_config import resolve_moa_preset + + _preset = resolve_moa_preset(load_config().get("moa") or {}, main_model) + _agg = _preset.get("aggregator") or {} + _agg_provider = str(_agg.get("provider") or "").strip() + _agg_model = str(_agg.get("model") or "").strip() + if _agg_provider and _agg_model and _agg_provider.lower() != "moa": + main_provider = _agg_provider + main_model = _agg_model + # The MoA virtual runtime carries a non-HTTP base_url + # ("moa://local") and a placeholder api_key; they belong to the + # facade, not the aggregator's real provider. Drop them so the + # aggregator resolves through its own provider credentials. + runtime_base_url = "" + runtime_api_key = "" + runtime_api_mode = "" + except Exception: + logger.debug("MoA aux resolution to aggregator failed", exc_info=True) if (main_provider and main_model and main_provider not in {"auto", ""}): @@ -5091,10 +4626,6 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"): async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url) - elif base_url_host_matches(sync_base_url, "x.ai"): - from tools.xai_http import hermes_xai_default_headers - - async_kwargs["default_headers"] = hermes_xai_default_headers() else: # Fall back to profile.default_headers for providers that declare # client-level headers on their ProviderProfile (e.g. attribution @@ -5186,27 +4717,6 @@ def resolve_provider_client( # Normalise aliases provider = _normalize_aux_provider(provider) - # MoA virtual provider chokepoint: "moa" is not a real HTTP provider — - # its acting model is the preset's aggregator slot. The two resolver - # layers above (_resolve_auto, _resolve_task_provider_model) already - # unwrap their own paths, but callers that route here directly (vision - # auto-detect, _try_main_agent_model_fallback, get_available_vision_backends, - # plugin code) would otherwise dead-end in the unknown-provider branch. - # ``model`` carries the preset name for moa calls; when the preset can't - # be resolved we leave the call untouched and let the normal - # missing-provider handling produce its diagnostic. - if provider == "moa": - _agg_provider, _agg_model = _resolve_moa_aggregator(model) - if _agg_provider and _agg_model: - original_provider = _agg_provider.strip().lower() - provider = _normalize_aux_provider(_agg_provider) - model = _agg_model - # The moa:// facade endpoint and placeholder key belong to the - # virtual runtime, not the aggregator's real provider. - if explicit_base_url and str(explicit_base_url).lower().startswith("moa://"): - explicit_base_url = None - explicit_api_key = None - # Universal model-resolution fallback for concrete providers. ``auto`` is # intentionally excluded: `_resolve_auto(main_runtime=...)` returns the # model paired with the provider it actually selected. Pre-filling an auto @@ -5227,10 +4737,6 @@ def resolve_provider_client( # the load-bearing step for OAuth providers: an xai-oauth user # with grok-4.3 configured gets grok-4.3 for title generation # instead of silently dropping to whatever Step-2 fallback (#31845). - # When the main provider is MoA, ``_read_main_model_for_aux()`` - # substitutes the preset's aggregator model — the preset NAME is - # never a valid wire model id, so unset aux models default to the - # preset's acting model instead. # # Each provider branch below sees a non-empty ``model`` whenever the # user has *anything* configured — no provider-specific empty-model @@ -5247,7 +4753,7 @@ def resolve_provider_client( # return the actual current runtime model when the caller did not explicitly # request one. (# compression-current-model) if not model and provider != "auto": - model = _get_aux_model_for_provider(provider) or _read_main_model_for_aux() or model + model = _get_aux_model_for_provider(provider) or _read_main_model() or model def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool: """Decide if a plain OpenAI client should be wrapped for Responses API. @@ -5331,11 +4837,10 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", # ── Nous Portal (OAuth) ────────────────────────────────────────── if provider == "nous": - # Detect vision tasks: caller flag (strict vision backend), explicit - # model override from _PROVIDER_VISION_MODELS, or a known vision id. + # Detect vision tasks: either explicit model override from + # _PROVIDER_VISION_MODELS, or caller passed a known vision model. _is_vision = ( - is_vision - or model in _PROVIDER_VISION_MODELS.values() + model in _PROVIDER_VISION_MODELS.values() or (model or "").strip().lower() == "mimo-v2-omni" ) client, default = _try_nous(vision=_is_vision) @@ -5344,17 +4849,6 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", "but Nous Portal not configured (run: hermes auth)") return None, None final_model = _normalize_resolved_model(model or default, provider) - # Dual-wire: anthropic/* → /v1/messages, everything else stays on - # /chat/completions. Derive from the catalog id (not a stale - # api_mode=chat_completions) so aux matches the main agent. - from hermes_cli.providers import nous_api_mode - - portal_mode = nous_api_mode(final_model) - api_key_str = str(getattr(client, "api_key", "") or "") - base_url_str = str(getattr(client, "base_url", "") or "") - client = _maybe_wrap_anthropic( - client, final_model, api_key_str, base_url_str, portal_mode, - ) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) @@ -5533,7 +5027,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", model or custom_entry.get("model") or (main_runtime.get("model") if main_runtime else None) - or _read_main_model_for_aux() + or _read_main_model() or "gpt-4o-mini", provider, ) @@ -5718,10 +5212,6 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", )) elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): headers.update(build_nvidia_nim_headers(base_url)) - elif base_url_host_matches(base_url, "x.ai"): - from tools.xai_http import hermes_xai_default_headers - - headers.update(hermes_xai_default_headers()) else: # Fall back to profile.default_headers for providers that declare # client-level attribution headers on their profile (e.g. GMI @@ -5772,7 +5262,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", final_model = _normalize_resolved_model( model or (main_runtime.get("model") if main_runtime else None) - or _read_main_model_for_aux(), + or _read_main_model(), provider, ) if provider == "copilot-acp": @@ -6015,10 +5505,7 @@ def _resolve_strict_vision_backend( if provider == "openrouter": return _try_openrouter(model=model) if provider == "nous": - # Must go through resolve_provider_client so anthropic/* vision - # recommendations wrap onto /v1/messages — _try_nous alone returns - # a bare OpenAI client and the call 404s. - return resolve_provider_client("nous", model, is_vision=True) + return _try_nous(vision=True) if provider == "openai-codex": # Route through resolve_provider_client so the caller's explicit # model is used. There is no safe default Codex model (shifting @@ -6143,24 +5630,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[ # 5. Stop main_provider = str(runtime.get("provider") or _read_main_provider()) main_model = str(runtime.get("model") or _read_main_model()) - if main_provider.strip().lower() == "moa": - # MoA virtual provider: main_model is a preset NAME, and every - # capability probe below (_PROVIDERS_WITHOUT_VISION, - # _main_model_supports_vision, _resolve_provider_vision_default) - # would run against a provider/model pair that doesn't exist on - # any wire. Unwrap to the preset's aggregator slot first so the - # checks and the eventual client target the real acting model. - _agg_provider, _agg_model = _resolve_moa_aggregator(main_model) - if _agg_provider and _agg_model: - main_provider, main_model = _agg_provider, _agg_model - # Drop the moa:// facade endpoint from the runtime view used - # below — it belongs to the virtual provider, not the - # aggregator's real provider. - runtime = dict(runtime) - runtime["base_url"] = "" - runtime["api_key"] = "" - runtime["api_mode"] = "" - if main_provider and main_provider not in {"auto", "", "moa"}: + if main_provider and main_provider not in {"auto", ""}: # A provider-specific vision default wins over the user's chat model: # static overrides (xiaomi/zai) and catalog-backed discovery (the # DeepInfra profile hook) both yield a *known* vision-capable model, @@ -6757,8 +6227,8 @@ def _resolve_task_provider_model( task: str = None, provider: str = None, model: str = None, - base_url: Optional[str] = None, - api_key: Optional[str] = None, + base_url: str = None, + api_key: str = None, ) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]: """Determine provider + model for a call. @@ -6801,57 +6271,12 @@ def _resolve_task_provider_model( # which downstream consumers like ContextCompressor accept as the task output. # The provider-side 'auto' is handled in _resolve_auto() via main_runtime # fallback, so dropping cfg_model to None here lets that path do its job. - # - # The explicit `model` kwarg needs the identical normalization: MoA slots - # (agent/moa_loop.py's _slot_runtime) forward a preset's `model:` field as - # this explicit argument rather than through auxiliary. config, so a - # user-configured `model: auto` on a MoA reference/aggregator slot reaches - # this function here, not as cfg_model. Only normalizing cfg_model let that - # literal "auto" slip through via `model or cfg_model` below. - if model and model.lower() == "auto": - model = None if cfg_model and cfg_model.lower() == "auto": cfg_model = None resolved_model = model or cfg_model resolved_api_mode = cfg_api_mode - # MoA virtual provider: an *explicit* `provider: moa` override (either the - # caller-passed `provider` arg or `auxiliary..provider` in - # config.yaml) reaches this function directly — it never goes through - # _resolve_auto(), which only unwraps the *implicit* "main provider is - # moa" case (#53827). Left as-is, "moa" is returned verbatim and - # resolve_provider_client() looks it up in PROVIDER_REGISTRY (which has - # no "moa" entry — it's not a real HTTP provider), falls to the - # unknown-provider dead end, and call_llm surfaces a nonsensical - # "MOA_API_KEY environment variable" error for a provider that was never - # meant to be reached over the wire. Auxiliary tasks don't need the - # reference fan-out — resolve to the preset's aggregator slot instead, - # exactly like the implicit path does (shared helper: _resolve_moa_aggregator). - def _unwrap_moa_provider(prov: str, mdl: Optional[str]) -> Tuple[str, Optional[str]]: - if prov.strip().lower() != "moa": - return prov, mdl - agg_provider, agg_model = _resolve_moa_aggregator(mdl) - if agg_provider and agg_model: - return agg_provider, agg_model - return prov, mdl - - if provider and str(provider).strip().lower() == "moa": - provider, resolved_model = _unwrap_moa_provider(provider, resolved_model) - # The moa:// virtual endpoint (if any explicit base_url/api_key was - # passed alongside provider="moa") belongs to the facade, not the - # aggregator's real provider — drop it so the aggregator resolves - # through its own provider credentials, mirroring _resolve_auto(). - if provider and provider.lower() != "moa": - base_url = None - api_key = None - elif cfg_provider and str(cfg_provider).strip().lower() == "moa": - cfg_provider, cfg_model = _unwrap_moa_provider(cfg_provider, resolved_model) - if cfg_provider and cfg_provider.lower() != "moa": - resolved_model = cfg_model - cfg_base_url = None - cfg_api_key = None - # Convenience aliases for direct API-key endpoints that aren't first-class # providers (e.g. ``provider: openai`` → custom + api.openai.com/v1). # Applied to both explicit args and config-derived values. When the user @@ -7208,7 +6633,6 @@ def _build_call_kwargs( extra_body: Optional[dict] = None, reasoning_config: Optional[dict] = None, base_url: Optional[str] = None, - task: Optional[str] = None, ) -> dict: """Build kwargs for .chat.completions.create() with model/provider adjustments.""" kwargs: Dict[str, Any] = { @@ -7263,38 +6687,11 @@ def _build_call_kwargs( _provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"} or base_url_host_matches(_effective_base, "integrate.api.nvidia.com") ) - _is_moa = bool(task) and str(task) == "moa_reference" - # Gemini's native generateContent maps max_tokens → maxOutputTokens and, - # when it is omitted, applies a fixed 65,535-token ceiling rather than - # "the model's full budget" (see gemini_native_adapter.build_gemini_request). - # So an explicit cap is both safe and the ONLY way to honor it here — - # dropping max_tokens silently makes MoA's reference_max_tokens a no-op - # for gemini advisors (they run effectively uncapped). - _is_gemini_native = _provider_norm in { - "gemini", "google", "google-gemini", "google-ai-studio", - } - if not _is_gemini_native and _effective_base: - try: - from agent.gemini_native_adapter import is_native_gemini_base_url - _is_gemini_native = is_native_gemini_base_url(_effective_base) - except Exception: - pass - _nous_on_messages = False - if _provider_norm in {"nous", "nous-portal", "nousresearch"}: - from hermes_cli.providers import nous_api_mode - - _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( _is_anthropic_compat_endpoint(provider, _effective_base) - or _nous_on_messages or _is_nvidia_nim - or _is_moa - or _is_gemini_native ): - # Use auxiliary_max_tokens_param() so models that require - # max_completion_tokens (GPT-5 family, Copilot) get the right - # parameter name instead of a hardcoded max_tokens that 400s. - kwargs.update(auxiliary_max_tokens_param(max_tokens, model=model)) + kwargs["max_tokens"] = max_tokens if tools: # Defensive dedup: providers like Google Vertex, Azure, and Bedrock @@ -7381,43 +6778,21 @@ def _build_call_kwargs( else: effort = reasoning_config.get("effort") or "medium" merged_extra["reasoning"] = {"enabled": True, "effort": effort} - # Portal product tags + sticky session_id. The provider profile usually - # supplies both; this fallback covers profile-load failures and alias - # spellings the profile lookup might miss. session_id keeps aux - # compression/title/vision calls on the same upstream instance as the - # main turn (cache warmth) — tags alone are not enough on /v1/messages. - _provider_for_portal = str(provider or "").strip().lower() - if _provider_for_portal in {"nous", "nous-portal", "nousresearch"}: - if "tags" not in merged_extra: - merged_extra["tags"] = _nous_portal_tags() - if "session_id" not in merged_extra: - try: - from agent.portal_tags import get_conversation_context - - sticky_key = get_conversation_context() - except Exception: - sticky_key = None - if sticky_key: - merged_extra["session_id"] = sticky_key + if provider == "nous" and "tags" not in merged_extra: + merged_extra["tags"] = _nous_portal_tags() if merged_extra: kwargs["extra_body"] = merged_extra - # Anthropic Messages adapters translate Hermes reasoning into native - # ``thinking`` via a private kwarg (and strip OpenAI-shaped - # ``extra_body.reasoning``). Do not expose this private kwarg to ordinary - # OpenAI-compatible SDK clients, which would reject it. Portal Claude is - # dual-wire — include it when the catalog id selects /v1/messages. + # Native Anthropic Messages adapters do not consume ``extra_body``. Carry + # the normalized Hermes reasoning config through a private kwarg so the + # adapter can pass it into build_anthropic_kwargs(), where provider-aware + # thinking/output_config projection lives. Do not expose this private kwarg + # to ordinary OpenAI-compatible SDK clients, which would reject it. if reasoning_config and isinstance(reasoning_config, dict): provider_norm = str(provider or "").strip().lower() effective_base = base_url or "" - _nous_on_messages = False - if provider_norm in {"nous", "nous-portal", "nousresearch"}: - from hermes_cli.providers import nous_api_mode - - _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( provider_norm == "anthropic" - or _nous_on_messages or _endpoint_speaks_anthropic_messages(effective_base) or _is_anthropic_compat_endpoint(provider_norm, effective_base) ): @@ -7463,7 +6838,6 @@ def _validate_llm_response( except (AttributeError, TypeError, IndexError) as exc: recovered = _recover_aux_response_message(response) if recovered is not None: - _complete_relay_auxiliary_call() return recovered response_type = type(response).__name__ response_preview = str(response)[:120] @@ -7473,34 +6847,9 @@ def _validate_llm_response( f"Expected object with .choices[0].message — check provider " f"adapter or custom endpoint compatibility." ) from exc - _complete_relay_auxiliary_call() return response -def _complete_relay_auxiliary_call(*, outcome: str = "success") -> None: - """Close one auxiliary logical call after acceptance or terminal failure.""" - context = _RELAY_AUX_CALL_CONTEXT.get() - if context is None: - return - from agent import relay_llm - - relay_llm.complete_logical_call( - str(context.get("request_id") or ""), - outcome=outcome, - ) - - -def _fail_relay_auxiliary_call() -> None: - """Close a terminally failed call without replacing its original error.""" - try: - _complete_relay_auxiliary_call(outcome="failed") - except Exception: - logger.warning( - "Relay auxiliary failure finalization failed", - exc_info=True, - ) - - def _recover_aux_response_message(response: Any) -> Optional[Any]: """Synthesize chat-completions shape from Responses-style text fields. @@ -7559,347 +6908,6 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any: return value -# ── Streamed aggregation for progress-hooked auxiliary calls ───────────── -# When a forward-progress hook is installed (aux_progress_hook — today only -# by context compression), the primary chat.completions attempt is upgraded -# to a streamed request that is aggregated back into a complete response. -# Two effects, both deliberate: -# 1. The configured ``timeout`` becomes an INTER-CHUNK idle timeout instead -# of a total budget (httpx applies the read timeout per stream read), so -# a slow-but-generating summary model is never killed mid-generation -# while tokens are moving — only a genuinely silent connection dies. -# 2. Every arriving chunk ticks the progress hook, letting outer watchdogs -# (gateway session hygiene) extend their deadlines on liveness instead -# of guessing with a fixed wall clock. -# A total ceiling still bounds the pathological 1-token-per-idle-window -# stream; see _aux_stream_total_ceiling(). - -_AUX_STREAM_CEILING_FLOOR_SECONDS = 600.0 -_AUX_STREAM_CEILING_MULTIPLIER = 4.0 - - -def _aux_stream_total_ceiling(effective_timeout: Optional[float]) -> float: - """Absolute wall-clock bound for a progress-hooked streamed aux call. - - Generous by design — the idle timeout is the real guard; this only stops - a degenerate stream that trickles one token per idle window forever. - """ - try: - timeout = float(effective_timeout) if effective_timeout is not None else 0.0 - except (TypeError, ValueError): - timeout = 0.0 - return max(_AUX_STREAM_CEILING_FLOOR_SECONDS, - _AUX_STREAM_CEILING_MULTIPLIER * timeout) - - -def _client_streams_internally(client: Any) -> bool: - """Wire adapters that consume a stream inside .create() already tick the - progress hook themselves (Codex per SSE event, Anthropic per stream - event); Bedrock's Converse shim cannot stream at all. None of them - accept chat-completions ``stream=True`` semantics from us.""" - return isinstance(client, ( - CodexAuxiliaryClient, - AnthropicAuxiliaryClient, - BedrockAuxiliaryClient, - )) - - -def _is_streaming_rejected_error(exc: Exception) -> bool: - """Provider explicitly refused a streamed chat.completions request.""" - err = str(exc).lower() - if "stream_options" in err: - return True - return "stream" in err and ( - "not supported" in err - or "unsupported" in err - or "not allowed" in err - or "disabled" in err - ) - - -def _provider_requires_stream(provider: str, base_url: Optional[str]) -> bool: - """Detect providers that only accept streaming (non-stream = HTTP 400). - - Some OpenAI-compatible endpoints reject non-streaming chat requests - outright — e.g. Tencent Copilot returns - ``{"code": 11101, "msg": "Non-stream chat request is currently not - supported"}``. The main conversation loop already streams, so interactive - chat works; auxiliary tasks (title generation, compression, web extract) - used the non-streaming path and failed on every call. When this returns - True the auxiliary client sends ``stream=True`` and aggregates the chunks - itself (see :func:`_aggregate_chat_stream`). Credit @kudi88 (PR #60686). - - Beyond the known-host list, users can mark ANY custom endpoint as - stream-only via ``auxiliary.stream_only_base_urls`` in config.yaml - (list of substrings matched against the endpoint URL). - """ - _url = str(base_url or "").lower() - if not _url: - return False - # Tencent Copilot — "Non-stream chat request is currently not supported" - if base_url_host_matches(_url, "copilot.tencent.com"): - return True - try: - from hermes_cli.config import load_config - aux_cfg = (load_config() or {}).get("auxiliary", {}) - markers = aux_cfg.get("stream_only_base_urls") or [] - if isinstance(markers, (list, tuple)): - for marker in markers: - if isinstance(marker, str) and marker.strip() and marker.strip().lower() in _url: - return True - except Exception: - # Config read is best-effort; never break an aux call over it. - pass - return False - - -def _create_with_progress( - client: Any, - kwargs: Dict[str, Any], - task: Optional[str] = None, - *, - force_stream: bool = False, -) -> Any: - """chat.completions.create() that streams when a progress hook is active - or the provider only accepts streamed requests. - - Behavior is byte-for-byte identical to a plain ``create(**kwargs)`` when - neither trigger applies (every existing caller/task) or when the client's - wire adapter streams internally. With a hook + a chunk-capable client, - the request is sent with ``stream=True`` and aggregated, ticking the hook - per chunk — so the configured ``timeout`` acts per stream read (idle) - rather than as a total budget, and outer liveness watchdogs see tokens - moving. ``force_stream=True`` (stream-only providers such as Tencent - Copilot — credit @kudi88, PR #60686) takes the same streamed path even - without a hook. Providers that reject the streamed request fall back to - the plain non-streaming call — except under ``force_stream``, where a - stream-only provider rejects the plain call by definition, so the - original error is surfaced to the normal recovery chains instead. - """ - _notify_aux_progress() # request dispatched counts as progress - if (not _aux_progress_active() and not force_stream) or _client_streams_internally(client): - return client.chat.completions.create(**kwargs) - - total_ceiling = _aux_stream_total_ceiling(kwargs.get("timeout")) - stream_kwargs = dict(kwargs) - stream_kwargs["stream"] = True - stream_kwargs["stream_options"] = {"include_usage": True} - try: - chunks = client.chat.completions.create(**stream_kwargs) - except Exception as exc: - # Genuine provider failures (auth, credit, rate limit, network) are - # not streaming's fault — surface them unchanged so the existing - # recovery chains (credential refresh, pool rotation, provider - # fallback) see the same error they would on a plain call. - if ( - force_stream - or _is_transient_transport_error(exc) - or _is_auth_error(exc) - or _is_payment_error(exc) - or _is_rate_limit_error(exc) - ): - raise - # Anything else may be a streaming-specific rejection (explicit - # "stream not supported", stream_options 400, or an idiosyncratic - # 4xx). Retry non-streaming once; if the request itself is bad the - # plain call reproduces the real error for the normal except-chains. - logger.debug( - "Auxiliary %s: streamed request failed (%s); retrying " - "non-streaming", task or "call", exc, - ) - return client.chat.completions.create(**kwargs) - - # Some shims (MoA virtual provider under quiet mode, defensive adapters) - # return a complete response even when stream=True was requested. - if hasattr(chunks, "choices"): - _notify_aux_progress() - return chunks - return _aggregate_chat_stream( - chunks, model=str(kwargs.get("model") or ""), total_ceiling=total_ceiling, - ) - - -def _aggregate_chat_stream( - chunks: Any, - *, - model: str = "", - total_ceiling: Optional[float] = None, -) -> Any: - """Consume a chat.completions chunk stream into a complete response. - - Ticks the thread-local aux progress hook on every chunk. Raises - TimeoutError when *total_ceiling* seconds elapse before the stream - finishes — phrased with "timed out" so existing timeout classification - (``_is_timeout_error``) treats it exactly like a request timeout. - Accumulation is shared with the async mirror via - :class:`_ChatStreamAccumulator`. - """ - acc = _ChatStreamAccumulator(model=model, total_ceiling=total_ceiling) - try: - for chunk in chunks: - acc.feed(chunk) - finally: - close_fn = getattr(chunks, "close", None) - if callable(close_fn): - try: - close_fn() - except Exception: - pass - return acc.finish() - - -class _ChatStreamAccumulator: - """Shared per-chunk accumulation for sync and async stream aggregation. - - Mirrors :func:`_aggregate_chat_stream`'s chunk handling so the async - consumer below cannot drift from the sync one (same content/reasoning/ - tool-call delta reassembly, same "timed out" ceiling phrasing). - """ - - def __init__(self, model: str = "", total_ceiling: Optional[float] = None): - self._started = time.monotonic() - self._total_ceiling = total_ceiling - self.content_parts: List[str] = [] - self.reasoning_parts: List[str] = [] - self.tool_calls_acc: Dict[int, Dict[str, Any]] = {} - self.finish_reason = None - self.usage = None - self.resp_id = "" - self.resp_model = model or "" - - def feed(self, chunk: Any) -> None: - _notify_aux_progress() - if ( - self._total_ceiling is not None - and (time.monotonic() - self._started) >= self._total_ceiling - ): - raise TimeoutError( - f"Auxiliary streamed call timed out after {self._total_ceiling:.0f}s " - "total ceiling (stream still open but over budget)" - ) - self.resp_id = getattr(chunk, "id", None) or self.resp_id - self.resp_model = getattr(chunk, "model", None) or self.resp_model - chunk_usage = getattr(chunk, "usage", None) - if chunk_usage: - self.usage = chunk_usage - choices = getattr(chunk, "choices", None) or [] - if not choices: - return - choice = choices[0] - self.finish_reason = getattr(choice, "finish_reason", None) or self.finish_reason - delta = getattr(choice, "delta", None) - if delta is None: - return - piece = getattr(delta, "content", None) - if piece: - self.content_parts.append(piece) - reasoning_piece = ( - getattr(delta, "reasoning", None) - or getattr(delta, "reasoning_content", None) - ) - if reasoning_piece and isinstance(reasoning_piece, str): - self.reasoning_parts.append(reasoning_piece) - for tc in (getattr(delta, "tool_calls", None) or []): - idx = getattr(tc, "index", 0) or 0 - acc = self.tool_calls_acc.setdefault( - idx, {"id": "", "name": "", "arguments": []} - ) - if getattr(tc, "id", None): - acc["id"] = tc.id - fn = getattr(tc, "function", None) - if fn is not None: - if getattr(fn, "name", None): - acc["name"] = fn.name - if getattr(fn, "arguments", None): - acc["arguments"].append(fn.arguments) - - def finish(self) -> Any: - tool_calls = None - if self.tool_calls_acc: - tool_calls = [ - SimpleNamespace( - id=acc["id"], - type="function", - function=SimpleNamespace( - name=acc["name"], - arguments="".join(acc["arguments"]), - ), - ) - for _idx, acc in sorted(self.tool_calls_acc.items()) - ] - message = SimpleNamespace( - role="assistant", - content="".join(self.content_parts), - tool_calls=tool_calls, - reasoning="".join(self.reasoning_parts) or None, - ) - choice = SimpleNamespace( - index=0, - message=message, - finish_reason=self.finish_reason or "stop", - ) - return SimpleNamespace( - id=self.resp_id, - model=self.resp_model, - object="chat.completion", - choices=[choice], - usage=self.usage, - ) - - -async def _aggregate_chat_stream_async( - chunks: Any, - *, - model: str = "", - total_ceiling: Optional[float] = None, -) -> Any: - """Async mirror of :func:`_aggregate_chat_stream` (``async for`` consumer). - - The AsyncOpenAI stream contract is an async iterator — consuming it with - the sync helper raises. Same accumulation and ceiling semantics via - :class:`_ChatStreamAccumulator`. - """ - acc = _ChatStreamAccumulator(model=model, total_ceiling=total_ceiling) - try: - async for chunk in chunks: - acc.feed(chunk) - finally: - close_fn = getattr(chunks, "close", None) or getattr(chunks, "aclose", None) - if callable(close_fn): - try: - result = close_fn() - if inspect.isawaitable(result): - await result - except Exception: - pass - return acc.finish() - - -async def _acreate_with_stream( - client: Any, - kwargs: Dict[str, Any], - task: Optional[str] = None, -) -> Any: - """Async chat.completions.create() for stream-only providers. - - Sends ``stream=True`` and aggregates the async chunk stream into a - complete response (credit @kudi88, PR #60686 — async contract fixed to - ``async for`` and tool-call deltas preserved per sweeper review). - """ - total_ceiling = _aux_stream_total_ceiling(kwargs.get("timeout")) - stream_kwargs = dict(kwargs) - stream_kwargs["stream"] = True - stream_kwargs["stream_options"] = {"include_usage": True} - chunks = await client.chat.completions.create(**stream_kwargs) - # Defensive: shims may hand back a complete response despite stream=True. - if hasattr(chunks, "choices"): - return chunks - return await _aggregate_chat_stream_async( - chunks, model=str(kwargs.get("model") or ""), total_ceiling=total_ceiling, - ) - - -@_relay_auxiliary_call def call_llm( task: str = None, *, @@ -7915,7 +6923,6 @@ def call_llm( timeout: float = None, extra_body: dict = None, reasoning_config: Optional[dict] = None, - extra_headers: Optional[Dict[str, str]] = None, api_mode: str = None, stream: bool = False, stream_options: dict = None, @@ -7941,9 +6948,6 @@ def call_llm( extra_body: Additional request body fields. reasoning_config: Optional Hermes reasoning config for direct model calls such as MoA reference/aggregator slots. - extra_headers: Additional per-request HTTP headers. These override - client-level defaults for providers that gate capabilities on - request attribution (for example Copilot's ``x-initiator``). stream: When True, return the raw SDK streaming iterator instead of a validated complete response. The caller is responsible for consuming chunks (and for any fallback). Used by the MoA aggregator so its @@ -8040,11 +7044,6 @@ def call_llm( f"Run: hermes setup") effective_timeout = _effective_aux_timeout(task, timeout) - _set_relay_auxiliary_route( - resolved_provider, - final_model, - resolved_api_mode, - ) # Log what we're about to do — makes auxiliary operations visible _base_info = str(getattr(client, "base_url", resolved_base_url) or "") @@ -8061,9 +7060,7 @@ def call_llm( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=_base_info or resolved_base_url, task=task) - if extra_headers: - kwargs["extra_headers"] = dict(extra_headers) + base_url=_base_info or resolved_base_url) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) _client_base = str(getattr(client, "base_url", "") or "") @@ -8082,12 +7079,7 @@ def call_llm( kwargs["stream"] = True if stream_options: kwargs["stream_options"] = stream_options - return _relay_sync_stream( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ) + return client.chat.completions.create(**kwargs) # Handle unsupported temperature, max_tokens vs max_completion_tokens retry, # then payment fallback. @@ -8110,21 +7102,7 @@ def call_llm( # for the transient retry every auxiliary task shares. (PR #16587) try: return _validate_llm_response( - _relay_sync_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - create=lambda request: _create_with_progress( - client, - request, - task, - force_stream=_provider_requires_stream( - resolved_provider, _base_info or resolved_base_url, - ), - ), - ), - task, + client.chat.completions.create(**kwargs), task, provider=resolved_provider, base_url=_base_info) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -8157,22 +7135,7 @@ def call_llm( time.sleep(_backoff) try: return _validate_llm_response( - _relay_sync_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - create=lambda request: _create_with_progress( - client, - request, - task, - force_stream=_provider_requires_stream( - resolved_provider, - _base_info or resolved_base_url, - ), - ), - ), - task) + client.chat.completions.create(**kwargs), task) except Exception as retry_transient: if not _is_transient_transport_error(retry_transient): raise @@ -8189,12 +7152,7 @@ def call_llm( ) try: return _validate_llm_response( - _relay_sync_completion( - client, - retry_kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + client.chat.completions.create(**retry_kwargs), task) except Exception as retry_err: retry_err_str = str(retry_err) # If retry still fails, fall through to the max_tokens / @@ -8232,12 +7190,7 @@ def call_llm( kwargs.pop("max_completion_tokens", None) try: return _validate_llm_response( - _relay_sync_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + client.chat.completions.create(**kwargs), task) except Exception as retry_err: # If the max_tokens retry also hits a payment or connection # error, fall through to the fallback chain below. @@ -8267,12 +7220,7 @@ def call_llm( kwargs["model"] = healed_model try: return _validate_llm_response( - _relay_sync_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + client.chat.completions.create(**kwargs), task) except Exception as retry_err: first_err = retry_err @@ -8305,12 +7253,7 @@ def call_llm( kwargs["model"] = refreshed_model try: return _validate_llm_response( - _relay_sync_completion( - refreshed_client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + refreshed_client.chat.completions.create(**kwargs), task) except Exception as retry_err: if not ( _is_auth_error(retry_err) @@ -8338,12 +7281,7 @@ def call_llm( if refreshed_model and refreshed_model != kwargs.get("model"): kwargs["model"] = refreshed_model return _validate_llm_response( - _relay_sync_completion( - refreshed_client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + refreshed_client.chat.completions.create(**kwargs), task) # ── Auth refresh retry ─────────────────────────────────────── auth_refresh_provider = _auth_refresh_provider_for_route( @@ -8376,7 +7314,6 @@ def call_llm( effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, reasoning_config=reasoning_config, - extra_headers=extra_headers, ) # ── Same-provider credential-pool recovery ───────────────────── @@ -8393,12 +7330,7 @@ def call_llm( if _is_rate_limit_error(first_err) and not _is_payment_error(first_err): try: return _validate_llm_response( - _relay_sync_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + client.chat.completions.create(**kwargs), task) except Exception as retry_err: if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)): raise @@ -8425,7 +7357,6 @@ def call_llm( effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, reasoning_config=reasoning_config, - extra_headers=extra_headers, ) except Exception as retry2_err: # The rotated key also hit a quota/auth wall. Mark it @@ -8519,15 +7450,6 @@ def call_llm( logger.info("Auxiliary %s: %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) - # Narrow the configured-chain skip to the exact model that - # failed ONLY for model-specific failures. Auth (401) and - # payment (402) errors are provider-wide — the credentials or - # account behind every model on that provider are the same — so - # a sibling model can't recover; keep skipping the whole - # provider so the main-agent-model safety net is still reached. - _chain_failed_model = ( - None if reason in ("auth error", "payment error") else final_model - ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -8536,8 +7458,7 @@ def call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason, - failed_model=_chain_failed_model) + task, resolved_provider or "auto", reason=reason) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -8546,12 +7467,10 @@ def call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason, - failed_model=_chain_failed_model) + task, resolved_provider or "auto", reason=reason) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason, - failed_model=_chain_failed_model) + resolved_provider, task, reason=reason) if fb_client is not None: fb_resp = _call_fallback_candidate_sync( @@ -8656,7 +7575,6 @@ def extract_content_or_reasoning(response) -> str: return "" -@_relay_auxiliary_call_async async def async_call_llm( task: str = None, *, @@ -8748,11 +7666,6 @@ async def async_call_llm( f"Run: hermes setup") effective_timeout = _effective_aux_timeout(task, timeout) - _set_relay_auxiliary_route( - resolved_provider, - final_model, - resolved_api_mode, - ) # Pass the client's actual base_url (not just resolved_base_url) so # endpoint-specific temperature overrides can distinguish @@ -8763,7 +7676,7 @@ async def async_call_llm( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=_client_base or resolved_base_url, task=task) + base_url=_client_base or resolved_base_url) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) if _is_anthropic_compat_endpoint(resolved_provider, _client_base): @@ -8773,32 +7686,9 @@ async def async_call_llm( # Retry ONCE on the same provider for a transient transport blip # before the except-chain escalates to fallback — see call_llm() # for the rationale. (PR #16587) - _force_stream_async = ( - _provider_requires_stream( - resolved_provider, _client_base or resolved_base_url, - ) - and not isinstance(client, ( - AsyncCodexAuxiliaryClient, - AsyncAnthropicAuxiliaryClient, - AsyncBedrockAuxiliaryClient, - )) - ) - - async def _acreate(_kwargs: Dict[str, Any]) -> Any: - if _force_stream_async: - return await _acreate_with_stream(client, _kwargs, task) - return await client.chat.completions.create(**_kwargs) - try: return _validate_llm_response( - await _relay_async_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - create=_acreate, - ), - task, + await client.chat.completions.create(**kwargs), task, provider=resolved_provider, base_url=_client_base) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -8819,14 +7709,7 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: task or "call", transient_err, ) return _validate_llm_response( - await _relay_async_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - create=_acreate, - ), - task) + await client.chat.completions.create(**kwargs), task) except Exception as first_err: if "temperature" in kwargs and _is_unsupported_temperature_error(first_err): retry_kwargs = dict(kwargs) @@ -8837,12 +7720,7 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: ) try: return _validate_llm_response( - await _relay_async_completion( - client, - retry_kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + await client.chat.completions.create(**retry_kwargs), task) except Exception as retry_err: retry_err_str = str(retry_err) if not ( @@ -8876,12 +7754,7 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: kwargs.pop("max_completion_tokens", None) try: return _validate_llm_response( - await _relay_async_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + await client.chat.completions.create(**kwargs), task) except Exception as retry_err: # If the max_tokens retry also hits a payment or connection # error, fall through to the fallback chain below. @@ -8910,12 +7783,7 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: kwargs["model"] = healed_model try: return _validate_llm_response( - await _relay_async_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + await client.chat.completions.create(**kwargs), task) except Exception as retry_err: first_err = retry_err @@ -8947,12 +7815,7 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: kwargs["model"] = refreshed_model try: return _validate_llm_response( - await _relay_async_completion( - refreshed_client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + await refreshed_client.chat.completions.create(**kwargs), task) except Exception as retry_err: if not ( _is_auth_error(retry_err) @@ -8979,12 +7842,7 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: if refreshed_model and refreshed_model != kwargs.get("model"): kwargs["model"] = refreshed_model return _validate_llm_response( - await _relay_async_completion( - refreshed_client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + await refreshed_client.chat.completions.create(**kwargs), task) # ── Auth refresh retry (mirrors sync call_llm) ─────────────── auth_refresh_provider = _auth_refresh_provider_for_route( @@ -9028,12 +7886,7 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: if _is_rate_limit_error(first_err) and not _is_payment_error(first_err): try: return _validate_llm_response( - await _relay_async_completion( - client, - kwargs, - provider=resolved_provider, - api_mode=resolved_api_mode, - ), task) + await client.chat.completions.create(**kwargs), task) except Exception as retry_err: if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)): raise @@ -9115,15 +7968,6 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) - # Narrow the configured-chain skip to the exact model that - # failed ONLY for model-specific failures. Auth (401) and - # payment (402) errors are provider-wide — the credentials or - # account behind every model on that provider are the same — so - # a sibling model can't recover; keep skipping the whole - # provider so the main-agent-model safety net is still reached. - _chain_failed_model = ( - None if reason in ("auth error", "payment error") else final_model - ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -9132,8 +7976,7 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason, - failed_model=_chain_failed_model) + task, resolved_provider or "auto", reason=reason) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -9142,12 +7985,10 @@ async def _acreate(_kwargs: Dict[str, Any]) -> Any: resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason, - failed_model=_chain_failed_model) + task, resolved_provider or "auto", reason=reason) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason, - failed_model=_chain_failed_model) + resolved_provider, task, reason=reason) if fb_client is not None: # Convert sync fallback client to async diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 58d615e5c859..a734516bdac4 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -4635,7 +4635,7 @@ class TestAnthropicAuxiliaryReasoningTranslation: """ @staticmethod - def _build_adapter(model="claude-fable-5"): + def _build_adapter(model="claude-fable-5", base_url=None): from agent.auxiliary_client import _AnthropicCompletionsAdapter captured = {} @@ -4649,7 +4649,7 @@ def create(self, **kwargs): usage=SimpleNamespace(input_tokens=1, output_tokens=1, total_tokens=2), ) - real_client = SimpleNamespace(messages=_Messages()) + real_client = SimpleNamespace(messages=_Messages(), base_url=base_url) return _AnthropicCompletionsAdapter(real_client, model), captured def test_reasoning_config_reaches_native_anthropic_wire_kwargs(self): @@ -4665,6 +4665,95 @@ def test_reasoning_config_reaches_native_anthropic_wire_kwargs(self): assert captured["output_config"] == {"effort": "medium"} assert "extra_body" not in captured + def test_minimax_m3_cn_auxiliary_call_uses_adaptive_thinking(self): + adapter, captured = self._build_adapter( + model="MiniMax-M3", + base_url="https://api.minimaxi.com/anthropic", + ) + + adapter.create( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hi"}], + _reasoning_config={"enabled": True, "effort": "high"}, + ) + + assert captured["thinking"] == {"type": "adaptive"} + assert "output_config" not in captured + assert "temperature" not in captured + assert "extra_body" not in captured + + def test_minimax_m3_cn_auxiliary_accepts_httpx_url_with_trailing_slash(self): + from httpx import URL + + adapter, captured = self._build_adapter( + model="MiniMax-M3", + base_url=URL("https://api.minimaxi.com/anthropic/"), + ) + + adapter.create( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hi"}], + _reasoning_config={"enabled": True, "effort": "high"}, + ) + + assert captured["thinking"] == {"type": "adaptive"} + assert "output_config" not in captured + assert "temperature" not in captured + + def test_minimax_m3_cn_auxiliary_without_base_url_falls_back(self): + """AnthropicAuxiliaryClient without base_url must NOT apply M3 adaptive. + + Guards the silent-regression class where the auxiliary adapter's base_url + is ``None`` (or the underlying client lacks the attribute). In that case + the M3 contract must not be applied — it would otherwise mis-shape the + payload on a non-MiniMax Anthropic-compatible endpoint. + """ + adapter, captured = self._build_adapter( + model="MiniMax-M3", + base_url=None, + ) + + adapter.create( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hi"}], + _reasoning_config={"enabled": True, "effort": "high"}, + ) + + assert captured["thinking"]["type"] == "enabled" + assert "budget_tokens" in captured["thinking"] + + def test_minimax_m2_cn_auxiliary_uses_manual_thinking(self): + """M2.x on the MiniMax endpoint must keep manual thinking.""" + adapter, captured = self._build_adapter( + model="MiniMax-M2.7", + base_url="https://api.minimaxi.com/anthropic", + ) + + adapter.create( + model="MiniMax-M2.7", + messages=[{"role": "user", "content": "hi"}], + _reasoning_config={"enabled": True, "effort": "high"}, + ) + + assert captured["thinking"]["type"] == "enabled" + assert "budget_tokens" in captured["thinking"] + + def test_minimax_m3_on_non_minimax_anthropic_endpoint_uses_manual(self): + """M3 on a non-MiniMax Anthropic-compatible endpoint keeps manual.""" + adapter, captured = self._build_adapter( + model="MiniMax-M3", + base_url="https://example.test/anthropic", + ) + + adapter.create( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hi"}], + _reasoning_config={"enabled": True, "effort": "high"}, + ) + + assert captured["thinking"]["type"] == "enabled" + assert "budget_tokens" in captured["thinking"] + def test_build_call_kwargs_private_reasoning_only_for_anthropic_messages(self): anthropic_kwargs = _build_call_kwargs( "anthropic", diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py index 1152514c1e52..3f6e6f30787b 100644 --- a/tests/agent/test_minimax_provider.py +++ b/tests/agent/test_minimax_provider.py @@ -93,12 +93,11 @@ def test_m2_cache_not_clobbered(self, tmp_path, monkeypatch): class TestMinimaxThinkingSupport: - """Verify that MiniMax gets manual thinking (not adaptive). + """Verify MiniMax's model-specific Anthropic thinking contracts. - MiniMax's Anthropic-compat endpoint officially supports the thinking - parameter (https://platform.minimax.io/docs/api-reference/text-anthropic-api). - It should get manual thinking (type=enabled + budget_tokens), NOT adaptive - thinking (which is Claude 4.6-only). + MiniMax-M3 uses adaptive/disabled thinking on MiniMax's Anthropic-compatible + endpoints. M2.x keeps the legacy manual ``enabled + budget_tokens`` shape. + Source: https://platform.minimaxi.com/docs/api-reference/text-anthropic-api """ def test_minimax_m27_gets_manual_thinking(self): @@ -128,6 +127,390 @@ def test_minimax_m25_gets_manual_thinking(self): assert "thinking" in kwargs assert kwargs["thinking"]["type"] == "enabled" + def test_minimax_m3_cn_anthropic_uses_adaptive_thinking(self): + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://api.minimaxi.com/anthropic", + ) + + assert kwargs["thinking"] == {"type": "adaptive"} + assert "output_config" not in kwargs + assert "temperature" not in kwargs + assert kwargs["max_tokens"] == 4096 + + def test_minimax_m3_cn_anthropic_can_explicitly_disable_thinking(self): + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": False}, + base_url="https://api.minimaxi.com/anthropic", + ) + + assert kwargs["thinking"] == {"type": "disabled"} + assert "output_config" not in kwargs + assert "temperature" not in kwargs + assert kwargs["max_tokens"] == 4096 + + def test_minimax_m3_like_slug_does_not_trigger_adaptive_thinking(self): + """Exact-match the canonical M3 slugs; do not over-match substring slugs.""" + from agent.anthropic_adapter import build_anthropic_kwargs + + kwargs = build_anthropic_kwargs( + model="MiniMax-M3-preview", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://api.minimaxi.com/anthropic", + ) + + assert kwargs["thinking"]["type"] == "enabled" + assert "budget_tokens" in kwargs["thinking"] + + def test_minimax_m3_raw_response_round_trips_all_blocks_in_order(self): + """Exercise raw SDK response -> normalization -> storage -> replay.""" + from types import SimpleNamespace + + from agent.anthropic_adapter import convert_messages_to_anthropic + from agent.chat_completion_helpers import build_assistant_message + from agent.transports import get_transport + + response = SimpleNamespace( + content=[ + SimpleNamespace( + type="thinking", + thinking="Inspect the file before answering.", + signature="minimax-sig-1", + ), + SimpleNamespace(type="text", text="I will inspect it."), + SimpleNamespace( + type="tool_use", + id="toolu_1", + name="read_file", + input={"path": "a.py"}, + ), + ], + stop_reason="tool_use", + usage=None, + ) + + class StubAgent: + verbose_logging = False + reasoning_callback = None + stream_delta_callback = None + _stream_callback = None + + def _extract_reasoning(self, message): + return getattr(message, "reasoning", None) + + def _strip_think_blocks(self, text): + return text + + def _needs_thinking_reasoning_pad(self): + return False + + def _split_responses_tool_id(self, raw_id): + return None, None + + def _derive_responses_function_call_id(self, call_id, response_item_id): + return response_item_id or call_id + + def _deterministic_call_id(self, name, arguments, index): + return f"generated_{index}" + + normalized = get_transport("anthropic_messages").normalize_response(response) + stored = build_assistant_message( + StubAgent(), normalized, normalized.finish_reason + ) + + assert [block["type"] for block in stored["anthropic_content_blocks"]] == [ + "thinking", + "text", + "tool_use", + ] + + _, messages = convert_messages_to_anthropic( + [ + {"role": "user", "content": "Inspect a.py."}, + stored, + {"role": "tool", "tool_call_id": "toolu_1", "content": "ok"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert [block["type"] for block in assistant["content"]] == [ + "thinking", + "text", + "tool_use", + ] + assert assistant["content"][0]["signature"] == "minimax-sig-1" + assert assistant["content"][1]["text"] == "I will inspect it." + assert assistant["content"][2]["id"] == "toolu_1" + + def test_minimax_m3_accepts_prior_provider_reasoning_on_fallback(self): + """Document the current provider-agnostic history replay contract.""" + from agent.anthropic_adapter import build_anthropic_kwargs + + tools = [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Look up a value.", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + kwargs = build_anthropic_kwargs( + model="MiniMax-M3", + messages=[ + {"role": "user", "content": "Look up a value."}, + { + "role": "assistant", + "content": "", + "reasoning_content": "Prior-provider reasoning summary.", + "tool_calls": [ + { + "id": "call_prior", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_prior", "content": "value=42"}, + {"role": "user", "content": "What value was returned?"}, + ], + tools=tools, + max_tokens=1024, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://api.minimaxi.com/anthropic", + ) + + assistant = next( + message for message in kwargs["messages"] if message["role"] == "assistant" + ) + assert [block["type"] for block in assistant["content"]] == [ + "thinking", + "tool_use", + ] + assert assistant["content"][1]["id"] == "call_prior" + + def test_minimax_m3_cn_replays_thinking_block_after_tool_call(self): + from agent.anthropic_adapter import convert_messages_to_anthropic + + _, messages = convert_messages_to_anthropic( + [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": "", + "reasoning_details": [ + {"type": "thinking", "thinking": "I should use the tool."} + ], + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "toolu_1", "content": "result"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert [block["type"] for block in assistant["content"]] == ["thinking", "tool_use"] + assert assistant["content"][0]["thinking"] == "I should use the tool." + assert messages[-1]["content"][0]["type"] == "tool_result" + + def test_minimax_m3_drops_thinking_when_orphan_cleanup_mutates_tool_turn(self): + from agent.anthropic_adapter import convert_messages_to_anthropic + + _, messages = convert_messages_to_anthropic( + [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Call A and B."}, + {"type": "text", "text": "Will call A and B."}, + { + "type": "tool_use", + "id": "toolu_kept", + "name": "tool_a", + "input": {}, + }, + { + "type": "tool_use", + "id": "toolu_orphan", + "name": "tool_b", + "input": {}, + }, + ], + "reasoning_details": [ + {"type": "thinking", "thinking": "Call A and B."} + ], + "tool_calls": [ + { + "id": "toolu_kept", + "type": "function", + "function": {"name": "tool_a", "arguments": "{}"}, + }, + { + "id": "toolu_orphan", + "type": "function", + "function": {"name": "tool_b", "arguments": "{}"}, + }, + ], + }, + {"role": "tool", "tool_call_id": "toolu_kept", "content": "result"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert not any(block.get("type") == "thinking" for block in assistant["content"]) + kept_tool_uses = [ + block["id"] for block in assistant["content"] if block.get("type") == "tool_use" + ] + # Pre-existing dual-source behavior appends tool_use blocks from both + # `content` and `tool_calls`; allow duplicates but require the kept id + # to be present and the orphan id to be absent. + assert "toolu_kept" in kept_tool_uses + assert "toolu_orphan" not in kept_tool_uses + # Surviving text block must be preserved alongside the kept tool_use. + text_blocks = [ + block for block in assistant["content"] if block.get("type") == "text" + ] + assert text_blocks and text_blocks[0]["text"] == "Will call A and B." + assert "Call A and B." not in str(assistant["content"]) + assert "_thinking_signature_invalidated" not in assistant + + def test_minimax_m3_drops_thinking_when_all_tools_are_orphaned(self): + from agent.anthropic_adapter import convert_messages_to_anthropic + + _, messages = convert_messages_to_anthropic( + [ + { + "role": "assistant", + "content": "", + "reasoning_details": [ + {"type": "thinking", "thinking": "Call the tool."} + ], + "tool_calls": [ + { + "id": "toolu_orphan", + "type": "function", + "function": {"name": "tool_a", "arguments": "{}"}, + } + ], + }, + {"role": "user", "content": "never mind"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert assistant["content"] == [{"type": "text", "text": "(thinking elided)"}] + assert "Call the tool." not in str(assistant["content"]) + assert "_thinking_signature_invalidated" not in assistant + + def test_minimax_m3_replays_redacted_thinking_block(self): + """MiniMax-M3 must also preserve redacted_thinking across turns.""" + from agent.anthropic_adapter import convert_messages_to_anthropic + + _, messages = convert_messages_to_anthropic( + [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": "", + "reasoning_details": [ + { + "type": "redacted_thinking", + "data": "redacted-payload-1", + } + ], + "tool_calls": [ + { + "id": "toolu_1", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "toolu_1", "content": "result"}, + ], + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = next(message for message in messages if message["role"] == "assistant") + assert [block["type"] for block in assistant["content"]] == [ + "redacted_thinking", + "tool_use", + ] + assert assistant["content"][0]["data"] == "redacted-payload-1" + assert "_thinking_signature_invalidated" not in assistant + + def test_minimax_m3_orphan_flag_propagates_across_assistant_merge(self): + """An orphan flag on the second assistant must survive the merge.""" + from agent.anthropic_adapter import ( + _manage_thinking_signatures, + _merge_consecutive_roles, + ) + + msgs = [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Plan."}, + {"type": "text", "text": "First answer."}, + ], + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "Continuing..."}], + # Simulate the flag already set by orphan-tool stripping. + "_thinking_signature_invalidated": True, + }, + ] + + merged = _merge_consecutive_roles(msgs) + assert len(merged) == 1 + assert merged[0]["_thinking_signature_invalidated"] is True + + _manage_thinking_signatures( + merged, + base_url="https://api.minimaxi.com/anthropic", + model="MiniMax-M3", + ) + + assistant = merged[0] + assert "_thinking_signature_invalidated" not in assistant + assert not any( + block.get("type") == "thinking" for block in assistant["content"] + ) + assert [ + block["text"] for block in assistant["content"] if block.get("type") == "text" + ] == ["First answer.", "Continuing..."] + def test_thinking_still_works_for_claude(self): from agent.anthropic_adapter import build_anthropic_kwargs kwargs = build_anthropic_kwargs( From 7ad7e491313e9ce11da5c0b42ddab4e3a1eabae0 Mon Sep 17 00:00:00 2001 From: bbasketballer75 Date: Wed, 22 Jul 2026 21:51:18 -0400 Subject: [PATCH 2/3] test(minimax): pin effort labels to adaptive --- tests/agent/test_minimax_provider.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/agent/test_minimax_provider.py b/tests/agent/test_minimax_provider.py index 3f6e6f30787b..05c420b47170 100644 --- a/tests/agent/test_minimax_provider.py +++ b/tests/agent/test_minimax_provider.py @@ -144,6 +144,24 @@ def test_minimax_m3_cn_anthropic_uses_adaptive_thinking(self): assert "temperature" not in kwargs assert kwargs["max_tokens"] == 4096 + def test_minimax_m3_effort_labels_all_collapse_to_adaptive(self): + from agent.anthropic_adapter import build_anthropic_kwargs + + for effort in ("medium", "max", "ultra"): + kwargs = build_anthropic_kwargs( + model="MiniMax-M3", + messages=[{"role": "user", "content": "hello"}], + tools=None, + max_tokens=4096, + reasoning_config={"enabled": True, "effort": effort}, + base_url="https://api.minimax.io/anthropic", + ) + + assert kwargs["thinking"] == {"type": "adaptive"} + assert "output_config" not in kwargs + assert "temperature" not in kwargs + assert kwargs["max_tokens"] == 4096 + def test_minimax_m3_cn_anthropic_can_explicitly_disable_thinking(self): from agent.anthropic_adapter import build_anthropic_kwargs From b9b889ce4fcebe37cda458e18e0fa9531debe9f5 Mon Sep 17 00:00:00 2001 From: Austin Porada Date: Thu, 30 Jul 2026 22:47:46 -0400 Subject: [PATCH 3/3] fix(minimax): drop auxiliary_client.py changes from the M3-thinking cherry-pick The cherry-picked commit's own diff (2bd9721f0) showed it deleting large unrelated chunks of auxiliary_client.py -- the entire forward-progress-hook mechanism for streamed auxiliary calls, among other things. That's not an intentional part of the MiniMax fix; it means the upstream contributor's branch was based on a much older auxiliary_client.py than current main, and the 3-way cherry-pick merge silently reverted real, unrelated upstream progress instead of just adding the MiniMax-specific base_url threading. Restored auxiliary_client.py and its test file to the pre-cherry-pick (current-main) state. The anthropic_adapter.py half of the fix is untouched -- it's clean, fully tested, and covers the actual bug (M3 thinking not working on the /anthropic route). The auxiliary/MoA call path not sharing the same endpoint gate is a narrower, secondary gap; bringing that in properly needs the PR itself rebased on GitHub against current upstream, not a local hand-reconciliation of a stale branch. --- agent/auxiliary_client.py | 1359 ++++++++++++++++++++++++-- tests/agent/test_auxiliary_client.py | 93 +- 2 files changed, 1261 insertions(+), 191 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index c3e3783fd367..426479560427 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -42,6 +42,7 @@ import contextlib import contextvars +import functools import hashlib import inspect import json @@ -50,9 +51,10 @@ import re import threading import time +import uuid from pathlib import Path # noqa: F401 — used by test mocks from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING from urllib.parse import urlparse, parse_qs, urlunparse # NOTE: `from openai import OpenAI` is deliberately NOT at module top — the @@ -247,6 +249,50 @@ def aux_interrupt_protection(active: bool = True): _aux_interrupt_protection.active = prev +# ── Forward-progress hook for streamed auxiliary calls ─────────────────── +# Long auxiliary calls (context compression is the prime case) are watched by +# wall-clock deadlines in their hosts (gateway session hygiene). A fixed +# deadline punishes SLOW summary models exactly as hard as HUNG ones: a +# reasoning model happily streaming a large summary is killed mid-generation. +# This thread-local hook lets the host observe liveness instead: the wire +# consumers below tick it on every streamed token/SSE event, and the host +# extends its deadline while tokens are moving (see gateway/run.py session +# hygiene + CompressionCommitFence.touch_progress). Thread-local matches the +# call topology — the aux call and its stream consumption run synchronously +# on the thread that installed the hook. +_aux_progress = threading.local() + + +def _notify_aux_progress() -> None: + """Tick the installed forward-progress hook, if any. Never raises.""" + hook = getattr(_aux_progress, "hook", None) + if hook is None: + return + try: + hook() + except Exception: + logger.debug("aux progress hook failed", exc_info=True) + + +def _aux_progress_active() -> bool: + return getattr(_aux_progress, "hook", None) is not None + + +@contextlib.contextmanager +def aux_progress_hook(hook): + """Install *hook* as the current thread's aux forward-progress callback. + + ``hook=None`` is a no-op passthrough so callers can wire it + unconditionally. Re-entrant-safe: restores the previous hook on exit. + """ + prev = getattr(_aux_progress, "hook", None) + _aux_progress.hook = hook if callable(hook) else prev + try: + yield + finally: + _aux_progress.hook = prev + + def _safe_isinstance(obj: Any, maybe_type: Any) -> bool: """Return False instead of raising when a patched symbol is not a type.""" try: @@ -490,7 +536,7 @@ def _get_aux_model_for_provider(provider_id: str) -> str: # plus providers we intentionally keep pinned here (e.g. Anthropic predates # profiles). New providers should set default_aux_model on their profile instead. _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = { - "gemini": "gemini-3-flash-preview", + "gemini": "gemini-3.6-flash", "zai": "glm-4.5-flash", "kimi-coding": "kimi-k2-turbo-preview", "stepfun": "step-3.5-flash", @@ -499,7 +545,7 @@ def _get_aux_model_for_provider(provider_id: str) -> str: "anthropic": "claude-haiku-4-5-20251001", "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", - "kilocode": "google/gemini-3-flash-preview", + "kilocode": "google/gemini-3.6-flash", "ollama-cloud": "nemotron-3-nano:30b", "tencent-tokenhub": "hy3-preview", # NB: no "deepinfra" entry — its aux model lives on the ProviderProfile @@ -714,8 +760,8 @@ def _nous_extra_body() -> dict: auxiliary_is_nous: bool = False # Default auxiliary models per provider -_OPENROUTER_MODEL = "google/gemini-3-flash-preview" -_NOUS_MODEL = "google/gemini-3-flash-preview" +_OPENROUTER_MODEL = "google/gemini-3.6-flash" +_NOUS_MODEL = "google/gemini-3.6-flash" _NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1" _ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com" _AUTH_JSON_PATH = get_hermes_home() / "auth.json" @@ -1058,16 +1104,29 @@ def create(self, **kwargs) -> Any: # key in extra_body (not top-level) and GitHub/Copilot Responses opts # out of cache-key routing entirely — for those hosts, skip it here. try: - from agent.transports.codex import _content_cache_key + from agent.transports.codex import ( + _content_cache_key, + _default_prompt_cache_retention_for_request, + ) from utils import base_url_host_matches _host_src = str(getattr(self._client, "base_url", "") or "") _is_xai = base_url_host_matches(_host_src, "x.ai") or base_url_host_matches(_host_src, "api.x.ai") - _is_github = base_url_host_matches(_host_src, "githubcopilot.com") + _is_github = ( + base_url_host_matches(_host_src, "githubcopilot.com") + or base_url_host_matches(_host_src, "models.github.ai") + ) if not _is_xai and not _is_github and "prompt_cache_key" not in resp_kwargs: _cache_key = _content_cache_key(instructions, resp_kwargs.get("tools")) if _cache_key: resp_kwargs["prompt_cache_key"] = _cache_key + if "prompt_cache_retention" not in resp_kwargs: + _cache_retention = _default_prompt_cache_retention_for_request( + model, + _host_src, + ) + if _cache_retention: + resp_kwargs["prompt_cache_retention"] = _cache_retention except Exception: logger.debug( "Codex auxiliary: prompt_cache_key derivation skipped", exc_info=True @@ -1149,6 +1208,10 @@ def _check_cancelled() -> None: def _on_each_event(_event: Any) -> None: # Re-check timeout/cancellation per event, matching the # cadence the old in-line ``_check_cancelled()`` used. + # Each SSE event is also forward progress for hosts watching + # a progress hook (gateway session hygiene): a reasoning + # model streaming a long summary must not look hung. + _notify_aux_progress() _check_cancelled() event_stream = self._client.responses.create(**stream_kwargs) @@ -1301,10 +1364,32 @@ def __init__(self, sync_wrapper: "CodexAuxiliaryClient"): class _AnthropicCompletionsAdapter: """OpenAI-client-compatible adapter for Anthropic Messages API.""" - def __init__(self, real_client: Any, model: str, is_oauth: bool = False): + def __init__( + self, + real_client: Any, + model: str, + is_oauth: bool = False, + base_url: str | None = None, + ): self._client = real_client self._model = model self._is_oauth = is_oauth + # Prefer the caller-supplied URL (AnthropicAuxiliaryClient keeps the + # pre-strip Portal ``.../v1`` form). Only fall back to the SDK + # client's host for Nous Portal — a blanket fallback would flip + # MiniMax/Zhipu/etc. aux adapters from "unknown host = native + # Anthropic" to third-party (stripping thinking signatures). + self._base_url = base_url or None + if not self._base_url: + candidate = str(getattr(real_client, "base_url", "") or "") or None + if candidate: + try: + from agent.anthropic_adapter import _is_nous_portal_endpoint + + if _is_nous_portal_endpoint(candidate): + self._base_url = candidate + except Exception: + pass def create(self, **kwargs) -> Any: from agent.anthropic_adapter import build_anthropic_kwargs, create_anthropic_message @@ -1315,7 +1400,6 @@ def create(self, **kwargs) -> Any: tools = kwargs.get("tools") tool_choice = kwargs.get("tool_choice") reasoning_config = kwargs.get("_reasoning_config") - base_url = str(getattr(self._client, "base_url", "") or "") or None # ZAI's Anthropic-compatible endpoint rejects max_tokens on vision # models (glm-4v-flash etc.) with error code 1210. When the caller # signals this by setting _skip_zai_max_tokens in kwargs, omit it. @@ -1357,7 +1441,11 @@ def create(self, **kwargs) -> Any: reasoning_config=_reasoning_cfg, tool_choice=normalized_tool_choice, is_oauth=self._is_oauth, - base_url=base_url, + # Portal routes on ``anthropic/`` catalog ids and replays + # signed thinking like native Anthropic; both carve-outs key off + # base_url. Omitting it normalizes the id to a bare Anthropic + # slug and the Portal Messages route cannot resolve it. + base_url=self._base_url, ) # Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set # temperature for models that still accept it. build_anthropic_kwargs @@ -1392,7 +1480,18 @@ def create(self, **kwargs) -> Any: existing = {} anthropic_kwargs["extra_body"] = {**existing, **passthrough} - response = create_anthropic_message(self._client, anthropic_kwargs) + response = create_anthropic_message( + self._client, + anthropic_kwargs, + # Tick the aux forward-progress hook per streamed event so hosts + # watching liveness (gateway session hygiene) don't kill a + # slow-but-generating summary model. No-op when no hook is + # installed (None keeps the fast get_final_message path). + on_stream_event=( + (lambda _event: _notify_aux_progress()) + if _aux_progress_active() else None + ), + ) _transport = get_transport("anthropic_messages") _nr = _transport.normalize_response( response, strip_tool_prefix=self._is_oauth @@ -1440,7 +1539,9 @@ class AnthropicAuxiliaryClient: def __init__(self, real_client: Any, model: str, api_key: str, base_url: str, is_oauth: bool = False): self._real_client = real_client - adapter = _AnthropicCompletionsAdapter(real_client, model, is_oauth=is_oauth) + adapter = _AnthropicCompletionsAdapter( + real_client, model, is_oauth=is_oauth, base_url=base_url, + ) self.chat = _AnthropicChatShim(adapter) self.api_key = api_key self.base_url = base_url @@ -1705,7 +1806,7 @@ def _read_nous_auth() -> Optional[dict]: try: if not _AUTH_JSON_PATH.is_file(): return None - data = json.loads(_AUTH_JSON_PATH.read_text()) + data = json.loads(_AUTH_JSON_PATH.read_text(encoding="utf-8")) if data.get("active_provider") != "nous": return None provider = data.get("providers", {}).get("nous", {}) @@ -2305,6 +2406,62 @@ def _read_main_base_url() -> str: return "" +def _resolve_moa_aggregator(preset_name: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + """Resolve a MoA preset to its aggregator (provider, model) pair. + + "moa" is a virtual provider — the acting model of a preset is its + aggregator slot, and there is no real "moa" HTTP endpoint. Auxiliary + tasks (title generation, compression, vision, commit messages, …) don't + need the reference fan-out, so every aux resolution layer maps + provider="moa"/model= to the aggregator's real provider+model + through this single helper (shared by ``_resolve_auto``, + ``_resolve_task_provider_model``, and ``resolve_provider_client`` so the + preset lookup and validation cannot drift between paths). + + Args: + preset_name: The MoA preset name (usually carried in the "model" + field), or None/"" to resolve the user's default preset. + + Returns: + (aggregator_provider, aggregator_model), or (None, None) when the + preset cannot be resolved (missing config, renamed/deleted preset, + or a malformed aggregator slot). + """ + try: + from hermes_cli.config import load_config + from hermes_cli.moa_config import resolve_moa_preset + + preset = resolve_moa_preset(load_config().get("moa") or {}, preset_name or None) + agg = preset.get("aggregator") or {} + agg_provider = str(agg.get("provider") or "").strip() + agg_model = str(agg.get("model") or "").strip() + if agg_provider and agg_model and agg_provider.lower() != "moa": + return agg_provider, agg_model + except Exception: + logger.debug( + "MoA aggregator resolution failed for preset %r", preset_name, exc_info=True + ) + return None, None + + +def _read_main_model_for_aux() -> str: + """Main model with MoA presets unwrapped to the aggregator's model. + + When the main provider is ``moa``, ``_read_main_model()`` returns a MoA + *preset name* (e.g. "opus-gpt") — never a valid wire model id on any + provider. Auxiliary fallback chains that pre-fill a missing model from + the main model must use this reader instead, so unset aux models default + to the preset's acting (aggregator) model. Returns "" when the main + provider is moa but the preset cannot be resolved — sending nothing is + strictly better than sending a preset name that 400s. + """ + model = _read_main_model() + if (_read_main_provider() or "").strip().lower() == "moa": + _, agg_model = _resolve_moa_aggregator(model) + return agg_model or "" + return model + + def _read_main_api_key_if_same_host(aux_base_url: str) -> str: """Return the main api_key only when *aux_base_url* points at the same host as the main model's base_url. @@ -2337,6 +2494,167 @@ def _read_main_api_key_if_same_host(aux_base_url: str) -> str: _RUNTIME_MAIN_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = ( contextvars.ContextVar("auxiliary_runtime_main", default=None) ) + +_RELAY_AUX_CALL_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = ( + contextvars.ContextVar("auxiliary_relay_call", default=None) +) + + +def _relay_auxiliary_call(callback): + """Give every physical retry in one auxiliary call a shared Relay identity.""" + + @functools.wraps(callback) + def wrapped(*args, **kwargs): + task = args[0] if args else kwargs.get("task") + token = _RELAY_AUX_CALL_CONTEXT.set({ + "task": str(task or "unknown"), + "request_id": f"aux-{uuid.uuid4().hex}", + "attempt_count": 0, + "provider": "", + "model": "", + "api_mode": "chat_completions", + }) + try: + return callback(*args, **kwargs) + except BaseException: + _fail_relay_auxiliary_call() + raise + finally: + _RELAY_AUX_CALL_CONTEXT.reset(token) + + return wrapped + + +def _relay_auxiliary_call_async(callback): + """Async counterpart to :func:`_relay_auxiliary_call`.""" + + @functools.wraps(callback) + async def wrapped(*args, **kwargs): + task = args[0] if args else kwargs.get("task") + token = _RELAY_AUX_CALL_CONTEXT.set({ + "task": str(task or "unknown"), + "request_id": f"aux-{uuid.uuid4().hex}", + "attempt_count": 0, + "provider": "", + "model": "", + "api_mode": "chat_completions", + }) + try: + return await callback(*args, **kwargs) + except BaseException: + _fail_relay_auxiliary_call() + raise + finally: + _RELAY_AUX_CALL_CONTEXT.reset(token) + + return wrapped + + +def _set_relay_auxiliary_route( + provider: str | None, + model: str | None, + api_mode: str | None, +) -> None: + context = _RELAY_AUX_CALL_CONTEXT.get() + if context is None: + return + context["provider"] = str(provider or "auxiliary") + context["model"] = str(model or "unknown") + context["api_mode"] = str(api_mode or "chat_completions") + + +def _relay_auxiliary_metadata( + *, + provider: str | None = None, + api_mode: str | None = None, +) -> tuple[str, str, dict[str, Any]] | None: + context = _RELAY_AUX_CALL_CONTEXT.get() + if context is None: + return None + attempt_count = int(context.get("attempt_count") or 0) + context["attempt_count"] = attempt_count + 1 + provider_name = str(provider or context.get("provider") or "auxiliary") + model_name = str(context.get("model") or "unknown") + return provider_name, model_name, { + "api_mode": str(api_mode or context.get("api_mode") or "chat_completions"), + "api_request_id": str(context["request_id"]), + "call_role": f"auxiliary:{context['task']}", + "retry_count": attempt_count, + "auxiliary_task": str(context["task"]), + } + + +def _relay_sync_completion( + client: Any, + kwargs: dict[str, Any], + *, + provider: str | None = None, + api_mode: str | None = None, + create: Callable[[dict[str, Any]], Any] | None = None, +) -> Any: + callback = create or (lambda request: client.chat.completions.create(**request)) + route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode) + if route is None: + return callback(kwargs) + provider_name, fallback_model, metadata = route + from agent import relay_llm + + return relay_llm.execute_current( + kwargs, + callback, + name=provider_name, + model_name=str(kwargs.get("model") or fallback_model), + metadata=metadata, + defer_logical_completion=True, + ) + + +async def _relay_async_completion( + client: Any, + kwargs: dict[str, Any], + *, + provider: str | None = None, + api_mode: str | None = None, + create: Callable[[dict[str, Any]], Any] | None = None, +) -> Any: + callback = create or (lambda request: client.chat.completions.create(**request)) + route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode) + if route is None: + return await callback(kwargs) + provider_name, fallback_model, metadata = route + from agent import relay_llm + + return await relay_llm.execute_current_async( + kwargs, + callback, + name=provider_name, + model_name=str(kwargs.get("model") or fallback_model), + metadata=metadata, + defer_logical_completion=True, + ) + + +def _relay_sync_stream( + client: Any, + kwargs: dict[str, Any], + *, + provider: str | None = None, + api_mode: str | None = None, +) -> Any: + route = _relay_auxiliary_metadata(provider=provider, api_mode=api_mode) + if route is None: + return client.chat.completions.create(**kwargs) + provider_name, fallback_model, metadata = route + from agent import relay_llm + + return relay_llm.stream_current( + kwargs, + lambda request: client.chat.completions.create(**request), + name=provider_name, + model_name=str(kwargs.get("model") or fallback_model), + finalizer=dict, + metadata=metadata, + ) _RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = ("", "", "", "", "", "") _RUNTIME_MAIN_COMPAT_LOCK = threading.Lock() @@ -2380,6 +2698,7 @@ def set_runtime_main( provider: str, model: str, *, + requested_provider: str = "", base_url: str = "", api_key: Any = "", api_mode: str = "", @@ -2395,6 +2714,7 @@ def set_runtime_main( global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT runtime = { "provider": (provider or "").strip().lower(), + "requested_provider": (requested_provider or "").strip().lower(), "model": (model or "").strip(), "base_url": (base_url or "").strip(), "api_key": ( @@ -2575,7 +2895,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]: return None, None if custom_base.lower().startswith(_CODEX_AUX_BASE_URL.lower()): return None, None - model = _read_main_model() or "gpt-4o-mini" + model = _read_main_model_for_aux() or "gpt-4o-mini" logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions") _clean_base, _dq = _extract_url_query_params(custom_base) _extra = {"default_query": _dq} if _dq else {} @@ -2637,7 +2957,13 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str return None, None api_key, base_url = resolved logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model) - real_client = _create_openai_client(api_key=api_key, base_url=base_url) + from tools.xai_http import hermes_xai_default_headers + + real_client = _create_openai_client( + api_key=api_key, + base_url=base_url, + default_headers=hermes_xai_default_headers(), + ) return CodexAuxiliaryClient(real_client, model), model @@ -2867,6 +3193,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona } _MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode") +_MAIN_RUNTIME_CONTEXT_FIELDS = _MAIN_RUNTIME_FIELDS + ("requested_provider",) def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]: @@ -2889,7 +3216,7 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, if not isinstance(main_runtime, dict): return {} normalized: Dict[str, Any] = {} - for field in _MAIN_RUNTIME_FIELDS: + for field in _MAIN_RUNTIME_CONTEXT_FIELDS: value = main_runtime.get(field) # Preserve a callable api_key (Entra ID bearer provider) unchanged. if field == "api_key" and callable(value) and not isinstance(value, str): @@ -2897,9 +3224,10 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, continue if isinstance(value, str) and value.strip(): normalized[field] = value.strip() - provider = normalized.get("provider") - if isinstance(provider, str): - normalized["provider"] = provider.lower() + for identity_field in ("provider", "requested_provider"): + identity = normalized.get(identity_field) + if isinstance(identity, str): + normalized[identity_field] = identity.lower() return normalized @@ -3590,6 +3918,7 @@ def _retry_same_provider_sync( effective_timeout: float, effective_extra_body: dict, reasoning_config: Optional[dict], + extra_headers: Optional[Dict[str, str]] = None, ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3625,11 +3954,23 @@ def _retry_same_provider_sync( extra_body=effective_extra_body, reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, + task=task, ) + # Preserve per-request attribution headers (e.g. Copilot's + # ``x-initiator: user``) across the rebuilt-client retry — dropping them + # here would let a recovery retry silently lose capability gating (#60293). + if extra_headers: + retry_kwargs["extra_headers"] = dict(extra_headers) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) return _validate_llm_response( - retry_client.chat.completions.create(**retry_kwargs), task, + _relay_sync_completion( + retry_client, + retry_kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), + task, ) @@ -3649,6 +3990,7 @@ async def _retry_same_provider_async( effective_timeout: float, effective_extra_body: dict, reasoning_config: Optional[dict], + extra_headers: Optional[Dict[str, str]] = None, ) -> Any: if task == "vision": _, retry_client, retry_model = resolve_vision_provider_client( @@ -3684,11 +4026,22 @@ async def _retry_same_provider_async( extra_body=effective_extra_body, reasoning_config=reasoning_config, base_url=retry_base or resolved_base_url, + task=task, ) + # Preserve per-request attribution headers across the rebuilt-client + # retry — see the sync variant above (#60293). + if extra_headers: + retry_kwargs["extra_headers"] = dict(extra_headers) if _is_anthropic_compat_endpoint(resolved_provider, retry_base): retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"]) return _validate_llm_response( - await retry_client.chat.completions.create(**retry_kwargs), task, + await _relay_async_completion( + retry_client, + retry_kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), + task, ) @@ -3759,6 +4112,24 @@ def _refresh_provider_credentials(provider: str) -> bool: return False _evict_cached_clients(normalized) return True + if normalized == "vertex": + # Mirrors run_agent.py's _try_refresh_vertex_client_credentials + # for the main conversation loop. Without this branch, an + # auxiliary Vertex client (vision, title generation, reflection, + # context compression, ...) that 401s on its ~1h token expiry + # falls through to the final `return False` below: the stale + # client is never evicted from _client_cache (whose cache key + # ignores the rotating bearer token), so every subsequent + # auxiliary Vertex call keeps 401ing until process restart. + from agent.vertex_adapter import get_vertex_config + + token, base_url = get_vertex_config() + if not isinstance(token, str) or not token.strip(): + return False + if not isinstance(base_url, str) or not base_url.strip(): + return False + _evict_cached_clients(normalized) + return True except Exception as exc: logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc) return False @@ -3872,10 +4243,10 @@ def _call_fallback_candidate_sync( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=fb_base) + base_url=fb_base, task=task) try: return _validate_llm_response( - fb_client.chat.completions.create(**fb_kwargs), task) + _relay_sync_completion(fb_client, fb_kwargs, provider=fb_label), task) except Exception as fb_err: if not _is_auth_error(fb_err): raise @@ -3889,10 +4260,16 @@ def _call_fallback_candidate_sync( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=str(getattr(retry_client, "base_url", "") or fb_base)) + base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task) try: return _validate_llm_response( - retry_client.chat.completions.create(**retry_kwargs), task) + _relay_sync_completion( + retry_client, + retry_kwargs, + provider=fb_provider, + ), + task, + ) except Exception as retry_err: if not _is_auth_error(retry_err): raise @@ -3938,10 +4315,16 @@ async def _call_fallback_candidate_async( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=fb_base) + base_url=fb_base, task=task) try: return _validate_llm_response( - await fb_client.chat.completions.create(**fb_kwargs), task) + await _relay_async_completion( + fb_client, + fb_kwargs, + provider=fb_label, + ), + task, + ) except Exception as fb_err: if not _is_auth_error(fb_err): raise @@ -3956,10 +4339,16 @@ async def _call_fallback_candidate_async( tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=str(getattr(retry_client, "base_url", "") or fb_base)) + base_url=str(getattr(retry_client, "base_url", "") or fb_base), task=task) try: return _validate_llm_response( - await retry_client.chat.completions.create(**retry_kwargs), task) + await _relay_async_completion( + retry_client, + retry_kwargs, + provider=fb_provider, + ), + task, + ) except Exception as retry_err: if not _is_auth_error(retry_err): raise @@ -4027,6 +4416,7 @@ def _try_main_agent_model_fallback( failed_provider: str, task: str = None, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Last-resort fallback to the user's main agent provider + model. @@ -4035,20 +4425,56 @@ def _try_main_agent_model_fallback( layer: if nothing the user asked for can serve the request, try the main chat model before giving up. - Skips when the failed provider already IS the main provider (no point - retrying the same backend that just failed). + ``failed_model`` narrows the same-provider skip to the exact + (provider, model) pair that just failed, mirroring + :func:`_try_configured_fallback_chain`. This matters for self-hosted / + custom endpoints serving several models behind one provider label: the + aux compression model timing out says nothing about the health of the + main agent model deployed on the same URL (real incident: aux + ``glm-5.2`` hung and timed out while main ``macaron-v1-venti`` on the + identical endpoint was serving 448K-token turns fine — the + provider-label skip discarded the one fallback that would have worked). + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model``: skip the + main model only when it IS the exact model that failed. + - Provider-wide failures (auth 401, payment 402) and legacy callers + leave ``failed_model`` as None, keeping the whole-provider skip — + the shared credentials/account are broken, so the main model on the + same provider cannot help either. Returns: (client, model, provider_label) or (None, None, "") if no fallback. """ main_provider = (_read_main_provider() or "").strip() main_model = (_read_main_model() or "").strip() + if main_provider.lower() == "moa": + # MoA virtual provider: fall back to the preset's aggregator — the + # acting model — instead of the unreachable "moa"/ pair. + _agg_provider, _agg_model = _resolve_moa_aggregator(main_model) + if not _agg_provider or not _agg_model: + return None, None, "" + main_provider, main_model = _agg_provider, _agg_model if not main_provider or not main_model or main_provider.lower() in {"auto", ""}: return None, None, "" - skip = (failed_provider or "").lower().strip() - if main_provider.lower() == skip: - # The thing that failed IS the main model — nothing to fall back to. + # Identity + scope semantics owned by agent.backend_identity (#72468): + # model-scoped failures skip only the exact deployment that failed; + # provider-wide failures (no failed_model) skip the credential surface. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + skip_model = (failed_model or "").strip().lower() or None + if should_skip_candidate( + BackendIdentity.build(provider=main_provider, model=main_model), + BackendIdentity.build(provider=failed_provider, model=skip_model), + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL, + ): + # The thing that failed IS the main model (or the failure was + # provider-wide) — nothing to fall back to. return None, None, "" if _is_provider_unhealthy(main_provider): _log_skip_unhealthy(main_provider, task) @@ -4158,6 +4584,7 @@ def _try_configured_fallback_chain( task: str, failed_provider: str, reason: str = "error", + failed_model: Optional[str] = None, ) -> Tuple[Optional[Any], Optional[str], str]: """Try user-configured fallback_chain for a specific auxiliary task. @@ -4165,6 +4592,25 @@ def _try_configured_fallback_chain( entry in order. Each entry must have at least ``provider``; ``model``, ``base_url``, and ``api_key`` are optional. + ``failed_model`` narrows the skip check to the exact (provider, model) + pair that just failed, rather than the whole provider. Without it every + entry sharing the failed provider is skipped (the original behaviour). + Callers pass it only when a sibling model on the same provider could + plausibly recover: + + - Model-specific runtime failures (timeout, connection, rate limit, + model-incompatible, invalid response) pass ``failed_model`` so a + chain that intentionally lists several models under the same provider + — e.g. two more NVIDIA NIM models after the primary NIM model times + out — is not skipped wholesale. Only the exact model that failed is + skipped; the siblings still run instead of jumping straight to the + main-agent-model safety net. + - Provider-wide failures (auth 401, payment 402) and "no client could + be built" callers leave ``failed_model`` as None, keeping the whole + provider skipped — the shared credentials/account behind every model + on that provider are broken, so a sibling can't help and the + main-agent-model safety net should be reached instead. + Returns: (client, model, provider_label) or (None, None, "") if no fallback. """ @@ -4176,7 +4622,24 @@ def _try_configured_fallback_chain( if not chain or not isinstance(chain, list): return None, None, "" - skip = failed_provider.lower().strip() + skip_model = (failed_model or "").strip().lower() or None + # Identity + scope semantics owned by agent.backend_identity (#59561, + # #72468): a failed_model means the failure was model-scoped (timeout / + # connection / rate limit) — only the exact deployment is skipped; no + # failed_model means provider-wide (auth/payment) — the whole credential + # surface is skipped. + from agent.backend_identity import ( + BackendIdentity, + FailureScope, + should_skip_candidate, + ) + + failed_ident = BackendIdentity.build( + provider=failed_provider, model=skip_model, + ) + failure_scope = ( + FailureScope.MODEL if skip_model else FailureScope.CREDENTIAL + ) tried = [] min_ctx = _task_minimum_context_length(task) @@ -4184,9 +4647,20 @@ def _try_configured_fallback_chain( if not isinstance(entry, dict): continue fb_provider = str(entry.get("provider", "")).strip() - if not fb_provider or fb_provider.lower() == skip: + if not fb_provider: + continue + fb_model_raw = str(entry.get("model", "")).strip() + if should_skip_candidate( + BackendIdentity.build( + provider=fb_provider, + model=fb_model_raw, + base_url=str(entry.get("base_url") or ""), + ), + failed_ident, + failure_scope, + ): continue - fb_model = str(entry.get("model", "")).strip() or None + fb_model = fb_model_raw or None label = f"fallback_chain[{i}]({fb_provider})" @@ -4443,26 +4917,17 @@ def _resolve_auto( # model. Resolve the MoA preset to its aggregator slot and continue Step 1 # with that real provider+model. Mirrors the MoA context-length resolution. if main_provider == "moa": - try: - from hermes_cli.config import load_config - from hermes_cli.moa_config import resolve_moa_preset - - _preset = resolve_moa_preset(load_config().get("moa") or {}, main_model) - _agg = _preset.get("aggregator") or {} - _agg_provider = str(_agg.get("provider") or "").strip() - _agg_model = str(_agg.get("model") or "").strip() - if _agg_provider and _agg_model and _agg_provider.lower() != "moa": - main_provider = _agg_provider - main_model = _agg_model - # The MoA virtual runtime carries a non-HTTP base_url - # ("moa://local") and a placeholder api_key; they belong to the - # facade, not the aggregator's real provider. Drop them so the - # aggregator resolves through its own provider credentials. - runtime_base_url = "" - runtime_api_key = "" - runtime_api_mode = "" - except Exception: - logger.debug("MoA aux resolution to aggregator failed", exc_info=True) + _agg_provider, _agg_model = _resolve_moa_aggregator(main_model) + if _agg_provider and _agg_model: + main_provider = _agg_provider + main_model = _agg_model + # The MoA virtual runtime carries a non-HTTP base_url + # ("moa://local") and a placeholder api_key; they belong to the + # facade, not the aggregator's real provider. Drop them so the + # aggregator resolves through its own provider credentials. + runtime_base_url = "" + runtime_api_key = "" + runtime_api_mode = "" if (main_provider and main_model and main_provider not in {"auto", ""}): @@ -4626,6 +5091,10 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False): async_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} elif base_url_host_matches(sync_base_url, "integrate.api.nvidia.com"): async_kwargs["default_headers"] = build_nvidia_nim_headers(sync_base_url) + elif base_url_host_matches(sync_base_url, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + async_kwargs["default_headers"] = hermes_xai_default_headers() else: # Fall back to profile.default_headers for providers that declare # client-level headers on their ProviderProfile (e.g. attribution @@ -4717,6 +5186,27 @@ def resolve_provider_client( # Normalise aliases provider = _normalize_aux_provider(provider) + # MoA virtual provider chokepoint: "moa" is not a real HTTP provider — + # its acting model is the preset's aggregator slot. The two resolver + # layers above (_resolve_auto, _resolve_task_provider_model) already + # unwrap their own paths, but callers that route here directly (vision + # auto-detect, _try_main_agent_model_fallback, get_available_vision_backends, + # plugin code) would otherwise dead-end in the unknown-provider branch. + # ``model`` carries the preset name for moa calls; when the preset can't + # be resolved we leave the call untouched and let the normal + # missing-provider handling produce its diagnostic. + if provider == "moa": + _agg_provider, _agg_model = _resolve_moa_aggregator(model) + if _agg_provider and _agg_model: + original_provider = _agg_provider.strip().lower() + provider = _normalize_aux_provider(_agg_provider) + model = _agg_model + # The moa:// facade endpoint and placeholder key belong to the + # virtual runtime, not the aggregator's real provider. + if explicit_base_url and str(explicit_base_url).lower().startswith("moa://"): + explicit_base_url = None + explicit_api_key = None + # Universal model-resolution fallback for concrete providers. ``auto`` is # intentionally excluded: `_resolve_auto(main_runtime=...)` returns the # model paired with the provider it actually selected. Pre-filling an auto @@ -4737,6 +5227,10 @@ def resolve_provider_client( # the load-bearing step for OAuth providers: an xai-oauth user # with grok-4.3 configured gets grok-4.3 for title generation # instead of silently dropping to whatever Step-2 fallback (#31845). + # When the main provider is MoA, ``_read_main_model_for_aux()`` + # substitutes the preset's aggregator model — the preset NAME is + # never a valid wire model id, so unset aux models default to the + # preset's acting model instead. # # Each provider branch below sees a non-empty ``model`` whenever the # user has *anything* configured — no provider-specific empty-model @@ -4753,7 +5247,7 @@ def resolve_provider_client( # return the actual current runtime model when the caller did not explicitly # request one. (# compression-current-model) if not model and provider != "auto": - model = _get_aux_model_for_provider(provider) or _read_main_model() or model + model = _get_aux_model_for_provider(provider) or _read_main_model_for_aux() or model def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool: """Decide if a plain OpenAI client should be wrapped for Responses API. @@ -4837,10 +5331,11 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", # ── Nous Portal (OAuth) ────────────────────────────────────────── if provider == "nous": - # Detect vision tasks: either explicit model override from - # _PROVIDER_VISION_MODELS, or caller passed a known vision model. + # Detect vision tasks: caller flag (strict vision backend), explicit + # model override from _PROVIDER_VISION_MODELS, or a known vision id. _is_vision = ( - model in _PROVIDER_VISION_MODELS.values() + is_vision + or model in _PROVIDER_VISION_MODELS.values() or (model or "").strip().lower() == "mimo-v2-omni" ) client, default = _try_nous(vision=_is_vision) @@ -4849,6 +5344,17 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", "but Nous Portal not configured (run: hermes auth)") return None, None final_model = _normalize_resolved_model(model or default, provider) + # Dual-wire: anthropic/* → /v1/messages, everything else stays on + # /chat/completions. Derive from the catalog id (not a stale + # api_mode=chat_completions) so aux matches the main agent. + from hermes_cli.providers import nous_api_mode + + portal_mode = nous_api_mode(final_model) + api_key_str = str(getattr(client, "api_key", "") or "") + base_url_str = str(getattr(client, "base_url", "") or "") + client = _maybe_wrap_anthropic( + client, final_model, api_key_str, base_url_str, portal_mode, + ) return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model)) @@ -5027,7 +5533,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", model or custom_entry.get("model") or (main_runtime.get("model") if main_runtime else None) - or _read_main_model() + or _read_main_model_for_aux() or "gpt-4o-mini", provider, ) @@ -5212,6 +5718,10 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", )) elif base_url_host_matches(base_url, "integrate.api.nvidia.com"): headers.update(build_nvidia_nim_headers(base_url)) + elif base_url_host_matches(base_url, "x.ai"): + from tools.xai_http import hermes_xai_default_headers + + headers.update(hermes_xai_default_headers()) else: # Fall back to profile.default_headers for providers that declare # client-level attribution headers on their profile (e.g. GMI @@ -5262,7 +5772,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", final_model = _normalize_resolved_model( model or (main_runtime.get("model") if main_runtime else None) - or _read_main_model(), + or _read_main_model_for_aux(), provider, ) if provider == "copilot-acp": @@ -5505,7 +6015,10 @@ def _resolve_strict_vision_backend( if provider == "openrouter": return _try_openrouter(model=model) if provider == "nous": - return _try_nous(vision=True) + # Must go through resolve_provider_client so anthropic/* vision + # recommendations wrap onto /v1/messages — _try_nous alone returns + # a bare OpenAI client and the call 404s. + return resolve_provider_client("nous", model, is_vision=True) if provider == "openai-codex": # Route through resolve_provider_client so the caller's explicit # model is used. There is no safe default Codex model (shifting @@ -5630,7 +6143,24 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[ # 5. Stop main_provider = str(runtime.get("provider") or _read_main_provider()) main_model = str(runtime.get("model") or _read_main_model()) - if main_provider and main_provider not in {"auto", ""}: + if main_provider.strip().lower() == "moa": + # MoA virtual provider: main_model is a preset NAME, and every + # capability probe below (_PROVIDERS_WITHOUT_VISION, + # _main_model_supports_vision, _resolve_provider_vision_default) + # would run against a provider/model pair that doesn't exist on + # any wire. Unwrap to the preset's aggregator slot first so the + # checks and the eventual client target the real acting model. + _agg_provider, _agg_model = _resolve_moa_aggregator(main_model) + if _agg_provider and _agg_model: + main_provider, main_model = _agg_provider, _agg_model + # Drop the moa:// facade endpoint from the runtime view used + # below — it belongs to the virtual provider, not the + # aggregator's real provider. + runtime = dict(runtime) + runtime["base_url"] = "" + runtime["api_key"] = "" + runtime["api_mode"] = "" + if main_provider and main_provider not in {"auto", "", "moa"}: # A provider-specific vision default wins over the user's chat model: # static overrides (xiaomi/zai) and catalog-backed discovery (the # DeepInfra profile hook) both yield a *known* vision-capable model, @@ -6227,8 +6757,8 @@ def _resolve_task_provider_model( task: str = None, provider: str = None, model: str = None, - base_url: str = None, - api_key: str = None, + base_url: Optional[str] = None, + api_key: Optional[str] = None, ) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]: """Determine provider + model for a call. @@ -6271,12 +6801,57 @@ def _resolve_task_provider_model( # which downstream consumers like ContextCompressor accept as the task output. # The provider-side 'auto' is handled in _resolve_auto() via main_runtime # fallback, so dropping cfg_model to None here lets that path do its job. + # + # The explicit `model` kwarg needs the identical normalization: MoA slots + # (agent/moa_loop.py's _slot_runtime) forward a preset's `model:` field as + # this explicit argument rather than through auxiliary. config, so a + # user-configured `model: auto` on a MoA reference/aggregator slot reaches + # this function here, not as cfg_model. Only normalizing cfg_model let that + # literal "auto" slip through via `model or cfg_model` below. + if model and model.lower() == "auto": + model = None if cfg_model and cfg_model.lower() == "auto": cfg_model = None resolved_model = model or cfg_model resolved_api_mode = cfg_api_mode + # MoA virtual provider: an *explicit* `provider: moa` override (either the + # caller-passed `provider` arg or `auxiliary..provider` in + # config.yaml) reaches this function directly — it never goes through + # _resolve_auto(), which only unwraps the *implicit* "main provider is + # moa" case (#53827). Left as-is, "moa" is returned verbatim and + # resolve_provider_client() looks it up in PROVIDER_REGISTRY (which has + # no "moa" entry — it's not a real HTTP provider), falls to the + # unknown-provider dead end, and call_llm surfaces a nonsensical + # "MOA_API_KEY environment variable" error for a provider that was never + # meant to be reached over the wire. Auxiliary tasks don't need the + # reference fan-out — resolve to the preset's aggregator slot instead, + # exactly like the implicit path does (shared helper: _resolve_moa_aggregator). + def _unwrap_moa_provider(prov: str, mdl: Optional[str]) -> Tuple[str, Optional[str]]: + if prov.strip().lower() != "moa": + return prov, mdl + agg_provider, agg_model = _resolve_moa_aggregator(mdl) + if agg_provider and agg_model: + return agg_provider, agg_model + return prov, mdl + + if provider and str(provider).strip().lower() == "moa": + provider, resolved_model = _unwrap_moa_provider(provider, resolved_model) + # The moa:// virtual endpoint (if any explicit base_url/api_key was + # passed alongside provider="moa") belongs to the facade, not the + # aggregator's real provider — drop it so the aggregator resolves + # through its own provider credentials, mirroring _resolve_auto(). + if provider and provider.lower() != "moa": + base_url = None + api_key = None + elif cfg_provider and str(cfg_provider).strip().lower() == "moa": + cfg_provider, cfg_model = _unwrap_moa_provider(cfg_provider, resolved_model) + if cfg_provider and cfg_provider.lower() != "moa": + resolved_model = cfg_model + cfg_base_url = None + cfg_api_key = None + # Convenience aliases for direct API-key endpoints that aren't first-class # providers (e.g. ``provider: openai`` → custom + api.openai.com/v1). # Applied to both explicit args and config-derived values. When the user @@ -6633,6 +7208,7 @@ def _build_call_kwargs( extra_body: Optional[dict] = None, reasoning_config: Optional[dict] = None, base_url: Optional[str] = None, + task: Optional[str] = None, ) -> dict: """Build kwargs for .chat.completions.create() with model/provider adjustments.""" kwargs: Dict[str, Any] = { @@ -6687,11 +7263,38 @@ def _build_call_kwargs( _provider_norm in {"nvidia", "nvidia-nim", "nim", "build-nvidia", "nemotron"} or base_url_host_matches(_effective_base, "integrate.api.nvidia.com") ) + _is_moa = bool(task) and str(task) == "moa_reference" + # Gemini's native generateContent maps max_tokens → maxOutputTokens and, + # when it is omitted, applies a fixed 65,535-token ceiling rather than + # "the model's full budget" (see gemini_native_adapter.build_gemini_request). + # So an explicit cap is both safe and the ONLY way to honor it here — + # dropping max_tokens silently makes MoA's reference_max_tokens a no-op + # for gemini advisors (they run effectively uncapped). + _is_gemini_native = _provider_norm in { + "gemini", "google", "google-gemini", "google-ai-studio", + } + if not _is_gemini_native and _effective_base: + try: + from agent.gemini_native_adapter import is_native_gemini_base_url + _is_gemini_native = is_native_gemini_base_url(_effective_base) + except Exception: + pass + _nous_on_messages = False + if _provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( _is_anthropic_compat_endpoint(provider, _effective_base) + or _nous_on_messages or _is_nvidia_nim + or _is_moa + or _is_gemini_native ): - kwargs["max_tokens"] = max_tokens + # Use auxiliary_max_tokens_param() so models that require + # max_completion_tokens (GPT-5 family, Copilot) get the right + # parameter name instead of a hardcoded max_tokens that 400s. + kwargs.update(auxiliary_max_tokens_param(max_tokens, model=model)) if tools: # Defensive dedup: providers like Google Vertex, Azure, and Bedrock @@ -6778,21 +7381,43 @@ def _build_call_kwargs( else: effort = reasoning_config.get("effort") or "medium" merged_extra["reasoning"] = {"enabled": True, "effort": effort} - if provider == "nous" and "tags" not in merged_extra: - merged_extra["tags"] = _nous_portal_tags() + # Portal product tags + sticky session_id. The provider profile usually + # supplies both; this fallback covers profile-load failures and alias + # spellings the profile lookup might miss. session_id keeps aux + # compression/title/vision calls on the same upstream instance as the + # main turn (cache warmth) — tags alone are not enough on /v1/messages. + _provider_for_portal = str(provider or "").strip().lower() + if _provider_for_portal in {"nous", "nous-portal", "nousresearch"}: + if "tags" not in merged_extra: + merged_extra["tags"] = _nous_portal_tags() + if "session_id" not in merged_extra: + try: + from agent.portal_tags import get_conversation_context + + sticky_key = get_conversation_context() + except Exception: + sticky_key = None + if sticky_key: + merged_extra["session_id"] = sticky_key if merged_extra: kwargs["extra_body"] = merged_extra - # Native Anthropic Messages adapters do not consume ``extra_body``. Carry - # the normalized Hermes reasoning config through a private kwarg so the - # adapter can pass it into build_anthropic_kwargs(), where provider-aware - # thinking/output_config projection lives. Do not expose this private kwarg - # to ordinary OpenAI-compatible SDK clients, which would reject it. + # Anthropic Messages adapters translate Hermes reasoning into native + # ``thinking`` via a private kwarg (and strip OpenAI-shaped + # ``extra_body.reasoning``). Do not expose this private kwarg to ordinary + # OpenAI-compatible SDK clients, which would reject it. Portal Claude is + # dual-wire — include it when the catalog id selects /v1/messages. if reasoning_config and isinstance(reasoning_config, dict): provider_norm = str(provider or "").strip().lower() effective_base = base_url or "" + _nous_on_messages = False + if provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + _nous_on_messages = nous_api_mode(model) == "anthropic_messages" if ( provider_norm == "anthropic" + or _nous_on_messages or _endpoint_speaks_anthropic_messages(effective_base) or _is_anthropic_compat_endpoint(provider_norm, effective_base) ): @@ -6838,6 +7463,7 @@ def _validate_llm_response( except (AttributeError, TypeError, IndexError) as exc: recovered = _recover_aux_response_message(response) if recovered is not None: + _complete_relay_auxiliary_call() return recovered response_type = type(response).__name__ response_preview = str(response)[:120] @@ -6847,9 +7473,34 @@ def _validate_llm_response( f"Expected object with .choices[0].message — check provider " f"adapter or custom endpoint compatibility." ) from exc + _complete_relay_auxiliary_call() return response +def _complete_relay_auxiliary_call(*, outcome: str = "success") -> None: + """Close one auxiliary logical call after acceptance or terminal failure.""" + context = _RELAY_AUX_CALL_CONTEXT.get() + if context is None: + return + from agent import relay_llm + + relay_llm.complete_logical_call( + str(context.get("request_id") or ""), + outcome=outcome, + ) + + +def _fail_relay_auxiliary_call() -> None: + """Close a terminally failed call without replacing its original error.""" + try: + _complete_relay_auxiliary_call(outcome="failed") + except Exception: + logger.warning( + "Relay auxiliary failure finalization failed", + exc_info=True, + ) + + def _recover_aux_response_message(response: Any) -> Optional[Any]: """Synthesize chat-completions shape from Responses-style text fields. @@ -6908,6 +7559,347 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any: return value +# ── Streamed aggregation for progress-hooked auxiliary calls ───────────── +# When a forward-progress hook is installed (aux_progress_hook — today only +# by context compression), the primary chat.completions attempt is upgraded +# to a streamed request that is aggregated back into a complete response. +# Two effects, both deliberate: +# 1. The configured ``timeout`` becomes an INTER-CHUNK idle timeout instead +# of a total budget (httpx applies the read timeout per stream read), so +# a slow-but-generating summary model is never killed mid-generation +# while tokens are moving — only a genuinely silent connection dies. +# 2. Every arriving chunk ticks the progress hook, letting outer watchdogs +# (gateway session hygiene) extend their deadlines on liveness instead +# of guessing with a fixed wall clock. +# A total ceiling still bounds the pathological 1-token-per-idle-window +# stream; see _aux_stream_total_ceiling(). + +_AUX_STREAM_CEILING_FLOOR_SECONDS = 600.0 +_AUX_STREAM_CEILING_MULTIPLIER = 4.0 + + +def _aux_stream_total_ceiling(effective_timeout: Optional[float]) -> float: + """Absolute wall-clock bound for a progress-hooked streamed aux call. + + Generous by design — the idle timeout is the real guard; this only stops + a degenerate stream that trickles one token per idle window forever. + """ + try: + timeout = float(effective_timeout) if effective_timeout is not None else 0.0 + except (TypeError, ValueError): + timeout = 0.0 + return max(_AUX_STREAM_CEILING_FLOOR_SECONDS, + _AUX_STREAM_CEILING_MULTIPLIER * timeout) + + +def _client_streams_internally(client: Any) -> bool: + """Wire adapters that consume a stream inside .create() already tick the + progress hook themselves (Codex per SSE event, Anthropic per stream + event); Bedrock's Converse shim cannot stream at all. None of them + accept chat-completions ``stream=True`` semantics from us.""" + return isinstance(client, ( + CodexAuxiliaryClient, + AnthropicAuxiliaryClient, + BedrockAuxiliaryClient, + )) + + +def _is_streaming_rejected_error(exc: Exception) -> bool: + """Provider explicitly refused a streamed chat.completions request.""" + err = str(exc).lower() + if "stream_options" in err: + return True + return "stream" in err and ( + "not supported" in err + or "unsupported" in err + or "not allowed" in err + or "disabled" in err + ) + + +def _provider_requires_stream(provider: str, base_url: Optional[str]) -> bool: + """Detect providers that only accept streaming (non-stream = HTTP 400). + + Some OpenAI-compatible endpoints reject non-streaming chat requests + outright — e.g. Tencent Copilot returns + ``{"code": 11101, "msg": "Non-stream chat request is currently not + supported"}``. The main conversation loop already streams, so interactive + chat works; auxiliary tasks (title generation, compression, web extract) + used the non-streaming path and failed on every call. When this returns + True the auxiliary client sends ``stream=True`` and aggregates the chunks + itself (see :func:`_aggregate_chat_stream`). Credit @kudi88 (PR #60686). + + Beyond the known-host list, users can mark ANY custom endpoint as + stream-only via ``auxiliary.stream_only_base_urls`` in config.yaml + (list of substrings matched against the endpoint URL). + """ + _url = str(base_url or "").lower() + if not _url: + return False + # Tencent Copilot — "Non-stream chat request is currently not supported" + if base_url_host_matches(_url, "copilot.tencent.com"): + return True + try: + from hermes_cli.config import load_config + aux_cfg = (load_config() or {}).get("auxiliary", {}) + markers = aux_cfg.get("stream_only_base_urls") or [] + if isinstance(markers, (list, tuple)): + for marker in markers: + if isinstance(marker, str) and marker.strip() and marker.strip().lower() in _url: + return True + except Exception: + # Config read is best-effort; never break an aux call over it. + pass + return False + + +def _create_with_progress( + client: Any, + kwargs: Dict[str, Any], + task: Optional[str] = None, + *, + force_stream: bool = False, +) -> Any: + """chat.completions.create() that streams when a progress hook is active + or the provider only accepts streamed requests. + + Behavior is byte-for-byte identical to a plain ``create(**kwargs)`` when + neither trigger applies (every existing caller/task) or when the client's + wire adapter streams internally. With a hook + a chunk-capable client, + the request is sent with ``stream=True`` and aggregated, ticking the hook + per chunk — so the configured ``timeout`` acts per stream read (idle) + rather than as a total budget, and outer liveness watchdogs see tokens + moving. ``force_stream=True`` (stream-only providers such as Tencent + Copilot — credit @kudi88, PR #60686) takes the same streamed path even + without a hook. Providers that reject the streamed request fall back to + the plain non-streaming call — except under ``force_stream``, where a + stream-only provider rejects the plain call by definition, so the + original error is surfaced to the normal recovery chains instead. + """ + _notify_aux_progress() # request dispatched counts as progress + if (not _aux_progress_active() and not force_stream) or _client_streams_internally(client): + return client.chat.completions.create(**kwargs) + + total_ceiling = _aux_stream_total_ceiling(kwargs.get("timeout")) + stream_kwargs = dict(kwargs) + stream_kwargs["stream"] = True + stream_kwargs["stream_options"] = {"include_usage": True} + try: + chunks = client.chat.completions.create(**stream_kwargs) + except Exception as exc: + # Genuine provider failures (auth, credit, rate limit, network) are + # not streaming's fault — surface them unchanged so the existing + # recovery chains (credential refresh, pool rotation, provider + # fallback) see the same error they would on a plain call. + if ( + force_stream + or _is_transient_transport_error(exc) + or _is_auth_error(exc) + or _is_payment_error(exc) + or _is_rate_limit_error(exc) + ): + raise + # Anything else may be a streaming-specific rejection (explicit + # "stream not supported", stream_options 400, or an idiosyncratic + # 4xx). Retry non-streaming once; if the request itself is bad the + # plain call reproduces the real error for the normal except-chains. + logger.debug( + "Auxiliary %s: streamed request failed (%s); retrying " + "non-streaming", task or "call", exc, + ) + return client.chat.completions.create(**kwargs) + + # Some shims (MoA virtual provider under quiet mode, defensive adapters) + # return a complete response even when stream=True was requested. + if hasattr(chunks, "choices"): + _notify_aux_progress() + return chunks + return _aggregate_chat_stream( + chunks, model=str(kwargs.get("model") or ""), total_ceiling=total_ceiling, + ) + + +def _aggregate_chat_stream( + chunks: Any, + *, + model: str = "", + total_ceiling: Optional[float] = None, +) -> Any: + """Consume a chat.completions chunk stream into a complete response. + + Ticks the thread-local aux progress hook on every chunk. Raises + TimeoutError when *total_ceiling* seconds elapse before the stream + finishes — phrased with "timed out" so existing timeout classification + (``_is_timeout_error``) treats it exactly like a request timeout. + Accumulation is shared with the async mirror via + :class:`_ChatStreamAccumulator`. + """ + acc = _ChatStreamAccumulator(model=model, total_ceiling=total_ceiling) + try: + for chunk in chunks: + acc.feed(chunk) + finally: + close_fn = getattr(chunks, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception: + pass + return acc.finish() + + +class _ChatStreamAccumulator: + """Shared per-chunk accumulation for sync and async stream aggregation. + + Mirrors :func:`_aggregate_chat_stream`'s chunk handling so the async + consumer below cannot drift from the sync one (same content/reasoning/ + tool-call delta reassembly, same "timed out" ceiling phrasing). + """ + + def __init__(self, model: str = "", total_ceiling: Optional[float] = None): + self._started = time.monotonic() + self._total_ceiling = total_ceiling + self.content_parts: List[str] = [] + self.reasoning_parts: List[str] = [] + self.tool_calls_acc: Dict[int, Dict[str, Any]] = {} + self.finish_reason = None + self.usage = None + self.resp_id = "" + self.resp_model = model or "" + + def feed(self, chunk: Any) -> None: + _notify_aux_progress() + if ( + self._total_ceiling is not None + and (time.monotonic() - self._started) >= self._total_ceiling + ): + raise TimeoutError( + f"Auxiliary streamed call timed out after {self._total_ceiling:.0f}s " + "total ceiling (stream still open but over budget)" + ) + self.resp_id = getattr(chunk, "id", None) or self.resp_id + self.resp_model = getattr(chunk, "model", None) or self.resp_model + chunk_usage = getattr(chunk, "usage", None) + if chunk_usage: + self.usage = chunk_usage + choices = getattr(chunk, "choices", None) or [] + if not choices: + return + choice = choices[0] + self.finish_reason = getattr(choice, "finish_reason", None) or self.finish_reason + delta = getattr(choice, "delta", None) + if delta is None: + return + piece = getattr(delta, "content", None) + if piece: + self.content_parts.append(piece) + reasoning_piece = ( + getattr(delta, "reasoning", None) + or getattr(delta, "reasoning_content", None) + ) + if reasoning_piece and isinstance(reasoning_piece, str): + self.reasoning_parts.append(reasoning_piece) + for tc in (getattr(delta, "tool_calls", None) or []): + idx = getattr(tc, "index", 0) or 0 + acc = self.tool_calls_acc.setdefault( + idx, {"id": "", "name": "", "arguments": []} + ) + if getattr(tc, "id", None): + acc["id"] = tc.id + fn = getattr(tc, "function", None) + if fn is not None: + if getattr(fn, "name", None): + acc["name"] = fn.name + if getattr(fn, "arguments", None): + acc["arguments"].append(fn.arguments) + + def finish(self) -> Any: + tool_calls = None + if self.tool_calls_acc: + tool_calls = [ + SimpleNamespace( + id=acc["id"], + type="function", + function=SimpleNamespace( + name=acc["name"], + arguments="".join(acc["arguments"]), + ), + ) + for _idx, acc in sorted(self.tool_calls_acc.items()) + ] + message = SimpleNamespace( + role="assistant", + content="".join(self.content_parts), + tool_calls=tool_calls, + reasoning="".join(self.reasoning_parts) or None, + ) + choice = SimpleNamespace( + index=0, + message=message, + finish_reason=self.finish_reason or "stop", + ) + return SimpleNamespace( + id=self.resp_id, + model=self.resp_model, + object="chat.completion", + choices=[choice], + usage=self.usage, + ) + + +async def _aggregate_chat_stream_async( + chunks: Any, + *, + model: str = "", + total_ceiling: Optional[float] = None, +) -> Any: + """Async mirror of :func:`_aggregate_chat_stream` (``async for`` consumer). + + The AsyncOpenAI stream contract is an async iterator — consuming it with + the sync helper raises. Same accumulation and ceiling semantics via + :class:`_ChatStreamAccumulator`. + """ + acc = _ChatStreamAccumulator(model=model, total_ceiling=total_ceiling) + try: + async for chunk in chunks: + acc.feed(chunk) + finally: + close_fn = getattr(chunks, "close", None) or getattr(chunks, "aclose", None) + if callable(close_fn): + try: + result = close_fn() + if inspect.isawaitable(result): + await result + except Exception: + pass + return acc.finish() + + +async def _acreate_with_stream( + client: Any, + kwargs: Dict[str, Any], + task: Optional[str] = None, +) -> Any: + """Async chat.completions.create() for stream-only providers. + + Sends ``stream=True`` and aggregates the async chunk stream into a + complete response (credit @kudi88, PR #60686 — async contract fixed to + ``async for`` and tool-call deltas preserved per sweeper review). + """ + total_ceiling = _aux_stream_total_ceiling(kwargs.get("timeout")) + stream_kwargs = dict(kwargs) + stream_kwargs["stream"] = True + stream_kwargs["stream_options"] = {"include_usage": True} + chunks = await client.chat.completions.create(**stream_kwargs) + # Defensive: shims may hand back a complete response despite stream=True. + if hasattr(chunks, "choices"): + return chunks + return await _aggregate_chat_stream_async( + chunks, model=str(kwargs.get("model") or ""), total_ceiling=total_ceiling, + ) + + +@_relay_auxiliary_call def call_llm( task: str = None, *, @@ -6923,6 +7915,7 @@ def call_llm( timeout: float = None, extra_body: dict = None, reasoning_config: Optional[dict] = None, + extra_headers: Optional[Dict[str, str]] = None, api_mode: str = None, stream: bool = False, stream_options: dict = None, @@ -6948,6 +7941,9 @@ def call_llm( extra_body: Additional request body fields. reasoning_config: Optional Hermes reasoning config for direct model calls such as MoA reference/aggregator slots. + extra_headers: Additional per-request HTTP headers. These override + client-level defaults for providers that gate capabilities on + request attribution (for example Copilot's ``x-initiator``). stream: When True, return the raw SDK streaming iterator instead of a validated complete response. The caller is responsible for consuming chunks (and for any fallback). Used by the MoA aggregator so its @@ -7044,6 +8040,11 @@ def call_llm( f"Run: hermes setup") effective_timeout = _effective_aux_timeout(task, timeout) + _set_relay_auxiliary_route( + resolved_provider, + final_model, + resolved_api_mode, + ) # Log what we're about to do — makes auxiliary operations visible _base_info = str(getattr(client, "base_url", resolved_base_url) or "") @@ -7060,7 +8061,9 @@ def call_llm( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=_base_info or resolved_base_url) + base_url=_base_info or resolved_base_url, task=task) + if extra_headers: + kwargs["extra_headers"] = dict(extra_headers) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) _client_base = str(getattr(client, "base_url", "") or "") @@ -7079,7 +8082,12 @@ def call_llm( kwargs["stream"] = True if stream_options: kwargs["stream_options"] = stream_options - return client.chat.completions.create(**kwargs) + return _relay_sync_stream( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ) # Handle unsupported temperature, max_tokens vs max_completion_tokens retry, # then payment fallback. @@ -7102,7 +8110,21 @@ def call_llm( # for the transient retry every auxiliary task shares. (PR #16587) try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task, + _relay_sync_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + create=lambda request: _create_with_progress( + client, + request, + task, + force_stream=_provider_requires_stream( + resolved_provider, _base_info or resolved_base_url, + ), + ), + ), + task, provider=resolved_provider, base_url=_base_info) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -7135,7 +8157,22 @@ def call_llm( time.sleep(_backoff) try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task) + _relay_sync_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + create=lambda request: _create_with_progress( + client, + request, + task, + force_stream=_provider_requires_stream( + resolved_provider, + _base_info or resolved_base_url, + ), + ), + ), + task) except Exception as retry_transient: if not _is_transient_transport_error(retry_transient): raise @@ -7152,7 +8189,12 @@ def call_llm( ) try: return _validate_llm_response( - client.chat.completions.create(**retry_kwargs), task) + _relay_sync_completion( + client, + retry_kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: retry_err_str = str(retry_err) # If retry still fails, fall through to the max_tokens / @@ -7190,7 +8232,12 @@ def call_llm( kwargs.pop("max_completion_tokens", None) try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task) + _relay_sync_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: # If the max_tokens retry also hits a payment or connection # error, fall through to the fallback chain below. @@ -7220,7 +8267,12 @@ def call_llm( kwargs["model"] = healed_model try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task) + _relay_sync_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: first_err = retry_err @@ -7253,7 +8305,12 @@ def call_llm( kwargs["model"] = refreshed_model try: return _validate_llm_response( - refreshed_client.chat.completions.create(**kwargs), task) + _relay_sync_completion( + refreshed_client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: if not ( _is_auth_error(retry_err) @@ -7281,7 +8338,12 @@ def call_llm( if refreshed_model and refreshed_model != kwargs.get("model"): kwargs["model"] = refreshed_model return _validate_llm_response( - refreshed_client.chat.completions.create(**kwargs), task) + _relay_sync_completion( + refreshed_client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) # ── Auth refresh retry ─────────────────────────────────────── auth_refresh_provider = _auth_refresh_provider_for_route( @@ -7314,6 +8376,7 @@ def call_llm( effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, reasoning_config=reasoning_config, + extra_headers=extra_headers, ) # ── Same-provider credential-pool recovery ───────────────────── @@ -7330,7 +8393,12 @@ def call_llm( if _is_rate_limit_error(first_err) and not _is_payment_error(first_err): try: return _validate_llm_response( - client.chat.completions.create(**kwargs), task) + _relay_sync_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)): raise @@ -7357,6 +8425,7 @@ def call_llm( effective_timeout=effective_timeout, effective_extra_body=effective_extra_body, reasoning_config=reasoning_config, + extra_headers=extra_headers, ) except Exception as retry2_err: # The rotated key also hit a quota/auth wall. Mark it @@ -7450,6 +8519,15 @@ def call_llm( logger.info("Auxiliary %s: %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -7458,7 +8536,8 @@ def call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -7467,10 +8546,12 @@ def call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: fb_resp = _call_fallback_candidate_sync( @@ -7575,6 +8656,7 @@ def extract_content_or_reasoning(response) -> str: return "" +@_relay_auxiliary_call_async async def async_call_llm( task: str = None, *, @@ -7666,6 +8748,11 @@ async def async_call_llm( f"Run: hermes setup") effective_timeout = _effective_aux_timeout(task, timeout) + _set_relay_auxiliary_route( + resolved_provider, + final_model, + resolved_api_mode, + ) # Pass the client's actual base_url (not just resolved_base_url) so # endpoint-specific temperature overrides can distinguish @@ -7676,7 +8763,7 @@ async def async_call_llm( temperature=temperature, max_tokens=max_tokens, tools=tools, timeout=effective_timeout, extra_body=effective_extra_body, reasoning_config=reasoning_config, - base_url=_client_base or resolved_base_url) + base_url=_client_base or resolved_base_url, task=task) # Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax) if _is_anthropic_compat_endpoint(resolved_provider, _client_base): @@ -7686,9 +8773,32 @@ async def async_call_llm( # Retry ONCE on the same provider for a transient transport blip # before the except-chain escalates to fallback — see call_llm() # for the rationale. (PR #16587) + _force_stream_async = ( + _provider_requires_stream( + resolved_provider, _client_base or resolved_base_url, + ) + and not isinstance(client, ( + AsyncCodexAuxiliaryClient, + AsyncAnthropicAuxiliaryClient, + AsyncBedrockAuxiliaryClient, + )) + ) + + async def _acreate(_kwargs: Dict[str, Any]) -> Any: + if _force_stream_async: + return await _acreate_with_stream(client, _kwargs, task) + return await client.chat.completions.create(**_kwargs) + try: return _validate_llm_response( - await client.chat.completions.create(**kwargs), task, + await _relay_async_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + create=_acreate, + ), + task, provider=resolved_provider, base_url=_client_base) except Exception as transient_err: if not _is_transient_transport_error(transient_err): @@ -7709,7 +8819,14 @@ async def async_call_llm( task or "call", transient_err, ) return _validate_llm_response( - await client.chat.completions.create(**kwargs), task) + await _relay_async_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + create=_acreate, + ), + task) except Exception as first_err: if "temperature" in kwargs and _is_unsupported_temperature_error(first_err): retry_kwargs = dict(kwargs) @@ -7720,7 +8837,12 @@ async def async_call_llm( ) try: return _validate_llm_response( - await client.chat.completions.create(**retry_kwargs), task) + await _relay_async_completion( + client, + retry_kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: retry_err_str = str(retry_err) if not ( @@ -7754,7 +8876,12 @@ async def async_call_llm( kwargs.pop("max_completion_tokens", None) try: return _validate_llm_response( - await client.chat.completions.create(**kwargs), task) + await _relay_async_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: # If the max_tokens retry also hits a payment or connection # error, fall through to the fallback chain below. @@ -7783,7 +8910,12 @@ async def async_call_llm( kwargs["model"] = healed_model try: return _validate_llm_response( - await client.chat.completions.create(**kwargs), task) + await _relay_async_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: first_err = retry_err @@ -7815,7 +8947,12 @@ async def async_call_llm( kwargs["model"] = refreshed_model try: return _validate_llm_response( - await refreshed_client.chat.completions.create(**kwargs), task) + await _relay_async_completion( + refreshed_client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: if not ( _is_auth_error(retry_err) @@ -7842,7 +8979,12 @@ async def async_call_llm( if refreshed_model and refreshed_model != kwargs.get("model"): kwargs["model"] = refreshed_model return _validate_llm_response( - await refreshed_client.chat.completions.create(**kwargs), task) + await _relay_async_completion( + refreshed_client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) # ── Auth refresh retry (mirrors sync call_llm) ─────────────── auth_refresh_provider = _auth_refresh_provider_for_route( @@ -7886,7 +9028,12 @@ async def async_call_llm( if _is_rate_limit_error(first_err) and not _is_payment_error(first_err): try: return _validate_llm_response( - await client.chat.completions.create(**kwargs), task) + await _relay_async_completion( + client, + kwargs, + provider=resolved_provider, + api_mode=resolved_api_mode, + ), task) except Exception as retry_err: if not (_is_auth_error(retry_err) or _is_payment_error(retry_err) or _is_rate_limit_error(retry_err)): raise @@ -7968,6 +9115,15 @@ async def async_call_llm( logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback", task or "call", reason, resolved_provider, first_err) + # Narrow the configured-chain skip to the exact model that + # failed ONLY for model-specific failures. Auth (401) and + # payment (402) errors are provider-wide — the credentials or + # account behind every model on that provider are the same — so + # a sibling model can't recover; keep skipping the whole + # provider so the main-agent-model safety net is still reached. + _chain_failed_model = ( + None if reason in ("auth error", "payment error") else final_model + ) # Fallback order (#26882, #26803): # 1. User-configured fallback_chain (per-task) if set # 2. For auto: top-level main fallback_providers/fallback_model @@ -7976,7 +9132,8 @@ async def async_call_llm( fb_client, fb_model, fb_label = (None, None, "") if is_auto: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_fallback_chain( task, resolved_provider or "auto", reason=reason) @@ -7985,10 +9142,12 @@ async def async_call_llm( resolved_provider, task, reason=reason) else: fb_client, fb_model, fb_label = _try_configured_fallback_chain( - task, resolved_provider or "auto", reason=reason) + task, resolved_provider or "auto", reason=reason, + failed_model=_chain_failed_model) if fb_client is None: fb_client, fb_model, fb_label = _try_main_agent_model_fallback( - resolved_provider, task, reason=reason) + resolved_provider, task, reason=reason, + failed_model=_chain_failed_model) if fb_client is not None: # Convert sync fallback client to async diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index a734516bdac4..58d615e5c859 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -4635,7 +4635,7 @@ class TestAnthropicAuxiliaryReasoningTranslation: """ @staticmethod - def _build_adapter(model="claude-fable-5", base_url=None): + def _build_adapter(model="claude-fable-5"): from agent.auxiliary_client import _AnthropicCompletionsAdapter captured = {} @@ -4649,7 +4649,7 @@ def create(self, **kwargs): usage=SimpleNamespace(input_tokens=1, output_tokens=1, total_tokens=2), ) - real_client = SimpleNamespace(messages=_Messages(), base_url=base_url) + real_client = SimpleNamespace(messages=_Messages()) return _AnthropicCompletionsAdapter(real_client, model), captured def test_reasoning_config_reaches_native_anthropic_wire_kwargs(self): @@ -4665,95 +4665,6 @@ def test_reasoning_config_reaches_native_anthropic_wire_kwargs(self): assert captured["output_config"] == {"effort": "medium"} assert "extra_body" not in captured - def test_minimax_m3_cn_auxiliary_call_uses_adaptive_thinking(self): - adapter, captured = self._build_adapter( - model="MiniMax-M3", - base_url="https://api.minimaxi.com/anthropic", - ) - - adapter.create( - model="MiniMax-M3", - messages=[{"role": "user", "content": "hi"}], - _reasoning_config={"enabled": True, "effort": "high"}, - ) - - assert captured["thinking"] == {"type": "adaptive"} - assert "output_config" not in captured - assert "temperature" not in captured - assert "extra_body" not in captured - - def test_minimax_m3_cn_auxiliary_accepts_httpx_url_with_trailing_slash(self): - from httpx import URL - - adapter, captured = self._build_adapter( - model="MiniMax-M3", - base_url=URL("https://api.minimaxi.com/anthropic/"), - ) - - adapter.create( - model="MiniMax-M3", - messages=[{"role": "user", "content": "hi"}], - _reasoning_config={"enabled": True, "effort": "high"}, - ) - - assert captured["thinking"] == {"type": "adaptive"} - assert "output_config" not in captured - assert "temperature" not in captured - - def test_minimax_m3_cn_auxiliary_without_base_url_falls_back(self): - """AnthropicAuxiliaryClient without base_url must NOT apply M3 adaptive. - - Guards the silent-regression class where the auxiliary adapter's base_url - is ``None`` (or the underlying client lacks the attribute). In that case - the M3 contract must not be applied — it would otherwise mis-shape the - payload on a non-MiniMax Anthropic-compatible endpoint. - """ - adapter, captured = self._build_adapter( - model="MiniMax-M3", - base_url=None, - ) - - adapter.create( - model="MiniMax-M3", - messages=[{"role": "user", "content": "hi"}], - _reasoning_config={"enabled": True, "effort": "high"}, - ) - - assert captured["thinking"]["type"] == "enabled" - assert "budget_tokens" in captured["thinking"] - - def test_minimax_m2_cn_auxiliary_uses_manual_thinking(self): - """M2.x on the MiniMax endpoint must keep manual thinking.""" - adapter, captured = self._build_adapter( - model="MiniMax-M2.7", - base_url="https://api.minimaxi.com/anthropic", - ) - - adapter.create( - model="MiniMax-M2.7", - messages=[{"role": "user", "content": "hi"}], - _reasoning_config={"enabled": True, "effort": "high"}, - ) - - assert captured["thinking"]["type"] == "enabled" - assert "budget_tokens" in captured["thinking"] - - def test_minimax_m3_on_non_minimax_anthropic_endpoint_uses_manual(self): - """M3 on a non-MiniMax Anthropic-compatible endpoint keeps manual.""" - adapter, captured = self._build_adapter( - model="MiniMax-M3", - base_url="https://example.test/anthropic", - ) - - adapter.create( - model="MiniMax-M3", - messages=[{"role": "user", "content": "hi"}], - _reasoning_config={"enabled": True, "effort": "high"}, - ) - - assert captured["thinking"]["type"] == "enabled" - assert "budget_tokens" in captured["thinking"] - def test_build_call_kwargs_private_reasoning_only_for_anthropic_messages(self): anthropic_kwargs = _build_call_kwargs( "anthropic",