oclaw/platform/llm/transports/openai_chat_completions.py
oliver 7941b27f58 feat(llm): send strict_tool_mode for DeepSeek OpenAI-compat tool calls
Merge strict_tool_mode: true into extra_body when tools are used and the
endpoint/model looks like DeepSeek. Opt out with AIA_DEEPSEEK_STRICT_TOOL_MODE=0.
Add unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 09:22:22 +08:00

728 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import logging
import os
import re
import uuid
from typing import Any, Optional
from collections.abc import Callable
from oclaw.platform.llm.tool_schema import complete_openai_tools_wire_parameters
from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload, coerce_thought_signature_for_storage
logger = logging.getLogger(__name__)
def _likely_gemini_openai_compat_base_url(url: str | None) -> bool:
if not url:
return False
u = url.lower()
return "generativelanguage.googleapis.com" in u or "aiplatform.googleapis.com" in u
def _model_id_suggests_gemini(model: str | None) -> bool:
return "gemini" in (model or "").lower()
def _is_minimax_compat(model: str | None, base_url: str | None) -> bool:
m = str(model or "").strip().lower()
b = str(base_url or "").strip().lower()
return ("minimax" in m) or ("minimax" in b)
def _truthy_env(name: str, default: str = "") -> bool:
v = os.getenv(name)
if v is None:
v = default
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
def _should_disable_thinking(base_url: str | None) -> bool:
# Some OpenAI-compatible gateways enable "thinking" mode and require replaying
# reasoning_content in subsequent turns. When the client does not preserve it,
# the gateway returns HTTP 400. Allow disabling thinking at request level.
#
# Safety: do NOT send unknown fields to official OpenAI endpoints by default.
if _truthy_env("AIA_LLM_THINKING_FORCE_DISABLED", "0"):
return True
if _truthy_env("AIA_LLM_THINKING_FORCE_ENABLED", "0"):
return False
b = str(base_url or "").strip().lower()
if not b:
return False
if "api.openai.com" in b:
return False
# Default-on for non-official OpenAI-compatible gateways.
return _truthy_env("AIA_LLM_THINKING_DISABLED", "1")
def _should_enable_thinking(base_url: str | None, *, thinking_mode_enabled: bool = False) -> bool:
if _truthy_env("AIA_LLM_THINKING_FORCE_ENABLED", "0"):
return True
if _truthy_env("AIA_LLM_THINKING_FORCE_DISABLED", "0"):
return False
if not bool(thinking_mode_enabled):
return False
b = str(base_url or "").strip().lower()
if not b:
return False
if "api.openai.com" in b:
return False
return True
def _should_force_text_only_messages(base_url: str | None) -> bool:
del base_url
# Opt-in only. Avoid hard-coding vendor/domain specific heuristics.
return _truthy_env("AIA_OPENAI_FORCE_TEXT_CONTENT", "0")
def _multimodal_proactive_downgrade_enabled() -> bool:
raw = str(os.getenv("AIA_OPENAI_MULTIMODAL_PROACTIVE_DOWNGRADE") or "1").strip().lower()
return raw not in {"0", "false", "no", "off"}
def _text_only_multimodal_substrings() -> tuple[str, ...]:
raw = os.getenv("AIA_OPENAI_TEXT_ONLY_MULTIMODAL_SUBSTRINGS")
if raw is None:
return ("deepseek",)
s = str(raw).strip().lower()
if s in {"", "-", "none", "off"}:
return ()
return tuple(part.strip().lower() for part in str(raw).split(",") if part.strip())
def _messages_contain_list_with_image(messages: list[dict[str, Any]]) -> bool:
for m in messages or []:
if not isinstance(m, dict):
continue
c = m.get("content")
if not isinstance(c, list):
continue
for item in c:
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in ("image_url", "input_image"):
return True
return False
def _deepseek_strict_tool_mode_enabled(*, base_url: str | None, model: str | None) -> bool:
"""DeepSeek OpenAI-compat: optional ``strict_tool_mode`` in request body (via ``extra_body``).
Upstream documents this as reducing DSML-in-text tool output in favor of native ``tool_calls``.
Opt out with ``AIA_DEEPSEEK_STRICT_TOOL_MODE=0`` when a proxy rejects unknown fields.
"""
raw = str(os.getenv("AIA_DEEPSEEK_STRICT_TOOL_MODE") or "").strip().lower()
if raw in ("0", "false", "no", "off"):
return False
hay = f"{base_url or ''} {model or ''}".lower()
return "deepseek" in hay
def _merge_deepseek_strict_tool_mode_extra_body(
kwargs: dict[str, Any],
*,
base_url: str | None,
model: str | None,
use_tools: bool,
) -> None:
if not use_tools or not _deepseek_strict_tool_mode_enabled(base_url=base_url, model=model):
return
extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {}
extra_body = dict(extra_body)
extra_body["strict_tool_mode"] = True
kwargs["extra_body"] = extra_body
def _should_proactively_downgrade_multimodal_messages(
messages: list[dict[str, Any]],
*,
model: str | None,
base_url: str | None,
) -> bool:
if _should_force_text_only_messages(base_url):
return _messages_contain_list_with_image(messages)
if not _multimodal_proactive_downgrade_enabled():
return False
if not _messages_contain_list_with_image(messages):
return False
parts = _text_only_multimodal_substrings()
if not parts:
return False
hay = f"{model or ''} {base_url or ''}".lower()
return any(p and p in hay for p in parts)
def _wire_error_message(exc: BaseException) -> str:
chunks: list[str] = [str(exc)]
em = getattr(exc, "message", None)
if isinstance(em, str) and em.strip():
chunks.append(em)
body = getattr(exc, "body", None)
if isinstance(body, dict):
try:
chunks.append(json.dumps(body, ensure_ascii=False, default=str))
except Exception:
chunks.append(repr(body))
elif isinstance(body, str) and body.strip():
chunks.append(body)
resp = getattr(exc, "response", None)
if resp is not None:
t = ""
try:
rt = getattr(resp, "text", "")
if isinstance(rt, str):
t = rt
except Exception:
pass
if not t.strip():
try:
ct = getattr(resp, "content", None)
if isinstance(ct, (bytes, bytearray)):
t = bytes(ct).decode("utf-8", errors="replace")
except Exception:
pass
if t.strip():
chunks.append(t)
inner = getattr(exc, "__cause__", None)
if inner is not None:
chunks.append(str(inner))
return "\n".join(chunks)
def _is_text_only_gateway_error(msg: str) -> bool:
m = str(msg or "").strip().lower()
if not m:
return False
if "unknown variant image_url" in m:
return True
if "image_url" in m and "expected text" in m:
return True
if "failed to deserialize" in m and ("expected text" in m or "unknown variant image_url" in m):
return True
if "deserialize" in m and "image_url" in m and ("expected text" in m or "messages[" in m):
return True
if "invalid_request_error" in m and ("image_url" in m or "expected text" in m):
return True
return False
def _flatten_message_content_to_text(content: Any) -> str:
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content or "")
parts: list[str] = []
for item in content:
if isinstance(item, str):
if item.strip():
parts.append(item)
continue
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in ("text", "input_text"):
txt = str(item.get("text") or item.get("content") or "").strip()
if txt:
parts.append(txt)
continue
if typ in ("image_url", "input_image"):
# Proactive / forced text-only path without OCR injection (or OCR disabled).
parts.append(
"【图片】本消息含图片,但当前以纯文本发往上游模型,像素未传入;"
"若需要图中细节,请改用支持多模态的模型或开启看图 OCR 降级通道。"
)
continue
return "\n".join([p for p in parts if p]).strip()
def _multimodal_downgrade_ocr_enabled() -> bool:
"""When True, text-only downgrade replaces image blocks with OCR text via AIA_OCR_* lane."""
return str(os.getenv("AIA_MULTIMODAL_DOWNGRADE_OCR") or "1").strip().lower() not in {
"0",
"false",
"no",
"off",
}
def _image_block_to_url(item: dict[str, Any]) -> str | None:
typ = str(item.get("type") or "").strip().lower()
if typ == "image_url":
u = item.get("image_url")
if isinstance(u, dict):
s = str(u.get("url") or "").strip()
return s or None
if isinstance(u, str) and u.strip():
return u.strip()
if typ == "input_image":
b64 = normalize_image_b64_payload(item.get("image_base64") or item.get("data"))
mime = str(item.get("mime") or "image/jpeg").strip() or "image/jpeg"
if b64:
return f"data:{mime};base64,{b64}"
return None
def _message_list_contains_image_block(content: Any) -> bool:
if not isinstance(content, list):
return False
for item in content:
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in ("image_url", "input_image"):
return True
return False
def _downgrade_ocr_wrap_for_llm(ocr_text: str) -> str:
"""User-facing copy injected into the main model when multimodal was OCR-downgraded to text."""
head = (
"【图片内容·OCR】当前主对话模型仅接收纯文本,无法直接读图;"
"下面是由独立看图通道从用户图片中转写提取的正文。请把它和用户的文字问题一起当作依据来回答;"
"转写可能有漏字、错行或与截图不完全一致,重要结论可请用户核对原图或补充说明。"
)
return f"{head}\n\n{ocr_text.strip()}"
def _flatten_message_content_for_text_gateway(content: Any) -> str:
"""Flatten list content to a string; image blocks may become OCR text when configured."""
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content or "")
if _multimodal_downgrade_ocr_enabled() and _message_list_contains_image_block(content):
try:
from oclaw.platform.llm.image_ocr_client import (
VISION_OCR_EXTRACT_PROMPT_ZH,
send_ocr_image_messages,
vision_llm_backend_status,
)
except Exception:
return _flatten_message_content_to_text(content)
if vision_llm_backend_status().get("ok"):
parts: list[str] = []
for item in content:
if isinstance(item, str):
if item.strip():
parts.append(item)
continue
if not isinstance(item, dict):
continue
typ = str(item.get("type") or "").strip().lower()
if typ in ("image_url", "input_image"):
url = _image_block_to_url(item)
if not url:
parts.append(
"【图片】无法从本消息中还原图片数据,图中细节未传入本轮模型;"
"若作答需要像素级信息,请提示用户重发或改用支持多模态的模型配置。"
)
continue
try:
out = send_ocr_image_messages(images=[url], prompt=VISION_OCR_EXTRACT_PROMPT_ZH)
except Exception as exc:
parts.append(
f"【图片】独立看图通道 OCR 调用异常({type(exc).__name__}),"
"本张图的正文未能写入对话;可请用户改述图中要点或稍后重试。"
)
continue
if not out.get("ok"):
err = str(out.get("error") or "unknown")
parts.append(
f"【图片】看图通道返回失败({err}),本张图未转成文字;"
"请据用户文字说明作答,或提示检查 OCR 配置/重发图片。"
)
continue
t = str(out.get("text") or "").strip()
if not t:
parts.append(
"【图片】看图通道未返回有效文字(可能模型拒识或图不清晰)。"
"请提示用户补充文字说明或重发更清晰的截图。"
)
else:
parts.append(_downgrade_ocr_wrap_for_llm(t))
continue
if typ in ("text", "input_text"):
txt = str(item.get("text") or item.get("content") or "").strip()
if txt:
parts.append(txt)
continue
return "\n\n".join([p for p in parts if p]).strip()
return _flatten_message_content_to_text(content)
def _normalize_messages_for_text_only_gateway(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for m in messages or []:
if not isinstance(m, dict):
continue
mm = dict(m)
if isinstance(mm.get("content"), list):
mm["content"] = _flatten_message_content_for_text_gateway(mm.get("content"))
out.append(mm)
return out
def _find_thought_signature_in_obj(o: Any) -> str | None:
if isinstance(o, dict):
for k in ("thought_signature", "thoughtSignature"):
v = o.get(k)
if v is not None:
coerced = coerce_thought_signature_for_storage(v)
if coerced is not None:
return coerced
for v in o.values():
found = _find_thought_signature_in_obj(v)
if found is not None:
return found
elif isinstance(o, list):
for v in o:
found = _find_thought_signature_in_obj(v)
if found is not None:
return found
return None
def _extract_thought_signature_from_message_tool_call(tc: Any) -> str | None:
if hasattr(tc, "model_dump"):
try:
d = tc.model_dump(mode="python")
ec = d.get("extra_content")
if isinstance(ec, dict):
g = ec.get("google")
if isinstance(g, dict) and "thought_signature" in g:
return coerce_thought_signature_for_storage(g.get("thought_signature"))
except Exception:
pass
ec = getattr(tc, "extra_content", None)
if isinstance(ec, dict):
g = ec.get("google")
if isinstance(g, dict) and "thought_signature" in g:
return coerce_thought_signature_for_storage(g.get("thought_signature"))
return _extract_thought_signature_from_tool_delta(tc)
def _extract_thought_signature_from_tool_delta(tc: Any) -> str | None:
for attr in ("thought_signature", "thoughtSignature"):
v = getattr(tc, attr, None)
if v is not None:
return coerce_thought_signature_for_storage(v)
extra = getattr(tc, "model_extra", None)
if isinstance(extra, dict):
for k in ("thought_signature", "thoughtSignature"):
ev = extra.get(k)
if ev is not None:
return coerce_thought_signature_for_storage(ev)
pyd_ex = getattr(tc, "__pydantic_extra__", None)
if isinstance(pyd_ex, dict):
for k in ("thought_signature", "thoughtSignature"):
ev = pyd_ex.get(k)
if ev is not None:
return coerce_thought_signature_for_storage(ev)
if hasattr(tc, "model_dump"):
try:
found = _find_thought_signature_in_obj(tc.model_dump(mode="python"))
if found is not None:
return found
except Exception:
pass
return None
class OpenAIChatModel(ChatModel):
def __init__(
self,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
thinking_mode_enabled: bool = False,
reasoning_effort: str | None = None,
):
self.model = model or os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
self.api_key = api_key or os.getenv("OPENAI_API_KEY")
self.base_url = base_url or os.getenv("OPENAI_BASE_URL")
self.thinking_mode_enabled = bool(thinking_mode_enabled)
eff = str(reasoning_effort or "").strip().lower()
self.reasoning_effort = eff if eff in ("low", "medium", "high") else ""
if not self.api_key:
raise RuntimeError("未设置 OPENAI_API_KEY,无法使用 OpenAI 模型")
try:
from openai import OpenAI
except Exception as e:
raise RuntimeError("未安装 openai 依赖,请先 pip install -r requirements.txt") from e
client_kwargs: dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
client_kwargs["base_url"] = self.base_url
self._client = OpenAI(**client_kwargs)
self._skip_tools: bool = False
def _create_chat_completion(self, norm_msgs: list[dict[str, Any]], tools: list[dict[str, Any]], *, stream: bool):
# replay_policy/tool_wire_policy live in sibling modules; import lazily to avoid cycles.
try:
from oclaw.platform.llm.replay_policy import apply_replay_policy_to_messages, resolve_replay_policy
policy = resolve_replay_policy(self.base_url, self.model)
if policy.enabled:
norm_msgs = apply_replay_policy_to_messages(norm_msgs, policy)
except Exception as exc:
logger.warning("replay_policy apply failed (%s); continuing without rewrite", exc)
minimax_compat = _is_minimax_compat(self.model, self.base_url)
use_tools = bool(tools)
cleaned_msgs: list[dict[str, Any]] = []
for m in norm_msgs or []:
if not isinstance(m, dict):
continue
role = str(m.get("role") or "")
if minimax_compat and role == "assistant" and isinstance(m.get("tool_calls"), list):
# MiniMax OpenAI-compat can reject long-history tool_use/tool_result replays.
# Keep text semantics, but remove historical wire-level tool_calls.
mm = dict(m)
mm.pop("tool_calls", None)
cleaned_msgs.append(mm)
continue
if minimax_compat and role == "tool":
# Downgrade history tool_result rows to plain assistant text context,
# avoiding strict tool_result sequence validation on replay.
tcid = str(m.get("tool_call_id") or m.get("call_id") or "").strip()
tname = str(m.get("name") or "").strip()
raw = str(m.get("content") or "")
prefix = "[tool_result replay]"
if tname:
prefix += f" name={tname}"
if tcid:
prefix += f" id={tcid}"
cleaned_msgs.append({"role": "assistant", "content": f"{prefix}\n{raw}".strip()})
continue
if role == "tool":
mm = dict(m)
for k in ("tool_call_id", "call_id"):
if k in mm:
v = mm.get(k)
try:
sv = str(v).strip() if v is not None else ""
except Exception:
sv = ""
if not sv:
mm.pop(k, None)
else:
mm[k] = sv
cleaned_msgs.append(mm)
else:
cleaned_msgs.append(m)
msgs_wire = cleaned_msgs
if _should_force_text_only_messages(self.base_url) or _should_proactively_downgrade_multimodal_messages(
cleaned_msgs,
model=self.model,
base_url=self.base_url,
):
msgs_wire = _normalize_messages_for_text_only_gateway(cleaned_msgs)
kwargs: dict[str, Any] = {"model": self.model, "messages": msgs_wire, "stream": stream}
if _should_enable_thinking(self.base_url, thinking_mode_enabled=bool(getattr(self, "thinking_mode_enabled", False))):
extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {}
extra_body = dict(extra_body)
extra_body["thinking"] = {"type": "enabled"}
kwargs["extra_body"] = extra_body
eff = str(getattr(self, "reasoning_effort", "") or "").strip().lower()
if eff in ("low", "medium", "high"):
kwargs["reasoning_effort"] = eff
elif _should_disable_thinking(self.base_url):
extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {}
extra_body = dict(extra_body)
extra_body["thinking"] = {"type": "disabled"}
kwargs["extra_body"] = extra_body
if use_tools:
try:
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.exposure_plan import build_llm_tools_plan
plan = build_llm_tools_plan(
store=SqliteStore(db_path()),
role="",
base_url=self.base_url,
max_json_bytes=_default_max_openai_tools_json_bytes(self.base_url),
include_mcp=False,
preview_internal=False,
raw_openai_tools_override=tools,
)
kwargs["tools"] = plan.tools_wired
except Exception:
kwargs["tools"] = complete_openai_tools_wire_parameters(tools)
_merge_deepseek_strict_tool_mode_extra_body(
kwargs, base_url=self.base_url, model=self.model, use_tools=use_tools
)
try:
return self._client.chat.completions.create(**kwargs)
except Exception as exc:
msg = _wire_error_message(exc)
if _is_text_only_gateway_error(msg) and isinstance(kwargs.get("messages"), list):
kwargs["messages"] = _normalize_messages_for_text_only_gateway(kwargs["messages"])
return self._client.chat.completions.create(**kwargs)
if "reasoning_content" in msg and "thinking mode" in msg and "must be passed back" in msg:
# Provider requires replaying assistant.reasoning_content in thinking mode.
# As a safety fallback, force-disable thinking and retry once.
extra_body = kwargs.get("extra_body") if isinstance(kwargs.get("extra_body"), dict) else {}
extra_body = dict(extra_body)
extra_body["thinking"] = {"type": "disabled"}
kwargs["extra_body"] = extra_body
kwargs.pop("reasoning_effort", None)
return self._client.chat.completions.create(**kwargs)
raise
def _llm_response_from_completion(self, completion: Any, *, on_token: Optional[Callable[[str], None]]) -> LLMResponse:
msg = completion.choices[0].message
reasoning_parts = getattr(msg, "reasoning_content", None) or ""
reasoning_text = str(reasoning_parts).strip() if reasoning_parts else ""
content = msg.content or ""
if on_token:
if reasoning_text:
on_token(reasoning_text)
if content:
on_token(content)
tool_calls: list[LLMToolCall] = []
if msg.tool_calls:
for tc in msg.tool_calls:
tid = str(getattr(tc, "id", "") or "")
fn = getattr(tc, "function", None)
name = str(fn.name) if fn and getattr(fn, "name", None) else ""
args_raw = str(fn.arguments) if fn and getattr(fn, "arguments", None) is not None else "{}"
if not name and not tid:
continue
if not tid:
tid = f"call_{uuid.uuid4().hex}"
try:
args = json.loads(args_raw)
except json.JSONDecodeError:
args = {"_raw": args_raw}
sig = _extract_thought_signature_from_message_tool_call(tc)
if sig is None and fn is not None:
sig = _extract_thought_signature_from_tool_delta(fn)
tool_calls.append(LLMToolCall(id=tid, name=name, arguments=args, thought_signature=sig))
return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text)
def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
*,
on_token: Optional[Callable[[str], None]] = None,
) -> LLMResponse:
# Normalize input_image to image_url.
norm_msgs = []
for m in messages:
if m.get("role") == "user" and isinstance(m.get("content"), list):
norm_content = []
for item in m["content"]:
if isinstance(item, dict) and item.get("type") == "input_image":
mime = str(item.get("mime") or "image/jpeg").strip() or "image/jpeg"
b64 = normalize_image_b64_payload(item.get("image_base64") or item.get("data"))
if not b64:
continue
norm_content.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
else:
norm_content.append(item)
norm_msgs.append({"role": m["role"], "content": norm_content})
else:
norm_msgs.append(m)
try:
stream = self._create_chat_completion(norm_msgs, tools, stream=True)
except Exception as exc:
logger.info("Streaming chat completion failed; fallback to non-stream (%s)", exc)
completion = self._create_chat_completion(norm_msgs, tools, stream=False)
return self._llm_response_from_completion(completion, on_token=on_token)
content_parts: list[str] = []
tool_acc: dict[int, dict[str, Any]] = {}
reasoning_parts: list[str] = []
def _consume_stream(stream_obj: Any) -> None:
nonlocal content_parts, tool_acc, reasoning_parts
for chunk in stream_obj:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
rc = getattr(delta, "reasoning_content", None) or ""
if rc:
reasoning_parts.append(rc)
if on_token:
on_token(rc)
if delta.tool_calls:
for tc in delta.tool_calls:
idx = int(tc.index)
slot = tool_acc.setdefault(idx, {"id": None, "name": None, "arguments": "", "thought_signature": None})
if tc.id:
slot["id"] = tc.id
if tc.function:
if tc.function.name:
slot["name"] = tc.function.name
if tc.function.arguments:
slot["arguments"] = f"{slot['arguments']}{tc.function.arguments}"
sig = _extract_thought_signature_from_message_tool_call(tc)
if sig is None:
sig = _extract_thought_signature_from_tool_delta(tc)
if sig is None and tc.function is not None:
sig = _extract_thought_signature_from_tool_delta(tc.function)
if sig is not None:
slot["thought_signature"] = sig
if delta.content:
content_parts.append(delta.content)
if on_token:
on_token(delta.content)
try:
_consume_stream(stream)
except Exception:
raise
content = "".join(content_parts)
reasoning_text = "".join(reasoning_parts).strip()
tool_calls: list[LLMToolCall] = []
for idx in sorted(tool_acc.keys()):
slot = tool_acc[idx]
tid = slot.get("id") or ""
name = slot.get("name") or ""
args_raw = slot.get("arguments") or "{}"
if not name and not tid:
continue
if not tid:
tid = f"call_{uuid.uuid4().hex}"
try:
args = json.loads(args_raw)
except json.JSONDecodeError:
args = {"_raw": args_raw}
ts_raw = slot.get("thought_signature")
tsig = coerce_thought_signature_for_storage(ts_raw) if ts_raw is not None else None
tool_calls.append(LLMToolCall(id=str(tid), name=str(name), arguments=args, thought_signature=tsig))
return LLMResponse(content=content, tool_calls=tool_calls, reasoning_content=reasoning_text)
__all__ = [
"OpenAIChatModel",
"_likely_gemini_openai_compat_base_url",
"_model_id_suggests_gemini",
"_deepseek_strict_tool_mode_enabled",
"_merge_deepseek_strict_tool_mode_extra_body",
]
def _default_max_openai_tools_json_bytes(base_url: str | None) -> int | None:
u = str(base_url or "").strip().lower()
if not u:
return None
# Conservative caps for some OpenAI-compatible gateways.
if "dashscope" in u or "aliyuncs" in u:
return 28_000
return None