diff --git a/_local/system.env.example b/_local/system.env.example index 85c54a44..0a651b2c 100644 --- a/_local/system.env.example +++ b/_local/system.env.example @@ -276,18 +276,25 @@ AIA_REPLAY_REASONING_SIGNATURE_POLICY= OPENAI_IMAGE_MODEL= OPENAI_AUDIO_TRANSCRIPTION_MODEL= -# query_image_attachment / send_ocr_image_messages(三项必填,无缺省模型 id) +# query_image_attachment、OCR 降级 send_ocr_image_messages — 仅用 AIA_OCR_*(与图片专家互不继承) AIA_OCR_MODEL= AIA_OCR_BASE_URL= AIA_OCR_API_KEY= AIA_OCR_CHAT_ENDPOINT= +# specialist=image → send_legacy_image_messages — 仅用 AIA_IMAGE_EXPERT_*(禁止与 AIA_OCR_* 混用通道) +AIA_IMAGE_EXPERT_MODEL= +AIA_IMAGE_EXPERT_BASE_URL= +AIA_IMAGE_EXPERT_API_KEY= +AIA_IMAGE_EXPERT_CHAT_ENDPOINT= + +# Optional; image expert top-level kwargs (parity with DashScope multimodal-gen samples) +DASHSCOPE_IMAGE_STREAM= DASHSCOPE_IMAGE_N= DASHSCOPE_IMAGE_WATERMARK= DASHSCOPE_IMAGE_NEGATIVE_PROMPT= DASHSCOPE_IMAGE_PROMPT_EXTEND= DASHSCOPE_IMAGE_SIZE= -DASHSCOPE_IMAGE_MODEL= CLOUDFLARE_ACCOUNT_ID= CLOUDFLARE_API_TOKEN= diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 9a197560..8507439e 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -308,31 +308,68 @@ - 作用:Gemini OpenAI 兼容下 tools 非流式开关 - 生效:`oclaw/platform/llm/chat_models.py` -## OCR / 看多模态专线 +## OCR / 看图工具与降级专线(``AIA_OCR_*``) -用于工具 **`query_image_attachment`**、纯文本模型降级 OCR、**图片专家**单次看图与底层 **`send_ocr_image_messages`**:向 **OpenAI-compatible** 网关发送带图 `messages`(通常为 `POST …/chat/completions`)。旧版 DashScope 形态载荷见 **`send_legacy_image_messages`**(一般不再用于图片专家)。与主聊天模型配置的 Key/Base URL **互不继承**。 +仅用于:**`query_image_attachment`**、OpenAI-compatible **`send_ocr_image_messages`**、以及 **`openai_chat_completions`** 在纯文本模型上的多模态→OCR 降级。规范为 OpenAI **`image_url` + `messages`** Chat Completions 形态。**与下方「图片专家」专线互相独立**(不配齐不会自动复用另一端)。 - `AIA_OCR_BASE_URL` - 默认:无(必填,否则工具判为未配置) - - 作用:多模态网关根地址(例如 `https://api.example.com/v1`;实际请求为 `BASE_URL` + `CHAT_ENDPOINT`) + - 作用:OCR / 看图工具网关根地址(实际请求为 `BASE_URL` + `CHAT_ENDPOINT`) - 生效:`oclaw/platform/llm/image_ocr_client.py` - `AIA_OCR_API_KEY` - 默认:无(必填) - - 作用:上述网关的 Bearer API Key + - 作用:上述网关 Bearer API Key - 生效:`oclaw/platform/llm/image_ocr_client.py` - `AIA_OCR_MODEL` - 默认:无(必填,**不提供默认模型 id**) - - 作用:网关上支持图+文的多模态模型名(由你的厂商决定) - - 生效:`oclaw/platform/llm/image_ocr_client.py`,`oclaw/runtime/agents/specialist_agent.py` + - 作用:支持 OCR/描述的 vision 模型 id + - 生效:`oclaw/platform/llm/image_ocr_client.py` - `AIA_OCR_CHAT_ENDPOINT` - 默认:`/chat/completions` - 作用:相对 `AIA_OCR_BASE_URL` 的路径 - 生效:`oclaw/platform/llm/image_ocr_client.py` -**动态参数:** 代码中也可在调用 `send_ocr_image_messages` / `send_legacy_image_messages` 时传入 `base_url` / `api_key` / `model`,优先级高于环境变量(见 `vision_llm_backend_status` 仅在未传参时使用 env 判断「是否已配置」)。 +**动态参数:** 调用 `send_ocr_image_messages` 时传入 `base_url` / `api_key` / `model` 优先于上述环境变量;`vision_llm_backend_status` 仅检查 `AIA_OCR_*` 是否在未显式传参时可用。 + +--- + +## 图片专家专线(``AIA_IMAGE_EXPERT_*``) + +**路由 specialist=`image`** 时由 **`send_legacy_image_messages`** 调用;可走 native `{"image"}`/`{"text"}` 或 compatible-mode **`image_url` + `text` 块状**载荷。**不使用 `AIA_OCR_*`**,也不在图片专家链路继承主会话模型的 Base URL/API Key。 + +- `AIA_IMAGE_EXPERT_BASE_URL` + - 默认:无(必填,除非在代码中为 `send_legacy_image_messages(..., base_url=...)` 传入) + - 作用:图片专家 HTTP 网关根路径(兼容 OpenAI multimodal 时常见 `https://dashscope.aliyuncs.com/compatible-mode/v1` 等)。 + - 生效:`oclaw/platform/llm/image_legacy_client.py` + +- `AIA_IMAGE_EXPERT_API_KEY` + - 默认:无(必填,除非显式传 `api_key`) + - 作用:Bearer API Key(可与百炼「图片/多模态」文档中的 Key 一致;与 OCR Key **可不同**)。 + - 生效:`oclaw/platform/llm/image_legacy_client.py` + +- `AIA_IMAGE_EXPERT_MODEL` + - 默认:无(在用户界面已选会话模型时,`send_legacy_image_messages` **优先使用该模型的 `model` id**;仅当会话未带模型信息时才读本变量作补全)。 + - 作用:无 UI 会话模型/`model=` 可传时的专家默认模型 id(与 OCR 所用 VL **无需相同**)。 + - 生效:`oclaw/platform/llm/image_legacy_client.py` + +- `AIA_IMAGE_EXPERT_CHAT_ENDPOINT` + - 默认:`/chat/completions` + - 作用:相对 `AIA_IMAGE_EXPERT_BASE_URL` 的路径。 + - 生效:`oclaw/platform/llm/image_legacy_client.py` + +- `AIA_IMAGE_EXPERT_REQUEST_EXTRA` + - 默认:无 + - 别名:`AIA_LEGACY_IMAGE_REQUEST_EXTRA`(旧名仍可读)。 + - 作用:发往图片专家请求的**顶层附加 JSON**。顶层 `model` / `messages` 仍由运行时代码覆盖。 + - 生效:`oclaw/platform/llm/image_legacy_client.py` + +- `DASHSCOPE_IMAGE_STREAM` / `DASHSCOPE_IMAGE_N` / `DASHSCOPE_IMAGE_WATERMARK` / `DASHSCOPE_IMAGE_NEGATIVE_PROMPT` / `DASHSCOPE_IMAGE_PROMPT_EXTEND` / `DASHSCOPE_IMAGE_SIZE` + - 默认:均未设置则不附加对应字段。 + - 作用:与 SDK 示例关键词对齐的零散变量;可被 `AIA_IMAGE_EXPERT_REQUEST_EXTRA`(或别名 JSON)中与同名键覆盖。 + - 生效:`oclaw/platform/llm/image_legacy_client.py` > **说明:** 历史上曾使用 `AIA_IMAGE_BASE_URL` / `AIA_IMAGE_API_KEY` / `AIA_IMAGE_MODEL` / `AIA_IMAGE_CHAT_ENDPOINT` 作为同一路由;当前实现 **不再读取** 上述变量作 OCR 通道配置,请统一改为 `AIA_OCR_*`。(`AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS` 等为 **另一用途**,与 OCR 网关无关,仍保留原名。) diff --git a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md index 3bfc8b17..a2f211c5 100644 --- a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md +++ b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md @@ -60,13 +60,13 @@ ## 2026-05-10 / Unreleased ### Added -- `AIA_OCR_BASE_URL`, `AIA_OCR_API_KEY`, `AIA_OCR_MODEL`, `AIA_OCR_CHAT_ENDPOINT` - - 默认值:`CHAT_ENDPOINT` 为 `/chat/completions`,其余无默认(三项主体必填方视为已配置) - - 用途:`query_image_attachment` / `send_ocr_image_messages` 等多模态 HTTP 通道命名(与主 LLM 分离) - - 影响模块:`oclaw/platform/llm/image_ocr_client.py`、`image_legacy_client.py`,`oclaw/runtime/tools/public/query_image_attachment_tool.py`,`oclaw/runtime/agents/specialist_agent.py`(图片专家走 OCR 多模态 HTTP) +- `AIA_IMAGE_EXPERT_API_KEY`、`AIA_IMAGE_EXPERT_BASE_URL`、`AIA_IMAGE_EXPERT_MODEL`、`AIA_IMAGE_EXPERT_CHAT_ENDPOINT`:图片专家(`send_legacy_image_messages`)专线,与 **`AIA_OCR_*`** 互不继承。 +- `AIA_IMAGE_EXPERT_REQUEST_EXTRA`:图片专家顶层 JSON;旧名 **`AIA_LEGACY_IMAGE_REQUEST_EXTRA`** 仍作别名可读。 +- `DASHSCOPE_IMAGE_*`(零散变量):由 `image_legacy_client` 映射为请求体顶层字段。 +- **`AIA_OCR_*`**(四项):仅存 **`query_image_attachment` / OCR 降级** 链路;已与图片专家链路拆分。 ### Changed -- `send_ocr_image_messages` / `send_legacy_image_messages` 不再有隐式默认模型 id;未配 `AIA_OCR_MODEL`(且未传 `model`)将直接失败 +- `send_ocr_image_messages` 未配 `AIA_OCR_MODEL`(且未传 `model`)失败;图片专家 **`send_legacy_image_messages`** 首选 **用户所选会话/专家绑定的模型的 `model`/`base_url`/`api_key`**,缺省时再回落 **`AIA_IMAGE_EXPERT_*`**(不读取 `AIA_OCR_*`);服务端若模型不支持看图则直接报错,不做备用 payload。 ### Removed(OCR 通道) - `AIA_IMAGE_BASE_URL` / `AIA_IMAGE_API_KEY` / `AIA_IMAGE_MODEL` / `AIA_IMAGE_CHAT_ENDPOINT` **不再**作为看图/OCR 通道的环境变量读取(须改用 `AIA_OCR_*`)。与附件回放相关的 `AIA_IMAGE_TOOL_RESULT_REPLAY_CAP_CHARS` 等 **不受影响**。 diff --git a/docs/IMAGE_SPECIALIST_LANE.md b/docs/IMAGE_SPECIALIST_LANE.md new file mode 100644 index 00000000..fc65f2c0 --- /dev/null +++ b/docs/IMAGE_SPECIALIST_LANE.md @@ -0,0 +1,84 @@ +# 图片专家(Chat UI)专用链路 + +本文描述 **Admin `/chat` 选择「图片」专家** 时的端到端路径。这是一条 **与通用 Responses / 主对话模型环路隔离** 的特殊分支:仅在 `skill_binding_role == "image"` 时进入,其它专家或综合模式不受影响。 + +> 与本文无关:`docs/GATEWAY_IMAGE_GENERATE.md`(网关 RPC `image.generate`)、OCR 工具使用的 `image_ocr_client` 等。 + +--- + +## 1. 触发条件与隔离边界 + +| 条件 | 说明 | +|------|------| +| UI / Gateway | 用户在前端选择专家 **`image`**,请求体携带 `skill_binding_role`(或等价字段)为 **`image`**。 | +| 入口守卫 | `runtime/direct_loop.py` 中 **`_maybe_image_specialist_legacy_gateway_turn`**:仅当 `skill_binding_role.lower() == "image"` 且未设置禁用开关时执行;否则返回 `None`,后续仍走常规 `run_oclaw_direct_loop`。 | +| 禁用开关 | `AIA_IMAGE_SPECIALIST_DISABLE_LEGACY_GATEWAY_LANE=1`:关闭本 Early Return,图片专家改走与普通会话相同的模型/传输栈(用于调试或迁移)。 | + +**不要在本链路外混用**:DashScope 形态的多模态 HTTP、`qwen-image*` 的空兼容响应回退等,均封装在 `platform/llm/image_legacy_client.py` 与 `image_http_common.py`,避免改到 `openai_responses` 的通用逻辑。 + +--- + +## 2. 运行时数据流(网关 → 落库) + +1. **`run_oclaw_direct_loop`** 在用户消息落库后立刻调用 **`_maybe_image_specialist_legacy_gateway_turn`**。 +2. **输入附件**:`collect_legacy_lane_images_from_attachments` 将 UI 附件规范为 `data:` URL 或 HTTP URL(`image_ref` / `input_image` / `image_url` 等)。 +3. **无图**:直接写入一条 `assistant` / `assistant_text` 提示语并返回,不调用上游。 +4. **有图**:调用 **`send_legacy_image_messages`**(`/chat/completions` 兼容路径,非 Responses API)。 +5. **输出解析**:**`legacy_image_turn_bundle`** + - 文本可为空;若有生成图则 **`materialize_legacy_response_output_attachments`** 写入本地 blob,产出 **`image_ref`**(或退化为 **`image_url`**)。 +6. **占位文案**:成功但只有图、无模型正文时,由 **`legacy_image_assistant_body_with_placeholder`**(`image_legacy_client`)写入中英文占位句;网关与 `specialist_agent` 共用,避免两处字符串分叉。 +7. **持久化**:**`store.add_message(role=assistant, event_type=assistant_text, attachments=…)`** —— 附件以 JSON 形式挂在助手消息上,而非 tool 行。 + +--- + +## 3. 与其它入口的差异 + +| 场景 | 模块 | 说明 | +|------|------|------| +| Chat 网关 + 图片专家 | `direct_loop._maybe_image_specialist_legacy_gateway_turn` | 上文主路径;Early Return,不进主 LLM 循环。 | +| Specialist 编排临时会话 | `runtime/agents/specialist_agent.py` | 同样调用 `send_legacy_image_messages` / `legacy_image_turn_bundle`,逻辑对齐但不经过同一 Early Return。 | + +两处共用 **`platform/llm/image_legacy_client.py`**,避免分叉实现。 + +--- + +## 4. 鉴权与下载(严格 ACL) + +助手消息上的 **`image_ref`** 需在 **`attachment_acl`** 中有记录,才能在 **`AIA_ATTACHMENT_ACL_STRICT=1`** 下通过 **`GET /admin/api/chat/attachments/{id}`**。 + +- **`SqliteStore.add_message`** 会在落库后根据会话 **`ui_session_owner`** 对引用型附件执行 **`link_attachment_acl`**(与 tool 结果路径一致)。 +- 历史数据可用 **`POST /admin/api/chat/admin/attachments/acl/backfill`**(管理员)补齐。详见 **`docs/attachment-acl.md`**。 + +--- + +## 5. WebSocket 收口与前端展示 + +- **`interfaces/ws/turn_runner.py`**:`final_msg` 从最近一条非空助手消息组装;**`_persisted_chat_attachments_nonempty`** 需识别 **JSON 数组或单个 JSON 对象**,否则纯附件回合会选错行。 +- **`interfaces/admin/static/chat.js`**:聚合气泡 **`_buildAggregatedAssistantBubble`** 须对 **`assistant_text`** 片段调用 **`renderAttachmentsEl`**(不仅 `tool_result`),否则会出现「只有配文、无图」的现象。 + +--- + +## 6. 环境变量(索引) + +详细列表与默认值以 **`docs/ENVIRONMENT_VARIABLES.md`** 为准。与本链路相关的典型前缀: + +- **`AIA_IMAGE_EXPERT_*`** / **`AIA_IMAGE_SPECIALIST_*`**:图片专家 HTTP 基址、端点、模型、请求扩展等(与 **`AIA_OCR_*`** 分离)。 +- **`AIA_IMAGE_SPECIALIST_DISABLE_LEGACY_GATEWAY_LANE`**:禁用网关侧 legacy 专用线。 +- **`AIA_ATTACHMENT_ACL_STRICT`**:附件下载是否仅信任 ACL。 + +--- + +## 7. 测试与回归 + +- **`tests/test_image_legacy_gateway_lane.py`**:legacy 解析与规范化。 +- **`tests/test_attachment_acl_backfill.py`**:严格 ACL 下助手附件与 ACL 写入。 +- 修改 **`chat.js`** 聚合或 **`turn_runner`** final 消息逻辑后,应用图片专家跑一轮 **生成图 + 刷新历史** 做冒烟。 + +--- + +## 8. 变更原则(避免波及其它链路) + +1. **默认改动范围**:`image_legacy_client.py`、`image_http_common.py`、`direct_loop` 中 **`_maybe_image_specialist_*` 函数体**、`specialist_agent` 中与 legacy image 调用相邻代码、`turn_runner` / `chat.js` 中与 **assistant + attachments** 展示相邻逻辑。 +2. **勿在** `openai_responses.py` **中为图片专家单独分支**,除非明确要做「非 legacy」通用能力。 +3. 新增开关优先 **`AIA_IMAGE_*` / `AIA_IMAGE_SPECIALIST_*`**,勿复用 OCR 变量。 +4. UI 层附件渲染:**assistant_text 与 tool_result** 对称处理引用型附件,避免只修一端。 diff --git a/docs/LLM_TRANSPORTS.md b/docs/LLM_TRANSPORTS.md index 8a590bf7..35da4ab3 100644 --- a/docs/LLM_TRANSPORTS.md +++ b/docs/LLM_TRANSPORTS.md @@ -30,6 +30,11 @@ Transport selection happens in `oclaw/runtime/agents/factory.py`. - **Streaming**: output text deltas via `on_token` → WS `chat.delta` - **Key**: profile secret or `OPENAI_API_KEY` +### Chat UI「图片专家」(绕行本矩阵) + +- **Not** a separate profile transport: when the user selects specialist **`image`** in `/chat`, `runtime/direct_loop.py` takes an early return and calls **`platform/llm/image_legacy_client.send_legacy_image_messages`** (DashScope-style `/chat/completions`), so that turn does **not** use `OpenAIResponsesModel` / chat transports above. +- Details, env vars, ACL, and UI hooks: **`docs/IMAGE_SPECIALIST_LANE.md`**. + ### `anthropic` (Anthropic Messages streaming) - **Transport**: `oclaw/platform/llm/transports/anthropic_messages.py::AnthropicMessagesModel` - **API**: Anthropic `messages.stream` surface (gateway must provide Anthropic-compatible protocol) diff --git a/interfaces/admin/chat_api.py b/interfaces/admin/chat_api.py index f761b674..1a045bf6 100644 --- a/interfaces/admin/chat_api.py +++ b/interfaces/admin/chat_api.py @@ -343,9 +343,10 @@ def _can_access_attachment(store: SqliteStore, ctx: dict[str, Any], *, attachmen def _rate_limit_attachment_download(*, actor_tenant_id: str, actor_user_id: str) -> bool: tid = str(actor_tenant_id or "").strip() uid = str(actor_user_id or "").strip() - if not tid or not uid: + # Require tenant; allow downloads when user id is unexpectedly empty (rate bucket is tenant-scoped). + if not tid: return False - key = f"{tid}:{uid}" + key = f"{tid}:{uid or '__actor__'}" now = time.time() with _ATT_DOWNLOAD_LOCK: tokens, last = _ATT_DOWNLOAD_BUCKET.get(key, (_ATT_DOWNLOAD_BURST, now)) diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js index 7fc216a2..a62b2104 100644 --- a/interfaces/admin/static/chat.js +++ b/interfaces/admin/static/chat.js @@ -480,34 +480,22 @@ function _appendAssistantTextSegments(inner, rawText, collapsedItems) { async function _buildAggregatedAssistantBubble(tsIso, items) { const inner = el("div", { class: "chat-msg chat-msg--assistant chat-msg--rich" }); const collapsedItems = []; - const _inlineImageHtmlFromAttachments = (raw) => { - const list = parseAttachments(raw); - if (!Array.isArray(list) || !list.length) return ""; - const blocks = []; - for (const att of list) { - if (!att || typeof att !== "object") continue; - const typ = String(att.type || "").toLowerCase(); - if (typ === "image" || typ === "input_image") { - const b64 = String(att.image_base64 || att.data || "").trim(); - if (!b64) continue; - const mime = String(att.mime || att.mime_type || "image/png").trim() || "image/png"; - const src = `data:${mime};base64,${b64.replace(/\s/g, "")}`; - blocks.push(`
tool image
`); - } else if (typ === "image_url") { - const src = String(att.url || att.image_url || "").trim(); - if (!src) continue; - blocks.push(`
tool image
`); - } - } - return blocks.join(""); - }; for (const it of items || []) { const kind = String((it && it.kind) || ""); const text = String((it && it.text) || ""); - const hasInlineAtt = !!(it && it.attachments); - if (!text.trim() && !(kind === "tool_result" && hasInlineAtt)) continue; + const parsedAtts = parseAttachments(it && it.attachments); + const hasInlineAtt = parsedAtts.length > 0; + if ( + !text.trim() && + !(kind === "tool_result" && hasInlineAtt) && + !(kind === "assistant_text" && hasInlineAtt) + ) { + continue; + } if (kind === "assistant_text") { _appendAssistantTextSegments(inner, text, collapsedItems); + const attsAssistant = await renderAttachmentsEl(it && it.attachments); + if (attsAssistant) inner.appendChild(attsAssistant); } else if (kind === "reasoning") { if (adminChatShowToolOutput) collapsedItems.push({ title: t("reasoning.summary"), text }); } else if (kind === "tool_call") { @@ -538,6 +526,7 @@ async function _buildAggregatedAssistantBubble(tsIso, items) { } const hasVisible = !!inner.querySelector(".chat-msg__md, .chat-msg__reasoning, .chat-msg__wiki") || + !!inner.querySelector(".chat-att-wrap, img.chat-att-img, a.chat-att-ref__link, .chat-att-chip") || String(inner.textContent || "").trim().length > 0; if (!hasVisible) { inner.appendChild(el("div", { class: "muted", text: t("chat.tools.hidden") })); @@ -596,8 +585,14 @@ function _buildRenderRows(msgs) { if (String(content || "").trim()) { agg._items.push({ kind: "reasoning", text: content }); } - } else if ((eventType === "assistant_text" || eventType === "assistant" || !eventType) && String(content || "").trim()) { - agg._items.push({ kind: "assistant_text", text: content }); + } else if (eventType === "assistant_text" || eventType === "assistant" || !eventType) { + const hasText = !!String(content || "").trim(); + const attsParsed = parseAttachments(m && m.attachments); + if (hasText || attsParsed.length) { + const piece = { kind: "assistant_text", text: content }; + if (attsParsed.length) piece.attachments = attsParsed; + agg._items.push(piece); + } } const tc = m.tool_calls; if (tc != null && tc !== "") { @@ -610,9 +605,16 @@ function _buildRenderRows(msgs) { if (line) agg._items.push({ kind: "tool_call", text: line }); } if (m.attachments) agg.attachments = m.attachments; - } else if (String(content || "").trim()) { - agg._items.push({ kind: "tool_result", text: content, attachments: m.attachments || null }); - if (m.attachments) agg.attachments = m.attachments; + } else { + const hasToolText = !!String(content || "").trim(); + const toolAtts = parseAttachments(m && m.attachments); + if (hasToolText || toolAtts.length) { + const piece = { kind: "tool_result", text: content }; + if (toolAtts.length) piece.attachments = toolAtts; + else if (m.attachments) piece.attachments = m.attachments; + agg._items.push(piece); + if (m.attachments) agg.attachments = m.attachments; + } } continue; } @@ -2235,7 +2237,37 @@ async function renderAttachmentsEl(raw) { } } else if (typ === "image_url") { const src = String(att.url || att.image_url || "").trim(); - const img = el("img", { class: "chat-att-img", src, alt: "" }); + if (!src) continue; + const img = el("img", { + class: "chat-att-img", + src, + alt: String(att.name || "generated image"), + decoding: "async", + referrerPolicy: "no-referrer", + }); + img.addEventListener( + "error", + () => { + try { + const parent = img.parentNode; + if (!parent) return; + parent.replaceChild( + el("a", { + class: "chat-att-ref__link", + href: src, + target: "_blank", + rel: "noopener noreferrer", + text: + currentLang === "zh" + ? "图片无法嵌入显示(可能被防盗链),点击打开原始链接" + : "Image blocked from inline display; open original URL", + }), + img, + ); + } catch (_) {} + }, + { once: true }, + ); wrap.appendChild(img); } else if (typ === "video_ref" || typ === "text_ref" || typ === "binary_ref") { wrap.appendChild(await buildRefCard(att, typ)); @@ -4026,16 +4058,18 @@ async function renderChatUi() { const role = String(m.role || "").toLowerCase(); if (requireRole && role !== "assistant") return null; if (requireContentArray && !Array.isArray(m.content)) return null; - if (!("content" in m) && !("text" in m)) return null; + const hasRootAttachments = m.attachments != null && m.attachments !== ""; + if (!("content" in m) && !("text" in m) && !hasRootAttachments) return null; return m; }; const _expandAssistantMessageForRender = (message) => { if (!message || typeof message !== "object") return []; + const mesAttParsed = parseAttachments(message.attachments); + const mesAtt = mesAttParsed.length ? mesAttParsed : null; const base = { role: "assistant", id: message.id, timestamp: message.timestamp != null ? message.timestamp : new Date().toISOString(), - attachments: message.attachments || null, }; const out = []; const contentItems = Array.isArray(message.content) ? message.content : []; @@ -4061,18 +4095,28 @@ async function renderChatUi() { ...out[lastIdx], tool_calls: message.tool_calls != null ? message.tool_calls : message.toolCalls, }; + // Avoid duplicating blobs: attach WS/API root attachments to the latest assistant_text row only. + if (mesAtt && mesAtt.length) { + for (let i = out.length - 1; i >= 0; i -= 1) { + if (String(out[i].event_type || "") === "assistant_text") { + out[i] = { ...out[i], attachments: mesAtt }; + break; + } + } + } return out; } const textFallback = decodeEscapedNewlines( typeof message.content === "string" ? message.content : typeof message.text === "string" ? message.text : "", ).trim(); - if (textFallback) { + if (textFallback || (mesAtt && mesAtt.length)) { return [ { ...base, content: textFallback, event_type: "assistant_text", tool_calls: message.tool_calls != null ? message.tool_calls : message.toolCalls, + ...(mesAtt && mesAtt.length ? { attachments: mesAtt } : {}), }, ]; } @@ -4488,11 +4532,16 @@ ${autoLimit ? `
auto-added claus chatStreamSegments.push({ type: "tool", key, title: `${name} ${liveTag}`, body, sqlAudit, images }); }; const appendFinalAssistant = async (message, fallbackText) => { + const wsAttachments = + message && typeof message === "object" && message.attachments != null ? message.attachments : null; const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false }); if (normalized) { const rows = _buildRenderRows(_expandAssistantMessageForRender(normalized)); const last = rows && rows.length ? rows[rows.length - 1] : null; if (last) { + if (wsAttachments != null && (last.attachments == null || last.attachments === "")) { + last.attachments = wsAttachments; + } if (streamRow && streamRow.parentNode) streamRow.remove(); await appendMessageRow(messagesEl, last, rowRenderOptions); scrollMessagesToBottom(true); @@ -4502,11 +4551,16 @@ ${autoLimit ? `
auto-added claus const t0 = String(fallbackText || "").trim(); if (t0 && !_isSilentReplyStream(t0)) { if (streamRow && streamRow.parentNode) streamRow.remove(); - await appendMessageRow(messagesEl, { - role: "assistant", - content: decodeEscapedNewlines(t0), - timestamp: new Date().toISOString(), - }, rowRenderOptions); + await appendMessageRow( + messagesEl, + { + role: "assistant", + content: decodeEscapedNewlines(t0), + timestamp: new Date().toISOString(), + ...(wsAttachments != null ? { attachments: wsAttachments } : {}), + }, + rowRenderOptions, + ); scrollMessagesToBottom(true); return true; } diff --git a/interfaces/ws/turn_runner.py b/interfaces/ws/turn_runner.py index 1f7ffe94..23bda0de 100644 --- a/interfaces/ws/turn_runner.py +++ b/interfaces/ws/turn_runner.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import threading import uuid from datetime import datetime @@ -13,6 +14,28 @@ from oclaw.platform.config.paths import db_path from oclaw.platform.persistence.sqlite_store import SqliteStore +def _persisted_chat_attachments_nonempty(raw: Any) -> bool: + """True when chat_message.attachments has at least one JSON object (list or dict or encoded string).""" + if raw is None: + return False + if isinstance(raw, list): + return len(raw) > 0 + if isinstance(raw, dict): + return bool(raw) + s = str(raw).strip() + if not s or s.lower() == "null": + return False + try: + parsed = json.loads(s) + if isinstance(parsed, list): + return len(parsed) > 0 + if isinstance(parsed, dict): + return bool(parsed) + except Exception: + return bool(s) + return False + + async def run_agent_turn_via_bridge( *, conn: Any, @@ -533,12 +556,13 @@ async def run_agent_turn_via_bridge( pass elif run_status != "failed": try: - persisted = store.get_messages(session_id=session_id, limit=12) + persisted = store.get_messages(session_id=session_id, limit=64) for m in reversed(list(persisted or [])): if str(getattr(m, "role", "") or "").lower() != "assistant": continue content = str(getattr(m, "content", "") or "") - if not content.strip(): + atraw = getattr(m, "attachments", None) + if not content.strip() and not _persisted_chat_attachments_nonempty(atraw): continue final_text = content final_msg = { @@ -547,7 +571,7 @@ async def run_agent_turn_via_bridge( "content": content, "timestamp": str(getattr(m, "timestamp", "") or ""), "tool_calls": getattr(m, "tool_calls", None), - "attachments": getattr(m, "attachments", None), + "attachments": atraw, } break except Exception: diff --git a/platform/llm/image_http_common.py b/platform/llm/image_http_common.py index 1fe936ba..8ba5a1a6 100644 --- a/platform/llm/image_http_common.py +++ b/platform/llm/image_http_common.py @@ -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," + if len(s) > 500: + return s[:400] + f"..." + 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…" + 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] + "…" + 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", diff --git a/platform/llm/image_legacy_client.py b/platform/llm/image_legacy_client.py index 08c63a32..cfa9bd64 100644 --- a/platform/llm/image_legacy_client.py +++ b/platform/llm/image_legacy_client.py @@ -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": ""}, {"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…" + 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," + 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", +] diff --git a/platform/llm/transports/openai_responses.py b/platform/llm/transports/openai_responses.py index 479171dd..29046182 100644 --- a/platform/llm/transports/openai_responses.py +++ b/platform/llm/transports/openai_responses.py @@ -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:") + 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...<>\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," + if len(s) > 16_000: + return s[:12_000] + f"..." + 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...<>\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":"","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:]) diff --git a/platform/persistence/sqlite_store.py b/platform/persistence/sqlite_store.py index 270d5c25..6f49ece8 100644 --- a/platform/persistence/sqlite_store.py +++ b/platform/persistence/sqlite_store.py @@ -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, diff --git a/runtime/agent_core_attempt.py b/runtime/agent_core_attempt.py index 92379768..fa30c897 100644 --- a/runtime/agent_core_attempt.py +++ b/runtime/agent_core_attempt.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from dataclasses import dataclass from typing import Any, Callable, Optional @@ -74,36 +75,67 @@ class AttemptRunnerOutput: outcome: TurnRunOutcome +def _exception_reason_for_chat(exc: BaseException, *, max_chars: int = 12000) -> str: + """Human-visible attempt failure text (WS/chat reads ``AttemptState.reason``). + + OpenAI Python SDK's ``APIError`` often carries a decoded JSON ``body``; include it so oclaw /chat + can show the full upstream validation payload instead of only ``str(exc)``. + """ + head = f"{type(exc).__name__}:{exc}" + blob = "" + cur: BaseException | None = exc + seen_ids: set[int] = set() + depth = 0 + while cur is not None and depth < 16: + if id(cur) in seen_ids: + break + seen_ids.add(id(cur)) + depth += 1 + body = getattr(cur, "body", None) + if body is not None: + try: + blob = json.dumps(body, ensure_ascii=False, indent=2) if isinstance(body, (dict, list)) else str(body) + except Exception: + blob = str(body) + break + cur = cur.__cause__ + out = head if not blob else f"{head}\n\nupstream_json_body:\n{blob}" + if len(out) > max_chars: + out = out[: max_chars - 24] + "\n...[truncated]" + return out + + def _classify_attempt_error(exc: Exception) -> tuple[str, str, bool]: raw = f"{type(exc).__name__}:{exc}" low = raw.lower() + reason = _exception_reason_for_chat(exc) if "relay_envelope_unsupported_version" in low: - return ("relay_envelope_unsupported_version", raw[:500], False) + return ("relay_envelope_unsupported_version", reason, False) if "relay_envelope_invalid" in low: - return ("relay_envelope_invalid", raw[:500], False) + return ("relay_envelope_invalid", reason, False) if "interrupted" in low or "cancel" in low or "stopped" in low: - return ("control_interrupted", raw[:500], False) + return ("control_interrupted", reason, False) if "api_key" in low or "invalid api key" in low or "unauthorized" in low or "401" in low or "forbidden" in low or "403" in low: - return ("auth_invalid_credentials", raw[:500], False) + return ("auth_invalid_credentials", reason, False) if "invalid tool_result sequence" in low or "unexpected tool_use_id" in low: - return ("tool_replay_protocol_mismatch", raw[:500], True) + return ("tool_replay_protocol_mismatch", reason, True) if "invalid_request" in low or "bad_request" in low or "400" in low: - return ("input_invalid_request", raw[:500], False) + return ("input_invalid_request", reason, False) if "context_length" in low or "token limit" in low or "max context" in low: - return ("context_overflow", raw[:500], True) + return ("context_overflow", reason, True) if "tool_loop_guard" in low: - return ("tool_loop_guard", raw[:500], False) + return ("tool_loop_guard", reason, False) if "tool_timeout_or_failed" in low or "tool_execution_error" in low: - return ("tool_execution_failed", raw[:500], True) + return ("tool_execution_failed", reason, True) if "timeout" in low: - return ("provider_timeout", raw[:500], True) + return ("provider_timeout", reason, True) if "rate" in low and "limit" in low: - return ("provider_rate_limited", raw[:500], True) + return ("provider_rate_limited", reason, True) if "temporary" in low or "temporar" in low: - return ("provider_temporary_error", raw[:500], True) + return ("provider_temporary_error", reason, True) if "connection" in low or "network" in low or "503" in low or "502" in low: - return ("provider_unavailable", raw[:500], True) - return ("runtime_unknown_error", raw[:500], False) + return ("provider_unavailable", reason, True) + return ("runtime_unknown_error", reason, False) def run_attempt(*, store: Any, data: AttemptRunnerInput) -> AttemptRunnerOutput: diff --git a/runtime/agents/specialist_agent.py b/runtime/agents/specialist_agent.py index c9b95aff..e658fae1 100644 --- a/runtime/agents/specialist_agent.py +++ b/runtime/agents/specialist_agent.py @@ -2,10 +2,9 @@ from __future__ import annotations import json import os +import sys import time -import base64 import hashlib -import httpx from collections.abc import Callable from dataclasses import dataclass, field from typing import Any, Optional @@ -14,8 +13,13 @@ from oclaw.runtime.chat.agent import Agent from oclaw.runtime.chat.agent import GenerationInterrupted from oclaw.runtime.agents.network_ops_agent import NetworkOpsAgent from oclaw.platform.persistence.sqlite_store import SqliteStore -from oclaw.platform.files.attachment_assets import AttachmentAssetStore, attachment_id_to_data_url -from oclaw.platform.llm.image_ocr_client import send_ocr_image_messages +from oclaw.platform.llm.image_legacy_client import ( + IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH, + collect_legacy_lane_images_from_attachments, + legacy_image_assistant_body_with_placeholder, + legacy_image_turn_bundle, + send_legacy_image_messages, +) from oclaw.runtime.tools import default_registry from oclaw.runtime.agents.specialists import expert_name_for_specialist @@ -233,56 +237,44 @@ class SpecialistAgentRunner: try: if step.specialist == "image": image_protocol = "messages.content.image" - selected_images: list[str] = [] - for att in parent_task.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: - selected_images.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:"): - selected_images.append(raw) - else: - selected_images.append(f"data:{mime};base64,{raw}") - elif t == "image_url": - u = str(att.get("url") or "").strip() - if u: - selected_images.append(u) - if len(selected_images) >= 3: - break + selected_images = collect_legacy_lane_images_from_attachments( + list(parent_task.attachments or []), + max_images=3, + ) image_input_count = len(selected_images) image_input_kind = ["data_url" if s.startswith("data:") else "url" for s in selected_images] if not selected_images: output = "Image specialist received no image input." ok = False else: + # Use the user's chosen model/session profile (same as specialist routing UI). Wrong model ⇒ upstream HTTP error as-is (no OCR lane, no alternate payload). _, chosen_model, _ = self._resolve_profile_and_model(step.specialist) - model_name = str( - os.getenv("AIA_OCR_MODEL") or getattr(chosen_model, "model", None) or "" - ).strip() or None - api_key = str(getattr(chosen_model, "api_key", "") or "").strip() or None - base_url = str(getattr(chosen_model, "base_url", "") or "").strip() or None text_parts = [ str(x).strip() for x in (step.objective, step.input_text, parent_task.user_text) if str(x or "").strip() ] - user_text = "\n".join(text_parts) if text_parts else "请根据用户上传的图片作答:描述可见场景、物体与文字;不确定处请标明。" - resp = send_ocr_image_messages( + user_text = "\n".join(text_parts) if text_parts else IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH + if str(os.getenv("AIA_IMAGE_EXPERT_DEBUG_PRINT_PAYLOAD") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ): + try: + sys.stderr.write( + "[oclaw specialist:image] lane=legacy_http → send_legacy_image_messages " + "(NOT OpenAIResponsesModel).\n" + ) + sys.stderr.flush() + except Exception: + pass + resp = send_legacy_image_messages( images=selected_images, prompt=user_text, - model=model_name, - api_key=api_key, - base_url=base_url, + model=str(getattr(chosen_model, "model", "") or "").strip() or None, + api_key=str(getattr(chosen_model, "api_key", "") or "").strip() or None, + base_url=str(getattr(chosen_model, "base_url", "") or "").strip() or None, ) image_debug_schema = str(resp.get("debug_used_schema") or "").strip() dbg = resp.get("debug_used_debug") @@ -290,91 +282,12 @@ class SpecialistAgentRunner: image_debug_payload = dbg elif dbg is not None: image_debug_payload = str(dbg) - ok = bool(resp.get("ok")) - output = str(resp.get("text") or "").strip() - if not ok: - err = str(resp.get("error") or "").strip() - output = f"Image generation failed: {err or 'unknown error'}" - elif not output: - output = "Image processed." - # persist output images as attachment assets for UI rendering - produced_attachments: list[dict[str, Any]] = [] - if ok: - out_images = resp.get("images") - if isinstance(out_images, list): - store = AttachmentAssetStore() - for idx, item in enumerate(out_images[:3], 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_attachments.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: - with httpx.Client(timeout=20.0, follow_redirects=True) as client: - r = client.get(s) - if r.status_code < 400 and r.content: - mime = str(r.headers.get("content-type") or "image/png").split(";", 1)[0].strip() 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( - r.content, - filename=f"image-output-{idx}{ext}", - mime=mime, - ) - produced_attachments.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_attachments.append( - { - "type": "image_url", - "url": s, - "name": f"image-output-{idx}.png", - } - ) - # Treat missing image outputs as failure to avoid false "generated" state. - if not produced_attachments: - ok = False - output = ( - "Image generation failed: response succeeded but no image output was returned." - ) + ok, output, produced_attachments = legacy_image_turn_bundle(resp) + output = legacy_image_assistant_body_with_placeholder( + lang=self.lang, + body_text=output, + produced=produced_attachments if ok else None, + ) self.store.add_message( session_id=session_id, role="assistant", diff --git a/runtime/chat/agent_messages.py b/runtime/chat/agent_messages.py index f9d19c9e..9915e404 100644 --- a/runtime/chat/agent_messages.py +++ b/runtime/chat/agent_messages.py @@ -210,8 +210,10 @@ def build_llm_messages( :func:`~oclaw.runtime.direct_loop._guard_tool_results_for_llm_context`). Omit or leave empty to apply image-blob stripping for every tool row (safe default for callers without turn context). - Only the **last** user message may expand attachments into native multimodal ``input_image``; - older user attachments are replayed as text metadata only. + Only the **last** user message may expand attachments into native multimodal ``input_image`` + blocks shaped like ``{"type":"input_image","image_base64","mime"}``; ``OpenAIChatModel`` and + ``OpenAIResponsesModel`` both accept this shape (see transport normalization code). Older user + attachments are replayed as text metadata only. """ out: list[dict[str, Any]] = [{"role": "system", "content": (system_prompt or "").strip()}] dropped_unpaired_tool_rows = 0 diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py index a74da5e8..dd4adbca 100644 --- a/runtime/direct_loop.py +++ b/runtime/direct_loop.py @@ -1102,6 +1102,101 @@ def _execute_tool_step( return int((time.perf_counter() - t0) * 1000), results_by_id +def _maybe_image_specialist_legacy_gateway_turn( + *, + store: Any, + session_id: str, + turn_uuid: str, + lang: str, + model: ChatModel, + user_text: str, + attachments: list[dict[str, Any]] | None, + skill_binding_role: str | None, + on_token: Optional[Callable[[str], None]], + on_progress: Optional[Callable[[str], None]], +) -> TurnRunOutcome | None: + """When the UI selects **image** specialist, skip Responses/chat-model transports. + + Vision/gen HTTP goes through :func:`oclaw.platform.llm.image_legacy_client.send_legacy_image_messages` + (``/chat/completions`` lane). Disable with ``AIA_IMAGE_SPECIALIST_DISABLE_LEGACY_GATEWAY_LANE=1``. + + End-to-end notes and safe edit boundaries: ``docs/IMAGE_SPECIALIST_LANE.md``. + """ + if str(os.getenv("AIA_IMAGE_SPECIALIST_DISABLE_LEGACY_GATEWAY_LANE") or "").strip().lower() in ( + "1", + "true", + "yes", + "on", + ): + return None + if str(skill_binding_role or "").strip().lower() != "image": + return None + + from oclaw.platform.llm.image_legacy_client import ( + IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH, + collect_legacy_lane_images_from_attachments, + legacy_image_assistant_body_with_placeholder, + legacy_image_turn_bundle, + send_legacy_image_messages, + ) + + imgs = collect_legacy_lane_images_from_attachments(attachments) + if not imgs: + hint_en = "Image specialist received no image input. Attach an image and try again." + hint_zh = "图片专家未收到可用的图片输入;请先上传或附上图片后再试。" + hint = hint_en if str(lang or "").startswith("en") else hint_zh + store.add_message( + session_id=session_id, + role="assistant", + content=hint, + turn_uuid=turn_uuid, + event_type="assistant_text", + ) + return TurnRunOutcome( + final_text=hint, + tool_traces=tuple(), + handoff_note="image_specialist_legacy_missing_attachment", + turn_uuid=turn_uuid, + ) + + if on_progress: + on_progress("oclaw: image specialist (legacy multimodal HTTP)…") + + prompt_plain = str(user_text or "").strip() + if not prompt_plain: + prompt_plain = IMAGE_SPECIALIST_DEFAULT_PROMPT_ZH + + resp = send_legacy_image_messages( + images=imgs, + prompt=prompt_plain, + model=str(getattr(model, "model", "") or "").strip() or None, + api_key=str(getattr(model, "api_key", "") or "").strip() or None, + base_url=str(getattr(model, "base_url", "") or "").strip() or None, + ) + ok, body_text, produced = legacy_image_turn_bundle(resp) + body_text = legacy_image_assistant_body_with_placeholder( + lang=lang, + body_text=body_text, + produced=produced if ok else None, + ) + store.add_message( + session_id=session_id, + role="assistant", + content=body_text, + turn_uuid=turn_uuid, + event_type="assistant_text", + attachments=(produced or None) if ok else None, + ) + if ok and on_token and body_text: + on_token(body_text) + return TurnRunOutcome( + final_text=body_text, + tool_traces=tuple(), + handoff_note="image_specialist_legacy_http" if ok else "image_specialist_legacy_upstream_failed", + turn_uuid=turn_uuid, + ) + + def run_oclaw_direct_loop( *, store: Any, @@ -1149,6 +1244,20 @@ def run_oclaw_direct_loop( turn_uuid=turn_uuid, event_type="user_text", ) + legacy_early = _maybe_image_specialist_legacy_gateway_turn( + store=store, + session_id=session_id, + turn_uuid=turn_uuid, + lang=lang, + model=model, + user_text=str(user_text or ""), + attachments=attachments, + skill_binding_role=skill_binding_role, + on_token=on_token, + on_progress=on_progress, + ) + if legacy_early is not None: + return legacy_early skill_exec = SkillExecutor(config=ToolExecutionConfig(max_workers=max(1, min(int(max_tool_workers or 8), 32)))) tool_traces: list[dict[str, Any]] = [] diff --git a/runtime/operations/scripts/probe_openai_responses_image.py b/runtime/operations/scripts/probe_openai_responses_image.py new file mode 100644 index 00000000..219130c7 --- /dev/null +++ b/runtime/operations/scripts/probe_openai_responses_image.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""POST minimal vision payloads to an OpenAI-compatible ``/responses`` endpoint. + +Run locally: loads ``oclaw/_local/system.env`` the same way the gateway does, then POSTs variants. + +Example:: + + set OPENAI_BASE_URL=https://... + set OPENAI_API_KEY=sk-... + set OPENAI_MODEL=qwen-vl-plus + python runtime/operations/scripts/probe_openai_responses_image.py +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +# Same bootstrap as gateway: ``interfaces/http/fastapi_app.py`` calls ``load_system_env()`` so +# ``oclaw/_local/system.env`` is merged before reading ``OPENAI_*``. +try: + from oclaw.platform.config.bootstrap_env import load_system_env + + load_system_env() +except ImportError: + pass + +try: + import httpx +except ImportError: + print("install httpx: pip install httpx", file=sys.stderr) + raise SystemExit(2) + +# 1x1 transparent PNG +_PNG_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" +) + + +def _variants(model: str, data_uri: str) -> list[tuple[str, dict]]: + user_block_resp = { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "What color is this pixel image? One word."}, + {"type": "input_image", "image_url": data_uri, "detail": "auto"}, + ], + } + user_plain_resp = { + "role": "user", + "content": [ + {"type": "input_text", "text": "What color is this pixel image? One word."}, + {"type": "input_image", "image_url": data_uri, "detail": "auto"}, + ], + } + user_chat = { + "role": "user", + "content": [ + {"type": "text", "text": "What color is this pixel image? One word."}, + {"type": "image_url", "image_url": {"url": data_uri}}, + ], + } + return [ + ("flat_resp_envelope", {"model": model, "input": [user_block_resp]}), + ("flat_resp_plain", {"model": model, "input": [user_plain_resp]}), + ("nested_messages_resp_env", {"model": model, "input": {"messages": [user_block_resp]}}), + ("nested_messages_resp_plain", {"model": model, "input": {"messages": [user_plain_resp]}}), + ("nested_messages_chat", {"model": model, "input": {"messages": [user_chat]}}), + ] + + +def main() -> None: + base = (os.getenv("OPENAI_BASE_URL") or "https://api.openai.com/v1").strip().rstrip("/") + key = (os.getenv("OPENAI_API_KEY") or "").strip() + model = (os.getenv("OPENAI_MODEL") or "gpt-4o-mini").strip() + if not key: + print("OPENAI_API_KEY is required", file=sys.stderr) + raise SystemExit(2) + + url = f"{base}/responses" + data_uri = f"data:image/png;base64,{_PNG_B64}" + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + + print(f"POST {url}", flush=True) + for label, body in _variants(model, data_uri): + try: + r = httpx.post(url, headers=headers, json=body, timeout=120.0) + except httpx.HTTPError as exc: + print(f"\n=== {label} transport_error {exc}", flush=True) + continue + tail = (r.text or "")[:2400] + print(f"\n=== {label} status={r.status_code}", flush=True) + print(tail, flush=True) + if r.status_code < 400: + print("(first successful variant — use this shape for your gateway)", flush=True) + break + + +if __name__ == "__main__": + main() diff --git a/tests/test_attachment_acl_backfill.py b/tests/test_attachment_acl_backfill.py index 52be39ce..bb3fdc49 100644 --- a/tests/test_attachment_acl_backfill.py +++ b/tests/test_attachment_acl_backfill.py @@ -58,7 +58,7 @@ class AttachmentAclBackfillTests(unittest.TestCase): def _h(self) -> dict[str, str]: return {"authorization": f"Bearer {self.token}"} - def test_backfill_enables_strict_acl_download(self) -> None: + def test_add_message_links_acl_for_strict_download(self) -> None: store = SqliteStore(str(self.db)) sess = store.create_session_for_user(title="t", tenant_id=self.tenant_id, user_id=self.alice_id) ast = AttachmentAssetStore() @@ -75,11 +75,12 @@ class AttachmentAclBackfillTests(unittest.TestCase): prev = os.environ.get("AIA_ATTACHMENT_ACL_STRICT") os.environ["AIA_ATTACHMENT_ACL_STRICT"] = "1" try: - # Strict mode: without backfill, this should be forbidden (not avatar). + # Strict mode: add_message should have written attachment_acl (not only tool_result rows). r0 = self.client.get(f"/admin/api/chat/attachments/{aid}", headers=self._h()) - self.assertEqual(r0.status_code, 403, r0.text) + self.assertEqual(r0.status_code, 200, r0.text) + self.assertTrue(len(r0.content) > 10) - # Backfill ACL for tenant, then strict download should succeed. + # Backfill remains idempotent. res = store.backfill_attachment_acl_from_messages(tenant_id=self.tenant_id, limit_messages=5000) self.assertTrue(res.get("ok"), res) r1 = self.client.get(f"/admin/api/chat/attachments/{aid}", headers=self._h()) diff --git a/tests/test_image_expert_env_lane.py b/tests/test_image_expert_env_lane.py new file mode 100644 index 00000000..a54e77f8 --- /dev/null +++ b/tests/test_image_expert_env_lane.py @@ -0,0 +1,60 @@ +"""Image specialist lane must use ``AIA_IMAGE_EXPERT_*``, not ``AIA_OCR_*``.""" + +from __future__ import annotations + +from oclaw.platform.llm.image_legacy_client import send_legacy_image_messages + + +def test_legacy_fails_when_only_ocr_env_configured(monkeypatch: object) -> None: + monkeypatch.setenv("AIA_OCR_API_KEY", "k-ocr") + monkeypatch.setenv("AIA_OCR_BASE_URL", "https://ocr.example/v1") + monkeypatch.setenv("AIA_OCR_MODEL", "ocr-model") + for k in ("AIA_IMAGE_EXPERT_API_KEY", "AIA_IMAGE_EXPERT_BASE_URL", "AIA_IMAGE_EXPERT_MODEL"): + monkeypatch.delenv(k, raising=False) + out = send_legacy_image_messages( + images=["https://example.com/x.png"], + prompt="hi", + model=None, + api_key=None, + base_url=None, + ) + assert out.get("ok") is False + err = str(out.get("error") or "") + assert "AIA_IMAGE_EXPERT" in err + + +def test_legacy_accepts_explicit_kwargs_without_expert_env(monkeypatch: object) -> None: + """Explicit api_key/base_url/model override env (for scripts/tests only).""" + for k in ( + "AIA_OCR_API_KEY", + "AIA_OCR_BASE_URL", + "AIA_IMAGE_EXPERT_API_KEY", + "AIA_IMAGE_EXPERT_BASE_URL", + ): + monkeypatch.delenv(k, raising=False) + + recorded: dict[str, object] = {} + + def fake_post(client, *, url: str, headers: dict[str, str], payload: dict[str, object]) -> object: + recorded["url"] = url + + class R: + status_code = 200 + + def json(_self): # noqa: PLR6301 + return {"choices": [{"message": {"content": [{"text": "ok"}]}}]} + + return R() + + import oclaw.platform.llm.image_legacy_client as mod + + monkeypatch.setattr(mod, "post_with_retry", fake_post) + out = send_legacy_image_messages( + images=["https://example.com/x.png"], + prompt="hello", + model="m-special", + api_key="explicit-key", + base_url="https://gw.example/expert/v1", + ) + assert out.get("ok") is True + assert "/chat/completions" in str(recorded.get("url") or "") diff --git a/tests/test_image_legacy_gateway_lane.py b/tests/test_image_legacy_gateway_lane.py new file mode 100644 index 00000000..e9dc72c1 --- /dev/null +++ b/tests/test_image_legacy_gateway_lane.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from oclaw.platform.llm.image_http_common import ( + dashscope_multimodal_http_ok, + dashscope_native_multimodal_url_from_compatible_base, + extract_text_and_images, +) +from oclaw.platform.llm.image_http_common import build_extract_diag_empty +from oclaw.platform.llm.image_legacy_client import ( + collect_legacy_lane_images_from_attachments, + legacy_image_assistant_body_with_placeholder, + legacy_image_turn_bundle, + normalize_legacy_output_image_urls, +) + + +def test_normalize_legacy_output_dict_image_parts() -> None: + u = "https://dashscope-result-sz.oss-cn-shenzhen.aliyuncs.com/x.png?Expires=1" + assert normalize_legacy_output_image_urls([{"image": u}]) == [u] + assert normalize_legacy_output_image_urls([{"image_url": {"url": u}}]) == [u] + + +def test_legacy_turn_bundle_coerces_dict_images_to_attachments() -> None: + ok, text, att = legacy_image_turn_bundle( + {"ok": True, "text": "", "images": [{"image": "https://example.invalid/a.png"}]} + ) + assert ok is True + assert len(att) == 1 + assert att[0]["type"] == "image_url" + assert att[0]["url"] == "https://example.invalid/a.png" + + +def test_dashscope_native_url_from_compatible_base() -> None: + assert ( + dashscope_native_multimodal_url_from_compatible_base( + "https://dashscope.aliyuncs.com/compatible-mode/v1" + ) + == "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation" + ) + assert ( + dashscope_native_multimodal_url_from_compatible_base("https://example.com/openai/v1") is None + ) + + +def test_collect_legacy_lane_images_image_url() -> None: + atts = [{"type": "image_url", "url": "https://example.invalid/x.png"}] + assert collect_legacy_lane_images_from_attachments(atts) == ["https://example.invalid/x.png"] + + +def test_collect_legacy_lane_images_raw_base64() -> None: + atts = [{"type": "input_image", "mime": "image/png", "image_base64": "SGVsbG8="}] + got = collect_legacy_lane_images_from_attachments(atts) + assert len(got) == 1 + assert got[0].startswith("data:image/png;base64,") + + +def test_legacy_turn_bundle_text_only_success() -> None: + ok, text, att = legacy_image_turn_bundle({"ok": True, "text": "caption only", "images": []}) + assert ok is True + assert text == "caption only" + assert att == [] + + +def test_legacy_image_assistant_placeholder_zh_en() -> None: + produced = [{"type": "image_ref", "attachment_id": "a" * 64}] + assert "附件" in legacy_image_assistant_body_with_placeholder( + lang="zh", body_text="", produced=produced + ) + assert "attachment" in legacy_image_assistant_body_with_placeholder( + lang="en", body_text="", produced=produced + ).lower() + assert legacy_image_assistant_body_with_placeholder(lang="zh", body_text="x", produced=produced) == "x" + + +def test_legacy_turn_bundle_upstream_error() -> None: + ok, text, att = legacy_image_turn_bundle({"ok": False, "error": "rate"}) + assert ok is False + assert "rate" in text + assert att == [] + + +def test_extract_harvests_nested_https_under_message() -> None: + url = "https://cdn.example.invalid/generated.png" + text, images = extract_text_and_images( + { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "metadata": {"preview_image": url}, + } + } + ] + } + ) + assert text == "" + assert images == [url] + + +def test_build_extract_diag_top_level_openai_choices() -> None: + d = build_extract_diag_empty( + { + "choices": [], + "model": "x", + } + ) + assert d.get("choices_len") == 0 + + +def test_legacy_turn_bundle_includes_provider_redacted() -> None: + ok, msg, att = legacy_image_turn_bundle( + { + "ok": True, + "text": "", + "images": [], + "extract_diag": {"choices_len": 0}, + "provider_response_redacted": '{"choices":[]}', + } + ) + assert ok is False + assert "provider_json=" in msg + assert att == [] + + +def test_legacy_turn_bundle_empty_ok_response_fails() -> None: + ok, text, att = legacy_image_turn_bundle({"ok": True, "text": "", "images": []}) + assert ok is False + assert att == [] + + +def test_extract_text_and_images_dashscope_output_wrapper() -> None: + """Native multimodal HTTP wraps ``choices`` under ``output`` (not top-level OpenAI shape).""" + url = "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/x.png?Expires=1" + payload = { + "status_code": 200, + "request_id": "959afba6-544e-487e-b58a-6bd9fea97xxx", + "code": "", + "message": "", + "output": { + "text": None, + "finish_reason": None, + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"image": url}], + }, + } + ], + "audio": None, + }, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "image_count": 1, + "width": 2048, + "height": 2048, + }, + } + text, images = extract_text_and_images(payload) + assert text == "" + assert images == [url] + assert dashscope_multimodal_http_ok(payload)[0] is True + + +def test_extract_text_and_images_content_dict_not_list() -> None: + """Some gateways return a single object for ``message.content`` instead of an array.""" + url = "https://dashscope-result-hz.oss-cn-hangzhou.aliyuncs.com/out.png" + text, images = extract_text_and_images( + { + "output": { + "choices": [ + { + "message": { + "role": "assistant", + "content": {"image": url}, + } + } + ] + } + } + ) + assert text == "" + assert images == [url] + + +def test_extract_text_and_images_messages_fallback() -> None: + text, images = extract_text_and_images( + { + "output": { + "messages": [ + {"role": "user", "content": "x"}, + { + "role": "assistant", + "content": [{"image": "https://example.invalid/gen.png"}], + }, + ] + } + } + ) + assert images == ["https://example.invalid/gen.png"] + + +def test_extract_text_and_images_typed_image_url_part() -> None: + text, images = extract_text_and_images( + { + "choices": [ + { + "message": { + "content": [ + { + "type": "image_url", + "image_url": {"url": "https://example.invalid/v.png"}, + } + ] + } + } + ] + } + ) + assert images == ["https://example.invalid/v.png"] + + +def test_dashscope_envelope_rejects_non_success_code() -> None: + ok, msg = dashscope_multimodal_http_ok({"code": "InvalidParameter", "message": "bad"}) + assert ok is False + assert "bad" in msg + + +def test_extract_text_and_images_openai_top_level_unchanged() -> None: + text, images = extract_text_and_images( + { + "choices": [ + { + "message": { + "content": [ + {"type": "text", "text": "hi"}, + {"image": "https://example.invalid/a.jpg"}, + ] + } + } + ] + } + ) + assert "hi" in text + assert images == ["https://example.invalid/a.jpg"] diff --git a/tests/test_openai_responses_normalize.py b/tests/test_openai_responses_normalize.py new file mode 100644 index 00000000..d197c8ef --- /dev/null +++ b/tests/test_openai_responses_normalize.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import httpx +from openai import BadRequestError + +from oclaw.platform.llm.transports.openai_responses import OpenAIResponsesModel, _is_input_messages_validation_error + + +def test_input_messages_validation_detects_body_not_str_exc() -> None: + """OpenAI SDK ``str(exc)`` is usually only ``Error code: 400``; gateway detail is in ``body``.""" + req = httpx.Request("POST", "http://example.invalid/v1/responses") + resp = httpx.Response(400, request=req) + body = { + "message": ( + "Input should be 'user': input.messages.0.role & Input should be a valid list: " + "input.messages.0.content" + ), + "type": "invalid_request_error", + "code": "invalid_parameter_error", + "param": None, + } + exc = BadRequestError("Error code: 400", response=resp, body=body) + assert "input.messages" not in str(exc).lower() + assert _is_input_messages_validation_error(exc) is True + + +def test_strip_leading_system_to_instructions_kw() -> None: + msgs = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hi"}, + ] + txt, tail = OpenAIResponsesModel._strip_leading_system_messages(msgs) + assert txt == "You are helpful." + assert tail == [{"role": "user", "content": "Hi"}] + + +def test_normalize_default_responses_parts_and_openai_envelope() -> None: + msgs = [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "https://example.invalid/x.png"}}, + {"type": "text", "text": "what?"}, + ], + } + ] + out = OpenAIResponsesModel._normalize_messages(msgs) + assert len(out) == 1 + assert out[0]["type"] == "message" + assert out[0]["role"] == "user" + cc = out[0]["content"] + assert isinstance(cc, list) + assert any( + x.get("type") == "input_image" + and isinstance(x.get("image_url"), str) + and x.get("detail") == "auto" + for x in cc + ) + assert any(x.get("type") == "input_text" and x.get("text") == "what?" for x in cc) + + +def test_normalize_nested_chat_parts_opt_in() -> None: + msgs = [ + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": "https://example.invalid/x.png"}}], + } + ] + out = OpenAIResponsesModel._normalize_messages( + msgs, envelope_openai_message=False, content_chat_completions_parts=True + ) + assert "type" not in out[0] + assert out[0]["role"] == "user" + assert any(x.get("type") == "image_url" for x in out[0]["content"]) + + +def test_normalize_dashscope_shorthand_image_text_blocks() -> None: + msgs = [ + {"role": "user", "content": [{"image": "https://example.invalid/y.png"}, {"text": "caption"}]}, + ] + out = OpenAIResponsesModel._normalize_messages(msgs) + assert out[0]["type"] == "message" + assert out[0]["role"] == "user" + parts = out[0]["content"] + assert isinstance(parts, list) + imgs = [p for p in parts if isinstance(p, dict) and p.get("type") == "input_image"] + assert len(imgs) == 1 and imgs[0].get("detail") == "auto" + txts = [p for p in parts if isinstance(p, dict) and p.get("type") == "input_text"] + assert any("caption" in str(p.get("text")) for p in txts) + + +def test_responses_input_candidates_cover_flat_and_nested() -> None: + msgs = [{"role": "user", "content": "hi"}] + flat = OpenAIResponsesModel._responses_input_candidates( + msgs, flat_responses=True, prefer_envelope=True, prefer_chat_parts=False + ) + assert len(flat) >= 1 + nested = OpenAIResponsesModel._responses_input_candidates( + msgs, flat_responses=False, prefer_envelope=True, prefer_chat_parts=False + ) + tags = [t for t, _ in nested] + assert any("_messages" in t for t in tags) + assert any("_flat_input" in t for t in tags) + + +def test_responses_input_candidates_primary_combo_first() -> None: + msgs = [{"role": "user", "content": "hi"}] + nested = OpenAIResponsesModel._responses_input_candidates( + msgs, flat_responses=False, prefer_envelope=True, prefer_chat_parts=False + ) + assert nested[0][0] == "e1c0_messages" + assert nested[1][0] == "e1c0_flat_input" + + nested2 = OpenAIResponsesModel._responses_input_candidates( + msgs, flat_responses=False, prefer_envelope=False, prefer_chat_parts=True + ) + assert nested2[0][0] == "e0c1_messages" + assert nested2[1][0] == "e0c1_flat_input" + + +def test_normalize_agent_messages_style_input_image() -> None: + """Matches ``build_llm_messages`` last-turn multimodal blocks (``input_image`` + ``image_base64``).""" + msgs = [ + { + "role": "user", + "content": [ + {"type": "input_image", "image_base64": "SGk=", "mime": "image/png"}, + {"type": "text", "text": "what is this"}, + ], + } + ] + out = OpenAIResponsesModel._normalize_messages(msgs) + assert len(out) == 1 + assert out[0]["type"] == "message" + parts = out[0]["content"] + imgs = [p for p in parts if isinstance(p, dict) and p.get("type") == "input_image"] + assert len(imgs) == 1 + assert imgs[0]["image_url"].startswith("data:image/png;base64,") + assert imgs[0].get("detail") == "auto" + txts = [p for p in parts if isinstance(p, dict) and p.get("type") == "input_text"] + assert any(p.get("text") == "what is this" for p in txts) + + chat_parts = OpenAIResponsesModel._normalize_messages( + msgs, envelope_openai_message=False, content_chat_completions_parts=True + ) + cp = chat_parts[0]["content"] + assert any( + isinstance(p, dict) and p.get("type") == "image_url" and "base64" in str(p.get("image_url", {}).get("url")) + for p in cp + ) + assert any(p.get("type") == "text" and p.get("text") == "what is this" for p in cp) + + +def test_image_legacy_compatible_mode_uses_image_url_parts() -> None: + from oclaw.platform.llm.image_legacy_client import _openai_compatible_vision_content + + cc = _openai_compatible_vision_content(["data:image/jpeg;base64,SGk="], "go") + assert cc[-1]["type"] == "text" + assert cc[-1]["text"] == "go" + assert cc[0]["type"] == "image_url"