feat(models): add vision input capabilities (#43)
This commit is contained in:
+14
-1
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user