Merge branch 'simp/r3-03-review' into simp/r3-03
This commit is contained in:
+323
-576
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,11 @@
|
||||
"""Endpoint-family detection for Anthropic-compatible base URLs.
|
||||
|
||||
A dozen services speak the Anthropic Messages API but differ in auth style,
|
||||
accepted beta headers, and request quirks (MiniMax, Kimi/Moonshot, DeepSeek,
|
||||
OpenCode, Azure AI Foundry, Nous Portal, Bedrock). Every such difference is
|
||||
decided from the configured base URL, so the predicates live together here.
|
||||
|
||||
Pure functions over a base-URL string - no I/O, no SDK, no credentials - so
|
||||
both ``agent/anthropic_adapter.py`` and ``agent/anthropic_message_convert.py``
|
||||
can depend on this module without a cycle. ``agent.anthropic_adapter``
|
||||
re-exports every name below.
|
||||
A dozen services speak the Anthropic Messages API but differ in auth style, accepted beta
|
||||
headers, and request quirks (MiniMax, Kimi/Moonshot, DeepSeek, OpenCode, Azure AI Foundry, Nous
|
||||
Portal, Bedrock). Every such difference is decided from the configured base URL, so the
|
||||
predicates live together here as pure functions (no I/O, SDK or credentials) that both
|
||||
``agent/anthropic_adapter.py`` and ``agent/anthropic_message_convert.py`` can import without a
|
||||
cycle. ``agent.anthropic_adapter`` re-exports every name below.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
@@ -20,9 +17,7 @@ _MINIMAX_ANTHROPIC_PREFIXES = ("https://api.minimax.io/anthropic", "https://api.
|
||||
|
||||
def _normalize_base_url_text(base_url) -> str:
|
||||
"""Coerce a base URL (str or ``httpx.URL``) to a stripped string; "" when falsy."""
|
||||
if not base_url:
|
||||
return ""
|
||||
return str(base_url).strip()
|
||||
return str(base_url).strip() if base_url else ""
|
||||
|
||||
|
||||
def _normalized_lower(base_url) -> str:
|
||||
@@ -31,120 +26,84 @@ def _normalized_lower(base_url) -> str:
|
||||
|
||||
|
||||
def _is_third_party_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""True for any non-anthropic.com endpoint (own API keys via x-api-key; skip OAuth detection).
|
||||
|
||||
No base_url means the direct Anthropic API.
|
||||
"""
|
||||
"""Any non-anthropic.com endpoint (own x-api-key keys; skip OAuth detection). No base_url =
|
||||
direct Anthropic API."""
|
||||
normalized = _normalized_lower(base_url)
|
||||
return bool(normalized) and "anthropic.com" not in normalized
|
||||
|
||||
|
||||
def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
|
||||
"""True for Kimi's /coding endpoint, which requires a claude-code User-Agent."""
|
||||
"""Kimi's /coding endpoint, which requires a claude-code User-Agent."""
|
||||
return _normalized_lower(base_url).startswith("https://api.kimi.com/coding")
|
||||
|
||||
|
||||
def _is_opencode_endpoint(base_url: str | None) -> bool:
|
||||
"""True for OpenCode's Zen/Go relay (opencode.ai)."""
|
||||
"""OpenCode's Zen/Go relay (opencode.ai)."""
|
||||
return base_url_host_matches(base_url or "", "opencode.ai")
|
||||
|
||||
|
||||
# Model-name prefixes identifying the Kimi / Moonshot family: official slugs
|
||||
# (``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``) and release lines
|
||||
# (``k1.5-…``, ``k2-thinking``, ``k25-…``, ``k3.x``/``k3-…``). Matched
|
||||
# case-insensitively after stripping any ``vendor/`` prefix.
|
||||
# Kimi / Moonshot family model-name prefixes: official slugs (``kimi-k2.5``, ``kimi_thinking``,
|
||||
# ``moonshot-v1-8k``) and release lines (``k1.5-…``, ``k2-thinking``, ``k25-…``, ``k3.x``/``k3-…``).
|
||||
# Matched case-insensitively after stripping any ``vendor/`` prefix.
|
||||
_KIMI_FAMILY_MODEL_PREFIXES = (
|
||||
"kimi-", "kimi_",
|
||||
"moonshot-", "moonshot_",
|
||||
"k1.", "k1-",
|
||||
"k2.", "k2-",
|
||||
"k25", "k2.5",
|
||||
"k3.", "k3-",
|
||||
"kimi-", "kimi_", "moonshot-", "moonshot_", "k1.", "k1-", "k2.", "k2-", "k25", "k2.5", "k3.", "k3-",
|
||||
)
|
||||
|
||||
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3 as
|
||||
# exactly ``k3``). Exact-match so unrelated names sharing the prefix don't match.
|
||||
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3 as exactly ``k3``);
|
||||
# exact-match so unrelated names sharing the prefix don't match.
|
||||
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
|
||||
|
||||
|
||||
def _model_name_is_kimi_family(model: str | None) -> bool:
|
||||
if not isinstance(model, str):
|
||||
return False
|
||||
m = model.strip().lower()
|
||||
if not m:
|
||||
return False
|
||||
if "/" in m: # ``moonshotai/kimi-k2.5`` -> ``kimi-k2.5``
|
||||
m = m.rsplit("/", 1)[-1]
|
||||
return m in _KIMI_FAMILY_EXACT_SLUGS or m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
|
||||
m = model.strip().lower().rsplit("/", 1)[-1] # ``moonshotai/kimi-k2.5`` -> ``kimi-k2.5``
|
||||
return bool(m) and (m in _KIMI_FAMILY_EXACT_SLUGS or m.startswith(_KIMI_FAMILY_MODEL_PREFIXES))
|
||||
|
||||
|
||||
def _is_kimi_family_endpoint(base_url: str | None, model: str | None = None) -> bool:
|
||||
"""True for any Kimi / Moonshot Anthropic-Messages endpoint.
|
||||
|
||||
Broader than ``_is_kimi_coding_endpoint``: also matches any api.kimi.com /
|
||||
moonshot.ai / moonshot.cn host, and any endpoint (e.g. a private gateway)
|
||||
whose *model* is in the Kimi family — the upstream still enforces Kimi's
|
||||
thinking semantics regardless of hostname. Decides whether unsigned
|
||||
reasoning_content-derived thinking blocks are preserved on replay.
|
||||
"""
|
||||
if _is_kimi_coding_endpoint(base_url):
|
||||
return True
|
||||
if any(base_url_host_matches(base_url or "", d) for d in ("api.kimi.com", "moonshot.ai", "moonshot.cn")):
|
||||
return True
|
||||
return _model_name_is_kimi_family(model)
|
||||
"""Any Kimi / Moonshot Anthropic-Messages endpoint: the /coding endpoint, any api.kimi.com /
|
||||
moonshot.ai / moonshot.cn host, or any endpoint (e.g. a private gateway) whose *model* is in
|
||||
the Kimi family — the upstream enforces Kimi's thinking semantics regardless of hostname.
|
||||
Decides whether unsigned reasoning_content-derived thinking blocks are preserved on replay."""
|
||||
return (
|
||||
_is_kimi_coding_endpoint(base_url)
|
||||
or any(base_url_host_matches(base_url or "", d) for d in ("api.kimi.com", "moonshot.ai", "moonshot.cn"))
|
||||
or _model_name_is_kimi_family(model)
|
||||
)
|
||||
|
||||
|
||||
def _is_deepseek_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""True for DeepSeek's ``/anthropic`` route.
|
||||
|
||||
In thinking mode DeepSeek requires prior-turn ``thinking`` blocks to round-trip
|
||||
("The content[].thinking in the thinking mode must be passed back to the API"),
|
||||
while the generic third-party path strips them. Its blocks are unsigned, so it
|
||||
gets the same strip-signed / keep-unsigned policy as Kimi. Pinned to the
|
||||
``/anthropic`` path so the OpenAI-compatible base URL is not misclassified.
|
||||
"""
|
||||
if not base_url_host_matches(base_url or "", "api.deepseek.com"):
|
||||
return False
|
||||
return "/anthropic" in _normalized_lower(base_url)
|
||||
"""DeepSeek's ``/anthropic`` route. In thinking mode DeepSeek requires prior-turn ``thinking``
|
||||
blocks to round-trip while the generic third-party path strips them; its blocks are unsigned,
|
||||
so it gets the same strip-signed / keep-unsigned policy as Kimi. Pinned to the ``/anthropic``
|
||||
path so the OpenAI-compatible base URL is not misclassified."""
|
||||
return base_url_host_matches(base_url or "", "api.deepseek.com") and "/anthropic" in _normalized_lower(base_url)
|
||||
|
||||
|
||||
def _is_nous_portal_endpoint(base_url: str | None) -> bool:
|
||||
"""True for Nous Portal's Anthropic Messages route (Bearer JWT, verbatim catalog
|
||||
ids, native thinking-signature replay).
|
||||
|
||||
Trusted hosts only: prod ``inference-api.nousresearch.com`` or the operator-set
|
||||
``NOUS_INFERENCE_BASE_URL`` host (exact hostname equality, so neither lookalike
|
||||
domains nor sibling hosts of the override match).
|
||||
"""
|
||||
"""Nous Portal's Anthropic Messages route (Bearer JWT, verbatim catalog ids, native
|
||||
thinking-signature replay). Trusted hosts only: prod ``inference-api.nousresearch.com`` or the
|
||||
operator-set ``NOUS_INFERENCE_BASE_URL`` host (exact hostname equality, so neither lookalike
|
||||
domains nor sibling hosts of the override match)."""
|
||||
if base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.auth import _nous_inference_env_override
|
||||
|
||||
override = _nous_inference_env_override()
|
||||
except Exception:
|
||||
return False
|
||||
if not override:
|
||||
return False
|
||||
override_host = base_url_hostname(override)
|
||||
override_host = base_url_hostname(override) if override else ""
|
||||
return bool(override_host) and base_url_hostname(base_url or "") == override_host
|
||||
|
||||
|
||||
def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
"""True for Anthropic-compatible providers that need ``Authorization: Bearer``
|
||||
instead of ``x-api-key``: MiniMax, Azure AI Foundry, Palantir Foundry's LLM
|
||||
proxy, CommandCode, and Nous Portal.
|
||||
|
||||
Palantir/CommandCode use hostname matching (not substring) so e.g.
|
||||
``evil.com/palantirfoundry`` paths don't trigger Bearer auth.
|
||||
"""
|
||||
if _is_nous_portal_endpoint(base_url):
|
||||
return True
|
||||
"""Providers needing ``Authorization: Bearer`` instead of ``x-api-key``: MiniMax, Azure AI
|
||||
Foundry, Palantir Foundry's LLM proxy, CommandCode, Nous Portal. Palantir/CommandCode use
|
||||
hostname matching (not substring) so ``evil.com/palantirfoundry`` paths don't trigger it."""
|
||||
normalized = _normalized_lower(base_url)
|
||||
if not normalized:
|
||||
return False
|
||||
return (
|
||||
normalized.startswith(_MINIMAX_ANTHROPIC_PREFIXES)
|
||||
_is_nous_portal_endpoint(base_url)
|
||||
or normalized.startswith(_MINIMAX_ANTHROPIC_PREFIXES)
|
||||
or "azure.com" in normalized
|
||||
or base_url_host_matches(normalized, "palantirfoundry.com")
|
||||
or base_url_host_matches(normalized, "api.commandcode.ai")
|
||||
@@ -152,24 +111,21 @@ def _requires_bearer_auth(base_url: str | None) -> bool:
|
||||
|
||||
|
||||
def _base_url_needs_context_1m_beta(base_url: str | None) -> bool:
|
||||
"""True for endpoints that still gate 1M context behind a beta (Azure)."""
|
||||
"""Endpoints that still gate 1M context behind a beta (Azure)."""
|
||||
return "azure.com" in _normalize_base_url_text(base_url).lower()
|
||||
|
||||
|
||||
def _is_minimax_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""True for MiniMax's Anthropic-compatible endpoints, which reject the
|
||||
fine-grained-tool-streaming and context-1m betas (stripped even though MiniMax
|
||||
also uses Bearer auth)."""
|
||||
"""MiniMax's Anthropic-compatible endpoints, which reject the fine-grained-tool-streaming and
|
||||
context-1m betas (stripped even though MiniMax also uses Bearer auth)."""
|
||||
return _normalized_lower(base_url).startswith(_MINIMAX_ANTHROPIC_PREFIXES)
|
||||
|
||||
|
||||
def _is_azure_anthropic_endpoint(base_url: str | None) -> bool:
|
||||
"""True for Azure-hosted Anthropic Messages endpoints serving ``/anthropic``:
|
||||
modern Foundry (``*.services.ai.azure.*``) and legacy Azure OpenAI
|
||||
(``*.openai.azure.*``) hosts. Opts them into ``api-version`` query plumbing.
|
||||
|
||||
Deliberately no finite TLD allow-list, so sovereign/private clouds work.
|
||||
"""
|
||||
"""Azure-hosted Anthropic Messages endpoints serving ``/anthropic``: modern Foundry
|
||||
(``*.services.ai.azure.*``) and legacy Azure OpenAI (``*.openai.azure.*``) hosts; opts them
|
||||
into ``api-version`` query plumbing. Deliberately no finite TLD allow-list, so
|
||||
sovereign/private clouds work."""
|
||||
normalized = _normalize_base_url_text(base_url)
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
+240
-400
@@ -1,15 +1,8 @@
|
||||
"""OpenAI-style -> Anthropic Messages API request conversion.
|
||||
|
||||
Everything here rewrites *request payloads*: model-id normalization, tool
|
||||
schemas, and the message list (content blocks, thinking blocks and their
|
||||
signatures, tool_use/tool_result pairing, cache_control placement, screenshot
|
||||
eviction, blank-block scrubbing).
|
||||
|
||||
Split out of ``agent/anthropic_adapter.py`` so the adapter keeps client
|
||||
construction and the API call itself. Endpoint predicates come from
|
||||
``agent/anthropic_endpoints.py``, so this module never imports the adapter and
|
||||
there is no cycle. ``agent.anthropic_adapter`` re-exports every name below.
|
||||
"""
|
||||
"""OpenAI-style -> Anthropic Messages API request conversion: model-id normalization, tool
|
||||
schemas, and the message list (content blocks, thinking blocks and their signatures,
|
||||
tool_use/tool_result pairing, cache_control placement, screenshot eviction, blank-block
|
||||
scrubbing). Endpoint predicates come from ``agent/anthropic_endpoints.py`` so this module never
|
||||
imports the adapter (no cycle); ``agent.anthropic_adapter`` re-exports the public names."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
@@ -18,9 +11,7 @@ import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.anthropic_endpoints import (
|
||||
_is_deepseek_anthropic_endpoint,
|
||||
_is_kimi_family_endpoint,
|
||||
_is_nous_portal_endpoint,
|
||||
_is_deepseek_anthropic_endpoint, _is_kimi_family_endpoint, _is_nous_portal_endpoint,
|
||||
_is_third_party_anthropic_endpoint,
|
||||
)
|
||||
|
||||
@@ -29,14 +20,8 @@ logger = logging.getLogger(__name__)
|
||||
_THINKING_TYPES = frozenset(("thinking", "redacted_thinking"))
|
||||
_CACHEABLE_TYPES = frozenset(("text", "tool_use"))
|
||||
_EMPTY_TEXT_PLACEHOLDER = "(empty)"
|
||||
_BEDROCK_REGION_PREFIXES = (
|
||||
"global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small shared predicates
|
||||
# ---------------------------------------------------------------------------
|
||||
_EMPTY_SCHEMA = {"type": "object", "properties": {}}
|
||||
_BEDROCK_REGION_PREFIXES = ("global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af.")
|
||||
|
||||
|
||||
def _block_type(b: Any) -> Any:
|
||||
@@ -49,16 +34,9 @@ def _has_block_type(blocks: List[Any], types) -> bool:
|
||||
|
||||
|
||||
def _is_blank_text_block(b: Any) -> bool:
|
||||
"""A text block whose ``text`` is not a non-whitespace string (None/int/blank all count).
|
||||
|
||||
Anthropic rejects such blocks with HTTP 400 ("text content blocks must contain
|
||||
non-whitespace text"); checking isinstance first keeps a non-string from
|
||||
reaching ``.strip()``.
|
||||
"""
|
||||
if _block_type(b) != "text":
|
||||
return False
|
||||
text = b.get("text")
|
||||
return not (isinstance(text, str) and text.strip())
|
||||
"""A text block whose ``text`` is not a non-whitespace string (None/int/blank all count) —
|
||||
Anthropic 400s on them ("text content blocks must contain non-whitespace text")."""
|
||||
return _block_type(b) == "text" and not (isinstance(b.get("text"), str) and b["text"].strip())
|
||||
|
||||
|
||||
def _cache_control_of(b: Any) -> Optional[Dict[str, Any]]:
|
||||
@@ -70,6 +48,14 @@ def _text_block(text: str) -> Dict[str, str]:
|
||||
return {"type": "text", "text": text}
|
||||
|
||||
|
||||
def _text_block_with_citations(text: Any, cits: Any) -> Dict[str, Any]:
|
||||
"""Text block carrying ``citations`` only when it is a non-empty list (the only input-valid shape)."""
|
||||
block: Dict[str, Any] = _text_block(text)
|
||||
if isinstance(cits, list) and cits:
|
||||
block["citations"] = cits
|
||||
return block
|
||||
|
||||
|
||||
def _parse_tool_args(raw: Any) -> Any:
|
||||
"""JSON-decode a tool_call ``arguments`` string; non-strings pass through, bad JSON -> {}."""
|
||||
try:
|
||||
@@ -78,26 +64,48 @@ def _parse_tool_args(raw: Any) -> Any:
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model / tool conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
def _strip_thinking(blocks: List[Any]) -> List[Any]:
|
||||
return [b for b in blocks if _block_type(b) not in _THINKING_TYPES]
|
||||
|
||||
|
||||
def _block_ids(blocks: List[Any], btype: str, key: str) -> set:
|
||||
return {b.get(key) for b in blocks if _block_type(b) == btype}
|
||||
|
||||
|
||||
def _assistant_block_lists(result: List[Dict[str, Any]]):
|
||||
"""``(index, message)`` for every assistant message whose content is a block list."""
|
||||
return ((i, m) for i, m in enumerate(result) if m.get("role") == "assistant" and isinstance(m.get("content"), list))
|
||||
|
||||
|
||||
def _carry_cache_control(out: Dict[str, Any], b: Any, *, copy: bool = False) -> Dict[str, Any]:
|
||||
"""Carry a dict-valued ``cache_control`` marker from ``b`` onto ``out`` (returned); ``copy``
|
||||
shallow-copies it so the caller's dict is never shared with the wire payload."""
|
||||
cc = _cache_control_of(b)
|
||||
if cc is not None:
|
||||
out["cache_control"] = dict(cc) if copy else cc
|
||||
return out
|
||||
|
||||
|
||||
def _split_blank_text_blocks(blocks: List[Any]) -> Tuple[List[Any], Any, List[int]]:
|
||||
"""``(kept, relocated_cache_control, dropped_indexes)``: drop blank text blocks, remembering
|
||||
the cache_control of the last one dropped so the caller can relocate the breakpoint."""
|
||||
dropped = [i for i, blk in enumerate(blocks) if _is_blank_text_block(blk)]
|
||||
kept = [blk for i, blk in enumerate(blocks) if i not in dropped]
|
||||
relocated = [cc for i in dropped if (cc := _cache_control_of(blocks[i])) is not None]
|
||||
return kept, relocated[-1] if relocated else None, dropped
|
||||
|
||||
|
||||
def _is_bedrock_model_id(model: str) -> bool:
|
||||
"""Bedrock ids (``anthropic.claude-opus-4-7``, ``us.anthropic.claude-*``) use dots
|
||||
as namespace separators that must be preserved verbatim."""
|
||||
"""Bedrock ids (``anthropic.claude-opus-4-7``, ``us.anthropic.claude-*``) use dots as namespace
|
||||
separators that must be preserved verbatim."""
|
||||
return model.lower().startswith(_BEDROCK_REGION_PREFIXES + ("anthropic.",))
|
||||
|
||||
|
||||
def normalize_model_name(model: str, preserve_dots: bool = False) -> str:
|
||||
"""Normalize a model name for the Anthropic API.
|
||||
|
||||
Strips the ``anthropic/`` prefix (case-insensitive) and, unless
|
||||
``preserve_dots`` (DashScope: ``qwen3.5-plus``), converts version dots to
|
||||
hyphens for Claude models only (``claude-opus-4.6`` -> ``claude-opus-4-6``).
|
||||
Bedrock ids keep their namespace dots; non-Anthropic names (``gpt-5.4``)
|
||||
keep dots as part of their canonical form.
|
||||
"""
|
||||
"""Strip the ``anthropic/`` prefix (case-insensitive) and, unless ``preserve_dots`` (DashScope:
|
||||
``qwen3.5-plus``), convert version dots to hyphens for Claude models only (``claude-opus-4.6``
|
||||
-> ``claude-opus-4-6``). Bedrock ids keep their namespace dots; non-Anthropic names
|
||||
(``gpt-5.4``) keep dots as part of their canonical form."""
|
||||
if model.lower().startswith("anthropic/"):
|
||||
model = model[len("anthropic/"):]
|
||||
if not preserve_dots and not _is_bedrock_model_id(model) and model.lower().startswith(("claude-", "anthropic/")):
|
||||
@@ -107,28 +115,24 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str:
|
||||
|
||||
def _sanitize_tool_id(tool_id: str) -> str:
|
||||
"""Anthropic requires ids matching [a-zA-Z0-9_-]; replace the rest, never empty."""
|
||||
if not tool_id:
|
||||
return "tool_0"
|
||||
return re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id) or "tool_0"
|
||||
return (re.sub(r"[^a-zA-Z0-9_-]", "_", tool_id) if tool_id else "") or "tool_0"
|
||||
|
||||
|
||||
def _tool_use_block(tool_id: Any, name: Any, tool_input: Any) -> Dict[str, Any]:
|
||||
return {"type": "tool_use", "id": _sanitize_tool_id(tool_id), "name": name, "input": tool_input}
|
||||
|
||||
|
||||
def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
|
||||
"""Normalize a tool schema for Anthropic's validator.
|
||||
|
||||
Collapses nullable unions (``anyOf: [{type: string}, {type: null}]``, which
|
||||
Pydantic/MCP emit for optional fields) to the non-null branch — optionality is
|
||||
already expressed by ``required``. ``keep_nullable_hint=False`` because the
|
||||
OpenAPI ``nullable`` keyword is not recognized. Top-level oneOf/allOf/anyOf are
|
||||
rejected with a generic 400, so they are dropped in favour of a plain object.
|
||||
"""
|
||||
if not schema:
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
"""Normalize a tool schema for Anthropic's validator: collapse nullable unions (``anyOf:
|
||||
[{type: string}, {type: null}]`` from Pydantic/MCP optional fields) to the non-null branch —
|
||||
optionality is already expressed by ``required``; ``keep_nullable_hint=False`` because the
|
||||
OpenAPI ``nullable`` keyword is not recognized. Top-level oneOf/allOf/anyOf are rejected with a
|
||||
generic 400, so they are dropped in favour of a plain object."""
|
||||
from tools.schema_sanitizer import strip_nullable_unions
|
||||
|
||||
normalized = strip_nullable_unions(schema, keep_nullable_hint=False)
|
||||
normalized = strip_nullable_unions(schema, keep_nullable_hint=False) if schema else None
|
||||
if not isinstance(normalized, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
return dict(_EMPTY_SCHEMA)
|
||||
banned = {"oneOf", "allOf", "anyOf"}
|
||||
if banned & normalized.keys():
|
||||
normalized = {k: v for k, v in normalized.items() if k not in banned}
|
||||
@@ -139,42 +143,26 @@ def _normalize_tool_input_schema(schema: Any) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]:
|
||||
"""Convert OpenAI tool definitions to Anthropic format.
|
||||
|
||||
Duplicate names are dropped with a warning (Anthropic hard-400s on them);
|
||||
``cache_control`` on the OpenAI tool dict is forwarded.
|
||||
"""
|
||||
if not tools:
|
||||
return []
|
||||
"""Convert OpenAI tool definitions to Anthropic format. Duplicate names are dropped with a
|
||||
warning (Anthropic hard-400s on them); ``cache_control`` on the OpenAI tool dict is forwarded."""
|
||||
result = []
|
||||
seen_names: set = set()
|
||||
for t in tools:
|
||||
for t in tools or []:
|
||||
fn = t.get("function", {})
|
||||
name = fn.get("name", "")
|
||||
if name and name in seen_names:
|
||||
logger.warning(
|
||||
"convert_tools_to_anthropic: duplicate tool name '%s' — dropping second occurrence", name
|
||||
)
|
||||
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", ""),
|
||||
"name": name, "description": fn.get("description", ""),
|
||||
"input_schema": _normalize_tool_input_schema(fn.get("parameters") or {}),
|
||||
}
|
||||
cache_control = _cache_control_of(t)
|
||||
if cache_control is not None:
|
||||
anthropic_tool["cache_control"] = dict(cache_control)
|
||||
result.append(anthropic_tool)
|
||||
result.append(_carry_cache_control(anthropic_tool, t, copy=True))
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content-part conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _image_source_from_openai_url(url: str) -> Dict[str, str]:
|
||||
"""OpenAI image URL / data URL -> Anthropic image ``source``."""
|
||||
url = str(url or "").strip()
|
||||
@@ -190,46 +178,31 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Convert one OpenAI-style content part to an Anthropic block (None -> dropped)."""
|
||||
if part is None:
|
||||
return None
|
||||
if isinstance(part, str):
|
||||
return _text_block(part)
|
||||
if not isinstance(part, dict):
|
||||
return _text_block(str(part))
|
||||
|
||||
return _text_block(part if isinstance(part, str) else str(part))
|
||||
ptype = part.get("type")
|
||||
if ptype in ("input_text", "text"):
|
||||
# Rebuild from whitelisted fields only: stored SDK text blocks carry
|
||||
# output-only siblings (parsed_output, citations=None) that the INPUT
|
||||
# schema rejects with 400 "Extra inputs are not permitted".
|
||||
block: Dict[str, Any] = _text_block(part.get("text", ""))
|
||||
cits = part.get("citations")
|
||||
if ptype == "text" and isinstance(cits, list) and cits:
|
||||
block["citations"] = cits
|
||||
# Rebuild from whitelisted fields only: stored SDK text blocks carry output-only siblings
|
||||
# (parsed_output, citations=None) that the INPUT schema rejects with 400.
|
||||
block = _text_block_with_citations(part.get("text", ""), part.get("citations") if ptype == "text" else None)
|
||||
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)
|
||||
|
||||
cache_control = _cache_control_of(part)
|
||||
if cache_control is not None:
|
||||
if (cache_control := _cache_control_of(part)) is not None:
|
||||
block.setdefault("cache_control", dict(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.
|
||||
|
||||
``_path`` tracks ids on the *current* recursion path (so shared but
|
||||
non-cyclic objects convert normally while true cycles stringify); depth is
|
||||
capped at 20.
|
||||
"""
|
||||
if _depth > 20:
|
||||
return str(value)
|
||||
if _path is None:
|
||||
_path = set()
|
||||
"""Recursively convert SDK objects to plain Python data. ``_path`` tracks ids on the *current*
|
||||
recursion path (shared but non-cyclic objects convert normally while true cycles stringify);
|
||||
depth is capped at 20."""
|
||||
_path = set() if _path is None else _path
|
||||
obj_id = id(value)
|
||||
if obj_id in _path:
|
||||
if _depth > 20 or obj_id in _path:
|
||||
return str(value)
|
||||
|
||||
def rec(v):
|
||||
@@ -238,8 +211,8 @@ def _to_plain_data(value: Any, *, _depth: int = 0, _path: Optional[set] = None)
|
||||
_path.add(obj_id)
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
# warnings=False: streaming-accumulator blocks trip pydantic's
|
||||
# serializer-mismatch UserWarning, which otherwise leaks to the terminal.
|
||||
# warnings=False: streaming-accumulator blocks trip pydantic's serializer-mismatch
|
||||
# UserWarning, which otherwise leaks to the terminal.
|
||||
dumped = value.model_dump(warnings=False)
|
||||
except TypeError: # duck-typed model_dump without pydantic's signature
|
||||
dumped = value.model_dump()
|
||||
@@ -276,12 +249,10 @@ def _convert_content_to_anthropic(content: Any) -> Any:
|
||||
|
||||
|
||||
def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
||||
"""Tool-message content parts -> tool_result inner blocks (text + image only,
|
||||
the types Anthropic accepts there). Used for multimodal tool results."""
|
||||
if not isinstance(parts, list):
|
||||
return []
|
||||
"""Tool-message content parts -> tool_result inner blocks (text + image only, the types
|
||||
Anthropic accepts there). Used for multimodal tool results."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for block in map(_convert_content_part_to_anthropic, parts):
|
||||
for block in map(_convert_content_part_to_anthropic, parts if isinstance(parts, list) else []):
|
||||
if not block:
|
||||
continue
|
||||
btype, text_val, src = block.get("type"), block.get("text"), block.get("source")
|
||||
@@ -293,44 +264,25 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def _safe_text(text: Any) -> str:
|
||||
"""``text`` if non-whitespace, else the placeholder.
|
||||
|
||||
A blank text block stored in history (e.g. by compression) is replayed on every
|
||||
turn and wedges the session with HTTP 400; the placeholder is self-healing.
|
||||
Mirrors ``bedrock_adapter._safe_text`` (kept separate on purpose).
|
||||
"""
|
||||
if text is None:
|
||||
return _EMPTY_TEXT_PLACEHOLDER
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
"""``text`` if non-whitespace, else the placeholder. A blank text block stored in history (e.g.
|
||||
by compression) is replayed on every turn and wedges the session with HTTP 400; the placeholder
|
||||
is self-healing. Mirrors ``bedrock_adapter._safe_text`` (kept separate on purpose)."""
|
||||
text = "" if text is None else str(text)
|
||||
return text if text.strip() else _EMPTY_TEXT_PLACEHOLDER
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replay-block sanitizing (per-type whitelist)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _replay_text(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
# Drop blank blocks rather than coerce in place: the caller relocates any
|
||||
# cache_control and falls back to a placeholder only when nothing survives,
|
||||
# so "(empty)" never sits as model-visible noise next to real blocks.
|
||||
# Drop blank blocks rather than coerce in place: the caller relocates any cache_control and
|
||||
# falls back to a placeholder only when nothing survives, so "(empty)" never sits as
|
||||
# model-visible noise next to real blocks.
|
||||
if _is_blank_text_block(b):
|
||||
return None
|
||||
out: Dict[str, Any] = _text_block(b["text"])
|
||||
cits = b.get("citations") # input-valid ONLY as a non-empty list
|
||||
if isinstance(cits, list) and cits:
|
||||
out["citations"] = cits
|
||||
if _cache_control_of(b) is not None:
|
||||
out["cache_control"] = b["cache_control"]
|
||||
return out
|
||||
return _carry_cache_control(_text_block_with_citations(b["text"], b.get("citations")), b)
|
||||
|
||||
|
||||
def _replay_thinking(b: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = {"type": "thinking", "thinking": b.get("thinking", "")}
|
||||
if b.get("signature"):
|
||||
out["signature"] = b["signature"]
|
||||
return out
|
||||
return {**out, "signature": b["signature"]} if b.get("signature") else out
|
||||
|
||||
|
||||
def _replay_redacted_thinking(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
@@ -338,15 +290,7 @@ def _replay_redacted_thinking(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
def _replay_tool_use(b: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out = {
|
||||
"type": "tool_use",
|
||||
"id": _sanitize_tool_id(b.get("id", "")),
|
||||
"name": b.get("name", ""),
|
||||
"input": b.get("input", {}),
|
||||
}
|
||||
if _cache_control_of(b) is not None:
|
||||
out["cache_control"] = b["cache_control"]
|
||||
return out
|
||||
return _carry_cache_control(_tool_use_block(b.get("id", ""), b.get("name", ""), b.get("input", {})), b)
|
||||
|
||||
|
||||
def _replay_image(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
@@ -355,33 +299,24 @@ def _replay_image(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
_REPLAY_SANITIZERS = {
|
||||
"text": _replay_text,
|
||||
"thinking": _replay_thinking,
|
||||
"redacted_thinking": _replay_redacted_thinking,
|
||||
"tool_use": _replay_tool_use,
|
||||
"image": _replay_image,
|
||||
"text": _replay_text, "thinking": _replay_thinking, "redacted_thinking": _replay_redacted_thinking,
|
||||
"tool_use": _replay_tool_use, "image": _replay_image,
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Whitelist a stored Anthropic block so it is valid as REQUEST input.
|
||||
|
||||
SDK response blocks carry output-only fields the INPUT schema forbids
|
||||
("Extra inputs are not permitted": ``parsed_output``, ``caller``,
|
||||
``citations=None``), and ``_to_plain_data`` captured them verbatim. Whitelist
|
||||
per type (not blacklist) so future SDK fields can't reintroduce the bug;
|
||||
unknown types are dropped. Returns a clean block or None.
|
||||
"""
|
||||
"""Whitelist a stored Anthropic block so it is valid as REQUEST input. SDK response blocks carry
|
||||
output-only fields the INPUT schema forbids ("Extra inputs are not permitted": ``parsed_output``,
|
||||
``caller``, ``citations=None``), and ``_to_plain_data`` captured them verbatim. Whitelist per
|
||||
type (not blacklist) so future SDK fields can't reintroduce the bug; unknown types are dropped.
|
||||
Returns a clean block or None."""
|
||||
if not isinstance(b, dict):
|
||||
return None
|
||||
sanitizer = _REPLAY_SANITIZERS.get(b.get("type"))
|
||||
return sanitizer(b) if sanitizer else None
|
||||
|
||||
|
||||
def _apply_assistant_cache_control_to_last_cacheable_block(
|
||||
blocks: List[Dict[str, Any]],
|
||||
cache_control: Any,
|
||||
) -> None:
|
||||
def _apply_assistant_cache_control_to_last_cacheable_block(blocks: List[Dict[str, Any]], cache_control: Any) -> None:
|
||||
if not isinstance(cache_control, dict):
|
||||
return
|
||||
for block in reversed(blocks):
|
||||
@@ -390,20 +325,12 @@ def _apply_assistant_cache_control_to_last_cacheable_block(
|
||||
break
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-message conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Interleaved-thinking replay: rebuild the assistant turn from the verbatim
|
||||
block list normalize_response stored (only for turns interleaving SIGNED
|
||||
thinking with tool_use). Preserves block ORDER; returns None if nothing survives.
|
||||
|
||||
tool_use ``input`` is re-sourced from ``tool_calls`` (redacted at storage time)
|
||||
rather than the captured block (raw API response, NOT redacted), so a secret
|
||||
the model inlined into a tool call never goes back on the wire.
|
||||
"""
|
||||
"""Interleaved-thinking replay: rebuild the assistant turn from the verbatim block list
|
||||
normalize_response stored (only for turns interleaving SIGNED thinking with tool_use).
|
||||
Preserves block ORDER; returns None if nothing survives. tool_use ``input`` is re-sourced from
|
||||
``tool_calls`` (redacted at storage time) rather than the captured block (raw API response, NOT
|
||||
redacted), so a secret the model inlined into a tool call never goes back on the wire."""
|
||||
redacted_input_by_id = {
|
||||
_sanitize_tool_id(tc.get("id", "")): _parse_tool_args((tc.get("function", {}) or {}).get("arguments", "{}"))
|
||||
for tc in m.get("tool_calls", []) or []
|
||||
@@ -415,27 +342,24 @@ def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Opti
|
||||
for b in ordered_blocks:
|
||||
clean = _sanitize_replay_block(b)
|
||||
if clean is None:
|
||||
if _block_type(b) == "text":
|
||||
dropped_blank_text = True
|
||||
if _cache_control_of(b) is not None: # relocate a dropped block's breakpoint
|
||||
relocated_cc = b["cache_control"]
|
||||
dropped_blank_text = dropped_blank_text or _block_type(b) == "text"
|
||||
if (cc := _cache_control_of(b)) is not None: # relocate a dropped block's breakpoint
|
||||
relocated_cc = cc
|
||||
continue
|
||||
if clean.get("type") == "tool_use":
|
||||
redacted = redacted_input_by_id.get(clean.get("id", ""))
|
||||
if redacted is not None:
|
||||
clean["input"] = redacted
|
||||
if clean.get("type") == "tool_use" and (redacted := redacted_input_by_id.get(clean.get("id", ""))) is not None:
|
||||
clean["input"] = redacted
|
||||
replayed.append(clean)
|
||||
# Nothing cacheable survived (e.g. signed thinking + blank text): emit the
|
||||
# placeholder so the turn stays schema-valid and a relocated marker has a carrier.
|
||||
# Nothing cacheable survived (e.g. signed thinking + blank text): emit the placeholder so the
|
||||
# turn stays schema-valid and a relocated marker has a carrier.
|
||||
if not _has_block_type(replayed, _CACHEABLE_TYPES) and (dropped_blank_text or relocated_cc is not None):
|
||||
replayed.append(_text_block(_EMPTY_TEXT_PLACEHOLDER))
|
||||
if not replayed:
|
||||
return None
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(replayed, relocated_cc)
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(replayed, m.get("cache_control"))
|
||||
# prompt_caching marks an assistant turn with text by writing cache_control
|
||||
# INTO ``content`` (not top-level). This path never reads ``content``, so
|
||||
# carry that marker over or the breakpoint is burned rather than relocated.
|
||||
# prompt_caching marks an assistant turn with text by writing cache_control INTO ``content``
|
||||
# (not top-level). This path never reads ``content``, so carry that marker over or the
|
||||
# breakpoint is burned rather than relocated.
|
||||
msg_content = m.get("content")
|
||||
if isinstance(msg_content, list):
|
||||
inline_cc = next((cc for cc in map(_cache_control_of, msg_content) if cc is not None), None)
|
||||
@@ -444,52 +368,40 @@ def _replay_ordered_blocks(m: Dict[str, Any], ordered_blocks: List[Any]) -> Opti
|
||||
|
||||
|
||||
def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Assistant message -> Anthropic content blocks (thinking, text, tool_use,
|
||||
Kimi/DeepSeek reasoning_content injection)."""
|
||||
"""Assistant message -> Anthropic content blocks (thinking, text, tool_use, Kimi/DeepSeek
|
||||
reasoning_content injection)."""
|
||||
content = m.get("content", "")
|
||||
ordered_blocks = m.get("anthropic_content_blocks")
|
||||
if isinstance(ordered_blocks, list) and ordered_blocks:
|
||||
replayed = _replay_ordered_blocks(m, ordered_blocks)
|
||||
if replayed:
|
||||
return {"role": "assistant", "content": replayed}
|
||||
|
||||
blocks = _extract_preserved_thinking_blocks(m)
|
||||
# Blank text blocks are dropped; a cache marker riding on one is relocated
|
||||
# onto the last surviving cacheable block (prompt_caching sets cache_control
|
||||
# on content[-1], which may be exactly the blank block).
|
||||
# Blank text blocks are dropped; a cache marker riding on one is relocated onto the last
|
||||
# surviving cacheable block (prompt_caching sets cache_control on content[-1], which may be
|
||||
# exactly the blank block).
|
||||
relocated_cc = None
|
||||
if isinstance(content, list):
|
||||
for blk in _convert_content_to_anthropic(content):
|
||||
if _is_blank_text_block(blk):
|
||||
if _cache_control_of(blk) is not None:
|
||||
relocated_cc = blk["cache_control"]
|
||||
continue
|
||||
blocks.append(blk)
|
||||
kept, relocated_cc, _ = _split_blank_text_blocks(_convert_content_to_anthropic(content))
|
||||
blocks.extend(kept)
|
||||
elif content and str(content).strip():
|
||||
blocks.append(_text_block(str(content)))
|
||||
for tc in m.get("tool_calls", []):
|
||||
if not tc or not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function", {})
|
||||
blocks.append({
|
||||
"type": "tool_use",
|
||||
"id": _sanitize_tool_id(tc.get("id", "")),
|
||||
"name": fn.get("name", ""),
|
||||
"input": _parse_tool_args(fn.get("arguments", "{}")),
|
||||
})
|
||||
# Kimi's /coding endpoint requires reasoning_content on replayed tool-call
|
||||
# turns — even "" (injected as a fallback upstream). Prepend, since thinking
|
||||
# must precede text/tool_use. Skip when reasoning_details already supplied
|
||||
# (signed) thinking blocks: a duplicate unsigned one would be downgraded to a
|
||||
# spurious text block on the last assistant message.
|
||||
blocks.append(_tool_use_block(tc.get("id", ""), fn.get("name", ""), _parse_tool_args(fn.get("arguments", "{}"))))
|
||||
# Kimi's /coding endpoint requires reasoning_content on replayed tool-call turns — even ""
|
||||
# (injected as a fallback upstream). Prepend, since thinking must precede text/tool_use. Skip
|
||||
# when reasoning_details already supplied (signed) thinking blocks: a duplicate unsigned one
|
||||
# would be downgraded to a spurious text block on the last assistant message.
|
||||
reasoning_content = m.get("reasoning_content")
|
||||
if isinstance(reasoning_content, str) and not _has_block_type(blocks, _THINKING_TYPES):
|
||||
blocks.insert(0, {"type": "thinking", "thinking": reasoning_content})
|
||||
# Empty assistant content is rejected. Fall back ONLY to the placeholder,
|
||||
# never to raw ``content`` — that is the unfiltered blank payload just removed.
|
||||
# Empty assistant content is rejected. Fall back ONLY to the placeholder, never to raw
|
||||
# ``content`` — that is the unfiltered blank payload just removed. Markers are applied after
|
||||
# the fallback so one from a sole dropped blank block lands on the placeholder.
|
||||
effective = blocks or [_text_block(_EMPTY_TEXT_PLACEHOLDER)]
|
||||
# Applied after the fallback so a marker from a sole dropped blank block
|
||||
# lands on the placeholder instead of being lost.
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(effective, relocated_cc)
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(effective, m.get("cache_control"))
|
||||
return {"role": "assistant", "content": effective}
|
||||
@@ -505,36 +417,30 @@ def _tool_result_content(m: Dict[str, Any]) -> Any:
|
||||
multimodal_blocks = [_text_block(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):
|
||||
if _has_block_type(converted, {"image"}):
|
||||
multimodal_blocks = converted
|
||||
if multimodal_blocks is None: # back-compat: blocks stashed under a private key
|
||||
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 = [_text_block(text_content)] + stashed if text_content else list(stashed)
|
||||
|
||||
if multimodal_blocks:
|
||||
return multimodal_blocks
|
||||
if isinstance(content, str):
|
||||
return content or "(no output)"
|
||||
return json.dumps(content) if content else "(no output)"
|
||||
return (content if isinstance(content, str) else json.dumps(content) if content else "") or "(no output)"
|
||||
|
||||
|
||||
def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, Any]) -> None:
|
||||
"""Append a tool_result to ``result``, merging into a trailing tool_result user
|
||||
message when there is one. Mutates ``result`` in place."""
|
||||
"""Append a tool_result to ``result``, merging into a trailing tool_result user message when
|
||||
there is one. Mutates ``result`` in place."""
|
||||
tool_result = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
|
||||
"type": "tool_result", "tool_use_id": _sanitize_tool_id(m.get("tool_call_id", "")),
|
||||
"content": _tool_result_content(m),
|
||||
}
|
||||
cache_control = _cache_control_of(m)
|
||||
if cache_control is not None:
|
||||
tool_result["cache_control"] = dict(cache_control)
|
||||
_carry_cache_control(tool_result, m, copy=True)
|
||||
last = result[-1] if result else {}
|
||||
if last.get("role") == "user" and isinstance(last.get("content"), list) and last["content"] \
|
||||
and last["content"][0].get("type") == "tool_result":
|
||||
last["content"].append(tool_result)
|
||||
last_content = last.get("content") if last.get("role") == "user" else None
|
||||
if isinstance(last_content, list) and last_content and last_content[0].get("type") == "tool_result":
|
||||
last_content.append(tool_result)
|
||||
else:
|
||||
result.append({"role": "user", "content": [tool_result]})
|
||||
|
||||
@@ -542,85 +448,66 @@ def _convert_tool_message_to_result(result: List[Dict[str, Any]], m: Dict[str, A
|
||||
def _convert_user_message(content: Any) -> Dict[str, Any]:
|
||||
"""Validate and convert a user message to Anthropic format."""
|
||||
if isinstance(content, list):
|
||||
kept_blocks = _fix_blank_text_blocks_in_list(
|
||||
content = _fix_blank_text_blocks_in_list(
|
||||
_convert_content_to_anthropic(content), placeholder_text="(empty message)",
|
||||
msg_index=-1, role="user", location="_convert_user_message",
|
||||
)
|
||||
return {"role": "user", "content": kept_blocks}
|
||||
if not content or (isinstance(content, str) and not content.strip()):
|
||||
elif not content or (isinstance(content, str) and not content.strip()):
|
||||
content = "(empty message)"
|
||||
return {"role": "user", "content": content}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Whole-list passes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
"""Strip tool_use blocks with no matching tool_result, and vice versa.
|
||||
|
||||
Compression/truncation can remove either side of a pair or insert messages
|
||||
between them. Anthropic requires the tool_result in the IMMEDIATELY FOLLOWING
|
||||
user message — a global id match is not enough. Mutates ``result`` in place.
|
||||
"""
|
||||
"""Strip tool_use blocks with no matching tool_result, and vice versa. Compression/truncation
|
||||
can remove either side of a pair or insert messages between them. Anthropic requires the
|
||||
tool_result in the IMMEDIATELY FOLLOWING user message — a global id match is not enough.
|
||||
Mutates ``result`` in place."""
|
||||
# Pass 1: tool_use without an adjacent result.
|
||||
for i, m in enumerate(result):
|
||||
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
tool_use_ids_in_turn = {b.get("id") for b in m["content"] if _block_type(b) == "tool_use"}
|
||||
for i, m in _assistant_block_lists(result):
|
||||
tool_use_ids_in_turn = _block_ids(m["content"], "tool_use", "id")
|
||||
if not tool_use_ids_in_turn:
|
||||
continue
|
||||
adjacent_result_ids: set = set()
|
||||
if i + 1 < len(result):
|
||||
nxt = result[i + 1]
|
||||
if nxt.get("role") == "user" and isinstance(nxt.get("content"), list):
|
||||
adjacent_result_ids = {b.get("tool_use_id") for b in nxt["content"] if _block_type(b) == "tool_result"}
|
||||
adjacent_result_ids = _block_ids(nxt["content"], "tool_result", "tool_use_id")
|
||||
orphaned = tool_use_ids_in_turn - adjacent_result_ids
|
||||
if not orphaned:
|
||||
continue
|
||||
kept = [b for b in m["content"] if not (_block_type(b) == "tool_use" and b.get("id") in orphaned)]
|
||||
# A signed thinking block on this turn was signed against the ORIGINAL
|
||||
# content and is now dead (400 "thinking blocks in the latest assistant
|
||||
# message cannot be modified"). Flag so _manage_thinking_signatures demotes it.
|
||||
# A signed thinking block on this turn was signed against the ORIGINAL content and is now
|
||||
# dead (400 "thinking blocks in the latest assistant message cannot be modified"). Flag so
|
||||
# _manage_thinking_signatures demotes it.
|
||||
if len(kept) != len(m["content"]) and _has_block_type(m["content"], _THINKING_TYPES):
|
||||
m["_thinking_signature_invalidated"] = True
|
||||
m["content"] = kept if kept else [_text_block("(tool call removed)")]
|
||||
|
||||
# Pass 2: tool_result whose tool_use no longer exists anywhere.
|
||||
surviving_tool_use_ids = {
|
||||
b.get("id")
|
||||
for m in result
|
||||
if m.get("role") == "assistant" and isinstance(m.get("content"), list)
|
||||
for b in m["content"]
|
||||
if _block_type(b) == "tool_use"
|
||||
}
|
||||
surviving_tool_use_ids: set = set()
|
||||
for _, m in _assistant_block_lists(result):
|
||||
surviving_tool_use_ids |= _block_ids(m["content"], "tool_use", "id")
|
||||
for m in result:
|
||||
if m.get("role") != "user" or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
new_content = [
|
||||
b for b in m["content"]
|
||||
if _block_type(b) != "tool_result" or b.get("tool_use_id") in surviving_tool_use_ids
|
||||
b for b in m["content"] if _block_type(b) != "tool_result" or b.get("tool_use_id") in surviving_tool_use_ids
|
||||
]
|
||||
if len(new_content) != len(m["content"]):
|
||||
m["content"] = new_content if new_content else [_text_block("(tool result removed)")]
|
||||
|
||||
|
||||
def _concat_content(prev: Any, curr: Any) -> Any:
|
||||
"""Merge two message contents: str+str joined by newline, list+list concatenated,
|
||||
mixed shapes promoted to block lists."""
|
||||
"""Merge two message contents: str+str joined by newline, list+list concatenated, mixed shapes
|
||||
promoted to block lists."""
|
||||
if isinstance(prev, str) and isinstance(curr, str):
|
||||
return prev + "\n" + curr
|
||||
if isinstance(prev, str):
|
||||
prev = [_text_block(prev)]
|
||||
if isinstance(curr, str):
|
||||
curr = [_text_block(curr)]
|
||||
return prev + curr
|
||||
as_blocks = lambda c: [_text_block(c)] if isinstance(c, str) else c # noqa: E731
|
||||
return as_blocks(prev) + as_blocks(curr)
|
||||
|
||||
|
||||
def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Merge consecutive same-role messages to enforce alternation. Returns a new list."""
|
||||
fixed = []
|
||||
fixed: List[Dict[str, Any]] = []
|
||||
for m in result:
|
||||
if not (fixed and fixed[-1]["role"] == m["role"]):
|
||||
fixed.append(m)
|
||||
@@ -629,33 +516,47 @@ def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any
|
||||
# Keep the orphan-strip flag visible to _manage_thinking_signatures.
|
||||
if m.get("_thinking_signature_invalidated"):
|
||||
fixed[-1]["_thinking_signature_invalidated"] = True
|
||||
# The second message's thinking blocks were signed against a
|
||||
# different turn boundary and become invalid once merged.
|
||||
# The second message's thinking blocks were signed against a different turn boundary
|
||||
# and become invalid once merged.
|
||||
if isinstance(m["content"], list):
|
||||
m["content"] = [b for b in m["content"] if _block_type(b) not in _THINKING_TYPES]
|
||||
m["content"] = _strip_thinking(m["content"])
|
||||
fixed[-1]["content"] = _concat_content(fixed[-1]["content"], m["content"])
|
||||
return fixed
|
||||
|
||||
|
||||
def _keep_valid_latest_thinking(content: List[Any], signature_dead: bool) -> List[Any]:
|
||||
"""Latest assistant turn on direct Anthropic: keep signed thinking, demote unsigned to text so
|
||||
the reasoning isn't lost. If orphan-stripping mutated THIS turn every signature is dead (and a
|
||||
bare signed block with no tool_use is also invalid), so demote ALL of them."""
|
||||
new_content = []
|
||||
for b in content:
|
||||
if _block_type(b) not in _THINKING_TYPES:
|
||||
new_content.append(b)
|
||||
continue
|
||||
is_redacted = b.get("type") == "redacted_thinking"
|
||||
signed = b.get("data") if is_redacted else b.get("signature") # redacted 'data' IS the signature
|
||||
if signed and not signature_dead:
|
||||
new_content.append(b)
|
||||
elif (signature_dead or not is_redacted) and b.get("thinking"):
|
||||
new_content.append(_text_block(b["thinking"])) # demote to plain text; dataless redacted dropped
|
||||
return new_content
|
||||
|
||||
|
||||
def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | None, model: str | None) -> None:
|
||||
"""Strip or preserve thinking blocks per endpoint. Mutates ``result`` in place.
|
||||
|
||||
Anthropic signs thinking blocks against the full turn; any upstream mutation
|
||||
invalidates them (400 "Invalid signature in thinking block"), so on direct
|
||||
Anthropic only the LATEST assistant turn keeps signed blocks. Signatures are
|
||||
proprietary: third-party endpoints strip all thinking. Kimi replays as-is;
|
||||
DeepSeek needs unsigned blocks round-tripped but rejects signed ones. Nous
|
||||
Portal proxies Claude with sticky sessions and validates the same signatures,
|
||||
so it takes the native path despite not being anthropic.com.
|
||||
Anthropic signs thinking blocks against the full turn; any upstream mutation invalidates them
|
||||
(400 "Invalid signature in thinking block"), so on direct Anthropic only the LATEST assistant
|
||||
turn keeps signed blocks. Signatures are proprietary: third-party endpoints strip all thinking.
|
||||
Kimi replays as-is; DeepSeek needs unsigned blocks round-tripped but rejects signed ones. Nous
|
||||
Portal proxies Claude with sticky sessions and validates the same signatures, so it takes the
|
||||
native path despite not being anthropic.com.
|
||||
"""
|
||||
is_third_party = _is_third_party_anthropic_endpoint(base_url) and not _is_nous_portal_endpoint(base_url)
|
||||
is_kimi = _is_kimi_family_endpoint(base_url, model)
|
||||
is_deepseek = _is_deepseek_anthropic_endpoint(base_url)
|
||||
last_assistant_idx = next((i for i in range(len(result) - 1, -1, -1) if result[i].get("role") == "assistant"), None)
|
||||
|
||||
for idx, m in enumerate(result):
|
||||
if m.get("role") != "assistant" or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
for idx, m in _assistant_block_lists(result):
|
||||
if is_kimi:
|
||||
pass # shared cleanup below still strips cache markers + the flag
|
||||
elif is_deepseek:
|
||||
@@ -666,28 +567,10 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No
|
||||
]
|
||||
m["content"] = new_content or [_text_block("(empty)")]
|
||||
elif is_third_party or idx != last_assistant_idx:
|
||||
stripped = [b for b in m["content"] if _block_type(b) not in _THINKING_TYPES]
|
||||
m["content"] = stripped or [_text_block("(thinking elided)")]
|
||||
m["content"] = _strip_thinking(m["content"]) or [_text_block("(thinking elided)")]
|
||||
else:
|
||||
# Latest assistant on direct Anthropic: keep signed, demote unsigned to
|
||||
# text so the reasoning isn't lost. If orphan-stripping mutated THIS
|
||||
# turn every signature is dead (and a bare signed block with no
|
||||
# tool_use is also invalid), so demote ALL of them.
|
||||
signature_dead = bool(m.get("_thinking_signature_invalidated"))
|
||||
new_content = []
|
||||
for b in m["content"]:
|
||||
if _block_type(b) not in _THINKING_TYPES:
|
||||
new_content.append(b)
|
||||
continue
|
||||
is_redacted = b.get("type") == "redacted_thinking"
|
||||
signed = b.get("data") if is_redacted else b.get("signature") # redacted 'data' IS the signature
|
||||
if signed and not signature_dead:
|
||||
new_content.append(b)
|
||||
elif (signature_dead or not is_redacted) and b.get("thinking"):
|
||||
new_content.append(_text_block(b["thinking"])) # demote to plain text
|
||||
# else: redacted_thinking without data — unverifiable, dropped
|
||||
new_content = _keep_valid_latest_thinking(m["content"], bool(m.get("_thinking_signature_invalidated")))
|
||||
m["content"] = new_content or [_text_block("(empty)")]
|
||||
|
||||
# cache_control on thinking blocks interferes with signature validation.
|
||||
for b in m["content"]:
|
||||
if _block_type(b) in _THINKING_TYPES:
|
||||
@@ -696,76 +579,52 @@ def _manage_thinking_signatures(result: List[Dict[str, Any]], base_url: str | No
|
||||
|
||||
|
||||
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||||
"""Keep only the 3 most recent computer-use screenshots (~1,465 tokens each);
|
||||
older images become a placeholder text block. Mutates ``result`` in place."""
|
||||
"""Keep only the 3 most recent computer-use screenshots (~1,465 tokens each); older images
|
||||
become a placeholder text block. Mutates ``result`` in place."""
|
||||
image_count = 0
|
||||
for msg in reversed(result):
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if _block_type(block) != "tool_result":
|
||||
continue
|
||||
inner = block.get("content")
|
||||
for block in content if isinstance(content, list) else []:
|
||||
inner = block.get("content") if _block_type(block) == "tool_result" else None
|
||||
if not isinstance(inner, list) or not _has_block_type(inner, {"image"}):
|
||||
continue
|
||||
image_count += 1
|
||||
if image_count > 3:
|
||||
block["content"] = [
|
||||
b if b.get("type") != "image" else _text_block("[screenshot removed to save context]")
|
||||
for b in inner
|
||||
]
|
||||
placeholder = _text_block("[screenshot removed to save context]")
|
||||
block["content"] = [placeholder if b.get("type") == "image" else b for b in inner]
|
||||
|
||||
|
||||
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
|
||||
"""Anthropic requires messages[0].role == user; prepend a placeholder turn otherwise.
|
||||
|
||||
A second auto-compaction can leave a role=assistant summary first, which the
|
||||
API rejects (often masked as a misleading tool_use/tool_result 400). The filler
|
||||
must be non-whitespace text or it trades that 400 for the blank-block one.
|
||||
"""
|
||||
"""Anthropic requires messages[0].role == user; prepend a placeholder turn otherwise. A second
|
||||
auto-compaction can leave a role=assistant summary first, which the API rejects (often masked
|
||||
as a misleading tool_use/tool_result 400). The filler must be non-whitespace text or it trades
|
||||
that 400 for the blank-block one."""
|
||||
if result and result[0].get("role") != "user":
|
||||
result.insert(0, {"role": "user", "content": [_text_block(_EMPTY_TEXT_PLACEHOLDER)]})
|
||||
|
||||
|
||||
def _fix_blank_text_blocks_in_list(
|
||||
blocks: List[Any],
|
||||
*,
|
||||
placeholder_text: str,
|
||||
msg_index: int,
|
||||
role: Any,
|
||||
location: str,
|
||||
blocks: List[Any], *, placeholder_text: str, msg_index: int, role: Any, location: str
|
||||
) -> List[Any]:
|
||||
"""Drop blank text blocks; relocate any cache_control they carried onto the last
|
||||
surviving cacheable block; if nothing survives, substitute one placeholder block
|
||||
(carrying the relocated marker). Non-text blocks and order are untouched.
|
||||
Returns a new list; logs structure only (never text)."""
|
||||
kept: List[Any] = []
|
||||
relocated_cache_control = None
|
||||
for block_index, blk in enumerate(blocks):
|
||||
if _is_blank_text_block(blk):
|
||||
if _cache_control_of(blk) is not None:
|
||||
relocated_cache_control = blk["cache_control"]
|
||||
logger.warning(
|
||||
"Pre-call sanitizer: dropped blank text content block "
|
||||
"(message_index=%d role=%s location=%s block_index=%d block_type=text)",
|
||||
msg_index, role, location, block_index,
|
||||
)
|
||||
continue
|
||||
kept.append(blk)
|
||||
if not kept:
|
||||
kept.append(_text_block(placeholder_text))
|
||||
"""Drop blank text blocks; relocate any cache_control they carried onto the last surviving
|
||||
cacheable block; if nothing survives, substitute one placeholder block (carrying the relocated
|
||||
marker). Non-text blocks and order are untouched. Returns a new list; logs structure only."""
|
||||
kept, relocated_cache_control, dropped = _split_blank_text_blocks(blocks)
|
||||
for block_index in dropped:
|
||||
logger.warning(
|
||||
"Pre-call sanitizer: dropped blank text content block "
|
||||
"(message_index=%d role=%s location=%s block_index=%d block_type=text)",
|
||||
msg_index, role, location, block_index,
|
||||
)
|
||||
kept = kept or [_text_block(placeholder_text)]
|
||||
_apply_assistant_cache_control_to_last_cacheable_block(kept, relocated_cache_control)
|
||||
return kept
|
||||
|
||||
|
||||
def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
"""Final boundary guard against blank text blocks (HTTP 400 "text content blocks
|
||||
must contain non-whitespace text"), including inside tool_result content.
|
||||
|
||||
Runs LAST so a blank block from any current or future producer never reaches
|
||||
the wire. Diagnostics are structural only. Mutates ``result`` in place.
|
||||
"""
|
||||
"""Final boundary guard against blank text blocks (HTTP 400 "text content blocks must contain
|
||||
non-whitespace text"), including inside tool_result content. Runs LAST so a blank block from
|
||||
any current or future producer never reaches the wire. Mutates ``result`` in place."""
|
||||
for msg_index, msg in enumerate(result):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
@@ -773,77 +632,58 @@ def _scrub_blank_text_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list) or not content:
|
||||
continue
|
||||
new_content = _fix_blank_text_blocks_in_list(
|
||||
content,
|
||||
placeholder_text=_EMPTY_TEXT_PLACEHOLDER if role == "assistant" else "(empty message)",
|
||||
msg["content"] = _fix_blank_text_blocks_in_list(
|
||||
content, placeholder_text=_EMPTY_TEXT_PLACEHOLDER if role == "assistant" else "(empty message)",
|
||||
msg_index=msg_index, role=role, location="content",
|
||||
)
|
||||
for blk in new_content:
|
||||
if _block_type(blk) != "tool_result":
|
||||
continue
|
||||
inner = blk.get("content")
|
||||
for blk in msg["content"]:
|
||||
inner = blk.get("content") if _block_type(blk) == "tool_result" else None
|
||||
if isinstance(inner, list) and inner:
|
||||
blk["content"] = _fix_blank_text_blocks_in_list(
|
||||
inner, placeholder_text="(no output)", msg_index=msg_index, role=role, location="tool_result",
|
||||
)
|
||||
msg["content"] = new_content
|
||||
|
||||
|
||||
def _convert_system_content(content: Any) -> Any:
|
||||
"""System message content -> Anthropic ``system`` param (str, or block list when
|
||||
cache_control is present).
|
||||
|
||||
With cache markers the blocks are copied (never mutating the caller's dicts)
|
||||
and blank text is replaced by the placeholder: Anthropic rejects blank system
|
||||
blocks too, and a blank block carrying a breakpoint can't simply be dropped.
|
||||
"""
|
||||
"""System message content -> Anthropic ``system`` param (str, or block list when cache_control
|
||||
is present). With cache markers the blocks are copied (never mutating the caller's dicts) and
|
||||
blank text is replaced by the placeholder: Anthropic rejects blank system blocks too, and a
|
||||
blank block carrying a breakpoint can't simply be dropped."""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
if not any(p.get("cache_control") for p in content if isinstance(p, dict)):
|
||||
return "\n".join(p["text"] for p in content if p.get("type") == "text")
|
||||
system = []
|
||||
for p in content:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if p.get("type") == "text" and isinstance(p.get("text"), str) and not p["text"].strip():
|
||||
p = {**p, "text": _EMPTY_TEXT_PLACEHOLDER}
|
||||
system.append(p)
|
||||
return system
|
||||
return [
|
||||
{**p, "text": _EMPTY_TEXT_PLACEHOLDER} if _is_blank_text_block(p) and isinstance(p.get("text"), str) else p
|
||||
for p in content
|
||||
if isinstance(p, dict)
|
||||
]
|
||||
|
||||
|
||||
def convert_messages_to_anthropic(
|
||||
messages: List[Dict],
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
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, messages)``: system is extracted into its own param (a
|
||||
string, or a block list when cache_control is present). ``base_url``/``model``
|
||||
drive thinking-signature policy — third-party endpoints strip signatures
|
||||
"""Convert OpenAI-format messages to Anthropic format -> ``(system, messages)``. System is
|
||||
extracted into its own param (a string, or a block list when cache_control is present).
|
||||
``base_url``/``model`` drive thinking-signature policy — third-party endpoints strip signatures
|
||||
(proprietary, they 400 on them); Kimi-family endpoints/models keep unsigned
|
||||
reasoning_content-derived blocks, which Kimi requires even when empty.
|
||||
"""
|
||||
reasoning_content-derived blocks, which Kimi requires even when empty."""
|
||||
system = None
|
||||
result: List[Dict[str, Any]] = []
|
||||
|
||||
for m in messages:
|
||||
role = m.get("role", "user")
|
||||
content = m.get("content", "")
|
||||
if role == "system":
|
||||
system = _convert_system_content(content)
|
||||
system = _convert_system_content(m.get("content", ""))
|
||||
elif role == "assistant":
|
||||
result.append(_convert_assistant_message(m))
|
||||
elif role == "tool":
|
||||
_convert_tool_message_to_result(result, m)
|
||||
else:
|
||||
result.append(_convert_user_message(content))
|
||||
|
||||
result.append(_convert_user_message(m.get("content", "")))
|
||||
_strip_orphaned_tool_blocks(result)
|
||||
result = _merge_consecutive_roles(result)
|
||||
_ensure_leading_user_turn(result)
|
||||
_manage_thinking_signatures(result, base_url, model)
|
||||
_evict_old_screenshots(result)
|
||||
_scrub_blank_text_blocks(result)
|
||||
|
||||
return system, result
|
||||
|
||||
+577
-976
File diff suppressed because it is too large
Load Diff
@@ -160,7 +160,7 @@ def test_enabled_config_failure_logs_warning(caplog):
|
||||
"hermes_cli.config.load_config_readonly",
|
||||
side_effect=RuntimeError("boom"),
|
||||
), caplog.at_level(logging.WARNING, logger="agent.background_review"):
|
||||
assert background_review.is_background_review_enabled() is True
|
||||
assert background_review.load_background_review_settings()[0] is True
|
||||
assert any(
|
||||
"fail-open" in r.message.lower() or "leaving automatic" in r.message.lower()
|
||||
for r in caplog.records
|
||||
|
||||
@@ -166,10 +166,10 @@ def test_digest_records_tool_names_in_arc():
|
||||
|
||||
def test_enabled_defaults_true():
|
||||
with patch("hermes_cli.config.load_config_readonly", return_value={}):
|
||||
assert br.is_background_review_enabled() is True
|
||||
assert br.load_background_review_settings()[0] is True
|
||||
|
||||
|
||||
def test_enabled_false_disables_automatic_review():
|
||||
cfg = {"auxiliary": {"background_review": {"enabled": False}}}
|
||||
with patch("hermes_cli.config.load_config_readonly", return_value=cfg):
|
||||
assert br.is_background_review_enabled() is False
|
||||
assert br.load_background_review_settings()[0] is False
|
||||
|
||||
@@ -286,8 +286,7 @@ def test_e_call_does_not_unwind_module_callables():
|
||||
"summarize_background_review_actions returned partial results"
|
||||
in src
|
||||
), "expected partial-results guard message present"
|
||||
# And the prior-tonon-dict guard for the call_details lookup.
|
||||
assert "if not isinstance(detail, dict):" in src
|
||||
# And the non-dict guards on free-form tool payload fields.
|
||||
assert "if isinstance(ops_raw, list)" in src
|
||||
assert "if isinstance(change_raw, dict)" in src
|
||||
|
||||
|
||||
Reference in New Issue
Block a user