Skip to content

Commit 1b62026

Browse files
authored
Merge pull request #912 from DevMello/fix/tool-result-images-openai-chat
fix(openai-chat): deliver tool-result images to vision models Fixes #888. Chat Completions tool messages accept only strings or text parts, so images in a tool result cannot ride on the tool message itself. This collects them while the tool round is consumed and flushes them as a following user message once the round closes — the same carrier shape google.ts and kiro.ts already use. Verified on the merged tree against dev fa51fce: typecheck clean, 42 image and EOF tests pass, 37 parser/vision tests pass. Red-green ablation: four image-forwarding tests fail against unchanged dev while the image-free control still passes, so the coverage is not tautological. The #888 route was traced end to end — Claude Code tool_result, inbound.ts preserving input_image, parser.ts building the internal toolResult, and both Kimi OAuth and API-key presets resolving to openai-chat, which flattened it through contentPartsToText and produced the [image] placeholder the reporter saw. Thanks @DevMello.
2 parents 0fd775a + d0a525d commit 1b62026

7 files changed

Lines changed: 207 additions & 1 deletion

File tree

docs-site/src/content/docs/ja/reference/adapters.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ interface ProviderAdapter {
2727

2828
- 内部メッセージを OpenAI role に変換し、ツールは `{type:"function", function:{…}}`
2929
`tool_choice``auto`/`none`/`required` または指定関数)にマッピングします。
30+
- **ツール結果内の画像**は、`role:"tool"` がテキスト専用のため、ツールラウンドが閉じた後に後続の
31+
user vision メッセージ(`image_url` パート)として送られます。ツールメッセージ側には `[image]`
32+
マーカーがアンカーとして残ります。
3033
- **Codex の GPT-5 アイデンティティプロンプトを書き直し**、モデル中立な紹介に変えます。そのためルーティングされたモデルが自分を OpenAI だと主張しません。
3134
- 正確な段階がないときは **`reasoning_effort` をモデルが公表したサブセットに合わせて調整**します。
3235
プロバイダーが明示的に alias を設定しない限り、`xhigh``max` は異なるラベルのまま保ちます。`provider.noReasoningModels` に含まれる id には値を **一切送りません**

docs-site/src/content/docs/ko/reference/adapters.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ interface ProviderAdapter {
3131

3232
- 내부 메시지를 OpenAI role로 변환하고, 툴은 `{type:"function", function:{…}}`
3333
`tool_choice`(`auto`/`none`/`required` 또는 지정 함수)로 매핑합니다.
34+
- **툴 결과에 든 이미지**`role:"tool"`이 텍스트 전용이므로, 툴 라운드가 닫힌 뒤 후속
35+
user vision 메시지(`image_url` 파트)로 전달됩니다. 툴 메시지에는 `[image]` 마커가 앵커로
36+
남습니다.
3437
- **Codex의 GPT-5 정체성 프롬프트를 다시 작성**해 모델 중립적인 소개로 바꿉니다. 따라서 라우팅된
3538
모델이 자신을 OpenAI라고 주장하지 않습니다.
3639
- 정확한 단계가 없으면 **`reasoning_effort`를 모델이 알린 하위 집합에 맞춰 조정**합니다.

docs-site/src/content/docs/reference/adapters.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud),
3131

3232
- Converts internal messages to OpenAI roles; maps tools to `{type:"function", function:{…}}` and
3333
`tool_choice` (`auto`/`none`/`required` or a named function).
34+
- **Tool-result images** ride in a follow-up user vision message (`image_url` parts) released once
35+
the tool round closes, since `role:"tool"` content is text-only; the `[image]` marker stays in the
36+
tool message as the anchor.
3437
- **Rewrites Codex's GPT-5 identity prompt** to a model-agnostic intro so routed models don't claim to
3538
be OpenAI.
3639
- **Clamps `reasoning_effort`** to the model's advertised subset when an exact tier is unavailable;

