forked from boos/BoosAPI
Refactor proxy-core surfaces and transformer boundaries (#163)
* refactor: add canonical request contracts * test: harden transformer boundary contracts * refactor: adapt protocol transformers to canonical contract * refactor: add cli profile registry * refactor: extract provider profiles * refactor: add conductor and provider executors * refactor: add models surface * refactor: migrate responses surface * refactor: migrate chat surface * refactor: migrate gemini surface * refactor: migrate file surfaces * test: align runtime executor undici mock * fix: preserve compact SSE closeout payloads * feat: support all-api-hub v2 backup imports * fix: aggregate codex oauth SSE final payloads * fix: harden responses SSE failure handling
This commit is contained in:
@@ -15,6 +15,86 @@ import {
|
||||
} from './stream.js';
|
||||
|
||||
describe('geminiGenerateContentTransformer.inbound', () => {
|
||||
it('parses native Gemini requests into canonical envelopes', () => {
|
||||
const result = geminiGenerateContentTransformer.parseRequest({
|
||||
model: 'gemini-2.5-pro',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds native Gemini requests from canonical envelopes', () => {
|
||||
const body = geminiGenerateContentTransformer.buildProtocolRequest({
|
||||
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('preserves native Gemini request fields through normalization', () => {
|
||||
const body = geminiGenerateContentTransformer.inbound.normalizeRequest({
|
||||
contents: [
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js';
|
||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
}
|
||||
@@ -10,6 +13,10 @@ 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 resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (baseIncludesVersion(normalized)) return normalized;
|
||||
@@ -85,6 +92,189 @@ 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) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: request.tools.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 {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [request.toolChoice.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const geminiGenerateContentTransformer = {
|
||||
protocol: 'gemini/generate-content' as const,
|
||||
inbound: geminiGenerateContentInbound,
|
||||
@@ -108,6 +298,52 @@ export const geminiGenerateContentTransformer = {
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
parseRequest(
|
||||
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,
|
||||
}),
|
||||
};
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
return buildGeminiRequestFromCanonical(request);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
Reference in New Issue
Block a user