From 6aaa5c4c5dc0f9312f108a4d37558caf7b4a29a3 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 4 May 2026 17:35:03 -0700 Subject: [PATCH 1/3] fix deepseek image attachments --- .../deepseek-image-compat.integration.test.ts | 114 ++++++++++++++++++ web/src/llm-api/deepseek.ts | 85 ++++++++++++- web/src/llm-api/types.ts | 23 +++- 3 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts diff --git a/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts new file mode 100644 index 000000000..f7e14956b --- /dev/null +++ b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, mock } from 'bun:test' + +import { + createDeepSeekRequest, + normalizeDeepSeekRequestBody, +} from '../deepseek' + +import type { ChatCompletionRequestBody } from '../types' + +describe('normalizeDeepSeekRequestBody', () => { + it('converts multimodal user content into DeepSeek text content without mutating input', () => { + const body: ChatCompletionRequestBody = { + model: 'deepseek/deepseek-v4-pro', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,AAECAw==' }, + }, + ], + }, + ], + } + + const normalized = normalizeDeepSeekRequestBody(body) + + expect(normalized.messages[0].content).toBe( + 'What is in this image?\n\n[1 image was omitted because the DeepSeek API does not support image input.]', + ) + expect(body.messages[0].content).toEqual([ + { type: 'text', text: 'What is in this image?' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,AAECAw==' }, + }, + ]) + }) + + it('keeps text-only messages unchanged', () => { + const body: ChatCompletionRequestBody = { + model: 'deepseek/deepseek-v4-pro', + messages: [{ role: 'user', content: 'Hello' }], + } + + expect(normalizeDeepSeekRequestBody(body)).toEqual({ + ...body, + model: 'deepseek-v4-pro', + }) + }) +}) + +describe('createDeepSeekRequest', () => { + it('sends DeepSeek-compatible text content when the request contains an image attachment', async () => { + let sentBody: Record | null = null + const mockFetch = mock( + async (_url: string | URL | Request, init?: RequestInit) => { + sentBody = JSON.parse(init?.body as string) + return new Response(JSON.stringify({ ok: true }), { status: 200 }) + }, + ) as unknown as typeof globalThis.fetch + + const body: ChatCompletionRequestBody = { + model: 'deepseek/deepseek-v4-pro', + messages: [ + { role: 'system', content: 'You are a coding assistant.' }, + { + role: 'user', + content: [ + { type: 'text', text: 'Please inspect this screenshot.' }, + { + type: 'image_url', + image_url: { url: 'data:image/jpeg;base64,/9j/4AAQSkZJRg==' }, + }, + ], + }, + ], + stream: true, + reasoning: { enabled: true, effort: 'medium' }, + provider: { order: ['DeepSeek'] }, + transforms: ['middle-out'], + codebuff_metadata: { run_id: 'run-1', cost_mode: 'free' }, + usage: { include: true }, + } + + await createDeepSeekRequest({ + body, + originalModel: body.model, + fetch: mockFetch, + }) + + expect(sentBody).toMatchObject({ + model: 'deepseek-v4-pro', + stream: true, + stream_options: { include_usage: true }, + thinking: { type: 'enabled', reasoning_effort: 'high' }, + }) + expect(sentBody).not.toHaveProperty('reasoning') + expect(sentBody).not.toHaveProperty('provider') + expect(sentBody).not.toHaveProperty('transforms') + expect(sentBody).not.toHaveProperty('codebuff_metadata') + expect(sentBody).not.toHaveProperty('usage') + + const capturedBody = sentBody as unknown as Record + const messages = capturedBody.messages as Array<{ content: string }> + expect(messages[1].content).toBe( + 'Please inspect this screenshot.\n\n[1 image was omitted because the DeepSeek API does not support image input.]', + ) + expect(JSON.stringify(sentBody)).not.toContain('image_url') + expect(JSON.stringify(body)).toContain('image_url') + }) +}) diff --git a/web/src/llm-api/deepseek.ts b/web/src/llm-api/deepseek.ts index 12ac66265..8e7c2d905 100644 --- a/web/src/llm-api/deepseek.ts +++ b/web/src/llm-api/deepseek.ts @@ -91,16 +91,91 @@ function toDeepSeekReasoningEffort(effort: unknown): 'high' | 'max' { return effort === 'max' || effort === 'xhigh' ? 'max' : 'high' } -function createDeepSeekRequest(params: { +function unsupportedAttachmentNotice(kind: string, count: number): string { + const noun = count === 1 ? kind : `${kind}s` + const verb = count === 1 ? 'was' : 'were' + return `[${count} ${noun} ${verb} omitted because the DeepSeek API does not support ${kind} input.]` +} + +function contentPartsToDeepSeekText( + content: NonNullable< + ChatCompletionRequestBody['messages'][number]['content'] + >, +): string { + if (!Array.isArray(content)) { + return content + } + + const textParts: string[] = [] + let imageCount = 0 + let fileCount = 0 + let unsupportedCount = 0 + + for (const part of content) { + switch (part.type) { + case 'text': { + if (typeof part.text === 'string' && part.text.length > 0) { + textParts.push(part.text) + } + break + } + case 'image_url': { + imageCount += 1 + break + } + case 'file': { + fileCount += 1 + break + } + default: { + unsupportedCount += 1 + break + } + } + } + + if (imageCount > 0) { + textParts.push(unsupportedAttachmentNotice('image', imageCount)) + } + if (fileCount > 0) { + textParts.push(unsupportedAttachmentNotice('file', fileCount)) + } + if (unsupportedCount > 0) { + textParts.push( + unsupportedAttachmentNotice('unsupported content part', unsupportedCount), + ) + } + + return textParts.join('\n\n') +} + +export function normalizeDeepSeekRequestBody( + body: ChatCompletionRequestBody, + originalModel: string = body.model, +): ChatCompletionRequestBody { + return { + ...body, + model: getDeepSeekModelId(originalModel), + messages: body.messages.map((message) => ({ + ...message, + content: + message.content === undefined || message.content === null + ? message.content + : contentPartsToDeepSeekText(message.content), + })), + } +} + +export function createDeepSeekRequest(params: { body: ChatCompletionRequestBody originalModel: string fetch: typeof globalThis.fetch }) { const { body, originalModel, fetch } = params - const deepseekBody: Record = { - ...body, - model: getDeepSeekModelId(originalModel), - } + const deepseekBody = normalizeDeepSeekRequestBody( + body, + originalModel, + ) as unknown as Record // DeepSeek uses `thinking` instead of OpenRouter's `reasoning`. if (deepseekBody.reasoning && typeof deepseekBody.reasoning === 'object') { diff --git a/web/src/llm-api/types.ts b/web/src/llm-api/types.ts index 66a3425a5..3c8500bdb 100644 --- a/web/src/llm-api/types.ts +++ b/web/src/llm-api/types.ts @@ -15,7 +15,7 @@ export interface CodebuffMetadata { export interface ChatMessage { role: 'system' | 'user' | 'assistant' | 'tool' - content?: string | null + content?: string | ChatCompletionContentPart[] | null name?: string tool_calls?: Array<{ id: string @@ -28,6 +28,27 @@ export interface ChatMessage { tool_call_id?: string } +export type ChatCompletionContentPart = + | { + type: 'text' + text?: string + } + | { + type: 'image_url' + image_url?: string | { url?: string } + } + | { + type: 'file' + file?: { + filename?: string + file_data?: string + } + } + | { + type: string + [key: string]: unknown + } + export interface ChatCompletionTool { id?: string type: string From 34f8a0fd5468d5a056b893956f22dee975c8f37d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 4 May 2026 21:05:41 -0700 Subject: [PATCH 2/3] fix deepseek image test env import --- .../deepseek-image-compat.integration.test.ts | 27 +--- web/src/llm-api/deepseek-request-body.ts | 135 ++++++++++++++++ web/src/llm-api/deepseek.ts | 146 ++---------------- 3 files changed, 156 insertions(+), 152 deletions(-) create mode 100644 web/src/llm-api/deepseek-request-body.ts diff --git a/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts index f7e14956b..51f435a80 100644 --- a/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts +++ b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it, mock } from 'bun:test' +import { describe, expect, it } from 'bun:test' import { - createDeepSeekRequest, + buildDeepSeekRequestBody, normalizeDeepSeekRequestBody, -} from '../deepseek' +} from '../deepseek-request-body' import type { ChatCompletionRequestBody } from '../types' @@ -52,16 +52,8 @@ describe('normalizeDeepSeekRequestBody', () => { }) }) -describe('createDeepSeekRequest', () => { - it('sends DeepSeek-compatible text content when the request contains an image attachment', async () => { - let sentBody: Record | null = null - const mockFetch = mock( - async (_url: string | URL | Request, init?: RequestInit) => { - sentBody = JSON.parse(init?.body as string) - return new Response(JSON.stringify({ ok: true }), { status: 200 }) - }, - ) as unknown as typeof globalThis.fetch - +describe('buildDeepSeekRequestBody', () => { + it('builds DeepSeek-compatible JSON when the request contains an image attachment', () => { const body: ChatCompletionRequestBody = { model: 'deepseek/deepseek-v4-pro', messages: [ @@ -85,11 +77,7 @@ describe('createDeepSeekRequest', () => { usage: { include: true }, } - await createDeepSeekRequest({ - body, - originalModel: body.model, - fetch: mockFetch, - }) + const sentBody = buildDeepSeekRequestBody(body, body.model) expect(sentBody).toMatchObject({ model: 'deepseek-v4-pro', @@ -103,8 +91,7 @@ describe('createDeepSeekRequest', () => { expect(sentBody).not.toHaveProperty('codebuff_metadata') expect(sentBody).not.toHaveProperty('usage') - const capturedBody = sentBody as unknown as Record - const messages = capturedBody.messages as Array<{ content: string }> + const messages = sentBody.messages as Array<{ content: string }> expect(messages[1].content).toBe( 'Please inspect this screenshot.\n\n[1 image was omitted because the DeepSeek API does not support image input.]', ) diff --git a/web/src/llm-api/deepseek-request-body.ts b/web/src/llm-api/deepseek-request-body.ts new file mode 100644 index 000000000..1057d846b --- /dev/null +++ b/web/src/llm-api/deepseek-request-body.ts @@ -0,0 +1,135 @@ +import { deepseekModels } from '@codebuff/common/constants/model-config' + +import type { ChatCompletionRequestBody } from './types' + +export const DEEPSEEK_MODEL_IDS: Record = { + [deepseekModels.deepseekV4ProDirect]: deepseekModels.deepseekV4ProDirect, + [deepseekModels.deepseekV4Pro]: deepseekModels.deepseekV4ProDirect, +} + +export function getDeepSeekModelId(openrouterModel: string): string { + return DEEPSEEK_MODEL_IDS[openrouterModel] ?? openrouterModel +} + +function toDeepSeekReasoningEffort(effort: unknown): 'high' | 'max' { + return effort === 'max' || effort === 'xhigh' ? 'max' : 'high' +} + +function unsupportedAttachmentNotice(kind: string, count: number): string { + const noun = count === 1 ? kind : `${kind}s` + const verb = count === 1 ? 'was' : 'were' + return `[${count} ${noun} ${verb} omitted because the DeepSeek API does not support ${kind} input.]` +} + +function contentPartsToDeepSeekText( + content: NonNullable< + ChatCompletionRequestBody['messages'][number]['content'] + >, +): string { + if (!Array.isArray(content)) { + return content + } + + const textParts: string[] = [] + let imageCount = 0 + let fileCount = 0 + let unsupportedCount = 0 + + for (const part of content) { + switch (part.type) { + case 'text': { + if (typeof part.text === 'string' && part.text.length > 0) { + textParts.push(part.text) + } + break + } + case 'image_url': { + imageCount += 1 + break + } + case 'file': { + fileCount += 1 + break + } + default: { + unsupportedCount += 1 + break + } + } + } + + if (imageCount > 0) { + textParts.push(unsupportedAttachmentNotice('image', imageCount)) + } + if (fileCount > 0) { + textParts.push(unsupportedAttachmentNotice('file', fileCount)) + } + if (unsupportedCount > 0) { + textParts.push( + unsupportedAttachmentNotice('unsupported content part', unsupportedCount), + ) + } + + return textParts.join('\n\n') +} + +export function normalizeDeepSeekRequestBody( + body: ChatCompletionRequestBody, + originalModel: string = body.model, +): ChatCompletionRequestBody { + return { + ...body, + model: getDeepSeekModelId(originalModel), + messages: body.messages.map((message) => ({ + ...message, + content: + message.content === undefined || message.content === null + ? message.content + : contentPartsToDeepSeekText(message.content), + })), + } +} + +export function buildDeepSeekRequestBody( + body: ChatCompletionRequestBody, + originalModel: string = body.model, +): Record { + const deepseekBody = normalizeDeepSeekRequestBody( + body, + originalModel, + ) as unknown as Record + + // DeepSeek uses `thinking` instead of OpenRouter's `reasoning`. + if (deepseekBody.reasoning && typeof deepseekBody.reasoning === 'object') { + const reasoning = deepseekBody.reasoning as { + enabled?: boolean + effort?: 'high' | 'medium' | 'low' + } + deepseekBody.thinking = { + type: reasoning.enabled === false ? 'disabled' : 'enabled', + reasoning_effort: toDeepSeekReasoningEffort(reasoning.effort), + } + } else if (deepseekBody.reasoning_effort) { + deepseekBody.thinking = { + type: 'enabled', + reasoning_effort: toDeepSeekReasoningEffort( + deepseekBody.reasoning_effort, + ), + } + } + delete deepseekBody.reasoning + delete deepseekBody.reasoning_effort + + // Strip OpenRouter-specific / internal fields. + delete deepseekBody.provider + delete deepseekBody.transforms + delete deepseekBody.codebuff_metadata + delete deepseekBody.usage + + // For streaming, request usage in the final chunk. + if (deepseekBody.stream) { + deepseekBody.stream_options = { include_usage: true } + } + + return deepseekBody +} diff --git a/web/src/llm-api/deepseek.ts b/web/src/llm-api/deepseek.ts index 8e7c2d905..037851410 100644 --- a/web/src/llm-api/deepseek.ts +++ b/web/src/llm-api/deepseek.ts @@ -1,6 +1,5 @@ import { Agent } from 'undici' -import { deepseekModels } from '@codebuff/common/constants/model-config' import { PROFIT_MARGIN } from '@codebuff/common/constants/limits' import { getErrorObject } from '@codebuff/common/util/error' import { env } from '@codebuff/internal/env' @@ -10,6 +9,10 @@ import { extractRequestMetadata, insertMessageToBigQuery, } from './helpers' +import { + buildDeepSeekRequestBody, + DEEPSEEK_MODEL_IDS, +} from './deepseek-request-body' import type { UsageData } from './helpers' import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery' @@ -40,21 +43,18 @@ const DEEPSEEK_V4_PRO_PRICING: DeepSeekPricing = { outputCostPerToken: 0.87 / 1_000_000, } -/** Single source of truth for DeepSeek model metadata and pricing. - * Kept as one map so adding a model can't drift between routing and billing. */ const DEEPSEEK_MODELS: Record< string, { deepseekId: string; pricing: DeepSeekPricing } -> = { - [deepseekModels.deepseekV4ProDirect]: { - deepseekId: deepseekModels.deepseekV4ProDirect, - pricing: DEEPSEEK_V4_PRO_PRICING, - }, - [deepseekModels.deepseekV4Pro]: { - deepseekId: deepseekModels.deepseekV4ProDirect, - pricing: DEEPSEEK_V4_PRO_PRICING, - }, -} +> = Object.fromEntries( + Object.entries(DEEPSEEK_MODEL_IDS).map(([model, deepseekId]) => [ + model, + { + deepseekId, + pricing: DEEPSEEK_V4_PRO_PRICING, + }, + ]), +) const DEEPSEEK_ROUTED_MODELS = new Set(Object.keys(DEEPSEEK_MODELS)) @@ -62,10 +62,6 @@ export function isDeepSeekModel(model: string): boolean { return DEEPSEEK_ROUTED_MODELS.has(model) } -function getDeepSeekModelId(openrouterModel: string): string { - return DEEPSEEK_MODELS[openrouterModel]?.deepseekId ?? openrouterModel -} - function getDeepSeekPricing(model: string): DeepSeekPricing { const entry = DEEPSEEK_MODELS[model] if (!entry) { @@ -87,127 +83,13 @@ type LineResult = { patchedLine: string } -function toDeepSeekReasoningEffort(effort: unknown): 'high' | 'max' { - return effort === 'max' || effort === 'xhigh' ? 'max' : 'high' -} - -function unsupportedAttachmentNotice(kind: string, count: number): string { - const noun = count === 1 ? kind : `${kind}s` - const verb = count === 1 ? 'was' : 'were' - return `[${count} ${noun} ${verb} omitted because the DeepSeek API does not support ${kind} input.]` -} - -function contentPartsToDeepSeekText( - content: NonNullable< - ChatCompletionRequestBody['messages'][number]['content'] - >, -): string { - if (!Array.isArray(content)) { - return content - } - - const textParts: string[] = [] - let imageCount = 0 - let fileCount = 0 - let unsupportedCount = 0 - - for (const part of content) { - switch (part.type) { - case 'text': { - if (typeof part.text === 'string' && part.text.length > 0) { - textParts.push(part.text) - } - break - } - case 'image_url': { - imageCount += 1 - break - } - case 'file': { - fileCount += 1 - break - } - default: { - unsupportedCount += 1 - break - } - } - } - - if (imageCount > 0) { - textParts.push(unsupportedAttachmentNotice('image', imageCount)) - } - if (fileCount > 0) { - textParts.push(unsupportedAttachmentNotice('file', fileCount)) - } - if (unsupportedCount > 0) { - textParts.push( - unsupportedAttachmentNotice('unsupported content part', unsupportedCount), - ) - } - - return textParts.join('\n\n') -} - -export function normalizeDeepSeekRequestBody( - body: ChatCompletionRequestBody, - originalModel: string = body.model, -): ChatCompletionRequestBody { - return { - ...body, - model: getDeepSeekModelId(originalModel), - messages: body.messages.map((message) => ({ - ...message, - content: - message.content === undefined || message.content === null - ? message.content - : contentPartsToDeepSeekText(message.content), - })), - } -} - export function createDeepSeekRequest(params: { body: ChatCompletionRequestBody originalModel: string fetch: typeof globalThis.fetch }) { const { body, originalModel, fetch } = params - const deepseekBody = normalizeDeepSeekRequestBody( - body, - originalModel, - ) as unknown as Record - - // DeepSeek uses `thinking` instead of OpenRouter's `reasoning`. - if (deepseekBody.reasoning && typeof deepseekBody.reasoning === 'object') { - const reasoning = deepseekBody.reasoning as { - enabled?: boolean - effort?: 'high' | 'medium' | 'low' - } - deepseekBody.thinking = { - type: reasoning.enabled === false ? 'disabled' : 'enabled', - reasoning_effort: toDeepSeekReasoningEffort(reasoning.effort), - } - } else if (deepseekBody.reasoning_effort) { - deepseekBody.thinking = { - type: 'enabled', - reasoning_effort: toDeepSeekReasoningEffort( - deepseekBody.reasoning_effort, - ), - } - } - delete deepseekBody.reasoning - delete deepseekBody.reasoning_effort - - // Strip OpenRouter-specific / internal fields - delete deepseekBody.provider - delete deepseekBody.transforms - delete deepseekBody.codebuff_metadata - delete deepseekBody.usage - - // For streaming, request usage in the final chunk - if (deepseekBody.stream) { - deepseekBody.stream_options = { include_usage: true } - } + const deepseekBody = buildDeepSeekRequestBody(body, originalModel) if (!env.DEEPSEEK_API_KEY) { throw new Error('DEEPSEEK_API_KEY is not configured') From f9493c0b1d363532998ad403dde40e3b3e3ecca9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 4 May 2026 21:42:54 -0700 Subject: [PATCH 3/3] fix deepseek provider path tests --- .../completions/__tests__/completions.test.ts | 4 ++++ .../deepseek-image-compat.integration.test.ts | 12 ++++++++++++ web/src/llm-api/deepseek-request-body.ts | 18 +++++++++++------- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index 99c1e559a..a5a91dee0 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -779,6 +779,10 @@ describe('/api/v1/chat/completions POST endpoint', () => { const fetchedUrls: string[] = [] const fetchViaDeepSeek = mock( async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).startsWith('https://api.ipinfo.io/lookup/')) { + return Response.json({}) + } + fetchedUrls.push(String(url)) fetchedBodies.push(JSON.parse(init?.body as string)) return new Response( diff --git a/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts index 51f435a80..35ba1957b 100644 --- a/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts +++ b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts @@ -50,6 +50,18 @@ describe('normalizeDeepSeekRequestBody', () => { model: 'deepseek-v4-pro', }) }) + + it('does not throw on minimal provider-path bodies without messages', () => { + const body = { + model: 'deepseek/deepseek-v4-pro', + stream: false, + } as ChatCompletionRequestBody + + expect(normalizeDeepSeekRequestBody(body)).toEqual({ + ...body, + model: 'deepseek-v4-pro', + }) + }) }) describe('buildDeepSeekRequestBody', () => { diff --git a/web/src/llm-api/deepseek-request-body.ts b/web/src/llm-api/deepseek-request-body.ts index 1057d846b..582e690ef 100644 --- a/web/src/llm-api/deepseek-request-body.ts +++ b/web/src/llm-api/deepseek-request-body.ts @@ -77,16 +77,20 @@ export function normalizeDeepSeekRequestBody( body: ChatCompletionRequestBody, originalModel: string = body.model, ): ChatCompletionRequestBody { + const messages = Array.isArray(body.messages) + ? body.messages.map((message) => ({ + ...message, + content: + message.content === undefined || message.content === null + ? message.content + : contentPartsToDeepSeekText(message.content), + })) + : body.messages + return { ...body, model: getDeepSeekModelId(originalModel), - messages: body.messages.map((message) => ({ - ...message, - content: - message.content === undefined || message.content === null - ? message.content - : contentPartsToDeepSeekText(message.content), - })), + messages, } }