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:
cita
2026-03-08 16:42:41 +08:00
parent 7081be14c7
commit ede8998244
48 changed files with 6357 additions and 1110 deletions
@@ -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 {