Skip to content

Commit 83718a4

Browse files
committed
fix(models): respect CLI text-only image input limits
Apply the CommandCode 1.53.0 text-only model policy to Alpha and Provider requests, resolve aliases, preserve vision and unknown-model fallback, and replace omitted images with CLI markers without mutating history. Document separate reasoning output and cover modality behavior in regression tests. Closes #5
1 parent 8c59d78 commit 83718a4

9 files changed

Lines changed: 213 additions & 2 deletions

File tree

‎README.ko.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ curl -sS http://127.0.0.1:9992/v1/chat/completions \
208208

209209
### 설정과 운영
210210

211+
**Reasoning 출력 변경:** `INCLUDE_REASONING=true`이면 reasoning을 `content`에 붙이지 않고 스트리밍은 `delta.reasoning_content`, 비스트리밍은 `message.reasoning_content`로 따로 반환합니다. Reasoning을 표시하는 클라이언트는 이 필드를 읽어야 합니다. Provider 비스트리밍 재시도에서는 옵션이 켜졌을 때만 reasoning을 보이는 출력으로 세므로, 숨겨진 reasoning만 있는 빈 응답의 성공 처리와 노출된 reasoning의 불필요한 재시도를 방지합니다.
212+
213+
Alpha와 Provider 모두 CLI 1.53.0의 텍스트 전용 모델 목록과 별칭에 대해 이미지 입력을 제거합니다. 가장 최근 이미지 메시지에는 번호가 붙은 텍스트 마커를 넣고 과거 이미지는 생략합니다. 비전 모델의 이미지는 유지하며, 알 수 없는 모델이나 custom 모델은 CLI와 같이 이미지 입력을 허용하지만 실제 비전 지원을 보장하지는 않습니다. Alpha는 base64 data URI를 `mimeType`이 있는 네이티브 이미지 파트로 보내고, 원격 URL은 내려받지 않고 텍스트 자리표시자로 유지합니다.
214+
211215
저장된 dashboard catalog에서 업그레이드하면 현재 model의 enabled state와 모든 custom model은 보존하고, built-in metadata는 1.53.0 canonical 정의로 갱신합니다. Ox Alpha와 MiniMax M3/M2.7 Free를 포함한 retired built-in은 unknown upstream model로 전달하지 않으며, 제거된 default가 설정돼 있으면 `deepseek/deepseek-v4-pro`로 안전하게 fallback합니다.
212216

213217
브라우저에 key가 저장된 기존 사용자는 그대로 동작합니다. 새 브라우저에서는 저장·재시작 전에 **현재 Admin API Key**에 기존 key를 한 번 입력합니다. key 없는 runtime은 실제 loopback 연결이며 Host도 loopback인 경우에만 bootstrap할 수 있습니다.

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ Open `http://127.0.0.1:9992/dashboard`. The mobile-first UI stores its Korean/En
208208

209209
### Configuration and operations
210210

211+
**Reasoning output change:** with `INCLUDE_REASONING=true`, reasoning is returned separately as `delta.reasoning_content` (streaming) or `message.reasoning_content` (non-streaming), not appended to `content`. Clients that display reasoning must read that field. Non-streaming Provider retries count reasoning as visible only when enabled, avoiding both blank success with hidden reasoning and unnecessary retries for exposed reasoning.
212+
213+
Image inputs are removed for the CLI 1.53.0 text-only model list in both Alpha and Provider modes, including aliases. The newest image-bearing message gets numbered text markers; historical images are omitted. Vision models retain images, while unknown/custom models follow the CLI's image-capable fallback, not a guarantee of actual vision support. Alpha sends base64 data URIs as native image parts with `mimeType`; remote URLs remain text placeholders and are not downloaded.
214+
211215
Upgrades from a persisted dashboard catalog preserve each current model's enabled state and all custom models, while refreshing built-in metadata from the 1.53.0 canonical definitions. Retired built-ins, including Ox Alpha and the MiniMax M3/M2.7 Free models, are removed rather than forwarded as unknown upstream models; a retired configured default falls back to `deepseek/deepseek-v4-pro`.
212216

213217
Existing browsers with a saved key continue without interruption. On a fresh browser, enter the current key in **Current Admin API Key** before saving or restarting. A runtime with no key can bootstrap only from a real loopback connection whose Host is also loopback.

‎README.zh.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ curl -sS http://127.0.0.1:9992/v1/chat/completions \
208208

209209
### 配置与运维
210210

