From adc4fb1be785a6f4b309ed725e440f6da62f4db0 Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 20 Mar 2026 11:23:29 +0800 Subject: [PATCH] complete codex cliproxyapi parity --- src/server/config.ts | 15 + src/server/index.ts | 18 + .../conversationFileCapabilities.test.ts | 79 ++++ .../conversationFileCapabilities.ts | 267 +++++++++++ .../providers/codexProviderProfile.ts | 25 +- .../runtime/codexWebsocketHeaders.ts | 37 ++ .../runtime/codexWebsocketRuntime.test.ts | 137 ++++++ .../runtime/codexWebsocketRuntime.ts | 284 ++++++++++++ .../runtime/codexWebsocketSessionStore.ts | 34 ++ src/server/proxy-core/runtime/types.ts | 26 ++ src/server/proxy-core/surfaces/chatSurface.ts | 6 +- .../surfaces/openAiResponsesSurface.ts | 13 +- .../routes/proxy/responses.websocket.test.ts | 428 +++++++++++++++--- src/server/routes/proxy/responsesWebsocket.ts | 315 ++++++++++--- .../routes/proxy/upstreamEndpoint.test.ts | 148 +++++- src/server/routes/proxy/upstreamEndpoint.ts | 70 ++- src/server/services/payloadRules.ts | 312 +++++++++++++ src/server/services/proxyInputFileResolver.ts | 13 +- .../generate-content/conversion.test.ts | 44 ++ .../gemini/generate-content/conversion.ts | 327 +++++++++++++ .../openai/responses/conversion.test.ts | 99 ++++ .../openai/responses/conversion.ts | 108 +++-- .../transformers/shared/toolNameShortener.ts | 50 ++ src/web/pages/ModelTester.tsx | 29 +- .../conversationFileCapabilities.test.ts | 31 ++ .../helpers/conversationFileCapabilities.ts | 33 ++ .../pages/helpers/modelTesterSession.test.ts | 92 ++++ src/web/pages/helpers/modelTesterSession.ts | 133 +++++- 28 files changed, 2969 insertions(+), 204 deletions(-) create mode 100644 src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts create mode 100644 src/server/proxy-core/capabilities/conversationFileCapabilities.ts create mode 100644 src/server/proxy-core/runtime/codexWebsocketHeaders.ts create mode 100644 src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts create mode 100644 src/server/proxy-core/runtime/codexWebsocketRuntime.ts create mode 100644 src/server/proxy-core/runtime/codexWebsocketSessionStore.ts create mode 100644 src/server/proxy-core/runtime/types.ts create mode 100644 src/server/services/payloadRules.ts create mode 100644 src/server/transformers/gemini/generate-content/conversion.test.ts create mode 100644 src/server/transformers/gemini/generate-content/conversion.ts create mode 100644 src/server/transformers/shared/toolNameShortener.ts create mode 100644 src/web/pages/helpers/conversationFileCapabilities.test.ts create mode 100644 src/web/pages/helpers/conversationFileCapabilities.ts diff --git a/src/server/config.ts b/src/server/config.ts index e0c5ec7..cb87e1a 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -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), diff --git a/src/server/index.ts b/src/server/index.ts index 27815ff..f5facba 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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) { config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled; } + const codexHeaderDefaults = parseSettingFromMap(settingsMap, 'codex_header_defaults'); + if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') { + const next = codexHeaderDefaults as Record; + 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(settingsMap, 'payload_rules')); + } + const checkinCron = parseSettingFromMap(settingsMap, 'checkin_cron'); if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron; diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts new file mode 100644 index 0000000..6d00c02 --- /dev/null +++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts @@ -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']); + }); +}); diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts new file mode 100644 index 0000000..d2d22fc --- /dev/null +++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts @@ -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 { + 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, +): 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, +): 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, +): 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)), + ]; +} diff --git a/src/server/proxy-core/providers/codexProviderProfile.ts b/src/server/proxy-core/providers/codexProviderProfile.ts index e859aa6..948f03e 100644 --- a/src/server/proxy-core/providers/codexProviderProfile.ts +++ b/src/server/proxy-core/providers/codexProviderProfile.ts @@ -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; providerHeaders?: Record; + oauthProvider?: string; explicitSessionId?: string | null; continuityKey?: string | null; }): Record { @@ -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, }); diff --git a/src/server/proxy-core/runtime/codexWebsocketHeaders.ts b/src/server/proxy-core/runtime/codexWebsocketHeaders.ts new file mode 100644 index 0000000..308f6cc --- /dev/null +++ b/src/server/proxy-core/runtime/codexWebsocketHeaders.ts @@ -0,0 +1,37 @@ +const CODEX_RESPONSES_WEBSOCKET_BETA = 'responses_websockets=2026-02-06'; + +function getHeaderValue(headers: Record, 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): Record { + 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): Record { + 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(); +} diff --git a/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts b/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts new file mode 100644 index 0000000..318def5 --- /dev/null +++ b/src/server/proxy-core/runtime/codexWebsocketRuntime.test.ts @@ -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[] = []; + + 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; + 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((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((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'); + }); +}); diff --git a/src/server/proxy-core/runtime/codexWebsocketRuntime.ts b/src/server/proxy-core/runtime/codexWebsocketRuntime.ts new file mode 100644 index 0000000..560e76f --- /dev/null +++ b/src/server/proxy-core/runtime/codexWebsocketRuntime.ts @@ -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 { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isTerminalEvent(payload: Record): boolean { + const type = asTrimmedString(payload.type); + return type === 'response.completed' || type === 'response.failed'; +} + +export class CodexWebsocketRuntimeError extends Error { + events: Array>; + status?: number; + payload?: unknown; + + constructor( + message: string, + options?: { + events?: Array>; + 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 { + return await new Promise((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 { + if (socket.readyState === WebSocket.OPEN) return; + if (socket.readyState !== WebSocket.CONNECTING) { + throw new Error('upstream websocket is not open'); + } + await new Promise((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 { + if (!socket) return; + if (socket.readyState === WebSocket.CLOSED) return; + await new Promise((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 { + const { socket, reusedSession } = await ensureSessionSocket(session, input); + const events: Array> = []; + + return new Promise((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 { + 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 { + 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 { + const sessions = sessionStore.list(); + for (const session of sessions) { + await this.closeSession(session.sessionId); + } + }, + }; +} diff --git a/src/server/proxy-core/runtime/codexWebsocketSessionStore.ts b/src/server/proxy-core/runtime/codexWebsocketSessionStore.ts new file mode 100644 index 0000000..e8d6c50 --- /dev/null +++ b/src/server/proxy-core/runtime/codexWebsocketSessionStore.ts @@ -0,0 +1,34 @@ +import type { CodexWebsocketSession, CodexWebsocketSessionStore } from './types.js'; + +export function createCodexWebsocketSessionStore(): CodexWebsocketSessionStore { + const sessions = new Map(); + + 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()]; + }, + }; +} diff --git a/src/server/proxy-core/runtime/types.ts b/src/server/proxy-core/runtime/types.ts new file mode 100644 index 0000000..ada31c5 --- /dev/null +++ b/src/server/proxy-core/runtime/types.ts @@ -0,0 +1,26 @@ +import type WebSocket from 'ws'; + +export type CodexWebsocketRuntimeSendInput = { + sessionId: string; + requestUrl: string; + headers: Record; + body: Record; +}; + +export type CodexWebsocketRuntimeResult = { + events: Array>; + reusedSession: boolean; +}; + +export type CodexWebsocketSession = { + sessionId: string; + socket: WebSocket | null; + socketUrl: string | null; + queue: Promise; +}; + +export type CodexWebsocketSessionStore = { + getOrCreate(sessionId: string): CodexWebsocketSession; + take(sessionId: string): CodexWebsocketSession | null; + list(): CodexWebsocketSession[]; +}; diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 4c619fc..81e157a 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -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, }, ), ]; diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 98d9537..5e8e34a 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -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, }, ); diff --git a/src/server/routes/proxy/responses.websocket.test.ts b/src/server/routes/proxy/responses.websocket.test.ts index 540c6f3..3e1e389 100644 --- a/src/server/routes/proxy/responses.websocket.test.ts +++ b/src/server/routes/proxy/responses.websocket.test.ts @@ -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; + let upstreamRequests: Record[]; + let upstreamMessageHandler: (socket: WebSocket, parsed: Record, 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; + upstreamRequests.push(parsed); + upstreamMessageHandler(socket, parsed, upstreamRequests.length); + }); + }); + await new Promise((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((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((resolve) => rejectedUpgradeServer.close(() => resolve())); + await new Promise((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'); }); }); diff --git a/src/server/routes/proxy/responsesWebsocket.ts b/src/server/routes/proxy/responsesWebsocket.ts index c663f58..abfa389 100644 --- a/src/server/routes/proxy/responsesWebsocket.ts +++ b/src/server/routes/proxy/responsesWebsocket.ts @@ -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(); 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>>; 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 | 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 | 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, sessionId: string): string { + const promptCacheKey = asTrimmedString(body.prompt_cache_key); + return promptCacheKey || sessionId; +} + function parseJsonObject(raw: RawData): Record | 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; + preserveIncrementalMode: boolean; +}): Promise { + 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 { const headers: Record = {}; 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 | 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 = { + ...(request.headers as Record), [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((resolve) => { websocketServer.close(() => resolve()); }); diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 6b22a91..e80b526 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -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 => 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([ diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 660f33d..7173144 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -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; + claudeOriginalBody?: Record; + responsesOriginalBody?: Record; +}): 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 { @@ -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 = >(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).prompt_cache_key) + && !asTrimmedString((configuredResponsesBody as Record).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, }; } diff --git a/src/server/services/payloadRules.ts b/src/server/services/payloadRules.ts new file mode 100644 index 0000000..76f7bcb --- /dev/null +++ b/src/server/services/payloadRules.ts @@ -0,0 +1,312 @@ +import { minimatch } from 'minimatch'; + +export type PayloadRuleModel = { + name: string; + protocol?: string; +}; + +export type PayloadValueRule = { + models: PayloadRuleModel[]; + params: Record; +}; + +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 { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(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, 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, 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; + modelName?: string; + requestedModel?: string; + protocol?: string; +}): Record { + 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(); + + 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; +} diff --git a/src/server/services/proxyInputFileResolver.ts b/src/server/services/proxyInputFileResolver.ts index a3ed743..b987fe1 100644 --- a/src/server/services/proxyInputFileResolver.ts +++ b/src/server/services/proxyInputFileResolver.ts @@ -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 { return !!value && typeof value === 'object' && !Array.isArray(value); @@ -306,15 +307,5 @@ export async function resolveResponsesBodyInputFiles( } export function hasNonImageFileInputInOpenAiBody(body: Record): 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; } diff --git a/src/server/transformers/gemini/generate-content/conversion.test.ts b/src/server/transformers/gemini/generate-content/conversion.test.ts new file mode 100644 index 0000000..b5741e7 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/conversion.test.ts @@ -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=', + }, + }, + ], + }, + ]); + }); +}); diff --git a/src/server/transformers/gemini/generate-content/conversion.ts b/src/server/transformers/gemini/generate-content/conversion.ts new file mode 100644 index 0000000..f8c44ad --- /dev/null +++ b/src/server/transformers/gemini/generate-content/conversion.ts @@ -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 { + 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 { + return { + inlineData: { + mime_type: input.mimeType, + data: input.data, + }, + }; +} + +function convertContentToGeminiParts(content: unknown): Array> { + 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> = []; + 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> | 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; + 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 | 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; + modelName: string; + instructions?: string; +}) { + const request: Record = { + contents: [], + }; + + const messages = Array.isArray(input.body.messages) ? input.body.messages : []; + const toolNameById = new Map(); + 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(); + 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> = []; + 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> = []; + 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 = { + 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 = {}; + 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; +} diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index c6e8395..45d24e1 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -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', () => { diff --git a/src/server/transformers/openai/responses/conversion.ts b/src/server/transformers/openai/responses/conversion.ts index b61f986..1a83b21 100644 --- a/src/server/transformers/openai/responses/conversion.ts +++ b/src/server/transformers/openai/responses/conversion.ts @@ -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 { return !!value && typeof value === 'object'; @@ -247,7 +248,49 @@ function toResponsesInputMessageFromText(text: string): Record }; } -function convertOpenAiToolsToResponses(rawTools: unknown): unknown { +function collectOpenAiToolNames(body: Record): 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, +): unknown { if (!Array.isArray(rawTools)) return rawTools; const converted = rawTools @@ -262,7 +305,7 @@ function convertOpenAiToolsToResponses(rawTools: unknown): unknown { const mapped: Record = { 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, +): 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 = {}; - 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 = { ...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> = []; + 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( diff --git a/src/server/transformers/shared/toolNameShortener.ts b/src/server/transformers/shared/toolNameShortener.ts new file mode 100644 index 0000000..4d11666 --- /dev/null +++ b/src/server/transformers/shared/toolNameShortener.ts @@ -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 { + const uniqueNames = Array.from(new Set( + names + .map((name) => asTrimmedString(name)) + .filter((name) => name.length > 0), + )); + const used = new Set(); + const mapping: Record = {}; + + 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 { + const trimmed = asTrimmedString(name); + return mapping[trimmed] || shortenToolNameIfNeeded(trimmed); +} diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index 5b4149a..563e399 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -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。'} {conversationFiles.length > 0 && ( diff --git a/src/web/pages/helpers/conversationFileCapabilities.test.ts b/src/web/pages/helpers/conversationFileCapabilities.test.ts new file mode 100644 index 0000000..376d8ac --- /dev/null +++ b/src/web/pages/helpers/conversationFileCapabilities.test.ts @@ -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: '当前协议会以内联文档方式发送会话附件。', + }); + }); +}); diff --git a/src/web/pages/helpers/conversationFileCapabilities.ts b/src/web/pages/helpers/conversationFileCapabilities.ts new file mode 100644 index 0000000..eca7981 --- /dev/null +++ b/src/web/pages/helpers/conversationFileCapabilities.ts @@ -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: '当前协议暂不支持会话附件。', + }; +} diff --git a/src/web/pages/helpers/modelTesterSession.test.ts b/src/web/pages/helpers/modelTesterSession.test.ts index 64fb9d2..173d65f 100644 --- a/src/web/pages/helpers/modelTesterSession.test.ts +++ b/src/web/pages/helpers/modelTesterSession.test.ts @@ -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', diff --git a/src/web/pages/helpers/modelTesterSession.ts b/src/web/pages/helpers/modelTesterSession.ts index c836d54..0336ad7 100644 --- a/src/web/pages/helpers/modelTesterSession.ts +++ b/src/web/pages/helpers/modelTesterSession.ts @@ -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 = {}; - 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 = { 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 | 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 | 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 => !!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 => !!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 = (