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:
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { anthropicMessagesTransformer } from './index.js';
|
||||
|
||||
describe('anthropicMessagesTransformer protocol contract', () => {
|
||||
it('parses native messages requests into canonical envelopes', () => {
|
||||
const result = anthropicMessagesTransformer.parseRequest({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
user_id: 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account__session_11111111-2222-3333-4444-555555555555',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds native messages requests from canonical envelopes', () => {
|
||||
const body = anthropicMessagesTransformer.buildProtocolRequest({
|
||||
operation: 'count_tokens',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'claude_code',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'count these tokens' }],
|
||||
},
|
||||
],
|
||||
tools: [{ name: 'lookup', inputSchema: { type: 'object' } }],
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'count these tokens',
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js';
|
||||
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
|
||||
import { anthropicMessagesInbound } from './inbound.js';
|
||||
import { convertOpenAiBodyToAnthropicMessagesBody } from './conversion.js';
|
||||
import { anthropicMessagesOutbound } from './outbound.js';
|
||||
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
|
||||
import { anthropicMessagesUsage } from './usage.js';
|
||||
import { createAnthropicMessagesAggregateState } from './aggregator.js';
|
||||
import { isMessagesRequiredError, shouldRetryNormalizedMessagesBody } from './compatibility.js';
|
||||
import {
|
||||
isMessagesRequiredError,
|
||||
shouldRetryNormalizedMessagesBody,
|
||||
} from './compatibility.js';
|
||||
export {
|
||||
ANTHROPIC_RAW_SSE_EVENT_NAMES,
|
||||
consumeAnthropicSseEvent,
|
||||
@@ -30,6 +37,47 @@ export const anthropicMessagesTransformer = {
|
||||
aggregator: {
|
||||
createState: createAnthropicMessagesAggregateState,
|
||||
},
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = anthropicMessagesInbound.parse(body);
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid messages request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: parsed.value.parsed.upstreamBody,
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream);
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof anthropicMessagesInbound.parse> {
|
||||
return anthropicMessagesInbound.parse(body);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canonicalAttachmentFromInputFileBlock,
|
||||
canonicalAttachmentToNormalizedInputFile,
|
||||
} from './attachments.js';
|
||||
|
||||
describe('canonical attachment helpers', () => {
|
||||
it('normalizes input_file blocks and prefers file_data over file_url and file_id', () => {
|
||||
const attachment = canonicalAttachmentFromInputFileBlock({
|
||||
type: 'input_file',
|
||||
file_data: 'data:application/pdf;base64,QUJD',
|
||||
file_url: 'https://example.com/brief.pdf',
|
||||
file_id: 'file_ignored',
|
||||
filename: 'brief.pdf',
|
||||
});
|
||||
|
||||
expect(attachment).toEqual({
|
||||
kind: 'file',
|
||||
sourceType: 'input_file',
|
||||
fileData: 'data:application/pdf;base64,QUJD',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(canonicalAttachmentToNormalizedInputFile(attachment!)).toEqual({
|
||||
sourceType: 'input_file',
|
||||
fileData: 'data:application/pdf;base64,QUJD',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
hadDataUrl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves file_url when no file_data exists', () => {
|
||||
const attachment = canonicalAttachmentFromInputFileBlock({
|
||||
type: 'file',
|
||||
file: {
|
||||
file_url: 'https://example.com/report.json',
|
||||
filename: 'report.json',
|
||||
mime_type: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
expect(attachment).toEqual({
|
||||
kind: 'file',
|
||||
sourceType: 'file',
|
||||
fileUrl: 'https://example.com/report.json',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
normalizeInputFileBlock,
|
||||
type NormalizedInputFile,
|
||||
} from '../shared/inputFile.js';
|
||||
|
||||
export type CanonicalAttachment = {
|
||||
kind: 'file';
|
||||
sourceType?: 'file' | 'input_file';
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
fileData?: string;
|
||||
filename?: string;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
function cloneOptionalString(value: string | null | undefined): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function canonicalAttachmentFromNormalizedInputFile(
|
||||
file: NormalizedInputFile,
|
||||
): CanonicalAttachment {
|
||||
return {
|
||||
kind: 'file',
|
||||
sourceType: file.sourceType,
|
||||
...(cloneOptionalString(file.fileId) ? { fileId: file.fileId } : {}),
|
||||
...(cloneOptionalString(file.fileUrl) ? { fileUrl: file.fileUrl } : {}),
|
||||
...(cloneOptionalString(file.fileData) ? { fileData: file.fileData } : {}),
|
||||
...(cloneOptionalString(file.filename) ? { filename: file.filename } : {}),
|
||||
...(file.mimeType !== undefined ? { mimeType: file.mimeType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalAttachmentFromInputFileBlock(
|
||||
item: Record<string, unknown>,
|
||||
): CanonicalAttachment | null {
|
||||
const normalized = normalizeInputFileBlock(item);
|
||||
if (!normalized) return null;
|
||||
return canonicalAttachmentFromNormalizedInputFile(normalized);
|
||||
}
|
||||
|
||||
export function canonicalAttachmentToNormalizedInputFile(
|
||||
attachment: CanonicalAttachment,
|
||||
): NormalizedInputFile {
|
||||
const hadDataUrl = typeof attachment.fileData === 'string'
|
||||
? /^data:[^;,]+;base64,/i.test(attachment.fileData)
|
||||
: undefined;
|
||||
return {
|
||||
...(attachment.sourceType ? { sourceType: attachment.sourceType } : {}),
|
||||
...(cloneOptionalString(attachment.fileId) ? { fileId: attachment.fileId } : {}),
|
||||
...(cloneOptionalString(attachment.fileUrl) ? { fileUrl: attachment.fileUrl } : {}),
|
||||
...(cloneOptionalString(attachment.fileData) ? { fileData: attachment.fileData } : {}),
|
||||
...(cloneOptionalString(attachment.filename) ? { filename: attachment.filename } : {}),
|
||||
...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}),
|
||||
...(hadDataUrl !== undefined ? { hadDataUrl } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeCanonicalReasoningRequest } from './reasoning.js';
|
||||
|
||||
describe('canonical reasoning helpers', () => {
|
||||
it('normalizes reasoning config and keeps non-reasoning include entries in metadata', () => {
|
||||
const normalized = normalizeCanonicalReasoningRequest({
|
||||
include: [' reasoning.encrypted_content ', '', 'message.input_image.image_url'],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
reasoning_budget: '2048',
|
||||
reasoning_summary: 'detailed',
|
||||
});
|
||||
|
||||
expect(normalized.reasoning).toEqual({
|
||||
effort: 'high',
|
||||
budgetTokens: 2048,
|
||||
summary: 'detailed',
|
||||
includeEncryptedContent: true,
|
||||
});
|
||||
expect(normalized.metadata).toEqual({
|
||||
include: ['message.input_image.image_url'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when no reasoning hints are present', () => {
|
||||
expect(normalizeCanonicalReasoningRequest({})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { TransformerMetadata } from '../shared/normalized.js';
|
||||
import type {
|
||||
CanonicalReasoningEffort,
|
||||
CanonicalReasoningRequest,
|
||||
} from './types.js';
|
||||
|
||||
type CanonicalReasoningNormalizationInput = {
|
||||
include?: unknown;
|
||||
reasoning?: unknown;
|
||||
reasoning_effort?: unknown;
|
||||
reasoning_budget?: unknown;
|
||||
reasoning_summary?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toFiniteInteger(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isFinite(parsed)) return Math.trunc(parsed);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeReasoningEffort(value: unknown): CanonicalReasoningEffort | undefined {
|
||||
const effort = asTrimmedString(value).toLowerCase();
|
||||
switch (effort) {
|
||||
case 'none':
|
||||
case 'low':
|
||||
case 'medium':
|
||||
case 'high':
|
||||
case 'max':
|
||||
return effort;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIncludeEntries(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
export function normalizeCanonicalReasoningRequest(
|
||||
input: CanonicalReasoningNormalizationInput,
|
||||
): {
|
||||
reasoning?: CanonicalReasoningRequest;
|
||||
metadata?: Pick<TransformerMetadata, 'include'>;
|
||||
} {
|
||||
const rawReasoning = isRecord(input.reasoning) ? input.reasoning : {};
|
||||
const include = normalizeIncludeEntries(input.include);
|
||||
const filteredInclude = include.filter((item) => item !== 'reasoning.encrypted_content');
|
||||
|
||||
const effort = normalizeReasoningEffort(rawReasoning.effort ?? input.reasoning_effort);
|
||||
const budgetTokens = toFiniteInteger(rawReasoning.budget_tokens ?? rawReasoning.budgetTokens ?? input.reasoning_budget);
|
||||
const summary = asTrimmedString(rawReasoning.summary ?? input.reasoning_summary) || undefined;
|
||||
const includeEncryptedContent = include.includes('reasoning.encrypted_content') || undefined;
|
||||
|
||||
const reasoning: CanonicalReasoningRequest | undefined = (
|
||||
effort
|
||||
|| budgetTokens !== undefined
|
||||
|| summary
|
||||
|| includeEncryptedContent
|
||||
)
|
||||
? {
|
||||
...(effort ? { effort } : {}),
|
||||
...(budgetTokens !== undefined ? { budgetTokens } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(includeEncryptedContent ? { includeEncryptedContent } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const metadata = filteredInclude.length > 0
|
||||
? { include: filteredInclude }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createCanonicalRequestEnvelope } from './request.js';
|
||||
|
||||
describe('canonical request helpers', () => {
|
||||
it('normalizes a count_tokens request without provider-owned fields', () => {
|
||||
const request = createCanonicalRequestEnvelope({
|
||||
operation: 'count_tokens',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'claude_code',
|
||||
requestedModel: ' claude-sonnet-4-5 ',
|
||||
stream: false,
|
||||
continuation: {
|
||||
sessionId: ' session-1 ',
|
||||
promptCacheKey: ' cache-1 ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(request).toEqual({
|
||||
operation: 'count_tokens',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'claude_code',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [],
|
||||
continuation: {
|
||||
sessionId: 'session-1',
|
||||
promptCacheKey: 'cache-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults generate requests to generic profile and empty collections', () => {
|
||||
const request = createCanonicalRequestEnvelope({
|
||||
requestedModel: 'gpt-5.2-codex',
|
||||
surface: 'openai-responses',
|
||||
});
|
||||
|
||||
expect(request).toEqual({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gpt-5.2-codex',
|
||||
stream: false,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
import {
|
||||
canonicalAttachmentFromInputFileBlock,
|
||||
canonicalAttachmentToNormalizedInputFile,
|
||||
type CanonicalAttachment,
|
||||
} from './attachments.js';
|
||||
import { normalizeCanonicalReasoningRequest } from './reasoning.js';
|
||||
import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
|
||||
import type {
|
||||
CanonicalContentPart,
|
||||
CanonicalCliProfile,
|
||||
CanonicalContinuation,
|
||||
CanonicalMessage,
|
||||
CanonicalMessageRole,
|
||||
CanonicalOperation,
|
||||
CanonicalReasoningRequest,
|
||||
CanonicalRequestEnvelope,
|
||||
CanonicalSurface,
|
||||
} from './types.js';
|
||||
import { toOpenAiChatFileBlock } from '../shared/inputFile.js';
|
||||
|
||||
export type CreateCanonicalRequestEnvelopeInput = {
|
||||
operation?: CanonicalOperation;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
requestedModel: string;
|
||||
stream?: boolean;
|
||||
messages?: CanonicalMessage[];
|
||||
reasoning?: CanonicalReasoningRequest;
|
||||
tools?: CanonicalTool[];
|
||||
toolChoice?: CanonicalToolChoice;
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
attachments?: CanonicalAttachment[];
|
||||
};
|
||||
|
||||
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 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 safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCanonicalContinuation(
|
||||
continuation: CanonicalContinuation | undefined,
|
||||
): CanonicalContinuation | undefined {
|
||||
if (!continuation) return undefined;
|
||||
|
||||
const normalized: CanonicalContinuation = {
|
||||
...(asTrimmedString(continuation.sessionId) ? { sessionId: asTrimmedString(continuation.sessionId) } : {}),
|
||||
...(asTrimmedString(continuation.previousResponseId)
|
||||
? { previousResponseId: asTrimmedString(continuation.previousResponseId) }
|
||||
: {}),
|
||||
...(asTrimmedString(continuation.promptCacheKey)
|
||||
? { promptCacheKey: asTrimmedString(continuation.promptCacheKey) }
|
||||
: {}),
|
||||
...(asTrimmedString(continuation.turnState) ? { turnState: asTrimmedString(continuation.turnState) } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function createCanonicalRequestEnvelope(
|
||||
input: CreateCanonicalRequestEnvelopeInput,
|
||||
): CanonicalRequestEnvelope {
|
||||
const requestedModel = asTrimmedString(input.requestedModel);
|
||||
if (!requestedModel) {
|
||||
throw new Error('canonical request requires requestedModel');
|
||||
}
|
||||
|
||||
return {
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
cliProfile: input.cliProfile ?? 'generic',
|
||||
requestedModel,
|
||||
stream: input.stream === true,
|
||||
messages: Array.isArray(input.messages) ? input.messages : [],
|
||||
...(input.reasoning ? { reasoning: input.reasoning } : {}),
|
||||
...(Array.isArray(input.tools) && input.tools.length > 0 ? { tools: input.tools } : {}),
|
||||
...(input.toolChoice !== undefined ? { toolChoice: input.toolChoice } : {}),
|
||||
...(normalizeCanonicalContinuation(input.continuation)
|
||||
? { continuation: normalizeCanonicalContinuation(input.continuation) }
|
||||
: {}),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
...(input.passthrough ? { passthrough: input.passthrough } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type CanonicalRequestFromOpenAiBodyInput = {
|
||||
body: Record<string, unknown>;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
operation?: CanonicalOperation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
continuation?: CanonicalContinuation;
|
||||
};
|
||||
|
||||
function normalizeRole(value: unknown): CanonicalMessageRole {
|
||||
const role = asTrimmedString(value).toLowerCase();
|
||||
switch (role) {
|
||||
case 'system':
|
||||
case 'developer':
|
||||
case 'assistant':
|
||||
case 'tool':
|
||||
return role;
|
||||
default:
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
||||
function openAiContentToCanonicalParts(content: unknown): CanonicalContentPart[] {
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const parts: CanonicalContentPart[] = [];
|
||||
for (const item of content) {
|
||||
if (typeof item === 'string') {
|
||||
if (item) parts.push({ type: 'text', text: item });
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
||||
const text = asTrimmedString(item.text);
|
||||
if (text) parts.push({ type: 'text', text });
|
||||
continue;
|
||||
}
|
||||
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
||||
const text = asTrimmedString(item.text ?? item.reasoning ?? item.thinking);
|
||||
if (text) parts.push({ type: 'text', text, thought: true });
|
||||
continue;
|
||||
}
|
||||
if (type === 'image_url' && isRecord(item.image_url)) {
|
||||
const url = asTrimmedString(item.image_url.url);
|
||||
if (url) parts.push({ type: 'image', url });
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_image' && isRecord(item.image_url)) {
|
||||
const url = asTrimmedString(item.image_url.url);
|
||||
if (url) parts.push({ type: 'image', url });
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
const attachment = canonicalAttachmentFromInputFileBlock(item);
|
||||
if (attachment) {
|
||||
parts.push({
|
||||
type: 'file',
|
||||
...(attachment.fileId ? { fileId: attachment.fileId } : {}),
|
||||
...(attachment.fileUrl ? { fileUrl: attachment.fileUrl } : {}),
|
||||
...(attachment.fileData ? { fileData: attachment.fileData } : {}),
|
||||
...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}),
|
||||
...(attachment.filename ? { filename: attachment.filename } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefined {
|
||||
if (typeof rawToolChoice === 'string') {
|
||||
const normalized = rawToolChoice.trim().toLowerCase();
|
||||
if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized;
|
||||
if (normalized === 'any') return 'required';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isRecord(rawToolChoice)) return undefined;
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'auto' || type === 'none') return type;
|
||||
if (type === 'any' || type === 'required') return 'required';
|
||||
if (type !== 'tool') return undefined;
|
||||
|
||||
const name = asTrimmedString(
|
||||
rawToolChoice.name
|
||||
?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined),
|
||||
);
|
||||
return name ? { type: 'tool', name } : undefined;
|
||||
}
|
||||
|
||||
function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
|
||||
if (!Array.isArray(rawTools)) return undefined;
|
||||
|
||||
const tools = rawTools
|
||||
.flatMap((item) => {
|
||||
if (!isRecord(item)) return [];
|
||||
|
||||
if (asTrimmedString(item.type).toLowerCase() === 'function' && isRecord(item.function)) {
|
||||
const name = asTrimmedString(item.function.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(item.function.description)
|
||||
? { description: asTrimmedString(item.function.description) }
|
||||
: {}),
|
||||
...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}),
|
||||
}];
|
||||
}
|
||||
|
||||
if (Array.isArray(item.functionDeclarations)) {
|
||||
return item.functionDeclarations.flatMap((declaration) => {
|
||||
if (!isRecord(declaration)) return [];
|
||||
const name = asTrimmedString(declaration.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(declaration.description)
|
||||
? { description: asTrimmedString(declaration.description) }
|
||||
: {}),
|
||||
...(isRecord(declaration.parametersJsonSchema)
|
||||
? { inputSchema: cloneJsonValue(declaration.parametersJsonSchema) }
|
||||
: (isRecord(declaration.parameters) ? { inputSchema: cloneJsonValue(declaration.parameters) } : {})),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
export function canonicalRequestFromOpenAiBody(
|
||||
input: CanonicalRequestFromOpenAiBodyInput,
|
||||
): CanonicalRequestEnvelope {
|
||||
const body = input.body;
|
||||
const messages: CanonicalMessage[] = [];
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
|
||||
for (const rawMessage of rawMessages) {
|
||||
if (!isRecord(rawMessage)) continue;
|
||||
const role = normalizeRole(rawMessage.role);
|
||||
|
||||
if (role === 'tool') {
|
||||
const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id);
|
||||
const resultText = typeof rawMessage.content === 'string'
|
||||
? rawMessage.content
|
||||
: safeJsonStringify(rawMessage.content ?? '');
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: toolCallId || 'tool',
|
||||
...(resultText ? { resultText } : {}),
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = openAiContentToCanonicalParts(rawMessage.content);
|
||||
const toolCalls = Array.isArray(rawMessage.tool_calls) ? rawMessage.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const fn = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const name = asTrimmedString(toolCall.name ?? fn.name);
|
||||
const argumentsJson = typeof fn.arguments === 'string'
|
||||
? fn.arguments
|
||||
: safeJsonStringify(fn.arguments ?? toolCall.arguments ?? {});
|
||||
if (!name) continue;
|
||||
parts.push({
|
||||
type: 'tool_call',
|
||||
id: id || `tool_${parts.length}`,
|
||||
name,
|
||||
argumentsJson,
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role,
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
const reasoningResult = normalizeCanonicalReasoningRequest({
|
||||
include: body.include,
|
||||
reasoning: body.reasoning,
|
||||
reasoning_effort: body.reasoning_effort,
|
||||
reasoning_budget: body.reasoning_budget,
|
||||
reasoning_summary: body.reasoning_summary,
|
||||
});
|
||||
|
||||
const continuation: CanonicalContinuation = {
|
||||
...(normalizeCanonicalContinuation(input.continuation) ?? {}),
|
||||
...(asTrimmedString(body.previous_response_id)
|
||||
? { previousResponseId: asTrimmedString(body.previous_response_id) }
|
||||
: {}),
|
||||
...(asTrimmedString(body.prompt_cache_key)
|
||||
? { promptCacheKey: asTrimmedString(body.prompt_cache_key) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return createCanonicalRequestEnvelope({
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
cliProfile: input.cliProfile ?? 'generic',
|
||||
requestedModel: asTrimmedString(body.model),
|
||||
stream: body.stream === true,
|
||||
messages,
|
||||
...(reasoningResult.reasoning ? { reasoning: reasoningResult.reasoning } : {}),
|
||||
...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}),
|
||||
...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}),
|
||||
...(Object.keys(continuation).length > 0 ? { continuation } : {}),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
...((reasoningResult.metadata || input.passthrough)
|
||||
? {
|
||||
passthrough: {
|
||||
...(input.passthrough ?? {}),
|
||||
...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalPartsToOpenAiContent(
|
||||
role: CanonicalMessageRole,
|
||||
parts: CanonicalContentPart[],
|
||||
): { content: string | Array<Record<string, unknown>>; reasoning?: string; toolCalls?: Array<Record<string, unknown>> } {
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const toolCalls: Array<Record<string, unknown>> = [];
|
||||
const visibleText: string[] = [];
|
||||
const reasoningText: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
if (part.thought === true) {
|
||||
reasoningText.push(part.text);
|
||||
} else {
|
||||
visibleText.push(part.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'image') {
|
||||
const url = asTrimmedString(part.url ?? part.dataUrl);
|
||||
if (url) {
|
||||
contentBlocks.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'file') {
|
||||
const normalizedFile = canonicalAttachmentToNormalizedInputFile({
|
||||
kind: 'file',
|
||||
...(part.fileId ? { fileId: part.fileId } : {}),
|
||||
...(part.fileUrl ? { fileUrl: part.fileUrl } : {}),
|
||||
...(part.fileData ? { fileData: part.fileData } : {}),
|
||||
...(part.filename ? { filename: part.filename } : {}),
|
||||
...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}),
|
||||
});
|
||||
contentBlocks.push(toOpenAiChatFileBlock(normalizedFile));
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_call') {
|
||||
toolCalls.push({
|
||||
id: part.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: part.argumentsJson,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_result' && role !== 'tool') {
|
||||
const text = part.resultText ?? safeJsonStringify(part.resultJson ?? '');
|
||||
if (text) {
|
||||
visibleText.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length <= 0) {
|
||||
return {
|
||||
content: visibleText.join(''),
|
||||
...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (visibleText.length > 0) {
|
||||
contentBlocks.unshift({
|
||||
type: 'text',
|
||||
text: visibleText.join(''),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: contentBlocks,
|
||||
...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined): unknown {
|
||||
if (!toolChoice) return undefined;
|
||||
if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice;
|
||||
if (toolChoice === 'required') return 'required';
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolChoice.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalRequestToOpenAiChatBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'tool') {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool_result') continue;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: part.toolCallId,
|
||||
content: part.resultText ?? safeJsonStringify(part.resultJson ?? ''),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const converted = canonicalPartsToOpenAiContent(message.role, message.parts);
|
||||
const nextMessage: Record<string, unknown> = {
|
||||
role: message.role,
|
||||
content: converted.content,
|
||||
};
|
||||
if (message.role === 'assistant' && converted.reasoning) {
|
||||
nextMessage.reasoning_content = converted.reasoning;
|
||||
}
|
||||
if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) {
|
||||
nextMessage.tool_calls = converted.toolCalls;
|
||||
if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array<unknown>).length <= 0) {
|
||||
nextMessage.content = '';
|
||||
}
|
||||
}
|
||||
messages.push(nextMessage);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: request.requestedModel,
|
||||
stream: request.stream,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort;
|
||||
if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens;
|
||||
if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary;
|
||||
if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey;
|
||||
if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId;
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
body.tools = request.tools.map((tool) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
|
||||
},
|
||||
}));
|
||||
}
|
||||
const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice);
|
||||
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
||||
|
||||
if (isRecord(request.passthrough)) {
|
||||
for (const [key, value] of Object.entries(request.passthrough)) {
|
||||
if (key === 'transformerMetadata' || body[key] !== undefined) continue;
|
||||
body[key] = cloneJsonValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export * from './attachments.js';
|
||||
export * from './reasoning.js';
|
||||
export * from './tools.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,14 @@
|
||||
export type CanonicalTool = {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CanonicalToolChoice =
|
||||
| 'auto'
|
||||
| 'none'
|
||||
| 'required'
|
||||
| {
|
||||
type: 'tool';
|
||||
name: string;
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
|
||||
|
||||
export type CanonicalOperation =
|
||||
| 'generate'
|
||||
| 'count_tokens';
|
||||
|
||||
export type CanonicalSurface =
|
||||
| 'openai-chat'
|
||||
| 'openai-responses'
|
||||
| 'anthropic-messages'
|
||||
| 'gemini-generate-content';
|
||||
|
||||
export type CanonicalCliProfile =
|
||||
| 'generic'
|
||||
| 'codex'
|
||||
| 'claude_code'
|
||||
| 'gemini_cli';
|
||||
|
||||
export type CanonicalMessageRole =
|
||||
| 'system'
|
||||
| 'developer'
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'tool';
|
||||
|
||||
export type CanonicalTextPart = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
thought?: boolean;
|
||||
};
|
||||
|
||||
export type CanonicalImagePart = {
|
||||
type: 'image';
|
||||
dataUrl?: string;
|
||||
url?: string;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
export type CanonicalFilePart = {
|
||||
type: 'file';
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
fileData?: string;
|
||||
mimeType?: string | null;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type CanonicalToolCallPart = {
|
||||
type: 'tool_call';
|
||||
id: string;
|
||||
name: string;
|
||||
argumentsJson: string;
|
||||
};
|
||||
|
||||
export type CanonicalToolResultPart = {
|
||||
type: 'tool_result';
|
||||
toolCallId: string;
|
||||
resultText?: string;
|
||||
resultJson?: unknown;
|
||||
};
|
||||
|
||||
export type CanonicalContentPart =
|
||||
| CanonicalTextPart
|
||||
| CanonicalImagePart
|
||||
| CanonicalFilePart
|
||||
| CanonicalToolCallPart
|
||||
| CanonicalToolResultPart;
|
||||
|
||||
export type CanonicalMessage = {
|
||||
role: CanonicalMessageRole;
|
||||
parts: CanonicalContentPart[];
|
||||
};
|
||||
|
||||
export type CanonicalReasoningEffort =
|
||||
| 'none'
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'max';
|
||||
|
||||
export type CanonicalReasoningRequest = {
|
||||
effort?: CanonicalReasoningEffort;
|
||||
budgetTokens?: number;
|
||||
summary?: string;
|
||||
includeEncryptedContent?: boolean;
|
||||
};
|
||||
|
||||
export type CanonicalContinuation = {
|
||||
sessionId?: string;
|
||||
previousResponseId?: string;
|
||||
promptCacheKey?: string;
|
||||
turnState?: string;
|
||||
};
|
||||
|
||||
export type CanonicalRequestEnvelope = {
|
||||
operation: CanonicalOperation;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile: CanonicalCliProfile;
|
||||
requestedModel: string;
|
||||
stream: boolean;
|
||||
messages: CanonicalMessage[];
|
||||
reasoning?: CanonicalReasoningRequest;
|
||||
tools?: CanonicalTool[];
|
||||
toolChoice?: CanonicalToolChoice;
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
CanonicalCliProfile,
|
||||
CanonicalContinuation,
|
||||
CanonicalOperation,
|
||||
CanonicalRequestEnvelope,
|
||||
} from './canonical/types.js';
|
||||
import type {
|
||||
ClaudeDownstreamContext,
|
||||
NormalizedFinalResponse,
|
||||
NormalizedStreamEvent,
|
||||
StreamTransformContext,
|
||||
} from './shared/normalized.js';
|
||||
|
||||
export type ProtocolParseContext = {
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
operation?: CanonicalOperation;
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
defaultEncryptedReasoningInclude?: boolean;
|
||||
};
|
||||
|
||||
export type ProtocolBuildContext = {
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
};
|
||||
|
||||
export type ProtocolResponseContext = {
|
||||
modelName: string;
|
||||
fallbackText?: string;
|
||||
};
|
||||
|
||||
export type ProtocolStreamContext = {
|
||||
modelName: string;
|
||||
streamContext?: StreamTransformContext;
|
||||
};
|
||||
|
||||
export type ProtocolSerializeContext = {
|
||||
modelName: string;
|
||||
usage?: {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
};
|
||||
streamContext?: StreamTransformContext;
|
||||
claudeContext?: ClaudeDownstreamContext;
|
||||
};
|
||||
|
||||
export interface ProtocolTransformer {
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } };
|
||||
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown>;
|
||||
|
||||
normalizeFinal(
|
||||
payload: unknown,
|
||||
ctx: ProtocolResponseContext,
|
||||
): NormalizedFinalResponse;
|
||||
|
||||
normalizeStreamEvent(
|
||||
payload: unknown,
|
||||
ctx: ProtocolStreamContext,
|
||||
): NormalizedStreamEvent;
|
||||
|
||||
serializeFinal(
|
||||
normalized: NormalizedFinalResponse,
|
||||
ctx: ProtocolSerializeContext,
|
||||
): unknown;
|
||||
|
||||
serializeStreamEvent(
|
||||
normalized: NormalizedStreamEvent,
|
||||
ctx: ProtocolSerializeContext,
|
||||
): string[] | unknown[];
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function readWorkspaceFile(relativePath: string): string {
|
||||
return readFileSync(path.resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function listTransformerFiles(directory: string): string[] {
|
||||
const entries = readdirSync(directory);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...listTransformerFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (entry.endsWith('.ts') && !entry.endsWith('.test.ts')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('final transformer hard-cut architecture', () => {
|
||||
it('keeps shared normalized helpers independent from route chatFormats', () => {
|
||||
const sharedNormalized = readWorkspaceFile('src/server/transformers/shared/normalized.ts');
|
||||
@@ -37,4 +54,22 @@ describe('final transformer hard-cut architecture', () => {
|
||||
expect(geminiStream).not.toContain('passthrough');
|
||||
expect(geminiAggregator).not.toContain('parts: unknown[]');
|
||||
});
|
||||
|
||||
it('forbids transformer imports from routes, oauth, token router, runtime executor, and fastify', () => {
|
||||
const transformerRoot = path.resolve(process.cwd(), 'src/server/transformers');
|
||||
const files = listTransformerFiles(transformerRoot);
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
expect(source).not.toContain("from '../../routes/proxy/");
|
||||
expect(source).not.toContain("from '../routes/proxy/");
|
||||
expect(source).not.toContain("from '../../services/oauth/");
|
||||
expect(source).not.toContain("from '../services/oauth/");
|
||||
expect(source).not.toContain("from '../../services/tokenRouter.js'");
|
||||
expect(source).not.toContain("from '../services/tokenRouter.js'");
|
||||
expect(source).not.toContain("from '../../routes/proxy/runtimeExecutor.js'");
|
||||
expect(source).not.toContain("from '../routes/proxy/runtimeExecutor.js'");
|
||||
expect(source).not.toContain("from 'fastify'");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,66 @@ function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
describe('openAiChatTransformer.inbound', () => {
|
||||
it('parses chat requests into canonical envelopes', () => {
|
||||
const result = openAiChatTransformer.parseRequest({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning_effort: 'high',
|
||||
reasoning_budget: 1024,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
continuation: {
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
budgetTokens: 1024,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds chat requests from canonical envelopes', () => {
|
||||
const body = openAiChatTransformer.buildProtocolRequest({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
reasoning: {
|
||||
effort: 'medium',
|
||||
budgetTokens: 512,
|
||||
},
|
||||
continuation: {
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning_effort: 'medium',
|
||||
reasoning_budget: 512,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures chat request metadata fields without changing upstream body', () => {
|
||||
const result = openAiChatTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
|
||||
import { openAiChatInbound } from './inbound.js';
|
||||
@@ -28,6 +31,46 @@ export const openAiChatTransformer = {
|
||||
proxyStream: {
|
||||
createSession: createChatProxyStreamSession,
|
||||
},
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = openAiChatInbound.parse(body);
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid chat request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: parsed.value.parsed.upstreamBody,
|
||||
surface: 'openai-chat',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
return canonicalRequestToOpenAiChatBody(request);
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
|
||||
return openAiChatInbound.parse(body);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,75 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openAiResponsesTransformer } from './index.js';
|
||||
|
||||
describe('openAiResponsesTransformer.inbound', () => {
|
||||
it('parses responses requests into canonical envelopes', () => {
|
||||
const result = openAiResponsesTransformer.parseRequest({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: false,
|
||||
continuation: {
|
||||
previousResponseId: 'resp_prev_1',
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds responses requests from canonical envelopes', () => {
|
||||
const body = openAiResponsesTransformer.buildProtocolRequest({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
previousResponseId: 'resp_prev_1',
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a protocol request envelope with a normalized responses body', () => {
|
||||
const result = openAiResponsesTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
@@ -61,6 +64,64 @@ export const openAiResponsesTransformer = {
|
||||
proxyStream: {
|
||||
createSession: createResponsesProxyStreamSession,
|
||||
},
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = openAiResponsesInbound.parse(body, {
|
||||
defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude,
|
||||
});
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid responses request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const responsesBody = parsed.value.parsed.normalizedBody;
|
||||
const openAiBody = convertResponsesBodyToOpenAiBody(
|
||||
responsesBody,
|
||||
typeof responsesBody.model === 'string' ? responsesBody.model : parsed.value.model,
|
||||
responsesBody.stream === true,
|
||||
{ defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude },
|
||||
);
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'openai-responses',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
if (request.reasoning) {
|
||||
openAiBody.reasoning = {
|
||||
...(request.reasoning.effort ? { effort: request.reasoning.effort } : {}),
|
||||
...(request.reasoning.budgetTokens !== undefined ? { budget_tokens: request.reasoning.budgetTokens } : {}),
|
||||
...(request.reasoning.summary ? { summary: request.reasoning.summary } : {}),
|
||||
};
|
||||
}
|
||||
return convertOpenAiBodyToResponsesBody(openAiBody, request.requestedModel, request.stream);
|
||||
},
|
||||
transformRequest(
|
||||
body: unknown,
|
||||
options?: { defaultEncryptedReasoningInclude?: boolean },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Response as UndiciResponse } from 'undici';
|
||||
import type { DownstreamFormat } from './normalized.js';
|
||||
import type { EndpointAttemptContext, EndpointRecoverResult } from '../../routes/proxy/endpointFlow.js';
|
||||
import {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
isEndpointDispatchDeniedError,
|
||||
@@ -13,6 +13,18 @@ import {
|
||||
shouldRetryNormalizedMessagesBody,
|
||||
} from '../anthropic/messages/compatibility.js';
|
||||
|
||||
type EndpointAttemptContext = {
|
||||
request: CompatibilityRequest;
|
||||
targetUrl: string;
|
||||
response: UndiciResponse;
|
||||
rawErrText: string;
|
||||
};
|
||||
|
||||
type EndpointRecoverResult = {
|
||||
upstream: UndiciResponse;
|
||||
upstreamPath: string;
|
||||
} | null;
|
||||
|
||||
type CompatibilityRequest = {
|
||||
endpoint: CompatibilityEndpoint;
|
||||
path: string;
|
||||
|
||||
Reference in New Issue
Block a user