mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 06:43:16 +08:00
包含模型配置/thinking 透传、MCP 运行时与安装体验增强、以及会话渲染与图片显示链路修复,确保流式与历史展示行为一致。 Made-with: Cursor
409 lines
17 KiB
Python
409 lines
17 KiB
Python
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.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 _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)
|
||
|
||
kwargs: dict[str, Any] = {"model": self.model, "messages": cleaned_msgs, "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"] = tools
|
||
try:
|
||
return self._client.chat.completions.create(**kwargs)
|
||
except Exception as exc:
|
||
msg = str(exc)
|
||
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 and 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 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"]
|
||
|
||
|
||
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
|
||
|