Skip to content

Commit 9c8a0c5

Browse files
committed
fix(openai-chat): deliver tool-result images to vision models
Images in a tool_result were flattened to a literal "[image]" marker, so vision-capable routed models (kimi-code, DeepSeek, ...) described images they never saw. role:"tool" content is text-only on chat providers, so images now ride in a follow-up user vision message released once the tool round closes, mirroring the Google and Kiro adapters. Models in noVisionModels still use the vision sidecar. Fixes #888
1 parent 4a0d038 commit 9c8a0c5

5 files changed

Lines changed: 199 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;

src/adapters/openai-chat.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,22 @@ function developerSystemText(message: OcxMessage): string | undefined {
7777
return message.content.map(part => (part as OcxTextContent).text).join("");
7878
}
7979

80+
/**
81+
* Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
82+
* content is text-only on every chat provider, so these ride in a follow-up user message instead of
83+
* being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
84+
* URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
85+
*/
86+
function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
87+
if (typeof content === "string") return [];
88+
const parts: unknown[] = [];
89+
for (const p of content) {
90+
if (p.type !== "image") continue;
91+
parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
92+
}
93+
return parts;
94+
}
95+
8096
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
8197
const out: unknown[] = [];
8298
const { context, options } = parsed;
@@ -91,6 +107,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
91107
interface PendingToolCall { id: string; name: string }
92108
let pendingToolCalls: PendingToolCall[] = [];
93109
let deferredBarrierMessages: unknown[] = [];
110+
let pendingToolResultImageParts: unknown[] = [];
94111
let mintedIdSeq = 0;
95112
const seenWireCallIds = new Set<string>();
96113

@@ -109,6 +126,22 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
109126
deferredBarrierMessages = [];
110127
};
111128

129+
// Tool-result images collected during the open round land in ONE user vision message once the
130+
// round closes — never inside it, where strict providers (Kimi/Moonshot) 400 on interleaved
131+
// user messages. Released before deferred barriers so the images stay adjacent to the results
132+
// they came from (mirrors google.ts sibling inline_data parts and the Kiro carrier images).
133+
const flushToolResultImages = (): void => {
134+
if (pendingToolResultImageParts.length === 0) return;
135+
out.push({
136+
role: "user",
137+
content: [
138+
{ type: "text", text: "[ocx] image output from the preceding tool result(s):" },
139+
...pendingToolResultImageParts,
140+
],
141+
});
142+
pendingToolResultImageParts = [];
143+
};
144+
112145
// Close an unresolved tool round with explicit unavailable-result messages. The wording
113146
// must not claim interruption, success, failure, or user intent: execution status is
114147
// UNKNOWN, and for user-input tools this must not read as an answer.
@@ -122,6 +155,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
122155
});
123156
}
124157
pendingToolCalls = [];
158+
flushToolResultImages();
125159
releaseDeferredBarriers();
126160
};
127161

@@ -232,8 +266,12 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
232266
tool_call_id: toolCallId,
233267
content: contentPartsToText(msg.content),
234268
});
269+
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
235270
pendingToolCalls.splice(matchIdx, 1);
236-
if (pendingToolCalls.length === 0) releaseDeferredBarriers();
271+
if (pendingToolCalls.length === 0) {
272+
flushToolResultImages();
273+
releaseDeferredBarriers();
274+
}
237275
} else {
238276
if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
239277
// No matching call in the open round. Close any unresolved round first so the
@@ -257,6 +295,8 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
257295
tool_call_id: toolCallId,
258296
content: contentPartsToText(msg.content),
259297
});
298+
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
299+
flushToolResultImages();
260300
}
261301
break;
262302
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
]),
89+
]);
90+
assertRoundsUnbroken(messages);
91+
const tool = messages.find(m => m.role === "tool")!;
92+
expect(tool.content).toBe("1 match found[image][image]");
93+
const carrier = messages.find(isImageCarrier)!;
94+
expect(carrier).toBeDefined();
95+
expect(messages.indexOf(carrier)).toBe(messages.indexOf(tool) + 1);
96+
const parts = (carrier.content as ChatPart[]).filter(p => p.type === "image_url");
97+
expect(parts.map(p => p.image_url)).toEqual([
98+
{ url: IMAGE_URL, detail: "high" },
99+
{ url: "https://example.test/shot.png" },
100+
]);
101+
});
102+
103+
test("images from a multi-call round flush once, only after the whole round closes", () => {
104+
const messages = wire([
105+
assistantWithCalls([{ id: "call_1", name: "shot" }, { id: "call_2", name: "list" }]),
106+
toolResult("call_1", "shot", [{ type: "image", imageUrl: IMAGE_URL }]),
107+
toolResult("call_2", "list", "file1.txt"),
108+
]);
109+
assertRoundsUnbroken(messages);
110+
const toolIdx = messages.map((m, i) => (m.role === "tool" ? i : -1)).filter(i => i >= 0);
111+
expect(toolIdx).toEqual([toolIdx[0], toolIdx[0] + 1]); // nothing interleaves the round
112+
const carriers = messages.filter(isImageCarrier);
113+
expect(carriers.length).toBe(1);
114+
expect(messages.indexOf(carriers[0])).toBe(toolIdx[1] + 1);
115+
});
116+
117+
test("orphan tool result with an image still emits the carrier after its synthesized pair", () => {
118+
const messages = wire([
119+
user("hi"),
120+
toolResult("call_orphan", "shot", [{ type: "image", imageUrl: IMAGE_URL }]),
121+
]);
122+
assertRoundsUnbroken(messages);
123+
const tool = messages.find(m => m.role === "tool")!;
124+
expect(tool.content).toBe("[image]");
125+
const carrier = messages.find(isImageCarrier)!;
126+
expect(messages.indexOf(carrier)).toBe(messages.indexOf(tool) + 1);
127+
});
128+
129+
test("interrupted round: the synthetic closure still flushes collected images", () => {
130+
const messages = wire([
131+
assistantWithCalls([{ id: "call_1", name: "shot" }, { id: "call_2", name: "list" }]),
132+
toolResult("call_1", "shot", [{ type: "image", imageUrl: IMAGE_URL }]),
133+
]);
134+
assertRoundsUnbroken(messages);
135+
const carrier = messages.find(isImageCarrier)!;
136+
expect(carrier).toBeDefined();
137+
expect(messages.indexOf(carrier)).toBe(messages.length - 1);
138+
});
139+
140+
test("image-free tool results emit no carrier and an unchanged wire", () => {
141+
const messages = wire([
142+
user("hi"),
143+
assistantWithCalls([{ id: "call_1", name: "list" }]),
144+
toolResult("call_1", "list", "file1.txt"),
145+
user("thanks"),
146+
]);
147+
expect(messages.some(isImageCarrier)).toBe(false);
148+
expect(messages.map(m => m.role)).toEqual(["user", "assistant", "tool", "user"]);
149+
});

0 commit comments

Comments
 (0)