oclaw/platform/llm/transports/openai_responses.py
oliver ba3836f00f 初始化:独立 oclaw 仓库首提交
- 在 oclaw/ 下重新初始化 Git 仓库
- 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等)
- 提交当前工程代码与配置

Made-with: Cursor
2026-04-24 22:31:22 +08:00

236 lines
10 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 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):
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
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] = []
stream_variants: list[dict[str, Any]] = [
{"model": self.model, "input": {"messages": norm}, "tools": tools or None, "stream": True},
{"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:
stream_errors.append(str(exc))
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 (
{"model": self.model, "input": {"messages": norm}, "tools": tools or None},
{"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:
nonstream_errors.append(str(e2))
continue
raise RuntimeError(
"openai_responses_request_failed: "
+ " | ".join([str(exc)] + nonstream_errors[-2:])
) from exc
__all__ = ["OpenAIResponsesModel", "parse_openai_responses_stream_events"]