mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 04:10:44 +08:00
包含模型配置/thinking 透传、MCP 运行时与安装体验增强、以及会话渲染与图片显示链路修复,确保流式与历史展示行为一致。 Made-with: Cursor
330 lines
15 KiB
Python
330 lines
15 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import uuid
|
||
from typing import Any, Optional
|
||
from collections.abc import Callable, Iterable
|
||
|
||
from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCall, normalize_image_b64_payload
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _as_dict(obj: Any) -> dict[str, Any] | None:
|
||
if obj is None:
|
||
return None
|
||
if isinstance(obj, dict):
|
||
return obj
|
||
if hasattr(obj, "model_dump"):
|
||
try:
|
||
d = obj.model_dump(mode="python")
|
||
return d if isinstance(d, dict) else None
|
||
except Exception:
|
||
return None
|
||
return None
|
||
|
||
|
||
def _collect_tool_calls_from_response_dict(resp: dict[str, Any]) -> list[LLMToolCall]:
|
||
out: list[LLMToolCall] = []
|
||
items = resp.get("output")
|
||
if not isinstance(items, list):
|
||
return out
|
||
for it in items:
|
||
d = _as_dict(it)
|
||
if not d:
|
||
continue
|
||
if str(d.get("type") or "") in ("function_call", "tool_call"):
|
||
call_id = str(d.get("call_id") or d.get("id") or "") or f"call_{uuid.uuid4().hex}"
|
||
name = str(d.get("name") or "") or str((d.get("function") or {}).get("name") or "")
|
||
args = d.get("arguments") or d.get("input") or {}
|
||
if isinstance(args, str):
|
||
try:
|
||
args = json.loads(args)
|
||
except Exception:
|
||
args = {"_raw": args}
|
||
if not isinstance(args, dict):
|
||
args = {"_raw": args}
|
||
out.append(LLMToolCall(id=call_id, name=name, arguments=args, thought_signature=None))
|
||
return out
|
||
|
||
|
||
def parse_openai_responses_stream_events(
|
||
events: Iterable[Any],
|
||
*,
|
||
on_token: Optional[Callable[[str], None]] = None,
|
||
) -> tuple[str, list[LLMToolCall], dict[str, Any] | None]:
|
||
"""Pure stream parser (offline-testable).
|
||
|
||
Returns: (assembled_text, tool_calls, final_response_dict?)
|
||
"""
|
||
parts: list[str] = []
|
||
final_resp: dict[str, Any] | None = None
|
||
for ev in events:
|
||
d = _as_dict(ev) or {}
|
||
typ = str(d.get("type") or "")
|
||
# Text deltas
|
||
if typ in ("response.output_text.delta", "response.output_text"):
|
||
delta = d.get("delta")
|
||
if delta is None:
|
||
delta = d.get("text")
|
||
s = str(delta or "")
|
||
if s:
|
||
parts.append(s)
|
||
if on_token:
|
||
on_token(s)
|
||
continue
|
||
# Sometimes the SDK emits generic `response.delta` with nested segments.
|
||
if typ == "response.delta":
|
||
delta = d.get("delta")
|
||
if isinstance(delta, dict):
|
||
txt = delta.get("output_text")
|
||
if isinstance(txt, str) and txt:
|
||
parts.append(txt)
|
||
if on_token:
|
||
on_token(txt)
|
||
continue
|
||
# Terminal response object
|
||
if typ in ("response.completed", "response.complete", "response.done"):
|
||
resp = d.get("response") or d.get("data") or d.get("result")
|
||
final_resp = _as_dict(resp) or final_resp
|
||
continue
|
||
if typ == "response.created":
|
||
continue
|
||
# Some SDK versions yield the response object directly (no explicit event type).
|
||
if d.get("output") is not None and d.get("id") is not None:
|
||
final_resp = d
|
||
|
||
text = "".join(parts)
|
||
tool_calls = _collect_tool_calls_from_response_dict(final_resp) if final_resp else []
|
||
return text, tool_calls, final_resp
|
||
|
||
|
||
class OpenAIResponsesModel(ChatModel):
|
||
"""OpenAI Responses API transport (OpenAI-compatible gateways may implement this surface)."""
|
||
|
||
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").strip()
|
||
self.api_key = (api_key or os.getenv("OPENAI_API_KEY") or "").strip()
|
||
self.base_url = (base_url or os.getenv("OPENAI_BASE_URL") or "").strip() or None
|
||
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 Responses")
|
||
try:
|
||
from openai import OpenAI
|
||
except Exception as e:
|
||
raise RuntimeError("未安装 openai 依赖,请先 pip install -r requirements.txt") from e
|
||
kw: dict[str, Any] = {"api_key": self.api_key}
|
||
if self.base_url:
|
||
kw["base_url"] = self.base_url
|
||
self._client = OpenAI(**kw)
|
||
|
||
@staticmethod
|
||
def _normalize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
"""
|
||
Normalize to the strictest OpenAI-compatible `responses` shape:
|
||
- input.messages[*].role MUST be "user"
|
||
- input.messages[*].content MUST be a list (content blocks)
|
||
- text blocks use {"type":"text","text":...}
|
||
- image blocks use {"type":"image_url","image_url":{"url":"..."}} or {"type":"input_image","image_url":"..."} depending on gateway;
|
||
we prefer the common "image_url" block here.
|
||
"""
|
||
out: list[dict[str, Any]] = []
|
||
for m in messages or []:
|
||
if not isinstance(m, dict):
|
||
continue
|
||
role = str(m.get("role") or "user").strip().lower() or "user"
|
||
content = m.get("content")
|
||
norm_content: list[dict[str, Any]] = []
|
||
if isinstance(content, list):
|
||
for item in 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}"}})
|
||
continue
|
||
if isinstance(item, dict) and item.get("type") in ("input_text", "text"):
|
||
t = str(item.get("text") or "").strip()
|
||
if t:
|
||
norm_content.append({"type": "text", "text": t})
|
||
continue
|
||
if isinstance(item, dict):
|
||
# Best-effort coerce unknown blocks into valid text blocks.
|
||
s = str(item.get("text") or "").strip()
|
||
if s:
|
||
norm_content.append({"type": "text", "text": s})
|
||
continue
|
||
s = str(item or "").strip()
|
||
if s:
|
||
norm_content.append({"type": "text", "text": s})
|
||
elif isinstance(content, str):
|
||
txt = content.strip()
|
||
if txt:
|
||
norm_content.append({"type": "text", "text": txt})
|
||
elif content is not None:
|
||
s = str(content).strip()
|
||
if s:
|
||
norm_content.append({"type": "text", "text": s})
|
||
if not norm_content:
|
||
# Keep shape valid even for empty messages.
|
||
continue
|
||
|
||
# Enforce role=user and preserve other roles via prefix tag.
|
||
if role != "user":
|
||
prefix = "assistant" if role == "assistant" else ("system" if role == "system" else role)
|
||
norm_content.insert(0, {"type": "text", "text": f"[{prefix}]"})
|
||
out.append({"role": "user", "content": norm_content})
|
||
return out
|
||
|
||
def chat(
|
||
self,
|
||
messages: list[dict[str, Any]],
|
||
tools: list[dict[str, Any]],
|
||
*,
|
||
on_token: Optional[Callable[[str], None]] = None,
|
||
) -> LLMResponse:
|
||
norm = self._normalize_messages(messages)
|
||
# OpenAI-compatible gateways differ: some require input={"messages":[...]} with role=user only.
|
||
stream_errors: list[str] = []
|
||
b = str(self.base_url or "").strip().lower()
|
||
force_disable = str(os.getenv("AIA_LLM_THINKING_FORCE_DISABLED") or "").strip().lower() in ("1", "true", "yes", "on")
|
||
force_enable = str(os.getenv("AIA_LLM_THINKING_FORCE_ENABLED") or "").strip().lower() in ("1", "true", "yes", "on")
|
||
mode_enabled = bool(getattr(self, "thinking_mode_enabled", False))
|
||
extra_body: dict[str, Any] = {}
|
||
if b and ("api.openai.com" not in b):
|
||
if force_disable:
|
||
extra_body["thinking"] = {"type": "disabled"}
|
||
elif force_enable or mode_enabled:
|
||
extra_body["thinking"] = {"type": "enabled"}
|
||
else:
|
||
# Default: disable thinking for non-official gateways unless explicitly enabled.
|
||
if str(os.getenv("AIA_LLM_THINKING_DISABLED") or "1").strip().lower() in ("1", "true", "yes", "on"):
|
||
extra_body["thinking"] = {"type": "disabled"}
|
||
thinking = {"extra_body": extra_body} if extra_body else {}
|
||
reasoning_effort = str(getattr(self, "reasoning_effort", "") or "").strip().lower()
|
||
if reasoning_effort not in ("low", "medium", "high"):
|
||
reasoning_effort = ""
|
||
stream_variants: list[dict[str, Any]] = [
|
||
{
|
||
**thinking,
|
||
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
|
||
"model": self.model,
|
||
"input": {"messages": norm},
|
||
"tools": tools or None,
|
||
"stream": True,
|
||
},
|
||
{
|
||
**thinking,
|
||
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
|
||
"model": self.model,
|
||
"input": norm,
|
||
"tools": tools or None,
|
||
"stream": True,
|
||
},
|
||
]
|
||
try:
|
||
for payload in stream_variants:
|
||
try:
|
||
stream = self._client.responses.create(**payload)
|
||
text, tool_calls, final_resp = parse_openai_responses_stream_events(stream, on_token=on_token)
|
||
if (not text.strip()) and final_resp:
|
||
ot = final_resp.get("output_text")
|
||
if isinstance(ot, str) and ot.strip():
|
||
text = ot
|
||
if on_token:
|
||
on_token(text)
|
||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||
except Exception as exc:
|
||
emsg = str(exc)
|
||
# Fallback: provider thinking-mode replay contract.
|
||
if "reasoning_content" in emsg and "thinking mode" in emsg and "must be passed back" in emsg:
|
||
try:
|
||
forced = dict(payload)
|
||
eb = forced.get("extra_body") if isinstance(forced.get("extra_body"), dict) else {}
|
||
eb = dict(eb)
|
||
eb["thinking"] = {"type": "disabled"}
|
||
forced["extra_body"] = eb
|
||
forced.pop("reasoning_effort", None)
|
||
stream = self._client.responses.create(**forced)
|
||
text, tool_calls, final_resp = parse_openai_responses_stream_events(stream, on_token=on_token)
|
||
if (not text.strip()) and final_resp:
|
||
ot = final_resp.get("output_text")
|
||
if isinstance(ot, str) and ot.strip():
|
||
text = ot
|
||
if on_token:
|
||
on_token(text)
|
||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||
except Exception:
|
||
pass
|
||
stream_errors.append(emsg)
|
||
continue
|
||
raise RuntimeError("; ".join(stream_errors) or "responses_stream_all_variants_failed")
|
||
except Exception as exc:
|
||
logger.info("responses stream failed; fallback to non-stream (%s)", exc)
|
||
nonstream_errors: list[str] = []
|
||
for payload in (
|
||
{
|
||
**thinking,
|
||
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
|
||
"model": self.model,
|
||
"input": {"messages": norm},
|
||
"tools": tools or None,
|
||
},
|
||
{
|
||
**thinking,
|
||
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
|
||
"model": self.model,
|
||
"input": norm,
|
||
"tools": tools or None,
|
||
},
|
||
):
|
||
try:
|
||
resp = self._client.responses.create(**payload)
|
||
d = _as_dict(resp) or {}
|
||
text = str(d.get("output_text") or "")
|
||
tool_calls = _collect_tool_calls_from_response_dict(d)
|
||
if on_token and text:
|
||
on_token(text)
|
||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||
except Exception as e2:
|
||
emsg2 = str(e2)
|
||
if "reasoning_content" in emsg2 and "thinking mode" in emsg2 and "must be passed back" in emsg2:
|
||
try:
|
||
forced = dict(payload)
|
||
eb = forced.get("extra_body") if isinstance(forced.get("extra_body"), dict) else {}
|
||
eb = dict(eb)
|
||
eb["thinking"] = {"type": "disabled"}
|
||
forced["extra_body"] = eb
|
||
forced.pop("reasoning_effort", None)
|
||
resp = self._client.responses.create(**forced)
|
||
d = _as_dict(resp) or {}
|
||
text = str(d.get("output_text") or "")
|
||
tool_calls = _collect_tool_calls_from_response_dict(d)
|
||
if on_token and text:
|
||
on_token(text)
|
||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||
except Exception:
|
||
pass
|
||
nonstream_errors.append(emsg2)
|
||
continue
|
||
raise RuntimeError(
|
||
"openai_responses_request_failed: "
|
||
+ " | ".join([str(exc)] + nonstream_errors[-2:])
|
||
) from exc
|
||
|
||
|
||
__all__ = ["OpenAIResponsesModel", "parse_openai_responses_stream_events"]
|
||
|