mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-20 18:42:24 +00:00
fix(ai): preserve Gemini native completion and refusal reasons
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user