complete codex cliproxyapi parity

This commit is contained in:
cita
2026-03-20 11:23:29 +08:00
parent 5403a946c6
commit adc4fb1be7
28 changed files with 2969 additions and 204 deletions
+15
View File
@@ -1,5 +1,6 @@
import 'dotenv/config';
import type { FastifyServerOptions } from 'fastify';
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
@@ -32,6 +33,15 @@ function parseOptionalSecret(value: string | undefined): string {
return (value || '').trim();
}
function parseJsonValue(value: string | undefined): unknown {
if (!value) return undefined;
try {
return JSON.parse(value);
} catch {
return undefined;
}
}
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
const normalized = (value || 'sqlite').trim().toLowerCase();
if (normalized === 'mysql') return 'mysql';
@@ -96,6 +106,11 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
codexHeaderDefaults: {
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
betaFeatures: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_BETA_FEATURES),
},
payloadRules: normalizePayloadRulesConfig(parseJsonValue(env.PAYLOAD_RULES_JSON || env.PAYLOAD_RULES)),
routingWeights: {
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
+18
View File
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import { buildFastifyOptions, config } from './config.js';
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
import { authMiddleware } from './middleware/auth.js';
import { sitesRoutes } from './routes/api/sites.js';
import { accountsRoutes } from './routes/api/accounts.js';
@@ -139,6 +140,23 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
}
const codexHeaderDefaults = parseSettingFromMap<unknown>(settingsMap, 'codex_header_defaults');
if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') {
const next = codexHeaderDefaults as Record<string, unknown>;
config.codexHeaderDefaults = {
userAgent: typeof next.userAgent === 'string'
? next.userAgent.trim()
: (typeof next['user-agent'] === 'string' ? next['user-agent'].trim() : config.codexHeaderDefaults.userAgent),
betaFeatures: typeof next.betaFeatures === 'string'
? next.betaFeatures.trim()
: (typeof next['beta-features'] === 'string' ? next['beta-features'].trim() : config.codexHeaderDefaults.betaFeatures),
};
}
if (settingsMap.has('payload_rules')) {
config.payloadRules = normalizePayloadRulesConfig(parseSettingFromMap<unknown>(settingsMap, 'payload_rules'));
}
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
rankConversationFileEndpoints,
resolveConversationFileEndpointCapability,
summarizeConversationFileInputsInOpenAiBody,
} from './conversationFileCapabilities.js';
describe('conversationFileCapabilities', () => {
it('summarizes image, audio, document, and remote file inputs from OpenAI bodies', () => {
const summary = summarizeConversationFileInputsInOpenAiBody({
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
{ type: 'input_audio', input_audio: { data: 'UklGRg==', format: 'wav' } },
{
type: 'file',
file: {
filename: 'brief.pdf',
file_data: 'JVBERi0xLjc=',
},
},
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
],
});
expect(summary).toEqual({
hasImage: true,
hasAudio: true,
hasDocument: true,
hasRemoteDocumentUrl: true,
});
});
it('describes inline-only document support for claude messages and gemini chat paths', () => {
expect(resolveConversationFileEndpointCapability({
sitePlatform: 'claude',
endpoint: 'messages',
})).toMatchObject({
image: 'native',
audio: 'unsupported',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
});
expect(resolveConversationFileEndpointCapability({
sitePlatform: 'gemini-cli',
endpoint: 'chat',
})).toMatchObject({
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
});
});
it('ranks document-capable endpoints ahead of lossy fallbacks', () => {
const ranked = rankConversationFileEndpoints({
sitePlatform: 'new-api',
requestedOrder: ['chat', 'messages', 'responses'],
summary: {
hasImage: false,
hasAudio: false,
hasDocument: true,
hasRemoteDocumentUrl: false,
},
});
expect(ranked).toEqual(['responses', 'messages', 'chat']);
});
});
@@ -0,0 +1,267 @@
import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native';
export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses';
export type ConversationFileInputSummary = {
hasImage: boolean;
hasAudio: boolean;
hasDocument: boolean;
hasRemoteDocumentUrl: boolean;
};
export type ConversationFileEndpointCapability = {
image: ConversationFileTransport;
audio: ConversationFileTransport;
document: ConversationFileTransport;
preservesRemoteDocumentUrl: boolean;
};
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 classifyMimeFamily(mimeType: string | null): 'image' | 'audio' | 'document' {
const normalized = asTrimmedString(mimeType).toLowerCase();
if (normalized.startsWith('image/')) return 'image';
if (normalized.startsWith('audio/')) return 'audio';
return 'document';
}
function appendConversationFileSummary(
summary: ConversationFileInputSummary,
item: Record<string, unknown>,
): void {
const normalizedFile = normalizeInputFileBlock(item);
if (normalizedFile) {
const mimeType = inferInputFileMimeType(normalizedFile);
const family = classifyMimeFamily(mimeType);
if (family === 'image') {
summary.hasImage = true;
return;
}
if (family === 'audio') {
summary.hasAudio = true;
return;
}
summary.hasDocument = true;
if (normalizedFile.fileUrl) {
summary.hasRemoteDocumentUrl = true;
}
return;
}
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'image_url' || type === 'input_image') {
summary.hasImage = true;
return;
}
if (type === 'input_audio') {
summary.hasAudio = true;
return;
}
if (Array.isArray(item.content)) {
for (const nested of item.content) {
if (isRecord(nested)) {
appendConversationFileSummary(summary, nested);
}
}
}
}
function createEmptySummary(): ConversationFileInputSummary {
return {
hasImage: false,
hasAudio: false,
hasDocument: false,
hasRemoteDocumentUrl: false,
};
}
export function summarizeConversationFileInputsInOpenAiBody(
body: Record<string, unknown>,
): ConversationFileInputSummary {
const summary = createEmptySummary();
const messages = Array.isArray(body.messages) ? body.messages : [];
for (const message of messages) {
if (!isRecord(message)) continue;
const content = message.content;
if (Array.isArray(content)) {
for (const item of content) {
if (isRecord(item)) appendConversationFileSummary(summary, item);
}
continue;
}
if (isRecord(content)) {
appendConversationFileSummary(summary, content);
}
}
return summary;
}
export function summarizeConversationFileInputsInResponsesBody(
body: Record<string, unknown>,
): ConversationFileInputSummary {
const summary = createEmptySummary();
const input = body.input;
if (Array.isArray(input)) {
for (const item of input) {
if (isRecord(item)) appendConversationFileSummary(summary, item);
}
return summary;
}
if (isRecord(input)) {
appendConversationFileSummary(summary, input);
}
return summary;
}
export function resolveConversationFileEndpointCapability(input: {
sitePlatform?: string;
endpoint: ConversationFileEndpoint;
}): ConversationFileEndpointCapability {
const platform = asTrimmedString(input.sitePlatform).toLowerCase();
const endpoint = input.endpoint;
if (platform === 'codex') {
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'claude') {
if (endpoint === 'messages') {
return {
image: 'native',
audio: 'unsupported',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'gemini-cli' || platform === 'antigravity') {
if (endpoint === 'chat') {
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'gemini') {
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
if (endpoint === 'chat') {
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
if (endpoint === 'messages') {
return {
image: 'native',
audio: 'unsupported',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
export function rankConversationFileEndpoints(input: {
sitePlatform?: string;
requestedOrder: ConversationFileEndpoint[];
summary: ConversationFileInputSummary;
preferMessagesForClaudeModel?: boolean;
}): ConversationFileEndpoint[] {
if (!input.summary.hasDocument) {
return [...input.requestedOrder];
}
const preferredDocumentOrder = input.preferMessagesForClaudeModel === true
? ['messages', 'responses', 'chat']
: ['responses', 'messages', 'chat'];
const supportedDocumentEndpoints = preferredDocumentOrder.filter((endpoint) => {
if (!input.requestedOrder.includes(endpoint as ConversationFileEndpoint)) return false;
const capability = resolveConversationFileEndpointCapability({
sitePlatform: input.sitePlatform,
endpoint: endpoint as ConversationFileEndpoint,
});
if (capability.document === 'unsupported') return false;
if (input.summary.hasRemoteDocumentUrl && !capability.preservesRemoteDocumentUrl) return false;
return true;
}) as ConversationFileEndpoint[];
if (supportedDocumentEndpoints.length <= 0) {
return [...input.requestedOrder];
}
return [
...supportedDocumentEndpoints,
...input.requestedOrder.filter((endpoint) => !supportedDocumentEndpoints.includes(endpoint)),
];
}
@@ -1,8 +1,10 @@
import { createHash, randomUUID } from 'node:crypto';
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
import { config } from '../../config.js';
const CODEX_CLIENT_VERSION = '0.101.0';
const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
const CODEX_RESPONSES_WEBSOCKET_BETA = 'responses_websockets=2026-02-06';
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
@@ -53,6 +55,7 @@ function uuidFromSeed(seed: string): string {
function buildCodexRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
providerHeaders?: Record<string, string>;
oauthProvider?: string;
explicitSessionId?: string | null;
continuityKey?: string | null;
}): Record<string, string> {
@@ -64,7 +67,21 @@ function buildCodexRuntimeHeaders(input: {
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION;
const userAgent = getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT;
const isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex';
const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : '';
const websocketTransport = getInputHeader(input.baseHeaders, 'x-metapi-responses-websocket-transport') === '1';
const configuredBetaFeatures = (
isCodexOauth && websocketTransport
? asTrimmedString(config.codexHeaderDefaults.betaFeatures)
: ''
);
const userAgent = configuredUserAgent || getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT;
const codexBetaFeatures = getInputHeader(input.baseHeaders, 'x-codex-beta-features') || configuredBetaFeatures;
const codexTurnState = getInputHeader(input.baseHeaders, 'x-codex-turn-state');
const codexTurnMetadata = getInputHeader(input.baseHeaders, 'x-codex-turn-metadata');
const timingMetrics = getInputHeader(input.baseHeaders, 'x-responsesapi-include-timing-metrics');
const openAiBeta = getInputHeader(input.baseHeaders, 'openai-beta')
|| (websocketTransport ? CODEX_RESPONSES_WEBSOCKET_BETA : null);
const explicitSessionId = asTrimmedString(input.explicitSessionId);
const continuityKey = asTrimmedString(input.continuityKey);
const sessionId = (
@@ -87,6 +104,11 @@ function buildCodexRuntimeHeaders(input: {
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
Originator: originator,
Version: version,
...(codexBetaFeatures ? { 'x-codex-beta-features': codexBetaFeatures } : {}),
...(codexTurnState ? { 'x-codex-turn-state': codexTurnState } : {}),
...(codexTurnMetadata ? { 'x-codex-turn-metadata': codexTurnMetadata } : {}),
...(timingMetrics ? { 'x-responsesapi-include-timing-metrics': timingMetrics } : {}),
...(openAiBeta ? { 'OpenAI-Beta': openAiBeta } : {}),
Session_id: sessionId,
...(conversationId ? { Conversation_id: conversationId } : {}),
'User-Agent': userAgent,
@@ -101,6 +123,7 @@ export const codexProviderProfile: ProviderProfile = {
const headers = buildCodexRuntimeHeaders({
baseHeaders: input.baseHeaders,
providerHeaders: input.providerHeaders,
oauthProvider: input.oauthProvider,
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
});
@@ -0,0 +1,37 @@
const CODEX_RESPONSES_WEBSOCKET_BETA = 'responses_websockets=2026-02-06';
function getHeaderValue(headers: Record<string, string>, key: string): string {
const expected = key.trim().toLowerCase();
for (const [candidateKey, candidateValue] of Object.entries(headers)) {
if (candidateKey.trim().toLowerCase() !== expected) continue;
return candidateValue;
}
return '';
}
export function buildCodexWebsocketHandshakeHeaders(headers: Record<string, string>): Record<string, string> {
const next = { ...headers };
const openAiBeta = getHeaderValue(next, 'openai-beta').trim();
if (!openAiBeta) {
next['OpenAI-Beta'] = CODEX_RESPONSES_WEBSOCKET_BETA;
return next;
}
if (!openAiBeta.includes('responses_websockets=')) {
next['OpenAI-Beta'] = `${openAiBeta},${CODEX_RESPONSES_WEBSOCKET_BETA}`;
}
return next;
}
export function buildCodexWebsocketRequestBody(body: Record<string, unknown>): Record<string, unknown> {
return {
type: 'response.create',
...body,
};
}
export function toCodexWebsocketUrl(requestUrl: string): string {
const parsed = new URL(requestUrl);
if (parsed.protocol === 'https:') parsed.protocol = 'wss:';
if (parsed.protocol === 'http:') parsed.protocol = 'ws:';
return parsed.toString();
}
@@ -0,0 +1,137 @@
import { AddressInfo } from 'node:net';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { WebSocketServer } from 'ws';
describe('codexWebsocketRuntime', () => {
let upstreamServer: WebSocketServer;
let upstreamWsUrl: string;
let upstreamConnectionCount = 0;
let upstreamRequests: Record<string, unknown>[] = [];
beforeAll(async () => {
upstreamServer = new WebSocketServer({ port: 0 });
upstreamServer.on('connection', (socket) => {
upstreamConnectionCount += 1;
socket.on('message', (payload) => {
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
upstreamRequests.push(parsed);
const responseId = `resp-${upstreamRequests.length}`;
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: responseId,
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
});
});
await new Promise<void>((resolve) => upstreamServer.once('listening', () => resolve()));
const address = upstreamServer.address() as AddressInfo;
upstreamWsUrl = `ws://127.0.0.1:${address.port}/backend-api/codex/responses`;
});
beforeEach(() => {
upstreamConnectionCount = 0;
upstreamRequests = [];
});
afterAll(async () => {
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
});
it('reuses the same upstream websocket connection across turns for one execution session', async () => {
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
const runtime = createCodexWebsocketRuntime();
const first = await runtime.sendRequest({
sessionId: 'exec-session-1',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
input: [],
},
});
const second = await runtime.sendRequest({
sessionId: 'exec-session-1',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
previous_response_id: 'resp-1',
input: [],
},
});
expect(first.events[0]).toMatchObject({
type: 'response.completed',
response: { id: 'resp-1' },
});
expect(second.events[0]).toMatchObject({
type: 'response.completed',
response: { id: 'resp-2' },
});
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
model: 'gpt-5.4',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp-1',
});
await runtime.closeSession('exec-session-1');
});
it('closes the upstream websocket when the execution session is closed explicitly', async () => {
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
const runtime = createCodexWebsocketRuntime();
await runtime.sendRequest({
sessionId: 'exec-session-close',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
input: [],
},
});
await runtime.closeSession('exec-session-close');
await runtime.sendRequest({
sessionId: 'exec-session-close',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
input: [],
},
});
expect(upstreamConnectionCount).toBe(2);
await runtime.closeSession('exec-session-close');
});
});
@@ -0,0 +1,284 @@
import type { IncomingMessage } from 'node:http';
import WebSocket from 'ws';
import {
buildCodexWebsocketHandshakeHeaders,
buildCodexWebsocketRequestBody,
toCodexWebsocketUrl,
} from './codexWebsocketHeaders.js';
import { createCodexWebsocketSessionStore } from './codexWebsocketSessionStore.js';
import type {
CodexWebsocketRuntimeResult,
CodexWebsocketRuntimeSendInput,
CodexWebsocketSession,
CodexWebsocketSessionStore,
} from './types.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 isTerminalEvent(payload: Record<string, unknown>): boolean {
const type = asTrimmedString(payload.type);
return type === 'response.completed' || type === 'response.failed';
}
export class CodexWebsocketRuntimeError extends Error {
events: Array<Record<string, unknown>>;
status?: number;
payload?: unknown;
constructor(
message: string,
options?: {
events?: Array<Record<string, unknown>>;
status?: number;
payload?: unknown;
},
) {
super(message);
this.name = 'CodexWebsocketRuntimeError';
this.events = options?.events ?? [];
this.status = options?.status;
this.payload = options?.payload;
}
}
function tryParseJson(raw: string): unknown {
const trimmed = raw.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
async function readUnexpectedResponseBody(response: IncomingMessage): Promise<string> {
return await new Promise<string>((resolve) => {
const chunks: Buffer[] = [];
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.once('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'));
});
response.once('error', () => {
resolve('');
});
});
}
async function waitForSocketOpen(socket: WebSocket): Promise<void> {
if (socket.readyState === WebSocket.OPEN) return;
if (socket.readyState !== WebSocket.CONNECTING) {
throw new Error('upstream websocket is not open');
}
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
socket.off('open', onOpen);
socket.off('error', onError);
socket.off('close', onClose);
socket.off('unexpected-response', onUnexpectedResponse);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onClose = () => {
cleanup();
reject(new Error('upstream websocket closed before opening'));
};
const onUnexpectedResponse = (_request: unknown, response: IncomingMessage) => {
void readUnexpectedResponseBody(response).then((body) => {
cleanup();
reject(new CodexWebsocketRuntimeError(
body.trim() || response.statusMessage || `upstream websocket upgrade failed with status ${response.statusCode || 502}`,
{
status: response.statusCode || 502,
payload: tryParseJson(body),
},
));
});
};
socket.once('open', onOpen);
socket.once('error', onError);
socket.once('close', onClose);
socket.once('unexpected-response', onUnexpectedResponse);
});
}
async function closeSocket(socket: WebSocket | null): Promise<void> {
if (!socket) return;
if (socket.readyState === WebSocket.CLOSED) return;
await new Promise<void>((resolve) => {
const onClose = () => resolve();
socket.once('close', onClose);
try {
socket.close();
} catch {
socket.off('close', onClose);
resolve();
}
setTimeout(() => {
socket.off('close', onClose);
resolve();
}, 200);
});
}
function clearSessionSocket(session: CodexWebsocketSession, socket: WebSocket): void {
if (session.socket !== socket) return;
session.socket = null;
session.socketUrl = null;
}
async function ensureSessionSocket(
session: CodexWebsocketSession,
input: CodexWebsocketRuntimeSendInput,
): Promise<{ socket: WebSocket; reusedSession: boolean }> {
const requestUrl = toCodexWebsocketUrl(input.requestUrl);
const existing = session.socket;
if (
existing
&& session.socketUrl === requestUrl
&& existing.readyState === WebSocket.OPEN
) {
return {
socket: existing,
reusedSession: true,
};
}
if (existing) {
await closeSocket(existing);
clearSessionSocket(session, existing);
}
const nextSocket = new WebSocket(requestUrl, {
headers: buildCodexWebsocketHandshakeHeaders(input.headers),
});
await waitForSocketOpen(nextSocket);
session.socket = nextSocket;
session.socketUrl = requestUrl;
nextSocket.on('close', () => {
clearSessionSocket(session, nextSocket);
});
nextSocket.on('error', () => {
clearSessionSocket(session, nextSocket);
});
return {
socket: nextSocket,
reusedSession: false,
};
}
async function sendSessionRequest(
session: CodexWebsocketSession,
input: CodexWebsocketRuntimeSendInput,
): Promise<CodexWebsocketRuntimeResult> {
const { socket, reusedSession } = await ensureSessionSocket(session, input);
const events: Array<Record<string, unknown>> = [];
return new Promise<CodexWebsocketRuntimeResult>((resolve, reject) => {
let settled = false;
const cleanup = () => {
socket.off('message', onMessage);
socket.off('error', onError);
socket.off('close', onClose);
};
const rejectWith = (message: string) => {
if (settled) return;
settled = true;
cleanup();
reject(new CodexWebsocketRuntimeError(message, { events: [...events] }));
};
const onMessage = (payload: WebSocket.RawData) => {
try {
const parsed = JSON.parse(String(payload));
if (!isRecord(parsed)) return;
events.push(parsed);
if (!isTerminalEvent(parsed)) return;
if (settled) return;
settled = true;
cleanup();
resolve({
events: [...events],
reusedSession,
});
} catch {
// Ignore malformed frames and wait for a terminal event.
}
};
const onError = (error: Error) => {
clearSessionSocket(session, socket);
rejectWith(error.message || 'upstream websocket error');
};
const onClose = () => {
clearSessionSocket(session, socket);
rejectWith('stream closed before response.completed');
};
socket.on('message', onMessage);
socket.once('error', onError);
socket.once('close', onClose);
socket.send(JSON.stringify(buildCodexWebsocketRequestBody(input.body)), (error) => {
if (!error) return;
clearSessionSocket(session, socket);
rejectWith(error.message || 'failed to send upstream websocket request');
});
});
}
export function createCodexWebsocketRuntime(input?: {
sessionStore?: CodexWebsocketSessionStore;
}) {
const sessionStore = input?.sessionStore || createCodexWebsocketSessionStore();
return {
async sendRequest(payload: CodexWebsocketRuntimeSendInput): Promise<CodexWebsocketRuntimeResult> {
const sessionId = payload.sessionId.trim();
if (!sessionId) {
throw new CodexWebsocketRuntimeError('missing websocket session id');
}
const session = sessionStore.getOrCreate(sessionId);
const run = session.queue
.catch(() => undefined)
.then(() => sendSessionRequest(session, payload));
session.queue = run.then(() => undefined, () => undefined);
return run;
},
async closeSession(sessionId: string): Promise<void> {
const session = sessionStore.take(sessionId);
if (!session) return;
await session.queue.catch(() => undefined);
await closeSocket(session.socket);
session.socket = null;
session.socketUrl = null;
},
async closeAllSessions(): Promise<void> {
const sessions = sessionStore.list();
for (const session of sessions) {
await this.closeSession(session.sessionId);
}
},
};
}
@@ -0,0 +1,34 @@
import type { CodexWebsocketSession, CodexWebsocketSessionStore } from './types.js';
export function createCodexWebsocketSessionStore(): CodexWebsocketSessionStore {
const sessions = new Map<string, CodexWebsocketSession>();
return {
getOrCreate(sessionId) {
const normalized = sessionId.trim();
const existing = sessions.get(normalized);
if (existing) return existing;
const created: CodexWebsocketSession = {
sessionId: normalized,
socket: null,
socketUrl: null,
queue: Promise.resolve(),
};
sessions.set(normalized, created);
return created;
},
take(sessionId) {
const normalized = sessionId.trim();
if (!normalized) return null;
const existing = sessions.get(normalized) || null;
if (existing) {
sessions.delete(normalized);
}
return existing;
},
list() {
return [...sessions.values()];
},
};
}
+26
View File
@@ -0,0 +1,26 @@
import type WebSocket from 'ws';
export type CodexWebsocketRuntimeSendInput = {
sessionId: string;
requestUrl: string;
headers: Record<string, string>;
body: Record<string, unknown>;
};
export type CodexWebsocketRuntimeResult = {
events: Array<Record<string, unknown>>;
reusedSession: boolean;
};
export type CodexWebsocketSession = {
sessionId: string;
socket: WebSocket | null;
socketUrl: string | null;
queue: Promise<unknown>;
};
export type CodexWebsocketSessionStore = {
getOrCreate(sessionId: string): CodexWebsocketSession;
take(sessionId: string): CodexWebsocketSession | null;
list(): CodexWebsocketSession[];
};
@@ -30,7 +30,6 @@ import { anthropicMessagesTransformer } from '../../transformers/anthropic/messa
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
resolveOpenAiBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
@@ -52,6 +51,7 @@ import {
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { detectCliProfile } from '../cliProfiles/registry.js';
import type { CliProfileId } from '../cliProfiles/types.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
const MAX_RETRIES = 2;
@@ -122,7 +122,8 @@ export async function handleChatSurfaceRequest(
throw error;
}
}
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(resolvedOpenAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const codexSessionCacheKey = deriveCodexSessionCacheKey({
downstreamFormat,
body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body,
@@ -172,6 +173,7 @@ export async function handleChatSurfaceRequest(
requestedModel,
{
hasNonImageFileInput,
conversationFileSummary,
},
),
];
@@ -25,7 +25,6 @@ import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
resolveResponsesBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
@@ -47,6 +46,10 @@ import {
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
import { detectCliProfile } from '../cliProfiles/registry.js';
import type { CliProfileId } from '../cliProfiles/types.js';
import {
summarizeConversationFileInputsInOpenAiBody,
summarizeConversationFileInputsInResponsesBody,
} from '../capabilities/conversationFileCapabilities.js';
const MAX_RETRIES = 2;
@@ -248,9 +251,12 @@ export async function handleOpenAiResponsesSurfaceRequest(
isStream,
{ defaultEncryptedReasoningInclude },
);
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
const requiresNativeResponsesFileUrl = carriesResponsesFileUrlInput(normalizedResponsesBody.input);
const responsesConversationFileSummary = summarizeConversationFileInputsInResponsesBody(normalizedResponsesBody);
const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl
|| carriesResponsesFileUrlInput(normalizedResponsesBody.input);
if (requiresNativeResponsesFileUrl && String(selected.site.platform || '').trim().toLowerCase() === 'claude') {
return reply.code(400).send({
error: {
@@ -269,6 +275,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
requestedModel,
{
hasNonImageFileInput,
conversationFileSummary,
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
},
);
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import WebSocket from 'ws';
import WebSocket, { WebSocketServer } from 'ws';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
@@ -175,6 +176,14 @@ function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 100
describe('responses websocket transport', () => {
let app: FastifyInstance;
let baseUrl: string;
let upstreamServer: WebSocketServer;
let upstreamSiteUrl: string;
let upstreamConnectionCount: number;
let upstreamUpgradeHeaders: Record<string, string>;
let upstreamRequests: Record<string, unknown>[];
let upstreamMessageHandler: (socket: WebSocket, parsed: Record<string, unknown>, requestIndex: number) => void;
let rejectedUpgradeServer: Server;
let rejectedUpgradeSiteUrl: string;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
@@ -183,6 +192,39 @@ describe('responses websocket transport', () => {
await app.listen({ port: 0, host: '127.0.0.1' });
const address = app.server.address() as AddressInfo;
baseUrl = `ws://127.0.0.1:${address.port}`;
upstreamServer = new WebSocketServer({ port: 0 });
upstreamServer.on('connection', (socket, request) => {
upstreamConnectionCount += 1;
upstreamUpgradeHeaders = Object.fromEntries(
Object.entries(request.headers)
.map(([key, value]) => [key, Array.isArray(value) ? value[0] || '' : value || '']),
);
socket.on('message', (payload) => {
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
upstreamRequests.push(parsed);
upstreamMessageHandler(socket, parsed, upstreamRequests.length);
});
});
await new Promise<void>((resolve) => upstreamServer.once('listening', () => resolve()));
const upstreamAddress = upstreamServer.address() as AddressInfo;
upstreamSiteUrl = `http://127.0.0.1:${upstreamAddress.port}/backend-api/codex`;
rejectedUpgradeServer = createServer();
rejectedUpgradeServer.on('upgrade', (_request, socket) => {
socket.write(
'HTTP/1.1 426 Upgrade Required\r\n'
+ 'Content-Type: text/plain\r\n'
+ 'Content-Length: 16\r\n'
+ 'Connection: close\r\n'
+ '\r\n'
+ 'Upgrade Required',
);
socket.destroy();
});
await new Promise<void>((resolve) => rejectedUpgradeServer.listen(0, '127.0.0.1', () => resolve()));
const rejectedAddress = rejectedUpgradeServer.address() as AddressInfo;
rejectedUpgradeSiteUrl = `http://127.0.0.1:${rejectedAddress.port}/backend-api/codex`;
});
beforeEach(() => {
@@ -202,24 +244,90 @@ describe('responses websocket transport', () => {
selectChannelMock.mockReturnValue(selectedChannel);
selectNextChannelMock.mockReturnValue(null);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamConnectionCount = 0;
upstreamUpgradeHeaders = {};
upstreamRequests = [];
upstreamMessageHandler = (socket, parsed, requestIndex) => {
const responseId = `resp_upstream_${requestIndex}`;
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: responseId,
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
};
});
afterAll(async () => {
await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve()));
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
await app.close();
});
it('accepts response.create over GET /v1/responses websocket and forwards streamed responses events', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_ws","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_item.added\n',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_ws","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"pong"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws","model":"gpt-5.4","status":"completed","output":[{"id":"msg_ws","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"pong"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]));
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket) => {
socket.send(JSON.stringify({
type: 'response.created',
response: {
id: 'resp_ws',
model: 'gpt-5.4',
created_at: 1706000000,
status: 'in_progress',
output: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'msg_ws',
type: 'message',
role: 'assistant',
status: 'in_progress',
content: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_ws',
delta: 'pong',
}));
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp_ws',
model: 'gpt-5.4',
status: 'completed',
output: [{
id: 'msg_ws',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'pong' }],
}],
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
},
}));
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -247,7 +355,8 @@ describe('responses websocket transport', () => {
'response.completed',
]);
expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamConnectionCount).toBe(1);
});
it('echoes x-codex-turn-state on websocket upgrade responses', async () => {
@@ -266,6 +375,89 @@ describe('responses websocket transport', () => {
expect(upgrade.headers['x-codex-turn-state']).toBe('turn-state-123');
});
it('reuses one upstream codex websocket session across sequential websocket turns', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const socket = new WebSocket(`${baseUrl}/v1/responses`, {
headers: {
'x-codex-turn-state': 'turn-state-123',
'x-codex-beta-features': 'feature-a,feature-b',
},
});
await waitForSocketOpen(socket);
const firstMessagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
const firstMessages = await firstMessagesPromise;
const secondMessagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
previous_response_id: firstMessages[0]?.response?.id,
input: [],
}));
const secondMessages = await secondMessagesPromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(firstMessages[0]?.type).toBe('response.completed');
expect(secondMessages[0]?.type).toBe('response.completed');
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
model: 'gpt-5.4',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: firstMessages[0]?.response?.id,
});
expect(upstreamUpgradeHeaders['x-codex-turn-state']).toBe('turn-state-123');
expect(upstreamUpgradeHeaders['x-codex-beta-features']).toBe('feature-a,feature-b');
expect(upstreamUpgradeHeaders['openai-beta']).toContain('responses_websockets=');
});
it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 426', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: rejectedUpgradeSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_fallback","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const messagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
const messages = await messagesPromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
expect(messages[0]?.response?.id).toBe('resp_http_fallback');
});
it('merges follow-up response.create payloads when the selected upstream does not support incremental mode', async () => {
const selectedChannel = createSelectedChannel({
sitePlatform: 'openai',
@@ -364,21 +556,56 @@ describe('responses websocket transport', () => {
});
it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => {
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.output_item.done\n',
'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n',
'event: response.output_item.done\n',
'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-5.4","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]))
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n',
'data: [DONE]\n\n',
]));
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket, _parsed, requestIndex) => {
if (requestIndex === 1) {
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp_ws_1',
model: 'gpt-5.4',
status: 'completed',
output: [{
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'call tool' }],
}],
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
},
}));
return;
}
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp_ws_2',
model: 'gpt-5.4',
status: 'completed',
output: [{
id: 'msg_2',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'done' }],
}],
usage: {
input_tokens: 5,
output_tokens: 1,
total_tokens: 6,
},
},
}));
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -415,12 +642,92 @@ describe('responses websocket transport', () => {
await secondResponsePromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
model: 'gpt-5.4',
instructions: 'be helpful',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp_ws_1',
model: 'gpt-5.4',
instructions: 'be helpful',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
});
});
it('disables codex websocket incremental transport when the selected account marks websockets as disabled', async () => {
const selectedChannel = createSelectedChannel({
extraConfig: JSON.stringify({
credentialMode: 'session',
websockets: false,
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
},
}),
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_1","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"first"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
]))
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n',
'data: [DONE]\n\n',
]));
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const firstResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
instructions: 'be helpful',
input: [],
}));
const firstMessages = await firstResponsePromise;
const secondResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
previous_response_id: 'resp_http_1',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
}));
await secondResponsePromise;
socket.close();
expect(firstMessages[0]?.type).toBe('response.completed');
expect(upstreamConnectionCount).toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
const secondBody = JSON.parse(String(secondOptions.body));
expect(secondBody.previous_response_id).toBe('resp_ws_1');
expect(secondBody.model).toBe('gpt-5.4');
expect(secondBody.instructions).toBe('be helpful');
expect(secondBody.previous_response_id).toBeUndefined();
expect(secondBody.input).toEqual([
{
id: 'tool_out_1',
@@ -490,11 +797,11 @@ describe('responses websocket transport', () => {
});
it('forwards generate=false upstream for websocket-capable upstreams instead of synthesizing prewarm events', async () => {
fetchMock.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_capable_generate_false","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}\n\n',
'data: [DONE]\n\n',
]));
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -510,23 +817,42 @@ describe('responses websocket transport', () => {
socket.close();
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.generate).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
generate: false,
});
});
it('emits websocket error when the upstream stream closes before a terminal responses event', async () => {
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_incomplete","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.output_text.delta\n',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"partial"}\n\n',
]));
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket) => {
socket.send(JSON.stringify({
type: 'response.created',
response: {
id: 'resp_incomplete',
model: 'gpt-5.4',
created_at: 1706000000,
status: 'in_progress',
output: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_ws',
delta: 'partial',
}));
socket.close();
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const messagesPromise = waitForSocketMessages(socket, 6, 400);
const messagesPromise = waitForSocketMessages(socket, 3, 400);
socket.send(JSON.stringify({
type: 'response.create',
@@ -546,12 +872,8 @@ describe('responses websocket transport', () => {
expect(messages.map((message) => message.type)).toEqual([
'response.created',
'response.output_text.delta',
'response.output_text.done',
'response.content_part.done',
'response.output_item.done',
'response.failed',
'error',
]);
expect(messages[5]?.response?.status).toBe('failed');
expect(messages[5]?.error?.message).toContain('stream closed before response.completed');
expect(messages[2]?.error?.message).toContain('stream closed before response.completed');
});
});
+259 -56
View File
@@ -2,13 +2,19 @@ import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import type { IncomingMessage } from 'node:http';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
import { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } from '../../proxy-core/runtime/codexWebsocketRuntime.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
const installedApps = new WeakSet<FastifyInstance>();
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
const RESPONSES_WEBSOCKET_MODE_HEADER = 'x-metapi-responses-websocket-mode';
const RESPONSES_WEBSOCKET_TRANSPORT_HEADER = 'x-metapi-responses-websocket-transport';
const codexWebsocketRuntime = createCodexWebsocketRuntime();
type SelectedChannel = NonNullable<Awaited<ReturnType<typeof tokenRouter.selectChannel>>>;
type NormalizedResponsesWebsocketRequest =
| {
@@ -42,6 +48,93 @@ function headerValueToTrimmedString(value: unknown): string {
return '';
}
function toBooleanLike(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
}
return null;
}
function parseExtraConfigRecord(extraConfig: unknown): Record<string, unknown> | null {
if (typeof extraConfig !== 'string') return null;
try {
const parsed = JSON.parse(extraConfig);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function readNestedRecord(value: unknown, key: string): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const nested = value[key];
return isRecord(nested) ? nested : null;
}
function selectedChannelModelMatches(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const selectedModel = asTrimmedString(selectedChannel.actualModel).toLowerCase();
const normalizedRequestModel = asTrimmedString(requestModel).toLowerCase();
if (!selectedModel || !normalizedRequestModel) return true;
return selectedModel === normalizedRequestModel;
}
function selectedChannelSupportsCodexWebsocketTransport(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const platform = asTrimmedString(selectedChannel.site?.platform).toLowerCase();
if (platform !== 'codex') return false;
if (!selectedChannelModelMatches(selectedChannel, requestModel)) return false;
const extraConfig = parseExtraConfigRecord(selectedChannel.account.extraConfig);
const oauth = readNestedRecord(extraConfig, 'oauth');
const providerData = readNestedRecord(oauth, 'providerData');
const candidateFlags = [
extraConfig?.websockets,
readNestedRecord(extraConfig, 'attributes')?.websockets,
readNestedRecord(extraConfig, 'metadata')?.websockets,
providerData?.websockets,
readNestedRecord(providerData, 'attributes')?.websockets,
readNestedRecord(providerData, 'metadata')?.websockets,
];
for (const candidate of candidateFlags) {
const parsed = toBooleanLike(candidate);
if (parsed !== null) return parsed;
}
return true;
}
function selectedChannelSupportsIncrementalInput(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
return selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel);
}
function shouldReuseSelectedChannel(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const selectedModel = asTrimmedString(selectedChannel.actualModel).toLowerCase();
const normalizedRequestModel = asTrimmedString(requestModel).toLowerCase();
if (!selectedModel || !normalizedRequestModel) return true;
return selectedModel === normalizedRequestModel;
}
function deriveCodexExplicitSessionId(body: Record<string, unknown>, sessionId: string): string {
const promptCacheKey = asTrimmedString(body.prompt_cache_key);
return promptCacheKey || sessionId;
}
function parseJsonObject(raw: RawData): Record<string, unknown> | null {
try {
const parsed = JSON.parse(String(raw));
@@ -232,6 +325,77 @@ function collectResponsesOutput(payloads: unknown[]): unknown[] {
.map(([, value]) => value);
}
async function forwardResponsesRequestViaHttp(input: {
app: FastifyInstance;
socket: WebSocket;
request: IncomingMessage;
payload: Record<string, unknown>;
preserveIncrementalMode: boolean;
}): Promise<unknown[] | null> {
const response = await input.app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
...buildInjectHeaders(input.request),
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
...(input.preserveIncrementalMode ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
},
payload: input.payload,
});
if (response.statusCode < 200 || response.statusCode >= 300) {
let payload: unknown = null;
try {
payload = JSON.parse(response.body);
} catch {
payload = null;
}
writeResponsesWebsocketError(
input.socket,
response.statusCode,
response.statusMessage || 'Upstream error',
payload,
);
return null;
}
const contentType = String(response.headers['content-type'] || '').toLowerCase();
if (!contentType.includes('text/event-stream')) {
try {
const payload = JSON.parse(response.body);
input.socket.send(JSON.stringify(payload));
return isRecord(payload?.response) && Array.isArray(payload.response.output)
? cloneJsonObject(payload.response.output)
: [];
} catch {
writeResponsesWebsocketError(input.socket, 502, 'Unexpected non-JSON websocket proxy response');
return null;
}
}
const pulled = openAiResponsesTransformer.pullSseEvents(response.body);
const forwardedPayloads: unknown[] = [];
let sawTerminalPayload = false;
for (const event of pulled.events) {
if (event.data === '[DONE]') continue;
try {
const payload = JSON.parse(event.data);
forwardedPayloads.push(payload);
const type = isRecord(payload) ? asTrimmedString(payload.type) : '';
if (type === 'response.completed' || type === 'response.failed') {
sawTerminalPayload = true;
}
input.socket.send(JSON.stringify(payload));
} catch {
// Ignore malformed SSE frames; the HTTP route already normalizes them.
}
}
if (!sawTerminalPayload) {
writeResponsesWebsocketError(input.socket, 408, 'stream closed before response.completed');
}
return collectResponsesOutput(forwardedPayloads);
}
function buildInjectHeaders(request: IncomingMessage): Record<string, string | string[]> {
const headers: Record<string, string | string[]> = {};
for (const [rawKey, rawValue] of Object.entries(request.headers)) {
@@ -262,7 +426,7 @@ async function supportsResponsesWebsocketIncrementalInput(
try {
const selected = await tokenRouter.previewSelectedChannel(requestModel);
return asTrimmedString(selected?.site?.platform).toLowerCase() === 'codex';
return selectedChannelSupportsIncrementalInput(selected, requestModel);
} catch {
return false;
}
@@ -273,8 +437,16 @@ async function handleResponsesWebsocketConnection(
socket: WebSocket,
request: IncomingMessage,
) {
const websocketSessionId = headerValueToTrimmedString(request.headers['session_id'])
|| headerValueToTrimmedString(request.headers['session-id'])
|| randomUUID();
let lastRequest: Record<string, unknown> | null = null;
let lastResponseOutput: unknown[] = [];
let selectedChannel: SelectedChannel | null = null;
socket.once('close', () => {
void codexWebsocketRuntime.closeSession(websocketSessionId);
});
socket.on('message', async (raw) => {
const parsed = parseJsonObject(raw);
@@ -283,7 +455,9 @@ async function handleResponsesWebsocketConnection(
return;
}
const supportsIncrementalInput = await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest);
const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model);
const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel)
|| await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest);
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
parsed,
lastRequest,
@@ -309,67 +483,95 @@ async function handleResponsesWebsocketConnection(
return;
}
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
...buildInjectHeaders(request),
if (!shouldReuseSelectedChannel(selectedChannel, requestModel)) {
selectedChannel = requestModel
? await tokenRouter.selectChannel(requestModel)
: null;
}
const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel)
? selectedChannel
: null;
if (codexWebsocketChannel) {
const downstreamHeaders: Record<string, unknown> = {
...(request.headers as Record<string, unknown>),
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
},
payload: normalized.request,
});
};
const providerHeaders = buildOauthProviderHeaders({
extraConfig: typeof codexWebsocketChannel.account.extraConfig === 'string'
? codexWebsocketChannel.account.extraConfig
: null,
downstreamHeaders,
});
const prepared = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: asTrimmedString(codexWebsocketChannel.actualModel) || requestModel,
stream: true,
tokenValue: codexWebsocketChannel.tokenValue,
sitePlatform: codexWebsocketChannel.site.platform,
siteUrl: codexWebsocketChannel.site.url,
openaiBody: normalized.request,
downstreamFormat: 'responses',
responsesOriginalBody: normalized.request,
downstreamHeaders,
providerHeaders,
codexExplicitSessionId: deriveCodexExplicitSessionId(normalized.request, websocketSessionId),
});
const requestUrl = `${codexWebsocketChannel.site.url.replace(/\/+$/, '')}${prepared.path}`;
if (response.statusCode < 200 || response.statusCode >= 300) {
let payload: unknown = null;
try {
payload = JSON.parse(response.body);
} catch {
payload = null;
}
writeResponsesWebsocketError(
socket,
response.statusCode,
response.statusMessage || 'Upstream error',
payload,
);
return;
}
const contentType = String(response.headers['content-type'] || '').toLowerCase();
if (!contentType.includes('text/event-stream')) {
try {
const payload = JSON.parse(response.body);
socket.send(JSON.stringify(payload));
lastResponseOutput = isRecord(payload?.response) && Array.isArray(payload.response.output)
? cloneJsonObject(payload.response.output)
: [];
} catch {
writeResponsesWebsocketError(socket, 502, 'Unexpected non-JSON websocket proxy response');
}
return;
}
const pulled = openAiResponsesTransformer.pullSseEvents(response.body);
const forwardedPayloads: unknown[] = [];
let sawTerminalPayload = false;
for (const event of pulled.events) {
if (event.data === '[DONE]') continue;
try {
const payload = JSON.parse(event.data);
forwardedPayloads.push(payload);
const type = isRecord(payload) ? asTrimmedString(payload.type) : '';
if (type === 'response.completed' || type === 'response.failed') {
sawTerminalPayload = true;
const runtimeResult = await codexWebsocketRuntime.sendRequest({
sessionId: websocketSessionId,
requestUrl,
headers: prepared.headers,
body: prepared.body,
});
lastResponseOutput = collectResponsesOutput(runtimeResult.events);
for (const payload of runtimeResult.events) {
socket.send(JSON.stringify(payload));
}
socket.send(JSON.stringify(payload));
} catch {
// Ignore malformed SSE frames; the HTTP route already normalizes them.
} catch (error) {
const runtimeError = error instanceof CodexWebsocketRuntimeError
? error
: new CodexWebsocketRuntimeError('upstream websocket request failed');
if (runtimeError.status === 426) {
const fallbackOutput = await forwardResponsesRequestViaHttp({
app,
socket,
request,
payload: normalized.request,
preserveIncrementalMode: false,
});
if (fallbackOutput) {
lastResponseOutput = fallbackOutput;
}
return;
}
lastResponseOutput = collectResponsesOutput(runtimeError.events);
for (const payload of runtimeError.events) {
socket.send(JSON.stringify(payload));
}
writeResponsesWebsocketError(
socket,
runtimeError.status || 408,
runtimeError.message,
runtimeError.payload,
);
}
return;
}
lastResponseOutput = collectResponsesOutput(forwardedPayloads);
if (!sawTerminalPayload) {
writeResponsesWebsocketError(socket, 408, 'stream closed before response.completed');
const forwardedOutput = await forwardResponsesRequestViaHttp({
app,
socket,
request,
payload: normalized.request,
preserveIncrementalMode: supportsIncrementalInput,
});
if (forwardedOutput) {
lastResponseOutput = forwardedOutput;
}
});
}
@@ -394,6 +596,7 @@ export function ensureResponsesWebsocketTransport(app: FastifyInstance) {
});
app.addHook('onClose', async () => {
await codexWebsocketRuntime.closeAllSessions();
await new Promise<void>((resolve) => {
websocketServer.close(() => resolve());
});
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise<any> => null);
@@ -32,6 +33,17 @@ describe('resolveUpstreamEndpointCandidates', () => {
beforeEach(() => {
fetchModelPricingCatalogMock.mockReset();
fetchModelPricingCatalogMock.mockResolvedValue(null);
(config as any).codexHeaderDefaults = {
userAgent: '',
betaFeatures: '',
};
(config as any).payloadRules = {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
});
it('uses downstream-aligned endpoint priority for unknown platforms', async () => {
@@ -468,6 +480,136 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.body.prompt_cache_key).toBe('codex-cache-123');
});
it('applies configured codex header defaults with CLIProxyAPI-compatible precedence', () => {
(config as any).codexHeaderDefaults = {
userAgent: 'codex-config-ua/1.0',
betaFeatures: 'multi_agent',
};
const websocketRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello codex' }],
},
downstreamFormat: 'openai',
downstreamHeaders: {
'x-metapi-responses-websocket-transport': '1',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(websocketRequest.headers['User-Agent']).toBe('codex-config-ua/1.0');
expect(websocketRequest.headers['x-codex-beta-features']).toBe('multi_agent');
const clientHeaderRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello again codex' }],
},
downstreamFormat: 'openai',
downstreamHeaders: {
'x-metapi-responses-websocket-transport': '1',
'user-agent': 'client-ua/2.0',
'x-codex-beta-features': 'client-beta',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(clientHeaderRequest.headers['User-Agent']).toBe('codex-config-ua/1.0');
expect(clientHeaderRequest.headers['x-codex-beta-features']).toBe('client-beta');
const httpRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'plain http codex' }],
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(httpRequest.headers['x-codex-beta-features']).toBeUndefined();
});
it('applies configured payload rules before preparing codex responses requests', () => {
(config as any).payloadRules = {
default: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
'reasoning.effort': 'high',
},
},
],
defaultRaw: [],
override: [
{
models: [{ name: 'gpt-5.4', protocol: 'codex' }],
params: {
'text.verbosity': 'low',
},
},
],
overrideRaw: [],
filter: [
{
models: [{ name: 'gpt-5.4', protocol: 'codex' }],
params: ['safety_identifier'],
},
],
};
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello codex' }],
verbosity: 'high',
safety_identifier: 'drop-me',
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(request.body.reasoning).toEqual({ effort: 'high' });
expect(request.body.text).toEqual({ verbosity: 'low' });
expect(request.body.safety_identifier).toBeUndefined();
});
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
@@ -850,7 +992,7 @@ describe('buildUpstreamEndpointRequest', () => {
]);
});
it('applies global responses standardization and drops non-standard fields', () => {
it('preserves unknown native responses fields while still normalizing known compatibility fields', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'upstream-gpt',
@@ -865,13 +1007,13 @@ describe('buildUpstreamEndpointRequest', () => {
input: 'hello',
metadata: { trace: 'abc123' },
max_completion_tokens: 512,
custom_vendor_flag: 'drop-me',
custom_vendor_flag: 'keep-me',
},
});
expect(request.path).toBe('/v1/responses');
expect(request.body.metadata).toEqual({ trace: 'abc123' });
expect(request.body.custom_vendor_flag).toBeUndefined();
expect(request.body.custom_vendor_flag).toBe('keep-me');
expect(request.body.max_completion_tokens).toBeUndefined();
expect(request.body.max_output_tokens).toBe(512);
expect(request.body.input).toEqual([
+58 -12
View File
@@ -1,6 +1,12 @@
import { createHash, randomUUID } from 'node:crypto';
import {
rankConversationFileEndpoints,
type ConversationFileInputSummary,
} from '../../proxy-core/capabilities/conversationFileCapabilities.js';
import { resolveProviderProfile } from '../../proxy-core/providers/registry.js';
import { config } from '../../config.js';
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
import { applyPayloadRules } from '../../services/payloadRules.js';
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer,
@@ -47,6 +53,20 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function resolveRequestedModelForPayloadRules(input: {
modelName: string;
openaiBody: Record<string, unknown>;
claudeOriginalBody?: Record<string, unknown>;
responsesOriginalBody?: Record<string, unknown>;
}): string {
return (
asTrimmedString(input.responsesOriginalBody?.model)
|| asTrimmedString(input.claudeOriginalBody?.model)
|| asTrimmedString(input.openaiBody.model)
|| asTrimmedString(input.modelName)
);
}
function normalizePlatformName(platform: unknown): string {
return asTrimmedString(platform).toLowerCase();
}
@@ -626,6 +646,7 @@ export async function resolveUpstreamEndpointCandidates(
requestedModelHint?: string,
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
},
): Promise<UpstreamEndpoint[]> {
@@ -634,7 +655,13 @@ export async function resolveUpstreamEndpointCandidates(
isClaudeFamilyModel(modelName)
|| isClaudeFamilyModel(asTrimmedString(requestedModelHint))
);
const hasNonImageFileInput = requestCapabilities?.hasNonImageFileInput === true;
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
hasImage: false,
hasAudio: false,
hasDocument: requestCapabilities?.hasNonImageFileInput === true,
hasRemoteDocumentUrl: false,
};
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const wantsNativeResponsesReasoning = requestCapabilities?.wantsNativeResponsesReasoning === true;
if (sitePlatform === 'anyrouter') {
// anyrouter deployments are effectively anthropic-protocol first.
@@ -659,8 +686,14 @@ export async function resolveUpstreamEndpointCandidates(
if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[];
if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[];
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[];
if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[];
return ['responses', 'messages', 'chat'] as UpstreamEndpoint[];
return rankConversationFileEndpoints({
sitePlatform,
requestedOrder: preferMessagesForClaudeModel
? ['messages', 'responses', 'chat']
: ['responses', 'messages', 'chat'],
summary: conversationFileSummary,
preferMessagesForClaudeModel,
});
})()
: preferred;
const prioritizedPreferredEndpoints: UpstreamEndpoint[] = (
@@ -885,6 +918,16 @@ export function buildUpstreamEndpointRequest(input: {
stream: input.stream,
oauthProjectId: asTrimmedString(input.oauthProjectId) || null,
};
const requestedModelForPayloadRules = resolveRequestedModelForPayloadRules(input);
const applyConfiguredPayloadRules = <T extends Record<string, unknown>>(body: T): T => (
applyPayloadRules({
rules: config.payloadRules,
payload: body,
modelName: input.modelName,
requestedModel: requestedModelForPayloadRules,
protocol: sitePlatform,
}) as T
);
if (isInternalGeminiUpstream) {
const instructions = (
@@ -898,6 +941,7 @@ export function buildUpstreamEndpointRequest(input: {
modelName: input.modelName,
instructions,
});
const configuredGeminiRequest = applyConfiguredPayloadRules(geminiRequest);
if (!providerProfile) {
throw new Error(`missing provider profile for platform: ${sitePlatform}`);
}
@@ -911,7 +955,7 @@ export function buildUpstreamEndpointRequest(input: {
sitePlatform,
baseHeaders: commonHeaders,
providerHeaders: input.providerHeaders,
body: geminiRequest,
body: configuredGeminiRequest,
action: input.stream ? 'streamGenerateContent' : 'generateContent',
});
}
@@ -952,6 +996,7 @@ export function buildUpstreamEndpointRequest(input: {
?? sanitizeAnthropicMessagesBody(
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
);
const configuredClaudeBody = applyConfiguredPayloadRules(sanitizedBody);
if (providerProfile?.id === 'claude') {
return providerProfile.prepareRequest({
@@ -964,7 +1009,7 @@ export function buildUpstreamEndpointRequest(input: {
sitePlatform,
baseHeaders: commonHeaders,
claudeHeaders,
body: sanitizedBody,
body: configuredClaudeBody,
});
}
@@ -980,7 +1025,7 @@ export function buildUpstreamEndpointRequest(input: {
return {
path: resolveEndpointPath('messages'),
headers,
body: sanitizedBody,
body: configuredClaudeBody,
runtime,
};
}
@@ -1015,6 +1060,7 @@ export function buildUpstreamEndpointRequest(input: {
),
sitePlatform,
);
const configuredResponsesBody = applyConfiguredPayloadRules(body);
if (providerProfile?.id === 'codex') {
return providerProfile.prepareRequest({
@@ -1032,7 +1078,7 @@ export function buildUpstreamEndpointRequest(input: {
providerHeaders: input.providerHeaders,
codexSessionCacheKey: input.codexSessionCacheKey,
codexExplicitSessionId: input.codexExplicitSessionId,
body,
body: configuredResponsesBody,
});
}
@@ -1055,15 +1101,15 @@ export function buildUpstreamEndpointRequest(input: {
: null;
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
&& !!codexSessionId
&& !asTrimmedString((body as Record<string, unknown>).prompt_cache_key)
&& !asTrimmedString((configuredResponsesBody as Record<string, unknown>).prompt_cache_key)
&& !asTrimmedString(input.codexExplicitSessionId)
&& !!asTrimmedString(input.codexSessionCacheKey);
const runtimeBody = shouldInjectDerivedPromptCacheKey
? {
...body,
...configuredResponsesBody,
prompt_cache_key: codexSessionId,
}
: body;
: configuredResponsesBody;
return {
path: resolveEndpointPath('responses'),
@@ -1077,11 +1123,11 @@ export function buildUpstreamEndpointRequest(input: {
return {
path: resolveEndpointPath('chat'),
headers,
body: {
body: applyConfiguredPayloadRules({
...openaiBody,
model: input.modelName,
stream: input.stream,
},
}),
runtime,
};
}
+312
View File
@@ -0,0 +1,312 @@
import { minimatch } from 'minimatch';
export type PayloadRuleModel = {
name: string;
protocol?: string;
};
export type PayloadValueRule = {
models: PayloadRuleModel[];
params: Record<string, unknown>;
};
export type PayloadFilterRule = {
models: PayloadRuleModel[];
params: string[];
};
export type PayloadRulesConfig = {
default: PayloadValueRule[];
defaultRaw: PayloadValueRule[];
override: PayloadValueRule[];
overrideRaw: PayloadValueRule[];
filter: PayloadFilterRule[];
};
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 toPathSegments(path: string): string[] {
const normalized = asTrimmedString(path).replace(/^\.+/, '');
return normalized
.split('.')
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
function parseIndexSegment(segment: string): number | null {
if (!/^\d+$/.test(segment)) return null;
const parsed = Number.parseInt(segment, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function hasPath(target: unknown, path: string): boolean {
const segments = toPathSegments(path);
if (segments.length <= 0) return false;
let current: unknown = target;
for (const segment of segments) {
const index = parseIndexSegment(segment);
if (index !== null) {
if (!Array.isArray(current) || index >= current.length) return false;
current = current[index];
continue;
}
if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return false;
current = current[segment];
}
return true;
}
function setPath(target: Record<string, unknown>, path: string, value: unknown): void {
const segments = toPathSegments(path);
if (segments.length <= 0) return;
let current: unknown = target;
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
const nextSegment = segments[index + 1];
const segmentIndex = parseIndexSegment(segment);
const isLast = index === segments.length - 1;
if (segmentIndex !== null) {
if (!Array.isArray(current)) return;
while (current.length <= segmentIndex) current.push(undefined);
if (isLast) {
current[segmentIndex] = cloneJsonValue(value);
return;
}
if (!isRecord(current[segmentIndex]) && !Array.isArray(current[segmentIndex])) {
current[segmentIndex] = parseIndexSegment(nextSegment) !== null ? [] : {};
}
current = current[segmentIndex];
continue;
}
if (!isRecord(current)) return;
if (isLast) {
current[segment] = cloneJsonValue(value);
return;
}
if (!isRecord(current[segment]) && !Array.isArray(current[segment])) {
current[segment] = parseIndexSegment(nextSegment) !== null ? [] : {};
}
current = current[segment];
}
}
function deletePath(target: Record<string, unknown>, path: string): void {
const segments = toPathSegments(path);
if (segments.length <= 0) return;
let current: unknown = target;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index];
const segmentIndex = parseIndexSegment(segment);
if (segmentIndex !== null) {
if (!Array.isArray(current) || segmentIndex >= current.length) return;
current = current[segmentIndex];
continue;
}
if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return;
current = current[segment];
}
const lastSegment = segments[segments.length - 1];
const lastIndex = parseIndexSegment(lastSegment);
if (lastIndex !== null) {
if (!Array.isArray(current) || lastIndex >= current.length) return;
current.splice(lastIndex, 1);
return;
}
if (!isRecord(current)) return;
delete current[lastSegment];
}
function normalizePayloadRuleModels(value: unknown): PayloadRuleModel[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const name = asTrimmedString(item.name);
if (!name) return null;
const protocol = asTrimmedString(item.protocol);
return {
name,
...(protocol ? { protocol } : {}),
};
})
.filter((item): item is PayloadRuleModel => !!item);
}
function normalizePayloadValueRules(value: unknown): PayloadValueRule[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const models = normalizePayloadRuleModels(item.models);
const params = isRecord(item.params) ? cloneJsonValue(item.params) : null;
if (models.length <= 0 || !params) return null;
return { models, params };
})
.filter((item): item is PayloadValueRule => !!item);
}
function normalizePayloadFilterRules(value: unknown): PayloadFilterRule[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (!isRecord(item)) return null;
const models = normalizePayloadRuleModels(item.models);
const params = Array.isArray(item.params)
? item.params
.map((entry) => asTrimmedString(entry))
.filter((entry) => entry.length > 0)
: [];
if (models.length <= 0 || params.length <= 0) return null;
return { models, params };
})
.filter((item): item is PayloadFilterRule => !!item);
}
function modelRuleMatches(rule: PayloadRuleModel, protocol: string, candidates: string[]): boolean {
if (!rule.name || candidates.length <= 0) return false;
const ruleProtocol = asTrimmedString(rule.protocol).toLowerCase();
if (ruleProtocol && protocol && ruleProtocol !== protocol) return false;
return candidates.some((candidate) => minimatch(candidate, rule.name, { nocase: true }));
}
function rulesMatch(models: PayloadRuleModel[], protocol: string, candidates: string[]): boolean {
if (models.length <= 0 || candidates.length <= 0) return false;
return models.some((rule) => modelRuleMatches(rule, protocol, candidates));
}
function parseRawRuleValue(value: unknown): unknown {
if (typeof value !== 'string') return cloneJsonValue(value);
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
export function createEmptyPayloadRulesConfig(): PayloadRulesConfig {
return {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
}
export function normalizePayloadRulesConfig(value: unknown): PayloadRulesConfig {
if (!isRecord(value)) return createEmptyPayloadRulesConfig();
return {
default: normalizePayloadValueRules(value.default),
defaultRaw: normalizePayloadValueRules(value.defaultRaw ?? value['default-raw']),
override: normalizePayloadValueRules(value.override),
overrideRaw: normalizePayloadValueRules(value.overrideRaw ?? value['override-raw']),
filter: normalizePayloadFilterRules(value.filter),
};
}
export function applyPayloadRules(input: {
rules: PayloadRulesConfig;
payload: Record<string, unknown>;
modelName?: string;
requestedModel?: string;
protocol?: string;
}): Record<string, unknown> {
const candidates = Array.from(new Set(
[input.modelName, input.requestedModel]
.map((value) => asTrimmedString(value))
.filter((value) => value.length > 0),
));
if (candidates.length <= 0) return input.payload;
const rules = input.rules;
const hasAnyRules = rules.default.length > 0
|| rules.defaultRaw.length > 0
|| rules.override.length > 0
|| rules.overrideRaw.length > 0
|| rules.filter.length > 0;
if (!hasAnyRules) return input.payload;
const protocol = asTrimmedString(input.protocol).toLowerCase();
const original = cloneJsonValue(input.payload);
const output = cloneJsonValue(input.payload);
const appliedDefaults = new Set<string>();
for (const rule of rules.default) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const [path, value] of Object.entries(rule.params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath || hasPath(original, normalizedPath) || appliedDefaults.has(normalizedPath)) continue;
setPath(output, normalizedPath, value);
appliedDefaults.add(normalizedPath);
}
}
for (const rule of rules.defaultRaw) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const [path, value] of Object.entries(rule.params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath || hasPath(original, normalizedPath) || appliedDefaults.has(normalizedPath)) continue;
const parsed = parseRawRuleValue(value);
if (parsed === undefined) continue;
setPath(output, normalizedPath, parsed);
appliedDefaults.add(normalizedPath);
}
}
for (const rule of rules.override) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const [path, value] of Object.entries(rule.params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
setPath(output, normalizedPath, value);
}
}
for (const rule of rules.overrideRaw) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const [path, value] of Object.entries(rule.params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
const parsed = parseRawRuleValue(value);
if (parsed === undefined) continue;
setPath(output, normalizedPath, parsed);
}
}
for (const rule of rules.filter) {
if (!rulesMatch(rule.models, protocol, candidates)) continue;
for (const path of rule.params) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
deletePath(output, normalizedPath);
}
}
return output;
}
+2 -11
View File
@@ -1,6 +1,7 @@
import type { ProxyResourceOwner } from '../middleware/auth.js';
import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
@@ -306,15 +307,5 @@ export async function resolveResponsesBodyInputFiles(
}
export function hasNonImageFileInputInOpenAiBody(body: Record<string, unknown>): boolean {
const messages = Array.isArray(body.messages) ? body.messages : [];
return messages.some((message) => {
if (!isRecord(message)) return false;
const content = message.content;
if (isRecord(content)) {
const type = asTrimmedString(content.type).toLowerCase();
return type === 'file' || type === 'input_file';
}
if (!Array.isArray(content)) return false;
return content.some((item) => isRecord(item) && ['file', 'input_file'].includes(asTrimmedString(item.type).toLowerCase()));
});
return summarizeConversationFileInputsInOpenAiBody(body).hasDocument;
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { convertOpenAiBodyToGeminiGenerateContentRequest } from './conversion.js';
describe('convertOpenAiBodyToGeminiGenerateContentRequest', () => {
it('maps OpenAI generic file blocks to Gemini inlineData parts', () => {
const request = convertOpenAiBodyToGeminiGenerateContentRequest({
modelName: 'gemini-2.5-pro',
body: {
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this file' },
{
type: 'file',
file: {
filename: 'brief.pdf',
mime_type: 'application/pdf',
file_data: 'JVBERi0xLjc=',
},
},
],
},
],
},
});
expect(request.contents).toEqual([
{
role: 'user',
parts: [
{ text: 'summarize this file' },
{
inlineData: {
mime_type: 'application/pdf',
data: 'JVBERi0xLjc=',
},
},
],
},
]);
});
});
@@ -0,0 +1,327 @@
import { normalizeInputFileBlock } from '../../shared/inputFile.js';
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I=';
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 parseDataUrl(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 toGeminiInlineDataPart(input: {
mimeType: string;
data: string;
}): Record<string, unknown> {
return {
inlineData: {
mime_type: input.mimeType,
data: input.data,
},
};
}
function convertContentToGeminiParts(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' || type === 'input_image') {
const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.image_url ?? item.url);
const parsed = imageUrl ? parseDataUrl(imageUrl) : null;
if (parsed) {
parts.push(toGeminiInlineDataPart(parsed));
continue;
}
if (imageUrl) {
parts.push({
fileData: {
fileUri: imageUrl,
},
});
}
continue;
}
if (type === 'input_audio') {
const audio = isRecord(item.input_audio) ? item.input_audio : item;
const data = asTrimmedString(audio.data);
if (data) {
parts.push(toGeminiInlineDataPart({
mimeType: asTrimmedString(audio.mime_type ?? audio.mimeType) || 'audio/wav',
data,
}));
}
continue;
}
const normalizedFile = normalizeInputFileBlock(item);
if (normalizedFile) {
if (normalizedFile.fileData) {
const parsed = parseDataUrl(normalizedFile.fileData);
parts.push(toGeminiInlineDataPart({
mimeType: normalizedFile.mimeType || parsed?.mimeType || 'application/octet-stream',
data: parsed?.data || normalizedFile.fileData,
}));
continue;
}
const fileUri = normalizedFile.fileUrl || normalizedFile.fileId;
if (fileUri) {
parts.push({
fileData: {
fileUri,
...(normalizedFile.mimeType ? { mimeType: normalizedFile.mimeType } : {}),
},
});
}
}
}
return parts;
}
function buildGeminiTools(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 buildGeminiToolConfig(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 convertOpenAiBodyToGeminiGenerateContentRequest(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 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(...convertContentToGeminiParts(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 = convertContentToGeminiParts(message.content);
const functionCallParts: 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 functionCallPart: Record<string, unknown> = {
functionCall: { name, args },
};
const id = asTrimmedString(toolCall.id);
const signature = thoughtSignatureById.get(id);
if (signature) {
functionCallPart.thoughtSignature = signature;
} else if (hasThinkingEnabled) {
functionCallPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE;
}
functionCallParts.push(functionCallPart);
}
const geminiRole = role === 'assistant' ? 'model' : 'user';
const hasSigned = functionCallParts.some((part) => 'thoughtSignature' in part);
if (hasSigned && textParts.length > 0 && functionCallParts.length > 0) {
request.contents = [
...(Array.isArray(request.contents) ? request.contents : []),
{ role: geminiRole, parts: textParts },
{ role: geminiRole, parts: functionCallParts },
];
} else {
const allParts = [...textParts, ...functionCallParts];
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) {
generationConfig.thinkingConfig = thinkingConfig;
}
if (Object.keys(generationConfig).length > 0) {
request.generationConfig = generationConfig;
}
const geminiTools = buildGeminiTools(input.body.tools);
if (geminiTools) {
request.tools = geminiTools;
}
const toolConfig = buildGeminiToolConfig(input.body.tool_choice);
if (toolConfig) {
request.toolConfig = toolConfig;
}
return request;
}
@@ -187,6 +187,27 @@ describe('sanitizeResponsesBodyForProxy', () => {
},
});
});
it('preserves unknown top-level fields while still normalizing known compatibility fields', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
max_completion_tokens: 256,
custom_vendor_flag: 'keep-me',
},
'gpt-5',
false,
);
expect(result).toMatchObject({
model: 'gpt-5',
stream: false,
custom_vendor_flag: 'keep-me',
max_output_tokens: 256,
});
expect(result.max_completion_tokens).toBeUndefined();
});
});
describe('convertOpenAiBodyToResponsesBody', () => {
@@ -432,6 +453,84 @@ describe('convertOpenAiBodyToResponsesBody', () => {
},
]);
});
it('shortens long MCP tool names consistently across tools, tool_choice and assistant tool calls', () => {
const sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening';
const firstName = `mcp__alpha_workspace__${sharedSuffix}`;
const secondName = `mcp__beta_workspace__${sharedSuffix}`;
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [
{
role: 'assistant',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: firstName,
arguments: '{"city":"shanghai"}',
},
},
],
},
{
role: 'tool',
tool_call_id: 'call_1',
content: 'done',
},
],
tools: [
{
type: 'function',
function: {
name: firstName,
parameters: { type: 'object' },
},
},
{
type: 'function',
function: {
name: secondName,
parameters: { type: 'object' },
},
},
],
tool_choice: {
type: 'function',
function: {
name: secondName,
},
},
},
'gpt-5',
false,
);
const toolNames = Array.isArray(result.tools)
? result.tools.map((tool: any) => tool.name)
: [];
const assistantCall = Array.isArray(result.input)
? result.input.find((item: any) => item.type === 'function_call')
: null;
expect(toolNames).toHaveLength(2);
expect(toolNames[0].length).toBeLessThanOrEqual(64);
expect(toolNames[1].length).toBeLessThanOrEqual(64);
expect(toolNames[0].startsWith('mcp__')).toBe(true);
expect(toolNames[1].startsWith('mcp__')).toBe(true);
expect(toolNames[0]).not.toBe(toolNames[1]);
expect(result.tool_choice).toEqual({
type: 'function',
name: toolNames[1],
});
expect(assistantCall).toMatchObject({
type: 'function_call',
name: toolNames[0],
});
});
});
describe('convertResponsesBodyToOpenAiBody', () => {
@@ -3,6 +3,7 @@ import {
normalizeResponsesMessageItem,
} from './compatibility.js';
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
@@ -247,7 +248,49 @@ function toResponsesInputMessageFromText(text: string): Record<string, unknown>
};
}
function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
function collectOpenAiToolNames(body: Record<string, unknown>): string[] {
const names: string[] = [];
const pushName = (value: unknown) => {
const name = asTrimmedString(value);
if (name) names.push(name);
};
const rawTools = Array.isArray(body.tools) ? body.tools : [];
for (const item of rawTools) {
if (!isRecord(item)) continue;
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'function' && isRecord(item.function)) {
pushName(item.function.name);
continue;
}
if (type === 'function') {
pushName(item.name);
}
}
const toolChoice = isRecord(body.tool_choice) ? body.tool_choice : null;
if (toolChoice && asTrimmedString(toolChoice.type).toLowerCase() === 'function') {
pushName(isRecord(toolChoice.function) ? toolChoice.function.name : toolChoice.name);
}
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
for (const message of rawMessages) {
if (!isRecord(message) || asTrimmedString(message.role).toLowerCase() !== 'assistant') continue;
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
for (const toolCall of toolCalls) {
if (!isRecord(toolCall)) continue;
const functionPart = isRecord(toolCall.function) ? toolCall.function : null;
pushName(functionPart?.name ?? toolCall.name);
}
}
return names;
}
function convertOpenAiToolsToResponses(
rawTools: unknown,
toolNameMap: Record<string, string>,
): unknown {
if (!Array.isArray(rawTools)) return rawTools;
const converted = rawTools
@@ -262,7 +305,7 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
const mapped: Record<string, unknown> = {
type: 'function',
name,
name: getShortToolName(name, toolNameMap),
};
const description = asTrimmedString(fn.description);
if (description) mapped.description = description;
@@ -272,7 +315,10 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
}
if (type === 'function' && asTrimmedString(item.name)) {
return item;
return {
...item,
name: getShortToolName(asTrimmedString(item.name), toolNameMap),
};
}
if (type === 'image_generation') {
@@ -290,7 +336,10 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown {
return converted.length > 0 ? converted : rawTools;
}
function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
function convertOpenAiToolChoiceToResponses(
rawToolChoice: unknown,
toolNameMap: Record<string, string>,
): unknown {
if (rawToolChoice === undefined) return undefined;
if (typeof rawToolChoice === 'string') return rawToolChoice;
if (!isRecord(rawToolChoice)) return rawToolChoice;
@@ -299,7 +348,7 @@ function convertOpenAiToolChoiceToResponses(rawToolChoice: unknown): unknown {
if (type === 'function' && isRecord(rawToolChoice.function)) {
const name = asTrimmedString(rawToolChoice.function.name);
if (!name) return 'required';
return { type: 'function', name };
return { type: 'function', name: getShortToolName(name, toolNameMap) };
}
return rawToolChoice;
@@ -330,38 +379,12 @@ export function normalizeResponsesMessageContent(role: string, content: unknown)
return [];
}
const ALLOWED_RESPONSES_FIELDS = new Set([
'model',
'input',
'instructions',
'max_output_tokens',
const RESPONSES_COMPATIBILITY_FILTER_FIELDS = new Set([
'max_completion_tokens',
'temperature',
'top_p',
'truncation',
'tools',
'tool_choice',
'parallel_tool_calls',
'metadata',
'reasoning',
'store',
'safety_identifier',
'stream',
'stream_options',
'user',
'max_tool_calls',
'prompt_cache_key',
'prompt_cache_retention',
'background',
'previous_response_id',
'text',
'audio',
'include',
'messages',
'prompt',
'response_format',
'service_tier',
'top_logprobs',
'stop',
'n',
'verbosity',
]);
export function sanitizeResponsesBodyForProxy(
@@ -397,11 +420,9 @@ export function sanitizeResponsesBodyForProxy(
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
});
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(normalized)) {
if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue;
if (key === 'max_completion_tokens') continue;
sanitized[key] = value;
const sanitized: Record<string, unknown> = { ...normalized };
for (const key of RESPONSES_COMPATIBILITY_FILTER_FIELDS) {
delete sanitized[key];
}
const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens);
@@ -427,6 +448,7 @@ export function convertOpenAiBodyToResponsesBody(
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
const systemContents: string[] = [];
const inputItems: Array<Record<string, unknown>> = [];
const toolNameMap = buildShortToolNameMap(collectOpenAiToolNames(openaiBody));
for (const item of rawMessages) {
if (!isRecord(item)) continue;
@@ -466,7 +488,7 @@ export function convertOpenAiBodyToResponsesBody(
inputItems.push({
type: 'function_call',
call_id: callId,
name,
name: getShortToolName(name, toolNameMap),
arguments: argumentsValue,
});
}
@@ -520,7 +542,7 @@ export function convertOpenAiBodyToResponsesBody(
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning;
if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools);
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap);
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
if (openaiBody.max_tool_calls !== undefined) body.max_tool_calls = openaiBody.max_tool_calls;
if (openaiBody.prompt_cache_key !== undefined) body.prompt_cache_key = openaiBody.prompt_cache_key;
@@ -546,7 +568,7 @@ export function convertOpenAiBodyToResponsesBody(
body.text = textConfig;
}
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice);
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap);
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
return normalizeResponsesBodyForCompatibility(
@@ -0,0 +1,50 @@
const TOOL_NAME_LIMIT = 64;
const MCP_PREFIX = 'mcp__';
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
export function shortenToolNameIfNeeded(name: string): string {
const trimmed = asTrimmedString(name);
if (trimmed.length <= TOOL_NAME_LIMIT) return trimmed;
if (trimmed.startsWith(MCP_PREFIX)) {
const lastSeparator = trimmed.lastIndexOf('__');
if (lastSeparator > 0) {
const candidate = `${MCP_PREFIX}${trimmed.slice(lastSeparator + 2)}`;
return candidate.length > TOOL_NAME_LIMIT ? candidate.slice(0, TOOL_NAME_LIMIT) : candidate;
}
}
return trimmed.slice(0, TOOL_NAME_LIMIT);
}
export function buildShortToolNameMap(names: string[]): Record<string, string> {
const uniqueNames = Array.from(new Set(
names
.map((name) => asTrimmedString(name))
.filter((name) => name.length > 0),
));
const used = new Set<string>();
const mapping: Record<string, string> = {};
for (const name of uniqueNames) {
const base = shortenToolNameIfNeeded(name);
let candidate = base;
let suffixIndex = 1;
while (used.has(candidate)) {
const suffix = `_${suffixIndex}`;
const allowedLength = Math.max(0, TOOL_NAME_LIMIT - suffix.length);
candidate = `${base.slice(0, allowedLength)}${suffix}`;
suffixIndex += 1;
}
used.add(candidate);
mapping[name] = candidate;
}
return mapping;
}
export function getShortToolName(name: string, mapping: Record<string, string>): string {
const trimmed = asTrimmedString(name);
return mapping[trimmed] || shortenToolNameIfNeeded(trimmed);
}
+23 -6
View File
@@ -44,6 +44,7 @@ import {
type TestTargetFormat,
type TestChatPayload,
} from './helpers/modelTesterSession.js';
import { resolveConversationFileCapability } from './helpers/conversationFileCapabilities.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
@@ -970,6 +971,14 @@ export default function ModelTester() {
return uploaded;
}, [conversationFiles, pushDebug]);
const inlineConversationFiles = useCallback((): ConversationUploadedFile[] =>
conversationFiles.map((item) => ({
fileId: item.fileId,
filename: item.name,
mimeType: item.mimeType,
data: item.dataUrl,
})), [conversationFiles]);
const buildConversationMessagesWithSystem = useCallback((baseMessages: ChatMessage[]) => {
if (!inputs.systemPrompt.trim()) return baseMessages;
return [
@@ -1339,7 +1348,11 @@ export default function ModelTester() {
() => filteredModels.map((item) => ({ value: item, label: item })),
[filteredModels],
);
const conversationFileSupported = inputs.protocol === 'openai' || inputs.protocol === 'responses';
const conversationFileCapability = useMemo(
() => resolveConversationFileCapability(inputs.protocol),
[inputs.protocol],
);
const conversationFileSupported = conversationFileCapability.supported;
const canSend = useMemo(() => {
if (sending || pendingJobId || !inputs.model) return false;
if (inputs.mode !== 'conversation') {
@@ -1808,7 +1821,7 @@ export default function ModelTester() {
}
if (conversationFiles.length > 0 && !conversationFileSupported) {
const message = '当前协议的会话附件仅支持 OpenAI / Responses;请切换协议或移除附件。';
const message = conversationFileCapability.reason || '当前协议暂不支持会话附件。';
setError(message);
pushDebug('warn', message);
return;
@@ -1817,7 +1830,9 @@ export default function ModelTester() {
if (!customRequestMode && conversationFileSupported && conversationFiles.length > 0) {
setSending(true);
try {
const uploadedFiles = await uploadConversationFiles();
const uploadedFiles = conversationFileCapability.documentMode === 'inline_only'
? inlineConversationFiles()
: await uploadConversationFiles();
setInput('');
setConversationFiles([]);
await sendWithPrompt(trimmed, messages, uploadedFiles);
@@ -1855,7 +1870,7 @@ export default function ModelTester() {
{ stream: inputs.stream, jobMode: !inputs.stream },
),
);
}, [canSend, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
}, [canSend, conversationFileCapability, conversationFileSupported, conversationFiles.length, customRequestBody, customRequestMode, dispatchPayload, inlineConversationFiles, input, inputs.mode, messages, pushDebug, sendModeRequest, sendWithPrompt, uploadConversationFiles]);
const retryPending = useCallback(async () => {
if (sending || pendingJobId || !pendingPayload) return;
@@ -2812,8 +2827,10 @@ export default function ModelTester() {
{customRequestMode
? '自定义请求模式不会自动上传这些附件;关闭自定义模式后可走标准 /v1/files 链路。'
: !conversationFileSupported
? '当前协议暂不支持会话附件注入;请切换到 OpenAI 或 Responses。'
: '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送前会先上传到 /v1/files。'}
? (conversationFileCapability.reason || '当前协议暂不支持会话附件注入。')
: conversationFileCapability.documentMode === 'inline_only'
? '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送时会以内联文档方式注入。'
: '支持 PDF / TXT / Markdown / JSON / 图片 / 音频;发送前会先上传到 /v1/files。'}
</span>
</div>
{conversationFiles.length > 0 && (
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { resolveConversationFileCapability } from './conversationFileCapabilities.js';
describe('resolveConversationFileCapability', () => {
it('keeps OpenAI and Responses on native file references', () => {
expect(resolveConversationFileCapability('openai')).toEqual({
supported: true,
documentMode: 'native',
reason: '',
});
expect(resolveConversationFileCapability('responses')).toEqual({
supported: true,
documentMode: 'native',
reason: '',
});
});
it('marks Claude and Gemini as inline-only document transports', () => {
expect(resolveConversationFileCapability('claude')).toEqual({
supported: true,
documentMode: 'inline_only',
reason: '当前协议会以内联文档方式发送会话附件。',
});
expect(resolveConversationFileCapability('gemini')).toEqual({
supported: true,
documentMode: 'inline_only',
reason: '当前协议会以内联文档方式发送会话附件。',
});
});
});
@@ -0,0 +1,33 @@
import type { PlaygroundProtocol } from './modelTesterSession.js';
export type ConversationFileCapability = {
supported: boolean;
documentMode: 'native' | 'inline_only' | 'unsupported';
reason: string;
};
export function resolveConversationFileCapability(
protocol: PlaygroundProtocol,
): ConversationFileCapability {
if (protocol === 'openai' || protocol === 'responses') {
return {
supported: true,
documentMode: 'native',
reason: '',
};
}
if (protocol === 'claude' || protocol === 'gemini') {
return {
supported: true,
documentMode: 'inline_only',
reason: '当前协议会以内联文档方式发送会话附件。',
};
}
return {
supported: false,
documentMode: 'unsupported',
reason: '当前协议暂不支持会话附件。',
};
}
@@ -435,6 +435,98 @@ describe('modelTesterSession', () => {
});
});
it('builds Claude conversation payloads with inline document blocks for conversation files', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'summarize this',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjc=',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'claude-opus-4-6',
protocol: 'claude',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
model: 'claude-opus-4-6',
stream: false,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'summarize this' },
{
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: 'JVBERi0xLjc=',
},
title: 'brief.pdf',
},
],
},
],
temperature: 0.7,
});
});
it('builds Gemini conversation payloads with inlineData document parts for conversation files', () => {
const payload = buildApiPayload(
[{
id: 'u1',
role: 'user',
content: 'summarize this',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjc=',
},
],
} as ChatMessage],
{
...DEFAULT_INPUTS,
model: 'gemini-2.5-pro',
protocol: 'gemini',
},
DEFAULT_PARAMETER_ENABLED,
);
expect(payload.jsonBody).toEqual({
contents: [
{
role: 'user',
parts: [
{ text: 'summarize this' },
{
inlineData: {
mimeType: 'application/pdf',
data: 'JVBERi0xLjc=',
},
},
],
},
],
generationConfig: { temperature: 0.7 },
});
});
it('builds embeddings and search envelopes', () => {
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
method: 'POST',
+119 -14
View File
@@ -70,9 +70,10 @@ export type PlaygroundMultipartFile = {
};
export type ConversationUploadedFile = {
fileId: string;
fileId?: string | null;
filename?: string | null;
mimeType?: string | null;
data?: string | null;
};
export type TesterProxyEnvelope = {
@@ -295,9 +296,9 @@ const toOpenAiContentPart = (part: ConversationContentPart): Record<string, unkn
if (part.type === 'input_file') {
const filePayload: Record<string, unknown> = {};
if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = part.fileId.trim();
if (typeof part.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
if (typeof part.data === 'string' && part.data.trim()) filePayload.file_data = part.data.trim();
else if (typeof part.fileId === 'string' && part.fileId.trim()) filePayload.file_id = part.fileId.trim();
if (typeof part.filename === 'string' && part.filename.trim()) filePayload.filename = part.filename.trim();
if (typeof part.mimeType === 'string' && part.mimeType.trim()) filePayload.mime_type = part.mimeType.trim();
if (Object.keys(filePayload).length === 0) return null;
return {
@@ -340,9 +341,9 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
const fileBlock: Record<string, unknown> = {
type: 'input_file',
};
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
else if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
if (Object.keys(fileBlock).length === 1) return null;
return fileBlock;
}
@@ -350,11 +351,95 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
return null;
};
const toClaudeContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
if (part.type !== 'input_file' || typeof part.data !== 'string' || !part.data.trim()) return null;
const parsed = parseDataUrl(part.data);
if (!parsed) return null;
return {
type: 'document',
source: {
type: 'base64',
media_type: part.mimeType || parsed.mimeType || 'application/octet-stream',
data: parsed.data,
},
...(typeof part.filename === 'string' && part.filename.trim()
? { title: part.filename.trim() }
: {}),
};
};
const toGeminiContentPart = (part: ConversationContentPart): Record<string, unknown> | null => {
if (part.type === 'image_url') {
return {
fileData: {
fileUri: part.url,
},
};
}
if (part.type === 'image_inline') {
const parsed = parseDataUrl(part.dataUrl);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType,
data: parsed.data,
},
};
}
if (part.type === 'input_audio') {
const parsed = parseDataUrl(part.dataUrl);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType,
data: parsed.data,
},
};
}
if (part.type === 'input_file') {
if (typeof part.data === 'string' && part.data.trim()) {
const parsed = parseDataUrl(part.data);
if (!parsed) return null;
return {
inlineData: {
mimeType: part.mimeType || parsed.mimeType || 'application/octet-stream',
data: parsed.data,
},
};
}
const fileUri = typeof part.fileId === 'string' && part.fileId.trim()
? part.fileId.trim()
: '';
if (!fileUri) return null;
return {
fileData: {
fileUri,
...(typeof part.mimeType === 'string' && part.mimeType.trim()
? { mimeType: part.mimeType.trim() }
: {}),
},
};
}
return null;
};
const toGeminiContents = (messages: ApiChatMessage[]) =>
messages.map((message) => ({
role: message.role === 'assistant' ? 'model' : 'user',
parts: [{ text: message.content }],
}));
parts: [
...(message.content.trim() ? [{ text: message.content }] : []),
...((Array.isArray(message.parts)
? message.parts
.map((part) => toGeminiContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [])),
],
})).filter((message) => Array.isArray(message.parts) && message.parts.length > 0);
const toOpenAiMessageContent = (message: ApiChatMessage): unknown => {
const parts = Array.isArray(message.parts)
@@ -409,10 +494,21 @@ const toResponsesInput = (messages: ApiChatMessage[]) => {
const toClaudeMessages = (messages: ApiChatMessage[]) =>
messages
.filter((message) => message.role !== 'system' && message.role !== 'developer')
.map((message) => ({
role: message.role === 'assistant' ? 'assistant' : 'user',
content: message.content,
}));
.map((message) => {
const parts = [
...(message.content.trim() ? [{ type: 'text', text: message.content }] : []),
...((Array.isArray(message.parts)
? message.parts
.map((part) => toClaudeContentPart(part))
.filter((item): item is Record<string, unknown> => !!item)
: [])),
];
return {
role: message.role === 'assistant' ? 'assistant' : 'user',
content: parts.length > 0 ? parts : message.content,
};
});
const buildConversationJsonBody = (
messages: ChatMessage[],
@@ -779,9 +875,18 @@ export const createConversationInputFilePart = (
file: ConversationUploadedFile,
): ConversationContentPart => ({
type: 'input_file',
fileId: file.fileId,
filename: file.filename ?? null,
mimeType: file.mimeType ?? null,
...(typeof file.fileId === 'string' && file.fileId.trim()
? { fileId: file.fileId.trim() }
: {}),
...(typeof file.filename === 'string' && file.filename.trim()
? { filename: file.filename.trim() }
: {}),
...(typeof file.mimeType === 'string' && file.mimeType.trim()
? { mimeType: file.mimeType.trim() }
: {}),
...(typeof file.data === 'string' && file.data.trim()
? { data: file.data.trim() }
: {}),
});
export const createConversationUserMessage = (