diff --git a/src/models.ts b/src/models.ts index e102c00..706689d 100644 --- a/src/models.ts +++ b/src/models.ts @@ -43,6 +43,7 @@ export const MODEL_INPUT_MODALITIES: Readonly { it("matches command-code@1.32.1 image input capabilities", () => { assert.deepEqual(inputModalitiesForModel("gpt-5.6-luna"), ["text", "image"]) assert.deepEqual(inputModalitiesForModel("meta/muse-spark-1.2"), ["text", "image"]) + assert.deepEqual(inputModalitiesForModel("deepseek/deepseek-v4-flash-vision-exp"), ["text", "image"]) assert.deepEqual(inputModalitiesForModel("deepseek/deepseek-v4-pro"), ["text"]) assert.deepEqual(inputModalitiesForModel("unknown-new-model"), ["text"]) assert.equal(modelSupportsImageInput("gpt-5.6-luna"), true) + assert.equal(modelSupportsImageInput("deepseek/deepseek-v4-flash-vision-exp"), true) assert.equal(modelSupportsImageInput("deepseek/deepseek-v4-pro"), false) - assert.equal(Object.keys(MODEL_INPUT_MODALITIES).length, 37) + assert.equal(Object.keys(MODEL_INPUT_MODALITIES).length, 38) }) it("marks only known reasoning models as reasoning-capable", () => { diff --git a/tests/test-stream.ts b/tests/test-stream.ts index a429798..fa7626c 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -173,6 +173,85 @@ describe("streamCommandCode — successful streams", () => { ) }) + it("forwards a tool-result image as a following user image for vision-capable models", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode( + makeModel({ id: "deepseek/deepseek-v4-flash-vision-exp" }), + makeContext({ + messages: [ + { role: "user", content: "read the image" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "read", + content: [ + { type: "text", text: "image attached" }, + { type: "image", data: "aGVsbG8=", mimeType: "image/png" }, + ], + }, + ], + }), + { apiKey: "mock-key" }, + ), + ) + + // No error: the tool-result image must not be rejected for this model. + assert.equal(events.at(-1)?.type, "done") + + const body = server.lastRequestBody() + // The tool-result text is forwarded on the tool message at index 2. + assert.equal( + objectAt(body, ["params", "messages", "2", "content", "0", "output", "value"]), + "image attached", + ) + // The tool-result image is forwarded as a following user image message at index 3. + assert.equal(objectAt(body, ["params", "messages", "3", "role"]), "user") + assert.equal( + objectAt(body, ["params", "messages", "3", "content", "0", "image"]), + "data:image/png;base64,aGVsbG8=", + ) + }) + + it("rejects a tool-result image before network access for text-only models", async () => { + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + const events = await collectEvents( + streamCommandCode( + makeModel({ id: "deepseek/deepseek-v4-pro" }), + makeContext({ + messages: [ + { role: "user", content: "read the image" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "read", + content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }], + }, + ], + }), + { apiKey: "mock-key" }, + ), + ) + + assert.equal(events.at(-1)?.type, "error") + assert.match(events.at(-1)?.error.errorMessage ?? "", /does not support image content/i) + assert.equal(server.requestCount(), 0) + }) + it("rejects images before network access for text-only models", async () => { const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })