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:
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 +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$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user