wipppppppppppppppppppppppppppppppp
This commit is contained in:
parent
10421ec9ee
commit
a08725e52a
19
.github/workflows/lint.yml
vendored
19
.github/workflows/lint.yml
vendored
@ -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
166
agent/anthropic_aux.py
Normal 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
1319
agent/anthropic_format.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}>"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
261
conftest.py
261
conftest.py
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 ---------------------------------------
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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=[],
|
||||
))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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"}],
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"}],
|
||||
|
||||
@ -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
|
||||
@ -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"},
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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=[],
|
||||
))
|
||||
|
||||
@ -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
|
||||
71
plugins/model-providers/azure-foundry/tests/conftest.py
Normal file
71
plugins/model-providers/azure-foundry/tests/conftest.py
Normal 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)
|
||||
@ -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",
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"],
|
||||
))
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
@ -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
|
||||
@ -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)
|
||||
86
plugins/model-providers/bedrock/tests/conftest.py
Normal file
86
plugins/model-providers/bedrock/tests/conftest.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
19
plugins/model-providers/conftest.py
Normal file
19
plugins/model-providers/conftest.py
Normal 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)
|
||||
39
plugins/platforms/discord/tests/conftest.py
Normal file
39
plugins/platforms/discord/tests/conftest.py
Normal 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()
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -4,6 +4,10 @@ from hermes_agent_slack.adapter import ( # noqa: F401
|
||||
SlackAdapter,
|
||||
check_slack_requirements,
|
||||
_slash_user_id,
|
||||
SLACK_AVAILABLE,
|
||||
AsyncApp,
|
||||
AsyncWebClient,
|
||||
AsyncSocketModeHandler,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
160
scripts/check_no_plugin_imports_in_core.py
Normal file
160
scripts/check_no_plugin_imports_in_core.py
Normal 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())
|
||||
@ -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)"
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user