Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 60 additions & 12 deletions .github/scripts/deepseek-common.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,50 @@ export function parseJsonObject(text) {
return JSON.parse(text);
} catch {}

const match = text.match(/\{[\s\S]*\}/);
if (!match) {
return null;
}
try {
return JSON.parse(match[0]);
} catch {
return null;
let candidateStart = -1;
let depth = 0;
let escaped = false;
let inString = false;
let lastParsed = null;

for (let index = 0; index < text.length; index += 1) {
const character = text[index];

if (candidateStart === -1) {
if (character === '{') {
candidateStart = index;
depth = 1;
}
continue;
}

if (inString) {
if (escaped) {
escaped = false;
} else if (character === '\\') {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}

if (character === '"') {
inString = true;
} else if (character === '{') {
depth += 1;
} else if (character === '}') {
depth -= 1;
if (depth === 0) {
try {
lastParsed = JSON.parse(text.slice(candidateStart, index + 1));
} catch {}
candidateStart = -1;
}
}
}

return lastParsed;
}

export async function callDeepSeekJson({
Expand Down Expand Up @@ -291,14 +326,26 @@ export async function callDeepSeekJson({
}

const payload = JSON.parse(rawText);
const content = payload.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new Error(`DeepSeek API returned no message content: ${truncate(rawText, 1000, 'response')}`);
const message = payload.choices?.[0]?.message;
const standardContent =
typeof message?.content === 'string' && message.content.trim() ? message.content : null;
const reasoningContent =
typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()
? message.reasoning_content
: null;
const content = standardContent || reasoningContent;

if (!content) {
throw new Error(
`DeepSeek API returned no message or reasoning content: ${truncate(rawText, 1000, 'response')}`
);
}

const parsed = parseJsonObject(content);
if (!parsed || typeof parsed !== 'object') {
throw new Error(`DeepSeek returned non-JSON content: ${truncate(content, 1000, 'model output')}`);
throw new Error(
`DeepSeek returned non-JSON content: ${truncate(content, 1000, 'model output')}`
);
}

return {
Expand All @@ -312,6 +359,7 @@ function isRetryableDeepSeekOutputError(error) {
const message = String(error?.message || error)
return (
message.includes('DeepSeek API returned no message content') ||
message.includes('DeepSeek API returned no message or reasoning content') ||
message.includes('DeepSeek returned non-JSON content') ||
message.includes('Model returned an empty body.')
)
Expand Down
80 changes: 80 additions & 0 deletions tests/deepseek-common.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,83 @@ describe('deepseek-common PR file pagination', () => {
]);
});
});

describe('deepseek-common structured response parsing', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('uses reasoning_content when DeepSeek returns an empty content field', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
choices: [
{
message: {
content: '',
reasoning_content: '{"body":"No findings.\\n\\n*Open Cowork Bot*"}',
role: 'assistant',
},
},
],
usage: { completion_tokens: 128 },
}),
{ status: 200 }
)
);
vi.stubGlobal('fetch', fetchMock);
const { callDeepSeekJson } = await import('../.github/scripts/deepseek-common.mjs');

const result = await callDeepSeekJson({
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.com',
effort: 'high',
model: 'deepseek-v4-flash',
systemPrompt: 'Review the pull request.',
userPrompt: 'Return JSON.',
});

expect(result.parsed).toEqual({
body: 'No findings.\n\n*Open Cowork Bot*',
});
expect(result.content).toContain('No findings.');
});

it('extracts the final JSON object from reasoning prose', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
choices: [
{
message: {
content: null,
reasoning_content: [
'I should return an object such as {"example":true}.',
'The review is complete.',
'{"body":"Review mode: initial\\n\\nNo findings."}',
].join('\n'),
role: 'assistant',
},
},
],
}),
{ status: 200 }
)
);
vi.stubGlobal('fetch', fetchMock);
const { callDeepSeekJson } = await import('../.github/scripts/deepseek-common.mjs');

const result = await callDeepSeekJson({
apiKey: 'test-key',
baseUrl: 'https://api.deepseek.com',
effort: 'high',
model: 'deepseek-v4-flash',
systemPrompt: 'Review the pull request.',
userPrompt: 'Return JSON.',
});

expect(result.parsed).toEqual({
body: 'Review mode: initial\n\nNo findings.',
});
});
});
Loading