diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 1fe5acc2b659..d625b75ba19c 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -260,11 +260,16 @@ def _resolve_runtime_from_pool_entry( if cfg_base_url: base_url = cfg_base_url configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider): - api_mode = configured_mode - elif provider in ("opencode-zen", "opencode-go"): + if provider in ("opencode-zen", "opencode-go"): + # Re-derive api_mode from the effective model rather than the + # persisted api_mode: the opencode providers serve both + # anthropic_messages and chat_completions models, so the previous + # session's mode must not leak across /model switches. + # Refs #16878. from hermes_cli.models import opencode_model_api_mode api_mode = opencode_model_api_mode(provider, effective_model) + elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider): + api_mode = configured_mode else: # Auto-detect Anthropic-compatible endpoints (/anthropic suffix, # Kimi /coding, api.openai.com → codex_responses, api.x.ai → @@ -1212,15 +1217,20 @@ def resolve_runtime_provider( configured_provider = str(model_cfg.get("provider") or "").strip().lower() # Only honor persisted api_mode when it belongs to the same provider family. configured_mode = _parse_api_mode(model_cfg.get("api_mode")) - if configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider): - api_mode = configured_mode - elif provider in ("opencode-zen", "opencode-go"): + if provider in ("opencode-zen", "opencode-go"): + # opencode-zen/go must always re-derive api_mode from the + # target model (not the stale persisted api_mode), because + # the same provider serves both anthropic_messages + # (e.g. minimax-m2.7) and chat_completions (e.g. + # deepseek-v4-flash) and switching models via /model would + # otherwise carry the previous mode forward, stripping /v1 + # from base_url for chat_completions models and 404'ing. + # Refs #16878. from hermes_cli.models import opencode_model_api_mode - # Prefer the target_model from the caller (explicit mid-session - # switch) over the stale model.default; see _resolve_runtime_from_pool_entry - # for the same rationale. _effective = target_model or model_cfg.get("default", "") api_mode = opencode_model_api_mode(provider, _effective) + elif configured_mode and _provider_supports_explicit_api_mode(provider, configured_provider): + api_mode = configured_mode else: # Auto-detect Anthropic-compatible endpoints by URL convention # (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic) diff --git a/run_agent.py b/run_agent.py index 3f2b7830824f..20153e62e47c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8089,16 +8089,23 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic "finish_reason": finish_reason, } - if hasattr(assistant_message, "reasoning_content"): - raw_reasoning_content = getattr(assistant_message, "reasoning_content", None) - if raw_reasoning_content is not None: - msg["reasoning_content"] = _sanitize_surrogates(raw_reasoning_content) - elif msg.get("tool_calls") and self._needs_deepseek_tool_reasoning(): - # DeepSeek thinking mode requires reasoning_content on every - # assistant tool-call message. Without it, replaying the - # persisted message causes HTTP 400. Include empty string - # as a defensive compatibility fallback (refs #15250). - msg["reasoning_content"] = "" + # Always persist `reasoning_content` on assistant turns. Without this + # the persisted history is silently incompatible with DeepSeek/Kimi + # thinking-mode replay (HTTP 400: "The reasoning_content in the + # thinking mode must be passed back to the API.") whenever the user + # later switches model. Prefer the SDK-supplied value (may carry + # structured data); otherwise fall back to the already-sanitized + # `reasoning_text` we accumulated from streaming deltas; finally + # default to "" so non-thinking providers ignore it harmlessly while + # thinking providers see a valid (empty) field. Refs #16844 (write + # side) / #15213, #15250, #15353, #15741, #15748 (read-side patches). + sdk_reasoning_content = getattr(assistant_message, "reasoning_content", None) + if sdk_reasoning_content is not None: + msg["reasoning_content"] = _sanitize_surrogates(sdk_reasoning_content) + elif reasoning_text: + msg["reasoning_content"] = reasoning_text + else: + msg["reasoning_content"] = "" if hasattr(assistant_message, 'reasoning_details') and assistant_message.reasoning_details: # Pass reasoning_details back unmodified so providers (OpenRouter,