diff --git a/nanobot-patches/README.md b/nanobot-patches/README.md new file mode 100644 index 0000000..8011771 --- /dev/null +++ b/nanobot-patches/README.md @@ -0,0 +1,128 @@ +# nanobot 图片识别修复补丁 + +> 适用对象:`nanobot`(uv 工具安装,位于 `~/.local/share/uv/tools/nanobot-ai/`) +> 修复日期:2026-08-21 +> 备份目录:本目录 `/home/ubuntu/nanocode/nanobot-patches/` + +--- + +## 一、问题现象 + +使用支持视觉的模型(如 `deepseek-v4-flash-vision-exp`)时,通过 `read_file` 读取图片后,模型**看不到图片**,只收到类似 `(Image file: xxx.png)` 的占位符文本,导致无法识别图片内容。 + +## 二、根因 + +`nanobot/providers/openai_compat_provider.py` 的 `_sanitize_messages()` 中有一行逻辑: + +```python +force_string_content = bool(self._spec and self._spec.name == "deepseek") +``` + +只要 provider 名字是 `deepseek`,它就把**所有**消息的 `content` 强制转为纯字符串。而 `content` 一旦是字符串,里面 `image_url` / `input_image` / `image` / `file` 等视觉块就会被剥离,图片信息丢失。 + +> 该逻辑本意是兼容 DeepSeek 纯文本模型的"只收字符串 content",但**一刀切**地也作用到了视觉模型上,导致视觉内容被丢弃。 + +## 三、修复方法 + +在 `openai_compat_provider.py` 中: + +1. 新增一个静态方法,判断 `content` 是否包含视觉块: + +```python +@staticmethod +def _content_has_vision_blocks(content: Any) -> bool: + """Return True when content is a block list containing image/file blocks. + + Used to keep vision content intact for multimodal models (e.g. + ``deepseek-v4-flash-vision-exp``) instead of coercing it to plain text. + """ + if not isinstance(content, list): + return False + for item in content: + mapping = OpenAICompatProvider._maybe_mapping(item) + if not mapping: + continue + if mapping.get("type") in ("image_url", "input_image", "image", "file"): + return True + return False +``` + +2. 在 `_sanitize_messages()` 的强制转字符串条件里,增加"含视觉块则跳过"的判断: + +```python +if ( + force_string_content + and not (clean.get("role") == "assistant" and clean.get("tool_calls")) + and not OpenAICompatProvider._content_has_vision_blocks(clean.get("content")) +): + clean["content"] = self._coerce_content_to_string(clean.get("content")) +``` + +> 关键点:`_content_has_vision_blocks()` 判断在 `_coerce_content_to_string()` **之前**生效,视觉块因此被保留。纯文本消息仍会走原字符串转换逻辑,不受影响。 + +## 四、涉及文件与路径 + +- 被修改文件: + `/home/ubuntu/.local/share/uv/tools/nanobot-ai/lib/python3.11/site-packages/nanobot/providers/openai_compat_provider.py` + +- 本目录备份: + - `openai_compat_provider.py.before_patch` —— 修复**前**(旧)文件 + - `openai_compat_provider.py.after_patch` —— 修复**后**(当前)文件 + +## 五、⚠️ 重要:uv 升级会覆盖补丁 + +`nanobot-ai` 是通过 `uv tool` 安装的。一旦执行升级(例如 `uv tool upgrade nanobot-ai` 或重新安装),`site-packages` 下的文件会被**覆盖**,补丁随之丢失。 + +**重新打补丁的方法**(升级后执行): + +```bash +# 1. 确认当前文件与 before_patch 是否一致(若一致说明补丁已丢失) +diff /home/ubuntu/nanocode/nanobot-patches/openai_compat_provider.py.before_patch \ + /home/ubuntu/.local/share/uv/tools/nanobot-ai/lib/python3.11/site-packages/nanobot/providers/openai_compat_provider.py + +# 2. 若补丁丢失,用 after_patch 覆盖回去 +cp /home/ubuntu/nanocode/nanobot-patches/openai_compat_provider.py.after_patch \ + /home/ubuntu/.local/share/uv/tools/nanobot-ai/lib/python3.11/site-packages/nanobot/providers/openai_compat_provider.py + +# 3. 重启 nanobot 服务使补丁生效 +sudo systemctl restart nanobot +``` + +## 六、验证方法 + +用 nanobot 自身的 venv Python 跑一次完整链路(构造图片块 → `_sanitize_messages` → `chat`),确认 `content` 仍为 `list` 且保留 `image_url` 块,并能返回图片描述: + +```bash +PY=/home/ubuntu/.local/share/uv/tools/nanobot-ai/bin/python +$PY -c " +import asyncio, base64 +from nanobot.providers.registry import PROVIDERS +from nanobot.providers.openai_compat_provider import OpenAICompatProvider + +spec = next(s for s in PROVIDERS if s.name=='deepseek') +key = 'sk-xxxx' # 替换为 DeepSeek API Key +model = 'deepseek-v4-flash-vision-exp' +b64 = base64.b64encode(open('/path/to/img.png','rb').read()).decode() +messages = [{'role':'user','content':[ + {'type':'image_url','image_url':{'url':'data:image/png;base64,'+b64}}, + {'type':'text','text':'请描述这张图片'} +]}] +prov = OpenAICompatProvider(api_key=key, spec=spec, default_model=model) +async def main(): + s = prov._sanitize_messages(messages) + print('blocks:', [b.get('type') for b in s[0]['content']]) # 期望 ['image_url','text'] + resp = await prov.chat(messages, model=model) + print(resp.text) +asyncio.run(main()) +" +``` + +## 七、关于"识别能力"的说明 + +- **图片识别能力本身**是模型自带的(如 `deepseek-v4-flash-vision-exp`、Kimi 等视觉模型都支持),**不需要**记忆到 nanobot 的长期记忆里。 +- **需要防丢失的**是"代码补丁"这一工程事实:它属于 `site-packages` 的源码改动,会随 `uv` 升级被覆盖。因此用**文档 + 前后备份**来保护,而不是当作模型能力去记忆。 + +## 八、关联历史 + +- 修改前备份文件:`/home/ubuntu/backup_openai_compat_provider.py.1787316174` +- 历史记忆(memory)中的补丁位置与"uv 升级会覆盖、需重新打"的记录与此文档一致。 diff --git a/nanobot-patches/openai_compat_provider.py.after_patch b/nanobot-patches/openai_compat_provider.py.after_patch new file mode 100644 index 0000000..82726ea --- /dev/null +++ b/nanobot-patches/openai_compat_provider.py.after_patch @@ -0,0 +1,1764 @@ +"""OpenAI-compatible provider for all non-Anthropic LLM APIs.""" + +from __future__ import annotations + +import asyncio +import hashlib +import importlib.util +import json +import os +import re +import secrets +import string +import time +import uuid +from collections import deque +from collections.abc import Awaitable, Callable +from ipaddress import ip_address +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from loguru import logger +from pydantic.alias_generators import to_snake + +from nanobot.providers.base import ( + LLMProvider, + LLMResponse, + ToolCallRequest, + parse_tool_arguments, + resolve_stream_idle_timeout_s, + tool_arguments_json_for_replay, +) +from nanobot.providers.openai_responses import ( + consume_sdk_stream, + convert_messages, + convert_tools, + parse_response_output, +) + +if TYPE_CHECKING: + from openai import AsyncOpenAI as AsyncOpenAIType + + from nanobot.providers.registry import ProviderSpec + +# Module-level placeholder — set lazily by _ensure_client on first real +# use, or replaced by tests via ``patch(...)``. Kept as a plain name so +# that ``unittest.mock.patch`` can find and replace it. +AsyncOpenAI: Any = None + +_ALLOWED_MSG_KEYS = frozenset({ + "role", "content", "tool_calls", "tool_call_id", "name", + "reasoning_content", "extra_content", +}) +_ALNUM = string.ascii_letters + string.digits + +_STANDARD_TC_KEYS = frozenset({"id", "type", "index", "function"}) +_STANDARD_FN_KEYS = frozenset({"name", "arguments"}) +_DEFAULT_OPENROUTER_HEADERS = { + "HTTP-Referer": "https://github.com/HKUDS/nanobot", + "X-OpenRouter-Title": "nanobot", + "X-OpenRouter-Categories": "cli-agent,personal-agent", +} +_KIMI_K3_MODEL = "kimi-k3" +_KIMI_THINKING_MODELS: frozenset[str] = frozenset({ + "kimi-k2.5", + "kimi-k2.6", + "kimi-k2.7", + "kimi-k2.7-code", + "kimi-k2.7-code-highspeed", + "k2.6-code-preview", +}) +_KIMI_ALWAYS_THINKING_MODELS: frozenset[str] = frozenset({ + "kimi-k2.7-code", + "kimi-k2.7-code-highspeed", +}) +_KIMI_SERVER_MANAGED_TEMPERATURE_MODELS: frozenset[str] = frozenset({ + "kimi-k2.5", + "kimi-k2.6", +}) +_TEXT_TOOL_CALL_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL) +# Thinking-capable MiMo models per Xiaomi docs (see +# tests/providers/test_xiaomi_mimo_thinking.py). mimo-v2-flash is omitted +# because it does not support thinking. +_MIMO_THINKING_MODELS: frozenset[str] = frozenset({ + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2-pro", + "mimo-v2-omni", +}) +_OPENAI_COMPAT_REQUEST_TIMEOUT_S = 120.0 + +# Maps ProviderSpec.thinking_style → extra_body builder. +# Each builder takes a bool (thinking_enabled) and returns the dict to +# merge into extra_body, keeping the style→wire-format mapping in one place. +_THINKING_STYLE_MAP: dict[str, Any] = { + "thinking_type": lambda on: {"thinking": {"type": "enabled" if on else "disabled"}}, + "enable_thinking": lambda on: {"enable_thinking": on}, + "reasoning_split": lambda on: {"reasoning_split": on}, +} +_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = { + "reasoning_effort": lambda effort: {"reasoning": {"effort": effort}}, +} +_QWEN_THINKING_MODELS: frozenset[str] = frozenset({ + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-max-preview", + "qwen3.6-plus", + "qwen3.6-flash", + "qwen3.5-plus", + "qwen3.5-flash", +}) + +_MODEL_THINKING_STYLES: dict[str, str] = { + **dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"), + **dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"), + **dict.fromkeys(_QWEN_THINKING_MODELS, "enable_thinking"), +} + + +def _model_slug(model_name: str) -> str: + return model_name.lower().rsplit("/", 1)[-1] + + +def _provider_prefix_key(name: str) -> str: + return to_snake(name.replace("-", "_")).lower() + + +def _requires_max_completion_tokens(model_name: str) -> bool: + """Return True for models that require ``max_completion_tokens``.""" + slug = _model_slug(model_name) + return slug == _KIMI_K3_MODEL or "gpt-5" in slug or any( + slug == p or slug.startswith((p + "-", p + ".")) for p in ("o1", "o3", "o4") + ) + + +def _model_thinking_style(model_name: str) -> str: + return _MODEL_THINKING_STYLES.get(_model_slug(model_name), "") + + +def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]: + styles: list[str] = [] + if spec and spec.thinking_style: + styles.append(spec.thinking_style) + model_style = _model_thinking_style(model_name) + if model_style and model_style not in styles: + styles.append(model_style) + return styles + + +def _thinking_extra_body(style: str, thinking_enabled: bool) -> dict[str, Any] | None: + builder = _THINKING_STYLE_MAP.get(style) + return builder(thinking_enabled) if builder else None + + +def _gateway_reasoning_extra_body(style: str, effort: str | None) -> dict[str, Any] | None: + if not effort: + return None + builder = _GATEWAY_REASONING_STYLE_MAP.get(style) + return builder(effort) if builder else None + + +def _openai_compat_timeout_s() -> float: + """Return the bounded request timeout used for OpenAI-compatible providers.""" + return _float_env("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", _OPENAI_COMPAT_REQUEST_TIMEOUT_S) + + +def _float_env(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return default + try: + value = float(raw) + except (TypeError, ValueError): + logger.warning("Ignoring invalid {}={!r}; using {}", name, raw, default) + return default + if value <= 0: + logger.warning("Ignoring non-positive {}={!r}; using {}", name, raw, default) + return default + return value + + +def _short_tool_id() -> str: + """9-char alphanumeric ID compatible with all providers (incl. Mistral).""" + return "".join(secrets.choice(_ALNUM) for _ in range(9)) + + +def _strip_json_fence(text: str) -> str: + stripped = text.strip() + if not stripped.startswith("```") or not stripped.endswith("```"): + return stripped + lines = stripped.splitlines() + if len(lines) < 2: + return stripped + return "\n".join(lines[1:-1]).strip() + + +def _extract_text_tool_calls(content: str | None) -> tuple[str | None, list[ToolCallRequest]]: + """Normalize common text-format tool call blocks into structured calls.""" + if not content or "" not in content: + return content, [] + + tool_calls: list[ToolCallRequest] = [] + spans: list[tuple[int, int]] = [] + for match in _TEXT_TOOL_CALL_RE.finditer(content): + try: + payload = json.loads(_strip_json_fence(match.group(1))) + except Exception: + continue + if not isinstance(payload, dict): + continue + + nested = payload.get("tool_call") + if isinstance(nested, dict): + payload = nested + function = payload.get("function") + if not isinstance(function, dict): + function = payload + name = function.get("name") + if not isinstance(name, str) or not name: + continue + + arguments = function.get("arguments", payload.get("arguments", {})) + tool_calls.append(ToolCallRequest( + id=str(payload.get("id") or _short_tool_id()), + name=name, + arguments=parse_tool_arguments(arguments), + )) + spans.append(match.span()) + + if not tool_calls: + return content, [] + + visible_parts: list[str] = [] + last = 0 + for start, end in spans: + visible_parts.append(content[last:start]) + last = end + visible_parts.append(content[last:]) + visible_content = "".join(visible_parts).strip() or None + return visible_content, tool_calls + + +def _get(obj: Any, key: str) -> Any: + """Get a value from dict or object attribute, returning None if absent.""" + if isinstance(obj, dict): + return obj.get(key) + return getattr(obj, key, None) + + +def _coerce_dict(value: Any) -> dict[str, Any] | None: + """Try to coerce *value* to a dict; return None if not possible or empty.""" + if value is None: + return None + if isinstance(value, dict): + return value if value else None + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict) and dumped: + return dumped + return None + + +def _extract_tc_extras(tc: Any) -> tuple[ + dict[str, Any] | None, + dict[str, Any] | None, + dict[str, Any] | None, +]: + """Extract (extra_content, provider_specific_fields, fn_provider_specific_fields). + + Works for both SDK objects and dicts. Captures Gemini ``extra_content`` + verbatim and any non-standard keys on the tool-call / function. + """ + extra_content = _coerce_dict(_get(tc, "extra_content")) + + tc_dict = _coerce_dict(tc) + prov = None + fn_prov = None + if tc_dict is not None: + leftover = {k: v for k, v in tc_dict.items() + if k not in _STANDARD_TC_KEYS and k != "extra_content" and v is not None} + if leftover: + prov = leftover + fn = _coerce_dict(tc_dict.get("function")) + if fn is not None: + fn_leftover = {k: v for k, v in fn.items() + if k not in _STANDARD_FN_KEYS and v is not None} + if fn_leftover: + fn_prov = fn_leftover + else: + prov = _coerce_dict(_get(tc, "provider_specific_fields")) + fn_obj = _get(tc, "function") + if fn_obj is not None: + fn_prov = _coerce_dict(_get(fn_obj, "provider_specific_fields")) + + return extra_content, prov, fn_prov + + +def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | None) -> bool: + """Apply Nanobot attribution headers to OpenRouter requests by default.""" + if spec and spec.name == "openrouter": + return True + return bool(api_base and "openrouter" in api_base.lower()) + + +_RESPONSES_FAILURE_THRESHOLD = 3 +_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes + + +def _is_local_endpoint( + spec: "ProviderSpec | None", + api_base: str | None, +) -> bool: + """Return True when the endpoint is a local or LAN model server. + + Matches either the provider spec's ``is_local`` flag or common private- + network patterns in the base URL (localhost, 127.x, 192.168.x, 10.x, + 172.16-31.x, Docker ``host.docker.internal``). + """ + if spec and spec.is_local: + return True + if not api_base: + return False + raw = api_base.strip().lower() + parsed = urlparse(raw if "://" in raw else f"//{raw}") + try: + host = parsed.hostname + except ValueError: + return False + if host in {"localhost", "host.docker.internal"}: + return True + if not host: + return False + try: + addr = ip_address(host) + except ValueError: + return False + return addr.is_loopback or addr.is_private + + +def _is_direct_openai_base(api_base: str | None) -> bool: + """Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways.""" + if not api_base: + return True + normalized = api_base.strip().lower().rstrip("/") + return "api.openai.com" in normalized and "openrouter" not in normalized + + +def _responses_circuit_key( + model: str | None, + default_model: str, + reasoning_effort: str | None, +) -> str: + model_name = (model or default_model).lower() + effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else "" + return f"{model_name}:{effort}" + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*, returning a new dict. + + Nested dicts are merged key-by-key; all other types in *override* + replace the corresponding key in *base*. + """ + merged = dict(base) + for key, value in override.items(): + if ( + key in merged + and isinstance(merged[key], dict) + and isinstance(value, dict) + ): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def _merge_unique_list(base: Any, override: Any) -> Any: + """Append list values while preserving order and removing duplicates.""" + if not isinstance(base, list) or not isinstance(override, list): + return override + result: list[Any] = [] + seen: set[str] = set() + for value in [*base, *override]: + try: + key = json.dumps(value, sort_keys=True, ensure_ascii=False) + except Exception: + key = repr(value) + if key in seen: + continue + seen.add(key) + result.append(value) + return result + + +def _merge_responses_extra_body( + body: dict[str, Any], + extra_body: dict[str, Any], +) -> dict[str, Any]: + """Merge configured Responses API body fields without clobbering tools.""" + reserved = {"include", "tools"} + regular_extra = {key: value for key, value in extra_body.items() if key not in reserved} + merged = _deep_merge(body, regular_extra) + + if "include" in extra_body: + merged["include"] = _merge_unique_list(body.get("include"), extra_body["include"]) + + if "tools" in extra_body: + current_tools = body.get("tools") + configured_tools = extra_body["tools"] + if isinstance(current_tools, list) and isinstance(configured_tools, list): + merged["tools"] = [*current_tools, *configured_tools] + else: + merged["tools"] = configured_tools + + return merged + + +class OpenAICompatProvider(LLMProvider): + """Unified provider for all OpenAI-compatible APIs. + + Receives a resolved ``ProviderSpec`` from the caller — no internal + registry lookups needed. + """ + + def __init__( + self, + api_key: str | None = None, + api_base: str | None = None, + default_model: str = "gpt-4o", + extra_headers: dict[str, str] | None = None, + spec: ProviderSpec | None = None, + extra_body: dict[str, Any] | None = None, + api_type: str = "auto", + extra_query: dict[str, str] | None = None, + proxy: str | None = None, + ): + super().__init__(api_key, api_base) + self.default_model = default_model + self.extra_headers = extra_headers or {} + self._spec = spec + self._extra_body = extra_body or {} + self._api_type = api_type if spec and spec.name == "openai" else "auto" + self._extra_query = extra_query or {} + self._proxy = proxy or None + + if api_key and spec and spec.env_key: + self._setup_env(api_key, api_base) + + effective_base = api_base or (spec.default_api_base if spec else None) or None + self._effective_base = effective_base + self._default_headers = {"x-session-affinity": uuid.uuid4().hex} + if _uses_openrouter_attribution(spec, effective_base): + self._default_headers.update(_DEFAULT_OPENROUTER_HEADERS) + if extra_headers: + self._default_headers.update(extra_headers) + self._api_key_for_client = api_key or "no-key" + self._is_local = _is_local_endpoint(spec, effective_base) + + # Lazy-init: the OpenAI client and its httpx transport are expensive + # to create (~700 ms on Windows). Defer until first use. + self._client: AsyncOpenAIType | None = None + self._client_lock = asyncio.Lock() + + # Responses API circuit breaker: skip after repeated failures, + # probe again after _RESPONSES_PROBE_INTERVAL_S seconds. + self._responses_failures: dict[str, int] = {} + self._responses_tripped_at: dict[str, float] = {} + + def _build_client(self) -> None: + """Create the OpenAI client using the current module-level AsyncOpenAI.""" + import httpx + + timeout_s = _openai_compat_timeout_s() + http_client: httpx.AsyncClient | None = None + if self._proxy: + http_client = httpx.AsyncClient( + timeout=timeout_s, + proxy=self._proxy, + trust_env=False, + follow_redirects=True, + ) + elif self._is_local: + # Local model servers (Ollama, llama.cpp, vLLM) often close idle + # HTTP connections before the client-side keepalive expires. When + # two LLM calls happen seconds apart (e.g. heartbeat _decide then + # process_direct), the second call may grab a now-dead pooled + # connection, causing a transient APIConnectionError on every first + # attempt. Disabling keepalive for local endpoints avoids this by + # opening a fresh connection for each request, which is cheap on a + # LAN. Cloud providers benefit from keepalive, so we leave the + # default pool settings for them. + # + # Also disable proxy for local endpoints: when the host has + # HTTP_PROXY / HTTPS_PROXY / ALL_PROXY set, httpx would try to + # route local traffic through the proxy, which typically cannot + # reach localhost or LAN addresses. + _local_limits = httpx.Limits(keepalive_expiry=0) + http_client = httpx.AsyncClient( + limits=_local_limits, + timeout=timeout_s, + transport=httpx.AsyncHTTPTransport(proxy=None, limits=_local_limits), + ) + # else: http_client stays None → SDK creates DefaultAsyncHttpxClient + # which already reads proxy env vars via trust_env=True, has proper + # connection limits, and follows redirects. + self._client = AsyncOpenAI( + api_key=self._api_key_for_client, + base_url=self._effective_base, + default_headers=self._default_headers, + default_query=self._extra_query or None, + max_retries=0, + timeout=timeout_s, + http_client=http_client, + ) + + async def _ensure_client(self): + """Return the shared OpenAI client, creating it on first call.""" + if self._client is not None: + return self._client + async with self._client_lock: + if self._client is not None: + return self._client + global AsyncOpenAI + if AsyncOpenAI is None: + if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"): + from langfuse.openai import AsyncOpenAI as _AsyncOpenAI + else: + if os.environ.get("LANGFUSE_SECRET_KEY"): + logger.warning( + "LANGFUSE_SECRET_KEY is set but langfuse is not installed; " + "install with `pip install langfuse` to enable tracing" + ) + from openai import AsyncOpenAI as _AsyncOpenAI + AsyncOpenAI = _AsyncOpenAI + + self._build_client() + return self._client + + def _setup_env(self, api_key: str, api_base: str | None) -> None: + """Set environment variables based on provider spec.""" + spec = self._spec + if not spec or not spec.env_key: + return + if spec.is_gateway: + os.environ[spec.env_key] = api_key + else: + os.environ.setdefault(spec.env_key, api_key) + effective_base = api_base or spec.default_api_base + for env_name, env_val in spec.env_extras: + resolved = env_val.replace("{api_key}", api_key).replace("{api_base}", effective_base) + os.environ.setdefault(env_name, resolved) + + @classmethod + def _apply_cache_control( + cls, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]: + """Inject cache_control markers for prompt caching.""" + cache_marker = {"type": "ephemeral"} + new_messages = list(messages) + + def _mark(msg: dict[str, Any]) -> dict[str, Any]: + content = msg.get("content") + if isinstance(content, str): + return {**msg, "content": [ + {"type": "text", "text": content, "cache_control": cache_marker}, + ]} + if isinstance(content, list) and content: + nc = list(content) + nc[-1] = {**nc[-1], "cache_control": cache_marker} + return {**msg, "content": nc} + return msg + + if new_messages and new_messages[0].get("role") == "system": + new_messages[0] = _mark(new_messages[0]) + if len(new_messages) >= 3: + new_messages[-2] = _mark(new_messages[-2]) + + new_tools = tools + if tools: + new_tools = list(tools) + for idx in cls._tool_cache_marker_indices(new_tools): + new_tools[idx] = {**new_tools[idx], "cache_control": cache_marker} + return new_messages, new_tools + + @staticmethod + def _normalize_tool_call_id(tool_call_id: Any) -> Any: + """Normalize to a provider-safe 9-char alphanumeric form.""" + if not isinstance(tool_call_id, str): + return tool_call_id + if len(tool_call_id) == 9 and tool_call_id.isalnum(): + return tool_call_id + return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9] + + def _should_normalize_tool_call_ids(self) -> bool: + """Return True for providers that reject normal OpenAI tool call IDs.""" + return bool(self._spec and self._spec.name == "mistral") + + @staticmethod + def _content_has_vision_blocks(content: Any) -> bool: + """Return True when content is a block list containing image/file blocks. + + Used to keep vision content intact for multimodal models (e.g. + ``deepseek-v4-flash-vision-exp``) instead of coercing it to plain text. + """ + if not isinstance(content, list): + return False + for item in content: + mapping = OpenAICompatProvider._maybe_mapping(item) + if not mapping: + continue + if mapping.get("type") in ("image_url", "input_image", "image", "file"): + return True + return False + + @staticmethod + def _coerce_content_to_string(content: Any) -> str | None: + """Coerce block/list content into plain text for strict string-only APIs.""" + if content is None or isinstance(content, str): + return content + text = OpenAICompatProvider._extract_text_content(content) + if isinstance(text, str) and text: + return text + try: + dumped = json.dumps(content, ensure_ascii=False) + except Exception: + dumped = str(content) + return dumped or "(empty)" + + def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Strip non-standard keys, normalize tool_call IDs.""" + sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS) + id_map: dict[str, str] = {} + pending_tool_ids: dict[str, deque[str]] = {} + force_string_content = bool(self._spec and self._spec.name == "deepseek") + normalize_tool_ids = self._should_normalize_tool_call_ids() + strip_reasoning = bool( + self._spec + and getattr(self._spec, "strip_history_reasoning_content", False) + ) + if strip_reasoning: + for msg in sanitized: + msg.pop("reasoning_content", None) + + def map_id(value: Any) -> Any: + if not isinstance(value, str): + return value + if not normalize_tool_ids: + return value + return id_map.setdefault(value, self._normalize_tool_call_id(value)) + + def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str: + if isinstance(value, str) and value: + base = map_id(value) + else: + base = _short_tool_id() + if not isinstance(base, str) or not base: + base = _short_tool_id() + if base not in used_ids: + return base + seed = value if isinstance(value, str) and value else base + salt = 1 + while True: + candidate = self._normalize_tool_call_id(f"{seed}:{idx}:{salt}") + if isinstance(candidate, str) and candidate not in used_ids: + return candidate + salt += 1 + + def map_tool_result_id(value: Any) -> Any: + if not isinstance(value, str): + return value + queue = pending_tool_ids.get(value) + if queue: + mapped = queue.popleft() + if not queue: + pending_tool_ids.pop(value, None) + return mapped + return map_id(value) + + for clean in sanitized: + if isinstance(clean.get("tool_calls"), list): + normalized = [] + used_ids: set[str] = set() + for idx, tc in enumerate(clean["tool_calls"]): + if not isinstance(tc, dict): + normalized.append(tc) + continue + tc_clean = dict(tc) + raw_id = tc_clean.get("id") + mapped_id = unique_tool_id(raw_id, used_ids, idx) + tc_clean["id"] = mapped_id + used_ids.add(mapped_id) + if isinstance(raw_id, str) and raw_id: + pending_tool_ids.setdefault(raw_id, deque()).append(mapped_id) + function = tc_clean.get("function") + if isinstance(function, dict): + function_clean = dict(function) + if "arguments" in function_clean: + function_clean["arguments"] = tool_arguments_json_for_replay( + function_clean.get("arguments") + ) + else: + function_clean["arguments"] = "{}" + tc_clean["function"] = function_clean + normalized.append(tc_clean) + clean["tool_calls"] = normalized + if clean.get("role") == "assistant": + # Some OpenAI-compatible gateways reject assistant messages + # that mix non-empty content with tool_calls. + clean["content"] = None + if "tool_call_id" in clean and clean["tool_call_id"]: + clean["tool_call_id"] = map_tool_result_id(clean["tool_call_id"]) + if ( + force_string_content + and not (clean.get("role") == "assistant" and clean.get("tool_calls")) + and not OpenAICompatProvider._content_has_vision_blocks(clean.get("content")) + ): + clean["content"] = self._coerce_content_to_string(clean.get("content")) + return self._enforce_role_alternation(sanitized) + + # ------------------------------------------------------------------ + # Build kwargs + # ------------------------------------------------------------------ + + def _request_model_name(self, model_name: str) -> str: + spec = self._spec + if not spec or "/" not in model_name: + return model_name + if spec.strip_model_prefix: + return model_name.split("/")[-1] + + route_prefixes = getattr(spec, "strip_model_prefixes", ()) + if not isinstance(route_prefixes, tuple) or not route_prefixes: + return model_name + model_prefix, routed_model = model_name.split("/", 1) + model_prefix_key = _provider_prefix_key(model_prefix) + if any(_provider_prefix_key(prefix) == model_prefix_key for prefix in route_prefixes): + return routed_model + return model_name + + @staticmethod + def _supports_temperature( + model_name: str, + reasoning_effort: str | None = None, + ) -> bool: + """Return True when the model accepts a temperature parameter. + + Kimi K3 uses a fixed temperature that should be omitted. GPT-5 family + and reasoning models (o1/o3/o4) reject temperature when + reasoning_effort is set to anything other than ``"none"``. + """ + if _model_slug(model_name) == _KIMI_K3_MODEL: + return False + if reasoning_effort and reasoning_effort.lower() != "none": + return False + name = model_name.lower() + return not any(token in name for token in ("gpt-5", "o1", "o3", "o4")) + + def _build_kwargs( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + model: str | None, + max_tokens: int, + temperature: float, + reasoning_effort: str | None, + tool_choice: str | dict[str, Any] | None, + ) -> dict[str, Any]: + model_name = model or self.default_model + spec = self._spec + + if spec and spec.supports_prompt_caching: + model_name = model or self.default_model + if any(model_name.lower().startswith(k) for k in ("anthropic/", "claude")): + messages, tools = self._apply_cache_control(messages, tools) + + model_name = self._request_model_name(model_name) + + kwargs: dict[str, Any] = { + "model": model_name, + "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), + } + + # GPT-5 and reasoning models (o1/o3/o4) reject temperature when + # reasoning_effort is active. Only include it when safe. + if self._supports_temperature(model_name, reasoning_effort): + kwargs["temperature"] = temperature + + if ( + spec and getattr(spec, "supports_max_completion_tokens", False) + ) or _requires_max_completion_tokens(model_name): + kwargs["max_completion_tokens"] = max(1, max_tokens) + else: + kwargs["max_tokens"] = max(1, max_tokens) + + if spec: + model_lower = model_name.lower() + for pattern, overrides in spec.model_overrides: + if pattern in model_lower: + kwargs.update(overrides) + break + + # Moonshot selects the only valid temperature from the K2.5/K2.6 thinking mode: + # 1.0 when enabled and 0.6 when disabled. Omitting the parameter lets the API + # apply the matching value for both its default and explicit thinking controls. + if ( + spec + and spec.name == "moonshot" + and _model_slug(model_name) in _KIMI_SERVER_MANAGED_TEMPERATURE_MODELS + ): + kwargs.pop("temperature", None) + + # Normalize reasoning_effort into a semantic form (OpenAI vocab) + # used for internal decisions, and a wire form actually sent out. + # "minimum" is accepted as a DashScope-native alias for "minimal". + semantic_effort: str | None = None + if isinstance(reasoning_effort, str): + semantic_effort = reasoning_effort.lower() + if semantic_effort == "minimum": + semantic_effort = "minimal" + + wire_effort = reasoning_effort + slug = _model_slug(model_name) + if slug == _KIMI_K3_MODEL and semantic_effort is not None: + # K3 always reasons and currently accepts only the top-level + # reasoning_effort="max". Preserve disabled/default semantics by + # omitting the field; normalize older enabled presets to "max" so + # switching from a K2.x model does not send an unsupported value. + if semantic_effort in ("none", "minimal"): + wire_effort = None + else: + semantic_effort = "max" + wire_effort = "max" + if spec and spec.name == "dashscope" and semantic_effort == "minimal": + # DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s. + wire_effort = "minimum" + + # Magistral and other providers where reasoning is implicit reject the + # reasoning_effort kwarg entirely. Strip it before the remap so we don't + # accidentally send "none"/"high" to a model that always reasons. + strip_effort = False + if spec and getattr(spec, "implicit_reasoning_models", ()): + model_lower = model_name.lower() + strip_effort = any( + pat in model_lower for pat in spec.implicit_reasoning_models + ) + + # Some providers accept a constrained reasoning_effort vocabulary + # (Mistral: only "high"/"none"). Remap from OpenAI vocab to the + # provider's accepted set; an empty mapped value means "omit". + if ( + not strip_effort + and spec + and getattr(spec, "reasoning_effort_remap", ()) + and isinstance(semantic_effort, str) + ): + remap = dict(spec.reasoning_effort_remap) + mapped = remap.get(semantic_effort) + if mapped is not None: + wire_effort = mapped or None + semantic_effort = mapped or "none" + + if strip_effort: + wire_effort = None + elif wire_effort and semantic_effort != "none": + kwargs["reasoning_effort"] = wire_effort + + # Only send thinking controls when reasoning_effort is explicit so + # omitting the config preserves each provider's default. + if reasoning_effort is not None: + thinking_enabled = semantic_effort not in ("none", "minimal") + for thinking_style in _thinking_styles_for(spec, model_name): + if not thinking_enabled and slug in _KIMI_ALWAYS_THINKING_MODELS: + continue + extra = _thinking_extra_body(thinking_style, thinking_enabled) + if extra: + kwargs.setdefault("extra_body", {}).update(extra) + gateway_style = getattr(spec, "gateway_reasoning_style", "") if spec else "" + if ( + gateway_style + and _model_thinking_style(model_name) + and (thinking_enabled or slug not in _KIMI_ALWAYS_THINKING_MODELS) + ): + extra = _gateway_reasoning_extra_body(gateway_style, semantic_effort) + if extra: + kwargs.setdefault("extra_body", {}).update(extra) + + # Moonshot rejects requests that carry both 'reasoning_effort' + # and the native 'thinking' param. We already expressed the + # user's intent via the provider-native shape, so drop the + # redundant wire-level kwarg. Only kimi models need this — + # Xiaomi's API accepts both params. + if slug in _KIMI_THINKING_MODELS: + kwargs.pop("reasoning_effort", None) + + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = tool_choice or "auto" + + # Backfill reasoning_content="" on assistants missing it: DeepSeek + # thinking mode rejects history otherwise (#3554, #3584); "" reads + # as "no thinking that turn". DeepSeek-V4/reasoner reason natively, + # so backfill even without explicit reasoning_effort. + explicit_thinking = ( + reasoning_effort is not None + and semantic_effort not in ("none", "minimal") + and ( + (spec and spec.thinking_style) + or _model_thinking_style(model_name) + ) + ) + implicit_deepseek_thinking = ( + spec is not None + and spec.name == "deepseek" + and semantic_effort not in ("none", "minimal", "minimum") + and any(t in model_name.lower() for t in ("deepseek-v4", "deepseek-reasoner")) + ) + if explicit_thinking or implicit_deepseek_thinking: + for msg in kwargs["messages"]: + if msg.get("role") == "assistant" and "reasoning_content" not in msg: + msg["reasoning_content"] = "" + + # Merge user-configured extra_body last so it can override or + # extend provider-specific defaults (e.g. chat_template_kwargs, + # guided_json, repetition_penalty). Uses recursive merge so + # nested dicts like {"chat_template_kwargs": {"enable_thinking": false}} + # do not clobber sibling keys already set by thinking-style logic. + if self._extra_body: + existing = kwargs.get("extra_body", {}) + kwargs["extra_body"] = _deep_merge(existing, self._extra_body) + + return kwargs + + def _should_use_responses_api( + self, + model: str | None, + reasoning_effort: str | None, + ) -> bool: + """Use Responses API only for direct OpenAI requests that benefit from it.""" + if self._api_type == "chat_completions": + return False + if self._spec and self._spec.name not in ("openai", "github_copilot"): + return False + if self._api_type == "responses": + # Explicit configuration means Responses is mandatory; do not + # consult the circuit breaker or fall back to Chat Completions. + return True + if self._spec is None or self._spec.name != "github_copilot": + if not _is_direct_openai_base(self._effective_base): + return False + + model_name = (model or self.default_model).lower() + wants = False + if reasoning_effort and reasoning_effort.lower() != "none": + wants = True + elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")): + wants = True + if not wants: + return False + + return self._responses_circuit_allows_probe(model, reasoning_effort) + + def _responses_circuit_allows_probe( + self, + model: str | None, + reasoning_effort: str | None, + ) -> bool: + """Return False when the Responses API circuit breaker is open.""" + key = _responses_circuit_key(model, self.default_model, reasoning_effort) + failures = self._responses_failures.get(key, 0) + if failures >= _RESPONSES_FAILURE_THRESHOLD: + tripped = self._responses_tripped_at.get(key, 0.0) + if (time.monotonic() - tripped) < _RESPONSES_PROBE_INTERVAL_S: + return False + # Half-open: allow one probe attempt + return True + + def _record_responses_failure(self, model: str | None, reasoning_effort: str | None) -> None: + key = _responses_circuit_key(model, self.default_model, reasoning_effort) + count = self._responses_failures.get(key, 0) + 1 + self._responses_failures[key] = count + if count >= _RESPONSES_FAILURE_THRESHOLD: + self._responses_tripped_at[key] = time.monotonic() + logger.warning( + "Responses API circuit open for {} — falling back to Chat Completions", + key, + ) + + def _record_responses_success(self, model: str | None, reasoning_effort: str | None) -> None: + key = _responses_circuit_key(model, self.default_model, reasoning_effort) + self._responses_failures.pop(key, None) + self._responses_tripped_at.pop(key, None) + + @staticmethod + def _should_fallback_from_responses_error(e: Exception) -> bool: + """Fallback only for likely Responses API compatibility errors.""" + response = getattr(e, "response", None) + status_code = getattr(e, "status_code", None) + if status_code is None and response is not None: + status_code = getattr(response, "status_code", None) + if status_code not in {400, 404, 422}: + return False + + body = ( + getattr(e, "body", None) + or getattr(e, "doc", None) + or getattr(response, "text", None) + ) + body_text = str(body).lower() if body is not None else "" + compatibility_markers = ( + "responses", + "response api", + "max_output_tokens", + "instructions", + "previous_response", + "unsupported", + "not supported", + "unknown parameter", + "unrecognized request argument", + ) + return any(marker in body_text for marker in compatibility_markers) + + def _build_responses_body( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + model: str | None, + max_tokens: int, + temperature: float, + reasoning_effort: str | None, + tool_choice: str | dict[str, Any] | None, + ) -> dict[str, Any]: + """Build a Responses API body for direct OpenAI requests.""" + model_name = model or self.default_model + model_name = self._request_model_name(model_name) + sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages)) + instructions, input_items = convert_messages(sanitized_messages) + + body: dict[str, Any] = { + "model": model_name, + "instructions": instructions or None, + "input": input_items, + "max_output_tokens": max(1, max_tokens), + "store": False, + "stream": False, + } + + if self._supports_temperature(model_name, reasoning_effort): + body["temperature"] = temperature + + if reasoning_effort and reasoning_effort.lower() != "none": + body["reasoning"] = {"effort": reasoning_effort} + body["include"] = ["reasoning.encrypted_content"] + + if tools: + body["tools"] = convert_tools(tools) + body["tool_choice"] = tool_choice or "auto" + + extra_body = getattr(self, "_extra_body", {}) + if extra_body: + body = _merge_responses_extra_body(body, extra_body) + + return body + + # ------------------------------------------------------------------ + # Response parsing + # ------------------------------------------------------------------ + + @staticmethod + def _maybe_mapping(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + return value + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict): + return dumped + return None + + @classmethod + def _extract_text_content(cls, value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, list): + parts: list[str] = [] + for item in value: + item_map = cls._maybe_mapping(item) + if item_map: + # Skip Mistral-style {"type":"thinking","thinking":[...]} + # blocks: their text belongs in reasoning_content. + if item_map.get("type") == "thinking": + continue + text = item_map.get("text") + if isinstance(text, str): + parts.append(text) + continue + text = getattr(item, "text", None) + if isinstance(text, str): + parts.append(text) + continue + if isinstance(item, str): + parts.append(item) + return "".join(parts) or None + return str(value) + + @classmethod + def _extract_thinking_content(cls, value: Any) -> str | None: + """Extract reasoning text from Mistral-style thinking blocks. + + Mistral returns content as a list mixing + ``{"type":"thinking","thinking":[{"type":"text","text":...}]}`` and + ``{"type":"text","text":...}``. The thinking text belongs in + ``reasoning_content`` so the agent can surface it as a reasoning + trace rather than as the assistant's reply. + """ + if not isinstance(value, list): + return None + parts: list[str] = [] + for item in value: + item_map = cls._maybe_mapping(item) + if not item_map: + continue + if item_map.get("type") != "thinking": + continue + inner = item_map.get("thinking") + text = cls._extract_text_content(inner) + if text: + parts.append(text) + return "".join(parts) or None + + @classmethod + def _extract_usage(cls, response: Any) -> dict[str, int]: + """Extract token usage from an OpenAI-compatible response. + + Handles both dict-based (raw JSON) and object-based (SDK Pydantic) + responses. Provider-specific ``cached_tokens`` fields are normalised + under a single key; see the priority chain inside for details. + """ + # --- resolve usage object --- + usage_obj = None + response_map = cls._maybe_mapping(response) + if response_map is not None: + usage_obj = response_map.get("usage") + elif hasattr(response, "usage") and response.usage: + usage_obj = response.usage + + usage_map = cls._maybe_mapping(usage_obj) + if usage_map is not None: + result = { + "prompt_tokens": int(usage_map.get("prompt_tokens") or 0), + "completion_tokens": int(usage_map.get("completion_tokens") or 0), + "total_tokens": int(usage_map.get("total_tokens") or 0), + } + elif usage_obj: + result = { + "prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0, + "completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0, + "total_tokens": getattr(usage_obj, "total_tokens", 0) or 0, + } + else: + return {} + + # --- cached_tokens (normalised across providers) --- + # Try nested paths first (dict), fall back to attribute (SDK object). + # Priority order ensures the most specific field wins. + for path in ( + ("prompt_tokens_details", "cached_tokens"), # OpenAI/Zhipu/MiniMax/Qwen/Mistral/xAI + ("cached_tokens",), # StepFun/Moonshot (top-level) + ("prompt_cache_hit_tokens",), # DeepSeek/SiliconFlow + ): + cached = cls._get_nested_int(usage_map, path) + if not cached and usage_obj: + cached = cls._get_nested_int(usage_obj, path) + if cached: + result["cached_tokens"] = cached + break + + return result + + @staticmethod + def _get_nested_int(obj: Any, path: tuple[str, ...]) -> int: + """Drill into *obj* by *path* segments and return an ``int`` value. + + Supports both dict-key access and attribute access so it works + uniformly with raw JSON dicts **and** SDK Pydantic models. + """ + current = obj + for segment in path: + if current is None: + return 0 + if isinstance(current, dict): + current = current.get(segment) + else: + current = getattr(current, segment, None) + return int(current or 0) if current is not None else 0 + + def _parse(self, response: Any) -> LLMResponse: + if isinstance(response, str): + return LLMResponse(content=response, finish_reason="stop") + + response_map = self._maybe_mapping(response) + if response_map is not None: + choices = response_map.get("choices") or [] + if not choices: + content = self._extract_text_content( + response_map.get("content") or response_map.get("output_text") + ) + reasoning_content = self._extract_text_content( + response_map.get("reasoning_content") + ) + if content is not None: + return LLMResponse( + content=content, + reasoning_content=reasoning_content, + finish_reason=str(response_map.get("finish_reason") or "stop"), + usage=self._extract_usage(response_map), + ) + return LLMResponse( + content="Error: API returned empty choices.", + finish_reason="error", + error_kind="empty", + ) + + choice0 = self._maybe_mapping(choices[0]) or {} + msg0 = self._maybe_mapping(choice0.get("message")) or {} + content = self._extract_text_content(msg0.get("content")) + finish_reason = str(choice0.get("finish_reason") or "stop") + + raw_tool_calls: list[Any] = [] + # StepFun: fallback to reasoning field when content is empty + if not content and msg0.get("reasoning") and self._spec and self._spec.reasoning_as_content: + content = self._extract_text_content(msg0.get("reasoning")) + reasoning_content = msg0.get("reasoning_content") + if reasoning_content is None and msg0.get("reasoning"): + reasoning_content = self._extract_text_content(msg0.get("reasoning")) + # Mistral reasoning models return thinking text inside the content + # array; lift it into reasoning_content so the runner records it + # under the reasoning trace. + spec = getattr(self, "_spec", None) + if reasoning_content is None and getattr(spec, "extract_thinking_blocks", False): + reasoning_content = self._extract_thinking_content(msg0.get("content")) + for ch in choices: + ch_map = self._maybe_mapping(ch) or {} + m = self._maybe_mapping(ch_map.get("message")) or {} + tool_calls = m.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + raw_tool_calls.extend(tool_calls) + if ch_map.get("finish_reason") in ("tool_calls", "stop"): + finish_reason = str(ch_map["finish_reason"]) + if not content: + content = self._extract_text_content(m.get("content")) + if reasoning_content is None: + reasoning_content = m.get("reasoning_content") + + # Deduplicate tool call IDs (same pattern as streaming path) + # Some providers reuse the same ID for parallel tool calls. + _seen_tc_ids: set[str] = set() + parsed_tool_calls = [] + for tc in raw_tool_calls: + tc_map = self._maybe_mapping(tc) or {} + fn = self._maybe_mapping(tc_map.get("function")) or {} + args = parse_tool_arguments(fn.get("arguments", {})) + ec, prov, fn_prov = _extract_tc_extras(tc) + raw_id = str(tc_map.get("id") or _short_tool_id()) + if not raw_id or raw_id in _seen_tc_ids: + raw_id = _short_tool_id() + _seen_tc_ids.add(raw_id) + parsed_tool_calls.append(ToolCallRequest( + id=raw_id, + name=str(fn.get("name") or ""), + arguments=args, + extra_content=ec, + provider_specific_fields=prov, + function_provider_specific_fields=fn_prov, + )) + if not parsed_tool_calls: + content, parsed_tool_calls = _extract_text_tool_calls(content) + + return LLMResponse( + content=content, + tool_calls=parsed_tool_calls, + finish_reason=finish_reason, + usage=self._extract_usage(response_map), + reasoning_content=reasoning_content if isinstance(reasoning_content, str) else None, + ) + + if not response.choices: + return LLMResponse( + content="Error: API returned empty choices.", + finish_reason="error", + error_kind="empty", + ) + + choice = response.choices[0] + msg = choice.message + content = msg.content + finish_reason = choice.finish_reason + + raw_tool_calls: list[Any] = [] + for ch in response.choices: + m = ch.message + if hasattr(m, "tool_calls") and m.tool_calls: + raw_tool_calls.extend(m.tool_calls) + if ch.finish_reason in ("tool_calls", "stop"): + finish_reason = ch.finish_reason + if not content and m.content: + content = m.content + if not content and getattr(m, "reasoning", None) and self._spec and self._spec.reasoning_as_content: + content = m.reasoning + + tool_calls = [] + for tc in raw_tool_calls: + args = parse_tool_arguments(tc.function.arguments) + ec, prov, fn_prov = _extract_tc_extras(tc) + tool_calls.append(ToolCallRequest( + id=str(getattr(tc, "id", None) or _short_tool_id()), + name=tc.function.name, + arguments=args, + extra_content=ec, + provider_specific_fields=prov, + function_provider_specific_fields=fn_prov, + )) + if not tool_calls: + content, tool_calls = _extract_text_tool_calls(content) + + reasoning_content = getattr(msg, "reasoning_content", None) + if reasoning_content is None and getattr(msg, "reasoning", None): + reasoning_content = msg.reasoning + + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason or "stop", + usage=self._extract_usage(response), + reasoning_content=reasoning_content, + ) + + @classmethod + def _parse_chunks(cls, chunks: list[Any]) -> LLMResponse: + content_parts: list[str] = [] + reasoning_parts: list[str] = [] + tc_bufs: dict[int, dict[str, Any]] = {} + finish_reason = "stop" + usage: dict[str, int] = {} + + def _accum_tc(tc: Any, idx_hint: int) -> None: + """Accumulate one streaming tool-call delta into *tc_bufs*.""" + tc_index: int = _get(tc, "index") if _get(tc, "index") is not None else idx_hint + buf = tc_bufs.setdefault(tc_index, { + "id": "", "name": "", "arguments": "", + "extra_content": None, "prov": None, "fn_prov": None, + }) + tc_id = _get(tc, "id") + if tc_id: + buf["id"] = str(tc_id) + fn = _get(tc, "function") + if fn is not None: + fn_name = _get(fn, "name") + if fn_name: + buf["name"] = str(fn_name) + fn_args = _get(fn, "arguments") + if fn_args: + buf["arguments"] += str(fn_args) + ec, prov, fn_prov = _extract_tc_extras(tc) + if ec: + buf["extra_content"] = ec + if prov: + buf["prov"] = prov + if fn_prov: + buf["fn_prov"] = fn_prov + + def _accum_legacy_function_call(function_call: Any) -> None: + """Accumulate legacy ``delta.function_call`` streaming chunks.""" + if not function_call: + return + buf = tc_bufs.setdefault(0, { + "id": "", "name": "", "arguments": "", + "extra_content": None, "prov": None, "fn_prov": None, + }) + fn_name = _get(function_call, "name") + if fn_name: + buf["name"] = str(fn_name) + fn_args = _get(function_call, "arguments") + if fn_args: + buf["arguments"] += str(fn_args) + + for chunk in chunks: + if isinstance(chunk, str): + content_parts.append(chunk) + continue + + chunk_map = cls._maybe_mapping(chunk) + if chunk_map is not None: + choices = chunk_map.get("choices") or [] + if not choices: + usage = cls._extract_usage(chunk_map) or usage + text = cls._extract_text_content( + chunk_map.get("content") or chunk_map.get("output_text") + ) + if text: + content_parts.append(text) + continue + choice = cls._maybe_mapping(choices[0]) or {} + if choice.get("finish_reason"): + finish_reason = str(choice["finish_reason"]) + delta = cls._maybe_mapping(choice.get("delta")) or {} + raw_delta_content = delta.get("content") + text = cls._extract_text_content(raw_delta_content) + if text: + content_parts.append(text) + text = cls._extract_text_content(delta.get("reasoning_content")) + if not text: + text = cls._extract_text_content(delta.get("reasoning")) + if not text: + # Mistral streams thinking inside the content array as + # {"type":"thinking", thinking:[{"type":"text", ...}]}. + text = cls._extract_thinking_content(raw_delta_content) + if text: + reasoning_parts.append(text) + for idx, tc in enumerate(delta.get("tool_calls") or []): + _accum_tc(tc, idx) + _accum_legacy_function_call(delta.get("function_call")) + usage = cls._extract_usage(chunk_map) or usage + continue + + if not chunk.choices: + usage = cls._extract_usage(chunk) or usage + continue + choice = chunk.choices[0] + if choice.finish_reason: + finish_reason = choice.finish_reason + delta = choice.delta + if delta and delta.content: + text = cls._extract_text_content(delta.content) + if text: + content_parts.append(text) + thinking_text = cls._extract_thinking_content(delta.content) + if thinking_text: + reasoning_parts.append(thinking_text) + if delta: + reasoning = getattr(delta, "reasoning_content", None) + if not reasoning: + reasoning = getattr(delta, "reasoning", None) + if reasoning: + text = cls._extract_text_content(reasoning) + if text: + reasoning_parts.append(text) + for tc in (getattr(delta, "tool_calls", None) or []) if delta else []: + _accum_tc(tc, getattr(tc, "index", 0)) + if delta: + _accum_legacy_function_call(getattr(delta, "function_call", None)) + + # Some providers (e.g. Zhipu/GLM) reuse the same tool_call id for + # parallel tool calls in streaming mode. Deduplicate before building + # the response so downstream tool messages don't collide. + _seen_tc_ids: set[str] = set() + for b in tc_bufs.values(): + if not b["id"] or b["id"] in _seen_tc_ids: + b["id"] = _short_tool_id() + _seen_tc_ids.add(b["id"]) + + content = "".join(content_parts) or None + tool_calls = [ + ToolCallRequest( + id=b["id"] or _short_tool_id(), + name=b["name"], + arguments=parse_tool_arguments(b["arguments"]), + extra_content=b.get("extra_content"), + provider_specific_fields=b.get("prov"), + function_provider_specific_fields=b.get("fn_prov"), + ) + for b in tc_bufs.values() + ] + if not tool_calls: + content, tool_calls = _extract_text_tool_calls(content) + + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason, + usage=usage, + reasoning_content="".join(reasoning_parts) or None, + ) + + @classmethod + def _extract_error_metadata(cls, e: Exception) -> dict[str, Any]: + response = getattr(e, "response", None) + headers = getattr(response, "headers", None) + payload = ( + getattr(e, "body", None) + or getattr(e, "doc", None) + or getattr(response, "text", None) + ) + if payload is None and response is not None: + response_json = getattr(response, "json", None) + if callable(response_json): + try: + payload = response_json() + except Exception: + payload = None + error_type, error_code = LLMProvider._extract_error_type_code(payload) + + status_code = getattr(e, "status_code", None) + if status_code is None and response is not None: + status_code = getattr(response, "status_code", None) + + should_retry: bool | None = None + if headers is not None: + raw = headers.get("x-should-retry") + if isinstance(raw, str): + lowered = raw.strip().lower() + if lowered == "true": + should_retry = True + elif lowered == "false": + should_retry = False + + error_kind: str | None = None + error_name = e.__class__.__name__.lower() + if "timeout" in error_name: + error_kind = "timeout" + elif "connection" in error_name: + error_kind = "connection" + + return { + "error_status_code": int(status_code) if status_code is not None else None, + "error_kind": error_kind, + "error_type": error_type, + "error_code": error_code, + "error_retry_after_s": cls._extract_retry_after_from_headers(headers), + "error_should_retry": should_retry, + } + + @staticmethod + def _handle_error( + e: Exception, + *, + spec: ProviderSpec | None = None, + api_base: str | None = None, + ) -> LLMResponse: + body = ( + getattr(e, "doc", None) + or getattr(e, "body", None) + or getattr(getattr(e, "response", None), "text", None) + ) + body_text = body if isinstance(body, str) else str(body) if body is not None else "" + msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}" + + text = f"{body_text} {e}".lower() + if spec and spec.is_local and ("502" in text or "connection" in text or "refused" in text): + msg += ( + "\nHint: this is a local model endpoint. Check that the local server is reachable at " + f"{api_base or spec.default_api_base}, and if you are using a proxy/tunnel, make sure it " + "can reach your local Ollama/vLLM service instead of routing localhost through the remote host." + ) + + response = getattr(e, "response", None) + retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None)) + if retry_after is None: + retry_after = LLMProvider._extract_retry_after(msg) + return LLMResponse( + content=msg, + finish_reason="error", + retry_after=retry_after, + **OpenAICompatProvider._extract_error_metadata(e), + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> LLMResponse: + await self._ensure_client() + try: + if self._should_use_responses_api(model, reasoning_effort): + try: + body = self._build_responses_body( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, + ) + result = parse_response_output(await self._client.responses.create(**body)) + self._record_responses_success(model, reasoning_effort) + return result + except Exception as responses_error: + if self._spec and self._spec.name == "github_copilot": + # Copilot gateway exposes GPT-5/o-series only via /responses; + # falling back to /chat/completions cannot succeed and would + # hide the real error. + raise + if self._api_type == "responses": + raise + if not self._should_fallback_from_responses_error(responses_error): + raise + self._record_responses_failure(model, reasoning_effort) + + kwargs = self._build_kwargs( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, + ) + return self._parse(await self._client.chat.completions.create(**kwargs)) + except Exception as e: + return self._handle_error(e, spec=self._spec, api_base=self.api_base) + + async def chat_stream( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, + on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, + ) -> LLMResponse: + await self._ensure_client() + idle_timeout_s = resolve_stream_idle_timeout_s() + try: + if self._should_use_responses_api(model, reasoning_effort): + try: + body = self._build_responses_body( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, + ) + body["stream"] = True + stream = await self._client.responses.create(**body) + + async def _timed_stream(): + stream_iter = stream.__aiter__() + while True: + try: + yield await asyncio.wait_for( + stream_iter.__anext__(), + timeout=idle_timeout_s, + ) + except StopAsyncIteration: + break + + ( + content, + tool_calls, + finish_reason, + usage, + reasoning_content, + ) = await consume_sdk_stream( + _timed_stream(), + on_content_delta, + on_tool_call_delta=on_tool_call_delta, + ) + self._record_responses_success(model, reasoning_effort) + return LLMResponse( + content=content or None, + tool_calls=tool_calls, + finish_reason=finish_reason, + usage=usage, + reasoning_content=reasoning_content, + ) + except Exception as responses_error: + if self._spec and self._spec.name == "github_copilot": + # Copilot gateway exposes GPT-5/o-series only via /responses; + # falling back to /chat/completions cannot succeed and would + # hide the real error. + raise + if self._api_type == "responses": + raise + if not self._should_fallback_from_responses_error(responses_error): + raise + self._record_responses_failure(model, reasoning_effort) + + kwargs = self._build_kwargs( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, + ) + if self._spec and self._spec.name == "zhipu" and tools and on_tool_call_delta: + # Z.AI/GLM keeps streaming tool-call arguments behind an + # explicit provider flag. Pass it through the OpenAI SDK's + # extra_body escape hatch so the usual delta.tool_calls path + # can surface live file-edit progress. + kwargs.setdefault("extra_body", {})["tool_stream"] = True + kwargs["stream"] = True + kwargs["stream_options"] = {"include_usage": True} + stream = await self._client.chat.completions.create(**kwargs) + chunks: list[Any] = [] + stream_iter = stream.__aiter__() + while True: + try: + chunk = await asyncio.wait_for( + stream_iter.__anext__(), + timeout=idle_timeout_s, + ) + except StopAsyncIteration: + break + chunks.append(chunk) + if chunk.choices: + delta_obj = chunk.choices[0].delta + raw_delta_content = getattr(delta_obj, "content", None) + if on_content_delta: + # Mistral streams content as a list of {"type":"thinking", + # ...} + {"type":"text",...} blocks. Extract just the + # text portion before invoking the callback so callers + # never see non-string content. + text = self._extract_text_content(raw_delta_content) + if text: + await on_content_delta(text) + if on_thinking_delta: + reasoning = getattr(delta_obj, "reasoning_content", None) or getattr( + delta_obj, "reasoning", None, + ) + r_text = self._extract_text_content(reasoning) + if not r_text: + # Mistral keeps the thinking trace inside the + # content array rather than a separate field. + r_text = self._extract_thinking_content(raw_delta_content) + if r_text: + await on_thinking_delta(r_text) + if on_tool_call_delta: + for idx, tool_delta in enumerate( + getattr(delta_obj, "tool_calls", None) or [] + ): + fn = _get(tool_delta, "function") + tool_index = _get(tool_delta, "index") + await on_tool_call_delta({ + "index": tool_index if tool_index is not None else idx, + "call_id": str(_get(tool_delta, "id") or ""), + "name": str(_get(fn, "name") or "") if fn is not None else "", + "arguments_delta": ( + str(_get(fn, "arguments") or "") if fn is not None else "" + ), + }) + function_call = getattr(delta_obj, "function_call", None) + if function_call: + await on_tool_call_delta({ + "index": 0, + "call_id": "", + "name": str(_get(function_call, "name") or ""), + "arguments_delta": str(_get(function_call, "arguments") or ""), + }) + return self._parse_chunks(chunks) + except asyncio.TimeoutError: + return LLMResponse( + content=( + f"Error calling LLM: stream stalled for more than " + f"{idle_timeout_s:g} seconds" + ), + finish_reason="error", + error_kind="timeout", + ) + except Exception as e: + return self._handle_error(e, spec=self._spec, api_base=self.api_base) + + def get_default_model(self) -> str: + return self.default_model diff --git a/nanobot-patches/openai_compat_provider.py.before_patch b/nanobot-patches/openai_compat_provider.py.before_patch new file mode 100644 index 0000000..c8bad3c --- /dev/null +++ b/nanobot-patches/openai_compat_provider.py.before_patch @@ -0,0 +1,1746 @@ +"""OpenAI-compatible provider for all non-Anthropic LLM APIs.""" + +from __future__ import annotations + +import asyncio +import hashlib +import importlib.util +import json +import os +import re +import secrets +import string +import time +import uuid +from collections import deque +from collections.abc import Awaitable, Callable +from ipaddress import ip_address +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from loguru import logger +from pydantic.alias_generators import to_snake + +from nanobot.providers.base import ( + LLMProvider, + LLMResponse, + ToolCallRequest, + parse_tool_arguments, + resolve_stream_idle_timeout_s, + tool_arguments_json_for_replay, +) +from nanobot.providers.openai_responses import ( + consume_sdk_stream, + convert_messages, + convert_tools, + parse_response_output, +) + +if TYPE_CHECKING: + from openai import AsyncOpenAI as AsyncOpenAIType + + from nanobot.providers.registry import ProviderSpec + +# Module-level placeholder — set lazily by _ensure_client on first real +# use, or replaced by tests via ``patch(...)``. Kept as a plain name so +# that ``unittest.mock.patch`` can find and replace it. +AsyncOpenAI: Any = None + +_ALLOWED_MSG_KEYS = frozenset({ + "role", "content", "tool_calls", "tool_call_id", "name", + "reasoning_content", "extra_content", +}) +_ALNUM = string.ascii_letters + string.digits + +_STANDARD_TC_KEYS = frozenset({"id", "type", "index", "function"}) +_STANDARD_FN_KEYS = frozenset({"name", "arguments"}) +_DEFAULT_OPENROUTER_HEADERS = { + "HTTP-Referer": "https://github.com/HKUDS/nanobot", + "X-OpenRouter-Title": "nanobot", + "X-OpenRouter-Categories": "cli-agent,personal-agent", +} +_KIMI_K3_MODEL = "kimi-k3" +_KIMI_THINKING_MODELS: frozenset[str] = frozenset({ + "kimi-k2.5", + "kimi-k2.6", + "kimi-k2.7", + "kimi-k2.7-code", + "kimi-k2.7-code-highspeed", + "k2.6-code-preview", +}) +_KIMI_ALWAYS_THINKING_MODELS: frozenset[str] = frozenset({ + "kimi-k2.7-code", + "kimi-k2.7-code-highspeed", +}) +_KIMI_SERVER_MANAGED_TEMPERATURE_MODELS: frozenset[str] = frozenset({ + "kimi-k2.5", + "kimi-k2.6", +}) +_TEXT_TOOL_CALL_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL) +# Thinking-capable MiMo models per Xiaomi docs (see +# tests/providers/test_xiaomi_mimo_thinking.py). mimo-v2-flash is omitted +# because it does not support thinking. +_MIMO_THINKING_MODELS: frozenset[str] = frozenset({ + "mimo-v2.5-pro", + "mimo-v2.5", + "mimo-v2-pro", + "mimo-v2-omni", +}) +_OPENAI_COMPAT_REQUEST_TIMEOUT_S = 120.0 + +# Maps ProviderSpec.thinking_style → extra_body builder. +# Each builder takes a bool (thinking_enabled) and returns the dict to +# merge into extra_body, keeping the style→wire-format mapping in one place. +_THINKING_STYLE_MAP: dict[str, Any] = { + "thinking_type": lambda on: {"thinking": {"type": "enabled" if on else "disabled"}}, + "enable_thinking": lambda on: {"enable_thinking": on}, + "reasoning_split": lambda on: {"reasoning_split": on}, +} +_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = { + "reasoning_effort": lambda effort: {"reasoning": {"effort": effort}}, +} +_QWEN_THINKING_MODELS: frozenset[str] = frozenset({ + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-max-preview", + "qwen3.6-plus", + "qwen3.6-flash", + "qwen3.5-plus", + "qwen3.5-flash", +}) + +_MODEL_THINKING_STYLES: dict[str, str] = { + **dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"), + **dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"), + **dict.fromkeys(_QWEN_THINKING_MODELS, "enable_thinking"), +} + + +def _model_slug(model_name: str) -> str: + return model_name.lower().rsplit("/", 1)[-1] + + +def _provider_prefix_key(name: str) -> str: + return to_snake(name.replace("-", "_")).lower() + + +def _requires_max_completion_tokens(model_name: str) -> bool: + """Return True for models that require ``max_completion_tokens``.""" + slug = _model_slug(model_name) + return slug == _KIMI_K3_MODEL or "gpt-5" in slug or any( + slug == p or slug.startswith((p + "-", p + ".")) for p in ("o1", "o3", "o4") + ) + + +def _model_thinking_style(model_name: str) -> str: + return _MODEL_THINKING_STYLES.get(_model_slug(model_name), "") + + +def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]: + styles: list[str] = [] + if spec and spec.thinking_style: + styles.append(spec.thinking_style) + model_style = _model_thinking_style(model_name) + if model_style and model_style not in styles: + styles.append(model_style) + return styles + + +def _thinking_extra_body(style: str, thinking_enabled: bool) -> dict[str, Any] | None: + builder = _THINKING_STYLE_MAP.get(style) + return builder(thinking_enabled) if builder else None + + +def _gateway_reasoning_extra_body(style: str, effort: str | None) -> dict[str, Any] | None: + if not effort: + return None + builder = _GATEWAY_REASONING_STYLE_MAP.get(style) + return builder(effort) if builder else None + + +def _openai_compat_timeout_s() -> float: + """Return the bounded request timeout used for OpenAI-compatible providers.""" + return _float_env("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", _OPENAI_COMPAT_REQUEST_TIMEOUT_S) + + +def _float_env(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return default + try: + value = float(raw) + except (TypeError, ValueError): + logger.warning("Ignoring invalid {}={!r}; using {}", name, raw, default) + return default + if value <= 0: + logger.warning("Ignoring non-positive {}={!r}; using {}", name, raw, default) + return default + return value + + +def _short_tool_id() -> str: + """9-char alphanumeric ID compatible with all providers (incl. Mistral).""" + return "".join(secrets.choice(_ALNUM) for _ in range(9)) + + +def _strip_json_fence(text: str) -> str: + stripped = text.strip() + if not stripped.startswith("```") or not stripped.endswith("```"): + return stripped + lines = stripped.splitlines() + if len(lines) < 2: + return stripped + return "\n".join(lines[1:-1]).strip() + + +def _extract_text_tool_calls(content: str | None) -> tuple[str | None, list[ToolCallRequest]]: + """Normalize common text-format tool call blocks into structured calls.""" + if not content or "" not in content: + return content, [] + + tool_calls: list[ToolCallRequest] = [] + spans: list[tuple[int, int]] = [] + for match in _TEXT_TOOL_CALL_RE.finditer(content): + try: + payload = json.loads(_strip_json_fence(match.group(1))) + except Exception: + continue + if not isinstance(payload, dict): + continue + + nested = payload.get("tool_call") + if isinstance(nested, dict): + payload = nested + function = payload.get("function") + if not isinstance(function, dict): + function = payload + name = function.get("name") + if not isinstance(name, str) or not name: + continue + + arguments = function.get("arguments", payload.get("arguments", {})) + tool_calls.append(ToolCallRequest( + id=str(payload.get("id") or _short_tool_id()), + name=name, + arguments=parse_tool_arguments(arguments), + )) + spans.append(match.span()) + + if not tool_calls: + return content, [] + + visible_parts: list[str] = [] + last = 0 + for start, end in spans: + visible_parts.append(content[last:start]) + last = end + visible_parts.append(content[last:]) + visible_content = "".join(visible_parts).strip() or None + return visible_content, tool_calls + + +def _get(obj: Any, key: str) -> Any: + """Get a value from dict or object attribute, returning None if absent.""" + if isinstance(obj, dict): + return obj.get(key) + return getattr(obj, key, None) + + +def _coerce_dict(value: Any) -> dict[str, Any] | None: + """Try to coerce *value* to a dict; return None if not possible or empty.""" + if value is None: + return None + if isinstance(value, dict): + return value if value else None + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict) and dumped: + return dumped + return None + + +def _extract_tc_extras(tc: Any) -> tuple[ + dict[str, Any] | None, + dict[str, Any] | None, + dict[str, Any] | None, +]: + """Extract (extra_content, provider_specific_fields, fn_provider_specific_fields). + + Works for both SDK objects and dicts. Captures Gemini ``extra_content`` + verbatim and any non-standard keys on the tool-call / function. + """ + extra_content = _coerce_dict(_get(tc, "extra_content")) + + tc_dict = _coerce_dict(tc) + prov = None + fn_prov = None + if tc_dict is not None: + leftover = {k: v for k, v in tc_dict.items() + if k not in _STANDARD_TC_KEYS and k != "extra_content" and v is not None} + if leftover: + prov = leftover + fn = _coerce_dict(tc_dict.get("function")) + if fn is not None: + fn_leftover = {k: v for k, v in fn.items() + if k not in _STANDARD_FN_KEYS and v is not None} + if fn_leftover: + fn_prov = fn_leftover + else: + prov = _coerce_dict(_get(tc, "provider_specific_fields")) + fn_obj = _get(tc, "function") + if fn_obj is not None: + fn_prov = _coerce_dict(_get(fn_obj, "provider_specific_fields")) + + return extra_content, prov, fn_prov + + +def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | None) -> bool: + """Apply Nanobot attribution headers to OpenRouter requests by default.""" + if spec and spec.name == "openrouter": + return True + return bool(api_base and "openrouter" in api_base.lower()) + + +_RESPONSES_FAILURE_THRESHOLD = 3 +_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes + + +def _is_local_endpoint( + spec: "ProviderSpec | None", + api_base: str | None, +) -> bool: + """Return True when the endpoint is a local or LAN model server. + + Matches either the provider spec's ``is_local`` flag or common private- + network patterns in the base URL (localhost, 127.x, 192.168.x, 10.x, + 172.16-31.x, Docker ``host.docker.internal``). + """ + if spec and spec.is_local: + return True + if not api_base: + return False + raw = api_base.strip().lower() + parsed = urlparse(raw if "://" in raw else f"//{raw}") + try: + host = parsed.hostname + except ValueError: + return False + if host in {"localhost", "host.docker.internal"}: + return True + if not host: + return False + try: + addr = ip_address(host) + except ValueError: + return False + return addr.is_loopback or addr.is_private + + +def _is_direct_openai_base(api_base: str | None) -> bool: + """Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways.""" + if not api_base: + return True + normalized = api_base.strip().lower().rstrip("/") + return "api.openai.com" in normalized and "openrouter" not in normalized + + +def _responses_circuit_key( + model: str | None, + default_model: str, + reasoning_effort: str | None, +) -> str: + model_name = (model or default_model).lower() + effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else "" + return f"{model_name}:{effort}" + + +def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*, returning a new dict. + + Nested dicts are merged key-by-key; all other types in *override* + replace the corresponding key in *base*. + """ + merged = dict(base) + for key, value in override.items(): + if ( + key in merged + and isinstance(merged[key], dict) + and isinstance(value, dict) + ): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def _merge_unique_list(base: Any, override: Any) -> Any: + """Append list values while preserving order and removing duplicates.""" + if not isinstance(base, list) or not isinstance(override, list): + return override + result: list[Any] = [] + seen: set[str] = set() + for value in [*base, *override]: + try: + key = json.dumps(value, sort_keys=True, ensure_ascii=False) + except Exception: + key = repr(value) + if key in seen: + continue + seen.add(key) + result.append(value) + return result + + +def _merge_responses_extra_body( + body: dict[str, Any], + extra_body: dict[str, Any], +) -> dict[str, Any]: + """Merge configured Responses API body fields without clobbering tools.""" + reserved = {"include", "tools"} + regular_extra = {key: value for key, value in extra_body.items() if key not in reserved} + merged = _deep_merge(body, regular_extra) + + if "include" in extra_body: + merged["include"] = _merge_unique_list(body.get("include"), extra_body["include"]) + + if "tools" in extra_body: + current_tools = body.get("tools") + configured_tools = extra_body["tools"] + if isinstance(current_tools, list) and isinstance(configured_tools, list): + merged["tools"] = [*current_tools, *configured_tools] + else: + merged["tools"] = configured_tools + + return merged + + +class OpenAICompatProvider(LLMProvider): + """Unified provider for all OpenAI-compatible APIs. + + Receives a resolved ``ProviderSpec`` from the caller — no internal + registry lookups needed. + """ + + def __init__( + self, + api_key: str | None = None, + api_base: str | None = None, + default_model: str = "gpt-4o", + extra_headers: dict[str, str] | None = None, + spec: ProviderSpec | None = None, + extra_body: dict[str, Any] | None = None, + api_type: str = "auto", + extra_query: dict[str, str] | None = None, + proxy: str | None = None, + ): + super().__init__(api_key, api_base) + self.default_model = default_model + self.extra_headers = extra_headers or {} + self._spec = spec + self._extra_body = extra_body or {} + self._api_type = api_type if spec and spec.name == "openai" else "auto" + self._extra_query = extra_query or {} + self._proxy = proxy or None + + if api_key and spec and spec.env_key: + self._setup_env(api_key, api_base) + + effective_base = api_base or (spec.default_api_base if spec else None) or None + self._effective_base = effective_base + self._default_headers = {"x-session-affinity": uuid.uuid4().hex} + if _uses_openrouter_attribution(spec, effective_base): + self._default_headers.update(_DEFAULT_OPENROUTER_HEADERS) + if extra_headers: + self._default_headers.update(extra_headers) + self._api_key_for_client = api_key or "no-key" + self._is_local = _is_local_endpoint(spec, effective_base) + + # Lazy-init: the OpenAI client and its httpx transport are expensive + # to create (~700 ms on Windows). Defer until first use. + self._client: AsyncOpenAIType | None = None + self._client_lock = asyncio.Lock() + + # Responses API circuit breaker: skip after repeated failures, + # probe again after _RESPONSES_PROBE_INTERVAL_S seconds. + self._responses_failures: dict[str, int] = {} + self._responses_tripped_at: dict[str, float] = {} + + def _build_client(self) -> None: + """Create the OpenAI client using the current module-level AsyncOpenAI.""" + import httpx + + timeout_s = _openai_compat_timeout_s() + http_client: httpx.AsyncClient | None = None + if self._proxy: + http_client = httpx.AsyncClient( + timeout=timeout_s, + proxy=self._proxy, + trust_env=False, + follow_redirects=True, + ) + elif self._is_local: + # Local model servers (Ollama, llama.cpp, vLLM) often close idle + # HTTP connections before the client-side keepalive expires. When + # two LLM calls happen seconds apart (e.g. heartbeat _decide then + # process_direct), the second call may grab a now-dead pooled + # connection, causing a transient APIConnectionError on every first + # attempt. Disabling keepalive for local endpoints avoids this by + # opening a fresh connection for each request, which is cheap on a + # LAN. Cloud providers benefit from keepalive, so we leave the + # default pool settings for them. + # + # Also disable proxy for local endpoints: when the host has + # HTTP_PROXY / HTTPS_PROXY / ALL_PROXY set, httpx would try to + # route local traffic through the proxy, which typically cannot + # reach localhost or LAN addresses. + _local_limits = httpx.Limits(keepalive_expiry=0) + http_client = httpx.AsyncClient( + limits=_local_limits, + timeout=timeout_s, + transport=httpx.AsyncHTTPTransport(proxy=None, limits=_local_limits), + ) + # else: http_client stays None → SDK creates DefaultAsyncHttpxClient + # which already reads proxy env vars via trust_env=True, has proper + # connection limits, and follows redirects. + self._client = AsyncOpenAI( + api_key=self._api_key_for_client, + base_url=self._effective_base, + default_headers=self._default_headers, + default_query=self._extra_query or None, + max_retries=0, + timeout=timeout_s, + http_client=http_client, + ) + + async def _ensure_client(self): + """Return the shared OpenAI client, creating it on first call.""" + if self._client is not None: + return self._client + async with self._client_lock: + if self._client is not None: + return self._client + global AsyncOpenAI + if AsyncOpenAI is None: + if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"): + from langfuse.openai import AsyncOpenAI as _AsyncOpenAI + else: + if os.environ.get("LANGFUSE_SECRET_KEY"): + logger.warning( + "LANGFUSE_SECRET_KEY is set but langfuse is not installed; " + "install with `pip install langfuse` to enable tracing" + ) + from openai import AsyncOpenAI as _AsyncOpenAI + AsyncOpenAI = _AsyncOpenAI + + self._build_client() + return self._client + + def _setup_env(self, api_key: str, api_base: str | None) -> None: + """Set environment variables based on provider spec.""" + spec = self._spec + if not spec or not spec.env_key: + return + if spec.is_gateway: + os.environ[spec.env_key] = api_key + else: + os.environ.setdefault(spec.env_key, api_key) + effective_base = api_base or spec.default_api_base + for env_name, env_val in spec.env_extras: + resolved = env_val.replace("{api_key}", api_key).replace("{api_base}", effective_base) + os.environ.setdefault(env_name, resolved) + + @classmethod + def _apply_cache_control( + cls, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]: + """Inject cache_control markers for prompt caching.""" + cache_marker = {"type": "ephemeral"} + new_messages = list(messages) + + def _mark(msg: dict[str, Any]) -> dict[str, Any]: + content = msg.get("content") + if isinstance(content, str): + return {**msg, "content": [ + {"type": "text", "text": content, "cache_control": cache_marker}, + ]} + if isinstance(content, list) and content: + nc = list(content) + nc[-1] = {**nc[-1], "cache_control": cache_marker} + return {**msg, "content": nc} + return msg + + if new_messages and new_messages[0].get("role") == "system": + new_messages[0] = _mark(new_messages[0]) + if len(new_messages) >= 3: + new_messages[-2] = _mark(new_messages[-2]) + + new_tools = tools + if tools: + new_tools = list(tools) + for idx in cls._tool_cache_marker_indices(new_tools): + new_tools[idx] = {**new_tools[idx], "cache_control": cache_marker} + return new_messages, new_tools + + @staticmethod + def _normalize_tool_call_id(tool_call_id: Any) -> Any: + """Normalize to a provider-safe 9-char alphanumeric form.""" + if not isinstance(tool_call_id, str): + return tool_call_id + if len(tool_call_id) == 9 and tool_call_id.isalnum(): + return tool_call_id + return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9] + + def _should_normalize_tool_call_ids(self) -> bool: + """Return True for providers that reject normal OpenAI tool call IDs.""" + return bool(self._spec and self._spec.name == "mistral") + + @staticmethod + def _coerce_content_to_string(content: Any) -> str | None: + """Coerce block/list content into plain text for strict string-only APIs.""" + if content is None or isinstance(content, str): + return content + text = OpenAICompatProvider._extract_text_content(content) + if isinstance(text, str) and text: + return text + try: + dumped = json.dumps(content, ensure_ascii=False) + except Exception: + dumped = str(content) + return dumped or "(empty)" + + def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Strip non-standard keys, normalize tool_call IDs.""" + sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS) + id_map: dict[str, str] = {} + pending_tool_ids: dict[str, deque[str]] = {} + force_string_content = bool(self._spec and self._spec.name == "deepseek") + normalize_tool_ids = self._should_normalize_tool_call_ids() + strip_reasoning = bool( + self._spec + and getattr(self._spec, "strip_history_reasoning_content", False) + ) + if strip_reasoning: + for msg in sanitized: + msg.pop("reasoning_content", None) + + def map_id(value: Any) -> Any: + if not isinstance(value, str): + return value + if not normalize_tool_ids: + return value + return id_map.setdefault(value, self._normalize_tool_call_id(value)) + + def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str: + if isinstance(value, str) and value: + base = map_id(value) + else: + base = _short_tool_id() + if not isinstance(base, str) or not base: + base = _short_tool_id() + if base not in used_ids: + return base + seed = value if isinstance(value, str) and value else base + salt = 1 + while True: + candidate = self._normalize_tool_call_id(f"{seed}:{idx}:{salt}") + if isinstance(candidate, str) and candidate not in used_ids: + return candidate + salt += 1 + + def map_tool_result_id(value: Any) -> Any: + if not isinstance(value, str): + return value + queue = pending_tool_ids.get(value) + if queue: + mapped = queue.popleft() + if not queue: + pending_tool_ids.pop(value, None) + return mapped + return map_id(value) + + for clean in sanitized: + if isinstance(clean.get("tool_calls"), list): + normalized = [] + used_ids: set[str] = set() + for idx, tc in enumerate(clean["tool_calls"]): + if not isinstance(tc, dict): + normalized.append(tc) + continue + tc_clean = dict(tc) + raw_id = tc_clean.get("id") + mapped_id = unique_tool_id(raw_id, used_ids, idx) + tc_clean["id"] = mapped_id + used_ids.add(mapped_id) + if isinstance(raw_id, str) and raw_id: + pending_tool_ids.setdefault(raw_id, deque()).append(mapped_id) + function = tc_clean.get("function") + if isinstance(function, dict): + function_clean = dict(function) + if "arguments" in function_clean: + function_clean["arguments"] = tool_arguments_json_for_replay( + function_clean.get("arguments") + ) + else: + function_clean["arguments"] = "{}" + tc_clean["function"] = function_clean + normalized.append(tc_clean) + clean["tool_calls"] = normalized + if clean.get("role") == "assistant": + # Some OpenAI-compatible gateways reject assistant messages + # that mix non-empty content with tool_calls. + clean["content"] = None + if "tool_call_id" in clean and clean["tool_call_id"]: + clean["tool_call_id"] = map_tool_result_id(clean["tool_call_id"]) + if ( + force_string_content + and not (clean.get("role") == "assistant" and clean.get("tool_calls")) + ): + clean["content"] = self._coerce_content_to_string(clean.get("content")) + return self._enforce_role_alternation(sanitized) + + # ------------------------------------------------------------------ + # Build kwargs + # ------------------------------------------------------------------ + + def _request_model_name(self, model_name: str) -> str: + spec = self._spec + if not spec or "/" not in model_name: + return model_name + if spec.strip_model_prefix: + return model_name.split("/")[-1] + + route_prefixes = getattr(spec, "strip_model_prefixes", ()) + if not isinstance(route_prefixes, tuple) or not route_prefixes: + return model_name + model_prefix, routed_model = model_name.split("/", 1) + model_prefix_key = _provider_prefix_key(model_prefix) + if any(_provider_prefix_key(prefix) == model_prefix_key for prefix in route_prefixes): + return routed_model + return model_name + + @staticmethod + def _supports_temperature( + model_name: str, + reasoning_effort: str | None = None, + ) -> bool: + """Return True when the model accepts a temperature parameter. + + Kimi K3 uses a fixed temperature that should be omitted. GPT-5 family + and reasoning models (o1/o3/o4) reject temperature when + reasoning_effort is set to anything other than ``"none"``. + """ + if _model_slug(model_name) == _KIMI_K3_MODEL: + return False + if reasoning_effort and reasoning_effort.lower() != "none": + return False + name = model_name.lower() + return not any(token in name for token in ("gpt-5", "o1", "o3", "o4")) + + def _build_kwargs( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + model: str | None, + max_tokens: int, + temperature: float, + reasoning_effort: str | None, + tool_choice: str | dict[str, Any] | None, + ) -> dict[str, Any]: + model_name = model or self.default_model + spec = self._spec + + if spec and spec.supports_prompt_caching: + model_name = model or self.default_model + if any(model_name.lower().startswith(k) for k in ("anthropic/", "claude")): + messages, tools = self._apply_cache_control(messages, tools) + + model_name = self._request_model_name(model_name) + + kwargs: dict[str, Any] = { + "model": model_name, + "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), + } + + # GPT-5 and reasoning models (o1/o3/o4) reject temperature when + # reasoning_effort is active. Only include it when safe. + if self._supports_temperature(model_name, reasoning_effort): + kwargs["temperature"] = temperature + + if ( + spec and getattr(spec, "supports_max_completion_tokens", False) + ) or _requires_max_completion_tokens(model_name): + kwargs["max_completion_tokens"] = max(1, max_tokens) + else: + kwargs["max_tokens"] = max(1, max_tokens) + + if spec: + model_lower = model_name.lower() + for pattern, overrides in spec.model_overrides: + if pattern in model_lower: + kwargs.update(overrides) + break + + # Moonshot selects the only valid temperature from the K2.5/K2.6 thinking mode: + # 1.0 when enabled and 0.6 when disabled. Omitting the parameter lets the API + # apply the matching value for both its default and explicit thinking controls. + if ( + spec + and spec.name == "moonshot" + and _model_slug(model_name) in _KIMI_SERVER_MANAGED_TEMPERATURE_MODELS + ): + kwargs.pop("temperature", None) + + # Normalize reasoning_effort into a semantic form (OpenAI vocab) + # used for internal decisions, and a wire form actually sent out. + # "minimum" is accepted as a DashScope-native alias for "minimal". + semantic_effort: str | None = None + if isinstance(reasoning_effort, str): + semantic_effort = reasoning_effort.lower() + if semantic_effort == "minimum": + semantic_effort = "minimal" + + wire_effort = reasoning_effort + slug = _model_slug(model_name) + if slug == _KIMI_K3_MODEL and semantic_effort is not None: + # K3 always reasons and currently accepts only the top-level + # reasoning_effort="max". Preserve disabled/default semantics by + # omitting the field; normalize older enabled presets to "max" so + # switching from a K2.x model does not send an unsupported value. + if semantic_effort in ("none", "minimal"): + wire_effort = None + else: + semantic_effort = "max" + wire_effort = "max" + if spec and spec.name == "dashscope" and semantic_effort == "minimal": + # DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s. + wire_effort = "minimum" + + # Magistral and other providers where reasoning is implicit reject the + # reasoning_effort kwarg entirely. Strip it before the remap so we don't + # accidentally send "none"/"high" to a model that always reasons. + strip_effort = False + if spec and getattr(spec, "implicit_reasoning_models", ()): + model_lower = model_name.lower() + strip_effort = any( + pat in model_lower for pat in spec.implicit_reasoning_models + ) + + # Some providers accept a constrained reasoning_effort vocabulary + # (Mistral: only "high"/"none"). Remap from OpenAI vocab to the + # provider's accepted set; an empty mapped value means "omit". + if ( + not strip_effort + and spec + and getattr(spec, "reasoning_effort_remap", ()) + and isinstance(semantic_effort, str) + ): + remap = dict(spec.reasoning_effort_remap) + mapped = remap.get(semantic_effort) + if mapped is not None: + wire_effort = mapped or None + semantic_effort = mapped or "none" + + if strip_effort: + wire_effort = None + elif wire_effort and semantic_effort != "none": + kwargs["reasoning_effort"] = wire_effort + + # Only send thinking controls when reasoning_effort is explicit so + # omitting the config preserves each provider's default. + if reasoning_effort is not None: + thinking_enabled = semantic_effort not in ("none", "minimal") + for thinking_style in _thinking_styles_for(spec, model_name): + if not thinking_enabled and slug in _KIMI_ALWAYS_THINKING_MODELS: + continue + extra = _thinking_extra_body(thinking_style, thinking_enabled) + if extra: + kwargs.setdefault("extra_body", {}).update(extra) + gateway_style = getattr(spec, "gateway_reasoning_style", "") if spec else "" + if ( + gateway_style + and _model_thinking_style(model_name) + and (thinking_enabled or slug not in _KIMI_ALWAYS_THINKING_MODELS) + ): + extra = _gateway_reasoning_extra_body(gateway_style, semantic_effort) + if extra: + kwargs.setdefault("extra_body", {}).update(extra) + + # Moonshot rejects requests that carry both 'reasoning_effort' + # and the native 'thinking' param. We already expressed the + # user's intent via the provider-native shape, so drop the + # redundant wire-level kwarg. Only kimi models need this — + # Xiaomi's API accepts both params. + if slug in _KIMI_THINKING_MODELS: + kwargs.pop("reasoning_effort", None) + + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = tool_choice or "auto" + + # Backfill reasoning_content="" on assistants missing it: DeepSeek + # thinking mode rejects history otherwise (#3554, #3584); "" reads + # as "no thinking that turn". DeepSeek-V4/reasoner reason natively, + # so backfill even without explicit reasoning_effort. + explicit_thinking = ( + reasoning_effort is not None + and semantic_effort not in ("none", "minimal") + and ( + (spec and spec.thinking_style) + or _model_thinking_style(model_name) + ) + ) + implicit_deepseek_thinking = ( + spec is not None + and spec.name == "deepseek" + and semantic_effort not in ("none", "minimal", "minimum") + and any(t in model_name.lower() for t in ("deepseek-v4", "deepseek-reasoner")) + ) + if explicit_thinking or implicit_deepseek_thinking: + for msg in kwargs["messages"]: + if msg.get("role") == "assistant" and "reasoning_content" not in msg: + msg["reasoning_content"] = "" + + # Merge user-configured extra_body last so it can override or + # extend provider-specific defaults (e.g. chat_template_kwargs, + # guided_json, repetition_penalty). Uses recursive merge so + # nested dicts like {"chat_template_kwargs": {"enable_thinking": false}} + # do not clobber sibling keys already set by thinking-style logic. + if self._extra_body: + existing = kwargs.get("extra_body", {}) + kwargs["extra_body"] = _deep_merge(existing, self._extra_body) + + return kwargs + + def _should_use_responses_api( + self, + model: str | None, + reasoning_effort: str | None, + ) -> bool: + """Use Responses API only for direct OpenAI requests that benefit from it.""" + if self._api_type == "chat_completions": + return False + if self._spec and self._spec.name not in ("openai", "github_copilot"): + return False + if self._api_type == "responses": + # Explicit configuration means Responses is mandatory; do not + # consult the circuit breaker or fall back to Chat Completions. + return True + if self._spec is None or self._spec.name != "github_copilot": + if not _is_direct_openai_base(self._effective_base): + return False + + model_name = (model or self.default_model).lower() + wants = False + if reasoning_effort and reasoning_effort.lower() != "none": + wants = True + elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")): + wants = True + if not wants: + return False + + return self._responses_circuit_allows_probe(model, reasoning_effort) + + def _responses_circuit_allows_probe( + self, + model: str | None, + reasoning_effort: str | None, + ) -> bool: + """Return False when the Responses API circuit breaker is open.""" + key = _responses_circuit_key(model, self.default_model, reasoning_effort) + failures = self._responses_failures.get(key, 0) + if failures >= _RESPONSES_FAILURE_THRESHOLD: + tripped = self._responses_tripped_at.get(key, 0.0) + if (time.monotonic() - tripped) < _RESPONSES_PROBE_INTERVAL_S: + return False + # Half-open: allow one probe attempt + return True + + def _record_responses_failure(self, model: str | None, reasoning_effort: str | None) -> None: + key = _responses_circuit_key(model, self.default_model, reasoning_effort) + count = self._responses_failures.get(key, 0) + 1 + self._responses_failures[key] = count + if count >= _RESPONSES_FAILURE_THRESHOLD: + self._responses_tripped_at[key] = time.monotonic() + logger.warning( + "Responses API circuit open for {} — falling back to Chat Completions", + key, + ) + + def _record_responses_success(self, model: str | None, reasoning_effort: str | None) -> None: + key = _responses_circuit_key(model, self.default_model, reasoning_effort) + self._responses_failures.pop(key, None) + self._responses_tripped_at.pop(key, None) + + @staticmethod + def _should_fallback_from_responses_error(e: Exception) -> bool: + """Fallback only for likely Responses API compatibility errors.""" + response = getattr(e, "response", None) + status_code = getattr(e, "status_code", None) + if status_code is None and response is not None: + status_code = getattr(response, "status_code", None) + if status_code not in {400, 404, 422}: + return False + + body = ( + getattr(e, "body", None) + or getattr(e, "doc", None) + or getattr(response, "text", None) + ) + body_text = str(body).lower() if body is not None else "" + compatibility_markers = ( + "responses", + "response api", + "max_output_tokens", + "instructions", + "previous_response", + "unsupported", + "not supported", + "unknown parameter", + "unrecognized request argument", + ) + return any(marker in body_text for marker in compatibility_markers) + + def _build_responses_body( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + model: str | None, + max_tokens: int, + temperature: float, + reasoning_effort: str | None, + tool_choice: str | dict[str, Any] | None, + ) -> dict[str, Any]: + """Build a Responses API body for direct OpenAI requests.""" + model_name = model or self.default_model + model_name = self._request_model_name(model_name) + sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages)) + instructions, input_items = convert_messages(sanitized_messages) + + body: dict[str, Any] = { + "model": model_name, + "instructions": instructions or None, + "input": input_items, + "max_output_tokens": max(1, max_tokens), + "store": False, + "stream": False, + } + + if self._supports_temperature(model_name, reasoning_effort): + body["temperature"] = temperature + + if reasoning_effort and reasoning_effort.lower() != "none": + body["reasoning"] = {"effort": reasoning_effort} + body["include"] = ["reasoning.encrypted_content"] + + if tools: + body["tools"] = convert_tools(tools) + body["tool_choice"] = tool_choice or "auto" + + extra_body = getattr(self, "_extra_body", {}) + if extra_body: + body = _merge_responses_extra_body(body, extra_body) + + return body + + # ------------------------------------------------------------------ + # Response parsing + # ------------------------------------------------------------------ + + @staticmethod + def _maybe_mapping(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + return value + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + dumped = model_dump() + if isinstance(dumped, dict): + return dumped + return None + + @classmethod + def _extract_text_content(cls, value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, list): + parts: list[str] = [] + for item in value: + item_map = cls._maybe_mapping(item) + if item_map: + # Skip Mistral-style {"type":"thinking","thinking":[...]} + # blocks: their text belongs in reasoning_content. + if item_map.get("type") == "thinking": + continue + text = item_map.get("text") + if isinstance(text, str): + parts.append(text) + continue + text = getattr(item, "text", None) + if isinstance(text, str): + parts.append(text) + continue + if isinstance(item, str): + parts.append(item) + return "".join(parts) or None + return str(value) + + @classmethod + def _extract_thinking_content(cls, value: Any) -> str | None: + """Extract reasoning text from Mistral-style thinking blocks. + + Mistral returns content as a list mixing + ``{"type":"thinking","thinking":[{"type":"text","text":...}]}`` and + ``{"type":"text","text":...}``. The thinking text belongs in + ``reasoning_content`` so the agent can surface it as a reasoning + trace rather than as the assistant's reply. + """ + if not isinstance(value, list): + return None + parts: list[str] = [] + for item in value: + item_map = cls._maybe_mapping(item) + if not item_map: + continue + if item_map.get("type") != "thinking": + continue + inner = item_map.get("thinking") + text = cls._extract_text_content(inner) + if text: + parts.append(text) + return "".join(parts) or None + + @classmethod + def _extract_usage(cls, response: Any) -> dict[str, int]: + """Extract token usage from an OpenAI-compatible response. + + Handles both dict-based (raw JSON) and object-based (SDK Pydantic) + responses. Provider-specific ``cached_tokens`` fields are normalised + under a single key; see the priority chain inside for details. + """ + # --- resolve usage object --- + usage_obj = None + response_map = cls._maybe_mapping(response) + if response_map is not None: + usage_obj = response_map.get("usage") + elif hasattr(response, "usage") and response.usage: + usage_obj = response.usage + + usage_map = cls._maybe_mapping(usage_obj) + if usage_map is not None: + result = { + "prompt_tokens": int(usage_map.get("prompt_tokens") or 0), + "completion_tokens": int(usage_map.get("completion_tokens") or 0), + "total_tokens": int(usage_map.get("total_tokens") or 0), + } + elif usage_obj: + result = { + "prompt_tokens": getattr(usage_obj, "prompt_tokens", 0) or 0, + "completion_tokens": getattr(usage_obj, "completion_tokens", 0) or 0, + "total_tokens": getattr(usage_obj, "total_tokens", 0) or 0, + } + else: + return {} + + # --- cached_tokens (normalised across providers) --- + # Try nested paths first (dict), fall back to attribute (SDK object). + # Priority order ensures the most specific field wins. + for path in ( + ("prompt_tokens_details", "cached_tokens"), # OpenAI/Zhipu/MiniMax/Qwen/Mistral/xAI + ("cached_tokens",), # StepFun/Moonshot (top-level) + ("prompt_cache_hit_tokens",), # DeepSeek/SiliconFlow + ): + cached = cls._get_nested_int(usage_map, path) + if not cached and usage_obj: + cached = cls._get_nested_int(usage_obj, path) + if cached: + result["cached_tokens"] = cached + break + + return result + + @staticmethod + def _get_nested_int(obj: Any, path: tuple[str, ...]) -> int: + """Drill into *obj* by *path* segments and return an ``int`` value. + + Supports both dict-key access and attribute access so it works + uniformly with raw JSON dicts **and** SDK Pydantic models. + """ + current = obj + for segment in path: + if current is None: + return 0 + if isinstance(current, dict): + current = current.get(segment) + else: + current = getattr(current, segment, None) + return int(current or 0) if current is not None else 0 + + def _parse(self, response: Any) -> LLMResponse: + if isinstance(response, str): + return LLMResponse(content=response, finish_reason="stop") + + response_map = self._maybe_mapping(response) + if response_map is not None: + choices = response_map.get("choices") or [] + if not choices: + content = self._extract_text_content( + response_map.get("content") or response_map.get("output_text") + ) + reasoning_content = self._extract_text_content( + response_map.get("reasoning_content") + ) + if content is not None: + return LLMResponse( + content=content, + reasoning_content=reasoning_content, + finish_reason=str(response_map.get("finish_reason") or "stop"), + usage=self._extract_usage(response_map), + ) + return LLMResponse( + content="Error: API returned empty choices.", + finish_reason="error", + error_kind="empty", + ) + + choice0 = self._maybe_mapping(choices[0]) or {} + msg0 = self._maybe_mapping(choice0.get("message")) or {} + content = self._extract_text_content(msg0.get("content")) + finish_reason = str(choice0.get("finish_reason") or "stop") + + raw_tool_calls: list[Any] = [] + # StepFun: fallback to reasoning field when content is empty + if not content and msg0.get("reasoning") and self._spec and self._spec.reasoning_as_content: + content = self._extract_text_content(msg0.get("reasoning")) + reasoning_content = msg0.get("reasoning_content") + if reasoning_content is None and msg0.get("reasoning"): + reasoning_content = self._extract_text_content(msg0.get("reasoning")) + # Mistral reasoning models return thinking text inside the content + # array; lift it into reasoning_content so the runner records it + # under the reasoning trace. + spec = getattr(self, "_spec", None) + if reasoning_content is None and getattr(spec, "extract_thinking_blocks", False): + reasoning_content = self._extract_thinking_content(msg0.get("content")) + for ch in choices: + ch_map = self._maybe_mapping(ch) or {} + m = self._maybe_mapping(ch_map.get("message")) or {} + tool_calls = m.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + raw_tool_calls.extend(tool_calls) + if ch_map.get("finish_reason") in ("tool_calls", "stop"): + finish_reason = str(ch_map["finish_reason"]) + if not content: + content = self._extract_text_content(m.get("content")) + if reasoning_content is None: + reasoning_content = m.get("reasoning_content") + + # Deduplicate tool call IDs (same pattern as streaming path) + # Some providers reuse the same ID for parallel tool calls. + _seen_tc_ids: set[str] = set() + parsed_tool_calls = [] + for tc in raw_tool_calls: + tc_map = self._maybe_mapping(tc) or {} + fn = self._maybe_mapping(tc_map.get("function")) or {} + args = parse_tool_arguments(fn.get("arguments", {})) + ec, prov, fn_prov = _extract_tc_extras(tc) + raw_id = str(tc_map.get("id") or _short_tool_id()) + if not raw_id or raw_id in _seen_tc_ids: + raw_id = _short_tool_id() + _seen_tc_ids.add(raw_id) + parsed_tool_calls.append(ToolCallRequest( + id=raw_id, + name=str(fn.get("name") or ""), + arguments=args, + extra_content=ec, + provider_specific_fields=prov, + function_provider_specific_fields=fn_prov, + )) + if not parsed_tool_calls: + content, parsed_tool_calls = _extract_text_tool_calls(content) + + return LLMResponse( + content=content, + tool_calls=parsed_tool_calls, + finish_reason=finish_reason, + usage=self._extract_usage(response_map), + reasoning_content=reasoning_content if isinstance(reasoning_content, str) else None, + ) + + if not response.choices: + return LLMResponse( + content="Error: API returned empty choices.", + finish_reason="error", + error_kind="empty", + ) + + choice = response.choices[0] + msg = choice.message + content = msg.content + finish_reason = choice.finish_reason + + raw_tool_calls: list[Any] = [] + for ch in response.choices: + m = ch.message + if hasattr(m, "tool_calls") and m.tool_calls: + raw_tool_calls.extend(m.tool_calls) + if ch.finish_reason in ("tool_calls", "stop"): + finish_reason = ch.finish_reason + if not content and m.content: + content = m.content + if not content and getattr(m, "reasoning", None) and self._spec and self._spec.reasoning_as_content: + content = m.reasoning + + tool_calls = [] + for tc in raw_tool_calls: + args = parse_tool_arguments(tc.function.arguments) + ec, prov, fn_prov = _extract_tc_extras(tc) + tool_calls.append(ToolCallRequest( + id=str(getattr(tc, "id", None) or _short_tool_id()), + name=tc.function.name, + arguments=args, + extra_content=ec, + provider_specific_fields=prov, + function_provider_specific_fields=fn_prov, + )) + if not tool_calls: + content, tool_calls = _extract_text_tool_calls(content) + + reasoning_content = getattr(msg, "reasoning_content", None) + if reasoning_content is None and getattr(msg, "reasoning", None): + reasoning_content = msg.reasoning + + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason or "stop", + usage=self._extract_usage(response), + reasoning_content=reasoning_content, + ) + + @classmethod + def _parse_chunks(cls, chunks: list[Any]) -> LLMResponse: + content_parts: list[str] = [] + reasoning_parts: list[str] = [] + tc_bufs: dict[int, dict[str, Any]] = {} + finish_reason = "stop" + usage: dict[str, int] = {} + + def _accum_tc(tc: Any, idx_hint: int) -> None: + """Accumulate one streaming tool-call delta into *tc_bufs*.""" + tc_index: int = _get(tc, "index") if _get(tc, "index") is not None else idx_hint + buf = tc_bufs.setdefault(tc_index, { + "id": "", "name": "", "arguments": "", + "extra_content": None, "prov": None, "fn_prov": None, + }) + tc_id = _get(tc, "id") + if tc_id: + buf["id"] = str(tc_id) + fn = _get(tc, "function") + if fn is not None: + fn_name = _get(fn, "name") + if fn_name: + buf["name"] = str(fn_name) + fn_args = _get(fn, "arguments") + if fn_args: + buf["arguments"] += str(fn_args) + ec, prov, fn_prov = _extract_tc_extras(tc) + if ec: + buf["extra_content"] = ec + if prov: + buf["prov"] = prov + if fn_prov: + buf["fn_prov"] = fn_prov + + def _accum_legacy_function_call(function_call: Any) -> None: + """Accumulate legacy ``delta.function_call`` streaming chunks.""" + if not function_call: + return + buf = tc_bufs.setdefault(0, { + "id": "", "name": "", "arguments": "", + "extra_content": None, "prov": None, "fn_prov": None, + }) + fn_name = _get(function_call, "name") + if fn_name: + buf["name"] = str(fn_name) + fn_args = _get(function_call, "arguments") + if fn_args: + buf["arguments"] += str(fn_args) + + for chunk in chunks: + if isinstance(chunk, str): + content_parts.append(chunk) + continue + + chunk_map = cls._maybe_mapping(chunk) + if chunk_map is not None: + choices = chunk_map.get("choices") or [] + if not choices: + usage = cls._extract_usage(chunk_map) or usage + text = cls._extract_text_content( + chunk_map.get("content") or chunk_map.get("output_text") + ) + if text: + content_parts.append(text) + continue + choice = cls._maybe_mapping(choices[0]) or {} + if choice.get("finish_reason"): + finish_reason = str(choice["finish_reason"]) + delta = cls._maybe_mapping(choice.get("delta")) or {} + raw_delta_content = delta.get("content") + text = cls._extract_text_content(raw_delta_content) + if text: + content_parts.append(text) + text = cls._extract_text_content(delta.get("reasoning_content")) + if not text: + text = cls._extract_text_content(delta.get("reasoning")) + if not text: + # Mistral streams thinking inside the content array as + # {"type":"thinking", thinking:[{"type":"text", ...}]}. + text = cls._extract_thinking_content(raw_delta_content) + if text: + reasoning_parts.append(text) + for idx, tc in enumerate(delta.get("tool_calls") or []): + _accum_tc(tc, idx) + _accum_legacy_function_call(delta.get("function_call")) + usage = cls._extract_usage(chunk_map) or usage + continue + + if not chunk.choices: + usage = cls._extract_usage(chunk) or usage + continue + choice = chunk.choices[0] + if choice.finish_reason: + finish_reason = choice.finish_reason + delta = choice.delta + if delta and delta.content: + text = cls._extract_text_content(delta.content) + if text: + content_parts.append(text) + thinking_text = cls._extract_thinking_content(delta.content) + if thinking_text: + reasoning_parts.append(thinking_text) + if delta: + reasoning = getattr(delta, "reasoning_content", None) + if not reasoning: + reasoning = getattr(delta, "reasoning", None) + if reasoning: + text = cls._extract_text_content(reasoning) + if text: + reasoning_parts.append(text) + for tc in (getattr(delta, "tool_calls", None) or []) if delta else []: + _accum_tc(tc, getattr(tc, "index", 0)) + if delta: + _accum_legacy_function_call(getattr(delta, "function_call", None)) + + # Some providers (e.g. Zhipu/GLM) reuse the same tool_call id for + # parallel tool calls in streaming mode. Deduplicate before building + # the response so downstream tool messages don't collide. + _seen_tc_ids: set[str] = set() + for b in tc_bufs.values(): + if not b["id"] or b["id"] in _seen_tc_ids: + b["id"] = _short_tool_id() + _seen_tc_ids.add(b["id"]) + + content = "".join(content_parts) or None + tool_calls = [ + ToolCallRequest( + id=b["id"] or _short_tool_id(), + name=b["name"], + arguments=parse_tool_arguments(b["arguments"]), + extra_content=b.get("extra_content"), + provider_specific_fields=b.get("prov"), + function_provider_specific_fields=b.get("fn_prov"), + ) + for b in tc_bufs.values() + ] + if not tool_calls: + content, tool_calls = _extract_text_tool_calls(content) + + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason, + usage=usage, + reasoning_content="".join(reasoning_parts) or None, + ) + + @classmethod + def _extract_error_metadata(cls, e: Exception) -> dict[str, Any]: + response = getattr(e, "response", None) + headers = getattr(response, "headers", None) + payload = ( + getattr(e, "body", None) + or getattr(e, "doc", None) + or getattr(response, "text", None) + ) + if payload is None and response is not None: + response_json = getattr(response, "json", None) + if callable(response_json): + try: + payload = response_json() + except Exception: + payload = None + error_type, error_code = LLMProvider._extract_error_type_code(payload) + + status_code = getattr(e, "status_code", None) + if status_code is None and response is not None: + status_code = getattr(response, "status_code", None) + + should_retry: bool | None = None + if headers is not None: + raw = headers.get("x-should-retry") + if isinstance(raw, str): + lowered = raw.strip().lower() + if lowered == "true": + should_retry = True + elif lowered == "false": + should_retry = False + + error_kind: str | None = None + error_name = e.__class__.__name__.lower() + if "timeout" in error_name: + error_kind = "timeout" + elif "connection" in error_name: + error_kind = "connection" + + return { + "error_status_code": int(status_code) if status_code is not None else None, + "error_kind": error_kind, + "error_type": error_type, + "error_code": error_code, + "error_retry_after_s": cls._extract_retry_after_from_headers(headers), + "error_should_retry": should_retry, + } + + @staticmethod + def _handle_error( + e: Exception, + *, + spec: ProviderSpec | None = None, + api_base: str | None = None, + ) -> LLMResponse: + body = ( + getattr(e, "doc", None) + or getattr(e, "body", None) + or getattr(getattr(e, "response", None), "text", None) + ) + body_text = body if isinstance(body, str) else str(body) if body is not None else "" + msg = f"Error: {body_text.strip()[:500]}" if body_text.strip() else f"Error calling LLM: {e}" + + text = f"{body_text} {e}".lower() + if spec and spec.is_local and ("502" in text or "connection" in text or "refused" in text): + msg += ( + "\nHint: this is a local model endpoint. Check that the local server is reachable at " + f"{api_base or spec.default_api_base}, and if you are using a proxy/tunnel, make sure it " + "can reach your local Ollama/vLLM service instead of routing localhost through the remote host." + ) + + response = getattr(e, "response", None) + retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None)) + if retry_after is None: + retry_after = LLMProvider._extract_retry_after(msg) + return LLMResponse( + content=msg, + finish_reason="error", + retry_after=retry_after, + **OpenAICompatProvider._extract_error_metadata(e), + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> LLMResponse: + await self._ensure_client() + try: + if self._should_use_responses_api(model, reasoning_effort): + try: + body = self._build_responses_body( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, + ) + result = parse_response_output(await self._client.responses.create(**body)) + self._record_responses_success(model, reasoning_effort) + return result + except Exception as responses_error: + if self._spec and self._spec.name == "github_copilot": + # Copilot gateway exposes GPT-5/o-series only via /responses; + # falling back to /chat/completions cannot succeed and would + # hide the real error. + raise + if self._api_type == "responses": + raise + if not self._should_fallback_from_responses_error(responses_error): + raise + self._record_responses_failure(model, reasoning_effort) + + kwargs = self._build_kwargs( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, + ) + return self._parse(await self._client.chat.completions.create(**kwargs)) + except Exception as e: + return self._handle_error(e, spec=self._spec, api_base=self.api_base) + + async def chat_stream( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + tool_choice: str | dict[str, Any] | None = None, + on_content_delta: Callable[[str], Awaitable[None]] | None = None, + on_thinking_delta: Callable[[str], Awaitable[None]] | None = None, + on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None, + ) -> LLMResponse: + await self._ensure_client() + idle_timeout_s = resolve_stream_idle_timeout_s() + try: + if self._should_use_responses_api(model, reasoning_effort): + try: + body = self._build_responses_body( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, + ) + body["stream"] = True + stream = await self._client.responses.create(**body) + + async def _timed_stream(): + stream_iter = stream.__aiter__() + while True: + try: + yield await asyncio.wait_for( + stream_iter.__anext__(), + timeout=idle_timeout_s, + ) + except StopAsyncIteration: + break + + ( + content, + tool_calls, + finish_reason, + usage, + reasoning_content, + ) = await consume_sdk_stream( + _timed_stream(), + on_content_delta, + on_tool_call_delta=on_tool_call_delta, + ) + self._record_responses_success(model, reasoning_effort) + return LLMResponse( + content=content or None, + tool_calls=tool_calls, + finish_reason=finish_reason, + usage=usage, + reasoning_content=reasoning_content, + ) + except Exception as responses_error: + if self._spec and self._spec.name == "github_copilot": + # Copilot gateway exposes GPT-5/o-series only via /responses; + # falling back to /chat/completions cannot succeed and would + # hide the real error. + raise + if self._api_type == "responses": + raise + if not self._should_fallback_from_responses_error(responses_error): + raise + self._record_responses_failure(model, reasoning_effort) + + kwargs = self._build_kwargs( + messages, tools, model, max_tokens, temperature, + reasoning_effort, tool_choice, + ) + if self._spec and self._spec.name == "zhipu" and tools and on_tool_call_delta: + # Z.AI/GLM keeps streaming tool-call arguments behind an + # explicit provider flag. Pass it through the OpenAI SDK's + # extra_body escape hatch so the usual delta.tool_calls path + # can surface live file-edit progress. + kwargs.setdefault("extra_body", {})["tool_stream"] = True + kwargs["stream"] = True + kwargs["stream_options"] = {"include_usage": True} + stream = await self._client.chat.completions.create(**kwargs) + chunks: list[Any] = [] + stream_iter = stream.__aiter__() + while True: + try: + chunk = await asyncio.wait_for( + stream_iter.__anext__(), + timeout=idle_timeout_s, + ) + except StopAsyncIteration: + break + chunks.append(chunk) + if chunk.choices: + delta_obj = chunk.choices[0].delta + raw_delta_content = getattr(delta_obj, "content", None) + if on_content_delta: + # Mistral streams content as a list of {"type":"thinking", + # ...} + {"type":"text",...} blocks. Extract just the + # text portion before invoking the callback so callers + # never see non-string content. + text = self._extract_text_content(raw_delta_content) + if text: + await on_content_delta(text) + if on_thinking_delta: + reasoning = getattr(delta_obj, "reasoning_content", None) or getattr( + delta_obj, "reasoning", None, + ) + r_text = self._extract_text_content(reasoning) + if not r_text: + # Mistral keeps the thinking trace inside the + # content array rather than a separate field. + r_text = self._extract_thinking_content(raw_delta_content) + if r_text: + await on_thinking_delta(r_text) + if on_tool_call_delta: + for idx, tool_delta in enumerate( + getattr(delta_obj, "tool_calls", None) or [] + ): + fn = _get(tool_delta, "function") + tool_index = _get(tool_delta, "index") + await on_tool_call_delta({ + "index": tool_index if tool_index is not None else idx, + "call_id": str(_get(tool_delta, "id") or ""), + "name": str(_get(fn, "name") or "") if fn is not None else "", + "arguments_delta": ( + str(_get(fn, "arguments") or "") if fn is not None else "" + ), + }) + function_call = getattr(delta_obj, "function_call", None) + if function_call: + await on_tool_call_delta({ + "index": 0, + "call_id": "", + "name": str(_get(function_call, "name") or ""), + "arguments_delta": str(_get(function_call, "arguments") or ""), + }) + return self._parse_chunks(chunks) + except asyncio.TimeoutError: + return LLMResponse( + content=( + f"Error calling LLM: stream stalled for more than " + f"{idle_timeout_s:g} seconds" + ), + finish_reason="error", + error_kind="timeout", + ) + except Exception as e: + return self._handle_error(e, spec=self._spec, api_base=self.api_base) + + def get_default_model(self) -> str: + return self.default_model