feat: Add normalized types and utility functions for handling chat formats

- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling.
- Implemented utility functions for creating and merging normalized usage statistics.
- Added a new module for shared transformers to manage chat formats.

feat: Implement app local state management

- Created functions to clear application installation state and manage local storage keys.
- Added constants for theme and user profile storage keys.

test: Add tests for BrandIcon component rendering

- Implemented tests to ensure BrandIcon does not force a colored background for recognized brands.

test: Enhance ModelAnalysisPanel tests

- Added tests for rendering token summaries with compact units in ModelAnalysisPanel.

test: Add tests for ModernSelect component

- Implemented tests to verify icon rendering for selected options in ModernSelect.

test: Add number formatting tests

- Created tests for `formatCompactTokenMetric` to validate token formatting logic.

feat: Implement account creation intent handling

- Added logic to handle session and API key creation intents in the Accounts page.

test: Add tests for dashboard performance card

- Implemented tests to verify rendering of performance metrics in the dashboard.

feat: Implement default connection segment resolution

- Added logic to determine initial connection segments based on platform types.

test: Add tests for settings factory reset functionality

- Implemented tests to verify the factory reset process and local state clearing.

test: Add tests for site creation redirect logic

- Implemented tests to verify redirection based on site capabilities during creation.

test: Add tests for token routes refresh decision action

- Implemented tests to verify refresh decision actions in TokenRoutes.

test: Add tests for token routes snapshot load

- Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
cita
2026-03-08 12:08:57 +08:00
parent ed05b37626
commit 23922a5965
115 changed files with 18597 additions and 4411 deletions
@@ -0,0 +1,182 @@
type GeminiRecord = Record<string, unknown>;
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 pushUniqueJson(target: GeminiRecord[], incoming: unknown): void {
if (!isRecord(incoming)) return;
const serialized = JSON.stringify(incoming);
if (target.some((item) => JSON.stringify(item) === serialized)) return;
target.push(cloneJsonValue(incoming));
}
function collectPartsFromPayload(payload: unknown): GeminiRecord[] {
if (Array.isArray(payload)) {
return payload.flatMap((item) => collectPartsFromPayload(item));
}
if (!isRecord(payload)) return [];
const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];
const parts: GeminiRecord[] = [];
for (const candidate of candidates) {
if (!isRecord(candidate)) continue;
const content = isRecord(candidate.content) ? candidate.content : null;
if (!content || !Array.isArray(content.parts)) continue;
for (const part of content.parts) {
if (isRecord(part)) parts.push(cloneJsonValue(part));
}
}
return parts;
}
function updateIfDefined<T extends keyof GeminiGenerateContentUsageSummary>(
usage: GeminiGenerateContentUsageSummary,
key: T,
value: unknown,
): void {
if (typeof value === 'number' && Number.isFinite(value)) {
usage[key] = value;
}
}
export type GeminiGenerateContentUsageSummary = {
promptTokenCount?: number | null;
candidatesTokenCount?: number | null;
totalTokenCount?: number | null;
cachedContentTokenCount?: number | null;
thoughtsTokenCount?: number | null;
};
export type GeminiGenerateContentCandidateAggregate = {
index: number;
finishReason: string | null;
parts: GeminiRecord[];
groundingMetadata?: GeminiRecord;
citationMetadata?: GeminiRecord;
};
export type GeminiGenerateContentAggregateState = {
responseId: string | null;
modelVersion: string | null;
finishReason: string | null;
parts: GeminiRecord[];
citations: GeminiRecord[];
groundingMetadata: GeminiRecord[];
thoughtSignatures: string[];
usage: GeminiGenerateContentUsageSummary;
candidates: GeminiGenerateContentCandidateAggregate[];
};
export function createGeminiGenerateContentAggregateState(): GeminiGenerateContentAggregateState {
return {
responseId: null,
modelVersion: null,
finishReason: null,
parts: [],
citations: [],
groundingMetadata: [],
thoughtSignatures: [],
usage: {},
candidates: [],
};
}
function ensureCandidateAggregate(
state: GeminiGenerateContentAggregateState,
rawIndex: unknown,
): GeminiGenerateContentCandidateAggregate {
const normalizedIndex = typeof rawIndex === 'number' && Number.isFinite(rawIndex)
? Math.max(0, Math.trunc(rawIndex))
: 0;
let existing = state.candidates.find((candidate) => candidate.index === normalizedIndex);
if (!existing) {
existing = {
index: normalizedIndex,
finishReason: null,
parts: [],
};
state.candidates.push(existing);
state.candidates.sort((left, right) => left.index - right.index);
}
return existing;
}
export function applyGeminiGenerateContentAggregate(
state: GeminiGenerateContentAggregateState,
payload: unknown,
): GeminiGenerateContentAggregateState {
for (const part of collectPartsFromPayload(payload)) {
state.parts.push(part);
if (typeof part.thoughtSignature === 'string' && part.thoughtSignature.trim()) {
if (!state.thoughtSignatures.includes(part.thoughtSignature)) {
state.thoughtSignatures.push(part.thoughtSignature);
}
}
}
const payloads = Array.isArray(payload) ? payload : [payload];
for (const item of payloads) {
if (!isRecord(item)) continue;
if (typeof item.responseId === 'string' && item.responseId.trim()) {
state.responseId = item.responseId.trim();
}
if (typeof item.modelVersion === 'string' && item.modelVersion.trim()) {
state.modelVersion = item.modelVersion.trim();
}
const candidates = Array.isArray(item.candidates) ? item.candidates : [];
for (const candidate of candidates) {
if (!isRecord(candidate)) continue;
const candidateAggregate = ensureCandidateAggregate(state, candidate.index);
if (candidate.groundingMetadata !== undefined) {
pushUniqueJson(state.groundingMetadata, candidate.groundingMetadata);
if (isRecord(candidate.groundingMetadata)) {
candidateAggregate.groundingMetadata = cloneJsonValue(candidate.groundingMetadata);
}
}
if (candidate.citationMetadata !== undefined) {
pushUniqueJson(state.citations, candidate.citationMetadata);
if (isRecord(candidate.citationMetadata)) {
candidateAggregate.citationMetadata = cloneJsonValue(candidate.citationMetadata);
}
}
if (typeof candidate.finishReason === 'string' && candidate.finishReason.trim()) {
state.finishReason = candidate.finishReason.trim();
candidateAggregate.finishReason = candidate.finishReason.trim();
}
const content = isRecord(candidate.content) ? candidate.content : null;
if (content && Array.isArray(content.parts)) {
for (const part of content.parts) {
if (!isRecord(part)) continue;
candidateAggregate.parts.push(cloneJsonValue(part));
}
}
}
const usageMetadata = isRecord(item.usageMetadata) ? item.usageMetadata : null;
if (usageMetadata) {
updateIfDefined(state.usage, 'promptTokenCount', usageMetadata.promptTokenCount);
updateIfDefined(state.usage, 'candidatesTokenCount', usageMetadata.candidatesTokenCount);
updateIfDefined(state.usage, 'totalTokenCount', usageMetadata.totalTokenCount);
updateIfDefined(state.usage, 'cachedContentTokenCount', usageMetadata.cachedContentTokenCount);
updateIfDefined(state.usage, 'thoughtsTokenCount', usageMetadata.thoughtsTokenCount);
}
}
return state;
}
@@ -0,0 +1,165 @@
export type GeminiReasoningEffort = 'none' | 'low' | 'medium' | 'high' | 'max';
type GeminiThinkingConfig = {
thinkingBudget?: number;
thinkingLevel?: string;
};
function normalizeReasoningEffort(value: unknown): GeminiReasoningEffort {
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
if (
normalized === 'none'
|| normalized === 'low'
|| normalized === 'medium'
|| normalized === 'high'
|| normalized === 'max'
) {
return normalized;
}
return 'medium';
}
function normalizeThinkingLevel(value: unknown): GeminiReasoningEffort | null {
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
if (normalized === 'minimal') return 'low';
if (
normalized === 'none'
|| normalized === 'low'
|| normalized === 'medium'
|| normalized === 'high'
|| normalized === 'max'
) {
return normalized;
}
return null;
}
function thinkingBudgetToReasoningEffort(budget: number): GeminiReasoningEffort {
if (budget <= 0) return 'none';
if (budget <= 1024) return 'low';
if (budget <= 8192) return 'medium';
if (budget <= 32768) return 'high';
return 'max';
}
function shouldUseThinkingLevel(model: string, effort: GeminiReasoningEffort): boolean {
if (!/gemini-3-/i.test(model)) return false;
return effort === 'none' || effort === 'low' || effort === 'medium' || effort === 'high';
}
function reasoningEffortToThinkingBudget(effort: GeminiReasoningEffort): number {
switch (effort) {
case 'none':
return 0;
case 'low':
return 1024;
case 'high':
return 32768;
case 'max':
return 65536;
case 'medium':
default:
return 8192;
}
}
export function reasoningEffortToGeminiThinkingConfig(
model: string,
reasoningEffort: unknown,
): GeminiThinkingConfig {
const effort = normalizeReasoningEffort(reasoningEffort);
if (shouldUseThinkingLevel(model, effort)) {
return { thinkingLevel: effort };
}
return { thinkingBudget: reasoningEffortToThinkingBudget(effort) };
}
export function geminiThinkingConfigToReasoning(
thinkingConfig: Record<string, unknown> | null | undefined,
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
if (!thinkingConfig || typeof thinkingConfig !== 'object') return null;
const level = normalizeThinkingLevel(thinkingConfig.thinkingLevel);
if (level) {
return {
reasoningEffort: level,
reasoningBudget: reasoningEffortToThinkingBudget(level),
};
}
const budget = typeof thinkingConfig.thinkingBudget === 'number' && Number.isFinite(thinkingConfig.thinkingBudget)
? Math.max(0, Math.trunc(thinkingConfig.thinkingBudget))
: null;
if (budget === null) return null;
return {
reasoningEffort: thinkingBudgetToReasoningEffort(budget),
reasoningBudget: budget,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function toFiniteInt(value: unknown): number | null {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.max(0, Math.trunc(numeric));
}
export function resolveGeminiThinkingConfigFromRequest(
model: string,
body: Record<string, unknown>,
): GeminiThinkingConfig | null {
const generationConfig = isRecord(body.generationConfig) ? body.generationConfig : null;
const nativeThinkingConfig = isRecord(generationConfig?.thinkingConfig)
? generationConfig!.thinkingConfig as Record<string, unknown>
: null;
if (nativeThinkingConfig) {
const nativeLevel = normalizeThinkingLevel(nativeThinkingConfig.thinkingLevel);
const nativeBudget = toFiniteInt(nativeThinkingConfig.thinkingBudget);
if (nativeLevel) {
return { thinkingLevel: nativeLevel };
}
if (nativeBudget !== null) {
return { thinkingBudget: nativeBudget };
}
}
const reasoning = isRecord(body.reasoning) ? body.reasoning : null;
const reasoningEffort = (
typeof body.reasoning_effort === 'string' ? body.reasoning_effort
: (typeof reasoning?.effort === 'string' ? reasoning.effort : undefined)
);
const reasoningBudget = (
body.reasoning_budget
?? reasoning?.budget_tokens
?? reasoning?.thinkingBudget
?? reasoning?.max_tokens
);
const parsedBudget = toFiniteInt(reasoningBudget);
if (parsedBudget !== null) {
return reasoningEffortToGeminiThinkingConfig(model, thinkingBudgetToReasoningEffort(parsedBudget));
}
if (reasoningEffort !== undefined) {
return reasoningEffortToGeminiThinkingConfig(model, reasoningEffort);
}
return null;
}
export function extractReasoningMetadataFromGeminiRequest(
body: Record<string, unknown> | null | undefined,
): { reasoningEffort: GeminiReasoningEffort; reasoningBudget: number } | null {
if (!body) return null;
const generationConfig = isRecord(body.generationConfig) ? body.generationConfig : null;
const thinkingConfig = isRecord(generationConfig?.thinkingConfig)
? generationConfig!.thinkingConfig as Record<string, unknown>
: null;
return geminiThinkingConfigToReasoning(thinkingConfig);
}
@@ -0,0 +1,119 @@
type GeminiRecord = Record<string, unknown>;
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
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 cloneContents(value: unknown): unknown[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.filter((item) => isRecord(item))
.map((item) => {
const next: GeminiRecord = { ...item };
if (Array.isArray(item.parts)) {
next.parts = item.parts.map((part) => (isRecord(part) ? cloneJsonValue(part) : part));
}
return next;
});
}
function cloneGenerationConfig(value: unknown): GeminiRecord | undefined {
if (!isRecord(value)) return undefined;
const allowedKeys = [
'stopSequences',
'responseModalities',
'responseMimeType',
'responseSchema',
'candidateCount',
'maxOutputTokens',
'temperature',
'topP',
'topK',
'presencePenalty',
'frequencyPenalty',
'seed',
'responseLogprobs',
'logprobs',
'thinkingConfig',
'imageConfig',
];
const next: GeminiRecord = {};
for (const key of allowedKeys) {
if (value[key] !== undefined) next[key] = cloneJsonValue(value[key]);
}
return Object.keys(next).length > 0 ? next : undefined;
}
function cloneTools(value: unknown): unknown[] | undefined {
if (!Array.isArray(value)) return undefined;
return value
.filter((item) => isRecord(item))
.map((item) => {
const next: GeminiRecord = {};
if (item.functionDeclarations !== undefined) next.functionDeclarations = cloneJsonValue(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);
return Object.keys(next).length > 0 ? next : cloneJsonValue(item);
});
}
export type GeminiGenerateContentRequest = GeminiRecord;
export const geminiGenerateContentInbound = {
normalizeRequest(body: unknown, modelName = ''): GeminiGenerateContentRequest {
if (!isRecord(body)) return {};
const next: GeminiGenerateContentRequest = {};
if (body.contents !== undefined) next.contents = cloneContents(body.contents) ?? cloneJsonValue(body.contents);
if (body.systemInstruction !== undefined) next.systemInstruction = cloneJsonValue(body.systemInstruction);
if (body.cachedContent !== undefined) next.cachedContent = cloneJsonValue(body.cachedContent);
if (body.safetySettings !== undefined) next.safetySettings = cloneJsonValue(body.safetySettings);
if (body.generationConfig !== undefined) {
next.generationConfig = cloneGenerationConfig(body.generationConfig) ?? cloneJsonValue(body.generationConfig);
}
if (body.tools !== undefined) next.tools = cloneTools(body.tools) ?? cloneJsonValue(body.tools);
if (body.toolConfig !== undefined) next.toolConfig = cloneJsonValue(body.toolConfig);
const derivedThinkingConfig = resolveGeminiThinkingConfigFromRequest(
modelName || (typeof body.model === 'string' ? body.model : ''),
body,
);
if (derivedThinkingConfig) {
const generationConfig = isRecord(next.generationConfig)
? { ...next.generationConfig }
: {};
if (!isRecord(generationConfig.thinkingConfig)) {
generationConfig.thinkingConfig = derivedThinkingConfig;
}
next.generationConfig = generationConfig;
}
const strippedKeys = new Set([
'reasoning',
'reasoning_effort',
'reasoning_budget',
]);
for (const [key, value] of Object.entries(body)) {
if (strippedKeys.has(key)) continue;
if (next[key] !== undefined) continue;
next[key] = cloneJsonValue(value);
}
return next;
},
};
@@ -0,0 +1,439 @@
import { describe, expect, it } from 'vitest';
import {
geminiGenerateContentTransformer,
reasoningEffortToGeminiThinkingConfig,
geminiThinkingConfigToReasoning,
} from './index.js';
import { extractGeminiUsage } from './usage.js';
import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js';
describe('geminiGenerateContentTransformer.inbound', () => {
it('preserves native Gemini request fields through normalization', () => {
const body = geminiGenerateContentTransformer.inbound.normalizeRequest({
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
systemInstruction: {
role: 'system',
parts: [{ text: 'system prompt' }],
},
cachedContent: 'cached/abc',
generationConfig: {
responseModalities: ['TEXT'],
responseMimeType: 'application/json',
responseSchema: {
type: 'object',
properties: {
answer: { type: 'string' },
},
},
thinkingConfig: {
thinkingBudget: 512,
},
imageConfig: {
aspectRatio: '1:1',
},
},
tools: [
{
functionDeclarations: [
{
name: 'lookup',
description: 'find facts',
},
],
},
{ googleSearch: {} },
{ urlContext: {} },
{ codeExecution: {} },
],
});
expect(body).toEqual({
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
systemInstruction: {
role: 'system',
parts: [{ text: 'system prompt' }],
},
cachedContent: 'cached/abc',
generationConfig: {
responseModalities: ['TEXT'],
responseMimeType: 'application/json',
responseSchema: {
type: 'object',
properties: {
answer: { type: 'string' },
},
},
thinkingConfig: {
thinkingBudget: 512,
},
imageConfig: {
aspectRatio: '1:1',
},
},
tools: [
{
functionDeclarations: [
{
name: 'lookup',
description: 'find facts',
},
],
},
{ googleSearch: {} },
{ urlContext: {} },
{ codeExecution: {} },
],
});
});
});
describe('geminiGenerateContentTransformer.aggregator', () => {
it('collects grounding metadata, citations, thought signatures, and usage from streamed chunks', () => {
const state = geminiGenerateContentTransformer.aggregator.createState();
geminiGenerateContentTransformer.aggregator.apply(state, [
{
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [
{ text: 'hello', thoughtSignature: 'sig-1' },
],
},
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
},
]);
expect(state.groundingMetadata).toEqual([{ webSearchQueries: ['cat'] }]);
expect(state.citations).toEqual([{ citations: [{ uri: 'https://example.com' }] }]);
expect(state.thoughtSignatures).toEqual(['sig-1']);
expect(state.usage).toEqual({
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
});
});
it('serializes aggregate state back into Gemini response semantics', () => {
const state = geminiGenerateContentTransformer.aggregator.createState();
geminiGenerateContentTransformer.aggregator.apply(state, {
responseId: 'resp-1',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
{ text: 'answer' },
],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
});
const response = serializeGeminiAggregateResponse(state);
expect(response).toEqual({
responseId: 'resp-1',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
index: 0,
finishReason: 'STOP',
content: {
role: 'model',
parts: [
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
{ text: 'answer' },
],
},
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
});
expect(extractResponseMetadata(state)).toEqual({
citations: [{ citations: [{ uri: 'https://example.com' }] }],
groundingMetadata: [{ webSearchQueries: ['cat'] }],
thoughtSignature: 'sig-1',
thoughtSignatures: ['sig-1'],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
});
});
it('preserves multiple candidate streams instead of collapsing them into one candidate', () => {
const state = geminiGenerateContentTransformer.aggregator.createState();
geminiGenerateContentTransformer.aggregator.apply(state, {
responseId: 'resp-multi',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
index: 0,
content: {
parts: [{ text: 'candidate-a' }],
},
finishReason: 'STOP',
groundingMetadata: { webSearchQueries: ['a'] },
citationMetadata: { citations: [{ uri: 'https://a.example.com' }] },
},
{
index: 1,
content: {
parts: [{ text: 'candidate-b' }],
},
finishReason: 'MAX_TOKENS',
groundingMetadata: { webSearchQueries: ['b'] },
citationMetadata: { citations: [{ uri: 'https://b.example.com' }] },
},
],
});
expect(serializeGeminiAggregateResponse(state)).toEqual({
responseId: 'resp-multi',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
index: 0,
finishReason: 'STOP',
content: {
role: 'model',
parts: [{ text: 'candidate-a' }],
},
groundingMetadata: { webSearchQueries: ['a'] },
citationMetadata: { citations: [{ uri: 'https://a.example.com' }] },
},
{
index: 1,
finishReason: 'MAX_TOKENS',
content: {
role: 'model',
parts: [{ text: 'candidate-b' }],
},
groundingMetadata: { webSearchQueries: ['b'] },
citationMetadata: { citations: [{ uri: 'https://b.example.com' }] },
},
],
});
});
it('merges preserved request semantics into extracted metadata when provided', () => {
const metadata = extractResponseMetadata(
{
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [{ text: 'answer', thoughtSignature: 'sig-1' }],
},
},
],
},
{
systemInstruction: { role: 'system', parts: [{ text: 'system prompt' }] },
cachedContent: 'cached/abc',
generationConfig: {
responseModalities: ['TEXT'],
responseSchema: { type: 'object' },
responseMimeType: 'application/json',
},
tools: [
{ googleSearch: {} },
{ urlContext: {} },
{ codeExecution: {} },
],
},
);
expect(metadata.systemInstruction).toEqual({ role: 'system', parts: [{ text: 'system prompt' }] });
expect(metadata.cachedContent).toBe('cached/abc');
expect(metadata.responseModalities).toEqual(['TEXT']);
expect(metadata.responseSchema).toEqual({ type: 'object' });
expect(metadata.responseMimeType).toBe('application/json');
expect(metadata.tools).toEqual([
{ googleSearch: {} },
{ urlContext: {} },
{ codeExecution: {} },
]);
});
});
describe('extractGeminiUsage', () => {
it('maps cached and thought token counts into normalized usage', () => {
expect(extractGeminiUsage({
usageMetadata: {
promptTokenCount: 11,
candidatesTokenCount: 7,
totalTokenCount: 18,
cachedContentTokenCount: 5,
thoughtsTokenCount: 3,
},
})).toEqual({
promptTokens: 11,
completionTokens: 10,
totalTokens: 18,
cachedTokens: 5,
reasoningTokens: 3,
});
});
});
describe('Gemini reasoning mapping', () => {
it('supports explicit none reasoning effort and maps it back from thinking config', () => {
expect(reasoningEffortToGeminiThinkingConfig('gemini-3-5m', 'none')).toEqual({
thinkingLevel: 'none',
});
expect(geminiThinkingConfigToReasoning({
thinkingLevel: 'none',
})).toEqual({
reasoningEffort: 'none',
reasoningBudget: 0,
});
});
it('uses thinkingLevel for Gemini 3 models when reasoning effort is provided', () => {
expect(reasoningEffortToGeminiThinkingConfig('gemini-3-5m', 'high')).toEqual({
thinkingLevel: 'high',
});
});
it('uses thinkingBudget for non-Gemini 3 models when reasoning effort is provided', () => {
expect(reasoningEffortToGeminiThinkingConfig('gemini-2.5-flash', 'medium')).toEqual({
thinkingBudget: 8192,
});
});
it('maps Gemini thinking config back to normalized reasoning hints', () => {
expect(geminiThinkingConfigToReasoning({
thinkingLevel: 'medium',
})).toEqual({
reasoningEffort: 'medium',
reasoningBudget: 8192,
});
expect(geminiThinkingConfigToReasoning({
thinkingBudget: 512,
})).toEqual({
reasoningEffort: 'low',
reasoningBudget: 512,
});
});
});
describe('geminiGenerateContentTransformer.stream', () => {
it('aggregates SSE payloads and JSON-array payloads to the same final semantics', () => {
const chunks = [
{
responseId: 'resp-1',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [
{ text: 'thinking', thought: true, thoughtSignature: 'sig-1' },
],
},
finishReason: '',
},
],
},
{
responseId: 'resp-1',
modelVersion: 'gemini-2.5-pro',
candidates: [
{
groundingMetadata: { webSearchQueries: ['cat'] },
citationMetadata: { citations: [{ uri: 'https://example.com' }] },
content: {
parts: [
{ text: 'answer' },
],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 4,
totalTokenCount: 14,
cachedContentTokenCount: 3,
thoughtsTokenCount: 2,
},
},
];
const ssePayload = chunks
.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`)
.join('');
const sseParsed = geminiGenerateContentTransformer.stream.parseSsePayloads(ssePayload);
const sseState = geminiGenerateContentTransformer.aggregator.createState();
for (const payload of sseParsed.events) {
geminiGenerateContentTransformer.aggregator.apply(sseState, payload);
}
const jsonState = geminiGenerateContentTransformer.aggregator.createState();
for (const payload of geminiGenerateContentTransformer.stream.parseJsonArrayPayload(chunks)) {
geminiGenerateContentTransformer.aggregator.apply(jsonState, payload);
}
expect(serializeGeminiAggregateResponse(sseState)).toEqual(
serializeGeminiAggregateResponse(jsonState),
);
expect(extractResponseMetadata(sseState)).toEqual(
extractResponseMetadata(jsonState),
);
expect(extractGeminiUsage(sseState)).toEqual(
extractGeminiUsage(jsonState),
);
});
});
@@ -0,0 +1,75 @@
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function baseIncludesVersion(baseUrl: string): boolean {
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
}
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (baseIncludesVersion(normalized)) return normalized;
return `${normalized}/${apiVersion}`;
}
export function resolveGeminiModelsUrl(
baseUrl: string,
apiVersion: string,
apiKey: string,
): string {
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
const separator = base.includes('?') ? '&' : '?';
return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`;
}
export function resolveGeminiGenerateContentUrl(
baseUrl: string,
apiVersion: string,
modelActionPath: string,
apiKey: string,
search: string,
): string {
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
const normalizedAction = modelActionPath.replace(/^\/+/, '');
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
params.set('key', apiKey);
const query = params.toString();
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`;
}
import { geminiGenerateContentInbound } from './inbound.js';
import { geminiGenerateContentOutbound } from './outbound.js';
import { geminiGenerateContentStream } from './stream.js';
import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js';
import { geminiGenerateContentUsage } from './usage.js';
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
export const geminiGenerateContentTransformer = {
protocol: 'gemini/generate-content' as const,
inbound: geminiGenerateContentInbound,
outbound: geminiGenerateContentOutbound,
stream: geminiGenerateContentStream,
aggregator: {
createState: createGeminiGenerateContentAggregateState,
apply: applyGeminiGenerateContentAggregate,
},
usage: geminiGenerateContentUsage,
convert: {
reasoningEffortToGeminiThinkingConfig,
geminiThinkingConfigToReasoning,
},
resolveBaseUrl: resolveGeminiNativeBaseUrl,
resolveModelsUrl: resolveGeminiModelsUrl,
resolveActionUrl: resolveGeminiGenerateContentUrl,
};
export {
geminiGenerateContentInbound,
geminiGenerateContentOutbound,
geminiGenerateContentStream,
createGeminiGenerateContentAggregateState,
applyGeminiGenerateContentAggregate,
geminiGenerateContentUsage,
reasoningEffortToGeminiThinkingConfig,
geminiThinkingConfigToReasoning,
};
@@ -0,0 +1,190 @@
import {
applyGeminiGenerateContentAggregate,
createGeminiGenerateContentAggregateState,
type GeminiGenerateContentAggregateState,
} from './aggregator.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function baseIncludesVersion(baseUrl: string): boolean {
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
}
function resolveBaseUrl(baseUrl: string, apiVersion: string): string {
const normalized = normalizeBaseUrl(baseUrl);
if (baseIncludesVersion(normalized)) return normalized;
return `${normalized}/${apiVersion}`;
}
function resolveModelsUrl(
baseUrl: string,
apiVersion: string,
apiKey: string,
): string {
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
const separator = resolvedBaseUrl.includes('?') ? '&' : '?';
return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`;
}
function resolveActionUrl(
baseUrl: string,
apiVersion: string,
modelActionPath: string,
apiKey: string,
search: string,
): string {
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
const normalizedAction = modelActionPath.replace(/^\/+/, '');
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
params.set('key', apiKey);
const query = params.toString();
return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`;
}
type GeminiRecord = Record<string, unknown>;
function isRecord(value: unknown): value is GeminiRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
return isRecord(value) && Array.isArray(value.parts) && Array.isArray(value.groundingMetadata) && Array.isArray(value.citations);
}
function ensureAggregateState(payload: unknown): GeminiGenerateContentAggregateState {
if (isAggregateState(payload)) return payload;
const state = createGeminiGenerateContentAggregateState();
const chunks = Array.isArray(payload) ? payload : [payload];
for (const chunk of chunks) {
applyGeminiGenerateContentAggregate(state, chunk);
}
return state;
}
function buildUsageMetadata(state: GeminiGenerateContentAggregateState): GeminiRecord | undefined {
const usage = state.usage;
const next: GeminiRecord = {};
if (typeof usage.promptTokenCount === 'number') next.promptTokenCount = usage.promptTokenCount;
if (typeof usage.candidatesTokenCount === 'number') next.candidatesTokenCount = usage.candidatesTokenCount;
if (typeof usage.totalTokenCount === 'number') next.totalTokenCount = usage.totalTokenCount;
if (typeof usage.cachedContentTokenCount === 'number') next.cachedContentTokenCount = usage.cachedContentTokenCount;
if (typeof usage.thoughtsTokenCount === 'number') next.thoughtsTokenCount = usage.thoughtsTokenCount;
return Object.keys(next).length > 0 ? next : undefined;
}
function buildCandidates(state: GeminiGenerateContentAggregateState): GeminiRecord[] {
if (state.candidates.length > 0) {
return state.candidates
.slice()
.sort((left, right) => left.index - right.index)
.map((candidate) => {
const next: GeminiRecord = {
index: candidate.index,
finishReason: candidate.finishReason || 'STOP',
content: {
role: 'model',
parts: candidate.parts,
},
};
if (candidate.groundingMetadata) next.groundingMetadata = candidate.groundingMetadata;
if (candidate.citationMetadata) next.citationMetadata = candidate.citationMetadata;
return next;
});
}
const fallback: GeminiRecord = {
index: 0,
finishReason: state.finishReason || 'STOP',
content: {
role: 'model',
parts: state.parts,
},
};
if (state.groundingMetadata.length > 0) {
fallback.groundingMetadata = state.groundingMetadata[0];
}
if (state.citations.length > 0) {
fallback.citationMetadata = state.citations[0];
}
return [fallback];
}
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;
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;
}
if (Array.isArray(requestPayload.tools)) {
const requestTools = requestPayload.tools
.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;
return next;
})
.filter((item) => Object.keys(item).length > 0);
if (requestTools.length > 0) {
metadata.tools = requestTools;
}
}
return metadata;
}
export function serializeGeminiAggregateResponse(
payload: GeminiGenerateContentAggregateState | unknown,
): GeminiRecord {
const state = ensureAggregateState(payload);
const response: GeminiRecord = {
responseId: state.responseId || '',
modelVersion: state.modelVersion || '',
candidates: buildCandidates(state),
};
const usageMetadata = buildUsageMetadata(state);
if (usageMetadata) {
response.usageMetadata = usageMetadata;
}
return response;
}
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
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 usageMetadata = buildUsageMetadata(state);
if (usageMetadata) metadata.usageMetadata = usageMetadata;
return metadata;
}
export const geminiGenerateContentOutbound = {
resolveBaseUrl,
resolveModelsUrl,
resolveActionUrl,
extractResponseMetadata,
serializeAggregateResponse: serializeGeminiAggregateResponse,
};
@@ -0,0 +1,67 @@
import {
applyGeminiGenerateContentAggregate,
createGeminiGenerateContentAggregateState,
type GeminiGenerateContentAggregateState,
} from './aggregator.js';
type ParsedSsePayloads = {
events: unknown[];
rest: string;
};
function serializeSsePayload(payload: unknown): string {
return `data: ${JSON.stringify(payload)}\n\n`;
}
function parseSsePayloads(buffer: string): ParsedSsePayloads {
const normalized = buffer.replace(/\r\n/g, '\n');
const events: unknown[] = [];
let rest = normalized;
while (true) {
const boundary = rest.indexOf('\n\n');
if (boundary < 0) break;
const block = rest.slice(0, boundary);
rest = rest.slice(boundary + 2);
if (!block.trim()) continue;
const data = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n')
.trim();
if (!data || data === '[DONE]') continue;
try {
events.push(JSON.parse(data));
} catch {
// Ignore malformed event payloads so aggregation remains tolerant.
}
}
return { events, rest };
}
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 : [];
},
parseSsePayloads,
serializeSsePayload,
createAggregateState(): GeminiGenerateContentAggregateState {
return createGeminiGenerateContentAggregateState();
},
applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState {
return applyGeminiGenerateContentAggregate(state, payload);
},
};
@@ -0,0 +1,54 @@
import { createEmptyNormalizedUsage, mergeNormalizedUsage, type NormalizedUsage } from '../../shared/normalized.js';
import { type GeminiGenerateContentAggregateState } from './aggregator.js';
type GeminiRecord = Record<string, unknown>;
function isRecord(value: unknown): value is GeminiRecord {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isAggregateState(value: unknown): value is GeminiGenerateContentAggregateState {
return isRecord(value) && Array.isArray(value.parts) && isRecord(value.usage);
}
export function extractGeminiUsage(payload: unknown): NormalizedUsage {
const usageMetadata = isAggregateState(payload)
? payload.usage
: (isRecord(payload) && isRecord(payload.usageMetadata) ? payload.usageMetadata : null);
if (!usageMetadata) {
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 completionTokens = candidatesTokenCount + thoughtsTokenCount;
const totalTokens = typeof usageMetadata.totalTokenCount === 'number'
? usageMetadata.totalTokenCount
: promptTokens + completionTokens;
const cachedTokens = typeof usageMetadata.cachedContentTokenCount === 'number'
? usageMetadata.cachedContentTokenCount
: 0;
const reasoningTokens = thoughtsTokenCount;
return {
promptTokens,
completionTokens,
totalTokens,
cachedTokens,
reasoningTokens,
};
}
export const geminiGenerateContentUsage = {
empty: createEmptyNormalizedUsage,
fromPayload: extractGeminiUsage,
merge: mergeNormalizedUsage,
};