feat: implement Codex OAuth flow with loopback callback server and management UI

- Add localCallbackServer for handling OAuth callback requests.
- Implement service functions for managing OAuth sessions and accounts.
- Create CodexAdapter for platform-specific interactions.
- Develop OAuthManagement page for managing OAuth connections and providers.
- Add tests for localCallbackServer and OAuthManagement components.
This commit is contained in:
ciat-777
2026-03-17 11:07:25 +08:00
parent c156e0c164
commit 575a15232b
40 changed files with 3219 additions and 32 deletions
+52 -8
View File
@@ -32,6 +32,10 @@ import {
hasNonImageFileInputInOpenAiBody,
resolveOpenAiBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
buildCodexOauthProviderHeaders,
refreshCodexOauthAccessToken,
} from '../../services/oauth/service.js';
const MAX_RETRIES = 2;
@@ -130,6 +134,14 @@ async function handleChatProxyRequest(
},
),
];
const buildProviderHeaders = () => (
String(selected.site.platform || '').trim().toLowerCase() === 'codex'
? buildCodexOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
})
: {}
);
const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
options: { forceNormalizeClaudeBody?: boolean } = {},
@@ -146,6 +158,7 @@ async function handleChatProxyRequest(
claudeOriginalBody,
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
downstreamHeaders: request.headers as Record<string, unknown>,
providerHeaders: buildProviderHeaders(),
});
return {
endpoint,
@@ -174,17 +187,48 @@ async function handleChatProxyRequest(
}),
),
});
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
if (ctx.response.status === 401 && String(selected.site.platform || '').trim().toLowerCase() === 'codex') {
const refreshed = await refreshCodexOauthAccessToken(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedResponse = await fetch(
refreshedTargetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: refreshedRequest.headers,
body: JSON.stringify(refreshedRequest.body),
}),
);
if (refreshedResponse.ok) {
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
};
}
ctx.request = refreshedRequest;
ctx.response = refreshedResponse;
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
}
return endpointStrategy.tryRecover(ctx);
};
let startTime = Date.now();
try {
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
selected,
requestedModel,
@@ -0,0 +1,216 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const refreshCodexAccessTokenMock = vi.fn();
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: ({ status }: { status?: number }) => status === 401,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: async () => 0,
buildProxyBillingDetails: async () => null,
fetchModelPricingCatalog: async () => null,
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../services/oauth/service.js', async () => {
const actual = await vi.importActual<typeof import('../../services/oauth/service.js')>('../../services/oauth/service.js');
return {
...actual,
refreshCodexOauthAccessToken: (...args: unknown[]) => refreshCodexAccessTokenMock(...args),
};
});
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
}));
describe('responses proxy codex oauth refresh', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(responsesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
refreshCodexAccessTokenMock.mockReset();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
account: {
id: 33,
username: 'codex-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'plus',
},
}),
},
tokenName: 'default',
tokenValue: 'expired-access-token',
actualModel: 'gpt-5.2-codex',
});
selectNextChannelMock.mockReturnValue(null);
refreshCodexAccessTokenMock.mockResolvedValue({
accessToken: 'fresh-access-token',
accountId: 33,
accountKey: 'chatgpt-account-123',
});
});
afterAll(async () => {
await app.close();
});
it('refreshes codex oauth token and retries the same responses request on 401', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'expired token', type: 'invalid_request_error' },
}), {
status: 401,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_refreshed',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok after codex token refresh',
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',
input: 'hello codex',
},
});
expect(response.statusCode).toBe(200);
expect(refreshCodexAccessTokenMock).toHaveBeenCalledWith(33);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
expect(firstUrl).toBe('https://chatgpt.com/backend-api/codex/responses');
expect(secondUrl).toBe('https://chatgpt.com/backend-api/codex/responses');
expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token');
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
expect(secondOptions.headers.Originator || secondOptions.headers.originator).toBe('codex_cli_rs');
expect(secondOptions.headers['Chatgpt-Account-Id'] || secondOptions.headers['chatgpt-account-id']).toBe('chatgpt-account-123');
expect(response.json()?.output_text).toContain('ok after codex token refresh');
});
it('sends an explicit empty 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',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'ok without system prompt',
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',
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(forwardedBody.instructions).toBe('');
expect(forwardedBody.input).toEqual([
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello codex' }],
},
]);
});
});
+45 -1
View File
@@ -27,6 +27,10 @@ import {
hasNonImageFileInputInOpenAiBody,
resolveResponsesBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
buildCodexOauthProviderHeaders,
refreshCodexOauthAccessToken,
} from '../../services/oauth/service.js';
const MAX_RETRIES = 2;
@@ -228,6 +232,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
if (endpointCandidates.length === 0) {
endpointCandidates.push('responses', 'chat', 'messages');
}
const buildProviderHeaders = () => (
String(selected.site.platform || '').trim().toLowerCase() === 'codex'
? buildCodexOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
downstreamHeaders: request.headers as Record<string, unknown>,
})
: {}
);
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
@@ -240,6 +252,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
downstreamFormat: 'responses',
responsesOriginalBody: normalizedResponsesBody,
downstreamHeaders: request.headers as Record<string, unknown>,
providerHeaders: buildProviderHeaders(),
});
const upstreamPath = (
isCompactRequest && endpoint === 'responses'
@@ -265,6 +278,37 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}),
),
});
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
if (ctx.response.status === 401 && String(selected.site.platform || '').trim().toLowerCase() === 'codex') {
const refreshed = await refreshCodexOauthAccessToken(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedResponse = await fetch(
refreshedTargetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: refreshedRequest.headers,
body: JSON.stringify(refreshedRequest.body),
}),
);
if (refreshedResponse.ok) {
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
};
}
ctx.request = refreshedRequest;
ctx.response = refreshedResponse;
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
}
return endpointStrategy.tryRecover(ctx);
};
const startTime = Date.now();
@@ -274,7 +318,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
@@ -151,6 +151,16 @@ describe('resolveUpstreamEndpointCandidates', () => {
'claude',
);
expect(claudeOrder).toEqual(['messages']);
const codexOrder = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'codex', url: 'https://chatgpt.com/backend-api/codex' },
},
'gpt-5.2-codex',
'openai',
);
expect(codexOrder).toEqual(['responses']);
});
it('prefers document-capable endpoints when downstream content contains non-image files', async () => {
@@ -319,6 +329,31 @@ describe('buildUpstreamEndpointRequest', () => {
]);
});
it('builds codex responses requests against backend-api path and preserves oauth provider headers', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.2-codex',
stream: false,
tokenValue: 'oauth-access-token',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.2-codex',
messages: [{ role: 'user', content: 'hello codex' }],
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
'Chatgpt-Account-Id': 'chatgpt-account-123',
},
} as any);
expect(request.path).toBe('/responses');
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
expect(request.headers.Originator).toBe('codex_cli_rs');
expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123');
});
it('normalizes downstream responses input string before forwarding upstream', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
+26 -1
View File
@@ -186,6 +186,18 @@ function toFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function ensureCodexResponsesInstructions(
body: Record<string, unknown>,
sitePlatform: string,
): Record<string, unknown> {
if (sitePlatform !== 'codex') return body;
if (typeof body.instructions === 'string') return body;
return {
...body,
instructions: '',
};
}
function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
const raw = asTrimmedString(value).toLowerCase();
@@ -237,6 +249,10 @@ function preferredEndpointOrder(
): UpstreamEndpoint[] {
const platform = normalizePlatformName(sitePlatform);
if (platform === 'codex') {
return ['responses'];
}
if (platform === 'gemini') {
// Gemini upstream is routed through OpenAI-compatible chat endpoint.
return ['chat'];
@@ -430,6 +446,7 @@ export function buildUpstreamEndpointRequest(input: {
forceNormalizeClaudeBody?: boolean;
responsesOriginalBody?: Record<string, unknown>;
downstreamHeaders?: Record<string, unknown>;
providerHeaders?: Record<string, string>;
}): {
path: string;
headers: Record<string, string>;
@@ -463,6 +480,10 @@ export function buildUpstreamEndpointRequest(input: {
return '/v1/chat/completions';
}
if (sitePlatform === 'codex') {
return '/responses';
}
if (sitePlatform === 'claude') {
return '/v1/messages';
}
@@ -476,6 +497,7 @@ export function buildUpstreamEndpointRequest(input: {
const commonHeaders: Record<string, string> = {
...passthroughHeaders,
'Content-Type': 'application/json',
...(input.providerHeaders || {}),
};
if (!isClaudeUpstream) {
commonHeaders.Authorization = `Bearer ${input.tokenValue}`;
@@ -564,7 +586,10 @@ export function buildUpstreamEndpointRequest(input: {
}
: convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream)
);
const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream);
const body = ensureCodexResponsesInstructions(
sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream),
sitePlatform,
);
const headers = ensureStreamAcceptHeader({
...commonHeaders,