docs-site/src/content/docs/ru/reference/adapters.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ interface ProviderAdapter {
3434
- Преобразует внутренние сообщения в роли OpenAI; инструменты отображаются в
3535
`{type:"function", function:{…}}` и `tool_choice` (`auto`/`none`/`required` или именованная
3636
функция).
37+
- **Изображения из результатов инструментов** отправляются отдельным последующим user-сообщением
38+
(части `image_url`) после закрытия раунда инструментов, так как содержимое `role:"tool"` может
39+
быть только текстом; маркер `[image]` остаётся в сообщении инструмента как якорь.
3740
- **Переписывает идентификационный промпт Codex про GPT-5** в модельно-нейтральное вступление,
3841
чтобы маршрутизируемые модели не заявляли, что они от OpenAI.
3942
- **Прижимает `reasoning_effort`** к объявленному моделью подмножеству, когда точный уровень

docs-site/src/content/docs/zh-cn/reference/adapters.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ interface ProviderAdapter {
3030

3131
- 把内部消息转换成 OpenAI role;工具映射为 `{type:"function", function:{…}}`
3232
`tool_choice``auto`/`none`/`required` 或具名函数)。
33+
- **工具结果中的图片**会在工具轮次结束后,作为后续 user vision 消息(`image_url` 部分)发送,
34+
因为 `role:"tool"` 的内容只能是文本;`[image]` 标记仍保留在工具消息中作为锚点。
3335
- **重写 Codex 的 GPT-5 身份提示词**,改成与模型无关的介绍,避免路由模型自称 OpenAI。
3436
- 精确层级不可用时,**`reasoning_effort` 限制到模型公布的子集**。除非 provider 显式配置
3537
alias,`xhigh``max` 保持为不同标签。对于 `provider.noReasoningModels` 中的 id,则**完全

src/adapters/openai-chat.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,24 @@ function developerSystemText(message: OcxMessage): string | undefined {
174174
return message.content.map(part => (part as OcxTextContent).text).join("");
175175
}
176176

177+
/**
178+
* Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
179+
* content is text-only on every chat provider, so these ride in a follow-up user message instead of
180+
* being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
181+
* URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
182+
*/
183+
function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
184+
if (typeof content === "string") return [];
185+
const parts: unknown[] = [];
186+
for (const p of content) {
187+
// Skip parts without a usable URL (the tool-output parser accepts the empty file_id shape):
188+
// a {"url":""} part would fail the whole request where the "[image]" marker degrades safely.
189+
if (p.type !== "image" || !p.imageUrl) continue;
190+
parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
191+
}
192+
return parts;
193+
}
194+
177195
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
178196
const out: unknown[] = [];
179197
const { context, options } = parsed;
@@ -188,6 +206,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
188206
interface PendingToolCall { id: string; name: string }
189207
let pendingToolCalls: PendingToolCall[] = [];
190208
let deferredBarrierMessages: unknown[] = [];
209+
let pendingToolResultImageParts: unknown[] = [];
191210
let mintedIdSeq = 0;
192211
const seenWireCallIds = new Set<string>();
193212

@@ -206,6 +225,22 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
206225
deferredBarrierMessages = [];
207226
};
208227

228+
// Tool-result images collected during the open round land in ONE user vision message once the
229+
// round closes — never inside it, where strict providers (Kimi/Moonshot) 400 on interleaved
230+
// user messages. Released before deferred barriers so the images stay adjacent to the results
231+
// they came from (mirrors google.ts sibling inline_data parts and the Kiro carrier images).
232+
const flushToolResultImages = (): void => {
233+
if (pendingToolResultImageParts.length === 0) return;
234+
out.push({
235+
role: "user",
236+
content: [
237+
{ type: "text", text: "[ocx] image output from the preceding tool result(s):" },
238+
...pendingToolResultImageParts,
239+
],
240+
});
241+
pendingToolResultImageParts = [];
242+
};
243+
209244
// Close an unresolved tool round with explicit unavailable-result messages. The wording
210245
// must not claim interruption, success, failure, or user intent: execution status is
211246
// UNKNOWN, and for user-input tools this must not read as an answer.
@@ -219,6 +254,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
219254
});
220255
}
221256
pendingToolCalls = [];
257+
flushToolResultImages();
222258
releaseDeferredBarriers();
223259
};
224260

