mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 05:30:48 +08:00
- 在 oclaw/ 下重新初始化 Git 仓库 - 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等) - 提交当前工程代码与配置 Made-with: Cursor
236 lines
10 KiB
Python
236 lines
10 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):
|
||
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"]
|
||
|