Merge PR #2617: fix(ai): promote DeepSeek reasoning_content when content is empty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-13 11:26:35 -07:00
co-authored by Claude Fable 5
4 changed files with 153 additions and 2 deletions
+9 -2
View File
@@ -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,
};
}
/**
+55
View File
@@ -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'],
+9
View File
@@ -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
+80
View File
@@ -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<string, unknown>): 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<string> {
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('');
});
});