forked from boos/BoosAPI
Add comprehensive tests for Anthropics and Gemini transformers
- Implement tests for anthropicMessagesTransformer including serialization and event handling in stream.test.ts. - Add usage extraction tests for anthropicMessagesUsage in usage.test.ts. - Create extensive tests for Gemini generate content aggregation and outbound usage in aggregator-outbound-usage.test.ts. - Introduce tests for Gemini stream handling and payload parsing in stream.test.ts. - Enhance OpenAI response serialization tests to ensure proper mapping of usage details and preservation of annotations in outbound.test.ts.
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import {
|
||||
extractResponseMetadata,
|
||||
serializeGeminiAggregateResponse,
|
||||
} from './outbound.js';
|
||||
import { extractGeminiUsage } from './usage.js';
|
||||
|
||||
describe('Gemini aggregate/outbound/usage', () => {
|
||||
it('coalesces chunked text and thought parts per candidate while preserving tool thought signatures', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
|
||||
applyGeminiGenerateContentAggregate(state, [
|
||||
{
|
||||
responseId: 'resp-coalesce',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'Think ', thought: true, thoughtSignature: 'sig-think-1' },
|
||||
{ text: 'Hello ' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'Alt ', thought: true, thoughtSignature: 'sig-think-2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: 'resp-coalesce',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
groundingMetadata: { webSearchQueries: ['cats'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/cats' }] },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'deeply', thought: true, thoughtSignature: 'sig-think-1' },
|
||||
{ text: 'world' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'tool-call-1',
|
||||
name: 'lookupDocs',
|
||||
args: { query: 'cats' },
|
||||
},
|
||||
thoughtSignature: 'sig-tool-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finishReason: 'MAX_TOKENS',
|
||||
groundingMetadata: { webSearchQueries: ['dogs'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/dogs' }] },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'answer', thought: true, thoughtSignature: 'sig-think-2' },
|
||||
{ text: 'candidate' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||
responseId: 'resp-coalesce',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Think deeply', thought: true, thoughtSignature: 'sig-think-1' },
|
||||
{ text: 'Hello world' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'tool-call-1',
|
||||
name: 'lookupDocs',
|
||||
args: { query: 'cats' },
|
||||
},
|
||||
thoughtSignature: 'sig-tool-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['cats'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/cats' }] },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finishReason: 'MAX_TOKENS',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Alt answer', thought: true, thoughtSignature: 'sig-think-2' },
|
||||
{ text: 'candidate' },
|
||||
],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['dogs'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/dogs' }] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reflows preserved request metadata alongside aggregated grounding, citation, thought, and usage data', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
applyGeminiGenerateContentAggregate(state, {
|
||||
responseId: 'resp-meta',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
groundingMetadata: { webSearchQueries: ['cats'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/cats' }] },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'reasoning', thought: true, thoughtSignature: 'sig-think-1' },
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_1',
|
||||
name: 'lookupWeather',
|
||||
args: { city: 'Paris' },
|
||||
},
|
||||
thoughtSignature: 'sig-tool-1',
|
||||
},
|
||||
{ text: 'final answer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 15,
|
||||
candidatesTokenCount: 5,
|
||||
totalTokenCount: 24,
|
||||
cachedContentTokenCount: 2,
|
||||
thoughtsTokenCount: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const metadata = extractResponseMetadata(state, {
|
||||
systemInstruction: { role: 'system', parts: [{ text: 'stay concise' }] },
|
||||
cachedContent: 'cached/item-1',
|
||||
safetySettings: [{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_LOW_AND_ABOVE' }],
|
||||
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
|
||||
generationConfig: {
|
||||
stopSequences: ['END'],
|
||||
responseModalities: ['TEXT', 'AUDIO'],
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: { type: 'object', properties: { answer: { type: 'string' } } },
|
||||
candidateCount: 2,
|
||||
temperature: 0.3,
|
||||
topP: 0.8,
|
||||
topK: 20,
|
||||
presencePenalty: 0.1,
|
||||
frequencyPenalty: 0.2,
|
||||
seed: 42,
|
||||
responseLogprobs: true,
|
||||
logprobs: 5,
|
||||
thinkingConfig: { thinkingLevel: 'high', includeThoughts: true },
|
||||
imageConfig: { aspectRatio: '16:9' },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookupWeather',
|
||||
description: 'Lookup weather',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ googleSearch: {} },
|
||||
{ urlContext: {} },
|
||||
{ codeExecution: {} },
|
||||
],
|
||||
});
|
||||
|
||||
expect(metadata).toEqual({
|
||||
systemInstruction: { role: 'system', parts: [{ text: 'stay concise' }] },
|
||||
cachedContent: 'cached/item-1',
|
||||
safetySettings: [{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_LOW_AND_ABOVE' }],
|
||||
toolConfig: { functionCallingConfig: { mode: 'ANY' } },
|
||||
stopSequences: ['END'],
|
||||
responseModalities: ['TEXT', 'AUDIO'],
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: { type: 'object', properties: { answer: { type: 'string' } } },
|
||||
candidateCount: 2,
|
||||
temperature: 0.3,
|
||||
topP: 0.8,
|
||||
topK: 20,
|
||||
presencePenalty: 0.1,
|
||||
frequencyPenalty: 0.2,
|
||||
seed: 42,
|
||||
responseLogprobs: true,
|
||||
logprobs: 5,
|
||||
thinkingConfig: { thinkingLevel: 'high', includeThoughts: true },
|
||||
imageConfig: { aspectRatio: '16:9' },
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookupWeather',
|
||||
description: 'Lookup weather',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ googleSearch: {} },
|
||||
{ urlContext: {} },
|
||||
{ codeExecution: {} },
|
||||
],
|
||||
citations: [{ citations: [{ uri: 'https://example.com/cats' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cats'] }],
|
||||
thoughtSignature: 'sig-think-1',
|
||||
thoughtSignatures: ['sig-think-1', 'sig-tool-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 15,
|
||||
candidatesTokenCount: 5,
|
||||
totalTokenCount: 24,
|
||||
cachedContentTokenCount: 2,
|
||||
thoughtsTokenCount: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('merges split candidate metadata and emits top-level metadata in candidate order', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
applyGeminiGenerateContentAggregate(state, [
|
||||
{
|
||||
responseId: 'resp-metadata-merge',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 1,
|
||||
groundingMetadata: {
|
||||
webSearchQueries: ['dogs'],
|
||||
},
|
||||
citationMetadata: {
|
||||
citations: [{ uri: 'https://example.com/dogs-1' }],
|
||||
},
|
||||
content: {
|
||||
parts: [{ text: 'candidate-1' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
groundingMetadata: {
|
||||
webSearchQueries: ['cats'],
|
||||
},
|
||||
citationMetadata: {
|
||||
citations: [{ uri: 'https://example.com/cats-1' }],
|
||||
},
|
||||
content: {
|
||||
parts: [{ text: 'candidate-0' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: 'resp-metadata-merge',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 1,
|
||||
finishReason: 'STOP',
|
||||
groundingMetadata: {
|
||||
groundingChunks: [{ id: 'dogs-chunk' }],
|
||||
},
|
||||
citationMetadata: {
|
||||
citations: [{ uri: 'https://example.com/dogs-2' }],
|
||||
},
|
||||
content: {
|
||||
parts: [{ text: '-done' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
groundingMetadata: {
|
||||
groundingChunks: [{ id: 'cats-chunk' }],
|
||||
},
|
||||
citationMetadata: {
|
||||
citations: [{ uri: 'https://example.com/cats-2' }],
|
||||
},
|
||||
content: {
|
||||
parts: [{ text: '-done' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const response = serializeGeminiAggregateResponse(state);
|
||||
const metadata = extractResponseMetadata(state);
|
||||
|
||||
expect(response.candidates).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'candidate-0-done' }],
|
||||
},
|
||||
groundingMetadata: {
|
||||
webSearchQueries: ['cats'],
|
||||
groundingChunks: [{ id: 'cats-chunk' }],
|
||||
},
|
||||
citationMetadata: {
|
||||
citations: [
|
||||
{ uri: 'https://example.com/cats-1' },
|
||||
{ uri: 'https://example.com/cats-2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'candidate-1-done' }],
|
||||
},
|
||||
groundingMetadata: {
|
||||
webSearchQueries: ['dogs'],
|
||||
groundingChunks: [{ id: 'dogs-chunk' }],
|
||||
},
|
||||
citationMetadata: {
|
||||
citations: [
|
||||
{ uri: 'https://example.com/dogs-1' },
|
||||
{ uri: 'https://example.com/dogs-2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(metadata.groundingMetadata).toEqual([
|
||||
{
|
||||
webSearchQueries: ['cats'],
|
||||
groundingChunks: [{ id: 'cats-chunk' }],
|
||||
},
|
||||
{
|
||||
webSearchQueries: ['dogs'],
|
||||
groundingChunks: [{ id: 'dogs-chunk' }],
|
||||
},
|
||||
]);
|
||||
expect(metadata.citations).toEqual([
|
||||
{
|
||||
citations: [
|
||||
{ uri: 'https://example.com/cats-1' },
|
||||
{ uri: 'https://example.com/cats-2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
citations: [
|
||||
{ uri: 'https://example.com/dogs-1' },
|
||||
{ uri: 'https://example.com/dogs-2' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves multi-candidate tool thought signatures in serialized Gemini responses', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
applyGeminiGenerateContentAggregate(state, [
|
||||
{
|
||||
responseId: 'resp-multi-tools',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 1,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_2',
|
||||
name: 'lookupDocs',
|
||||
args: { query: 'transformers' },
|
||||
},
|
||||
thoughtSignature: 'sig-candidate-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['transformers'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: 'resp-multi-tools',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'candidate zero answer', thoughtSignature: 'sig-candidate-1' },
|
||||
],
|
||||
},
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/zero' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||
responseId: 'resp-multi-tools',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'candidate zero answer', thoughtSignature: 'sig-candidate-1' }],
|
||||
},
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/zero' }] },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_2',
|
||||
name: 'lookupDocs',
|
||||
args: { query: 'transformers' },
|
||||
},
|
||||
thoughtSignature: 'sig-candidate-2',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['transformers'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers final answer thought signature for scalar metadata while retaining tool signatures in the array', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
applyGeminiGenerateContentAggregate(state, [
|
||||
{
|
||||
responseId: 'resp-thought-order',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call_1',
|
||||
name: 'lookupWeather',
|
||||
args: { city: 'Paris' },
|
||||
},
|
||||
thoughtSignature: 'sig-tool-first',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: 'resp-thought-order',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'reasoning', thought: true, thoughtSignature: 'sig-answer-final' },
|
||||
{ text: 'answer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const metadata = extractResponseMetadata(state);
|
||||
|
||||
expect(metadata.thoughtSignature).toBe('sig-answer-final');
|
||||
expect(metadata.thoughtSignatures).toEqual([
|
||||
'sig-tool-first',
|
||||
'sig-answer-final',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts Gemini usage from aggregate state and serialized response consistently', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
applyGeminiGenerateContentAggregate(state, {
|
||||
usageMetadata: {
|
||||
promptTokenCount: 20,
|
||||
candidatesTokenCount: 7,
|
||||
totalTokenCount: 31,
|
||||
cachedContentTokenCount: 4,
|
||||
thoughtsTokenCount: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const serialized = serializeGeminiAggregateResponse(state);
|
||||
|
||||
expect(extractGeminiUsage(state)).toEqual({
|
||||
promptTokens: 20,
|
||||
completionTokens: 11,
|
||||
totalTokens: 31,
|
||||
cachedTokens: 4,
|
||||
reasoningTokens: 4,
|
||||
});
|
||||
expect(extractGeminiUsage(serialized)).toEqual({
|
||||
promptTokens: 20,
|
||||
completionTokens: 11,
|
||||
totalTokens: 31,
|
||||
cachedTokens: 4,
|
||||
reasoningTokens: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops non-finite Gemini usage values instead of leaking NaN or Infinity into normalized usage', () => {
|
||||
const usage = extractGeminiUsage({
|
||||
usageMetadata: {
|
||||
promptTokenCount: Number.NaN,
|
||||
candidatesTokenCount: 6,
|
||||
totalTokenCount: Number.POSITIVE_INFINITY,
|
||||
cachedContentTokenCount: Number.NaN,
|
||||
thoughtsTokenCount: Number.NEGATIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
|
||||
expect(usage).toEqual({
|
||||
promptTokens: 0,
|
||||
completionTokens: 6,
|
||||
totalTokens: 6,
|
||||
cachedTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps request-preserved metadata stable after serialize -> metadata extraction round-trip', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
applyGeminiGenerateContentAggregate(state, {
|
||||
responseId: 'resp-roundtrip',
|
||||
modelVersion: 'gemini-3.1-pro-preview',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
groundingMetadata: { webSearchQueries: ['roundtrip'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/roundtrip' }] },
|
||||
content: {
|
||||
parts: [
|
||||
{ text: 'thinking', thought: true, thoughtSignature: 'sig-roundtrip' },
|
||||
{ text: 'final answer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 8,
|
||||
candidatesTokenCount: 3,
|
||||
totalTokenCount: 13,
|
||||
cachedContentTokenCount: 1,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const requestPayload = {
|
||||
systemInstruction: { role: 'system', parts: [{ text: 'preserve me' }] },
|
||||
cachedContent: 'cached/roundtrip',
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT', 'AUDIO'],
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: { type: 'object' },
|
||||
thinkingConfig: { thinkingLevel: 'high', includeThoughts: true },
|
||||
},
|
||||
tools: [{ googleSearch: {} }, { codeExecution: {} }],
|
||||
};
|
||||
|
||||
const fromState = extractResponseMetadata(state, requestPayload);
|
||||
const serialized = serializeGeminiAggregateResponse(state);
|
||||
const fromSerialized = extractResponseMetadata(serialized, requestPayload);
|
||||
|
||||
expect(fromSerialized).toEqual(fromState);
|
||||
});
|
||||
});
|
||||
@@ -16,13 +16,78 @@ function cloneJsonValue<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableSerialize(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function mergeJsonArrays(existing: unknown[], incoming: unknown[]): unknown[] {
|
||||
const merged = existing.map((item) => cloneJsonValue(item));
|
||||
const seen = new Set(merged.map((item) => stableSerialize(item)));
|
||||
for (const item of incoming) {
|
||||
const cloned = cloneJsonValue(item);
|
||||
const serialized = stableSerialize(cloned);
|
||||
if (seen.has(serialized)) continue;
|
||||
seen.add(serialized);
|
||||
merged.push(cloned);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeJsonValues(existing: unknown, incoming: unknown): unknown {
|
||||
if (incoming === undefined) return cloneJsonValue(existing);
|
||||
if (existing === undefined) return cloneJsonValue(incoming);
|
||||
if (Array.isArray(existing) && Array.isArray(incoming)) {
|
||||
return mergeJsonArrays(existing, incoming);
|
||||
}
|
||||
if (isRecord(existing) && isRecord(incoming)) {
|
||||
return mergeJsonRecords(existing, incoming);
|
||||
}
|
||||
return cloneJsonValue(incoming);
|
||||
}
|
||||
|
||||
function mergeJsonRecords(existing: GeminiRecord, incoming: GeminiRecord): GeminiRecord {
|
||||
const merged: GeminiRecord = Object.fromEntries(
|
||||
Object.entries(existing).map(([key, value]) => [key, cloneJsonValue(value)]),
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
merged[key] = mergeJsonValues(merged[key], value);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function pushUniqueJson(target: GeminiRecord[], incoming: unknown): void {
|
||||
if (!isRecord(incoming)) return;
|
||||
const serialized = JSON.stringify(incoming);
|
||||
if (target.some((item) => JSON.stringify(item) === serialized)) return;
|
||||
const serialized = stableSerialize(incoming);
|
||||
if (target.some((item) => stableSerialize(item) === serialized)) return;
|
||||
target.push(cloneJsonValue(incoming));
|
||||
}
|
||||
|
||||
function isTextPart(value: unknown): value is GeminiRecord & { text: string } {
|
||||
return isRecord(value) && typeof value.text === 'string';
|
||||
}
|
||||
|
||||
function partComparableShape(value: GeminiRecord & { text: string }): string {
|
||||
const { text: _text, ...rest } = value;
|
||||
return stableSerialize(rest);
|
||||
}
|
||||
|
||||
function appendPart(target: GeminiRecord[], incoming: unknown): void {
|
||||
if (!isRecord(incoming)) return;
|
||||
const next = cloneJsonValue(incoming);
|
||||
if (isTextPart(next)) {
|
||||
for (let index = target.length - 1; index >= 0; index -= 1) {
|
||||
const existing = target[index];
|
||||
if (!isTextPart(existing)) continue;
|
||||
if (partComparableShape(existing) !== partComparableShape(next)) continue;
|
||||
existing.text = `${existing.text}${next.text}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
target.push(next);
|
||||
}
|
||||
|
||||
function collectPartsFromPayload(payload: unknown): GeminiRecord[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.flatMap((item) => collectPartsFromPayload(item));
|
||||
@@ -119,7 +184,7 @@ export function applyGeminiGenerateContentAggregate(
|
||||
payload: unknown,
|
||||
): GeminiGenerateContentAggregateState {
|
||||
for (const part of collectPartsFromPayload(payload)) {
|
||||
state.parts.push(part);
|
||||
appendPart(state.parts, part);
|
||||
if (typeof part.thoughtSignature === 'string' && part.thoughtSignature.trim()) {
|
||||
if (!state.thoughtSignatures.includes(part.thoughtSignature)) {
|
||||
state.thoughtSignatures.push(part.thoughtSignature);
|
||||
@@ -145,13 +210,17 @@ export function applyGeminiGenerateContentAggregate(
|
||||
if (candidate.groundingMetadata !== undefined) {
|
||||
pushUniqueJson(state.groundingMetadata, candidate.groundingMetadata);
|
||||
if (isRecord(candidate.groundingMetadata)) {
|
||||
candidateAggregate.groundingMetadata = cloneJsonValue(candidate.groundingMetadata);
|
||||
candidateAggregate.groundingMetadata = candidateAggregate.groundingMetadata
|
||||
? mergeJsonRecords(candidateAggregate.groundingMetadata, candidate.groundingMetadata)
|
||||
: cloneJsonValue(candidate.groundingMetadata);
|
||||
}
|
||||
}
|
||||
if (candidate.citationMetadata !== undefined) {
|
||||
pushUniqueJson(state.citations, candidate.citationMetadata);
|
||||
if (isRecord(candidate.citationMetadata)) {
|
||||
candidateAggregate.citationMetadata = cloneJsonValue(candidate.citationMetadata);
|
||||
candidateAggregate.citationMetadata = candidateAggregate.citationMetadata
|
||||
? mergeJsonRecords(candidateAggregate.citationMetadata, candidate.citationMetadata)
|
||||
: cloneJsonValue(candidate.citationMetadata);
|
||||
}
|
||||
}
|
||||
if (typeof candidate.finishReason === 'string' && candidate.finishReason.trim()) {
|
||||
@@ -163,7 +232,7 @@ export function applyGeminiGenerateContentAggregate(
|
||||
if (content && Array.isArray(content.parts)) {
|
||||
for (const part of content.parts) {
|
||||
if (!isRecord(part)) continue;
|
||||
candidateAggregate.parts.push(cloneJsonValue(part));
|
||||
appendPart(candidateAggregate.parts, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,20 @@ export type GeminiReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'max';
|
||||
type GeminiThinkingConfig = {
|
||||
thinkingBudget?: number;
|
||||
thinkingLevel?: string;
|
||||
includeThoughts?: boolean;
|
||||
};
|
||||
|
||||
const GEMINI_3_STANDARD_BUDGETS: ReadonlyMap<number, GeminiReasoningEffort> = new Map([
|
||||
[0, 'none'],
|
||||
[1024, 'low'],
|
||||
[8192, 'medium'],
|
||||
[32768, 'high'],
|
||||
]);
|
||||
|
||||
function isGemini3Model(model: string): boolean {
|
||||
return /gemini-3(?:[.-]|$)/i.test(model);
|
||||
}
|
||||
|
||||
function normalizeReasoningEffort(value: unknown): GeminiReasoningEffort {
|
||||
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
if (
|
||||
@@ -43,10 +55,14 @@ function thinkingBudgetToReasoningEffort(budget: number): GeminiReasoningEffort
|
||||
}
|
||||
|
||||
function shouldUseThinkingLevel(model: string, effort: GeminiReasoningEffort): boolean {
|
||||
if (!/gemini-3-/i.test(model)) return false;
|
||||
if (!isGemini3Model(model)) return false;
|
||||
return effort === 'none' || effort === 'low' || effort === 'medium' || effort === 'high';
|
||||
}
|
||||
|
||||
function shouldUseThinkingLevelForBudget(model: string, budget: number): boolean {
|
||||
return isGemini3Model(model) && GEMINI_3_STANDARD_BUDGETS.has(budget);
|
||||
}
|
||||
|
||||
function reasoningEffortToThinkingBudget(effort: GeminiReasoningEffort): number {
|
||||
switch (effort) {
|
||||
case 'none':
|
||||
@@ -74,6 +90,23 @@ export function reasoningEffortToGeminiThinkingConfig(
|
||||
return { thinkingBudget: reasoningEffortToThinkingBudget(effort) };
|
||||
}
|
||||
|
||||
export function thinkingBudgetToGeminiThinkingConfig(
|
||||
model: string,
|
||||
thinkingBudget: unknown,
|
||||
): GeminiThinkingConfig | null {
|
||||
const budget = toFiniteInt(thinkingBudget);
|
||||
if (budget === null) return null;
|
||||
if (shouldUseThinkingLevelForBudget(model, budget)) {
|
||||
const standardEffort = GEMINI_3_STANDARD_BUDGETS.get(budget);
|
||||
return {
|
||||
thinkingLevel: standardEffort ?? thinkingBudgetToReasoningEffort(budget),
|
||||
};
|
||||
}
|
||||
return {
|
||||
thinkingBudget: budget,
|
||||
};
|
||||
}
|
||||
|
||||
export function geminiThinkingConfigToReasoning(
|
||||
thinkingConfig: Record<string, unknown> | null | undefined,
|
||||
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
|
||||
@@ -108,6 +141,16 @@ function toFiniteInt(value: unknown): number | null {
|
||||
return Math.max(0, Math.trunc(numeric));
|
||||
}
|
||||
|
||||
function withSynthesizedThoughts(
|
||||
thinkingConfig: GeminiThinkingConfig | null,
|
||||
): GeminiThinkingConfig | null {
|
||||
if (!thinkingConfig) return null;
|
||||
return {
|
||||
...thinkingConfig,
|
||||
includeThoughts: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGeminiThinkingConfigFromRequest(
|
||||
model: string,
|
||||
body: Record<string, unknown>,
|
||||
@@ -124,7 +167,7 @@ export function resolveGeminiThinkingConfigFromRequest(
|
||||
return { thinkingLevel: nativeLevel };
|
||||
}
|
||||
if (nativeBudget !== null) {
|
||||
return { thinkingBudget: nativeBudget };
|
||||
return thinkingBudgetToGeminiThinkingConfig(model, nativeBudget);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,11 +185,11 @@ export function resolveGeminiThinkingConfigFromRequest(
|
||||
|
||||
const parsedBudget = toFiniteInt(reasoningBudget);
|
||||
if (parsedBudget !== null) {
|
||||
return reasoningEffortToGeminiThinkingConfig(model, thinkingBudgetToReasoningEffort(parsedBudget));
|
||||
return withSynthesizedThoughts(thinkingBudgetToGeminiThinkingConfig(model, parsedBudget));
|
||||
}
|
||||
|
||||
if (reasoningEffort !== undefined) {
|
||||
return reasoningEffortToGeminiThinkingConfig(model, reasoningEffort);
|
||||
return withSynthesizedThoughts(reasoningEffortToGeminiThinkingConfig(model, reasoningEffort));
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -161,5 +204,13 @@ export function extractReasoningMetadataFromGeminiRequest(
|
||||
const thinkingConfig = isRecord(generationConfig?.thinkingConfig)
|
||||
? generationConfig!.thinkingConfig as Record<string, unknown>
|
||||
: null;
|
||||
return geminiThinkingConfigToReasoning(thinkingConfig);
|
||||
if (thinkingConfig) {
|
||||
return geminiThinkingConfigToReasoning(thinkingConfig);
|
||||
}
|
||||
|
||||
const synthesizedThinkingConfig = resolveGeminiThinkingConfigFromRequest(
|
||||
typeof body.model === 'string' ? body.model : '',
|
||||
body,
|
||||
);
|
||||
return geminiThinkingConfigToReasoning(synthesizedThinkingConfig ?? null);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
||||
import {
|
||||
geminiThinkingConfigToReasoning,
|
||||
resolveGeminiThinkingConfigFromRequest,
|
||||
} from './convert.js';
|
||||
|
||||
function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -30,6 +33,37 @@ function cloneContents(value: unknown): unknown[] | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
function cloneThinkingConfig(value: unknown): GeminiRecord | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const next: GeminiRecord = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === 'thinkingLevel') {
|
||||
const normalizedLevel = typeof item === 'string' ? item.trim().toLowerCase() : '';
|
||||
if (normalizedLevel === 'minimal') {
|
||||
next.thinkingLevel = 'low';
|
||||
continue;
|
||||
}
|
||||
if (normalizedLevel) {
|
||||
next.thinkingLevel = normalizedLevel;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'thinkingBudget') {
|
||||
const numeric = typeof item === 'number' ? item : Number(item);
|
||||
if (Number.isFinite(numeric)) {
|
||||
next.thinkingBudget = Math.max(0, Math.trunc(numeric));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
next[key] = cloneJsonValue(item);
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function cloneGenerationConfig(value: unknown): GeminiRecord | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const allowedKeys = [
|
||||
@@ -52,7 +86,12 @@ function cloneGenerationConfig(value: unknown): GeminiRecord | undefined {
|
||||
];
|
||||
const next: GeminiRecord = {};
|
||||
for (const key of allowedKeys) {
|
||||
if (value[key] !== undefined) next[key] = cloneJsonValue(value[key]);
|
||||
if (value[key] === undefined) continue;
|
||||
if (key === 'thinkingConfig') {
|
||||
next[key] = cloneThinkingConfig(value[key]) ?? cloneJsonValue(value[key]);
|
||||
continue;
|
||||
}
|
||||
next[key] = cloneJsonValue(value[key]);
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
@@ -71,6 +110,55 @@ function cloneTools(value: unknown): unknown[] | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
function hasMeaningfulThinkingConfig(value: unknown): boolean {
|
||||
return isRecord(value) && geminiThinkingConfigToReasoning(value) !== null;
|
||||
}
|
||||
|
||||
function sanitizeThinkingConfig(value: unknown): GeminiRecord | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const next = cloneJsonValue(value);
|
||||
const normalizedReasoning = geminiThinkingConfigToReasoning(next);
|
||||
if (normalizedReasoning) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if ('thinkingLevel' in next) {
|
||||
delete next.thinkingLevel;
|
||||
}
|
||||
|
||||
if ('thinkingBudget' in next) {
|
||||
const numeric = typeof next.thinkingBudget === 'number' ? next.thinkingBudget : Number(next.thinkingBudget);
|
||||
if (Number.isFinite(numeric)) {
|
||||
next.thinkingBudget = Math.max(0, Math.trunc(numeric));
|
||||
} else {
|
||||
delete next.thinkingBudget;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function mergeThinkingConfig(
|
||||
currentValue: unknown,
|
||||
derivedThinkingConfig: GeminiRecord | null,
|
||||
): GeminiRecord | undefined {
|
||||
const currentThinkingConfig = sanitizeThinkingConfig(currentValue);
|
||||
|
||||
if (!derivedThinkingConfig) {
|
||||
return currentThinkingConfig;
|
||||
}
|
||||
|
||||
if (!hasMeaningfulThinkingConfig(currentThinkingConfig)) {
|
||||
return {
|
||||
...(currentThinkingConfig ?? {}),
|
||||
...cloneJsonValue(derivedThinkingConfig),
|
||||
};
|
||||
}
|
||||
|
||||
return currentThinkingConfig;
|
||||
}
|
||||
|
||||
export type GeminiGenerateContentRequest = GeminiRecord;
|
||||
|
||||
export const geminiGenerateContentInbound = {
|
||||
@@ -96,8 +184,12 @@ export const geminiGenerateContentInbound = {
|
||||
const generationConfig = isRecord(next.generationConfig)
|
||||
? { ...next.generationConfig }
|
||||
: {};
|
||||
if (!isRecord(generationConfig.thinkingConfig)) {
|
||||
generationConfig.thinkingConfig = derivedThinkingConfig;
|
||||
const thinkingConfig = mergeThinkingConfig(
|
||||
generationConfig.thinkingConfig,
|
||||
derivedThinkingConfig,
|
||||
);
|
||||
if (thinkingConfig) {
|
||||
generationConfig.thinkingConfig = thinkingConfig;
|
||||
}
|
||||
next.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import {
|
||||
} from './index.js';
|
||||
import { extractGeminiUsage } from './usage.js';
|
||||
import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js';
|
||||
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
||||
import {
|
||||
parseGeminiStreamPayload,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
} from './stream.js';
|
||||
|
||||
describe('geminiGenerateContentTransformer.inbound', () => {
|
||||
it('preserves native Gemini request fields through normalization', () => {
|
||||
@@ -96,6 +102,130 @@ describe('geminiGenerateContentTransformer.inbound', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses thinkingLevel for Gemini 3 reasoning budgets within standard ranges and preserves very high budgets', () => {
|
||||
const standardBudget = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
generationConfig: {},
|
||||
reasoning_budget: 1024,
|
||||
},
|
||||
'gemini-3-pro',
|
||||
);
|
||||
|
||||
expect(standardBudget.generationConfig).toEqual({
|
||||
thinkingConfig: {
|
||||
thinkingLevel: 'low',
|
||||
includeThoughts: true,
|
||||
},
|
||||
});
|
||||
|
||||
const higherBudget = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
generationConfig: {},
|
||||
reasoning: {
|
||||
budget_tokens: 12000,
|
||||
},
|
||||
},
|
||||
'gemini-3-pro',
|
||||
);
|
||||
|
||||
expect(higherBudget.generationConfig).toEqual({
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 12000,
|
||||
includeThoughts: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers thinkingLevel for Gemini 3.1 standard reasoning budgets and preserves non-standard budgets', () => {
|
||||
const standardBudgetBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
generationConfig: {},
|
||||
reasoning: {
|
||||
budget_tokens: 1024,
|
||||
},
|
||||
},
|
||||
'gemini-3.1-pro-preview',
|
||||
);
|
||||
|
||||
expect(standardBudgetBody.generationConfig).toEqual({
|
||||
thinkingConfig: {
|
||||
thinkingLevel: 'low',
|
||||
includeThoughts: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nonStandardBudgetBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
generationConfig: {},
|
||||
reasoning: {
|
||||
budget_tokens: 50000,
|
||||
},
|
||||
},
|
||||
'gemini-3-flash',
|
||||
);
|
||||
|
||||
expect(nonStandardBudgetBody.generationConfig).toEqual({
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 50000,
|
||||
includeThoughts: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes native thinking config and keeps request metadata stable when injecting derived thinking config', () => {
|
||||
const body = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
model: 'gemini-3-pro',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
systemInstruction: {
|
||||
role: 'system',
|
||||
parts: [{ text: 'system prompt' }],
|
||||
},
|
||||
cachedContent: 'cached/abc',
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT', 'AUDIO'],
|
||||
responseMimeType: 'application/json',
|
||||
thinkingConfig: {
|
||||
thinkingLevel: 'minimal',
|
||||
},
|
||||
},
|
||||
tools: [
|
||||
{ googleSearch: {} },
|
||||
{ urlContext: {} },
|
||||
{ codeExecution: {} },
|
||||
],
|
||||
reasoning_effort: 'high',
|
||||
reasoning_budget: 4096,
|
||||
},
|
||||
'gemini-3-pro',
|
||||
);
|
||||
|
||||
expect(body.systemInstruction).toEqual({
|
||||
role: 'system',
|
||||
parts: [{ text: 'system prompt' }],
|
||||
});
|
||||
expect(body.cachedContent).toBe('cached/abc');
|
||||
expect(body.tools).toEqual([
|
||||
{ googleSearch: {} },
|
||||
{ urlContext: {} },
|
||||
{ codeExecution: {} },
|
||||
]);
|
||||
expect(body.generationConfig).toEqual({
|
||||
responseModalities: ['TEXT', 'AUDIO'],
|
||||
responseMimeType: 'application/json',
|
||||
thinkingConfig: {
|
||||
thinkingLevel: 'low',
|
||||
},
|
||||
});
|
||||
expect(body.reasoning_effort).toBeUndefined();
|
||||
expect(body.reasoning_budget).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('geminiGenerateContentTransformer.aggregator', () => {
|
||||
@@ -302,6 +432,127 @@ describe('geminiGenerateContentTransformer.aggregator', () => {
|
||||
{ codeExecution: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves tool thought signatures and request-side Gemini metadata in outbound helpers', () => {
|
||||
const state = geminiGenerateContentTransformer.aggregator.createState();
|
||||
geminiGenerateContentTransformer.aggregator.apply(state, {
|
||||
responseId: 'resp-tools',
|
||||
modelVersion: 'gemini-3-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call-1',
|
||||
name: 'lookup',
|
||||
args: { query: 'cat' },
|
||||
},
|
||||
thoughtSignature: 'tool-sig-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/cat' }] },
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 12,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 18,
|
||||
cachedContentTokenCount: 6,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||
responseId: 'resp-tools',
|
||||
modelVersion: 'gemini-3-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
id: 'call-1',
|
||||
name: 'lookup',
|
||||
args: { query: 'cat' },
|
||||
},
|
||||
thoughtSignature: 'tool-sig-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/cat' }] },
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 12,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 18,
|
||||
cachedContentTokenCount: 6,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(extractResponseMetadata(state, {
|
||||
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
|
||||
cachedContent: 'cached/tooling',
|
||||
safetySettings: [{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: ['lookup'],
|
||||
},
|
||||
},
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
responseSchema: { type: 'object' },
|
||||
responseMimeType: 'application/json',
|
||||
thinkingConfig: { thinkingLevel: 'high' },
|
||||
imageConfig: { aspectRatio: '1:1' },
|
||||
},
|
||||
tools: [
|
||||
{ functionDeclarations: [{ name: 'lookup', description: 'find facts' }] },
|
||||
{ googleSearch: {} },
|
||||
],
|
||||
})).toEqual({
|
||||
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
|
||||
cachedContent: 'cached/tooling',
|
||||
safetySettings: [{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_ONLY_HIGH' }],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: ['lookup'],
|
||||
},
|
||||
},
|
||||
responseModalities: ['TEXT'],
|
||||
responseSchema: { type: 'object' },
|
||||
responseMimeType: 'application/json',
|
||||
thinkingConfig: { thinkingLevel: 'high' },
|
||||
imageConfig: { aspectRatio: '1:1' },
|
||||
tools: [
|
||||
{ functionDeclarations: [{ name: 'lookup', description: 'find facts' }] },
|
||||
{ googleSearch: {} },
|
||||
],
|
||||
citations: [{ citations: [{ uri: 'https://example.com/cat' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
thoughtSignature: 'tool-sig-1',
|
||||
thoughtSignatures: ['tool-sig-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 12,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 18,
|
||||
cachedContentTokenCount: 6,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractGeminiUsage', () => {
|
||||
@@ -350,6 +601,44 @@ describe('Gemini reasoning mapping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses thinkingLevel for Gemini 3 budget values up to the high tier and preserves very high budgets', () => {
|
||||
expect(resolveGeminiThinkingConfigFromRequest('gemini-3-pro', {
|
||||
reasoning_budget: 8192,
|
||||
})).toEqual({
|
||||
thinkingLevel: 'medium',
|
||||
includeThoughts: true,
|
||||
});
|
||||
|
||||
expect(resolveGeminiThinkingConfigFromRequest('gemini-3-pro', {
|
||||
reasoning_budget: 12000,
|
||||
})).toEqual({
|
||||
thinkingBudget: 12000,
|
||||
includeThoughts: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('injects derived thinking config into normalized requests when runtime model name is provided', () => {
|
||||
const body = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
thinkingConfig: {},
|
||||
},
|
||||
reasoning_effort: 'high',
|
||||
},
|
||||
'gemini-3-pro',
|
||||
);
|
||||
|
||||
expect(body.generationConfig).toEqual({
|
||||
responseModalities: ['TEXT'],
|
||||
thinkingConfig: {
|
||||
thinkingLevel: 'high',
|
||||
includeThoughts: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Gemini thinking config back to normalized reasoning hints', () => {
|
||||
expect(geminiThinkingConfigToReasoning({
|
||||
thinkingLevel: 'medium',
|
||||
@@ -365,6 +654,132 @@ describe('Gemini reasoning mapping', () => {
|
||||
reasoningBudget: 512,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses thinkingLevel for Gemini 3 models when reasoning_budget is a standard tier', () => {
|
||||
const normalized = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
reasoning_budget: 8192,
|
||||
},
|
||||
'gemini-3-flash',
|
||||
);
|
||||
|
||||
expect(normalized.generationConfig).toEqual({
|
||||
thinkingConfig: {
|
||||
thinkingLevel: 'medium',
|
||||
includeThoughts: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves non-standard Gemini 3 reasoning_budget values instead of coercing them to a tier', () => {
|
||||
const normalized = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
reasoning_budget: 5000,
|
||||
},
|
||||
'gemini-3-flash',
|
||||
);
|
||||
|
||||
expect(normalized.generationConfig).toEqual({
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 5000,
|
||||
includeThoughts: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('fills missing native thinkingConfig from reasoning_effort while preserving request metadata', () => {
|
||||
const normalized = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
cachedContent: 'cached/abc',
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
},
|
||||
},
|
||||
reasoning_effort: 'high',
|
||||
},
|
||||
'gemini-3-flash',
|
||||
);
|
||||
|
||||
expect(normalized.cachedContent).toBe('cached/abc');
|
||||
expect(normalized.generationConfig).toEqual({
|
||||
responseModalities: ['TEXT'],
|
||||
thinkingConfig: {
|
||||
includeThoughts: true,
|
||||
thinkingLevel: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves non-standard explicit Gemini 3 budgets instead of coercing them to a standard effort level', () => {
|
||||
expect(
|
||||
geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
reasoning_budget: 50000,
|
||||
},
|
||||
'gemini-3-pro',
|
||||
),
|
||||
).toEqual({
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 50000,
|
||||
includeThoughts: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gemini convert/inbound runtime strategy', () => {
|
||||
it('uses thinkingLevel only for standard Gemini 3 budget tiers and preserves non-standard budgets', () => {
|
||||
expect(resolveGeminiThinkingConfigFromRequest('gemini-3-pro', {
|
||||
reasoning_budget: 1024,
|
||||
})).toEqual({
|
||||
thinkingLevel: 'low',
|
||||
includeThoughts: true,
|
||||
});
|
||||
|
||||
expect(resolveGeminiThinkingConfigFromRequest('gemini-3-pro', {
|
||||
reasoning_budget: 512,
|
||||
})).toEqual({
|
||||
thinkingBudget: 512,
|
||||
includeThoughts: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the runtime model name when injecting thinkingConfig and keeps request metadata stable', () => {
|
||||
const normalized = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
{
|
||||
model: 'gemini-2.5-flash',
|
||||
cachedContent: 'cached/runtime',
|
||||
systemInstruction: {
|
||||
role: 'system',
|
||||
parts: [{ text: 'system prompt' }],
|
||||
},
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
},
|
||||
reasoning_budget: 512,
|
||||
},
|
||||
'gemini-3-pro',
|
||||
);
|
||||
|
||||
expect(normalized.cachedContent).toBe('cached/runtime');
|
||||
expect(normalized.systemInstruction).toEqual({
|
||||
role: 'system',
|
||||
parts: [{ text: 'system prompt' }],
|
||||
});
|
||||
expect(normalized.generationConfig).toEqual({
|
||||
responseModalities: ['TEXT'],
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
includeThoughts: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('geminiGenerateContentTransformer.stream', () => {
|
||||
@@ -436,4 +851,91 @@ describe('geminiGenerateContentTransformer.stream', () => {
|
||||
extractGeminiUsage(jsonState),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes SSE and JSON-array payloads through the same parse/apply boundary', () => {
|
||||
const chunks = [
|
||||
{
|
||||
responseId: 'resp-boundary',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
parts: [{ text: 'hello', thought: true, thoughtSignature: 'sig-a' }],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://example.com/a' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: 'resp-boundary',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [{ text: 'world' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
cachedContentTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const sseInput = chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join('');
|
||||
const parsedSse = parseGeminiStreamPayload(sseInput, 'text/event-stream');
|
||||
const parsedJson = parseGeminiStreamPayload(chunks, 'application/json');
|
||||
|
||||
expect(parsedSse.events).toEqual(parsedJson.events);
|
||||
expect(parsedSse.rest).toBe('');
|
||||
expect(parsedJson.rest).toBe('');
|
||||
|
||||
const sseState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||
const jsonState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||
geminiGenerateContentTransformer.stream.applyParsedPayloadToAggregate(sseState, parsedSse);
|
||||
geminiGenerateContentTransformer.stream.applyParsedPayloadToAggregate(jsonState, parsedJson);
|
||||
|
||||
expect(serializeGeminiAggregateResponse(sseState)).toEqual(
|
||||
serializeGeminiAggregateResponse(jsonState),
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes aggregate payloads with identical final semantics for SSE and JSON modes', () => {
|
||||
const state = geminiGenerateContentTransformer.aggregator.createState();
|
||||
geminiGenerateContentTransformer.aggregator.apply(state, {
|
||||
responseId: 'resp-serialize',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [{ text: 'answer', thoughtSignature: 'sig-final' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 9,
|
||||
candidatesTokenCount: 5,
|
||||
totalTokenCount: 14,
|
||||
cachedContentTokenCount: 4,
|
||||
thoughtsTokenCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const jsonPayload = serializeAggregateJsonPayload(state);
|
||||
const ssePayload = serializeAggregatePayload(state, 'sse');
|
||||
const reparsedSse = parseGeminiStreamPayload(ssePayload, 'text/event-stream');
|
||||
|
||||
expect(jsonPayload).toEqual(serializeGeminiAggregateResponse(state));
|
||||
expect(reparsedSse.events).toEqual([jsonPayload]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,18 @@ function isRecord(value: unknown): value is GeminiRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
|
||||
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
|
||||
}
|
||||
@@ -112,18 +124,92 @@ function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiReco
|
||||
return [fallback];
|
||||
}
|
||||
|
||||
function extractOrderedCandidateMetadata(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
key: 'groundingMetadata' | 'citationMetadata',
|
||||
): GeminiRecord[] {
|
||||
if (state.candidates.length > 0) {
|
||||
return state.candidates
|
||||
.slice()
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate[key])
|
||||
.filter((item): item is GeminiRecord => isRecord(item))
|
||||
.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
const fallback = key === 'groundingMetadata' ? state.groundingMetadata : state.citations;
|
||||
return fallback.map((item) => cloneJsonValue(item));
|
||||
}
|
||||
|
||||
function extractOrderedThoughtSignatures(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
): {
|
||||
all: string[];
|
||||
preferred: string | undefined;
|
||||
} {
|
||||
const ordered = new Set<string>();
|
||||
const preferredThoughts = new Set<string>();
|
||||
|
||||
const candidates = state.candidates.length > 0
|
||||
? state.candidates.slice().sort((left, right) => left.index - right.index)
|
||||
: [{ index: 0, finishReason: state.finishReason, parts: state.parts }];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
for (const part of candidate.parts) {
|
||||
if (!isRecord(part) || typeof part.thoughtSignature !== 'string' || !part.thoughtSignature.trim()) {
|
||||
continue;
|
||||
}
|
||||
ordered.add(part.thoughtSignature);
|
||||
if (part.thought === true) {
|
||||
preferredThoughts.add(part.thoughtSignature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedList = [...ordered];
|
||||
const preferred = [...preferredThoughts][0]
|
||||
?? (orderedList.length === 1 ? orderedList[0] : undefined);
|
||||
|
||||
return {
|
||||
all: orderedList.length > 0 ? orderedList : [...state.thoughtSignatures],
|
||||
preferred,
|
||||
};
|
||||
}
|
||||
|
||||
function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
||||
if (!isRecord(requestPayload)) return {};
|
||||
|
||||
const metadata: GeminiRecord = {};
|
||||
if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = requestPayload.systemInstruction;
|
||||
if (requestPayload.cachedContent !== undefined) metadata.cachedContent = requestPayload.cachedContent;
|
||||
if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||
if (requestPayload.cachedContent !== undefined) metadata.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||
if (requestPayload.safetySettings !== undefined) metadata.safetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||
if (requestPayload.toolConfig !== undefined) metadata.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||
|
||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||
if (generationConfig) {
|
||||
if (generationConfig.responseModalities !== undefined) metadata.responseModalities = generationConfig.responseModalities;
|
||||
if (generationConfig.responseSchema !== undefined) metadata.responseSchema = generationConfig.responseSchema;
|
||||
if (generationConfig.responseMimeType !== undefined) metadata.responseMimeType = generationConfig.responseMimeType;
|
||||
const preservedKeys = [
|
||||
'stopSequences',
|
||||
'responseModalities',
|
||||
'responseMimeType',
|
||||
'responseSchema',
|
||||
'candidateCount',
|
||||
'maxOutputTokens',
|
||||
'temperature',
|
||||
'topP',
|
||||
'topK',
|
||||
'presencePenalty',
|
||||
'frequencyPenalty',
|
||||
'seed',
|
||||
'responseLogprobs',
|
||||
'logprobs',
|
||||
'thinkingConfig',
|
||||
'imageConfig',
|
||||
];
|
||||
for (const key of preservedKeys) {
|
||||
if (generationConfig[key] !== undefined) {
|
||||
metadata[key] = cloneJsonValue(generationConfig[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(requestPayload.tools)) {
|
||||
@@ -131,10 +217,10 @@ function extractRequestSemantics(requestPayload: unknown): GeminiRecord {
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
if (item.googleSearch !== undefined) next.googleSearch = item.googleSearch;
|
||||
if (item.urlContext !== undefined) next.urlContext = item.urlContext;
|
||||
if (item.codeExecution !== undefined) next.codeExecution = item.codeExecution;
|
||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = item.functionDeclarations;
|
||||
if (item.googleSearch !== undefined) next.googleSearch = cloneJsonValue(item.googleSearch);
|
||||
if (item.urlContext !== undefined) next.urlContext = cloneJsonValue(item.urlContext);
|
||||
if (item.codeExecution !== undefined) next.codeExecution = cloneJsonValue(item.codeExecution);
|
||||
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(item.functionDeclarations);
|
||||
return next;
|
||||
})
|
||||
.filter((item) => Object.keys(item).length > 0);
|
||||
@@ -169,11 +255,17 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno
|
||||
const state = ensureAggregateState(payload);
|
||||
const metadata: GeminiRecord = extractRequestSemantics(requestPayload);
|
||||
|
||||
if (state.citations.length > 0) metadata.citations = state.citations;
|
||||
if (state.groundingMetadata.length > 0) metadata.groundingMetadata = state.groundingMetadata;
|
||||
if (state.thoughtSignatures.length > 0) {
|
||||
metadata.thoughtSignature = state.thoughtSignatures[0];
|
||||
metadata.thoughtSignatures = state.thoughtSignatures;
|
||||
const citations = extractOrderedCandidateMetadata(state, 'citationMetadata');
|
||||
const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata');
|
||||
const thoughtSignatures = extractOrderedThoughtSignatures(state);
|
||||
|
||||
if (citations.length > 0) metadata.citations = citations;
|
||||
if (groundingMetadata.length > 0) metadata.groundingMetadata = groundingMetadata;
|
||||
if (thoughtSignatures.all.length > 0) {
|
||||
if (thoughtSignatures.preferred) {
|
||||
metadata.thoughtSignature = thoughtSignatures.preferred;
|
||||
}
|
||||
metadata.thoughtSignatures = thoughtSignatures.all;
|
||||
}
|
||||
const usageMetadata = buildUsageMetadata(state);
|
||||
if (usageMetadata) metadata.usageMetadata = usageMetadata;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGeminiGenerateContentAggregateState } from './aggregator.js';
|
||||
import { extractResponseMetadata, serializeGeminiAggregateResponse } from './outbound.js';
|
||||
import {
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
geminiGenerateContentStream,
|
||||
parseGeminiStreamPayload,
|
||||
serializeAggregateSsePayload,
|
||||
} from './stream.js';
|
||||
import { extractGeminiUsage } from './usage.js';
|
||||
|
||||
describe('geminiGenerateContentStream', () => {
|
||||
it('treats stringified json payloads as json when content type is not sse', () => {
|
||||
const payload = JSON.stringify([
|
||||
{
|
||||
responseId: 'resp-json-string',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [{ text: 'json-string-body' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(parseGeminiStreamPayload(payload, 'application/json')).toEqual({
|
||||
format: 'json',
|
||||
events: [
|
||||
{
|
||||
responseId: 'resp-json-string',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [{ text: 'json-string-body' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
rest: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates SSE and JSON-array payloads into the same final semantics', () => {
|
||||
const requestPayload = {
|
||||
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
|
||||
cachedContent: 'cached/abc',
|
||||
generationConfig: {
|
||||
responseModalities: ['TEXT'],
|
||||
responseSchema: { type: 'object' },
|
||||
responseMimeType: 'application/json',
|
||||
},
|
||||
tools: [
|
||||
{ googleSearch: {} },
|
||||
{ codeExecution: {} },
|
||||
],
|
||||
};
|
||||
|
||||
const chunks = [
|
||||
{
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
content: {
|
||||
parts: [{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' }],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['cat'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://a.example.com' }] },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
content: {
|
||||
parts: [{ text: 'alt-answer' }],
|
||||
},
|
||||
groundingMetadata: { webSearchQueries: ['dog'] },
|
||||
citationMetadata: { citations: [{ uri: 'https://b.example.com' }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [{ text: 'answer' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
finishReason: 'MAX_TOKENS',
|
||||
content: {
|
||||
parts: [{ functionCall: { id: 'tool-1', name: 'lookup', args: { q: 'cat' } }, thoughtSignature: 'sig-tool-1' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 16,
|
||||
cachedContentTokenCount: 3,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ssePayload = `${chunks
|
||||
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
|
||||
.join('')}data: [DONE]\n\n`;
|
||||
|
||||
const sseState = createGeminiGenerateContentAggregateState();
|
||||
const sseResult = applySsePayloadsToAggregate(sseState, ssePayload);
|
||||
|
||||
const jsonState = createGeminiGenerateContentAggregateState();
|
||||
applyJsonPayloadToAggregate(jsonState, chunks);
|
||||
|
||||
expect(sseResult.rest).toBe('');
|
||||
expect(serializeGeminiAggregateResponse(sseResult.state)).toEqual(
|
||||
serializeGeminiAggregateResponse(jsonState),
|
||||
);
|
||||
expect(extractResponseMetadata(sseResult.state, requestPayload)).toEqual(
|
||||
extractResponseMetadata(jsonState, requestPayload),
|
||||
);
|
||||
expect(extractGeminiUsage(sseResult.state)).toEqual(
|
||||
extractGeminiUsage(jsonState),
|
||||
);
|
||||
expect(serializeAggregateSsePayload(sseResult.state)).toBe(
|
||||
`data: ${JSON.stringify(serializeGeminiAggregateResponse(jsonState))}\n\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps trailing rest for partial SSE blocks and ignores done markers', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
const payload = [
|
||||
'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]}}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'data: {"responseId":"resp-1"',
|
||||
].join('\n');
|
||||
|
||||
const result = applySsePayloadsToAggregate(state, payload);
|
||||
|
||||
expect(result.rest).toBe('data: {"responseId":"resp-1"');
|
||||
expect(serializeGeminiAggregateResponse(result.state)).toEqual({
|
||||
responseId: 'resp-1',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes object payloads through the JSON-array path', () => {
|
||||
const state = createGeminiGenerateContentAggregateState();
|
||||
|
||||
applyJsonPayloadToAggregate(state, {
|
||||
responseId: 'resp-2',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
parts: [{ text: 'single-object' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(geminiGenerateContentStream.parseJsonArrayPayload({
|
||||
responseId: 'resp-2',
|
||||
})).toEqual([{ responseId: 'resp-2' }]);
|
||||
expect(serializeGeminiAggregateResponse(state)).toEqual({
|
||||
responseId: 'resp-2',
|
||||
modelVersion: '',
|
||||
candidates: [
|
||||
{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'single-object' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,25 @@ import {
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import { serializeGeminiAggregateResponse } from './outbound.js';
|
||||
|
||||
type ParsedSsePayloads = {
|
||||
events: unknown[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type GeminiGenerateContentStreamFormat = 'sse' | 'json';
|
||||
|
||||
type ParsedGeminiStreamPayload = {
|
||||
format: GeminiGenerateContentStreamFormat;
|
||||
events: unknown[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type AppliedGeminiStreamPayloads = ParsedSsePayloads & {
|
||||
state: GeminiGenerateContentAggregateState;
|
||||
};
|
||||
|
||||
function serializeSsePayload(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
@@ -45,17 +58,157 @@ function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
return { events, rest };
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonArrayPayload(payload: unknown): unknown[] {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (isRecord(payload)) return [payload];
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseGeminiStreamPayload(
|
||||
payload: unknown,
|
||||
contentType?: string | null,
|
||||
): ParsedGeminiStreamPayload {
|
||||
if (geminiGenerateContentStream.isSseContentType(contentType)) {
|
||||
const parsed = parseSsePayloads(String(payload ?? ''));
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
const trimmed = payload.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
const parsedJson = JSON.parse(trimmed);
|
||||
return {
|
||||
format: 'json',
|
||||
events: parseJsonArrayPayload(parsedJson),
|
||||
rest: '',
|
||||
};
|
||||
} catch {
|
||||
const parsed = parseSsePayloads(payload);
|
||||
return {
|
||||
format: 'sse',
|
||||
events: parsed.events,
|
||||
rest: parsed.rest,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
format: 'json',
|
||||
events: [],
|
||||
rest: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
format: 'json',
|
||||
events: parseJsonArrayPayload(payload),
|
||||
rest: '',
|
||||
};
|
||||
}
|
||||
|
||||
function applyParsedPayloadToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
parsed: ParsedGeminiStreamPayload,
|
||||
): AppliedGeminiStreamPayloads {
|
||||
for (const event of parsed.events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
|
||||
return {
|
||||
events: parsed.events,
|
||||
rest: parsed.rest,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
function applyJsonPayloadToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
): GeminiGenerateContentAggregateState {
|
||||
applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(payload, 'application/json'));
|
||||
return state;
|
||||
}
|
||||
|
||||
function applySsePayloadsToAggregate(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
buffer: string,
|
||||
): AppliedGeminiStreamPayloads {
|
||||
return applyParsedPayloadToAggregate(state, parseGeminiStreamPayload(buffer, 'text/event-stream'));
|
||||
}
|
||||
|
||||
function consumeUpstreamSseBuffer(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
buffer: string,
|
||||
): AppliedGeminiStreamPayloads & { lines: string[] } {
|
||||
const applied = applySsePayloadsToAggregate(state, buffer);
|
||||
return {
|
||||
...applied,
|
||||
lines: applied.events.map((event) => serializeAggregateSsePayload(event)),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAggregateJsonPayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): unknown {
|
||||
return serializeGeminiAggregateResponse(payload);
|
||||
}
|
||||
|
||||
function serializeAggregateSsePayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): string {
|
||||
return serializeSsePayload(serializeAggregateJsonPayload(payload));
|
||||
}
|
||||
|
||||
function serializeAggregatePayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
format: GeminiGenerateContentStreamFormat = 'json',
|
||||
): unknown {
|
||||
return format === 'sse'
|
||||
? serializeAggregateSsePayload(payload)
|
||||
: serializeAggregateJsonPayload(payload);
|
||||
}
|
||||
|
||||
function serializeUpstreamJsonPayload(
|
||||
state: GeminiGenerateContentAggregateState,
|
||||
payload: unknown,
|
||||
streamAction = false,
|
||||
): unknown {
|
||||
if (streamAction) {
|
||||
return parseJsonArrayPayload(payload).map((event) => {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
return serializeAggregateJsonPayload(event);
|
||||
});
|
||||
}
|
||||
|
||||
applyJsonPayloadToAggregate(state, payload);
|
||||
return serializeAggregateJsonPayload(state);
|
||||
}
|
||||
|
||||
export const geminiGenerateContentStream = {
|
||||
isSseContentType(contentType: string | null | undefined): boolean {
|
||||
return (contentType || '').toLowerCase().includes('text/event-stream');
|
||||
},
|
||||
|
||||
parseJsonArrayPayload(payload: unknown): unknown[] {
|
||||
return Array.isArray(payload) ? payload : [];
|
||||
},
|
||||
|
||||
parseJsonArrayPayload,
|
||||
parseGeminiStreamPayload,
|
||||
parseSsePayloads,
|
||||
serializeSsePayload,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
|
||||
createAggregateState(): GeminiGenerateContentAggregateState {
|
||||
return createGeminiGenerateContentAggregateState();
|
||||
@@ -65,3 +218,24 @@ export const geminiGenerateContentStream = {
|
||||
return applyGeminiGenerateContentAggregate(state, payload);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
parseGeminiStreamPayload,
|
||||
parseJsonArrayPayload,
|
||||
parseSsePayloads,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
serializeSsePayload,
|
||||
consumeUpstreamSseBuffer,
|
||||
};
|
||||
export type {
|
||||
AppliedGeminiStreamPayloads,
|
||||
GeminiGenerateContentStreamFormat,
|
||||
ParsedGeminiStreamPayload,
|
||||
ParsedSsePayloads,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ function isAggregateState(value: unknown): value is GeminiGenerateContentAggrega
|
||||
return isRecord(value) && Array.isArray(value.parts) && isRecord(value.usage);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function extractGeminiUsage(payload: unknown): NormalizedUsage {
|
||||
const usageMetadata = isAggregateState(payload)
|
||||
? payload.usage
|
||||
@@ -20,22 +24,12 @@ export function extractGeminiUsage(payload: unknown): NormalizedUsage {
|
||||
return createEmptyNormalizedUsage();
|
||||
}
|
||||
|
||||
const promptTokens = typeof usageMetadata.promptTokenCount === 'number'
|
||||
? usageMetadata.promptTokenCount
|
||||
: 0;
|
||||
const candidatesTokenCount = typeof usageMetadata.candidatesTokenCount === 'number'
|
||||
? usageMetadata.candidatesTokenCount
|
||||
: 0;
|
||||
const thoughtsTokenCount = typeof usageMetadata.thoughtsTokenCount === 'number'
|
||||
? usageMetadata.thoughtsTokenCount
|
||||
: 0;
|
||||
const promptTokens = finiteNumber(usageMetadata.promptTokenCount) ?? 0;
|
||||
const candidatesTokenCount = finiteNumber(usageMetadata.candidatesTokenCount) ?? 0;
|
||||
const thoughtsTokenCount = finiteNumber(usageMetadata.thoughtsTokenCount) ?? 0;
|
||||
const completionTokens = candidatesTokenCount + thoughtsTokenCount;
|
||||
const totalTokens = typeof usageMetadata.totalTokenCount === 'number'
|
||||
? usageMetadata.totalTokenCount
|
||||
: promptTokens + completionTokens;
|
||||
const cachedTokens = typeof usageMetadata.cachedContentTokenCount === 'number'
|
||||
? usageMetadata.cachedContentTokenCount
|
||||
: 0;
|
||||
const totalTokens = finiteNumber(usageMetadata.totalTokenCount) ?? (promptTokens + completionTokens);
|
||||
const cachedTokens = finiteNumber(usageMetadata.cachedContentTokenCount) ?? 0;
|
||||
const reasoningTokens = thoughtsTokenCount;
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user