mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-17 06:11:01 +08:00
feat(chat): image specialist legacy lane, Responses fixes, ACL + UI attachments
- Image expert: DashScope-style /chat/completions via image_legacy_client; early exit in direct_loop when skill_binding_role is image; shared placeholder helper; docs/IMAGE_SPECIALIST_LANE.md. - Strict attachment ACL: link_attachment_acl on assistant chat_message rows (sqlite_store); chat attachment rate limit when user_id empty; admin chat tests updated. - Admin chat UI: aggregate bubbles render assistant_text attachments (image_ref); WS expand path. - turn_runner: persisted_chat_attachments_nonempty for final_msg selection. - OpenAI Responses transport + agent_messages/agent_core_attempt adjustments; env docs and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d1bcc4debe
commit
faeb067856
21 changed files with 2516 additions and 331 deletions
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
from collections.abc import Callable, Iterable
|
||||
|
|
@ -12,6 +13,140 @@ from oclaw.platform.llm.transports.base import ChatModel, LLMResponse, LLMToolCa
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _redact_nested_json_preview(obj: Any, *, max_chars: int = 4000) -> str:
|
||||
"""Best-effort JSON preview for logs (truncate; redact mega data URLs)."""
|
||||
|
||||
try:
|
||||
s = json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
s = str(obj)
|
||||
|
||||
def _clip_data_urls(t: str) -> str:
|
||||
out_parts: list[str] = []
|
||||
i = 0
|
||||
needle = "data:"
|
||||
while i < len(t):
|
||||
j = t.find(needle, i)
|
||||
if j < 0:
|
||||
out_parts.append(t[i:])
|
||||
break
|
||||
out_parts.append(t[i:j])
|
||||
k = j + len(needle)
|
||||
while k < len(t) and t[k] not in "?;, \n\r\t\"]":
|
||||
k += 1
|
||||
if k < len(t) and t[k:k + 8] == ";base64,":
|
||||
end = k + 8
|
||||
while end < len(t) and t[end] not in "\"}] \n\r\t":
|
||||
end += 1
|
||||
seg = end - (k + 8)
|
||||
out_parts.append(f"data:<redacted base64 ~{seg} chars>")
|
||||
i = end
|
||||
continue
|
||||
out_parts.append(t[j:k])
|
||||
i = k
|
||||
return "".join(out_parts)
|
||||
|
||||
s = _clip_data_urls(s)
|
||||
if len(s) > max_chars:
|
||||
return s[: max_chars - 40] + "\n...<<truncated>>\n..."
|
||||
return s
|
||||
|
||||
|
||||
def _deep_redact_for_debug(obj: Any) -> Any:
|
||||
"""Recursive copy for logs: shorten giant ``data:...;base64,...`` and very long strings."""
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _deep_redact_for_debug(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_deep_redact_for_debug(x) for x in obj]
|
||||
if isinstance(obj, str):
|
||||
s = obj
|
||||
if ";base64," in s and s.strip().startswith("data:") and len(s) > 160:
|
||||
head, _, _tail = s.partition(";base64,")
|
||||
return f"{head};base64,<redacted ~{len(s) - len(head) - 8} b64 chars>"
|
||||
if len(s) > 16_000:
|
||||
return s[:12_000] + f"...<truncated {len(s) - 12_000} chars>"
|
||||
return s
|
||||
return obj
|
||||
|
||||
|
||||
def _safe_int(raw: str | None, default: int, *, max_value: int = 2_000_000) -> int:
|
||||
try:
|
||||
value = int(str(raw or "").strip())
|
||||
except Exception:
|
||||
return default
|
||||
if value < 1:
|
||||
return default
|
||||
return min(value, max_value)
|
||||
|
||||
|
||||
def _log_openai_responses_wire_kwargs(payload: dict[str, Any], *, variant: str, phase: str) -> None:
|
||||
"""Temporary diagnostics: log kwargs passed into ``OpenAI.responses.create`` (after our assembly).
|
||||
|
||||
Enable: ``AIA_RESPONSES_DEBUG_PRINT_PAYLOAD=1`` — emits ``logging.warning`` **and** the same JSON on **stderr**
|
||||
(gateway控制台默认可见,不依赖 logging level)。
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenAI SDK may still apply minor JSON transforms on send; this matches **our** arguments.
|
||||
- Base64 / long ``data:`` URLs are redacted to keep logs readable.
|
||||
"""
|
||||
raw = str(os.getenv("AIA_RESPONSES_DEBUG_PRINT_PAYLOAD") or "").strip().lower()
|
||||
if raw not in ("1", "true", "yes", "on"):
|
||||
return
|
||||
try:
|
||||
dbg = _deep_redact_for_debug(dict(payload))
|
||||
txt = json.dumps(dbg, ensure_ascii=False, indent=2, default=str)
|
||||
cap = _safe_int(os.getenv("AIA_RESPONSES_DEBUG_PRINT_MAX_CHARS"), 120_000, max_value=500_000)
|
||||
if len(txt) > cap:
|
||||
txt = txt[: max(cap - 80, 0)] + "\n...<<truncated>>\n"
|
||||
logger.warning(
|
||||
"openai_responses DEBUG wire kwargs [%s] variant=%s (%d chars):\n%s",
|
||||
phase,
|
||||
variant,
|
||||
len(txt),
|
||||
txt,
|
||||
)
|
||||
try:
|
||||
sys.stderr.write(
|
||||
f"\n[oclaw openai_responses DEBUG] phase={phase} variant={variant} chars={len(txt)}\n{txt}\n\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("openai_responses DEBUG payload serialization failed: %s", exc)
|
||||
|
||||
|
||||
def _openai_sdk_diagnostic_text(exc: BaseException) -> str:
|
||||
"""``str(APIStatusError)`` is often only ``Error code: 400``; validation detail lives in ``body['message']``."""
|
||||
chunks: list[str] = [str(exc)]
|
||||
msg = getattr(exc, "message", None)
|
||||
if isinstance(msg, str) and msg.strip() and msg not in chunks:
|
||||
chunks.append(msg)
|
||||
bod = getattr(exc, "body", None)
|
||||
if isinstance(bod, dict):
|
||||
bm = bod.get("message")
|
||||
if isinstance(bm, str) and bm.strip():
|
||||
chunks.append(bm)
|
||||
err = bod.get("error")
|
||||
if isinstance(err, dict):
|
||||
em = err.get("message")
|
||||
if isinstance(em, str) and em.strip():
|
||||
chunks.append(em)
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
def _is_input_messages_validation_error(exc: BaseException) -> bool:
|
||||
"""Third-party gateways often return 400/422 with Pydantic paths like ``input.messages.0.role``."""
|
||||
m = _openai_sdk_diagnostic_text(exc).lower()
|
||||
if "input.messages" in m:
|
||||
return True
|
||||
if "input should be 'user'" in m and "content" in m:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _as_dict(obj: Any) -> dict[str, Any] | None:
|
||||
if obj is None:
|
||||
return None
|
||||
|
|
@ -131,14 +266,56 @@ class OpenAIResponsesModel(ChatModel):
|
|||
self._client = OpenAI(**kw)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _strip_leading_system_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Separate leading ``role=system`` rows for ``instructions=` (Responses API expects user-shaped ``input.messages``)."""
|
||||
msgs = messages or []
|
||||
sys_chunks: list[str] = []
|
||||
i = 0
|
||||
while i < len(msgs):
|
||||
m = msgs[i]
|
||||
if not isinstance(m, dict):
|
||||
break
|
||||
if str(m.get("role") or "").strip().lower() != "system":
|
||||
break
|
||||
c = m.get("content")
|
||||
if isinstance(c, str) and c.strip():
|
||||
sys_chunks.append(c.strip())
|
||||
elif isinstance(c, list):
|
||||
texts: list[str] = []
|
||||
for it in c:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
tt = str(it.get("type") or "").strip().lower()
|
||||
if tt in ("text", "input_text"):
|
||||
xs = str(it.get("text") or "").strip()
|
||||
if xs:
|
||||
texts.append(xs)
|
||||
elif isinstance(it.get("text"), str) and str(it.get("text")).strip():
|
||||
texts.append(str(it.get("text")).strip())
|
||||
if texts:
|
||||
sys_chunks.append("\n".join(texts))
|
||||
i += 1
|
||||
joined = "\n\n".join(sys_chunks).strip()
|
||||
return joined, list(msgs[i:])
|
||||
|
||||
@staticmethod
|
||||
def _normalize_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
envelope_openai_message: bool = True,
|
||||
content_chat_completions_parts: bool = False,
|
||||
image_detail_auto: bool = True,
|
||||
) -> 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.
|
||||
Build Response input rows used under ``input`` (flat array) or ``input.messages``.
|
||||
|
||||
- **Responses content** (default when ``content_chat_completions_parts=False``): each part is
|
||||
``{"type":"input_text",...}`` / ``{"type":"input_image","image_url":"<str>","detail":"auto"}``
|
||||
(`OpenAI response_input_*` TypedDict surface).
|
||||
- **Chat completions content** (``content_chat_completions_parts=True``): ``text`` /
|
||||
``image_url`` multimodal blocks.
|
||||
- **Envelope** ``envelope_openai_message=True``: ``{"type":"message","role","content":[...]}``;
|
||||
``False``: ``{"role","content":[...]}`` (per some Bailian curls).
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in messages or []:
|
||||
|
|
@ -147,6 +324,22 @@ class OpenAIResponsesModel(ChatModel):
|
|||
role = str(m.get("role") or "user").strip().lower() or "user"
|
||||
content = m.get("content")
|
||||
norm_content: list[dict[str, Any]] = []
|
||||
|
||||
def _append_image_part(url_value: str) -> None:
|
||||
if content_chat_completions_parts:
|
||||
norm_content.append({"type": "image_url", "image_url": {"url": url_value}})
|
||||
else:
|
||||
p: dict[str, Any] = {"type": "input_image", "image_url": url_value}
|
||||
if image_detail_auto:
|
||||
p["detail"] = "auto"
|
||||
norm_content.append(p)
|
||||
|
||||
def _append_text_part(txt: str) -> None:
|
||||
if content_chat_completions_parts:
|
||||
norm_content.append({"type": "text", "text": txt})
|
||||
else:
|
||||
norm_content.append({"type": "input_text", "text": txt})
|
||||
|
||||
if isinstance(content, list):
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "input_image":
|
||||
|
|
@ -154,39 +347,118 @@ class OpenAIResponsesModel(ChatModel):
|
|||
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}"}})
|
||||
_append_image_part(f"data:{mime};base64,{b64}")
|
||||
continue
|
||||
if isinstance(item, dict) and str(item.get("type") or "").strip().lower() == "image_url":
|
||||
iu = item.get("image_url")
|
||||
url = ""
|
||||
if isinstance(iu, dict):
|
||||
url = str(iu.get("url") or "").strip()
|
||||
elif isinstance(iu, str):
|
||||
url = iu.strip()
|
||||
if url.startswith(("http://", "https://", "data:")):
|
||||
_append_image_part(url)
|
||||
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})
|
||||
_append_text_part(t)
|
||||
continue
|
||||
if isinstance(item, dict):
|
||||
# Best-effort coerce unknown blocks into valid text blocks.
|
||||
itype = str(item.get("type") or "").strip().lower()
|
||||
img_key = item.get("image")
|
||||
if isinstance(img_key, str) and img_key.strip() and itype != "input_image":
|
||||
uu = img_key.strip()
|
||||
if uu.startswith(("http://", "https://", "data:")):
|
||||
_append_image_part(uu)
|
||||
continue
|
||||
if isinstance(item.get("text"), str) and str(item.get("text") or "").strip():
|
||||
_append_text_part(str(item.get("text") or "").strip())
|
||||
continue
|
||||
s = str(item.get("text") or "").strip()
|
||||
if s:
|
||||
norm_content.append({"type": "text", "text": s})
|
||||
_append_text_part(s)
|
||||
continue
|
||||
s = str(item or "").strip()
|
||||
if s:
|
||||
norm_content.append({"type": "text", "text": s})
|
||||
_append_text_part(s)
|
||||
elif isinstance(content, str):
|
||||
txt = content.strip()
|
||||
if txt:
|
||||
norm_content.append({"type": "text", "text": txt})
|
||||
_append_text_part(txt)
|
||||
elif content is not None:
|
||||
s = str(content).strip()
|
||||
if s:
|
||||
norm_content.append({"type": "text", "text": s})
|
||||
_append_text_part(s)
|
||||
|
||||
if not norm_content:
|
||||
# Keep shape valid even for empty messages.
|
||||
continue
|
||||
|
||||
# Enforce role=user and preserve other roles via prefix tag.
|
||||
coerced_role = "user"
|
||||
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})
|
||||
norm_content.insert(
|
||||
0,
|
||||
OpenAIResponsesModel._role_prefix_part(prefix, content_chat_completions_parts),
|
||||
)
|
||||
|
||||
if envelope_openai_message:
|
||||
out.append({"type": "message", "role": coerced_role, "content": norm_content})
|
||||
else:
|
||||
out.append({"role": coerced_role, "content": norm_content})
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _role_prefix_part(prefix: str, chat_parts: bool) -> dict[str, Any]:
|
||||
t = f"[{prefix}]"
|
||||
if chat_parts:
|
||||
return {"type": "text", "text": t}
|
||||
return {"type": "input_text", "text": t}
|
||||
|
||||
@staticmethod
|
||||
def _responses_input_candidates(
|
||||
msgs: list[dict[str, Any]],
|
||||
*,
|
||||
flat_responses: bool,
|
||||
prefer_envelope: bool,
|
||||
prefer_chat_parts: bool,
|
||||
) -> list[tuple[str, Any]]:
|
||||
"""Several gateways validate ``input.messages`` differently; try a small deterministic set."""
|
||||
|
||||
def N(env: bool, chat: bool) -> list[dict[str, Any]]:
|
||||
return OpenAIResponsesModel._normalize_messages(
|
||||
msgs,
|
||||
envelope_openai_message=env,
|
||||
content_chat_completions_parts=chat,
|
||||
)
|
||||
|
||||
keys_seen: set[str] = set()
|
||||
out: list[tuple[str, Any]] = []
|
||||
|
||||
def push(tag: str, inp: Any) -> None:
|
||||
try:
|
||||
sk = json.dumps(inp, ensure_ascii=False, sort_keys=True, default=str)
|
||||
except Exception:
|
||||
sk = repr(inp)
|
||||
if sk in keys_seen:
|
||||
return
|
||||
keys_seen.add(sk)
|
||||
out.append((tag, inp))
|
||||
|
||||
if flat_responses:
|
||||
for env in (prefer_envelope, not prefer_envelope):
|
||||
push(f"flat_envelope_{env}", N(env, False))
|
||||
return out
|
||||
|
||||
# Prefer profile/env defaults first (``AIA_RESPONSES_NESTED_CHAT_PARTS`` / envelope toggles), then a fixed
|
||||
# fallback ring so picky gateways still get Chat-shaped ``input.messages`` without dropping multimodal pixels.
|
||||
primary_combo = (prefer_envelope, prefer_chat_parts)
|
||||
fallback_ring = [(False, True), (False, False), (True, False), (True, True)]
|
||||
combos = [primary_combo] + [p for p in fallback_ring if p != primary_combo]
|
||||
for env, chat in combos:
|
||||
norm = N(env, chat)
|
||||
push(f"e{int(env)}c{int(chat)}_messages", {"messages": norm})
|
||||
push(f"e{int(env)}c{int(chat)}_flat_input", norm)
|
||||
return out
|
||||
|
||||
def chat(
|
||||
|
|
@ -196,8 +468,96 @@ class OpenAIResponsesModel(ChatModel):
|
|||
*,
|
||||
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.
|
||||
sys_instructions, rest = OpenAIResponsesModel._strip_leading_system_messages(messages)
|
||||
def _env_truthy(name: str) -> bool:
|
||||
return str(os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
if _env_truthy("AIA_RESPONSES_DEBUG_PRINT_PAYLOAD"):
|
||||
try:
|
||||
sys.stderr.write(
|
||||
"[oclaw openai_responses] chat() ENTERED "
|
||||
f"model={self.model!r} base_url={self.base_url!r} "
|
||||
f"messages={len(messages)} tools={len(tools or [])}\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default: nested ``input.messages`` rows use **Responses** parts (``input_text`` / ``input_image``).
|
||||
# Flat Bailian ``input`` array: ``AIA_RESPONSES_INPUT_IS_FLAT_LIST=1`` (optional ``AIA_RESPONSES_FLAT_OPENAI_ENVELOPE``).
|
||||
flat_raw = os.getenv("AIA_RESPONSES_INPUT_IS_FLAT_LIST")
|
||||
if flat_raw is not None and str(flat_raw).strip():
|
||||
flat_responses = _env_truthy("AIA_RESPONSES_INPUT_IS_FLAT_LIST")
|
||||
else:
|
||||
legacy = os.getenv("AIA_RESPONSES_INPUT_USE_MESSAGES_ARRAY")
|
||||
if legacy is None or not str(legacy).strip():
|
||||
flat_responses = False
|
||||
else:
|
||||
flat_responses = not _env_truthy("AIA_RESPONSES_INPUT_USE_MESSAGES_ARRAY")
|
||||
|
||||
# Nested ``input.messages`` defaults to **Responses** content parts (``input_*``) + ``type:message``,
|
||||
# matching OpenAI ``EasyInputMessageParam`` / Model Studio expanded examples. Some proxies wrongly
|
||||
# expect Chat ``text``/``image_url`` parts → ``AIA_RESPONSES_NESTED_CHAT_PARTS=1``.
|
||||
if flat_responses:
|
||||
envelope_openai_message = _env_truthy("AIA_RESPONSES_FLAT_OPENAI_ENVELOPE")
|
||||
nested_chat_parts = False
|
||||
else:
|
||||
envelope_openai_message = True
|
||||
nested_chat_parts = _env_truthy("AIA_RESPONSES_NESTED_CHAT_PARTS")
|
||||
|
||||
def _normalize_batch(msgs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return OpenAIResponsesModel._normalize_messages(
|
||||
msgs,
|
||||
envelope_openai_message=envelope_openai_message,
|
||||
content_chat_completions_parts=nested_chat_parts,
|
||||
)
|
||||
|
||||
msgs_for_norm: list[dict[str, Any]]
|
||||
if rest:
|
||||
msgs_for_norm = rest
|
||||
norm = _normalize_batch(rest)
|
||||
inst_kw: dict[str, Any] = {}
|
||||
if sys_instructions.strip():
|
||||
si = sys_instructions.strip()
|
||||
if len(si) > 80_000:
|
||||
si = si[:80_000] + "\n...[truncated]"
|
||||
inst_kw = {"instructions": si}
|
||||
if not norm:
|
||||
msgs_for_norm = messages
|
||||
norm = _normalize_batch(messages)
|
||||
inst_kw = {}
|
||||
else:
|
||||
msgs_for_norm = messages
|
||||
norm = _normalize_batch(messages)
|
||||
inst_kw = {}
|
||||
|
||||
explicit_variants = os.getenv("AIA_RESPONSES_INPUT_VARIANTS")
|
||||
bu_norm = (self.base_url or "").strip().lower()
|
||||
# CRITICAL: do **not** treat empty ``base_url`` as "official" here. Clients often omit storing the
|
||||
# default URL in profiles while still hitting third-party gateways via env/SDK defaults—but then we
|
||||
# would wrongly disable alternate ``input`` shapes and only ever send ``primary``.
|
||||
host_is_explicit_official_api = bool(bu_norm) and ("api.openai.com" in bu_norm)
|
||||
if explicit_variants is not None and str(explicit_variants).strip():
|
||||
multi_shape = _env_truthy("AIA_RESPONSES_INPUT_VARIANTS")
|
||||
else:
|
||||
multi_shape = not host_is_explicit_official_api
|
||||
|
||||
if flat_responses:
|
||||
primary_input: Any = norm
|
||||
else:
|
||||
primary_input = {"messages": norm}
|
||||
|
||||
input_candidates: list[tuple[str, Any]]
|
||||
if multi_shape and not _env_truthy("AIA_RESPONSES_DISABLE_INPUT_VARIANTS"):
|
||||
input_candidates = OpenAIResponsesModel._responses_input_candidates(
|
||||
msgs_for_norm,
|
||||
flat_responses=flat_responses,
|
||||
prefer_envelope=envelope_openai_message,
|
||||
prefer_chat_parts=nested_chat_parts,
|
||||
)
|
||||
else:
|
||||
input_candidates = [("primary", primary_input)]
|
||||
|
||||
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")
|
||||
|
|
@ -217,26 +577,33 @@ class OpenAIResponsesModel(ChatModel):
|
|||
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,
|
||||
},
|
||||
]
|
||||
base_stream_template: dict[str, Any] = {
|
||||
**thinking,
|
||||
**inst_kw,
|
||||
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
|
||||
"model": self.model,
|
||||
"tools": tools or None,
|
||||
"stream": True,
|
||||
}
|
||||
try:
|
||||
for payload in stream_variants:
|
||||
for cand_label, responses_input in input_candidates:
|
||||
stream_payload = {**base_stream_template, "input": responses_input}
|
||||
if _env_truthy("AIA_RESPONSES_LOG_PAYLOAD_SUMMARY"):
|
||||
logger.warning(
|
||||
"openai_responses [%s] payload summary (redacted): %s",
|
||||
cand_label,
|
||||
_redact_nested_json_preview(
|
||||
{
|
||||
"model": self.model,
|
||||
"input": responses_input,
|
||||
"has_instructions": bool(inst_kw.get("instructions")),
|
||||
"tools_n": len(tools or []),
|
||||
"stream": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
payload = stream_payload
|
||||
_log_openai_responses_wire_kwargs(payload, variant=str(cand_label), phase="stream")
|
||||
try:
|
||||
stream = self._client.responses.create(**payload)
|
||||
text, tool_calls, final_resp = parse_openai_responses_stream_events(stream, on_token=on_token)
|
||||
|
|
@ -246,6 +613,8 @@ class OpenAIResponsesModel(ChatModel):
|
|||
text = ot
|
||||
if on_token:
|
||||
on_token(text)
|
||||
if cand_label != input_candidates[0][0]:
|
||||
logger.info("openai_responses: succeeded with input variant %s", cand_label)
|
||||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||||
except Exception as exc:
|
||||
emsg = str(exc)
|
||||
|
|
@ -258,6 +627,11 @@ class OpenAIResponsesModel(ChatModel):
|
|||
eb["thinking"] = {"type": "disabled"}
|
||||
forced["extra_body"] = eb
|
||||
forced.pop("reasoning_effort", None)
|
||||
_log_openai_responses_wire_kwargs(
|
||||
forced,
|
||||
variant=f"{cand_label}_thinking_disabled",
|
||||
phase="stream_retry_thinking",
|
||||
)
|
||||
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:
|
||||
|
|
@ -266,31 +640,36 @@ class OpenAIResponsesModel(ChatModel):
|
|||
text = ot
|
||||
if on_token:
|
||||
on_token(text)
|
||||
if cand_label != input_candidates[0][0]:
|
||||
logger.info("openai_responses: succeeded with input variant %s", cand_label)
|
||||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||||
except Exception:
|
||||
pass
|
||||
if _env_truthy("AIA_RESPONSES_LOG_API_ERROR_DETAIL"):
|
||||
eb = getattr(exc, "body", None)
|
||||
if eb is not None:
|
||||
logger.warning(
|
||||
"responses.create stream [%s] body: %s", cand_label, eb
|
||||
)
|
||||
if len(input_candidates) > 1 and _is_input_messages_validation_error(exc):
|
||||
stream_errors.append(f"{cand_label}:{emsg}")
|
||||
continue
|
||||
stream_errors.append(emsg)
|
||||
continue
|
||||
break
|
||||
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,
|
||||
},
|
||||
):
|
||||
base_nonstream = {
|
||||
**thinking,
|
||||
**inst_kw,
|
||||
**({"reasoning_effort": reasoning_effort} if reasoning_effort else {}),
|
||||
"model": self.model,
|
||||
"tools": tools or None,
|
||||
}
|
||||
for cand_label, responses_input in input_candidates:
|
||||
payload = {**base_nonstream, "input": responses_input}
|
||||
_log_openai_responses_wire_kwargs(payload, variant=str(cand_label), phase="non_stream")
|
||||
try:
|
||||
resp = self._client.responses.create(**payload)
|
||||
d = _as_dict(resp) or {}
|
||||
|
|
@ -298,6 +677,8 @@ class OpenAIResponsesModel(ChatModel):
|
|||
tool_calls = _collect_tool_calls_from_response_dict(d)
|
||||
if on_token and text:
|
||||
on_token(text)
|
||||
if cand_label != input_candidates[0][0]:
|
||||
logger.info("openai_responses non-stream: succeeded with input variant %s", cand_label)
|
||||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||||
except Exception as e2:
|
||||
emsg2 = str(e2)
|
||||
|
|
@ -309,17 +690,36 @@ class OpenAIResponsesModel(ChatModel):
|
|||
eb["thinking"] = {"type": "disabled"}
|
||||
forced["extra_body"] = eb
|
||||
forced.pop("reasoning_effort", None)
|
||||
_log_openai_responses_wire_kwargs(
|
||||
forced,
|
||||
variant=f"{cand_label}_thinking_disabled",
|
||||
phase="non_stream_retry_thinking",
|
||||
)
|
||||
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)
|
||||
if cand_label != input_candidates[0][0]:
|
||||
logger.info(
|
||||
"openai_responses non-stream: succeeded with input variant %s",
|
||||
cand_label,
|
||||
)
|
||||
return LLMResponse(content=text, tool_calls=tool_calls)
|
||||
except Exception:
|
||||
pass
|
||||
if _env_truthy("AIA_RESPONSES_LOG_API_ERROR_DETAIL"):
|
||||
eb = getattr(e2, "body", None)
|
||||
if eb is not None:
|
||||
logger.warning(
|
||||
"responses.create non-stream [%s] body: %s", cand_label, eb
|
||||
)
|
||||
if len(input_candidates) > 1 and _is_input_messages_validation_error(e2):
|
||||
nonstream_errors.append(f"{cand_label}:{emsg2}")
|
||||
continue
|
||||
nonstream_errors.append(emsg2)
|
||||
continue
|
||||
break
|
||||
raise RuntimeError(
|
||||
"openai_responses_request_failed: "
|
||||
+ " | ".join([str(exc)] + nonstream_errors[-2:])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue