fix(ai): preserve Gemini native completion and refusal reasons

This commit is contained in:
404oops authored and GitHub committed 2026-10-16 21:22:01 -07:00
1 parent ed51042820
commit 46d1395715
2 files changed
+209 -11

No files matched your search

@@ -700,6 +700,157 @@ describe('gemini-4-argon streaming', () => {
});
});
describe('gemini-4-argon finish reasons', () => {
const LIVE_METERED = {
prompt_tokens: 36,
completion_tokens: 5,
thinking_tokens: 69,
cached_tokens: 0,
};
const partial = (finishReason: string) => ({
candidates: [
{
content: { role: 'model', parts: [{ text: 'Partial' }] },
finishReason,
},
],
usageMetadata: LIVE_USAGE,
});
// A native reason with no OpenAI analog reaches the caller verbatim.
const REASONS = [
['STOP', 'stop'],
['MAX_TOKENS', 'length'],
['SAFETY', 'content_filter'],
['RECITATION', 'content_filter'],
['BLOCKLIST', 'content_filter'],
['PROHIBITED_CONTENT', 'content_filter'],
['SPII', 'content_filter'],
['MALFORMED_FUNCTION_CALL', 'MALFORMED_FUNCTION_CALL'],
['MISSING_THOUGHT_SIGNATURE', 'MISSING_THOUGHT_SIGNATURE'],
['MALFORMED_RESPONSE', 'MALFORMED_RESPONSE'],
['UNEXPECTED_TOOL_CALL', 'UNEXPECTED_TOOL_CALL'],
['LANGUAGE', 'LANGUAGE'],
['OTHER', 'OTHER'],
['CONTINUATION', 'CONTINUATION'],
['SOME_FUTURE_REASON', 'SOME_FUTURE_REASON'],
] as const;
it.each(REASONS)(
'returns %s as %s with the partial output and its usage',
async (native, expected) => {
generateContent.mockResolvedValueOnce(partial(native));
const result = await complete({});
expect(result).toMatchObject({
message: { role: 'assistant', content: 'Partial' },
finish_reason: expected,
});
expect(recordSpy.mock.calls[0]![0]).toEqual(LIVE_METERED);
},
);
it.each(REASONS)(
'streams %s as %s with the partial output and its usage',
async (native, expected) => {
generateContentStream.mockResolvedValueOnce(
asAsyncIterable([partial(native)]),
);
const { events } = await runStream({});
expect(
events.filter((e) => e.type === 'text').map((e) => e.text),
).toEqual(['Partial']);
expect(events.find((e) => e.type === 'usage')).toMatchObject({
usage: LIVE_METERED,
finish_reason: expected,
});
},
);
});
describe('gemini-4-argon blocked prompts', () => {
const PROMPT_USAGE = { promptTokenCount: 12, totalTokenCount: 12 };
const blocked = (usageMetadata?: object) => ({
promptFeedback: {
blockReason: 'PROHIBITED_CONTENT',
blockReasonMessage: 'internal classifier detail',
},
...(usageMetadata ? { usageMetadata } : {}),
});
it('answers a blocked prompt with an empty content_filter turn and meters its prompt', async () => {
generateContent.mockResolvedValueOnce(blocked(PROMPT_USAGE));
const result = await complete({});
expect(result).toMatchObject({
message: { role: 'assistant', content: '', refusal: null },
finish_reason: 'content_filter',
});
expect(JSON.stringify(result)).not.toMatch(
/internal classifier detail|PROHIBITED_CONTENT/,
);
expect(recordSpy.mock.calls[0]![0]).toEqual({
prompt_tokens: 12,
completion_tokens: 0,
thinking_tokens: 0,
cached_tokens: 0,
});
});
it('answers a blocked prompt without usage and meters nothing', async () => {
generateContent.mockResolvedValueOnce(blocked());
const result = await complete({});
expect(result).toMatchObject({ finish_reason: 'content_filter' });
expect(
recordSpy.mock.calls.flatMap(([usage]) => Object.values(usage)),
).toEqual([]);
});
it.each([
['an unspecified block reason', { blockReason: 'BLOCKED_REASON_UNSPECIFIED' }],
['the API docs spelling of it', { blockReason: 'BLOCK_REASON_UNSPECIFIED' }],
['no prompt feedback', undefined],
])('still rejects a response with no candidates and %s', async (_label, promptFeedback) => {
generateContent.mockResolvedValueOnce({
...(promptFeedback ? { promptFeedback } : {}),
usageMetadata: PROMPT_USAGE,
});
await expect(complete({})).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_response',
});
});
it('streams a blocked prompt as a content_filter end with its usage', async () => {
generateContentStream.mockResolvedValueOnce(
asAsyncIterable([blocked(PROMPT_USAGE)]),
);
const { events, chatStream } = await runStream({});
expect(events.some((e) => e.type === 'text' && e.text)).toBe(false);
expect(events.find((e) => e.type === 'usage')).toMatchObject({
finish_reason: 'content_filter',
stopReason: 'refusal',
});
expect(JSON.stringify(events)).not.toMatch(
/internal classifier detail|PROHIBITED_CONTENT/,
);
expect(chatStream.reportedUsage).toMatchObject({ prompt_tokens: 12 });
});
it('streams a blocked prompt without usage as an unreported content_filter end', async () => {
generateContentStream.mockResolvedValueOnce(
asAsyncIterable([blocked()]),
);
const { events, chatStream } = await runStream({});
expect(events.find((e) => e.type === 'usage')).toMatchObject({
finish_reason: 'content_filter',
});
expect(chatStream.reportedUsage).toBeNull();
});
});
describe('gemini-4-argon tool calls', () => {
it('declares tools, forces a named tool and returns the call with its signature', async () => {
generateContent.mockResolvedValueOnce(
@@ -32,12 +32,15 @@ import type {
ThinkingLevel,
} from '@google/genai';
import { HttpError } from '../../../../core/http/HttpError.js';
import { parseDataUri, unsupportedMediaTextPart } from '../../utils/mediaParts.js';
import {
parseDataUri,
unsupportedMediaTextPart,
} from '../../utils/mediaParts.js';
import { promoteStopForToolCalls } from '../../utils/stopReason.js';
import type { ChatCompletionsClient } from '../OpenAICompatProvider.js';
type ChatContentPart =
| string
| { type?: string; text?: string; image_url?: { url?: string } };
string | { type?: string; text?: string; image_url?: { url?: string } };
type ChatContent = string | ChatContentPart[] | null | undefined;
@@ -154,8 +157,7 @@ const toGeminiContents = (
const parts = contentParts(message.content);
for (const call of message.tool_calls ?? []) {
if (call.id) toolNames.set(call.id, call.function.name);
const signature =
call.extra_content?.google?.thought_signature;
const signature = call.extra_content?.google?.thought_signature;
parts.push({
functionCall: {
...nativeCallId(call.id),
@@ -200,11 +202,7 @@ const toolConfigOf = (
): GenerateContentConfig['toolConfig'] => {
if (toolChoice === undefined) return undefined;
const mode = (
toolChoice === 'auto'
? 'AUTO'
: toolChoice === 'none'
? 'NONE'
: 'ANY'
toolChoice === 'auto' ? 'AUTO' : toolChoice === 'none' ? 'NONE' : 'ANY'
) as FunctionCallingConfigMode;
return {
functionCallingConfig: {
@@ -271,8 +269,12 @@ const FILTERED_FINISH_REASONS = new Set([
'BLOCKLIST',
'PROHIBITED_CONTENT',
'SPII',
'IMAGE_SAFETY',
'IMAGE_PROHIBITED_CONTENT',
'IMAGE_RECITATION',
]);
/** Native finish reason → `finish_reason`; one with no analog passes through. */
const finishReasonOf = (
reason: string | undefined,
sawToolCall: boolean,
@@ -280,7 +282,27 @@ const finishReasonOf = (
if (!reason) return undefined;
if (reason === 'MAX_TOKENS') return 'length';
if (FILTERED_FINISH_REASONS.has(reason)) return 'content_filter';
return sawToolCall ? 'tool_calls' : 'stop';
return promoteStopForToolCalls(
reason === 'STOP' ? 'stop' : reason,
sawToolCall,
'tool_calls',
);
};
// The SDK and the API docs spell the "no reason given" value differently.
const UNSPECIFIED_BLOCK_REASONS = new Set([
'BLOCKED_REASON_UNSPECIFIED',
'BLOCK_REASON_UNSPECIFIED',
]);
/** Google refused the prompt itself, so there is no candidate to read. */
const isPromptBlocked = (response: GenerateContentResponse): boolean => {
const reason = response.promptFeedback?.blockReason;
return (
!response.candidates?.length &&
!!reason &&
!UNSPECIFIED_BLOCK_REASONS.has(reason)
);
};
/** Answer text and tool calls; thought parts are not output and are skipped. */
@@ -319,6 +341,22 @@ const readParts = (parts: Part[] | undefined, nextCallIndex: () => number) => {
};
const toChatCompletion = (response: GenerateContentResponse) => {
if (isPromptBlocked(response)) {
return {
choices: [
{
index: 0,
message: {
role: 'assistant',
content: '',
refusal: null as string | null,
},
finish_reason: 'content_filter',
},
],
usage: response.usageMetadata,
};
}
const candidate = response.candidates?.[0];
let index = 0;
const { text, toolCalls } = readParts(
@@ -362,6 +400,15 @@ async function* toChatChunks(stream: AsyncIterable<GenerateContentResponse>) {
let index = 0;
let sawToolCall = false;
for await (const response of stream) {
if (isPromptBlocked(response)) {
yield {
choices: [
{ index: 0, delta: {}, finish_reason: 'content_filter' },
],
usage: response.usageMetadata,
};
continue;
}
const candidate = response.candidates?.[0];
const { text, toolCalls } = readParts(
candidate?.content?.parts,