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:
cita-777
2026-04-17 19:41:14 +08:00
parent 6cca74b039
commit 88f40e91f7
133 changed files with 13780 additions and 5670 deletions
@@ -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);
});
});
@@ -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,
};