mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #72736 from rob-maron/nous-portal-anthropic-wire
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / JS & TS checks (push) Blocked by required conditions
CI / Desktop E2E (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / Check no committed infographics (push) Blocked by required conditions
CI / package-lock.json diff (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / Review label gate (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / CI review comment (live) (push) Blocked by required conditions
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Waiting to run
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Blocked by required conditions
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Docker Build, Test, and Publish / merge (push) Blocked by required conditions
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Waiting to run
auto-fix lint issues & formatting / Apply patch (push) Blocked by required conditions
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / JS & TS checks (push) Blocked by required conditions
CI / Desktop E2E (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / Check no committed infographics (push) Blocked by required conditions
CI / package-lock.json diff (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / Review label gate (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / CI review comment (live) (push) Blocked by required conditions
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Waiting to run
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Blocked by required conditions
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Docker Build, Test, and Publish / merge (push) Blocked by required conditions
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Waiting to run
auto-fix lint issues & formatting / Apply patch (push) Blocked by required conditions
Nous portal anthropic wire
This commit is contained in:
commit
2b0fb72aca
18 changed files with 1415 additions and 38 deletions
|
|
@ -645,6 +645,13 @@ def init_agent(
|
|||
# AWS Bedrock — auto-detect from provider name or base URL
|
||||
# (bedrock-runtime.<region>.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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <portal-jwt>`` —
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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/<slug>`` 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)
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
29
run_agent.py
29
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:
|
||||
|
|
|
|||
757
tests/agent/test_nous_portal_anthropic_wire.py
Normal file
757
tests/agent/test_nous_portal_anthropic_wire.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
55
tests/run_agent/test_anthropic_response_header_capture.py
Normal file
55
tests/run_agent/test_anthropic_response_header_capture.py
Normal file
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue