forked from boos/BoosAPI
Merge pull request #149 from cita-777/fix/codex-responses-runtime-parity
fix: align codex responses continuity handling
This commit is contained in:
@@ -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['Chatgpt-Account-Id'] || secondOptions.headers['chatgpt-account-id']).toBe('chatgpt-account-123');
|
||||||
expect(secondOptions.headers.Version || secondOptions.headers.version).toBe('0.101.0');
|
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(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(
|
expect(secondOptions.headers.Conversation_id || secondOptions.headers.conversation_id).toBeUndefined();
|
||||||
secondOptions.headers.Session_id || secondOptions.headers.session_id,
|
|
||||||
);
|
|
||||||
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['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.Accept || secondOptions.headers.accept).toBe('text/event-stream');
|
||||||
expect(secondOptions.headers.Connection || secondOptions.headers.connection).toBe('Keep-Alive');
|
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 [, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
const forwardedBody = JSON.parse(options.body);
|
const forwardedBody = JSON.parse(options.body);
|
||||||
expect(forwardedBody.instructions).toBe('');
|
expect(forwardedBody.instructions).toBe('');
|
||||||
expect(forwardedBody.prompt_cache_key).toBe(
|
expect(forwardedBody.prompt_cache_key).toBeUndefined();
|
||||||
options.headers.Session_id || options.headers.session_id,
|
|
||||||
);
|
|
||||||
expect(forwardedBody.stream).toBe(true);
|
expect(forwardedBody.stream).toBe(true);
|
||||||
expect(forwardedBody.input).toEqual([
|
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 () => {
|
it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
|
|||||||
@@ -124,24 +124,11 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
|
|||||||
|
|
||||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||||
|
|
||||||
function deriveCodexSessionCacheKey(input: {
|
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
|
||||||
body: Record<string, unknown>;
|
const promptCacheKey = typeof body.prompt_cache_key === 'string'
|
||||||
requestedModel: string;
|
? body.prompt_cache_key.trim()
|
||||||
proxyToken: string | null;
|
|
||||||
}): string | null {
|
|
||||||
const promptCacheKey = typeof input.body.prompt_cache_key === 'string'
|
|
||||||
? input.body.prompt_cache_key.trim()
|
|
||||||
: '';
|
: '';
|
||||||
if (promptCacheKey) {
|
return promptCacheKey || null;
|
||||||
return `${input.requestedModel}:responses:${promptCacheKey}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyToken = typeof input.proxyToken === 'string' ? input.proxyToken.trim() : '';
|
|
||||||
if (proxyToken) {
|
|
||||||
return `${input.requestedModel}:proxy:${proxyToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function responsesProxyRoute(app: FastifyInstance) {
|
export async function responsesProxyRoute(app: FastifyInstance) {
|
||||||
@@ -271,11 +258,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||||
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||||
const codexSessionCacheKey = deriveCodexSessionCacheKey({
|
const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody);
|
||||||
body: normalizedResponsesBody,
|
|
||||||
requestedModel,
|
|
||||||
proxyToken: getProxyAuthContext(request)?.token || null,
|
|
||||||
});
|
|
||||||
const endpointRequest = buildUpstreamEndpointRequest({
|
const endpointRequest = buildUpstreamEndpointRequest({
|
||||||
endpoint,
|
endpoint,
|
||||||
modelName,
|
modelName,
|
||||||
@@ -290,7 +273,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
responsesOriginalBody: normalizedResponsesBody,
|
responsesOriginalBody: normalizedResponsesBody,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
providerHeaders: buildProviderHeaders(),
|
providerHeaders: buildProviderHeaders(),
|
||||||
codexSessionCacheKey,
|
codexExplicitSessionId,
|
||||||
});
|
});
|
||||||
const upstreamPath = (
|
const upstreamPath = (
|
||||||
isCompactRequest && endpoint === 'responses'
|
isCompactRequest && endpoint === 'responses'
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
Originator: 'codex_cli_rs',
|
Originator: 'codex_cli_rs',
|
||||||
'Chatgpt-Account-Id': 'chatgpt-account-123',
|
'Chatgpt-Account-Id': 'chatgpt-account-123',
|
||||||
},
|
},
|
||||||
|
codexSessionCacheKey: 'gpt-5.2-codex:proxy:test-key',
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
expect(request.path).toBe('/responses');
|
expect(request.path).toBe('/responses');
|
||||||
@@ -417,6 +418,56 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
expect(firstRequest.body.prompt_cache_key).toBe(secondRequest.body.prompt_cache_key);
|
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', () => {
|
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
|
||||||
const request = buildUpstreamEndpointRequest({
|
const request = buildUpstreamEndpointRequest({
|
||||||
endpoint: 'responses',
|
endpoint: 'responses',
|
||||||
|
|||||||
@@ -283,16 +283,26 @@ function extractClaudeBetasFromBody(body: Record<string, unknown>): {
|
|||||||
function buildCodexRuntimeHeaders(input: {
|
function buildCodexRuntimeHeaders(input: {
|
||||||
baseHeaders: Record<string, string>;
|
baseHeaders: Record<string, string>;
|
||||||
providerHeaders?: Record<string, string>;
|
providerHeaders?: Record<string, string>;
|
||||||
|
explicitSessionId?: string | null;
|
||||||
continuityKey?: string | null;
|
continuityKey?: string | null;
|
||||||
}): Record<string, string> {
|
}): Record<string, string> {
|
||||||
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
|
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
|
||||||
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
|
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
|
||||||
|
const explicitSessionId = asTrimmedString(input.explicitSessionId);
|
||||||
|
const continuityKey = asTrimmedString(input.continuityKey);
|
||||||
const sessionId = (
|
const sessionId = (
|
||||||
getInputHeader(input.baseHeaders, 'session_id')
|
getInputHeader(input.baseHeaders, 'session_id')
|
||||||
|| 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()
|
|| randomUUID()
|
||||||
);
|
);
|
||||||
|
const conversationId = (
|
||||||
|
getInputHeader(input.baseHeaders, 'conversation_id')
|
||||||
|
|| getInputHeader(input.baseHeaders, 'conversation-id')
|
||||||
|
|| explicitSessionId
|
||||||
|
|| (continuityKey ? sessionId : null)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...input.baseHeaders,
|
...input.baseHeaders,
|
||||||
@@ -300,7 +310,7 @@ function buildCodexRuntimeHeaders(input: {
|
|||||||
Originator: originator,
|
Originator: originator,
|
||||||
Version: CODEX_CLIENT_VERSION,
|
Version: CODEX_CLIENT_VERSION,
|
||||||
Session_id: sessionId,
|
Session_id: sessionId,
|
||||||
Conversation_id: sessionId,
|
...(conversationId ? { Conversation_id: conversationId } : {}),
|
||||||
'User-Agent': CODEX_DEFAULT_USER_AGENT,
|
'User-Agent': CODEX_DEFAULT_USER_AGENT,
|
||||||
Accept: 'text/event-stream',
|
Accept: 'text/event-stream',
|
||||||
Connection: 'Keep-Alive',
|
Connection: 'Keep-Alive',
|
||||||
@@ -753,6 +763,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
downstreamHeaders?: Record<string, unknown>;
|
downstreamHeaders?: Record<string, unknown>;
|
||||||
providerHeaders?: Record<string, string>;
|
providerHeaders?: Record<string, string>;
|
||||||
codexSessionCacheKey?: string | null;
|
codexSessionCacheKey?: string | null;
|
||||||
|
codexExplicitSessionId?: string | null;
|
||||||
}): {
|
}): {
|
||||||
path: string;
|
path: string;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -985,6 +996,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
...responsesHeaders,
|
...responsesHeaders,
|
||||||
},
|
},
|
||||||
providerHeaders: input.providerHeaders,
|
providerHeaders: input.providerHeaders,
|
||||||
|
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
||||||
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
|
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
|
||||||
})
|
})
|
||||||
: ensureStreamAcceptHeader({
|
: ensureStreamAcceptHeader({
|
||||||
@@ -994,7 +1006,12 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
const codexSessionId = sitePlatform === 'codex'
|
const codexSessionId = sitePlatform === 'codex'
|
||||||
? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'))
|
? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'))
|
||||||
: null;
|
: 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,
|
...body,
|
||||||
prompt_cache_key: codexSessionId,
|
prompt_cache_key: codexSessionId,
|
||||||
|
|||||||
Reference in New Issue
Block a user