Merge pull request #149 from cita-777/fix/codex-responses-runtime-parity

fix: align codex responses continuity handling
This commit is contained in:
Cita
2026-03-18 13:28:28 +08:00
committed by GitHub
4 changed files with 112 additions and 32 deletions
@@ -193,9 +193,7 @@ describe('responses proxy codex oauth refresh', () => {
expect(secondOptions.headers['Chatgpt-Account-Id'] || secondOptions.headers['chatgpt-account-id']).toBe('chatgpt-account-123');
expect(secondOptions.headers.Version || secondOptions.headers.version).toBe('0.101.0');
expect(String(secondOptions.headers.Session_id || secondOptions.headers.session_id || '')).toMatch(/^[0-9a-f-]{36}$/i);
expect(secondOptions.headers.Conversation_id || secondOptions.headers.conversation_id).toBe(
secondOptions.headers.Session_id || secondOptions.headers.session_id,
);
expect(secondOptions.headers.Conversation_id || secondOptions.headers.conversation_id).toBeUndefined();
expect(secondOptions.headers['User-Agent'] || secondOptions.headers['user-agent']).toBe('codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464');
expect(secondOptions.headers.Accept || secondOptions.headers.accept).toBe('text/event-stream');
expect(secondOptions.headers.Connection || secondOptions.headers.connection).toBe('Keep-Alive');
@@ -230,9 +228,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.prompt_cache_key).toBe(
options.headers.Session_id || options.headers.session_id,
);
expect(forwardedBody.prompt_cache_key).toBeUndefined();
expect(forwardedBody.stream).toBe(true);
expect(forwardedBody.input).toEqual([
{
@@ -243,6 +239,39 @@ describe('responses proxy codex oauth refresh', () => {
]);
});
it('preserves explicit prompt_cache_key for codex responses requests', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_with_cache_key',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok with cache key',
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',
prompt_cache_key: 'codex-cache-123',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, options] = fetchMock.mock.calls[0] as [string, any];
const forwardedBody = JSON.parse(options.body);
expect(options.headers.Session_id || options.headers.session_id).toBe('codex-cache-123');
expect(options.headers.Conversation_id || options.headers.conversation_id).toBe('codex-cache-123');
expect(forwardedBody.prompt_cache_key).toBe('codex-cache-123');
});
it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
+6 -23
View File
@@ -124,24 +124,11 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
type UsageSummary = ReturnType<typeof parseProxyUsage>;
function deriveCodexSessionCacheKey(input: {
body: Record<string, unknown>;
requestedModel: string;
proxyToken: string | null;
}): string | null {
const promptCacheKey = typeof input.body.prompt_cache_key === 'string'
? input.body.prompt_cache_key.trim()
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
const promptCacheKey = typeof body.prompt_cache_key === 'string'
? body.prompt_cache_key.trim()
: '';
if (promptCacheKey) {
return `${input.requestedModel}:responses:${promptCacheKey}`;
}
const proxyToken = typeof input.proxyToken === 'string' ? input.proxyToken.trim() : '';
if (proxyToken) {
return `${input.requestedModel}:proxy:${proxyToken}`;
}
return null;
return promptCacheKey || null;
}
export async function responsesProxyRoute(app: FastifyInstance) {
@@ -271,11 +258,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
);
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
const codexSessionCacheKey = deriveCodexSessionCacheKey({
body: normalizedResponsesBody,
requestedModel,
proxyToken: getProxyAuthContext(request)?.token || null,
});
const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody);
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
@@ -290,7 +273,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
responsesOriginalBody: normalizedResponsesBody,
downstreamHeaders: request.headers as Record<string, unknown>,
providerHeaders: buildProviderHeaders(),
codexSessionCacheKey,
codexExplicitSessionId,
});
const upstreamPath = (
isCompactRequest && endpoint === 'responses'
@@ -350,6 +350,7 @@ describe('buildUpstreamEndpointRequest', () => {
Originator: 'codex_cli_rs',
'Chatgpt-Account-Id': 'chatgpt-account-123',
},
codexSessionCacheKey: 'gpt-5.2-codex:proxy:test-key',
} as any);
expect(request.path).toBe('/responses');
@@ -417,6 +418,56 @@ describe('buildUpstreamEndpointRequest', () => {
expect(firstRequest.body.prompt_cache_key).toBe(secondRequest.body.prompt_cache_key);
});
it('does not synthesize prompt_cache_key or conversation_id for native codex responses requests without one', () => {
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',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i);
expect(request.headers.Conversation_id).toBeUndefined();
expect(request.body.prompt_cache_key).toBeUndefined();
});
it('preserves explicit prompt_cache_key for native codex responses requests', () => {
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',
prompt_cache_key: 'codex-cache-123',
input: 'hello codex',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
codexExplicitSessionId: 'codex-cache-123',
} as any);
expect(request.headers.Session_id).toBe('codex-cache-123');
expect(request.headers.Conversation_id).toBe('codex-cache-123');
expect(request.body.prompt_cache_key).toBe('codex-cache-123');
});
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
+20 -3
View File
@@ -283,16 +283,26 @@ function extractClaudeBetasFromBody(body: Record<string, unknown>): {
function buildCodexRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
providerHeaders?: Record<string, string>;
explicitSessionId?: string | null;
continuityKey?: string | null;
}): Record<string, string> {
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
const explicitSessionId = asTrimmedString(input.explicitSessionId);
const continuityKey = asTrimmedString(input.continuityKey);
const sessionId = (
getInputHeader(input.baseHeaders, 'session_id')
|| getInputHeader(input.baseHeaders, 'session-id')
|| (input.continuityKey ? uuidFromSeed(`metapi:codex:${input.continuityKey}`) : null)
|| explicitSessionId
|| (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null)
|| randomUUID()
);
const conversationId = (
getInputHeader(input.baseHeaders, 'conversation_id')
|| getInputHeader(input.baseHeaders, 'conversation-id')
|| explicitSessionId
|| (continuityKey ? sessionId : null)
);
return {
...input.baseHeaders,
@@ -300,7 +310,7 @@ function buildCodexRuntimeHeaders(input: {
Originator: originator,
Version: CODEX_CLIENT_VERSION,
Session_id: sessionId,
Conversation_id: sessionId,
...(conversationId ? { Conversation_id: conversationId } : {}),
'User-Agent': CODEX_DEFAULT_USER_AGENT,
Accept: 'text/event-stream',
Connection: 'Keep-Alive',
@@ -753,6 +763,7 @@ export function buildUpstreamEndpointRequest(input: {
downstreamHeaders?: Record<string, unknown>;
providerHeaders?: Record<string, string>;
codexSessionCacheKey?: string | null;
codexExplicitSessionId?: string | null;
}): {
path: string;
headers: Record<string, string>;
@@ -985,6 +996,7 @@ export function buildUpstreamEndpointRequest(input: {
...responsesHeaders,
},
providerHeaders: input.providerHeaders,
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
})
: ensureStreamAcceptHeader({
@@ -994,7 +1006,12 @@ export function buildUpstreamEndpointRequest(input: {
const codexSessionId = sitePlatform === 'codex'
? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'))
: null;
const runtimeBody = sitePlatform === 'codex' && codexSessionId
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
&& !!codexSessionId
&& !asTrimmedString((body as Record<string, unknown>).prompt_cache_key)
&& !asTrimmedString(input.codexExplicitSessionId)
&& !!asTrimmedString(input.codexSessionCacheKey);
const runtimeBody = shouldInjectDerivedPromptCacheKey
? {
...body,
prompt_cache_key: codexSessionId,