diff --git a/package.json b/package.json index 85d38cafe..80960f37b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dev:profile:fast": "bun run scripts/provider-launch.ts auto --fast --bare", "dev:codex": "bun run scripts/provider-launch.ts codex", "dev:openai": "bun run scripts/provider-launch.ts openai", + "dev:deepseek": "bun run scripts/provider-launch.ts deepseek", "dev:gemini": "bun run scripts/provider-launch.ts gemini", "dev:ollama": "bun run scripts/provider-launch.ts ollama", "dev:ollama:fast": "bun run scripts/provider-launch.ts ollama --fast --bare", @@ -26,6 +27,7 @@ "profile:recommend": "bun run scripts/provider-recommend.ts", "profile:auto": "bun run scripts/provider-recommend.ts --apply", "profile:codex": "bun run profile:init -- --provider codex --model codexplan", + "profile:deepseek": "bun run profile:init -- --provider deepseek", "profile:fast": "bun run profile:init -- --provider ollama --model llama3.2:3b", "profile:code": "bun run profile:init -- --provider ollama --model qwen2.5-coder:7b", "dev:fast": "bun run profile:fast && bun run dev:ollama:fast", diff --git a/scripts/provider-bootstrap.ts b/scripts/provider-bootstrap.ts index 404bcb32c..cee207632 100644 --- a/scripts/provider-bootstrap.ts +++ b/scripts/provider-bootstrap.ts @@ -10,6 +10,7 @@ import { import { buildAtomicChatProfileEnv, buildCodexProfileEnv, + buildDeepSeekProfileEnv, buildGeminiProfileEnv, buildMistralProfileEnv, buildOllamaProfileEnv, @@ -38,7 +39,7 @@ function parseArg(name: string): string | null { function parseProviderArg(): ProviderProfile | 'auto' { const p = parseArg('--provider')?.toLowerCase() - if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini' || p === 'mistral' || p === 'atomic-chat') return p + if (p === 'openai' || p === 'ollama' || p === 'codex' || p === 'gemini' || p === 'mistral' || p === 'deepseek' || p === 'atomic-chat') return p return 'auto' } @@ -106,6 +107,21 @@ async function main(): Promise { process.exit(1) } + env = builtEnv + } else if (selected === 'deepseek') { + const builtEnv = buildDeepSeekProfileEnv({ + model: argModel || null, + baseUrl: argBaseUrl || null, + apiKey: argApiKey || null, + processEnv: process.env, + }) + + if (!builtEnv) { + console.error('DeepSeek profile requires an API key. Use --api-key or set DEEPSEEK_API_KEY/OPENAI_API_KEY.') + console.error('Get a key at: https://platform.deepseek.com/api_keys') + process.exit(1) + } + env = builtEnv } else if (selected === 'ollama') { resolvedOllamaModel ??= await resolveOllamaModel(argModel, argBaseUrl, goal) diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index 21a04ec50..2b70058b7 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -50,7 +50,7 @@ function parseLaunchOptions(argv: string[]): LaunchOptions { continue } - if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower ==='mistral' || lower === 'atomic-chat') && requestedProfile === 'auto') { + if ((lower === 'auto' || lower === 'openai' || lower === 'ollama' || lower === 'codex' || lower === 'gemini' || lower ==='mistral' || lower === 'deepseek' || lower === 'atomic-chat') && requestedProfile === 'auto') { requestedProfile = lower as ProviderProfile | 'auto' continue } @@ -126,6 +126,8 @@ function printSummary(profile: ProviderProfile): void { console.log('Using configured Gemini provider settings.') } else if (profile === 'mistral') { console.log('Using configured Mistral provider settings.') + } else if (profile === 'deepseek') { + console.log('Using configured DeepSeek provider settings.') } else if (profile === 'codex') { console.log('Using configured Codex/OpenAI-compatible provider settings.') } else if (profile === 'atomic-chat') { @@ -141,7 +143,7 @@ async function main(): Promise { const options = parseLaunchOptions(process.argv.slice(2)) const requestedProfile = options.requestedProfile if (!requestedProfile) { - console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|gemini|mistral|atomic-chat|mistral|auto] [--fast] [--goal ] [-- ]') + console.error('Usage: bun run scripts/provider-launch.ts [openai|ollama|codex|gemini|mistral|deepseek|atomic-chat|auto] [--fast] [--goal ] [-- ]') process.exit(1) } @@ -212,6 +214,11 @@ async function main(): Promise { process.exit(1) } + if (profile === 'deepseek' && (!env.OPENAI_API_KEY || env.OPENAI_API_KEY === 'SUA_CHAVE')) { + console.error('DeepSeek profile requires a real API key. Run: bun run profile:init -- --provider deepseek --api-key ') + process.exit(1) + } + if (profile === 'openai' && (!env.OPENAI_API_KEY || env.OPENAI_API_KEY === 'SUA_CHAVE')) { console.error('OPENAI_API_KEY is required for openai profile and cannot be SUA_CHAVE. Run: bun run profile:init -- --provider openai --api-key ') process.exit(1) diff --git a/src/commands/provider/provider.tsx b/src/commands/provider/provider.tsx index db6de7192..2812d69ec 100644 --- a/src/commands/provider/provider.tsx +++ b/src/commands/provider/provider.tsx @@ -30,6 +30,7 @@ import { applySavedProfileToCurrentSession as applySharedProfileToCurrentSession, buildCodexOAuthProfileEnv as buildSharedCodexOAuthProfileEnv, buildCodexProfileEnv, + buildDeepSeekProfileEnv, buildGeminiProfileEnv, buildMistralProfileEnv, buildOllamaProfileEnv, @@ -39,6 +40,8 @@ import { DEFAULT_GEMINI_MODEL, DEFAULT_MISTRAL_BASE_URL, DEFAULT_MISTRAL_MODEL, + DEFAULT_DEEPSEEK_BASE_URL, + DEFAULT_DEEPSEEK_MODEL, deleteProfileFile, loadProfileFile, maskSecretForDisplay, @@ -137,6 +140,8 @@ type Step = | { name: 'auto-goal' } | { name: 'auto-detect'; goal: RecommendationGoal } | { name: 'ollama-detect' } + | { name: 'deepseek-key' } + | { name: 'deepseek-model'; apiKey: string } | { name: 'openai-key'; defaultModel: string } | { name: 'openai-base'; apiKey: string; defaultModel: string } | { @@ -315,6 +320,8 @@ export function buildCurrentProviderSummary(options?: { providerLabel = 'Codex' } else if (isLocalProviderUrl(request.baseUrl)) { providerLabel = getLocalOpenAICompatibleProviderLabel(request.baseUrl) + } else if (/api\.deepseek\.com/i.test(request.baseUrl)) { + providerLabel = 'DeepSeek V4' } return { @@ -404,6 +411,25 @@ function buildSavedProfileSummary( ? 'configured' : undefined, } + case 'deepseek': + return { + providerLabel: 'DeepSeek V4', + modelLabel: getSafeDisplayValue( + env.OPENAI_MODEL ?? DEFAULT_DEEPSEEK_MODEL, + process.env, + env, + ), + endpointLabel: getSafeDisplayValue( + env.OPENAI_BASE_URL ?? DEFAULT_DEEPSEEK_BASE_URL, + process.env, + env, + ), + credentialLabel: + maskSecretForDisplay(env.OPENAI_API_KEY) !== undefined || + maskSecretForDisplay(env.DEEPSEEK_API_KEY) !== undefined + ? 'configured' + : undefined, + } case 'ollama': return { providerLabel: 'Ollama', @@ -482,8 +508,8 @@ export function buildProfileSaveMessage( function buildUsageText(): string { const summary = buildCurrentProviderSummary() const availableProviders = isBareMode() - ? 'Choose Auto, Ollama, OpenAI-compatible, Gemini, or Codex, then save a provider profile.' - : 'Choose Auto, Ollama, OpenAI-compatible, Gemini, Codex, or Codex OAuth, then save a provider profile.' + ? 'Choose Auto, Ollama, DeepSeek V4, OpenAI-compatible, Gemini, or Codex, then save a provider profile.' + : 'Choose Auto, Ollama, DeepSeek V4, OpenAI-compatible, Gemini, Codex, or Codex OAuth, then save a provider profile.' return [ 'Usage: /provider', '', @@ -645,7 +671,12 @@ function ProviderChooser({ label: 'OpenAI-compatible', value: 'openai', description: - 'GPT-4o, DeepSeek, OpenRouter, Groq, LM Studio, and similar APIs', + 'GPT-4o, OpenRouter, Groq, LM Studio, and similar APIs', + }, + { + label: 'DeepSeek V4', + value: 'deepseek', + description: 'Use DeepSeek V4 Pro thinking mode with the official API', }, { label: 'Gemini', @@ -1252,6 +1283,8 @@ export function ProviderWizard({ name: 'openai-key', defaultModel: defaults.openAIModel, }) + } else if (value === 'deepseek') { + setStep({ name: 'deepseek-key' }) } else if (value === 'gemini') { setStep({ name: 'gemini-auth-method' }) } else if (value === 'mistral') { @@ -1304,6 +1337,67 @@ export function ProviderWizard({ /> ) + case 'deepseek-key': + return ( + { + const candidate = + value.trim() || + process.env.DEEPSEEK_API_KEY || + process.env.OPENAI_API_KEY || + '' + return sanitizeApiKey(candidate) + ? null + : 'Enter a real DeepSeek API key.' + }} + onSubmit={value => { + const apiKey = + value.trim() || + process.env.DEEPSEEK_API_KEY || + process.env.OPENAI_API_KEY || + '' + setStep({ name: 'deepseek-model', apiKey }) + }} + onCancel={() => setStep({ name: 'choose' })} + /> + ) + + case 'deepseek-model': + return ( + { + const env = buildDeepSeekProfileEnv({ + apiKey: step.apiKey, + model: value.trim() || DEFAULT_DEEPSEEK_MODEL, + processEnv: {}, + }) + if (env) { + finishProfileSave(onDone, 'deepseek', env) + } + }} + onCancel={() => setStep({ name: 'deepseek-key' })} + /> + ) + case 'openai-key': return ( { return Number.isFinite(n) ? n : undefined; }).hideHelp()).option('--from-pr [value]', 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', value => value || true).option('--no-session-persistence', 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)').addOption(new Option('--resume-session-at ', 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)').argParser(String).hideHelp()).addOption(new Option('--rewind-files ', 'Restore files to state at the specified user message and exit (requires --resume)').hideHelp()) // @[MODEL LAUNCH]: Update the example model ID in the --model help text. - .option('--model ', `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`).option('--provider ', `AI provider to use (anthropic, openai, gemini, github, bedrock, vertex, ollama). Reads API keys from environment variables.`).addOption(new Option('--effort ', `Effort level for the current session (low, medium, high, max)`).argParser((rawValue: string) => { + .option('--model ', `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`).option('--provider ', `AI provider to use (anthropic, openai, deepseek, gemini, github, bedrock, vertex, ollama). Reads API keys from environment variables.`).addOption(new Option('--effort ', `Effort level for the current session (low, medium, high, max)`).argParser((rawValue: string) => { const value = rawValue.toLowerCase(); const allowed = ['low', 'medium', 'high', 'max']; if (!allowed.includes(value)) { diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index a3174219a..d87b7ce1f 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -3666,7 +3666,7 @@ test('Moonshot: echoes reasoning_content on assistant tool-call messages', async test('DeepSeek echoes reasoning_content on assistant tool-call messages', async () => { process.env.OPENAI_BASE_URL = 'https://api.deepseek.com/v1' - process.env.OPENAI_API_KEY = 'sk-deepseek' + process.env.OPENAI_API_KEY = 'deepseek-test-key' let requestBody: Record | undefined globalThis.fetch = (async (_input, init) => { @@ -3722,6 +3722,143 @@ test('DeepSeek echoes reasoning_content on assistant tool-call messages', async expect(assistantWithToolCall?.reasoning_content).toBe('thought') }) +test('DeepSeek echoes reasoning_content when streaming split thinking and tool call blocks share a message id', async () => { + process.env.OPENAI_BASE_URL = 'https://api.deepseek.com/v1' + process.env.OPENAI_API_KEY = 'deepseek-test-key' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'deepseek-v4-flash', + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'deepseek-v4-flash', + system: 'test', + messages: [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + message: { + id: 'msg_split_1', + role: 'assistant', + content: [{ type: 'thinking', thinking: 'split thought' }], + }, + }, + { + role: 'assistant', + message: { + id: 'msg_split_1', + role: 'assistant', + content: [{ type: 'text', text: 'running a command' }], + }, + }, + { + role: 'assistant', + message: { + id: 'msg_split_1', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'Bash', + input: { command: 'ls' }, + }, + ], + }, + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call_1', content: 'files' }, + ], + }, + ], + max_tokens: 32, + stream: false, + }) + + const messages = requestBody?.messages as Array> + const assistantWithToolCall = messages.find( + m => m.role === 'assistant' && Array.isArray(m.tool_calls), + ) + expect(assistantWithToolCall).toBeDefined() + expect(assistantWithToolCall?.content).toBe('running a command') + expect(assistantWithToolCall?.reasoning_content).toBe('split thought') +}) + +test('DeepSeek synthesizes missing reasoning_content for legacy assistant tool-call history', async () => { + process.env.OPENAI_BASE_URL = 'https://api.deepseek.com/v1' + process.env.OPENAI_API_KEY = 'deepseek-test-key' + + let requestBody: Record | undefined + globalThis.fetch = (async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + model: 'deepseek-v4-pro', + choices: [ + { message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }) as FetchType + + const client = createOpenAIShimClient({}) as OpenAIShimClient + await client.beta.messages.create({ + model: 'deepseek-v4-pro', + system: 'test', + messages: [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'running a command' }, + { + type: 'tool_use', + id: 'call_1', + name: 'Bash', + input: { command: 'pwd' }, + }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'call_1', content: 'workspace' }, + ], + }, + ], + max_tokens: 32, + stream: false, + thinking: { type: 'enabled' }, + }) + + const messages = requestBody?.messages as Array> + const assistantWithToolCall = messages.find( + m => m.role === 'assistant' && Array.isArray(m.tool_calls), + ) + expect(assistantWithToolCall).toBeDefined() + expect(assistantWithToolCall?.reasoning_content).toBe( + 'Reasoning content unavailable from prior client transcript.', + ) +}) + test('generic OpenAI-compatible providers do not echo reasoning_content on assistant tool-call messages', async () => { process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1' process.env.OPENAI_API_KEY = 'sk-openai-test' diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 015c0e2e4..d6d60416e 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -259,6 +259,9 @@ interface OpenAIMessage { reasoning_content?: string } +const MISSING_REASONING_CONTENT_FALLBACK = + 'Reasoning content unavailable from prior client transcript.' + interface OpenAITool { type: 'function' function: { @@ -424,11 +427,17 @@ function convertMessages( content?: unknown }>, system: unknown, - options?: { preserveReasoningContent?: boolean }, + options?: { + preserveReasoningContent?: boolean + synthesizeMissingReasoningContent?: boolean + }, ): OpenAIMessage[] { const preserveReasoningContent = options?.preserveReasoningContent === true + const synthesizeMissingReasoningContent = + options?.synthesizeMissingReasoningContent === true const result: OpenAIMessage[] = [] const knownToolCallIds = new Set() + const pendingReasoningByAssistantMessageId = new Map() // Pre-scan for all tool results in the history to identify valid tool calls const toolResultIds = new Set() @@ -505,6 +514,12 @@ function convertMessages( }) } } else if (role === 'assistant') { + const assistantMessageId = (inner as { id?: unknown }).id + const assistantMessageKey = + typeof assistantMessageId === 'string' && assistantMessageId.length > 0 + ? assistantMessageId + : undefined + // Check for tool_use blocks if (Array.isArray(content)) { const toolUses = content.filter( @@ -540,7 +555,22 @@ function convertMessages( if (preserveReasoningContent) { const thinkingText = (thinkingBlock as { thinking?: string } | undefined)?.thinking if (typeof thinkingText === 'string' && thinkingText.trim().length > 0) { + if (assistantMessageKey) { + pendingReasoningByAssistantMessageId.set( + assistantMessageKey, + thinkingText, + ) + } assistantMsg.reasoning_content = thinkingText + } else if (toolUses.length > 0 && assistantMessageKey) { + const pendingThinking = + pendingReasoningByAssistantMessageId.get(assistantMessageKey) + if ( + typeof pendingThinking === 'string' && + pendingThinking.trim().length > 0 + ) { + assistantMsg.reasoning_content = pendingThinking + } } } @@ -614,6 +644,13 @@ function convertMessages( if (mappedToolCalls.length > 0) { assistantMsg.tool_calls = mappedToolCalls + if ( + preserveReasoningContent && + synthesizeMissingReasoningContent && + !assistantMsg.reasoning_content + ) { + assistantMsg.reasoning_content = MISSING_REASONING_CONTENT_FALLBACK + } } } @@ -702,6 +739,20 @@ function convertMessages( ...msg.tool_calls, ] } + if ( + msg.reasoning_content && + !lastAfterPossibleInjection.reasoning_content + ) { + lastAfterPossibleInjection.reasoning_content = msg.reasoning_content + } + if ( + synthesizeMissingReasoningContent && + lastAfterPossibleInjection.tool_calls?.length && + !lastAfterPossibleInjection.reasoning_content + ) { + lastAfterPossibleInjection.reasoning_content = + MISSING_REASONING_CONTENT_FALLBACK + } } else { coalesced.push(msg) } @@ -1529,6 +1580,12 @@ class OpenAIShimMessages { isMoonshotCompatibleBaseUrl(request.baseUrl) || isDeepSeekBaseUrl(request.baseUrl) || isZaiBaseUrl(request.baseUrl), + // Older OpenClaude builds could persist DeepSeek assistant tool-call + // messages after stripping the reasoning_content source. DeepSeek's + // stateless validator rejects those histories in thinking mode. Prefer + // the real thinking text when present, but synthesize a tiny fallback so + // already-corrupted local transcripts can recover after upgrading. + synthesizeMissingReasoningContent: isDeepSeekBaseUrl(request.baseUrl), }) const body: Record = { diff --git a/src/utils/conversationRecovery.hooks.test.ts b/src/utils/conversationRecovery.hooks.test.ts index b19ae2559..e3ed4ebd4 100644 --- a/src/utils/conversationRecovery.hooks.test.ts +++ b/src/utils/conversationRecovery.hooks.test.ts @@ -10,6 +10,7 @@ import { join } from 'node:path' const tempDirs: string[] = [] const originalSimple = process.env.CLAUDE_CODE_SIMPLE +const originalOpenAIBaseUrl = process.env.OPENAI_BASE_URL const sessionId = '00000000-0000-4000-8000-000000001999' const ts = '2026-04-02T00:00:00.000Z' @@ -47,6 +48,11 @@ async function writeJsonl(entry: unknown): Promise { afterEach(async () => { mock.restore() process.env.CLAUDE_CODE_SIMPLE = originalSimple + if (originalOpenAIBaseUrl === undefined) { + delete process.env.OPENAI_BASE_URL + } else { + process.env.OPENAI_BASE_URL = originalOpenAIBaseUrl + } await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) }) @@ -82,6 +88,7 @@ test('deserializeMessagesWithInterruptDetection strips thinking blocks only for sessionId, version: 'test', message: { + id: 'msg_visible_thinking', role: 'assistant', content: [ { type: 'thinking', thinking: 'secret reasoning' }, @@ -98,6 +105,7 @@ test('deserializeMessagesWithInterruptDetection strips thinking blocks only for sessionId, version: 'test', message: { + id: 'msg_orphan_thinking', role: 'assistant', content: [{ type: 'thinking', thinking: 'only hidden reasoning' }], }, @@ -159,3 +167,96 @@ test('deserializeMessagesWithInterruptDetection strips thinking blocks only for JSON.stringify(anthropicAssistantMessages.map(message => message.message?.content)), ).not.toContain('only hidden reasoning') }) + +test('deserializeMessagesWithInterruptDetection preserves DeepSeek tool-call thinking on resume', async () => { + process.env.OPENAI_BASE_URL = 'https://api.deepseek.com/v1' + + const serializedMessages = [ + user(id(20), 'hello'), + { + type: 'assistant', + uuid: id(21), + parentUuid: id(20), + timestamp: ts, + cwd: '/tmp', + sessionId, + version: 'test', + message: { + id: 'msg_no_tool', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'no tool reasoning' }, + { type: 'text', text: 'visible no-tool reply' }, + ], + }, + }, + user(id(22), 'use a tool'), + { + type: 'assistant', + uuid: id(23), + parentUuid: id(22), + timestamp: ts, + cwd: '/tmp', + sessionId, + version: 'test', + message: { + id: 'msg_tool_call', + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'tool-call reasoning' }, + { type: 'text', text: 'running a command' }, + { + type: 'tool_use', + id: 'call_1', + name: 'Bash', + input: { command: 'pwd' }, + }, + ], + }, + }, + { + type: 'user', + uuid: id(24), + parentUuid: id(23), + timestamp: ts, + cwd: '/tmp', + userType: 'external', + sessionId, + version: 'test', + isSidechain: false, + isMeta: false, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_1', + content: 'workspace', + }, + ], + }, + }, + user(id(25), 'follow up'), + ] + + mock.module('./model/providers.js', () => ({ + getAPIProvider: () => 'openai', + isOpenAICompatibleProvider: (provider: string) => + provider === 'openai' || + provider === 'gemini' || + provider === 'github' || + provider === 'codex', + })) + + const openaiModule = await import(`./conversationRecovery.ts?provider=deepseek-${Date.now()}`) + const result = openaiModule.deserializeMessagesWithInterruptDetection(serializedMessages as never[]) + const assistantContent = JSON.stringify( + result.messages + .filter(message => message.type === 'assistant') + .map(message => message.message?.content), + ) + + expect(assistantContent).toContain('tool-call reasoning') + expect(assistantContent).toContain('running a command') + expect(assistantContent).not.toContain('no tool reasoning') +}) diff --git a/src/utils/conversationRecovery.ts b/src/utils/conversationRecovery.ts index 3d4ad44bc..ca5604ac0 100644 --- a/src/utils/conversationRecovery.ts +++ b/src/utils/conversationRecovery.ts @@ -178,18 +178,70 @@ export type DeserializeResult = { turnInterruptionState: TurnInterruptionState } +function isDeepSeekReasoningContinuityBaseUrl( + baseUrl: string | undefined, +): boolean { + if (!baseUrl) return false + try { + return new URL(baseUrl).hostname.toLowerCase() === 'api.deepseek.com' + } catch { + return false + } +} + +function getAssistantMessageIdsWithToolUse( + messages: NormalizedMessage[], +): Set { + const ids = new Set() + for (const msg of messages) { + if (msg.type !== 'assistant' || !Array.isArray(msg.message?.content)) { + continue + } + if ( + msg.message.id && + msg.message.content.some((block: { type?: string }) => block.type === 'tool_use') + ) { + ids.add(msg.message.id) + } + } + return ids +} + /** * Remove thinking/redacted_thinking content blocks from assistant messages. * Messages that become empty after stripping are removed entirely. + * + * DeepSeek thinking mode is the exception: assistant messages that performed + * tool calls must replay their reasoning_content in later requests. We preserve + * the matching thinking block so the OpenAI shim can round-trip it. */ -function stripThinkingBlocks(messages: NormalizedMessage[]): NormalizedMessage[] { +function stripThinkingBlocks( + messages: NormalizedMessage[], + options?: { preserveToolUseThinking?: boolean }, +): NormalizedMessage[] { + const toolUseMessageIds = options?.preserveToolUseThinking + ? getAssistantMessageIdsWithToolUse(messages) + : new Set() + return messages.reduce((acc, msg) => { if (msg.type !== 'assistant' || !Array.isArray(msg.message?.content)) { acc.push(msg) return acc } const filtered = msg.message.content.filter( - (block: { type?: string }) => block.type !== 'thinking' && block.type !== 'redacted_thinking', + (block: { type?: string }) => { + if (block.type === 'redacted_thinking') { + return false + } + if (block.type !== 'thinking') { + return true + } + return Boolean( + options?.preserveToolUseThinking && + msg.message.id && + toolUseMessageIds.has(msg.message.id), + ) + }, ) if (filtered.length === 0) return acc acc.push({ ...msg, message: { ...msg.message, content: filtered } }) @@ -252,8 +304,11 @@ export function deserializeMessagesWithInterruptDetection( // 400 errors or context corruption on OpenAI-compatible providers (issue #248 finding 5). const provider = getAPIProvider() const isThirdPartyProvider = provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex' && provider !== 'foundry' + const preserveToolUseThinking = + isThirdPartyProvider && + isDeepSeekReasoningContinuityBaseUrl(process.env.OPENAI_BASE_URL) const thinkingStripped = isThirdPartyProvider - ? stripThinkingBlocks(filteredThinking) + ? stripThinkingBlocks(filteredThinking, { preserveToolUseThinking }) : filteredThinking // Filter out assistant messages with only whitespace text content. diff --git a/src/utils/providerFlag.test.ts b/src/utils/providerFlag.test.ts index f9ef6c706..c152062b8 100644 --- a/src/utils/providerFlag.test.ts +++ b/src/utils/providerFlag.test.ts @@ -13,8 +13,10 @@ const ENV_KEYS = [ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'OPENAI_BASE_URL', + 'OPENAI_API_FORMAT', 'OPENAI_API_KEY', 'OPENAI_MODEL', + 'DEEPSEEK_API_KEY', 'GEMINI_MODEL', ] @@ -34,8 +36,10 @@ const RESET_KEYS = [ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'OPENAI_BASE_URL', + 'OPENAI_API_FORMAT', 'OPENAI_API_KEY', 'OPENAI_MODEL', + 'DEEPSEEK_API_KEY', 'GEMINI_MODEL', ] as const @@ -107,6 +111,31 @@ describe('applyProviderFlag - openai', () => { }) }) +describe('applyProviderFlag - deepseek', () => { + test('sets DeepSeek V4 defaults when unset', () => { + const result = applyProviderFlag('deepseek', []) + expect(result.error).toBeUndefined() + expect(process.env.CLAUDE_CODE_USE_OPENAI).toBe('1') + expect(process.env.OPENAI_BASE_URL).toBe('https://api.deepseek.com/v1') + expect(process.env.OPENAI_MODEL).toBe('deepseek-v4-pro?reasoning=xhigh') + expect(process.env.OPENAI_API_FORMAT).toBe('chat_completions') + }) + + test('sets OPENAI_MODEL when --model is provided', () => { + applyProviderFlag('deepseek', ['--model', 'deepseek-v4-flash']) + expect(process.env.OPENAI_MODEL).toBe('deepseek-v4-flash') + }) + + test('propagates DEEPSEEK_API_KEY to OPENAI_API_KEY when only DEEPSEEK_API_KEY is set', () => { + delete process.env.OPENAI_API_KEY + process.env.DEEPSEEK_API_KEY = 'deepseek-test-key' + + applyProviderFlag('deepseek', []) + + expect(process.env.OPENAI_API_KEY).toBe('deepseek-test-key') + }) +}) + describe('applyProviderFlag - gemini', () => { test('sets CLAUDE_CODE_USE_GEMINI=1', () => { const result = applyProviderFlag('gemini', []) diff --git a/src/utils/providerFlag.ts b/src/utils/providerFlag.ts index 35193c4c8..8daeb7764 100644 --- a/src/utils/providerFlag.ts +++ b/src/utils/providerFlag.ts @@ -7,6 +7,7 @@ * Usage: * openclaude --provider openai --model gpt-4o * openclaude --provider gemini --model gemini-2.0-flash + * openclaude --provider deepseek --model deepseek-v4-pro?reasoning=xhigh * openclaude --provider mistral --model ministral-3b-latest * openclaude --provider ollama --model llama3.2 * openclaude --provider anthropic (default, no-op) @@ -17,6 +18,7 @@ export const VALID_PROVIDERS = [ 'bankr', 'zai', 'xai', + 'deepseek', 'openai', 'gemini', 'mistral', @@ -103,6 +105,17 @@ export function applyProviderFlag( if (model) process.env.OPENAI_MODEL = model break + case 'deepseek': + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL ??= 'https://api.deepseek.com/v1' + process.env.OPENAI_MODEL ??= 'deepseek-v4-pro?reasoning=xhigh' + process.env.OPENAI_API_FORMAT = 'chat_completions' + if (model) process.env.OPENAI_MODEL = model + if (process.env.DEEPSEEK_API_KEY && !process.env.OPENAI_API_KEY) { + process.env.OPENAI_API_KEY = process.env.DEEPSEEK_API_KEY + } + break + case 'gemini': process.env.CLAUDE_CODE_USE_GEMINI = '1' if (model) process.env.GEMINI_MODEL = model diff --git a/src/utils/providerProfile.test.ts b/src/utils/providerProfile.test.ts index 874797eba..9f879c2ce 100644 --- a/src/utils/providerProfile.test.ts +++ b/src/utils/providerProfile.test.ts @@ -10,6 +10,7 @@ import { buildStartupEnvFromProfile, buildAtomicChatProfileEnv, buildCodexProfileEnv, + buildDeepSeekProfileEnv, buildGeminiProfileEnv, buildLaunchEnv, buildOllamaProfileEnv, @@ -918,6 +919,45 @@ test('openai profiles normalize multi-model profile values to the primary model' }) }) +test('deepseek profiles default to V4 Pro thinking mode without inheriting generic OpenAI shell routing', () => { + const env = buildDeepSeekProfileEnv({ + apiKey: 'deepseek-test-key', + processEnv: { + OPENAI_BASE_URL: 'https://api.openai.com/v1', + OPENAI_MODEL: 'gpt-4o', + }, + }) + + assert.deepEqual(env, { + OPENAI_BASE_URL: 'https://api.deepseek.com/v1', + OPENAI_MODEL: 'deepseek-v4-pro?reasoning=xhigh', + OPENAI_API_FORMAT: 'chat_completions', + OPENAI_API_KEY: 'deepseek-test-key', + DEEPSEEK_API_KEY: 'deepseek-test-key', + }) +}) + +test('deepseek launch env reuses persisted V4 profile values', async () => { + const env = await buildLaunchEnv({ + profile: 'deepseek', + persisted: profile('deepseek', { + OPENAI_BASE_URL: 'https://api.deepseek.com/v1', + OPENAI_MODEL: 'deepseek-v4-pro?reasoning=xhigh', + OPENAI_API_FORMAT: 'chat_completions', + OPENAI_API_KEY: 'deepseek-test-key', + }), + goal: 'balanced', + processEnv: {}, + }) + + assert.equal(env.CLAUDE_CODE_USE_OPENAI, '1') + assert.equal(env.OPENAI_BASE_URL, 'https://api.deepseek.com/v1') + assert.equal(env.OPENAI_MODEL, 'deepseek-v4-pro?reasoning=xhigh') + assert.equal(env.OPENAI_API_FORMAT, 'chat_completions') + assert.equal(env.OPENAI_API_KEY, 'deepseek-test-key') + assert.equal(env.DEEPSEEK_API_KEY, 'deepseek-test-key') +}) + test('startup env ignores poisoned persisted openai model and base url', async () => { const env = await buildStartupEnvFromProfile({ persisted: profile('openai', { diff --git a/src/utils/providerProfile.ts b/src/utils/providerProfile.ts index ecbc2a673..aadeb7594 100644 --- a/src/utils/providerProfile.ts +++ b/src/utils/providerProfile.ts @@ -40,6 +40,8 @@ export const DEFAULT_GEMINI_BASE_URL = export const DEFAULT_GEMINI_MODEL = 'gemini-2.0-flash' export const DEFAULT_MISTRAL_BASE_URL = 'https://api.mistral.ai/v1' export const DEFAULT_MISTRAL_MODEL = 'devstral-latest' +export const DEFAULT_DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1' +export const DEFAULT_DEEPSEEK_MODEL = 'deepseek-v4-pro?reasoning=xhigh' const PROFILE_ENV_KEYS = [ 'CLAUDE_CODE_USE_OPENAI', @@ -56,6 +58,7 @@ const PROFILE_ENV_KEYS = [ 'OPENAI_AUTH_SCHEME', 'OPENAI_AUTH_HEADER_VALUE', 'OPENAI_API_KEY', + 'DEEPSEEK_API_KEY', 'CODEX_API_KEY', 'CODEX_CREDENTIAL_SOURCE', 'CHATGPT_ACCOUNT_ID', @@ -83,6 +86,7 @@ const PROFILE_ENV_KEYS = [ const SECRET_ENV_KEYS = [ 'OPENAI_API_KEY', + 'DEEPSEEK_API_KEY', 'OPENAI_AUTH_HEADER_VALUE', 'CODEX_API_KEY', 'GEMINI_API_KEY', @@ -94,7 +98,7 @@ const SECRET_ENV_KEYS = [ 'XAI_API_KEY', ] as const -export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'nvidia-nim' | 'minimax' | 'mistral' | 'xai' +export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'nvidia-nim' | 'minimax' | 'mistral' | 'deepseek' | 'xai' export type ProfileEnv = { OPENAI_BASE_URL?: string @@ -104,6 +108,7 @@ export type ProfileEnv = { OPENAI_AUTH_SCHEME?: 'bearer' | 'raw' OPENAI_AUTH_HEADER_VALUE?: string OPENAI_API_KEY?: string + DEEPSEEK_API_KEY?: string CODEX_API_KEY?: string CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing' CHATGPT_ACCOUNT_ID?: string @@ -136,6 +141,7 @@ export type ProfileFile = { type SecretValueSource = Partial< Record< | 'OPENAI_API_KEY' + | 'DEEPSEEK_API_KEY' | 'OPENAI_AUTH_HEADER_VALUE' | 'CODEX_API_KEY' | 'GEMINI_API_KEY' @@ -183,6 +189,7 @@ export function isProviderProfile(value: unknown): value is ProviderProfile { value === 'nvidia-nim' || value === 'minimax' || value === 'mistral' || + value === 'deepseek' || value === 'xai' ) } @@ -390,6 +397,40 @@ export function buildOpenAIProfileEnv(options: { } } +export function buildDeepSeekProfileEnv(options: { + model?: string | null + baseUrl?: string | null + apiKey?: string | null + processEnv?: NodeJS.ProcessEnv +}): ProfileEnv | null { + const processEnv = options.processEnv ?? process.env + const key = sanitizeApiKey( + options.apiKey ?? processEnv.DEEPSEEK_API_KEY ?? processEnv.OPENAI_API_KEY, + ) + if (!key) { + return null + } + + const secretSource: SecretValueSource = { + OPENAI_API_KEY: key, + DEEPSEEK_API_KEY: key, + } + + return { + OPENAI_BASE_URL: + sanitizeProviderConfigValue(options.baseUrl, secretSource) || + DEFAULT_DEEPSEEK_BASE_URL, + OPENAI_MODEL: + normalizeProfileModel( + sanitizeProviderConfigValue(options.model, secretSource), + ) || + DEFAULT_DEEPSEEK_MODEL, + OPENAI_API_FORMAT: 'chat_completions', + OPENAI_API_KEY: key, + DEEPSEEK_API_KEY: key, + } +} + export function buildCodexProfileEnv(options: { model?: string | null baseUrl?: string | null @@ -944,6 +985,34 @@ export async function buildLaunchEnv(options: { return env } + if (options.profile === 'deepseek') { + env.OPENAI_BASE_URL = persistedOpenAIBaseUrl || DEFAULT_DEEPSEEK_BASE_URL + env.OPENAI_MODEL = persistedOpenAIModel || DEFAULT_DEEPSEEK_MODEL + env.OPENAI_API_FORMAT = 'chat_completions' + + const deepSeekKey = + sanitizeApiKey(processEnv.DEEPSEEK_API_KEY) || + sanitizeApiKey(processEnv.OPENAI_API_KEY) || + sanitizeApiKey(persistedEnv.DEEPSEEK_API_KEY) || + sanitizeApiKey(persistedEnv.OPENAI_API_KEY) + if (deepSeekKey) { + env.OPENAI_API_KEY = deepSeekKey + env.DEEPSEEK_API_KEY = deepSeekKey + } else { + delete env.OPENAI_API_KEY + delete env.DEEPSEEK_API_KEY + } + + delete env.CODEX_API_KEY + delete env.CHATGPT_ACCOUNT_ID + delete env.CODEX_ACCOUNT_ID + delete env.OPENAI_AUTH_HEADER + delete env.OPENAI_AUTH_SCHEME + delete env.OPENAI_AUTH_HEADER_VALUE + + return env + } + const defaultOpenAIModel = getGoalDefaultOpenAIModel(options.goal) const shellOpenAIRequest = resolveProviderRequest({ model: shellOpenAIModel, diff --git a/src/utils/providerProfiles.test.ts b/src/utils/providerProfiles.test.ts index 1c5e9c98b..f4b564dc8 100644 --- a/src/utils/providerProfiles.test.ts +++ b/src/utils/providerProfiles.test.ts @@ -716,16 +716,16 @@ describe('getProviderPresetDefaults', () => { expect(defaults.baseUrl).toBe('https://api.moonshot.ai/v1') expect(defaults.model).toBe('kimi-k2.5') }) - test('deepseek preset defaults to DeepSeek V4 flash and exposes flash/pro aliases', async () => { + test('deepseek preset defaults to DeepSeek V4 Pro thinking mode and exposes aliases', async () => { const { getProviderPresetDefaults } = await importFreshProviderProfileModules() const defaults = getProviderPresetDefaults('deepseek') expect(defaults.provider).toBe('openai') - expect(defaults.name).toBe('DeepSeek') + expect(defaults.name).toBe('DeepSeek V4') expect(defaults.baseUrl).toBe('https://api.deepseek.com/v1') expect(defaults.model).toBe( - 'deepseek-v4-flash, deepseek-v4-pro, deepseek-chat, deepseek-reasoner', + 'deepseek-v4-pro?reasoning=xhigh, deepseek-v4-flash, deepseek-chat, deepseek-reasoner', ) expect(defaults.requiresApiKey).toBe(true) }) diff --git a/src/utils/providerProfiles.ts b/src/utils/providerProfiles.ts index 45a95f016..a987631e7 100644 --- a/src/utils/providerProfiles.ts +++ b/src/utils/providerProfiles.ts @@ -16,6 +16,8 @@ import { buildGeminiProfileEnv, buildMistralProfileEnv, buildOpenAIProfileEnv, + DEFAULT_DEEPSEEK_BASE_URL, + DEFAULT_DEEPSEEK_MODEL, type ProfileEnv, type ProviderProfile as ProviderProfileStartup, } from './providerProfile.js' @@ -224,9 +226,9 @@ export function getProviderPresetDefaults( case 'deepseek': return { provider: 'openai', - name: 'DeepSeek', - baseUrl: 'https://api.deepseek.com/v1', - model: 'deepseek-v4-flash, deepseek-v4-pro, deepseek-chat, deepseek-reasoner', + name: 'DeepSeek V4', + baseUrl: DEFAULT_DEEPSEEK_BASE_URL, + model: `${DEFAULT_DEEPSEEK_MODEL}, deepseek-v4-flash, deepseek-chat, deepseek-reasoner`, apiKey: '', requiresApiKey: true, }