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:
oliver 2026-05-10 07:06:05 +08:00
parent d1bcc4debe
commit faeb067856
21 changed files with 2516 additions and 331 deletions

View file

@ -4,7 +4,9 @@ from __future__ import annotations
import base64
import io
import json
import os
import re
import time
from typing import Any
@ -18,6 +20,32 @@ def join_url(base: str, path: str) -> str:
return f"{b}/{p}"
def dashscope_native_multimodal_url_from_compatible_base(base_url: str) -> str | None:
"""
``qwen-image`` and similar models often return an **empty** OpenAI ``chat/completions`` message on
``/compatible-mode/v1``. Native HTTP uses ``/api/v1/services/aigc/multimodal-generation/generation``
and returns ``output.choices[].message.content[].image`` URLs.
Override: ``AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_URL`` (full URL).
Path suffix: ``AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_PATH`` (default multimodal-generation path).
"""
explicit = (os.getenv("AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_URL") or "").strip().rstrip("/")
if explicit:
return explicit
b = (base_url or "").strip().rstrip("/")
if not b or "compatible-mode" not in b.lower():
return None
root = re.sub(r"/compatible-mode/v\d+(?:/.*)?$", "", b, flags=re.IGNORECASE).rstrip("/")
if not root or root == b:
root = re.sub(r"/compatible-mode/?$", "", b, flags=re.IGNORECASE).rstrip("/")
if not root:
return None
path = (os.getenv("AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_PATH") or "").strip().lstrip("/")
if not path:
path = "api/v1/services/aigc/multimodal-generation/generation"
return f"{root}/{path}"
def env_ocr_lane_api_key() -> str:
return (os.getenv("AIA_OCR_API_KEY") or "").strip()
@ -35,6 +63,25 @@ def env_ocr_lane_chat_endpoint() -> str:
return raw or "/chat/completions"
def env_image_expert_api_key() -> str:
"""Bearer key for **image specialist** multimodal/gen HTTP only (never shared with OCR ``AIA_OCR_*``)."""
return (os.getenv("AIA_IMAGE_EXPERT_API_KEY") or "").strip()
def env_image_expert_base_url() -> str:
return (os.getenv("AIA_IMAGE_EXPERT_BASE_URL") or "").strip()
def env_image_expert_model() -> str:
return (os.getenv("AIA_IMAGE_EXPERT_MODEL") or "").strip()
def env_image_expert_chat_endpoint() -> str:
raw = (os.getenv("AIA_IMAGE_EXPERT_CHAT_ENDPOINT") or "").strip()
return raw or "/chat/completions"
def is_data_url(s: str) -> bool:
return s.startswith("data:") and ";base64," in s
@ -97,51 +144,406 @@ def post_with_retry(
raise last_exc
def env_image_expert_download_timeout_sec() -> float:
"""Read timeout for DashScope/OSS signed result URLs (official samples use ~300s)."""
raw = (os.getenv("AIA_IMAGE_EXPERT_DOWNLOAD_TIMEOUT_SEC") or "").strip()
if not raw:
return 300.0
try:
v = float(raw)
except ValueError:
return 300.0
return max(15.0, min(v, 900.0))
def download_http_url_bytes(
url: str,
*,
timeout_sec: float | None = None,
user_agent: str | None = None,
) -> tuple[bytes, str]:
"""Streamed GET with ``raise_for_status`` — matches DashScope OSS download guidance (long reads)."""
t = float(timeout_sec) if timeout_sec is not None else env_image_expert_download_timeout_sec()
ua = (user_agent or "").strip() or (
"Mozilla/5.0 (compatible; oclaw-image-expert/1.0; +https://github.com/)"
)
req_headers = {"User-Agent": ua, "Accept": "*/*"}
connect_cap = min(45.0, max(10.0, t / 10.0))
timeout = httpx.Timeout(t, connect=connect_cap)
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
with client.stream("GET", url, headers=req_headers) as r:
r.raise_for_status()
ctype = str(r.headers.get("content-type") or "").split(";", 1)[0].strip()
parts: list[bytes] = []
for chunk in r.iter_bytes(chunk_size=65_536):
parts.append(chunk)
return b"".join(parts), ctype
def dashscope_multimodal_http_ok(body: dict[str, Any]) -> tuple[bool, str]:
"""DashScope HTTP often uses HTTP 200 with a business-level ``code`` for failures."""
sc = body.get("status_code")
if isinstance(sc, str) and sc.strip().isdigit():
sc = int(sc.strip())
if isinstance(sc, int) and sc >= 400:
return False, str(body.get("message") or body.get("msg") or f"status_code={sc}")
code = body.get("code")
if code is None:
return True, ""
if isinstance(code, int):
if code == 200:
return True, ""
msg = str(body.get("message") or body.get("msg") or "").strip()
return False, msg or str(code)
cs = str(code).strip()
if cs == "":
return True, ""
if cs.lower() in ("success", "ok", "200"):
return True, ""
msg = str(body.get("message") or body.get("msg") or "").strip()
return False, msg or cs
def _extract_response_roots(resp_json: dict[str, Any]) -> list[dict[str, Any]]:
"""Collect dict slices that may carry ``choices`` / ``messages`` (DashScope nests under ``output``)."""
roots: list[dict[str, Any]] = []
seen: set[int] = set()
def push(d: dict[str, Any]) -> None:
i = id(d)
if i in seen:
return
seen.add(i)
roots.append(d)
push(resp_json)
nested = resp_json.get("output")
if isinstance(nested, dict):
push(nested)
deeper = nested.get("output")
if isinstance(deeper, dict):
push(deeper)
result = resp_json.get("result")
if isinstance(result, dict):
push(result)
data_obj = resp_json.get("data")
if isinstance(data_obj, dict):
push(data_obj)
return roots
def _append_image_from_any(images: list[str], raw: Any) -> None:
if isinstance(raw, str) and raw.strip():
s = raw.strip()
if s.startswith(("http://", "https://", "data:")):
images.append(s)
return
if isinstance(raw, dict):
u = raw.get("url")
if isinstance(u, str) and u.strip() and u.strip().startswith(("http://", "https://")):
images.append(u.strip())
def _parse_content_part(it: Any, *, text_parts: list[str], images: list[str]) -> None:
"""Parse one multimodal element (DashScope shorthand, OpenAI-style typed blocks, etc.)."""
if isinstance(it, str) and it.strip():
text_parts.append(it.strip())
return
if not isinstance(it, dict):
return
typ = str(it.get("type") or "").strip().lower()
if typ == "text":
tx = it.get("text")
if isinstance(tx, str) and tx.strip():
text_parts.append(tx.strip())
return
if typ == "input_text":
tx = it.get("text")
if isinstance(tx, str) and tx.strip():
text_parts.append(tx.strip())
return
if typ == "image_url":
iu = it.get("image_url")
if isinstance(iu, dict):
_append_image_from_any(images, iu)
elif isinstance(iu, str):
_append_image_from_any(images, iu)
return
if typ == "input_image":
iu = it.get("image_url")
if isinstance(iu, dict):
_append_image_from_any(images, iu)
elif isinstance(iu, str):
_append_image_from_any(images, iu)
return
if typ == "image" and it.get("image") is not None:
_append_image_from_any(images, it.get("image"))
return
# Untyped / DashScope shorthand blocks
tx = it.get("text")
if isinstance(tx, str) and tx.strip():
text_parts.append(tx.strip())
if it.get("image") is not None:
_append_image_from_any(images, it.get("image"))
ius = it.get("image_url")
if isinstance(ius, str):
_append_image_from_any(images, ius)
elif isinstance(ius, dict):
_append_image_from_any(images, ius)
uu = it.get("url")
if isinstance(uu, str) and uu.strip().startswith(("http://", "https://")):
images.append(uu.strip())
if isinstance(it.get("b64_json"), str) and str(it.get("b64_json")).strip():
images.append(f"data:image/png;base64,{it['b64_json']}")
def _try_parse_content_json_string(raw: str, *, text_parts: list[str], images: list[str]) -> bool:
"""Some proxies stringify multimodal ``content`` as JSON."""
s = raw.strip()
if not s or s[0] not in "[{":
return False
try:
parsed = json.loads(s)
except Exception:
return False
if isinstance(parsed, list):
for it in parsed:
_parse_content_part(it, text_parts=text_parts, images=images)
return True
if isinstance(parsed, dict):
_parse_content_part(parsed, text_parts=text_parts, images=images)
return True
return False
def _harvest_http_urls_from_value(val: Any, images: list[str], *, depth: int, max_depth: int = 6) -> None:
"""Last resort: collect image URLs nested under ``message`` (unknown multimodal block shapes)."""
if depth > max_depth or val is None:
return
if isinstance(val, str):
s = val.strip()
if s.startswith(("http://", "https://")) and s not in images:
images.append(s)
return
if isinstance(val, dict):
for _k, v in val.items():
_harvest_http_urls_from_value(v, images, depth=depth + 1, max_depth=max_depth)
return
if isinstance(val, list):
for it in val:
_harvest_http_urls_from_value(it, images, depth=depth + 1, max_depth=max_depth)
def _parse_choice_message_content(msg: dict[str, Any], *, text_parts: list[str], images: list[str]) -> None:
c = msg.get("content")
if c is None:
_harvest_http_urls_from_value(msg, images, depth=0)
return
if isinstance(c, str):
if c.strip() and _try_parse_content_json_string(c, text_parts=text_parts, images=images):
_harvest_http_urls_from_value(msg, images, depth=0)
return
if c.strip():
text_parts.append(c.strip())
_harvest_http_urls_from_value(msg, images, depth=0)
return
if isinstance(c, dict):
_parse_content_part(c, text_parts=text_parts, images=images)
_harvest_http_urls_from_value(msg, images, depth=0)
return
if isinstance(c, list):
for it in c:
_parse_content_part(it, text_parts=text_parts, images=images)
_harvest_http_urls_from_value(msg, images, depth=0)
return
def _parse_messages_array_for_assistant(
messages: list[Any], *, text_parts: list[str], images: list[str]
) -> None:
for m in reversed(messages):
if not isinstance(m, dict):
continue
if str(m.get("role") or "").strip().lower() != "assistant":
continue
_parse_choice_message_content(m, text_parts=text_parts, images=images)
break
def extract_text_and_images(resp_json: dict[str, Any]) -> tuple[str, list[str]]:
text_parts: list[str] = []
images: list[str] = []
choices = resp_json.get("choices")
roots = _extract_response_roots(resp_json)
for root in roots:
ot = root.get("text")
if isinstance(ot, str) and ot.strip():
text_parts.append(ot.strip())
choices: list[Any] | None = None
for root in roots:
ch = root.get("choices")
if isinstance(ch, list) and ch:
choices = ch
break
if isinstance(choices, list) and choices:
msg = choices[0].get("message") if isinstance(choices[0], dict) else None
if isinstance(msg, dict):
c = msg.get("content")
if isinstance(c, str):
text_parts.append(c)
elif isinstance(c, list):
for it in c:
if not isinstance(it, dict):
continue
if isinstance(it.get("text"), str):
text_parts.append(str(it.get("text")))
elif isinstance(it.get("image"), str):
images.append(str(it.get("image")))
elif isinstance(it.get("image_url"), str):
images.append(str(it.get("image_url")))
elif isinstance(it.get("image_url"), dict) and isinstance(it["image_url"].get("url"), str):
images.append(str(it["image_url"]["url"]))
elif isinstance(it.get("b64_json"), str):
images.append(f"data:image/png;base64,{it['b64_json']}")
for choice in choices:
if not isinstance(choice, dict):
continue
legacy_txt = choice.get("text")
if isinstance(legacy_txt, str) and legacy_txt.strip():
text_parts.append(legacy_txt.strip())
msg = choice.get("message")
if isinstance(msg, dict):
_parse_choice_message_content(msg, text_parts=text_parts, images=images)
elif not isinstance(msg, dict):
_harvest_http_urls_from_value(choice, images, depth=0)
if not text_parts and not images:
for root in roots:
msgs = root.get("messages")
if isinstance(msgs, list) and msgs:
_parse_messages_array_for_assistant(msgs, text_parts=text_parts, images=images)
if text_parts or images:
break
if not images:
data = resp_json.get("data")
if isinstance(data, list):
for root in roots:
data = root.get("data")
if not isinstance(data, list):
continue
for it in data:
if not isinstance(it, dict):
continue
if isinstance(it.get("url"), str):
images.append(str(it.get("url")))
if isinstance(it.get("b64_json"), str):
if isinstance(it.get("url"), str) and str(it.get("url")).strip():
images.append(str(it.get("url")).strip())
if isinstance(it.get("b64_json"), str) and str(it.get("b64_json")).strip():
images.append(f"data:image/png;base64,{it['b64_json']}")
if not images:
for root in roots:
for key in ("results", "artifacts", "task_outputs"):
arr = root.get(key)
if not isinstance(arr, list):
continue
for it in arr:
if not isinstance(it, dict):
continue
for ik in ("image", "url", "output_image_url", "image_url"):
v = it.get(ik)
if isinstance(v, str) and v.strip().startswith(("http://", "https://")):
images.append(v.strip())
elif isinstance(v, dict) and isinstance(v.get("url"), str):
u = str(v.get("url")).strip()
if u.startswith(("http://", "https://")):
images.append(u)
# De-dupe URLs while preserving order
seen_u: set[str] = set()
uniq_images: list[str] = []
for u in images:
if u not in seen_u:
seen_u.add(u)
uniq_images.append(u)
images = uniq_images
return "\n".join([x for x in text_parts if x]).strip(), images
def _diag_fill_choices(out: dict[str, Any], ch: Any, *, prefix: str) -> None:
out[f"{prefix}choices_typename"] = type(ch).__name__
out[f"{prefix}choices_len"] = len(ch) if isinstance(ch, list) else None
if not isinstance(ch, list) or not ch or not isinstance(ch[0], dict):
return
c0 = ch[0]
out[f"{prefix}choice0_keys"] = sorted(c0.keys())[:24]
msg = c0.get("message")
if isinstance(msg, dict):
out[f"{prefix}msg_keys"] = sorted(msg.keys())[:24]
c = msg.get("content")
out[f"{prefix}content_typename"] = type(c).__name__
if isinstance(c, list):
out[f"{prefix}content_len"] = len(c)
if c:
out[f"{prefix}content0_typename"] = type(c[0]).__name__
if isinstance(c[0], dict):
out[f"{prefix}content0_keys"] = sorted(c[0].keys())[:24]
elif isinstance(c, dict):
out[f"{prefix}content_keys"] = sorted(c.keys())[:24]
elif c is None:
out[f"{prefix}content_is_null"] = True
def build_extract_diag_empty(obj: dict[str, Any]) -> dict[str, Any]:
"""Compact shape hints when extraction yielded nothing (stderr / chat debug)."""
out: dict[str, Any] = {"top_level_keys": sorted(obj.keys())[:40]}
ch_top = obj.get("choices")
if ch_top is not None:
_diag_fill_choices(out, ch_top, prefix="")
outp = obj.get("output")
if isinstance(outp, dict):
out["output_keys"] = sorted(outp.keys())[:40]
ch = outp.get("choices")
_diag_fill_choices(out, ch, prefix="output_")
return out
def redact_response_for_debug(obj: Any, *, max_chars: int = 2800) -> str:
"""JSON preview for error surfaces (truncate; redact long base64 / data URLs)."""
def _walk(x: Any) -> Any:
if isinstance(x, dict):
return {str(k): _walk(v) for k, v in x.items()}
if isinstance(x, list):
return [_walk(v) for v in x[:80]]
if isinstance(x, str):
s = x
if ";base64," in s and s.strip().startswith("data:") and len(s) > 120:
h, _, _ = s.partition(";base64,")
return f"{h};base64,<redacted {len(s)} chars>"
if len(s) > 500:
return s[:400] + f"...<truncated {len(s) - 400}>"
return s
return x
try:
txt = json.dumps(_walk(obj), ensure_ascii=False, default=str)
except Exception:
txt = str(obj)
if len(txt) > max_chars:
return txt[: max_chars - 24] + "\n…<truncated>"
return txt
def format_extract_diag(diag: dict[str, Any], *, max_chars: int = 900) -> str:
try:
s = json.dumps(diag, ensure_ascii=False, default=str)
except Exception:
s = str(diag)
if len(s) > max_chars:
return s[: max_chars - 20] + "…<truncated>"
return s
__all__ = [
"build_extract_diag_empty",
"compress_data_url_image",
"dashscope_multimodal_http_ok",
"dashscope_native_multimodal_url_from_compatible_base",
"download_http_url_bytes",
"env_image_expert_download_timeout_sec",
"redact_response_for_debug",
"env_image_expert_api_key",
"env_image_expert_base_url",
"env_image_expert_chat_endpoint",
"env_image_expert_model",
"env_ocr_lane_api_key",
"env_ocr_lane_base_url",
"env_ocr_lane_chat_endpoint",
"env_ocr_lane_model",
"extract_text_and_images",
"format_extract_diag",
"is_data_url",
"join_url",
"post_with_retry",

View file

@ -1,28 +1,373 @@
"""Legacy image+text payloads for DashScope-style gateways.
Uses ``{"image":...}/{"text":...}`` or typed compatible-mode blocks on ``/chat/completions`` only.
Prefer :mod:`oclaw.platform.llm.image_ocr_client` for OpenAI-compatible vision (图片专家已改用该路径).
The **image specialist** uses this module from:
- :mod:`~oclaw.runtime.agents.specialist_agent` (orchestration temp sessions)
- :mod:`~oclaw.runtime.direct_loop` when ``skill_binding_role=="image"`` (**gateway /chat UI**), so vision
turns never hit :class:`~oclaw.platform.llm.transports.openai_responses.OpenAIResponsesModel` unless explicitly disabled via env.
Alignment with Alibaba ``dashscope.MultiModalConversation`` examples (**message shape**):
``messages = [{"role": "user", "content": [{"image": "<url or data:...>"}, {"text": "..."}]}]``
matches our non--``typed`` branch (same ``image`` / ``text`` keys as the SDK doc).
Lane separation (**not** OCR):
- Resolved as ``kwargs …`` from the user's **chosen chat model/profile** first, then ``AIA_IMAGE_EXPERT_*`` when a field is empty.
The **`AIA_OCR_*`** variables remain for **`query_image_attachment`** / OCR downgrade only.
- Bearer HTTP targets ``BASE_URL`` + ``AIA_IMAGE_EXPERT_CHAT_ENDPOINT`` (default ``/chat/completions``), not ``MultiModalConversation`` SDK.
SDK-style extras:
- Optional top-level fields (``stream``, ``n``, ``watermark``, ``negative_prompt``, ``prompt_extend``, ``size``, …):
use ``DASHSCOPE_IMAGE_*`` env vars or JSON in ``AIA_IMAGE_EXPERT_REQUEST_EXTRA`` (alias: ``AIA_LEGACY_IMAGE_REQUEST_EXTRA``).
Compatibility roots:
- For OpenAI-compat multimodal, use ``AIA_IMAGE_EXPERT_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1`` etc.
For OpenAI-style ``image_url`` chat payloads (tool OCR / multimodal downgrade), use :mod:`oclaw.platform.llm.image_ocr_client`.
Chat UI **图片专家**端到端说明(与其它链路隔离的变更边界)见仓库内 ``docs/IMAGE_SPECIALIST_LANE.md``.
"""
from __future__ import annotations
import base64
import json
import os
import sys
from typing import Any
import httpx
from oclaw.platform.files.attachment_assets import AttachmentAssetStore
from oclaw.platform.llm.image_http_common import (
build_extract_diag_empty,
compress_data_url_image,
env_ocr_lane_api_key,
env_ocr_lane_base_url,
env_ocr_lane_model,
env_ocr_lane_chat_endpoint,
dashscope_multimodal_http_ok,
dashscope_native_multimodal_url_from_compatible_base,
download_http_url_bytes,
env_image_expert_api_key,
env_image_expert_base_url,
env_image_expert_chat_endpoint,
env_image_expert_model,
extract_text_and_images,
format_extract_diag,
is_data_url,
join_url,
post_with_retry,
redact_response_for_debug,
)
from oclaw.runtime.prompt_templates import render_prompt
IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH = (
"请根据用户上传的图片作答:描述可见场景、物体与文字;不确定处请标明。"
)
def collect_legacy_lane_images_from_attachments(
attachments: list[dict[str, Any]] | None,
*,
max_images: int = 3,
) -> list[str]:
"""Normalize incoming UI/store attachments to URLs/data URLs for :func:`send_legacy_image_messages`."""
from oclaw.platform.files.attachment_assets import attachment_id_to_data_url
cap = max(1, min(int(max_images), 12))
out: list[str] = []
for att in attachments or []:
if not isinstance(att, dict):
continue
t = str(att.get("type") or "").strip().lower()
if t == "image_ref":
aid = str(att.get("attachment_id") or "").strip()
if not aid:
continue
data_url = attachment_id_to_data_url(aid, mime=str(att.get("mime") or ""))
if data_url:
out.append(data_url)
elif t in ("input_image", "image"):
raw = str(att.get("image_base64") or att.get("data") or "").strip()
if raw:
mime = str(att.get("mime") or "image/jpeg")
if raw.startswith("data:"):
out.append(raw)
else:
out.append(f"data:{mime};base64,{raw}")
elif t == "image_url":
u = str(att.get("url") or "").strip()
if u:
out.append(u)
if len(out) >= cap:
break
return out
def normalize_legacy_output_image_urls(resp_images: Any, *, max_items: int = 12) -> list[str]:
"""Flatten provider ``images`` / content parts to HTTP/data URLs (strings only)."""
cap = max(1, min(int(max_items), 24))
out: list[str] = []
if resp_images is None:
return out
if isinstance(resp_images, str):
s = resp_images.strip()
if s:
out.append(s)
return out[:cap]
if not isinstance(resp_images, list):
return out
for it in resp_images[:cap]:
if isinstance(it, str):
s = it.strip()
if s:
out.append(s)
continue
if not isinstance(it, dict):
continue
u = it.get("image")
if isinstance(u, str) and u.strip():
out.append(u.strip())
continue
u = it.get("url")
if isinstance(u, str) and u.strip():
out.append(u.strip())
continue
iu = it.get("image_url")
if isinstance(iu, str) and iu.strip():
out.append(iu.strip())
elif isinstance(iu, dict):
u2 = iu.get("url")
if isinstance(u2, str) and u2.strip():
out.append(u2.strip())
return out[:cap]
def materialize_legacy_response_output_attachments(
resp_images: Any,
*,
max_images: int = 3,
) -> list[dict[str, Any]]:
"""Persist remote/base64 model outputs as ``image_ref`` / ``image_url`` rows for chat UI."""
cap = max(1, min(int(max_images), 12))
produced: list[dict[str, Any]] = []
urls = normalize_legacy_output_image_urls(resp_images, max_items=cap)
if not urls:
return produced
store = AttachmentAssetStore()
for idx, item in enumerate(urls[:cap], start=1):
s = str(item or "").strip()
if not s:
continue
if s.startswith("data:") and ";base64," in s:
head, b64 = s.split(";base64,", 1)
mime = head.replace("data:", "", 1) or "image/png"
try:
blob = base64.b64decode(b64.encode("ascii"))
except Exception:
continue
meta = store.save_bytes(blob, filename=f"image-output-{idx}.png", mime=mime)
produced.append(
{
"type": "image_ref",
"attachment_id": meta.attachment_id,
"name": meta.name,
"mime": meta.mime,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
)
elif s.startswith("http://") or s.startswith("https://"):
try:
blob, ctype = download_http_url_bytes(s)
if blob:
mime = (ctype.split(";", 1)[0].strip() if ctype else "") or "image/png"
ext = ".png"
if mime == "image/jpeg":
ext = ".jpg"
elif mime == "image/webp":
ext = ".webp"
elif mime == "image/gif":
ext = ".gif"
meta = store.save_bytes(blob, filename=f"image-output-{idx}{ext}", mime=mime)
produced.append(
{
"type": "image_ref",
"attachment_id": meta.attachment_id,
"name": meta.name,
"mime": meta.mime,
"bytes": meta.bytes,
"width": meta.width,
"height": meta.height,
}
)
continue
except Exception:
pass
produced.append({"type": "image_url", "url": s, "name": f"image-output-{idx}.png"})
return produced
def legacy_image_turn_bundle(resp: dict[str, Any]) -> tuple[bool, str, list[dict[str, Any]]]:
"""Interpret ``send_legacy_image_messages`` result for persistence (text-only vision answers allowed)."""
ok = bool(resp.get("ok"))
text = str(resp.get("text") or "").strip()
if not ok:
err = str(resp.get("error") or "").strip()
return False, f"Image generation failed: {err or 'unknown error'}", []
raw_urls = normalize_legacy_output_image_urls(resp.get("images"), max_items=6)
imgs = materialize_legacy_response_output_attachments(raw_urls, max_images=3)
if not imgs and raw_urls:
imgs = [
{"type": "image_url", "url": u, "name": f"image-output-{i}.png"}
for i, u in enumerate(raw_urls[:3], start=1)
if u.startswith(("http://", "https://", "data:"))
]
if imgs:
return True, text, imgs
if text:
return True, text, []
diag = resp.get("extract_diag")
hint = ""
if isinstance(diag, dict) and diag:
hint = format_extract_diag(diag, max_chars=1400)
red = resp.get("provider_response_redacted")
if isinstance(red, str) and red.strip():
hint = f"{hint}\nprovider_json={red.strip()}" if hint else f"provider_json={red.strip()}"
base = "Image specialist failed: empty response from provider."
out_msg = f"{base} {hint}".strip()
if len(out_msg) > 12_000:
out_msg = out_msg[:11_980] + "\n…<truncated>"
return False, out_msg, []
def legacy_image_assistant_body_with_placeholder(
*,
lang: str | None,
body_text: str,
produced: list[dict[str, Any]] | None,
) -> str:
"""If the model returned images but no visible text, use the standard chat placeholder (ZH/EN).
Shared by ``direct_loop`` (gateway /chat) and ``specialist_agent`` (temp sessions).
"""
if str(body_text or "").strip():
return str(body_text or "")
if produced:
return (
"Generated image (see attachment below)."
if str(lang or "").startswith("en")
else "已生成图片(见下方附件)。"
)
return str(body_text or "")
def _truthy_env(name: str) -> bool:
return str(os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on")
def _redact_payload_for_stderr(obj: Any) -> Any:
if isinstance(obj, dict):
return {str(k): _redact_payload_for_stderr(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_redact_payload_for_stderr(x) for x in obj]
if isinstance(obj, str):
s = obj
if ";base64," in s and s.strip().startswith("data:") and len(s) > 120:
h, _, _ = s.partition(";base64,")
return f"{h};base64,<redacted ~{len(s) - len(h) - 8} chars>"
return s
return obj
def _stderr_debug_image_legacy(url: str, payload: dict[str, Any]) -> None:
if not _truthy_env("AIA_IMAGE_EXPERT_DEBUG_PRINT_PAYLOAD"):
return
try:
txt = json.dumps(_redact_payload_for_stderr(dict(payload)), ensure_ascii=False, indent=2, default=str)
sys.stderr.write(f"\n[oclaw image_legacy] POST {url}\n{txt}\n\n")
sys.stderr.flush()
except Exception:
pass
def _extra_request_fields_from_env() -> dict[str, Any]:
"""Merge JSON from ``AIA_IMAGE_EXPERT_REQUEST_EXTRA`` (or legacy alias ``AIA_LEGACY_IMAGE_REQUEST_EXTRA``)."""
raw = (os.getenv("AIA_IMAGE_EXPERT_REQUEST_EXTRA") or os.getenv("AIA_LEGACY_IMAGE_REQUEST_EXTRA") or "").strip()
if not raw:
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def _dashscope_image_env_kw() -> dict[str, Any]:
"""Map `_local/system.env.example` ``DASHSCOPE_IMAGE_*`` vars to multimodal/top-level kwargs (parity with SDK samples)."""
out: dict[str, Any] = {}
raw_n = (os.getenv("DASHSCOPE_IMAGE_N") or "").strip()
if raw_n.isdigit():
out["n"] = max(1, min(int(raw_n), 6))
wm = (os.getenv("DASHSCOPE_IMAGE_WATERMARK") or "").strip().lower()
if wm in ("1", "true", "yes", "on"):
out["watermark"] = True
elif wm in ("0", "false", "no", "off"):
out["watermark"] = False
raw_stream = (os.getenv("DASHSCOPE_IMAGE_STREAM") or "").strip().lower()
if raw_stream in ("1", "true", "yes", "on"):
out["stream"] = True
elif raw_stream in ("0", "false", "no", "off"):
out["stream"] = False
neg = os.getenv("DASHSCOPE_IMAGE_NEGATIVE_PROMPT")
if neg is not None:
ns = str(neg)
if ns.strip():
out["negative_prompt"] = ns
pe = (os.getenv("DASHSCOPE_IMAGE_PROMPT_EXTEND") or "").strip().lower()
if pe in ("1", "true", "yes", "on"):
out["prompt_extend"] = True
elif pe in ("0", "false", "no", "off"):
out["prompt_extend"] = False
size = (os.getenv("DASHSCOPE_IMAGE_SIZE") or "").strip()
if size:
out["size"] = size
return out
def _openai_compatible_vision_content(images: list[str], prompt_text: str) -> list[dict[str, Any]]:
"""DashScope *compatible-mode* / OpenAI Chat Completions vision shape (NOT ``{"image":..., "text":...}``)."""
blocks: list[dict[str, Any]] = []
for img in images:
blocks.append({"type": "image_url", "image_url": {"url": img}})
blocks.append({"type": "text", "text": str(prompt_text or "").strip()})
return blocks
def _model_triggers_dashscope_native_fallback(model_name: str) -> bool:
"""``qwen-image`` on OpenAI-compat ``/chat/completions`` often returns ``message.content=null``."""
if str(os.getenv("AIA_IMAGE_EXPERT_DISABLE_DASHSCOPE_NATIVE_FALLBACK") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
return False
if str(os.getenv("AIA_IMAGE_EXPERT_FORCE_DASHSCOPE_NATIVE") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
):
return True
m = (model_name or "").strip().lower()
needle = (os.getenv("AIA_IMAGE_EXPERT_NATIVE_FALLBACK_MODEL_SUBSTR") or "qwen-image").strip().lower()
return bool(needle) and needle in m
def _http_content_blocks(images: list[str], prompt: str, *, typed: bool) -> list[dict[str, Any]]:
prompt_text = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
@ -44,21 +389,21 @@ def send_legacy_image_messages(
api_key: str | None = None,
base_url: str | None = None,
) -> dict[str, Any]:
"""Legacy multimodal HTTP (non--OpenAI-``image_url`` schema). Optional; specialists use OCR client."""
resolved_base_url = (base_url or env_ocr_lane_base_url()).strip()
resolved_api_key = (api_key or env_ocr_lane_api_key()).strip()
model_name = ((model or "").strip() or env_ocr_lane_model())
endpoint = env_ocr_lane_chat_endpoint()
"""Legacy multimodal HTTP (non--OpenAI-``image_url`` schema); image specialist uses this entry point."""
resolved_base_url = (base_url or env_image_expert_base_url()).strip()
resolved_api_key = (api_key or env_image_expert_api_key()).strip()
model_name = ((model or "").strip() or env_image_expert_model())
endpoint = env_image_expert_chat_endpoint()
url = join_url(resolved_base_url, endpoint)
if not resolved_api_key or not resolved_base_url:
return {
"ok": False,
"error": "missing AIA_OCR_API_KEY or AIA_OCR_BASE_URL (or pass api_key and base_url)",
"error": "missing AIA_IMAGE_EXPERT_API_KEY or AIA_IMAGE_EXPERT_BASE_URL (or pass api_key and base_url)",
}
if not model_name:
return {
"ok": False,
"error": "missing AIA_OCR_MODEL (or pass model=...) — no default model id",
"error": "missing model (chosen profile/model=… or set AIA_IMAGE_EXPERT_MODEL)",
}
if not images:
return {"ok": False, "error": "at least one image input is required"}
@ -78,22 +423,28 @@ def send_legacy_image_messages(
if not selected:
return {"ok": False, "error": "no usable image input (expected URL or data URL)"}
prefer_typed_http = "compatible-mode" in resolved_base_url.lower()
content_multi = _http_content_blocks(selected, prompt, typed=prefer_typed_http)
content_multi_fallback = _http_content_blocks(selected, prompt, typed=not prefer_typed_http)
# Compatible-mode expects OpenAI-style ``image_url`` + ``text`` parts; DashScope-native HTTP uses plain ``{"image"}`` blocks.
use_openai_blocks = "compatible-mode" in resolved_base_url.lower()
prompt_plain = str(prompt or "").strip() or render_prompt("image/default_edit_prompt.zh.md", strict=True)
if use_openai_blocks:
content_multi = _openai_compatible_vision_content(selected, prompt_plain)
else:
content_multi = _http_content_blocks(selected, prompt, typed=False)
headers = {
"Authorization": f"Bearer {resolved_api_key}",
"Content-Type": "application/json",
}
payload_multi = {
extra_ds = _dashscope_image_env_kw()
extra_json = _extra_request_fields_from_env()
extra = {**extra_ds, **extra_json}
core_multi = {
"model": model_name,
"messages": [{"role": "user", "content": content_multi}],
}
payload_single = {
"model": model_name,
"messages": [{"role": "user", "content": content_multi_fallback[-2:] if len(content_multi_fallback) >= 2 else content_multi_fallback}],
}
payload_multi = {**extra, **core_multi}
_stderr_debug_image_legacy(url, payload_multi)
with httpx.Client(timeout=float(timeout_sec)) as client:
try:
@ -102,45 +453,152 @@ def send_legacy_image_messages(
return {"ok": False, "error": f"http request failed: {type(e).__name__}: {e}", "backend_shape": "multi"}
if r.status_code >= 400:
try:
r2 = post_with_retry(client, url=url, headers=headers, payload=payload_single)
except Exception as e:
return {
"ok": False,
"error": f"http fallback request failed: {type(e).__name__}: {e}",
"backend_shape": "single-fallback-failed",
}
if r2.status_code >= 400:
return {
"ok": False,
"error": f"http {r2.status_code}: {r2.text[:500]}",
"backend_shape": "single-fallback-failed",
}
try:
obj2 = r2.json()
except Exception:
return {"ok": False, "error": f"non-json response: {r2.text[:500]}", "backend_shape": "single"}
text, out_images = extract_text_and_images(obj2 if isinstance(obj2, dict) else {})
return {
"ok": True,
"text": text,
"images": out_images,
"backend_shape": "single",
"input_kind": input_kind[-1:] if input_kind else ["data_url"],
}
return {"ok": False, "error": f"http {r.status_code}: {r.text[:800]}", "backend_shape": "multi"}
try:
obj = r.json()
except Exception:
return {"ok": False, "error": f"non-json response: {r.text[:500]}", "backend_shape": "multi"}
text, out_images = extract_text_and_images(obj if isinstance(obj, dict) else {})
if not isinstance(obj, dict):
return {
"ok": False,
"error": f"expected JSON object from provider, got {type(obj).__name__}",
"backend_shape": "multi",
"input_kind": input_kind if input_kind else ["data_url"],
}
body = obj
ds_ok, ds_err = dashscope_multimodal_http_ok(body)
if not ds_ok:
return {
"ok": False,
"error": ds_err or "provider rejected request (DashScope code/message)",
"backend_shape": "multi",
"input_kind": input_kind if input_kind else ["data_url"],
}
text, out_images = extract_text_and_images(body)
compat_extract_diag: dict[str, Any] | None = None
native_fallback_meta: dict[str, Any] = {}
if (
not str(text or "").strip()
and not out_images
and use_openai_blocks
and _model_triggers_dashscope_native_fallback(model_name)
):
compat_extract_diag = build_extract_diag_empty(body)
native_url = dashscope_native_multimodal_url_from_compatible_base(resolved_base_url)
if not native_url:
native_fallback_meta = {
"attempted": False,
"hint": "set AIA_IMAGE_EXPERT_DASHSCOPE_NATIVE_URL or use a compatible-mode base_url",
}
else:
content_native = _http_content_blocks(selected, prompt, typed=False)
param_merge = {**_dashscope_image_env_kw(), **_extra_request_fields_from_env()}
param_merge.pop("stream", None)
payload_native = {
"model": model_name,
"input": {"messages": [{"role": "user", "content": content_native}]},
"parameters": {"result_format": "message", **param_merge},
}
_stderr_debug_image_legacy(native_url, payload_native)
try:
r2 = post_with_retry(client, url=native_url, headers=headers, payload=payload_native)
except Exception as e:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"error": f"{type(e).__name__}: {e}",
}
else:
if r2.status_code >= 400:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"http_status": int(r2.status_code),
"body_head": r2.text[:600],
}
else:
try:
b2 = r2.json()
except Exception as e:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"error": f"json: {type(e).__name__}: {e}",
}
else:
if isinstance(b2, dict):
ok2, err2 = dashscope_multimodal_http_ok(b2)
if not ok2:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"dashscope_error": err2 or "business code",
}
else:
t2, im2 = extract_text_and_images(b2)
if str(t2 or "").strip() or im2:
text, out_images = t2, im2
body = b2
native_fallback_meta = {
"attempted": True,
"url": native_url,
"succeeded": True,
}
else:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"succeeded": False,
"native_diag": build_extract_diag_empty(b2),
}
else:
native_fallback_meta = {
"attempted": True,
"url": native_url,
"error": "native response not a JSON object",
}
extract_diag: dict[str, Any] | None = None
provider_response_redacted: str | None = None
if not str(text or "").strip() and not out_images:
extract_diag = build_extract_diag_empty(body)
if compat_extract_diag is not None:
extract_diag["openai_compat_empty"] = compat_extract_diag
if native_fallback_meta:
extract_diag["dashscope_native_fallback"] = native_fallback_meta
provider_response_redacted = redact_response_for_debug(body, max_chars=3200)
if _truthy_env("AIA_IMAGE_EXPERT_DEBUG_PRINT_PAYLOAD"):
try:
sys.stderr.write(
"\n[oclaw image_legacy] extract_empty extract_diag="
+ format_extract_diag(extract_diag, max_chars=4000)
+ "\n"
+ (provider_response_redacted or "")
+ "\n\n"
)
sys.stderr.flush()
except Exception:
pass
out_images = normalize_legacy_output_image_urls(out_images, max_items=12)
return {
"ok": True,
"text": text,
"images": out_images,
"backend_shape": "multi",
"input_kind": input_kind if input_kind else ["data_url"],
"extract_diag": extract_diag,
"provider_response_redacted": provider_response_redacted,
}
__all__ = ["send_legacy_image_messages"]
__all__ = [
"IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH",
"collect_legacy_lane_images_from_attachments",
"legacy_image_assistant_body_with_placeholder",
"legacy_image_turn_bundle",
"materialize_legacy_response_output_attachments",
"normalize_legacy_output_image_urls",
"send_legacy_image_messages",
]

View file

@ -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:])

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import base64
import json
import os
import re
import sqlite3
import sys
import hashlib
@ -1436,6 +1437,78 @@ class SqliteStore:
(aid, tid, uid, sid, src, ts),
)
@staticmethod
def _attachment_ids_for_acl_from_payload_items(items: list[dict[str, Any]]) -> list[str]:
"""Collect stable attachment ids from message attachment JSON (for ACL rows)."""
out: list[str] = []
seen: set[str] = set()
ref_types = {"image_ref", "video_ref", "text_ref", "binary_ref"}
relay_re = re.compile(r"^relay://attachments/[^/]+/([a-f0-9]{8,64})$", re.IGNORECASE)
for a in items:
if not isinstance(a, dict):
continue
typ = str(a.get("type") or "").strip().lower()
aid = str(a.get("attachment_id") or a.get("attachmentId") or "").strip().lower()
if typ in ref_types and aid:
if aid not in seen:
seen.add(aid)
out.append(aid)
continue
if typ != "relay_pointer":
continue
if not aid:
uri = str(a.get("pointer_uri") or "").strip()
m = relay_re.match(uri)
if m:
aid = str(m.group(1) or "").strip().lower()
if aid and aid not in seen:
seen.add(aid)
out.append(aid)
return out
def sync_attachment_acl_from_chat_message_attachments(
self,
*,
session_id: str,
role: str,
event_type: str | None,
attachments: Any,
) -> None:
"""Best-effort: link chat_message attachments to session owner so strict ACL downloads work.
Tool results already link in runtime; assistant rows (image specialist, inbound, etc.)
historically did not, which breaks ``AIA_ATTACHMENT_ACL_STRICT=1``.
"""
sid = str(session_id or "").strip()
if not sid or attachments is None:
return
owner = self.get_ui_session_owner(session_id=sid) or {}
tid = str(owner.get("tenant_id") or "").strip()
uid = str(owner.get("user_id") or "").strip()
if not tid or not uid:
return
items: list[dict[str, Any]] = []
if isinstance(attachments, list):
items = [x for x in attachments if isinstance(x, dict)]
elif isinstance(attachments, dict):
items = [attachments]
else:
return
ids = self._attachment_ids_for_acl_from_payload_items(items)
if not ids:
return
r = str(role or "").strip() or "-"
ev = str(event_type or "").strip() or "-"
src = f"chat_message:{r}:{ev}"[:240]
for aid in ids:
self.link_attachment_acl(
tenant_id=tid,
user_id=uid,
session_id=sid,
attachment_id=aid,
source=src,
)
def attachment_acl_allows_user(self, *, tenant_id: str, user_id: str, attachment_id: str) -> bool:
tid = str(tenant_id or "").strip()
uid = str(user_id or "").strip()
@ -1922,6 +1995,15 @@ class SqliteStore:
"UPDATE chat_session SET last_message_at = ? WHERE id = ?",
(ts, session_id),
)
try:
self.sync_attachment_acl_from_chat_message_attachments(
session_id=str(session_id or "").strip(),
role=str(role or ""),
event_type=str(event_type or "").strip() or None,
attachments=attachments,
)
except Exception:
pass
return ChatMessage(
id=msg_id,
session_id=session_id,