forked from boos/BoosAPI
fix: preserve protocol parity across compatibility hops (#259)
* wip: preserve local changes before upstream merge * Improve proxy routing stability and add stable-first strategy * Sync explicit-group routing strategy to eligible source routes * Fix proxy retry and runtime health review issues * fix: make proxy channel retry attempts configurable * fix: harden proxy retry recovery and bookkeeping * fix: address pr 257 review comments * fix: preserve oe tool_result structure * fix: preserve protocol parity across compatibility hops * fix: satisfy server build and typecheck * fix: preserve codex and mcp fallback semantics * fix: address ci and review follow-ups --------- Co-authored-by: bnvnvnv <bnvnvnv@gmail.com>
This commit is contained in:
@@ -177,10 +177,10 @@ describe('chat proxy codex oauth compatibility', () => {
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.stream).toBe(true);
|
||||
expect(forwardedBody.instructions).toBe('');
|
||||
expect(forwardedBody.store).toBe(false);
|
||||
expect(forwardedBody.parallel_tool_calls).toBe(true);
|
||||
expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']);
|
||||
expect(forwardedBody.max_output_tokens).toBeUndefined();
|
||||
expect(forwardedBody.store).toBeUndefined();
|
||||
expect(forwardedBody.parallel_tool_calls).toBeUndefined();
|
||||
expect(forwardedBody.include).toBeUndefined();
|
||||
expect(forwardedBody.max_output_tokens).toBe(256);
|
||||
expect(forwardedBody.max_completion_tokens).toBeUndefined();
|
||||
|
||||
const body = response.json();
|
||||
|
||||
@@ -1921,6 +1921,7 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(forwarded.tool_choice?.function?.name).toBe('Glob');
|
||||
});
|
||||
|
||||
|
||||
it('preserves function_call/function_call_output when /v1/responses falls back to /v1/messages', async () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
|
||||
@@ -298,6 +298,22 @@ describe('executeEndpointFlow', () => {
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses');
|
||||
});
|
||||
|
||||
it('normalizes proxyUrl with versioned base paths instead of duplicating path segments', async () => {
|
||||
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})));
|
||||
|
||||
await executeEndpointFlow({
|
||||
siteUrl: 'https://example.com',
|
||||
proxyUrl: 'https://proxy.internal/api/v1?mode=relay#frag',
|
||||
endpointCandidates: ['responses'],
|
||||
buildRequest: () => requestFor('/v1/responses'),
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/api/v1/responses?mode=relay#frag');
|
||||
});
|
||||
it('returns normalized final error when all endpoints fail', async () => {
|
||||
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||
|
||||
@@ -76,12 +76,6 @@ type ExecuteEndpointFlowInput = {
|
||||
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function buildAbsoluteUrl(base: string, path: string): string {
|
||||
const normalizedBase = base.replace(/\/+$/, '');
|
||||
const normalizedPath = path.replace(/^\/+/, '');
|
||||
return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase;
|
||||
}
|
||||
|
||||
async function runEndpointFlowHook<T>(
|
||||
hook: ((ctx: T) => void | Promise<void>) | undefined,
|
||||
ctx: T,
|
||||
@@ -113,7 +107,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
||||
const request = input.buildRequest(endpoint, endpointIndex);
|
||||
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
|
||||
const targetUrl = input.proxyUrl
|
||||
? buildAbsoluteUrl(input.proxyUrl, request.path)
|
||||
? buildUpstreamUrl(input.proxyUrl, request.path)
|
||||
: defaultTarget;
|
||||
|
||||
let response = input.dispatchRequest
|
||||
@@ -155,7 +149,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
||||
const recoveredRequest = recovered.request ?? baseContext.request;
|
||||
const recoveredTargetUrl = recovered.targetUrl ?? (
|
||||
input.proxyUrl
|
||||
? buildAbsoluteUrl(input.proxyUrl, recovered.upstreamPath)
|
||||
? buildUpstreamUrl(input.proxyUrl, recovered.upstreamPath)
|
||||
: buildUpstreamUrl(input.siteUrl, recovered.upstreamPath)
|
||||
);
|
||||
await runEndpointFlowHook(input.onAttemptSuccess, {
|
||||
|
||||
@@ -143,11 +143,27 @@ describe('/v1/images/edits route', () => {
|
||||
expect(targetUrl).toBe('https://upstream.example.com/v1/images/edits');
|
||||
});
|
||||
|
||||
it('returns an upstream error instead of fabricating an empty success when image generation JSON is malformed', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('not-json', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
it('retries the next channel when image generation JSON is malformed', async () => {
|
||||
selectNextChannelMock.mockReturnValueOnce({
|
||||
channel: { id: 12, routeId: 23 },
|
||||
site: { id: 45, name: 'fallback-site', url: 'https://fallback.example.com', platform: 'openai' },
|
||||
account: { id: 34, username: 'fallback-user' },
|
||||
tokenName: 'fallback',
|
||||
tokenValue: 'sk-fallback',
|
||||
actualModel: 'fallback-gpt-image',
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response('not-json', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
created: 2,
|
||||
data: [{ b64_json: 'ZmFsbGJhY2s=' }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -161,14 +177,13 @@ describe('/v1/images/edits route', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
error: {
|
||||
message: 'not-json',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
created: 2,
|
||||
data: [{ b64_json: 'ZmFsbGJhY2s=' }],
|
||||
});
|
||||
expect(selectNextChannelMock).not.toHaveBeenCalled();
|
||||
expect(selectNextChannelMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps returning a successful image edit response when post-success accounting fails', async () => {
|
||||
|
||||
@@ -132,6 +132,10 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: data.message,
|
||||
@@ -324,6 +328,10 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
);
|
||||
if (canRetryProxyChannel(retryCount)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: data.message,
|
||||
|
||||
@@ -525,7 +525,8 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.stream).toBe(true);
|
||||
expect(forwardedBody.instructions).toBe('');
|
||||
expect(forwardedBody.store).toBe(false);
|
||||
expect(forwardedBody.store).toBeUndefined();
|
||||
expect(forwardedBody.max_output_tokens).toBeUndefined();
|
||||
|
||||
expect(response.json()).toMatchObject({
|
||||
id: 'resp_codex_stream',
|
||||
@@ -567,11 +568,13 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
const secondBody = JSON.parse(secondOptions.body);
|
||||
|
||||
expect(firstBody.instructions).toBe('');
|
||||
expect(firstBody.store).toBe(false);
|
||||
expect(firstBody.store).toBeUndefined();
|
||||
expect(firstBody.stream).toBe(true);
|
||||
expect(firstBody.max_output_tokens).toBeUndefined();
|
||||
expect(secondBody.instructions).toBe('');
|
||||
expect(secondBody.store).toBe(false);
|
||||
expect(secondBody.store).toBeUndefined();
|
||||
expect(secondBody.stream).toBe(true);
|
||||
expect(secondBody.max_output_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not record success when a streaming responses request ends with response.failed', async () => {
|
||||
|
||||
@@ -626,16 +626,16 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.headers.Accept).toBe('text/event-stream');
|
||||
expect(request.headers.Connection).toBe('Keep-Alive');
|
||||
expect(request.body.instructions).toBe('');
|
||||
expect(request.body.prompt_cache_key).toBe(request.headers.Session_id);
|
||||
expect(request.body.stream).toBe(true);
|
||||
expect(request.body.store).toBe(false);
|
||||
expect(request.body.parallel_tool_calls).toBe(true);
|
||||
expect(request.body.include).toEqual(['reasoning.encrypted_content']);
|
||||
expect(request.body.max_output_tokens).toBeUndefined();
|
||||
expect(request.body.temperature).toBeUndefined();
|
||||
expect(request.body.top_p).toBeUndefined();
|
||||
expect(request.body.user).toBeUndefined();
|
||||
expect(request.body.service_tier).toBeUndefined();
|
||||
expect(request.body.prompt_cache_key).toBeUndefined();
|
||||
expect(request.body.stream).toBe(false);
|
||||
expect(request.body.store).toBeUndefined();
|
||||
expect(request.body.parallel_tool_calls).toBeUndefined();
|
||||
expect(request.body.include).toBeUndefined();
|
||||
expect(request.body.max_output_tokens).toBe(4096);
|
||||
expect(request.body.temperature).toBe(0.2);
|
||||
expect(request.body.top_p).toBe(0.9);
|
||||
expect(request.body.user).toBe('drop-me');
|
||||
expect(request.body.service_tier).toBe('auto');
|
||||
});
|
||||
|
||||
it('reuses a stable codex session id when the same downstream continuity key is provided', () => {
|
||||
@@ -729,6 +729,48 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.body.prompt_cache_key).toBe('codex-cache-123');
|
||||
});
|
||||
|
||||
it('preserves native codex responses continuity and request fields without compatibility rewrites', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-5.4',
|
||||
stream: false,
|
||||
tokenValue: 'oauth-access-token',
|
||||
sitePlatform: 'codex',
|
||||
siteUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
openaiBody: {},
|
||||
downstreamFormat: 'responses',
|
||||
responsesOriginalBody: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello codex',
|
||||
stream: false,
|
||||
store: true,
|
||||
parallel_tool_calls: false,
|
||||
include: ['reasoning.encrypted_content', 'mcp_approval_request.details'],
|
||||
previous_response_id: 'resp_prev_123',
|
||||
temperature: 0.3,
|
||||
top_p: 0.8,
|
||||
max_output_tokens: 512,
|
||||
},
|
||||
providerHeaders: {
|
||||
Originator: 'codex_cli_rs',
|
||||
},
|
||||
codexSessionCacheKey: 'gpt-5.4:user-456',
|
||||
} as any);
|
||||
|
||||
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.stream).toBe(false);
|
||||
expect(request.body.store).toBe(true);
|
||||
expect(request.body.parallel_tool_calls).toBe(false);
|
||||
expect(request.body.include).toEqual(['reasoning.encrypted_content', 'mcp_approval_request.details']);
|
||||
expect(request.body.previous_response_id).toBe('resp_prev_123');
|
||||
expect(request.body.temperature).toBe(0.3);
|
||||
expect(request.body.top_p).toBe(0.8);
|
||||
expect(request.body.max_output_tokens).toBe(512);
|
||||
});
|
||||
|
||||
it('applies configured codex header defaults with CLIProxyAPI-compatible precedence', () => {
|
||||
(config as any).codexHeaderDefaults = {
|
||||
userAgent: 'codex-config-ua/1.0',
|
||||
|
||||
@@ -413,12 +413,7 @@ function ensureCodexResponsesStoreFalse(
|
||||
body: Record<string, unknown>,
|
||||
sitePlatform: string,
|
||||
): Record<string, unknown> {
|
||||
if (sitePlatform !== 'codex') return body;
|
||||
if (body.store === false) return body;
|
||||
return {
|
||||
...body,
|
||||
store: false,
|
||||
};
|
||||
return body;
|
||||
}
|
||||
|
||||
function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
|
||||
@@ -437,18 +432,11 @@ function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
|
||||
function applyCodexResponsesCompatibility(
|
||||
body: Record<string, unknown>,
|
||||
sitePlatform: string,
|
||||
options?: {
|
||||
preservePreviousResponseId?: boolean;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
if (sitePlatform !== 'codex') return body;
|
||||
|
||||
const next: Record<string, unknown> = {
|
||||
...body,
|
||||
stream: true,
|
||||
store: false,
|
||||
parallel_tool_calls: true,
|
||||
include: ['reasoning.encrypted_content'],
|
||||
input: convertCodexSystemRoleToDeveloper(body.input),
|
||||
};
|
||||
|
||||
@@ -456,27 +444,6 @@ function applyCodexResponsesCompatibility(
|
||||
next.instructions = '';
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'max_output_tokens',
|
||||
'max_completion_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'truncation',
|
||||
'user',
|
||||
'context_management',
|
||||
'prompt_cache_retention',
|
||||
'safety_identifier',
|
||||
]) {
|
||||
delete next[key];
|
||||
}
|
||||
if (!options?.preservePreviousResponseId) {
|
||||
delete next.previous_response_id;
|
||||
}
|
||||
|
||||
if (asTrimmedString(next.service_tier).toLowerCase() !== 'priority') {
|
||||
delete next.service_tier;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1284,7 +1251,6 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
applyCodexResponsesCompatibility(
|
||||
sanitizedResponsesBody,
|
||||
sitePlatform,
|
||||
{ preservePreviousResponseId: preserveWebsocketIncrementalMode },
|
||||
),
|
||||
sitePlatform,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user