mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user