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