Preserve the active worktree snapshot on dev for follow-on integration
The root checkout already contained a large in-progress proxy, transformer, and settings refactor. This commit captures that exact work on a dedicated local dev branch without disturbing the original dirty worktree, so the changes can be continued or split from dev instead of living only as uncommitted state. Constraint: The source checkout was already dirty on another branch, so the move had to preserve that worktree while excluding local .idea and .omx residue from the commit Rejected: Switch the dirty root checkout directly to dev | risked disrupting the original branch and in-progress state Confidence: medium Scope-risk: broad Reversibility: clean Directive: Treat this as a transfer snapshot; fix the failing oauth/accountTokens/normalized/docs test regressions before merge or publication Tested: git diff --check; npm run typecheck; npm run repo:drift-check Not-tested: Clean test pass; npm test currently fails in oauth routes, accountTokens sync, shared normalized helpers, gemini route expectations, architecture-boundaries, and docs vitepress config
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
wrapGeminiCliRequest,
|
||||
} from './cliBridge.js';
|
||||
|
||||
describe('gemini cli bridge', () => {
|
||||
it('wraps Gemini CLI requests with project and stripped request model', () => {
|
||||
expect(wrapGeminiCliRequest({
|
||||
modelName: 'models/gemini-2.5-pro',
|
||||
projectId: 'project-123',
|
||||
request: {
|
||||
model: 'ignored-model',
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
},
|
||||
})).toEqual({
|
||||
project: 'project-123',
|
||||
model: 'models/gemini-2.5-pro',
|
||||
request: {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('unwraps Gemini CLI payload envelopes to the response body', () => {
|
||||
expect(unwrapGeminiCliPayload({ response: { ok: true }, project: 'p' })).toEqual({ ok: true });
|
||||
expect(unwrapGeminiCliPayload({ ok: true })).toEqual({ ok: true });
|
||||
expect(unwrapGeminiCliPayload('raw')).toBe('raw');
|
||||
});
|
||||
|
||||
it('rewrites SSE data blocks to the inner Gemini CLI response payload', async () => {
|
||||
const chunks = [
|
||||
Buffer.from('data: {"response":{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}}\n\n'),
|
||||
Buffer.from('data: [DONE]\n\n'),
|
||||
];
|
||||
let index = 0;
|
||||
const reader = createGeminiCliStreamReader({
|
||||
async read() {
|
||||
if (index >= chunks.length) return { done: true, value: undefined };
|
||||
return { done: false, value: new Uint8Array(chunks[index++]) };
|
||||
},
|
||||
async cancel() {
|
||||
return undefined;
|
||||
},
|
||||
releaseLock() {},
|
||||
});
|
||||
|
||||
const first = await reader.read();
|
||||
const second = await reader.read();
|
||||
const third = await reader.read();
|
||||
|
||||
expect(Buffer.from(first.value ?? []).toString('utf8')).toContain('data: {"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}');
|
||||
expect(Buffer.from(second.value ?? []).toString('utf8')).toBe('data: [DONE]\n\n');
|
||||
expect(third.done).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TextDecoder, TextEncoder } from 'node:util';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function wrapGeminiCliRequest(input: {
|
||||
modelName: string;
|
||||
projectId: string;
|
||||
request: Record<string, unknown>;
|
||||
}) {
|
||||
const { model: _model, ...requestPayload } = input.request;
|
||||
return {
|
||||
project: input.projectId,
|
||||
model: input.modelName,
|
||||
request: requestPayload,
|
||||
};
|
||||
}
|
||||
|
||||
export function unwrapGeminiCliPayload<T>(payload: T): unknown {
|
||||
if (!isRecord(payload)) return payload;
|
||||
if (payload.response !== undefined) {
|
||||
return payload.response;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function rewriteGeminiCliSseEventBlock(block: string): string {
|
||||
const lines = block.split(/\r?\n/g);
|
||||
return lines.map((line) => {
|
||||
if (!line.startsWith('data:')) return line;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') return line;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`;
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function createGeminiCliStreamReader(reader: {
|
||||
read(): Promise<{ done: boolean; value?: Uint8Array }>;
|
||||
cancel(reason?: unknown): Promise<unknown>;
|
||||
releaseLock(): void;
|
||||
}) {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
const outputQueue: Uint8Array[] = [];
|
||||
let buffer = '';
|
||||
let done = false;
|
||||
|
||||
async function fillQueue() {
|
||||
while (outputQueue.length <= 0 && !done) {
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
done = true;
|
||||
const tail = decoder.decode();
|
||||
if (tail) buffer += tail;
|
||||
if (buffer.trim()) {
|
||||
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`));
|
||||
buffer = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!result.value) continue;
|
||||
buffer += decoder.decode(result.value, { stream: true });
|
||||
let separatorIndex = buffer.indexOf('\n\n');
|
||||
while (separatorIndex >= 0) {
|
||||
const block = buffer.slice(0, separatorIndex);
|
||||
buffer = buffer.slice(separatorIndex + 2);
|
||||
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`));
|
||||
separatorIndex = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async read() {
|
||||
await fillQueue();
|
||||
if (outputQueue.length > 0) {
|
||||
return { done: false, value: outputQueue.shift() };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
cancel(reason?: unknown) {
|
||||
return reader.cancel(reason);
|
||||
},
|
||||
releaseLock() {
|
||||
reader.releaseLock();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
canonicalRequestFromOpenAiBody,
|
||||
isCanonicalFunctionTool,
|
||||
isCanonicalNamedToolChoice,
|
||||
} from '../../canonical/request.js';
|
||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import {
|
||||
resolveGeminiGenerateContentUrl,
|
||||
@@ -20,12 +15,8 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string {
|
||||
return asTrimmedString(params?.geminiApiVersion) || 'v1beta';
|
||||
return (typeof params?.geminiApiVersion === 'string' ? params.geminiApiVersion.trim() : '') || 'v1beta';
|
||||
}
|
||||
|
||||
export function parseGeminiProxyRequestPath(input: {
|
||||
@@ -61,203 +52,21 @@ export function parseGeminiProxyRequestPath(input: {
|
||||
}
|
||||
|
||||
import { geminiGenerateContentInbound } from './inbound.js';
|
||||
import { geminiGenerateContentOutbound } from './outbound.js';
|
||||
import { geminiGenerateContentStream } from './stream.js';
|
||||
import { geminiGenerateContentResponseBridge } from './responseBridge.js';
|
||||
import { geminiGenerateContentStream } from './streamBridge.js';
|
||||
import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js';
|
||||
import { geminiGenerateContentUsage } from './usage.js';
|
||||
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
|
||||
import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js';
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { raw };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataUrl(value: string): { mimeType: string; data: string } | null {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1],
|
||||
data: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalPartToGeminiPart(part: CanonicalContentPart): Record<string, unknown> | null {
|
||||
if (part.type === 'text') {
|
||||
return {
|
||||
text: part.text,
|
||||
...(part.thought === true ? { thought: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image') {
|
||||
const source = typeof part.dataUrl === 'string' && part.dataUrl.trim()
|
||||
? part.dataUrl
|
||||
: (typeof part.url === 'string' ? part.url : '');
|
||||
if (!source) return null;
|
||||
|
||||
const dataUrl = parseDataUrl(source);
|
||||
if (dataUrl) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: dataUrl.mimeType,
|
||||
data: dataUrl.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: source,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'file') {
|
||||
if (part.fileData) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
data: part.fileData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileUri = part.fileUrl || part.fileId;
|
||||
if (!fileUri) return null;
|
||||
return {
|
||||
fileData: {
|
||||
fileUri,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_call') {
|
||||
return {
|
||||
functionCall: {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
args: parseJsonString(part.argumentsJson),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_result') {
|
||||
return {
|
||||
functionResponse: {
|
||||
name: part.toolCallId,
|
||||
response: part.resultJson ?? parseJsonString(part.resultText ?? ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Record<string, unknown> {
|
||||
const contents: Array<Record<string, unknown>> = [];
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'system' || message.role === 'developer') {
|
||||
systemParts.push(
|
||||
...message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part);
|
||||
|
||||
if (parts.length <= 0) continue;
|
||||
|
||||
if (message.role === 'tool') {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: message.role === 'assistant' ? 'model' : 'user',
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
contents,
|
||||
};
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
payload.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
if (request.reasoning?.budgetTokens !== undefined) {
|
||||
generationConfig.thinkingConfig = {
|
||||
thinkingBudget: request.reasoning.budgetTokens,
|
||||
};
|
||||
} else if (request.reasoning?.effort) {
|
||||
generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig(
|
||||
request.requestedModel,
|
||||
request.reasoning.effort,
|
||||
);
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
payload.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
const functionTools = request.tools.filter(isCanonicalFunctionTool);
|
||||
if (functionTools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: functionTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (request.toolChoice) {
|
||||
if (request.toolChoice === 'none') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (request.toolChoice === 'auto') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (request.toolChoice === 'required') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
} else if (isCanonicalNamedToolChoice(request.toolChoice)) {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [request.toolChoice.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
import {
|
||||
buildCanonicalRequestToGeminiGenerateContentBody,
|
||||
parseGeminiGenerateContentRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
export const geminiGenerateContentTransformer = {
|
||||
protocol: 'gemini/generate-content' as const,
|
||||
inbound: geminiGenerateContentInbound,
|
||||
outbound: geminiGenerateContentOutbound,
|
||||
outbound: geminiGenerateContentResponseBridge,
|
||||
stream: geminiGenerateContentStream,
|
||||
aggregator: {
|
||||
createState: createGeminiGenerateContentAggregateState,
|
||||
@@ -281,53 +90,19 @@ export const geminiGenerateContentTransformer = {
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const rawBody = isRecord(body) ? body : {};
|
||||
const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'model is required',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stream = rawBody.stream === true || ctx?.metadata?.stream === true;
|
||||
const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel);
|
||||
const openAiBody = buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody,
|
||||
modelName: requestedModel,
|
||||
stream,
|
||||
});
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
return parseGeminiGenerateContentRequestToCanonical(body, ctx);
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
return buildGeminiRequestFromCanonical(request);
|
||||
return buildCanonicalRequestToGeminiGenerateContentBody(request);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
geminiGenerateContentInbound,
|
||||
geminiGenerateContentOutbound,
|
||||
geminiGenerateContentResponseBridge as geminiGenerateContentOutbound,
|
||||
geminiGenerateContentStream,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
applyGeminiGenerateContentAggregate,
|
||||
|
||||
@@ -1,279 +1,15 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import {
|
||||
toTransformerMetadataRecord,
|
||||
type TransformerMetadata,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
resolveGeminiGenerateContentUrl,
|
||||
resolveGeminiModelsUrl,
|
||||
resolveGeminiNativeBaseUrl,
|
||||
} from './urlResolver.js';
|
||||
extractGeminiGenerateContentResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata,
|
||||
geminiGenerateContentResponseBridge,
|
||||
serializeGeminiGenerateContentAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
|
||||
type GeminiRecord = Record<string, unknown>;
|
||||
export const geminiGenerateContentOutbound = geminiGenerateContentResponseBridge;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 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 ensurePassthrough(metadata: TransformerMetadata): Record<string, unknown> {
|
||||
if (!metadata.passthrough) metadata.passthrough = {};
|
||||
return metadata.passthrough;
|
||||
}
|
||||
|
||||
function extractRequestSemantics(requestPayload: unknown): TransformerMetadata {
|
||||
if (!isRecord(requestPayload)) return {};
|
||||
|
||||
const metadata: TransformerMetadata = {};
|
||||
const passthrough = ensurePassthrough(metadata);
|
||||
if (requestPayload.systemInstruction !== undefined) {
|
||||
passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||
}
|
||||
if (requestPayload.cachedContent !== undefined) {
|
||||
passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||
}
|
||||
if (requestPayload.safetySettings !== undefined) {
|
||||
metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||
}
|
||||
if (requestPayload.toolConfig !== undefined) {
|
||||
passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||
}
|
||||
|
||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||
if (generationConfig) {
|
||||
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) {
|
||||
if (key === 'imageConfig') {
|
||||
metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]);
|
||||
} else {
|
||||
passthrough[key] = cloneJsonValue(generationConfig[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(requestPayload.tools)) {
|
||||
const requestTools = requestPayload.tools
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
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);
|
||||
|
||||
if (requestTools.length > 0) {
|
||||
passthrough.tools = requestTools;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) {
|
||||
delete metadata.passthrough;
|
||||
}
|
||||
|
||||
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 extractTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata {
|
||||
const state = ensureAggregateState(payload);
|
||||
const metadata = extractRequestSemantics(requestPayload);
|
||||
|
||||
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;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
||||
return toTransformerMetadataRecord(extractTransformerMetadata(payload, requestPayload)) ?? {};
|
||||
}
|
||||
|
||||
export const geminiGenerateContentOutbound = {
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
extractTransformerMetadata,
|
||||
extractResponseMetadata,
|
||||
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
||||
};
|
||||
export {
|
||||
geminiGenerateContentResponseBridge,
|
||||
extractGeminiGenerateContentResponseMetadata as extractResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata as extractTransformerMetadata,
|
||||
serializeGeminiGenerateContentAggregateResponse as serializeGeminiAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildGeminiGenerateContentRequestFromOpenAi,
|
||||
buildCanonicalRequestToGeminiGenerateContentBody,
|
||||
parseGeminiGenerateContentRequestToCanonical,
|
||||
} from './requestBridge.js';
|
||||
|
||||
describe('gemini generate-content request bridge', () => {
|
||||
it('parses Gemini generateContent bodies into canonical envelopes', () => {
|
||||
const result = parseGeminiGenerateContentRequestToCanonical({
|
||||
model: 'gemini-2.5-pro',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
surface: 'gemini-generate-content',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Gemini generateContent bodies from canonical envelopes', () => {
|
||||
const body = buildCanonicalRequestToGeminiGenerateContentBody({
|
||||
operation: 'generate',
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: 'gemini_cli',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
tools: [{ name: 'lookup', inputSchema: { type: 'object' } }],
|
||||
toolChoice: 'required',
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookup',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
},
|
||||
},
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('injects provider thought signatures into functionCall parts', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me check.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
|
||||
provider_specific_fields: { thought_signature: 'real_sig_abc' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(functionCallParts[0].thoughtSignature).toBe('real_sig_abc');
|
||||
});
|
||||
|
||||
it('splits text and signed functionCall parts into separate model messages', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read the file.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'I will read it.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_456',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/tmp/x"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_split_test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_456', content: 'file content here' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
|
||||
expect(modelMessages).toHaveLength(2);
|
||||
expect((modelMessages[0].parts as Array<Record<string, unknown>>).every((part) => 'text' in part)).toBe(true);
|
||||
expect((modelMessages[1].parts as Array<Record<string, unknown>>).every((part) => 'functionCall' in part)).toBe(true);
|
||||
expect((modelMessages[1].parts as Array<Record<string, unknown>>)[0].thoughtSignature).toBe('sig_split_test');
|
||||
});
|
||||
|
||||
it('disables thinking config when signatures are missing for non-gemini targets', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
body: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig_non_gemini',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig_non_gemini', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(functionCallParts[0].thoughtSignature).toBeUndefined();
|
||||
expect((result.generationConfig as Record<string, unknown> | undefined)?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('injects a dummy thought signature when thinking is enabled and no signature is available', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMessages = contents.filter((content) => content.role === 'model');
|
||||
const functionCallParts = modelMessages
|
||||
.flatMap((message) => message.parts as Array<Record<string, unknown>>)
|
||||
.filter((part) => 'functionCall' in part);
|
||||
|
||||
expect(functionCallParts).toHaveLength(1);
|
||||
expect(typeof functionCallParts[0].thoughtSignature).toBe('string');
|
||||
expect((functionCallParts[0].thoughtSignature as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('preserves functionResponse count matching functionCall count', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read two files.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_a',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/a"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_a' },
|
||||
},
|
||||
{
|
||||
id: 'call_b',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/b"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_b' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_a', content: 'content a' },
|
||||
{ role: 'tool', tool_call_id: 'call_b', content: 'content b' },
|
||||
],
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
let functionCallCount = 0;
|
||||
let functionResponseCount = 0;
|
||||
for (const content of contents) {
|
||||
for (const part of (content.parts as Array<Record<string, unknown>>)) {
|
||||
if ('functionCall' in part) functionCallCount += 1;
|
||||
if ('functionResponse' in part) functionResponseCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
expect(functionCallCount).toBe(2);
|
||||
expect(functionResponseCount).toBe(2);
|
||||
});
|
||||
});
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildGeminiGenerateContentRequestFromOpenAi } from './requestBridge.js';
|
||||
|
||||
describe('thoughtSignature injection in OpenAI→Gemini conversion', () => {
|
||||
it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'What is the weather?' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me check.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
|
||||
provider_specific_fields: { thought_signature: 'real_sig_abc' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
// Find the model message with functionCall
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
expect(fcParts[0].thoughtSignature).toBe('real_sig_abc');
|
||||
});
|
||||
|
||||
it('splits text and signed functionCall parts into separate model messages', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read the file.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'I will read it.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_456',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/tmp/x"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_split_test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_456', content: 'file content here' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
|
||||
// Should be 2 model messages: text (unsigned) + functionCall (signed)
|
||||
expect(modelMsgs.length).toBe(2);
|
||||
|
||||
const firstParts = modelMsgs[0].parts as Array<Record<string, unknown>>;
|
||||
const secondParts = modelMsgs[1].parts as Array<Record<string, unknown>>;
|
||||
|
||||
// First: text only, no thoughtSignature
|
||||
expect(firstParts.every((p) => 'text' in p)).toBe(true);
|
||||
expect(firstParts.every((p) => !('thoughtSignature' in p))).toBe(true);
|
||||
|
||||
// Second: functionCall with thoughtSignature
|
||||
expect(secondParts.every((p) => 'functionCall' in p)).toBe(true);
|
||||
expect(secondParts[0].thoughtSignature).toBe('sig_split_test');
|
||||
});
|
||||
|
||||
it('injects dummy sentinel when thinking enabled but no signature available', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
// No provider_specific_fields — signature missing
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
// Should have a dummy sentinel, not be missing
|
||||
expect(typeof fcParts[0].thoughtSignature).toBe('string');
|
||||
expect((fcParts[0].thoughtSignature as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not inject thoughtSignature when thinking is not enabled', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_think',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/x"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_think', content: 'data' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-2.5-flash',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
// No thinking → no signature injected
|
||||
expect(fcParts[0].thoughtSignature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not inject dummy signature or preserve thinkingConfig for non-gemini targets when signature is missing', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
reasoning_effort: 'high',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Do something.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_no_sig_non_gemini',
|
||||
type: 'function',
|
||||
function: { name: 'Bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_no_sig_non_gemini', content: 'file1\nfile2' },
|
||||
],
|
||||
},
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
const modelMsgs = contents.filter((c) => c.role === 'model');
|
||||
const fcParts = modelMsgs
|
||||
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
|
||||
.filter((p) => 'functionCall' in p);
|
||||
|
||||
expect(fcParts.length).toBe(1);
|
||||
expect(fcParts[0].thoughtSignature).toBeUndefined();
|
||||
expect((result.generationConfig as Record<string, unknown> | undefined)?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves functionResponse count matching functionCall count', () => {
|
||||
const result = buildGeminiGenerateContentRequestFromOpenAi({
|
||||
body: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Read two files.' },
|
||||
{
|
||||
role: 'assistant',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_a',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/a"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_a' },
|
||||
},
|
||||
{
|
||||
id: 'call_b',
|
||||
type: 'function',
|
||||
function: { name: 'Read', arguments: '{"path":"/b"}' },
|
||||
provider_specific_fields: { thought_signature: 'sig_b' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_a', content: 'content a' },
|
||||
{ role: 'tool', tool_call_id: 'call_b', content: 'content b' },
|
||||
],
|
||||
},
|
||||
modelName: 'gemini-3-flash-preview',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
const contents = result.contents as Array<Record<string, unknown>>;
|
||||
|
||||
// Count functionCall and functionResponse parts
|
||||
let fcCount = 0;
|
||||
let frCount = 0;
|
||||
for (const content of contents) {
|
||||
for (const part of (content.parts as Array<Record<string, unknown>>)) {
|
||||
if ('functionCall' in part) fcCount++;
|
||||
if ('functionResponse' in part) frCount++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(fcCount).toBe(2);
|
||||
expect(frCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
import { canonicalRequestFromOpenAiBody } from '../../canonical/openAiRequestBridge.js';
|
||||
import { isCanonicalFunctionTool, isCanonicalNamedToolChoice } from '../../canonical/tools.js';
|
||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolParseContext } from '../../contracts.js';
|
||||
import { geminiGenerateContentInbound } from './inbound.js';
|
||||
import { buildOpenAiBodyFromGeminiRequest } from './compatibility.js';
|
||||
import {
|
||||
reasoningEffortToGeminiThinkingConfig,
|
||||
resolveGeminiThinkingConfigFromRequest,
|
||||
} from './convert.js';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { raw };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataUrl(value: string): { mimeType: string; data: string } | null {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1],
|
||||
data: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
// Dummy sentinel used when no real thoughtSignature is available but thinking
|
||||
// mode is enabled. Gemini accepts any base64 string and won't reject this.
|
||||
const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=';
|
||||
|
||||
function isDummyThoughtSafeModel(modelName: string): boolean {
|
||||
const normalized = asTrimmedString(modelName).toLowerCase();
|
||||
return normalized.startsWith('gemini-') || normalized.startsWith('models/gemini-');
|
||||
}
|
||||
|
||||
function parseInlineDataUrl(url: string): { mimeType: string; data: string } | null {
|
||||
if (!url.startsWith('data:')) return null;
|
||||
const [, rest] = url.split('data:', 2);
|
||||
const [meta, data] = rest.split(',', 2);
|
||||
if (!meta || !data) return null;
|
||||
const [mimeType] = meta.split(';', 1);
|
||||
return {
|
||||
mimeType: mimeType || 'application/octet-stream',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFunctionResponseResult(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function convertOpenAiContentToGeminiParts(content: unknown): Array<Record<string, unknown>> {
|
||||
if (typeof content === 'string') {
|
||||
const trimmed = content.trim();
|
||||
return trimmed ? [{ text: trimmed }] : [];
|
||||
}
|
||||
|
||||
if (isRecord(content)) {
|
||||
if (typeof content.text === 'string') {
|
||||
const trimmed = content.text.trim();
|
||||
return trimmed ? [{ text: trimmed }] : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
for (const item of content) {
|
||||
if (!isRecord(item)) continue;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'text') {
|
||||
const text = asTrimmedString(item.text);
|
||||
if (text) parts.push({ text });
|
||||
continue;
|
||||
}
|
||||
if (type === 'image_url') {
|
||||
const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.url);
|
||||
const parsed = imageUrl ? parseInlineDataUrl(imageUrl) : null;
|
||||
if (parsed) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mime_type: parsed.mimeType,
|
||||
data: parsed.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_audio') {
|
||||
const data = asTrimmedString(item.data);
|
||||
if (data) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mime_type: 'audio/wav',
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildGeminiToolsFromOpenAi(tools: unknown): Array<Record<string, unknown>> | undefined {
|
||||
if (!Array.isArray(tools)) return undefined;
|
||||
const declarations = tools
|
||||
.filter((item) => isRecord(item))
|
||||
.flatMap((item) => {
|
||||
if (asTrimmedString(item.type) !== 'function' || !isRecord(item.function)) return [];
|
||||
const fn = item.function as Record<string, unknown>;
|
||||
const name = asTrimmedString(fn.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(fn.description) ? { description: asTrimmedString(fn.description) } : {}),
|
||||
parametersJsonSchema: isRecord(fn.parameters) ? fn.parameters : { type: 'object', properties: {} },
|
||||
}];
|
||||
});
|
||||
|
||||
if (declarations.length <= 0) return undefined;
|
||||
return [{ functionDeclarations: declarations }];
|
||||
}
|
||||
|
||||
function buildGeminiToolConfigFromOpenAi(toolChoice: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof toolChoice === 'string') {
|
||||
const normalized = toolChoice.trim().toLowerCase();
|
||||
if (normalized === 'none') {
|
||||
return { functionCallingConfig: { mode: 'NONE' } };
|
||||
}
|
||||
if (normalized === 'required') {
|
||||
return { functionCallingConfig: { mode: 'ANY' } };
|
||||
}
|
||||
return { functionCallingConfig: { mode: 'AUTO' } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildGeminiGenerateContentRequestFromOpenAi(input: {
|
||||
body: Record<string, unknown>;
|
||||
modelName: string;
|
||||
instructions?: string;
|
||||
}) {
|
||||
const request: Record<string, unknown> = {
|
||||
contents: [],
|
||||
};
|
||||
|
||||
const messages = Array.isArray(input.body.messages) ? input.body.messages : [];
|
||||
const toolNameById = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const name = asTrimmedString(toolCall.function.name);
|
||||
if (id && name) {
|
||||
toolNameById.set(id, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
|
||||
const allowsDummyThoughtSignature = isDummyThoughtSafeModel(input.modelName);
|
||||
let shouldDisableThinkingConfig = false;
|
||||
|
||||
const thoughtSignatureById = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue;
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
if (!id) continue;
|
||||
const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null;
|
||||
if (providerFields && typeof providerFields.thought_signature === 'string') {
|
||||
thoughtSignatureById.set(id, providerFields.thought_signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
if (typeof input.instructions === 'string' && input.instructions.trim()) {
|
||||
systemParts.push({ text: input.instructions.trim() });
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (!isRecord(message)) continue;
|
||||
const role = asTrimmedString(message.role).toLowerCase();
|
||||
if (role === 'system' || role === 'developer') {
|
||||
systemParts.push(...convertOpenAiContentToGeminiParts(message.content));
|
||||
continue;
|
||||
}
|
||||
if (role === 'tool') {
|
||||
const toolCallId = asTrimmedString(message.tool_call_id);
|
||||
const name = toolNameById.get(toolCallId) || 'unknown';
|
||||
const result = normalizeFunctionResponseResult(message.content);
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{
|
||||
functionResponse: {
|
||||
name,
|
||||
response: {
|
||||
result,
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
const textParts = convertOpenAiContentToGeminiParts(message.content);
|
||||
const fcParts: Array<Record<string, unknown>> = [];
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue;
|
||||
const name = asTrimmedString(toolCall.function.name);
|
||||
if (!name) continue;
|
||||
const rawArguments = toolCall.function.arguments;
|
||||
let args: unknown = {};
|
||||
if (typeof rawArguments === 'string' && rawArguments.trim()) {
|
||||
try {
|
||||
args = JSON.parse(rawArguments);
|
||||
} catch {
|
||||
args = { raw: rawArguments };
|
||||
}
|
||||
} else if (isRecord(rawArguments)) {
|
||||
args = rawArguments;
|
||||
}
|
||||
const fcPart: Record<string, unknown> = {
|
||||
functionCall: { name, args },
|
||||
};
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const signature = thoughtSignatureById.get(id);
|
||||
if (signature) {
|
||||
fcPart.thoughtSignature = signature;
|
||||
} else if (hasThinkingEnabled && allowsDummyThoughtSignature) {
|
||||
fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE;
|
||||
} else if (hasThinkingEnabled) {
|
||||
shouldDisableThinkingConfig = true;
|
||||
}
|
||||
fcParts.push(fcPart);
|
||||
}
|
||||
|
||||
const geminiRole = role === 'assistant' ? 'model' : 'user';
|
||||
const hasSigned = fcParts.some((part) => 'thoughtSignature' in part);
|
||||
if (hasSigned && textParts.length > 0 && fcParts.length > 0) {
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{ role: geminiRole, parts: textParts },
|
||||
{ role: geminiRole, parts: fcParts },
|
||||
];
|
||||
} else {
|
||||
const allParts = [...textParts, ...fcParts];
|
||||
if (allParts.length <= 0) continue;
|
||||
request.contents = [
|
||||
...(Array.isArray(request.contents) ? request.contents : []),
|
||||
{ role: geminiRole, parts: allParts },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
request.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
const maxOutputTokens = Number(
|
||||
input.body.max_output_tokens
|
||||
?? input.body.max_completion_tokens
|
||||
?? input.body.max_tokens
|
||||
?? 0,
|
||||
);
|
||||
if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
|
||||
generationConfig.maxOutputTokens = Math.trunc(maxOutputTokens);
|
||||
}
|
||||
const temperature = Number(input.body.temperature);
|
||||
if (Number.isFinite(temperature)) generationConfig.temperature = temperature;
|
||||
const topP = Number(input.body.top_p);
|
||||
if (Number.isFinite(topP)) generationConfig.topP = topP;
|
||||
const topK = Number(input.body.top_k);
|
||||
if (Number.isFinite(topK)) generationConfig.topK = topK;
|
||||
if (Array.isArray(input.body.stop) && input.body.stop.length > 0) {
|
||||
generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
|
||||
if (thinkingConfig && !shouldDisableThinkingConfig) {
|
||||
generationConfig.thinkingConfig = thinkingConfig;
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
request.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
const geminiTools = buildGeminiToolsFromOpenAi(input.body.tools);
|
||||
if (geminiTools) {
|
||||
request.tools = geminiTools;
|
||||
}
|
||||
const toolConfig = buildGeminiToolConfigFromOpenAi(input.body.tool_choice);
|
||||
if (toolConfig) {
|
||||
request.toolConfig = toolConfig;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
function canonicalPartToGeminiPart(part: CanonicalContentPart): Record<string, unknown> | null {
|
||||
if (part.type === 'text') {
|
||||
return {
|
||||
text: part.text,
|
||||
...(part.thought === true ? { thought: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image') {
|
||||
const source = typeof part.dataUrl === 'string' && part.dataUrl.trim()
|
||||
? part.dataUrl
|
||||
: (typeof part.url === 'string' ? part.url : '');
|
||||
if (!source) return null;
|
||||
|
||||
const dataUrl = parseDataUrl(source);
|
||||
if (dataUrl) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: dataUrl.mimeType,
|
||||
data: dataUrl.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: source,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'file') {
|
||||
if (part.fileData) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
data: part.fileData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileUri = part.fileUrl || part.fileId;
|
||||
if (!fileUri) return null;
|
||||
return {
|
||||
fileData: {
|
||||
fileUri,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_call') {
|
||||
return {
|
||||
functionCall: {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
args: parseJsonString(part.argumentsJson),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_result') {
|
||||
return {
|
||||
functionResponse: {
|
||||
name: part.toolCallId,
|
||||
response: part.resultJson ?? parseJsonString(part.resultText ?? ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCanonicalRequestToGeminiGenerateContentBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const contents: Array<Record<string, unknown>> = [];
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'system' || message.role === 'developer') {
|
||||
systemParts.push(
|
||||
...message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part);
|
||||
|
||||
if (parts.length <= 0) continue;
|
||||
|
||||
if (message.role === 'tool') {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: message.role === 'assistant' ? 'model' : 'user',
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
contents,
|
||||
};
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
payload.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
if (request.reasoning?.budgetTokens !== undefined) {
|
||||
generationConfig.thinkingConfig = {
|
||||
thinkingBudget: request.reasoning.budgetTokens,
|
||||
};
|
||||
} else if (request.reasoning?.effort) {
|
||||
generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig(
|
||||
request.requestedModel,
|
||||
request.reasoning.effort,
|
||||
);
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
payload.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
const functionTools = request.tools.filter(isCanonicalFunctionTool);
|
||||
if (functionTools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: functionTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
if (request.toolChoice) {
|
||||
if (request.toolChoice === 'none') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (request.toolChoice === 'auto') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (request.toolChoice === 'required') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
} else if (isCanonicalNamedToolChoice(request.toolChoice)) {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [request.toolChoice.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function parseGeminiGenerateContentRequestToCanonical(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const rawBody = isRecord(body) ? body : {};
|
||||
const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'model is required',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stream = rawBody.stream === true || ctx?.metadata?.stream === true;
|
||||
const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel);
|
||||
const openAiBody = buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody,
|
||||
modelName: requestedModel,
|
||||
stream,
|
||||
});
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractGeminiGenerateContentResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata,
|
||||
geminiGenerateContentResponseBridge,
|
||||
serializeGeminiGenerateContentAggregateResponse,
|
||||
} from './responseBridge.js';
|
||||
import { geminiGenerateContentOutbound } from './outbound.js';
|
||||
|
||||
describe('gemini generate-content response bridge', () => {
|
||||
it('serializes aggregate responses with usage metadata', () => {
|
||||
const payload = serializeGeminiGenerateContentAggregateResponse({
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
finishReason: 'STOP',
|
||||
parts: [{ text: 'hello' }],
|
||||
candidates: [],
|
||||
groundingMetadata: [],
|
||||
citations: [],
|
||||
thoughtSignatures: [],
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload).toEqual({
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
candidates: [{
|
||||
index: 0,
|
||||
finishReason: 'STOP',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
}],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts transformer and response metadata from aggregate payloads', () => {
|
||||
const payload = {
|
||||
responseId: 'resp-1',
|
||||
modelVersion: 'gemini-2.5-pro',
|
||||
finishReason: 'STOP',
|
||||
parts: [{ text: 'hello', thoughtSignature: 'sig-1', thought: true }],
|
||||
candidates: [],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usage: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractGeminiGenerateContentTransformerMetadata(payload, {
|
||||
systemInstruction: { parts: [{ text: 'system prompt' }] },
|
||||
})).toMatchObject({
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
thoughtSignature: 'sig-1',
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
passthrough: {
|
||||
systemInstruction: { parts: [{ text: 'system prompt' }] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(extractGeminiGenerateContentResponseMetadata(payload)).toMatchObject({
|
||||
citations: [{ citations: [{ uri: 'https://a.example' }] }],
|
||||
groundingMetadata: [{ webSearchQueries: ['cat'] }],
|
||||
thoughtSignature: 'sig-1',
|
||||
thoughtSignatures: ['sig-1'],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 4,
|
||||
totalTokenCount: 14,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the outbound facade pointed at the response bridge object', () => {
|
||||
expect(geminiGenerateContentOutbound).toBe(geminiGenerateContentResponseBridge);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import {
|
||||
toTransformerMetadataRecord,
|
||||
type TransformerMetadata,
|
||||
} from '../../shared/normalized.js';
|
||||
import {
|
||||
resolveGeminiGenerateContentUrl,
|
||||
resolveGeminiModelsUrl,
|
||||
resolveGeminiNativeBaseUrl,
|
||||
} from './urlResolver.js';
|
||||
|
||||
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 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 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 ensurePassthrough(metadata: TransformerMetadata): Record<string, unknown> {
|
||||
if (!metadata.passthrough) metadata.passthrough = {};
|
||||
return metadata.passthrough;
|
||||
}
|
||||
|
||||
function extractRequestSemantics(requestPayload: unknown): TransformerMetadata {
|
||||
if (!isRecord(requestPayload)) return {};
|
||||
|
||||
const metadata: TransformerMetadata = {};
|
||||
const passthrough = ensurePassthrough(metadata);
|
||||
if (requestPayload.systemInstruction !== undefined) {
|
||||
passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction);
|
||||
}
|
||||
if (requestPayload.cachedContent !== undefined) {
|
||||
passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent);
|
||||
}
|
||||
if (requestPayload.safetySettings !== undefined) {
|
||||
metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings);
|
||||
}
|
||||
if (requestPayload.toolConfig !== undefined) {
|
||||
passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig);
|
||||
}
|
||||
|
||||
const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null;
|
||||
if (generationConfig) {
|
||||
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) {
|
||||
if (key === 'imageConfig') {
|
||||
metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]);
|
||||
} else {
|
||||
passthrough[key] = cloneJsonValue(generationConfig[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(requestPayload.tools)) {
|
||||
const requestTools = requestPayload.tools
|
||||
.filter((item) => isRecord(item))
|
||||
.map((item) => {
|
||||
const next: GeminiRecord = {};
|
||||
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);
|
||||
|
||||
if (requestTools.length > 0) {
|
||||
passthrough.tools = requestTools;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) {
|
||||
delete metadata.passthrough;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function serializeGeminiGenerateContentAggregateResponse(
|
||||
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 extractGeminiGenerateContentTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata {
|
||||
const state = ensureAggregateState(payload);
|
||||
const metadata = extractRequestSemantics(requestPayload);
|
||||
|
||||
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;
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function extractGeminiGenerateContentResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord {
|
||||
return toTransformerMetadataRecord(extractGeminiGenerateContentTransformerMetadata(payload, requestPayload)) ?? {};
|
||||
}
|
||||
|
||||
export const geminiGenerateContentResponseBridge = {
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
extractTransformerMetadata: extractGeminiGenerateContentTransformerMetadata,
|
||||
extractResponseMetadata: extractGeminiGenerateContentResponseMetadata,
|
||||
serializeAggregateResponse: serializeGeminiGenerateContentAggregateResponse,
|
||||
};
|
||||
|
||||
export const geminiGenerateContentOutbound = geminiGenerateContentResponseBridge;
|
||||
|
||||
export {
|
||||
extractGeminiGenerateContentResponseMetadata as extractResponseMetadata,
|
||||
extractGeminiGenerateContentTransformerMetadata as extractTransformerMetadata,
|
||||
serializeGeminiGenerateContentAggregateResponse as serializeGeminiAggregateResponse,
|
||||
};
|
||||
@@ -1,253 +1,21 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import { serializeGeminiAggregateResponse } from './outbound.js';
|
||||
|
||||
type ParsedSsePayloads = {
|
||||
events: unknown[];
|
||||
lines: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type GeminiGenerateContentStreamFormat = 'sse' | 'json';
|
||||
|
||||
type ParsedGeminiStreamPayload = {
|
||||
format: GeminiGenerateContentStreamFormat;
|
||||
events: unknown[];
|
||||
lines?: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & {
|
||||
state: GeminiGenerateContentAggregateState;
|
||||
};
|
||||
|
||||
function serializeSsePayload(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
const events: unknown[] = [];
|
||||
const lines: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor));
|
||||
if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break;
|
||||
|
||||
const boundary = cursor + boundaryMatch.index;
|
||||
const block = buffer.slice(cursor, boundary);
|
||||
const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length);
|
||||
cursor = boundary + boundaryMatch[0].length;
|
||||
|
||||
if (!block.trim()) continue;
|
||||
lines.push(rawBlock);
|
||||
|
||||
const data = block
|
||||
.replace(/\r\n/g, '\n')
|
||||
.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,
|
||||
lines,
|
||||
rest: buffer.slice(cursor),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
lines: parsed.lines,
|
||||
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 {
|
||||
...parsed,
|
||||
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.lines ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const events = parseJsonArrayPayload(payload);
|
||||
for (const event of events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
applyJsonPayloadToAggregate(state, payload);
|
||||
return serializeAggregateJsonPayload(state);
|
||||
}
|
||||
|
||||
export const geminiGenerateContentStream = {
|
||||
isSseContentType(contentType: string | null | undefined): boolean {
|
||||
return (contentType || '').toLowerCase().includes('text/event-stream');
|
||||
},
|
||||
|
||||
parseJsonArrayPayload,
|
||||
parseGeminiStreamPayload,
|
||||
parseSsePayloads,
|
||||
serializeSsePayload,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
|
||||
createAggregateState(): GeminiGenerateContentAggregateState {
|
||||
return createGeminiGenerateContentAggregateState();
|
||||
},
|
||||
|
||||
applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState {
|
||||
return applyGeminiGenerateContentAggregate(state, payload);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applyParsedPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
geminiGenerateContentStream,
|
||||
parseGeminiStreamPayload,
|
||||
parseJsonArrayPayload,
|
||||
parseSsePayloads,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
serializeSsePayload,
|
||||
consumeUpstreamSseBuffer,
|
||||
};
|
||||
serializeUpstreamJsonPayload,
|
||||
} from './streamBridge.js';
|
||||
export type {
|
||||
AppliedGeminiStreamPayloads,
|
||||
GeminiGenerateContentStreamFormat,
|
||||
ParsedGeminiStreamPayload,
|
||||
ParsedSsePayloads,
|
||||
};
|
||||
} from './streamBridge.js';
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
consumeUpstreamSseBuffer,
|
||||
geminiGenerateContentStream,
|
||||
parseGeminiStreamPayload,
|
||||
serializeAggregateSsePayload,
|
||||
} from './streamBridge.js';
|
||||
|
||||
describe('gemini generate-content stream bridge', () => {
|
||||
it('parses SSE payloads and preserves trailing rest blocks', () => {
|
||||
const parsed = parseGeminiStreamPayload([
|
||||
'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]}}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'data: {"responseId":"partial"',
|
||||
].join('\n'), 'text/event-stream');
|
||||
|
||||
expect(parsed.format).toBe('sse');
|
||||
expect(parsed.events).toHaveLength(1);
|
||||
expect(parsed.rest).toBe('data: {"responseId":"partial"');
|
||||
});
|
||||
|
||||
it('aggregates upstream SSE blocks while preserving raw lines and serializable final output', () => {
|
||||
const state = geminiGenerateContentStream.createAggregateState();
|
||||
const result = consumeUpstreamSseBuffer(state, [
|
||||
'data: {"responseId":"resp-1","candidates":[{"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
expect(result.lines).toHaveLength(2);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.rest).toBe('');
|
||||
expect(serializeAggregateSsePayload(result.state)).toContain('"responseId":"resp-1"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
applyGeminiGenerateContentAggregate,
|
||||
createGeminiGenerateContentAggregateState,
|
||||
type GeminiGenerateContentAggregateState,
|
||||
} from './aggregator.js';
|
||||
import { serializeGeminiGenerateContentAggregateResponse } from './responseBridge.js';
|
||||
|
||||
type ParsedSsePayloads = {
|
||||
events: unknown[];
|
||||
lines: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type GeminiGenerateContentStreamFormat = 'sse' | 'json';
|
||||
|
||||
type ParsedGeminiStreamPayload = {
|
||||
format: GeminiGenerateContentStreamFormat;
|
||||
events: unknown[];
|
||||
lines?: string[];
|
||||
rest: string;
|
||||
};
|
||||
|
||||
type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & {
|
||||
state: GeminiGenerateContentAggregateState;
|
||||
};
|
||||
|
||||
function serializeSsePayload(payload: unknown): string {
|
||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
||||
}
|
||||
|
||||
function parseSsePayloads(buffer: string): ParsedSsePayloads {
|
||||
const events: unknown[] = [];
|
||||
const lines: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < buffer.length) {
|
||||
const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor));
|
||||
if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break;
|
||||
|
||||
const boundary = cursor + boundaryMatch.index;
|
||||
const block = buffer.slice(cursor, boundary);
|
||||
const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length);
|
||||
cursor = boundary + boundaryMatch[0].length;
|
||||
|
||||
if (!block.trim()) continue;
|
||||
lines.push(rawBlock);
|
||||
|
||||
const data = block
|
||||
.replace(/\r\n/g, '\n')
|
||||
.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,
|
||||
lines,
|
||||
rest: buffer.slice(cursor),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
lines: parsed.lines,
|
||||
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 {
|
||||
...parsed,
|
||||
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.lines ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAggregateJsonPayload(
|
||||
payload: GeminiGenerateContentAggregateState | unknown,
|
||||
): unknown {
|
||||
return serializeGeminiGenerateContentAggregateResponse(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) {
|
||||
const events = parseJsonArrayPayload(payload);
|
||||
for (const event of events) {
|
||||
applyGeminiGenerateContentAggregate(state, event);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
applyJsonPayloadToAggregate(state, payload);
|
||||
return serializeAggregateJsonPayload(state);
|
||||
}
|
||||
|
||||
export const geminiGenerateContentStream = {
|
||||
isSseContentType(contentType: string | null | undefined): boolean {
|
||||
return (contentType || '').toLowerCase().includes('text/event-stream');
|
||||
},
|
||||
|
||||
parseJsonArrayPayload,
|
||||
parseGeminiStreamPayload,
|
||||
parseSsePayloads,
|
||||
serializeSsePayload,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
consumeUpstreamSseBuffer,
|
||||
|
||||
createAggregateState(): GeminiGenerateContentAggregateState {
|
||||
return createGeminiGenerateContentAggregateState();
|
||||
},
|
||||
|
||||
applyAggregate(state: GeminiGenerateContentAggregateState, payload: unknown): GeminiGenerateContentAggregateState {
|
||||
return applyGeminiGenerateContentAggregate(state, payload);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
applyParsedPayloadToAggregate,
|
||||
applyJsonPayloadToAggregate,
|
||||
applySsePayloadsToAggregate,
|
||||
parseGeminiStreamPayload,
|
||||
parseJsonArrayPayload,
|
||||
parseSsePayloads,
|
||||
serializeAggregateJsonPayload,
|
||||
serializeAggregatePayload,
|
||||
serializeAggregateSsePayload,
|
||||
serializeUpstreamJsonPayload,
|
||||
serializeSsePayload,
|
||||
consumeUpstreamSseBuffer,
|
||||
};
|
||||
export type {
|
||||
AppliedGeminiStreamPayloads,
|
||||
GeminiGenerateContentStreamFormat,
|
||||
ParsedGeminiStreamPayload,
|
||||
ParsedSsePayloads,
|
||||
};
|
||||
Reference in New Issue
Block a user