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:
@@ -172,6 +172,62 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('marks codex oauth connection as proxy-capable but not checkin/balance-capable', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Codex Site',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'codex-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
checkinEnabled: false,
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'plus',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'gpt-5.2-codex',
|
||||
available: true,
|
||||
checkedAt: '2026-03-16T12:00:00.000Z',
|
||||
}).run();
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/accounts',
|
||||
});
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
|
||||
const list = listResponse.json() as Array<{
|
||||
id: number;
|
||||
credentialMode?: string;
|
||||
capabilities?: {
|
||||
canCheckin?: boolean;
|
||||
canRefreshBalance?: boolean;
|
||||
proxyOnly?: boolean;
|
||||
};
|
||||
}>;
|
||||
const item = list.find((entry) => entry.id === account.id);
|
||||
expect(item?.credentialMode).toBe('session');
|
||||
expect(item?.capabilities).toMatchObject({
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores managed refresh token for sub2api session account', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({
|
||||
tokenType: 'session',
|
||||
|
||||
@@ -19,6 +19,7 @@ import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
||||
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
|
||||
import { getLocalDayRangeUtc } from '../../services/localTimeService.js';
|
||||
import { isCodexPlatform } from '../../services/oauth/codexAccount.js';
|
||||
import {
|
||||
buildRuntimeHealthForAccount,
|
||||
setAccountRuntimeHealth,
|
||||
@@ -79,7 +80,15 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec
|
||||
function buildCapabilitiesFromCredentialMode(
|
||||
credentialMode: AccountCredentialMode,
|
||||
hasSessionToken: boolean,
|
||||
extraConfig?: string | null,
|
||||
): AccountCapabilities {
|
||||
if (isCodexPlatform(extraConfig)) {
|
||||
return {
|
||||
canCheckin: false,
|
||||
canRefreshBalance: false,
|
||||
proxyOnly: false,
|
||||
};
|
||||
}
|
||||
const sessionCapable = credentialMode === 'session'
|
||||
? hasSessionToken
|
||||
: (credentialMode === 'apikey' ? false : hasSessionToken);
|
||||
@@ -92,7 +101,7 @@ function buildCapabilitiesFromCredentialMode(
|
||||
|
||||
function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities {
|
||||
const credentialMode = resolveStoredCredentialMode(account);
|
||||
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken));
|
||||
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account.extraConfig);
|
||||
}
|
||||
|
||||
function normalizeBatchIds(input: unknown): number[] {
|
||||
@@ -410,6 +419,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
capabilities: buildCapabilitiesFromCredentialMode(
|
||||
credentialMode,
|
||||
hasSessionTokenValue(r.accounts.accessToken),
|
||||
r.accounts.extraConfig,
|
||||
),
|
||||
todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
|
||||
todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
|
||||
@@ -426,6 +436,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
sessionCapable: buildCapabilitiesFromCredentialMode(
|
||||
credentialMode,
|
||||
hasSessionTokenValue(r.accounts.accessToken),
|
||||
r.accounts.extraConfig,
|
||||
).canRefreshBalance,
|
||||
hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0,
|
||||
}),
|
||||
@@ -968,7 +979,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
account: latest,
|
||||
tokenType: 'session',
|
||||
credentialMode: 'session',
|
||||
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true),
|
||||
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true, null),
|
||||
apiTokenFound: !!nextApiToken,
|
||||
};
|
||||
},
|
||||
@@ -1138,7 +1149,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
|
||||
const capabilities = account
|
||||
? buildCapabilitiesForAccount(account)
|
||||
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session');
|
||||
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null);
|
||||
return {
|
||||
...account,
|
||||
tokenType,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('oauth route registration', () => {
|
||||
it('registers oauth routes in the main server entry', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/server/index.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain("import { oauthRoutes } from './routes/api/oauth.js';");
|
||||
expect(source).toContain('await app.register(oauthRoutes);');
|
||||
});
|
||||
|
||||
it('treats oauth callback as a public API route', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/server/desktop.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain("url.startsWith('/api/oauth/callback/')");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
function buildJwt(payload: Record<string, unknown>) {
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value))
|
||||
.toString('base64url');
|
||||
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
|
||||
}
|
||||
|
||||
describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./oauth.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.oauthRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.mockReset();
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.tokenModelAvailability).run();
|
||||
await db.delete(schema.modelAvailability).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('lists codex oauth provider metadata', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/oauth/providers',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
providers: [
|
||||
expect.objectContaining({
|
||||
provider: 'codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('starts a codex oauth session and exposes pending status', async () => {
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/codex/start',
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
|
||||
expect(startResponse.statusCode).toBe(200);
|
||||
const startBody = startResponse.json() as {
|
||||
provider: string;
|
||||
state: string;
|
||||
authorizationUrl: string;
|
||||
};
|
||||
expect(startBody.provider).toBe('codex');
|
||||
expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/);
|
||||
expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?');
|
||||
expect(startBody.authorizationUrl).toContain('client_id=');
|
||||
expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback'));
|
||||
expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`);
|
||||
expect(startBody.authorizationUrl).toContain('code_challenge=');
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/oauth/sessions/${startBody.state}`,
|
||||
});
|
||||
expect(sessionResponse.statusCode).toBe(200);
|
||||
expect(sessionResponse.json()).toMatchObject({
|
||||
provider: 'codex',
|
||||
state: startBody.state,
|
||||
status: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles codex callback, creates oauth-backed account, and discovers plan models', async () => {
|
||||
const jwt = buildJwt({
|
||||
email: 'codex-user@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'chatgpt-account-123',
|
||||
chatgpt_plan_type: 'plus',
|
||||
},
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'oauth-access-token',
|
||||
refresh_token: 'oauth-refresh-token',
|
||||
id_token: jwt,
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ id: 'gpt-5.2-codex' },
|
||||
{ id: 'gpt-5' },
|
||||
{ id: 'gpt-5.4' },
|
||||
],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/codex/start',
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
const startBody = startResponse.json() as { state: string };
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`,
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(200);
|
||||
expect(callbackResponse.body).toContain('window.close()');
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/oauth/sessions/${startBody.state}`,
|
||||
});
|
||||
expect(sessionResponse.statusCode).toBe(200);
|
||||
const sessionBody = sessionResponse.json() as {
|
||||
provider: string;
|
||||
status: string;
|
||||
accountId?: number;
|
||||
siteId?: number;
|
||||
};
|
||||
expect(sessionBody).toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'success',
|
||||
});
|
||||
expect(sessionBody.accountId).toBeTypeOf('number');
|
||||
expect(sessionBody.siteId).toBeTypeOf('number');
|
||||
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
expect(sites).toHaveLength(1);
|
||||
expect(sites[0]).toMatchObject({
|
||||
platform: 'codex',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]).toMatchObject({
|
||||
siteId: sites[0]?.id,
|
||||
username: 'codex-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
});
|
||||
expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'plus',
|
||||
refreshToken: 'oauth-refresh-token',
|
||||
idToken: jwt,
|
||||
},
|
||||
});
|
||||
|
||||
const models = await db.select().from(schema.modelAvailability).all();
|
||||
const modelNames = models.map((row) => row.modelName);
|
||||
expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']);
|
||||
});
|
||||
|
||||
it('marks oauth session as error and avoids creating a connection when codex model discovery fails', async () => {
|
||||
const jwt = buildJwt({
|
||||
email: 'codex-fail@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'chatgpt-account-fail',
|
||||
chatgpt_plan_type: 'team',
|
||||
},
|
||||
});
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
access_token: 'oauth-access-token',
|
||||
refresh_token: 'oauth-refresh-token',
|
||||
id_token: jwt,
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ error: 'forbidden' }),
|
||||
text: async () => 'forbidden',
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/codex/start',
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
const startBody = startResponse.json() as { state: string };
|
||||
|
||||
const callbackResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/oauth/callback/codex?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`,
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
expect(callbackResponse.statusCode).toBe(200);
|
||||
expect(callbackResponse.body).toContain('OAuth authorization failed');
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/oauth/sessions/${startBody.state}`,
|
||||
});
|
||||
expect(sessionResponse.statusCode).toBe(200);
|
||||
expect(sessionResponse.json()).toMatchObject({
|
||||
provider: 'codex',
|
||||
status: 'error',
|
||||
error: expect.stringContaining('HTTP 403'),
|
||||
});
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toEqual([]);
|
||||
|
||||
const connectionsResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/oauth/connections',
|
||||
});
|
||||
expect(connectionsResponse.statusCode).toBe(200);
|
||||
expect(connectionsResponse.json()).toEqual({ items: [] });
|
||||
});
|
||||
|
||||
it('lists oauth connection health metadata and supports deleting the connection', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ChatGPT Codex OAuth',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'codex-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'team',
|
||||
modelDiscoveryStatus: 'abnormal',
|
||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'gpt-5.2-codex',
|
||||
available: true,
|
||||
checkedAt: '2026-03-17T08:00:00.000Z',
|
||||
}).run();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5.2-codex',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).run();
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/oauth/connections',
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()).toEqual({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
accountId: account.id,
|
||||
provider: 'codex',
|
||||
email: 'codex-user@example.com',
|
||||
status: 'abnormal',
|
||||
routeChannelCount: 1,
|
||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/oauth/connections/${account.id}`,
|
||||
});
|
||||
expect(deleteResponse.statusCode).toBe(200);
|
||||
expect(deleteResponse.json()).toEqual({ success: true });
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
deleteOauthConnection,
|
||||
getOauthSessionStatus,
|
||||
handleOauthCallback,
|
||||
listOauthConnections,
|
||||
listOauthProviders,
|
||||
startOauthProviderFlow,
|
||||
startOauthRebindFlow,
|
||||
} from '../../services/oauth/service.js';
|
||||
|
||||
function resolveRequestOrigin(request: FastifyRequest): string {
|
||||
const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string'
|
||||
? request.headers['x-forwarded-proto'].split(',')[0]?.trim()
|
||||
: '';
|
||||
const protocol = forwardedProto || request.protocol || 'http';
|
||||
const forwardedHost = typeof request.headers['x-forwarded-host'] === 'string'
|
||||
? request.headers['x-forwarded-host'].split(',')[0]?.trim()
|
||||
: '';
|
||||
const host = forwardedHost
|
||||
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '')
|
||||
|| 'localhost';
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function oauthRoutes(app: FastifyInstance) {
|
||||
app.get('/api/oauth/providers', async () => ({
|
||||
providers: listOauthProviders(),
|
||||
}));
|
||||
|
||||
app.post<{ Params: { provider: string }; Body: { accountId?: number } }>(
|
||||
'/api/oauth/providers/:provider/start',
|
||||
async (request, reply) => {
|
||||
try {
|
||||
return startOauthProviderFlow({
|
||||
provider: request.params.provider,
|
||||
redirectOrigin: resolveRequestOrigin(request),
|
||||
rebindAccountId: request.body?.accountId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return reply.code(404).send({ message: error?.message || 'oauth provider not found' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { state: string } }>('/api/oauth/sessions/:state', async (request, reply) => {
|
||||
const session = getOauthSessionStatus(request.params.state);
|
||||
if (!session) {
|
||||
return reply.code(404).send({ message: 'oauth session not found' });
|
||||
}
|
||||
return session;
|
||||
});
|
||||
|
||||
app.get('/api/oauth/connections', async () => ({
|
||||
items: await listOauthConnections(),
|
||||
}));
|
||||
|
||||
app.post<{ Params: { accountId: string } }>(
|
||||
'/api/oauth/connections/:accountId/rebind',
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const accountId = Number.parseInt(request.params.accountId, 10);
|
||||
return await startOauthRebindFlow(accountId, resolveRequestOrigin(request));
|
||||
} catch (error: any) {
|
||||
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { accountId: string } }>(
|
||||
'/api/oauth/connections/:accountId',
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const accountId = Number.parseInt(request.params.accountId, 10);
|
||||
return await deleteOauthConnection(accountId);
|
||||
} catch (error: any) {
|
||||
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
|
||||
'/api/oauth/callback/:provider',
|
||||
async (request, reply) => {
|
||||
let message = 'OAuth callback received.';
|
||||
try {
|
||||
await handleOauthCallback({
|
||||
provider: request.params.provider,
|
||||
state: String(request.query.state || ''),
|
||||
code: request.query.code,
|
||||
error: request.query.error,
|
||||
});
|
||||
message = 'OAuth authorization succeeded. You can close this window.';
|
||||
} catch (error: any) {
|
||||
message = `OAuth authorization failed: ${error?.message || 'unknown error'}`;
|
||||
}
|
||||
|
||||
reply.type('text/html; charset=utf-8');
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
${message}
|
||||
</body>
|
||||
</html>`;
|
||||
});
|
||||
}
|
||||
@@ -137,4 +137,57 @@ describe('search routes', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('includes oauth direct-account model availability in model search results', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'codex site',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'codex-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(null, {
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'team',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'gpt-5.2-codex',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/search',
|
||||
payload: {
|
||||
query: 'gpt-5.2',
|
||||
limit: 20,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
name: 'gpt-5.2-codex',
|
||||
accountCount: 1,
|
||||
tokenCount: 0,
|
||||
siteCount: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { and, like, desc, eq, or } from 'drizzle-orm';
|
||||
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
|
||||
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import { getCredentialModeFromExtraConfig, supportsDirectAccountRoutingConnection } from '../../services/accountExtraConfig.js';
|
||||
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
||||
|
||||
function hasSessionTokenValue(value: string | null | undefined): boolean {
|
||||
@@ -141,6 +141,25 @@ export async function searchRoutes(app: FastifyInstance) {
|
||||
)
|
||||
.limit(perCategory * 20)
|
||||
.all();
|
||||
const directAccountModelRows = await db.select({
|
||||
modelName: schema.modelAvailability.modelName,
|
||||
accountId: schema.accounts.id,
|
||||
siteId: schema.sites.id,
|
||||
accounts: schema.accounts,
|
||||
})
|
||||
.from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(
|
||||
and(
|
||||
like(schema.modelAvailability.modelName, q),
|
||||
eq(schema.modelAvailability.available, true),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
eq(schema.sites.status, 'active'),
|
||||
),
|
||||
)
|
||||
.limit(perCategory * 20)
|
||||
.all();
|
||||
|
||||
const modelAgg = new Map<string, { tokenIds: Set<number>; accountIds: Set<number>; siteIds: Set<number> }>();
|
||||
for (const row of modelRows) {
|
||||
@@ -153,6 +172,16 @@ export async function searchRoutes(app: FastifyInstance) {
|
||||
agg.accountIds.add(row.accountId);
|
||||
agg.siteIds.add(row.siteId);
|
||||
}
|
||||
for (const row of directAccountModelRows) {
|
||||
if (!supportsDirectAccountRoutingConnection(row.accounts)) continue;
|
||||
const key = row.modelName;
|
||||
if (!modelAgg.has(key)) {
|
||||
modelAgg.set(key, { tokenIds: new Set(), accountIds: new Set(), siteIds: new Set() });
|
||||
}
|
||||
const agg = modelAgg.get(key)!;
|
||||
agg.accountIds.add(row.accountId);
|
||||
agg.siteIds.add(row.siteId);
|
||||
}
|
||||
|
||||
const models = Array.from(modelAgg.entries())
|
||||
.map(([name, agg]) => ({
|
||||
|
||||
@@ -157,6 +157,52 @@ describe('/api/models/token-candidates', () => {
|
||||
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not report oauth direct connections as missing account tokens', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'codex-site',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'codex-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'team',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'gpt-5.2-codex',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/models/token-candidates',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
|
||||
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
|
||||
};
|
||||
|
||||
expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined();
|
||||
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-b',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
parseProxyLogBillingDetails,
|
||||
withProxyLogSelectFields,
|
||||
} from '../../services/proxyLogStore.js';
|
||||
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
|
||||
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
||||
import {
|
||||
formatLocalDateTime,
|
||||
@@ -34,12 +34,6 @@ function parseBooleanFlag(raw?: string): boolean {
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return !(account.accessToken || '').trim();
|
||||
}
|
||||
|
||||
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
|
||||
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
|
||||
|
||||
@@ -1012,6 +1006,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
siteId: schema.sites.id,
|
||||
siteName: schema.sites.name,
|
||||
accessToken: schema.accounts.accessToken,
|
||||
apiToken: schema.accounts.apiToken,
|
||||
extraConfig: schema.accounts.extraConfig,
|
||||
})
|
||||
.from(schema.modelAvailability)
|
||||
@@ -1090,7 +1085,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
for (const row of availableModelRows) {
|
||||
if (isApiKeyConnection(row)) continue;
|
||||
if (!requiresManagedAccountTokens(row)) continue;
|
||||
const modelName = (row.modelName || '').trim();
|
||||
if (!modelName) continue;
|
||||
const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`;
|
||||
@@ -1107,7 +1102,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
|
||||
const accountIdsForGroupHints = new Set(
|
||||
availableModelRows
|
||||
.filter((row) => !isApiKeyConnection(row))
|
||||
.filter((row) => requiresManagedAccountTokens(row))
|
||||
.map((row) => row.accountId),
|
||||
);
|
||||
const requiredGroupsByAccountModel = new Map<string, Map<string, string>>();
|
||||
@@ -1169,7 +1164,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
for (const row of availableModelRows) {
|
||||
if (isApiKeyConnection(row)) continue;
|
||||
if (!requiresManagedAccountTokens(row)) continue;
|
||||
const modelName = (row.modelName || '').trim();
|
||||
if (!modelName) continue;
|
||||
const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`;
|
||||
|
||||
@@ -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' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user