211+
**Reasoning 输出变更:** 启用 `INCLUDE_REASONING=true` 时,reasoning 不再追加到 `content`,而是通过流式的 `delta.reasoning_content` 或非流式的 `message.reasoning_content` 单独返回。需要展示 reasoning 的客户端必须读取该字段。Provider 非流式重试仅在启用时将 reasoning 计为可见输出,避免隐藏 reasoning 导致空响应被当作成功,也避免对已展示的 reasoning 进行不必要的重试。
212+
213+
Alpha 和 Provider 均根据 CLI 1.53.0 的纯文本模型列表移除图片输入,别名也适用。最新的图片消息会得到带编号的文本标记,历史图片则被省略。视觉模型保留图片;未知或自定义模型遵循 CLI 允许图片的默认行为,但这不保证实际支持视觉。Alpha 将 base64 data URI 作为带有 `mimeType` 的原生图片部分发送;远程 URL 保留为文本占位符,不会被下载。
214+
211215
从持久化的 dashboard catalog 升级时,会保留当前 model 的 enabled state 和所有 custom model,并用 1.53.0 canonical 定义刷新 built-in metadata。包括 Ox Alpha 和 MiniMax M3/M2.7 Free 在内的 retired built-in 不会作为 unknown upstream model 转发;若 default 已退役,则安全回退到 `deepseek/deepseek-v4-pro`。
212216

213217
浏览器已保存 key 的现有用户可继续使用。新浏览器在保存或重启前,需要在 **当前管理员 API Key** 中输入一次现有 key。无 key runtime 仅在真实 loopback 连接且 Host 也是 loopback 时允许 bootstrap。

‎docs/DEPLOYMENT.ko.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,14 @@ Credential JSON 파일 예시:
451451

452452
이 정책은 reasoning-heavy model이 hidden token만 쓰다가 끝난 응답을 client가 정상 빈 답변으로 오인하지 않게 막습니다.
453453

454+
Provider 비스트리밍 응답에서는 `INCLUDE_REASONING=true`일 때만 `reasoning_content`를 사용자에게 보이는 출력으로 셉니다. 이 경우 reasoning만 있는 응답도 빈 응답 재시도 없이 반환합니다. 옵션을 끄면 숨겨진 reasoning은 빈 `length` 응답의 재시도 소진과 오류 반환을 막지 않습니다. 텍스트와 tool call은 옵션과 관계없이 유효한 출력입니다.
455+
456+
### 모델의 이미지 입력
457+
458+
Alpha와 Provider 요청 변환 모두 `src/model-images.ts`에 기록된 CommandCode CLI 1.53.0 모델 목록을 기준으로 텍스트 전용 모델의 이미지 입력을 제거합니다. 별칭에도 같은 정책을 적용합니다. 이전 이미지 입력은 제거하고, 가장 최근 이미지가 포함된 user 또는 tool 메시지에는 번호가 붙은 텍스트 마커를 넣습니다. 이미지만 있던 과거 메시지에는 생략 마커를 남겨 빈 메시지가 되지 않도록 합니다. 입력 대화 객체는 변경하지 않습니다.
459+
460+
비전 모델의 이미지는 유지합니다. 알 수 없는 모델이나 custom 모델은 CLI와 같이 이미지 입력을 허용하는 기본 동작을 따르므로, 텍스트 전용 목록에 없다고 실제 상류의 비전 지원이 보장되지는 않습니다. 향후 CLI 카탈로그 정렬 시 이 목록도 함께 갱신해야 합니다. Alpha에서는 base64 data URI를 `mimeType`이 있는 네이티브 이미지 파트로 변환하며, 원격 URL은 텍스트 자리표시자로 유지하고 bridge가 내려받지 않습니다.
461+
454462
### Balance alert 옵션
455463

456464
Balance alert는 기본적으로 꺼져 있습니다.

‎docs/DEPLOYMENT.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,14 @@ When upgrading a persisted 1.3.1 dashboard catalog, enabled state and custom mod
452452

453453
This protects clients from treating hidden-token exhaustion as a valid empty answer.
454454

455+
For non-streaming Provider responses, `reasoning_content` counts as visible output only when `INCLUDE_REASONING=true`. Reasoning-only responses then return without an empty-response retry. With the flag off, hidden reasoning does not prevent an empty `length` response from exhausting retries and returning an error. Text and tool calls remain valid output regardless of the flag.
456+
457+
### Model image input
458+
459+
Both Alpha and Provider request builders remove image inputs for text-only models using the CommandCode CLI 1.53.0 model list in `src/model-images.ts`. Aliases resolve to the same policy. Older image inputs are removed; the latest image-bearing user or tool message receives numbered text markers instead. Image-only historical messages retain an omission marker so the message is not empty. The input conversation is not mutated.
460+
461+
Vision-capable models keep their images. Unknown/custom models follow the CLI's image-capable fallback; absence from the text-only list is not a guarantee of upstream vision support. Update the list alongside future CLI catalog alignments. Alpha converts base64 data URIs to native image parts with `mimeType`; remote URLs remain text placeholders and are not downloaded by the bridge.
462+
455463
### Balance alert options
456464

