refactor(agent/chat_completion_helpers): split build_api_kwargs per api_mode; lift build_assistant_message phases
This commit is contained in:
+290
-357
@@ -1890,165 +1890,133 @@ def _alias_tool_search_bridge_for_xai(agent, transport, tools_for_api):
|
||||
return tools_for_api
|
||||
|
||||
|
||||
def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = None) -> dict:
|
||||
"""Build the keyword arguments dict for the active API mode."""
|
||||
# One-shot continuation override — consumed exactly once, on the FIRST
|
||||
# request this call builds (only one api_mode branch runs per invocation).
|
||||
_wire_reasoning_config = _reasoning_config_for_wire(agent)
|
||||
if tools_for_api is None:
|
||||
tools_for_api = agent.tools
|
||||
# The one place request_overrides are consumed: static /fast values are
|
||||
# already pinned in agent.request_overrides; auto/cold windows layer the
|
||||
# fast override here, per request, only while the window is open.
|
||||
_request_overrides = effective_request_overrides(agent)
|
||||
def _consume_ephemeral_max_output(agent):
|
||||
"""Pop the one-shot ephemeral output cap; whichever path builds the request consumes it."""
|
||||
ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None)
|
||||
if ephemeral_out is not None:
|
||||
agent._ephemeral_max_output_tokens = None
|
||||
return ephemeral_out
|
||||
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
_transport = agent._get_transport()
|
||||
anthropic_messages = agent._prepare_anthropic_messages_for_api(api_messages)
|
||||
ctx_len = getattr(agent, "context_compressor", None)
|
||||
ctx_len = ctx_len.context_length if ctx_len else None
|
||||
ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None)
|
||||
if ephemeral_out is not None:
|
||||
agent._ephemeral_max_output_tokens = None # consume immediately
|
||||
anthropic_kwargs = _transport.build_kwargs(
|
||||
model=agent.model,
|
||||
messages=anthropic_messages,
|
||||
tools=tools_for_api,
|
||||
max_tokens=ephemeral_out if ephemeral_out is not None else agent.max_tokens,
|
||||
reasoning_config=_wire_reasoning_config,
|
||||
is_oauth=agent._is_anthropic_oauth,
|
||||
preserve_dots=agent._anthropic_preserve_dots(),
|
||||
context_length=ctx_len,
|
||||
base_url=getattr(agent, "_anthropic_base_url", None),
|
||||
fast_mode=_request_overrides.get("speed") == "fast",
|
||||
drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)),
|
||||
)
|
||||
# Nous Portal reads ``tags`` and ``session_id`` as top-level body fields
|
||||
# on its Messages route the same way it does on /chat/completions, but
|
||||
# the profile hook that produces them is only consulted by the
|
||||
# OpenAI-wire transport. Merge them here so Messages traffic keeps
|
||||
# product attribution and sticky routing.
|
||||
return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs)
|
||||
|
||||
# AWS Bedrock native Converse API — bypasses the OpenAI client entirely.
|
||||
# The adapter handles message/tool conversion and boto3 calls directly.
|
||||
if agent.api_mode == "bedrock_converse":
|
||||
_bt = agent._get_transport()
|
||||
region = getattr(agent, "_bedrock_region", None) or "us-east-1"
|
||||
guardrail = getattr(agent, "_bedrock_guardrail_config", None)
|
||||
return _bt.build_kwargs(
|
||||
model=agent.model,
|
||||
messages=api_messages,
|
||||
tools=tools_for_api,
|
||||
max_tokens=agent.max_tokens or 4096,
|
||||
region=region,
|
||||
guardrail_config=guardrail,
|
||||
)
|
||||
def _build_anthropic_kwargs(agent, api_messages, tools_for_api, reasoning_config, request_overrides):
|
||||
ctx_len = getattr(agent, "context_compressor", None)
|
||||
ephemeral_out = _consume_ephemeral_max_output(agent)
|
||||
anthropic_kwargs = agent._get_transport().build_kwargs(
|
||||
model=agent.model,
|
||||
messages=agent._prepare_anthropic_messages_for_api(api_messages),
|
||||
tools=tools_for_api,
|
||||
max_tokens=ephemeral_out if ephemeral_out is not None else agent.max_tokens,
|
||||
reasoning_config=reasoning_config,
|
||||
is_oauth=agent._is_anthropic_oauth,
|
||||
preserve_dots=agent._anthropic_preserve_dots(),
|
||||
context_length=ctx_len.context_length if ctx_len else None,
|
||||
base_url=getattr(agent, "_anthropic_base_url", None),
|
||||
fast_mode=request_overrides.get("speed") == "fast",
|
||||
drop_context_1m_beta=bool(getattr(agent, "_oauth_1m_beta_disabled", False)),
|
||||
)
|
||||
# Nous Portal reads ``tags`` / ``session_id`` as top-level body fields on
|
||||
# its Messages route too, but the profile hook that produces them is only
|
||||
# consulted by the OpenAI-wire transport — merge here so Messages traffic
|
||||
# keeps product attribution and sticky routing.
|
||||
return _merge_nous_portal_messages_extra_body(agent, anthropic_kwargs)
|
||||
|
||||
# Rotation-stable logical cache scope, shared by every OpenAI-wire branch
|
||||
# below (codex + both chat_completions paths). Memoized on the agent —
|
||||
# cheap after the first call. Resolved after the anthropic/bedrock early
|
||||
# returns above, which don't use prompt_cache_key.
|
||||
_cache_scope_id = _prompt_cache_scope_for_agent(agent)
|
||||
|
||||
if agent.api_mode == "codex_responses":
|
||||
_ct = agent._get_transport()
|
||||
from agent.codex_responses_adapter import classify_responses_route
|
||||
def _build_bedrock_kwargs(agent, api_messages, tools_for_api):
|
||||
# AWS Bedrock native Converse API — the adapter handles message/tool
|
||||
# conversion and boto3 calls directly, bypassing the OpenAI client.
|
||||
return agent._get_transport().build_kwargs(
|
||||
model=agent.model,
|
||||
messages=api_messages,
|
||||
tools=tools_for_api,
|
||||
max_tokens=agent.max_tokens or 4096,
|
||||
region=getattr(agent, "_bedrock_region", None) or "us-east-1",
|
||||
guardrail_config=getattr(agent, "_bedrock_guardrail_config", None),
|
||||
)
|
||||
|
||||
is_codex_backend, is_xai_responses, is_github_responses = (
|
||||
classify_responses_route(agent)
|
||||
)
|
||||
_msgs_for_codex = agent._prepare_messages_for_non_vision_model(api_messages)
|
||||
|
||||
# Native server-side compaction (gpt-5.6 on direct OpenAI API /
|
||||
# ChatGPT Codex routes only) — None on every other route/model, in
|
||||
# which case the request is unchanged from pre-feature behavior.
|
||||
from agent.native_compaction import native_compaction_context_management
|
||||
_context_management = native_compaction_context_management(
|
||||
agent,
|
||||
is_codex_backend=is_codex_backend,
|
||||
is_xai_responses=is_xai_responses,
|
||||
is_github_responses=is_github_responses,
|
||||
)
|
||||
def _build_codex_kwargs(agent, api_messages, tools_for_api, reasoning_config, request_overrides, cache_scope_id):
|
||||
from agent.codex_responses_adapter import classify_responses_route
|
||||
from agent.native_compaction import native_compaction_context_management
|
||||
|
||||
# xAI's /responses endpoint rejects ``pattern`` and ``format`` keywords
|
||||
# in tool schemas (HTTP 400 "Invalid arguments passed to the model").
|
||||
# Most commonly hit when MCP-derived tools carry JSON Schema validation
|
||||
# keywords through. Strip them before building kwargs. See #27197.
|
||||
# It also rejects ``enum`` values containing ``/`` (HuggingFace IDs
|
||||
# like ``Qwen/Qwen3.5-0.8B`` shipped by MCP servers) — same 400 with
|
||||
# the same opaque message; strip those enums too.
|
||||
#
|
||||
# Deep-copy ``tools_for_api`` before sanitizing: the sanitizers
|
||||
# mutate in place (documented contract on ``strip_slash_enum`` /
|
||||
# ``strip_pattern_and_format``), and ``tools_for_api`` is a direct
|
||||
# reference to ``agent.tools``. Without the copy, the first xAI
|
||||
# request permanently strips constraints from the shared per-agent
|
||||
# tool registry — every subsequent non-xAI call from the same
|
||||
# agent (auxiliary task routed to Anthropic, OpenRouter fallback,
|
||||
# main-model swap) sees the already-stripped schema. See #27907.
|
||||
if is_xai_responses:
|
||||
try:
|
||||
import copy as _copy
|
||||
from tools.schema_sanitizer import (
|
||||
strip_pattern_and_format,
|
||||
strip_slash_enum,
|
||||
)
|
||||
tools_for_api = _copy.deepcopy(tools_for_api)
|
||||
tools_for_api, _ = strip_pattern_and_format(tools_for_api)
|
||||
tools_for_api, _ = strip_slash_enum(tools_for_api)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"%s⚠️ Failed to sanitize tool schemas for xAI: %s",
|
||||
getattr(agent, "log_prefix", ""), exc,
|
||||
)
|
||||
is_codex_backend, is_xai_responses, is_github_responses = classify_responses_route(agent)
|
||||
# Native server-side compaction (gpt-5.6 on direct OpenAI API / ChatGPT
|
||||
# Codex routes only) — None on every other route/model, leaving the
|
||||
# request unchanged from pre-feature behavior.
|
||||
context_management = native_compaction_context_management(
|
||||
agent,
|
||||
is_codex_backend=is_codex_backend,
|
||||
is_xai_responses=is_xai_responses,
|
||||
is_github_responses=is_github_responses,
|
||||
)
|
||||
# xAI's /responses endpoint 400s on ``pattern``/``format`` schema keywords
|
||||
# and on ``enum`` values containing ``/`` (HuggingFace IDs from MCP
|
||||
# servers) — strip them (#27197). Deep-copy first: the sanitizers mutate
|
||||
# in place and tools_for_api aliases agent.tools, so an in-place strip
|
||||
# would permanently degrade the shared registry for every later non-xAI
|
||||
# request (#27907).
|
||||
if is_xai_responses:
|
||||
try:
|
||||
import copy as _copy
|
||||
from tools.schema_sanitizer import strip_pattern_and_format, strip_slash_enum
|
||||
|
||||
return _ct.build_kwargs(
|
||||
model=agent.model,
|
||||
messages=_msgs_for_codex,
|
||||
tools=tools_for_api,
|
||||
reasoning_config=_wire_reasoning_config,
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
cache_scope_id=_cache_scope_id,
|
||||
base_url=agent.base_url,
|
||||
max_tokens=agent.max_tokens,
|
||||
timeout=agent._resolved_api_call_timeout(),
|
||||
request_overrides=_request_overrides,
|
||||
provider=getattr(agent, "provider", None),
|
||||
is_github_responses=is_github_responses,
|
||||
is_codex_backend=is_codex_backend,
|
||||
is_xai_responses=is_xai_responses,
|
||||
github_reasoning_extra=agent._github_models_reasoning_extra_body() if is_github_responses else None,
|
||||
replay_encrypted_reasoning=bool(
|
||||
getattr(agent, "_codex_reasoning_replay_enabled", True)
|
||||
),
|
||||
context_management=_context_management,
|
||||
)
|
||||
tools_for_api = _copy.deepcopy(tools_for_api)
|
||||
tools_for_api, _ = strip_pattern_and_format(tools_for_api)
|
||||
tools_for_api, _ = strip_slash_enum(tools_for_api)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"%s⚠️ Failed to sanitize tool schemas for xAI: %s",
|
||||
getattr(agent, "log_prefix", ""), exc,
|
||||
)
|
||||
return agent._get_transport().build_kwargs(
|
||||
model=agent.model,
|
||||
messages=agent._prepare_messages_for_non_vision_model(api_messages),
|
||||
tools=tools_for_api,
|
||||
reasoning_config=reasoning_config,
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
cache_scope_id=cache_scope_id,
|
||||
base_url=agent.base_url,
|
||||
max_tokens=agent.max_tokens,
|
||||
timeout=agent._resolved_api_call_timeout(),
|
||||
request_overrides=request_overrides,
|
||||
provider=getattr(agent, "provider", None),
|
||||
is_github_responses=is_github_responses,
|
||||
is_codex_backend=is_codex_backend,
|
||||
is_xai_responses=is_xai_responses,
|
||||
github_reasoning_extra=agent._github_models_reasoning_extra_body() if is_github_responses else None,
|
||||
replay_encrypted_reasoning=bool(getattr(agent, "_codex_reasoning_replay_enabled", True)),
|
||||
context_management=context_management,
|
||||
)
|
||||
|
||||
# ── chat_completions (default) ─────────────────────────────────────
|
||||
_ct = agent._get_transport()
|
||||
|
||||
tools_for_api = _alias_tool_search_bridge_for_xai(agent, _ct, tools_for_api)
|
||||
def _anthropic_max_output_for_model(agent):
|
||||
"""Anthropic-compatible max-output fallback (last resort — applied in
|
||||
build_kwargs after ephemeral/user/profile max_tokens, never overriding an
|
||||
explicit value). Model-gated, not URL-gated: any chat-completions proxy
|
||||
serving a Claude/MiniMax/Qwen3 model needs max_tokens because the Messages
|
||||
API treats it as mandatory and proxies that omit it default as low as 4096."""
|
||||
try:
|
||||
from agent.anthropic_adapter import _get_anthropic_max_output, _ANTHROPIC_OUTPUT_LIMITS
|
||||
|
||||
model_norm = (agent.model or "").lower().replace(".", "-")
|
||||
if any(key in model_norm for key in _ANTHROPIC_OUTPUT_LIMITS):
|
||||
return _get_anthropic_max_output(agent.model)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _build_chat_completions_kwargs(agent, api_messages, tools_for_api, reasoning_config, request_overrides, cache_scope_id):
|
||||
transport = agent._get_transport()
|
||||
tools_for_api = _alias_tool_search_bridge_for_xai(agent, transport, tools_for_api)
|
||||
|
||||
# Provider detection flags
|
||||
_is_qwen = agent._is_qwen_portal()
|
||||
_is_or = agent._is_openrouter_url()
|
||||
_is_gh = (
|
||||
base_url_host_matches(agent._base_url_lower, "models.github.ai")
|
||||
or base_url_host_matches(agent._base_url_lower, "githubcopilot.com")
|
||||
)
|
||||
_is_nous = base_url_host_matches(agent._base_url_lower, "nousresearch.com")
|
||||
_is_nvidia = base_url_host_matches(agent._base_url_lower, "integrate.api.nvidia.com")
|
||||
_is_kimi = (
|
||||
base_url_host_matches(agent.base_url, "api.kimi.com")
|
||||
or base_url_host_matches(agent.base_url, "moonshot.ai")
|
||||
or base_url_host_matches(agent.base_url, "moonshot.cn")
|
||||
)
|
||||
_is_tokenhub = base_url_host_matches(agent._base_url_lower, "tokenhub.tencentmaas.com")
|
||||
_host = agent._base_url_lower
|
||||
_is_gh = base_url_host_matches(_host, "models.github.ai") or base_url_host_matches(_host, "githubcopilot.com")
|
||||
_is_lmstudio = (agent.provider or "").strip().lower() == "lmstudio"
|
||||
|
||||
# Temperature: _fixed_temperature_for_model may return OMIT_TEMPERATURE
|
||||
# sentinel (temperature omitted entirely), a numeric override, or None.
|
||||
# _fixed_temperature_for_model may return the OMIT_TEMPERATURE sentinel
|
||||
# (temperature omitted entirely), a numeric override, or None.
|
||||
try:
|
||||
from agent.auxiliary_client import _fixed_temperature_for_model, OMIT_TEMPERATURE
|
||||
_ft = _fixed_temperature_for_model(agent.model, agent.base_url)
|
||||
@@ -2058,65 +2026,34 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
|
||||
_omit_temp = False
|
||||
_fixed_temp = None
|
||||
|
||||
# Provider preferences (aggregator profile decides whether to emit them).
|
||||
_prefs = _provider_preferences_for_agent(agent)
|
||||
|
||||
# Anthropic-compatible max-output fallback (last resort only — applied in
|
||||
# build_kwargs *after* ephemeral/user/profile max_tokens, never overriding
|
||||
# an explicit value). Model-gated, not URL-gated: any chat-completions
|
||||
# proxy serving a Claude/MiniMax/Qwen3 model needs max_tokens, because the
|
||||
# Anthropic Messages API treats it as mandatory and proxies that omit it
|
||||
# (AWS Bedrock, NVIDIA, LiteLLM, vLLM, corporate gateways) default as low
|
||||
# as 4096 output tokens — easily exhausted by thinking + large tool calls
|
||||
# like write_file/patch. OpenRouter/Nous were the only routes covered
|
||||
# before; gating on _ANTHROPIC_OUTPUT_LIMITS membership covers them all.
|
||||
_ant_max = None
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
_get_anthropic_max_output,
|
||||
_ANTHROPIC_OUTPUT_LIMITS,
|
||||
)
|
||||
_model_norm = (agent.model or "").lower().replace(".", "-")
|
||||
if any(key in _model_norm for key in _ANTHROPIC_OUTPUT_LIMITS):
|
||||
_ant_max = _get_anthropic_max_output(agent.model)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Qwen session metadata
|
||||
_qwen_meta = None
|
||||
if _is_qwen:
|
||||
_qwen_meta = {
|
||||
"sessionId": agent.session_id or "hermes",
|
||||
"promptId": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
# ── Provider profile path (registered providers) vs legacy flag path ──
|
||||
_ant_max = _anthropic_max_output_for_model(agent)
|
||||
_qwen_meta = (
|
||||
{"sessionId": agent.session_id or "hermes", "promptId": str(uuid.uuid4())}
|
||||
if _is_qwen else None
|
||||
)
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
_profile = get_provider_profile(agent.provider)
|
||||
except Exception:
|
||||
_profile = None
|
||||
|
||||
# One-shot ephemeral output cap is consumed by whichever path builds the request.
|
||||
_ephemeral_out = getattr(agent, "_ephemeral_max_output_tokens", None)
|
||||
if _ephemeral_out is not None:
|
||||
agent._ephemeral_max_output_tokens = None
|
||||
# Strip image parts for non-vision models (no-op when vision-capable) —
|
||||
# on BOTH paths; registered providers with profiles used to bypass it.
|
||||
_msgs_for_chat = agent._prepare_messages_for_non_vision_model(api_messages)
|
||||
_ephemeral_out = _consume_ephemeral_max_output(agent)
|
||||
# Strip image parts for non-vision models on BOTH paths (registered
|
||||
# providers with profiles used to bypass it).
|
||||
_common = dict(
|
||||
model=agent.model,
|
||||
messages=_msgs_for_chat,
|
||||
messages=agent._prepare_messages_for_non_vision_model(api_messages),
|
||||
tools=tools_for_api,
|
||||
base_url=agent.base_url,
|
||||
timeout=agent._resolved_api_call_timeout(),
|
||||
max_tokens=agent.max_tokens,
|
||||
ephemeral_max_output_tokens=_ephemeral_out,
|
||||
max_tokens_param_fn=agent._max_tokens_param,
|
||||
reasoning_config=_wire_reasoning_config,
|
||||
request_overrides=_request_overrides,
|
||||
reasoning_config=reasoning_config,
|
||||
request_overrides=request_overrides,
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
cache_scope_id=_cache_scope_id,
|
||||
cache_scope_id=cache_scope_id,
|
||||
ollama_num_ctx=agent._ollama_num_ctx,
|
||||
provider_preferences=_prefs or None,
|
||||
openrouter_min_coding_score=agent.openrouter_min_coding_score,
|
||||
@@ -2124,24 +2061,24 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
|
||||
supports_reasoning=agent._supports_reasoning_extra_body(),
|
||||
qwen_session_metadata=_qwen_meta,
|
||||
)
|
||||
|
||||
if _profile:
|
||||
# Profiles handle per-provider quirks via hooks fed the context above.
|
||||
return _ct.build_kwargs(provider_profile=_profile, **_common)
|
||||
return transport.build_kwargs(provider_profile=_profile, **_common)
|
||||
|
||||
# ── Legacy flag path ────────────────────────────────────────────
|
||||
# Reached only when get_provider_profile() returns None — i.e. a
|
||||
# completely unknown provider not in providers/ registry.
|
||||
return _ct.build_kwargs(
|
||||
# Legacy flag path: only for a provider absent from the providers/ registry.
|
||||
return transport.build_kwargs(
|
||||
**_common,
|
||||
model_lower=(agent.model or "").lower(),
|
||||
is_openrouter=_is_or,
|
||||
is_nous=_is_nous,
|
||||
is_nous=base_url_host_matches(_host, "nousresearch.com"),
|
||||
is_qwen_portal=_is_qwen,
|
||||
is_github_models=_is_gh,
|
||||
is_nvidia_nim=_is_nvidia,
|
||||
is_kimi=_is_kimi,
|
||||
is_tokenhub=_is_tokenhub,
|
||||
is_nvidia_nim=base_url_host_matches(_host, "integrate.api.nvidia.com"),
|
||||
is_kimi=any(
|
||||
base_url_host_matches(agent.base_url, h)
|
||||
for h in ("api.kimi.com", "moonshot.ai", "moonshot.cn")
|
||||
),
|
||||
is_tokenhub=base_url_host_matches(_host, "tokenhub.tencentmaas.com"),
|
||||
is_lmstudio=_is_lmstudio,
|
||||
is_custom_provider=agent.provider == "custom",
|
||||
qwen_prepare_fn=agent._qwen_prepare_chat_messages if _is_qwen else None,
|
||||
@@ -2154,74 +2091,142 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non
|
||||
)
|
||||
|
||||
|
||||
def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = None) -> dict:
|
||||
"""Build the keyword arguments dict for the active API mode."""
|
||||
# One-shot continuation override — consumed exactly once, on the FIRST
|
||||
# request this call builds (only one api_mode branch runs per invocation).
|
||||
reasoning_config = _reasoning_config_for_wire(agent)
|
||||
if tools_for_api is None:
|
||||
tools_for_api = agent.tools
|
||||
# The one place request_overrides are consumed: static /fast values are
|
||||
# already pinned in agent.request_overrides; auto/cold windows layer the
|
||||
# fast override here, per request, only while the window is open.
|
||||
request_overrides = effective_request_overrides(agent)
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
return _build_anthropic_kwargs(agent, api_messages, tools_for_api, reasoning_config, request_overrides)
|
||||
if agent.api_mode == "bedrock_converse":
|
||||
return _build_bedrock_kwargs(agent, api_messages, tools_for_api)
|
||||
# Rotation-stable logical cache scope shared by every OpenAI-wire branch
|
||||
# (memoized on the agent); anthropic/bedrock above don't use it.
|
||||
cache_scope_id = _prompt_cache_scope_for_agent(agent)
|
||||
builder = (
|
||||
_build_codex_kwargs if agent.api_mode == "codex_responses" else _build_chat_completions_kwargs
|
||||
)
|
||||
return builder(agent, api_messages, tools_for_api, reasoning_config, request_overrides, cache_scope_id)
|
||||
|
||||
|
||||
def _model_dump_safe(obj):
|
||||
"""``model_dump(warnings=False)`` (avoids pydantic serializer UserWarnings on
|
||||
generic-union SDK models), falling back for shims that reject the kwarg."""
|
||||
try:
|
||||
return obj.model_dump(warnings=False)
|
||||
except TypeError:
|
||||
return obj.model_dump()
|
||||
|
||||
|
||||
def _assistant_reasoning_text(agent, assistant_message) -> Optional[str]:
|
||||
"""Structured reasoning, else inline ``<think>`` blocks embedded in content."""
|
||||
reasoning_text = agent._extract_reasoning(assistant_message)
|
||||
if not reasoning_text:
|
||||
content = flatten_message_text(getattr(assistant_message, "content", None))
|
||||
think_blocks = re.findall(r'<think>(.*?)</think>', content, flags=re.DOTALL)
|
||||
if think_blocks:
|
||||
reasoning_text = "\n\n".join(b.strip() for b in think_blocks if b.strip()) or None
|
||||
if reasoning_text and agent.verbose_logging:
|
||||
logging.debug(f"Captured reasoning ({len(reasoning_text)} chars): {reasoning_text}")
|
||||
# When streaming is active the reasoning was already displayed during the
|
||||
# stream (structured deltas or <think> tag extraction); fire only for
|
||||
# non-streaming modes (gateway, batch, quiet). Anything not shown during
|
||||
# streaming is caught by the CLI post-response fallback.
|
||||
if (
|
||||
reasoning_text
|
||||
and agent.reasoning_callback
|
||||
and not agent.stream_delta_callback
|
||||
and not agent._stream_callback
|
||||
):
|
||||
try:
|
||||
agent.reasoning_callback(reasoning_text)
|
||||
except Exception:
|
||||
pass
|
||||
return _sanitize_surrogates(reasoning_text) if reasoning_text else reasoning_text
|
||||
|
||||
|
||||
def _assistant_content_for_storage(agent, assistant_message):
|
||||
# Sanitize surrogates (Kimi/GLM via Ollama return code points that crash
|
||||
# json.dumps on persist), strip inline <think> tags at the storage boundary
|
||||
# (they leaked to platforms, inflated context, polluted titles), then
|
||||
# redact credentials the model inlined in prose before the message enters
|
||||
# history / state.db / gateway delivery (no-op with HERMES_REDACT_SECRETS off).
|
||||
content = _sanitize_surrogates(flatten_message_text(getattr(assistant_message, "content", None)))
|
||||
if isinstance(content, str) and content:
|
||||
content = agent._strip_think_blocks(content).strip()
|
||||
if isinstance(content, str) and content:
|
||||
from agent.redact import redact_sensitive_text
|
||||
content = redact_sensitive_text(content)
|
||||
return content
|
||||
|
||||
|
||||
def _assistant_tool_call_dict(agent, tool_call, index: int) -> dict:
|
||||
raw_id = getattr(tool_call, "id", None)
|
||||
call_id = getattr(tool_call, "call_id", None)
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
call_id, _ = agent._split_responses_tool_id(raw_id)
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
if isinstance(raw_id, str) and raw_id.strip():
|
||||
call_id = raw_id.strip()
|
||||
else:
|
||||
_fn = getattr(tool_call, "function", None)
|
||||
call_id = agent._deterministic_call_id(
|
||||
getattr(_fn, "name", "") if _fn else "",
|
||||
getattr(_fn, "arguments", "{}") if _fn else "{}",
|
||||
index,
|
||||
)
|
||||
call_id = call_id.strip()
|
||||
|
||||
response_item_id = getattr(tool_call, "response_item_id", None)
|
||||
if not isinstance(response_item_id, str) or not response_item_id.strip():
|
||||
_, response_item_id = agent._split_responses_tool_id(raw_id)
|
||||
response_item_id = agent._derive_responses_function_call_id(
|
||||
call_id,
|
||||
response_item_id if isinstance(response_item_id, str) else None,
|
||||
)
|
||||
# Arguments are deliberately NOT redacted: this dict is replayed to the
|
||||
# model every turn, so a ``***`` mask would break every credential-
|
||||
# dependent command (#43083) while protecting nothing (tool OUTPUT leaks).
|
||||
tc_dict = {
|
||||
"id": call_id,
|
||||
"call_id": call_id,
|
||||
"response_item_id": response_item_id,
|
||||
"type": tool_call.type,
|
||||
"function": {
|
||||
"name": tool_call.function.name,
|
||||
"arguments": tool_call.function.arguments
|
||||
},
|
||||
}
|
||||
# Preserve extra_content (Gemini thought_signature) or Gemini 3 thinking
|
||||
# models 400 on the next request.
|
||||
extra = getattr(tool_call, "extra_content", None)
|
||||
if extra is not None:
|
||||
if hasattr(extra, "model_dump"):
|
||||
extra = _model_dump_safe(extra)
|
||||
tc_dict["extra_content"] = extra
|
||||
return tc_dict
|
||||
|
||||
|
||||
def build_assistant_message(agent, assistant_message, finish_reason: str) -> dict:
|
||||
"""Build a normalized assistant message dict from an API response message.
|
||||
|
||||
Handles reasoning extraction, reasoning_details, and optional tool_calls
|
||||
so both the tool-call path and the final-response path share one builder.
|
||||
Textless turns are NOT padded here: ``repair_empty_non_final_messages``
|
||||
(pre-send chokepoint) is the single owner — write-time padding broke codex
|
||||
commentary turns and cannot survive ``_rows_to_conversation``.
|
||||
"""
|
||||
assistant_tool_calls = getattr(assistant_message, "tool_calls", None)
|
||||
reasoning_text = agent._extract_reasoning(assistant_message)
|
||||
_from_structured = bool(reasoning_text)
|
||||
|
||||
# Fallback: extract inline <think> blocks from content when no structured
|
||||
# reasoning fields are present (some models/providers embed thinking
|
||||
# directly in the content rather than returning separate API fields).
|
||||
if not reasoning_text:
|
||||
content = flatten_message_text(getattr(assistant_message, "content", None))
|
||||
think_blocks = re.findall(r'<think>(.*?)</think>', content, flags=re.DOTALL)
|
||||
if think_blocks:
|
||||
combined = "\n\n".join(b.strip() for b in think_blocks if b.strip())
|
||||
reasoning_text = combined or None
|
||||
|
||||
if reasoning_text and agent.verbose_logging:
|
||||
logging.debug(f"Captured reasoning ({len(reasoning_text)} chars): {reasoning_text}")
|
||||
|
||||
if reasoning_text and agent.reasoning_callback:
|
||||
# Skip callback when streaming is active — reasoning was already
|
||||
# displayed during the stream via one of two paths:
|
||||
# (a) _fire_reasoning_delta (structured reasoning_content deltas)
|
||||
# (b) _stream_delta tag extraction (<think>/<REASONING_SCRATCHPAD>)
|
||||
# When streaming is NOT active, always fire so non-streaming modes
|
||||
# (gateway, batch, quiet) still get reasoning.
|
||||
# Any reasoning that wasn't shown during streaming is caught by the
|
||||
# CLI post-response display fallback (cli.py _reasoning_shown_this_turn).
|
||||
if not agent.stream_delta_callback and not agent._stream_callback:
|
||||
try:
|
||||
agent.reasoning_callback(reasoning_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sanitize surrogates from API response — some models (e.g. Kimi/GLM via Ollama)
|
||||
# can return invalid surrogate code points that crash json.dumps() on persist.
|
||||
_raw_content = flatten_message_text(getattr(assistant_message, "content", None))
|
||||
_san_content = _sanitize_surrogates(_raw_content)
|
||||
if reasoning_text:
|
||||
reasoning_text = _sanitize_surrogates(reasoning_text)
|
||||
|
||||
# Strip inline <think> tags at the storage boundary — reasoning is already
|
||||
# in ``reasoning_text``. Left in, they leaked to messaging platforms
|
||||
# (#8878, #9568), inflated context (#9306) and polluted session titles.
|
||||
if isinstance(_san_content, str) and _san_content:
|
||||
_san_content = agent._strip_think_blocks(_san_content).strip()
|
||||
|
||||
# Redact credentials the model inlined in prose BEFORE the message enters
|
||||
# history / state.db / gateway delivery. No-op when HERMES_REDACT_SECRETS
|
||||
# is off (#19798).
|
||||
if isinstance(_san_content, str) and _san_content:
|
||||
from agent.redact import redact_sensitive_text
|
||||
_san_content = redact_sensitive_text(_san_content)
|
||||
|
||||
# Textless turns are NOT padded here: ``repair_empty_non_final_messages``
|
||||
# (inside ``sanitize_api_messages``, the pre-send chokepoint) is the single
|
||||
# owner. Write-time padding was tried and rejected — it broke codex
|
||||
# commentary turns (content:'' is designed there) and cannot survive
|
||||
# ``_rows_to_conversation``'s whitespace strip.
|
||||
|
||||
reasoning_text = _assistant_reasoning_text(agent, assistant_message)
|
||||
msg = stamp_message_timestamp({
|
||||
"role": "assistant",
|
||||
"content": _san_content,
|
||||
"content": _assistant_content_for_storage(agent, assistant_message),
|
||||
"reasoning": reasoning_text,
|
||||
"finish_reason": finish_reason,
|
||||
})
|
||||
@@ -2234,127 +2239,55 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
||||
if raw_reasoning_content is not None:
|
||||
msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content)
|
||||
elif assistant_tool_calls and agent._needs_thinking_reasoning_pad():
|
||||
# DeepSeek v4 / Kimi thinking modes 400 on a replayed tool-call
|
||||
# message without reasoning_content. Pad with a single space (empty
|
||||
# string is rejected too) without fabricating reasoning.
|
||||
# Refs #15250, #17400, #17341.
|
||||
# DeepSeek v4 / Kimi thinking modes 400 on a replayed tool-call message
|
||||
# without reasoning_content; pad with a single space (empty string is
|
||||
# rejected too) without fabricating reasoning.
|
||||
msg["reasoning_content"] = reasoning_text or " "
|
||||
|
||||
# Streaming-only providers accumulate reasoning via delta chunks and never
|
||||
# set it on the message, so neither branch above fires; replaying through
|
||||
# a thinking model then 400s (#16844, #16884). Promote streamed reasoning
|
||||
# ONLY when nothing set the field and text was captured: SDK-exposed
|
||||
# reasoning_content and the tool-call pad still win, and reasoning-less
|
||||
# turns leave the field absent so the replay-time leak guard (#15748)
|
||||
# and promotion tiers still apply.
|
||||
if "reasoning_content" not in msg and reasoning_text:
|
||||
elif reasoning_text:
|
||||
# Streaming-only providers accumulate reasoning via deltas and never set
|
||||
# it on the message; replaying through a thinking model then 400s.
|
||||
# Promote ONLY when nothing set the field: SDK reasoning_content and the
|
||||
# tool-call pad win, and reasoning-less turns leave the field absent so
|
||||
# the replay-time leak guard and promotion tiers still apply.
|
||||
msg["reasoning_content"] = reasoning_text
|
||||
|
||||
if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details:
|
||||
if getattr(assistant_message, "reasoning_details", None):
|
||||
# Preserve reasoning_details exactly (opaque signature /
|
||||
# encrypted_content fields) for cross-turn reasoning continuity.
|
||||
raw_details = assistant_message.reasoning_details
|
||||
preserved = []
|
||||
for d in raw_details:
|
||||
for d in assistant_message.reasoning_details:
|
||||
if isinstance(d, dict):
|
||||
preserved.append(d)
|
||||
elif hasattr(d, "__dict__"):
|
||||
preserved.append(d.__dict__)
|
||||
elif hasattr(d, "model_dump"):
|
||||
try:
|
||||
# warnings=False: avoid pydantic serializer UserWarnings
|
||||
# on generic-union SDK models leaking to the terminal.
|
||||
preserved.append(d.model_dump(warnings=False))
|
||||
except TypeError:
|
||||
preserved.append(d.model_dump())
|
||||
preserved.append(_model_dump_safe(d))
|
||||
if preserved:
|
||||
msg["reasoning_details"] = preserved
|
||||
|
||||
# Anthropic interleaved thinking: reasoning_details + tool_calls lose the
|
||||
# cross-type order and reconstruction reorders signed blocks (HTTP 400
|
||||
# "thinking blocks ... cannot be modified"). Carry the verbatim ordered
|
||||
# block list so the adapter replays the message unchanged.
|
||||
ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
|
||||
if ordered_blocks:
|
||||
msg["anthropic_content_blocks"] = ordered_blocks
|
||||
|
||||
bedrock_blocks = getattr(assistant_message, "bedrock_content_blocks", None)
|
||||
if bedrock_blocks:
|
||||
msg["bedrock_content_blocks"] = bedrock_blocks
|
||||
|
||||
# Codex Responses API: preserve encrypted reasoning items for
|
||||
# multi-turn continuity. These get replayed as input on the next turn.
|
||||
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
|
||||
if codex_items:
|
||||
msg["codex_reasoning_items"] = codex_items
|
||||
|
||||
# Codex Responses API: preserve exact assistant message items (with
|
||||
# id/phase) so follow-up turns can replay structured items instead of
|
||||
# flattening to plain text. This is required for prefix cache hits.
|
||||
codex_message_items = getattr(assistant_message, "codex_message_items", None)
|
||||
if codex_message_items:
|
||||
msg["codex_message_items"] = codex_message_items
|
||||
# Provider-native carriers replayed verbatim on later turns:
|
||||
# anthropic_content_blocks keeps interleaved thinking + tool_use order
|
||||
# (reconstruction reorders signed blocks -> HTTP 400); codex_* items are
|
||||
# the encrypted reasoning / exact message items Responses prefix caching
|
||||
# needs.
|
||||
for attr in (
|
||||
"anthropic_content_blocks",
|
||||
"bedrock_content_blocks",
|
||||
"codex_reasoning_items",
|
||||
"codex_message_items",
|
||||
):
|
||||
value = getattr(assistant_message, attr, None)
|
||||
if value:
|
||||
msg[attr] = value
|
||||
|
||||
if assistant_tool_calls:
|
||||
tool_calls = []
|
||||
for tool_call in assistant_tool_calls:
|
||||
raw_id = getattr(tool_call, "id", None)
|
||||
call_id = getattr(tool_call, "call_id", None)
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
embedded_call_id, _ = agent._split_responses_tool_id(raw_id)
|
||||
call_id = embedded_call_id
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
if isinstance(raw_id, str) and raw_id.strip():
|
||||
call_id = raw_id.strip()
|
||||
else:
|
||||
_fn = getattr(tool_call, "function", None)
|
||||
_fn_name = getattr(_fn, "name", "") if _fn else ""
|
||||
_fn_args = getattr(_fn, "arguments", "{}") if _fn else "{}"
|
||||
call_id = agent._deterministic_call_id(_fn_name, _fn_args, len(tool_calls))
|
||||
call_id = call_id.strip()
|
||||
|
||||
response_item_id = getattr(tool_call, "response_item_id", None)
|
||||
if not isinstance(response_item_id, str) or not response_item_id.strip():
|
||||
_, embedded_response_item_id = agent._split_responses_tool_id(raw_id)
|
||||
response_item_id = embedded_response_item_id
|
||||
|
||||
response_item_id = agent._derive_responses_function_call_id(
|
||||
call_id,
|
||||
response_item_id if isinstance(response_item_id, str) else None,
|
||||
)
|
||||
|
||||
tc_dict = {
|
||||
"id": call_id,
|
||||
"call_id": call_id,
|
||||
"response_item_id": response_item_id,
|
||||
"type": tool_call.type,
|
||||
"function": {
|
||||
"name": tool_call.function.name,
|
||||
"arguments": tool_call.function.arguments
|
||||
},
|
||||
}
|
||||
# Tool-call arguments are deliberately NOT redacted: this dict is
|
||||
# replayed to the model every turn (and verbatim on resume), so a
|
||||
# `***` mask gets copied into the next call and breaks every
|
||||
# credential-dependent command (#43083). It also protected
|
||||
# nothing — the secret still leaks via tool OUTPUT.
|
||||
# Preserve extra_content (Gemini thought_signature) or Gemini 3
|
||||
# thinking models 400 on the next request.
|
||||
extra = getattr(tool_call, "extra_content", None)
|
||||
if extra is not None:
|
||||
if hasattr(extra, "model_dump"):
|
||||
try:
|
||||
extra = extra.model_dump(warnings=False)
|
||||
except TypeError:
|
||||
extra = extra.model_dump()
|
||||
tc_dict["extra_content"] = extra
|
||||
tool_calls.append(tc_dict)
|
||||
msg["tool_calls"] = tool_calls
|
||||
|
||||
msg["tool_calls"] = [
|
||||
_assistant_tool_call_dict(agent, tool_call, index)
|
||||
for index, tool_call in enumerate(assistant_tool_calls)
|
||||
]
|
||||
return msg
|
||||
|
||||
|
||||
|
||||
def rewrite_prompt_model_identity(agent, model: str, provider: str) -> None:
|
||||
"""Point the cached system prompt's ``Model:``/``Provider:`` lines at
|
||||
the active runtime after a provider switch.
|
||||
|
||||
Reference in New Issue
Block a user