From 02d5e2308589b34f2faf6615656b21caf523c8da Mon Sep 17 00:00:00 2001 From: rob-maron <132852777+rob-maron@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:53:48 -0400 Subject: [PATCH] nous portal anthropic wire --- agent/agent_init.py | 7 + agent/agent_runtime_helpers.py | 25 +- agent/anthropic_adapter.py | 94 ++- agent/auxiliary_client.py | 98 ++- agent/chat_completion_helpers.py | 49 +- agent/conversation_loop.py | 2 +- hermes_cli/model_switch.py | 14 +- hermes_cli/providers.py | 39 +- hermes_cli/runtime_provider.py | 12 +- run_agent.py | 29 +- .../agent/test_nous_portal_anthropic_wire.py | 757 ++++++++++++++++++ .../test_anthropic_prompt_cache_policy.py | 22 + .../test_anthropic_response_header_capture.py | 55 ++ .../run_agent/test_primary_runtime_restore.py | 42 +- tests/run_agent/test_provider_fallback.py | 85 ++ tests/run_agent/test_run_agent.py | 62 ++ tests/tools/test_delegate.py | 50 ++ tools/delegate_tool.py | 11 + 18 files changed, 1415 insertions(+), 38 deletions(-) create mode 100644 tests/agent/test_nous_portal_anthropic_wire.py create mode 100644 tests/run_agent/test_anthropic_response_header_capture.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 95161f8cdee5..5c022f4f6fb7 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -645,6 +645,13 @@ def init_agent( # AWS Bedrock — auto-detect from provider name or base URL # (bedrock-runtime..amazonaws.com). agent.api_mode = "bedrock_converse" + elif agent.provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* → Messages, everything else → + # chat_completions. Callers that already pass api_mode win above; + # this covers direct AIAgent construction without a resolved runtime. + from hermes_cli.providers import nous_api_mode + + agent.api_mode = nous_api_mode(agent.model) else: agent.api_mode = "chat_completions" diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 323c7487d75a..9fb894287a33 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1221,7 +1221,15 @@ def try_recover_primary_transport( if agent._is_openrouter_url(): return False provider_lower = (agent.provider or "").strip().lower() - if provider_lower in {"nous", "nous-research"}: + # Portal OpenAI-wire traffic still rides aggregator retry infra, so one + # more rebuilt OpenAI client won't help. Portal Claude on the native + # Messages route holds a local Anthropic SDK client whose connection + # pool *does* need the rebuild every other anthropic_messages provider + # already gets — don't blanket-skip the dual-wire path. + if ( + provider_lower in {"nous", "nous-portal", "nousresearch"} + and getattr(agent, "api_mode", None) != "anthropic_messages" + ): return False try: @@ -1895,7 +1903,15 @@ def anthropic_prompt_cache_policy( if is_native_anthropic: return True, True - if (is_openrouter or is_nous_portal) and (is_claude or is_kimi): + # Envelope layout is an OpenAI-wire construct. Portal Claude on the native + # Messages route must fall through to the third-party anthropic_messages + # branch below, which emits inner-block cache_control breakpoints; the + # envelope form would be dropped and serve 0% cache hits. + if ( + (is_openrouter or is_nous_portal) + and (is_claude or is_kimi) + and not is_anthropic_wire + ): return True, False # Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout # cache_control path as Portal Claude. Portal proxies to OpenRouter @@ -2059,8 +2075,11 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo from hermes_cli.providers import determine_api_mode # ── Determine api_mode if not provided ── + # Pass model so dual-wire providers (Nous Portal anthropic/* → Messages) + # resolve correctly; without it determine_api_mode falls back to the + # openai_chat overlay default. if not api_mode: - api_mode = determine_api_mode(new_provider, base_url) + api_mode = determine_api_mode(new_provider, base_url, model=new_model) # Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing # /v1 into the anthropic_messages client, which would cause the SDK to diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index e7c02e9db692..0d59d94c9c32 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -23,7 +23,7 @@ from urllib.parse import urlparse from hermes_constants import get_hermes_home from typing import Any, Dict, List, Optional, Tuple -from utils import base_url_host_matches, normalize_proxy_env_vars +from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars # NOTE: `import anthropic` is deliberately NOT at module top — the SDK pulls # ~220 ms of imports (anthropic.types, anthropic.lib.tools._beta_runner, etc.) @@ -546,15 +546,49 @@ def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool: return "/anthropic" in normalized.rstrip("/").lower() +def _is_nous_portal_endpoint(base_url: str | None) -> bool: + """Return True for Nous Portal's Anthropic Messages route. + + Portal serves its ``anthropic/*`` catalog natively at + ``https://inference-api.nousresearch.com/v1/messages``. Portal-specific + behaviours key off this: Bearer JWT auth, verbatim catalog model ids, + and native thinking-signature replay. + + Trusted hosts only: + + 1. Prod hostname ``inference-api.nousresearch.com`` + 2. The operator-set ``NOUS_INFERENCE_BASE_URL`` hostname (staging/preview) + + Lookalikes such as ``inference-api.nousresearch.com.attacker.test`` are + rejected (hostname match, not substring). + """ + if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"): + return True + try: + from hermes_cli.auth import _nous_inference_env_override + + override = _nous_inference_env_override() + except Exception: + return False + if not override: + return False + # Exact host equality (not subdomain) so the env override can't broaden + # into sibling hosts the operator did not set. + override_host = base_url_hostname(override) + return bool(override_host) and base_url_hostname(base_url or "") == override_host + + def _requires_bearer_auth(base_url: str | None) -> bool: """Return True for Anthropic-compatible providers that require Bearer auth. Some third-party /anthropic endpoints implement Anthropic's Messages API but require Authorization: Bearer instead of Anthropic's native x-api-key header. MiniMax's global and China Anthropic-compatible endpoints, Azure AI - Foundry's Anthropic-style endpoint, and Palantir Foundry's LLM proxy - follow this pattern. + Foundry's Anthropic-style endpoint, Palantir Foundry's LLM proxy, and Nous + Portal's Messages route follow this pattern. """ + if _is_nous_portal_endpoint(base_url): + return True normalized = _normalize_base_url_text(base_url) if not normalized: return False @@ -721,7 +755,11 @@ def _build_anthropic_client_with_bearer_hook( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} - return _anthropic_sdk.Anthropic(**kwargs) + client = _anthropic_sdk.Anthropic(**kwargs) + # Same env-inference trap as build_anthropic_client: auth_token-only + # construction would otherwise also send ANTHROPIC_API_KEY as X-Api-Key. + client.api_key = None + return client def build_anthropic_client( @@ -850,7 +888,16 @@ def build_anthropic_client( if common_betas: kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)} - return _anthropic_sdk.Anthropic(**kwargs) + client = _anthropic_sdk.Anthropic(**kwargs) + # Bearer-only construction leaves ``api_key`` unset, so the SDK fills it + # from ``ANTHROPIC_API_KEY`` (Hermes loads that into the process env from + # ``~/.hermes/.env``). The result is dual auth — + # ``X-Api-Key: sk-ant-…`` *and* ``Authorization: Bearer `` — + # on every Portal / MiniMax / OAuth Messages request. Clear the env-filled + # key whenever we intentionally authenticated via auth_token alone. + if "auth_token" in kwargs and "api_key" not in kwargs: + client.api_key = None + return client def build_anthropic_bedrock_client(region: str): @@ -2416,10 +2463,22 @@ def _manage_thinking_signatures( replayed assistant tool-call messages. See 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 + same signed thinking blocks. Sticky ``session_id`` keeps a conversation + on one upstream instance so those signatures stay warm — stripping them + here would 400 the first tool-loop turn ("thinking must be passed back"). + Portal therefore takes the native Anthropic replay path below. + Mutates ``result`` in place. """ _THINKING_TYPES = frozenset(("thinking", "redacted_thinking")) - _is_third_party = _is_third_party_anthropic_endpoint(base_url) + # Portal speaks Anthropic's thinking contract end-to-end; do not treat it + # as a signature-blind proxy even though the host is not anthropic.com. + _is_third_party = ( + _is_third_party_anthropic_endpoint(base_url) + and not _is_nous_portal_endpoint(base_url) + ) last_assistant_idx = None for i in range(len(result) - 1, -1, -1): @@ -2676,7 +2735,12 @@ def build_anthropic_kwargs( ) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] - model = normalize_model_name(model, preserve_dots=preserve_dots) + # Nous Portal routes on its own catalog ids (``anthropic/claude-opus-4.8``); + # normalizing to the bare Anthropic slug would make the model unresolvable + # there. Skipping the call preserves the prefix AND the dots, so + # ``preserve_dots`` stays irrelevant for Portal. + if not _is_nous_portal_endpoint(base_url): + model = normalize_model_name(model, preserve_dots=preserve_dots) # effective_max_tokens = output cap for this call (≠ total context window) # Use the resolver helper so non-positive values (negative ints, # fractional floats, NaN, non-numeric) fail locally with a clear error @@ -2916,6 +2980,7 @@ def create_anthropic_message( log_prefix: str = "", prefer_stream: bool = True, on_stream_event=None, + on_response=None, ) -> Any: """Create an Anthropic message, aggregating via stream when available. @@ -2932,6 +2997,13 @@ def create_anthropic_message( progress hook so a slow-but-generating summary model isn't treated as hung. Only fires on the streaming path; the ``create()`` fallback has no events to report. + + ``on_response``: optional callable invoked once with the underlying httpx + response before the message is aggregated (best-effort, exceptions + swallowed). Response *headers* carry out-of-band provider state that the + parsed ``Message`` drops — Nous Portal's ``x-nous-credits-*`` balance family + in particular. Only fires on the streaming path, which is the one the main + turn loop takes. """ sanitize_anthropic_kwargs(api_kwargs, log_prefix=log_prefix) @@ -2942,6 +3014,14 @@ def create_anthropic_message( stream_kwargs.pop("stream", None) try: with stream_fn(**stream_kwargs) as stream: + if callable(on_response): + try: + on_response(getattr(stream, "response", None)) + except Exception: + logger.debug( + "%son_response callback failed", + log_prefix, exc_info=True, + ) if callable(on_stream_event): # Consume the event stream manually so each event can # tick the caller's progress callback; get_final_message diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 0165987d85fa..46b40b2a58a0 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1362,10 +1362,32 @@ class AsyncCodexAuxiliaryClient: 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 @@ -1417,6 +1439,11 @@ class _AnthropicCompletionsAdapter: 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, ) # Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set # temperature for models that still accept it. build_anthropic_kwargs @@ -1510,7 +1537,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 @@ -5099,10 +5128,11 @@ def resolve_provider_client( # ── 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) @@ -5111,6 +5141,17 @@ def resolve_provider_client( "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)) @@ -5767,7 +5808,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 @@ -7028,8 +7072,14 @@ def _build_call_kwargs( _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 @@ -7124,21 +7174,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) ): diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 65415d4cb589..b0c3faaf6f73 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -188,6 +188,31 @@ def _provider_preferences_for_agent(agent) -> Dict[str, Any]: return preferences +def _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs: dict) -> dict: + """Merge Portal ``tags`` / ``session_id`` onto an Anthropic Messages kwargs dict. + + The Nous provider profile is only consulted by the OpenAI-wire transport; + anthropic_messages callers must merge it themselves. Passes ``session_id`` + only — not ``provider_preferences`` (those become a top-level ``provider`` + routing object on the OpenAI wire). Never blocks a turn on tagging. + """ + if getattr(agent, "provider", None) not in {"nous", "nous-portal", "nousresearch"}: + return anthropic_kwargs + try: + from providers import get_provider_profile + + nous_profile = get_provider_profile("nous") + if nous_profile is not None: + anthropic_kwargs.setdefault("extra_body", {}).update( + nous_profile.build_extra_body( + session_id=getattr(agent, "session_id", None) + ) + ) + except Exception as exc: # noqa: BLE001 — never block a turn on tagging + logger.debug("Nous Portal extra_body merge failed: %s", exc) + return anthropic_kwargs + + def _env_float(name: str, default: float) -> float: try: return float(os.getenv(name, str(default))) @@ -1023,7 +1048,7 @@ def build_api_kwargs(agent, api_messages: list) -> dict: ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None) if ephemeral_out is not None: agent._ephemeral_max_output_tokens = None # consume immediately - return _transport.build_kwargs( + anthropic_kwargs = _transport.build_kwargs( model=agent.model, messages=anthropic_messages, tools=tools_for_api, @@ -1036,6 +1061,12 @@ def build_api_kwargs(agent, api_messages: list) -> dict: fast_mode=(agent.request_overrides or {}).get("speed") == "fast", drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)), ) + # Nous Portal reads ``tags`` and ``session_id`` as top-level body fields + # on its Messages route the same way it does on /chat/completions, but + # the profile hook that produces them is only consulted by the + # OpenAI-wire transport. Merge them here so Messages traffic keeps + # product attribution and sticky routing. + return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs) # AWS Bedrock native Converse API — bypasses the OpenAI client entirely. # The adapter handles message/tool conversion and boto3 calls directly. @@ -1701,6 +1732,14 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool _fb_is_azure = agent._is_azure_openai_url(fb_base_url) if fb_provider == "openai-codex": fb_api_mode = "codex_responses" + elif fb_provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* must land on /v1/messages. + # resolve_provider_client still returns an OpenAI client for + # Nous; the anthropic_messages branch below rebuilds the native + # client from that credential + base_url. + from hermes_cli.providers import nous_api_mode + + fb_api_mode = nous_api_mode(fb_model) elif ( fb_provider == "anthropic" or fb_base_url.rstrip("/").lower().endswith("/anthropic") @@ -2127,7 +2166,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: _ant_kw = _tsum.build_kwargs(model=agent.model, messages=api_messages, tools=None, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, is_oauth=agent._is_anthropic_oauth, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None)) + _ant_kw = _merge_nous_portal_messages_extra_body(agent, _ant_kw) summary_response = agent._anthropic_messages_create(_ant_kw) _summary_result = _tsum.normalize_response(summary_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_summary_result.content or "").strip() @@ -2157,7 +2198,9 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: _ant_kw2 = _tretry.build_kwargs(model=agent.model, messages=api_messages, tools=None, is_oauth=agent._is_anthropic_oauth, max_tokens=agent.max_tokens, reasoning_config=agent.reasoning_config, - preserve_dots=agent._anthropic_preserve_dots()) + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None)) + _ant_kw2 = _merge_nous_portal_messages_extra_body(agent, _ant_kw2) retry_response = agent._anthropic_messages_create(_ant_kw2) _retry_result = _tretry.normalize_response(retry_response, strip_tool_prefix=agent._is_anthropic_oauth) final_response = (_retry_result.content or "").strip() diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 86d26690b7fd..cbeee7a1df83 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -3595,7 +3595,7 @@ def run_conversation( agent._buffer_vprint("🔐 Vertex AI token refreshed after 401. Retrying request...") continue if ( - agent.api_mode == "chat_completions" + agent.api_mode in ("chat_completions", "anthropic_messages") and agent.provider == "nous" and status_code == 401 and not _retry.nous_auth_retry_attempted diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index bc39fe8020c5..80c1ed0e077a 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1560,9 +1560,21 @@ def switch_model( if target_provider in {"opencode-zen", "opencode-go", "opencode"}: api_mode = opencode_model_api_mode(target_provider, new_model) + # --- Nous Portal dual-wire override --- + # Portal serves anthropic/* on /v1/messages and everything else on + # /chat/completions. resolve_runtime_provider already sets this when it + # succeeds; always re-derive from the *final* (post-normalize) model so + # alias clears / empty fallbacks cannot leave Claude on the OpenAI wire. + if target_provider in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + api_mode = nous_api_mode(new_model) + # --- Determine api_mode if not already set --- if not api_mode: - api_mode = determine_api_mode(target_provider, base_url) + api_mode = determine_api_mode( + target_provider, base_url, model=new_model + ) # OpenCode base URLs end with /v1 for OpenAI-compatible models, but the # Anthropic SDK prepends its own /v1/messages to the base_url. Normalize diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 6bfbf01feb09..5515a16700e6 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -597,19 +597,50 @@ def host_mandated_api_mode(base_url: str = "") -> Optional[str]: return None -def determine_api_mode(provider: str, base_url: str = "") -> str: +def nous_api_mode(model: str = "") -> str: + """Resolve the wire protocol for a Nous Portal model. + + Portal serves its ``anthropic/*`` catalog on a native Anthropic Messages + route (``/v1/messages``) alongside the OpenAI-compatible + ``/v1/chat/completions`` used by every other model it proxies. Claude + traffic goes to the native route so it gets Anthropic's own request shape + (inner-block ``cache_control`` breakpoints, thinking blocks) instead of the + OpenAI-wire translation. + + When *model* is empty/unknown, defaults to ``chat_completions`` — the + historical Nous transport — so callers that don't yet know the model + stay on the safer OpenAI-compatible path. + """ + if str(model or "").strip().lower().startswith("anthropic/"): + return "anthropic_messages" + return "chat_completions" + + +def determine_api_mode(provider: str, base_url: str = "", model: str = "") -> str: """Determine the API mode (wire protocol) for a provider/endpoint. Resolution order: 1. Host-mandated mode (special endpoints that only accept one protocol). - 2. Known provider → transport → TRANSPORT_TO_API_MODE. - 3. Direct provider checks (bedrock). - 4. Default: 'chat_completions'. + 2. Nous Portal dual-wire (model-derived; overlay alone is openai_chat). + 3. Known provider → transport → TRANSPORT_TO_API_MODE. + 4. Direct provider checks (bedrock). + 5. Default: 'chat_completions'. + + *model* is optional but required for dual-wire providers (Nous) whose + transport depends on the catalog id, not just the provider/host. """ mandated = host_mandated_api_mode(base_url) if mandated is not None: return mandated + # Nous is dual-wire: anthropic/* → Messages, everything else → + # chat_completions. The Hermes overlay still advertises openai_chat + # (the majority of the Portal catalog), so the transport lookup below + # would pin Claude on the wrong wire without this carve-out. + provider_norm = (provider or "").strip().lower() + if provider_norm in {"nous", "nous-portal", "nousresearch"}: + return nous_api_mode(model) + pdef = get_provider(provider) if pdef is not None: return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions") diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 58202c7ec194..3b3d0eafb8ea 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -457,7 +457,9 @@ def _resolve_runtime_from_pool_entry( elif provider == "xai": api_mode = "codex_responses" elif provider == "nous": - api_mode = "chat_completions" + from hermes_cli.providers import nous_api_mode + + api_mode = nous_api_mode(effective_model) base_url = _nous_inference_base_url_override() or base_url elif provider == "copilot": api_mode = _copilot_runtime_api_mode( @@ -1522,6 +1524,8 @@ def _resolve_explicit_runtime( } if provider == "nous": + from hermes_cli.providers import nous_api_mode + state = auth_mod.get_provider_auth_state("nous") or {} base_url = ( explicit_base_url @@ -1550,7 +1554,7 @@ def _resolve_explicit_runtime( base_url = creds.get("base_url", "").rstrip("/") or base_url return { "provider": "nous", - "api_mode": "chat_completions", + "api_mode": nous_api_mode(target_model or model_cfg.get("default") or ""), "base_url": base_url, "api_key": api_key, "source": "explicit", @@ -1891,12 +1895,14 @@ def resolve_runtime_provider( if provider == "nous": try: + from hermes_cli.providers import nous_api_mode + creds = resolve_nous_runtime_credentials( timeout_seconds=float(_getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), ) return { "provider": "nous", - "api_mode": "chat_completions", + "api_mode": nous_api_mode(target_model or model_cfg.get("default") or ""), "base_url": creds.get("base_url", "").rstrip("/"), "api_key": creds.get("api_key", ""), "source": creds.get("source", "portal"), diff --git a/run_agent.py b/run_agent.py index 703759d8ea62..aabaf78b87b7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3497,6 +3497,17 @@ class AIAgent: """Return the last captured RateLimitState, or None.""" return self._rate_limit_state + def _capture_anthropic_response_headers(self, http_response: Any) -> None: + """Capture out-of-band state from Anthropic Messages response headers. + + The Anthropic SDK's aggregated ``Message`` drops HTTP headers. Portal + (and other providers) put rate-limit and credits state there — the same + families the OpenAI-wire streaming path captures via + ``stream.response``. Fail-open: each capture swallows its own errors. + """ + self._capture_rate_limits(http_response) + self._capture_credits(http_response) + def _capture_credits(self, http_response: Any) -> None: """Parse x-nous-credits-* headers, cache CreditsState, fire threshold notices. @@ -4868,7 +4879,12 @@ class AIAgent: *, force: bool = True, ) -> bool: - if self.api_mode != "chat_completions" or self.provider != "nous": + if self.provider != "nous": + return False + # Portal serves anthropic/* on the native Messages route, so a session + # can be holding either client kind when its short-lived invoke JWT + # expires. Both need the refresh or the turn dies on a 401. + if self.api_mode not in ("chat_completions", "anthropic_messages"): return False try: @@ -4891,6 +4907,13 @@ class AIAgent: self.api_key = api_key.strip() self.base_url = base_url.strip().rstrip("/") + + if self.api_mode == "anthropic_messages": + self._anthropic_api_key = self.api_key + self._anthropic_base_url = self.base_url + self._rebuild_anthropic_client() + return True + self._client_kwargs["api_key"] = self.api_key self._client_kwargs["base_url"] = self.base_url # Nous requests should not inherit OpenRouter-only attribution headers. @@ -5212,6 +5235,10 @@ class AIAgent: api_kwargs, log_prefix=getattr(self, "log_prefix", ""), prefer_stream=not bool(getattr(self, "_disable_streaming", False)), + # Rate-limit + credits state live in response headers, which the + # parsed Message drops. No-ops on providers that don't send the + # matching header families (x-ratelimit-* / x-nous-credits-*). + on_response=self._capture_anthropic_response_headers, ) def _rebuild_anthropic_client(self) -> None: diff --git a/tests/agent/test_nous_portal_anthropic_wire.py b/tests/agent/test_nous_portal_anthropic_wire.py new file mode 100644 index 000000000000..52779bbdea97 --- /dev/null +++ b/tests/agent/test_nous_portal_anthropic_wire.py @@ -0,0 +1,757 @@ +"""Nous Portal ``anthropic/*`` models route on the native Messages wire. + +Portal serves its ``anthropic/*`` catalog at +``https://inference-api.nousresearch.com/v1/messages`` alongside the +OpenAI-compatible ``/v1/chat/completions`` used by everything else it proxies. +These tests pin the contracts that make that routing correct: + +1. ``api_mode`` is derived from the model, not hardcoded per provider. +2. The Anthropic SDK's base URL + auth land on Portal's route with Bearer auth. +3. Portal catalog ids are forwarded verbatim (it routes on ``vendor/model``). +4. Portal's ``tags`` / ``session_id`` body fields survive onto the new wire. +5. Signed thinking blocks replay like native Anthropic (not strip-all third-party). +6. Auxiliary clients inherit the same dual-wire split (Claude → Messages). +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli import runtime_provider as rp +from hermes_cli.providers import nous_api_mode + +PORTAL_URL = "https://inference-api.nousresearch.com/v1" +# Staging / preview hosts used via NOUS_INFERENCE_BASE_URL — not the prod +# hostname, so Portal behaviour must key off provider=nous. +STAGING_URL = "https://ai.wildebeest-newton.ts.net/v1" + + +# ── 1. api_mode is model-derived ───────────────────────────────────────────── + + +class TestApiModeRouting: + """``nous_api_mode`` / ``determine_api_mode`` split the Portal catalog.""" + + @pytest.mark.parametrize( + "model", + [ + "anthropic/claude-opus-5", + "anthropic/claude-opus-4.8", + "anthropic/claude-haiku-4.5", + "ANTHROPIC/Claude-Sonnet-5", # case-insensitive + ], + ) + def test_anthropic_prefixed_models_use_the_messages_wire(self, model): + assert nous_api_mode(model) == "anthropic_messages" + + @pytest.mark.parametrize( + "model", + [ + "openai/gpt-5.5", + "hermes-4-405b", + "qwen/qwen3.6-plus", + "x-ai/grok-5", + "", + ], + ) + def test_every_other_portal_model_stays_on_chat_completions(self, model): + assert nous_api_mode(model) == "chat_completions" + + def test_a_claude_model_without_the_vendor_prefix_is_not_rerouted(self): + """Portal ids carry the vendor prefix. A bare ``claude-*`` slug is not a + Portal Anthropic id, so it must not be pushed onto the native wire.""" + assert nous_api_mode("claude-opus-4.8") == "chat_completions" + + def test_determine_api_mode_honors_the_model_for_nous(self): + """Callers that skip resolve_runtime_provider (fallback, switch_model + empty-mode path) must still land Claude on Messages — the Hermes + overlay alone advertises openai_chat for every Nous model.""" + from hermes_cli.providers import determine_api_mode + + assert ( + determine_api_mode( + "nous", + PORTAL_URL, + model="anthropic/claude-opus-4.8", + ) + == "anthropic_messages" + ) + assert ( + determine_api_mode("nous", PORTAL_URL, model="hermes-4-405b") + == "chat_completions" + ) + # No model → historical OpenAI-wire default (safer than guessing). + assert determine_api_mode("nous", PORTAL_URL) == "chat_completions" + + +class TestRuntimeResolution: + """The resolved runtime dict carries the model-derived mode end to end.""" + + @pytest.fixture(autouse=True) + def _stub_portal_credentials(self, monkeypatch): + monkeypatch.setattr(rp, "load_config", lambda: {}) + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous") + monkeypatch.setattr(rp, "load_pool", lambda p: SimpleNamespace( + has_credentials=lambda: False, + )) + monkeypatch.setattr( + rp, + "resolve_nous_runtime_credentials", + lambda **kw: { + "base_url": PORTAL_URL, + "api_key": "portal-invoke-jwt", + "source": "portal", + "expires_at": None, + }, + ) + + def test_anthropic_model_resolves_to_the_messages_wire(self, monkeypatch): + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "nous"}) + + resolved = rp.resolve_runtime_provider( + requested="nous", target_model="anthropic/claude-opus-5" + ) + + assert resolved["provider"] == "nous" + assert resolved["api_mode"] == "anthropic_messages" + # base_url keeps its /v1; the adapter strips it before the SDK + # re-appends /v1/messages (see TestClientShape). + assert resolved["base_url"] == PORTAL_URL + + def test_non_anthropic_model_stays_on_chat_completions(self, monkeypatch): + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "nous"}) + + resolved = rp.resolve_runtime_provider( + requested="nous", target_model="hermes-4-405b" + ) + + assert resolved["api_mode"] == "chat_completions" + + def test_target_model_wins_over_the_persisted_default(self, monkeypatch): + """A mid-session ``/model`` switch passes the model it is switching TO; + deriving the mode from the stale config default would leave the session + on the wrong wire.""" + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "nous", "default": "hermes-4-405b"}, + ) + + resolved = rp.resolve_runtime_provider( + requested="nous", target_model="anthropic/claude-opus-5" + ) + + assert resolved["api_mode"] == "anthropic_messages" + + def test_persisted_default_is_used_when_no_target_model_is_given(self, monkeypatch): + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "nous", "default": "anthropic/claude-sonnet-5"}, + ) + + resolved = rp.resolve_runtime_provider(requested="nous") + + assert resolved["api_mode"] == "anthropic_messages" + + +class TestPoolRuntimeResolution: + """Portal credential pools take a separate resolution path.""" + + def _pool(self, entry): + return SimpleNamespace( + has_credentials=lambda: True, + select=lambda: entry, + ) + + @pytest.fixture + def portal_entry(self): + return SimpleNamespace( + provider="nous", + source="device_code", + runtime_api_key="pool-invoke-jwt", + agent_key="pool-invoke-jwt", + agent_key_expires_at="2099-01-01T00:00:00+00:00", + scope="inference:invoke", + runtime_base_url=PORTAL_URL, + ) + + def test_pool_entry_honors_the_model_derived_mode(self, monkeypatch, portal_entry): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous") + monkeypatch.setattr(rp, "_agent_key_is_usable", lambda *a, **k: True) + monkeypatch.setattr(rp, "load_pool", lambda p: self._pool(portal_entry)) + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "nous"}) + + resolved = rp.resolve_runtime_provider( + requested="nous", target_model="anthropic/claude-opus-4.8" + ) + + assert resolved["api_mode"] == "anthropic_messages" + + def test_pool_entry_keeps_other_models_on_chat_completions( + self, monkeypatch, portal_entry + ): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "nous") + monkeypatch.setattr(rp, "_agent_key_is_usable", lambda *a, **k: True) + monkeypatch.setattr(rp, "load_pool", lambda p: self._pool(portal_entry)) + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "nous"}) + + resolved = rp.resolve_runtime_provider( + requested="nous", target_model="openai/gpt-5.5" + ) + + assert resolved["api_mode"] == "chat_completions" + + +# ── 2. Client shape: endpoint + auth ──────────────────────────────────────── + + +class TestClientShape: + def test_portal_endpoint_predicate_matches_on_hostname(self): + from agent.anthropic_adapter import _is_nous_portal_endpoint + + assert _is_nous_portal_endpoint(PORTAL_URL) + assert _is_nous_portal_endpoint("https://inference-api.nousresearch.com") + assert not _is_nous_portal_endpoint("https://api.anthropic.com") + assert not _is_nous_portal_endpoint("") + assert not _is_nous_portal_endpoint(None) + + def test_staging_host_matches_only_when_env_override_points_there( + self, monkeypatch + ): + """Staging is trusted via NOUS_INFERENCE_BASE_URL host match only.""" + from agent.anthropic_adapter import ( + _is_nous_portal_endpoint, + _requires_bearer_auth, + ) + + assert not _is_nous_portal_endpoint(STAGING_URL) + assert not _requires_bearer_auth(STAGING_URL) + + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", STAGING_URL) + assert _is_nous_portal_endpoint(STAGING_URL) + assert _requires_bearer_auth(STAGING_URL) + # Override does not open unrelated hosts. + assert not _is_nous_portal_endpoint("https://evil.example/v1") + + def test_lookalike_host_does_not_get_portal_treatment(self): + """Substring matching would hand a spoofed host the Portal JWT as a + Bearer token. Hostname matching must reject it.""" + from agent.anthropic_adapter import ( + _is_nous_portal_endpoint, + _requires_bearer_auth, + ) + + spoofed = "https://inference-api.nousresearch.com.attacker.test/v1" + assert not _is_nous_portal_endpoint(spoofed) + assert not _requires_bearer_auth(spoofed) + + def test_configured_base_url_resolves_to_portals_messages_route(self): + """The config carries ``.../v1``; the adapter strips it so the SDK's own + ``/v1/messages`` suffix does not double up into ``/v1/v1/messages``.""" + from agent.anthropic_adapter import build_anthropic_client + + client = build_anthropic_client("portal-invoke-jwt", PORTAL_URL) + + assert str(client.base_url).rstrip("/") == ( + "https://inference-api.nousresearch.com" + ) + + def test_portal_jwt_authenticates_with_bearer_not_x_api_key(self): + """Portal validates the OAuth invoke JWT as a Bearer credential, the + same way its /chat/completions route does. Sending it as x-api-key + (the adapter's third-party default) 401s.""" + from agent.anthropic_adapter import build_anthropic_client + + client = build_anthropic_client("portal-invoke-jwt", PORTAL_URL) + + assert client.auth_token == "portal-invoke-jwt" + assert client.api_key is None + + def test_portal_bearer_does_not_also_send_env_anthropic_api_key( + self, monkeypatch + ): + """The Anthropic SDK fills api_key from ANTHROPIC_API_KEY when the + constructor omits it. Hermes loads that env from ~/.hermes/.env, so + without an explicit clear every Portal request would dual-auth as + X-Api-Key: sk-ant-… + Authorization: Bearer portal.jwt.""" + from agent.anthropic_adapter import build_anthropic_client + + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-should-not-leak") + client = build_anthropic_client("portal-invoke-jwt", PORTAL_URL) + + assert client.auth_token == "portal-invoke-jwt" + assert client.api_key is None + assert "X-Api-Key" not in client.auth_headers + assert client.auth_headers.get("Authorization", "").startswith( + "Bearer portal-invoke-jwt" + ) + + def test_staging_host_with_env_override_uses_bearer_auth(self, monkeypatch): + from agent.anthropic_adapter import build_anthropic_client + + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", STAGING_URL) + client = build_anthropic_client("portal-invoke-jwt", STAGING_URL) + + assert client.auth_token == "portal-invoke-jwt" + assert client.api_key is None + assert str(client.base_url).rstrip("/") == ( + "https://ai.wildebeest-newton.ts.net" + ) + + +# ── 3. Portal catalog ids are forwarded verbatim ───────────────────────────── + + +class TestModelIdPassthrough: + def _kwargs(self, model, base_url): + from agent.anthropic_adapter import build_anthropic_kwargs + + return build_anthropic_kwargs( + model=model, + messages=[{"role": "user", "content": "hi"}], + tools=None, + max_tokens=1024, + reasoning_config=None, + base_url=base_url, + ) + + @pytest.mark.parametrize( + "model", + [ + "anthropic/claude-opus-4.8", + "anthropic/claude-opus-5", + "anthropic/claude-haiku-4.5", + ], + ) + def test_portal_receives_the_catalog_id_unchanged(self, model): + """Portal routes on its own ``vendor/model`` ids. Stripping the prefix + or hyphenating the dots makes the model unresolvable there.""" + assert self._kwargs(model, PORTAL_URL)["model"] == model + + def test_staging_host_with_env_override_keeps_the_catalog_id( + self, monkeypatch + ): + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", STAGING_URL) + assert self._kwargs("anthropic/claude-opus-4.8", STAGING_URL)[ + "model" + ] == "anthropic/claude-opus-4.8" + + def test_staging_host_without_env_override_still_normalizes(self): + """Without NOUS_INFERENCE_BASE_URL, a non-prod host is not Portal.""" + assert self._kwargs("anthropic/claude-opus-4.8", STAGING_URL)[ + "model" + ] == "claude-opus-4-8" + + def test_native_anthropic_still_gets_the_normalized_slug(self): + """The Portal carve-out must not leak into real Anthropic, which needs + the bare hyphenated slug.""" + kwargs = self._kwargs("anthropic/claude-opus-4.8", "https://api.anthropic.com") + assert kwargs["model"] == "claude-opus-4-8" + + def test_normalization_still_applies_when_no_base_url_is_known(self): + assert self._kwargs("anthropic/claude-opus-4.8", None)["model"] == ( + "claude-opus-4-8" + ) + + +# ── 4. Portal body fields survive onto the Messages wire ───────────────────── + + +class TestPortalBodyFields: + """``tags`` and ``session_id`` are top-level Portal body fields. + + They are produced by the Nous provider profile, which only the OpenAI-wire + transport consults — so the Anthropic branch has to merge them in itself. + """ + + def _build(self, provider="nous", session_id="sess-abc123"): + from agent.chat_completion_helpers import build_api_kwargs + from agent.transports.anthropic import AnthropicTransport + + transport = AnthropicTransport() + agent = SimpleNamespace( + api_mode="anthropic_messages", + provider=provider, + model="anthropic/claude-opus-4.8", + session_id=session_id, + tools=None, + max_tokens=1024, + reasoning_config=None, + request_overrides={}, + context_compressor=None, + _ephemeral_max_output_tokens=None, + _is_anthropic_oauth=False, + _anthropic_base_url=PORTAL_URL, + _oauth_1m_beta_disabled=False, + _get_transport=lambda: transport, + _prepare_anthropic_messages_for_api=lambda msgs: msgs, + _anthropic_preserve_dots=lambda: False, + ) + return build_api_kwargs(agent, [{"role": "user", "content": "hi"}]) + + def test_portal_tags_reach_the_messages_request(self): + from agent.portal_tags import hermes_client_tag + + tags = self._build()["extra_body"]["tags"] + + assert "product=hermes-agent" in tags + assert hermes_client_tag() in tags + assert all(isinstance(tag, str) for tag in tags), ( + "Portal skips non-string tag entries unpredictably" + ) + + def test_session_id_reaches_the_messages_request(self): + extra_body = self._build(session_id="sess-abc123")["extra_body"] + + assert extra_body["session_id"] == "sess-abc123" + assert "conversation=sess-abc123" in extra_body["tags"] + + def test_provider_routing_object_is_not_emitted_by_default(self): + """Messages merge omits provider_preferences, so no top-level + ``provider`` routing object is added when the agent has none set.""" + assert "provider" not in self._build()["extra_body"] + + def test_session_id_is_omitted_when_the_agent_has_none(self): + """Portal treats a blank/absent value as unset; sending an empty string + is pointless noise on every request.""" + assert "session_id" not in self._build(session_id=None)["extra_body"] + + def test_other_anthropic_providers_get_no_portal_fields(self): + """The merge is Portal-specific — native Anthropic and third-party + gateways would reject the unknown top-level keys.""" + extra_body = self._build(provider="anthropic").get("extra_body", {}) + + assert "tags" not in extra_body + assert "session_id" not in extra_body + + def test_the_model_id_survives_the_merge(self): + """Guards against the merge path accidentally bypassing the Portal + model-id carve-out.""" + assert self._build()["model"] == "anthropic/claude-opus-4.8" + + def test_helper_merge_is_a_no_op_for_non_nous(self): + from agent.chat_completion_helpers import ( + _merge_nous_portal_messages_extra_body, + ) + + kwargs = {"model": "claude-opus-4-8", "messages": []} + agent = SimpleNamespace(provider="anthropic", session_id="s") + assert _merge_nous_portal_messages_extra_body(agent, kwargs) is kwargs + assert "extra_body" not in kwargs + + +# ── 5. Thinking signatures replay like native Anthropic ────────────────────── + + +class TestPortalThinkingReplay: + """Portal proxies Claude to Anthropic-family backends that validate signed + thinking blocks. The generic third-party strip would drop them and 400 the + first tool-loop turn; Portal must take the native Anthropic replay path. + """ + + def _messages(self): + return [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "calling", + "tool_calls": [ + { + "id": "t1", + "type": "function", + "function": { + "name": "terminal", + "arguments": '{"cmd":"ls"}', + }, + } + ], + "anthropic_content_blocks": [ + { + "type": "thinking", + "thinking": "plan the listing", + "signature": "sig-portal-abc", + }, + {"type": "text", "text": "calling"}, + { + "type": "tool_use", + "id": "t1", + "name": "terminal", + "input": {"cmd": "ls"}, + }, + ], + }, + { + "role": "tool", + "tool_call_id": "t1", + "name": "terminal", + "content": "done", + }, + ] + + def _assert_thinking_kept(self, base_url): + from agent.anthropic_adapter import convert_messages_to_anthropic + + _system, converted = convert_messages_to_anthropic( + self._messages(), + base_url=base_url, + model="anthropic/claude-opus-4.8", + ) + assistant = next(m for m in converted if m["role"] == "assistant") + thinking = [ + b + for b in assistant["content"] + if isinstance(b, dict) and b.get("type") == "thinking" + ] + + assert len(thinking) == 1 + assert thinking[0]["thinking"] == "plan the listing" + assert thinking[0]["signature"] == "sig-portal-abc" + assert any( + isinstance(b, dict) and b.get("type") == "tool_use" + for b in assistant["content"] + ) + + def test_portal_keeps_signed_thinking_on_the_latest_assistant_turn(self): + self._assert_thinking_kept(PORTAL_URL) + + def test_staging_host_with_env_override_keeps_signed_thinking( + self, monkeypatch + ): + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", STAGING_URL) + self._assert_thinking_kept(STAGING_URL) + + def test_other_third_party_gateways_still_strip_thinking(self): + """The Portal carve-out must not leak into MiniMax-style proxies.""" + from agent.anthropic_adapter import convert_messages_to_anthropic + + _system, converted = convert_messages_to_anthropic( + self._messages(), + base_url="https://api.minimax.io/anthropic", + model="MiniMax-M2.7", + ) + assistant = next(m for m in converted if m["role"] == "assistant") + thinking = [ + b + for b in assistant["content"] + if isinstance(b, dict) and b.get("type") == "thinking" + ] + + assert thinking == [] + + +# ── 6. Auxiliary clients inherit the dual-wire split ───────────────────────── + + +class TestAuxiliaryDualWire: + """``resolve_provider_client('nous', …)`` must wrap Claude onto Messages.""" + + def test_anthropic_catalog_model_wraps_to_messages(self): + from agent.auxiliary_client import ( + AnthropicAuxiliaryClient, + resolve_provider_client, + ) + + plain = MagicMock(name="openai-client") + plain.api_key = "portal-invoke-jwt" + plain.base_url = PORTAL_URL + fake_anthropic = MagicMock(name="anthropic-sdk") + + with ( + patch( + "agent.auxiliary_client._try_nous", + return_value=(plain, "google/gemini-3-flash-preview"), + ), + patch( + "agent.anthropic_adapter.build_anthropic_client", + return_value=fake_anthropic, + ), + ): + client, model = resolve_provider_client( + "nous", "anthropic/claude-opus-4.8" + ) + + assert model == "anthropic/claude-opus-4.8" + assert isinstance(client, AnthropicAuxiliaryClient) + + def test_non_anthropic_catalog_model_stays_on_chat_completions(self): + from agent.auxiliary_client import ( + AnthropicAuxiliaryClient, + resolve_provider_client, + ) + + plain = MagicMock(name="openai-client") + plain.api_key = "portal-invoke-jwt" + plain.base_url = PORTAL_URL + + with ( + patch( + "agent.auxiliary_client._try_nous", + return_value=(plain, "hermes-4-405b"), + ), + patch( + "agent.anthropic_adapter.build_anthropic_client", + side_effect=AssertionError("must not build Anthropic client"), + ), + ): + client, model = resolve_provider_client("nous", "hermes-4-405b") + + assert model == "hermes-4-405b" + assert client is plain + assert not isinstance(client, AnthropicAuxiliaryClient) + + def test_stale_chat_completions_api_mode_cannot_keep_claude_on_openai_wire( + self, + ): + """Callers that still pass api_mode=chat_completions must not pin + Portal Claude on the OpenAI wire — the catalog id wins.""" + from agent.auxiliary_client import ( + AnthropicAuxiliaryClient, + resolve_provider_client, + ) + + plain = MagicMock(name="openai-client") + plain.api_key = "portal-invoke-jwt" + plain.base_url = PORTAL_URL + fake_anthropic = MagicMock(name="anthropic-sdk") + + with ( + patch( + "agent.auxiliary_client._try_nous", + return_value=(plain, "google/gemini-3-flash-preview"), + ), + patch( + "agent.anthropic_adapter.build_anthropic_client", + return_value=fake_anthropic, + ), + ): + client, _model = resolve_provider_client( + "nous", + "anthropic/claude-opus-4.8", + api_mode="chat_completions", + ) + + assert isinstance(client, AnthropicAuxiliaryClient) + + def test_build_call_kwargs_keeps_max_tokens_and_reasoning_for_portal_claude( + self, + ): + from agent.auxiliary_client import _build_call_kwargs + + kwargs = _build_call_kwargs( + "nous", + "anthropic/claude-opus-4.8", + [{"role": "user", "content": "hi"}], + max_tokens=512, + reasoning_config={"enabled": True, "effort": "low"}, + base_url=PORTAL_URL, + ) + + assert kwargs["max_tokens"] == 512 + assert kwargs["_reasoning_config"] == { + "enabled": True, + "effort": "low", + } + # Tags / session sticky key still land in extra_body for the adapter + # passthrough (product attribution + cache pinning). + assert "tags" in kwargs.get("extra_body", {}) + + def test_build_call_kwargs_includes_sticky_session_id(self): + """Aux Messages calls must pin session_id, not just tags.""" + from agent.auxiliary_client import _build_call_kwargs + from agent.portal_tags import ( + reset_conversation_context, + set_conversation_context, + ) + + token = set_conversation_context("sess-sticky-aux") + try: + # Alias spelling + profile stubbed out so the fallback path is the + # one under test (profile success already covers session_id). + with patch( + "providers.get_provider_profile", + side_effect=ImportError("no profile"), + ): + kwargs = _build_call_kwargs( + "nous-portal", + "anthropic/claude-opus-4.8", + [{"role": "user", "content": "hi"}], + max_tokens=64, + base_url=PORTAL_URL, + ) + finally: + reset_conversation_context(token) + + extra = kwargs.get("extra_body", {}) + assert "tags" in extra + assert extra.get("session_id") == "sess-sticky-aux" + + def test_strict_vision_backend_wraps_anthropic_catalog_models(self): + """Vision aux must not skip the dual-wire wrap that text aux gets.""" + from agent.auxiliary_client import ( + AnthropicAuxiliaryClient, + _resolve_strict_vision_backend, + ) + + plain = MagicMock(name="openai-client") + plain.api_key = "portal-invoke-jwt" + plain.base_url = PORTAL_URL + fake_anthropic = MagicMock(name="anthropic-sdk") + + with ( + patch( + "agent.auxiliary_client._try_nous", + return_value=(plain, "anthropic/claude-opus-4.8"), + ), + patch( + "agent.anthropic_adapter.build_anthropic_client", + return_value=fake_anthropic, + ), + ): + client, model = _resolve_strict_vision_backend( + "nous", "anthropic/claude-opus-4.8" + ) + + assert model == "anthropic/claude-opus-4.8" + assert isinstance(client, AnthropicAuxiliaryClient) + + def test_aux_create_forwards_portal_catalog_id_verbatim(self): + """Regression: adapter must pass base_url into build_anthropic_kwargs. + + Without it the Portal carve-out never fires and + ``anthropic/claude-opus-4.8`` is normalized to ``claude-opus-4-8``, + which Portal's Messages route cannot resolve. + """ + from agent.auxiliary_client import AnthropicAuxiliaryClient + + captured = {} + + def _fake_create(client, api_kwargs, **kwargs): + captured["model"] = api_kwargs.get("model") + return SimpleNamespace( + content=[], + stop_reason="end_turn", + usage=SimpleNamespace( + input_tokens=1, output_tokens=1, total_tokens=2 + ), + ) + + client = AnthropicAuxiliaryClient( + MagicMock(name="anthropic-sdk"), + "anthropic/claude-opus-4.8", + "portal-invoke-jwt", + PORTAL_URL, + ) + with patch( + "agent.anthropic_adapter.create_anthropic_message", + side_effect=_fake_create, + ): + client.chat.completions.create( + model="anthropic/claude-opus-4.8", + messages=[{"role": "user", "content": "hi"}], + ) + + assert captured["model"] == "anthropic/claude-opus-4.8" diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index 679a9219fbe9..4b8c1b7f78f7 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -343,6 +343,28 @@ class TestQwenAlibabaFamily: assert agent._anthropic_prompt_cache_policy() == (False, False) +class TestNousPortalAnthropicWire: + def test_portal_claude_on_the_messages_wire_uses_the_native_layout(self): + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="anthropic_messages", + model="anthropic/claude-opus-4.8", + ) + assert agent._anthropic_prompt_cache_policy() == (True, True) + + def test_portal_claude_on_chat_completions_keeps_the_envelope_layout(self): + """The wire, not the provider, picks the layout — Portal models still on + /chat/completions must not be flipped to inner-block markers.""" + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + api_mode="chat_completions", + model="anthropic/claude-opus-4.8", + ) + assert agent._anthropic_prompt_cache_policy() == (True, False) + + class TestExplicitOverrides: """Policy accepts keyword overrides for switch_model / fallback activation.""" diff --git a/tests/run_agent/test_anthropic_response_header_capture.py b/tests/run_agent/test_anthropic_response_header_capture.py new file mode 100644 index 000000000000..50815e692501 --- /dev/null +++ b/tests/run_agent/test_anthropic_response_header_capture.py @@ -0,0 +1,55 @@ +"""Anthropic Messages path must capture rate-limit + credits headers. + +Portal Claude moved off /chat/completions onto /v1/messages. The OpenAI-wire +streaming path captured both header families from ``stream.response``; the +Messages path must do the same via ``on_response`` or /status and the Nous +429 classifier lose last-known state. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from run_agent import AIAgent + + +def test_capture_anthropic_response_headers_forwards_to_both_captures(): + agent = object.__new__(AIAgent) + agent._capture_rate_limits = MagicMock() + agent._capture_credits = MagicMock() + + response = SimpleNamespace(headers={"x-ratelimit-remaining-requests": "10"}) + agent._capture_anthropic_response_headers(response) + + agent._capture_rate_limits.assert_called_once_with(response) + agent._capture_credits.assert_called_once_with(response) + + +def test_anthropic_messages_create_passes_combined_header_callback(): + agent = object.__new__(AIAgent) + agent.api_mode = "anthropic_messages" + agent._anthropic_client = MagicMock() + agent._disable_streaming = False + agent.log_prefix = "" + agent._try_refresh_anthropic_client_credentials = MagicMock(return_value=False) + agent._capture_anthropic_response_headers = MagicMock() + + captured = {} + + def _fake_create(client, api_kwargs, **kwargs): + captured.update(kwargs) + return SimpleNamespace(content=[], stop_reason="end_turn") + + import agent.anthropic_adapter as adapter + + original = adapter.create_anthropic_message + adapter.create_anthropic_message = _fake_create + try: + agent._anthropic_messages_create({"model": "anthropic/claude-opus-4.8"}) + finally: + adapter.create_anthropic_message = original + + assert ( + captured.get("on_response") is agent._capture_anthropic_response_headers + ) diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index c56c4897ffcb..f6efd93e02d7 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -518,8 +518,13 @@ class TestTryRecoverPrimaryTransport: ) assert result is False - def test_skipped_for_nous_provider(self): - agent = _make_agent(provider="nous", base_url="https://inference.nous.nousresearch.com/v1") + def test_skipped_for_nous_chat_completions(self): + """OpenAI-wire Portal traffic still rides aggregator retry infra.""" + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + ) + agent.api_mode = "chat_completions" error = _make_transport_error("ReadTimeout") result = agent._try_recover_primary_transport( @@ -527,6 +532,39 @@ class TestTryRecoverPrimaryTransport: ) assert result is False + def test_allowed_for_nous_anthropic_messages(self): + """Portal Claude holds a local Anthropic SDK client — rebuild it.""" + agent = _make_agent( + provider="nous", + base_url="https://inference-api.nousresearch.com/v1", + ) + agent.api_mode = "anthropic_messages" + agent.model = "anthropic/claude-opus-4.8" + agent._primary_runtime.update({ + "api_mode": "anthropic_messages", + "model": "anthropic/claude-opus-4.8", + "provider": "nous", + "anthropic_api_key": "portal-jwt", + "anthropic_base_url": "https://inference-api.nousresearch.com/v1", + "is_anthropic_oauth": False, + }) + error = _make_transport_error("ReadTimeout") + rebuilt = MagicMock(name="anthropic-client") + + with ( + patch( + "agent.anthropic_adapter.build_anthropic_client", + return_value=rebuilt, + ), + patch("time.sleep"), + ): + result = agent._try_recover_primary_transport( + error, retry_count=3, max_retries=3, + ) + + assert result is True + assert agent._anthropic_client is rebuilt + def test_allowed_for_anthropic_direct(self): """Direct Anthropic endpoint should get recovery.""" agent = _make_agent(provider="anthropic", base_url="https://api.anthropic.com") diff --git a/tests/run_agent/test_provider_fallback.py b/tests/run_agent/test_provider_fallback.py index efdd6ef38f26..361289e28962 100644 --- a/tests/run_agent/test_provider_fallback.py +++ b/tests/run_agent/test_provider_fallback.py @@ -211,6 +211,91 @@ class TestFallbackChainAdvancement: assert agent._try_activate_fallback() is True assert agent.api_mode == "anthropic_messages" + def test_nous_anthropic_fallback_uses_the_messages_wire(self): + """Portal Claude fallbacks must not stay on chat_completions. + + ``resolve_provider_client`` still returns an OpenAI client for Nous; + activation has to re-derive api_mode from the model and rebuild the + Anthropic client — otherwise the turn POSTs /chat/completions. + """ + portal = "https://inference-api.nousresearch.com/v1" + fbs = [ + { + "provider": "nous", + "model": "anthropic/claude-opus-4.8", + } + ] + agent = _make_agent(fallback_model=fbs) + rebuilt = {"count": 0} + + def _fake_build(api_key, base_url, timeout=None, **kwargs): + rebuilt["count"] += 1 + rebuilt["api_key"] = api_key + rebuilt["base_url"] = base_url + return MagicMock(name="anthropic-client") + + with ( + patch( + "agent.chat_completion_helpers._fallback_entry_unavailable_without_network", + return_value=None, + ), + patch( + "agent.auxiliary_client.resolve_provider_client", + return_value=( + _mock_client(base_url=portal, api_key="portal-jwt"), + "anthropic/claude-opus-4.8", + ), + ), + patch( + "hermes_cli.model_normalize.normalize_model_for_provider", + side_effect=lambda m, p: m, + ), + patch( + "agent.anthropic_adapter.build_anthropic_client", + side_effect=_fake_build, + ), + ): + assert agent._try_activate_fallback() is True + + assert agent.api_mode == "anthropic_messages" + assert agent.provider == "nous" + assert agent.model == "anthropic/claude-opus-4.8" + assert agent.client is None + assert rebuilt["count"] == 1 + assert rebuilt["api_key"] == "portal-jwt" + assert rebuilt["base_url"] == portal + assert agent._anthropic_client is not None + + def test_nous_non_anthropic_fallback_stays_on_chat_completions(self): + portal = "https://inference-api.nousresearch.com/v1" + fbs = [{"provider": "nous", "model": "hermes-4-405b"}] + agent = _make_agent(fallback_model=fbs) + with ( + patch( + "agent.chat_completion_helpers._fallback_entry_unavailable_without_network", + return_value=None, + ), + patch( + "agent.auxiliary_client.resolve_provider_client", + return_value=( + _mock_client(base_url=portal, api_key="portal-jwt"), + "hermes-4-405b", + ), + ), + patch( + "hermes_cli.model_normalize.normalize_model_for_provider", + side_effect=lambda m, p: m, + ), + patch( + "agent.anthropic_adapter.build_anthropic_client", + side_effect=AssertionError("must not build Anthropic client"), + ), + ): + assert agent._try_activate_fallback() is True + + assert agent.api_mode == "chat_completions" + assert agent.client is not None + # ── Pool-rotation vs fallback gating (#11314) ──────────────────────────── diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 24370703b3e6..f44dca8882e0 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -6613,6 +6613,68 @@ class TestNousCredentialRefresh: assert "default_headers" not in rebuilt["kwargs"] assert isinstance(agent.client, _RebuiltClient) + def test_try_refresh_nous_client_credentials_rebuilds_anthropic_client( + self, agent, monkeypatch + ): + """Portal anthropic/* sessions hold an Anthropic client, not OpenAI. + + A 401 on the Messages wire must refresh the invoke JWT into + ``_anthropic_api_key`` / ``_anthropic_base_url`` and rebuild that + client — swapping only ``agent.client`` would leave the turn stuck + on the expired Bearer token. + """ + agent.provider = "nous" + agent.api_mode = "anthropic_messages" + agent.model = "anthropic/claude-opus-4.8" + agent.api_key = "stale-nous-key" + agent.base_url = "https://inference-api.nousresearch.com/v1" + agent._anthropic_api_key = "stale-nous-key" + agent._anthropic_base_url = "https://inference-api.nousresearch.com/v1" + agent._client_kwargs = {} + agent.client = None + + captured = {} + rebuild_calls = {"count": 0} + + class _RebuiltAnthropic: + pass + + def _fake_resolve(**kwargs): + captured.update(kwargs) + return { + "api_key": "fresh-portal-jwt", + "base_url": "https://inference-api.nousresearch.com/v1", + } + + def _fake_rebuild(): + rebuild_calls["count"] += 1 + agent._anthropic_client = _RebuiltAnthropic() + + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_runtime_credentials", _fake_resolve + ) + monkeypatch.setattr(agent, "_rebuild_anthropic_client", _fake_rebuild) + monkeypatch.setattr( + agent, + "_replace_primary_openai_client", + MagicMock(side_effect=AssertionError("OpenAI client must not be rebuilt")), + ) + + ok = agent._try_refresh_nous_client_credentials(force=True) + + assert ok is True + assert captured["force_refresh"] is True + assert agent.api_key == "fresh-portal-jwt" + assert agent.base_url == "https://inference-api.nousresearch.com/v1" + assert agent._anthropic_api_key == "fresh-portal-jwt" + assert agent._anthropic_base_url == ( + "https://inference-api.nousresearch.com/v1" + ) + assert rebuild_calls["count"] == 1 + assert isinstance(agent._anthropic_client, _RebuiltAnthropic) + assert agent.client is None + agent._replace_primary_openai_client.assert_not_called() + class TestCredentialPoolRecovery: def test_recover_with_pool_rotates_on_402(self, agent): diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 48d5a629d871..7c0b763043ab 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -505,6 +505,56 @@ class TestDelegateTask(unittest.TestCase): self.assertEqual(kwargs["provider"], parent.provider) self.assertEqual(kwargs["api_mode"], parent.api_mode) + def test_nous_child_rederives_api_mode_from_model(self): + """Portal is dual-wire — same provider + different model prefix must + not inherit the parent's Messages/chat_completions mode verbatim.""" + parent = _make_mock_parent(depth=0) + parent.base_url = "https://inference-api.nousresearch.com/v1" + parent.api_key = "portal-jwt" + parent.provider = "nous" + parent.api_mode = "anthropic_messages" + parent.model = "anthropic/claude-opus-4.8" + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + MockAgent.return_value = mock_child + + _build_child_agent( + task_index=0, + goal="Stay on chat completions", + context=None, + toolsets=None, + model="hermes-4-405b", + max_iterations=10, + parent_agent=parent, + task_count=1, + ) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["provider"], "nous") + self.assertEqual(kwargs["model"], "hermes-4-405b") + self.assertEqual(kwargs["api_mode"], "chat_completions") + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + MockAgent.return_value = mock_child + parent.api_mode = "chat_completions" + parent.model = "hermes-4-405b" + + _build_child_agent( + task_index=0, + goal="Move onto Messages", + context=None, + toolsets=None, + model="anthropic/claude-opus-4.8", + max_iterations=10, + parent_agent=parent, + task_count=1, + ) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["api_mode"], "anthropic_messages") + def test_child_inherits_parent_print_fn(self): parent = _make_mock_parent(depth=0) sink = MagicMock() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 32726446aab9..7894a6af9bfc 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1377,9 +1377,20 @@ def _build_child_agent( # (e.g. MiniMax uses anthropic_messages, DeepSeek uses chat_completions). # Inheriting the parent's mode causes 404 errors when the child routes to the # wrong endpoint. Derive the mode from the target provider when it differs. + # + # Nous Portal is dual-wire within a single provider: anthropic/* → Messages, + # everything else → chat_completions. Same-provider inheritance would pin a + # child Hermes/Qwen subagent onto the parent's Claude Messages wire (or the + # reverse). agent_init honors an explicit api_mode above its nous branch, so + # re-derive here before construction. _parent_provider = getattr(parent_agent, "provider", None) or "" + _effective_provider_norm = (effective_provider or "").strip().lower() if override_api_mode is not None: effective_api_mode = override_api_mode + elif _effective_provider_norm in {"nous", "nous-portal", "nousresearch"}: + from hermes_cli.providers import nous_api_mode + + effective_api_mode = nous_api_mode(effective_model) elif effective_provider != _parent_provider: effective_api_mode = None # force re-derivation from provider's defaults else: