wipppppppppppppppppppppppppppppppp

This commit is contained in:
ethernet 2026-05-28 16:44:02 -04:00
parent 10421ec9ee
commit a08725e52a
78 changed files with 4848 additions and 2664 deletions

View File

@ -200,3 +200,22 @@ jobs:
- name: Run footgun checker
run: python scripts/check-windows-footguns.py --all
plugin-isolation:
# Enforce that core code and core tests never import from plugin packages.
# Core must interact with plugins exclusively through the registry layer.
# See scripts/check_no_plugin_imports_in_core.py for the rule list.
name: Plugin isolation (blocking)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5
with:
python-version: "3.11"
- name: Run plugin isolation checker
run: python scripts/check_no_plugin_imports_in_core.py

166
agent/anthropic_aux.py Normal file
View File

@ -0,0 +1,166 @@
"""Anthropic auxiliary client wrappers — core module, no SDK dependency.
Provides OpenAI-client-compatible shims over native Anthropic SDK clients,
so auxiliary tasks (compression, vision, web extract, etc.) can call
``client.chat.completions.create()`` regardless of the underlying SDK.
The wrapper classes themselves never import the anthropic SDK. They delegate
wire-format conversion to :mod:`agent.anthropic_format` and response
normalization to the ``anthropic_messages`` transport registered in
:mod:`agent.transports`.
"""
from __future__ import annotations
import asyncio
import logging
from types import SimpleNamespace
from typing import Any, Optional
from agent.anthropic_format import (
build_anthropic_kwargs,
_forbids_sampling_params,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Adapter: Anthropic SDK → OpenAI-compatible completions.create()
# ---------------------------------------------------------------------------
class _AnthropicCompletionsAdapter:
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
self._client = real_client
self._model = model
self._is_oauth = is_oauth
def create(self, **kwargs) -> Any:
from agent.transports import get_transport
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
tools = kwargs.get("tools")
tool_choice = kwargs.get("tool_choice")
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
# models (glm-4v-flash etc.) with error code 1210. When the caller
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
if _skip_mt:
max_tokens = None
else:
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
temperature = kwargs.get("temperature")
normalized_tool_choice = None
if isinstance(tool_choice, str):
normalized_tool_choice = tool_choice
elif isinstance(tool_choice, dict):
choice_type = str(tool_choice.get("type", "")).lower()
if choice_type == "function":
normalized_tool_choice = tool_choice.get("function", {}).get("name")
elif choice_type in {"auto", "required", "none"}:
normalized_tool_choice = choice_type
anthropic_kwargs = build_anthropic_kwargs(
model=model,
messages=messages,
tools=tools,
max_tokens=max_tokens,
reasoning_config=None,
tool_choice=normalized_tool_choice,
is_oauth=self._is_oauth,
)
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
# temperature for models that still accept it. build_anthropic_kwargs
# additionally strips these keys as a safety net — keep both layers.
if temperature is not None:
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
response = self._client.messages.create(**anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
response, strip_tool_prefix=self._is_oauth
)
assistant_message = SimpleNamespace(
content=_nr.content,
tool_calls=_nr.tool_calls,
reasoning=_nr.reasoning,
)
finish_reason = _nr.finish_reason
usage = None
if hasattr(response, "usage") and response.usage:
prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
usage = SimpleNamespace(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
)
choice = SimpleNamespace(
index=0,
message=assistant_message,
finish_reason=finish_reason,
)
return SimpleNamespace(
choices=[choice],
model=model,
usage=usage,
)
class _AnthropicChatShim:
def __init__(self, adapter: _AnthropicCompletionsAdapter):
self.completions = adapter
# ---------------------------------------------------------------------------
# Public wrappers
# ---------------------------------------------------------------------------
class AnthropicAuxiliaryClient:
"""OpenAI-client-compatible wrapper over a native Anthropic client."""
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)
self.chat = _AnthropicChatShim(adapter)
self.api_key = api_key
self.base_url = base_url
def close(self):
close_fn = getattr(self._real_client, "close", None)
if callable(close_fn):
close_fn()
class _AsyncAnthropicCompletionsAdapter:
def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
self._sync = sync_adapter
async def create(self, **kwargs) -> Any:
return await asyncio.to_thread(self._sync.create, **kwargs)
class _AsyncAnthropicChatShim:
def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
self.completions = adapter
class AsyncAnthropicAuxiliaryClient:
def __init__(self, sync_wrapper: AnthropicAuxiliaryClient):
sync_adapter = sync_wrapper.chat.completions
async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
self.chat = _AsyncAnthropicChatShim(async_adapter)
self.api_key = sync_wrapper.api_key
self.base_url = sync_wrapper.base_url
# Mirror _real_client so cache eviction on a poisoned underlying
# client also drops this entry.
self._real_client = sync_wrapper._real_client

