Add DeepSeek V4 flash/pro support and DeepSeek thinking compatibility (#877)

* Add DeepSeek V4 support and thinking compatibility

* Fix DeepSeek profile persistence regression

* Align multi-model handling with openai-multi-model
This commit is contained in:
JATMN
2026-04-24 11:29:46 -07:00
committed by GitHub
parent c4cb98a4f0
commit ff2a380723
15 changed files with 356 additions and 31 deletions

View File

@@ -31,25 +31,36 @@ afterEach(() => {
}
})
test('deepseek-chat uses provider-specific context and output caps', () => {
test('deepseek-v4-flash uses provider-specific context and output caps', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
delete process.env.OPENAI_MODEL
expect(getContextWindowForModel('deepseek-v4-flash')).toBe(1_048_576)
expect(getModelMaxOutputTokens('deepseek-v4-flash')).toEqual({
default: 262_144,
upperLimit: 262_144,
})
expect(getMaxOutputTokensForModel('deepseek-v4-flash')).toBe(262_144)
})
test('deepseek legacy aliases keep their documented provider caps', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
delete process.env.OPENAI_MODEL
expect(getContextWindowForModel('deepseek-chat')).toBe(128_000)
expect(getModelMaxOutputTokens('deepseek-chat')).toEqual({
default: 8_192,
upperLimit: 8_192,
})
expect(getContextWindowForModel('deepseek-reasoner')).toBe(128_000)
expect(getMaxOutputTokensForModel('deepseek-chat')).toBe(8_192)
expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(65_536)
})
test('deepseek-chat clamps oversized max output overrides to the provider limit', () => {
test('deepseek-v4-flash clamps oversized max output overrides to the provider limit', () => {
process.env.CLAUDE_CODE_USE_OPENAI = '1'
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '32000'
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '500000'
delete process.env.OPENAI_MODEL
expect(getMaxOutputTokensForModel('deepseek-chat')).toBe(8_192)
expect(getMaxOutputTokensForModel('deepseek-v4-flash')).toBe(262_144)
})
test('gpt-4o uses provider-specific context and output caps', () => {

View File

@@ -96,7 +96,13 @@ const OPENAI_CONTEXT_WINDOWS: Record<string, number> = {
'o3-mini': 200_000,
'o4-mini': 200_000,
// DeepSeek (V3: 128k context per official docs)
// DeepSeek V4 coding-agent models. DeepSeek's official coding-agent guide
// publishes V4 Pro at 1,048,576 context / 262,144 output; Flash is treated
// as the same family for local budgeting until a dedicated public model card
// lands.
'deepseek-v4-flash': 1_048_576,
'deepseek-v4-pro': 1_048_576,
// Legacy DeepSeek API aliases documented in the public pricing/model pages.
'deepseek-chat': 128_000,
'deepseek-reasoner': 128_000,
@@ -316,9 +322,12 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
'o3-mini': 100_000,
'o4-mini': 100_000,
// DeepSeek
// DeepSeek V4 coding-agent models. See context-window note above.
'deepseek-v4-flash': 262_144,
'deepseek-v4-pro': 262_144,
// Legacy DeepSeek API aliases documented in the public pricing/model pages.
'deepseek-chat': 8_192,
'deepseek-reasoner': 32_768,
'deepseek-reasoner': 65_536,
// Groq
'llama-3.3-70b-versatile': 32_768,

View File

@@ -16,6 +16,21 @@ describe('parseModelList', () => {
])
})
test('splits semicolon-separated models', () => {
expect(parseModelList('glm-4.7; glm-4.7-flash')).toEqual([
'glm-4.7',
'glm-4.7-flash',
])
})
test('splits mixed comma- and semicolon-separated models', () => {
expect(parseModelList('gpt-5.4; gpt-5.4-mini, o3')).toEqual([
'gpt-5.4',
'gpt-5.4-mini',
'o3',
])
})
test('returns single model in an array', () => {
expect(parseModelList('llama3.1:8b')).toEqual(['llama3.1:8b'])
})

View File

@@ -814,6 +814,22 @@ test('openai profiles ignore poisoned shell model and base url values', () => {
})
})
test('openai profiles normalize multi-model profile values to the primary model', () => {
const env = buildOpenAIProfileEnv({
goal: 'balanced',
apiKey: 'sk-live',
model: 'deepseek-v4-flash, deepseek-v4-pro, deepseek-chat',
baseUrl: 'https://api.deepseek.com/v1',
processEnv: {},
})
assert.deepEqual(env, {
OPENAI_BASE_URL: 'https://api.deepseek.com/v1',
OPENAI_MODEL: 'deepseek-v4-flash',
OPENAI_API_KEY: 'sk-live',
})
})
test('startup env ignores poisoned persisted openai model and base url', async () => {
const env = await buildStartupEnvFromProfile({
persisted: profile('openai', {

View File

@@ -15,6 +15,7 @@ import {
} from './providerRecommendation.js'
import { readGeminiAccessToken } from './geminiCredentials.js'
import { getOllamaChatBaseUrl } from './providerDiscovery.js'
import { getPrimaryModel } from './providerModels.js'
import { getProviderValidationError } from './providerValidation.js'
import {
maskSecretForDisplay,

View File

@@ -590,6 +590,20 @@ describe('getProviderPresetDefaults', () => {
expect(defaults.baseUrl).toBe('http://127.0.0.1:1337/v1')
expect(defaults.requiresApiKey).toBe(false)
})
test('deepseek preset defaults to DeepSeek V4 flash and exposes flash/pro aliases', async () => {
const { getProviderPresetDefaults } = await importFreshProviderProfileModules()
const defaults = getProviderPresetDefaults('deepseek')
expect(defaults.provider).toBe('openai')
expect(defaults.name).toBe('DeepSeek')
expect(defaults.baseUrl).toBe('https://api.deepseek.com/v1')
expect(defaults.model).toBe(
'deepseek-v4-flash, deepseek-v4-pro, deepseek-chat, deepseek-reasoner',
)
expect(defaults.requiresApiKey).toBe(true)
})
})
describe('setActiveProviderProfile', () => {
@@ -659,6 +673,45 @@ describe('setActiveProviderProfile', () => {
}
})
test('persists primary model for keyed openai-compatible multi-model profiles', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-provider-'))
process.chdir(tempDir)
try {
const { setActiveProviderProfile } =
await importFreshProviderProfileModules()
const deepSeekProfile = buildProfile({
id: 'deepseek_prof',
name: 'DeepSeek',
provider: 'openai',
baseUrl: 'https://api.deepseek.com/v1',
model: 'deepseek-v4-flash, deepseek-v4-pro, deepseek-chat',
apiKey: 'sk-deepseek-live',
})
saveMockGlobalConfig(current => ({
...current,
providerProfiles: [deepSeekProfile],
}))
const result = setActiveProviderProfile('deepseek_prof')
const persisted = JSON.parse(
readFileSync(join(tempDir, '.openclaude-profile.json'), 'utf8'),
)
expect(result?.id).toBe('deepseek_prof')
expect(persisted.profile).toBe('openai')
expect(persisted.env).toEqual({
OPENAI_BASE_URL: 'https://api.deepseek.com/v1',
OPENAI_MODEL: 'deepseek-v4-flash',
OPENAI_API_KEY: 'sk-deepseek-live',
})
} finally {
process.chdir(originalCwd)
rmSync(tempDir, { recursive: true, force: true })
}
})
test('sets ANTHROPIC_MODEL env var when switching to an anthropic-type provider', async () => {
const { setActiveProviderProfile } =
await importFreshProviderProfileModules()

View File

@@ -174,7 +174,7 @@ export function getProviderPresetDefaults(
provider: 'openai',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/v1',
model: 'deepseek-chat',
model: 'deepseek-v4-flash, deepseek-v4-pro, deepseek-chat, deepseek-reasoner',
apiKey: '',
requiresApiKey: true,
}
@@ -839,7 +839,7 @@ export function persistActiveProviderProfileModel(
/**
* Generate model options from a provider profile's model field.
* Each comma-separated model becomes a separate option in the picker.
* Each parsed model becomes a separate option in the picker.
*/
export function getProfileModelOptions(profile: ProviderProfile): ModelOption[] {
const models = parseModelList(profile.model)

View File

@@ -105,6 +105,12 @@ export function modelSupportsThinking(model: string): boolean {
if (provider === 'foundry' || provider === 'firstParty') {
return !canonical.includes('claude-3-')
}
if (
canonical.startsWith('deepseek-v4-') ||
canonical === 'deepseek-reasoner'
) {
return true
}
// 3P (Bedrock/Vertex): only Opus 4+ and Sonnet 4+
return canonical.includes('sonnet-4') || canonical.includes('opus-4')
}