Skip to content

Commit 34f8a0f

Browse files
committed
fix deepseek image test env import
1 parent 6aaa5c4 commit 34f8a0f

3 files changed

Lines changed: 156 additions & 152 deletions

File tree

web/src/llm-api/__tests__/deepseek-image-compat.integration.test.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { describe, expect, it, mock } from 'bun:test'
1+
import { describe, expect, it } from 'bun:test'
22

33
import {
4-
createDeepSeekRequest,
4+
buildDeepSeekRequestBody,
55
normalizeDeepSeekRequestBody,
6-
} from '../deepseek'
6+
} from '../deepseek-request-body'
77

88
import type { ChatCompletionRequestBody } from '../types'
99

@@ -52,16 +52,8 @@ describe('normalizeDeepSeekRequestBody', () => {
5252
})
5353
})
5454

55-
describe('createDeepSeekRequest', () => {
56-
it('sends DeepSeek-compatible text content when the request contains an image attachment', async () => {
57-
let sentBody: Record<string, unknown> | null = null
58-
const mockFetch = mock(
59-
async (_url: string | URL | Request, init?: RequestInit) => {
60-
sentBody = JSON.parse(init?.body as string)
61-
return new Response(JSON.stringify({ ok: true }), { status: 200 })
62-
},
63-
) as unknown as typeof globalThis.fetch
64-
55+
describe('buildDeepSeekRequestBody', () => {
56+
it('builds DeepSeek-compatible JSON when the request contains an image attachment', () => {
6557
const body: ChatCompletionRequestBody = {
6658
model: 'deepseek/deepseek-v4-pro',
6759
messages: [
@@ -85,11 +77,7 @@ describe('createDeepSeekRequest', () => {
8577
usage: { include: true },
8678
}
8779

88-
await createDeepSeekRequest({
89-
body,
90-
originalModel: body.model,
91-
fetch: mockFetch,
92-
})
80+
const sentBody = buildDeepSeekRequestBody(body, body.model)
9381

9482
expect(sentBody).toMatchObject({
9583
model: 'deepseek-v4-pro',
@@ -103,8 +91,7 @@ describe('createDeepSeekRequest', () => {
10391
expect(sentBody).not.toHaveProperty('codebuff_metadata')
10492
expect(sentBody).not.toHaveProperty('usage')
10593

106-
const capturedBody = sentBody as unknown as Record<string, unknown>
107-
const messages = capturedBody.messages as Array<{ content: string }>
94+
const messages = sentBody.messages as Array<{ content: string }>
10895
expect(messages[1].content).toBe(
10996
'Please inspect this screenshot.\n\n[1 image was omitted because the DeepSeek API does not support image input.]',
11097
)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { deepseekModels } from '@codebuff/common/constants/model-config'
2+
3+
import type { ChatCompletionRequestBody } from './types'
4+
5+
export const DEEPSEEK_MODEL_IDS: Record<string, string> = {
6+
[deepseekModels.deepseekV4ProDirect]: deepseekModels.deepseekV4ProDirect,
7+
[deepseekModels.deepseekV4Pro]: deepseekModels.deepseekV4ProDirect,
8+
}
9+
10+
export function getDeepSeekModelId(openrouterModel: string): string {
11+
return DEEPSEEK_MODEL_IDS[openrouterModel] ?? openrouterModel
12+
}
13+
14+
function toDeepSeekReasoningEffort(effort: unknown): 'high' | 'max' {
15+
return effort === 'max' || effort === 'xhigh' ? 'max' : 'high'
16+
}
17+
18+
function unsupportedAttachmentNotice(kind: string, count: number): string {
19+
const noun = count === 1 ? kind : `${kind}s`
20+
const verb = count === 1 ? 'was' : 'were'
21+
return `[${count} ${noun} ${verb} omitted because the DeepSeek API does not support ${kind} input.]`
22+
}
23+
24+
function contentPartsToDeepSeekText(
25+
content: NonNullable<
26+
ChatCompletionRequestBody['messages'][number]['content']
27+
>,
28+
): string {
29+
if (!Array.isArray(content)) {
30+
return content
31+
}
32+
33+
const textParts: string[] = []
34+
let imageCount = 0
35+
let fileCount = 0
36+
let unsupportedCount = 0
37+
38+
for (const part of content) {
39+
switch (part.type) {
40+
case 'text': {
41+
if (typeof part.text === 'string' && part.text.length > 0) {
42+
textParts.push(part.text)
43+
}
44+
break
45+
}
46+
case 'image_url': {
47+
imageCount += 1
48+
break
49+
}
50+
case 'file': {
51+
fileCount += 1
52+
break
53+
}
54+
default: {
55+
unsupportedCount += 1
56+
break
57+
}
58+
}
59+
}
60+
61+
if (imageCount > 0) {
62+
textParts.push(unsupportedAttachmentNotice('image', imageCount))
63+
}
64+
if (fileCount > 0) {
65+
textParts.push(unsupportedAttachmentNotice('file', fileCount))
66+
}
67+
if (unsupportedCount > 0) {
68+
textParts.push(
69+
unsupportedAttachmentNotice('unsupported content part', unsupportedCount),
70+
)
71+
}
72+
73+
return textParts.join('\n\n')
74+
}
75+
76+
export function normalizeDeepSeekRequestBody(
77+
body: ChatCompletionRequestBody,
78+
originalModel: string = body.model,
79+
): ChatCompletionRequestBody {
80+
return {
81+
...body,
82+
model: getDeepSeekModelId(originalModel),
83+
messages: body.messages.map((message) => ({
84+
...message,
85+
content:
86+
message.content === undefined || message.content === null
87+
? message.content
88+
: contentPartsToDeepSeekText(message.content),
89+
})),
90+
}
91+
}
92+
93+
export function buildDeepSeekRequestBody(
94+
body: ChatCompletionRequestBody,
95+
originalModel: string = body.model,
96+
): Record<string, unknown> {
97+
const deepseekBody = normalizeDeepSeekRequestBody(
98+
body,
99+
originalModel,
100+
) as unknown as Record<string, unknown>
101+
102+
// DeepSeek uses `thinking` instead of OpenRouter's `reasoning`.
103+
if (deepseekBody.reasoning && typeof deepseekBody.reasoning === 'object') {
104+
const reasoning = deepseekBody.reasoning as {
105+
enabled?: boolean
106+
effort?: 'high' | 'medium' | 'low'
107+
}
108+
deepseekBody.thinking = {
109+
type: reasoning.enabled === false ? 'disabled' : 'enabled',
110+
reasoning_effort: toDeepSeekReasoningEffort(reasoning.effort),
111+
}
112+
} else if (deepseekBody.reasoning_effort) {
113+
deepseekBody.thinking = {
114+
type: 'enabled',
115+
reasoning_effort: toDeepSeekReasoningEffort(
116+
deepseekBody.reasoning_effort,
117+
),
118+
}
119+
}
120+
delete deepseekBody.reasoning
121+
delete deepseekBody.reasoning_effort
122+
123+
// Strip OpenRouter-specific / internal fields.
124+
delete deepseekBody.provider
125+
delete deepseekBody.transforms
126+
delete deepseekBody.codebuff_metadata
127+
delete deepseekBody.usage
128+
129+
// For streaming, request usage in the final chunk.
130+
if (deepseekBody.stream) {
131+
deepseekBody.stream_options = { include_usage: true }
132+
}
133+
134+
return deepseekBody
135+
}

web/src/llm-api/deepseek.ts

Lines changed: 14 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Agent } from 'undici'
22

3-
import { deepseekModels } from '@codebuff/common/constants/model-config'
43
import { PROFIT_MARGIN } from '@codebuff/common/constants/limits'
54
import { getErrorObject } from '@codebuff/common/util/error'
65
import { env } from '@codebuff/internal/env'
@@ -10,6 +9,10 @@ import {
109
extractRequestMetadata,
1110
insertMessageToBigQuery,
1211
} from './helpers'
12+
import {
13+
buildDeepSeekRequestBody,
14+
DEEPSEEK_MODEL_IDS,
15+
} from './deepseek-request-body'
1316

1417
import type { UsageData } from './helpers'
1518
import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/bigquery'
@@ -40,32 +43,25 @@ const DEEPSEEK_V4_PRO_PRICING: DeepSeekPricing = {
4043
outputCostPerToken: 0.87 / 1_000_000,
4144
}
4245

43-
/** Single source of truth for DeepSeek model metadata and pricing.
44-
* Kept as one map so adding a model can't drift between routing and billing. */
4546
const DEEPSEEK_MODELS: Record<
4647
string,
4748
{ deepseekId: string; pricing: DeepSeekPricing }
48-
> = {
49-
[deepseekModels.deepseekV4ProDirect]: {
50-
deepseekId: deepseekModels.deepseekV4ProDirect,
51-
pricing: DEEPSEEK_V4_PRO_PRICING,
52-
},
53-
[deepseekModels.deepseekV4Pro]: {
54-
deepseekId: deepseekModels.deepseekV4ProDirect,
55-
pricing: DEEPSEEK_V4_PRO_PRICING,
56-
},
57-
}
49+
> = Object.fromEntries(
50+
Object.entries(DEEPSEEK_MODEL_IDS).map(([model, deepseekId]) => [
51+
model,
52+
{
53+
deepseekId,
54+
pricing: DEEPSEEK_V4_PRO_PRICING,
55+
},
56+
]),
57+
)
5858

5959
const DEEPSEEK_ROUTED_MODELS = new Set<string>(Object.keys(DEEPSEEK_MODELS))
6060

6161
export function isDeepSeekModel(model: string): boolean {
6262
return DEEPSEEK_ROUTED_MODELS.has(model)
6363
}
6464

65-
function getDeepSeekModelId(openrouterModel: string): string {
66-
return DEEPSEEK_MODELS[openrouterModel]?.deepseekId ?? openrouterModel
67-
}
68-
6965
function getDeepSeekPricing(model: string): DeepSeekPricing {
7066
const entry = DEEPSEEK_MODELS[model]
7167
if (!entry) {
@@ -87,127 +83,13 @@ type LineResult = {
8783
patchedLine: string
8884
}
8985

90-
function toDeepSeekReasoningEffort(effort: unknown): 'high' | 'max' {
91-
return effort === 'max' || effort === 'xhigh' ? 'max' : 'high'
92-
}
93-
94-
function unsupportedAttachmentNotice(kind: string, count: number): string {
95-
const noun = count === 1 ? kind : `${kind}s`
96-
const verb = count === 1 ? 'was' : 'were'
97-
return `[${count} ${noun} ${verb} omitted because the DeepSeek API does not support ${kind} input.]`
98-
}
99-
100-
function contentPartsToDeepSeekText(
101-
content: NonNullable<
102-
ChatCompletionRequestBody['messages'][number]['content']
103-
>,
104-
): string {
105-
if (!Array.isArray(content)) {
106-
return content
107-
}
108-
109-
const textParts: string[] = []
110-
let imageCount = 0
111-
let fileCount = 0
112-
let unsupportedCount = 0
113-
114-
for (const part of content) {
115-
switch (part.type) {
116-
case 'text': {
117-
if (typeof part.text === 'string' && part.text.length > 0) {
118-
textParts.push(part.text)
119-
}
120-
break
121-
}
122-
case 'image_url': {
123-
imageCount += 1
124-
break
125-
}
126-
case 'file': {
127-
fileCount += 1
128-
break
129-
}
130-
default: {
131-
unsupportedCount += 1
132-
break
133-
}
134-
}
135-
}
136-
137-
if (imageCount > 0) {
138-
textParts.push(unsupportedAttachmentNotice('image', imageCount))
139-
}
140-
if (fileCount > 0) {
141-
textParts.push(unsupportedAttachmentNotice('file', fileCount))
142-
}
143-
if (unsupportedCount > 0) {
144-
textParts.push(
145-
unsupportedAttachmentNotice('unsupported content part', unsupportedCount),
146-
)
147-
}
148-
149-
return textParts.join('\n\n')
150-
}
151-
152-
export function normalizeDeepSeekRequestBody(
153-
body: ChatCompletionRequestBody,
154-
originalModel: string = body.model,
155-
): ChatCompletionRequestBody {
156-
return {
157-
...body,
158-
model: getDeepSeekModelId(originalModel),
159-
messages: body.messages.map((message) => ({
160-
...message,
161-
content:
162-
message.content === undefined || message.content === null
163-
? message.content
164-
: contentPartsToDeepSeekText(message.content),
165-
})),
166-
}
167-
}
168-
16986
export function createDeepSeekRequest(params: {
17087
body: ChatCompletionRequestBody
17188
originalModel: string
17289
fetch: typeof globalThis.fetch
17390
}) {
17491
const { body, originalModel, fetch } = params
175-
const deepseekBody = normalizeDeepSeekRequestBody(
176-
body,
177-
originalModel,
178-
) as unknown as Record<string, unknown>
179-
180-
// DeepSeek uses `thinking` instead of OpenRouter's `reasoning`.
181-
if (deepseekBody.reasoning && typeof deepseekBody.reasoning === 'object') {
182-
const reasoning = deepseekBody.reasoning as {
183-
enabled?: boolean
184-
effort?: 'high' | 'medium' | 'low'
185-
}
186-
deepseekBody.thinking = {
187-
type: reasoning.enabled === false ? 'disabled' : 'enabled',
188-
reasoning_effort: toDeepSeekReasoningEffort(reasoning.effort),
189-
}
190-
} else if (deepseekBody.reasoning_effort) {
191-
deepseekBody.thinking = {
192-
type: 'enabled',
193-
reasoning_effort: toDeepSeekReasoningEffort(
194-
deepseekBody.reasoning_effort,
195-
),
196-
}
197-
}
198-
delete deepseekBody.reasoning
199-
delete deepseekBody.reasoning_effort
200-
201-
// Strip OpenRouter-specific / internal fields
202-
delete deepseekBody.provider
203-
delete deepseekBody.transforms
204-
delete deepseekBody.codebuff_metadata
205-
delete deepseekBody.usage
206-
207-
// For streaming, request usage in the final chunk
208-
if (deepseekBody.stream) {
209-
deepseekBody.stream_options = { include_usage: true }
210-
}
92+
const deepseekBody = buildDeepSeekRequestBody(body, originalModel)
21193

21294
if (!env.DEEPSEEK_API_KEY) {
21395
throw new Error('DEEPSEEK_API_KEY is not configured')

0 commit comments

Comments
 (0)