Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Preserve Qoder user images and image-bearing tool results, emitting tool-result images after their complete ordered tool batch.
- Bridge Responses custom tools through function calls, restoring custom output/events and replaying tool results. Format rules are descriptive, not grammar-enforced; custom input events are emitted after argument collection.

- Preserve typed upstream stream errors through the OpenAI, Anthropic, and Responses relays so invalid Devin requests do not falsely cool accounts, while transport interruptions remain retryable
Expand All @@ -21,6 +22,7 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### 中文

- 保留 Qoder 用户消息及工具结果中的图片,并在完整、有序的工具结果批次之后发送工具图片。
- 通过 function 调用桥接 Responses custom 工具,还原 custom 输出与事件并回放工具结果。格式规则仅作为描述传递,不强制执行语法约束;custom 输入事件在参数收集后发送。

- OpenAI、Anthropic 与 Responses 流式转发会保留上游的类型化错误,避免无效的 Devin 请求被错误地冷却账号,同时传输中断仍可重试
Expand Down
23 changes: 20 additions & 3 deletions worker/src/plaintext.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ function normalizeMessagesForUpstream(messages = []) {

const flushToolResults = () => {
if (!pendingToolResults.length) return;
const images = [];
const order = new Map(currentBatch.map((id, index) => [id, index]));
pendingToolResults.sort((left, right) => {
const leftOrder = order.has(left.toolCallId) ? order.get(left.toolCallId) : Number.MAX_SAFE_INTEGER;
Expand All @@ -245,8 +246,12 @@ function normalizeMessagesForUpstream(messages = []) {
const belongsToCurrentBatch = currentBatch.length > 0
? order.has(result.toolCallId)
: callsById.has(result.toolCallId);
if (belongsToCurrentBatch) normalized.push(result.message);
if (belongsToCurrentBatch) {
normalized.push(result.message);
images.push(...result.images);
}
}
if (images.length) normalized.push({ role: "user", content: images });
pendingToolResults = [];
};

Expand Down Expand Up @@ -288,14 +293,26 @@ function normalizeMessagesForUpstream(messages = []) {
tool_call_id: toolCallId,
};
if (message?.name) out.name = String(message.name);
pendingToolResults.push({ message: out, toolCallId });
const images = (Array.isArray(message.content) ? message.content : []).flatMap((part) => {
if (part?.type === "image_url") {
return normalizeContentForUpstream([part]);
}
if (part?.type === "image" && typeof part.data === "string" && part.data && (part.mimeType || part.mime_type)) {
return [{ type: "image_url", image_url: { url: `data:${part.mimeType || part.mime_type};base64,${part.data}` } }];
}
return [];
});
pendingToolResults.push({ message: out, toolCallId, images });
continue;
}

flushToolResults();
currentBatch = [];
consumedBatchIds = new Set();
normalized.push({ role, content: contentToString(message?.content) });
normalized.push({
role,
content: role === "user" ? normalizeContentForUpstream(message?.content) : contentToString(message?.content),
});
}

flushToolResults();
Expand Down
46 changes: 46 additions & 0 deletions worker/test/plaintext.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,52 @@ test("normalizes model ids only for stable public/settings keys", () => {
assert.equal(canonicalModelID("GLM-5.2"), "glm-5.2");
});

test("preserves user images in the final Qoder request without changing system text", () => {
const images = [
{ type: "image_url", image_url: { url: "data:image/png;base64,cHJvYmU=", detail: "high" } },
{ type: "image_url", image_url: { url: "https://example.com/probe.png" } },
];
const body = buildPlainChatBody({
model: "deepseek-flash",
messages: [
{ role: "system", content: [{ type: "text", text: "Inspect images" }] },
{ role: "user", content: [{ type: "text", text: "Compare these" }, ...images] },
],
});
assert.equal(body.system, "Inspect images");
assert.deepEqual(body.messages[1].content, [{ type: "text", text: "Compare these" }, ...images]);
});

test("preserves tool images after the complete parallel result batch and drops orphan images", () => {
const image = { type: "image_url", image_url: { url: "data:image/png;base64,cHJvYmU=" } };
const body = buildPlainChatBody({ model: "deepseek-flash", messages: [
{ role: "assistant", content: "", tool_calls: [
{ id: "a", function: { name: "view", arguments: "{}" } },
{ id: "b", function: { name: "view", arguments: "{}" } },
] },
{ role: "tool", tool_call_id: "orphan", content: [{ type: "image_url", image_url: { url: "orphan" } }] },
{ role: "tool", tool_call_id: "b", content: [image] },
{ role: "tool", tool_call_id: "a", content: [{ type: "image", mimeType: "image/png", data: "bWNw" }] },
{ role: "user", content: "Describe the results" },
] });
assert.deepEqual(body.messages.map(x => x.role), ["assistant", "tool", "tool", "user", "user"]);
assert.deepEqual(body.messages.slice(1, 3).map(x => x.tool_call_id), ["a", "b"]);
assert.deepEqual(body.messages[3].content, [
{ type: "image_url", image_url: { url: "data:image/png;base64,bWNw" } }, image,
]);
assert.equal(JSON.stringify(body).includes('"url":"orphan"'), false);
});

test("preserves images extracted from Responses tool output into a user message", () => {
const image = { type: "image_url", image_url: { url: "data:image/png;base64,cHJvYmU=" } };
const body = buildPlainChatBody({ model: "deepseek-flash", messages: [
{ role: "assistant", content: "", tool_calls: [{ id: "a", function: { name: "view", arguments: "{}" } }] },
{ role: "tool", tool_call_id: "a", content: "Image attached" },
{ role: "user", content: [image] },
] });
assert.deepEqual(body.messages[2], { role: "user", content: [image] });
});

test("detects reasoning flags from OpenAI-style fields", () => {
assert.equal(wantsReasoning({ enable_thinking: true }), true);
assert.equal(wantsReasoning({ reasoning_effort: "high" }), true);
Expand Down
Loading