forked from boos/BoosAPI
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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user