Preserve the active worktree snapshot on dev for follow-on integration

The root checkout already contained a large in-progress proxy, transformer,
and settings refactor. This commit captures that exact work on a dedicated
local dev branch without disturbing the original dirty worktree, so the
changes can be continued or split from dev instead of living only as
uncommitted state.

Constraint: The source checkout was already dirty on another branch, so the move had to preserve that worktree while excluding local .idea and .omx residue from the commit
Rejected: Switch the dirty root checkout directly to dev | risked disrupting the original branch and in-progress state
Confidence: medium
Scope-risk: broad
Reversibility: clean
Directive: Treat this as a transfer snapshot; fix the failing oauth/accountTokens/normalized/docs test regressions before merge or publication
Tested: git diff --check; npm run typecheck; npm run repo:drift-check
Not-tested: Clean test pass; npm test currently fails in oauth routes, accountTokens sync, shared normalized helpers, gemini route expectations, architecture-boundaries, and docs vitepress config
This commit is contained in:
cita-777
2026-04-17 19:41:14 +08:00
parent 6cca74b039
commit 88f40e91f7
133 changed files with 13780 additions and 5670 deletions
@@ -68,6 +68,13 @@ describe('settings and auth events', () => {
(config as any).proxyFirstByteTimeoutSec = 0;
(config as any).tokenRouterFailureCooldownMaxSec = 30 * 24 * 60 * 60;
(config as any).disableCrossProtocolFallback = false;
(config as any).payloadRules = {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
(config as any).telegramEnabled = false;
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
(config as any).telegramBotToken = '';
@@ -219,6 +226,101 @@ describe('settings and auth events', () => {
expect(savedMaxBodyBytes?.value).toBe(JSON.stringify(131072));
});
it('persists payload rules and returns the normalized runtime value', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
payloadRules: {
override: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
'reasoning.effort': 'high',
},
},
],
'override-raw': [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
response_format: '{"type":"json_schema"}',
},
},
],
},
},
});
expect(response.statusCode).toBe(200);
const payload = response.json() as {
payloadRules?: {
default?: unknown[];
defaultRaw?: unknown[];
override?: Array<{ params?: Record<string, unknown> }>;
overrideRaw?: Array<{ params?: Record<string, unknown> }>;
filter?: unknown[];
};
};
expect(payload.payloadRules).toEqual({
default: [],
defaultRaw: [],
override: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
'reasoning.effort': 'high',
},
},
],
overrideRaw: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
response_format: '{"type":"json_schema"}',
},
},
],
filter: [],
});
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get();
expect(saved).toBeTruthy();
expect(JSON.parse(String(saved?.value))).toEqual(payload.payloadRules);
expect(config.payloadRules).toEqual(payload.payloadRules);
const events = await db.select().from(schema.events).all();
expect(events.some((event) => (event.message || '').includes('Payload 规则'))).toBe(true);
});
it('rejects invalid payload raw rules with a clear message', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
payloadRules: {
'override-raw': [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
response_format: '{broken-json',
},
},
],
},
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
message: 'Payload 规则 override-raw 第 1 条的 response_format 不是合法 JSON',
});
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'payload_rules')).get();
expect(saved).toBeFalsy();
});
it('returns current recognized admin IP in runtime settings response', async () => {
const response = await app.inject({
method: 'GET',
+21
View File
@@ -44,12 +44,14 @@ import {
startModelAvailabilityProbeScheduler,
stopModelAvailabilityProbeScheduler,
} from '../../services/modelAvailabilityProbeService.js';
import { parsePayloadRulesConfigInput } from '../../services/payloadRules.js';
type RoutingWeights = typeof config.routingWeights;
interface RuntimeSettingsBody {
proxyToken?: string;
systemProxyUrl?: string;
payloadRules?: unknown;
modelAvailabilityProbeEnabled?: boolean;
codexUpstreamWebsocketEnabled?: boolean;
responsesCompactFallbackToResponsesEnabled?: boolean;
@@ -759,6 +761,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
currentAdminIp,
serverTimeZone: getResolvedTimeZone(),
systemProxyUrl: config.systemProxyUrl,
payloadRules: config.payloadRules,
proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
proxyTokenMasked: maskSecret(config.proxyToken),
@@ -1132,6 +1135,24 @@ export async function settingsRoutes(app: FastifyInstance) {
invalidateSiteProxyCache();
}
if (body.payloadRules !== undefined) {
const parsedPayloadRules = parsePayloadRulesConfigInput(body.payloadRules);
if (!parsedPayloadRules.success) {
return reply.code(400).send({
success: false,
message: parsedPayloadRules.message,
});
}
const previousRules = JSON.stringify(config.payloadRules);
const nextRules = JSON.stringify(parsedPayloadRules.normalized);
if (previousRules !== nextRules) {
changedLabels.push('Payload 规则');
}
config.payloadRules = parsedPayloadRules.normalized;
await upsertSetting('payload_rules', config.payloadRules);
}
if (body.modelAvailabilityProbeEnabled !== undefined) {
let nextValue = false;
try {
+49 -12
View File
@@ -520,8 +520,30 @@ function mapProxyLogRow(
};
}
function buildProxyLogModelAnalysisSelectFields() {
return {
createdAt: schema.proxyLogs.createdAt,
modelActual: schema.proxyLogs.modelActual,
modelRequested: schema.proxyLogs.modelRequested,
status: schema.proxyLogs.status,
latencyMs: schema.proxyLogs.latencyMs,
totalTokens: schema.proxyLogs.totalTokens,
estimatedCost: schema.proxyLogs.estimatedCost,
};
}
function buildProxyLogSiteTrendSelectFields() {
return {
createdAt: schema.proxyLogs.createdAt,
estimatedCost: schema.proxyLogs.estimatedCost,
totalTokens: schema.proxyLogs.totalTokens,
};
}
export async function statsRoutes(app: FastifyInstance) {
const proxyLogBaseFields = getProxyLogBaseSelectFields();
const proxyLogModelAnalysisFields = buildProxyLogModelAnalysisSelectFields();
const proxyLogSiteTrendFields = buildProxyLogSiteTrendSelectFields();
// Dashboard summary
app.get('/api/stats/dashboard', async () => {
@@ -565,9 +587,7 @@ export async function statsRoutes(app: FastifyInstance) {
const lastMinuteDate = formatUtcSqlDateTime(new Date(nowTs - 60_000));
const last7dDate = getLocalRangeStartUtc(7);
const recentProxyLogs = (await db.select({
proxy_logs: proxyLogBaseFields,
accounts: schema.accounts,
sites: schema.sites,
proxy_logs: proxyLogModelAnalysisFields,
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -720,8 +740,14 @@ export async function statsRoutes(app: FastifyInstance) {
const listRows = await withProxyLogSelectFields(({ fields }) => {
let query = db.select({
proxy_logs: fields,
accounts: schema.accounts,
sites: schema.sites,
accounts: {
username: schema.accounts.username,
},
sites: {
id: schema.sites.id,
name: schema.sites.name,
url: schema.sites.url,
},
downstream_api_keys: {
id: schema.downstreamApiKeys.id,
name: schema.downstreamApiKeys.name,
@@ -778,7 +804,9 @@ export async function statsRoutes(app: FastifyInstance) {
query = query.where(clientOptionsWhere) as typeof query;
}
return query.all();
return query
.groupBy(fields.clientFamily!, fields.clientAppId!, fields.clientAppName!)
.all();
}, { includeBillingDetails: false, includeClientFields: true }) as Array<{
clientFamily?: string | null;
clientAppId?: string | null;
@@ -1616,9 +1644,11 @@ export async function statsRoutes(app: FastifyInstance) {
const sinceDate = getLocalRangeStartUtc(days);
const rows = await db.select({
proxy_logs: proxyLogBaseFields,
accounts: schema.accounts,
sites: schema.sites,
proxy_logs: proxyLogSiteTrendFields,
sites: {
name: schema.sites.name,
platform: schema.sites.platform,
},
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -1676,9 +1706,16 @@ export async function statsRoutes(app: FastifyInstance) {
}
const rows = await db.select({
proxy_logs: proxyLogBaseFields,
accounts: schema.accounts,
sites: schema.sites,
proxy_logs: {
accountId: schema.proxyLogs.accountId,
modelActual: schema.proxyLogs.modelActual,
modelRequested: schema.proxyLogs.modelRequested,
totalTokens: schema.proxyLogs.totalTokens,
estimatedCost: schema.proxyLogs.estimatedCost,
},
sites: {
platform: schema.sites.platform,
},
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -21,6 +21,8 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
}),
}));
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
vi.mock('undici', async () => {
const actual = await vi.importActual<typeof import('undici')>('undici');
return {
@@ -206,7 +208,7 @@ describe('chat proxy codex oauth compatibility', () => {
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS);
expect(forwardedBody.store).toBe(false);
expect(forwardedBody.parallel_tool_calls).toBeUndefined();
expect(forwardedBody.include).toBeUndefined();
@@ -213,6 +213,7 @@ describe('claude count_tokens proxy route', () => {
expect(targetUrl).toBe('https://api.anthropic.com/v1/messages/count_tokens?beta=true');
expect(options.headers['anthropic-version']).toBe('2023-06-01');
expect(options.headers['anthropic-beta']).toContain('claude-code-20250219');
expect(options.headers['anthropic-beta']).toContain('token-counting-2024-11-01');
expect(options.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
expect(startSurfaceProxyDebugTraceMock).toHaveBeenCalledWith(expect.objectContaining({
downstreamPath: '/v1/messages/count_tokens',
@@ -276,4 +277,38 @@ describe('claude count_tokens proxy route', () => {
expect(options.headers['x-api-key']).toBe('sk-gateway');
expect(options.headers['anthropic-version']).toBe('2023-06-01');
});
it('does not forward when claude count_tokens upstream compatibility is unavailable', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 12, routeId: 23 },
site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
account: { id: 34, username: 'codex-user@example.com' },
tokenName: 'default',
tokenValue: 'sk-codex',
actualModel: 'gpt-5.4',
});
const response = await app.inject({
method: 'POST',
url: '/v1/messages/count_tokens',
payload: {
model: 'gpt-5.4',
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'count through codex' }],
},
],
},
});
expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({
error: {
message: 'No available channels for this model',
type: 'server_error',
},
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -222,10 +222,10 @@ describe('chat proxy site api endpoint rotation', () => {
expect(response.statusCode).toBe(200);
expect(response.json()?.choices?.[0]?.message?.content).toBe('ok via api-b');
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-a.example.com/v1/chat/completions');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-a.example.com/v1/responses');
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-a.example.com/v1/responses');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-a.example.com/v1/chat/completions');
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toBe('https://api-a.example.com/v1/messages');
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toBe('https://api-b.example.com/v1/chat/completions');
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toBe('https://api-b.example.com/v1/responses');
expect(selectNextChannelMock).not.toHaveBeenCalled();
expect(recordFailureMock).not.toHaveBeenCalled();
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
+421 -90
View File
@@ -1389,6 +1389,17 @@ describe('chat proxy stream behavior', () => {
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
'accept-language': 'zh-CN',
'openai-beta': 'responses-2025-03-11',
'x-stainless-lang': 'typescript',
originator: 'codex_cli_rs',
session_id: 'session-123',
conversation_id: 'conversation-123',
'x-codex-turn-state': 'turn-state',
'x-codex-turn-metadata': 'turn-metadata',
version: '0.202.0',
},
payload: {
model: 'gpt-5.2',
input: 'hello',
@@ -1414,6 +1425,55 @@ describe('chat proxy stream behavior', () => {
]);
});
it('forces upstream SSE for non-stream /v1/responses requests on sub2api and aggregates the final payload', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'generic-site', url: 'https://generic.example.com', platform: 'sub2api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-generic',
actualModel: 'gpt-5.2-codex',
});
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_sub2api_forced_stream","model":"gpt-5.2-codex","created_at":1706000000,"status":"in_progress","output":[]}}\n\n'));
controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_sub2api_forced_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n'));
controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_sub2api_forced_stream","delta":"hello from forced sub2api stream"}\n\n'));
controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_sub2api_forced_stream","model":"gpt-5.2-codex","status":"completed","output":[{"id":"msg_sub2api_forced_stream","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"hello from forced sub2api stream"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
fetchMock.mockResolvedValue(new Response(upstreamBody, {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
input: 'hello',
store: true,
},
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body.object).toBe('response');
expect(body.output_text).toContain('hello from forced sub2api stream');
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
const forwarded = JSON.parse(options.body);
expect(options.headers.accept).toBe('text/event-stream');
expect(forwarded.stream).toBe(true);
expect(forwarded.store).toBe(false);
});
it('continues downgrade to /v1/messages when /v1/chat/completions returns messages is required for /v1/responses', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
@@ -1958,6 +2018,108 @@ describe('chat proxy stream behavior', () => {
});
});
it('preserves sub2api responses semantics across compatibility retries without using a strict field-dropping body', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-sub2api',
actualModel: 'upstream-gpt',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'upstream_error',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_sub2api_safe_headers',
object: 'response',
status: 'completed',
model: 'upstream-gpt',
output_text: 'sub2api safe header retry preserved responses semantics',
output: [],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
'accept-language': 'zh-CN',
'openai-beta': 'responses-2025-03-11',
'x-stainless-lang': 'typescript',
originator: 'codex_cli_rs',
session_id: 'session-123',
conversation_id: 'conversation-123',
'x-codex-turn-state': 'turn-state',
'x-codex-turn-metadata': 'turn-metadata',
version: '0.202.0',
},
payload: {
model: 'gpt-5.2',
input: 'hello',
previous_response_id: 'resp_prev_1',
include: ['reasoning.encrypted_content'],
reasoning: { effort: 'high' },
prompt_cache_key: 'cache-key-1',
service_tier: 'priority',
background: true,
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
const firstBody = JSON.parse(firstOptions.body);
const secondBody = JSON.parse(secondOptions.body);
expect(firstOptions.headers.accept).toBe('text/event-stream');
expect(secondOptions.headers.accept).toBe('text/event-stream');
expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11');
expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11');
expect(firstOptions.headers.originator).toBe('codex_cli_rs');
expect(secondOptions.headers.originator).toBe('codex_cli_rs');
expect(secondOptions.headers['accept-language']).toBe('zh-CN');
expect(secondOptions.headers.session_id).toBe('session-123');
expect(secondOptions.headers.conversation_id).toBe('conversation-123');
expect(secondOptions.headers['x-codex-turn-state']).toBe('turn-state');
expect(secondOptions.headers['x-codex-turn-metadata']).toBe('turn-metadata');
expect(secondOptions.headers['x-stainless-lang']).toBeUndefined();
expect(secondOptions.headers.version).toBeUndefined();
expect(secondOptions.headers['user-agent']).toBe('lightMyRequest');
expect(firstBody).toMatchObject({
stream: true,
store: false,
previous_response_id: 'resp_prev_1',
include: ['reasoning.encrypted_content'],
reasoning: { effort: 'high' },
prompt_cache_key: 'cache-key-1',
service_tier: 'priority',
background: true,
});
expect(secondBody).toMatchObject({
stream: true,
store: false,
previous_response_id: 'resp_prev_1',
include: ['reasoning.encrypted_content'],
reasoning: { effort: 'high' },
prompt_cache_key: 'cache-key-1',
service_tier: 'priority',
background: true,
});
});
it('retries generic 400 /v1/responses with minimal headers for strict compatibility fallback', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
@@ -2063,7 +2225,7 @@ describe('chat proxy stream behavior', () => {
expect(body.error?.message).not.toContain('<!DOCTYPE html>');
});
it('downgrades /v1/responses to /v1/chat/completions when upstream returns 400 upstream_error', async () => {
it('does not downgrade /v1/responses to /v1/chat/completions on generic 400 upstream_error without endpoint mismatch hints', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
@@ -2098,16 +2260,13 @@ describe('chat proxy stream behavior', () => {
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(response.statusCode).toBe(400);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
expect(firstUrl).toContain('/v1/responses');
expect(secondUrl).toContain('/v1/chat/completions');
const body = response.json();
expect(body.object).toBe('response');
expect(body.output_text).toContain('ok via chat fallback from upstream_error');
expect(body.error?.message).toBeTruthy();
});
it('downgrades /v1/responses to /v1/chat/completions when upstream responses endpoint returns 502', async () => {
@@ -3285,7 +3444,7 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/messages');
});
it('prefers /v1/messages on openai platform for claude-family models on /v1/chat/completions', async () => {
it('prefers /v1/responses on openai platform for claude-family models on /v1/chat/completions', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
@@ -3306,11 +3465,17 @@ describe('chat proxy stream behavior', () => {
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_openai_platform_claude',
type: 'message',
id: 'resp_openai_platform_claude',
object: 'response',
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'messages endpoint selected' }],
stop_reason: 'end_turn',
status: 'completed',
output: [{
id: 'msg_openai_platform_claude',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'responses endpoint selected' }],
}],
usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 },
}), {
status: 200,
@@ -3330,10 +3495,10 @@ describe('chat proxy stream behavior', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/messages');
expect(targetUrl).toContain('/v1/responses');
});
it('falls back from /v1/messages to /v1/chat/completions on openai platform when messages endpoint is unavailable', async () => {
it('falls back from /v1/responses to /v1/messages on openai platform when responses endpoint is unavailable', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
@@ -3351,16 +3516,12 @@ describe('chat proxy stream behavior', () => {
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'chatcmpl_openai_fallback_chat',
object: 'chat.completion',
created: 1_706_000_777,
id: 'msg_openai_fallback_messages',
type: 'message',
model: 'claude-opus-4-6',
choices: [{
index: 0,
message: { role: 'assistant', content: 'fallback to chat completed' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
content: [{ type: 'text', text: 'fallback to messages completed' }],
stop_reason: 'end_turn',
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -3380,7 +3541,7 @@ describe('chat proxy stream behavior', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
expect(firstUrl).toContain('/v1/messages');
expect(firstUrl).toContain('/v1/responses');
expect(secondUrl).toContain('/v1/chat/completions');
});
@@ -3558,16 +3719,19 @@ describe('chat proxy stream behavior', () => {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_legacy_protocol_preferred',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok via direct responses preference',
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
.mockResolvedValueOnce(new Response(new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_legacy_protocol_preferred","model":"gpt-5.2-codex","created_at":1706000000,"status":"in_progress","output":[]}}\n\n'));
controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_legacy_protocol_preferred","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n'));
controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_legacy_protocol_preferred","delta":"ok via direct responses preference"}\n\n'));
controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_legacy_protocol_preferred","model":"gpt-5.2-codex","status":"completed","output":[{"id":"msg_legacy_protocol_preferred","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok via direct responses preference"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
@@ -3583,13 +3747,178 @@ describe('chat proxy stream behavior', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
expect(firstUrl).toContain('/v1/chat/completions');
expect(secondUrl).toContain('/v1/responses');
expect(secondUrl).not.toContain('/v1/messages');
expect(secondOptions.headers.accept).toBe('text/event-stream');
const forwarded = JSON.parse(secondOptions.body);
expect(forwarded.stream).toBe(true);
expect(forwarded.store).toBe(false);
expect(response.json()?.choices?.[0]?.message?.content).toContain('ok via direct responses preference');
});
it('prefers /v1/messages immediately after a generic chat endpoint says messages is required', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'generic-site', url: 'https://generic.example.com', platform: 'new-api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-generic',
actualModel: 'gpt-5.2',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'messages is required',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'msg_messages_required_preferred_1',
type: 'message',
model: 'upstream-gpt',
content: [{ type: 'text', text: 'ok via messages fallback' }],
stop_reason: 'end_turn',
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'msg_messages_required_preferred_2',
type: 'message',
model: 'upstream-gpt',
content: [{ type: 'text', text: 'ok via direct messages preference' }],
stop_reason: 'end_turn',
usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const firstResponse = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-5.2',
stream: false,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(firstResponse.statusCode).toBe(200);
expect(firstResponse.json()?.choices?.[0]?.message?.content).toContain('ok via messages fallback');
const secondResponse = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-5.2',
stream: false,
messages: [{ role: 'user', content: 'hello again' }],
},
});
expect(secondResponse.statusCode).toBe(200);
expect(secondResponse.json()?.choices?.[0]?.message?.content).toContain('ok via direct messages preference');
expect(fetchMock).toHaveBeenCalledTimes(3);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [thirdUrl] = fetchMock.mock.calls[2] as [string, any];
expect(firstUrl).toContain('/v1/chat/completions');
expect(secondUrl).toContain('/v1/messages');
expect(thirdUrl).toContain('/v1/messages');
expect(thirdUrl).not.toContain('/v1/chat/completions');
});
it('promotes /v1/responses to the next same-request attempt when a generic chat endpoint says input is required', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'generic-site', url: 'https://generic.example.com', platform: 'new-api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-generic',
actualModel: 'gpt-5.2',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'input is required',
type: 'invalid_request_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_input_required_preferred_1',
object: 'response',
output: [{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'ok via same-request responses promotion' }],
}],
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_input_required_preferred_2',
object: 'response',
output: [{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'ok via learned direct responses preference' }],
}],
usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const firstResponse = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-5.2',
stream: false,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(firstResponse.statusCode).toBe(200);
expect(firstResponse.json()?.choices?.[0]?.message?.content).toContain('ok via same-request responses promotion');
const secondResponse = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-5.2',
stream: false,
messages: [{ role: 'user', content: 'hello again' }],
},
});
expect(secondResponse.statusCode).toBe(200);
expect(secondResponse.json()?.choices?.[0]?.message?.content).toContain('ok via learned direct responses preference');
expect(fetchMock).toHaveBeenCalledTimes(3);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [thirdUrl] = fetchMock.mock.calls[2] as [string, any];
expect(firstUrl).toContain('/v1/chat/completions');
expect(secondUrl).toContain('/v1/responses');
expect(secondUrl).not.toContain('/v1/messages');
expect(thirdUrl).toContain('/v1/responses');
expect(thirdUrl).not.toContain('/v1/chat/completions');
});
it('keeps messages-first semantics for claude-family models on generic upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -3649,7 +3978,7 @@ describe('chat proxy stream behavior', () => {
expect(response.json()?.choices?.[0]?.message?.content).toContain('ok via responses');
});
it('forces openai platform to use /v1/chat/completions for claude downstream requests', async () => {
it('forces openai platform to use /v1/responses for claude downstream requests', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
@@ -3660,16 +3989,18 @@ describe('chat proxy stream behavior', () => {
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-for-claude-downstream',
object: 'chat.completion',
created: 1_706_000_003,
id: 'resp-openai-for-claude-downstream',
object: 'response',
model: 'gpt-4o-mini',
choices: [{
index: 0,
message: { role: 'assistant', content: 'openai endpoint selected' },
finish_reason: 'stop',
status: 'completed',
output: [{
id: 'msg-openai-for-claude-downstream',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'openai endpoint selected' }],
}],
usage: { prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 },
usage: { input_tokens: 4, output_tokens: 3, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -3688,11 +4019,11 @@ describe('chat proxy stream behavior', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/chat/completions');
expect(targetUrl).toContain('/v1/responses');
expect(targetUrl).not.toContain('/v1/messages');
});
it('preserves claude tool_use/tool_result when claude downstream is routed to openai chat endpoint', async () => {
it('preserves claude tool_use/tool_result when claude downstream is routed to openai responses endpoint', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
@@ -3703,16 +4034,18 @@ describe('chat proxy stream behavior', () => {
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-tools',
object: 'chat.completion',
created: 1_706_000_006,
id: 'resp-openai-tools',
object: 'response',
model: 'gpt-4o-mini',
choices: [{
index: 0,
message: { role: 'assistant', content: 'tool payload received' },
finish_reason: 'stop',
status: 'completed',
output: [{
id: 'msg-openai-tools',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'tool payload received' }],
}],
usage: { prompt_tokens: 10, completion_tokens: 3, total_tokens: 13 },
usage: { input_tokens: 10, output_tokens: 3, total_tokens: 13 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -3758,25 +4091,22 @@ describe('chat proxy stream behavior', () => {
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
const forwardedMessages = Array.isArray(forwardedBody.messages) ? forwardedBody.messages : [];
expect(fetchMock.mock.calls[0]?.[0]).toContain('/v1/responses');
const forwardedInput = Array.isArray(forwardedBody.input) ? forwardedBody.input : [];
const assistantWithToolCalls = forwardedMessages.find((item: any) =>
item?.role === 'assistant'
&& Array.isArray(item?.tool_calls)
&& item.tool_calls.length > 0,
);
expect(assistantWithToolCalls).toBeTruthy();
expect(assistantWithToolCalls.tool_calls[0].id).toBe('toolu_abc');
expect(assistantWithToolCalls.tool_calls[0].function?.name).toBe('Glob');
expect(assistantWithToolCalls.tool_calls[0].function?.arguments).toContain('README*');
const functionCall = forwardedInput.find((item: any) => item?.type === 'function_call');
expect(functionCall).toBeTruthy();
expect(functionCall.call_id).toBe('toolu_abc');
expect(functionCall.name).toBe('Glob');
expect(String(functionCall.arguments || '')).toContain('README*');
const toolMessage = forwardedMessages.find((item: any) => item?.role === 'tool');
expect(toolMessage).toBeTruthy();
expect(toolMessage.tool_call_id).toBe('toolu_abc');
expect(toolMessage.content).toContain('matches');
const toolOutput = forwardedInput.find((item: any) => item?.type === 'function_call_output');
expect(toolOutput).toBeTruthy();
expect(toolOutput.call_id).toBe('toolu_abc');
expect(String(toolOutput.output || '')).toContain('matches');
});
it('maps claude tool config and thinking budget before routing claude downstream requests to openai chat endpoint', async () => {
it('maps claude tool config and thinking budget before routing claude downstream requests to openai responses endpoint', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
@@ -3787,16 +4117,18 @@ describe('chat proxy stream behavior', () => {
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-config-mapped',
object: 'chat.completion',
created: 1_706_000_007,
id: 'resp-openai-config-mapped',
object: 'response',
model: 'gpt-4o-mini',
choices: [{
index: 0,
message: { role: 'assistant', content: 'tool config mapped' },
finish_reason: 'stop',
status: 'completed',
output: [{
id: 'msg-openai-config-mapped',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'tool config mapped' }],
}],
usage: { prompt_tokens: 9, completion_tokens: 3, total_tokens: 12 },
usage: { input_tokens: 9, output_tokens: 3, total_tokens: 12 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -3833,27 +4165,26 @@ describe('chat proxy stream behavior', () => {
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(fetchMock.mock.calls[0]?.[0]).toContain('/v1/responses');
expect(forwardedBody.metadata).toEqual({ user_id: 'user-1' });
expect(forwardedBody.reasoning_budget).toBe(1024);
expect(forwardedBody.reasoning).toEqual({
budget_tokens: 1024,
});
expect(forwardedBody.tools).toEqual([{
type: 'function',
function: {
name: 'Glob',
description: 'Search files',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string' },
},
required: ['pattern'],
name: 'Glob',
description: 'Search files',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string' },
},
required: ['pattern'],
},
}]);
expect(forwardedBody.tool_choice).toEqual({
type: 'function',
function: {
name: 'Glob',
},
name: 'Glob',
});
});
@@ -4192,7 +4523,7 @@ describe('chat proxy stream behavior', () => {
expect(matches.length).toBe(1);
});
it('emits finish_reason error when /v1/chat/completions receives response.failed from /v1/responses upstream', async () => {
it('emits finish_reason stop when /v1/chat/completions receives response.failed from /v1/responses upstream', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
@@ -4229,7 +4560,7 @@ describe('chat proxy stream behavior', () => {
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"finish_reason":"error"');
expect(response.body).toContain('"finish_reason":"stop"');
expect(response.body).toContain('[DONE]');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
+2
View File
@@ -7,6 +7,8 @@ import {
export async function chatProxyRoute(app: FastifyInstance) {
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatSurfaceRequest(request, reply, 'openai'));
app.post('/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatSurfaceRequest(request, reply, 'openai'));
}
export async function claudeMessagesProxyRoute(app: FastifyInstance) {
+1 -1
View File
@@ -12,7 +12,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
import { detectProxyFailure } from '../../services/proxyFailureJudge.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
@@ -80,6 +80,22 @@ describe('detectDownstreamClientContext', () => {
});
});
it('recognizes conversation_id-only Codex requests as continuation-capable sessions', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
conversation_id: 'codex-conversation-123',
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex',
clientAppName: 'Codex',
clientConfidence: 'heuristic',
sessionId: 'codex-conversation-123',
traceHint: 'codex-conversation-123',
});
});
it('recognizes broader Codex official-client user-agent families without requiring stainless headers', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
+52 -51
View File
@@ -799,23 +799,22 @@ describe('gemini native proxy routes', () => {
actualModel: 'gpt-4.1',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-1',
object: 'chat.completion',
created: 1_742_160_000,
id: 'resp-openai-1',
object: 'response',
model: 'gpt-4.1',
choices: [
status: 'completed',
output: [
{
index: 0,
message: {
role: 'assistant',
content: 'hello from openai',
},
finish_reason: 'stop',
id: 'msg-openai-1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'hello from openai' }],
},
],
usage: {
prompt_tokens: 4,
completion_tokens: 3,
input_tokens: 4,
output_tokens: 3,
total_tokens: 7,
},
}), {
@@ -841,7 +840,7 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions');
expect(targetUrl).toBe('https://api.openai.com/v1/responses');
expect(requestInit.headers).toMatchObject({
Authorization: 'Bearer openai-access-token',
'Content-Type': 'application/json',
@@ -862,21 +861,28 @@ describe('gemini native proxy routes', () => {
expect.objectContaining({ traceId: 801 }),
expect.objectContaining({
finalStatus: 'success',
finalUpstreamPath: '/v1/chat/completions',
finalUpstreamPath: '/v1/responses',
}),
);
expect(JSON.parse(String(requestInit.body))).toEqual({
model: 'gpt-4.1',
stream: false,
messages: [
max_output_tokens: 4096,
input: [
{
type: 'message',
role: 'user',
content: 'hello',
content: [
{
type: 'input_text',
text: 'hello',
},
],
},
],
});
expect(response.json()).toEqual({
responseId: 'chatcmpl-openai-1',
responseId: 'resp-openai-1',
modelVersion: 'gpt-4.1',
candidates: [
{
@@ -906,23 +912,22 @@ describe('gemini native proxy routes', () => {
actualModel: 'gpt-4.1',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-stream-1',
object: 'chat.completion',
created: 1_742_160_003,
id: 'resp-openai-stream-1',
object: 'response',
model: 'gpt-4.1',
choices: [
status: 'completed',
output: [
{
index: 0,
message: {
role: 'assistant',
content: 'hello from openai stream fallback',
},
finish_reason: 'stop',
id: 'msg-openai-stream-1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'hello from openai stream fallback' }],
},
],
usage: {
prompt_tokens: 5,
completion_tokens: 6,
input_tokens: 5,
output_tokens: 6,
total_tokens: 11,
},
}), {
@@ -950,7 +955,7 @@ describe('gemini native proxy routes', () => {
expect(response.headers['content-type']).toContain('text/event-stream');
expect(parseSsePayloads(response.body)).toEqual([
{
responseId: 'chatcmpl-openai-stream-1',
responseId: 'resp-openai-stream-1',
modelVersion: 'gpt-4.1',
candidates: [
{
@@ -981,23 +986,22 @@ describe('gemini native proxy routes', () => {
actualModel: 'gpt-4.1',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-openai-2',
object: 'chat.completion',
created: 1_742_160_001,
id: 'resp-openai-2',
object: 'response',
model: 'gpt-4.1',
choices: [
status: 'completed',
output: [
{
index: 0,
message: {
role: 'assistant',
content: 'hello from gemini cli downstream',
},
finish_reason: 'stop',
id: 'msg-openai-2',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'hello from gemini cli downstream' }],
},
],
usage: {
prompt_tokens: 6,
completion_tokens: 5,
input_tokens: 6,
output_tokens: 5,
total_tokens: 11,
},
}), {
@@ -1024,20 +1028,17 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions');
expect(JSON.parse(String(requestInit.body))).toEqual({
expect(targetUrl).toBe('https://api.openai.com/v1/responses');
const forwardedBody = JSON.parse(String(requestInit.body));
expect(forwardedBody).toMatchObject({
model: 'gpt-4.1',
stream: false,
messages: [
{
role: 'user',
content: 'hello',
},
],
});
expect(Array.isArray(forwardedBody.input)).toBe(true);
expect(JSON.stringify(forwardedBody.input)).toContain('hello');
expect(response.json()).toEqual({
response: {
responseId: 'chatcmpl-openai-2',
responseId: 'resp-openai-2',
modelVersion: 'gpt-4.1',
candidates: [
{
@@ -1,196 +0,0 @@
import { describe, it, expect } from 'vitest';
import { buildGeminiGenerateContentRequestFromOpenAi } from './geminiCliCompat.js';
describe('thoughtSignature injection in OpenAI→Gemini conversion', () => {
it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'What is the weather?' },
{
role: 'assistant',
content: 'Let me check.',
tool_calls: [
{
id: 'call_123',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
provider_specific_fields: { thought_signature: 'real_sig_abc' },
},
],
},
{ role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
// Find the model message with functionCall
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
expect(fcParts[0].thoughtSignature).toBe('real_sig_abc');
});
it('splits text and signed functionCall parts into separate model messages', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'Read the file.' },
{
role: 'assistant',
content: 'I will read it.',
tool_calls: [
{
id: 'call_456',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/tmp/x"}' },
provider_specific_fields: { thought_signature: 'sig_split_test' },
},
],
},
{ role: 'tool', tool_call_id: 'call_456', content: 'file content here' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
// Should be 2 model messages: text (unsigned) + functionCall (signed)
expect(modelMsgs.length).toBe(2);
const firstParts = modelMsgs[0].parts as Array<Record<string, unknown>>;
const secondParts = modelMsgs[1].parts as Array<Record<string, unknown>>;
// First: text only, no thoughtSignature
expect(firstParts.every((p) => 'text' in p)).toBe(true);
expect(firstParts.every((p) => !('thoughtSignature' in p))).toBe(true);
// Second: functionCall with thoughtSignature
expect(secondParts.every((p) => 'functionCall' in p)).toBe(true);
expect(secondParts[0].thoughtSignature).toBe('sig_split_test');
});
it('injects dummy sentinel when thinking enabled but no signature available', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
reasoning_effort: 'high',
messages: [
{ role: 'user', content: 'Do something.' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_no_sig',
type: 'function',
function: { name: 'Bash', arguments: '{"command":"ls"}' },
// No provider_specific_fields — signature missing
},
],
},
{ role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
// Should have a dummy sentinel, not be missing
expect(typeof fcParts[0].thoughtSignature).toBe('string');
expect((fcParts[0].thoughtSignature as string).length).toBeGreaterThan(0);
});
it('does not inject thoughtSignature when thinking is not enabled', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-2.5-flash',
messages: [
{ role: 'user', content: 'Hello' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_no_think',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/x"}' },
},
],
},
{ role: 'tool', tool_call_id: 'call_no_think', content: 'data' },
],
},
modelName: 'gemini-2.5-flash',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
const modelMsgs = contents.filter((c) => c.role === 'model');
const fcParts = modelMsgs
.flatMap((m) => (m.parts as Array<Record<string, unknown>>))
.filter((p) => 'functionCall' in p);
expect(fcParts.length).toBe(1);
// No thinking → no signature injected
expect(fcParts[0].thoughtSignature).toBeUndefined();
});
it('preserves functionResponse count matching functionCall count', () => {
const result = buildGeminiGenerateContentRequestFromOpenAi({
body: {
model: 'gemini-3-flash-preview',
messages: [
{ role: 'user', content: 'Read two files.' },
{
role: 'assistant',
tool_calls: [
{
id: 'call_a',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/a"}' },
provider_specific_fields: { thought_signature: 'sig_a' },
},
{
id: 'call_b',
type: 'function',
function: { name: 'Read', arguments: '{"path":"/b"}' },
provider_specific_fields: { thought_signature: 'sig_b' },
},
],
},
{ role: 'tool', tool_call_id: 'call_a', content: 'content a' },
{ role: 'tool', tool_call_id: 'call_b', content: 'content b' },
],
},
modelName: 'gemini-3-flash-preview',
}) as Record<string, unknown>;
const contents = result.contents as Array<Record<string, unknown>>;
// Count functionCall and functionResponse parts
let fcCount = 0;
let frCount = 0;
for (const content of contents) {
for (const part of (content.parts as Array<Record<string, unknown>>)) {
if ('functionCall' in part) fcCount++;
if ('functionResponse' in part) frCount++;
}
}
expect(fcCount).toBe(2);
expect(frCount).toBe(2);
});
});
-396
View File
@@ -1,396 +0,0 @@
import { TextDecoder, TextEncoder } from 'node:util';
import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js';
// Dummy sentinel used when no real thoughtSignature is available but thinking
// mode is enabled. Gemini accepts any base64 string and won't reject this.
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 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') {
const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.url);
const parsed = imageUrl ? parseDataUrl(imageUrl) : null;
if (parsed) {
parts.push({
inlineData: {
mime_type: parsed.mimeType,
data: parsed.data,
},
});
}
continue;
}
if (type === 'input_audio') {
const data = asTrimmedString(item.data);
if (data) {
parts.push({
inlineData: {
mime_type: 'audio/wav',
data,
},
});
}
}
}
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 buildGeminiGenerateContentRequestFromOpenAi(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);
}
}
}
// Detect if thinking mode is enabled (needed for dummy signature fallback)
const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body);
// Collect thoughtSignatures from assistant tool_calls for injection.
// OpenAI format stores signatures in provider_specific_fields or encoded in tool call IDs.
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;
// Check provider_specific_fields first
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 fcParts: 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 fcPart: Record<string, unknown> = {
functionCall: { name, args },
};
// Inject thoughtSignature if available from provider_specific_fields.
// If no signature is found, use a dummy sentinel so Gemini doesn't reject
// the request outright. Gemini 3+ requires thoughtSignature on all
// functionCall parts when thinking is enabled.
// Ref: https://ai.google.dev/gemini-api/docs/thought-signatures
const id = asTrimmedString(toolCall.id);
const signature = thoughtSignatureById.get(id);
if (signature) {
fcPart.thoughtSignature = signature;
} else if (hasThinkingEnabled) {
fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE;
}
fcParts.push(fcPart);
}
const geminiRole = role === 'assistant' ? 'model' : 'user';
// Gemini requires that parts WITH thoughtSignature and parts WITHOUT
// are not mixed in the same message. Split if needed.
// Ref: https://ai.google.dev/gemini-api/docs/thought-signatures
const hasSigned = fcParts.some((p) => 'thoughtSignature' in p);
if (hasSigned && textParts.length > 0 && fcParts.length > 0) {
// Emit text parts first, then functionCall parts in a separate message
request.contents = [
...(Array.isArray(request.contents) ? request.contents : []),
{ role: geminiRole, parts: textParts },
{ role: geminiRole, parts: fcParts },
];
} else {
const allParts = [...textParts, ...fcParts];
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;
}
export function wrapGeminiCliRequest(input: {
modelName: string;
projectId: string;
request: Record<string, unknown>;
}) {
const { model, ...requestPayload } = input.request;
return {
project: input.projectId,
model: input.modelName,
request: requestPayload,
};
}
export function unwrapGeminiCliPayload<T>(payload: T): unknown {
if (!isRecord(payload)) return payload;
if (payload.response !== undefined) {
return payload.response;
}
return payload;
}
function rewriteGeminiCliSseEventBlock(block: string): string {
const lines = block.split(/\r?\n/g);
return lines.map((line) => {
if (!line.startsWith('data:')) return line;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') return line;
try {
const parsed = JSON.parse(data);
return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`;
} catch {
return line;
}
}).join('\n');
}
export function createGeminiCliStreamReader(reader: {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
}) {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const outputQueue: Uint8Array[] = [];
let buffer = '';
let done = false;
async function fillQueue() {
while (outputQueue.length <= 0 && !done) {
const result = await reader.read();
if (result.done) {
done = true;
const tail = decoder.decode();
if (tail) buffer += tail;
if (buffer.trim()) {
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`));
buffer = '';
}
break;
}
if (!result.value) continue;
buffer += decoder.decode(result.value, { stream: true });
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex >= 0) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`));
separatorIndex = buffer.indexOf('\n\n');
}
}
}
return {
async read() {
await fillQueue();
if (outputQueue.length > 0) {
return { done: false, value: outputQueue.shift() };
}
return { done: true, value: undefined };
},
cancel(reason?: unknown) {
return reader.cancel(reason);
},
releaseLock() {
reader.releaseLock();
},
};
}
@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest';
import { composeProxyLogMessage } from './logPathMeta.js';
describe('composeProxyLogMessage', () => {
it('adds downstream path metadata before plain errors', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/responses',
errorMessage: 'upstream failed',
})).toBe('[downstream:/v1/responses] upstream failed');
});
it('keeps existing upstream metadata and avoids duplicate prefixes', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/responses',
errorMessage: '[upstream:/v1/chat/completions] bad request',
})).toBe('[downstream:/v1/responses] [upstream:/v1/chat/completions] bad request');
});
it('returns metadata-only message when no error text exists', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/chat/completions',
upstreamPath: '/v1/messages',
errorMessage: null,
})).toBe('[downstream:/v1/chat/completions] [upstream:/v1/messages]');
});
it('returns null when nothing can be recorded', () => {
expect(composeProxyLogMessage({
downstreamPath: null,
upstreamPath: null,
errorMessage: '',
})).toBe(null);
});
it('adds client and session metadata ahead of path metadata', () => {
expect(composeProxyLogMessage({
clientKind: 'codex',
sessionId: 'codex-session-123',
downstreamPath: '/v1/responses',
errorMessage: 'upstream failed',
})).toBe('[client:codex] [session:codex-session-123] [downstream:/v1/responses] upstream failed');
});
it('reuses existing client and session metadata without duplication', () => {
expect(composeProxyLogMessage({
clientKind: 'claude_code',
sessionId: 'session-123',
downstreamPath: '/v1/messages',
errorMessage: '[client:claude_code] [session:session-123] [upstream:/v1/messages] bad request',
})).toBe('[client:claude_code] [session:session-123] [downstream:/v1/messages] [upstream:/v1/messages] bad request');
});
});
+5 -51
View File
@@ -1,51 +1,5 @@
import {
parseProxyLogMetadata,
type ParsedProxyLogMetadata,
type ProxyLogUsageSource,
} from '../../../shared/proxyLogMeta.js';
type ComposeProxyLogMessageArgs = {
clientKind?: string | null;
sessionId?: string | null;
traceHint?: string | null;
downstreamPath?: string | null;
upstreamPath?: string | null;
usageSource?: ProxyLogUsageSource;
errorMessage?: string | null;
};
export type ParsedProxyLogMessageMeta = ParsedProxyLogMetadata;
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
return parseProxyLogMetadata(rawMessage);
}
export function composeProxyLogMessage({
clientKind,
sessionId,
traceHint,
downstreamPath,
upstreamPath,
usageSource,
errorMessage,
}: ComposeProxyLogMessageArgs): string | null {
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
const parsed = parseProxyLogMessageMeta(rawMessage);
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
const finalUsageSource = (usageSource || parsed.usageSource || '').trim();
const finalMessageText = parsed.messageText.trim();
const prefixParts: string[] = [];
if (finalClientKind) prefixParts.push(`[client:${finalClientKind}]`);
if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`);
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
if (finalUsageSource) prefixParts.push(`[usage:${finalUsageSource}]`);
if (prefixParts.length === 0 && !finalMessageText) return null;
if (finalMessageText) return `${prefixParts.join(' ')} ${finalMessageText}`.trim();
return prefixParts.join(' ');
}
export {
composeProxyLogMessage,
parseProxyLogMessageMeta,
type ParsedProxyLogMessageMeta,
} from '../../services/proxyLogMessage.js';
@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest';
import { resolveProxyLogBilling } from './proxyBilling.js';
describe('resolveProxyLogBilling', () => {
it('uses self-log billing metadata for detail breakdown while preserving quota-derived total cost', async () => {
const result = await resolveProxyLogBilling({
site: {
id: 1,
url: 'https://anyrouter.top',
platform: 'anyrouter',
},
account: {
id: 2,
},
modelName: 'claude-haiku-4-5-20251001',
parsedUsage: {
promptTokens: 146638,
completionTokens: 172,
totalTokens: 146810,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
},
resolvedUsage: {
promptTokens: 146638,
completionTokens: 172,
totalTokens: 146810,
recoveredFromSelfLog: true,
estimatedCostFromQuota: 0.083056,
selfLogBillingMeta: {
modelRatio: 2.5,
completionRatio: 5,
cacheRatio: 0.1,
cacheCreationRatio: 1.25,
groupRatio: 1,
cacheReadTokens: 145692,
cacheCreationTokens: 945,
promptTokensIncludeCache: true,
},
},
});
expect(result.estimatedCost).toBe(0.083056);
expect(result.billingDetails).toMatchObject({
usage: {
cacheReadTokens: 145692,
cacheCreationTokens: 945,
billablePromptTokens: 1,
},
pricing: {
modelRatio: 2.5,
completionRatio: 5,
cacheRatio: 0.1,
cacheCreationRatio: 1.25,
groupRatio: 1,
},
breakdown: {
inputPerMillion: 5,
outputPerMillion: 25,
cacheReadPerMillion: 0.5,
cacheCreationPerMillion: 6.25,
totalCost: 0.083057,
},
});
});
});
+1 -92
View File
@@ -1,92 +1 @@
import {
buildProxyBillingDetails,
estimateProxyCost,
type ProxyBillingDetails,
type ProxyBillingPricingOverride,
} from '../../services/modelPricingService.js';
import type { SelfLogBillingMeta } from '../../services/proxyUsageFallbackService.js';
interface ProxyBillingUsageSummary {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
promptTokensIncludeCache: boolean | null;
}
interface ResolvedProxyUsageSummary {
promptTokens: number;
completionTokens: number;
totalTokens: number;
recoveredFromSelfLog: boolean;
estimatedCostFromQuota: number;
selfLogBillingMeta: SelfLogBillingMeta | null;
}
interface ResolveProxyLogBillingInput {
site: {
id: number;
url: string;
platform: string;
apiKey?: string | null;
};
account: {
id: number;
accessToken?: string | null;
apiToken?: string | null;
};
modelName: string;
parsedUsage: ProxyBillingUsageSummary;
resolvedUsage: ResolvedProxyUsageSummary;
}
function toPricingOverride(meta: SelfLogBillingMeta | null): ProxyBillingPricingOverride | null {
if (!meta) return null;
return {
modelRatio: meta.modelRatio,
completionRatio: meta.completionRatio,
cacheRatio: meta.cacheRatio,
cacheCreationRatio: meta.cacheCreationRatio,
groupRatio: meta.groupRatio,
};
}
export async function resolveProxyLogBilling(
input: ResolveProxyLogBillingInput,
): Promise<{ estimatedCost: number; billingDetails: ProxyBillingDetails | null }> {
const selfLogMeta = input.resolvedUsage.selfLogBillingMeta;
const billingPricingOverride = toPricingOverride(selfLogMeta);
const cacheReadTokens = selfLogMeta?.cacheReadTokens ?? input.parsedUsage.cacheReadTokens;
const cacheCreationTokens = selfLogMeta?.cacheCreationTokens ?? input.parsedUsage.cacheCreationTokens;
const promptTokensIncludeCache = selfLogMeta?.promptTokensIncludeCache
?? input.parsedUsage.promptTokensIncludeCache;
const billingInput = {
site: input.site,
account: input.account,
modelName: input.modelName,
promptTokens: input.resolvedUsage.promptTokens,
completionTokens: input.resolvedUsage.completionTokens,
totalTokens: input.resolvedUsage.totalTokens,
cacheReadTokens,
cacheCreationTokens,
promptTokensIncludeCache,
billingPricingOverride,
};
let estimatedCost = await estimateProxyCost(billingInput);
const billingDetails = await buildProxyBillingDetails(billingInput);
if (
input.resolvedUsage.estimatedCostFromQuota > 0
&& (input.resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)
) {
estimatedCost = input.resolvedUsage.estimatedCostFromQuota;
}
return {
estimatedCost,
billingDetails,
};
}
export { resolveProxyLogBilling } from '../../services/proxyBilling.js';
@@ -1,139 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import { config } from '../../config.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
describe('detectProxyFailure (empty content)', () => {
const originalEmptyFail = config.proxyEmptyContentFailEnabled;
const originalKeywords = Array.isArray(config.proxyErrorKeywords) ? [...config.proxyErrorKeywords] : config.proxyErrorKeywords;
afterEach(() => {
config.proxyEmptyContentFailEnabled = originalEmptyFail;
config.proxyErrorKeywords = originalKeywords as any;
});
it('flags empty assistant content even when total tokens > 0', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_empty',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 },
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 12, completionTokens: 0, totalTokens: 12 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('does not flag when output exists even if usage is missing', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_has_output',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: 'hi' },
finish_reason: 'stop',
}],
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toBeNull();
});
it('does not treat tool call payloads as empty content', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'resp_tool',
object: 'response',
status: 'completed',
output: [{
type: 'function_call',
id: 'fc_1',
call_id: 'call_abc',
name: 'Glob',
arguments: '{"pattern":"README*"}',
}],
output_text: '',
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toBeNull();
});
it('flags empty SSE streams that contain no content deltas', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = [
'data: {"id":"evt_1","choices":[{"delta":{}}]}',
'',
'data: [DONE]',
'',
'',
].join('\n');
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('treats DONE-only SSE as no output and flags failure', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = [
'data: [DONE]',
'',
'',
].join('\n');
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('truncates fractional token counts before empty-content detection', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_fractional_empty',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 3.9, completion_tokens: 0.6, total_tokens: 4.5 },
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 3.9, completionTokens: 0.6, totalTokens: 4.5 } as any,
});
expect(failure).toMatchObject({ status: 502 });
});
});
@@ -1,203 +0,0 @@
import { config } from '../../config.js';
import { pullSseDataEvents } from '../../services/proxyUsageParser.js';
type FailureResult = {
status: number;
reason: string;
};
type UsageSummary = {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
function normalizeKeywords(values: string[]): string[] {
return values
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0)
.map((item) => item.toLowerCase());
}
function toNonNegativeInt(value: unknown): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.trunc(n));
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function hasToolCallLike(value: unknown): boolean {
if (!value) return false;
if (Array.isArray(value)) return value.length > 0;
if (isRecord(value)) return Object.keys(value).length > 0;
return false;
}
function hasCompletionContentFromChoice(choice: any): boolean {
if (hasNonEmptyString(choice?.text)) return true;
if (hasNonEmptyString(choice?.completion)) return true;
if (hasNonEmptyString(choice?.output_text)) return true;
const message = choice?.message;
if (hasNonEmptyString(message?.content)) return true;
if (Array.isArray(message?.content)) {
for (const part of message.content) {
if (hasNonEmptyString(part?.text) || hasNonEmptyString(part?.output_text) || hasNonEmptyString(part?.content)) {
return true;
}
}
}
if (hasNonEmptyString(message?.refusal)) return true;
if (hasToolCallLike(message?.tool_calls)) return true;
if (hasToolCallLike(message?.toolCalls)) return true;
if (hasToolCallLike(message?.function_call)) return true;
if (hasToolCallLike(message?.functionCall)) return true;
if (hasToolCallLike(choice?.tool_calls)) return true;
if (hasToolCallLike(choice?.toolCalls)) return true;
if (hasToolCallLike(choice?.function_call)) return true;
if (hasToolCallLike(choice?.functionCall)) return true;
const delta = choice?.delta;
if (hasNonEmptyString(delta?.content)) return true;
if (hasNonEmptyString(delta?.refusal)) return true;
if (hasToolCallLike(delta?.tool_calls)) return true;
if (hasToolCallLike(delta?.toolCalls)) return true;
if (hasToolCallLike(delta?.function_call)) return true;
if (hasToolCallLike(delta?.functionCall)) return true;
return false;
}
function hasCompletionContentFromPayload(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') return false;
const obj: any = payload;
if (Array.isArray(obj?.choices)) {
for (const choice of obj.choices) {
if (hasCompletionContentFromChoice(choice)) return true;
}
if (hasCompletionContentFromChoice(obj)) return true;
}
if (hasNonEmptyString(obj?.output_text)) return true;
if (hasNonEmptyString(obj?.outputText)) return true;
if (Array.isArray(obj?.output)) {
for (const item of obj.output) {
if (!isRecord(item)) continue;
const type = String((item as any).type || '').toLowerCase();
if (type.includes('function_call') || type.includes('tool_call')) return true;
if (hasNonEmptyString((item as any).text) || hasNonEmptyString((item as any).output_text)) return true;
if (Array.isArray((item as any).content)) {
for (const part of (item as any).content) {
if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) {
return true;
}
const partType = String((part as any)?.type || '').toLowerCase();
if (partType.includes('function_call') || partType.includes('tool_call')) return true;
}
}
if (hasToolCallLike((item as any).tool_calls) || hasToolCallLike((item as any).toolCalls)) return true;
if (hasToolCallLike((item as any).function_call) || hasToolCallLike((item as any).functionCall)) return true;
}
}
if (Array.isArray(obj?.content)) {
for (const part of obj.content) {
if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) {
return true;
}
const partType = String((part as any)?.type || '').toLowerCase();
if (partType.includes('function_call') || partType.includes('tool_call')) return true;
}
}
if (hasNonEmptyString(obj?.delta)) return true;
if (hasNonEmptyString(obj?.text)) return true;
if (hasToolCallLike(obj?.tool_calls) || hasToolCallLike(obj?.toolCalls)) return true;
if (hasToolCallLike(obj?.function_call) || hasToolCallLike(obj?.functionCall)) return true;
return false;
}
function detectHasUpstreamOutput(rawText: string): boolean {
const text = typeof rawText === 'string' ? rawText : '';
const trimmed = text.trim();
if (!trimmed) return false;
try {
const parsed = JSON.parse(trimmed);
return hasCompletionContentFromPayload(parsed);
} catch {
// Important: don't trim before SSE parsing, otherwise `data: [DONE]\n\n` can be lost.
const pulled = pullSseDataEvents(text);
if (pulled.events.length > 0) {
for (const event of pulled.events) {
const payload = event.trim();
if (!payload || payload === '[DONE]') continue;
try {
const parsedEvent = JSON.parse(payload);
if (hasCompletionContentFromPayload(parsedEvent)) return true;
} catch {
// Non-JSON payload still counts as upstream output.
return true;
}
}
// SSE payloads exist but none contain output.
return false;
}
// Looks like SSE but contains no non-DONE payloads.
if (text.includes('data:')) return false;
// Not JSON and not SSE: assume it's plain text output.
return true;
}
}
export function detectProxyFailure(input: {
rawText: string;
usage?: UsageSummary | null;
// Backward-compatible fields (older call sites)
completionTokens?: number;
totalTokens?: number;
}): FailureResult | null {
const rawText = typeof input.rawText === 'string' ? input.rawText : '';
const keywords = normalizeKeywords(config.proxyErrorKeywords || []);
if (keywords.length > 0) {
const normalizedText = rawText.toLowerCase();
const matched = keywords.find((keyword) => normalizedText.includes(keyword));
if (matched) {
return {
status: 502,
reason: `Upstream response matched failure keyword: ${matched}`,
};
}
}
if (config.proxyEmptyContentFailEnabled) {
const completionTokens = toNonNegativeInt(input.usage?.completionTokens ?? input.completionTokens);
const hasOutput = detectHasUpstreamOutput(rawText);
if (!hasOutput && completionTokens <= 0) {
return {
status: 502,
reason: 'Upstream returned empty content',
};
}
}
return null;
}
@@ -20,7 +20,8 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
recoveredFromSelfLog: false,
}));
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const recordOauthQuotaResetHintMock = vi.fn();
const recordOauthQuotaHeadersSnapshotMock = vi.fn<(input: unknown) => Promise<void>>(async (_input) => undefined);
const recordOauthQuotaResetHintMock = vi.fn<(input: unknown) => Promise<void>>(async (_input) => undefined);
const insertedProxyLogs: Record<string, unknown>[] = [];
const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled;
const originalProxyStickySessionEnabled = config.proxyStickySessionEnabled;
@@ -35,6 +36,8 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
},
}));
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
vi.mock('undici', async () => {
const actual = await vi.importActual<typeof import('undici')>('undici');
return {
@@ -87,7 +90,8 @@ vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
}));
vi.mock('../../services/oauth/quota.js', () => ({
recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args),
recordOauthQuotaHeadersSnapshot: async (input: unknown) => recordOauthQuotaHeadersSnapshotMock(input),
recordOauthQuotaResetHint: async (input: unknown) => recordOauthQuotaResetHintMock(input),
}));
vi.mock('../../db/index.js', () => ({
@@ -197,7 +201,8 @@ describe('responses proxy codex oauth refresh', () => {
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
refreshOauthAccessTokenSingleflightMock.mockReset();
recordOauthQuotaResetHintMock.mockReset();
recordOauthQuotaHeadersSnapshotMock.mockClear();
recordOauthQuotaResetHintMock.mockClear();
dbInsertMock.mockClear();
insertedProxyLogs.length = 0;
@@ -335,6 +340,68 @@ describe('responses proxy codex oauth refresh', () => {
expect(response.json()?.output_text).toContain('ok after codex forbidden refresh');
});
it('infers official codex originator from desktop user-agent when downstream originator is absent', async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_desktop_originator',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'desktop originator preserved',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3',
},
payload: {
model: 'gpt-5.2-codex',
input: 'hello desktop codex',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
expect(options.headers.Originator || options.headers.originator).toBe('codex_chatgpt_desktop');
});
it('canonicalizes official codex originator aliases before proxying upstream', async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_originator_alias',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'originator alias preserved',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
originator: 'Codex Desktop',
},
payload: {
model: 'gpt-5.2-codex',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
expect(options.headers.Originator || options.headers.originator).toBe('codex_chatgpt_desktop');
});
it('does not refresh codex oauth token on non-auth 403 responses', async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'quota exceeded for workspace', type: 'usage_limit_reached' },
@@ -425,7 +492,7 @@ describe('responses proxy codex oauth refresh', () => {
expect(response.json()?.output_text).toBe('ok after refresh');
});
it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => {
it('sends a non-empty default instructions field to codex responses when downstream body has no system prompt', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_no_system',
object: 'response',
@@ -452,7 +519,7 @@ describe('responses proxy codex oauth refresh', () => {
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS);
expect(forwardedBody.prompt_cache_key).toBeUndefined();
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.input).toEqual([
@@ -464,6 +531,53 @@ describe('responses proxy codex oauth refresh', () => {
]);
});
it('extracts codex system input into top-level instructions before proxying upstream', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_system_to_instructions',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok with extracted instructions',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2-codex',
instructions: 'keep edits narrow',
input: [
{
type: 'message',
role: 'system',
content: [{ type: 'input_text', text: 'be precise' }],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello codex' }],
},
],
},
});
expect(response.statusCode).toBe(200);
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.instructions).toBe('be precise\n\nkeep edits narrow');
expect(forwardedBody.input).toEqual([
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello codex' }],
},
]);
});
it('preserves explicit prompt_cache_key for codex responses requests without converting it into codex session headers', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_with_cache_key',
@@ -571,6 +685,75 @@ describe('responses proxy codex oauth refresh', () => {
]);
});
it('infers previous_response_id for codex tool-output follow-up turns when the client only sends conversation_id', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_prev_conv_1',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'tool call issued',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_prev_conv_2',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'tool result accepted',
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const headers = {
conversation_id: 'conversation-http-prev-1',
'user-agent': 'CodexClient/1.0',
};
const firstResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
headers,
payload: {
model: 'gpt-5.2-codex',
input: 'start codex tool flow',
},
});
expect(firstResponse.statusCode).toBe(200);
const secondResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
headers,
payload: {
model: 'gpt-5.2-codex',
input: [
{
id: 'tool_out_conv_1',
type: 'function_call_output',
call_id: 'call_conv_1',
output: '{"ok":true}',
},
],
},
});
expect(secondResponse.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
const secondBody = JSON.parse(secondOptions.body);
expect(firstOptions.headers.Session_id || firstOptions.headers.session_id).toBe('conversation-http-prev-1');
expect(secondOptions.headers.Session_id || secondOptions.headers.session_id).toBe('conversation-http-prev-1');
expect(secondBody.previous_response_id).toBe('resp_codex_prev_conv_1');
});
it('reuses previous_response_id for codex tool-output follow-up turns after channel/account drift on the same downstream session', async () => {
const firstSelected = {
channel: { id: 11, routeId: 22 },
@@ -1004,7 +1187,7 @@ describe('responses proxy codex oauth refresh', () => {
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS);
expect(forwardedBody.store).toBe(false);
expect(forwardedBody.max_output_tokens).toBeUndefined();
@@ -1148,11 +1331,11 @@ describe('responses proxy codex oauth refresh', () => {
const firstBody = JSON.parse(firstOptions.body);
const secondBody = JSON.parse(secondOptions.body);
expect(firstBody.instructions).toBe('');
expect(firstBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS);
expect(firstBody.store).toBe(false);
expect(firstBody.stream).toBe(true);
expect(firstBody.max_output_tokens).toBeUndefined();
expect(secondBody.instructions).toBe('');
expect(secondBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS);
expect(secondBody.store).toBe(false);
expect(secondBody.stream).toBe(true);
expect(secondBody.max_output_tokens).toBeUndefined();
@@ -25,6 +25,8 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
}),
}));
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
vi.mock('undici', async () => {
const actual = await vi.importActual<typeof import('undici')>('undici');
return {
@@ -177,7 +179,7 @@ describe('responses proxy compact upstream routing', () => {
expect(targetUrl).toContain('/v1/responses/compact');
});
it('keeps native compact as the default even for generic openai-compatible upstreams', async () => {
it('automatically falls back from compact to ordinary responses for generic openai-compatible upstreams when compact is unsupported', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' },
@@ -186,15 +188,25 @@ describe('responses proxy compact upstream routing', () => {
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'Invalid URL (POST /v1/responses/compact)',
type: 'invalid_request_error',
},
}), {
status: 404,
headers: { 'content-type': 'application/json' },
}));
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'Invalid URL (POST /v1/responses/compact)',
type: 'invalid_request_error',
},
}), {
status: 404,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_123',
object: 'response',
output_text: 'hello from fallback',
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
@@ -205,10 +217,19 @@ describe('responses proxy compact upstream routing', () => {
},
});
expect(response.statusCode).toBe(404);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/responses/compact');
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('/v1/responses');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).not.toContain('/compact');
expect(response.json()).toMatchObject({
object: 'response.compaction',
usage: {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
},
});
});
it('optionally falls back from compact to ordinary responses when compact is explicitly unsupported', async () => {
@@ -260,6 +281,117 @@ describe('responses proxy compact upstream routing', () => {
});
});
it('automatically falls back from compact when a generic upstream rejects the stream parameter on the compact endpoint', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'generic-openai-site', url: 'https://upstream.example.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: "Unknown parameter: 'stream'.",
type: 'invalid_request_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_stream_fallback',
object: 'response',
output_text: 'fallback after stream validation error',
usage: { input_tokens: 12, output_tokens: 3, total_tokens: 15 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('/v1/responses');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).not.toContain('/compact');
expect(response.json()).toMatchObject({
object: 'response.compaction',
output: expect.any(Array),
});
});
it('rebuilds compact fallback as an SSE-capable non-compact /v1/responses request for sub2api', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' },
account: { id: 33, username: 'sub2api-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'gpt-5.4',
});
const encoder = new TextEncoder();
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'Invalid URL (POST /v1/responses/compact)',
type: 'invalid_request_error',
},
}), {
status: 404,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_sub2api_compact_fallback","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n'));
controller.enqueue(encoder.encode('event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_sub2api_compact_fallback","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n'));
controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_sub2api_compact_fallback","delta":"fallback via sub2api responses stream"}\n\n'));
controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_sub2api_compact_fallback","model":"gpt-5.4","status":"completed","output":[{"id":"msg_sub2api_compact_fallback","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"fallback via sub2api responses stream"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}\n\n'));
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
}), {
status: 200,
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.4',
input: 'hello',
store: true,
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('/v1/responses');
expect(String(fetchMock.mock.calls[1]?.[0] || '')).not.toContain('/compact');
const secondOptions = fetchMock.mock.calls[1]?.[1] as Record<string, any>;
const secondBody = JSON.parse(String(secondOptions?.body || '{}'));
expect(secondOptions?.headers?.accept).toBe('text/event-stream');
expect(secondBody).toMatchObject({
stream: true,
store: false,
});
expect(response.json()).toMatchObject({
object: 'response.compaction',
output: expect.any(Array),
});
});
it('does not fall back for unrelated unsupported errors that do not mention compact', async () => {
config.responsesCompactFallbackToResponsesEnabled = true;
selectChannelMock.mockReturnValue({
@@ -304,7 +436,7 @@ describe('responses proxy compact upstream routing', () => {
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/v1/responses/compact');
});
it('strips stream fields when forwarding compact requests to codex upstreams', async () => {
it('strips stream fields and store when forwarding compact requests to codex upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
@@ -360,8 +492,57 @@ describe('responses proxy compact upstream routing', () => {
const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.stream).toBeUndefined();
expect(forwardedBody.stream_options).toBeUndefined();
expect(forwardedBody.instructions).toBe('');
expect(forwardedBody.store).toBe(false);
expect(forwardedBody.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS);
expect(forwardedBody.store).toBeUndefined();
expect(options.headers.Accept || options.headers.accept).toBe('application/json');
});
it('strips stream fields and store when forwarding compact requests to sub2api upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'sub2api-site', url: 'https://sub2api.example.com', platform: 'sub2api' },
account: { id: 33, username: 'sub2api-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'gpt-5.4',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'cmp_sub2api_123',
object: 'response.compaction',
input_tokens: 4,
output_tokens: 2,
total_tokens: 6,
output: [
{
id: 'rs_sub2api_123',
type: 'compaction',
encrypted_content: 'enc-compact-payload',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.4',
input: 'hello',
store: true,
stream_options: { include_obfuscation: true },
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/responses/compact');
const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.stream).toBeUndefined();
expect(forwardedBody.stream_options).toBeUndefined();
expect(forwardedBody.store).toBeUndefined();
expect(options.headers.Accept || options.headers.accept).toBe('application/json');
});
@@ -525,4 +706,62 @@ describe('responses proxy compact upstream routing', () => {
});
expect(body.created_at).toEqual(expect.any(Number));
});
it('does not fall back from compact on generic upstream errors without compact capability hints', async () => {
config.responsesCompactFallbackToResponsesEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'upstream_error',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(400);
expect(fetchMock.mock.calls.length).toBeGreaterThan(0);
for (const [targetUrl] of fetchMock.mock.calls as Array<[string, any]>) {
expect(String(targetUrl)).toContain('/v1/responses/compact');
expect(String(targetUrl)).not.toMatch(/\/v1\/responses$/);
}
});
it('does not fall back from compact on generic invalid url errors that do not mention compact', async () => {
config.responsesCompactFallbackToResponsesEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'Invalid URL (POST /v1/responses)',
type: 'invalid_request_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(400);
expect(fetchMock.mock.calls.length).toBeGreaterThan(0);
for (const [targetUrl] of fetchMock.mock.calls as Array<[string, any]>) {
expect(String(targetUrl)).toContain('/v1/responses/compact');
expect(String(targetUrl)).not.toMatch(/\/v1\/responses$/);
}
});
});
+22
View File
@@ -2,6 +2,16 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { handleOpenAiResponsesSurfaceRequest } from '../../proxy-core/surfaces/openAiResponsesSurface.js';
import { ensureResponsesWebsocketTransport } from './responsesWebsocket.js';
function resolveAliasedResponsesDownstreamPath(
request: FastifyRequest,
): '/v1/responses' | '/v1/responses/compact' {
const rawUrl = request.raw.url || request.url || '';
const pathname = rawUrl.split('?')[0] || rawUrl;
return pathname.endsWith('/compact')
? '/v1/responses/compact'
: '/v1/responses';
}
export async function responsesProxyRoute(app: FastifyInstance) {
ensureResponsesWebsocketTransport(app);
@@ -16,4 +26,16 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}));
app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) =>
handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses/compact'));
app.post('/responses', async (request: FastifyRequest, reply: FastifyReply) =>
handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses'));
app.post('/responses/*', async (request: FastifyRequest, reply: FastifyReply) =>
handleOpenAiResponsesSurfaceRequest(request, reply, resolveAliasedResponsesDownstreamPath(request)));
app.get('/responses', async (_request: FastifyRequest, reply: FastifyReply) =>
reply.code(426).send({
error: {
message: 'WebSocket upgrade required for GET /responses',
type: 'invalid_request_error',
},
}));
}
@@ -96,6 +96,7 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
}));
vi.mock('../../services/oauth/quota.js', () => ({
recordOauthQuotaHeadersSnapshot: async () => undefined,
recordOauthQuotaResetHint: async () => undefined,
}));
@@ -194,6 +195,16 @@ function waitForSocketOpen(socket: WebSocket) {
});
}
function waitForSocketClose(socket: WebSocket) {
return new Promise<void>((resolve) => {
if (socket.readyState === WebSocket.CLOSED) {
resolve();
return;
}
socket.once('close', () => resolve());
});
}
function waitForSocketUpgrade(socket: WebSocket) {
return new Promise<{ headers: Record<string, string | string[] | undefined> }>((resolve, reject) => {
socket.once('upgrade', (response) => resolve({ headers: response.headers as Record<string, string | string[] | undefined> }));
@@ -748,6 +759,131 @@ describe('responses websocket transport', () => {
});
});
it('infers previous_response_id for websocket tool-output follow-up turns when the client only sends conversation_id', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const socket = createClientSocket(baseUrl, {
conversation_id: 'ws-conversation-prev-infer',
});
await waitForSocketOpen(socket);
const firstResponsePromise = waitForSocketMessageMatching(
socket,
(message) => message?.type === 'response.completed',
);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
const firstResponse = await firstResponsePromise;
const secondResponsePromise = waitForSocketMessageMatching(
socket,
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_2',
);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [
{
id: 'tool_out_ws_conv_1',
type: 'function_call_output',
call_id: 'call_ws_conv_1',
output: '{"ok":true}',
},
],
}));
await secondResponsePromise;
socket.close();
expect(firstResponse?.response?.id).toBe('resp_upstream_1');
expect(upstreamUpgradeHeaders.session_id).toBe('ws-conversation-prev-infer');
expect(upstreamUpgradeHeaders.conversation_id).toBe('ws-conversation-prev-infer');
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp_upstream_1',
input: [
{
id: 'tool_out_ws_conv_1',
type: 'function_call_output',
call_id: 'call_ws_conv_1',
output: '{"ok":true}',
},
],
});
});
it('preserves websocket continuation across downstream reconnects on the same conversation_id', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const firstSocket = createClientSocket(baseUrl, {
conversation_id: 'ws-conversation-reconnect-1',
});
await waitForSocketOpen(firstSocket);
const firstResponsePromise = waitForSocketMessageMatching(
firstSocket,
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_1',
);
firstSocket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
await firstResponsePromise;
firstSocket.close();
await waitForSocketClose(firstSocket);
const secondSocket = createClientSocket(baseUrl, {
conversation_id: 'ws-conversation-reconnect-1',
});
await waitForSocketOpen(secondSocket);
const secondResponsePromise = waitForSocketMessageMatching(
secondSocket,
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_2',
);
secondSocket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [
{
id: 'tool_out_ws_reconnect_1',
type: 'function_call_output',
call_id: 'call_ws_reconnect_1',
output: '{"ok":true}',
},
],
}));
await secondResponsePromise;
secondSocket.close();
expect(upstreamConnectionCount).toBe(2);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp_upstream_1',
input: [
{
id: 'tool_out_ws_reconnect_1',
type: 'function_call_output',
call_id: 'call_ws_reconnect_1',
output: '{"ok":true}',
},
],
});
});
it('retries websocket turns once without previous_response_id when the upstream reports previous_response_not_found', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
@@ -1,270 +0,0 @@
import { zstdCompressSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import { Response } from 'undici';
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
describe('collectResponsesFinalPayloadFromSse', () => {
it('treats event:error payloads as upstream failures', async () => {
const upstream = {
async text() {
return [
'event: error',
'data: {"error":{"message":"quota exceeded"},"type":"error"}',
'',
'data: [DONE]',
'',
].join('\n');
},
};
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.rejects
.toThrow('quota exceeded');
});
it('prefers aggregated stream content when response.completed only carries an empty output array', async () => {
const upstream = {
async text() {
return [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_empty_completed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
'',
'event: response.output_item.added',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_empty_completed","type":"message","role":"assistant","status":"in_progress","content":[]}}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_empty_completed","delta":"pong"}',
'',
'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_empty_completed","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
'',
'data: [DONE]',
'',
].join('\n');
},
};
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.resolves
.toMatchObject({
payload: {
id: 'resp_empty_completed',
status: 'completed',
output: [
{
id: 'msg_empty_completed',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'pong',
},
],
},
],
output_text: 'pong',
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
},
});
});
it('decodes zstd-compressed responses SSE before aggregating the final payload', async () => {
const upstream = new Response(zstdCompressSync(Buffer.from([
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_zstd_completed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
'',
'event: response.output_item.added',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_zstd_completed","type":"message","role":"assistant","status":"in_progress","content":[]}}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_zstd_completed","delta":"你好,来自 zstd 聚合 SSE"}',
'',
'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_zstd_completed","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
'',
'data: [DONE]',
'',
].join('\n'))), {
status: 200,
headers: {
'content-encoding': 'zstd',
'content-type': 'text/event-stream; charset=utf-8',
},
});
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.resolves
.toMatchObject({
payload: {
id: 'resp_zstd_completed',
status: 'completed',
output_text: '你好,来自 zstd 聚合 SSE',
},
rawText: expect.stringContaining('你好,来自 zstd 聚合 SSE'),
});
});
it('returns response.incomplete payloads instead of treating them as upstream failures', async () => {
const upstream = {
async text() {
return [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_incomplete_terminal","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
'',
'event: response.incomplete',
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_terminal","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial answer"}]}],"output_text":"partial answer","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
'',
'data: [DONE]',
'',
].join('\n');
},
};
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.resolves
.toMatchObject({
payload: {
id: 'resp_incomplete_terminal',
status: 'incomplete',
output_text: 'partial answer',
incomplete_details: {
reason: 'max_output_tokens',
},
},
});
});
it('returns response.incomplete terminal payloads instead of throwing', async () => {
const upstream = {
async text() {
return [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_incomplete_1","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_incomplete_1","delta":"partial"}',
'',
'event: response.incomplete',
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_1","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
'',
'data: [DONE]',
'',
].join('\n');
},
};
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.resolves
.toMatchObject({
payload: {
id: 'resp_incomplete_1',
status: 'incomplete',
incomplete_details: {
reason: 'max_output_tokens',
},
output_text: 'partial',
},
});
});
it('treats response.incomplete as terminal even when only response.output carries the visible text', async () => {
const upstream = {
async text() {
return [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_incomplete_output_only","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
'',
'event: response.incomplete',
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_output_only","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_output_only","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial from output"}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
'',
'data: [DONE]',
'',
].join('\n');
},
};
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.resolves
.toMatchObject({
payload: {
id: 'resp_incomplete_output_only',
status: 'incomplete',
output: [
{
id: 'msg_incomplete_output_only',
type: 'message',
role: 'assistant',
status: 'incomplete',
content: [
{
type: 'output_text',
text: 'partial from output',
},
],
},
],
output_text: 'partial from output',
incomplete_details: {
reason: 'max_output_tokens',
},
},
});
});
it('preserves incomplete item status when response.incomplete needs aggregate output repair', async () => {
const upstream = {
async text() {
return [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_incomplete_repair","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
'',
'event: response.output_item.added',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_incomplete_repair","type":"message","role":"assistant","status":"in_progress","content":[]}}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_incomplete_repair","delta":"partial repair"}',
'',
'event: response.incomplete',
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_repair","model":"gpt-5.4","status":"incomplete","output":[],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
'',
'data: [DONE]',
'',
].join('\n');
},
};
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.resolves
.toMatchObject({
payload: {
id: 'resp_incomplete_repair',
status: 'incomplete',
output: [
{
id: 'msg_incomplete_repair',
type: 'message',
role: 'assistant',
status: 'incomplete',
content: [
{
type: 'output_text',
text: 'partial repair',
},
],
},
],
output_text: 'partial repair',
incomplete_details: {
reason: 'max_output_tokens',
},
},
});
});
});
@@ -1,431 +0,0 @@
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
type ResponsesTerminalStatus = 'completed' | 'incomplete';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function parseResponsesSsePayload(data: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(data);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function getResponsesFailureMessage(payload: Record<string, unknown>): string {
if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message.trim();
}
if (typeof payload.message === 'string' && payload.message.trim()) {
return payload.message.trim();
}
return 'upstream stream failed';
}
function hasMeaningfulMessageContent(content: unknown): boolean {
if (!Array.isArray(content)) return false;
return content.some((part) => {
if (!isRecord(part)) return false;
const partType = typeof part.type === 'string' ? part.type.trim().toLowerCase() : '';
if (partType === 'output_text' || partType === 'text') {
return typeof part.text === 'string' && part.text.length > 0;
}
return true;
});
}
function hasMeaningfulResponsesOutput(output: unknown): boolean {
if (!Array.isArray(output)) return false;
return output.some((item) => {
if (!isRecord(item)) return false;
const itemType = typeof item.type === 'string' ? item.type.trim().toLowerCase() : '';
if (itemType === 'message') {
return hasMeaningfulMessageContent(item.content);
}
if (itemType === 'reasoning') {
return (
(Array.isArray(item.summary) && item.summary.length > 0)
|| (typeof item.encrypted_content === 'string' && item.encrypted_content.trim().length > 0)
);
}
return itemType.length > 0;
});
}
function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boolean {
return (
payload.object === 'response.compaction'
|| Array.isArray(payload.output)
|| Object.prototype.hasOwnProperty.call(payload, 'output_text')
);
}
function hasMeaningfulFinalResponsesPayload(payload: Record<string, unknown>): boolean {
if (payload.object === 'response.compaction') {
return Array.isArray(payload.output) && payload.output.length > 0;
}
if (typeof payload.output_text === 'string' && payload.output_text.length > 0) {
return true;
}
return hasMeaningfulResponsesOutput(payload.output);
}
function collectResponsesOutputText(payload: Record<string, unknown>): string {
const output = Array.isArray(payload.output) ? payload.output : [];
const parts: string[] = [];
for (const item of output) {
if (!isRecord(item)) continue;
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
const content = Array.isArray(item.content) ? item.content : [];
for (const part of content) {
if (!isRecord(part)) continue;
const partType = asTrimmedString(part.type).toLowerCase();
const text = typeof part.text === 'string' ? part.text : '';
if ((partType === 'output_text' || partType === 'text') && text) {
parts.push(text);
}
}
}
return parts.join('');
}
function rememberStreamResponseEnvelope(
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
payload: Record<string, unknown>,
): void {
const responsePayload = isRecord(payload.response) ? payload.response : payload;
if (typeof responsePayload.id === 'string' && responsePayload.id.trim().length > 0) {
streamContext.id = responsePayload.id;
}
if (typeof responsePayload.model === 'string' && responsePayload.model.trim().length > 0) {
streamContext.model = responsePayload.model;
}
const createdAt = (
typeof responsePayload.created_at === 'number' && Number.isFinite(responsePayload.created_at)
? responsePayload.created_at
: (typeof responsePayload.created === 'number' && Number.isFinite(responsePayload.created)
? responsePayload.created
: null)
);
if (createdAt !== null) {
streamContext.created = createdAt;
}
}
function ensureResponseId(rawId: string): string {
const trimmed = rawId.trim() || `resp_${Date.now()}`;
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
}
function buildUsagePayload(usage: ReturnType<typeof parseProxyUsage>): Record<string, unknown> {
const payload: Record<string, unknown> = {
input_tokens: usage.promptTokens,
output_tokens: usage.completionTokens,
total_tokens: usage.totalTokens,
};
const inputDetails: Record<string, unknown> = {};
if ((usage.cacheReadTokens || 0) > 0) inputDetails.cached_tokens = usage.cacheReadTokens;
if ((usage.cacheCreationTokens || 0) > 0) inputDetails.cache_creation_tokens = usage.cacheCreationTokens;
if (Object.keys(inputDetails).length > 0) payload.input_tokens_details = inputDetails;
return payload;
}
function cloneAggregateOutputItem(
item: unknown,
terminalStatus: ResponsesTerminalStatus,
): Record<string, unknown> | null {
if (!isRecord(item)) return null;
const next = structuredClone(item);
const currentStatus = asTrimmedString(next.status).toLowerCase();
next.status = currentStatus && currentStatus !== 'in_progress'
? currentStatus
: terminalStatus;
return next;
}
function materializeTerminalPayloadFromAggregate(
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
usage: ReturnType<typeof parseProxyUsage>,
terminalStatus: ResponsesTerminalStatus,
): Record<string, unknown> {
const output = aggregateState.outputItems
.map((item) => cloneAggregateOutputItem(item, terminalStatus))
.filter((item): item is Record<string, unknown> => !!item);
const usagePayload = buildUsagePayload(usage);
const usageWithExtras = Object.keys(aggregateState.usageExtras).length > 0
? { ...usagePayload, ...structuredClone(aggregateState.usageExtras) }
: usagePayload;
return {
id: ensureResponseId(
asTrimmedString(aggregateState.responseId)
|| asTrimmedString(streamContext.id)
|| asTrimmedString(aggregateState.modelName),
),
object: 'response',
created_at: (
typeof aggregateState.createdAt === 'number' && Number.isFinite(aggregateState.createdAt)
? aggregateState.createdAt
: streamContext.created
) || Math.floor(Date.now() / 1000),
status: terminalStatus,
model: asTrimmedString(streamContext.model) || asTrimmedString(aggregateState.modelName),
output,
output_text: collectResponsesOutputText({ output }),
usage: usageWithExtras,
};
}
function enrichTerminalPayload(
payload: Record<string, unknown>,
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
usage: ReturnType<typeof parseProxyUsage>,
terminalStatus: ResponsesTerminalStatus,
): Record<string, unknown> {
const next = structuredClone(payload);
const materialized = materializeTerminalPayloadFromAggregate(
aggregateState,
streamContext,
usage,
terminalStatus,
);
if (!hasMeaningfulResponsesOutput(next.output) && materialized && hasMeaningfulResponsesOutput(materialized.output)) {
next.output = materialized.output;
}
const currentOutputText = typeof next.output_text === 'string' ? next.output_text : '';
if (!currentOutputText) {
const derivedOutputText = collectResponsesOutputText(next)
|| (materialized && typeof materialized.output_text === 'string' ? materialized.output_text : '');
if (derivedOutputText) {
next.output_text = derivedOutputText;
}
}
if (materialized && next.usage === undefined && materialized.usage !== undefined) {
next.usage = materialized.usage;
}
return next;
}
function mergeMissingResponsesTerminalFields(
payload: Record<string, unknown>,
fallbackPayload: Record<string, unknown> | null,
): Record<string, unknown> {
if (!fallbackPayload) return payload;
const merged = { ...payload };
if (merged.output === undefined && fallbackPayload.output !== undefined) {
merged.output = fallbackPayload.output;
}
if (
(typeof merged.output_text !== 'string' || merged.output_text.length === 0)
&& typeof fallbackPayload.output_text === 'string'
&& fallbackPayload.output_text.length > 0
) {
merged.output_text = fallbackPayload.output_text;
}
if (merged.object === undefined && fallbackPayload.object !== undefined) {
merged.object = fallbackPayload.object;
}
if (merged.created_at === undefined && fallbackPayload.created_at !== undefined) {
merged.created_at = fallbackPayload.created_at;
}
if (merged.usage === undefined && fallbackPayload.usage !== undefined) {
merged.usage = fallbackPayload.usage;
}
return merged;
}
export function looksLikeResponsesSseText(rawText: string): boolean {
const { events, rest } = openAiResponsesTransformer.pullSseEvents(rawText);
if (events.length === 0 || rest.trim().length > 0) return false;
return events.some((event) => {
if (event.data === '[DONE]') return true;
if (event.event === 'error' || event.event.startsWith('response.')) return true;
const payload = parseResponsesSsePayload(event.data);
const payloadType = typeof payload?.type === 'string' ? payload.type : '';
return payloadType === 'error' || payloadType.startsWith('response.');
});
}
export function createSingleChunkStreamReader(rawText: string): {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
} {
const chunk = Buffer.from(rawText, 'utf8');
let done = false;
return {
async read() {
if (done) return { done: true };
done = true;
return { done: false, value: chunk };
},
async cancel() {
done = true;
return undefined;
},
releaseLock() {},
};
}
export function collectResponsesFinalPayloadFromSseText(
rawText: string,
modelName: string,
): { payload: Record<string, unknown>; rawText: string } {
const { events } = openAiResponsesTransformer.pullSseEvents(rawText);
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName);
let usage = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
promptTokensIncludeCache: null as boolean | null,
};
let completedPayload: Record<string, unknown> | null = null;
let terminalStatus: ResponsesTerminalStatus = 'completed';
const captureCompletedPayloadFromEvent = (
eventType: string,
payload: Record<string, unknown>,
) => {
if (completedPayload) return;
if (eventType === 'response.failed' || eventType === 'error') {
throw new Error(getResponsesFailureMessage(payload));
}
if (eventType !== 'response.completed' && eventType !== 'response.incomplete') {
return;
}
terminalStatus = eventType === 'response.incomplete' ? 'incomplete' : 'completed';
if (isRecord(payload.response) && hasCompleteFinalResponsesPayload(payload.response)) {
completedPayload = payload.response;
return;
}
if (hasCompleteFinalResponsesPayload(payload)) {
completedPayload = payload;
}
};
const captureCompletedPayloadFromLines = (lines: string[]) => {
if (completedPayload) return;
const parsed = openAiResponsesTransformer.pullSseEvents(lines.join(''));
for (const event of parsed.events) {
if (event.data === '[DONE]') continue;
const payload = parseResponsesSsePayload(event.data);
if (!payload) continue;
const payloadType = typeof payload.type === 'string' ? payload.type : '';
captureCompletedPayloadFromEvent(payloadType || event.event, payload);
if (completedPayload) {
return;
}
}
};
for (const event of events) {
if (event.data === '[DONE]') continue;
const payload = parseResponsesSsePayload(event.data);
if (!payload) continue;
const payloadType = typeof payload.type === 'string' ? payload.type : '';
const eventType = payloadType || event.event;
rememberStreamResponseEnvelope(streamContext, payload);
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
captureCompletedPayloadFromEvent(eventType, payload);
if (completedPayload) {
continue;
}
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(
payload,
streamContext,
modelName,
);
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({
state: aggregateState,
streamContext,
event: normalizedEvent,
usage,
}));
}
if (
completedPayload
&& !hasMeaningfulFinalResponsesPayload(completedPayload)
&& hasMeaningfulResponsesOutput(aggregateState.outputItems)
) {
completedPayload = mergeMissingResponsesTerminalFields(
completedPayload,
materializeTerminalPayloadFromAggregate(
aggregateState,
streamContext,
usage,
terminalStatus,
),
);
}
if (completedPayload) {
completedPayload = mergeMissingResponsesTerminalFields(
completedPayload,
materializeTerminalPayloadFromAggregate(
aggregateState,
streamContext,
usage,
terminalStatus,
),
);
}
if (!completedPayload) {
const materialized = materializeTerminalPayloadFromAggregate(
aggregateState,
streamContext,
usage,
terminalStatus,
);
if (materialized) {
completedPayload = materialized;
}
}
if (completedPayload) {
return {
payload: enrichTerminalPayload(
completedPayload,
aggregateState,
streamContext,
usage,
terminalStatus,
),
rawText,
};
}
throw new Error('stream disconnected before terminal responses event');
}
export async function collectResponsesFinalPayloadFromSse(
upstream: { text(): Promise<string>; headers?: { get(name: string): string | null } },
modelName: string,
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
const rawText = typeof upstream.headers?.get === 'function'
? await readRuntimeResponseText(upstream as Parameters<typeof readRuntimeResponseText>[0])
: await upstream.text();
return collectResponsesFinalPayloadFromSseText(rawText, modelName);
}
@@ -554,6 +554,8 @@ async function handleResponsesWebsocketConnection(
) {
const websocketSessionId = headerValueToTrimmedString(request.headers['session_id'])
|| headerValueToTrimmedString(request.headers['session-id'])
|| headerValueToTrimmedString(request.headers['conversation_id'])
|| headerValueToTrimmedString(request.headers['conversation-id'])
|| randomUUID();
const runtimeSessionKeys = new Set<string>();
let lastRequest: Record<string, unknown> | null = null;
@@ -0,0 +1,112 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const handleOpenAiResponsesSurfaceRequestMock = vi.fn(async (_request, reply, downstreamPath) => (
reply.code(200).send({
ok: true,
downstreamPath,
})
));
const handleChatSurfaceRequestMock = vi.fn(async (_request, reply, downstreamFormat) => (
reply.code(200).send({
ok: true,
downstreamFormat,
})
));
const ensureResponsesWebsocketTransportMock = vi.fn();
vi.mock('../../proxy-core/surfaces/openAiResponsesSurface.js', () => ({
handleOpenAiResponsesSurfaceRequest: (...args: unknown[]) => handleOpenAiResponsesSurfaceRequestMock(...args),
}));
vi.mock('../../proxy-core/surfaces/chatSurface.js', () => ({
handleChatSurfaceRequest: (...args: unknown[]) => handleChatSurfaceRequestMock(...args),
handleClaudeCountTokensSurfaceRequest: vi.fn(),
}));
vi.mock('./responsesWebsocket.js', () => ({
ensureResponsesWebsocketTransport: (...args: unknown[]) => ensureResponsesWebsocketTransportMock(...args),
}));
describe('proxy route aliases', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
const { chatProxyRoute } = await import('./chat.js');
app = Fastify();
await app.register(responsesProxyRoute);
await app.register(chatProxyRoute);
});
beforeEach(() => {
handleOpenAiResponsesSurfaceRequestMock.mockClear();
handleChatSurfaceRequestMock.mockClear();
ensureResponsesWebsocketTransportMock.mockClear();
});
afterAll(async () => {
await app.close();
});
it('registers bare responses aliases against the same downstream handlers', async () => {
const response = await app.inject({
method: 'POST',
url: '/responses',
payload: { model: 'gpt-5.2', input: 'hello' },
});
expect(response.statusCode).toBe(200);
expect(handleOpenAiResponsesSurfaceRequestMock).toHaveBeenCalledTimes(1);
expect(response.json()).toEqual({
ok: true,
downstreamPath: '/v1/responses',
});
});
it('routes bare compact responses aliases to compact downstream path', async () => {
const response = await app.inject({
method: 'POST',
url: '/responses/compact',
payload: { model: 'gpt-5.2', input: 'hello' },
});
expect(response.statusCode).toBe(200);
expect(handleOpenAiResponsesSurfaceRequestMock).toHaveBeenCalledTimes(1);
expect(response.json()).toEqual({
ok: true,
downstreamPath: '/v1/responses/compact',
});
});
it('returns websocket upgrade required for bare GET /responses', async () => {
const response = await app.inject({
method: 'GET',
url: '/responses',
});
expect(response.statusCode).toBe(426);
expect(handleOpenAiResponsesSurfaceRequestMock).not.toHaveBeenCalled();
expect(response.json()).toEqual({
error: {
message: 'WebSocket upgrade required for GET /responses',
type: 'invalid_request_error',
},
});
});
it('registers bare chat completions alias against the openai chat handler', async () => {
const response = await app.inject({
method: 'POST',
url: '/chat/completions',
payload: { model: 'gpt-5.2', messages: [{ role: 'user', content: 'hello' }] },
});
expect(response.statusCode).toBe(200);
expect(handleChatSurfaceRequestMock).toHaveBeenCalledTimes(1);
expect(response.json()).toEqual({
ok: true,
downstreamFormat: 'openai',
});
});
});
+136 -14
View File
@@ -25,6 +25,8 @@ import {
MODEL_KEY_HASH_SUFFIX_LENGTH,
} from '../../services/upstreamEndpointRuntimeMemory.js';
const CODEX_DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
const baseContext = {
site: {
id: 1,
@@ -146,7 +148,7 @@ describe('resolveUpstreamEndpointCandidates', () => {
'gpt-5.3',
'openai',
);
expect(openaiOrder).toEqual(['chat', 'responses', 'messages']);
expect(openaiOrder).toEqual(['responses', 'chat', 'messages']);
const openaiResponsesOrder = await resolveUpstreamEndpointCandidates(
{
@@ -166,7 +168,17 @@ describe('resolveUpstreamEndpointCandidates', () => {
'claude-opus-4-6',
'openai',
);
expect(openaiClaudeOrder).toEqual(['messages', 'chat', 'responses']);
expect(openaiClaudeOrder).toEqual(['responses', 'chat', 'messages']);
const antigravityOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'antigravity' },
},
'claude-opus-4-6',
'openai',
);
expect(antigravityOrder).toEqual(['messages']);
const claudeOrder = await resolveUpstreamEndpointCandidates(
{
@@ -203,7 +215,63 @@ describe('resolveUpstreamEndpointCandidates', () => {
},
);
expect(order).toEqual(['responses', 'messages', 'chat']);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('derives responses-only candidates for compact requests before surface fallback logic', async () => {
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'responses',
undefined,
undefined,
{
requestKind: 'responses-compact',
},
);
expect(order).toEqual(['responses']);
});
it('derives responses-only candidates when the request requires native responses file-url handling', async () => {
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'responses',
undefined,
{
hasNonImageFileInput: true,
},
{
requiresNativeResponsesFileUrl: true,
},
);
expect(order).toEqual(['responses']);
});
it('derives responses-first candidates for codex oauth hints without surface-local reordering', async () => {
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
undefined,
undefined,
{
oauthProvider: 'codex',
},
);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('prefers document-capable endpoints when downstream content contains non-image files', async () => {
@@ -547,7 +615,63 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(order).toEqual(['responses', 'messages']);
});
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
it('learns to prefer /v1/messages after a chat endpoint says messages is required', async () => {
const memoryWrite = recordUpstreamEndpointFailure({
siteId: baseContext.site.id,
endpoint: 'chat',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
status: 400,
errorText: '{"error":{"message":"messages is required","type":"upstream_error"}}',
});
expect(memoryWrite).toMatchObject({
action: 'failure',
endpoint: 'chat',
blockedEndpoint: 'chat',
preferredEndpoint: 'messages',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(order).toEqual(['messages', 'responses']);
});
it('learns to prefer /v1/responses after a non-responses endpoint says input is required', async () => {
const memoryWrite = recordUpstreamEndpointFailure({
siteId: baseContext.site.id,
endpoint: 'chat',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
status: 400,
errorText: '{"error":{"message":"input is required","type":"invalid_request_error"}}',
});
expect(memoryWrite).toMatchObject({
action: 'failure',
endpoint: 'chat',
blockedEndpoint: 'chat',
preferredEndpoint: 'responses',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(order).toEqual(['responses', 'messages']);
});
it('keeps openai platform responses-first even when the catalog only advertises generic openai/chat support', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
@@ -567,7 +691,7 @@ describe('resolveUpstreamEndpointCandidates', () => {
'openai',
);
expect(order).toEqual(['messages', 'chat', 'responses']);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('keeps anyrouter messages-first special case', async () => {
@@ -623,7 +747,8 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(isEndpointDowngradeError(404, '{"error":{"message":"Not Found","type":"not_found_error"}}')).toBe(true);
expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true);
expect(isEndpointDowngradeError(400, '{"error":{"message":"unsupported endpoint","type":"invalid_request_error"}}')).toBe(true);
expect(isEndpointDowngradeError(400, '{"error":{"message":"","type":"upstream_error"}}')).toBe(true);
expect(isEndpointDowngradeError(400, '{"error":{"message":"","type":"upstream_error"}}')).toBe(false);
expect(isEndpointDowngradeError(400, '{"error":{"message":"upstream_error: unsupported endpoint /v1/responses","type":"upstream_error"}}')).toBe(true);
expect(isEndpointDowngradeError(400, '{"error":{"message":"openai_error","type":"bad_response_status_code"}}')).toBe(true);
expect(isEndpointDowngradeError(415, '{"error":{"message":"Unsupported Media Type: Only \\"application/json\\" is allowed"}}')).toBe(true);
});
@@ -817,7 +942,7 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers['User-Agent']).toBe('codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464');
expect(request.headers.Accept).toBe('application/json');
expect(request.headers.Connection).toBe('Keep-Alive');
expect(request.body.instructions).toBe('');
expect(request.body.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS);
expect(request.body.prompt_cache_key).toBeUndefined();
expect(request.body.stream).toBe(false);
expect(request.body.store).toBe(false);
@@ -956,7 +1081,7 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i);
expect(request.headers.Conversation_id).toBe(request.headers.Session_id);
expect(request.body.prompt_cache_key).toBeUndefined();
expect(request.body.instructions).toBe('');
expect(request.body.instructions).toBe(CODEX_DEFAULT_INSTRUCTIONS);
expect(request.body.stream).toBe(false);
expect(request.body.store).toBe(false);
expect(request.body.parallel_tool_calls).toBe(false);
@@ -1300,7 +1425,7 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
});
it('converts system roles to developer in native codex responses bodies', () => {
it('extracts system input into top-level instructions in native codex responses bodies', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
@@ -1312,6 +1437,7 @@ describe('buildUpstreamEndpointRequest', () => {
downstreamFormat: 'responses',
responsesOriginalBody: {
model: 'gpt-5.4',
instructions: 'keep edits narrow',
input: [
{
type: 'message',
@@ -1328,17 +1454,13 @@ describe('buildUpstreamEndpointRequest', () => {
});
expect(request.body.input).toEqual([
{
type: 'message',
role: 'developer',
content: [{ type: 'input_text', text: 'be careful' }],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
},
]);
expect(request.body.instructions).toBe('be careful\n\nkeep edits narrow');
});
it('normalizes downstream responses input string before forwarding upstream', () => {
File diff suppressed because it is too large Load Diff