diff --git a/README.ko.md b/README.ko.md index 73793d4..496b656 100644 --- a/README.ko.md +++ b/README.ko.md @@ -208,6 +208,10 @@ curl -sS http://127.0.0.1:9992/v1/chat/completions \ ### 설정과 운영 +**Reasoning 출력 변경:** `INCLUDE_REASONING=true`이면 reasoning을 `content`에 붙이지 않고 스트리밍은 `delta.reasoning_content`, 비스트리밍은 `message.reasoning_content`로 따로 반환합니다. Reasoning을 표시하는 클라이언트는 이 필드를 읽어야 합니다. Provider 비스트리밍 재시도에서는 옵션이 켜졌을 때만 reasoning을 보이는 출력으로 세므로, 숨겨진 reasoning만 있는 빈 응답의 성공 처리와 노출된 reasoning의 불필요한 재시도를 방지합니다. + +Alpha와 Provider 모두 CLI 1.53.0의 텍스트 전용 모델 목록과 별칭에 대해 이미지 입력을 제거합니다. 가장 최근 이미지 메시지에는 번호가 붙은 텍스트 마커를 넣고 과거 이미지는 생략합니다. 비전 모델의 이미지는 유지하며, 알 수 없는 모델이나 custom 모델은 CLI와 같이 이미지 입력을 허용하지만 실제 비전 지원을 보장하지는 않습니다. Alpha는 base64 data URI를 `mimeType`이 있는 네이티브 이미지 파트로 보내고, 원격 URL은 내려받지 않고 텍스트 자리표시자로 유지합니다. + 저장된 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합니다. 브라우저에 key가 저장된 기존 사용자는 그대로 동작합니다. 새 브라우저에서는 저장·재시작 전에 **현재 Admin API Key**에 기존 key를 한 번 입력합니다. key 없는 runtime은 실제 loopback 연결이며 Host도 loopback인 경우에만 bootstrap할 수 있습니다. diff --git a/README.md b/README.md index 91c9b29..f0b96b4 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,10 @@ Open `http://127.0.0.1:9992/dashboard`. The mobile-first UI stores its Korean/En ### Configuration and operations +**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. + +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. + 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`. 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. diff --git a/README.zh.md b/README.zh.md index a83223c..17624f0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -208,6 +208,10 @@ curl -sS http://127.0.0.1:9992/v1/chat/completions \ ### 配置与运维 +**Reasoning 输出变更:** 启用 `INCLUDE_REASONING=true` 时,reasoning 不再追加到 `content`,而是通过流式的 `delta.reasoning_content` 或非流式的 `message.reasoning_content` 单独返回。需要展示 reasoning 的客户端必须读取该字段。Provider 非流式重试仅在启用时将 reasoning 计为可见输出,避免隐藏 reasoning 导致空响应被当作成功,也避免对已展示的 reasoning 进行不必要的重试。 + +Alpha 和 Provider 均根据 CLI 1.53.0 的纯文本模型列表移除图片输入,别名也适用。最新的图片消息会得到带编号的文本标记,历史图片则被省略。视觉模型保留图片;未知或自定义模型遵循 CLI 允许图片的默认行为,但这不保证实际支持视觉。Alpha 将 base64 data URI 作为带有 `mimeType` 的原生图片部分发送;远程 URL 保留为文本占位符,不会被下载。 + 从持久化的 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`。 浏览器已保存 key 的现有用户可继续使用。新浏览器在保存或重启前,需要在 **当前管理员 API Key** 中输入一次现有 key。无 key runtime 仅在真实 loopback 连接且 Host 也是 loopback 时允许 bootstrap。 diff --git a/docs/DEPLOYMENT.ko.md b/docs/DEPLOYMENT.ko.md index 921982e..1bc49b9 100644 --- a/docs/DEPLOYMENT.ko.md +++ b/docs/DEPLOYMENT.ko.md @@ -375,17 +375,17 @@ npm run smoke ### 서버와 client auth 옵션 -| 변수 | 기본값 | 설명 | -| -------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------- | -| `HOST` | `127.0.0.1` | bind 주소입니다. local-only면 `127.0.0.1`, Tailscale/VPN/reverse proxy 뒤에서 접근하려면 `0.0.0.0`을 사용합니다. | -| `PORT` | `9992` | HTTP listen port입니다. | -| `BRIDGE_API_KEY` | 미설정 | client-facing bearer key입니다. 강력 권장합니다. Admin endpoint는 이 값이 설정되어 있어야 접근 가능합니다. | -| `REQUEST_BODY_LIMIT_BYTES` | `1048576` | Fastify request body limit입니다. 매우 큰 prompt/tool schema를 받을 때만 늘리십시오. | -| `RATE_LIMIT_MAX` | `60` | client별 rate-limit window 안에서 허용할 최대 request 수입니다. | -| `RATE_LIMIT_WINDOW` | `1 minute` | `@fastify/rate-limit`이 이해하는 window 문자열입니다. | -| `LOG_LEVEL` | `info` | Pino/Fastify log level입니다. 보통 `debug`, `info`, `warn`, `error`, `silent`를 사용합니다. | -| `CORS_ORIGIN` | 미설정 | browser client 특정 origin에 CORS를 열 때 사용합니다. CLI/server client만 쓰면 비워두십시오. | -| `INCLUDE_REASONING` | `false` | `true`면 reasoning delta를 visible content에 붙입니다. 일반 OpenAI-compatible client에서는 `false` 유지가 안전합니다. | +| 변수 | 기본값 | 설명 | +| -------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `HOST` | `127.0.0.1` | bind 주소입니다. local-only면 `127.0.0.1`, Tailscale/VPN/reverse proxy 뒤에서 접근하려면 `0.0.0.0`을 사용합니다. | +| `PORT` | `9992` | HTTP listen port입니다. | +| `BRIDGE_API_KEY` | 미설정 | client-facing bearer key입니다. 강력 권장합니다. Admin endpoint는 이 값이 설정되어 있어야 접근 가능합니다. | +| `REQUEST_BODY_LIMIT_BYTES` | `1048576` | Fastify request body limit입니다. 매우 큰 prompt/tool schema를 받을 때만 늘리십시오. | +| `RATE_LIMIT_MAX` | `60` | client별 rate-limit window 안에서 허용할 최대 request 수입니다. | +| `RATE_LIMIT_WINDOW` | `1 minute` | `@fastify/rate-limit`이 이해하는 window 문자열입니다. | +| `LOG_LEVEL` | `info` | Pino/Fastify log level입니다. 보통 `debug`, `info`, `warn`, `error`, `silent`를 사용합니다. | +| `CORS_ORIGIN` | 미설정 | browser client 특정 origin에 CORS를 열 때 사용합니다. CLI/server client만 쓰면 비워두십시오. | +| `INCLUDE_REASONING` | `false` | `true`면 reasoning delta가 `content` 대신 `reasoning_content` 필드로 반환됩니다. 일반 OpenAI-compatible client에서는 `false` 유지가 안전합니다. | ### CommandCode upstream 옵션 @@ -451,6 +451,14 @@ Credential JSON 파일 예시: 이 정책은 reasoning-heavy model이 hidden token만 쓰다가 끝난 응답을 client가 정상 빈 답변으로 오인하지 않게 막습니다. +Provider 비스트리밍 응답에서는 `INCLUDE_REASONING=true`일 때만 `reasoning_content`를 사용자에게 보이는 출력으로 셉니다. 이 경우 reasoning만 있는 응답도 빈 응답 재시도 없이 반환합니다. 옵션을 끄면 숨겨진 reasoning은 빈 `length` 응답의 재시도 소진과 오류 반환을 막지 않습니다. 텍스트와 tool call은 옵션과 관계없이 유효한 출력입니다. + +### 모델의 이미지 입력 + +Alpha와 Provider 요청 변환 모두 `src/model-images.ts`에 기록된 CommandCode CLI 1.53.0 모델 목록을 기준으로 텍스트 전용 모델의 이미지 입력을 제거합니다. 별칭에도 같은 정책을 적용합니다. 이전 이미지 입력은 제거하고, 가장 최근 이미지가 포함된 user 또는 tool 메시지에는 번호가 붙은 텍스트 마커를 넣습니다. 이미지만 있던 과거 메시지에는 생략 마커를 남겨 빈 메시지가 되지 않도록 합니다. 입력 대화 객체는 변경하지 않습니다. + +비전 모델의 이미지는 유지합니다. 알 수 없는 모델이나 custom 모델은 CLI와 같이 이미지 입력을 허용하는 기본 동작을 따르므로, 텍스트 전용 목록에 없다고 실제 상류의 비전 지원이 보장되지는 않습니다. 향후 CLI 카탈로그 정렬 시 이 목록도 함께 갱신해야 합니다. Alpha에서는 base64 data URI를 `mimeType`이 있는 네이티브 이미지 파트로 변환하며, 원격 URL은 텍스트 자리표시자로 유지하고 bridge가 내려받지 않습니다. + ### Balance alert 옵션 Balance alert는 기본적으로 꺼져 있습니다. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 6061dbd..db1a72c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -376,17 +376,17 @@ npm run smoke ### Server and client-auth options -| Variable | Default | Description | -| -------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------- | -| `HOST` | `127.0.0.1` | Bind address. Use `127.0.0.1` for local-only, `0.0.0.0` for Tailscale/VPN/reverse-proxy exposure. | -| `PORT` | `9992` | HTTP listen port. | -| `BRIDGE_API_KEY` | unset | Client-facing bearer key. Strongly recommended; required for admin endpoints. | -| `REQUEST_BODY_LIMIT_BYTES` | `1048576` | Fastify request body limit. Increase only for unusually large prompts/tool schemas. | -| `RATE_LIMIT_MAX` | `60` | Max requests per rate-limit window per client. | -| `RATE_LIMIT_WINDOW` | `1 minute` | Rate-limit window string accepted by `@fastify/rate-limit`. | -| `LOG_LEVEL` | `info` | Pino/Fastify log level. Common values: `debug`, `info`, `warn`, `error`, `silent`. | -| `CORS_ORIGIN` | unset | Enables CORS for a specific browser origin. Leave unset for non-browser clients. | -| `INCLUDE_REASONING` | `false` | If `true`, reasoning deltas are appended to visible content. Keep `false` for normal OpenAI-compatible use. | +| Variable | Default | Description | +| -------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `HOST` | `127.0.0.1` | Bind address. Use `127.0.0.1` for local-only, `0.0.0.0` for Tailscale/VPN/reverse-proxy exposure. | +| `PORT` | `9992` | HTTP listen port. | +| `BRIDGE_API_KEY` | unset | Client-facing bearer key. Strongly recommended; required for admin endpoints. | +| `REQUEST_BODY_LIMIT_BYTES` | `1048576` | Fastify request body limit. Increase only for unusually large prompts/tool schemas. | +| `RATE_LIMIT_MAX` | `60` | Max requests per rate-limit window per client. | +| `RATE_LIMIT_WINDOW` | `1 minute` | Rate-limit window string accepted by `@fastify/rate-limit`. | +| `LOG_LEVEL` | `info` | Pino/Fastify log level. Common values: `debug`, `info`, `warn`, `error`, `silent`. | +| `CORS_ORIGIN` | unset | Enables CORS for a specific browser origin. Leave unset for non-browser clients. | +| `INCLUDE_REASONING` | `false` | If `true`, reasoning deltas return in the `reasoning_content` field instead of `content`. Keep `false` for normal OpenAI-compatible use. | ### CommandCode upstream options @@ -452,6 +452,14 @@ When upgrading a persisted 1.3.1 dashboard catalog, enabled state and custom mod This protects clients from treating hidden-token exhaustion as a valid empty answer. +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. + +### Model image input + +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. + +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. + ### Balance alert options Balance alerts are disabled by default. diff --git a/src/converter.ts b/src/converter.ts index 8f9a575..89e33c9 100644 --- a/src/converter.ts +++ b/src/converter.ts @@ -1,9 +1,11 @@ import { randomUUID } from "node:crypto"; import { cwd as processCwd } from "node:process"; +import { messagesForModel } from "./model-images.js"; import type { CommandCodeContentPart, CommandCodeGenerateBody, + CommandCodeImagePart, CommandCodeMessage, CommandCodeTool, CommandCodeToolResultPart, @@ -37,18 +39,76 @@ function imageUrlToText(value: unknown): string { return ""; } +function imageMediaType(dataUri: string): string | undefined { + const match = /^data:([^;,]+);base64,/.exec(dataUri); + return match?.[1]; +} + +function imagePartFromUrl(value: unknown): CommandCodeImagePart | undefined { + const url = imageUrlToText(value); + const mimeType = url ? imageMediaType(url) : undefined; + if (!mimeType) return undefined; + return { type: "image", image: url, mimeType }; +} + +function imagePlaceholder(value: unknown): string { + const url = imageUrlToText(value); + if (!url) return "[image_url]"; + if (url.startsWith("data:")) { + const mediaType = /^data:([^;,]+)/.exec(url)?.[1]; + return `[image: ${mediaType ?? "image"}]`; + } + return `[image_url: ${url.slice(0, 256)}]`; +} + export function flattenOpenAIContent(content: OpenAIMessageContent | undefined): string { if (content === undefined || content === null) return ""; if (typeof content === "string") return content; return content .map((part) => { if (isTextPart(part)) return part.text; - if (part.type === "image_url") return `[image_url: ${imageUrlToText(part.image_url)}]`; + if (part.type === "image_url") return imagePlaceholder(part.image_url); return ""; }) .join(""); } +function toolResultImages(content: OpenAIMessageContent | undefined): CommandCodeContentPart[] { + if (content === undefined || content === null || typeof content === "string") return []; + const images: CommandCodeContentPart[] = []; + for (const part of content) { + if (part.type !== "image_url") continue; + const imagePart = imagePartFromUrl(part.image_url); + if (imagePart) images.push(imagePart); + } + return images; +} + +function convertUserContent( + content: OpenAIMessageContent | undefined, + prefix: string, +): CommandCodeContentPart[] { + const parts: CommandCodeContentPart[] = []; + if (prefix.length > 0) parts.push({ type: "text", text: prefix }); + if (content === undefined || content === null) return parts; + if (typeof content === "string") { + if (content.length > 0) parts.push({ type: "text", text: content }); + return parts; + } + for (const part of content) { + if (isTextPart(part)) { + if (part.text.length > 0) parts.push({ type: "text", text: part.text }); + continue; + } + if (part.type === "image_url") { + const imagePart = imagePartFromUrl(part.image_url); + if (imagePart) parts.push(imagePart); + else parts.push({ type: "text", text: imagePlaceholder(part.image_url) }); + } + } + return parts; +} + function asTextContent(text: string): OpenAITextContentPart[] { return [{ type: "text", text }]; } @@ -101,6 +161,9 @@ function convertMessages(messages: OpenAIChatMessage[]): CommandCodeMessage[] { if (message.role === "assistant") { const content: CommandCodeContentPart[] = []; + const reasoning = + typeof message.reasoning_content === "string" ? message.reasoning_content : ""; + if (reasoning.length > 0) content.push({ type: "reasoning", text: reasoning }); const text = flattenOpenAIContent(message.content).trim(); if (text.length > 0) content.push(...asTextContent(text)); @@ -130,13 +193,18 @@ function convertMessages(messages: OpenAIChatMessage[]): CommandCodeMessage[] { } else { converted.push({ role: "tool", content: [part] }); } + const images = toolResultImages(message.content); + if (images.length > 0) { + converted.push({ role: "user", content: images }); + } continue; } const userPrefix = message.name ? `name: ${message.name}\n` : ""; + const userParts = convertUserContent(message.content, userPrefix); converted.push({ role: "user", - content: asTextContent(`${userPrefix}${flattenOpenAIContent(message.content)}`), + content: userParts.length > 0 ? userParts : asTextContent(""), }); } @@ -186,7 +254,7 @@ export function buildCommandCodeGenerateBody( const params: CommandCodeGenerateBody["params"] = { model: options.upstreamModel, - messages: convertMessages(options.request.messages), + messages: convertMessages(messagesForModel(options.request.messages, options.upstreamModel)), tools: convertOpenAITools(options.request.tools, options.request.tool_choice), system: buildSystemPrompt(options.request), max_tokens: options.request.max_tokens ?? 64_000, diff --git a/src/model-images.ts b/src/model-images.ts new file mode 100644 index 0000000..c6f2760 --- /dev/null +++ b/src/model-images.ts @@ -0,0 +1,78 @@ +import { modelAliasMap } from "./model-catalog.js"; +import type { OpenAIChatMessage, OpenAIContentPart } from "./types.js"; + +// command-code 1.53.0: isKnownTextOnlyModel and the registry's inputModalities. +// Unknown models remain image-capable, matching the CLI registry fallback. +const textOnlyModels: ReadonlySet = new Set([ + "deepseek/deepseek-v4-pro", + "deepseek/deepseek-v4-flash", + "deepseek/deepseek-v4-flash-fast", + "zai-org/GLM-5.3", + "zai-org/GLM-5.2", + "zai-org/GLM-5.2-Fast", + "zai-org/GLM-5.1", + "zai-org/GLM-5", + "MiniMaxAI/MiniMax-M2.7", + "minimax/minimax-m2.7-free", + "MiniMaxAI/MiniMax-M2.5", + "xiaomi/mimo-v2.5-pro", + "Qwen/Qwen3.6-Max-Preview", + "Qwen/Qwen3.7-Max", + "meituan/LongCat-2.0:free", + "stepfun/Step-3.5-Flash", + "tencent/hy4-preview", + "tencent/Hy3", + "tencent/hy3-paid", + "nvidia/nemotron-3-ultra-550b-a55b", + "poolside/laguna-s-2.1-free", + "inclusionai/ling-3.0-flash-free", + "inclusionai/ling-3.0-flash-sante:free", +]); +const aliases = modelAliasMap(); + +export function messagesForModel( + messages: OpenAIChatMessage[], + upstreamModel: string, +): OpenAIChatMessage[] { + if (!textOnlyModels.has(aliases[upstreamModel] ?? upstreamModel)) return messages; + + let latest = -1; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if ( + message && + (message.role === "user" || message.role === "tool") && + Array.isArray(message.content) && + message.content.some((part) => part.type === "image_url") + ) { + latest = index; + break; + } + } + if (latest < 0) return messages; + + return messages.map((message, index) => { + if ((message.role !== "user" && message.role !== "tool") || !Array.isArray(message.content)) { + return message; + } + + let imageIndex = 0; + const content: OpenAIContentPart[] = []; + for (const part of message.content) { + if (part.type !== "image_url") { + content.push(part); + } else if (index === latest) { + imageIndex += 1; + // Shipped CLI visionMarker: keep numbering and instructions for vision tools. + content.push({ + type: "text", + text: `\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`, + }); + } + } + if (content.length === 0 && message.content.length > 0) { + content.push({ type: "text", text: "[image omitted: the active model is text-only]" }); + } + return { ...message, content }; + }); +} diff --git a/src/openai.ts b/src/openai.ts index 39a5ec2..24bb5d0 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -359,6 +359,7 @@ export async function collectOpenAICompletion( options: CollectOpenAICompletionOptions, ): Promise { let content = ""; + let reasoningContent = ""; const toolCalls: OpenAIToolCall[] = []; let usage: CommandCodeUsage | undefined; let finishReason: string | undefined; @@ -373,7 +374,7 @@ export async function collectOpenAICompletion( content += event.text; } else if (options.includeReasoning && isReasoningDelta(event)) { sawCompletionSignal = true; - content += event.text; + reasoningContent += event.text; } else if (isToolCallEvent(event)) { sawCompletionSignal = true; for (const toolCall of openAIToolCallsFromCommandCodeEvent( @@ -398,7 +399,7 @@ export async function collectOpenAICompletion( if ( shouldFailEmptyVisibleResponse({ policy: options.emptyVisibleResponsePolicy, - visibleContentLength: content.length, + visibleContentLength: content.length + reasoningContent.length, toolCallCount: toolCalls.length, finishReason: finalReason, }) @@ -406,7 +407,7 @@ export async function collectOpenAICompletion( throw new CommandCodeEmptyVisibleResponseError({ model: options.model, finishReason: "length", - visibleContentLength: content.length, + visibleContentLength: content.length + reasoningContent.length, toolCallCount: toolCalls.length, }); } @@ -415,6 +416,7 @@ export async function collectOpenAICompletion( role: "assistant", content: toolCalls.length > 0 && content.length === 0 ? null : content, }; + if (reasoningContent.length > 0) message.reasoning_content = reasoningContent; if (toolCalls.length > 0) message.tool_calls = toolCalls; return { @@ -508,7 +510,7 @@ export async function* streamOpenAIChunks( options.id, options.created, options.model, - { content: event.text }, + { reasoning_content: event.text }, null, options.includeUsage, ), diff --git a/src/provider-chat.ts b/src/provider-chat.ts index c57815b..f423111 100644 --- a/src/provider-chat.ts +++ b/src/provider-chat.ts @@ -48,10 +48,15 @@ export async function handleProviderChat(options: { firstChoice && isRecord(firstChoice.message) ? firstChoice.message : undefined; const content = typeof message?.content === "string" ? message.content : ""; const toolCalls = Array.isArray(message?.tool_calls) ? message.tool_calls : []; + const reasoningContent = + config.includeReasoning && typeof message?.reasoning_content === "string" + ? message.reasoning_content + : ""; const emptyVisible = config.emptyVisibleResponsePolicy === "error_on_length" && firstChoice?.finish_reason === "length" && content.length === 0 && + reasoningContent.length === 0 && toolCalls.length === 0; if (!emptyVisible) break; const retrying = attempt < retries; @@ -133,10 +138,18 @@ export async function handleProviderChat(options: { const message = firstChoice && isRecord(firstChoice.message) ? firstChoice.message : undefined; const content = typeof message?.content === "string" ? message.content : ""; const toolCalls = Array.isArray(message?.tool_calls) ? message.tool_calls : []; + const reasoningContent = + config.includeReasoning && typeof message?.reasoning_content === "string" + ? message.reasoning_content + : ""; + if (!config.includeReasoning && message && typeof message.reasoning_content === "string") { + delete message.reasoning_content; + } if ( config.emptyVisibleResponsePolicy === "error_on_length" && firstChoice?.finish_reason === "length" && content.length === 0 && + reasoningContent.length === 0 && toolCalls.length === 0 ) { reply.code(502).send({ diff --git a/src/provider.ts b/src/provider.ts index c973bdb..5768c04 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,4 +1,5 @@ import { Transform, type TransformCallback } from "node:stream"; +import { messagesForModel } from "./model-images.js"; import { combineAbortSignals, @@ -27,7 +28,7 @@ export function buildProviderChatRequestBody( ): ProviderChatRequestBody { const body: ProviderChatRequestBody = { model: upstreamModel, - messages: request.messages, + messages: messagesForModel(request.messages, upstreamModel), }; if (request.stream !== undefined) body.stream = request.stream; if (request.max_tokens !== undefined) body.max_tokens = request.max_tokens; @@ -260,13 +261,7 @@ export class CommandCodeProviderSseTransform extends Transform { const firstChoice = Array.isArray(json.choices) ? json.choices[0] : undefined; const delta = isRecord(firstChoice) && isRecord(firstChoice.delta) ? firstChoice.delta : undefined; - if ( - this.includeReasoning && - delta && - typeof delta.reasoning_content === "string" && - delta.reasoning_content.length > 0 - ) { - delta.content = `${typeof delta.content === "string" ? delta.content : ""}${delta.reasoning_content}`; + if (delta && typeof delta.reasoning_content === "string" && !this.includeReasoning) { delete delta.reasoning_content; } return `data: ${JSON.stringify(json)}\n`; diff --git a/src/server.ts b/src/server.ts index 8155b61..c90523d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -80,6 +80,7 @@ const messageSchema = z.object({ name: z.string().optional(), tool_call_id: z.string().optional(), tool_calls: z.array(toolCallSchema).optional(), + reasoning_content: z.string().nullish(), }); const chatCompletionRequestSchema = z.object({ diff --git a/src/types.ts b/src/types.ts index b5198e6..c0294d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,7 @@ export interface OpenAIChatMessage { name?: string; tool_call_id?: string; tool_calls?: OpenAIToolCall[]; + reasoning_content?: string | null; } export interface OpenAIFunctionTool { @@ -95,8 +96,21 @@ export interface CommandCodeToolResultPart { output: { type: "text" | "error-text"; value: string }; } +export interface CommandCodeImagePart { + type: "image"; + image: string; + mimeType: string; +} + +export interface CommandCodeReasoningPart { + type: "reasoning"; + text: string; +} + export type CommandCodeContentPart = | OpenAITextContentPart + | CommandCodeImagePart + | CommandCodeReasoningPart | CommandCodeToolCallPart | CommandCodeToolResultPart; @@ -247,6 +261,7 @@ export interface OpenAIChatCompletion { message: { role: "assistant"; content: string | null; + reasoning_content?: string; tool_calls?: OpenAIToolCall[]; }; finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | null; diff --git a/tests/converter.test.ts b/tests/converter.test.ts index c6bfc33..2fb9a36 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -293,6 +293,200 @@ describe("OpenAI to CommandCode conversion", () => { ]); }); + it("converts base64 image_url parts into native image parts with mimeType", () => { + const body = buildCommandCodeGenerateBody({ + request: { + model: "deepseek/deepseek-v4-flash-vision-exp", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + ], + }, + ], + }, + upstreamModel: "deepseek/deepseek-v4-flash-vision-exp", + }); + + expect(body.params.messages).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: "data:image/png;base64,AAAA", mimeType: "image/png" }, + ], + }, + ]); + }); + + it("flattens image parts to short placeholders instead of inlining base64", () => { + expect( + flattenOpenAIContent([ + { type: "text", text: "look" }, + { type: "image_url", image_url: { url: "data:image/jpeg;base64,AAAA" } }, + ]), + ).toBe("look[image: image/jpeg]"); + expect( + flattenOpenAIContent([{ type: "image_url", image_url: "https://example.com/cat.png" }]), + ).toBe("[image_url: https://example.com/cat.png]"); + }); + + it("keeps remote image URLs as text placeholders instead of image parts", () => { + const body = buildCommandCodeGenerateBody({ + request: { + model: "deepseek/deepseek-v4-flash-vision-exp", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { type: "image_url", image_url: { url: "https://example.com/cat.png" } }, + ], + }, + ], + }, + upstreamModel: "deepseek/deepseek-v4-flash-vision-exp", + }); + + expect(body.params.messages).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { type: "text", text: "[image_url: https://example.com/cat.png]" }, + ], + }, + ]); + }); + + it("falls back to text for data URIs that are not base64 encoded", () => { + const body = buildCommandCodeGenerateBody({ + request: { + model: "deepseek/deepseek-v4-flash-vision-exp", + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: "data:image/svg+xml,%3Csvg%3E" }], + }, + ], + }, + upstreamModel: "deepseek/deepseek-v4-flash-vision-exp", + }); + + expect(body.params.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "[image: image/svg+xml]" }], + }, + ]); + }); + + it("forwards tool-result images as a following user image message", () => { + const body = buildCommandCodeGenerateBody({ + request: { + model: "deepseek/deepseek-v4-flash-vision-exp", + messages: [ + { role: "user", content: "Inspect the screenshot." }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_shot", + type: "function", + function: { name: "read_image", arguments: "{}" }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_shot", + content: [ + { type: "text", text: "Screenshot captured." }, + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + ], + }, + ], + }, + upstreamModel: "deepseek/deepseek-v4-flash-vision-exp", + }); + + expect(body.params.messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "tool", + "user", + ]); + expect(body.params.messages[2]).toEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_shot", + toolName: "read_image", + output: { type: "text", value: "Screenshot captured.[image: image/png]" }, + }, + ], + }); + expect(body.params.messages[3]).toEqual({ + role: "user", + content: [{ type: "image", image: "data:image/png;base64,AAAA", mimeType: "image/png" }], + }); + }); + + it("keeps remote tool-result image URLs in the tool text only", () => { + const body = buildCommandCodeGenerateBody({ + request: { + model: "deepseek/deepseek-v4-flash-vision-exp", + messages: [ + { role: "user", content: "Inspect the screenshot." }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_shot", + type: "function", + function: { name: "read_image", arguments: "{}" }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_shot", + content: [ + { type: "text", text: "Screenshot captured." }, + { type: "image_url", image_url: { url: "https://example.com/shot.png" } }, + ], + }, + ], + }, + upstreamModel: "deepseek/deepseek-v4-flash-vision-exp", + }); + + expect(body.params.messages.map((message) => message.role)).toEqual([ + "user", + "assistant", + "tool", + ]); + expect(body.params.messages[2]).toEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_shot", + toolName: "read_image", + output: { + type: "text", + value: "Screenshot captured.[image_url: https://example.com/shot.png]", + }, + }, + ], + }); + }); + it("injects JSON-only guidance for OpenAI response_format", () => { const body = buildCommandCodeGenerateBody({ request: { @@ -308,4 +502,73 @@ describe("OpenAI to CommandCode conversion", () => { }); expect(body.params.system).toMatch(/valid JSON object/i); }); + + it("forwards assistant reasoning_content as a leading reasoning part", () => { + const body = buildCommandCodeGenerateBody({ + request: { + model: "deepseek/deepseek-v4.1-flash", + messages: [ + { role: "user", content: "Remember the number 7391." }, + { + role: "assistant", + content: "Got it.", + reasoning_content: "The secret number is 7391. I must remember it.", + }, + { role: "user", content: "What is the secret number?" }, + ], + }, + upstreamModel: "deepseek/deepseek-v4.1-flash", + }); + + expect(body.params.messages[1]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "The secret number is 7391. I must remember it." }, + { type: "text", text: "Got it." }, + ], + }); + }); + + it("keeps reasoning alongside tool calls and skips empty reasoning_content", () => { + const body = buildCommandCodeGenerateBody({ + request: { + model: "deepseek/deepseek-v4.1-flash", + messages: [ + { role: "user", content: "Check the time." }, + { + role: "assistant", + content: null, + reasoning_content: "I should call get_time first.", + tool_calls: [ + { + id: "call_time", + type: "function", + function: { name: "get_time", arguments: "{}" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_time", content: "12:00" }, + { role: "assistant", content: "It is noon.", reasoning_content: null }, + { role: "assistant", content: "Now.", reasoning_content: "" }, + ], + }, + upstreamModel: "deepseek/deepseek-v4.1-flash", + }); + + expect(body.params.messages[1]).toEqual({ + role: "assistant", + content: [ + { type: "reasoning", text: "I should call get_time first." }, + { type: "tool-call", toolCallId: "call_time", toolName: "get_time", input: {} }, + ], + }); + expect(body.params.messages[3]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "It is noon." }], + }); + expect(body.params.messages[4]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "Now." }], + }); + }); }); diff --git a/tests/model-images.test.ts b/tests/model-images.test.ts new file mode 100644 index 0000000..7fda746 --- /dev/null +++ b/tests/model-images.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { buildCommandCodeGenerateBody } from "../src/converter.js"; +import { buildProviderChatRequestBody } from "../src/provider.js"; +import type { OpenAIChatCompletionRequest, OpenAIContentPart } from "../src/types.js"; + +const image: OpenAIContentPart = { + type: "image_url", + image_url: { url: "data:image/png;base64,AAAA" }, +}; + +describe("model image input limits", () => { + it.each(["deepseek/deepseek-v4-pro", "deepseek-v4-pro", "alibaba/qwen3.7-max"])( + "omits image data for the CLI text-only model %s", + (model) => { + const request: OpenAIChatCompletionRequest = { + model, + messages: [{ role: "user", content: [image] }], + }; + + const alpha = buildCommandCodeGenerateBody({ request, upstreamModel: model }); + const provider = buildProviderChatRequestBody(request, model); + + expect(JSON.stringify(alpha.params.messages)).not.toContain("base64"); + expect(JSON.stringify(provider.messages)).not.toContain("base64"); + expect(alpha.params.messages[0]?.content[0]?.type).toBe("text"); + expect(JSON.stringify(alpha.params.messages)).toContain('index=\\"1\\"'); + expect(request.messages[0]?.content).toEqual([image]); + }, + ); + + it.each([ + "deepseek/deepseek-v4.1-flash", + "deepseek/deepseek-v4-flash-vision-exp", + "custom/model", + ])("preserves images for CLI vision-capable or unknown model %s", (model) => { + const request: OpenAIChatCompletionRequest = { + model, + messages: [{ role: "user", content: [image] }], + }; + + const alpha = buildCommandCodeGenerateBody({ request, upstreamModel: model }); + const provider = buildProviderChatRequestBody(request, model); + + expect(alpha.params.messages[0]?.content).toEqual([ + { type: "image", image: "data:image/png;base64,AAAA", mimeType: "image/png" }, + ]); + expect(provider.messages).toEqual(request.messages); + }); + + it("numbers only the most recent image message and preserves surrounding text", () => { + const request: OpenAIChatCompletionRequest = { + model: "deepseek/deepseek-v4-pro", + messages: [ + { role: "user", content: [image] }, + { role: "assistant", content: "Earlier answer" }, + { role: "user", content: [{ type: "text", text: "Compare" }, image, image] }, + { role: "user", content: "Please continue" }, + ], + }; + + const body = buildCommandCodeGenerateBody({ request, upstreamModel: request.model }); + + expect(body.params.messages[0]?.content).toEqual([expect.objectContaining({ type: "text" })]); + expect(JSON.stringify(body.params.messages[0])).not.toContain("attached_image"); + expect(body.params.messages[2]?.content[0]).toEqual({ type: "text", text: "Compare" }); + expect(JSON.stringify(body.params.messages[2])).toContain('index=\\"1\\"'); + expect(JSON.stringify(body.params.messages[2])).toContain('index=\\"2\\"'); + expect(JSON.stringify(body.params.messages)).not.toContain("base64"); + }); + + it("retains tool results without forwarding screenshot data to text-only models", () => { + const request: OpenAIChatCompletionRequest = { + model: "deepseek/deepseek-v4-pro", + messages: [ + { role: "user", content: "Read the screen" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "shot", type: "function", function: { name: "read_image", arguments: "{}" } }, + ], + }, + { + role: "tool", + tool_call_id: "shot", + content: [{ type: "text", text: "Captured" }, image], + }, + ], + }; + + const alpha = buildCommandCodeGenerateBody({ request, upstreamModel: request.model }); + const provider = buildProviderChatRequestBody(request, request.model); + + expect(JSON.stringify(alpha.params.messages)).not.toContain("base64"); + expect(JSON.stringify(provider.messages)).not.toContain("base64"); + expect(alpha.params.messages[2]?.content[0]).toMatchObject({ + type: "tool-result", + toolCallId: "shot", + output: { type: "text" }, + }); + }); +}); diff --git a/tests/provider-chat.test.ts b/tests/provider-chat.test.ts new file mode 100644 index 0000000..c54e0a2 --- /dev/null +++ b/tests/provider-chat.test.ts @@ -0,0 +1,240 @@ +import Fastify from "fastify"; +import { describe, expect, it, onTestFinished } from "vitest"; + +import { CommandCodeProviderClient } from "../src/provider.js"; +import { handleProviderChat } from "../src/provider-chat.js"; +import type { BridgeConfig, OpenAIChatCompletionRequest } from "../src/types.js"; + +function completion(message: Record, finishReason: string | null = "length") { + return { + id: "chatcmpl_fixture", + object: "chat.completion", + model: "upstream-model", + choices: [ + { index: 0, message: { role: "assistant", ...message }, finish_reason: finishReason }, + ], + }; +} + +async function fixture( + responses: readonly [ReturnType, ...ReturnType[]], + overrides: Partial, +) { + const upstream = Fastify(); + const app = Fastify(); + onTestFinished(async () => { + await app.close(); + await upstream.close(); + }); + let calls = 0; + upstream.get("/alpha/whoami", async () => ({ org: { id: "fixture-org" } })); + upstream.get("/alpha/billing/credits", async () => ({ credits: { purchasedCredits: 10 } })); + upstream.get("/alpha/billing/subscriptions", async () => ({ data: {} })); + upstream.get("/alpha/usage/summary", async () => ({ totalCost: 0, totalCount: 0 })); + upstream.post("/provider/v1/chat/completions", async () => { + const response = responses[calls] ?? responses.at(-1); + calls += 1; + return response; + }); + const apiBase = await upstream.listen({ host: "127.0.0.1", port: 0 }); + const config: BridgeConfig = { + host: "127.0.0.1", + port: 0, + apiBase, + cliVersion: "fixture", + upstreamMode: "provider", + zdr: false, + defaultModel: "public-model", + allowedModels: ["public-model"], + allowUnknownModels: false, + bridgeApiKey: undefined, + bridgeApiKeySource: "none", + commandCodeApiKey: "fixture-key", + commandCodeCredentials: [{ id: "fixture", apiKey: "fixture-key", weight: 1 }], + commandCodeRoutingPolicy: "round_robin", + commandCodeBillingRefreshMs: 60_000, + commandCodeBillingTimeoutMs: 1_000, + commandCodeCredentialCooldownMs: 0, + commandCodeRetryMaxAttempts: 1, + commandCodeRetryBackoffMs: 0, + requestBodyLimitBytes: 1_048_576, + rateLimitMax: 60, + rateLimitWindow: "1 minute", + logLevel: "silent", + corsOrigin: undefined, + includeReasoning: false, + emptyVisibleResponsePolicy: "error_on_length", + emptyVisibleRetryMaxAttempts: 2, + emptyVisibleRetryBackoffMs: 0, + balanceAlerts: { + enabled: false, + minCurrentBalance: 0, + minExpiringBalance: 0, + maxRequiredDailyBurn: 0, + intervalMs: 60_000, + repeatMs: 60_000, + webhookUrl: undefined, + webhookBearer: undefined, + }, + timeoutMs: 1_000, + ...overrides, + }; + const providerClient = new CommandCodeProviderClient(config); + app.post<{ Body: OpenAIChatCompletionRequest }>( + "/v1/chat/completions", + async (request, reply) => { + await handleProviderChat({ + reply, + httpRequest: request, + request: request.body, + providerClient, + resolvedModel: { + requestedModel: "public-model", + publicModel: "public-model", + upstreamModel: "upstream-model", + }, + signal: AbortSignal.timeout(2_000), + config, + }); + return reply; + }, + ); + const url = await app.listen({ host: "127.0.0.1", port: 0 }); + return { + calls: () => calls, + chat: () => + fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "public-model", + messages: [{ role: "user", content: "hi" }], + stream: false, + max_tokens: 8, + }), + signal: AbortSignal.timeout(3_000), + }), + }; +} + +const reasoningOnly = { content: "", reasoning_content: "THINK" }; +const toolCalls = [{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }]; + +describe("Provider non-streaming visibility", () => { + it.each([0, 2])("returns 502 when hidden reasoning exhausts %i retries", async (retries) => { + const upstream = await fixture([completion(reasoningOnly)], { + emptyVisibleRetryMaxAttempts: retries, + }); + + const response = await upstream.chat(); + + expect(response.status).toBe(502); + expect(await response.json()).toMatchObject({ + error: { code: "commandcode_empty_visible_response", upstream_status: 502 }, + }); + expect(upstream.calls()).toBe(retries + 1); + }); + + it("returns the first reasoning-only response without retry when reasoning is exposed", async () => { + const upstream = await fixture( + [completion(reasoningOnly), completion({ content: "RETRIED" })], + { includeReasoning: true }, + ); + + const response = await upstream.chat(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ...completion(reasoningOnly), model: "public-model" }); + expect(upstream.calls()).toBe(1); + }); + + it.each([ + { includeReasoning: false, content: "ANSWER", tool_calls: [] }, + { includeReasoning: true, content: "ANSWER", tool_calls: [] }, + { includeReasoning: false, content: "", tool_calls: toolCalls }, + { includeReasoning: true, content: "", tool_calls: toolCalls }, + ])( + "preserves visible output without retries for %j", + async ({ includeReasoning, ...message }) => { + const upstream = await fixture([completion({ ...message, reasoning_content: "THINK" })], { + includeReasoning, + }); + + const response = await upstream.chat(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ...completion({ ...message, ...(includeReasoning ? { reasoning_content: "THINK" } : {}) }), + model: "public-model", + }); + expect(upstream.calls()).toBe(1); + }, + ); + + it.each([false, true])( + "retries empty length responses with includeReasoning=%s", + async (includeReasoning) => { + const upstream = await fixture( + [ + completion({ content: null, reasoning_content: "", tool_calls: [] }), + completion({ content: "RECOVERED" }, "stop"), + ], + { includeReasoning }, + ); + + const response = await upstream.chat(); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + choices: [{ message: { content: "RECOVERED" } }], + }); + expect(upstream.calls()).toBe(2); + }, + ); + + it.each([false, true])( + "returns 502 after empty length retries with includeReasoning=%s", + async (includeReasoning) => { + const upstream = await fixture([completion({})], { includeReasoning }); + + const response = await upstream.chat(); + + expect(response.status).toBe(502); + expect(await response.json()).toMatchObject({ + error: { code: "commandcode_empty_visible_response" }, + }); + expect(upstream.calls()).toBe(3); + }, + ); + + it.each(["stop", "tool_calls", null])( + "does not retry empty responses when finish_reason=%s", + async (finishReason) => { + const upstream = await fixture([completion(reasoningOnly, finishReason)], {}); + + const response = await upstream.chat(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ...completion({ content: "" }, finishReason), + model: "public-model", + }); + expect(upstream.calls()).toBe(1); + }, + ); + + it("allows stripped reasoning without retries when the empty policy is allow", async () => { + const upstream = await fixture([completion(reasoningOnly)], { + emptyVisibleResponsePolicy: "allow", + }); + + const response = await upstream.chat(); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ...completion({ content: "" }), + model: "public-model", + }); + expect(upstream.calls()).toBe(1); + }); +}); diff --git a/tests/provider.test.ts b/tests/provider.test.ts index 911da2d..678590b 100644 --- a/tests/provider.test.ts +++ b/tests/provider.test.ts @@ -216,10 +216,28 @@ describe("provider chat request shaping", () => { expect(body.stream).toBe(true); expect(body.max_tokens).toBe(10); }); + + it("passes through assistant reasoning_content messages unchanged", () => { + const messages = [ + { role: "user" as const, content: "hi" }, + { + role: "assistant" as const, + content: "Got it.", + reasoning_content: "The secret number is 7391.", + }, + ]; + const body = buildProviderChatRequestBody( + { model: "default", messages }, + "deepseek/deepseek-v4-pro", + ); + + expect(body.messages).toEqual(messages); + expect(body.messages[1]?.reasoning_content).toBe("The secret number is 7391."); + }); }); describe("provider SSE transform", () => { - it("rewrites the public model id and folds reasoning deltas into content", async () => { + it("rewrites the public model id and keeps reasoning deltas as reasoning_content", async () => { const out = await transformSse( [ 'data: {"id":"x","model":"deepseek/deepseek-v4-pro","choices":[{"delta":{"content":"h"}}]}', @@ -240,19 +258,19 @@ describe("provider SSE transform", () => { expect(out).toContain('"model":"deepseek-v4-pro"'); expect(out).toContain('"content":"h"'); - expect(out).toContain('"content":"think "'); + expect(out).toContain('"reasoning_content":"think "'); + expect(out).not.toContain('"content":"think "'); expect(out).toContain('"content":"i"'); expect(out).toContain("data: [DONE]"); - expect(out).not.toContain("reasoning_content"); expect(out).not.toContain('"model":"deepseek/deepseek-v4-pro"'); }); - it("leaves reasoning deltas untouched when includeReasoning is disabled", async () => { + it("strips reasoning deltas when includeReasoning is disabled", async () => { const out = await transformSse( 'data: {"model":"m","choices":[{"delta":{"reasoning_content":"think"}}]}\n\ndata: [DONE]\n\n', { publicModel: "p", includeReasoning: false }, ); - expect(out).toContain('"reasoning_content":"think"'); + expect(out).not.toContain('"reasoning_content":"think"'); expect(out).toContain('"model":"p"'); });