diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index fdcae8779..cdd04509c 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -377,7 +377,11 @@ export function applyOpenAICompatConfig( cfg: AIGatewayConfig, ): { baseURL: string; fetch?: typeof fetch } { if (recipe.resolveOpenAICompatConfig) { - return recipe.resolveOpenAICompatConfig(cfg.env); + const compat = recipe.resolveOpenAICompatConfig(cfg.env); + return { + ...compat, + fetch: compat.fetch ?? recipe.compat?.fetch, + }; } const baseURL = cfg.base_urls?.[recipe.id] ?? recipe.base_url_default; if (!baseURL) { @@ -386,7 +390,10 @@ export function applyOpenAICompatConfig( recipe.setup_hint, ); } - return { baseURL }; + return { + baseURL, + fetch: recipe.compat?.fetch, + }; } /** diff --git a/src/core/ai/recipes/deepseek.ts b/src/core/ai/recipes/deepseek.ts index daa7cf2e9..a8ae6388f 100644 --- a/src/core/ai/recipes/deepseek.ts +++ b/src/core/ai/recipes/deepseek.ts @@ -1,5 +1,57 @@ import type { Recipe } from '../types.ts'; +/** + * DeepSeek-style reasoning models can return the assistant answer in + * `message.reasoning_content` while leaving `message.content` empty. The AI + * SDK's OpenAI-compatible adapter reads only `content`, so promote the field + * before the SDK parses the response. + */ +export const deepseekReasoningContentCompatFetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, +) => { + const resp = await fetch(input, init); + if (!resp.ok) return resp; + const ct = resp.headers.get('content-type') ?? ''; + if (!ct.toLowerCase().includes('application/json')) return resp; + + try { + const json: any = await resp.clone().json(); + if (!json || typeof json !== 'object' || !Array.isArray(json.choices)) { + return resp; + } + + let modified = false; + for (const choice of json.choices) { + const message = choice?.message; + if (!message || typeof message !== 'object') continue; + const content = message.content; + const reasoningContent = message.reasoning_content; + const contentIsEmpty = + content === null || + content === undefined || + (typeof content === 'string' && content.trim().length === 0); + if ( + contentIsEmpty && + typeof reasoningContent === 'string' && + reasoningContent.trim().length > 0 + ) { + message.content = reasoningContent; + modified = true; + } + } + + if (!modified) return resp; + return new Response(JSON.stringify(json), { + status: resp.status, + statusText: resp.statusText, + headers: resp.headers, + }); + } catch { + return resp; + } +}) as unknown as typeof fetch; + /** * DeepSeek exposes an OpenAI-compatible /v1/chat/completions endpoint. * Useful as the second hop in a refusal-fallback chain and for cheap- @@ -16,6 +68,9 @@ export const deepseek: Recipe = { required: ['DEEPSEEK_API_KEY'], setup_url: 'https://platform.deepseek.com/api_keys', }, + compat: { + fetch: deepseekReasoningContentCompatFetch, + }, touchpoints: { chat: { models: ['deepseek-chat', 'deepseek-reasoner'], diff --git a/src/core/ai/types.ts b/src/core/ai/types.ts index 4dced5c6d..6601cb828 100644 --- a/src/core/ai/types.ts +++ b/src/core/ai/types.ts @@ -326,6 +326,15 @@ export interface Recipe { baseURL: string; fetch?: typeof fetch; }; + /** + * Optional OpenAI-compatible transport shims for recipe-specific wire quirks. + * Kept separate from `resolveOpenAICompatConfig()` so a recipe can install a + * static fetch wrapper while preserving the normal `base_urls[recipe.id]` + * override chain. + */ + compat?: { + fetch?: typeof fetch; + }; /** * v0.32 (D13=A): optional runtime readiness check for local-server * recipes (ollama, llama-server, future lmstudio-recipe). Returns diff --git a/test/deepseek-reasoning-content.test.ts b/test/deepseek-reasoning-content.test.ts new file mode 100644 index 000000000..dc211b8e8 --- /dev/null +++ b/test/deepseek-reasoning-content.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + chat, + configureGateway, + resetGateway, +} from '../src/core/ai/gateway.ts'; + +const originalFetch = globalThis.fetch; + +function installDeepSeekResponse(message: Record): void { + globalThis.fetch = (async () => { + const json = { + id: 'fake-deepseek-chatcmpl', + object: 'chat.completion', + created: 0, + model: 'deepseek-reasoner', + choices: [ + { + index: 0, + message: { + role: 'assistant', + ...message, + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }, + }; + return new Response(JSON.stringify(json), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch; +} + +async function runDeepSeekChat(): Promise { + configureGateway({ + chat_model: 'deepseek:deepseek-reasoner', + env: { DEEPSEEK_API_KEY: 'sk-deepseek-fake' }, + }); + const result = await chat({ + model: 'deepseek:deepseek-reasoner', + messages: [{ role: 'user', content: 'summarize this chunk' }], + }); + return result.text; +} + +afterEach(() => { + globalThis.fetch = originalFetch; + resetGateway(); +}); + +describe('DeepSeek reasoning_content compatibility fetch', () => { + test('empty content + reasoning_content promotes reasoning text into chat output', async () => { + installDeepSeekResponse({ + content: '', + reasoning_content: 'The synopsis lives here.', + }); + + await expect(runDeepSeekChat()).resolves.toBe('The synopsis lives here.'); + }); + + test('non-empty content + reasoning_content passes content through unchanged', async () => { + installDeepSeekResponse({ + content: 'Use the final answer.', + reasoning_content: 'Do not duplicate this reasoning.', + }); + + await expect(runDeepSeekChat()).resolves.toBe('Use the final answer.'); + }); + + test('empty content + empty reasoning_content stays empty without crashing', async () => { + installDeepSeekResponse({ + content: '', + reasoning_content: '', + }); + + await expect(runDeepSeekChat()).resolves.toBe(''); + }); +});