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 new file mode 100644 index 000000000..35ba1957b --- /dev/null +++ b/web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'bun:test' + +import { + buildDeepSeekRequestBody, + normalizeDeepSeekRequestBody, +} from '../deepseek-request-body' + +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', + }) + }) + + 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', () => { + it('builds DeepSeek-compatible JSON when the request contains an image attachment', () => { + 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 }, + } + + const sentBody = buildDeepSeekRequestBody(body, body.model) + + 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 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.]', + ) + expect(JSON.stringify(sentBody)).not.toContain('image_url') + expect(JSON.stringify(body)).toContain('image_url') + }) +}) 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..582e690ef --- /dev/null +++ b/web/src/llm-api/deepseek-request-body.ts @@ -0,0 +1,139 @@ +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 { + 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, + } +} + +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 12ac66265..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,52 +83,13 @@ type LineResult = { patchedLine: string } -function toDeepSeekReasoningEffort(effort: unknown): 'high' | 'max' { - return effort === 'max' || effort === 'xhigh' ? 'max' : 'high' -} - -function createDeepSeekRequest(params: { +export function createDeepSeekRequest(params: { body: ChatCompletionRequestBody originalModel: string fetch: typeof globalThis.fetch }) { const { body, originalModel, fetch } = params - const deepseekBody: Record = { - ...body, - model: getDeepSeekModelId(originalModel), - } - - // 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') 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