457465
Balance alerts are disabled by default.

‎src/converter.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { randomUUID } from "node:crypto";
22
import { cwd as processCwd } from "node:process";
3+
import { messagesForModel } from "./model-images.js";
34

45
import type {
56
CommandCodeContentPart,
@@ -253,7 +254,7 @@ export function buildCommandCodeGenerateBody(
253254

254255
const params: CommandCodeGenerateBody["params"] = {
255256
model: options.upstreamModel,
256-
messages: convertMessages(options.request.messages),
257+
messages: convertMessages(messagesForModel(options.request.messages, options.upstreamModel)),
257258
tools: convertOpenAITools(options.request.tools, options.request.tool_choice),
258259
system: buildSystemPrompt(options.request),
259260
max_tokens: options.request.max_tokens ?? 64_000,

‎src/model-images.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { modelAliasMap } from "./model-catalog.js";
2+
import type { OpenAIChatMessage, OpenAIContentPart } from "./types.js";
3+
4+
// command-code 1.53.0: isKnownTextOnlyModel and the registry's inputModalities.
5+
// Unknown models remain image-capable, matching the CLI registry fallback.
6+
const textOnlyModels: ReadonlySet<string> = new Set([
7+
"deepseek/deepseek-v4-pro",
8+
"deepseek/deepseek-v4-flash",
9+
"deepseek/deepseek-v4-flash-fast",
10+
"zai-org/GLM-5.3",
11+
"zai-org/GLM-5.2",
12+
"zai-org/GLM-5.2-Fast",
13+
"zai-org/GLM-5.1",
14+
"zai-org/GLM-5",
15+
"MiniMaxAI/MiniMax-M2.7",
16+
"minimax/minimax-m2.7-free",
17+
"MiniMaxAI/MiniMax-M2.5",
18+
"xiaomi/mimo-v2.5-pro",
19+
"Qwen/Qwen3.6-Max-Preview",
20+
"Qwen/Qwen3.7-Max",
21+
"meituan/LongCat-2.0:free",
22+
"stepfun/Step-3.5-Flash",
23+
"tencent/hy4-preview",
24+
"tencent/Hy3",
25+
"tencent/hy3-paid",
26+
"nvidia/nemotron-3-ultra-550b-a55b",
27+
"poolside/laguna-s-2.1-free",
28+
"inclusionai/ling-3.0-flash-free",
29+
"inclusionai/ling-3.0-flash-sante:free",
30+
]);
31+
const aliases = modelAliasMap();
32+
33+
export function messagesForModel(
34+
messages: OpenAIChatMessage[],
35+
upstreamModel: string,
36+
): OpenAIChatMessage[] {
37+
if (!textOnlyModels.has(aliases[upstreamModel] ?? upstreamModel)) return messages;
38+
39+
let latest = -1;
40+
for (let index = messages.length - 1; index >= 0; index -= 1) {
41+
const message = messages[index];
42+
if (
43+
message &&
44+
(message.role === "user" || message.role === "tool") &&
45+
Array.isArray(message.content) &&
46+
message.content.some((part) => part.type === "image_url")
47+
) {
48+
latest = index;
49+
break;
50+
}
51+
}
52+
if (latest < 0) return messages;
53+
54+
return messages.map((message, index) => {
55+
if ((message.role !== "user" && message.role !== "tool") || !Array.isArray(message.content)) {
56+
return message;
57+
}
58+
59+
let imageIndex = 0;
60+
const content: OpenAIContentPart[] = [];
61+
for (const part of message.content) {
62+
if (part.type !== "image_url") {
63+
content.push(part);
64+
} else if (index === latest) {
65+
imageIndex += 1;
66+
// Shipped CLI visionMarker: keep numbering and instructions for vision tools.
67+
content.push({
68+
type: "text",
69+
text: `<attached_image index="${imageIndex}">\nAn image is attached here. You cannot view it directly. If a vision tool is available, call it with image_index=${imageIndex} to read the image; otherwise tell the user you cannot see images.\n</attached_image>`,
70+
});
71+
}
72+
}
73+
if (content.length === 0 && message.content.length > 0) {
74+
content.push({ type: "text", text: "[image omitted: the active model is text-only]" });
75+
}
76+
return { ...message, content };
77+
});
78+
}

‎src/provider.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Transform, type TransformCallback } from "node:stream";
2+
import { messagesForModel } from "./model-images.js";
23

34
import {
45
combineAbortSignals,
@@ -27,7 +28,7 @@ export function buildProviderChatRequestBody(
2728
): ProviderChatRequestBody {
2829
const body: ProviderChatRequestBody = {
2930
model: upstreamModel,
30-
messages: request.messages,
31+
messages: messagesForModel(request.messages, upstreamModel),
3132
};
3233
if (request.stream !== undefined) body.stream = request.stream;
3334
if (request.max_tokens !== undefined) body.max_tokens = request.max_tokens;

‎tests/model-images.test.ts‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { buildCommandCodeGenerateBody } from "../src/converter.js";
4+
import { buildProviderChatRequestBody } from "../src/provider.js";
5+
import type { OpenAIChatCompletionRequest, OpenAIContentPart } from "../src/types.js";
6+
7+
const image: OpenAIContentPart = {
8+
type: "image_url",
9+
image_url: { url: "data:image/png;base64,AAAA" },
10+
};
11+
12+
describe("model image input limits", () => {
13+
it.each(["deepseek/deepseek-v4-pro", "deepseek-v4-pro", "alibaba/qwen3.7-max"])(
14+
"omits image data for the CLI text-only model %s",
15+
(model) => {
16+
const request: OpenAIChatCompletionRequest = {
17+
model,
18+
messages: [{ role: "user", content: [image] }],
19+
};
20+
21+
const alpha = buildCommandCodeGenerateBody({ request, upstreamModel: model });
22+
const provider = buildProviderChatRequestBody(request, model);
23+
24+
expect(JSON.stringify(alpha.params.messages)).not.toContain("base64");
25+
expect(JSON.stringify(provider.messages)).not.toContain("base64");
26+
expect(alpha.params.messages[0]?.content[0]?.type).toBe("text");
27+
expect(JSON.stringify(alpha.params.messages)).toContain('index=\\"1\\"');
28+
expect(request.messages[0]?.content).toEqual([image]);
29+
},
30+
);
31+
32+
it.each([
33+
"deepseek/deepseek-v4.1-flash",
34+
"deepseek/deepseek-v4-flash-vision-exp",
35+
"custom/model",
36+
])("preserves images for CLI vision-capable or unknown model %s", (model) => {
37+
const request: OpenAIChatCompletionRequest = {
38+
model,
39+
messages: [{ role: "user", content: [image] }],
40+
};
41+
42+
const alpha = buildCommandCodeGenerateBody({ request, upstreamModel: model });
43+
const provider = buildProviderChatRequestBody(request, model);
44+
45+
expect(alpha.params.messages[0]?.content).toEqual([
46+
{ type: "image", image: "data:image/png;base64,AAAA", mimeType: "image/png" },
47+
]);
48+
expect(provider.messages).toEqual(request.messages);
49+
});
50+
51+
it("numbers only the most recent image message and preserves surrounding text", () => {
52+
const request: OpenAIChatCompletionRequest = {
53+
model: "deepseek/deepseek-v4-pro",
54+
messages: [
55+
{ role: "user", content: [image] },
56+
{ role: "assistant", content: "Earlier answer" },
57+
{ role: "user", content: [{ type: "text", text: "Compare" }, image, image] },
58+
{ role: "user", content: "Please continue" },
59+
],
60+
};
61+
62+
const body = buildCommandCodeGenerateBody({ request, upstreamModel: request.model });
63+
64+
expect(body.params.messages[0]?.content).toEqual([expect.objectContaining({ type: "text" })]);
65+
expect(JSON.stringify(body.params.messages[0])).not.toContain("attached_image");
66+
expect(body.params.messages[2]?.content[0]).toEqual({ type: "text", text: "Compare" });
67+
expect(JSON.stringify(body.params.messages[2])).toContain('index=\\"1\\"');
68+
expect(JSON.stringify(body.params.messages[2])).toContain('index=\\"2\\"');
69+
expect(JSON.stringify(body.params.messages)).not.toContain("base64");
70+
});
71+
72+
it("retains tool results without forwarding screenshot data to text-only models", () => {
73+
const request: OpenAIChatCompletionRequest = {
74+
model: "deepseek/deepseek-v4-pro",
75+
messages: [
76+
{ role: "user", content: "Read the screen" },
77+
{
78+
role: "assistant",
79+
content: null,
80+
tool_calls: [
81+
{ id: "shot", type: "function", function: { name: "read_image", arguments: "{}" } },
82+
],
83+
},
84+
{
85+
role: "tool",
86+
tool_call_id: "shot",
87+
content: [{ type: "text", text: "Captured" }, image],
88+
},
89+
],
90+
};
91+
92+
const alpha = buildCommandCodeGenerateBody({ request, upstreamModel: request.model });
93+
const provider = buildProviderChatRequestBody(request, request.model);
94+
95+
expect(JSON.stringify(alpha.params.messages)).not.toContain("base64");
96+
expect(JSON.stringify(provider.messages)).not.toContain("base64");
97+
expect(alpha.params.messages[2]?.content[0]).toMatchObject({
98+
type: "tool-result",
99+
toolCallId: "shot",
100+
output: { type: "text" },
101+
});
102+
});
103+
});

0 commit comments

Comments
 (0)