Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
113 changes: 113 additions & 0 deletions web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
139 changes: 139 additions & 0 deletions web/src/llm-api/deepseek-request-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { deepseekModels } from '@codebuff/common/constants/model-config'

import type { ChatCompletionRequestBody } from './types'

export const DEEPSEEK_MODEL_IDS: Record<string, string> = {
[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<string, unknown> {
const deepseekBody = normalizeDeepSeekRequestBody(
body,
originalModel,
) as unknown as Record<string, unknown>

// 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
}
73 changes: 15 additions & 58 deletions web/src/llm-api/deepseek.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -40,32 +43,25 @@ 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<string>(Object.keys(DEEPSEEK_MODELS))

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) {
Expand All @@ -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<string, unknown> = {
...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')
Expand Down
Loading
Loading