feat(models): add vision input capabilities (#43)

This commit is contained in:
Patrick Wozniak
2026-08-11 00:28:03 +02:00
committed by GitHub
parent 4b6e1cd85a
commit a367aed63c
9 changed files with 265 additions and 56 deletions
+14 -1
View File
@@ -9,8 +9,11 @@ import {
commandCodeModelsFromCache,
DEFAULT_MODELS_TIMEOUT_MS,
getModelsTimeoutMs,
inputModalitiesForModel,
loadCommandCodeModels,
MODEL_EFFORTS,
MODEL_INPUT_MODALITIES,
modelSupportsImageInput,
thinkingLevelMapForEfforts,
thinkingMetadataForModel,
type CommandCodeModel,
@@ -81,6 +84,16 @@ describe("commandCodeModelsFromApiResponse()", () => {
assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS)
})
it("matches command-code@1.15.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-pro"), ["text"])
assert.deepEqual(inputModalitiesForModel("unknown-new-model"), ["text"])
assert.equal(modelSupportsImageInput("gpt-5.6-luna"), true)
assert.equal(modelSupportsImageInput("deepseek/deepseek-v4-pro"), false)
assert.equal(Object.keys(MODEL_INPUT_MODALITIES).length, 37)
})
it("marks only known reasoning models as reasoning-capable", () => {
const models = commandCodeModelsFromApiResponse({
object: "list",
@@ -94,7 +107,7 @@ describe("commandCodeModelsFromApiResponse()", () => {
assert.equal(models[1]?.reasoning, false)
})
it("matches the exact command-code@1.14.1 reasoning effort catalog", () => {
it("matches the exact command-code@1.15.1 reasoning effort catalog", () => {
assert.deepEqual(MODEL_EFFORTS, {
"Qwen/Qwen3.8-Max": ["low", "medium", "xhigh"],
"claude-fable-5": ["low", "medium", "high", "xhigh", "max"],
+78 -19
View File
@@ -11,7 +11,6 @@ import { describe, it } from "node:test"
import {
assertTextOnlyMessages,
COMMAND_CODE_INPUT_TYPES,
getApiKey,
getEnvironmentInfo,
mapFinishReason,
@@ -118,11 +117,7 @@ describe("projectSlugFromPath()", () => {
})
describe("text-only image handling", () => {
it("does not advertise image input capability", () => {
assert.deepEqual(COMMAND_CODE_INPUT_TYPES, ["text"])
})
it("rejects image content instead of dropping it", () => {
it("rejects image content for models without image support", () => {
assert.throws(
() =>
assertTextOnlyMessages([
@@ -131,7 +126,7 @@ describe("text-only image handling", () => {
content: [{ type: "image", data: "base64-data", mimeType: "image/png" }],
},
]),
/does not support image content.*refusing to send it/i,
/does not support image content/i,
)
assert.throws(
() =>
@@ -142,7 +137,7 @@ describe("text-only image handling", () => {
content: [{ type: "image", data: "base64-data", mimeType: "image/png" }],
},
]),
/does not support image content.*refusing to send it/i,
/does not support image content/i,
)
})
})
@@ -160,17 +155,16 @@ describe("textContent()", () => {
)
})
it("rejects mixed text and image content instead of dropping the image", () => {
assert.throws(
() =>
textContent({
content: [
{ type: "text", text: "hello" },
{ type: "image", data: "x", mimeType: "image/png" },
{ type: "text", text: "world" },
],
}),
/does not support image content.*refusing to send it/i,
it("extracts text while images are handled separately", () => {
assert.equal(
textContent({
content: [
{ type: "text", text: "hello" },
{ type: "image", data: "x", mimeType: "image/png" },
{ type: "text", text: "world" },
],
}),
"hello\nworld",
)
})
@@ -506,6 +500,71 @@ describe("messagesToCC()", () => {
assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld")
})
it("serializes image inputs in the current Command Code wire format", () => {
assert.deepEqual(
messagesToCC(
[
{
role: "user",
content: [
{ type: "text", text: "inspect this" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
],
},
],
{ allowImages: true },
),
[
{
role: "user",
content: [
{ type: "text", text: "inspect this" },
{
type: "image",
image: "data:image/png;base64,aGVsbG8=",
mimeType: "image/png",
},
],
},
],
)
})
it("preserves tool-result images as a following user image message", () => {
const result = messagesToCC(
[
{ role: "user", content: "read 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/jpeg" },
],
},
],
{ allowImages: true },
)
assert.equal(objectAt(result, ["2", "role"]), "tool")
assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "image attached")
assert.deepEqual(objectAt(result, ["3"]), {
role: "user",
content: [
{
type: "image",
image: "data:image/jpeg;base64,aGVsbG8=",
mimeType: "image/jpeg",
},
],
})
})
it("drops previous assistant reasoning while preserving text and tool calls", () => {
const result = messagesToCC([
{ role: "user", content: "first question" },
+55 -5
View File
@@ -142,6 +142,59 @@ describe("streamCommandCode — successful streams", () => {
assert.equal(calculatedUsages.length, 1)
})
it("sends images for vision-capable models", async () => {
server.mockResponse({
type: "success",
events: [JSON.stringify({ type: "finish", finishReason: "stop" })],
})
const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() })
await collectEvents(
streamCommandCode(
makeModel({ id: "gpt-5.6-luna" }),
makeContext({
messages: [
{
role: "user",
content: [
{ type: "text", text: "inspect" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
],
},
],
}),
{ apiKey: "mock-key" },
),
)
assert.equal(
objectAt(server.lastRequestBody(), ["params", "messages", "0", "content", "1", "image"]),
"data:image/png;base64,aGVsbG8=",
)
})
it("rejects images 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: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
},
],
}),
{ apiKey: "mock-key" },
),
)
assert.equal(events.at(-1)?.type, "error")
assert.equal(server.requestCount(), 0)
})
it("derives uncached input when noCacheTokens is missing", async () => {
server.mockResponse({
type: "success",
@@ -375,10 +428,7 @@ describe("streamCommandCode — request serialization", () => {
const lastEvent = events.at(-1)
assert.equal(lastEvent?.type, "error")
if (lastEvent?.type === "error") {
assert.match(
lastEvent.error.errorMessage ?? "",
/does not support image content.*refusing to send it/i,
)
assert.match(lastEvent.error.errorMessage ?? "", /does not support image content/i)
}
assert.equal(server.requestCount(), 0)
})
@@ -438,7 +488,7 @@ describe("streamCommandCode — request serialization", () => {
const headers = server.lastRequestHeaders()
assert.equal(headers.authorization, "Bearer mock-key")
assert.equal(headers["x-command-code-version"], "0.29.0")
assert.equal(headers["x-command-code-version"], "1.15.1")
assert.equal(headers["x-project-slug"], "repo")
assert.equal(headers["x-taste-learning"], "true")
assert.equal(headers["x-co-flag"], "false")