@@ -332,8 +368,12 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
332368
tool_call_id: toolCallId,
333369
content: contentPartsToText(msg.content),
334370
});
371+
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
335372
pendingToolCalls.splice(matchIdx, 1);
336-
if (pendingToolCalls.length === 0) releaseDeferredBarriers();
373+
if (pendingToolCalls.length === 0) {
374+
flushToolResultImages();
375+
releaseDeferredBarriers();
376+
}
337377
} else {
338378
if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
339379
// No matching call in the open round. Close any unresolved round first so the
@@ -357,6 +397,8 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
357397
tool_call_id: toolCallId,
358398
content: contentPartsToText(msg.content),
359399
});
400+
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
401+
flushToolResultImages();
360402
}
361403
break;
362404
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { expect, test } from "bun:test";
2+
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
3+
import type { OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../src/types";
4+
5+
// Issue #888: role:"tool" content is text-only on chat-completions providers, so images inside a
6+
// tool result were flattened to an "[image]" marker and vision-capable routed models hallucinated
7+
// what they never saw. Tool-result images now ride in a follow-up user vision message released when
8+
// the tool round closes, without splitting the round (strict providers reject interleaved users).
9+
10+
const provider: OcxProviderConfig = {
11+
adapter: "openai-chat",
12+
baseUrl: "https://example.test/v1",
13+
apiKey: "sk-test",
14+
authMode: "key",
15+
};
16+
17+
const IMAGE_URL = "data:image/png;base64,aGVsbG8taW1hZ2UtYnl0ZXM=";
18+
19+
interface ChatPart {
20+
type: string;
21+
text?: string;
22+
image_url?: { url: string; detail?: string };
23+
}
24+
25+
interface ChatMsg {
26+
role: string;
27+
content?: string | ChatPart[];
28+
tool_calls?: { id: string; function: { name: string; arguments: string } }[];
29+
tool_call_id?: string;
30+
}
31+
32+
function wire(messages: OcxMessage[]): ChatMsg[] {
33+
const parsed: OcxParsedRequest = {
34+
modelId: "test-model",
35+
context: { messages },
36+
stream: false,
37+
options: {},
38+
};
39+
const req = createOpenAIChatAdapter(provider).buildRequest(parsed) as { body: string };
40+
return (JSON.parse(req.body) as { messages: ChatMsg[] }).messages;
41+
}
42+
43+
function user(text: string): OcxMessage {
44+
return { role: "user", content: text, timestamp: 0 };
45+
}
46+
47+
function assistantWithCalls(calls: { id: string; name: string }[]): OcxMessage {
48+
return {
49+
role: "assistant",
50+
content: calls.map(c => ({ type: "toolCall" as const, id: c.id, name: c.name, arguments: {} })),
51+
timestamp: 0,
52+
};
53+
}
54+
55+
function toolResult(callId: string, name: string, content: string | OcxContentPart[]): OcxMessage {
56+
return { role: "toolResult", toolCallId: callId, toolName: name, content, isError: false, timestamp: 0 };
57+
}
58+
59+
/** The carrier is a user message whose parts start with an "[ocx]" text label followed by image_url parts. */
60+
function isImageCarrier(msg: ChatMsg): boolean {
61+
if (msg.role !== "user" || !Array.isArray(msg.content)) return false;
62+
const [head, ...rest] = msg.content;
63+
return head?.type === "text" && typeof head.text === "string" && head.text.startsWith("[ocx]")
64+
&& rest.length > 0 && rest.every(p => p.type === "image_url");
65+
}
66+
67+
/** Every role:"tool" message must sit in an unbroken block right after its assistant tool_calls message. */
68+
function assertRoundsUnbroken(messages: ChatMsg[]): void {
69+
for (let i = 0; i < messages.length; i++) {
70+
const m = messages[i];
71+
if (m.role !== "tool") continue;
72+
let j = i - 1;
73+
while (j >= 0 && messages[j].role === "tool") j--;
74+
expect(j).toBeGreaterThanOrEqual(0);
75+
expect(messages[j].role).toBe("assistant");
76+
expect((messages[j].tool_calls ?? []).map(tc => tc.id)).toContain(m.tool_call_id);
77+
}
78+
}
79+
80+
test("tool-result images ride a follow-up user message; text, detail, and https URLs survive", () => {
81+
const messages = wire([
82+
user("read the screenshot"),
83+
assistantWithCalls([{ id: "call_1", name: "Read" }]),
84+
toolResult("call_1", "Read", [
85+
{ type: "text", text: "1 match found" },
86+
{ type: "image", imageUrl: IMAGE_URL, detail: "high" },
87+
{ type: "image", imageUrl: "https://example.test/shot.png" },
88+
{ type: "image", imageUrl: "" }, // empty file_id shape: keeps its marker, never reaches the carrier
89+
]),
90+
]);
91+
assertRoundsUnbroken(messages);
92+
const tool = messages.find(m => m.role === "tool")!;
93+
expect(tool.content).toBe("1 match found[image][image][image]");
94+
const carrier = messages.find(isImageCarrier)!;
95+
expect(carrier).toBeDefined();
96+
expect(messages.indexOf(carrier)).toBe(messages.indexOf(tool) + 1);
97+
const parts = (carrier.content as ChatPart[]).filter(p => p.type === "image_url");
98+
expect(parts.map(p => p.image_url)).toEqual([
99+
{ url: IMAGE_URL, detail: "high" },
100+
{ url: "https://example.test/shot.png" },
101+
]);
102+
});
103+
104+
test("images from a multi-call round flush once, only after the whole round closes", () => {
105+
const messages = wire([
106+
assistantWithCalls([{ id: "call_1", name: "shot" }, { id: "call_2", name: "list" }]),
107+
toolResult("call_1", "shot", [{ type: "image", imageUrl: IMAGE_URL }]),
108+
toolResult("call_2", "list", "file1.txt"),
109+
]);
110+
assertRoundsUnbroken(messages);
111+
const toolIdx = messages.map((m, i) => (m.role === "tool" ? i : -1)).filter(i => i >= 0);
112+
expect(toolIdx).toEqual([toolIdx[0], toolIdx[0] + 1]); // nothing interleaves the round
113+
const carriers = messages.filter(isImageCarrier);
114+
expect(carriers.length).toBe(1);
115+
expect(messages.indexOf(carriers[0])).toBe(toolIdx[1] + 1);
116+
});
117+
118+
test("orphan tool result with an image still emits the carrier after its synthesized pair", () => {
119+
const messages = wire([
120+
user("hi"),
121+
toolResult("call_orphan", "shot", [{ type: "image", imageUrl: IMAGE_URL }]),
122+
]);
123+
assertRoundsUnbroken(messages);
124+
const tool = messages.find(m => m.role === "tool")!;
125+
expect(tool.content).toBe("[image]");
126+
const carrier = messages.find(isImageCarrier)!;
127+
expect(messages.indexOf(carrier)).toBe(messages.indexOf(tool) + 1);
128+
});
129+
130+
test("interrupted round: the synthetic closure still flushes collected images", () => {
131+
const messages = wire([
132+
assistantWithCalls([{ id: "call_1", name: "shot" }, { id: "call_2", name: "list" }]),
133+
toolResult("call_1", "shot", [{ type: "image", imageUrl: IMAGE_URL }]),
134+
]);
135+
assertRoundsUnbroken(messages);
136+
const carrier = messages.find(isImageCarrier)!;
137+
expect(carrier).toBeDefined();
138+
expect(messages.indexOf(carrier)).toBe(messages.length - 1);
139+
});
140+
141+
test("image-free tool results emit no carrier and an unchanged wire", () => {
142+
const messages = wire([
143+
user("hi"),
144+
assistantWithCalls([{ id: "call_1", name: "list" }]),
145+
toolResult("call_1", "list", "file1.txt"),
146+
user("thanks"),
147+
]);
148+
expect(messages.some(isImageCarrier)).toBe(false);
149+
expect(messages.map(m => m.role)).toEqual(["user", "assistant", "tool", "user"]);
150+
});

0 commit comments

Comments
 (0)