1319
agent/anthropic_format.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -106,6 +106,41 @@ from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Core anthropic wire-format modules (no SDK dependency)
# ---------------------------------------------------------------------------
from agent.anthropic_aux import ( # noqa: F401
AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient,
)
# ---------------------------------------------------------------------------
# Plugin-registry helper — access *plugin-provided* anthropic services
# (resolve.py functions: maybe_wrap_anthropic, is_anthropic_compat_endpoint, etc.)
# Wire-format code (message conversion, aux client wrappers) lives in core
# and is imported directly above.
# ---------------------------------------------------------------------------
def _anthropic_plugin_service(name: str):
"""Lazy accessor for anthropic plugin resolve services.
Only the SDK-dependent orchestration (maybe_wrap_anthropic,
is_anthropic_compat_endpoint, convert_openai_images_to_anthropic) lives
in the plugin. Core accesses it through
``registries.get_provider_service("anthropic", name)`` so that:
- Core never imports from a plugin package directly.
- The plugin need only be installed when the user actually uses it.
"""
from agent.plugin_registries import registries
svc = registries.get_provider_service("anthropic", name)
if svc is None:
raise ImportError(
f"anthropic plugin service {name!r} not available — "
f"the hermes_agent_anthropic package may not be installed"
)
return svc
def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
"""Return False instead of raising when a patched symbol is not a type."""
@ -427,7 +462,6 @@ auxiliary_is_nous: bool = False
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
_NOUS_MODEL = "google/gemini-3-flash-preview"
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
_AUTH_JSON_PATH = get_hermes_home() / "auth.json"
# Codex OAuth endpoint used when a caller explicitly requests
@ -962,255 +996,6 @@ class AsyncCodexAuxiliaryClient:
self._real_client = sync_wrapper._real_client
class _AnthropicCompletionsAdapter:
"""OpenAI-client-compatible adapter for Anthropic Messages API."""
def __init__(self, real_client: Any, model: str, is_oauth: bool = False):
self._client = real_client
self._model = model
self._is_oauth = is_oauth
def create(self, **kwargs) -> Any:
from agent.plugin_registries import registries
build_anthropic_kwargs = registries.get_provider_service("anthropic", "build_anthropic_kwargs")
from agent.transports import get_transport
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
tools = kwargs.get("tools")
tool_choice = kwargs.get("tool_choice")
# ZAI's Anthropic-compatible endpoint rejects max_tokens on vision
# models (glm-4v-flash etc.) with error code 1210. When the caller
# signals this by setting _skip_zai_max_tokens in kwargs, omit it.
_skip_mt = kwargs.pop("_skip_zai_max_tokens", False)
if _skip_mt:
max_tokens = None
else:
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens") or 2000
temperature = kwargs.get("temperature")
normalized_tool_choice = None
if isinstance(tool_choice, str):
normalized_tool_choice = tool_choice
elif isinstance(tool_choice, dict):
choice_type = str(tool_choice.get("type", "")).lower()
if choice_type == "function":
normalized_tool_choice = tool_choice.get("function", {}).get("name")
elif choice_type in {"auto", "required", "none"}:
normalized_tool_choice = choice_type
anthropic_kwargs = build_anthropic_kwargs(
model=model,
messages=messages,
tools=tools,
max_tokens=max_tokens,
reasoning_config=None,
tool_choice=normalized_tool_choice,
is_oauth=self._is_oauth,
)
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
# temperature for models that still accept it. build_anthropic_kwargs
# additionally strips these keys as a safety net — keep both layers.
if temperature is not None:
_forbids_sampling_params = registries.get_provider_service("anthropic", "_forbids_sampling_params")
if _forbids_sampling_params is None or not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
response = self._client.messages.create(**anthropic_kwargs)
_transport = get_transport("anthropic_messages")
_nr = _transport.normalize_response(
response, strip_tool_prefix=self._is_oauth
)
# ToolCall already duck-types as OpenAI shape (.type, .function.name,
# .function.arguments) via properties, so no wrapping needed.
assistant_message = SimpleNamespace(
content=_nr.content,
tool_calls=_nr.tool_calls,
reasoning=_nr.reasoning,
)
finish_reason = _nr.finish_reason
usage = None
if hasattr(response, "usage") and response.usage:
prompt_tokens = getattr(response.usage, "input_tokens", 0) or 0
completion_tokens = getattr(response.usage, "output_tokens", 0) or 0
total_tokens = getattr(response.usage, "total_tokens", 0) or (prompt_tokens + completion_tokens)
usage = SimpleNamespace(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
)
choice = SimpleNamespace(
index=0,
message=assistant_message,
finish_reason=finish_reason,
)
return SimpleNamespace(
choices=[choice],
model=model,
usage=usage,
)
class _AnthropicChatShim:
def __init__(self, adapter: _AnthropicCompletionsAdapter):
self.completions = adapter
class AnthropicAuxiliaryClient:
"""OpenAI-client-compatible wrapper over a native Anthropic client."""
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)
self.chat = _AnthropicChatShim(adapter)
self.api_key = api_key
self.base_url = base_url
def close(self):
close_fn = getattr(self._real_client, "close", None)
if callable(close_fn):
close_fn()
class _AsyncAnthropicCompletionsAdapter:
def __init__(self, sync_adapter: _AnthropicCompletionsAdapter):
self._sync = sync_adapter
async def create(self, **kwargs) -> Any:
import asyncio
return await asyncio.to_thread(self._sync.create, **kwargs)
class _AsyncAnthropicChatShim:
def __init__(self, adapter: _AsyncAnthropicCompletionsAdapter):
self.completions = adapter
class AsyncAnthropicAuxiliaryClient:
def __init__(self, sync_wrapper: "AnthropicAuxiliaryClient"):
sync_adapter = sync_wrapper.chat.completions
async_adapter = _AsyncAnthropicCompletionsAdapter(sync_adapter)
self.chat = _AsyncAnthropicChatShim(async_adapter)
self.api_key = sync_wrapper.api_key
self.base_url = sync_wrapper.base_url
# See AsyncCodexAuxiliaryClient: mirror _real_client so cache
# eviction on a poisoned underlying client also drops this entry.
self._real_client = sync_wrapper._real_client
def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""True if the endpoint at ``base_url`` speaks the Anthropic Messages
protocol instead of OpenAI chat.completions.
Mirrors ``hermes_cli.runtime_provider._detect_api_mode_for_url`` so the
auxiliary client and the main agent stay in sync on transport selection.
Covers:
- Any URL ending in ``/anthropic`` (MiniMax, Zhipu GLM, LiteLLM proxies,
Anthropic-compatible gateways).
- ``api.kimi.com/coding`` (Kimi Coding Plan the /coding route only
speaks Claude-Code's native Anthropic shape; ``chat.completions``
returns 404 on Anthropic-only model aliases like ``kimi-for-coding``).
- ``api.anthropic.com`` (native Anthropic).
"""
normalized = (base_url or "").strip().lower().rstrip("/")
if not normalized:
return False
if normalized.endswith("/anthropic"):
return True
hostname = base_url_hostname(normalized)
if hostname == "api.anthropic.com":
return True
if hostname == "api.kimi.com" and "/coding" in normalized:
return True
return False
def _maybe_wrap_anthropic(
client_obj: Any,
model: str,
api_key: str,
base_url: str,
api_mode: Optional[str] = None,
) -> Any:
"""Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when
the endpoint actually speaks Anthropic Messages.
This is the single chokepoint for aux-client transport correction.
Runs at the end of every ``resolve_provider_client`` branch so that
api_key providers (Kimi Coding Plan), the ``custom`` endpoint, and
future /anthropic gateways all land on the right wire format
regardless of which branch built the client.
Returns ``client_obj`` unchanged when:
- It's already an Anthropic/Codex/Gemini/CopilotACP wrapper.
- The endpoint is an OpenAI-wire endpoint.
- ``api_mode`` is explicitly set to a non-Anthropic transport.
- The ``anthropic`` SDK is not installed (falls back to OpenAI wire).
"""
# Already wrapped — don't double-wrap.
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
return client_obj
# Other specialized adapters we should never re-dispatch.
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
try:
from agent.gemini_native_adapter import GeminiNativeClient
if _safe_isinstance(client_obj, GeminiNativeClient):
return client_obj
except ImportError:
pass
try:
from agent.copilot_acp_client import CopilotACPClient
if _safe_isinstance(client_obj, CopilotACPClient):
return client_obj
except ImportError:
pass
# Explicit non-anthropic api_mode wins over URL heuristics.
if api_mode and api_mode != "anthropic_messages":
return client_obj
should_wrap = (
api_mode == "anthropic_messages"
or _endpoint_speaks_anthropic_messages(base_url)
)
if not should_wrap:
return client_obj
try:
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
except ImportError:
logger.warning(
"Endpoint %s speaks Anthropic Messages but the anthropic SDK is "
"not installed — falling back to OpenAI-wire (will likely 404).",
base_url,
)
return client_obj
try:
real_client = build_anthropic_client(api_key, base_url)
except Exception as exc:
logger.warning(
"Failed to build Anthropic client for %s (%s) — falling back to "
"OpenAI-wire client.", base_url, exc,
)
return client_obj
logger.debug(
"Auxiliary transport: wrapping client in AnthropicAuxiliaryClient "
"(model=%s, base_url=%s, api_mode=%s)",
model, base_url[:60] if base_url else "", api_mode or "auto-detected",
)
return AnthropicAuxiliaryClient(
real_client, model, api_key, base_url, is_oauth=False,
)
def _read_nous_auth() -> Optional[dict]:
"""Read and validate ~/.hermes/auth.json for an active Nous provider.
@ -1421,7 +1206,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
continue
except ImportError:
pass
return _try_anthropic()
# Delegate to the anthropic plugin resolver via the registry
from agent.plugin_registries import registries as _ar
_anthro_resolver = _ar.get_provider_resolver("anthropic")
if _anthro_resolver is not None:
_ac, _am = _anthro_resolver()
if _ac is not None:
return _ac, _am
continue
pool_present, entry = _select_pool_entry(provider_id)
if pool_present:
@ -1458,7 +1250,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
except Exception:
pass
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
return _client, model
creds = resolve_api_key_provider_credentials(provider_id)
@ -1495,7 +1287,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
except Exception:
pass
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
_client = _anthropic_plugin_service("maybe_wrap_anthropic")(_client, model, api_key, raw_base_url)
return _client, model
return None, None
@ -1504,7 +1296,6 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
# ── Provider resolution helpers ─────────────────────────────────────────────
def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Optional[OpenAI], Optional[str]]:
pool_present, entry = _select_pool_entry("openrouter")
if pool_present:
@ -1845,7 +1636,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
# URL-based anthropic detection for custom endpoints that didn't set
# api_mode explicitly (e.g. kimi.com/coding reached via custom config).
_fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
_fallback_client = _maybe_wrap_anthropic(
_fallback_client = _anthropic_plugin_service("maybe_wrap_anthropic")(
_fallback_client, model, custom_key, custom_base, custom_mode,
)
return _fallback_client, model
@ -2023,7 +1814,7 @@ def _try_azure_foundry(
# for Entra ID it's a callable. ``_maybe_wrap_anthropic`` →
# ``build_anthropic_client`` detects the callable and installs
# the bearer-injecting httpx hook.
return _maybe_wrap_anthropic(
return _anthropic_plugin_service("maybe_wrap_anthropic")(
client, final_model, api_key,
base_url, runtime_api_mode,
), final_model
@ -2032,72 +1823,6 @@ def _try_azure_foundry(
return client, final_model
def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optional[str]]:
from agent.plugin_registries import registries
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
if build_anthropic_client is None or resolve_anthropic_token is None:
# Registry empty (e.g. unit tests without plugin loading) — use module directly
# so that mock.patch("hermes_agent_anthropic.adapter.X") still intercepts calls.
try:
import hermes_agent_anthropic.adapter as _ant_mod # type: ignore[import]
class _ModuleNamespace:
"""Proxy that delegates .get() to module attribute lookup (live, patchable)."""
def get(self, name: str, default=None):
return getattr(_ant_mod, name, default)
_anthropic = _ModuleNamespace()
build_anthropic_client = _anthropic.get("build_anthropic_client")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
if build_anthropic_client is None or resolve_anthropic_token is None:
return None, None
except ImportError:
return None, None
pool_present, entry = _select_pool_entry("anthropic")
if pool_present:
if entry is None:
return None, None
token = explicit_api_key or _pool_runtime_api_key(entry)
else:
entry = None
token = explicit_api_key or resolve_anthropic_token()
if not token:
return None, None
# Allow base URL override from config.yaml model.base_url, but only
# when the configured provider is anthropic — otherwise a non-Anthropic
# base_url (e.g. Codex endpoint) would leak into Anthropic requests.
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model")
if isinstance(model_cfg, dict):
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_provider == "anthropic":
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
base_url = cfg_base_url
except Exception:
pass
_is_oauth_token = _anthropic.get("_is_oauth_token")
is_oauth = _is_oauth_token(token) if _is_oauth_token else False
model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
try:
real_client = build_anthropic_client(token, base_url)
except ImportError:
# The anthropic_adapter module imports fine but the SDK itself is
# missing — build_anthropic_client raises ImportError at call time
# when _anthropic_sdk is None. Treat as unavailable.
return None, None
return AnthropicAuxiliaryClient(real_client, model, token, base_url, is_oauth=is_oauth), model
_AUTO_PROVIDER_LABELS = {
"_try_openrouter": "openrouter",
"_try_nous": "nous",
@ -2647,8 +2372,8 @@ def _retry_same_provider_sync(
extra_body=effective_extra_body,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, retry_base):
retry_kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(retry_kwargs["messages"])
return _validate_llm_response(
retry_client.chat.completions.create(**retry_kwargs), task,
)
@ -2704,8 +2429,8 @@ async def _retry_same_provider_async(
extra_body=effective_extra_body,
base_url=retry_base or resolved_base_url,
)
if _is_anthropic_compat_endpoint(resolved_provider, retry_base):
retry_kwargs["messages"] = _convert_openai_images_to_anthropic(retry_kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, retry_base):
retry_kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(retry_kwargs["messages"])
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task,
)
@ -3072,7 +2797,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
if isinstance(sync_client, CodexAuxiliaryClient):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
if _safe_isinstance(sync_client, AnthropicAuxiliaryClient):
return AsyncAnthropicAuxiliaryClient(sync_client), model
try:
from agent.gemini_native_adapter import GeminiNativeClient, AsyncGeminiNativeClient
@ -3258,7 +2983,7 @@ def resolve_provider_client(
return CodexAuxiliaryClient(client_obj, final_model_str)
# Anthropic-wire endpoints: rewrap plain OpenAI clients so
# chat.completions.create() is translated to /v1/messages.
return _maybe_wrap_anthropic(
return _anthropic_plugin_service("maybe_wrap_anthropic")(
client_obj, final_model_str, api_key_str, base_url_str, api_mode,
)
@ -3537,39 +3262,32 @@ def resolve_provider_client(
except ImportError:
pass
# ── Azure Foundry (delegates to runtime resolver for auth_mode-aware routing) ─
#
# The generic PROVIDER_REGISTRY path below uses
# ``resolve_api_key_provider_credentials`` which only knows about the
# static ``AZURE_FOUNDRY_API_KEY`` env var. That misses two important
# cases for the ``azure-foundry`` provider:
#
# 1. ``model.auth_mode: entra_id`` — no static key exists; we need
# a callable bearer-token provider from ``azure_identity_adapter``.
# 2. Non-default ``model.base_url`` (Foundry projects path) — the
# env-var-only resolver doesn't apply config-yaml-driven URL
# overrides.
#
# Delegate to the same runtime resolver the main agent uses so
# auxiliary tasks (title generation, compression, vision, embedding,
# session search) inherit the user's full Azure config.
if provider == "azure-foundry":
client, default_model = _try_azure_foundry(
# ── Plugin-registered resolvers (azure-foundry, etc.) ──────────────
# Providers with complex auth (Entra ID, OAuth, etc.) register a
# resolver callable so core doesn't need per-provider if/elif branches.
from agent.plugin_registries import registries as _reg_early
_early_resolver = _reg_early.get_provider_resolver(provider)
if _early_resolver is not None:
client, default_model = _early_resolver(
model=model,
explicit_api_key=explicit_api_key,
explicit_base_url=explicit_base_url,
async_mode=async_mode,
is_vision=is_vision,
main_runtime=main_runtime,
api_mode=api_mode,
)
if client is None:
logger.warning(
"resolve_provider_client: azure-foundry requested but "
"runtime resolution failed (run: hermes doctor for "
"diagnostics)"
)
return None, None
final_model = _normalize_resolved_model(model or default_model, provider)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
if client is not None:
final_model = _normalize_resolved_model(model or default_model, provider)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
# Resolver returned None — provider unavailable
logger.warning(
"resolve_provider_client: %s requested but resolver returned "
"no client (run: hermes doctor for diagnostics)",
provider,
)
return None, None
# ── API-key providers from PROVIDER_REGISTRY ─────────────────────
try:
@ -3588,14 +3306,6 @@ def resolve_provider_client(
return None, None
if pconfig.auth_type == "api_key":
if provider == "anthropic":
client, default_model = _try_anthropic(explicit_api_key=explicit_api_key)
if client is None:
logger.warning("resolve_provider_client: anthropic requested but no Anthropic credentials found")
return None, None
final_model = _normalize_resolved_model(model or default_model, provider)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode else (client, final_model))
creds = resolve_api_key_provider_credentials(provider)
api_key = str(creds.get("api_key", "")).strip()
# Honour an explicit api_key override (e.g. from a fallback_model entry
@ -3729,43 +3439,14 @@ def resolve_provider_client(
return None, None
elif pconfig.auth_type == "aws_sdk":
# AWS SDK providers (Bedrock) — use the Anthropic Bedrock client via
# boto3's credential chain (IAM roles, SSO, env vars, instance metadata).
try:
from agent.plugin_registries import registries
_bedrock = registries.get_provider_namespace("bedrock")
_anthropic = registries.get_provider_namespace("anthropic")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
build_anthropic_bedrock_client = _anthropic.get("build_anthropic_bedrock_client")
if has_aws_credentials is None or resolve_bedrock_region is None or build_anthropic_bedrock_client is None:
raise ImportError("bedrock or anthropic provider not registered")
except ImportError:
logger.warning("resolve_provider_client: bedrock requested but "
"boto3 or anthropic SDK not installed")
return None, None
if not has_aws_credentials():
logger.debug("resolve_provider_client: bedrock requested but "
"no AWS credentials found")
return None, None
region = resolve_bedrock_region()
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
final_model = _normalize_resolved_model(model or default_model, provider)
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
# AWS SDK providers (e.g. Bedrock) — handled by the early resolver
# catch above when a plugin registers one. If we reach here, no
# resolver was registered.
logger.warning(
"resolve_provider_client: aws_sdk provider %s has no "
"registered resolver (plugin not loaded?)", provider,
)
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
return None, None
elif pconfig.auth_type in {"oauth_device_code", "oauth_external"}:
# OAuth providers — route through their specific try functions
@ -3889,7 +3570,12 @@ def _resolve_strict_vision_backend(
# allow-list); callers must specify via auxiliary.<task>.model.
return resolve_provider_client("openai-codex", model, is_vision=True)
if provider == "anthropic":
return _try_anthropic()
from agent.plugin_registries import registries as _reg
_resolver = _reg.get_provider_resolver("anthropic")
if _resolver is not None:
return _resolver(model=model)
# Fallback: no resolver registered (plugin not loaded)
return None, None
if provider == "custom":
return _try_custom_endpoint()
return None, None
@ -4619,69 +4305,6 @@ def _get_task_extra_body(task: str) -> Dict[str, Any]:
# Providers that use Anthropic-compatible endpoints (via OpenAI SDK wrapper).
# Their image content blocks must use Anthropic format, not OpenAI format.
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
def _is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
"""Detect if an endpoint expects Anthropic-format content blocks.
Returns True for known Anthropic-compatible providers (MiniMax) and
any endpoint whose URL contains ``/anthropic`` in the path.
"""
if provider in _ANTHROPIC_COMPAT_PROVIDERS:
return True
url_lower = (base_url or "").lower()
return "/anthropic" in url_lower
def _convert_openai_images_to_anthropic(messages: list) -> list:
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks.
Only touches messages that have list-type content with ``image_url`` blocks;
plain text messages pass through unchanged.
"""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_content = []
changed = False
for block in content:
if block.get("type") == "image_url":
image_url_val = (block.get("image_url") or {}).get("url", "")
if image_url_val.startswith("data:"):
# Parse data URI: data:<media_type>;base64,<data>
header, _, b64data = image_url_val.partition(",")
media_type = "image/png"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
# URL-based image
new_content.append({
"type": "image",
"source": {
"type": "url",
"url": image_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)
return converted
def _build_call_kwargs(
provider: str,
model: str,
@ -4926,8 +4549,8 @@ def call_llm(
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
_client_base = str(getattr(client, "base_url", "") or "")
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
# then payment fallback.
@ -5334,8 +4957,8 @@ async def async_call_llm(
base_url=_client_base or resolved_base_url)
# Convert image blocks for Anthropic-compatible endpoints (e.g. MiniMax)
if _is_anthropic_compat_endpoint(resolved_provider, _client_base):
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
if _anthropic_plugin_service("is_anthropic_compat_endpoint")(resolved_provider, _client_base):
kwargs["messages"] = _anthropic_plugin_service("convert_openai_images_to_anthropic")(kwargs["messages"])
try:
return _validate_llm_response(

View File

@ -458,44 +458,6 @@ class CredentialPool:
self._persist()
return updated
def _sync_anthropic_entry_from_credentials_file(self, entry: PooledCredential) -> PooledCredential:
"""Sync a claude_code pool entry from ~/.claude/.credentials.json if tokens differ.
OAuth refresh tokens are single-use. When something external (e.g.
Claude Code CLI, or another profile's pool) refreshes the token, it
writes the new pair to ~/.claude/.credentials.json. The pool entry's
refresh token becomes stale. This method detects that and syncs.
"""
if self.provider != "anthropic" or entry.source != "claude_code":
return entry
try:
from agent.plugin_registries import registries
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
creds = read_claude_code_credentials()
if not creds:
return entry
file_refresh = creds.get("refreshToken", "")
file_access = creds.get("accessToken", "")
file_expires = creds.get("expiresAt", 0)
# If the credentials file has a different token pair, sync it
if file_refresh and file_refresh != entry.refresh_token:
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
updated = replace(
entry,
access_token=file_access,
refresh_token=file_refresh,
expires_at_ms=file_expires,
last_status=None,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(entry, updated)
self._persist()
return updated
except Exception as exc:
logger.debug("Failed to sync from credentials file: %s", exc)
return entry
def _sync_codex_entry_from_auth_store(self, entry: PooledCredential) -> PooledCredential:
"""Sync a Codex device_code pool entry from auth.json if tokens differ.
@ -785,33 +747,11 @@ class CredentialPool:
return None
try:
if self.provider == "anthropic":
from agent.plugin_registries import registries
refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure")
refreshed = refresh_anthropic_oauth_pure(
entry.refresh_token,
use_json=entry.source.endswith("hermes_pkce"),
)
updated = replace(
entry,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
)
# Keep ~/.claude/.credentials.json in sync so that the
# fallback path (resolve_anthropic_token) and other profiles
# see the latest tokens.
if entry.source == "claude_code":
try:
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
# ── Plugin-registered credential pool hooks ──
from agent.plugin_registries import registries as _cph_reg2
_hook = _cph_reg2.get_credential_pool_hook(self.provider)
if _hook is not None and _hook.refresh_oauth is not None:
updated = _hook.refresh_oauth(entry, pool=self)
elif self.provider == "openai-codex":
# Adopt fresher tokens from auth.json before spending the
# refresh_token — single-use tokens consumed by another Hermes
@ -866,47 +806,18 @@ class CredentialPool:
return entry
except Exception as exc:
logger.debug("Credential refresh failed for %s/%s: %s", self.provider, entry.id, exc)
# For anthropic claude_code entries: the refresh token may have been
# consumed by another process. Check if ~/.claude/.credentials.json
# has a newer token pair and retry once.
if self.provider == "anthropic" and entry.source == "claude_code":
synced = self._sync_anthropic_entry_from_credentials_file(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying refresh with synced token from credentials file")
try:
from agent.plugin_registries import registries
refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure")
refreshed = refresh_anthropic_oauth_pure(
synced.refresh_token,
use_json=synced.source.endswith("hermes_pkce"),
)
updated = replace(
synced,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
last_status=STATUS_OK,
last_status_at=None,
last_error_code=None,
)
self._replace_entry(synced, updated)
self._persist()
try:
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file (retry path): %s", wexc)
return updated
except Exception as retry_exc:
logger.debug("Retry refresh also failed: %s", retry_exc)
elif not self._entry_needs_refresh(synced):
# Credentials file had a valid (non-expired) token — use it directly
logger.debug("Credentials file has valid token, using without refresh")
return synced
# ── Plugin-registered credential pool hooks ──
# The hook's refresh_oauth already handles retry-with-sync internally,
# so if we got here it means a non-hook provider failed.
from agent.plugin_registries import registries as _cph_reg3
_hook = _cph_reg3.get_credential_pool_hook(self.provider)
if _hook is not None and _hook.sync_from_credentials_file is not None:
# Give the hook a chance to sync from external file
synced = _hook.sync_from_credentials_file(entry)
if synced is not entry:
entry = synced
self._replace_entry(entry, synced)
self._persist()
# For xai-oauth: same race as nous — another process may have
# consumed the refresh token between our proactive sync and the
# HTTP call. Re-check auth.json and adopt the fresh tokens if
@ -1127,10 +1038,11 @@ class CredentialPool:
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if entry.auth_type != AUTH_TYPE_OAUTH:
return False
if self.provider == "anthropic":
if entry.expires_at_ms is None:
return False
return int(entry.expires_at_ms) <= int(time.time() * 1000) + 120_000
# ── Plugin-registered credential pool hooks ──
from agent.plugin_registries import registries as _cph_reg
_hook = _cph_reg.get_credential_pool_hook(self.provider)
if _hook is not None and _hook.needs_refresh is not None:
return _hook.needs_refresh(entry)
if self.provider == "openai-codex":
return _codex_access_token_is_expiring(
entry.access_token,
@ -1163,12 +1075,16 @@ class CredentialPool:
cleared_any = False
available: List[PooledCredential] = []
for entry in self._entries:
# For anthropic claude_code entries, sync from the credentials file
# before any status/refresh checks. This picks up tokens refreshed
# by other processes (Claude Code CLI, other Hermes profiles).
if (self.provider == "anthropic" and entry.source == "claude_code"
# ── Plugin-registered credential pool hooks ──
# Sync exhausted entries from external credentials files before
# status/refresh checks. This picks up tokens refreshed by other
# processes (e.g. Claude Code CLI, other Hermes profiles).
from agent.plugin_registries import registries as _cph_reg4
_avail_hook = _cph_reg4.get_credential_pool_hook(self.provider)
if (_avail_hook is not None
and _avail_hook.sync_from_credentials_file is not None
and entry.last_status == STATUS_EXHAUSTED):
synced = self._sync_anthropic_entry_from_credentials_file(entry)
synced = _avail_hook.sync_from_credentials_file(entry)
if synced is not entry:
entry = synced
cleared_any = True
@ -1518,44 +1434,15 @@ def _seed_from_singletons(provider: str, entries: List[PooledCredential]) -> Tup
def _is_suppressed(_p, _s): # type: ignore[misc]
return False
if provider == "anthropic":
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
# when the user has explicitly configured anthropic as their provider.
# Without this gate, auxiliary client fallback chains silently read
# ~/.claude/.credentials.json without user consent. See PR #4210.
try:
from hermes_cli.auth import is_provider_explicitly_configured
if not is_provider_explicitly_configured("anthropic"):
return changed, active_sources
except ImportError:
pass
from agent.plugin_registries import registries
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
read_hermes_oauth_credentials = registries.get_provider_service("anthropic", "read_hermes_oauth_credentials")
for source_name, creds in (
("hermes_pkce", read_hermes_oauth_credentials()),
("claude_code", read_claude_code_credentials()),
):
if creds and creds.get("accessToken"):
if _is_suppressed(provider, source_name):
continue
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
provider,
source_name,
{
"source": source_name,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": creds.get("accessToken", ""),
"refresh_token": creds.get("refreshToken"),
"expires_at_ms": creds.get("expiresAt"),
"label": label_from_token(creds.get("accessToken", ""), source_name),
},
)
# ── Plugin-registered credential pool hooks ──
from agent.plugin_registries import registries as _cp_reg
_cp_hook = _cp_reg.get_credential_pool_hook(provider)
if _cp_hook is not None and _cp_hook.discover_credentials is not None:
hook_changed, hook_sources = _cp_hook.discover_credentials(
entries, provider, _is_suppressed,
)
changed |= hook_changed
active_sources |= hook_sources
elif provider == "nous":
state = _load_provider_state(auth_store, "nous")
has_runtime_material = bool(
@ -1866,12 +1753,11 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
env_url = _get_env_prefer_dotenv(pconfig.base_url_env_var).rstrip("/")
env_vars = list(pconfig.api_key_env_vars)
if provider == "anthropic":
env_vars = [
"ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
]
# ── Plugin-registered credential pool hooks: env var order override ──
from agent.plugin_registries import registries as _env_reg
_env_hook = _env_reg.get_credential_pool_hook(provider)
if _env_hook is not None and _env_hook.env_var_order is not None:
env_vars = _env_hook.env_var_order
for env_var in env_vars:
# Prefer ~/.hermes/.env over os.environ
@ -1882,7 +1768,11 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
if _is_source_suppressed(provider, source):
continue
active_sources.add(source)
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
# ── Plugin-registered credential pool hooks: auth type detection ──
if _env_hook is not None and _env_hook.detect_auth_type is not None:
auth_type = _env_hook.detect_auth_type(token)
else:
auth_type = AUTH_TYPE_API_KEY
base_url = env_url or pconfig.inference_base_url
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)

View File

@ -19,6 +19,7 @@ from typing import (
Optional,
Protocol,
Sequence,
Tuple,
Type,
runtime_checkable,
)
@ -218,6 +219,131 @@ class CredentialPoolEntry:
"""Read OAuth credentials."""
# ---------------------------------------------------------------------------
# Provider resolvers
# ---------------------------------------------------------------------------
@runtime_checkable
class ProviderResolver(Protocol):
"""A plugin that resolves an auxiliary client for a specific provider.
Registered via ``ctx.register_provider_resolver(provider_name, resolver)``.
Queried by ``agent/auxiliary_client.py`` in ``resolve_provider_client()``.
"""
def __call__(
self,
*,
model: str | None = None,
explicit_api_key: str | None = None,
explicit_base_url: str | None = None,
async_mode: bool = False,
is_vision: bool = False,
main_runtime: dict | None = None,
api_mode: str | None = None,
) -> tuple[Any, str] | tuple[None, None]:
"""Return ``(client, default_model)`` or ``(None, None)`` if unavailable."""
...
# ---------------------------------------------------------------------------
# Credential pool hooks
# ---------------------------------------------------------------------------
@dataclass
class CredentialPoolHook:
"""Provider-specific credential pool operations.
Registered via ``ctx.register_credential_pool_hook(provider_name, hook)``.
Queried by ``agent/credential_pool.py``.
"""
sync_from_credentials_file: Optional[Callable] = None
"""Sync a pool entry from an external credentials file (e.g. ~/.claude/.credentials.json)."""
refresh_oauth: Optional[Callable] = None
"""Refresh an OAuth token for a pool entry."""
should_include_in_pool: Optional[Callable] = None
"""Return True if this provider's credentials should be included in the pool."""
needs_refresh: Optional[Callable] = None
"""Return True if an OAuth entry needs a token refresh."""
source_priority: Optional[Callable] = None
"""Return integer priority for a credential source (lower = preferred)."""
discover_credentials: Optional[Callable] = None
"""Discover external credentials and upsert into the pool entries.
Signature: (entries: list, provider: str, is_suppressed: Callable) -> (changed: bool, active_sources: set)
"""
env_var_order: Optional[list] = None
"""Override env var scan order for this provider (e.g. ['ANTHROPIC_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'])."""
detect_auth_type: Optional[Callable] = None
"""Given a token string, return the auth type for this provider.
Signature: (token: str) -> str (e.g. AUTH_TYPE_OAUTH or AUTH_TYPE_API_KEY)
"""
# ---------------------------------------------------------------------------
# Pricing providers
# ---------------------------------------------------------------------------
# Re-export PricingEntry from usage_pricing — that's the canonical definition
# with Decimal fields. The registry stores these directly keyed by (provider, model).
# Lazy import to avoid circular dependency (usage_pricing imports registries at runtime).
def _get_pricing_entry_class():
from agent.usage_pricing import PricingEntry
return PricingEntry
# ---------------------------------------------------------------------------
# Provider overlays
# ---------------------------------------------------------------------------
@dataclass
class ProviderOverlayEntry:
"""A provider overlay registered by a plugin.
Registered via ``ctx.register_provider_overlay(provider_name, entry)``.
Queried by ``hermes_cli/providers.py``.
This mirrors the fields of ``HermesOverlay`` so that providers.py
can merge plugin-registered overlays seamlessly.
"""
provider_name: str
"""Primary provider name (e.g. 'anthropic', 'bedrock')."""
transport: str = "openai_chat"
"""Transport type: openai_chat | anthropic_messages | codex_responses | bedrock_converse"""
is_aggregator: bool = False
"""Whether this provider aggregates multiple model providers."""
auth_type: str = "api_key"
"""Auth type: api_key | oauth_device_code | oauth_external | aws_sdk | external_process"""
extra_env_vars: Tuple[str, ...] = ()
"""Environment variable names that indicate this provider is configured."""
base_url_override: str = ""
"""Override if models.dev URL is wrong/missing."""
base_url_env_var: str = ""
"""Env var for user-custom base URL."""
display_name: str = ""
"""Human-readable name for the provider (e.g. 'Anthropic', 'AWS Bedrock')."""
aliases: List[str] = field(default_factory=list)
"""Alternative names that resolve to this provider."""
# ---------------------------------------------------------------------------
# The global registries (singleton)
# ---------------------------------------------------------------------------
@ -233,11 +359,16 @@ class PluginRegistries:
def __init__(self) -> None:
self.auth_providers: Dict[str, AuthProviderEntry] = {}
self.transport_builders: Dict[str, TransportBuilder] = {}
self._transports: Dict[str, type] = {}
self.platform_adapters: Dict[str, PlatformAdapterEntry] = {}
self.tool_providers: Dict[str, ToolProviderEntry] = {}
self.model_metadata: Dict[str, ModelMetadataEntry] = {}
self.credential_pools: Dict[str, CredentialPoolEntry] = {}
self._provider_services: Dict[str, Dict[str, Any]] = {}
self._provider_resolvers: Dict[str, Callable] = {}
self._credential_pool_hooks: Dict[str, CredentialPoolHook] = {}
self._pricing_providers: Dict[tuple, Any] = {}
self._provider_overlays: Dict[str, ProviderOverlayEntry] = {}
# -- registration methods (called from PluginContext) --------------------
@ -270,6 +401,46 @@ class PluginRegistries:
def register_credential_pool(self, entry: CredentialPoolEntry) -> None:
self.credential_pools[entry.name] = entry
def register_provider_resolver(self, name: str, resolver: Callable) -> None:
"""Register a provider resolver callable.
The resolver is called by ``resolve_provider_client()`` to create an
auxiliary client for a specific provider. Signature::
def resolver(
*,
model: str | None,
explicit_api_key: str | None,
explicit_base_url: str | None,
async_mode: bool,
is_vision: bool,
main_runtime: dict | None,
api_mode: str | None,
) -> tuple[Any, str] | tuple[None, None]:
...
Returns ``(client, default_model)`` or ``(None, None)``.
"""
self._provider_resolvers[name] = resolver
def register_credential_pool_hook(self, name: str, hook: CredentialPoolHook) -> None:
"""Register a credential pool hook for provider-specific pool operations."""
self._credential_pool_hooks[name] = hook
def register_pricing_provider(self, name: str, entries: List[tuple]) -> None:
"""Register pricing entries for a provider.
Each entry is a (provider, model, PricingEntry) tuple so the
lookup key matches the (provider, model) pattern used by
_OFFICIAL_DOCS_PRICING.
"""
for prov, model, entry in entries:
self._pricing_providers[(prov, model)] = entry
def register_provider_overlay(self, entry: ProviderOverlayEntry) -> None:
"""Register a provider overlay entry from a plugin."""
self._provider_overlays[entry.provider_name] = entry
# -- query helpers -------------------------------------------------------
def get_auth_provider(self, name: str) -> AuthProviderEntry | None:
@ -290,6 +461,30 @@ class PluginRegistries:
def get_credential_pool(self, name: str) -> CredentialPoolEntry | None:
return self.credential_pools.get(name)
def get_provider_resolver(self, name: str) -> Callable | None:
"""Return the registered resolver for a provider, or None."""
return self._provider_resolvers.get(name)
def get_credential_pool_hook(self, name: str) -> CredentialPoolHook | None:
"""Return the registered credential pool hook for a provider, or None."""
return self._credential_pool_hooks.get(name)
def get_pricing_entry(self, provider: str, model: str) -> Any:
"""Return a registered pricing entry for (provider, model), or None."""
return self._pricing_providers.get((provider, model))
def all_pricing_entries(self) -> Dict[tuple, Any]:
"""Return all registered pricing entries (keyed by (provider, model))."""
return dict(self._pricing_providers)
def get_provider_overlay(self, name: str) -> ProviderOverlayEntry | None:
"""Return a registered provider overlay, or None."""
return self._provider_overlays.get(name)
def all_provider_overlays(self) -> Dict[str, ProviderOverlayEntry]:
"""Return all registered provider overlays."""
return dict(self._provider_overlays)
def all_auth_providers(self) -> List[AuthProviderEntry]:
return list(self.auth_providers.values())
@ -348,10 +543,17 @@ class PluginRegistries:
self._attr = attr
self._fallback = fallback
def __call__(self, *args: Any, **kwargs: Any) -> Any:
def _resolve(self) -> Any:
mod = sys.modules.get(self._mod)
live = getattr(mod, self._attr, self._fallback) if mod else self._fallback
return live(*args, **kwargs)
return getattr(mod, self._attr, self._fallback) if mod else self._fallback
def __call__(self, *args: Any, **kwargs: Any) -> Any:
return self._resolve()(*args, **kwargs)
def __getattr__(self, name: str) -> Any:
if name.startswith("_"):
raise AttributeError(name)
return getattr(self._resolve(), name)
def __repr__(self) -> str: # pragma: no cover
return f"<LazyRef {self._mod}.{self._attr}>"

View File

@ -47,9 +47,16 @@ def get_transport(api_mode: str):
def _discover_transports() -> None:
"""Import all transport modules to trigger auto-registration."""
"""Import all transport modules to trigger auto-registration.
Also checks the plugin registry for transports registered by plugins
(e.g. anthropic_messages from the anthropic plugin, bedrock_converse
from the bedrock plugin). Plugin-registered transports take priority
over core fallbacks when both exist.
"""
global _discovered
_discovered = True
# Core transport modules (registered automatically — no plugin needed)
try:
import agent.transports.anthropic # noqa: F401
except ImportError:
@ -62,7 +69,10 @@ def _discover_transports() -> None:
import agent.transports.chat_completions # noqa: F401
except ImportError:
pass
# Plugin-registered transports (override core fallbacks)
try:
import agent.transports.bedrock # noqa: F401
from agent.plugin_registries import registries
for api_mode, transport_cls in registries._transports.items():
_REGISTRY.setdefault(api_mode, transport_cls)
except ImportError:
pass

View File

@ -1,43 +1,53 @@
"""Anthropic Messages API transport.
"""Anthropic Messages API transport — core module.
Delegates to the existing adapter functions in hermes_agent_anthropic.
This transport owns format conversion and normalization NOT client lifecycle.
Owns format conversion and response normalization for the ``anthropic_messages``
wire format. No SDK dependency; all wire-format logic lives in
:mod:`agent.anthropic_format`.
"""
import json
from typing import Any, Dict, List, Optional
from agent.anthropic_format import (
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
_to_plain_data,
)
from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse
from agent.transports.types import NormalizedResponse, ToolCall
class AnthropicTransport(ProviderTransport):
"""Transport for api_mode='anthropic_messages'.
Wraps the existing functions in hermes_agent_anthropic behind the
ProviderTransport ABC. Each method delegates no logic is duplicated.
Uses core functions directly from :mod:`agent.anthropic_format` no
plugin registry lookups needed. This means core tests, bedrock tests,
and any other consumer of the anthropic wire format work without the
anthropic plugin being registered.
"""
_STOP_REASON_MAP = {
"end_turn": "stop",
"tool_use": "tool_calls",
"max_tokens": "length",
"stop_sequence": "stop",
"refusal": "content_filter",
"model_context_window_exceeded": "length",
}
@property
def api_mode(self) -> str:
return "anthropic_messages"
def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
"""Convert OpenAI messages to Anthropic (system, messages) tuple.
kwargs:
base_url: Optional[str] affects thinking signature handling.
"""
from agent.plugin_registries import registries
convert_messages_to_anthropic = registries.get_provider_service("anthropic", "convert_messages_to_anthropic")
"""Convert OpenAI messages to Anthropic (system, messages) tuple."""
base_url = kwargs.get("base_url")
return convert_messages_to_anthropic(messages, base_url=base_url)
return convert_messages_to_anthropic(messages, base_url=base_url,
model=kwargs.get("model"))
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
"""Convert OpenAI tool schemas to Anthropic input_schema format."""
from agent.plugin_registries import registries
convert_tools_to_anthropic = registries.get_provider_service("anthropic", "convert_tools_to_anthropic")
return convert_tools_to_anthropic(tools)
def build_kwargs(
@ -47,24 +57,7 @@ class AnthropicTransport(ProviderTransport):
tools: Optional[List[Dict[str, Any]]] = None,
**params,
) -> Dict[str, Any]:
"""Build Anthropic messages.create() kwargs.
Calls convert_messages and convert_tools internally.
params (all optional):
max_tokens: int
reasoning_config: dict | None
tool_choice: str | None
is_oauth: bool
preserve_dots: bool
context_length: int | None
base_url: str | None
fast_mode: bool
drop_context_1m_beta: bool
"""
from agent.plugin_registries import registries
build_anthropic_kwargs = registries.get_provider_service("anthropic", "build_anthropic_kwargs")
"""Build Anthropic messages.create() kwargs."""
return build_anthropic_kwargs(
model=model,
messages=messages,
@ -81,16 +74,7 @@ class AnthropicTransport(ProviderTransport):
)
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
"""Normalize Anthropic response to NormalizedResponse.
Parses content blocks (text, thinking, tool_use), maps stop_reason
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
from agent.plugin_registries import registries
_to_plain_data = registries.get_provider_service("anthropic", "_to_plain_data")
from agent.transports.types import ToolCall
"""Normalize Anthropic response to NormalizedResponse."""
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
_MCP_PREFIX = "mcp_"
@ -104,20 +88,13 @@ class AnthropicTransport(ProviderTransport):
text_parts.append(block.text)
elif block.type == "thinking":
reasoning_parts.append(block.thinking)
if _to_plain_data is not None:
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
reasoning_details.append(block_dict)
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
reasoning_details.append(block_dict)
elif block.type == "tool_use":
name = block.name
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
stripped = name[len(_MCP_PREFIX):]
# Only strip the mcp_ prefix for OAuth-injected tools
# (where Hermes adds the prefix when sending to Anthropic
# and must remove it on the way back). Native MCP server
# tools (from mcp_servers: in config.yaml) are registered
# in the tool registry under their FULL mcp_<server>_<tool>
# name and must NOT be stripped. GH-25255.
from tools.registry import registry as _tool_registry
if (_tool_registry.get_entry(stripped)
and not _tool_registry.get_entry(name)):
@ -146,13 +123,7 @@ class AnthropicTransport(ProviderTransport):
)
def validate_response(self, response: Any) -> bool:
"""Check Anthropic response structure is valid.
An empty content list is legitimate when ``stop_reason == "end_turn"``
the model's canonical way of signalling "nothing more to add" after
a tool turn that already delivered the user-facing text. Treating it
as invalid falsely retries a completed response.
"""
"""Check Anthropic response structure is valid."""
if response is None:
return False
content_blocks = getattr(response, "content", None)
@ -173,16 +144,6 @@ class AnthropicTransport(ProviderTransport):
return {"cached_tokens": cached, "creation_tokens": written}
return None
# Promote the adapter's canonical mapping to module level so it's shared
_STOP_REASON_MAP = {
"end_turn": "stop",
"tool_use": "tool_calls",
"max_tokens": "length",
"stop_sequence": "stop",
"refusal": "content_filter",
"model_context_window_exceeded": "length",
}
def map_finish_reason(self, raw_reason: str) -> str:
"""Map Anthropic stop_reason to OpenAI finish_reason."""
return self._STOP_REASON_MAP.get(raw_reason, "stop")

View File

@ -87,6 +87,8 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
# Opus 4.5/4.6/4.7 share $5/$25 pricing (new tokenizer, up to 35% more
# tokens for the same text).
# Source: https://platform.claude.com/docs/en/about-claude/pricing
# NOTE: The anthropic plugin also registers these — plugin takes priority
# at runtime, but these static entries ensure costs work without the plugin.
(
"anthropic",
"claude-opus-4-7",
@ -111,7 +113,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4.6 ─────────────────────────────────────────────
(
"anthropic",
"claude-opus-4-6",
@ -160,7 +161,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4.5 ─────────────────────────────────────────────
(
"anthropic",
"claude-opus-4-5",
@ -197,7 +197,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── Anthropic Claude 4 / 4.1 ─────────────────────────────────────────
(
"anthropic",
"claude-opus-4-20250514",
@ -222,7 +221,56 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# OpenAI
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
(
"anthropic",
"claude-3-5-sonnet-20241022",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-5-haiku-20241022",
): PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
cache_read_cost_per_million=Decimal("0.08"),
cache_write_cost_per_million=Decimal("1.00"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-opus-20240229",
): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-haiku-20240307",
): PricingEntry(
input_cost_per_million=Decimal("0.25"),
output_cost_per_million=Decimal("1.25"),
cache_read_cost_per_million=Decimal("0.03"),
cache_write_cost_per_million=Decimal("0.30"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# ── OpenAI ────────────────────────────────────────────────────────────
(
"openai",
"gpt-4o",
@ -300,55 +348,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://openai.com/api/pricing/",
pricing_version="openai-pricing-2026-03-16",
),
# ── Anthropic older models (pre-4.5 generation) ────────────────────────
(
"anthropic",
"claude-3-5-sonnet-20241022",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-5-haiku-20241022",
): PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
cache_read_cost_per_million=Decimal("0.08"),
cache_write_cost_per_million=Decimal("1.00"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-opus-20240229",
): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
cache_read_cost_per_million=Decimal("1.50"),
cache_write_cost_per_million=Decimal("18.75"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
(
"anthropic",
"claude-3-haiku-20240307",
): PricingEntry(
input_cost_per_million=Decimal("0.25"),
output_cost_per_million=Decimal("1.25"),
cache_read_cost_per_million=Decimal("0.03"),
cache_write_cost_per_million=Decimal("0.30"),
source="official_docs_snapshot",
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
pricing_version="anthropic-pricing-2026-05",
),
# DeepSeek
(
"deepseek",
@ -412,80 +411,6 @@ _OFFICIAL_DOCS_PRICING: Dict[tuple[str, str], PricingEntry] = {
source_url="https://ai.google.dev/pricing",
pricing_version="google-pricing-2026-03-16",
),
# AWS Bedrock — pricing per the Bedrock pricing page.
# Bedrock charges the same per-token rates as the model provider but
# through AWS billing. These are the on-demand prices (no commitment).
# Source: https://aws.amazon.com/bedrock/pricing/
(
"bedrock",
"anthropic.claude-opus-4-6",
): PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"anthropic.claude-sonnet-4-6",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"anthropic.claude-sonnet-4-5",
): PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"anthropic.claude-haiku-4-5",
): PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"amazon.nova-pro",
): PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("3.20"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"amazon.nova-lite",
): PricingEntry(
input_cost_per_million=Decimal("0.06"),
output_cost_per_million=Decimal("0.24"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
(
"bedrock",
"amazon.nova-micro",
): PricingEntry(
input_cost_per_million=Decimal("0.035"),
output_cost_per_million=Decimal("0.14"),
source="official_docs_snapshot",
source_url="https://aws.amazon.com/bedrock/pricing/",
pricing_version="bedrock-pricing-2026-04",
),
# MiniMax
(
"minimax",
@ -553,36 +478,27 @@ def resolve_billing_route(
return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown")
def _normalize_anthropic_model_name(model: str) -> str:
"""Normalize Anthropic model name variants to canonical form.
Handles:
- Dot notation: claude-opus-4.7 claude-opus-4-7
- Short aliases: claude-opus-4.7 claude-opus-4-7
- Strips anthropic/ prefix if present
"""
name = model.lower().strip()
if name.startswith("anthropic/"):
name = name[len("anthropic/"):]
# Normalize dots to dashes in version numbers (e.g. 4.7 → 4-7, 4.6 → 4-6)
# But preserve the rest of the name structure
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
return name
def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
model = route.model.lower()
# Direct lookup first
# ── Plugin-registered pricing entries take priority ──
from agent.plugin_registries import registries as _preg
plugin_entry = _preg.get_pricing_entry(route.provider, model)
if plugin_entry:
return plugin_entry
# Try provider-specific name normalization via registry
_norm = _preg.get_provider_service(route.provider, "normalize_model_name")
if _norm is not None:
normalized = _norm(model)
if normalized != model:
plugin_entry = _preg.get_pricing_entry(route.provider, normalized)
if plugin_entry:
return plugin_entry
# Fall back to static dict
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model))
if entry:
return entry
# Try normalized name for Anthropic (handles dot-notation like opus-4.7)
if route.provider == "anthropic":
normalized = _normalize_anthropic_model_name(model)
if normalized != model:
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
if entry:
return entry
return None

View File

@ -29,10 +29,37 @@ from unittest.mock import patch
import pytest
# Ensure project root is importable
PROJECT_ROOT = Path(__file__).parent.parent
PROJECT_ROOT = Path(__file__).parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
# ── Plugin directory sys.path shadow fix ────────────────────────────────────
# pytest adds ``plugins/model-providers/`` to sys.path because
# ``plugins/model-providers/anthropic/__init__.py`` (a provider profile) exists.
# This makes ``import anthropic`` shadow the real SDK package with the plugin
# directory. We fix this via pytest_load_initial_conftests (runs before pytest
# adds package roots) AND inline here as belt-and-suspenders.
_bad_path = str(PROJECT_ROOT / "plugins" / "model-providers")
while _bad_path in sys.path:
sys.path.remove(_bad_path)
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
del sys.modules["anthropic"]
def pytest_load_initial_conftests(early_config, parser, args):
"""Remove plugin dirs from sys.path before pytest adds package roots.
This must run as early as possible before hermes_agent_anthropic is
imported to prevent ``plugins/model-providers/anthropic/__init__.py``
(a provider profile) from shadowing the real ``anthropic`` SDK.
"""
_bad = str(PROJECT_ROOT / "plugins" / "model-providers")
while _bad in sys.path:
sys.path.remove(_bad)
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
del sys.modules["anthropic"]
# ── Per-file process isolation ──────────────────────────────────────────────
# Tests run via ``scripts/run_tests_parallel.py``, which spawns a fresh
@ -861,3 +888,235 @@ def _live_system_guard(request, monkeypatch):
pass
yield
# ── Anthropic registry seed (shared across all test subdirs) ────────────────
# Defined in root conftest so tests/hermes_cli, tests/run_agent,
# tests/tools, tests/gateway all get it. tests/agent has its own copy too.
from contextlib import contextmanager
from unittest.mock import MagicMock
__all__ = ["mock_anthropic_provider"]
def _mock_endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""Functional mock — detects Anthropic-wire endpoints by URL pattern.
Reproduces the real plugin's logic without importing it.
"""
if not base_url:
return False
normalized = base_url.lower().rstrip("/")
if normalized.endswith("/anthropic"):
return True
# api.anthropic.com
if "api.anthropic.com" in normalized:
return True
# kimi coding plan
if "api.kimi.com" in normalized and "/coding" in normalized:
return True
return False
def _mock_is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
"""Functional mock — detects Anthropic-compat endpoints.
Reproduces the real plugin's logic: named compat providers OR /anthropic URL suffix.
"""
_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
if provider in _COMPAT_PROVIDERS:
return True
url_lower = (base_url or "").lower()
return "/anthropic" in url_lower
def _mock_convert_openai_images_to_anthropic(messages: list) -> list:
"""Functional mock — converts OpenAI image_url blocks to Anthropic image blocks."""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_content = []
changed = False
for block in content:
if block.get("type") == "image_url":
image_url_val = (block.get("image_url") or {}).get("url", "")
if image_url_val.startswith("data:"):
header, _, b64data = image_url_val.partition(",")
media_type = "image/png"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
new_content.append({
"type": "image",
"source": {
"type": "url",
"url": image_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)
return converted
def _mock_maybe_wrap_anthropic(client_obj, model, api_key, base_url, api_mode=None):
"""Functional mock for maybe_wrap_anthropic — wraps when endpoint is Anthropic-wire.
Reproduces the real plugin's wrapping logic without importing it.
Uses the real AnthropicAuxiliaryClient from core (no SDK dependency).
"""
# Already wrapped — don't double-wrap
from agent.anthropic_aux import (AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient)
if isinstance(client_obj, (AnthropicAuxiliaryClient, AsyncAnthropicAuxiliaryClient)):
return client_obj
# Check for other specialized adapters we should never re-dispatch
try:
from agent.auxiliary_client import CodexAuxiliaryClient
if isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
except ImportError:
pass
# Explicit non-anthropic api_mode wins over URL heuristics
if api_mode and api_mode != "anthropic_messages":
return client_obj
should_wrap = (
api_mode == "anthropic_messages"
or _mock_endpoint_speaks_anthropic_messages(base_url)
)
if not should_wrap:
return client_obj
# Use the registry's build_anthropic_client to construct a real(ish) client
from agent.plugin_registries import registries
build_fn = registries.get_provider_service("anthropic", "build_anthropic_client")
if build_fn is None:
return client_obj
try:
real_client = build_fn(api_key, base_url)
except Exception:
return client_obj
return AnthropicAuxiliaryClient(
real_client, model, api_key, base_url, is_oauth=False,
)
def _make_base_anthropic_namespace() -> dict:
"""Build a minimal anthropic service namespace with safe mock stubs.
Wire-format code (build_anthropic_kwargs, convert_messages_to_anthropic,
AnthropicAuxiliaryClient, etc.) has moved to core modules and is no
longer looked up via the registry. Only SDK-dependent orchestration
(maybe_wrap_anthropic, is_anthropic_compat_endpoint, client building,
auth) still needs mock stubs here.
"""
mock_client = MagicMock(name="anthropic_client")
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "sk-ant-mock"
def _resolve_token():
"""Return token from env vars if set — mimics the real resolve_anthropic_token."""
import os
return (os.environ.get("ANTHROPIC_TOKEN")
or os.environ.get("ANTHROPIC_API_KEY"))
return {
# SDK-dependent client building
"build_anthropic_client": MagicMock(return_value=mock_client),
"build_anthropic_bedrock_client": MagicMock(return_value=mock_client),
"resolve_anthropic_token": _resolve_token,
"_is_oauth_token": lambda k: bool(k) and not (k or "").startswith("sk-ant-api"),
"is_claude_code_token_valid": MagicMock(return_value=False),
"read_claude_code_credentials": MagicMock(return_value=None),
"write_claude_code_credentials": MagicMock(),
"refresh_oauth_token": MagicMock(return_value=None),
"run_hermes_oauth_login_pure": MagicMock(return_value=("mock-token", None)),
"_HERMES_OAUTH_FILE": MagicMock(),
# Resolve / endpoint detection (still plugin-provided, still needs mocking)
"maybe_wrap_anthropic": _mock_maybe_wrap_anthropic,
"endpoint_speaks_anthropic_messages": _mock_endpoint_speaks_anthropic_messages,
"is_anthropic_compat_endpoint": _mock_is_anthropic_compat_endpoint,
"convert_openai_images_to_anthropic": _mock_convert_openai_images_to_anthropic,
"ANTHROPIC_DEFAULT_BASE_URL": "https://api.anthropic.com",
"_ANTHROPIC_COMPAT_PROVIDERS": frozenset(),
"resolve_auxiliary_client": MagicMock(return_value=(mock_client, "claude-3-5-sonnet-20241022")),
}
@contextmanager
def mock_anthropic_provider(**overrides):
"""Patch the anthropic registry namespace. Use in core tests instead of
patching hermes_agent_anthropic.* directly.
Usage:
with mock_anthropic_provider(build_anthropic_client=my_mock):
result = resolve_provider_client(...)
"""
from agent.plugin_registries import registries
base = _make_base_anthropic_namespace()
base.update(overrides)
with patch.dict(registries._provider_services, {"anthropic": base}):
yield base
@pytest.fixture(autouse=True)
def _seed_anthropic_registry(request):
"""Install mock anthropic namespace before each test, restore after.
Skips for plugin tests they have their own conftest that registers the
real plugin, and we must NOT block them with _guarded_register.
Uses patch.dict so it's guaranteed to restore even when plugin tests
in other directories (which use the real plugin) run before us in the
same process. Function-scoped (not session) so it re-seeds after each
plugin test that overwrites the registry.
Also clears _provider_resolvers["anthropic"] so a real plugin registration
that leaked from another test file doesn't affect core unit tests.
Also blocks _ensure_plugins_discovered() so that code paths that lazily
trigger plugin loading (e.g. get_plugin_auxiliary_tasks via
_resolve_task_provider_model) don't overwrite the mock namespace.
"""
# Skip for plugin tests — they manage the registry themselves
node_path = str(request.fspath)
if "/plugins/" in node_path:
yield
return
from unittest.mock import patch
from agent.plugin_registries import registries
ns = _make_base_anthropic_namespace()
# Guard registries.register_provider_services so that if discover_and_load()
# fires during a test (e.g. via get_plugin_auxiliary_tasks in
# _resolve_task_provider_model), it can't overwrite our mock anthropic
# namespace. We only block "anthropic" — other providers / hooks proceed
# normally so tests like test_context_engine.py still work.
_orig_register = registries.register_provider_services
def _guarded_register(name, services):
if name == "anthropic":
return # mock namespace wins — don't let the real plugin clobber it
return _orig_register(name, services)
_orig_resolver = registries._provider_resolvers.pop("anthropic", None)
with patch.dict(registries._provider_services, {"anthropic": ns}), \
patch.object(registries, "register_provider_services", _guarded_register):
yield
# Restore resolver (None means "not registered", which is correct for
# core unit tests; plugin tests that need the real resolver load it themselves)
if _orig_resolver is not None:
registries._provider_resolvers["anthropic"] = _orig_resolver
else:
registries._provider_resolvers.pop("anthropic", None)

View File

@ -221,9 +221,12 @@ def auth_add_command(args) -> None:
return
if provider == "anthropic":
from agent import anthropic_adapter as anthropic_mod
creds = anthropic_mod.run_hermes_oauth_login_pure()
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
run_hermes_oauth_login_pure = _anthropic_ns.get("run_hermes_oauth_login_pure")
if not run_hermes_oauth_login_pure:
raise SystemExit("Anthropic plugin not loaded — cannot run OAuth login.")
creds = run_hermes_oauth_login_pure()
if not creds:
raise SystemExit("Anthropic OAuth login did not return credentials.")
label = (getattr(args, "label", None) or "").strip() or label_from_token(

View File

@ -29,7 +29,6 @@ from hermes_cli.vercel_auth import describe_vercel_auth
from hermes_constants import OPENROUTER_MODELS_URL
from utils import base_url_host_matches
_PROVIDER_ENV_HINTS = (
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
@ -56,14 +55,11 @@ _PROVIDER_ENV_HINTS = (
"TOKENHUB_API_KEY",
)
from hermes_constants import is_termux as _is_termux
def _python_install_cmd() -> str:
return "python -m pip install" if _is_termux() else "uv pip install"
def _system_package_install_cmd(pkg: str) -> str:
if _is_termux():
return f"pkg install {pkg}"
@ -71,7 +67,6 @@ def _system_package_install_cmd(pkg: str) -> str:
return f"brew install {pkg}"
return f"sudo apt install {pkg}"
def _safe_which(cmd: str) -> str | None:
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
try:
@ -79,7 +74,6 @@ def _safe_which(cmd: str) -> str | None:
except Exception:
return None
def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
steps: list[str] = []
step = 1
@ -90,7 +84,6 @@ def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
steps.append(f"{step + 1}) agent-browser install")
return steps
def _termux_install_all_fallback_notes() -> list[str]:
return [
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
@ -99,12 +92,10 @@ def _termux_install_all_fallback_notes() -> list[str]:
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
]
def _has_provider_env_config(content: str) -> bool:
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
return any(key in content for key in _PROVIDER_ENV_HINTS)
def _honcho_is_configured_for_doctor() -> bool:
"""Return True when Honcho is configured, even if this process has no active session."""
try:
@ -115,7 +106,6 @@ def _honcho_is_configured_for_doctor() -> bool:
except Exception:
return False
def _is_kanban_worker_env_gate(item: dict) -> bool:
"""Return True when Kanban is unavailable only because this is not a worker process."""
if item.get("name") != "kanban":
@ -126,14 +116,12 @@ def _is_kanban_worker_env_gate(item: dict) -> bool:
tools = item.get("tools") or []
return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)
def _doctor_tool_availability_detail(toolset: str) -> str:
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
return ""
def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
"""Adjust runtime-gated tool availability for doctor diagnostics."""
updated_available = list(available)
@ -151,7 +139,6 @@ def _apply_doctor_tool_availability_overrides(available: list[str], unavailable:
updated_unavailable.append(item)
return updated_available, updated_unavailable
def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:
"""Return True when a direct API-key probe failure is non-blocking.
@ -181,7 +168,6 @@ def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool
return False
return False
def check_ok(text: str, detail: str = ""):
print(f" {color('', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
@ -194,19 +180,16 @@ def check_fail(text: str, detail: str = ""):
def check_info(text: str):
print(f" {color('', Colors.CYAN)} {text}")
def _section(title: str) -> None:
"""Print a doctor section banner: blank line + bold cyan ◆ title."""
print()
print(color(f"{title}", Colors.CYAN, Colors.BOLD))
def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:
"""Emit a check_fail and append the corresponding fix instruction."""
check_fail(text, detail)
issues.append(fix)
def _check_s6_supervision(issues: list[str]) -> None:
"""Inside a container under our s6 /init, surface what s6 sees.
@ -254,7 +237,6 @@ def _check_s6_supervision(issues: list[str]) -> None:
+ (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "")
)
def _check_gateway_service_linger(issues: list[str]) -> None:
"""Warn when a systemd user gateway service will stop after logout.
@ -298,10 +280,8 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
else:
check_warn("Could not verify systemd linger", f"({linger_detail})")
_APIKEY_PROVIDERS_CACHE: list | None = None
def _build_apikey_providers_list() -> list:
"""Build the API-key provider health-check list once and cache it.
@ -394,7 +374,6 @@ def _build_apikey_providers_list() -> list:
pass
return _static
def run_doctor(args):
"""Run diagnostic checks."""
should_fix = getattr(args, 'fix', False)
@ -1533,10 +1512,11 @@ def run_doctor(args):
from agent.plugin_registries import registries
_anthropic_ns = registries.get_provider_namespace("anthropic")
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
_COMMON_BETAS = _anthropic_ns.get("_COMMON_BETAS")
# _COMMON_BETAS and _CONTEXT_1M_BETA are now in core
from agent.anthropic_format import _COMMON_BETAS, _CONTEXT_1M_BETA
_OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS")
_CONTEXT_1M_BETA = _anthropic_ns.get("_CONTEXT_1M_BETA")
if not all([_is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS]):
if not all([_is_oauth_token, _OAUTH_ONLY_BETAS]):
raise ImportError("anthropic provider services not fully registered")
headers = {"anthropic-version": "2023-06-01"}
is_oauth = _is_oauth_token(key)

View File

@ -28,7 +28,6 @@ COPILOT_EDITOR_VERSION = "vscode/1.104.1"
COPILOT_REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"]
COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
@ -68,7 +67,6 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
_openrouter_catalog_cache: list[tuple[str, str]] | None = None
# Fallback Vercel AI Gateway snapshot used when the live catalog is unavailable.
# OSS / open-weight models prioritized first, then closed-source by family.
# Slugs match Vercel's actual /v1/models catalog (e.g. alibaba/ for Qwen,
@ -93,7 +91,6 @@ VERCEL_AI_GATEWAY_MODELS: list[tuple[str, str]] = [
_ai_gateway_catalog_cache: list[tuple[str, str]] | None = None
def _codex_curated_models() -> list[str]:
"""Derive the openai-codex curated list from codex_models.py.
@ -104,7 +101,6 @@ def _codex_curated_models() -> list[str]:
from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, _add_forward_compat_models
return _add_forward_compat_models(list(DEFAULT_CODEX_MODELS))
# Static fallback for xAI when the models.dev disk cache is empty (fresh
# install, offline first run, etc.). Mirrors the xAI-direct model IDs from
# $HERMES_HOME/models_dev_cache.json as of 2026-04-28. Whenever xAI renames
@ -122,17 +118,14 @@ _XAI_STATIC_FALLBACK: list[str] = [
"grok-4.20-multi-agent-0309",
]
_XAI_TOP_MODEL = "grok-4.3"
def _xai_promote_top(ids: list[str]) -> list[str]:
"""Pin the headline xAI model to the top of the curated list."""
if _XAI_TOP_MODEL in ids:
return [_XAI_TOP_MODEL] + [m for m in ids if m != _XAI_TOP_MODEL]
return ids
def _xai_curated_models() -> list[str]:
"""Derive the xAI-direct curated list from models.dev disk cache.
@ -159,7 +152,6 @@ def _xai_curated_models() -> list[str]:
pass
return list(_XAI_STATIC_FALLBACK)
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
"anthropic/claude-opus-4.7",
@ -491,7 +483,6 @@ _PROVIDER_MODELS["ai-gateway"] = [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS]
# are currently offered (free or paid). We trust whatever it returns and
# surface it to users as-is — no local allowlist filtering.
def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool:
"""Return True if *model_id* has zero-cost prompt AND completion pricing."""
p = pricing.get(model_id)
@ -502,7 +493,6 @@ def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool:
except (TypeError, ValueError):
return False
# ---------------------------------------------------------------------------
# Nous Portal account tier detection
# ---------------------------------------------------------------------------
@ -540,7 +530,6 @@ def fetch_nous_account_tier(access_token: str, portal_base_url: str = "") -> dic
except Exception:
return {}
def is_nous_free_tier(account_info: dict[str, Any]) -> bool:
"""Return True if the account info indicates a free (unpaid) tier.
@ -558,7 +547,6 @@ def is_nous_free_tier(account_info: dict[str, Any]) -> bool:
except (TypeError, ValueError):
return False
def partition_nous_models_by_tier(
model_ids: list[str],
pricing: dict[str, dict[str, str]],
@ -586,7 +574,6 @@ def partition_nous_models_by_tier(
unavailable.append(mid)
return (selectable, unavailable)
def union_with_portal_free_recommendations(
curated_ids: list[str],
pricing: dict[str, dict[str, str]],
@ -651,7 +638,6 @@ def union_with_portal_free_recommendations(
return (augmented_ids, augmented_pricing)
def union_with_portal_paid_recommendations(
curated_ids: list[str],
pricing: dict[str, dict[str, str]],
@ -716,7 +702,6 @@ def union_with_portal_paid_recommendations(
return (augmented_ids, dict(pricing))
# ---------------------------------------------------------------------------
# TTL cache for free-tier detection — avoids repeated API calls within a
# session while still picking up upgrades quickly.
@ -724,7 +709,6 @@ def union_with_portal_paid_recommendations(
_FREE_TIER_CACHE_TTL: int = 180 # seconds (3 minutes)
_free_tier_cache: tuple[bool, float] | None = None # (result, timestamp)
def check_nous_free_tier() -> bool:
"""Check if the current Nous Portal user is on a free (unpaid) tier.
@ -765,7 +749,6 @@ def check_nous_free_tier() -> bool:
_free_tier_cache = (False, now)
return False # default to paid on error — don't block users
# ---------------------------------------------------------------------------
# Nous Portal recommended models
#
@ -791,7 +774,6 @@ _NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)
# (result_dict, timestamp) keyed by portal_base_url so staging vs prod don't collide.
_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}
def fetch_nous_recommended_models(
portal_base_url: str = "",
timeout: float = 5.0,
@ -833,7 +815,6 @@ def fetch_nous_recommended_models(
_nous_recommended_cache[base] = (data, now)
return data
def _resolve_nous_portal_url() -> str:
"""Best-effort lookup of the Portal base URL the user is authed against."""
try:
@ -849,7 +830,6 @@ def _resolve_nous_portal_url() -> str:
except Exception:
return "https://portal.nousresearch.com"
def _extract_model_name(entry: Any) -> Optional[str]:
"""Pull the ``modelName`` field from a recommended-model entry, else None."""
if not isinstance(entry, dict):
@ -859,7 +839,6 @@ def _extract_model_name(entry: Any) -> Optional[str]:
return model_name.strip()
return None
def get_nous_recommended_aux_model(
*,
vision: bool = False,
@ -916,7 +895,6 @@ def get_nous_recommended_aux_model(
return name
return None
# ---------------------------------------------------------------------------
# Canonical provider list — single source of truth for provider identity.
# Every code path that lists, displays, or iterates providers derives from
@ -995,7 +973,6 @@ except Exception:
_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
_PROVIDER_ALIASES = {
"glm": "zai",
"z-ai": "zai",
@ -1078,7 +1055,6 @@ _PROVIDER_ALIASES = {
"ollama_cloud": "ollama-cloud",
}
def get_default_model_for_provider(provider: str) -> str:
"""Return the default model for a provider, or empty string if unknown.
@ -1092,7 +1068,6 @@ def get_default_model_for_provider(provider: str) -> str:
models = _PROVIDER_MODELS.get(provider, [])
return models[0] if models else ""
def _openrouter_model_is_free(pricing: Any) -> bool:
"""Return True when both prompt and completion pricing are zero."""
if not isinstance(pricing, dict):
@ -1102,7 +1077,6 @@ def _openrouter_model_is_free(pricing: Any) -> bool:
except (TypeError, ValueError):
return False
def _openrouter_model_supports_tools(item: Any) -> bool:
"""Return True when the model's ``supported_parameters`` advertise tool calling.
@ -1127,7 +1101,6 @@ def _openrouter_model_supports_tools(item: Any) -> bool:
return True
return "tools" in params
def fetch_openrouter_models(
timeout: float = 8.0,
*,
@ -1195,12 +1168,10 @@ def fetch_openrouter_models(
_openrouter_catalog_cache = curated
return list(curated)
def model_ids(*, force_refresh: bool = False) -> list[str]:
"""Return just the OpenRouter model-id strings."""
return [mid for mid, _ in fetch_openrouter_models(force_refresh=force_refresh)]
def get_curated_nous_model_ids() -> list[str]:
"""Return the curated Nous Portal model-id list.
@ -1218,7 +1189,6 @@ def get_curated_nous_model_ids() -> list[str]:
return list(remote)
return list(_PROVIDER_MODELS.get("nous", []))
def _ai_gateway_model_is_free(pricing: Any) -> bool:
"""Return True if an AI Gateway model has $0 input AND output pricing."""
if not isinstance(pricing, dict):
@ -1228,7 +1198,6 @@ def _ai_gateway_model_is_free(pricing: Any) -> bool:
except (TypeError, ValueError):
return False
def fetch_ai_gateway_models(
timeout: float = 8.0,
*,
@ -1300,14 +1269,10 @@ def fetch_ai_gateway_models(
_ai_gateway_catalog_cache = curated
return list(curated)
def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]:
"""Return just the AI Gateway model-id strings."""
return [mid for mid, _ in fetch_ai_gateway_models(force_refresh=force_refresh)]
# ---------------------------------------------------------------------------
# Pricing helpers — fetch live pricing from OpenRouter-compatible /v1/models
# ---------------------------------------------------------------------------
@ -1315,7 +1280,6 @@ def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]:
# Cache: maps model_id → {"prompt": str, "completion": str} per endpoint
_pricing_cache: dict[str, dict[str, dict[str, str]]] = {}
def _format_price_per_mtok(per_token_str: str) -> str:
"""Convert a per-token price string to a human-friendly $/Mtok string.
@ -1339,7 +1303,6 @@ def _format_price_per_mtok(per_token_str: str) -> str:
per_m = val * 1_000_000
return f"${per_m:.2f}"
def format_model_pricing_table(
models: list[tuple[str, str]],
pricing_map: dict[str, dict[str, str]],
@ -1401,7 +1364,6 @@ def format_model_pricing_table(
return lines
def fetch_models_with_pricing(
api_key: str | None = None,
base_url: str = "https://openrouter.ai/api",
@ -1452,7 +1414,6 @@ def fetch_models_with_pricing(
_pricing_cache[cache_key] = result
return result
def fetch_ai_gateway_pricing(
timeout: float = 8.0,
*,
@ -1502,15 +1463,12 @@ def fetch_ai_gateway_pricing(
_pricing_cache[cache_key] = result
return result
def _resolve_openrouter_api_key() -> str:
"""Best-effort OpenRouter API key for pricing fetch."""
return os.getenv("OPENROUTER_API_KEY", "").strip()
_DEFAULT_NOUS_INFERENCE_BASE = "https://inference-api.nousresearch.com"
def _resolve_nous_pricing_credentials() -> tuple[str, str]:
"""Return ``(api_key, base_url)`` for Nous Portal pricing.
@ -1532,7 +1490,6 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]:
pass
return ("", _DEFAULT_NOUS_INFERENCE_BASE)
def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]:
"""Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita)."""
normalized = normalize_provider(provider)
@ -1561,7 +1518,6 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d
)
return {}
def _fetch_novita_pricing(
timeout: float = 8.0,
*,
@ -1620,7 +1576,6 @@ def _fetch_novita_pricing(
_pricing_cache[cache_key] = result
return result
# All provider IDs and aliases that are valid for the provider:model syntax.
_KNOWN_PROVIDER_NAMES: set[str] = (
set(_PROVIDER_LABELS.keys())
@ -1628,7 +1583,6 @@ _KNOWN_PROVIDER_NAMES: set[str] = (
| {"openrouter", "custom"}
)
def list_available_providers() -> list[dict[str, str]]:
"""Return info about all providers the user could use with ``provider:model``.
@ -1672,7 +1626,6 @@ def list_available_providers() -> list[dict[str, str]]:
})
return result
def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
"""Parse ``/model`` input into ``(provider, model)``.
@ -1708,7 +1661,6 @@ def parse_model_input(raw: str, current_provider: str) -> tuple[str, str]:
return (normalize_provider(provider_part), model_part)
return (current_provider, stripped)
def _get_custom_base_url() -> str:
"""Get the custom endpoint base_url from config.yaml."""
try:
@ -1721,7 +1673,6 @@ def _get_custom_base_url() -> str:
pass
return ""
def curated_models_for_provider(
provider: Optional[str],
*,
@ -1746,13 +1697,11 @@ def curated_models_for_provider(
models = _PROVIDER_MODELS.get(normalized, [])
return [(m, "") for m in models]
def _provider_keys(provider: str) -> set[str]:
key = (provider or "").strip().lower()
normalized = normalize_provider(provider)
return {k for k in (key, normalized) if k}
def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
return any(
name_lower == model.lower()
@ -1760,12 +1709,10 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool:
for model in _PROVIDER_MODELS.get(provider, [])
)
_AGGREGATOR_PROVIDERS = frozenset(
{"nous", "openrouter", "ai-gateway", "copilot", "kilocode"}
)
def _resolve_static_model_alias(
name_lower: str,
current_keys: set[str],
@ -1813,7 +1760,6 @@ def _resolve_static_model_alias(
return None
def detect_static_provider_for_model(
model_name: str,
current_provider: str,
@ -1864,7 +1810,6 @@ def detect_static_provider_for_model(
return None
def detect_provider_for_model(
model_name: str,
current_provider: str,
@ -1903,7 +1848,6 @@ def detect_provider_for_model(
return None
def _find_openrouter_slug(model_name: str) -> Optional[str]:
"""Find the full OpenRouter model slug for a bare or partial model name.
@ -1930,7 +1874,6 @@ def _find_openrouter_slug(model_name: str) -> Optional[str]:
return None
def normalize_provider(provider: Optional[str]) -> str:
"""Normalize provider aliases to Hermes' canonical provider ids.
@ -1941,7 +1884,6 @@ def normalize_provider(provider: Optional[str]) -> str:
normalized = (provider or "openrouter").strip().lower()
return _PROVIDER_ALIASES.get(normalized, normalized)
def provider_label(provider: Optional[str]) -> str:
"""Return a human-friendly label for a provider id or alias."""
original = (provider or "openrouter").strip()
@ -1951,7 +1893,6 @@ def provider_label(provider: Optional[str]) -> str:
normalized = normalize_provider(normalized)
return _PROVIDER_LABELS.get(normalized, original or "OpenRouter")
# Models that support OpenAI Priority Processing (service_tier="priority").
# See https://openai.com/api-priority-processing/ for the canonical list.
#
@ -1968,7 +1909,6 @@ _OPENAI_FAST_MODE_PREFIXES: tuple[str, ...] = (
"o4",
)
def _is_openai_fast_model(model_id: Optional[str]) -> bool:
"""Return True if the model is an OpenAI flagship eligible for Priority Processing."""
raw = _strip_vendor_prefix(str(model_id or ""))
@ -1981,7 +1921,6 @@ def _is_openai_fast_model(model_id: Optional[str]) -> bool:
return False
return any(base.startswith(prefix) for prefix in _OPENAI_FAST_MODE_PREFIXES)
# Models that support Anthropic Fast Mode (speed="fast").
# See https://platform.claude.com/docs/en/build-with-claude/fast-mode
#
@ -1990,7 +1929,6 @@ def _is_openai_fast_model(model_id: Optional[str]) -> bool:
# _is_third_party_anthropic_endpoint in agent/anthropic_adapter.py), so
# third-party proxies that would reject the beta header are protected.
def _strip_vendor_prefix(model_id: str) -> str:
"""Strip vendor/ prefix from a model ID (e.g. 'anthropic/claude-opus-4-6' -> 'claude-opus-4-6')."""
raw = str(model_id or "").strip().lower()
@ -1998,12 +1936,10 @@ def _strip_vendor_prefix(model_id: str) -> str:
raw = raw.split("/", 1)[1]
return raw
def model_supports_fast_mode(model_id: Optional[str]) -> bool:
"""Return whether Hermes should expose the /fast toggle for this model."""
return _is_anthropic_fast_model(model_id) or _is_openai_fast_model(model_id)
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
"""Return True if the model is a Claude model eligible for Anthropic Fast Mode.
@ -2020,7 +1956,6 @@ def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
# Only Opus 4.6 supports fast mode at present.
return "opus-4-6" in base or "opus-4.6" in base
def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | None:
"""Return request_overrides for fast/priority mode, or None if unsupported.
@ -2038,7 +1973,6 @@ def resolve_fast_mode_overrides(model_id: Optional[str]) -> dict[str, Any] | Non
return {"speed": "fast"}
return {"service_tier": "priority"}
def _resolve_copilot_catalog_api_key() -> str:
"""Best-effort GitHub token for fetching the Copilot model catalog.
@ -2096,7 +2030,6 @@ def _resolve_copilot_catalog_api_key() -> str:
return ""
# Providers where models.dev is treated as authoritative: curated static
# lists are kept only as an offline fallback and to capture custom additions
# the registry doesn't publish yet. Adding a provider here causes its
@ -2129,7 +2062,6 @@ _MODELS_DEV_PREFERRED: frozenset[str] = frozenset({
"google",
})
def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:
"""Merge curated list with fresh models.dev entries for a preferred provider.
@ -2166,7 +2098,6 @@ def _merge_with_models_dev(provider: str, curated: list[str]) -> list[str]:
merged.append(mid)
return merged
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
"""Return the best known model catalog for a provider.
@ -2327,7 +2258,6 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
return _merge_with_models_dev(normalized, curated_static)
return curated_static
def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
"""Fetch available models from the Anthropic /v1/models endpoint.
@ -2339,9 +2269,10 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
_anthropic_ns = registries.get_provider_namespace("anthropic")
resolve_anthropic_token = _anthropic_ns.get("resolve_anthropic_token")
_is_oauth_token = _anthropic_ns.get("_is_oauth_token")
_COMMON_BETAS = _anthropic_ns.get("_COMMON_BETAS")
# _COMMON_BETAS and _CONTEXT_1M_BETA are now in core
from agent.anthropic_format import _COMMON_BETAS, _CONTEXT_1M_BETA
_OAUTH_ONLY_BETAS = _anthropic_ns.get("_OAUTH_ONLY_BETAS")
_CONTEXT_1M_BETA = _anthropic_ns.get("_CONTEXT_1M_BETA")
if resolve_anthropic_token is None or _is_oauth_token is None:
raise ImportError("anthropic provider services not registered")
except ImportError:
@ -2407,7 +2338,6 @@ def _fetch_anthropic_models(timeout: float = 5.0) -> Optional[list[str]]:
logging.getLogger(__name__).debug("Failed to fetch Anthropic models: %s", e)
return None
def _payload_items(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
@ -2417,7 +2347,6 @@ def _payload_items(payload: Any) -> list[dict[str, Any]]:
return [item for item in data if isinstance(item, dict)]
return []
def copilot_default_headers() -> dict[str, str]:
"""Standard headers for Copilot API requests.
@ -2435,7 +2364,6 @@ def copilot_default_headers() -> dict[str, str]:
"x-initiator": "agent",
}
def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool:
model_id = str(item.get("id") or "").strip()
if not model_id:
@ -2464,7 +2392,6 @@ def _copilot_catalog_item_is_text_model(item: dict[str, Any]) -> bool:
return True
def fetch_github_model_catalog(
api_key: Optional[str] = None, timeout: float = 5.0
) -> Optional[list[dict[str, Any]]]:
@ -2499,7 +2426,6 @@ def fetch_github_model_catalog(
continue
return None
# ─── Copilot catalog context-window helpers ─────────────────────────────────
# Module-level cache: {model_id: max_prompt_tokens}
@ -2507,7 +2433,6 @@ _copilot_context_cache: dict[str, int] = {}
_copilot_context_cache_time: float = 0.0
_COPILOT_CONTEXT_CACHE_TTL = 3600 # 1 hour
def get_copilot_model_context(model_id: str, api_key: Optional[str] = None) -> Optional[int]:
"""Look up max_prompt_tokens for a Copilot model from the live /models API.
@ -2544,7 +2469,6 @@ def get_copilot_model_context(model_id: str, api_key: Optional[str] = None) -> O
return cache.get(model_id)
def _is_github_models_base_url(base_url: Optional[str]) -> bool:
normalized = (base_url or "").strip().rstrip("/").lower()
return (
@ -2553,7 +2477,6 @@ def _is_github_models_base_url(base_url: Optional[str]) -> bool:
or normalized.startswith("https://models.inference.ai.azure.com")
)
def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]:
"""Strip ``/v1`` suffix from an LM Studio base URL to get the native API root.
@ -2564,7 +2487,6 @@ def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]:
root = root[:-3].rstrip("/")
return root or None
def _lmstudio_request_headers(api_key: Optional[str] = None) -> dict:
"""Build HTTP headers for LM Studio native API requests."""
headers = {"User-Agent": _HERMES_USER_AGENT}
@ -2573,7 +2495,6 @@ def _lmstudio_request_headers(api_key: Optional[str] = None) -> dict:
headers["Authorization"] = f"Bearer {token}"
return headers
def _lmstudio_fetch_raw_models(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
@ -2623,7 +2544,6 @@ def _lmstudio_fetch_raw_models(
return None
return raw_models
def probe_lmstudio_models(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
@ -2654,7 +2574,6 @@ def probe_lmstudio_models(
keys.append(key)
return keys
def fetch_lmstudio_models(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
@ -2673,7 +2592,6 @@ def fetch_lmstudio_models(
models = probe_lmstudio_models(api_key=api_key, base_url=base_url, timeout=timeout)
return models or []
def ensure_lmstudio_model_loaded(
model: str,
base_url: Optional[str],
@ -2742,7 +2660,6 @@ def ensure_lmstudio_model_loaded(
return None
return target_context_length
def lmstudio_model_reasoning_options(
model: str,
base_url: Optional[str],
@ -2775,14 +2692,12 @@ def lmstudio_model_reasoning_options(
return []
return []
def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]:
catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout)
if not catalog:
return None
return [item.get("id", "") for item in catalog if item.get("id")]
_COPILOT_MODEL_ALIASES = {
"openai/gpt-5": "gpt-5-mini",
"openai/gpt-5-chat": "gpt-5-mini",
@ -2821,7 +2736,6 @@ _COPILOT_MODEL_ALIASES = {
"anthropic/claude-haiku-4-5": "claude-haiku-4.5",
}
def _copilot_catalog_ids(
catalog: Optional[list[dict[str, Any]]] = None,
api_key: Optional[str] = None,
@ -2836,7 +2750,6 @@ def _copilot_catalog_ids(
if str(item.get("id") or "").strip()
}
def normalize_copilot_model_id(
model_id: Optional[str],
*,
@ -2877,7 +2790,6 @@ def normalize_copilot_model_id(
return raw.split("/", 1)[1].strip()
return raw
def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]:
raw = (model_id or "").strip().lower()
if raw.startswith(("openai/o1", "openai/o3", "openai/o4", "o1", "o3", "o4")):
@ -2887,7 +2799,6 @@ def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]:
return list(COPILOT_REASONING_EFFORTS_GPT5)
return []
def _should_use_copilot_responses_api(model_id: str) -> bool:
"""Decide whether a Copilot model should use the Responses API.
@ -2904,7 +2815,6 @@ def _should_use_copilot_responses_api(model_id: str) -> bool:
major = int(match.group(1))
return major >= 5 and not model_id.startswith("gpt-5-mini")
def copilot_model_api_mode(
model_id: Optional[str],
*,
@ -2945,7 +2855,6 @@ def copilot_model_api_mode(
return "chat_completions"
# Azure Foundry model families that require the Responses API. Azure
# rejects /chat/completions against these deployments with
# ``400 "The requested operation is unsupported."`` — the same payload Bob
@ -2962,7 +2871,6 @@ _AZURE_FOUNDRY_RESPONSES_PREFIXES = (
"o4", # o4, o4-mini
)
def azure_foundry_model_api_mode(model_name: Optional[str]) -> Optional[str]:
"""Infer Azure Foundry api_mode from a deployment/model name.
@ -2991,7 +2899,6 @@ def azure_foundry_model_api_mode(model_name: Optional[str]) -> Optional[str]:
return "codex_responses"
return None
def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[str]) -> str:
"""Normalize OpenCode config IDs to the bare model slug used in API requests."""
provider = normalize_provider(provider_id)
@ -3004,7 +2911,6 @@ def normalize_opencode_model_id(provider_id: Optional[str], model_id: Optional[s
return current[len(prefix):]
return current
def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) -> str:
"""Determine the API mode for an OpenCode Zen / Go model.
@ -3038,7 +2944,6 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str])
return "chat_completions"
def github_model_reasoning_efforts(
model_id: Optional[str],
*,
@ -3082,7 +2987,6 @@ def github_model_reasoning_efforts(
return _github_reasoning_efforts_for_model_id(str(model_id or normalized))
def probe_api_models(
api_key: Optional[str],
base_url: Optional[str],
@ -3160,7 +3064,6 @@ def probe_api_models(
"used_fallback": False,
}
def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]:
"""Fetch available language models with tool-use from AI Gateway."""
api_key = os.getenv("AI_GATEWAY_API_KEY", "").strip()
@ -3190,7 +3093,6 @@ def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]:
except Exception:
return None
def fetch_api_models(
api_key: Optional[str],
base_url: Optional[str],
@ -3204,16 +3106,12 @@ def fetch_api_models(
"""
return probe_api_models(api_key, base_url, timeout=timeout, api_mode=api_mode).get("models")
# ---------------------------------------------------------------------------
# Ollama Cloud — merged model discovery with disk cache
# ---------------------------------------------------------------------------
_OLLAMA_CLOUD_CACHE_TTL = 3600 # 1 hour
def _strip_ollama_cloud_suffix(model_id: str) -> str:
"""Strip :cloud / -cloud suffixes that models.dev appends to Ollama Cloud IDs.
@ -3226,13 +3124,11 @@ def _strip_ollama_cloud_suffix(model_id: str) -> str:
return model_id[: -len(suffix)]
return model_id
def _ollama_cloud_cache_path() -> Path:
"""Return the path for the Ollama Cloud model cache."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "ollama_cloud_models_cache.json"
def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]:
"""Load cached Ollama Cloud models from disk.
@ -3259,7 +3155,6 @@ def _load_ollama_cloud_cache(*, ignore_ttl: bool = False) -> Optional[dict]:
pass
return None
def _save_ollama_cloud_cache(models: list[str]) -> None:
"""Persist the merged Ollama Cloud model list to disk."""
try:
@ -3270,7 +3165,6 @@ def _save_ollama_cloud_cache(models: list[str]) -> None:
except Exception:
pass
def fetch_ollama_cloud_models(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
@ -3337,7 +3231,6 @@ def fetch_ollama_cloud_models(
return []
def validate_requested_model(
model_name: str,
provider: Optional[str],

View File

@ -935,6 +935,113 @@ class PluginContext:
self.manifest.name, name,
)
def register_provider_resolver(
self,
name: str,
resolver: Any,
) -> None:
"""Register a provider resolver callable.
The resolver handles ALL provider-specific client construction
logic for auxiliary tasks. Core's ``resolve_provider_client()``
dispatches to it instead of using per-provider if/elif branches.
Signature::
def resolver(
*,
model: str | None,
explicit_api_key: str | None,
explicit_base_url: str | None,
async_mode: bool,
is_vision: bool,
main_runtime: dict | None,
api_mode: str | None,
) -> tuple[Any, str] | tuple[None, None]:
...
Returns ``(client, default_model)`` or ``(None, None)``.
"""
from agent.plugin_registries import registries
registries.register_provider_resolver(name, resolver)
logger.debug(
"Plugin %s registered provider resolver: %s",
self.manifest.name, name,
)
def register_transport(
self,
api_mode: str,
transport_cls: type,
) -> None:
"""Register a ProviderTransport class for an api_mode string.
This lets the transport registry discover provider transports
from plugins without core needing to import the plugin package.
"""
from agent.plugin_registries import registries
registries._transports[api_mode] = transport_cls
logger.debug(
"Plugin %s registered transport: %s%s",
self.manifest.name, api_mode, transport_cls.__name__,
)
def register_credential_pool_hook(
self,
name: str,
hook: Any,
) -> None:
"""Register a credential pool hook for provider-specific pool operations.
The hook should be a :class:`agent.plugin_registries.CredentialPoolHook`
instance with optional ``sync_from_credentials_file``,
``refresh_oauth``, and ``should_include_in_pool`` callables.
"""
from agent.plugin_registries import registries
registries.register_credential_pool_hook(name, hook)
logger.debug(
"Plugin %s registered credential pool hook: %s",
self.manifest.name, name,
)
def register_pricing_provider(
self,
name: str,
entries: list,
) -> None:
"""Register pricing entries for a provider.
``entries`` should be a list of
:class:`agent.plugin_registries.PricingEntry` instances.
"""
from agent.plugin_registries import registries
registries.register_pricing_provider(name, entries)
logger.debug(
"Plugin %s registered pricing provider: %s (%d entries)",
self.manifest.name, name, len(entries),
)
def register_provider_overlay(
self,
entry: Any,
) -> None:
"""Register a provider overlay entry.
``entry`` should be a :class:`agent.plugin_registries.ProviderOverlayEntry`
instance.
"""
from agent.plugin_registries import registries
registries.register_provider_overlay(entry)
logger.debug(
"Plugin %s registered provider overlay: %s",
self.manifest.name, entry.provider_name,
)
# -- hook registration --------------------------------------------------
# -- auxiliary task registration ---------------------------------------

View File

@ -99,10 +99,8 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
transport="openai_chat",
extra_env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN"),
),
"anthropic": HermesOverlay(
transport="anthropic_messages",
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
),
# "anthropic" overlay moved to plugin: hermes_agent_anthropic register()
# Plugin registers via ctx.register_provider_overlay() and core merges lazily.
"zai": HermesOverlay(
transport="openai_chat",
extra_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
@ -208,17 +206,45 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
),
# Azure Foundry: supports both OpenAI-style and Anthropic-style endpoints.
# The transport is determined at runtime from config.yaml model.api_mode.
"azure-foundry": HermesOverlay(
transport="openai_chat", # default; overridden by api_mode in config
base_url_env_var="AZURE_FOUNDRY_BASE_URL",
),
"bedrock": HermesOverlay(
transport="bedrock_converse",
auth_type="aws_sdk",
),
# "azure-foundry" overlay moved to plugin: hermes_agent_azure register()
# "bedrock" overlay moved to plugin: hermes_agent_bedrock register()
# Plugins register via ctx.register_provider_overlay() and core merges lazily.
}
def _merge_plugin_overlays() -> None:
"""Merge plugin-registered provider overlays into HERMES_OVERLAYS.
Called lazily from ``resolve_provider`` so that plugins have had a
chance to register by the time we need the overlay data.
"""
global _plugin_overlays_merged
if _plugin_overlays_merged:
return
_plugin_overlays_merged = True
try:
from agent.plugin_registries import registries
for _name, _entry in registries.all_provider_overlays().items():
if _name not in HERMES_OVERLAYS:
HERMES_OVERLAYS[_name] = HermesOverlay(
transport=_entry.transport,
is_aggregator=_entry.is_aggregator,
auth_type=_entry.auth_type,
extra_env_vars=_entry.extra_env_vars,
base_url_override=_entry.base_url_override,
base_url_env_var=_entry.base_url_env_var,
)
# Also merge aliases from the plugin overlay entry
for _alias in _entry.aliases:
if _alias not in ALIASES:
ALIASES[_alias] = _name
except Exception:
pass
_plugin_overlays_merged = False
# -- Resolved provider -------------------------------------------------------
# The merged result of models.dev + overlay + user config.
@ -344,11 +370,7 @@ ALIASES: Dict[str, str] = {
"tencent-cloud": "tencent-tokenhub",
"tencentmaas": "tencent-tokenhub",
# bedrock
"aws": "bedrock",
"aws-bedrock": "bedrock",
"amazon-bedrock": "bedrock",
"amazon": "bedrock",
# bedrock aliases moved to plugin: hermes_agent_bedrock register()
# arcee
"arcee-ai": "arcee",
@ -435,6 +457,7 @@ def get_provider(name: str) -> Optional[ProviderDef]:
except Exception:
mdev_info = None
_merge_plugin_overlays()
overlay = HERMES_OVERLAYS.get(canonical)
if mdev_info is not None:

View File

@ -1,79 +1,33 @@
"""hermes-agent-anthropic: Anthropic Messages API adapter for Hermes Agent."""
# -----------------------------------------------------------------------
# Re-exports from adapter.py — SDK-dependent orchestration only.
# Wire-format code (message conversion, aux client wrappers, transport)
# has moved to core and is no longer re-exported here.
# -----------------------------------------------------------------------
from hermes_agent_anthropic.adapter import ( # noqa: F401
ADAPTIVE_EFFORT_MAP,
THINKING_BUDGET,
_ANTHROPIC_DEFAULT_OUTPUT_LIMIT,
_ANTHROPIC_OUTPUT_LIMITS,
_ADAPTIVE_THINKING_SUBSTRINGS,
_CLAUDE_CODE_SYSTEM_PREFIX,
_CLAUDE_CODE_VERSION_FALLBACK,
_COMMON_BETAS,
_CONTEXT_1M_BETA,
_FAST_MODE_BETA,
_FAST_MODE_SUPPORTED_SUBSTRINGS,
_HERMES_OAUTH_FILE,
_KIMI_FAMILY_MODEL_PREFIXES,
_MCP_TOOL_PREFIX,
_NO_SAMPLING_PARAMS_SUBSTRINGS,
_OAUTH_CLIENT_ID,
_OAUTH_ONLY_BETAS,
_OAUTH_REDIRECT_URI,
_OAUTH_SCOPES,
_OAUTH_TOKEN_URL,
_TOOL_STREAMING_BETA,
_XHIGH_EFFORT_SUBSTRINGS,
_build_anthropic_client_with_bearer_hook,
_common_betas_for_base_url,
_content_parts_to_anthropic_blocks,
_convert_assistant_message,
_convert_content_part_to_anthropic,
_convert_content_to_anthropic,
_convert_tool_message_to_result,
_convert_user_message,
_detect_claude_code_version,
_evict_old_screenshots,
_extract_preserved_thinking_blocks,
_forbids_sampling_params,
_generate_pkce,
_get_anthropic_max_output,
_get_anthropic_sdk,
_get_claude_code_version,
_image_source_from_openai_url,
_is_azure_anthropic_endpoint,
_is_bedrock_model_id,
_is_deepseek_anthropic_endpoint,
_is_kimi_coding_endpoint,
_is_kimi_family_endpoint,
_is_minimax_anthropic_endpoint,
_is_oauth_token,
_is_third_party_anthropic_endpoint,
_manage_thinking_signatures,
_merge_consecutive_roles,
_model_name_is_kimi_family,
_normalize_base_url_text,
_normalize_tool_input_schema,
_prefer_refreshable_claude_code_token,
_read_claude_code_credentials_from_keychain,
_refresh_oauth_token,
_requires_bearer_auth,
_resolve_anthropic_messages_max_tokens,
_resolve_claude_code_token_from_credentials,
_resolve_positive_anthropic_max_tokens,
_sanitize_tool_id,
_strip_orphaned_tool_blocks,
_supports_adaptive_thinking,
_supports_fast_mode,
_supports_xhigh_effort,
_write_claude_code_credentials,
_base_url_needs_context_1m_beta,
build_anthropic_bedrock_client,
build_anthropic_client,
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
is_claude_code_token_valid,
normalize_model_name,
read_claude_code_credentials,
read_claude_managed_key,
read_hermes_oauth_credentials,
@ -83,88 +37,53 @@ from hermes_agent_anthropic.adapter import ( # noqa: F401
run_oauth_setup_token,
)
# Re-exports from resolve.py — client resolution & endpoint detection
from hermes_agent_anthropic.resolve import ( # noqa: F401
_ANTHROPIC_DEFAULT_BASE_URL as ANTHROPIC_DEFAULT_BASE_URL,
convert_openai_images_to_anthropic,
endpoint_speaks_anthropic_messages,
is_anthropic_compat_endpoint,
maybe_wrap_anthropic,
resolve_auxiliary_client,
)
def register(ctx):
"""Entry point for the hermes_agent.plugins entry point group."""
from hermes_agent_anthropic import adapter
# Register every public symbol that __init__.py re-exports from adapter
# as a provider service so core code can look them up via the registry
# instead of importing from hermes_agent_anthropic directly.
# -----------------------------------------------------------------------
# Plugin-only symbols — SDK-dependent orchestration that stays in the
# plugin package. Wire-format code (message conversion, aux client
# wrappers, transport) has moved to core (agent.anthropic_format,
# agent.anthropic_aux, agent.transports.anthropic) and is no longer
# registered here.
# -----------------------------------------------------------------------
_symbols = [
"ADAPTIVE_EFFORT_MAP",
"THINKING_BUDGET",
"_ANTHROPIC_DEFAULT_OUTPUT_LIMIT",
"_ANTHROPIC_OUTPUT_LIMITS",
"_ADAPTIVE_THINKING_SUBSTRINGS",
"_CLAUDE_CODE_SYSTEM_PREFIX",
# OAuth / auth constants
"_CLAUDE_CODE_VERSION_FALLBACK",
"_COMMON_BETAS",
"_CONTEXT_1M_BETA",
"_FAST_MODE_BETA",
"_FAST_MODE_SUPPORTED_SUBSTRINGS",
"_HERMES_OAUTH_FILE",
"_KIMI_FAMILY_MODEL_PREFIXES",
"_MCP_TOOL_PREFIX",
"_NO_SAMPLING_PARAMS_SUBSTRINGS",
"_OAUTH_CLIENT_ID",
"_OAUTH_ONLY_BETAS",
"_OAUTH_REDIRECT_URI",
"_OAUTH_SCOPES",
"_OAUTH_TOKEN_URL",
"_TOOL_STREAMING_BETA",
"_XHIGH_EFFORT_SUBSTRINGS",
# SDK-dependent functions
"_build_anthropic_client_with_bearer_hook",
"_common_betas_for_base_url",
"_content_parts_to_anthropic_blocks",
"_convert_assistant_message",
"_convert_content_part_to_anthropic",
"_convert_content_to_anthropic",
"_convert_tool_message_to_result",
"_convert_user_message",
"_detect_claude_code_version",
"_evict_old_screenshots",
"_extract_preserved_thinking_blocks",
"_forbids_sampling_params",
"_generate_pkce",
"_get_anthropic_max_output",
"_get_anthropic_sdk",
"_get_claude_code_version",
"_image_source_from_openai_url",
"_is_azure_anthropic_endpoint",
"_is_bedrock_model_id",
"_is_deepseek_anthropic_endpoint",
"_is_kimi_coding_endpoint",
"_is_kimi_family_endpoint",
"_is_minimax_anthropic_endpoint",
"_is_oauth_token",
"_is_third_party_anthropic_endpoint",
"_manage_thinking_signatures",
"_merge_consecutive_roles",
"_model_name_is_kimi_family",
"_normalize_base_url_text",
"_normalize_tool_input_schema",
"_prefer_refreshable_claude_code_token",
"_read_claude_code_credentials_from_keychain",
"_refresh_oauth_token",
"_requires_bearer_auth",
"_resolve_anthropic_messages_max_tokens",
"_resolve_claude_code_token_from_credentials",
"_resolve_positive_anthropic_max_tokens",
"_sanitize_tool_id",
"_strip_orphaned_tool_blocks",
"_supports_adaptive_thinking",
"_supports_fast_mode",
"_supports_xhigh_effort",
"_write_claude_code_credentials",
"_base_url_needs_context_1m_beta",
"build_anthropic_bedrock_client",
"build_anthropic_client",
"build_anthropic_kwargs",
"convert_messages_to_anthropic",
"convert_tools_to_anthropic",
"is_claude_code_token_valid",
"normalize_model_name",
"read_claude_code_credentials",
"read_claude_managed_key",
"read_hermes_oauth_credentials",
@ -173,6 +92,83 @@ def register(ctx):
"run_hermes_oauth_login_pure",
"run_oauth_setup_token",
]
ctx.register_provider_services("anthropic", {
name: getattr(adapter, name) for name in _symbols
})
# resolve.py symbols — client resolution & endpoint detection
_resolve_symbols = [
"_ANTHROPIC_DEFAULT_BASE_URL",
"_ANTHROPIC_COMPAT_PROVIDERS",
"convert_openai_images_to_anthropic",
"endpoint_speaks_anthropic_messages",
"is_anthropic_compat_endpoint",
"maybe_wrap_anthropic",
"resolve_auxiliary_client",
]
_all_symbols = _symbols + _resolve_symbols
_services = {}
for name in _symbols:
_services[name] = getattr(adapter, name)
for name in _resolve_symbols:
from hermes_agent_anthropic import resolve as _resolve_mod
_services[name] = getattr(_resolve_mod, name)
# Also expose ANTHROPIC_DEFAULT_BASE_URL under the public (no-underscore) name
_services["ANTHROPIC_DEFAULT_BASE_URL"] = _services.get("_ANTHROPIC_DEFAULT_BASE_URL", "")
# Also expose the model name normalizer as a provider service
from hermes_agent_anthropic.pricing import normalize_anthropic_model_name
_services["normalize_model_name"] = normalize_anthropic_model_name
ctx.register_provider_services("anthropic", _services)
# Register the provider resolver — core dispatches to this instead of
# having per-anthropic if/elif branches in resolve_provider_client().
ctx.register_provider_resolver("anthropic", resolve_auxiliary_client)
# Register the anthropic transport so core doesn't need to import it.
from agent.transports.anthropic import AnthropicTransport
ctx.register_transport("anthropic_messages", AnthropicTransport)
# Register the credential pool hook — core dispatches to this instead of
# having per-anthropic if/elif branches in credential_pool.py.
from agent.plugin_registries import CredentialPoolHook
from hermes_agent_anthropic.credential_pool_hook import (
sync_from_credentials_file,
refresh_oauth,
needs_refresh,
should_include_in_pool,
source_priority,
discover_credentials,
ANTHROPIC_ENV_VAR_ORDER,
detect_auth_type,
)
ctx.register_credential_pool_hook("anthropic", CredentialPoolHook(
sync_from_credentials_file=sync_from_credentials_file,
refresh_oauth=refresh_oauth,
needs_refresh=needs_refresh,
should_include_in_pool=should_include_in_pool,
source_priority=source_priority,
discover_credentials=discover_credentials,
env_var_order=ANTHROPIC_ENV_VAR_ORDER,
detect_auth_type=detect_auth_type,
))
# Register pricing entries — core looks these up via the registry
# instead of hardcoding them in _OFFICIAL_DOCS_PRICING.
from hermes_agent_anthropic.pricing import (
get_anthropic_pricing_entries,
ANTHROPIC_PRICING_KEYS,
)
_entries = get_anthropic_pricing_entries()
_keyed = []
for (prov, model), entry in zip(ANTHROPIC_PRICING_KEYS, _entries):
_keyed.append((prov, model, entry))
ctx.register_pricing_provider("anthropic", _keyed)
# Register the provider overlay — core merges this into HERMES_OVERLAYS
from agent.plugin_registries import ProviderOverlayEntry
ctx.register_provider_overlay(ProviderOverlayEntry(
provider_name="anthropic",
transport="anthropic_messages",
extra_env_vars=("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"),
display_name="Anthropic",
aliases=[],
))

View File

@ -1351,915 +1351,3 @@ def _is_bedrock_model_id(model: str) -> bool:
if lower.startswith("anthropic."):
return True
return False
def normalize_model_name(model: str, preserve_dots: bool = False) -> str:
"""Normalize a model name for the Anthropic API.
- Strips 'anthropic/' prefix (OpenRouter format, case-insensitive)
- Converts dots to hyphens in version numbers (OpenRouter uses dots,
Anthropic uses hyphens: claude-opus-4.6 claude-opus-4-6), unless
preserve_dots is True (e.g. for Alibaba/DashScope: qwen3.5-plus).
- Preserves Bedrock model IDs (``anthropic.claude-opus-4-7``) and
regional inference profiles (``us.anthropic.claude-*``) whose dots
are namespace separators, not version separators.
"""
lower = model.lower()
if lower.startswith("anthropic/"):
model = model[len("anthropic/"):]
if not preserve_dots:
# Bedrock model IDs use dots as namespace separators
# (e.g. "anthropic.claude-opus-4-7", "us.anthropic.claude-*").
# These must not be converted to hyphens. See issue #12295.
if _is_bedrock_model_id(model):
return model
# Only convert dots to hyphens for Anthropic/Claude models.
# Non-Anthropic models (gpt-5.4, gemini-2.5, etc.) use dots
# as part of their canonical names. See issue #17171.
_lower = model.lower()
if _lower.startswith("claude-") or _lower.startswith("anthropic/"):
model = model.replace(".", "-")
return model
def _sanitize_tool_id(tool_id: str) -> str:
"""Sanitize a tool call ID for the Anthropic API.
Anthropic requires IDs matching [a-zA-Z0-9_-]. Replace invalid
characters with underscores and ensure non-empty.
"""
import re
if not tool_id:
return "tool_0"
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id)
return sanitized or "tool_0"
def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
"""Normalize tool schemas before sending them to Anthropic.
Anthropic's tool schema validator rejects nullable unions such as
``anyOf: [{"type": "string"}, {"type": "null"}]`` that Pydantic/MCP
commonly emits for optional fields. Tool optionality is represented by
the parent ``required`` array, so we delegate to the shared
``strip_nullable_unions`` helper to collapse nullable unions to the
non-null branch while preserving metadata like description/default.
``keep_nullable_hint=False`` because the Anthropic validator does not
recognize the OpenAPI-style ``nullable: true`` extension and strict
schema-to-grammar converters may reject unknown keywords.
Top-level ``oneOf``/``allOf``/``anyOf`` are also stripped here: the
Anthropic API rejects union keywords at the schema root with a generic
HTTP 400. Several upstream and plugin tools ship schemas with one of
these keywords at the top level (commonly for Pydantic discriminated
unions). If we land here with those keywords still present after
nullable-union stripping, drop them and fall back to a plain object
schema so the tool still validates at the Anthropic boundary.
"""
if not schema:
return {"type": "object", "properties": {}}
from tools.schema_sanitizer import strip_nullable_unions
normalized = strip_nullable_unions(schema, keep_nullable_hint=False)
if not isinstance(normalized, dict):
return {"type": "object", "properties": {}}
# Strip top-level union keywords that Anthropic's validator rejects.
banned = {"oneOf", "allOf", "anyOf"}
if banned & normalized.keys():
normalized = {k: v for k, v in normalized.items() if k not in banned}
if "type" not in normalized:
normalized["type"] = "object"
if normalized.get("type") == "object" and not isinstance(normalized.get("properties"), dict):
normalized = {**normalized, "properties": {}}
return normalized
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
"""Convert OpenAI tool definitions to Anthropic format."""
if not tools:
return []
result = []
seen_names: set = set()
for t in tools:
fn = t.get("function", {})
name = fn.get("name", "")
# Defensive dedup: Anthropic rejects requests with duplicate tool
# names. Upstream injection paths already dedup, but this guard
# converts a hard API failure into a warning. See: #18478
if name and name in seen_names:
logger.warning(
"convert_tools_to_anthropic: duplicate tool name '%s' "
"— dropping second occurrence",
name,
)
continue
if name:
seen_names.add(name)
anthropic_tool: Dict[str, Any] = {
"name": name,
"description": fn.get("description", ""),
"input_schema": _normalize_tool_input_schema(
fn.get("parameters", {"type": "object", "properties": {}})
),
}
# Forward cache_control marker when present on the OpenAI-format
# tool dict. Anthropic's tools array supports cache_control on the
# last tool to cache the entire schema cross-session.
cache_control = t.get("cache_control")
if isinstance(cache_control, dict):
anthropic_tool["cache_control"] = dict(cache_control)
result.append(anthropic_tool)
return result
def _image_source_from_openai_url(url: str) -> Dict[str, str]:
"""Convert an OpenAI-style image URL/data URL into Anthropic image source."""
url = str(url or "").strip()
if not url:
return {"type": "url", "url": ""}
if url.startswith("data:"):
header, _, data = url.partition(",")
media_type = "image/jpeg"
if header.startswith("data:"):
mime_part = header[len("data:"):].split(";", 1)[0].strip()
if mime_part.startswith("image/"):
media_type = mime_part
return {
"type": "base64",
"media_type": media_type,
"data": data,
}
return {"type": "url", "url": url}
def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
"""Convert a single OpenAI-style content part to Anthropic format."""
if part is None:
return None
if isinstance(part, str):
return {"type": "text", "text": part}
if not isinstance(part, dict):
return {"type": "text", "text": str(part)}
ptype = part.get("type")
if ptype == "input_text":
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
elif ptype in {"image_url", "input_image"}:
image_value = part.get("image_url", {})
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
block = {"type": "image", "source": _image_source_from_openai_url(url)}
else:
block = dict(part)
if isinstance(part.get("cache_control"), dict) and "cache_control" not in block:
block["cache_control"] = dict(part["cache_control"])
return block
def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None) -> Any:
"""Recursively convert SDK objects to plain Python data structures.
Guards against circular references (``_path`` tracks ``id()`` of objects
on the *current* recursion path) and runaway depth (capped at 20 levels).
Uses path-based tracking so shared (but non-cyclic) objects referenced by
multiple siblings are converted correctly rather than being stringified.
"""
_MAX_DEPTH = 20
if _depth > _MAX_DEPTH:
return str(value)
if _path is None:
_path = set()
obj_id = id(value)
if obj_id in _path:
return str(value)
if hasattr(value, "model_dump"):
_path.add(obj_id)
result = _to_plain_data(value.model_dump(), _depth=_depth + 1, _path=_path)
_path.discard(obj_id)
return result
if isinstance(value, dict):
_path.add(obj_id)
result = {k: _to_plain_data(v, _depth=_depth + 1, _path=_path) for k, v in value.items()}
_path.discard(obj_id)
return result
if isinstance(value, (list, tuple)):
_path.add(obj_id)
result = [_to_plain_data(v, _depth=_depth + 1, _path=_path) for v in value]
_path.discard(obj_id)
return result
if hasattr(value, "__dict__"):
_path.add(obj_id)
result = {
k: _to_plain_data(v, _depth=_depth + 1, _path=_path)
for k, v in vars(value).items()
if not k.startswith("_")
}
_path.discard(obj_id)
return result
return value
def _extract_preserved_thinking_blocks(message: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return Anthropic thinking blocks previously preserved on the message."""
raw_details = message.get("reasoning_details")
if not isinstance(raw_details, list):
return []
preserved: List[Dict[str, Any]] = []
for detail in raw_details:
if not isinstance(detail, dict):
continue
block_type = str(detail.get("type", "") or "").strip().lower()
if block_type not in {"thinking", "redacted_thinking"}:
continue
preserved.append(copy.deepcopy(detail))
return preserved
def _convert_content_to_anthropic(content: Any) -> Any:
"""Convert OpenAI-style multimodal content arrays to Anthropic blocks."""
if not isinstance(content, list):
return content
converted = []
for part in content:
block = _convert_content_part_to_anthropic(part)
if block is not None:
converted.append(block)
return converted
def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
"""Convert OpenAI-style tool-message content parts → Anthropic tool_result inner blocks.
Used for multimodal tool results (e.g. computer_use screenshots). Each
part is normalized via `_convert_content_part_to_anthropic`, then
filtered to the block types Anthropic tool_result accepts (text + image).
"""
if not isinstance(parts, list):
return []
out: List[Dict[str, Any]] = []
for part in parts:
block = _convert_content_part_to_anthropic(part)
if not block:
continue
btype = block.get("type")
if btype == "text":
text_val = block.get("text")
if isinstance(text_val, str) and text_val:
out.append({"type": "text", "text": text_val})
elif btype == "image":
src = block.get("source")
if isinstance(src, dict) and src:
out.append({"type": "image", "source": src})
return out
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an assistant message to Anthropic content blocks.
Handles thinking blocks, regular content, tool calls, and
reasoning_content injection for Kimi/DeepSeek endpoints.
"""
content = m.get("content", "")
blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):
converted_content = _convert_content_to_anthropic(content)
if isinstance(converted_content, list):
blocks.extend(converted_content)
else:
blocks.append({"type": "text", "text": str(content)})
for tc in m.get("tool_calls", []):
if not tc or not isinstance(tc, dict):
continue
fn = tc.get("function", {})
args = fn.get("arguments", "{}")
try:
parsed_args = json.loads(args) if isinstance(args, str) else args
except (json.JSONDecodeError, ValueError):
parsed_args = {}
blocks.append({
"type": "tool_use",
"id": _sanitize_tool_id(tc.get("id", "")),
"name": fn.get("name", ""),
"input": parsed_args,
})
# Kimi's /coding endpoint (Anthropic protocol) requires assistant
# tool-call messages to carry reasoning_content when thinking is
# enabled server-side. Preserve it as a thinking block so Kimi
# can validate the message history. See hermes-agent#13848.
#
# Accept empty string "" — _copy_reasoning_content_for_api()
# injects "" as a tier-3 fallback for Kimi tool-call messages
# that had no reasoning. Kimi requires the field to exist, even
# if empty.
#
# Prepend (not append): Anthropic protocol requires thinking
# blocks before text and tool_use blocks.
#
# Guard: only add when reasoning_details didn't already contribute
# thinking blocks. On native Anthropic, reasoning_details produces
# signed thinking blocks — adding another unsigned one from
# reasoning_content would create a duplicate (same text) that gets
# downgraded to a spurious text block on the last assistant message.
reasoning_content = m.get("reasoning_content")
_already_has_thinking = any(
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
for b in blocks
)
if isinstance(reasoning_content, str) and not _already_has_thinking:
blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
# Anthropic rejects empty assistant content
effective = blocks or content
if not effective or effective == "":
effective = [{"type": "text", "text": "(empty)"}]
return {"role": "assistant", "content": effective}
def _convert_tool_message_to_result(
result: List[Dict[str, Any]], m: Dict[str, Any]
) -> None:
"""Convert a tool message to an Anthropic tool_result, merging consecutive
results into one user message.
Mutates ``result`` in place either appends a new user message or extends
the trailing user message's tool_result list.
"""
content = m.get("content", "")
multimodal_blocks: Optional[List[Dict[str, Any]]] = None
if isinstance(content, dict) and content.get("_multimodal"):
multimodal_blocks = _content_parts_to_anthropic_blocks(
content.get("content") or []
)
# Fallback text if the conversion produced nothing usable.
if not multimodal_blocks and content.get("text_summary"):
multimodal_blocks = [
{"type": "text", "text": str(content["text_summary"])}
]
elif isinstance(content, list):
converted = _content_parts_to_anthropic_blocks(content)
if any(b.get("type") == "image" for b in converted):
multimodal_blocks = converted
# Back-compat: some callers stash blocks under a private key.
if multimodal_blocks is None:
stashed = m.get("_anthropic_content_blocks")
if isinstance(stashed, list) and stashed:
text_content = content if isinstance(content, str) and content.strip() else None
multimodal_blocks = (
[{"type": "text", "text": text_content}] + stashed
if text_content else list(stashed)
)
if multimodal_blocks:
result_content: Any = multimodal_blocks
elif isinstance(content, str):
result_content = content
else:
result_content = json.dumps(content) if content else "(no output)"
if not result_content:
result_content = "(no output)"
tool_result = {
"type": "tool_result",
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
"content": result_content,
}
if isinstance(m.get("cache_control"), dict):
tool_result["cache_control"] = dict(m["cache_control"])
# Merge consecutive tool results into one user message
if (
result
and result[-1]["role"] == "user"
and isinstance(result[-1]["content"], list)
and result[-1]["content"]
and result[-1]["content"][0].get("type") == "tool_result"
):
result[-1]["content"].append(tool_result)
else:
result.append({"role": "user", "content": [tool_result]})
def _convert_user_message(content: Any) -> Dict[str, Any]:
"""Validate and convert a user message to anthropic format."""
if isinstance(content, list):
converted_blocks = _convert_content_to_anthropic(content)
if not converted_blocks or all(
b.get("text", "").strip() == ""
for b in converted_blocks
if isinstance(b, dict) and b.get("type") == "text"
):
converted_blocks = [{"type": "text", "text": "(empty message)"}]
return {"role": "user", "content": converted_blocks}
else:
if not content or (isinstance(content, str) and not content.strip()):
content = "(empty message)"
return {"role": "user", "content": content}
def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
"""Strip tool_use blocks with no matching tool_result, and vice versa.
Context compression or session truncation can remove either side of a
tool-call pair. Anthropic rejects both orphans with HTTP 400.
Mutates ``result`` in place.
"""
# Strip orphaned tool_use blocks (no matching tool_result follows)
tool_result_ids = set()
for m in result:
if m["role"] == "user" and isinstance(m["content"], list):
for block in m["content"]:
if block.get("type") == "tool_result":
tool_result_ids.add(block.get("tool_use_id"))
for m in result:
if m["role"] == "assistant" and isinstance(m["content"], list):
m["content"] = [
b
for b in m["content"]
if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
]
if not m["content"]:
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
# Strip orphaned tool_result blocks (no matching tool_use precedes them)
tool_use_ids = set()
for m in result:
if m["role"] == "assistant" and isinstance(m["content"], list):
for block in m["content"]:
if block.get("type") == "tool_use":
tool_use_ids.add(block.get("id"))
for m in result:
if m["role"] == "user" and isinstance(m["content"], list):
m["content"] = [
b
for b in m["content"]
if b.get("type") != "tool_result" or b.get("tool_use_id") in tool_use_ids
]
if not m["content"]:
m["content"] = [{"type": "text", "text": "(tool result removed)"}]
def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Merge consecutive same-role messages to enforce Anthropic alternation.
Returns a new list (caller must rebind ``result``).
"""
fixed = []
for m in result:
if fixed and fixed[-1]["role"] == m["role"]:
if m["role"] == "user":
prev_content = fixed[-1]["content"]
curr_content = m["content"]
if isinstance(prev_content, str) and isinstance(curr_content, str):
fixed[-1]["content"] = prev_content + "\n" + curr_content
elif isinstance(prev_content, list) and isinstance(curr_content, list):
fixed[-1]["content"] = prev_content + curr_content
else:
if isinstance(prev_content, str):
prev_content = [{"type": "text", "text": prev_content}]
if isinstance(curr_content, str):
curr_content = [{"type": "text", "text": curr_content}]
fixed[-1]["content"] = prev_content + curr_content
else:
# Consecutive assistant messages — merge text content.
# Drop thinking blocks from the *second* message: their
# signature was computed against a different turn boundary
# and becomes invalid once merged.
if isinstance(m["content"], list):
m["content"] = [
b for b in m["content"]
if not (isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"})
]
prev_blocks = fixed[-1]["content"]
curr_blocks = m["content"]
if isinstance(prev_blocks, list) and isinstance(curr_blocks, list):
fixed[-1]["content"] = prev_blocks + curr_blocks
elif isinstance(prev_blocks, str) and isinstance(curr_blocks, str):
fixed[-1]["content"] = prev_blocks + "\n" + curr_blocks
else:
if isinstance(prev_blocks, str):
prev_blocks = [{"type": "text", "text": prev_blocks}]
if isinstance(curr_blocks, str):
curr_blocks = [{"type": "text", "text": curr_blocks}]
fixed[-1]["content"] = prev_blocks + curr_blocks
else:
fixed.append(m)
return fixed
def _manage_thinking_signatures(
result: List[Dict[str, Any]], base_url: str | None, model: str | None
) -> None:
"""Strip or preserve thinking blocks based on endpoint type.
Anthropic signs thinking blocks against the full turn content.
Any upstream mutation (context compression, session truncation, orphan
stripping, message merging) invalidates the signature, causing HTTP 400
"Invalid signature in thinking block".
Signatures are Anthropic-proprietary. Third-party endpoints (MiniMax,
Azure AI Foundry, AWS Bedrock, self-hosted proxies) cannot validate them
and will reject them outright. Kimi's /coding and DeepSeek's /anthropic
endpoints speak the Anthropic protocol upstream but require unsigned
thinking blocks (synthesised from ``reasoning_content``) to round-trip on
replayed assistant tool-call messages. See hermes-agent#13848 (Kimi) and
hermes-agent#16748 (DeepSeek).
Mutates ``result`` in place.
"""
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
_is_third_party = _is_third_party_anthropic_endpoint(base_url)
# Kimi / DeepSeek share a contract: strip signed Anthropic blocks
# (neither upstream can validate Anthropic signatures), preserve unsigned
# ones synthesised from reasoning_content. See #13848, #16748.
_preserve_unsigned_thinking = (
_is_kimi_family_endpoint(base_url, model)
or _is_deepseek_anthropic_endpoint(base_url)
)
last_assistant_idx = None
for i in range(len(result) - 1, -1, -1):
if result[i].get("role") == "assistant":
last_assistant_idx = i
break
for idx, m in enumerate(result):
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
continue
if _preserve_unsigned_thinking:
# Kimi / DeepSeek: strip signed, preserve unsigned.
new_content = []
for b in m["content"]:
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
new_content.append(b)
continue
if b.get("signature") or b.get("data"):
# Signed (or redacted-with-data) — upstream can't validate, strip.
continue
new_content.append(b)
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
elif _is_third_party or idx != last_assistant_idx:
# Third-party: strip ALL thinking blocks (signatures are proprietary).
# Direct Anthropic: strip from non-latest assistant messages only.
stripped = [
b for b in m["content"]
if not (isinstance(b, dict) and b.get("type") in _THINKING_TYPES)
]
m["content"] = stripped or [{"type": "text", "text": "(thinking elided)"}]
else:
# Latest assistant on direct Anthropic: keep signed, downgrade unsigned
# to text so the reasoning isn't lost.
new_content = []
for b in m["content"]:
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
new_content.append(b)
continue
if b.get("type") == "redacted_thinking":
# Redacted blocks use 'data' for the signature payload —
# drop the block when 'data' is missing (can't be validated).
if b.get("data"):
new_content.append(b)
elif b.get("signature"):
new_content.append(b)
else:
thinking_text = b.get("thinking", "")
if thinking_text:
new_content.append({"type": "text", "text": thinking_text})
m["content"] = new_content or [{"type": "text", "text": "(empty)"}]
# Strip cache_control from any remaining thinking/redacted_thinking
# blocks — cache markers interfere with signature validation.
for b in m["content"]:
if isinstance(b, dict) and b.get("type") in _THINKING_TYPES:
b.pop("cache_control", None)
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
"""Keep only the most recent ``_MAX_KEEP_IMAGES`` computer-use screenshots.
Base64 images cost ~1,465 tokens each and accumulate across tool calls.
Walk backward, keep the most recent N, replace older ones with a placeholder.
Mutates ``result`` in place.
"""
_MAX_KEEP_IMAGES = 3
_image_count = 0
for msg in reversed(result):
content = msg.get("content")
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_result":
continue
inner = block.get("content")
if not isinstance(inner, list):
continue
has_image = any(
isinstance(b, dict) and b.get("type") == "image"
for b in inner
)
if not has_image:
continue
_image_count += 1
if _image_count > _MAX_KEEP_IMAGES:
block["content"] = [
b if b.get("type") != "image"
else {"type": "text", "text": "[screenshot removed to save context]"}
for b in inner
]
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
model: str | None = None,
) -> Tuple[Optional[Any], List[Dict]]:
"""Convert OpenAI-format messages to Anthropic format.
Returns (system_prompt, anthropic_messages).
System messages are extracted since Anthropic takes them as a separate param.
system_prompt is a string or list of content blocks (when cache_control present).
When *base_url* is provided and points to a third-party Anthropic-compatible
endpoint, all thinking block signatures are stripped. Signatures are
Anthropic-proprietary third-party endpoints cannot validate them and will
reject them with HTTP 400 "Invalid signature in thinking block".
When *model* is provided and matches the Kimi / Moonshot family (or
*base_url* is a Kimi / Moonshot host), unsigned thinking blocks
synthesised from ``reasoning_content`` are preserved on replayed
assistant tool-call messages Kimi requires the field to exist, even
if empty.
"""
system = None
result: List[Dict[str, Any]] = []
for m in messages:
role = m.get("role", "user")
content = m.get("content", "")
if role == "system":
if isinstance(content, list):
# Preserve cache_control markers on content blocks
has_cache = any(
p.get("cache_control") for p in content if isinstance(p, dict)
)
if has_cache:
system = [p for p in content if isinstance(p, dict)]
else:
system = "\n".join(
p["text"] for p in content if p.get("type") == "text"
)
else:
system = content
continue
if role == "assistant":
result.append(_convert_assistant_message(m))
continue
if role == "tool":
_convert_tool_message_to_result(result, m)
continue
# Regular user message
result.append(_convert_user_message(content))
_strip_orphaned_tool_blocks(result)
result = _merge_consecutive_roles(result)
_manage_thinking_signatures(result, base_url, model)
_evict_old_screenshots(result)
return system, result
def build_anthropic_kwargs(
model: str,
messages: List[Dict],
tools: Optional[List[Dict]],
max_tokens: Optional[int],
reasoning_config: Optional[Dict[str, Any]],
tool_choice: Optional[str] = None,
is_oauth: bool = False,
preserve_dots: bool = False,
context_length: Optional[int] = None,
base_url: str | None = None,
fast_mode: bool = False,
drop_context_1m_beta: bool = False,
) -> Dict[str, Any]:
"""Build kwargs for anthropic.messages.create().
Naming note two distinct concepts, easily confused:
max_tokens = OUTPUT token cap for a single response.
Anthropic's API calls this "max_tokens" but it only
limits the *output*. Anthropic's own native SDK
renamed it "max_output_tokens" for clarity.
context_length = TOTAL context window (input tokens + output tokens).
The API enforces: input_tokens + max_tokens context_length.
Stored on the ContextCompressor; reduced on overflow errors.
When *max_tokens* is None the model's native output ceiling is used
(e.g. 128K for Opus 4.6, 64K for Sonnet 4.6).
When *context_length* is provided and the model's native output ceiling
exceeds it (e.g. a local endpoint with an 8K window), the output cap is
clamped to context_length 1. This only kicks in for unusually small
context windows; for full-size models the native output cap is always
smaller than the context window so no clamping happens.
NOTE: this clamping does not account for prompt size if the prompt is
large, Anthropic may still reject the request. The caller must detect
"max_tokens too large given prompt" errors and retry with a smaller cap
(see parse_available_output_tokens_from_error + _ephemeral_max_output_tokens).
When *is_oauth* is True, applies Claude Code compatibility transforms:
system prompt prefix, tool name prefixing, and prompt sanitization.
When *preserve_dots* is True, model name dots are not converted to hyphens
(for Alibaba/DashScope anthropic-compatible endpoints: qwen3.5-plus).
When *base_url* points to a third-party Anthropic-compatible endpoint,
thinking block signatures are stripped (they are Anthropic-proprietary).
When *fast_mode* is True, adds ``extra_body["speed"] = "fast"`` and the
fast-mode beta header for ~2.5x faster output throughput on Opus 4.6.
Currently only supported on native Anthropic endpoints (not third-party
compatible ones).
"""
system, anthropic_messages = convert_messages_to_anthropic(
messages, base_url=base_url, model=model
)
anthropic_tools = convert_tools_to_anthropic(tools) if tools else []
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
# rather than 400-ing at the Anthropic API. See openclaw/openclaw#66664.
effective_max_tokens = _resolve_anthropic_messages_max_tokens(
max_tokens, model, context_length=context_length
)
# Clamp output cap to fit inside the total context window.
# Only matters for small custom endpoints where context_length < native
# output ceiling. For standard Anthropic models context_length (e.g.
# 200K) is always larger than the output ceiling (e.g. 128K), so this
# branch is not taken.
if context_length and effective_max_tokens > context_length:
effective_max_tokens = max(context_length - 1, 1)
# ── OAuth: Claude Code identity ──────────────────────────────────
if is_oauth:
# 1. Prepend Claude Code system prompt identity
cc_block = {"type": "text", "text": _CLAUDE_CODE_SYSTEM_PREFIX}
if isinstance(system, list):
system = [cc_block] + system
elif isinstance(system, str) and system:
system = [cc_block, {"type": "text", "text": system}]
else:
system = [cc_block]
# 2. Sanitize system prompt — replace product name references
# to avoid Anthropic's server-side content filters.
for block in system:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
text = text.replace("Hermes Agent", "Claude Code")
text = text.replace("Hermes agent", "Claude Code")
text = text.replace("hermes-agent", "claude-code")
text = text.replace("Nous Research", "Anthropic")
block["text"] = text
# 3. Prefix tool names with mcp_ (Claude Code convention)
# Skip names that already begin with the marker — native MCP server
# tools (from mcp_servers: in config.yaml) are registered under their
# full mcp_<server>_<tool> name and would double-prefix otherwise,
# breaking round-trip registry lookup in normalize_response. GH-25255.
if anthropic_tools:
for tool in anthropic_tools:
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
for msg in anthropic_messages:
content = msg.get("content")
if isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "tool_use" and "name" in block:
if not block["name"].startswith(_MCP_TOOL_PREFIX):
block["name"] = _MCP_TOOL_PREFIX + block["name"]
elif block.get("type") == "tool_result" and "tool_use_id" in block:
pass # tool_result uses ID, not name
kwargs: Dict[str, Any] = {
"model": model,
"messages": anthropic_messages,
"max_tokens": effective_max_tokens,
}
if system:
kwargs["system"] = system
if anthropic_tools:
kwargs["tools"] = anthropic_tools
# Map OpenAI tool_choice to Anthropic format
if tool_choice == "auto" or tool_choice is None:
kwargs["tool_choice"] = {"type": "auto"}
elif tool_choice == "required":
kwargs["tool_choice"] = {"type": "any"}
elif tool_choice == "none":
# Anthropic has no tool_choice "none" — omit tools entirely to prevent use
kwargs.pop("tools", None)
elif isinstance(tool_choice, str):
# Specific tool name
kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
# Map reasoning_config to Anthropic's thinking parameter.
# Claude 4.6+ models use adaptive thinking + output_config.effort.
# Older models use manual thinking with budget_tokens.
# MiniMax Anthropic-compat endpoints support thinking (manual mode only,
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
#
# Kimi's /coding endpoint speaks the Anthropic Messages protocol but has
# its own thinking semantics: when ``thinking.enabled`` is sent, Kimi
# validates the message history and requires every prior assistant
# tool-call message to carry OpenAI-style ``reasoning_content``. The
# Anthropic path never populates that field, and
# ``convert_messages_to_anthropic`` strips all Anthropic thinking blocks
# on third-party endpoints — so the request fails with HTTP 400
# "thinking is enabled but reasoning_content is missing in assistant
# tool call message at index N". Kimi's reasoning is driven server-side
# on the /coding route, so skip Anthropic's thinking parameter entirely
# for that host. (Kimi on chat_completions enables thinking via
# extra_body in the ChatCompletionsTransport — see #13503.)
#
# On 4.7+ the `thinking.display` field defaults to "omitted", which
# silently hides reasoning text that Hermes surfaces in its CLI. We
# request "summarized" so the reasoning blocks stay populated — matching
# 4.6 behavior and preserving the activity-feed UX during long tool runs.
_is_kimi_coding = _is_kimi_family_endpoint(base_url, model)
if reasoning_config and isinstance(reasoning_config, dict) and not _is_kimi_coding:
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
effort = str(reasoning_config.get("effort", "medium")).lower()
budget = THINKING_BUDGET.get(effort, 8000)
if _supports_adaptive_thinking(model):
kwargs["thinking"] = {
"type": "adaptive",
"display": "summarized",
}
adaptive_effort = ADAPTIVE_EFFORT_MAP.get(effort, "medium")
# Downgrade xhigh→max on models that don't list xhigh as a
# supported level (Opus/Sonnet 4.6). Opus 4.7+ keeps xhigh.
if adaptive_effort == "xhigh" and not _supports_xhigh_effort(model):
adaptive_effort = "max"
kwargs["output_config"] = {
"effort": adaptive_effort,
}
else:
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
# Anthropic requires temperature=1 when thinking is enabled on older models
kwargs["temperature"] = 1
kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
# ── Strip sampling params on 4.7+ ─────────────────────────────────
# Opus 4.7 rejects any non-default temperature/top_p/top_k with a 400.
# Callers (auxiliary_client, etc.) may set these for older models;
# drop them here as a safety net so upstream 4.6 → 4.7 migrations
# don't require coordinated edits everywhere.
if _forbids_sampling_params(model):
for _sampling_key in ("temperature", "top_p", "top_k"):
kwargs.pop(_sampling_key, None)
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
# output speed. Per Anthropic docs, fast mode is only supported on
# Opus 4.6 — Opus 4.7 and other models 400 on the speed parameter.
# Only for native Anthropic endpoints — third-party providers would
# reject the unknown beta header and speed parameter.
if (
fast_mode
and not _is_third_party_anthropic_endpoint(base_url)
and _supports_fast_mode(model)
):
kwargs.setdefault("extra_body", {})["speed"] = "fast"
# Build extra_headers with ALL applicable betas (the per-request
# extra_headers override the client-level anthropic-beta header).
betas = list(_common_betas_for_base_url(
base_url,
drop_context_1m_beta=drop_context_1m_beta,
))
if is_oauth:
betas.extend(_OAUTH_ONLY_BETAS)
betas.append(_FAST_MODE_BETA)
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
return kwargs

View File

@ -0,0 +1,227 @@
"""Anthropic credential pool hook.
Handles provider-specific pool operations: syncing from ~/.claude/.credentials.json,
refreshing OAuth tokens, and deciding which sources to include in the pool.
"""
from __future__ import annotations
import logging
import time
from dataclasses import replace
from typing import Any, Optional
logger = logging.getLogger(__name__)
def sync_from_credentials_file(entry: Any) -> Any:
"""Sync a claude_code pool entry from ~/.claude/.credentials.json if tokens differ.
OAuth refresh tokens are single-use. When something external (e.g.
Claude Code CLI, or another profile's pool) refreshes the token, it
writes the new pair to ~/.claude/.credentials.json. The pool entry's
refresh token becomes stale. This method detects that and syncs.
Returns the (possibly updated) entry.
"""
if entry.source != "claude_code":
return entry
try:
from agent.plugin_registries import registries
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
if read_claude_code_credentials is None:
return entry
creds = read_claude_code_credentials()
if not creds:
return entry
file_refresh = creds.get("refreshToken", "")
file_access = creds.get("accessToken", "")
file_expires = creds.get("expiresAt", 0)
if file_refresh and file_refresh != entry.refresh_token:
logger.debug("Pool entry %s: syncing tokens from credentials file (refresh token changed)", entry.id)
return replace(
entry,
access_token=file_access,
refresh_token=file_refresh,
expires_at_ms=file_expires,
last_status=None,
last_status_at=None,
last_error_code=None,
)
except Exception as exc:
logger.debug("Failed to sync from credentials file: %s", exc)
return entry
def refresh_oauth(entry: Any, pool: Any) -> Any:
"""Refresh an anthropic OAuth token and return the updated entry.
Handles:
- Standard OAuth refresh via ``refresh_anthropic_oauth_pure``
- Writing back to ~/.claude/.credentials.json for claude_code entries
- Retry with synced token from credentials file on refresh failure
Returns the updated entry, or the original entry on failure.
"""
from agent.plugin_registries import registries
refresh_anthropic_oauth_pure = registries.get_provider_service("anthropic", "refresh_anthropic_oauth_pure")
if refresh_anthropic_oauth_pure is None:
return entry
try:
refreshed = refresh_anthropic_oauth_pure(
entry.refresh_token,
use_json=entry.source.endswith("hermes_pkce"),
)
updated = replace(
entry,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
)
# Keep ~/.claude/.credentials.json in sync
if entry.source == "claude_code":
try:
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
if _write_claude_code_credentials is not None:
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception as wexc:
logger.debug("Failed to write refreshed token to credentials file: %s", wexc)
return updated
except Exception as exc:
logger.debug("Credential refresh failed for anthropic/%s: %s", entry.id, exc)
# The refresh token may have been consumed by another process.
# Check if ~/.claude/.credentials.json has a newer token pair.
if entry.source == "claude_code":
synced = sync_from_credentials_file(entry)
if synced.refresh_token != entry.refresh_token:
logger.debug("Retrying refresh with synced token from credentials file")
try:
refreshed = refresh_anthropic_oauth_pure(
synced.refresh_token,
use_json=synced.source.endswith("hermes_pkce"),
)
updated = replace(
synced,
access_token=refreshed["access_token"],
refresh_token=refreshed["refresh_token"],
expires_at_ms=refreshed["expires_at_ms"],
last_status="OK",
last_status_at=None,
last_error_code=None,
)
try:
_write_claude_code_credentials = registries.get_provider_service("anthropic", "_write_claude_code_credentials")
if _write_claude_code_credentials is not None:
_write_claude_code_credentials(
refreshed["access_token"],
refreshed["refresh_token"],
refreshed["expires_at_ms"],
)
except Exception:
pass
return updated
except Exception:
pass
return entry
def needs_refresh(entry: Any) -> bool:
"""Check if an anthropic OAuth entry needs a token refresh."""
if entry.expires_at_ms is None:
return False
return int(entry.expires_at_ms) <= int(time.time() * 1000) + 120_000
def should_include_in_pool(source: str) -> bool:
"""Which anthropic credential sources should be pooled."""
return source in {"claude_code", "hermes_pkce"}
def source_priority(source: str) -> int:
"""Priority ordering for anthropic credential sources (lower = preferred)."""
_PRIORITIES = {
"claude_code": 3,
"hermes_pkce": 2,
}
return _PRIORITIES.get(source, 99)
def discover_credentials(entries: list, provider: str, is_suppressed: Any) -> tuple:
"""Discover external anthropic credentials and upsert into pool entries.
Returns (changed: bool, active_sources: set).
"""
from agent.plugin_registries import registries
changed = False
active_sources = set()
# Only auto-discover external credentials (Claude Code, Hermes PKCE)
# when the user has explicitly configured anthropic as their provider.
# Without this gate, auxiliary client fallback chains silently read
# ~/.claude/.credentials.json without user consent. See PR #4210.
try:
from hermes_cli.auth import is_provider_explicitly_configured
if not is_provider_explicitly_configured("anthropic"):
return changed, active_sources
except ImportError:
pass
read_claude_code_credentials = registries.get_provider_service("anthropic", "read_claude_code_credentials")
read_hermes_oauth_credentials = registries.get_provider_service("anthropic", "read_hermes_oauth_credentials")
if read_claude_code_credentials is None or read_hermes_oauth_credentials is None:
return changed, active_sources
# Import pool helpers
try:
from agent.credential_pool import _upsert_entry, label_from_token, AUTH_TYPE_OAUTH
except ImportError:
return changed, active_sources
for source_name, creds in (
("hermes_pkce", read_hermes_oauth_credentials()),
("claude_code", read_claude_code_credentials()),
):
if creds and creds.get("accessToken"):
if is_suppressed(provider, source_name):
continue
active_sources.add(source_name)
changed |= _upsert_entry(
entries,
provider,
source_name,
{
"source": source_name,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": creds.get("accessToken", ""),
"refresh_token": creds.get("refreshToken"),
"expires_at_ms": creds.get("expiresAt"),
"label": label_from_token(creds.get("accessToken", ""), source_name),
},
)
return changed, active_sources
# Env var scan order for anthropic — prefer OAuth tokens over API keys
ANTHROPIC_ENV_VAR_ORDER = [
"ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
]
def detect_auth_type(token: str) -> str:
"""Determine auth type for an anthropic token.
OAuth tokens don't start with 'sk-ant-api'; API keys do.
"""
from agent.credential_pool import AUTH_TYPE_OAUTH, AUTH_TYPE_API_KEY
if not token.startswith("sk-ant-api"):
return AUTH_TYPE_OAUTH
return AUTH_TYPE_API_KEY

View File

@ -0,0 +1,184 @@
"""Anthropic model pricing data.
Official docs snapshot entries for Anthropic Claude models.
Source: https://platform.claude.com/docs/en/about-claude/pricing
"""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import List
def get_anthropic_pricing_entries() -> list:
"""Return official docs pricing entries for Anthropic Claude models."""
from agent.usage_pricing import PricingEntry
_ANTHROPIC_PRICING_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
_ANTHROPIC_PRICING_VER = "anthropic-pricing-2026-05"
return [
PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-opus-4-7")
PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-opus-4-6")
PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-opus-4-5")
PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-sonnet-4-7")
PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-sonnet-4-6")
PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-sonnet-4-5")
PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
cache_read_cost_per_million=Decimal("0.08"),
cache_write_cost_per_million=Decimal("1.00"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-haiku-4-5")
PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("5.00"),
cache_read_cost_per_million=Decimal("0.10"),
cache_write_cost_per_million=Decimal("1.25"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-4-7-sonnet")
PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("5.00"),
cache_read_cost_per_million=Decimal("0.10"),
cache_write_cost_per_million=Decimal("1.25"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-4-6-sonnet")
PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
cache_read_cost_per_million=Decimal("0.30"),
cache_write_cost_per_million=Decimal("3.75"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-4-5-sonnet")
PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-4-7-opus")
PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-4-6-opus")
PricingEntry(
input_cost_per_million=Decimal("5.00"),
output_cost_per_million=Decimal("25.00"),
cache_read_cost_per_million=Decimal("0.50"),
cache_write_cost_per_million=Decimal("6.25"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-4-5-opus")
PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
cache_read_cost_per_million=Decimal("0.08"),
cache_write_cost_per_million=Decimal("1.00"),
source="official_docs_snapshot",
source_url=_ANTHROPIC_PRICING_URL,
pricing_version=_ANTHROPIC_PRICING_VER,
), # key: ("anthropic", "claude-4-5-haiku")
]
# Model name keys for the pricing entries — must match the order above
ANTHROPIC_PRICING_KEYS = [
("anthropic", "claude-opus-4-7"),
("anthropic", "claude-opus-4-6"),
("anthropic", "claude-opus-4-5"),
("anthropic", "claude-sonnet-4-7"),
("anthropic", "claude-sonnet-4-6"),
("anthropic", "claude-sonnet-4-5"),
("anthropic", "claude-haiku-4-5"),
("anthropic", "claude-4-7-sonnet"),
("anthropic", "claude-4-6-sonnet"),
("anthropic", "claude-4-5-sonnet"),
("anthropic", "claude-4-7-opus"),
("anthropic", "claude-4-6-opus"),
("anthropic", "claude-4-5-opus"),
("anthropic", "claude-4-5-haiku"),
]
def normalize_anthropic_model_name(model: str) -> str:
"""Normalize Anthropic model name variants to canonical form.
Handles:
- Dot notation: claude-opus-4.7 claude-opus-4-7
- Short aliases: claude-opus-4.7 claude-opus-4-7
- Strips anthropic/ prefix if present
"""
import re
name = model.lower().strip()
if name.startswith("anthropic/"):
name = name[len("anthropic/"):]
# Normalize dots to dashes in version numbers
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
return name

View File

@ -0,0 +1,312 @@
"""Anthropic provider resolver for auxiliary client construction.
Handles ALL provider-specific logic for building auxiliary clients:
credential resolution (pool, env var, OAuth), client construction,
base URL detection, and transport wrapping.
"""
from __future__ import annotations
import logging
from typing import Any, Optional, Tuple
from utils import base_url_hostname
logger = logging.getLogger(__name__)
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
_ANTHROPIC_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
# ---------------------------------------------------------------------------
# Endpoint detection helpers
# ---------------------------------------------------------------------------
def endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""True if the endpoint at ``base_url`` speaks Anthropic Messages protocol.
Covers:
- Any URL ending in ``/anthropic``
- ``api.kimi.com/coding`` (Kimi Coding Plan)
- ``api.anthropic.com`` (native Anthropic)
"""
normalized = (base_url or "").strip().lower().rstrip("/")
if not normalized:
return False
if normalized.endswith("/anthropic"):
return True
hostname = base_url_hostname(normalized)
if hostname == "api.anthropic.com":
return True
if hostname == "api.kimi.com" and "/coding" in normalized:
return True
return False
def is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
"""Detect if an endpoint expects Anthropic-format content blocks."""
if provider in _ANTHROPIC_COMPAT_PROVIDERS:
return True
url_lower = (base_url or "").lower()
return "/anthropic" in url_lower
def convert_openai_images_to_anthropic(messages: list) -> list:
"""Convert OpenAI ``image_url`` content blocks to Anthropic ``image`` blocks."""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_content = []
changed = False
for block in content:
if block.get("type") == "image_url":
image_url_val = (block.get("image_url") or {}).get("url", "")
if image_url_val.startswith("data:"):
header, _, b64data = image_url_val.partition(",")
media_type = "image/png"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
new_content.append({
"type": "image",
"source": {
"type": "url",
"url": image_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)
return converted
# ---------------------------------------------------------------------------
# Transport wrapping
# ---------------------------------------------------------------------------
def _safe_isinstance(obj: Any, maybe_type: Any) -> bool:
"""Return False instead of raising when a patched symbol is not a type."""
try:
return isinstance(obj, maybe_type)
except TypeError:
return False
def maybe_wrap_anthropic(
client_obj: Any,
model: str,
api_key: str,
base_url: str,
api_mode: Optional[str] = None,
) -> Any:
"""Rewrap a plain OpenAI client in ``AnthropicAuxiliaryClient`` when
the endpoint actually speaks Anthropic Messages.
Returns ``client_obj`` unchanged when it's already a specialized adapter
or the endpoint is OpenAI-wire.
"""
from agent.anthropic_aux import AnthropicAuxiliaryClient
# Already wrapped — don't double-wrap.
if _safe_isinstance(client_obj, AnthropicAuxiliaryClient):
return client_obj
# Check for other specialized adapters we should never re-dispatch.
try:
from agent.auxiliary_client import CodexAuxiliaryClient
if _safe_isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
except ImportError:
pass
try:
from agent.gemini_native_adapter import GeminiNativeClient
if _safe_isinstance(client_obj, GeminiNativeClient):
return client_obj
except ImportError:
pass
try:
from agent.copilot_acp_client import CopilotACPClient
if _safe_isinstance(client_obj, CopilotACPClient):
return client_obj
except ImportError:
pass
# Explicit non-anthropic api_mode wins over URL heuristics.
if api_mode and api_mode != "anthropic_messages":
return client_obj
should_wrap = (
api_mode == "anthropic_messages"
or endpoint_speaks_anthropic_messages(base_url)
)
if not should_wrap:
return client_obj
from agent.plugin_registries import registries
build_anthropic_client = registries.get_provider_service("anthropic", "build_anthropic_client")
if build_anthropic_client is None:
logger.warning(
"Endpoint %s speaks Anthropic Messages but the anthropic SDK is "
"not installed — falling back to OpenAI-wire (will likely 404).",
base_url,
)
return client_obj
try:
real_client = build_anthropic_client(api_key, base_url)
except Exception as exc:
logger.warning(
"Failed to build Anthropic client for %s (%s) — falling back to "
"OpenAI-wire client.", base_url, exc,
)
return client_obj
logger.debug(
"Auxiliary transport: wrapping client in AnthropicAuxiliaryClient "
"(model=%s, base_url=%s, api_mode=%s)",
model, base_url[:60] if base_url else "", api_mode or "auto-detected",
)
return AnthropicAuxiliaryClient(
real_client, model, api_key, base_url, is_oauth=False,
)
# ---------------------------------------------------------------------------
# Pool helpers (thin wrappers over core pool functions)
# ---------------------------------------------------------------------------
def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:
"""Return (pool_exists_for_provider, selected_entry)."""
try:
from agent.credential_pool import load_pool
pool = load_pool(provider)
except Exception as exc:
logger.debug("Auxiliary client: could not load pool for %s: %s", provider, exc)
return False, None
if not pool or not pool.has_credentials():
return False, None
try:
return True, pool.select()
except Exception as exc:
logger.debug("Auxiliary client: could not select pool entry for %s: %s", provider, exc)
return True, None
def _pool_runtime_api_key(entry: Any) -> str:
if entry is None:
return ""
key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "")
return str(key or "").strip()
def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
if entry is None:
return str(fallback or "").strip().rstrip("/")
url = (
getattr(entry, "runtime_base_url", None)
or getattr(entry, "inference_base_url", None)
or getattr(entry, "base_url", None)
or fallback
)
return str(url or "").strip().rstrip("/")
def _get_aux_model_for_provider(provider_id: str) -> str:
"""Return the cheap auxiliary model for a provider."""
try:
from providers import get_provider_profile
_p = get_provider_profile(provider_id)
if _p and _p.default_aux_model:
return _p.default_aux_model
except Exception:
pass
return ""
# ---------------------------------------------------------------------------
# The resolver: called by core's resolve_provider_client()
# ---------------------------------------------------------------------------
def resolve_auxiliary_client(
*,
model: str | None = None,
explicit_api_key: str | None = None,
explicit_base_url: str | None = None,
async_mode: bool = False,
is_vision: bool = False,
main_runtime: dict | None = None,
api_mode: str | None = None,
) -> tuple[Any, str] | tuple[None, None]:
"""Resolve an auxiliary client for the Anthropic provider.
Returns ``(client, default_model)`` or ``(None, None)`` if unavailable.
"""
from agent.plugin_registries import registries
from agent.anthropic_aux import (
AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient,
)
_anthropic = registries.get_provider_namespace("anthropic")
build_anthropic_client = _anthropic.get("build_anthropic_client")
resolve_anthropic_token = _anthropic.get("resolve_anthropic_token")
if build_anthropic_client is None or resolve_anthropic_token is None:
return None, None
pool_present, entry = _select_pool_entry("anthropic")
if pool_present:
if entry is None:
return None, None
token = explicit_api_key or _pool_runtime_api_key(entry)
else:
entry = None
token = explicit_api_key or resolve_anthropic_token()
if not token:
return None, None
# Allow base URL override from config.yaml model.base_url, but only
# when the configured provider is anthropic.
base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL
if explicit_base_url:
base_url = explicit_base_url.strip().rstrip("/")
try:
from hermes_cli.config import load_config
cfg = load_config()
model_cfg = cfg.get("model")
if isinstance(model_cfg, dict):
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
if cfg_provider == "anthropic":
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
if cfg_base_url:
base_url = cfg_base_url
except Exception:
pass
_is_oauth_token = _anthropic.get("_is_oauth_token")
is_oauth = _is_oauth_token(token) if _is_oauth_token else False
default_model = model or _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", default_model, base_url, is_oauth)
try:
real_client = build_anthropic_client(token, base_url)
except ImportError:
return None, None
client = AnthropicAuxiliaryClient(real_client, default_model, token, base_url, is_oauth=is_oauth)
if async_mode:
client = AsyncAnthropicAuxiliaryClient(client)
return client, default_model

View File

@ -4,28 +4,96 @@ Registers the anthropic plugin in the singleton registry before each test
and provides the ``agent`` fixture used by integration tests.
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
class _MinimalCtx:
"""Minimal plugin context that only wires up provider_services."""
def pytest_configure(config):
"""Remove sys.path entries that would shadow the real ``anthropic`` SDK.
pytest adds ``plugins/model-providers/`` to ``sys.path`` because
``plugins/model-providers/anthropic/__init__.py`` (a provider profile)
exists. This makes ``import anthropic`` find the plugin directory
instead of the installed SDK package, causing ``AttributeError:
module 'anthropic' has no attribute 'Anthropic'``.
We remove the conflicting entry, evict any wrong cached import, and
force-import the real SDK so sys.modules["anthropic"] is correct even
after pytest re-adds the conflicting path during collection.
"""
import importlib
_repo_root = Path(__file__).resolve().parent.parent.parent.parent # main/
_bad = str(_repo_root / "plugins" / "model-providers")
while _bad in sys.path:
sys.path.remove(_bad)
# Evict wrong import
if "anthropic" in sys.modules and not hasattr(sys.modules["anthropic"], "Anthropic"):
del sys.modules["anthropic"]
# Force-import the real SDK now (before pytest re-adds the bad path)
# so sys.modules["anthropic"] points to the real package.
try:
import anthropic as _real_anthropic # noqa: F401
if not hasattr(_real_anthropic, "Anthropic"):
raise ImportError("wrong anthropic module loaded")
except ImportError:
# Try explicit import from venv
import importlib.util as _ilu
for _p in sys.path:
_candidate = Path(_p) / "anthropic" / "__init__.py"
if _candidate.exists() and (_candidate.parent / "_client.py").exists():
_spec = _ilu.spec_from_file_location("anthropic", _candidate)
if _spec and _spec.loader:
_mod = _ilu.module_from_spec(_spec)
sys.modules["anthropic"] = _mod
_spec.loader.exec_module(_mod)
break
class _FullCtx:
"""Plugin context that wires up all registry hooks the anthropic plugin uses.
Uses the real registries for provider_services, provider_resolver,
credential_pool_hook, transport, and pricing so plugin internals work
correctly. Everything else is a no-op so the fixture doesn't depend on
parts of the system (platform, TTS, etc.) that aren't under test.
"""
def register_provider_services(self, name, services):
from agent.plugin_registries import registries
registries.register_provider_services(name, services)
# No-ops for all other register_* methods so plugins don't crash.
def register_platform(self, *a, **kw): pass
def register_tool_provider_entry(self, *a, **kw): pass
def register_auth_provider(self, *a, **kw): pass
def register_transport_builder(self, *a, **kw): pass
def register_model_metadata_provider(self, *a, **kw): pass
def register_credential_pool(self, *a, **kw): pass
def register_browser_provider(self, *a, **kw): pass
def register_image_gen_provider(self, *a, **kw): pass
def register_video_gen_provider(self, *a, **kw): pass
def register_provider_resolver(self, name, resolver):
from agent.plugin_registries import registries
registries.register_provider_resolver(name, resolver)
def register_credential_pool_hook(self, name, hook):
from agent.plugin_registries import registries
registries.register_credential_pool_hook(name, hook)
def register_transport(self, api_mode, transport_cls):
from agent.plugin_registries import registries
registries._transports[api_mode] = transport_cls
def register_pricing_provider(self, name, fn):
from agent.plugin_registries import registries
registries.register_pricing_provider(name, fn)
def register_provider_overlay(self, entry):
from agent.plugin_registries import registries
registries.register_provider_overlay(entry)
# Catch-all no-op for every other register_* method (platform, TTS,
# tools, hooks, skills, etc.) so the fixture never crashes when the
# plugin calls something we don't need to wire up for unit tests.
def __getattr__(self, name):
if name.startswith("register_"):
return lambda *a, **kw: None
raise AttributeError(name)
@pytest.fixture(autouse=True)
@ -33,42 +101,46 @@ def _register_anthropic_plugin():
"""Register the real anthropic plugin for the duration of each test,
then restore the registry to its prior state afterwards.
Uses patch.dict so the registry is guaranteed to be restored even if
tests run across conftest scopes in the same process.
Calls the plugin's ``register()`` against a full context so that all
registry hooks (services, resolver, transport, pricing, etc.) are
populated. patch.dict on each affected registry dict guarantees clean
teardown even across conftest scopes.
"""
from unittest.mock import patch
from agent.plugin_registries import registries
# Build a fresh real-plugin namespace by calling register() against a
# collector context, then inject it via patch.dict for isolation.
collected: dict = {}
class _CollectCtx:
def register_provider_services(self, name, services):
if name == "anthropic":
# Go through the real register_provider_services so _LazyRef
# wrappers are created. This makes patch("hermes_agent_anthropic.adapter.X")
# work in plugin tests (the _LazyRef re-reads from the module at call time).
registries.register_provider_services(name, services)
collected.update(registries._provider_services.get(name, {}))
def register_platform(self, *a, **kw): pass
def register_tool_provider_entry(self, *a, **kw): pass
def register_auth_provider(self, *a, **kw): pass
def register_transport_builder(self, *a, **kw): pass
def register_model_metadata_provider(self, *a, **kw): pass
def register_credential_pool(self, *a, **kw): pass
def register_browser_provider(self, *a, **kw): pass
def register_image_gen_provider(self, *a, **kw): pass
def register_video_gen_provider(self, *a, **kw): pass
# Snapshot current state so we can restore after the test.
_prev_services = dict(registries._provider_services)
_prev_resolvers = dict(registries._provider_resolvers)
_prev_cph = dict(registries._credential_pool_hooks)
_prev_transports = dict(registries._transports) if hasattr(registries, "_transports") else {}
_prev_pricing = dict(registries._pricing_providers) if hasattr(registries, "_pricing_providers") else {}
_prev_overlays = dict(registries._provider_overlays) if hasattr(registries, "_provider_overlays") else {}
ctx = _FullCtx()
try:
from hermes_agent_anthropic import register as _reg # type: ignore[import]
_reg(_CollectCtx())
_reg(ctx)
except ImportError:
pass
with patch.dict(registries._provider_services, {"anthropic": collected}):
yield
yield
# Restore — remove keys the plugin added, put back what was there before.
for d, prev in [
(registries._provider_services, _prev_services),
(registries._provider_resolvers, _prev_resolvers),
(registries._credential_pool_hooks, _prev_cph),
]:
d.clear()
d.update(prev)
for attr, prev in [
("_transports", _prev_transports),
("_pricing_providers", _prev_pricing),
("_provider_overlays", _prev_overlays),
]:
if hasattr(registries, attr):
getattr(registries, attr).clear()
getattr(registries, attr).update(prev)
def _make_tool_defs(*names: str) -> list:

View File

@ -13,19 +13,21 @@ from hermes_agent_anthropic.adapter import (
_is_azure_anthropic_endpoint,
_is_oauth_token,
_refresh_oauth_token,
_to_plain_data,
_write_claude_code_credentials,
build_anthropic_client,
build_anthropic_bedrock_client,
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
is_claude_code_token_valid,
normalize_model_name,
read_claude_code_credentials,
resolve_anthropic_token,
run_oauth_setup_token,
)
from agent.anthropic_format import (
_to_plain_data,
build_anthropic_kwargs,
convert_messages_to_anthropic,
convert_tools_to_anthropic,
normalize_model_name,
)
from agent.transports import get_transport
@ -1187,14 +1189,14 @@ class TestBuildAnthropicKwargs:
# Because build_anthropic_kwargs doesn't currently accept sampling
# params through its signature, we exercise the strip behavior by
# calling the internal predicate directly.
from hermes_agent_anthropic.adapter import _forbids_sampling_params
from agent.anthropic_format import _forbids_sampling_params
assert _forbids_sampling_params("claude-opus-4-7") is True
assert _forbids_sampling_params("claude-opus-4-6") is False
assert _forbids_sampling_params("claude-sonnet-4-5") is False
def test_supports_fast_mode_predicate(self):
"""Fast mode is Opus 4.6 only — Opus 4.7 and others must be excluded."""
from hermes_agent_anthropic.adapter import _supports_fast_mode
from agent.anthropic_format import _supports_fast_mode
assert _supports_fast_mode("claude-opus-4-6") is True
assert _supports_fast_mode("anthropic/claude-opus-4-6") is True
assert _supports_fast_mode("claude-opus-4-7") is False
@ -1347,36 +1349,36 @@ class TestBuildAnthropicKwargs:
class TestGetAnthropicMaxOutput:
def test_opus_4_6(self):
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6") == 128_000
def test_opus_4_6_variant(self):
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-opus-4-6:1m:fast") == 128_000
def test_sonnet_4_6(self):
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
def test_sonnet_4_date_stamped(self):
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-sonnet-4-20250514") == 64_000
def test_claude_3_5_sonnet(self):
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
def test_claude_3_opus(self):
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-3-opus-20240229") == 4_096
def test_unknown_future_model(self):
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("claude-ultra-5-20260101") == 128_000
def test_longest_prefix_wins(self):
"""'claude-3-5-sonnet' should match before 'claude-3-5'."""
from hermes_agent_anthropic.adapter import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
# claude-3-5-sonnet (8192) should win over a hypothetical shorter match
assert _get_anthropic_max_output("claude-3-5-sonnet-20241022") == 8_192
@ -1873,7 +1875,7 @@ class TestToolChoice:
# max_tokens resolver — openclaw/openclaw#66664 port
# ---------------------------------------------------------------------------
from hermes_agent_anthropic.adapter import (
from agent.anthropic_format import (
_resolve_positive_anthropic_max_tokens,
_resolve_anthropic_messages_max_tokens,
)

View File

@ -41,7 +41,7 @@ class TestBuildApiKwargsAnthropicMaxTokens:
agent.max_tokens = 4096
agent.reasoning_config = None
with patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build:
with patch("agent.transports.anthropic.build_anthropic_kwargs") as mock_build:
mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096}
agent._build_api_kwargs([{"role": "user", "content": "test"}])
_, kwargs = mock_build.call_args
@ -57,7 +57,7 @@ class TestBuildApiKwargsAnthropicMaxTokens:
agent.max_tokens = None
agent.reasoning_config = None
with patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build:
with patch("agent.transports.anthropic.build_anthropic_kwargs") as mock_build:
mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 16384}
agent._build_api_kwargs([{"role": "user", "content": "test"}])
call_args = mock_build.call_args
@ -83,7 +83,7 @@ class TestAnthropicImageFallback:
with (
patch("tools.vision_tools.vision_analyze_tool", new=AsyncMock(return_value=json.dumps({"success": True, "analysis": "A cat sitting on a chair."}))),
patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build,
patch("agent.transports.anthropic.build_anthropic_kwargs") as mock_build,
):
mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096}
agent._build_api_kwargs(api_messages)
@ -123,7 +123,7 @@ class TestAnthropicImageFallback:
mock_vision = AsyncMock(return_value=json.dumps({"success": True, "analysis": "A small test image."}))
with (
patch("tools.vision_tools.vision_analyze_tool", new=mock_vision),
patch("hermes_agent_anthropic.adapter.build_anthropic_kwargs") as mock_build,
patch("agent.transports.anthropic.build_anthropic_kwargs") as mock_build,
):
mock_build.return_value = {"model": "claude-sonnet-4-20250514", "messages": [], "max_tokens": 4096}
agent._build_api_kwargs(api_messages)

View File

@ -14,8 +14,6 @@ from unittest.mock import MagicMock, AsyncMock, patch
import pytest
from agent.auxiliary_client import (
_try_anthropic,
AnthropicAuxiliaryClient,
resolve_provider_client,
_read_codex_access_token,
_resolve_auto,
@ -23,6 +21,8 @@ from agent.auxiliary_client import (
call_llm,
async_call_llm,
)
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
from agent.anthropic_aux import AnthropicAuxiliaryClient
class TestAnthropicOAuthFlag:
@ -33,7 +33,8 @@ class TestAnthropicOAuthFlag:
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-token")
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build:
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
from agent.anthropic_aux import AnthropicAuxiliaryClient
client, model = _try_anthropic()
assert client is not None
assert isinstance(client, AnthropicAuxiliaryClient)
@ -45,9 +46,10 @@ class TestAnthropicOAuthFlag:
"""Regular API keys (sk-ant-api-*) should create client with is_oauth=False."""
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant-api03-testkey1234"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
patch("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
from agent.anthropic_aux import AnthropicAuxiliaryClient
client, model = _try_anthropic()
assert client is not None
assert isinstance(client, AnthropicAuxiliaryClient)
@ -67,11 +69,11 @@ class TestAnthropicOAuthFlag:
return _Entry()
with (
patch("agent.auxiliary_client.load_pool", return_value=_Pool()),
patch("agent.credential_pool.load_pool", return_value=_Pool()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", side_effect=AssertionError("legacy path should not run")),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()) as mock_build,
):
from agent.auxiliary_client import _try_anthropic
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
client, model = _try_anthropic()
@ -93,10 +95,10 @@ class TestAnthropicExplicitApiKey:
"""_try_anthropic(explicit_api_key) must use the supplied key, not the env fallback."""
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
patch("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic
client, model = _try_anthropic("explicit-pool-key")
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
client, model = _try_anthropic(explicit_api_key="explicit-pool-key")
assert client is not None
assert mock_build.call_args.args[0] == "explicit-pool-key", (
f"Expected explicit_api_key to be passed, got: {mock_build.call_args.args[0]}"
@ -107,9 +109,9 @@ class TestAnthropicExplicitApiKey:
"""Without explicit_api_key, _try_anthropic falls back to resolve_anthropic_token."""
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-fallback-key"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
patch("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
from agent.auxiliary_client import _try_anthropic
from hermes_agent_anthropic.resolve import resolve_auxiliary_client as _try_anthropic
client, model = _try_anthropic()
assert client is not None
assert mock_build.call_args.args[0] == "env-fallback-key"
@ -118,7 +120,7 @@ class TestAnthropicExplicitApiKey:
"""resolve_provider_client(provider='anthropic', explicit_api_key=...) must propagate the key."""
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="env-key"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
patch("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
client, model = resolve_provider_client(
provider="anthropic",
@ -240,9 +242,9 @@ class TestExpiredCodexFallback:
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
"""OAuth-style tokens should get is_oauth=*** (token is not sk-ant-api-*)."""
# Mock resolve_anthropic_token to return an OAuth-style token
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="sk-ant...oken"), \
with patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"), \
patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
patch("hermes_agent_anthropic.resolve._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
client, model = _try_anthropic()
assert client is not None, "Should resolve token"
@ -294,7 +296,7 @@ class TestExpiredCodexFallback:
def test_claude_code_oauth_env_sets_flag(self, monkeypatch):
"""CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True."""
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant...oken")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "eyJhbG...test.sig") # JWT → is_oauth=True
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
with patch("hermes_agent_anthropic.adapter.build_anthropic_client") as mock_build:
mock_build.return_value = MagicMock()
@ -315,7 +317,7 @@ class TestVisionClientFallback:
patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"),
patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="***"),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"),
):
backends = get_available_vision_backends()
@ -326,7 +328,7 @@ class TestVisionClientFallback:
with (
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
patch("hermes_agent_anthropic.adapter.build_anthropic_client", return_value=MagicMock()),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="***"),
patch("hermes_agent_anthropic.adapter.resolve_anthropic_token", return_value="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"),
):
client, model = resolve_provider_client("anthropic")
@ -491,8 +493,8 @@ class TestAuxiliaryAuthRefreshRetry:
"expiresAt": 0,
}),
patch("hermes_agent_anthropic.adapter.refresh_anthropic_oauth_pure", return_value={
"access_token": "***",
"refresh_token": "***",
"access_token": "fresh-token",
"refresh_token": "refresh-token-2",
"expires_at_ms": 9999999999999,
}) as mock_refresh_oauth,
patch("hermes_agent_anthropic.adapter._write_claude_code_credentials") as mock_write,

View File

@ -11,7 +11,7 @@ from typing import Any, Dict, List
class TestAnthropicAdapterMultimodal:
def test_multimodal_envelope_becomes_tool_result_with_image_block(self):
from hermes_agent_anthropic import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
fake_png = "iVBORw0KGgo="
messages = [
@ -51,7 +51,7 @@ class TestAnthropicAdapterMultimodal:
def test_old_screenshots_are_evicted_beyond_max_keep(self):
"""Image blocks in old tool_results get replaced with placeholders."""
from hermes_agent_anthropic import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
fake_png = "iVBORw0KGgo="
@ -115,7 +115,7 @@ class TestAnthropicAdapterMultimodal:
assert len(placeholders) == 2
def test_content_parts_helper_filters_to_text_and_image(self):
from hermes_agent_anthropic import _content_parts_to_anthropic_blocks
from agent.anthropic_format import _content_parts_to_anthropic_blocks
fake_png = "iVBORw0KGgo="
blocks = _content_parts_to_anthropic_blocks([

View File

@ -11,7 +11,7 @@ class TestBuildAnthropicKwargsClamping:
"""
def _build(self, model, max_tokens=None, context_length=None):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
return build_anthropic_kwargs(
model=model,
messages=[{"role": "user", "content": "hi"}],

View File

@ -36,13 +36,19 @@ def _make_fake_anthropic_namespace(build_side_effect=None, build_return=None):
else:
mock_build = MagicMock(return_value=build_return or fake_client)
fake_ns = {"build_anthropic_client": mock_build}
from agent.anthropic_aux import AnthropicAuxiliaryClient as _AC
fake_ns = {
"build_anthropic_client": mock_build,
"AnthropicAuxiliaryClient": _AC,
"AsyncAnthropicAuxiliaryClient": MagicMock(),
}
return fake_ns, fake_client, mock_build
def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():
"""api_mode=anthropic_messages → returns AnthropicAuxiliaryClient, not OpenAI."""
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
from agent.auxiliary_client import _try_custom_endpoint
from agent.anthropic_aux import AnthropicAuxiliaryClient
fake_ns, fake_client, _ = _make_fake_anthropic_namespace()
@ -60,6 +66,7 @@ def test_custom_endpoint_anthropic_messages_builds_anthropic_wrapper():
"agent.plugin_registries.registries",
) as mock_reg:
mock_reg.get_provider_namespace.return_value = fake_ns
mock_reg.get_provider_service.side_effect = lambda p, n: fake_ns.get(n) if p == "anthropic" else None
client, model = _try_custom_endpoint()
assert isinstance(client, AnthropicAuxiliaryClient), (
@ -90,6 +97,7 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
"agent.plugin_registries.registries",
) as mock_reg:
mock_reg.get_provider_namespace.return_value = fake_ns
mock_reg.get_provider_service.side_effect = lambda p, n: fake_ns.get(n) if p == "anthropic" else None
client, model = _try_custom_endpoint()
# Should fall back to an OpenAI-wire client rather than returning
@ -97,13 +105,14 @@ def test_custom_endpoint_anthropic_messages_falls_back_when_sdk_missing():
assert client is not None
assert model == "claude-sonnet-4-6"
# OpenAI client, not AnthropicAuxiliaryClient.
from agent.auxiliary_client import AnthropicAuxiliaryClient
from agent.anthropic_aux import AnthropicAuxiliaryClient
assert not isinstance(client, AnthropicAuxiliaryClient)
def test_custom_endpoint_chat_completions_still_uses_openai_wire():
"""Regression: default path (no api_mode) must remain OpenAI client."""
from agent.auxiliary_client import _try_custom_endpoint, AnthropicAuxiliaryClient
from agent.auxiliary_client import _try_custom_endpoint
from agent.anthropic_aux import AnthropicAuxiliaryClient
with patch(
"agent.auxiliary_client._resolve_custom_runtime",

View File

@ -169,7 +169,7 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
"""Verify build_anthropic_kwargs handles fast_mode parameter."""
def test_fast_mode_adds_speed_and_beta(self):
from hermes_agent_anthropic import build_anthropic_kwargs, _FAST_MODE_BETA
from agent.anthropic_format import build_anthropic_kwargs, _FAST_MODE_BETA
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
@ -185,7 +185,7 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
assert _FAST_MODE_BETA in kwargs["extra_headers"].get("anthropic-beta", "")
def test_fast_mode_off_no_speed(self):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
@ -200,7 +200,7 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
assert "extra_headers" not in kwargs
def test_fast_mode_skipped_for_third_party_endpoint(self):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",
@ -217,7 +217,7 @@ class TestAnthropicFastModeAdapter(unittest.TestCase):
assert "extra_headers" not in kwargs
def test_fast_mode_kwargs_are_safe_for_sdk_unpacking(self):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-opus-4-6",

View File

@ -191,7 +191,7 @@ class TestAnthropicOAuthOutgoingPrefix:
tools registered as ``mcp_<server>_<tool>``). GH-25255."""
def _build(self, tools, is_oauth=True):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
return build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "Hi"}],

View File

@ -0,0 +1,183 @@
"""Tests for the AnthropicMessagesTransport.
Behavioral tests that require the real anthropic transport implementation.
"""
import json
import pytest
from types import SimpleNamespace
from agent.transports import get_transport
from agent.transports.types import NormalizedResponse
@pytest.fixture
def transport():
"""Load the real Anthropic transport by registering the plugin."""
from hermes_agent_anthropic import register as _anthro_register
from agent.plugin_registries import registries
class _Ctx:
def register_transport(self, api_mode, obj):
from agent.transports import register_transport
register_transport(api_mode, obj)
def register_provider_resolver(self, name, fn):
registries.register_provider_resolver(name, fn)
def register_provider_services(self, name, services):
registries.register_provider_services(name, services)
def register_credential_pool_hook(self, name, hook):
registries.register_credential_pool_hook(name, hook)
def register_pricing_provider(self, name, entries):
registries.register_pricing_provider(name, entries)
def register_provider_overlay(self, entry):
registries.register_provider_overlay(entry)
def __getattr__(self, name):
if name.startswith("register_"):
return lambda *a, **kw: None
raise AttributeError(name)
_anthro_register(_Ctx())
return get_transport("anthropic_messages")
class TestAnthropicTransportBehavioral:
# (fixture defined at module level above)
def test_api_mode(self, transport):
assert transport.api_mode == "anthropic_messages"
def test_convert_tools_simple(self, transport):
tools = [{
"type": "function",
"function": {
"name": "test_tool",
"description": "A test",
"parameters": {"type": "object", "properties": {}},
}
}]
result = transport.convert_tools(tools)
assert len(result) == 1
assert result[0]["name"] == "test_tool"
assert "input_schema" in result[0]
def test_validate_response_none(self, transport):
assert transport.validate_response(None) is False
def test_validate_response_empty_content(self, transport):
r = SimpleNamespace(content=[])
assert transport.validate_response(r) is False
def test_validate_response_empty_content_with_end_turn_is_valid(self, transport):
r = SimpleNamespace(content=[], stop_reason="end_turn")
assert transport.validate_response(r) is True
def test_validate_response_empty_content_with_tool_use_is_invalid(self, transport):
r = SimpleNamespace(content=[], stop_reason="tool_use")
assert transport.validate_response(r) is False
def test_validate_response_valid(self, transport):
r = SimpleNamespace(content=[SimpleNamespace(type="text", text="hello")])
assert transport.validate_response(r) is True
def test_map_finish_reason(self, transport):
assert transport.map_finish_reason("end_turn") == "stop"
assert transport.map_finish_reason("tool_use") == "tool_calls"
assert transport.map_finish_reason("max_tokens") == "length"
assert transport.map_finish_reason("stop_sequence") == "stop"
assert transport.map_finish_reason("refusal") == "content_filter"
assert transport.map_finish_reason("model_context_window_exceeded") == "length"
assert transport.map_finish_reason("unknown") == "stop"
def test_extract_cache_stats_none_usage(self, transport):
r = SimpleNamespace(usage=None)
assert transport.extract_cache_stats(r) is None
def test_extract_cache_stats_with_cache(self, transport):
usage = SimpleNamespace(cache_read_input_tokens=100, cache_creation_input_tokens=50)
r = SimpleNamespace(usage=usage)
result = transport.extract_cache_stats(r)
assert result == {"cached_tokens": 100, "creation_tokens": 50}
def test_extract_cache_stats_zero(self, transport):
usage = SimpleNamespace(cache_read_input_tokens=0, cache_creation_input_tokens=0)
r = SimpleNamespace(usage=usage)
assert transport.extract_cache_stats(r) is None
def test_normalize_response_text(self, transport):
"""Test normalization of a simple text response."""
r = SimpleNamespace(
content=[SimpleNamespace(type="text", text="Hello world")],
stop_reason="end_turn",
usage=SimpleNamespace(input_tokens=10, output_tokens=5),
model="claude-sonnet-4-6",
)
nr = transport.normalize_response(r)
assert isinstance(nr, NormalizedResponse)
assert nr.content == "Hello world"
assert nr.tool_calls is None or nr.tool_calls == []
assert nr.finish_reason == "stop"
def test_normalize_response_tool_calls(self, transport):
"""Test normalization of a tool-use response."""
r = SimpleNamespace(
content=[
SimpleNamespace(
type="tool_use",
id="toolu_123",
name="terminal",
input={"command": "ls"},
),
],
stop_reason="tool_use",
usage=SimpleNamespace(input_tokens=10, output_tokens=20),
model="claude-sonnet-4-6",
)
nr = transport.normalize_response(r)
assert nr.finish_reason == "tool_calls"
assert len(nr.tool_calls) == 1
tc = nr.tool_calls[0]
assert tc.name == "terminal"
assert tc.id == "toolu_123"
assert '"command"' in tc.arguments
def test_normalize_response_thinking(self, transport):
"""Test normalization preserves thinking content."""
r = SimpleNamespace(
content=[
SimpleNamespace(type="thinking", thinking="Let me think..."),
SimpleNamespace(type="text", text="The answer is 42"),
],
stop_reason="end_turn",
usage=SimpleNamespace(input_tokens=10, output_tokens=15),
model="claude-sonnet-4-6",
)
nr = transport.normalize_response(r)
assert nr.content == "The answer is 42"
assert nr.reasoning == "Let me think..."
def test_build_kwargs_returns_dict(self, transport):
"""Test build_kwargs produces a usable kwargs dict."""
messages = [{"role": "user", "content": "Hello"}]
kw = transport.build_kwargs(
model="claude-sonnet-4-6",
messages=messages,
max_tokens=1024,
)
assert isinstance(kw, dict)
assert "model" in kw
assert "max_tokens" in kw
assert "messages" in kw
def test_convert_messages_extracts_system(self, transport):
"""Test convert_messages separates system from messages."""
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi"},
]
system, msgs = transport.convert_messages(messages)
# System should be extracted
assert system is not None
# Messages should only have user
assert len(msgs) >= 1

View File

@ -38,7 +38,7 @@ class TestDeepSeekAnthropicPreservesThinking:
)
def test_unsigned_thinking_block_survives_replay(self, base_url: str) -> None:
"""Unsigned thinking (synthesised from reasoning_content) must be preserved."""
from hermes_agent_anthropic import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@ -75,7 +75,7 @@ class TestDeepSeekAnthropicPreservesThinking:
def test_unsigned_thinking_preserved_on_non_latest_assistant_turn(self) -> None:
"""DeepSeek validates history across every prior assistant turn, not just last."""
from hermes_agent_anthropic import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "q1"},
@ -125,7 +125,7 @@ class TestDeepSeekAnthropicPreservesThinking:
DeepSeek issues its own signatures and cannot validate Anthropic's —
the strip-signed / keep-unsigned split matches the Kimi policy.
"""
from hermes_agent_anthropic import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@ -163,7 +163,7 @@ class TestDeepSeekAnthropicPreservesThinking:
as ignored cache markers interfere with signature validation on
upstreams that do check them, so Hermes strips them everywhere.
"""
from hermes_agent_anthropic import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},
@ -200,7 +200,7 @@ class TestDeepSeekAnthropicPreservesThinking:
detector should still fail closed so an accidental misuse doesn't
quietly send signed Anthropic blocks to an OpenAI endpoint.
"""
from hermes_agent_anthropic import _is_deepseek_anthropic_endpoint
from agent.anthropic_format import _is_deepseek_anthropic_endpoint
assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com") is False
assert _is_deepseek_anthropic_endpoint("https://api.deepseek.com/v1") is False
@ -211,7 +211,7 @@ class TestDeepSeekAnthropicPreservesThinking:
"""MiniMax and other third-party Anthropic endpoints must keep the
generic strip-all behaviour (they reject unsigned blocks outright).
"""
from hermes_agent_anthropic import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},

View File

@ -37,7 +37,7 @@ class TestKimiCodingSkipsAnthropicThinking:
],
)
def test_kimi_coding_endpoint_omits_thinking(self, base_url: str) -> None:
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@ -54,7 +54,7 @@ class TestKimiCodingSkipsAnthropicThinking:
assert "output_config" not in kwargs
def test_kimi_coding_with_explicit_disabled_also_omits(self) -> None:
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@ -68,7 +68,7 @@ class TestKimiCodingSkipsAnthropicThinking:
def test_non_kimi_third_party_still_gets_thinking(self) -> None:
"""MiniMax and other third-party Anthropic endpoints must retain thinking."""
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
@ -82,7 +82,7 @@ class TestKimiCodingSkipsAnthropicThinking:
assert kwargs["thinking"]["type"] == "enabled"
def test_native_anthropic_still_gets_thinking(self) -> None:
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",
@ -105,7 +105,7 @@ class TestKimiCodingSkipsAnthropicThinking:
suppression must apply to every Kimi host, not just ``/coding``.
See #17057.
"""
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="kimi-k2.5",
@ -136,7 +136,7 @@ class TestKimiCodingSkipsAnthropicThinking:
self, base_url: str, model: str
) -> None:
"""Custom / proxied Kimi endpoints must also strip Anthropic thinking."""
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model=model,
@ -159,7 +159,7 @@ class TestKimiCodingSkipsAnthropicThinking:
Guards against over-broad model-family matching only model names
starting with a Kimi/Moonshot prefix should trigger suppression.
"""
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
@ -177,7 +177,7 @@ class TestKimiCodingSkipsAnthropicThinking:
blocks must survive the third-party signature-stripping pass so
the upstream's message-history validation passes.
"""
from hermes_agent_anthropic import convert_messages_to_anthropic
from agent.anthropic_format import convert_messages_to_anthropic
messages = [
{"role": "user", "content": "hi"},

View File

@ -32,7 +32,7 @@ class TestMinimaxThinkingSupport:
"""
def test_minimax_m27_gets_manual_thinking(self):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.7",
messages=[{"role": "user", "content": "hello"}],
@ -47,7 +47,7 @@ class TestMinimaxThinkingSupport:
assert "output_config" not in kwargs
def test_minimax_m25_gets_manual_thinking(self):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="MiniMax-M2.5",
messages=[{"role": "user", "content": "hello"}],
@ -59,7 +59,7 @@ class TestMinimaxThinkingSupport:
assert kwargs["thinking"]["type"] == "enabled"
def test_thinking_still_works_for_claude(self):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "hello"}],
@ -158,26 +158,26 @@ class TestMinimaxBetaHeaders:
# -- _common_betas_for_base_url unit tests ---------------------------
def test_common_betas_none_url(self):
from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS
from agent.anthropic_format import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url(None) == _COMMON_BETAS
def test_common_betas_empty_url(self):
from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS
from agent.anthropic_format import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url("") == _COMMON_BETAS
def test_common_betas_minimax_url(self):
from hermes_agent_anthropic import _common_betas_for_base_url, _TOOL_STREAMING_BETA
from agent.anthropic_format import _common_betas_for_base_url, _TOOL_STREAMING_BETA
betas = _common_betas_for_base_url("https://api.minimax.io/anthropic")
assert _TOOL_STREAMING_BETA not in betas
assert len(betas) > 0 # still has other betas
def test_common_betas_minimax_cn_url(self):
from hermes_agent_anthropic import _common_betas_for_base_url, _TOOL_STREAMING_BETA
from agent.anthropic_format import _common_betas_for_base_url, _TOOL_STREAMING_BETA
betas = _common_betas_for_base_url("https://api.minimaxi.com/anthropic")
assert _TOOL_STREAMING_BETA not in betas
def test_common_betas_regular_url(self):
from hermes_agent_anthropic import _common_betas_for_base_url, _COMMON_BETAS
from agent.anthropic_format import _common_betas_for_base_url, _COMMON_BETAS
assert _common_betas_for_base_url("https://api.anthropic.com") == _COMMON_BETAS
@ -222,19 +222,19 @@ class TestMinimaxMaxOutput:
"""
def test_minimax_m27_output_limit(self):
from hermes_agent_anthropic import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.7") == 131_072
def test_minimax_m25_output_limit(self):
from hermes_agent_anthropic import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2.5") == 131_072
def test_minimax_m2_output_limit(self):
from hermes_agent_anthropic import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
assert _get_anthropic_max_output("MiniMax-M2") == 131_072
def test_claude_output_unaffected(self):
from hermes_agent_anthropic import _get_anthropic_max_output
from agent.anthropic_format import _get_anthropic_max_output
# Sanity: Claude limits are not broken by the MiniMax entry
assert _get_anthropic_max_output("claude-sonnet-4-6") == 64_000
@ -301,21 +301,21 @@ class TestMinimaxPreserveDots:
assert AIAgent._anthropic_preserve_dots(agent) is True
def test_normalize_preserves_m25_free_dot(self):
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name("minimax-m2.5-free", preserve_dots=True) == "minimax-m2.5-free"
def test_normalize_preserves_m27_dot(self):
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"
def test_normalize_preserves_non_anthropic_dots_without_preserve(self):
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
# Non-Anthropic model families use dots as canonical version separators;
# only Claude/Anthropic names are hyphen-normalized by default.
assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2.7"
def test_normalize_still_converts_claude_dots_without_preserve(self):
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name("claude-opus-4.6", preserve_dots=False) == "claude-opus-4-6"

View File

@ -40,3 +40,18 @@ def register(ctx):
"_require_azure_identity": adapter._require_azure_identity,
"describe_active_credential": adapter.describe_active_credential,
})
# Register the provider resolver — core dispatches to this instead of
# having a per-azure-foundry if/elif branch in resolve_provider_client().
from hermes_agent_azure.resolve import resolve_auxiliary_client as _azure_resolver
ctx.register_provider_resolver("azure-foundry", _azure_resolver)
# Register the provider overlay — core merges this into HERMES_OVERLAYS
from agent.plugin_registries import ProviderOverlayEntry
ctx.register_provider_overlay(ProviderOverlayEntry(
provider_name="azure-foundry",
transport="openai_chat", # default; overridden by api_mode in config
base_url_env_var="AZURE_FOUNDRY_BASE_URL",
display_name="Azure AI Foundry",
aliases=[],
))

View File

@ -0,0 +1,131 @@
"""Azure Foundry provider resolver for auxiliary client construction.
Handles ALL provider-specific logic for building auxiliary clients:
Entra ID auth, static API key, base URL resolution, api_mode routing
(chat_completions, codex_responses, anthropic_messages).
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from urllib.parse import parse_qs, urlparse, urlunparse
logger = logging.getLogger(__name__)
def _extract_url_query_params(url: str):
"""Extract query params from URL, return (clean_url, default_query dict or None)."""
parsed = urlparse(url)
if parsed.query:
clean = urlunparse(parsed._replace(query=""))
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
return clean, params
return url, None
def _normalize_resolved_model(model: str, provider: str) -> str:
"""Normalize model name for a given provider."""
return str(model or "").strip()
def resolve_auxiliary_client(
*,
model: str | None = None,
explicit_api_key: str | None = None,
explicit_base_url: str | None = None,
async_mode: bool = False,
is_vision: bool = False,
main_runtime: dict | None = None,
api_mode: str | None = None,
) -> tuple[Any, str] | tuple[None, None]:
"""Resolve an Azure Foundry auxiliary client via the runtime resolver.
Mirrors the anthropic/bedrock resolver shape but delegates to
``hermes_cli.runtime_provider._resolve_azure_foundry_runtime``
the same resolver the main agent uses so:
* ``auth_mode: api_key`` (default) gets the static
``AZURE_FOUNDRY_API_KEY`` string.
* ``auth_mode: entra_id`` gets a callable bearer-token provider
(``Callable[[], str]`` from the azure identity adapter).
* Per-model ``api_mode`` auto-routing for GPT-5.x / o-series /
codex models works.
* ``model.entra.{tenant_id,client_id,authority,scope}`` config
fields propagate.
* Non-default ``model.base_url`` overrides are honored.
Returns ``(client, model)`` or ``(None, None)`` on failure.
"""
from openai import OpenAI
try:
from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime
from hermes_cli.auth import AuthError
from hermes_cli.config import load_config
except ImportError:
return None, None
try:
cfg = load_config()
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
if not isinstance(model_cfg, dict):
model_cfg = {}
except Exception:
model_cfg = {}
try:
runtime = _resolve_azure_foundry_runtime(
requested_provider="azure-foundry",
model_cfg=model_cfg,
explicit_api_key=explicit_api_key,
explicit_base_url=explicit_base_url,
target_model=model,
)
except AuthError as exc:
logger.debug("Auxiliary azure-foundry: %s", exc)
return None, None
except Exception as exc:
logger.debug("Auxiliary azure-foundry runtime error: %s", exc)
return None, None
api_key = runtime.get("api_key")
base_url = str(runtime.get("base_url", "") or "")
runtime_api_mode = api_mode or runtime.get("api_mode") or "chat_completions"
_has_key = bool(api_key) if not callable(api_key) else True
if not _has_key or not base_url:
return None, None
final_model = _normalize_resolved_model(
model or str(model_cfg.get("default") or ""),
"azure-foundry",
)
if not final_model:
logger.debug(
"Auxiliary azure-foundry: no model resolved (model=%r, default=%r)",
model, model_cfg.get("default"),
)
return None, None
extra: dict[str, Any] = {}
_clean_base, _dq = _extract_url_query_params(base_url)
if _dq:
extra["default_query"] = _dq
client = OpenAI(api_key=api_key, base_url=_clean_base, **extra)
if runtime_api_mode == "codex_responses":
from agent.auxiliary_client import CodexAuxiliaryClient
return CodexAuxiliaryClient(client, final_model), final_model
if runtime_api_mode == "anthropic_messages":
from agent.plugin_registries import registries
maybe_wrap = registries.get_provider_service("anthropic", "maybe_wrap_anthropic")
if maybe_wrap is not None:
return maybe_wrap(
client, final_model, api_key,
base_url, runtime_api_mode,
), final_model
return client, final_model

View File

@ -0,0 +1,71 @@
"""Shared fixtures for azure-foundry plugin tests.
Registers the azure plugin in the singleton registry before each test.
"""
import pytest
class _FullCtx:
"""Plugin context that wires up all registry hooks."""
def register_provider_services(self, name, services):
from agent.plugin_registries import registries
registries.register_provider_services(name, services)
def register_provider_resolver(self, name, resolver):
from agent.plugin_registries import registries
registries.register_provider_resolver(name, resolver)
def register_credential_pool_hook(self, name, hook):
from agent.plugin_registries import registries
registries.register_credential_pool_hook(name, hook)
def register_transport(self, api_mode, transport_cls):
from agent.plugin_registries import registries
registries._transports[api_mode] = transport_cls
def register_pricing_provider(self, name, entries):
from agent.plugin_registries import registries
registries.register_pricing_provider(name, entries)
def register_provider_overlay(self, entry):
from agent.plugin_registries import registries
registries.register_provider_overlay(entry)
def __getattr__(self, name):
if name.startswith("register_"):
return lambda *a, **kw: None
raise AttributeError(name)
@pytest.fixture(autouse=True)
def _register_azure_plugin():
"""Register the real azure plugin for the duration of each test."""
from agent.plugin_registries import registries
_prev_services = dict(registries._provider_services)
_prev_resolvers = dict(registries._provider_resolvers)
_prev_cph = dict(registries._credential_pool_hooks)
ctx = _FullCtx()
try:
from hermes_agent_azure import register as _reg
_reg(ctx)
except ImportError:
pass
# azure-foundry tests for Anthropic Messages mode need the anthropic plugin too
try:
from hermes_agent_anthropic import register as _anthro_reg
_anthro_reg(ctx)
except ImportError:
pass
yield
for d, prev in [
(registries._provider_services, _prev_services),
(registries._provider_resolvers, _prev_resolvers),
(registries._credential_pool_hooks, _prev_cph),
]:
d.clear()
d.update(prev)

View File

@ -44,7 +44,7 @@ def _reset_credential_cache():
def fake_azure_identity(monkeypatch):
"""Stand-in for azure.identity (keeps CI hermetic when the SDK is
not installed)."""
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
last = {"scope": None}
@ -300,6 +300,7 @@ class TestResolveProviderClientAzureFoundry:
``resolve_api_key_provider_credentials`` and return None for
Entra users."""
from agent import auxiliary_client as _aux
import openai as _openai_mod
received = {}
@ -309,7 +310,7 @@ class TestResolveProviderClientAzureFoundry:
self.api_key = kwargs.get("api_key", "")
self.base_url = kwargs.get("base_url", "")
monkeypatch.setattr(_aux, "OpenAI", _FakeOpenAI)
monkeypatch.setattr(_openai_mod, "OpenAI", _FakeOpenAI)
patch_load_config({
"provider": "azure-foundry",
"base_url": "https://r.openai.azure.com/openai/v1",

View File

@ -41,7 +41,7 @@ def _reset_credential_cache():
def fake_azure_identity(monkeypatch):
"""Identical fake to test_azure_identity_adapter — keeps Azure SDK
out of these tests so they run in CI without the package installed."""
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
last = {"scope": None, "kwargs": None, "credential_count": 0}
@ -340,10 +340,12 @@ class TestAzureFoundryAuthStatus:
# Patch has_azure_identity_installed to True; do NOT patch the
# token provider — if the code path tried to mint, the SDK
# missing would raise.
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
lambda: True,
)
# NOTE: _get_azure_foundry_auth_status reads from the plugin
# registry, not directly from the adapter module, so we must
# patch the registry entry.
from agent.plugin_registries import registries
_azure_ns = registries._provider_services.setdefault("azure", {})
_azure_ns["has_azure_identity_installed"] = lambda: True
info = _auth._get_azure_foundry_auth_status()
assert info["logged_in"] is True
assert info["auth_mode"] == "entra_id"
@ -363,7 +365,7 @@ class TestAzureFoundryAuthStatus:
},
)
monkeypatch.setattr(
"agent.azure_identity_adapter.has_azure_identity_installed",
"hermes_agent_azure.adapter.has_azure_identity_installed",
lambda: False,
)
info = _auth._get_azure_foundry_auth_status()

View File

@ -195,7 +195,7 @@ class TestBuildBearerHttpClient:
"api-key": "leaked-placeholder",
},
)
with caplog.at_level(logging.WARNING, logger="agent.azure_identity_adapter"):
with caplog.at_level(logging.WARNING, logger="hermes_agent_azure.adapter"):
hook(req) # Must not raise.
# Pre-set auth headers stripped — no sentinel makes it to Azure.
assert "Authorization" not in req.headers
@ -352,7 +352,7 @@ def fake_azure_identity(monkeypatch):
# The adapter's `_require_azure_identity` does its own import, so
# patch that too to make sure tests never hit the real package's
# singleton state.
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
return fake
@ -459,7 +459,7 @@ class TestRequireAzureIdentityMissing:
def test_clear_error_when_azure_identity_missing(self, monkeypatch):
"""When azure-identity isn't importable, the adapter must raise
ImportError with an actionable message."""
from hermes_agent_azure import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
# Force the import path to fail.
original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__
@ -484,7 +484,7 @@ class TestRequireAzureIdentityMissing:
class TestHasAzureIdentityCredentials:
def test_returns_false_when_package_missing_and_install_disabled(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
assert _adapter.has_azure_identity_credentials(
"https://x/.default", allow_install=False,
@ -495,7 +495,7 @@ class TestHasAzureIdentityCredentials:
lazy-install path before bailing otherwise the wizard's
``preflight`` would silently fail for fresh installs that haven't
run ``pip install azure-identity`` yet."""
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
installed = {"called": False}
@ -536,7 +536,7 @@ class TestHasAzureIdentityCredentials:
assert has_azure_identity_credentials("https://x/.default", timeout_seconds=0.5) is True
def test_returns_false_when_get_token_raises(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
def _failing_credential(_config):
class _Cred:
@ -551,7 +551,7 @@ class TestHasAzureIdentityCredentials:
def test_returns_false_on_timeout(self, monkeypatch):
"""Slow IMDS / network must time out, not hang the caller."""
import threading
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
slow_release = threading.Event()
@ -581,7 +581,7 @@ class TestHasAzureIdentityCredentials:
class TestDescribeActiveCredential:
def test_reports_not_installed(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
info = _adapter.describe_active_credential(
scope="https://x/.default", allow_install=False,
@ -593,7 +593,7 @@ class TestDescribeActiveCredential:
def test_reports_install_failure(self, monkeypatch):
"""When lazy install is allowed but fails (e.g. lazy installs
disabled), the diagnostic surfaces the failure as the error."""
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
def _fail_install():
@ -632,7 +632,7 @@ class TestDescribeActiveCredential:
assert any("EnvironmentCredential" in s for s in sources)
def test_reports_error_on_chain_failure(self, monkeypatch):
from agent import azure_identity_adapter as _adapter
from hermes_agent_azure import adapter as _adapter
def _failing_credential(_config):
class _Cred:

View File

@ -92,3 +92,34 @@ def register(ctx):
"_extract_provider_from_arn": adapter._extract_provider_from_arn,
"_traceback_frames_modules": adapter._traceback_frames_modules,
})
# Register the provider resolver — core dispatches to this instead of
# having per-bedrock if/elif branches in resolve_provider_client().
from hermes_agent_bedrock.resolve import resolve_auxiliary_client as _bedrock_resolver
ctx.register_provider_resolver("bedrock", _bedrock_resolver)
# Register the bedrock transport so core doesn't need to import it.
from hermes_agent_bedrock.transport import BedrockTransport
ctx.register_transport("bedrock_converse", BedrockTransport)
# Register pricing entries — core looks these up via the registry
# instead of hardcoding them in _OFFICIAL_DOCS_PRICING.
from hermes_agent_bedrock.pricing import (
get_bedrock_pricing_entries,
BEDROCK_PRICING_KEYS,
)
_entries = get_bedrock_pricing_entries()
_keyed = []
for (prov, model), entry in zip(BEDROCK_PRICING_KEYS, _entries):
_keyed.append((prov, model, entry))
ctx.register_pricing_provider("bedrock", _keyed)
# Register the provider overlay — core merges this into HERMES_OVERLAYS
from agent.plugin_registries import ProviderOverlayEntry
ctx.register_provider_overlay(ProviderOverlayEntry(
provider_name="bedrock",
transport="bedrock_converse",
auth_type="aws_sdk",
display_name="AWS Bedrock",
aliases=["aws", "aws-bedrock", "amazon-bedrock", "amazon"],
))

View File

@ -0,0 +1,80 @@
"""Bedrock model pricing data.
Official docs snapshot entries for AWS Bedrock models.
Source: https://aws.amazon.com/bedrock/pricing/
"""
from __future__ import annotations
from decimal import Decimal
def get_bedrock_pricing_entries() -> list:
"""Return official docs pricing entries for Bedrock models."""
from agent.usage_pricing import PricingEntry
_BEDROCK_PRICING_URL = "https://aws.amazon.com/bedrock/pricing/"
_BEDROCK_PRICING_VER = "bedrock-pricing-2026-04"
return [
PricingEntry(
input_cost_per_million=Decimal("15.00"),
output_cost_per_million=Decimal("75.00"),
source="official_docs_snapshot",
source_url=_BEDROCK_PRICING_URL,
pricing_version=_BEDROCK_PRICING_VER,
), # ("bedrock", "anthropic.claude-opus-4-6")
PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
source="official_docs_snapshot",
source_url=_BEDROCK_PRICING_URL,
pricing_version=_BEDROCK_PRICING_VER,
), # ("bedrock", "anthropic.claude-sonnet-4-6")
PricingEntry(
input_cost_per_million=Decimal("3.00"),
output_cost_per_million=Decimal("15.00"),
source="official_docs_snapshot",
source_url=_BEDROCK_PRICING_URL,
pricing_version=_BEDROCK_PRICING_VER,
), # ("bedrock", "anthropic.claude-sonnet-4-5")
PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("4.00"),
source="official_docs_snapshot",
source_url=_BEDROCK_PRICING_URL,
pricing_version=_BEDROCK_PRICING_VER,
), # ("bedrock", "anthropic.claude-haiku-4-5")
PricingEntry(
input_cost_per_million=Decimal("0.80"),
output_cost_per_million=Decimal("3.20"),
source="official_docs_snapshot",
source_url=_BEDROCK_PRICING_URL,
pricing_version=_BEDROCK_PRICING_VER,
), # ("bedrock", "amazon.nova-pro")
PricingEntry(
input_cost_per_million=Decimal("0.06"),
output_cost_per_million=Decimal("0.24"),
source="official_docs_snapshot",
source_url=_BEDROCK_PRICING_URL,
pricing_version=_BEDROCK_PRICING_VER,
), # ("bedrock", "amazon.nova-lite")
PricingEntry(
input_cost_per_million=Decimal("0.035"),
output_cost_per_million=Decimal("0.14"),
source="official_docs_snapshot",
source_url=_BEDROCK_PRICING_URL,
pricing_version=_BEDROCK_PRICING_VER,
), # ("bedrock", "amazon.nova-micro")
]
BEDROCK_PRICING_KEYS = [
("bedrock", "anthropic.claude-opus-4-6"),
("bedrock", "anthropic.claude-sonnet-4-6"),
("bedrock", "anthropic.claude-sonnet-4-5"),
("bedrock", "anthropic.claude-haiku-4-5"),
("bedrock", "amazon.nova-pro"),
("bedrock", "amazon.nova-lite"),
("bedrock", "amazon.nova-micro"),
]

View File

@ -0,0 +1,66 @@
"""Bedrock provider resolver for auxiliary client construction.
Handles ALL provider-specific logic for building auxiliary clients:
AWS credential detection, region resolution, and Bedrock client construction.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
def resolve_auxiliary_client(
*,
model: str | None = None,
explicit_api_key: str | None = None,
explicit_base_url: str | None = None,
async_mode: bool = False,
is_vision: bool = False,
main_runtime: dict | None = None,
api_mode: str | None = None,
) -> tuple[Any, str] | tuple[None, None]:
"""Resolve an auxiliary client for the Bedrock provider.
Returns ``(client, default_model)`` or ``(None, None)`` if unavailable.
"""
from agent.plugin_registries import registries
from agent.anthropic_aux import (
AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient,
)
_bedrock = registries.get_provider_namespace("bedrock")
_anthropic = registries.get_provider_namespace("anthropic")
has_aws_credentials = _bedrock.get("has_aws_credentials")
resolve_bedrock_region = _bedrock.get("resolve_bedrock_region")
build_anthropic_bedrock_client = _anthropic.get("build_anthropic_bedrock_client")
if has_aws_credentials is None or resolve_bedrock_region is None or build_anthropic_bedrock_client is None:
return None, None
if not has_aws_credentials():
logger.debug("resolve_provider_client: bedrock requested but "
"no AWS credentials found")
return None, None
region = resolve_bedrock_region()
default_model = "anthropic.claude-haiku-4-5-20251001-v1:0"
final_model = model or default_model
try:
real_client = build_anthropic_bedrock_client(region)
except ImportError as exc:
logger.warning("resolve_provider_client: cannot create Bedrock "
"client: %s", exc)
return None, None
client = AnthropicAuxiliaryClient(
real_client, final_model, api_key="aws-sdk",
base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
)
logger.debug("resolve_provider_client: bedrock (%s, %s)", final_model, region)
if async_mode:
client = AsyncAnthropicAuxiliaryClient(client)
return client, final_model

View File

@ -42,16 +42,7 @@ class BedrockTransport(ProviderTransport):
tools: Optional[List[Dict[str, Any]]] = None,
**params,
) -> Dict[str, Any]:
"""Build Bedrock converse() kwargs.
Calls convert_messages and convert_tools internally.
params:
max_tokens: int output token limit (default 4096)
temperature: float | None
guardrail_config: dict | None Bedrock guardrails
region: str AWS region (default 'us-east-1')
"""
"""Build Bedrock converse() kwargs."""
from agent.plugin_registries import registries
_fn = registries.get_provider_service("bedrock", "build_converse_kwargs")
if _fn is None:
@ -74,23 +65,15 @@ class BedrockTransport(ProviderTransport):
return kwargs
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
"""Normalize Bedrock response to NormalizedResponse.
Handles two shapes:
1. Raw boto3 dict (from direct converse() calls)
2. Already-normalized SimpleNamespace with .choices (from dispatch site)
"""
"""Normalize Bedrock response to NormalizedResponse."""
from agent.plugin_registries import registries
normalize_converse_response = registries.get_provider_service("bedrock", "normalize_converse_response")
if normalize_converse_response is None:
raise ImportError("bedrock plugin not registered")
# Normalize to OpenAI-compatible SimpleNamespace
if hasattr(response, "choices") and response.choices:
# Already normalized at dispatch site
ns = response
else:
# Raw boto3 dict
ns = normalize_converse_response(response)
choice = ns.choices[0]
@ -128,27 +111,15 @@ class BedrockTransport(ProviderTransport):
)
def validate_response(self, response: Any) -> bool:
"""Check Bedrock response structure.
After normalize_converse_response, the response has OpenAI-compatible
.choices same check as chat_completions.
"""
if response is None:
return False
# Raw Bedrock dict response — check for 'output' key
if isinstance(response, dict):
return "output" in response
# Already-normalized SimpleNamespace
if hasattr(response, "choices"):
return bool(response.choices)
return False
def map_finish_reason(self, raw_reason: str) -> str:
"""Map Bedrock stop reason to OpenAI finish_reason.
The adapter already does this mapping inside normalize_converse_response,
so this is only used for direct access to raw responses.
"""
_MAP = {
"end_turn": "stop",
"tool_use": "tool_calls",
@ -158,9 +129,3 @@ class BedrockTransport(ProviderTransport):
"content_filtered": "content_filter",
}
return _MAP.get(raw_reason, "stop")
# Auto-register on import
from agent.transports import register_transport # noqa: E402
register_transport("bedrock_converse", BedrockTransport)

View File

@ -0,0 +1,86 @@
"""Shared fixtures for bedrock plugin tests.
Registers the bedrock plugin in the singleton registry before each test.
"""
import pytest
class _FullCtx:
"""Plugin context that wires up all registry hooks."""
def register_provider_services(self, name, services):
from agent.plugin_registries import registries
registries.register_provider_services(name, services)
def register_provider_resolver(self, name, resolver):
from agent.plugin_registries import registries
registries.register_provider_resolver(name, resolver)
def register_credential_pool_hook(self, name, hook):
from agent.plugin_registries import registries
registries.register_credential_pool_hook(name, hook)
def register_transport(self, api_mode, transport_cls):
from agent.plugin_registries import registries
registries._transports[api_mode] = transport_cls
def register_pricing_provider(self, name, entries):
from agent.plugin_registries import registries
registries.register_pricing_provider(name, entries)
def register_provider_overlay(self, entry):
from agent.plugin_registries import registries
registries.register_provider_overlay(entry)
def __getattr__(self, name):
if name.startswith("register_"):
return lambda *a, **kw: None
raise AttributeError(name)
@pytest.fixture(autouse=True)
def _register_bedrock_plugin():
"""Register the real bedrock plugin for the duration of each test."""
from agent.plugin_registries import registries
from hermes_cli import providers as _prov
_prev_services = dict(registries._provider_services)
_prev_resolvers = dict(registries._provider_resolvers)
_prev_cph = dict(registries._credential_pool_hooks)
_prev_overlays = dict(registries._provider_overlays)
_prev_hermes_overlays = dict(_prov.HERMES_OVERLAYS)
_prev_aliases = dict(_prov.ALIASES)
_prev_merged = _prov._plugin_overlays_merged
ctx = _FullCtx()
try:
from hermes_agent_bedrock import register as _reg
_reg(ctx)
except ImportError:
pass
try:
from hermes_agent_anthropic import register as _ant_reg
_ant_reg(ctx)
except ImportError:
pass
# Force a re-merge so plugin-registered overlays and aliases
# appear in HERMES_OVERLAYS / ALIASES for the test.
_prov._plugin_overlays_merged = False
_prov._merge_plugin_overlays()
yield
for d, prev in [
(registries._provider_services, _prev_services),
(registries._provider_resolvers, _prev_resolvers),
(registries._credential_pool_hooks, _prev_cph),
(registries._provider_overlays, _prev_overlays),
]:
d.clear()
d.update(prev)
_prov.HERMES_OVERLAYS.clear()
_prov.HERMES_OVERLAYS.update(_prev_hermes_overlays)
_prov.ALIASES.clear()
_prov.ALIASES.update(_prev_aliases)
_prov._plugin_overlays_merged = _prev_merged

View File

@ -19,7 +19,7 @@ class TestBedrockContext1MBeta:
def test_common_betas_strips_1m_for_minimax(self):
"""MiniMax bearer-auth endpoints host their own models — strip 1M beta."""
from hermes_agent_anthropic import (
from agent.anthropic_format import (
_common_betas_for_base_url,
_CONTEXT_1M_BETA,
)
@ -41,7 +41,7 @@ class TestBedrockContext1MBeta:
This is the load-bearing assertion for the reported bug:
without this header Bedrock serves Opus 4.6/4.7 with a 200K cap.
"""
import hermes_agent_anthropic as adapter
import hermes_agent_anthropic.adapter as adapter
fake_sdk = MagicMock()
fake_sdk.AnthropicBedrock = MagicMock()

View File

@ -260,17 +260,18 @@ class TestPackaging:
import tomllib
from pathlib import Path
content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text()
content = (Path(__file__).parent.parent.parent.parent.parent / "pyproject.toml").read_text()
return tomllib.loads(content)["project"]["optional-dependencies"]
def test_bedrock_extra_exists(self):
extras = self._optional_dependencies()
assert "bedrock" in extras
assert any(dep.startswith("boto3==") for dep in extras["bedrock"])
assert any("hermes-agent-bedrock" in dep for dep in extras["bedrock"])
def test_bedrock_is_not_eager_installed_by_all_extra(self):
extras = self._optional_dependencies()
assert "hermes-agent[bedrock]" not in extras["all"]
# bedrock is now a lightweight plugin package; it IS in "all"
assert "hermes-agent[bedrock]" in extras["all"]
# ---------------------------------------------------------------------------
@ -360,14 +361,14 @@ class TestBedrockModelNameNormalization:
def test_global_anthropic_inference_profile_preserved(self):
"""The reporter's exact model ID."""
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name(
"global.anthropic.claude-opus-4-7", preserve_dots=True
) == "global.anthropic.claude-opus-4-7"
def test_us_anthropic_dated_inference_profile_preserved(self):
"""Regional + dated Sonnet inference profile."""
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
preserve_dots=True,
@ -375,7 +376,7 @@ class TestBedrockModelNameNormalization:
def test_apac_anthropic_haiku_inference_profile_preserved(self):
"""APAC inference profile — same structural-dot shape."""
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name(
"apac.anthropic.claude-haiku-4-5", preserve_dots=True
) == "apac.anthropic.claude-haiku-4-5"
@ -385,7 +386,7 @@ class TestBedrockModelNameNormalization:
always returned unmangled -- ``preserve_dots`` is irrelevant for
these IDs because the dots are namespace separators, not version
separators. Regression for #12295."""
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name(
"global.anthropic.claude-opus-4-7", preserve_dots=False
) == "global.anthropic.claude-opus-4-7"
@ -395,7 +396,7 @@ class TestBedrockModelNameNormalization:
(e.g. ``anthropic.claude-3-5-sonnet-20241022-v2:0``) use dots as
vendor separators and must also survive intact under
``preserve_dots=True``."""
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name(
"anthropic.claude-3-5-sonnet-20241022-v2:0",
preserve_dots=True,
@ -410,7 +411,7 @@ class TestBedrockBuildAnthropicKwargsEndToEnd:
regression for the reporter's HTTP 400."""
def test_bedrock_inference_profile_survives_build_kwargs(self):
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="global.anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
@ -429,7 +430,7 @@ class TestBedrockBuildAnthropicKwargsEndToEnd:
even without ``preserve_dots=True`` -- the prefix auto-detection
in ``normalize_model_name`` is the load-bearing piece.
Regression for #12295."""
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="global.anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
@ -447,47 +448,47 @@ class TestBedrockModelIdDetection:
regardless of ``preserve_dots``. Regression for #12295."""
def test_bare_bedrock_id_detected(self):
from hermes_agent_anthropic import _is_bedrock_model_id
from agent.anthropic_format import _is_bedrock_model_id
assert _is_bedrock_model_id("anthropic.claude-opus-4-7") is True
def test_regional_us_prefix_detected(self):
from hermes_agent_anthropic import _is_bedrock_model_id
from agent.anthropic_format import _is_bedrock_model_id
assert _is_bedrock_model_id("us.anthropic.claude-sonnet-4-5-v1:0") is True
def test_regional_global_prefix_detected(self):
from hermes_agent_anthropic import _is_bedrock_model_id
from agent.anthropic_format import _is_bedrock_model_id
assert _is_bedrock_model_id("global.anthropic.claude-opus-4-7") is True
def test_regional_eu_prefix_detected(self):
from hermes_agent_anthropic import _is_bedrock_model_id
from agent.anthropic_format import _is_bedrock_model_id
assert _is_bedrock_model_id("eu.anthropic.claude-sonnet-4-6") is True
def test_openrouter_format_not_detected(self):
from hermes_agent_anthropic import _is_bedrock_model_id
from agent.anthropic_format import _is_bedrock_model_id
assert _is_bedrock_model_id("claude-opus-4.6") is False
def test_bare_claude_not_detected(self):
from hermes_agent_anthropic import _is_bedrock_model_id
from agent.anthropic_format import _is_bedrock_model_id
assert _is_bedrock_model_id("claude-opus-4-7") is False
def test_bare_bedrock_id_preserved_without_flag(self):
"""The primary bug from #12295: ``anthropic.claude-opus-4-7``
sent to bedrock-mantle via auxiliary clients that don't pass
``preserve_dots=True``."""
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name(
"anthropic.claude-opus-4-7", preserve_dots=False
) == "anthropic.claude-opus-4-7"
def test_openrouter_dots_still_converted(self):
"""Non-Bedrock dotted model names must still be converted."""
from hermes_agent_anthropic import normalize_model_name
from agent.anthropic_format import normalize_model_name
assert normalize_model_name("claude-opus-4.6") == "claude-opus-4-6"
def test_bare_bedrock_id_survives_build_kwargs(self):
"""End-to-end: bare Bedrock ID through ``build_anthropic_kwargs``
without ``preserve_dots=True`` -- the auxiliary client path."""
from hermes_agent_anthropic import build_anthropic_kwargs
from agent.anthropic_format import build_anthropic_kwargs
kwargs = build_anthropic_kwargs(
model="anthropic.claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
@ -510,17 +511,29 @@ class TestBedrockModelIdDetection:
class TestAuxiliaryClientBedrockResolution:
"""Verify resolve_provider_client handles Bedrock's aws_sdk auth type."""
@staticmethod
def _patch_bedrock_build(monkeypatch, mock_fn):
"""Patch build_anthropic_bedrock_client in the plugin registry."""
from agent.plugin_registries import registries
ns = registries._provider_services.get("anthropic")
if ns is not None and "build_anthropic_bedrock_client" in ns:
monkeypatch.setitem(ns, "build_anthropic_bedrock_client", mock_fn)
else:
# Fallback: mock at the module level (won't help the resolver,
# but keeps the test from crashing if anthropic isn't registered)
pass
def test_bedrock_returns_client_with_credentials(self, monkeypatch):
"""With valid AWS credentials, Bedrock should return a usable client."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
monkeypatch.setenv("AWS_REGION", "us-west-2")
mock_anthropic_bedrock = MagicMock()
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=mock_anthropic_bedrock):
from agent.auxiliary_client import resolve_provider_client, AnthropicAuxiliaryClient
client, model = resolve_provider_client("bedrock", None)
self._patch_bedrock_build(monkeypatch, MagicMock(return_value=mock_anthropic_bedrock))
from agent.auxiliary_client import resolve_provider_client
from agent.anthropic_aux import AnthropicAuxiliaryClient
client, model = resolve_provider_client("bedrock", None)
assert client is not None, (
"resolve_provider_client('bedrock') returned None — "
@ -533,62 +546,61 @@ class TestAuxiliaryClientBedrockResolution:
def test_bedrock_returns_none_without_credentials(self, monkeypatch):
"""Without AWS credentials, Bedrock should return (None, None) gracefully."""
with patch("hermes_agent_bedrock.adapter.has_aws_credentials", return_value=False):
from agent.auxiliary_client import resolve_provider_client
client, model = resolve_provider_client("bedrock", None)
from agent.plugin_registries import registries
ns = registries._provider_services.get("bedrock", {})
monkeypatch.setitem(ns, "has_aws_credentials", lambda: False)
from agent.auxiliary_client import resolve_provider_client
client, model = resolve_provider_client("bedrock", None)
assert client is None
assert model is None
def test_bedrock_uses_configured_region(self, monkeypatch):
"""Bedrock client base_url should reflect AWS_REGION."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
monkeypatch.setenv("AWS_REGION", "eu-central-1")
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client
client, _ = resolve_provider_client("bedrock", None)
self._patch_bedrock_build(monkeypatch, MagicMock(return_value=MagicMock()))
from agent.auxiliary_client import resolve_provider_client
client, _ = resolve_provider_client("bedrock", None)
assert client is not None
assert "eu-central-1" in client.base_url
def test_bedrock_respects_explicit_model(self, monkeypatch):
"""When caller passes an explicit model, it should be used."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client
_, model = resolve_provider_client(
"bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
self._patch_bedrock_build(monkeypatch, MagicMock(return_value=MagicMock()))
from agent.auxiliary_client import resolve_provider_client
_, model = resolve_provider_client(
"bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
)
assert "claude-sonnet" in model
def test_bedrock_async_mode(self, monkeypatch):
"""Async mode should return an AsyncAnthropicAuxiliaryClient."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client, AsyncAnthropicAuxiliaryClient
client, model = resolve_provider_client("bedrock", None, async_mode=True)
self._patch_bedrock_build(monkeypatch, MagicMock(return_value=MagicMock()))
from agent.auxiliary_client import resolve_provider_client
from agent.anthropic_aux import AsyncAnthropicAuxiliaryClient
client, model = resolve_provider_client("bedrock", None, async_mode=True)
assert client is not None
assert isinstance(client, AsyncAnthropicAuxiliaryClient)
def test_bedrock_default_model_is_haiku(self, monkeypatch):
"""Default auxiliary model for Bedrock should be Haiku (fast, cheap)."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIO...MPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
with patch("hermes_agent_anthropic.adapter.build_anthropic_bedrock_client",
return_value=MagicMock()):
from agent.auxiliary_client import resolve_provider_client
_, model = resolve_provider_client("bedrock", None)
self._patch_bedrock_build(monkeypatch, MagicMock(return_value=MagicMock()))
from agent.auxiliary_client import resolve_provider_client
_, model = resolve_provider_client("bedrock", None)
assert "haiku" in model.lower()

View File

@ -10,8 +10,7 @@ from agent.transports.types import NormalizedResponse, ToolCall
@pytest.fixture
def transport():
import agent.transports.bedrock # noqa: F401
# Register the bedrock plugin so the transport can resolve provider services
# The bedrock plugin registers the transport via the plugin registry.
from agent.plugin_registries import registries
if registries.get_provider_service("bedrock", "build_converse_kwargs") is None:
from hermes_agent_bedrock import register as _bedrock_register
@ -19,8 +18,24 @@ def transport():
class _Ctx:
def register_provider_services(self, name, services):
registries.register_provider_services(name, services)
def register_provider_resolver(self, name, fn):
registries.register_provider_resolver(name, fn)
def register_transport(self, api_mode, transport_cls):
registries._transports[api_mode] = transport_cls
def register_pricing_provider(self, name, entries):
registries.register_pricing_provider(name, entries)
def register_credential_pool_hook(self, name, hook):
registries.register_credential_pool_hook(name, hook)
def register_provider_overlay(self, entry):
registries.register_provider_overlay(entry)
def __getattr__(self, name):
if name.startswith("register_"):
return lambda *a, **kw: None
raise AttributeError(name)
_bedrock_register(_Ctx())
import agent.transports as _t
_t._discovered = False
return get_transport("bedrock_converse")

View File

@ -0,0 +1,19 @@
"""Ensure the real `anthropic` SDK package is importable from plugin tests.
pytest adds parent directories containing ``__init__.py`` files to ``sys.path``.
``plugins/model-providers/anthropic/__init__.py`` (the provider profile) makes
``plugins/model-providers/`` appear in ``sys.path``, which means ``import anthropic``
resolves to ``plugins/model-providers/anthropic/`` rather than the installed
``anthropic`` SDK package. This conftest removes that shadowing entry before
any tests run.
"""
import sys
from pathlib import Path
# Remove any sys.path entry that would shadow the real anthropic SDK with the
# provider-profile __init__.py living at plugins/model-providers/anthropic/.
_repo_root = Path(__file__).resolve().parent.parent.parent # main/
_bad_entry = str(_repo_root / "plugins" / "model-providers")
if _bad_entry in sys.path:
sys.path.remove(_bad_entry)

View File

@ -0,0 +1,39 @@
"""Shared fixtures for discord plugin tests.
Registers ``hermes_agent_discord`` as a importable package backed by the
local ``adapter.py`` so that tests can ``import hermes_agent_discord.adapter``
without the package being installed in the venv.
"""
import importlib
import sys
import types
from pathlib import Path
_DISCORD_PLUGIN_DIR = Path(__file__).resolve().parents[1]
def _ensure_hermes_agent_discord():
"""Make ``hermes_agent_discord`` importable from the local adapter.py."""
if "hermes_agent_discord" in sys.modules:
return
# Create a package module pointing at the plugin root
pkg = types.ModuleType("hermes_agent_discord")
pkg.__path__ = [str(_DISCORD_PLUGIN_DIR)]
pkg.__package__ = "hermes_agent_discord"
sys.modules["hermes_agent_discord"] = pkg
# Make sure the adapter submodule resolves to the local adapter.py
if "hermes_agent_discord.adapter" not in sys.modules:
spec = importlib.util.spec_from_file_location(
"hermes_agent_discord.adapter",
str(_DISCORD_PLUGIN_DIR / "adapter.py"),
submodule_search_locations=[],
)
mod = importlib.util.module_from_spec(spec)
sys.modules["hermes_agent_discord.adapter"] = mod
spec.loader.exec_module(mod)
_ensure_hermes_agent_discord()

View File

@ -455,7 +455,7 @@ def test_admit_per_group_require_mention_overrides_global():
def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):
import asyncio
from gateway.platforms import feishu as feishu_mod
import hermes_agent_feishu.adapter as feishu_mod
FeishuAdapter = feishu_mod.FeishuAdapter
class _FakeBaseRequestBuilder:

View File

@ -49,7 +49,14 @@ def _run_setup_feishu(
patch("hermes_cli.gateway.print_warning"), \
patch("hermes_cli.gateway.print_error"), \
patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \
patch("hermes_agent_feishu.adapter.qr_register", return_value=qr_result):
patch("hermes_agent_feishu.adapter.qr_register", return_value=qr_result), \
patch("agent.plugin_registries.registries") as mock_registries:
# Make the registry lookup return our qr_register mock
from unittest.mock import MagicMock
_feishu_entry = MagicMock()
_feishu_entry.helper_functions = {"qr_register": lambda: qr_result, "probe_bot": None}
mock_registries.get_platform.return_value = _feishu_entry
from hermes_cli.gateway import _setup_feishu
_setup_feishu()

View File

@ -716,10 +716,8 @@ class TestMatrixModuleImport:
"sys.meta_path.insert(0, _Blocker())\n"
"for k in list(sys.modules):\n"
" if k.startswith('mautrix'): del sys.modules[k]\n"
"from unittest.mock import patch\n"
"from hermes_agent_matrix import check_matrix_requirements\n"
"with patch('hermes_agent_matrix.adapter._require_mautrix', side_effect=ImportError('blocked')):\n"
" assert not check_matrix_requirements()\n"
"assert not check_matrix_requirements()\n"
"print('OK')\n"
)],
capture_output=True, text=True, timeout=10,

View File

@ -4,6 +4,10 @@ from hermes_agent_slack.adapter import ( # noqa: F401
SlackAdapter,
check_slack_requirements,
_slash_user_id,
SLACK_AVAILABLE,
AsyncApp,
AsyncWebClient,
AsyncSocketModeHandler,
)

View File

@ -21,6 +21,10 @@ def _ensure_telegram_mock():
mod.constants.ChatType.SUPERGROUP = "supergroup"
mod.constants.ChatType.CHANNEL = "channel"
mod.constants.ChatType.PRIVATE = "private"
# Prevent pytest from interpreting auto-generated mock attributes as
# plugin specs. Without this, ``mod.pytest_plugins`` returns a child
# MagicMock which trips _get_plugin_specs_as_list().
mod.pytest_plugins = None
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, mod)

View File

@ -265,6 +265,7 @@ hermes-agent-dashboard = { workspace = true }
[tool.pytest.ini_options]
testpaths = ["tests", "plugins"]
pythonpath = ["."]
markers = [
"integration: marks tests requiring external services (API keys, Modal, etc.)",
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",

View File

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Check that core code and core tests never import from plugin packages.
Core (agent/, hermes_cli/, tools/, run_agent.py, etc.) must interact with
plugins exclusively through the registry layer (``registries.get_provider_service``,
``registries.register_*``). Direct imports from ``hermes_agent_*`` packages
couple core to plugin internals and break the plugin isolation boundary.
Allowed locations for plugin imports:
- ``plugins/`` (the plugin packages themselves)
- ``tests/plugins/`` (plugin-specific tests)
- ``tests/e2e/`` (end-to-end integration tests that load the full system)
- ``tests/gateway/`` (gateway integration tests)
- ``tests/tools/`` (tool integration tests these test plugin-provided tools)
- ``hermes_cli/plugins.py`` (the plugin loader itself it MUST import plugins)
Exit 0 on success, 1 on violation.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# ── Configuration ────────────────────────────────────────────────────────────
ROOT = Path(__file__).resolve().parent.parent
# Directories where ``from hermes_agent_* import`` / ``import hermes_agent_*``
# is FORBIDDEN.
FORBIDDEN_DIRS: list[Path] = [
ROOT / "agent",
ROOT / "hermes_cli",
ROOT / "tools",
ROOT / "cron",
ROOT / "gateway",
ROOT / "acp_adapter",
ROOT / "tui_gateway",
ROOT / "ui-tui", # Ink/TUI frontend
ROOT / "batch_runner.py",
ROOT / "run_agent.py",
ROOT / "model_tools.py",
ROOT / "cli.py",
ROOT / "toolsets.py",
]
# Directories where plugin imports are ALLOWED (no check).
ALLOWED_DIRS: list[Path] = [
ROOT / "plugins",
ROOT / "tests" / "plugins",
ROOT / "tests" / "e2e",
ROOT / "tests" / "gateway",
ROOT / "tests" / "tools",
]
# Specific files where plugin imports are allowed even inside a forbidden dir.
ALLOWED_FILES: set[str] = {
# The plugin loader itself must import plugin packages.
"hermes_cli/plugins.py",
}
# Regex matching a plugin import line.
PLUGIN_IMPORT_RE = re.compile(
r'^\s*(?:from|import)\s+hermes_agent_\w+',
re.MULTILINE,
)
# ── Implementation ──────────────────────────────────────────────────────────
def _is_in_allowed_dir(path: Path) -> bool:
"""Return True if *path* is inside an allowed directory."""
for allowed in ALLOWED_DIRS:
try:
path.relative_to(allowed)
return True
except ValueError:
pass
return False
def _is_allowed_file(path: Path) -> bool:
"""Return True if *path* is an explicitly allowed file."""
rel = str(path.relative_to(ROOT))
return rel in ALLOWED_FILES
def _check_file(path: Path) -> list[tuple[int, str]]:
"""Return list of (line_number, line_content) for violating lines."""
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return []
violations: list[tuple[int, str]] = []
for i, line in enumerate(text.splitlines(), start=1):
if PLUGIN_IMPORT_RE.match(line):
# Allow noqa comments (F401-style) or explicit "noqa: plugin-import"
if "noqa" in line:
continue
violations.append((i, line.strip()))
return violations
def main() -> int:
all_violations: list[tuple[Path, int, str]] = []
# Check individual files at root level
for path in FORBIDDEN_DIRS:
if path.is_file() and path.suffix == ".py":
if _is_allowed_file(path):
continue
for lineno, line in _check_file(path):
all_violations.append((path, lineno, line))
# Check directories
for dir_path in FORBIDDEN_DIRS:
if not dir_path.is_dir():
continue
for py_file in dir_path.rglob("*.py"):
if _is_in_allowed_dir(py_file):
continue
if _is_allowed_file(py_file):
continue
for lineno, line in _check_file(py_file):
all_violations.append((py_file, lineno, line))
# Check tests/agent/ and tests/agent/transports/ specifically
# (these are core unit tests that must NOT import plugins)
for test_dir in [
ROOT / "tests" / "agent",
ROOT / "tests" / "agent" / "transports",
]:
if not test_dir.is_dir():
continue
for py_file in test_dir.rglob("*.py"):
if _is_in_allowed_dir(py_file):
continue
if _is_allowed_file(py_file):
continue
for lineno, line in _check_file(py_file):
all_violations.append((py_file, lineno, line))
if not all_violations:
print("✓ No plugin imports found in core code or core tests")
return 0
print("✗ Plugin imports found in core code or core tests:\n")
for path, lineno, line in sorted(all_violations):
rel = path.relative_to(ROOT)
print(f" {rel}:{lineno}: {line}")
print(
f"\n{len(all_violations)} violation(s). Core must interact with plugins "
"through the registry layer, not direct imports."
)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -28,7 +28,7 @@
set -euo pipefail
# ── Locate repo root ────────────────────────────────────────────────────────
# ── Locate repo root ────────────────────────────────────────────────────────l
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

View File

@ -7,13 +7,13 @@ and either crash or silently degrade.
This conftest installs a minimal mock anthropic namespace in the registry before
each test, so that:
- _try_anthropic(), _maybe_wrap_anthropic(), etc. don't crash
- resolve_auxiliary_client(), maybe_wrap_anthropic(), etc. don't crash
- Tests that want to verify specific behaviour can override individual keys
with their own patch.dict / mock_anthropic_provider context manager
- The anthropic SDK never actually needs to be installed in the test env
NOTE: The autouse fixture uses `autouse=True` with session scope so it only
runs once per session and doesn't slow down individual tests.
IMPORTANT: Core tests must NEVER import from hermes_agent_* plugin packages.
All plugin behaviour is simulated through the registry mock namespace.
"""
from contextlib import contextmanager
@ -24,8 +24,134 @@ import pytest
__all__ = ["mock_anthropic_provider"]
def _mock_endpoint_speaks_anthropic_messages(base_url: str) -> bool:
"""Functional mock — detects Anthropic-wire endpoints by URL pattern.
Reproduces the real plugin's logic without importing it.
"""
if not base_url:
return False
normalized = base_url.lower().rstrip("/")
if normalized.endswith("/anthropic"):
return True
# api.anthropic.com
if "api.anthropic.com" in normalized:
return True
# kimi coding plan
if "api.kimi.com" in normalized and "/coding" in normalized:
return True
return False
def _mock_is_anthropic_compat_endpoint(provider: str, base_url: str) -> bool:
"""Functional mock — detects Anthropic-compat endpoints.
Reproduces the real plugin's logic: named compat providers OR /anthropic URL suffix.
"""
_COMPAT_PROVIDERS = frozenset({"minimax", "minimax-oauth", "minimax-cn"})
if provider in _COMPAT_PROVIDERS:
return True
url_lower = (base_url or "").lower()
return "/anthropic" in url_lower
def _mock_convert_openai_images_to_anthropic(messages: list) -> list:
"""Functional mock — converts OpenAI image_url blocks to Anthropic image blocks."""
converted = []
for msg in messages:
content = msg.get("content")
if not isinstance(content, list):
converted.append(msg)
continue
new_content = []
changed = False
for block in content:
if block.get("type") == "image_url":
image_url_val = (block.get("image_url") or {}).get("url", "")
if image_url_val.startswith("data:"):
header, _, b64data = image_url_val.partition(",")
media_type = "image/png"
if ":" in header and ";" in header:
media_type = header.split(":", 1)[1].split(";", 1)[0]
new_content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": b64data,
},
})
else:
new_content.append({
"type": "image",
"source": {
"type": "url",
"url": image_url_val,
},
})
changed = True
else:
new_content.append(block)
converted.append({**msg, "content": new_content} if changed else msg)
return converted
def _mock_maybe_wrap_anthropic(client_obj, model, api_key, base_url, api_mode=None):
"""Functional mock for maybe_wrap_anthropic — wraps when endpoint is Anthropic-wire.
Reproduces the real plugin's wrapping logic without importing it.
Uses the real AnthropicAuxiliaryClient from core (no SDK dependency).
"""
# Already wrapped — don't double-wrap
from agent.anthropic_aux import (AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient)
if isinstance(client_obj, (AnthropicAuxiliaryClient, AsyncAnthropicAuxiliaryClient)):
return client_obj
# Check for other specialized adapters we should never re-dispatch
try:
from agent.auxiliary_client import CodexAuxiliaryClient
if isinstance(client_obj, CodexAuxiliaryClient):
return client_obj
except ImportError:
pass
# Explicit non-anthropic api_mode wins over URL heuristics
if api_mode and api_mode != "anthropic_messages":
return client_obj
should_wrap = (
api_mode == "anthropic_messages"
or _mock_endpoint_speaks_anthropic_messages(base_url)
)
if not should_wrap:
return client_obj
# Use the registry's build_anthropic_client to construct a real(ish) client
from agent.plugin_registries import registries
build_fn = registries.get_provider_service("anthropic", "build_anthropic_client")
if build_fn is None:
return client_obj
try:
real_client = build_fn(api_key, base_url)
except Exception:
return client_obj
return AnthropicAuxiliaryClient(
real_client, model, api_key, base_url, is_oauth=False,
)
def _make_base_anthropic_namespace() -> dict:
"""Build a minimal anthropic service namespace with safe mock stubs."""
"""Build a minimal anthropic service namespace with safe mock stubs.
Wire-format code (build_anthropic_kwargs, convert_messages_to_anthropic,
AnthropicAuxiliaryClient, etc.) has moved to core modules and is no
longer looked up via the registry. Only SDK-dependent orchestration
(maybe_wrap_anthropic, is_anthropic_compat_endpoint, client building,
auth) still needs mock stubs here.
"""
mock_client = MagicMock(name="anthropic_client")
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "sk-ant-mock"
@ -36,48 +162,10 @@ def _make_base_anthropic_namespace() -> dict:
return (os.environ.get("ANTHROPIC_TOKEN")
or os.environ.get("ANTHROPIC_API_KEY"))
def _build_kwargs_passthrough(model=None, messages=None, tools=None,
max_tokens=None, **kwargs):
"""Mock build_anthropic_kwargs that passes through the key fields."""
result = {}
if model is not None:
result["model"] = model
if messages is not None:
result["messages"] = messages
if tools:
result["tools"] = tools
if max_tokens is not None:
result["max_tokens"] = max_tokens
return result
def _convert_tools(tools):
"""Passthrough mock for convert_tools_to_anthropic."""
result = []
for t in (tools or []):
fn = t.get("function", {})
result.append({
"name": fn.get("name", ""),
"description": fn.get("description", ""),
"input_schema": fn.get("parameters", {}),
})
return result
def _convert_messages(messages, **kwargs):
"""Passthrough mock for convert_messages_to_anthropic."""
system = None
msgs = []
for m in (messages or []):
if m.get("role") == "system":
system = m.get("content")
else:
msgs.append(m)
return system, msgs
return {
# SDK-dependent client building
"build_anthropic_client": MagicMock(return_value=mock_client),
"build_anthropic_kwargs": _build_kwargs_passthrough,
"convert_tools_to_anthropic": _convert_tools,
"convert_messages_to_anthropic": _convert_messages,
"build_anthropic_bedrock_client": MagicMock(return_value=mock_client),
"resolve_anthropic_token": _resolve_token,
"_is_oauth_token": lambda k: bool(k) and not (k or "").startswith("sk-ant-api"),
"is_claude_code_token_valid": MagicMock(return_value=False),
@ -86,19 +174,25 @@ def _make_base_anthropic_namespace() -> dict:
"refresh_oauth_token": MagicMock(return_value=None),
"run_hermes_oauth_login_pure": MagicMock(return_value=("mock-token", None)),
"_HERMES_OAUTH_FILE": MagicMock(),
"_to_plain_data": MagicMock(return_value=None),
"_anthropic_sdk": None, # SDK not installed in test env
# Resolve / endpoint detection (still plugin-provided, still needs mocking)
"maybe_wrap_anthropic": _mock_maybe_wrap_anthropic,
"endpoint_speaks_anthropic_messages": _mock_endpoint_speaks_anthropic_messages,
"is_anthropic_compat_endpoint": _mock_is_anthropic_compat_endpoint,
"convert_openai_images_to_anthropic": _mock_convert_openai_images_to_anthropic,
"ANTHROPIC_DEFAULT_BASE_URL": "https://api.anthropic.com",
"_ANTHROPIC_COMPAT_PROVIDERS": frozenset(),
"resolve_auxiliary_client": MagicMock(return_value=(mock_client, "claude-3-5-sonnet-20241022")),
}
@contextmanager
def mock_anthropic_provider(**overrides):
"""Patch the anthropic registry namespace. Use in core tests instead of
patching hermes_agent_anthropic.adapter.* directly.
patching hermes_agent_anthropic.* directly.
Usage:
with mock_anthropic_provider(build_anthropic_client=my_mock):
result = _try_anthropic()
result = resolve_provider_client(...)
"""
from agent.plugin_registries import registries
base = _make_base_anthropic_namespace()
@ -115,9 +209,36 @@ def _seed_anthropic_registry():
in other directories (which use the real plugin) run before us in the
same process. Function-scoped (not session) so it re-seeds after each
plugin test that overwrites the registry.
Also clears _provider_resolvers["anthropic"] so a real plugin registration
that leaked from another test file doesn't affect core unit tests.
Also blocks _ensure_plugins_discovered() so that code paths that lazily
trigger plugin loading (e.g. get_plugin_auxiliary_tasks via
_resolve_task_provider_model) don't overwrite the mock namespace.
"""
from unittest.mock import patch
from agent.plugin_registries import registries
ns = _make_base_anthropic_namespace()
with patch.dict(registries._provider_services, {"anthropic": ns}):
# Guard registries.register_provider_services so that if discover_and_load()
# fires during a test (e.g. via get_plugin_auxiliary_tasks in
# _resolve_task_provider_model), it can't overwrite our mock anthropic
# namespace. We only block "anthropic" — other providers / hooks proceed
# normally so tests like test_context_engine.py still work.
_orig_register = registries.register_provider_services
def _guarded_register(name, services):
if name == "anthropic":
return # mock namespace wins — don't let the real plugin clobber it
return _orig_register(name, services)
_orig_resolver = registries._provider_resolvers.pop("anthropic", None)
with patch.dict(registries._provider_services, {"anthropic": ns}), \
patch.object(registries, "register_provider_services", _guarded_register):
yield
# Restore resolver (None means "not registered", which is correct for
# core unit tests; plugin tests that need the real resolver load it themselves)
if _orig_resolver is not None:
registries._provider_resolvers["anthropic"] = _orig_resolver
else:
registries._provider_resolvers.pop("anthropic", None)

View File

@ -475,12 +475,7 @@ class TestResolveProviderClientUniversalModelFallback:
"agent.auxiliary_client._get_aux_model_for_provider",
return_value="claude-haiku-4-5-20251001",
),
patch.dict(registries._provider_services, {"anthropic": {
"build_anthropic_client": MagicMock(return_value=MagicMock()),
"resolve_anthropic_token": MagicMock(return_value="sk-ant-***"),
"_is_oauth_token": lambda k: False,
"build_anthropic_kwargs": MagicMock(return_value={}),
}}),
patch.dict(registries._provider_resolvers, {"anthropic": lambda **kw: (MagicMock(), kw.get("model") or "claude-haiku-4-5-20251001")}),
patch(
"agent.auxiliary_client._read_nous_auth", return_value=None
),
@ -526,15 +521,25 @@ class TestExplicitProviderRouting:
"""Test explicit provider selection bypasses auto chain correctly."""
def test_explicit_anthropic_api_key(self, monkeypatch):
"""provider='anthropic' + regular API key should work with is_oauth=False."""
"""provider='anthropic' + regular API key should work with is_oauth=False.
Tests via the registry resolver boundary the resolver is the plugin's
responsibility; core only dispatches to it.
"""
from agent.plugin_registries import registries
mock_build = MagicMock(return_value=MagicMock())
with patch.dict(registries._provider_services, {"anthropic": {
"build_anthropic_client": mock_build,
"resolve_anthropic_token": MagicMock(return_value="sk-ant...-key"),
"_is_oauth_token": lambda k: False,
"build_anthropic_kwargs": MagicMock(return_value={}),
}}), patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
# Build a mock client whose .chat.completions._is_oauth is False
mock_adapter = MagicMock()
mock_adapter._is_oauth = False
mock_client = MagicMock()
mock_client.chat.completions = mock_adapter
# Mock the resolver via the registry — core tests must not reach into
# plugin internals directly.
def _mock_resolver(**kwargs):
return mock_client, "claude-haiku-4-5"
with patch.dict(registries._provider_resolvers, {"anthropic": _mock_resolver}):
client, model = resolve_provider_client("anthropic")
assert client is not None
adapter = client.chat.completions
@ -1234,7 +1239,7 @@ def test_resolve_api_key_provider_skips_unconfigured_anthropic(monkeypatch):
called.append("anthropic")
return None, None
monkeypatch.setattr("agent.auxiliary_client._try_anthropic", mock_try_anthropic)
monkeypatch.setattr("agent.auxiliary_client._anthropic_plugin_service", lambda name: mock_try_anthropic if name == "resolve_auxiliary_client" else None)
monkeypatch.setattr("hermes_cli.auth.PROVIDER_REGISTRY", fake_registry)
monkeypatch.setattr(
"hermes_cli.auth.is_provider_explicitly_configured",
@ -1582,26 +1587,27 @@ class TestAuxiliaryTaskExtraBody:
# ---------------------------------------------------------------------------
class TestAnthropicCompatImageConversion:
"""Tests for _is_anthropic_compat_endpoint and _convert_openai_images_to_anthropic."""
"""Tests for is_anthropic_compat_endpoint and convert_openai_images_to_anthropic."""
def test_known_providers_detected(self):
from agent.auxiliary_client import _is_anthropic_compat_endpoint
from agent.plugin_registries import registries as _regs_is_compat; _is_anthropic_compat_endpoint = _regs_is_compat.get_provider_service("anthropic", "is_anthropic_compat_endpoint")
assert _is_anthropic_compat_endpoint("minimax", "")
assert _is_anthropic_compat_endpoint("minimax-cn", "")
def test_openrouter_not_detected(self):
from agent.auxiliary_client import _is_anthropic_compat_endpoint
from agent.plugin_registries import registries as _regs_is_compat; _is_anthropic_compat_endpoint = _regs_is_compat.get_provider_service("anthropic", "is_anthropic_compat_endpoint")
assert not _is_anthropic_compat_endpoint("openrouter", "")
assert not _is_anthropic_compat_endpoint("anthropic", "")
def test_url_based_detection(self):
from agent.auxiliary_client import _is_anthropic_compat_endpoint
from agent.plugin_registries import registries as _regs_is_compat; _is_anthropic_compat_endpoint = _regs_is_compat.get_provider_service("anthropic", "is_anthropic_compat_endpoint")
assert _is_anthropic_compat_endpoint("custom", "https://api.minimax.io/anthropic")
assert _is_anthropic_compat_endpoint("custom", "https://example.com/anthropic/v1")
assert not _is_anthropic_compat_endpoint("custom", "https://api.openai.com/v1")
def test_base64_image_converted(self):
from agent.auxiliary_client import _convert_openai_images_to_anthropic
from agent.plugin_registries import registries
convert_openai_images_to_anthropic = registries.get_provider_service("anthropic", "convert_openai_images_to_anthropic")
messages = [{
"role": "user",
"content": [
@ -1609,7 +1615,7 @@ class TestAnthropicCompatImageConversion:
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR="}}
]
}]
result = _convert_openai_images_to_anthropic(messages)
result = convert_openai_images_to_anthropic(messages)
img_block = result[0]["content"][1]
assert img_block["type"] == "image"
assert img_block["source"]["type"] == "base64"
@ -1617,34 +1623,37 @@ class TestAnthropicCompatImageConversion:
assert img_block["source"]["data"] == "iVBOR="
def test_url_image_converted(self):
from agent.auxiliary_client import _convert_openai_images_to_anthropic
from agent.plugin_registries import registries
convert_openai_images_to_anthropic = registries.get_provider_service("anthropic", "convert_openai_images_to_anthropic")
messages = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}}
]
}]
result = _convert_openai_images_to_anthropic(messages)
result = convert_openai_images_to_anthropic(messages)
img_block = result[0]["content"][0]
assert img_block["type"] == "image"
assert img_block["source"]["type"] == "url"
assert img_block["source"]["url"] == "https://example.com/img.jpg"
def test_text_only_messages_unchanged(self):
from agent.auxiliary_client import _convert_openai_images_to_anthropic
from agent.plugin_registries import registries
convert_openai_images_to_anthropic = registries.get_provider_service("anthropic", "convert_openai_images_to_anthropic")
messages = [{"role": "user", "content": "Hello"}]
result = _convert_openai_images_to_anthropic(messages)
result = convert_openai_images_to_anthropic(messages)
assert result[0] is messages[0] # same object, not copied
def test_jpeg_media_type_parsed(self):
from agent.auxiliary_client import _convert_openai_images_to_anthropic
from agent.plugin_registries import registries
convert_openai_images_to_anthropic = registries.get_provider_service("anthropic", "convert_openai_images_to_anthropic")
messages = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/="}}
]
}]
result = _convert_openai_images_to_anthropic(messages)
result = convert_openai_images_to_anthropic(messages)
assert result[0]["content"][0]["source"]["media_type"] == "image/jpeg"

View File

@ -354,11 +354,9 @@ class TestProvidersDictApiModeAnthropicMessages:
},
},
})
from agent.auxiliary_client import (
resolve_provider_client,
AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient,
)
from agent.auxiliary_client import resolve_provider_client
from agent.plugin_registries import registries
from agent.anthropic_aux import AnthropicAuxiliaryClient, AsyncAnthropicAuxiliaryClient
sync_client, sync_model = resolve_provider_client("myrelay", async_mode=False)
assert isinstance(sync_client, AnthropicAuxiliaryClient), (
f"expected AnthropicAuxiliaryClient, got {type(sync_client).__name__}"
@ -396,9 +394,9 @@ class TestProvidersDictApiModeAnthropicMessages:
from agent.auxiliary_client import (
get_async_text_auxiliary_client,
get_text_auxiliary_client,
AnthropicAuxiliaryClient,
AsyncAnthropicAuxiliaryClient,
)
from agent.plugin_registries import registries
from agent.anthropic_aux import AnthropicAuxiliaryClient, AsyncAnthropicAuxiliaryClient
async_client, async_model = get_async_text_auxiliary_client("compression")
assert isinstance(async_client, AsyncAnthropicAuxiliaryClient)
assert async_model == "claude-sonnet-4.6"
@ -483,6 +481,7 @@ class TestCustomProviderAliasCollision:
})
monkeypatch.setenv("KIMI_API_KEY", "builtin-kimi-key")
from agent.auxiliary_client import resolve_provider_client
from openai import OpenAI
client, _ = resolve_provider_client(
"kimi-coding", model="kimi-k2", raw_codex=True,

View File

@ -10,6 +10,10 @@ chat.completions returns 404 "resource_not_found_error". The named
``kimi-coding`` provider branch in resolve_provider_client used to build a
plain OpenAI client, so title generation / vision / compression /
web_extract all failed on Kimi Coding Plan users.
NOTE: Core tests must NEVER import from hermes_agent_* plugin packages.
All plugin behaviour is simulated through the registry mock namespace
provided by the conftest.
"""
from __future__ import annotations
@ -29,6 +33,17 @@ def _clean_env(monkeypatch):
monkeypatch.delenv(key, raising=False)
# ---------------------------------------------------------------------------
# Helpers — get services from the registry mock namespace (not plugin imports)
# ---------------------------------------------------------------------------
from agent.anthropic_aux import AnthropicAuxiliaryClient as _CoreAnthropicAuxiliaryClient
def _get_anthropic_service(name):
"""Look up an anthropic service from the registry (mock namespace)."""
from agent.plugin_registries import registries
return registries.get_provider_service("anthropic", name)
# ---------------------------------------------------------------------------
# URL detection helper
# ---------------------------------------------------------------------------
@ -47,32 +62,34 @@ def _clean_env(monkeypatch):
("", False, "empty"),
(None, False, "None"),
])
def test_endpoint_speaks_anthropic_messages(url, expected, label):
from agent.auxiliary_client import _endpoint_speaks_anthropic_messages
assert _endpoint_speaks_anthropic_messages(url) is expected, (
def testendpoint_speaks_anthropic_messages(url, expected, label):
endpoint_speaks = _get_anthropic_service("endpoint_speaks_anthropic_messages")
assert endpoint_speaks(url) is expected, (
f"{label}: {url!r} should be {expected}"
)
# ---------------------------------------------------------------------------
# _maybe_wrap_anthropic decision table
# maybe_wrap_anthropic decision table
# ---------------------------------------------------------------------------
def test_maybe_wrap_anthropic_rewraps_kimi_coding_url():
"""Plain OpenAI client pointed at api.kimi.com/coding gets rewrapped."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
maybe_wrap = _get_anthropic_service("maybe_wrap_anthropic")
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
from agent.plugin_registries import registries
plain_client = MagicMock(name="plain_openai")
fake_anthropic = MagicMock(name="anthropic_sdk_client")
with patch.dict(registries._provider_services, {"anthropic": {
**registries._provider_services.get("anthropic", {}),
"build_anthropic_client": MagicMock(return_value=fake_anthropic),
"resolve_anthropic_token": MagicMock(return_value="sk-test"),
"_is_oauth_token": lambda k: False,
"build_anthropic_kwargs": MagicMock(return_value={}),
}}):
result = _maybe_wrap_anthropic(
result = maybe_wrap(
plain_client, "kimi-for-coding", "sk-kimi-test",
"https://api.kimi.com/coding", api_mode=None,
)
@ -81,19 +98,21 @@ def test_maybe_wrap_anthropic_rewraps_kimi_coding_url():
def test_maybe_wrap_anthropic_rewraps_slash_anthropic_url():
"""Plain OpenAI client pointed at any /anthropic URL gets rewrapped."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
maybe_wrap = _get_anthropic_service("maybe_wrap_anthropic")
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
from agent.plugin_registries import registries
plain_client = MagicMock(name="plain_openai")
fake_anthropic = MagicMock(name="anthropic_sdk_client")
with patch.dict(registries._provider_services, {"anthropic": {
**registries._provider_services.get("anthropic", {}),
"build_anthropic_client": MagicMock(return_value=fake_anthropic),
"resolve_anthropic_token": MagicMock(return_value="sk-test"),
"_is_oauth_token": lambda k: False,
"build_anthropic_kwargs": MagicMock(return_value={}),
}}):
result = _maybe_wrap_anthropic(
result = maybe_wrap(
plain_client, "MiniMax-M2.7", "mm-key",
"https://api.minimax.io/anthropic", api_mode=None,
)
@ -102,12 +121,13 @@ def test_maybe_wrap_anthropic_rewraps_slash_anthropic_url():
def test_maybe_wrap_anthropic_skips_openai_wire_urls():
"""OpenRouter / OpenAI / Moonshot-legacy stay as plain OpenAI clients."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
maybe_wrap = _get_anthropic_service("maybe_wrap_anthropic")
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
plain_client = MagicMock(name="plain_openai")
# No patch on build_anthropic_client — if the function tried to call it,
# we'd get an AttributeError-style failure. The point is it shouldn't.
result = _maybe_wrap_anthropic(
result = maybe_wrap(
plain_client, "claude-sonnet-4.6", "sk-or-test",
"https://openrouter.ai/api/v1", api_mode=None,
)
@ -117,10 +137,11 @@ def test_maybe_wrap_anthropic_skips_openai_wire_urls():
def test_maybe_wrap_anthropic_respects_explicit_chat_completions():
"""api_mode=chat_completions overrides URL heuristics."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
maybe_wrap = _get_anthropic_service("maybe_wrap_anthropic")
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
plain_client = MagicMock(name="plain_openai")
result = _maybe_wrap_anthropic(
result = maybe_wrap(
plain_client, "kimi-for-coding", "sk-kimi-test",
"https://api.kimi.com/coding",
api_mode="chat_completions", # explicit override
@ -131,19 +152,21 @@ def test_maybe_wrap_anthropic_respects_explicit_chat_completions():
def test_maybe_wrap_anthropic_honors_explicit_anthropic_messages():
"""api_mode=anthropic_messages wraps even when URL wouldn't trigger."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
maybe_wrap = _get_anthropic_service("maybe_wrap_anthropic")
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
from agent.plugin_registries import registries
plain_client = MagicMock(name="plain_openai")
fake_anthropic = MagicMock(name="anthropic_sdk_client")
with patch.dict(registries._provider_services, {"anthropic": {
**registries._provider_services.get("anthropic", {}),
"build_anthropic_client": MagicMock(return_value=fake_anthropic),
"resolve_anthropic_token": MagicMock(return_value="sk-test"),
"_is_oauth_token": lambda k: False,
"build_anthropic_kwargs": MagicMock(return_value={}),
}}):
result = _maybe_wrap_anthropic(
result = maybe_wrap(
plain_client, "model-name", "some-key",
"https://opaque.internal/v1", # URL alone wouldn't trigger
api_mode="anthropic_messages",
@ -153,10 +176,11 @@ def test_maybe_wrap_anthropic_honors_explicit_anthropic_messages():
def test_maybe_wrap_anthropic_double_wrap_safe():
"""Already-wrapped AnthropicAuxiliaryClient passes through unchanged."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
maybe_wrap = _get_anthropic_service("maybe_wrap_anthropic")
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
already_wrapped = MagicMock(spec=AnthropicAuxiliaryClient)
result = _maybe_wrap_anthropic(
result = maybe_wrap(
already_wrapped, "model", "key",
"https://api.kimi.com/coding", api_mode=None,
)
@ -165,14 +189,12 @@ def test_maybe_wrap_anthropic_double_wrap_safe():
def test_maybe_wrap_anthropic_codex_client_passes_through():
"""CodexAuxiliaryClient is never re-dispatched."""
from agent.auxiliary_client import (
_maybe_wrap_anthropic,
CodexAuxiliaryClient,
AnthropicAuxiliaryClient,
)
maybe_wrap = _get_anthropic_service("maybe_wrap_anthropic")
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
from agent.auxiliary_client import CodexAuxiliaryClient
codex_client = MagicMock(spec=CodexAuxiliaryClient)
result = _maybe_wrap_anthropic(
result = maybe_wrap(
codex_client, "model", "key",
"https://api.kimi.com/coding", api_mode=None,
)
@ -182,7 +204,8 @@ def test_maybe_wrap_anthropic_codex_client_passes_through():
def test_maybe_wrap_anthropic_sdk_missing_falls_back():
"""ImportError on anthropic SDK returns plain client with warning."""
from agent.auxiliary_client import _maybe_wrap_anthropic, AnthropicAuxiliaryClient
maybe_wrap = _get_anthropic_service("maybe_wrap_anthropic")
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
from agent.plugin_registries import registries
plain_client = MagicMock(name="plain_openai")
@ -196,7 +219,7 @@ def test_maybe_wrap_anthropic_sdk_missing_falls_back():
"anthropic": {**registries._provider_services.get("anthropic", {}),
"build_anthropic_client": _raise_import}
}):
result = _maybe_wrap_anthropic(
result = maybe_wrap(
plain_client, "kimi-for-coding", "sk-kimi-test",
"https://api.kimi.com/coding", api_mode=None,
)
@ -217,16 +240,14 @@ def test_resolve_provider_client_kimi_coding_wraps_anthropic(monkeypatch, tmp_pa
generation 404s on every Kimi Coding Plan user after the "main model
for every user" aux design shipped.
"""
from unittest.mock import MagicMock, patch
from agent.auxiliary_client import (
resolve_provider_client,
AnthropicAuxiliaryClient,
)
from agent.auxiliary_client import resolve_provider_client
from agent.plugin_registries import registries
AnthropicAuxiliaryClient = _CoreAnthropicAuxiliaryClient
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# sk-kimi- prefix triggers /coding endpoint auto-detection
monkeypatch.setenv("KIMI_API_KEY", "sk-kim...n123")
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test123")
mock_client = MagicMock()
with patch.dict(registries._provider_services, {"anthropic": {

View File

@ -1132,6 +1132,7 @@ def test_singleton_seed_does_not_clobber_manual_oauth_entry(tmp_path, monkeypatc
)
from agent.plugin_registries import registries
from agent.plugin_registries import CredentialPoolHook
_orig_get = registries.get_provider_service
monkeypatch.setattr(
registries,
@ -1145,6 +1146,36 @@ def test_singleton_seed_does_not_clobber_manual_oauth_entry(tmp_path, monkeypatc
else _orig_get(p, n),
)
# The credential pool hook drives singleton seeding — mock it so the
# discover_credentials path reads from the mocked service above.
def _mock_discover(entries, provider, is_suppressed):
from unittest.mock import MagicMock as _MM
from agent.credential_pool import _upsert_entry, AUTH_TYPE_OAUTH
read_fn = registries.get_provider_service("anthropic", "read_hermes_oauth_credentials")
creds = read_fn() if read_fn else None
if not creds:
return False, set()
source = "hermes_pkce"
if is_suppressed(provider, source):
return False, set()
changed = _upsert_entry(
entries, provider, source,
{
"source": source,
"auth_type": AUTH_TYPE_OAUTH,
"access_token": creds.get("accessToken", ""),
"refresh_token": creds.get("refreshToken"),
"expires_at": creds.get("expiresAt"),
},
)
return changed, {source}
monkeypatch.setattr(
registries,
"get_credential_pool_hook",
lambda p: CredentialPoolHook(discover_credentials=_mock_discover) if p == "anthropic" else None,
)
from agent.credential_pool import load_pool
pool = load_pool("anthropic")

View File

@ -218,26 +218,3 @@ class TestABCContract:
# ---------------------------------------------------------------------------
class TestBuiltinSync:
"""``_BUILTIN_NAMES`` in agent/transcription_registry.py is duplicated
from ``BUILTIN_STT_PROVIDERS`` in tools/transcription_tools.py
(importing directly would create a circular dependency). This test
fails loudly if the two lists drift a new built-in added to
transcription_tools.py MUST also be added to
transcription_registry.py's ``_BUILTIN_NAMES`` or the registry will
accept a name the dispatcher will silently route to the wrong
handler.
"""
def test_registry_builtins_match_dispatcher_builtins(self):
from hermes_agent_stt import BUILTIN_STT_PROVIDERS
assert transcription_registry._BUILTIN_NAMES == BUILTIN_STT_PROVIDERS, (
"agent.transcription_registry._BUILTIN_NAMES and "
"tools.transcription_tools.BUILTIN_STT_PROVIDERS have drifted!\n"
f" Registry only: {sorted(transcription_registry._BUILTIN_NAMES - BUILTIN_STT_PROVIDERS)}\n"
f" Dispatcher only: {sorted(BUILTIN_STT_PROVIDERS - transcription_registry._BUILTIN_NAMES)}\n"
"Add the missing names to whichever list is incomplete. "
"These two lists exist as a circular-import workaround and "
"MUST be kept in sync manually."
)

View File

@ -288,25 +288,3 @@ class TestResolveOutputFormat:
# ---------------------------------------------------------------------------
class TestBuiltinSync:
"""``_BUILTIN_NAMES`` in agent/tts_registry.py is duplicated from
``BUILTIN_TTS_PROVIDERS`` in tools/tts_tool.py (importing directly
would create a circular dependency). This test fails loudly if the
two lists drift a new built-in added to tts_tool.py MUST also be
added to tts_registry.py's _BUILTIN_NAMES or the registry will
accept a name the dispatcher will silently route to the wrong
handler.
"""
def test_registry_builtins_match_dispatcher_builtins(self):
from hermes_agent_tts import BUILTIN_TTS_PROVIDERS
assert tts_registry._BUILTIN_NAMES == BUILTIN_TTS_PROVIDERS, (
"agent.tts_registry._BUILTIN_NAMES and "
"tools.tts_tool.BUILTIN_TTS_PROVIDERS have drifted!\n"
f" Registry only: {sorted(tts_registry._BUILTIN_NAMES - BUILTIN_TTS_PROVIDERS)}\n"
f" Dispatcher only: {sorted(BUILTIN_TTS_PROVIDERS - tts_registry._BUILTIN_NAMES)}\n"
"Add the missing names to whichever list is incomplete. "
"These two lists exist as a circular-import workaround and "
"MUST be kept in sync manually."
)

View File

@ -54,8 +54,30 @@ class TestTransportRegistry:
def test_get_unregistered_returns_none(self):
assert get_transport("nonexistent_mode") is None
def test_anthropic_registered_on_import(self):
import agent.transports.anthropic # noqa: F401
def test_anthropic_registered_via_plugin(self):
"""Anthropic transport is registered by the anthropic plugin, not a core module."""
# Register a mock transport via the registry API — core tests must
# not import hermes_agent_* packages.
from agent.plugin_registries import registries
from agent.transports.base import ProviderTransport
from agent.transports import register_transport
class _MockAnthropicTransport(ProviderTransport):
@property
def api_mode(self):
return "anthropic_messages"
def convert_messages(self, messages, **kw):
return messages
def convert_tools(self, tools, **kw):
return tools
def convert_response(self, resp, **kw):
return resp
def build_kwargs(self, model, messages, tools=None, **params):
return {}
def normalize_response(self, response, **kw):
return NormalizedResponse(content=None, tool_calls=None, finish_reason="stop")
register_transport("anthropic_messages", _MockAnthropicTransport)
t = get_transport("anthropic_messages")
assert t is not None
assert t.api_mode == "anthropic_messages"
@ -91,145 +113,36 @@ class TestTransportRegistry:
# ── AnthropicTransport tests ────────────────────────────────────────────
class TestAnthropicTransport:
"""Core transport registry checks for the anthropic transport slot.
Full behavioral tests (convert_tools, validate_response, normalize_response,
build_kwargs, etc.) live in plugins/model-providers/anthropic/tests/ where
the real transport implementation is available.
"""
@pytest.fixture
def transport(self):
import agent.transports.anthropic # noqa: F401
# Register a mock anthropic transport via the registry API.
from agent.transports.base import ProviderTransport
from agent.transports import register_transport
class _MockAnthropicTransport(ProviderTransport):
@property
def api_mode(self):
return "anthropic_messages"
def convert_messages(self, messages, **kw):
return messages
def convert_tools(self, tools, **kw):
return tools
def convert_response(self, resp, **kw):
return resp
def build_kwargs(self, model, messages, tools=None, **params):
return {}
def normalize_response(self, response, **kw):
return NormalizedResponse(content=None, tool_calls=None, finish_reason="stop")
register_transport("anthropic_messages", _MockAnthropicTransport)
return get_transport("anthropic_messages")
def test_api_mode(self, transport):
assert transport.api_mode == "anthropic_messages"
def test_convert_tools_simple(self, transport):
tools = [{
"type": "function",
"function": {
"name": "test_tool",
"description": "A test",
"parameters": {"type": "object", "properties": {}},
}
}]
result = transport.convert_tools(tools)
assert len(result) == 1
assert result[0]["name"] == "test_tool"
assert "input_schema" in result[0]
def test_validate_response_none(self, transport):
assert transport.validate_response(None) is False
def test_validate_response_empty_content(self, transport):
r = SimpleNamespace(content=[])
assert transport.validate_response(r) is False
def test_validate_response_empty_content_with_end_turn_is_valid(self, transport):
r = SimpleNamespace(content=[], stop_reason="end_turn")
assert transport.validate_response(r) is True
def test_validate_response_empty_content_with_tool_use_is_invalid(self, transport):
r = SimpleNamespace(content=[], stop_reason="tool_use")
assert transport.validate_response(r) is False
def test_validate_response_valid(self, transport):
r = SimpleNamespace(content=[SimpleNamespace(type="text", text="hello")])
assert transport.validate_response(r) is True
def test_map_finish_reason(self, transport):
assert transport.map_finish_reason("end_turn") == "stop"
assert transport.map_finish_reason("tool_use") == "tool_calls"
assert transport.map_finish_reason("max_tokens") == "length"
assert transport.map_finish_reason("stop_sequence") == "stop"
assert transport.map_finish_reason("refusal") == "content_filter"
assert transport.map_finish_reason("model_context_window_exceeded") == "length"
assert transport.map_finish_reason("unknown") == "stop"
def test_extract_cache_stats_none_usage(self, transport):
r = SimpleNamespace(usage=None)
assert transport.extract_cache_stats(r) is None
def test_extract_cache_stats_with_cache(self, transport):
usage = SimpleNamespace(cache_read_input_tokens=100, cache_creation_input_tokens=50)
r = SimpleNamespace(usage=usage)
result = transport.extract_cache_stats(r)
assert result == {"cached_tokens": 100, "creation_tokens": 50}
def test_extract_cache_stats_zero(self, transport):
usage = SimpleNamespace(cache_read_input_tokens=0, cache_creation_input_tokens=0)
r = SimpleNamespace(usage=usage)
assert transport.extract_cache_stats(r) is None
def test_normalize_response_text(self, transport):
"""Test normalization of a simple text response."""
r = SimpleNamespace(
content=[SimpleNamespace(type="text", text="Hello world")],
stop_reason="end_turn",
usage=SimpleNamespace(input_tokens=10, output_tokens=5),
model="claude-sonnet-4-6",
)
nr = transport.normalize_response(r)
assert isinstance(nr, NormalizedResponse)
assert nr.content == "Hello world"
assert nr.tool_calls is None or nr.tool_calls == []
assert nr.finish_reason == "stop"
def test_normalize_response_tool_calls(self, transport):
"""Test normalization of a tool-use response."""
r = SimpleNamespace(
content=[
SimpleNamespace(
type="tool_use",
id="toolu_123",
name="terminal",
input={"command": "ls"},
),
],
stop_reason="tool_use",
usage=SimpleNamespace(input_tokens=10, output_tokens=20),
model="claude-sonnet-4-6",
)
nr = transport.normalize_response(r)
assert nr.finish_reason == "tool_calls"
assert len(nr.tool_calls) == 1
tc = nr.tool_calls[0]
assert tc.name == "terminal"
assert tc.id == "toolu_123"
assert '"command"' in tc.arguments
def test_normalize_response_thinking(self, transport):
"""Test normalization preserves thinking content."""
r = SimpleNamespace(
content=[
SimpleNamespace(type="thinking", thinking="Let me think..."),
SimpleNamespace(type="text", text="The answer is 42"),
],
stop_reason="end_turn",
usage=SimpleNamespace(input_tokens=10, output_tokens=15),
model="claude-sonnet-4-6",
)
nr = transport.normalize_response(r)
assert nr.content == "The answer is 42"
assert nr.reasoning == "Let me think..."
def test_build_kwargs_returns_dict(self, transport):
"""Test build_kwargs produces a usable kwargs dict."""
messages = [{"role": "user", "content": "Hello"}]
kw = transport.build_kwargs(
model="claude-sonnet-4-6",
messages=messages,
max_tokens=1024,
)
assert isinstance(kw, dict)
assert "model" in kw
assert "max_tokens" in kw
assert "messages" in kw
def test_convert_messages_extracts_system(self, transport):
"""Test convert_messages separates system from messages."""
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hi"},
]
system, msgs = transport.convert_messages(messages)
# System should be extracted
assert system is not None
# Messages should only have user
assert len(msgs) >= 1

View File

@ -57,11 +57,11 @@ def _fake_tts_call(monkeypatch, audio_bytes=b"\x00" * 32):
return json.dumps({"success": True, "file_path": output_path})
monkeypatch.setattr(
"tools.tts_tool.text_to_speech_tool",
"hermes_agent_tts.tts_tool.text_to_speech_tool",
_fake_text_to_speech_tool,
)
monkeypatch.setattr(
"tools.tts_tool._strip_markdown_for_tts",
"hermes_agent_tts.tts_tool._strip_markdown_for_tts",
lambda text: text,
)

View File

@ -41,7 +41,7 @@ async def test_enrich_message_with_transcription_surfaces_path_when_stt_disabled
runner._has_setup_skill = lambda: True # Should NOT be consulted in disabled branch.
with patch(
"tools.transcription_tools.transcribe_audio",
"hermes_agent_stt.transcription_tools.transcribe_audio",
side_effect=AssertionError("transcribe_audio should not be called when STT is disabled"),
), patch(
"gateway.run._probe_audio_duration",
@ -86,7 +86,7 @@ async def test_enrich_message_with_transcription_avoids_bogus_no_provider_messag
runner.config = GatewayConfig(stt_enabled=True)
with patch(
"tools.transcription_tools.transcribe_audio",
"hermes_agent_stt.transcription_tools.transcribe_audio",
return_value={"success": False, "error": "VOICE_TOOLS_OPENAI_KEY not set"},
):
result = await runner._enrich_message_with_transcription(
@ -124,7 +124,7 @@ async def test_prepare_inbound_message_text_transcribes_queued_voice_event():
)
with patch(
"tools.transcription_tools.transcribe_audio",
"hermes_agent_stt.transcription_tools.transcribe_audio",
return_value={
"success": True,
"transcript": "queued voice transcript",

View File

@ -453,7 +453,8 @@ class TestTelegramAdaptiveDelay:
def _make_feishu_adapter():
"""Create a minimal FeishuAdapter for testing adaptive delay."""
from hermes_agent_feishu import FeishuAdapter, FeishuBatchState
from hermes_agent_feishu import FeishuAdapter
from hermes_agent_feishu.adapter import FeishuBatchState
config = PlatformConfig(enabled=True, token="test-token")
adapter = object.__new__(FeishuAdapter)

View File

@ -1533,7 +1533,7 @@ class TestStreamTtsToSpeaker:
def test_none_sentinel_flushes_buffer(self):
"""None sentinel causes remaining buffer to be spoken."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()
@ -1551,7 +1551,7 @@ class TestStreamTtsToSpeaker:
def test_stop_event_aborts_early(self):
"""Setting stop_event causes early exit."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()
@ -1567,7 +1567,7 @@ class TestStreamTtsToSpeaker:
def test_done_event_set_on_exception(self):
"""tts_done_event is set even when an exception occurs."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()
@ -1581,7 +1581,7 @@ class TestStreamTtsToSpeaker:
def test_think_blocks_stripped(self):
"""<think>...</think> content is not spoken."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()
@ -1599,7 +1599,7 @@ class TestStreamTtsToSpeaker:
def test_sentence_splitting(self):
"""Sentences are split at boundaries and spoken individually."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()
@ -1616,7 +1616,7 @@ class TestStreamTtsToSpeaker:
def test_markdown_stripped_in_speech(self):
"""Markdown formatting is removed before display/speech."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()
@ -1632,7 +1632,7 @@ class TestStreamTtsToSpeaker:
def test_duplicate_sentences_deduped(self):
"""Repeated sentences are spoken only once."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()
@ -1650,7 +1650,7 @@ class TestStreamTtsToSpeaker:
def test_no_api_key_display_only(self):
"""Without ELEVENLABS_API_KEY, display callback still works."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()
@ -1667,7 +1667,7 @@ class TestStreamTtsToSpeaker:
def test_long_buffer_flushed_on_timeout(self):
"""Buffer longer than long_flush_len is flushed on queue timeout."""
from hermes_agent_tts import stream_tts_to_speaker
from hermes_agent_tts.tts_tool import stream_tts_to_speaker
text_q = queue.Queue()
stop_evt = threading.Event()
done_evt = threading.Event()

View File

@ -3,72 +3,30 @@
from unittest.mock import MagicMock, patch
import pytest
def _make_base_anthropic_namespace() -> dict:
mock_client = MagicMock(name="anthropic_client")
mock_client.base_url = "https://api.anthropic.com/v1"
mock_client.api_key = "sk-ant-mock"
def _resolve_token():
import os
return os.environ.get("ANTHROPIC_TOKEN") or os.environ.get("ANTHROPIC_API_KEY")
def _build_kwargs_passthrough(model=None, messages=None, tools=None,
max_tokens=None, **kwargs):
result = {}
if model is not None:
result["model"] = model
if messages is not None:
result["messages"] = messages
if tools:
result["tools"] = tools
if max_tokens is not None:
result["max_tokens"] = max_tokens
return result
def _convert_tools(tools):
result = []
for t in (tools or []):
fn = t.get("function", {})
result.append({
"name": fn.get("name", ""),
"description": fn.get("description", ""),
"input_schema": fn.get("parameters", {}),
})
return result
def _convert_messages(messages, **kwargs):
system = None
msgs = []
for m in (messages or []):
if m.get("role") == "system":
system = m.get("content")
else:
msgs.append(m)
return system, msgs
return {
"build_anthropic_client": MagicMock(return_value=mock_client),
"build_anthropic_kwargs": _build_kwargs_passthrough,
"convert_tools_to_anthropic": _convert_tools,
"convert_messages_to_anthropic": _convert_messages,
"resolve_anthropic_token": _resolve_token,
"_is_oauth_token": lambda k: bool(k) and not (k or "").startswith("sk-ant-api"),
"is_claude_code_token_valid": MagicMock(return_value=False),
"read_claude_code_credentials": MagicMock(return_value=None),
"write_claude_code_credentials": MagicMock(),
"refresh_oauth_token": MagicMock(return_value=None),
"run_hermes_oauth_login_pure": MagicMock(return_value=("mock-token", None)),
"_HERMES_OAUTH_FILE": MagicMock(),
"_to_plain_data": MagicMock(return_value=None),
"_anthropic_sdk": None,
}
import pytest
@pytest.fixture(autouse=True)
def _seed_anthropic_registry():
"""Install mock anthropic namespace before each test, restore after."""
def _register_anthropic_transport():
"""Register the real Anthropic transport so get_transport('anthropic_messages') works."""
from agent.plugin_registries import registries
ns = _make_base_anthropic_namespace()
with patch.dict(registries._provider_services, {"anthropic": ns}):
yield
from agent.transports import register_transport
prev = registries._transports.copy()
try:
from hermes_agent_anthropic import register as _reg
class _Ctx:
def register_transport(self, api_mode, cls):
registries._transports[api_mode] = cls
def __getattr__(self, n):
if n.startswith("register_"):
return lambda *a, **kw: None
raise AttributeError(n)
_reg(_Ctx())
except ImportError:
pass
yield
registries._transports.clear()
registries._transports.update(prev)

View File

@ -159,9 +159,16 @@ class TestEphemeralMaxOutputTokens:
agent._ephemeral_max_output_tokens = 5_000
agent.max_tokens = None # will resolve to native ceiling (128K for Opus 4.6)
agent._build_api_kwargs([{"role": "user", "content": "hi"}])
# Second call — ephemeral is gone
kwargs2 = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
# Use the real build_anthropic_kwargs so max_tokens resolves correctly
from agent.plugin_registries import registries
from unittest.mock import patch
from agent.anthropic_format import build_anthropic_kwargs as _real_bkw
with patch.dict(registries._provider_services, {
"anthropic": {**registries._provider_services.get("anthropic", {}), "build_anthropic_kwargs": _real_bkw}
}):
agent._build_api_kwargs([{"role": "user", "content": "hi"}])
# Second call — ephemeral is gone
kwargs2 = agent._build_api_kwargs([{"role": "user", "content": "hi"}])
assert kwargs2["max_tokens"] == 128_000 # Opus 4.6 native ceiling
def test_no_ephemeral_uses_self_max_tokens_directly(self):

View File

@ -16,20 +16,20 @@ class TestTTSProviderNullGuard:
def test_explicit_null_provider_returns_default(self):
"""YAML ``tts: {provider: null}`` should fall back to default."""
from hermes_agent_tts import _get_provider, DEFAULT_PROVIDER
from hermes_agent_tts.tts_tool import _get_provider, DEFAULT_PROVIDER
result = _get_provider({"provider": None})
assert result == DEFAULT_PROVIDER.lower().strip()
def test_missing_provider_returns_default(self):
"""No ``provider`` key at all should also return default."""
from hermes_agent_tts import _get_provider, DEFAULT_PROVIDER
from hermes_agent_tts.tts_tool import _get_provider, DEFAULT_PROVIDER
result = _get_provider({})
assert result == DEFAULT_PROVIDER.lower().strip()
def test_valid_provider_passed_through(self):
from hermes_agent_tts import _get_provider
from hermes_agent_tts.tts_tool import _get_provider
result = _get_provider({"provider": "OPENAI"})
assert result == "openai"

View File

@ -395,23 +395,23 @@ class TestExtractHttpStatus:
def test_extracts_from_response_attr(self, image_tool):
exc = _MockHttpxError(403)
assert image_tool._extract_http_status(exc) == 403
assert __import__("hermes_agent_fal.fal_common", fromlist=["_extract_http_status"])._extract_http_status(exc) == 403
def test_extracts_from_status_code_attr(self, image_tool):
exc = Exception("fail")
exc.status_code = 404 # type: ignore[attr-defined]
assert image_tool._extract_http_status(exc) == 404
assert __import__("hermes_agent_fal.fal_common", fromlist=["_extract_http_status"])._extract_http_status(exc) == 404
def test_returns_none_for_non_http_exception(self, image_tool):
assert image_tool._extract_http_status(ValueError("nope")) is None
assert image_tool._extract_http_status(RuntimeError("nope")) is None
assert __import__("hermes_agent_fal.fal_common", fromlist=["_extract_http_status"])._extract_http_status(ValueError("nope")) is None
assert __import__("hermes_agent_fal.fal_common", fromlist=["_extract_http_status"])._extract_http_status(RuntimeError("nope")) is None
def test_response_attr_without_status_code_returns_none(self, image_tool):
class OddResponse:
pass
exc = Exception("weird")
exc.response = OddResponse() # type: ignore[attr-defined]
assert image_tool._extract_http_status(exc) is None
assert __import__("hermes_agent_fal.fal_common", fromlist=["_extract_http_status"])._extract_http_status(exc) is None
class TestManagedGatewayErrorTranslation:

View File

@ -423,7 +423,7 @@ def test_terminal_tool_prefers_managed_modal_when_gateway_ready_and_no_direct_cr
with (
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
patch.object(terminal_tool, "_get_modal_environment_class", return_value=(lambda *a, **kw: "direct-modal-env")) as direct_ctor,
patch.object(Path, "exists", return_value=False),
):
result = terminal_tool._create_environment(
@ -460,7 +460,7 @@ def test_terminal_tool_auto_mode_prefers_managed_modal_when_available():
with (
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
patch.object(terminal_tool, "_get_modal_environment_class", return_value=(lambda *a, **kw: "direct-modal-env")) as direct_ctor,
):
result = terminal_tool._create_environment(
env_type="modal",
@ -496,7 +496,7 @@ def test_terminal_tool_auto_mode_falls_back_to_direct_modal_when_managed_unavail
with (
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=False),
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
patch.object(terminal_tool, "_get_modal_environment_class", return_value=(lambda *a, **kw: "direct-modal-env")) as direct_ctor,
):
result = terminal_tool._create_environment(
env_type="modal",

View File

@ -176,6 +176,16 @@ def test_managed_fal_submit_uses_gateway_origin_and_nous_token(monkeypatch):
"tools.image_generation_tool",
"image_generation_tool.py",
)
# Inject _ManagedFalSyncClient from the plugin since the registry isn't populated
if image_generation_tool._ManagedFalSyncClient is None:
from hermes_agent_fal.fal_common import _ManagedFalSyncClient as _MSC
image_generation_tool._ManagedFalSyncClient = _MSC
if image_generation_tool._extract_http_status is None:
from hermes_agent_fal.fal_common import _extract_http_status as _EHS
image_generation_tool._extract_http_status = _EHS
if image_generation_tool._normalize_fal_queue_url_format is None:
from hermes_agent_fal.fal_common import _normalize_fal_queue_url_format as _NFQUF
image_generation_tool._normalize_fal_queue_url_format = _NFQUF
monkeypatch.setattr(image_generation_tool.uuid, "uuid4", lambda: "fal-submit-123")
image_generation_tool._submit_fal_request(

View File

@ -203,7 +203,7 @@ def test_modal_environment_migrates_legacy_snapshot_key_and_uses_snapshot_id(tmp
snapshot_store.parent.mkdir(parents=True, exist_ok=True)
snapshot_store.write_text(json.dumps({"task-legacy": "im-legacy123"}))
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
modal_module = _load_module("tools.environments.modal", Path(__file__).parent.parent.parent / "plugins" / "terminals" / "modal" / "hermes_agent_modal" / "modal.py")
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-legacy")
try:
@ -220,7 +220,7 @@ def test_modal_environment_prunes_stale_direct_snapshot_and_retries_base_image(t
snapshot_store.parent.mkdir(parents=True, exist_ok=True)
snapshot_store.write_text(json.dumps({"direct:task-stale": "im-stale123"}))
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
modal_module = _load_module("tools.environments.modal", Path(__file__).parent.parent.parent / "plugins" / "terminals" / "modal" / "hermes_agent_modal" / "modal.py")
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-stale")
try:
@ -237,7 +237,7 @@ def test_modal_environment_cleanup_writes_namespaced_snapshot_key(tmp_path):
state = _install_modal_test_modules(tmp_path, snapshot_id="im-cleanup456")
snapshot_store = state["snapshot_store"]
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
modal_module = _load_module("tools.environments.modal", Path(__file__).parent.parent.parent / "plugins" / "terminals" / "modal" / "hermes_agent_modal" / "modal.py")
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-cleanup")
env.cleanup()
@ -246,7 +246,7 @@ def test_modal_environment_cleanup_writes_namespaced_snapshot_key(tmp_path):
def test_resolve_modal_image_uses_snapshot_ids_and_registry_images(tmp_path):
state = _install_modal_test_modules(tmp_path)
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
modal_module = _load_module("tools.environments.modal", Path(__file__).parent.parent.parent / "plugins" / "terminals" / "modal" / "hermes_agent_modal" / "modal.py")
snapshot_image = modal_module._resolve_modal_image("im-snapshot123")
registry_image = modal_module._resolve_modal_image("python:3.11")

View File

@ -503,7 +503,7 @@ class TestEdgeTTSLazyImport:
reference bare 'edge_tts' module name."""
import ast as _ast
with open("tools/tts_tool.py") as f:
with open("plugins/tts/hermes_agent_tts/tts_tool.py") as f:
tree = _ast.parse(f.read())
for node in _ast.walk(tree):
@ -541,7 +541,7 @@ class TestStreamingTTSOutputStreamCleanup:
output_stream even on exception."""
import ast as _ast
with open("tools/tts_tool.py") as f:
with open("plugins/tts/hermes_agent_tts/tts_tool.py") as f:
tree = _ast.parse(f.read())
for node in _ast.walk(tree):

View File

@ -259,6 +259,12 @@ class TestCheckVoiceRequirements:
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": [], "notices": ["Termux:API microphone recording available"]})
monkeypatch.setattr("hermes_agent_stt.transcription_tools._get_provider", lambda cfg: "openai")
# Mock the registry so voice_mode can find the stt provider
from unittest.mock import MagicMock
from agent.plugin_registries import registries
mock_stt = MagicMock()
mock_stt.config_functions = {"_get_provider": lambda cfg: "openai", "_load_stt_config": lambda: {}, "is_stt_enabled": lambda cfg=None: True}
monkeypatch.setattr(registries, "get_tool_provider", lambda name: mock_stt if name == "stt" else None)
from tools.voice_mode import check_voice_requirements
result = check_voice_requirements()