From e0f543a99a2785158d04d86b6f06a9fe0aa51972 Mon Sep 17 00:00:00 2001 From: chilung Date: Thu, 22 Oct 2026 17:58:21 +0000 Subject: [PATCH 1/6] fix(qoder): forward Qoder CN images through shared coding-agent projection Remove the Qoder CN adapter-local blanket raw-image rejection in runTurn, aligning Qoder CN with Qoder Global to use the shared coding-agent image projection. Supported image inputs (data: base64) are forwarded to the Qoder CN CLI, tool-result images are preserved as real image blocks with provenance notes, and unsupported schemes are safely omitted without crashing. Qoder CN preserves its existing sidecar classification so text-only models continue to route through the vision sidecar. Fixes #7516 --- .../src/content/docs/guides/providers.md | 2 +- src/adapters/qoder/adapter.ts | 16 -- structure/providers/chat-compat.md | 2 +- tests/providers/qoder-adapter.test.ts | 194 +++++++++++++++++- 4 files changed, 188 insertions(+), 26 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 41caa0b660f..363bac35127 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1328,7 +1328,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed. - **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. -- **Image Input:** Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter to the CLI without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and any residual raw-image request that reaches the CN adapter is rejected as `unsupported_input_modality`. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied. +- **Image Input:** Qoder Global and Qoder CN accept image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach either adapter to the CLI without maintaining an adapter-local image refusal. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and raw images reaching the CN adapter are forwarded through the shared coding-agent projection. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied. - **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI. - **Quota:** No public quota API is used, so totals and reset times are unavailable. Codes 110 (account daily limit) and 118 (personal Credits exhaustion) report `insufficient_quota` and permit consideration of PAT recovery. Other supported quota codes also report `insufficient_quota` but do not authorize it. Numeric error codes take precedence: an unrecognized numeric code does not fall back to quota or authentication text. Without a numeric code, only the supported account-daily message beginning “You've reached your daily usage limit for Chat”, with no team/model/shared qualifier, grants quota recovery; generic credit-limit text does not. Only quota failures eligible for PAT recovery enter key cooldown handling. When such a failure has no reset time, the failed key uses the existing default cooldown; nonrecoverable quota classifications do not create a cooldown through this path, and no next-day reset is inferred. - **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index e0254fbb7bd..7cb56c6fa7d 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -154,22 +154,6 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt yield { type: "error", message: "Qoder adapter uses runTurn; the fetch/parseStream path is disabled." }; }, async runTurn(parsed, incoming, emit): Promise { - if (resolveQoderProfile(provider.baseUrl)?.region === "cn") { - const hasImage = parsed.context.messages.some(message => - Array.isArray(message.content) && message.content.some(part => part.type === "image"), - ); - if (hasImage) { - emit({ - type: "error", - message: "Qoder image input is not enabled because the CLI provider route has no verified multimodal contract.", - status: 400, - errorType: "invalid_request_error", - code: "unsupported_input_modality", - retryable: false, - }); - return; - } - } let catalog: ReturnType; try { catalog = buildCodingAgentToolCatalog(parsed, QODER_MCP_SERVER_NAME); diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 4adb8f434cd..d232cfe27e5 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -589,7 +589,7 @@ real image blocks. Current tool-result prose marks supported images as `[image a adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, -and leaves discussed or quoted literals and code examples untouched. Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification and rejects residual raw-image requests that reach the adapter. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path. +and leaves discussed or quoted literals and code examples untouched. Qoder Global and Qoder CN accept image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach either adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining an adapter-local image refusal. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification, and raw images reaching the CN adapter are forwarded through the shared coding-agent projection. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index bba85c4ffff..214caf0cc73 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -589,25 +589,203 @@ describe("qoder adapter", () => { expect(isModelVisionSidecarConsumer(global, "unknown-live-id")).toBe(false); }); - test("keeps residual Qoder CN image input rejected before spawn", async () => { + test("forwards Qoder CN raw image input through the shared coding-agent projection", async () => { let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { which: () => "/bin/qodercn", spawn: () => { spawned++; - return fakeChild([]); + return child; }, }); const request = parsed({ context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } }); const events: AdapterEvent[] = []; await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); - expect(spawned).toBe(0); - expect(events[0]).toMatchObject({ - type: "error", - status: 400, - code: "unsupported_input_modality", - retryable: false, + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + expect(wire.message.content).toContainEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: "AA==" }, + }); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("forwards Qoder CN multiple images and preserves history ordering", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => "/bin/qodercn", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,HIST==" }], timestamp: 0 }, + { role: "assistant", content: "I see the first image.", timestamp: 1 }, + { + role: "user", + content: [ + { type: "text", text: "Look at these two:" }, + { type: "image", imageUrl: "data:image/jpeg;base64,CURR1==" }, + { type: "image", imageUrl: "data:image/png;base64,CURR2==" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(3); + expect(images[0]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "HIST==" } }); + expect(images[1]).toEqual({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "CURR1==" } }); + expect(images[2]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "CURR2==" } }); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("forwards Qoder CN toolResult images through shared projection", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => "/bin/qodercn", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: "take screenshot", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_1", name: "screenshot", arguments: {} }], timestamp: 1 }, + { + role: "toolResult", + toolCallId: "call_1", + content: [ + { type: "text", text: "captured screenshot:" }, + { type: "image", imageUrl: "data:image/png;base64,TOOL_IMG==" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + expect(wire.message.content).toContainEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: "TOOL_IMG==" }, }); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("[image attached below]"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("omits unsupported image reference schemes cleanly without crashing", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => "/bin/qodercn", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: "first turn", timestamp: 0 }, + { role: "assistant", content: "response", timestamp: 1 }, + { + role: "user", + content: [ + { type: "text", text: "inspect link" }, + { type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(0); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("[image omitted: unsupported reference]"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("leaves plain text-only requests completely unaffected", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => "/bin/qodercn", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [{ role: "user", content: "Hello, plain text only", timestamp: 0 }], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(0); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("Hello, plain text only"); + expect(events.at(-1)).toMatchObject({ type: "done" }); }); test("forwards Qoder Global raw image input through the shared coding-agent projection", async () => { From f0693027f21b56d8949728cbee7826787c44f497 Mon Sep 17 00:00:00 2001 From: chilung Date: Fri, 23 Oct 2026 11:12:56 +0000 Subject: [PATCH 2/6] test(qoder): normalize assistant history fixture content parts --- tests/providers/qoder-adapter.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 214caf0cc73..f3fbfecb45d 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -639,7 +639,7 @@ describe("qoder adapter", () => { context: { messages: [ { role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,HIST==" }], timestamp: 0 }, - { role: "assistant", content: "I see the first image.", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "I see the first image." }], timestamp: 1 }, { role: "user", content: [ @@ -732,7 +732,7 @@ describe("qoder adapter", () => { context: { messages: [ { role: "user", content: "first turn", timestamp: 0 }, - { role: "assistant", content: "response", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "response" }], timestamp: 1 }, { role: "user", content: [ From 098be3a7b852bc6f23f8c19e3bb8fd8f78d0e1c0 Mon Sep 17 00:00:00 2001 From: chilung Date: Sat, 24 Oct 2026 17:16:08 +0800 Subject: [PATCH 3/6] fix(coding-agent): mark unsupported image references in single-turn and history --- src/adapters/coding-agent/protocol.ts | 13 +++++- tests/providers/qoder-adapter.test.ts | 66 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 69686301b19..734b431ac98 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -735,7 +735,11 @@ export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: if (message.role === "user") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : `[${p.type}]`)).join("\n"); + : message.content.map(p => { + if (p.type === "text" || p.type === "document") return p.text; + if (p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]"; + return `[${p.type}]`; + }).join("\n"); return `USER:\n${text}`; } if (message.role === "assistant") { @@ -758,7 +762,11 @@ export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: if (message.role === "toolResult") { const text = typeof message.content === "string" ? message.content - : message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : "[image]")).join(""); + : message.content.map(p => { + if (p.type === "text" || p.type === "document") return p.text; + if (p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]"; + return "[image]"; + }).join(""); const status = message.isError ? " (error)" : ""; return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`; } @@ -788,6 +796,7 @@ export function buildInputLines( else if (part.type === "image") { const image = imagePart(part.imageUrl); if (image) content.push(image); + else content.push(textPart("[image omitted: unsupported reference]")); } else if (part.type === "document") { content.push(textPart(part.text)); } else { diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index f3fbfecb45d..27e14a70df0 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -752,9 +752,75 @@ describe("qoder adapter", () => { expect(images).toHaveLength(0); const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); expect(textPart?.text).toContain("[image omitted: unsupported reference]"); + expect(textPart?.text).toContain("ASSISTANT:\nresponse"); expect(events.at(-1)).toMatchObject({ type: "done" }); }); + test("marks unsupported image references in a single-turn user message", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [{ + role: "user", + content: [ + { type: "text", text: "inspect link" }, + { type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }, + ], + timestamp: 0, + }], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + expect(frame.message.content).toContainEqual({ type: "text", text: "inspect link" }); + expect(frame.message.content).toContainEqual({ type: "text", text: "[image omitted: unsupported reference]" }); + }); + + test("keeps a single-turn frame for an unsupported image-only message", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [{ + role: "user", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }], + timestamp: 0, + }], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + expect(frame.message.content).toContainEqual({ type: "text", text: "[image omitted: unsupported reference]" }); + }); + + test("marks unsupported images in prior user and tool-result history as omitted", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/user.png" }], + timestamp: 0, + }, + { role: "assistant", content: [{ type: "text", text: "I saw the image." }], timestamp: 1 }, + { + role: "toolResult", + toolCallId: "call_1", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/tool.png" }], + timestamp: 2, + }, + { role: "user", content: "What was attached?", timestamp: 3 }, + ], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + const text = frame.message.content.find((part: { type: string }) => part.type === "text")?.text; + expect(text).toContain("USER:\n[image omitted: unsupported reference]"); + expect(text).toContain("TOOL RESULT (call_id: call_1):\n[image omitted: unsupported reference]"); + expect(frame.message.content.filter((part: { type: string }) => part.type === "image")).toHaveLength(0); + }); + test("leaves plain text-only requests completely unaffected", async () => { let spawned = 0; let stdin = ""; From ba6707d3cb5ef3e49dcbe46ea2d378bf3cd58cf4 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:03:12 +0900 Subject: [PATCH 4/6] fix(qoder): bound CN image input to verified stdin contract Scope unsupported-reference markers to Qoder while preserving other coding-agent bytes. Keep existing sidecar and scaffold/replay guards; document the qoderclicn 1.1.70 evidence boundary. Co-authored-by: WU, CHI-LUNG <215337896+chilung-cgu@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 2 +- scripts/test-layout/layout-adapters.json | 2 + src/adapters/coding-agent/protocol.ts | 21 +- src/adapters/qoder/adapter.ts | 10 + src/adapters/qoder/image-input.ts | 22 ++ structure/adapters/registry.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/providers-and-adapters.md | 2 +- structure/providers/anthropic-progress.md | 2 +- structure/providers/chat-compat.md | 6 +- structure/providers/claude-cli-replay.md | 2 +- structure/providers/cursor.md | 2 +- structure/runtime.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- .../transports/native-reasoning-retention.md | 2 + structure/transports/responses-wire-shapes.md | 2 +- .../coding-agent-history-projection.test.ts | 26 ++ tests/fixtures/test-layout-expected.json | 2 + tests/providers/qoder-adapter.test.ts | 265 ------------- tests/providers/qoder-image-input.test.ts | 373 ++++++++++++++++++ 21 files changed, 462 insertions(+), 289 deletions(-) create mode 100644 src/adapters/qoder/image-input.ts create mode 100644 tests/adapters/coding-agent-history-projection.test.ts create mode 100644 tests/providers/qoder-image-input.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8265715579c..fadc3a02210 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1352,7 +1352,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. - **Output guard:** If the CLI echoes OpenCodex tool-call/result replay envelopes into assistant text, the turn fails with `vendor_scaffold_detected` (502) instead of returning the envelope as a successful answer, and neither reasoning nor tool calls following the envelope are forwarded. Reasoning starts a fresh replay-text block, so earlier prose or code fences cannot hide an envelope in later text. If the CLI's turn already ended with its own error, that error is reported instead, still without the envelope. The proxy does not retry that failure automatically. Ordinary prose and closed fenced examples follow the shared envelope recognizer; an unverified overflowing fenced example is refused. Assistant text preserves Unicode characters split across CLI frames; unpaired surrogates are replaced with U+FFFD. -- **Image Input:** Qoder Global and Qoder CN accept image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach either adapter to the CLI without maintaining an adapter-local image refusal. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and raw images reaching the CN adapter are forwarded through the shared coding-agent projection. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied. +- **Image Input:** Global continues forwarding supported images through the shared projection without a per-model vision allowlist. CN retains its vision-sidecar defaults. Its raw-image route supports only one base64 PNG in a single user turn with `DeepSeek-Flash` or `Qwen3.8-Flash`. Use `qoderclicn 1.1.70`: [issue #7516](https://github.com/lidge-jun/opencodex/issues/7516) reports Windows 11 x64 live success for those models on both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin; OpenCodex uses only stdin. Other CLI versions have not been verified, and the adapter does not probe the installed version. CN refuses raw URL images, other image formats/models, multiple images, tool-result images and image-bearing history/multi-turn replay before starting the CLI with `unsupported_input_modality`; use the vision sidecar for those inputs. Injected CLI tests cover serialization and cancellation without claiming live upstream support. Qoder's supported projection keeps image blocks in conversation order and marks unsupported references as `[image omitted: unsupported reference]`. Global stateless replay can still associate historical images unreliably with their original text; CN does not enable that unverified replay surface. - **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI. - **Quota:** No public quota API is used, so totals and reset times are unavailable. Codes 110 (account daily limit) and 118 (personal Credits exhaustion) report `insufficient_quota` and permit consideration of PAT recovery. Other supported quota codes also report `insufficient_quota` but do not authorize it. Numeric error codes take precedence: an unrecognized numeric code does not fall back to quota or authentication text. Without a numeric code, only the supported account-daily message beginning “You've reached your daily usage limit for Chat”, with no team/model/shared qualifier, grants quota recovery; generic credit-limit text does not. Only quota failures eligible for PAT recovery enter key cooldown handling. When such a failure has no reset time, the failed key uses the existing default cooldown; nonrecoverable quota classifications do not create a cooldown through this path, and no next-day reset is inferred. - **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 08f0ab47160..9a2442ede71 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,6 @@ { + "qoder-image-input.test.ts": "providers", + "coding-agent-history-projection.test.ts": "adapters", "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", "eventstream-fragmentation.test.ts": "responses", diff --git a/src/adapters/coding-agent/protocol.ts b/src/adapters/coding-agent/protocol.ts index 734b431ac98..f280844d4b7 100644 --- a/src/adapters/coding-agent/protocol.ts +++ b/src/adapters/coding-agent/protocol.ts @@ -731,13 +731,14 @@ function imagePart(imageUrl: string): WireContentPart | undefined { return undefined; } -export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: ReadonlyMap): string { +/** Render replay prose; only Qoder opts into unsupported-reference history markers. */ +export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: ReadonlyMap, markUnsupportedImages = false): string { if (message.role === "user") { const text = typeof message.content === "string" ? message.content : message.content.map(p => { if (p.type === "text" || p.type === "document") return p.text; - if (p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]"; + if (markUnsupportedImages && p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]"; return `[${p.type}]`; }).join("\n"); return `USER:\n${text}`; @@ -764,7 +765,7 @@ export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: ? message.content : message.content.map(p => { if (p.type === "text" || p.type === "document") return p.text; - if (p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]"; + if (markUnsupportedImages && p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]"; return "[image]"; }).join(""); const status = message.isError ? " (error)" : ""; @@ -782,7 +783,7 @@ export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: */ export function buildInputLines( message: OcxMessage, - options: { vendorNameByWire?: ReadonlyMap } = {}, + options: { vendorNameByWire?: ReadonlyMap; markUnsupportedImages?: boolean } = {}, ): string[] { if (message.role === "developer") return []; @@ -796,7 +797,7 @@ export function buildInputLines( else if (part.type === "image") { const image = imagePart(part.imageUrl); if (image) content.push(image); - else content.push(textPart("[image omitted: unsupported reference]")); + else if (options.markUnsupportedImages) content.push(textPart("[image omitted: unsupported reference]")); } else if (part.type === "document") { content.push(textPart(part.text)); } else { @@ -805,7 +806,7 @@ export function buildInputLines( } } } else { - const formatted = formatMessageForHistory(message, options.vendorNameByWire); + const formatted = formatMessageForHistory(message, options.vendorNameByWire, options.markUnsupportedImages); if (formatted) content.push(textPart(formatted)); } @@ -841,7 +842,7 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined */ export function buildConversationInput( parsed: OcxParsedRequest, - options: { maxHistoryChars?: number; vendorNameByWire?: ReadonlyMap } = {}, + options: { maxHistoryChars?: number; vendorNameByWire?: ReadonlyMap; markUnsupportedImages?: boolean } = {}, ): string[] { const nonDev = parsed.context.messages.filter(m => m.role !== "developer"); if (nonDev.length === 0) { @@ -849,7 +850,7 @@ export function buildConversationInput( } if (nonDev.length === 1 && nonDev[0]!.role === "user") { - return buildInputLines(nonDev[0]!, { vendorNameByWire: options.vendorNameByWire }); + return buildInputLines(nonDev[0]!, options); } // Multi-turn conversation or history with tool results: @@ -920,14 +921,14 @@ export function buildConversationInput( const status = currentMessage.isError ? " (error)" : ""; currentRequestText = `TOOL RESULT (call_id: ${currentMessage.toolCallId})${status}:\n${text}\n\nPlease proceed based on the above tool result.`; } else { - currentRequestText = formatMessageForHistory(currentMessage, options.vendorNameByWire); + currentRequestText = formatMessageForHistory(currentMessage, options.vendorNameByWire, options.markUnsupportedImages); } const imageBlocks: WireContentPart[] = [...historyImageBlocks, ...currentImageBlocks]; const maxHistoryChars = options.maxHistoryChars ?? MAX_PROJECTED_HISTORY_CHARS; let historyText = historyMessages - .map(msg => formatMessageForHistory(msg, options.vendorNameByWire)) + .map(msg => formatMessageForHistory(msg, options.vendorNameByWire, options.markUnsupportedImages)) .filter(Boolean) .join("\n\n"); if (historyText.length > maxHistoryChars) { diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts index f18c00430c3..3da6e0407cd 100644 --- a/src/adapters/qoder/adapter.ts +++ b/src/adapters/qoder/adapter.ts @@ -8,6 +8,7 @@ import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type CodingAge import { QODER_PROFILES, resolveQoderProfile, type QoderProfile } from "./profiles"; import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard"; import { ToolEnvelopeEchoFilter } from "../../lib/tool-envelope-echo-filter"; +import { buildQoderConversationInput, qoderCnImageInputSupported } from "./image-input"; export type QoderAdapterDeps = CodingAgentDeps; @@ -275,6 +276,14 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt yield { type: "error", message: "Qoder adapter uses runTurn; the fetch/parseStream path is disabled." }; }, async runTurn(parsed, incoming, emit): Promise { + if (resolveQoderProfile(provider.baseUrl)?.region === "cn" && !qoderCnImageInputSupported(parsed)) { + emit({ + type: "error", status: 400, errorType: "invalid_request_error", + code: "unsupported_input_modality", retryable: false, + message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.", + }); + return; + } let catalog: ReturnType; try { catalog = buildCodingAgentToolCatalog(parsed, QODER_MCP_SERVER_NAME); @@ -309,6 +318,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt const system = buildSystemPrompt(parsed); await runCodingAgentTurn({ profiles: QODER_PROFILES, + conversationInput: buildQoderConversationInput, classifyFailure: classifyQoderFailure, provider, parsed, diff --git a/src/adapters/qoder/image-input.ts b/src/adapters/qoder/image-input.ts new file mode 100644 index 00000000000..7ca0cdd411e --- /dev/null +++ b/src/adapters/qoder/image-input.ts @@ -0,0 +1,22 @@ +import type { OcxParsedRequest } from "../../types"; +import { buildConversationInput } from "../coding-agent/protocol"; + +/** #7516 live evidence: qoderclicn 1.1.70, one user PNG on stream-json stdin. */ +export function qoderCnImageInputSupported(parsed: OcxParsedRequest): boolean { + const messages = parsed.context.messages; + const imageMessages = messages.filter(message => Array.isArray(message.content) + && message.content.some(part => part.type === "image")); + if (imageMessages.length === 0) return true; + const nonDev = messages.filter(message => message.role !== "developer"); + if (imageMessages.length !== 1 || nonDev.length !== 1 || imageMessages[0] !== nonDev[0] + || nonDev[0]!.role !== "user" + || !["DeepSeek-Flash", "Qwen3.8-Flash"].includes(parsed.modelId)) return false; + const content = nonDev[0]!.content; + if (!Array.isArray(content)) return false; + const images = content.filter(part => part.type === "image"); + return images.length === 1 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(images[0]!.imageUrl); +} + +/** Keep Qoder omission markers out of CodeBuddy's and Claude's replay formats. */ +export const buildQoderConversationInput: typeof buildConversationInput = (parsed, options = {}) => + buildConversationInput(parsed, { ...options, markUnsupportedImages: true }); diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 0393249c948..f03e5c2edc8 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,7 +1,7 @@ # Adapter Registry Authority `workbuddy-ai` inherits the `openai-chat` contract with a fixed-origin subscription adapter; see [WorkBuddy AI](../providers/workbuddy.md). -Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 4e24d97a203..92dd79e616f 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,7 +1,7 @@ # Inbound Compatibility Surfaces The names used for these paths (native, translated, legacy bridge) and the declared per-feature -dispositions are owned by [Protocol Paths](protocol-paths.md). +dispositions are owned by [Protocol Paths](protocol-paths.md). Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 566cd1bb9eb..e7384271b4f 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -5,7 +5,7 @@ Combo target `metered` metadata and configured-key opt-in follow the [runtime co Devin combines only [consecutive same-ID tool results](adapters/registry.md#devin-consecutive-tool-results), using linear accumulation while preserving intervening message slots, image parts and error markers without mutating the parsed request. Claude CLI replay, client tool capture and usage admission follow [Claude CLI replay](providers/claude-cli-replay.md). Anthropic account pause, model routes, quota labels, and the separate `anthropic2` pool (its helpers, quota, reset grants and management surfaces) follow the [Anthropic account-pool contract](providers/anthropic-account-pool.md). Devin Messages follows the [per-turn output ordering contract](clients/claude-desktop.md#devin-messages-output-ordering), preserving late signatures before text/tools without changing Responses or Chat ordering. The DeepSeek Harness account provider follows the [DSH account contract](providers/dsh-account.md). WorkBuddy AI subscription authentication, catalog, display-only credit balances and inference follow the [WorkBuddy contract](providers/workbuddy.md). -API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint. +API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). Routed Anthropic compaction preserves the client tool/system prefix and guards summary output under the [compaction contract](transports/responses-failover.md#compaction-routing-overrides); image normalization and HTTP 413 recovery retain their existing policy. A shared-quota Anthropic 429 or classified pre-output account 403 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling for 429 is skipped only after the matching account cooldown is present. Exact revoked-token 401s persist generation-fenced reauthentication under [the Anthropic recovery contract](providers/anthropic-account-pool.md#revoked-oauth-access-token-recovery). diff --git a/structure/providers/anthropic-progress.md b/structure/providers/anthropic-progress.md index 5c49a7acb86..1b3d917994b 100644 --- a/structure/providers/anthropic-progress.md +++ b/structure/providers/anthropic-progress.md @@ -10,7 +10,7 @@ Hidden-summary preferences suppress reasoning, not these user-facing updates. Signed history preserves source block order, including thinking between tool calls; unsigned cross-provider history retains the adapter's preface-before-tools repair. `tests/adapters/anthropic/anthropic-progress-updates.test.ts` exercises SSE/JSON presentation, -exact replay, mixed block order, OAuth beta preservation and cancellation cleanup. +exact replay, mixed block order, OAuth beta preservation and cancellation cleanup. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). ## Signed Anthropic progress commentary diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 778f28260bc..d3b60996dc7 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -570,9 +570,9 @@ Chat-to-Responses projection still rejects audio/file parts, except user-content transport or URL fetch is added, and no client filename, payload, URL or metadata enters errors. Admission charges supported audio by decoded size at the conservative 256-bytes-per-token proxy, not an upstream token formula. -The shared coding-agent projection in `src/adapters/coding-agent/` (CodeBuddy, Qoder) carries supported tool-result image references as -real image blocks. Current tool-result prose marks supported images as `[image attached below]` and unsupported references as `[image omitted: unsupported reference]`; historical tool-result prose retains `[image]` while supported references are also emitted as image blocks. In multi-turn projection, image blocks follow conversation order — history before current. Vendor tool execution stays disabled on both -adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification and forwards raw-image requests through the shared projection. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path. +The shared coding-agent projection in `src/adapters/coding-agent/` carries supported tool-result images as real image blocks in conversation order, history before current. Current tool-result prose retains `[image attached below]` and `[image omitted: unsupported reference]`. Qoder alone opts into omission markers for single-user input and historical user/tool-result prose through `src/adapters/qoder/image-input.ts`; CodeBuddy retains its previous bytes (historical `[image]` and single-user unsupported-reference omission), and Claude keeps its stable replay builder. Vendor tool execution stays disabled. +CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder Global continues forwarding supported images without a per-model vision allowlist. CN retains its sidecar classification. Its raw-image route admits only one base64 PNG in one non-developer user message, on `DeepSeek-Flash` or `Qwen3.8-Flash`; developer images, URL references, other media types/models, multiple images, tool-result images and image-bearing multi-turn replay return a fixed non-retryable 400 `unsupported_input_modality` before CLI discovery/spawn. Text-only CN turns remain unrestricted by this gate. +Issue #7516 reports live verification on Windows 11 x64 with `qoderclicn 1.1.70` for those two models and a single PNG, through both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin. OpenCodex uses only stdin; attachment flags are not added. This is reported evidence, not a fresh live test or a guarantee for other CLI versions; operators must use the verified version (no runtime version probe is added). Injected CLI tests prove serialization, omission markers, refusals and cancellation, not upstream image understanding. Global still replays historical images with `--no-session-persistence`, whose text/image association remains unverified across turns. The scaffold/replay output guards ([ADR-7517](../clients/qoder.md#qoder-assistant-text-replay-guard)) and selector ownership ([ADR-7512](../clients/qoder.md#qoder-settings-ownership)) remain independent. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. Upstream API-key usage follows the [physical-attempt account attribution contract](../dashboard-and-usage.md#upstream-key-account-attribution), independently of subscription quota observations. diff --git a/structure/providers/claude-cli-replay.md b/structure/providers/claude-cli-replay.md index b316299c0e3..825029f3432 100644 --- a/structure/providers/claude-cli-replay.md +++ b/structure/providers/claude-cli-replay.md @@ -1,6 +1,6 @@ # Claude CLI Replay -`src/adapters/claude-cli/stable-replay.ts` supplies a Claude-only replay builder to the shared `src/adapters/coding-agent/turn.ts` runner through its `conversationInput` slot; CodeBuddy and Qoder keep the default projection. It emits one legal user frame with an immutable text block per message, using identical formatting for a current message and its later history form. History is trimmed at whole-message checkpoints. Developer messages remain in the system prompt; the cut skips orphan historical results and retains a current result's originating call. Checkpoints are at most 20k characters apart; the cut can also discard the boundary message and any leading orphan tool-result blocks. The soft cap counts history characters, excluding the current input and truncation notice; retaining the current tool group's originating call can exceed it. +`src/adapters/claude-cli/stable-replay.ts` supplies a Claude-only replay builder to the shared `src/adapters/coding-agent/turn.ts` runner through its `conversationInput` slot; CodeBuddy keeps the default projection; Qoder opts into its image-omission markers through the same slot. It emits one legal user frame with an immutable text block per message, using identical formatting for a current message and its later history form. History is trimmed at whole-message checkpoints. Developer messages remain in the system prompt; the cut skips orphan historical results and retains a current result's originating call. Checkpoints are at most 20k characters apart; the cut can also discard the boundary message and any leading orphan tool-result blocks. The soft cap counts history characters, excluding the current input and truncation notice; retaining the current tool group's originating call can exceed it. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Canonicalization accepts only plain JSON, preserves arrays/literal values, and sorts object keys, including schema object keys and historical tool arguments. Instruction changes intentionally invalidate that prefix. Tools and schema object keys serialize deterministically, preserving catalog membership, MCP aliases, tool choice, and array order. Object-key sorting intentionally changes model-visible property order while preserving JSON values; integer-like keys follow JavaScript's numeric-key ordering. Invalid non-JSON history supplied by an internal caller fails at the runner's stdin projection as a 502, after launch, and the owned child is terminated. Ordinary HTTP JSON requests cannot carry cycles, accessors or undefined properties. diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index eeb5576f462..665dbff89f7 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -1,7 +1,7 @@ # Cursor Provider The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Codex-native retirement does not retire a Cursor-owned model name. Cursor transport and namespace handling retain their provider contract; the bounded native scope lives in diff --git a/structure/runtime.md b/structure/runtime.md index ebeac303a54..53bf7cac91c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). ## Bun runtime and test runner diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 13ae6cd51e5..6646c09acd5 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,6 +1,6 @@ # Byte Accounting -Native result continuations and function-result injection follow [the mode-specific result and control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. +Native result continuations and function-result injection follow [the mode-specific result and control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Native steering follows [the shared WebSocket contract](streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 6a6cc614f6c..a0cb6099d16 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -2,7 +2,7 @@ WorkBuddy AI uses bounded fixed-origin login requests and shared Chat inference transport; see the [WorkBuddy contract](../providers/workbuddy.md). OpenCode Go's Gemini model uses the [Go Gemini transport contract](../providers/google.md#opencode-go-gemini-transport) -through the Google adapter and the default server fetch path. +through the Google adapter and the default server fetch path. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). Meta Muse's registry-owned compatibility User-Agent takes precedence over an incoming client User-Agent; explicit provider headers still win. See the [Muse provider contract](../providers-and-adapters.md). diff --git a/structure/transports/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md index d37b581bf42..da6cff96b4e 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -1,5 +1,7 @@ # Native Reasoning Retention +Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). + ## Configuration and management `src/config/schema/native-reasoning-retention.ts` owns strict optional boolean `nativeReasoningRetention.modelSwitch` and `nativeReasoningRetention.accountSwitch` fields, both resolved off. Invalid disk blocks disable retention with a warning without discarding unrelated config; candidate writes reject invalid fields and unknown keys. Runtime scope and recovery precedence follow [native reasoning retention](#runtime-boundary). diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 476f2267128..4cc19757f40 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -2,7 +2,7 @@ ## Client rate-limit retry advice -With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_error` codes `resource_exhausted`, `rate_limit_exceeded`, and `slow_down` to `rate_limit_exceeded`. `src/lib/retry-delay.ts` formats typed and message-only rate-limit advice with the longest lower bound first; competing original hints are retained under an explicit provider-detail label. Other explicit verdicts, proxy replay budgets and event ordering are unchanged. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). This creates no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. +With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_error` codes `resource_exhausted`, `rate_limit_exceeded`, and `slow_down` to `rate_limit_exceeded`. `src/lib/retry-delay.ts` formats typed and message-only rate-limit advice with the longest lower bound first; competing original hints are retained under an explicit provider-detail label. Other explicit verdicts, proxy replay budgets and event ordering are unchanged. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). This creates no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). ## Adapter incomplete reasons diff --git a/tests/adapters/coding-agent-history-projection.test.ts b/tests/adapters/coding-agent-history-projection.test.ts new file mode 100644 index 00000000000..2e1a36f0d61 --- /dev/null +++ b/tests/adapters/coding-agent-history-projection.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { buildConversationInput, buildInputLines, formatMessageForHistory } from "../../src/adapters/coding-agent/protocol"; +import type { OcxMessage, OcxParsedRequest } from "../../src/types"; + +const unsupported = { type: "image" as const, imageUrl: "ftp://example.test/image.png" }; +const user: OcxMessage = { role: "user", content: [{ type: "text", text: "inspect" }, unsupported], timestamp: 0 }; +const result: OcxMessage = { role: "toolResult", toolCallId: "call_1", content: [{ type: "text", text: "result" }, unsupported], timestamp: 1 }; + +test("non-Qoder single-message bytes retain the dev omission behavior", () => { + expect(buildInputLines(user)).toEqual(['{"type":"user","message":{"role":"user","content":[{"type":"text","text":"inspect"}]}}']); + expect(buildInputLines({ ...user, content: [unsupported] })).toEqual([]); +}); + +test("non-Qoder history bytes retain dev image markers", () => { + expect(formatMessageForHistory(user)).toBe("USER:\ninspect\n[image]"); + expect(formatMessageForHistory(result)).toBe("TOOL RESULT (call_id: call_1):\nresult[image]"); + const parsed: OcxParsedRequest = { modelId: "model", stream: false, options: {}, context: { messages: [user, result, { role: "user", content: "next", timestamp: 2 }] } }; + const text = "Prior conversation context:\n\nUSER:\ninspect\n[image]\n\nTOOL RESULT (call_id: call_1):\nresult[image]\n\nCurrent user request:\n\nnext"; + expect(buildConversationInput(parsed)).toEqual([JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })]); +}); + +test("ordinary user, document, assistant, tool and supported-image history stays byte-identical", () => { + expect(formatMessageForHistory({ role: "user", content: [{ type: "document", text: "document", mediaType: "text/plain", data: "ZG9jdW1lbnQ=" }, { type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 })).toBe("USER:\ndocument\n[image]"); + expect(formatMessageForHistory({ role: "assistant", content: [{ type: "text", text: " response " }, { type: "thinking", thinking: " reason " }, { type: "toolCall", id: "c", name: "echo", arguments: { x: 1 } }], timestamp: 0 })).toBe('ASSISTANT:\nresponse\n[Thinking: reason]\n[Tool call: echo (call_id: c) with args: {"x":1}]'); + expect(buildInputLines({ role: "user", content: [{ type: "text", text: "inspect" }, { type: "image", imageUrl: "https://example.test/image.png" }], timestamp: 0 })).toEqual(['{"type":"user","message":{"role":"user","content":[{"type":"text","text":"inspect"},{"type":"image","source":{"type":"url","url":"https://example.test/image.png"}}]}}']); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c7858ea3657..6a195c9bdf6 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,7 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "tool-identity-hash.test.ts": "adapters", + "qoder-image-input.test.ts": "providers", + "coding-agent-history-projection.test.ts": "adapters", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", "service-proxy-env.test.ts": "service", diff --git a/tests/providers/qoder-adapter.test.ts b/tests/providers/qoder-adapter.test.ts index 7c3c212fc8d..eca5cf4b1a1 100644 --- a/tests/providers/qoder-adapter.test.ts +++ b/tests/providers/qoder-adapter.test.ts @@ -605,271 +605,6 @@ describe("qoder adapter", () => { expect(isModelVisionSidecarConsumer(global, "unknown-live-id")).toBe(false); }); - test("forwards Qoder CN raw image input through the shared coding-agent projection", async () => { - let spawned = 0; - let stdin = ""; - const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); - child.stdin = new Writable({ - write(chunk, _encoding, callback) { - stdin += chunk.toString(); - callback(); - }, - }); - const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { - which: () => "/bin/qodercn", - spawn: () => { - spawned++; - return child; - }, - }); - const request = parsed({ context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } }); - const events: AdapterEvent[] = []; - await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); - expect(spawned).toBe(1); - const wire = JSON.parse(stdin.trim()); - expect(wire.message.content).toContainEqual({ - type: "image", - source: { type: "base64", media_type: "image/png", data: "AA==" }, - }); - expect(events.at(-1)).toMatchObject({ type: "done" }); - }); - - test("forwards Qoder CN multiple images and preserves history ordering", async () => { - let spawned = 0; - let stdin = ""; - const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); - child.stdin = new Writable({ - write(chunk, _encoding, callback) { - stdin += chunk.toString(); - callback(); - }, - }); - const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { - which: () => "/bin/qodercn", - spawn: () => { - spawned++; - return child; - }, - }); - const request = parsed({ - context: { - messages: [ - { role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,HIST==" }], timestamp: 0 }, - { role: "assistant", content: [{ type: "text", text: "I see the first image." }], timestamp: 1 }, - { - role: "user", - content: [ - { type: "text", text: "Look at these two:" }, - { type: "image", imageUrl: "data:image/jpeg;base64,CURR1==" }, - { type: "image", imageUrl: "data:image/png;base64,CURR2==" }, - ], - timestamp: 2, - }, - ], - }, - }); - const events: AdapterEvent[] = []; - await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); - expect(spawned).toBe(1); - const wire = JSON.parse(stdin.trim()); - const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); - expect(images).toHaveLength(3); - expect(images[0]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "HIST==" } }); - expect(images[1]).toEqual({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "CURR1==" } }); - expect(images[2]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "CURR2==" } }); - expect(events.at(-1)).toMatchObject({ type: "done" }); - }); - - test("forwards Qoder CN toolResult images through shared projection", async () => { - let spawned = 0; - let stdin = ""; - const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); - child.stdin = new Writable({ - write(chunk, _encoding, callback) { - stdin += chunk.toString(); - callback(); - }, - }); - const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { - which: () => "/bin/qodercn", - spawn: () => { - spawned++; - return child; - }, - }); - const request = parsed({ - context: { - messages: [ - { role: "user", content: "take screenshot", timestamp: 0 }, - { role: "assistant", content: [{ type: "toolCall", id: "call_1", name: "screenshot", arguments: {} }], timestamp: 1 }, - { - role: "toolResult", - toolCallId: "call_1", - content: [ - { type: "text", text: "captured screenshot:" }, - { type: "image", imageUrl: "data:image/png;base64,TOOL_IMG==" }, - ], - timestamp: 2, - }, - ], - }, - }); - const events: AdapterEvent[] = []; - await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); - expect(spawned).toBe(1); - const wire = JSON.parse(stdin.trim()); - expect(wire.message.content).toContainEqual({ - type: "image", - source: { type: "base64", media_type: "image/png", data: "TOOL_IMG==" }, - }); - const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); - expect(textPart?.text).toContain("[image attached below]"); - expect(events.at(-1)).toMatchObject({ type: "done" }); - }); - - test("omits unsupported image reference schemes cleanly without crashing", async () => { - let spawned = 0; - let stdin = ""; - const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); - child.stdin = new Writable({ - write(chunk, _encoding, callback) { - stdin += chunk.toString(); - callback(); - }, - }); - const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { - which: () => "/bin/qodercn", - spawn: () => { - spawned++; - return child; - }, - }); - const request = parsed({ - context: { - messages: [ - { role: "user", content: "first turn", timestamp: 0 }, - { role: "assistant", content: [{ type: "text", text: "response" }], timestamp: 1 }, - { - role: "user", - content: [ - { type: "text", text: "inspect link" }, - { type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }, - ], - timestamp: 2, - }, - ], - }, - }); - const events: AdapterEvent[] = []; - await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); - expect(spawned).toBe(1); - const wire = JSON.parse(stdin.trim()); - const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); - expect(images).toHaveLength(0); - const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); - expect(textPart?.text).toContain("[image omitted: unsupported reference]"); - expect(textPart?.text).toContain("ASSISTANT:\nresponse"); - expect(events.at(-1)).toMatchObject({ type: "done" }); - }); - - test("marks unsupported image references in a single-turn user message", () => { - const lines = buildConversationInput(parsed({ - context: { - messages: [{ - role: "user", - content: [ - { type: "text", text: "inspect link" }, - { type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }, - ], - timestamp: 0, - }], - }, - })); - - expect(lines).toHaveLength(1); - const frame = JSON.parse(lines[0]!); - expect(frame.message.content).toContainEqual({ type: "text", text: "inspect link" }); - expect(frame.message.content).toContainEqual({ type: "text", text: "[image omitted: unsupported reference]" }); - }); - - test("keeps a single-turn frame for an unsupported image-only message", () => { - const lines = buildConversationInput(parsed({ - context: { - messages: [{ - role: "user", - content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }], - timestamp: 0, - }], - }, - })); - - expect(lines).toHaveLength(1); - const frame = JSON.parse(lines[0]!); - expect(frame.message.content).toContainEqual({ type: "text", text: "[image omitted: unsupported reference]" }); - }); - - test("marks unsupported images in prior user and tool-result history as omitted", () => { - const lines = buildConversationInput(parsed({ - context: { - messages: [ - { - role: "user", - content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/user.png" }], - timestamp: 0, - }, - { role: "assistant", content: [{ type: "text", text: "I saw the image." }], timestamp: 1 }, - { - role: "toolResult", - toolCallId: "call_1", - content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/tool.png" }], - timestamp: 2, - }, - { role: "user", content: "What was attached?", timestamp: 3 }, - ], - }, - })); - - expect(lines).toHaveLength(1); - const frame = JSON.parse(lines[0]!); - const text = frame.message.content.find((part: { type: string }) => part.type === "text")?.text; - expect(text).toContain("USER:\n[image omitted: unsupported reference]"); - expect(text).toContain("TOOL RESULT (call_id: call_1):\n[image omitted: unsupported reference]"); - expect(frame.message.content.filter((part: { type: string }) => part.type === "image")).toHaveLength(0); - }); - - test("leaves plain text-only requests completely unaffected", async () => { - let spawned = 0; - let stdin = ""; - const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); - child.stdin = new Writable({ - write(chunk, _encoding, callback) { - stdin += chunk.toString(); - callback(); - }, - }); - const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { - which: () => "/bin/qodercn", - spawn: () => { - spawned++; - return child; - }, - }); - const request = parsed({ - context: { - messages: [{ role: "user", content: "Hello, plain text only", timestamp: 0 }], - }, - }); - const events: AdapterEvent[] = []; - await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); - expect(spawned).toBe(1); - const wire = JSON.parse(stdin.trim()); - const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); - expect(images).toHaveLength(0); - const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); - expect(textPart?.text).toContain("Hello, plain text only"); - expect(events.at(-1)).toMatchObject({ type: "done" }); - }); - test("forwards Qoder Global raw image input through the shared coding-agent projection", async () => { let spawned = 0; let stdin = ""; diff --git a/tests/providers/qoder-image-input.test.ts b/tests/providers/qoder-image-input.test.ts new file mode 100644 index 00000000000..f6fa883ea77 --- /dev/null +++ b/tests/providers/qoder-image-input.test.ts @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { PassThrough, Readable, Writable } from "node:stream"; +import type { ChildProcess } from "node:child_process"; +import { createQoderAdapter } from "../../src/adapters/qoder/adapter"; +import { buildQoderConversationInput as buildConversationInput } from "../../src/adapters/qoder/image-input"; +import { clearQoderBinaryCache } from "../../src/adapters/qoder/profiles"; +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +const enc = new TextEncoder(); +beforeEach(() => clearQoderBinaryCache()); + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { adapter: "qoder", baseUrl: "https://qoder.com", apiKey: "qoder-pat", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], ...overrides } as OcxProviderConfig; +} + +function parsed(overrides: Partial = {}): OcxParsedRequest { + return { modelId: "Qwen3.8-Max", stream: true, options: {}, context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, ...overrides } as OcxParsedRequest; +} + +function fakeChild(frames: string[], options: { parked?: boolean; exitCode?: number; stderr?: string } = {}): ChildProcess { + const child = new EventEmitter() as ChildProcess & { killed: boolean; exitCode: number | null }; + const stdout = new PassThrough(); + for (const frame of frames) stdout.write(enc.encode(frame)); + if (!options.parked) stdout.end(); + child.stdout = stdout; + child.stderr = Readable.from(options.stderr ? [options.stderr] : []); + child.stdin = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); + child.killed = false; + child.exitCode = null; + child.kill = () => { + child.killed = true; + child.exitCode = 0; + queueMicrotask(() => { + stdout.destroy(); + child.emit("close", 0); + }); + return true; + }; + if (!options.parked) { + setTimeout(() => { child.exitCode = options.exitCode ?? 0; child.emit("close", child.exitCode); }, 2); + } + return child; +} + +describe("Qoder image input", () => { + const png = { type: "image" as const, imageUrl: "data:image/png;base64,AA==" }; + const user = (content: Extract["content"]): OcxMessage => ({ role: "user", content, timestamp: 0 }); + const assistant = { role: "assistant" as const, content: [{ type: "text" as const, text: "response" }], timestamp: 1 }; + + test.each([ + ["URL", "DeepSeek-Flash", [user([{ type: "image", imageUrl: "https://example.test/a.png" }])]], + ["unsupported scheme", "DeepSeek-Flash", [user([{ type: "image", imageUrl: "ftp://example.test/a.png" }])]], + ["other media type", "DeepSeek-Flash", [user([{ type: "image", imageUrl: "data:image/jpeg;base64,AA==" }])]], + ["empty base64", "DeepSeek-Flash", [user([{ type: "image", imageUrl: "data:image/png;base64," }])]], + ["multiple images", "DeepSeek-Flash", [user([png, png])]], + ["unverified model", "Qwen3.8-Max", [user([png])]], + ["history image replay", "DeepSeek-Flash", [user([png]), assistant, user("next")]], + ["multi-turn image", "DeepSeek-Flash", [user("first"), assistant, user([png])]], + ["tool-result image", "DeepSeek-Flash", [user("inspect"), assistant, { role: "toolResult", toolCallId: "call_1", content: [png], timestamp: 2 }]], + ["developer image", "DeepSeek-Flash", [{ role: "developer", content: [png], timestamp: 0 }, user("inspect")]], + ] as const)("CN refuses unverified %s before CLI discovery or spawn", async (_surface, modelId, messages) => { + let discovered = 0; + let spawned = 0; + const events: AdapterEvent[] = []; + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => { discovered++; return "/bin/qoderclicn"; }, + spawn: () => { spawned++; return fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); }, + }); + await adapter.runTurn!(parsed({ modelId, context: { messages: [...messages] } }), + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", status: 400, code: "unsupported_input_modality", retryable: false }); + expect(events[0]).toMatchObject({ message: expect.stringContaining("qoderclicn 1.1.70") }); + expect(JSON.stringify(events)).not.toContain("example.test"); + expect(discovered).toBe(0); + expect(spawned).toBe(0); + }); + + test.each(["DeepSeek-Flash", "Qwen3.8-Flash"])("CN forwards the verified single-PNG contract for %s", async modelId => { + let stdin = ""; + const events: AdapterEvent[] = []; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ write(chunk, _encoding, callback) { stdin += chunk.toString(); callback(); } }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { which: () => "/bin/qoderclicn", spawn: () => child }); + await adapter.runTurn!(parsed({ modelId, context: { messages: [user([{ type: "text", text: "colour?" }, png])] } }), + { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(JSON.parse(stdin.trim()).message.content).toEqual([ + { type: "text", text: "colour?" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }, + ]); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("cancellation prevents a verified CN image from spawning", async () => { + let spawned = 0; + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => "/bin/qoderclicn", spawn: () => { spawned++; return fakeChild([]); }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(parsed({ modelId: "DeepSeek-Flash", context: { messages: [user([png])] } }), + { headers: new Headers(), abortSignal: AbortSignal.abort(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(0); + expect(events.at(-1)).toMatchObject({ type: "error", message: expect.stringContaining("aborted") }); + }); + + test("forwards Qoder CN raw image input through the shared coding-agent projection", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), { + which: () => "/bin/qodercn", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ modelId: "DeepSeek-Flash", context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + expect(wire.message.content).toContainEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: "AA==" }, + }); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("shared projection carries multiple images and preserves history ordering", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.com" }), { + which: () => "/bin/qoder", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,HIST==" }], timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "I see the first image." }], timestamp: 1 }, + { + role: "user", + content: [ + { type: "text", text: "Look at these two:" }, + { type: "image", imageUrl: "data:image/jpeg;base64,CURR1==" }, + { type: "image", imageUrl: "data:image/png;base64,CURR2==" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(3); + expect(wire.message.content[0].text).toContain("ASSISTANT:\nI see the first image."); + expect(images[0]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "HIST==" } }); + expect(images[1]).toEqual({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: "CURR1==" } }); + expect(images[2]).toEqual({ type: "image", source: { type: "base64", media_type: "image/png", data: "CURR2==" } }); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("shared projection carries toolResult images through shared projection", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.com" }), { + which: () => "/bin/qoder", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: "take screenshot", timestamp: 0 }, + { role: "assistant", content: [{ type: "toolCall", id: "call_1", name: "screenshot", arguments: {} }], timestamp: 1 }, + { + role: "toolResult", + toolCallId: "call_1", + content: [ + { type: "text", text: "captured screenshot:" }, + { type: "image", imageUrl: "data:image/png;base64,TOOL_IMG==" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + expect(wire.message.content).toContainEqual({ + type: "image", + source: { type: "base64", media_type: "image/png", data: "TOOL_IMG==" }, + }); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("[image attached below]"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("omits unsupported image reference schemes cleanly without crashing", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.com" }), { + which: () => "/bin/qoder", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [ + { role: "user", content: "first turn", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "response" }], timestamp: 1 }, + { + role: "user", + content: [ + { type: "text", text: "inspect link" }, + { type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }, + ], + timestamp: 2, + }, + ], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(0); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("[image omitted: unsupported reference]"); + expect(textPart?.text).toContain("ASSISTANT:\nresponse"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + + test("marks unsupported image references in a single-turn user message", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [{ + role: "user", + content: [ + { type: "text", text: "inspect link" }, + { type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }, + ], + timestamp: 0, + }], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + expect(frame.message.content).toContainEqual({ type: "text", text: "inspect link" }); + expect(frame.message.content).toContainEqual({ type: "text", text: "[image omitted: unsupported reference]" }); + }); + + test("keeps a single-turn frame for an unsupported image-only message", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [{ + role: "user", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/image.png" }], + timestamp: 0, + }], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + expect(frame.message.content).toContainEqual({ type: "text", text: "[image omitted: unsupported reference]" }); + }); + + test("marks unsupported images in prior user and tool-result history as omitted", () => { + const lines = buildConversationInput(parsed({ + context: { + messages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/user.png" }], + timestamp: 0, + }, + { role: "assistant", content: [{ type: "text", text: "I saw the image." }], timestamp: 1 }, + { + role: "toolResult", + toolCallId: "call_1", + content: [{ type: "image", imageUrl: "ftp://invalid-scheme.test/tool.png" }], + timestamp: 2, + }, + { role: "user", content: "What was attached?", timestamp: 3 }, + ], + }, + })); + + expect(lines).toHaveLength(1); + const frame = JSON.parse(lines[0]!); + const text = frame.message.content.find((part: { type: string }) => part.type === "text")?.text; + expect(text).toContain("USER:\n[image omitted: unsupported reference]"); + expect(text).toContain("TOOL RESULT (call_id: call_1):\n[image omitted: unsupported reference]"); + expect(frame.message.content.filter((part: { type: string }) => part.type === "image")).toHaveLength(0); + }); + + test("leaves plain text-only requests completely unaffected", async () => { + let spawned = 0; + let stdin = ""; + const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdin += chunk.toString(); + callback(); + }, + }); + const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.com" }), { + which: () => "/bin/qoder", + spawn: () => { + spawned++; + return child; + }, + }); + const request = parsed({ + context: { + messages: [{ role: "user", content: "Hello, plain text only", timestamp: 0 }], + }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(spawned).toBe(1); + const wire = JSON.parse(stdin.trim()); + const images = wire.message.content.filter((part: { type: string }) => part.type === "image"); + expect(images).toHaveLength(0); + const textPart = wire.message.content.find((part: { type: string }) => part.type === "text"); + expect(textPart?.text).toContain("Hello, plain text only"); + expect(events.at(-1)).toMatchObject({ type: "done" }); + }); + +}); From a78c64eed027a3cc297776e2811f738b98d2d901 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:16:05 +0900 Subject: [PATCH 5/6] docs(qoder): keep ownership updates with affected contracts Preserve the canonical image contract and replay-owner updates; leave reviewed owners with unchanged local contracts untouched, as structure/AGENTS.md requires. Co-authored-by: WU, CHI-LUNG <215337896+chilung-cgu@users.noreply.github.com> --- structure/adapters/registry.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/providers/anthropic-progress.md | 2 +- structure/providers/cursor.md | 2 +- structure/runtime.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/native-reasoning-retention.md | 2 -- structure/transports/responses-wire-shapes.md | 2 +- 9 files changed, 8 insertions(+), 10 deletions(-) diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index f03e5c2edc8..0393249c948 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,7 +1,7 @@ # Adapter Registry Authority `workbuddy-ai` inherits the `openai-chat` contract with a fixed-origin subscription adapter; see [WorkBuddy AI](../providers/workbuddy.md). -Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). +Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](../transports/streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 92dd79e616f..4e24d97a203 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,7 +1,7 @@ # Inbound Compatibility Surfaces The names used for these paths (native, translated, legacy bridge) and the declared per-feature -dispositions are owned by [Protocol Paths](protocol-paths.md). Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). +dispositions are owned by [Protocol Paths](protocol-paths.md). Native result continuations and function-result injection follow [the mode-specific result and control contract](../transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. diff --git a/structure/providers/anthropic-progress.md b/structure/providers/anthropic-progress.md index 1b3d917994b..5c49a7acb86 100644 --- a/structure/providers/anthropic-progress.md +++ b/structure/providers/anthropic-progress.md @@ -10,7 +10,7 @@ Hidden-summary preferences suppress reasoning, not these user-facing updates. Signed history preserves source block order, including thinking between tool calls; unsigned cross-provider history retains the adapter's preface-before-tools repair. `tests/adapters/anthropic/anthropic-progress-updates.test.ts` exercises SSE/JSON presentation, -exact replay, mixed block order, OAuth beta preservation and cancellation cleanup. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). +exact replay, mixed block order, OAuth beta preservation and cancellation cleanup. ## Signed Anthropic progress commentary diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 665dbff89f7..eeb5576f462 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -1,7 +1,7 @@ # Cursor Provider The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Codex-native retirement does not retire a Cursor-owned model name. Cursor transport and namespace handling retain their provider contract; the bounded native scope lives in diff --git a/structure/runtime.md b/structure/runtime.md index 53bf7cac91c..ebeac303a54 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,6 +1,6 @@ # Runtime -The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). +The minute sweep checks persisted activation deadlines locally; only missing deadlines trigger metadata discovery. See the [quota activation contract](providers/openai-tiers.md#public-provider-contract). Codex control-plane ids are defined in the dependency-free `src/codex/control-plane-models.ts`; `src/router.ts` routes these exact ids unchanged to canonical OpenAI, retaining the existing error when no OpenAI provider is enabled. They do not widen the public native roster. Catalog requests and scheduled source refreshes follow the [nonblocking catalog observation contract](catalog.md#shared-catalog). Async runtime persistence rejects observed selection/epoch changes and returns detached frozen results; bounded exec rejects promptly and releases output readers on deadline or abort, independently awaits actual child exit, and escalates SIGTERM to SIGKILL after the shared 2-second grace (Windows termination is forceful). Claude overflow normalization examines at most 64 Ki characters, extracts stated context/request counts in separate forward scans, retains the bounded rewritten prefix, and leaves other error classifications intact; the managed native Messages SSE route follows the same contract (`tests/claude-integration/claude-prompt-too-long.test.ts`). Optional native desktop observation follows the [Codex Desktop startup gate and awaited teardown](clients/codex-desktop.md#proxy-startup-preference). Claude Code tier defaults are projected by `src/claude/model-default-settings.ts` into user settings only after explicit preview/apply, independently of launcher environment injection; see the [management contract](gui-and-management-api.md). The explicit projection pairs selected aliases with readable labels from `src/claude/context-windows.ts`, preserving custom labels and clearing only a label matching its current alias when an explicitly selected tier becomes a non-alias; unset tiers retain both settings model IDs and labels. No startup or periodic sync writes these defaults. ## Bun runtime and test runner diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 6646c09acd5..13ae6cd51e5 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -1,6 +1,6 @@ # Byte Accounting -Native result continuations and function-result injection follow [the mode-specific result and control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). +Native result continuations and function-result injection follow [the mode-specific result and control contract](streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Native steering follows [the shared WebSocket contract](streaming-health.md#experimental-native-mid-turn-steering); this surface's defaults remain unchanged. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index a0cb6099d16..6a6cc614f6c 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -2,7 +2,7 @@ WorkBuddy AI uses bounded fixed-origin login requests and shared Chat inference transport; see the [WorkBuddy contract](../providers/workbuddy.md). OpenCode Go's Gemini model uses the [Go Gemini transport contract](../providers/google.md#opencode-go-gemini-transport) -through the Google adapter and the default server fetch path. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). +through the Google adapter and the default server fetch path. Meta Muse's registry-owned compatibility User-Agent takes precedence over an incoming client User-Agent; explicit provider headers still win. See the [Muse provider contract](../providers-and-adapters.md). diff --git a/structure/transports/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md index da6cff96b4e..d37b581bf42 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -1,7 +1,5 @@ # Native Reasoning Retention -Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). - ## Configuration and management `src/config/schema/native-reasoning-retention.ts` owns strict optional boolean `nativeReasoningRetention.modelSwitch` and `nativeReasoningRetention.accountSwitch` fields, both resolved off. Invalid disk blocks disable retention with a warning without discarding unrelated config; candidate writes reject invalid fields and unknown keys. Runtime scope and recovery precedence follow [native reasoning retention](#runtime-boundary). diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index 4cc19757f40..476f2267128 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -2,7 +2,7 @@ ## Client rate-limit retry advice -With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_error` codes `resource_exhausted`, `rate_limit_exceeded`, and `slow_down` to `rate_limit_exceeded`. `src/lib/retry-delay.ts` formats typed and message-only rate-limit advice with the longest lower bound first; competing original hints are retained under an explicit provider-detail label. Other explicit verdicts, proxy replay budgets and event ordering are unchanged. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). This creates no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](../providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped). +With a valid delay, `src/bridge/internal.ts` maps typed HTTP 429 `rate_limit_error` codes `resource_exhausted`, `rate_limit_exceeded`, and `slow_down` to `rate_limit_exceeded`. `src/lib/retry-delay.ts` formats typed and message-only rate-limit advice with the longest lower bound first; competing original hints are retained under an explicit provider-detail label. Other explicit verdicts, proxy replay budgets and event ordering are unchanged. Signed Anthropic progress follows the [progress-update contract](../providers/anthropic-progress.md). This creates no reasoning/history item; client retry policy, Grok HTTP status and combo preflight stay intact. ## Adapter incomplete reasons From 7481b67a1e06ff4e46123cd3585e904518746498 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:07:58 +0900 Subject: [PATCH 6/6] fix(qoder): validate bounded CN PNG bytes before CLI discovery Require canonical base64 and minimal PNG signature/IHDR validation, with a 1 MiB decoded local resource ceiling. Preserve the fixed unsupported-input event and cover malformed, mislabelled and oversized images before spawning. Use real tiny PNG fixtures and document the validation and evidence boundary. Co-authored-by: WU, CHI-LUNG <215337896+chilung-cgu@users.noreply.github.com> --- .../src/content/docs/guides/providers.md | 2 +- scripts/test-layout/layout-adapters.json | 1 + src/adapters/qoder/image-input.ts | 32 ++++++- structure/providers/chat-compat.md | 4 +- tests/fixtures/test-layout-expected.json | 1 + .../qoder-cn-image-validation.test.ts | 89 +++++++++++++++++++ tests/providers/qoder-image-input.test.ts | 9 +- 7 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 tests/providers/qoder-cn-image-validation.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index fadc3a02210..23026e580c8 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -1352,7 +1352,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa - **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix. - **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server. - **Output guard:** If the CLI echoes OpenCodex tool-call/result replay envelopes into assistant text, the turn fails with `vendor_scaffold_detected` (502) instead of returning the envelope as a successful answer, and neither reasoning nor tool calls following the envelope are forwarded. Reasoning starts a fresh replay-text block, so earlier prose or code fences cannot hide an envelope in later text. If the CLI's turn already ended with its own error, that error is reported instead, still without the envelope. The proxy does not retry that failure automatically. Ordinary prose and closed fenced examples follow the shared envelope recognizer; an unverified overflowing fenced example is refused. Assistant text preserves Unicode characters split across CLI frames; unpaired surrogates are replaced with U+FFFD. -- **Image Input:** Global continues forwarding supported images through the shared projection without a per-model vision allowlist. CN retains its vision-sidecar defaults. Its raw-image route supports only one base64 PNG in a single user turn with `DeepSeek-Flash` or `Qwen3.8-Flash`. Use `qoderclicn 1.1.70`: [issue #7516](https://github.com/lidge-jun/opencodex/issues/7516) reports Windows 11 x64 live success for those models on both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin; OpenCodex uses only stdin. Other CLI versions have not been verified, and the adapter does not probe the installed version. CN refuses raw URL images, other image formats/models, multiple images, tool-result images and image-bearing history/multi-turn replay before starting the CLI with `unsupported_input_modality`; use the vision sidecar for those inputs. Injected CLI tests cover serialization and cancellation without claiming live upstream support. Qoder's supported projection keeps image blocks in conversation order and marks unsupported references as `[image omitted: unsupported reference]`. Global stateless replay can still associate historical images unreliably with their original text; CN does not enable that unverified replay surface. +- **Image Input:** Global continues forwarding supported images through the shared projection without a per-model vision allowlist. CN retains its vision-sidecar defaults. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded in a single user turn with `DeepSeek-Flash` or `Qwen3.8-Flash`. Use `qoderclicn 1.1.70`: [issue #7516](https://github.com/lidge-jun/opencodex/issues/7516) reports Windows 11 x64 live success for those models on both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin; OpenCodex uses only stdin. The adapter validates base64 length/padding, decoded PNG signature and basic IHDR fields before CLI discovery; it does not decompress pixels or validate every PNG chunk/CRC. The 1 MiB ceiling is an OpenCodex resource policy, not a verified vendor maximum: the reported live evidence covers a 96×96 PNG, whose RGBA raster is only 36 KiB. Larger inputs require separate verification. Other CLI versions have not been verified, and the adapter does not probe the installed version. CN refuses raw URL images, other image formats/models, multiple images, tool-result images and image-bearing history/multi-turn replay before starting the CLI with `unsupported_input_modality`; use the vision sidecar for those inputs. Injected CLI tests cover serialization and cancellation without claiming live upstream support. Qoder's supported projection keeps image blocks in conversation order and marks unsupported references as `[image omitted: unsupported reference]`. Global stateless replay can still associate historical images unreliably with their original text; CN does not enable that unverified replay surface. - **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI. - **Quota:** No public quota API is used, so totals and reset times are unavailable. Codes 110 (account daily limit) and 118 (personal Credits exhaustion) report `insufficient_quota` and permit consideration of PAT recovery. Other supported quota codes also report `insufficient_quota` but do not authorize it. Numeric error codes take precedence: an unrecognized numeric code does not fall back to quota or authentication text. Without a numeric code, only the supported account-daily message beginning “You've reached your daily usage limit for Chat”, with no team/model/shared qualifier, grants quota recovery; generic credit-limit text does not. Only quota failures eligible for PAT recovery enter key cooldown handling. When such a failure has no reset time, the failed key uses the existing default cooldown; nonrecoverable quota classifications do not create a cooldown through this path, and no next-day reset is inferred. - **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring. diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 9a2442ede71..e0034f17d50 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,5 +1,6 @@ { "qoder-image-input.test.ts": "providers", + "qoder-cn-image-validation.test.ts": "providers", "coding-agent-history-projection.test.ts": "adapters", "cursor-native-grep-globs.test.ts": "providers/cursor", "tool-identity-hash.test.ts": "adapters", diff --git a/src/adapters/qoder/image-input.ts b/src/adapters/qoder/image-input.ts index 7ca0cdd411e..ea67a032e28 100644 --- a/src/adapters/qoder/image-input.ts +++ b/src/adapters/qoder/image-input.ts @@ -1,6 +1,36 @@ import type { OcxParsedRequest } from "../../types"; import { buildConversationInput } from "../coding-agent/protocol"; +// Local resource policy: #7516 verifies a 96x96 PNG, not a vendor size maximum. +// 1 MiB leaves ample room over its 36 KiB RGBA raster while bounding stdin copies. +const MAX_CN_PNG_BYTES = 1024 * 1024; +const PNG_DATA_PREFIX = "data:image/png;base64,"; +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const PNG_DEPTHS: Readonly> = { + 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16], +}; + +function boundedPngDataUrl(imageUrl: string): boolean { + // Bound encoded input before slicing, scanning, decoding or making stdin copies. + if (!imageUrl.startsWith(PNG_DATA_PREFIX) + || imageUrl.length > PNG_DATA_PREFIX.length + 4 * Math.ceil(MAX_CN_PNG_BYTES / 3)) return false; + const data = imageUrl.slice(PNG_DATA_PREFIX.length); + if (!data.length || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) return false; + const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; + if (data.length / 4 * 3 - padding > MAX_CN_PNG_BYTES) return false; + const bytes = Buffer.from(data, "base64"); + // Bun's decoder is permissive; round-trip also rejects nonzero padding bits. + if (bytes.toString("base64") !== data || bytes.length < 33 + || !bytes.subarray(0, 8).equals(PNG_SIGNATURE) + || bytes.readUInt32BE(8) !== 13 || bytes.readUInt32BE(12) !== 0x49484452) return false; + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + // Minimal IHDR validation only; this does not decompress or validate all chunks. + return width > 0 && width <= 0x7fffffff && height > 0 && height <= 0x7fffffff + && (PNG_DEPTHS[bytes[25]!] ?? []).includes(bytes[24]!) + && bytes[26] === 0 && bytes[27] === 0 && (bytes[28] === 0 || bytes[28] === 1); +} + /** #7516 live evidence: qoderclicn 1.1.70, one user PNG on stream-json stdin. */ export function qoderCnImageInputSupported(parsed: OcxParsedRequest): boolean { const messages = parsed.context.messages; @@ -14,7 +44,7 @@ export function qoderCnImageInputSupported(parsed: OcxParsedRequest): boolean { const content = nonDev[0]!.content; if (!Array.isArray(content)) return false; const images = content.filter(part => part.type === "image"); - return images.length === 1 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(images[0]!.imageUrl); + return images.length === 1 && boundedPngDataUrl(images[0]!.imageUrl); } /** Keep Qoder omission markers out of CodeBuddy's and Claude's replay formats. */ diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index d3b60996dc7..27c9e334194 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -571,8 +571,8 @@ transport or URL fetch is added, and no client filename, payload, URL or metadat charges supported audio by decoded size at the conservative 256-bytes-per-token proxy, not an upstream token formula. The shared coding-agent projection in `src/adapters/coding-agent/` carries supported tool-result images as real image blocks in conversation order, history before current. Current tool-result prose retains `[image attached below]` and `[image omitted: unsupported reference]`. Qoder alone opts into omission markers for single-user input and historical user/tool-result prose through `src/adapters/qoder/image-input.ts`; CodeBuddy retains its previous bytes (historical `[image]` and single-user unsupported-reference omission), and Claude keeps its stable replay builder. Vendor tool execution stays disabled. -CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder Global continues forwarding supported images without a per-model vision allowlist. CN retains its sidecar classification. Its raw-image route admits only one base64 PNG in one non-developer user message, on `DeepSeek-Flash` or `Qwen3.8-Flash`; developer images, URL references, other media types/models, multiple images, tool-result images and image-bearing multi-turn replay return a fixed non-retryable 400 `unsupported_input_modality` before CLI discovery/spawn. Text-only CN turns remain unrestricted by this gate. -Issue #7516 reports live verification on Windows 11 x64 with `qoderclicn 1.1.70` for those two models and a single PNG, through both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin. OpenCodex uses only stdin; attachment flags are not added. This is reported evidence, not a fresh live test or a guarantee for other CLI versions; operators must use the verified version (no runtime version probe is added). Injected CLI tests prove serialization, omission markers, refusals and cancellation, not upstream image understanding. Global still replays historical images with `--no-session-persistence`, whose text/image association remains unverified across turns. The scaffold/replay output guards ([ADR-7517](../clients/qoder.md#qoder-assistant-text-replay-guard)) and selector ownership ([ADR-7512](../clients/qoder.md#qoder-settings-ownership)) remain independent. +CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder Global continues forwarding supported images without a per-model vision allowlist. CN retains its sidecar classification. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded in one non-developer user message, on `DeepSeek-Flash` or `Qwen3.8-Flash`; developer images, URL references, other media types/models, multiple images, tool-result images and image-bearing multi-turn replay return a fixed non-retryable 400 `unsupported_input_modality` before CLI discovery/spawn. Before CLI discovery, admission bounds encoded length, validates base64 length/padding and round-trip decoding, then checks the PNG signature and basic IHDR fields (13-byte first chunk, positive PNG-range dimensions, legal depth/colour pair, compression/filter/interlace). It does not decompress pixels or validate all chunks/CRCs. Invalid or oversized images receive the same fixed error event; text-only CN turns remain unrestricted by this gate. +Issue #7516 reports live verification on Windows 11 x64 with `qoderclicn 1.1.70` for those two models and a single PNG, through both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin. OpenCodex uses only stdin; attachment flags are not added. The 1 MiB decoded ceiling is local resource policy, not a vendor maximum: the reported 96×96 PNG has a 36 KiB RGBA raster; larger upstream inputs remain unverified. This is reported evidence, not a fresh live test or a guarantee for other CLI versions; operators must use the verified version (no runtime version probe is added). Injected CLI tests prove serialization, omission markers, refusals and cancellation, not upstream image understanding. Global still replays historical images with `--no-session-persistence`, whose text/image association remains unverified across turns. The scaffold/replay output guards ([ADR-7517](../clients/qoder.md#qoder-assistant-text-replay-guard)) and selector ownership ([ADR-7512](../clients/qoder.md#qoder-settings-ownership)) remain independent. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. Upstream API-key usage follows the [physical-attempt account attribution contract](../dashboard-and-usage.md#upstream-key-account-attribution), independently of subscription quota observations. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index dfc17f09803..1482ecc06c1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,6 +1,7 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "tool-identity-hash.test.ts": "adapters", "qoder-image-input.test.ts": "providers", + "qoder-cn-image-validation.test.ts": "providers", "coding-agent-history-projection.test.ts": "adapters", "server-combo-required-tool-contract.test.ts": "server", "responses-anthropic-compaction-prefix.test.ts": "responses", diff --git a/tests/providers/qoder-cn-image-validation.test.ts b/tests/providers/qoder-cn-image-validation.test.ts new file mode 100644 index 00000000000..57d12a9d126 --- /dev/null +++ b/tests/providers/qoder-cn-image-validation.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { createQoderAdapter } from "../../src/adapters/qoder/adapter"; +import { qoderCnImageInputSupported } from "../../src/adapters/qoder/image-input"; +import { clearQoderBinaryCache } from "../../src/adapters/qoder/profiles"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +// A complete 1x1 PNG, including IDAT and IEND, rather than a MIME-only fixture. +const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=", "base64"); +const ceiling = 1024 * 1024; // OpenCodex policy, not a verified vendor maximum. +const dataUrl = (bytes: Uint8Array) => `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`; +const request = (imageUrl: string): OcxParsedRequest => ({ + modelId: "DeepSeek-Flash", stream: true, options: {}, + context: { messages: [{ role: "user", content: [{ type: "image", imageUrl }], timestamp: 0 }] }, +}); + +function changedHeader(offset: number, value: number): Buffer { + const bytes = Buffer.from(png); + bytes[offset] = value; + return bytes; +} + +// Insert a valid ancillary tEXt chunk before IEND to exercise exact decoded sizes. +function sizedPng(size: number): Buffer { + const chunk = Buffer.alloc(size - png.length, 0x20); + chunk.writeUInt32BE(chunk.length - 12, 0); + chunk.write("tEXt", 4, "ascii"); + chunk.write("Note\0", 8, "ascii"); + let crc = 0xffffffff; + for (const byte of chunk.subarray(4, -4)) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + chunk.writeUInt32BE((crc ^ 0xffffffff) >>> 0, chunk.length - 4); + return Buffer.concat([png.subarray(0, -12), chunk, png.subarray(-12)]); +} + +beforeEach(() => clearQoderBinaryCache()); + +describe("Qoder CN decoded PNG admission", () => { + test.each([ + ["incomplete quartet", "data:image/png;base64,A"], + ["excess padding", "data:image/png;base64,AAAA=="], + ["missing padding", dataUrl(png).replace(/=+$/, "")], + ["nonzero padding bits", dataUrl(png).replace(/I=$/, "J=")], + ["interior padding", "data:image/png;base64,AA==AAAA"], + ["invalid alphabet", "data:image/png;base64,AA-_"], + ["whitespace", `${dataUrl(png)}\n`], + ["encoded text", dataUrl(Buffer.from("not a PNG"))], + ["JPEG labelled PNG", dataUrl(Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46, 0x49, 0x46]))], + ["truncated signature", dataUrl(png.subarray(0, 7))], + ["truncated IHDR", dataUrl(png.subarray(0, 32))], + ["wrong first chunk", dataUrl(changedHeader(12, 0x4a))], + ["non-ASCII IHDR alias", dataUrl(changedHeader(12, 0xc9))], + ["wrong IHDR length", dataUrl(changedHeader(11, 12))], + ["zero width", dataUrl(changedHeader(19, 0))], + ["zero height", dataUrl(changedHeader(23, 0))], + ["width beyond PNG range", dataUrl(changedHeader(16, 0x80))], + ["height beyond PNG range", dataUrl(changedHeader(20, 0x80))], + ["invalid bit depth", dataUrl(changedHeader(24, 3))], + ["invalid colour type", dataUrl(changedHeader(25, 1))], + ["invalid compression", dataUrl(changedHeader(26, 1))], + ["invalid filter", dataUrl(changedHeader(27, 1))], + ["invalid interlace", dataUrl(changedHeader(28, 2))], + ["one byte above ceiling", dataUrl(sizedPng(ceiling + 1))], + ["review's 21 MiB payload", dataUrl(sizedPng(21 * 1024 * 1024))], + ])("rejects %s with the fixed event before discovery and stdin", async (_name, imageUrl) => { + let discoveries = 0; + let spawns = 0; + const adapter = createQoderAdapter({ adapter: "qoder", baseUrl: "https://qoder.cn", apiKey: "test-pat" } as OcxProviderConfig, { + which: () => { discoveries++; return "/bin/qoderclicn"; }, + spawn: () => { spawns++; throw new Error("invalid image reached spawn"); }, + }); + const events: AdapterEvent[] = []; + await adapter.runTurn!(request(imageUrl), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); + expect(discoveries).toBe(0); + expect(spawns).toBe(0); + expect(events).toEqual([{ + type: "error", status: 400, errorType: "invalid_request_error", code: "unsupported_input_modality", retryable: false, + message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.", + }]); + }); + + test("admits a real tiny PNG and the exact local decoded-size ceiling", () => { + expect(qoderCnImageInputSupported(request(dataUrl(png)))).toBe(true); + expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling))))).toBe(true); + expect(qoderCnImageInputSupported(request(dataUrl(sizedPng(ceiling - 1))))).toBe(true); + }); +}); diff --git a/tests/providers/qoder-image-input.test.ts b/tests/providers/qoder-image-input.test.ts index f6fa883ea77..1b683682e93 100644 --- a/tests/providers/qoder-image-input.test.ts +++ b/tests/providers/qoder-image-input.test.ts @@ -9,6 +9,7 @@ import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig } fr import { createTestTranslatorBudget } from "../helpers/translator-budget"; const enc = new TextEncoder(); +const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="; beforeEach(() => clearQoderBinaryCache()); function provider(overrides: Partial = {}): OcxProviderConfig { @@ -45,7 +46,7 @@ function fakeChild(frames: string[], options: { parked?: boolean; exitCode?: num } describe("Qoder image input", () => { - const png = { type: "image" as const, imageUrl: "data:image/png;base64,AA==" }; + const png = { type: "image" as const, imageUrl: `data:image/png;base64,${tinyPng}` }; const user = (content: Extract["content"]): OcxMessage => ({ role: "user", content, timestamp: 0 }); const assistant = { role: "assistant" as const, content: [{ type: "text" as const, text: "response" }], timestamp: 1 }; @@ -87,7 +88,7 @@ describe("Qoder image input", () => { await adapter.runTurn!(parsed({ modelId, context: { messages: [user([{ type: "text", text: "colour?" }, png])] } }), { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); expect(JSON.parse(stdin.trim()).message.content).toEqual([ - { type: "text", text: "colour?" }, { type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }, + { type: "text", text: "colour?" }, { type: "image", source: { type: "base64", media_type: "image/png", data: tinyPng } }, ]); expect(events.at(-1)).toMatchObject({ type: "done" }); }); @@ -121,14 +122,14 @@ describe("Qoder image input", () => { return child; }, }); - const request = parsed({ modelId: "DeepSeek-Flash", context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } }); + const request = parsed({ modelId: "DeepSeek-Flash", context: { messages: [user([png])] } }); const events: AdapterEvent[] = []; await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event)); expect(spawned).toBe(1); const wire = JSON.parse(stdin.trim()); expect(wire.message.content).toContainEqual({ type: "image", - source: { type: "base64", media_type: "image/png", data: "AA==" }, + source: { type: "base64", media_type: "image/png", data: tinyPng }, }); expect(events.at(-1)).toMatchObject({ type: "done" }); });