forked from boos/BoosAPI
Merge pull request #129 from cita-777/feature/oauth-codex-management
feat: implement Codex OAuth flow with loopback callback server and ma…
This commit is contained in:
@@ -3,7 +3,7 @@ import type { FastifyInstance } from 'fastify';
|
||||
const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
|
||||
|
||||
export function isPublicApiRoute(url: string): boolean {
|
||||
return url === DESKTOP_HEALTH_ROUTE;
|
||||
return url === DESKTOP_HEALTH_ROUTE || url.startsWith('/api/oauth/callback/');
|
||||
}
|
||||
|
||||
export async function registerDesktopRoutes(app: FastifyInstance) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { taskRoutes } from './routes/api/tasks.js';
|
||||
import { testRoutes } from './routes/api/test.js';
|
||||
import { monitorRoutes } from './routes/api/monitor.js';
|
||||
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||
import { oauthRoutes } from './routes/api/oauth.js';
|
||||
import { proxyRoutes } from './routes/proxy/router.js';
|
||||
import { startScheduler } from './services/checkinScheduler.js';
|
||||
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||
@@ -24,6 +25,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||
import { startCodexLoopbackCallbackServer, stopCodexLoopbackCallbackServer } from './services/oauth/localCallbackServer.js';
|
||||
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
import { existsSync } from 'fs';
|
||||
@@ -312,6 +314,7 @@ await app.register(taskRoutes);
|
||||
await app.register(testRoutes);
|
||||
await app.register(monitorRoutes);
|
||||
await app.register(downstreamApiKeysRoutes);
|
||||
await app.register(oauthRoutes);
|
||||
|
||||
// Register OpenAI-compatible proxy routes
|
||||
await app.register(proxyRoutes);
|
||||
@@ -345,9 +348,15 @@ if (existsSync(webDir)) {
|
||||
|
||||
// Start scheduler
|
||||
await startScheduler();
|
||||
try {
|
||||
await startCodexLoopbackCallbackServer();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to start Codex OAuth callback listener: ${(error as Error)?.message || 'unknown error'}`);
|
||||
}
|
||||
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
||||
app.addHook('onClose', async () => {
|
||||
stopProxyLogRetentionService();
|
||||
await stopCodexLoopbackCallbackServer();
|
||||
});
|
||||
|
||||
// Start server
|
||||
|
||||
@@ -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,6 +187,37 @@ 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 {
|
||||
@@ -182,7 +226,7 @@ async function handleChatProxyRequest(
|
||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||
tryRecover: endpointStrategy.tryRecover,
|
||||
tryRecover,
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
onDowngrade: (ctx) => {
|
||||
logProxy(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,10 @@ const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
|
||||
type AccountExtraConfig = {
|
||||
platformUserId?: unknown;
|
||||
credentialMode?: unknown;
|
||||
oauth?: {
|
||||
provider?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
autoRelogin?: AutoReloginConfig;
|
||||
sub2apiAuth?: Sub2ApiAuthConfig;
|
||||
[key: string]: unknown;
|
||||
@@ -77,6 +81,43 @@ export function getCredentialModeFromExtraConfig(extraConfig?: string | null): A
|
||||
return normalizeCredentialMode(parsed.credentialMode);
|
||||
}
|
||||
|
||||
export function getOauthProviderFromExtraConfig(extraConfig?: string | null): string | undefined {
|
||||
const parsed = parseExtraConfig(extraConfig);
|
||||
return normalizeNonEmptyString(parsed.oauth?.provider);
|
||||
}
|
||||
|
||||
export function hasOauthProvider(extraConfig?: string | null): boolean {
|
||||
return !!getOauthProviderFromExtraConfig(extraConfig);
|
||||
}
|
||||
|
||||
type DirectAccountRoutingInput = {
|
||||
accessToken?: string | null;
|
||||
apiToken?: string | null;
|
||||
extraConfig?: string | null;
|
||||
};
|
||||
|
||||
function hasCredentialValue(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean {
|
||||
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (credentialMode === 'apikey') {
|
||||
return hasCredentialValue(account.apiToken);
|
||||
}
|
||||
if (hasOauthProvider(account.extraConfig)) {
|
||||
return hasCredentialValue(account.accessToken);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean {
|
||||
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (credentialMode === 'apikey') return false;
|
||||
if (hasOauthProvider(account.extraConfig)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export type ManagedSub2ApiAuth = {
|
||||
refreshToken: string;
|
||||
tokenExpiresAt?: number;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
|
||||
const getApiTokenMock = vi.fn();
|
||||
const getModelsMock = vi.fn();
|
||||
const undiciFetchMock = vi.fn();
|
||||
|
||||
vi.mock('./platforms/index.js', () => ({
|
||||
getAdapter: () => ({
|
||||
@@ -14,6 +15,10 @@ vi.mock('./platforms/index.js', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => undiciFetchMock(...args),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type ModelServiceModule = typeof import('./modelService.js');
|
||||
|
||||
@@ -39,6 +44,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
beforeEach(async () => {
|
||||
getApiTokenMock.mockReset();
|
||||
getModelsMock.mockReset();
|
||||
undiciFetchMock.mockReset();
|
||||
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
@@ -300,4 +306,179 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
expect(placeholderModels).toEqual([]);
|
||||
expect(getModelsMock).not.toHaveBeenCalledWith(site.url, 'sk-mask***tail', account.username);
|
||||
});
|
||||
|
||||
it('discovers codex models from upstream cloud endpoint without adapter model fetch', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels'));
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ id: 'gpt-5.4' },
|
||||
{ id: 'gpt-5.3-codex' },
|
||||
{ id: 'gpt-5.2-codex' },
|
||||
{ id: 'gpt-5.2' },
|
||||
{ id: 'gpt-5.1-codex-max' },
|
||||
{ id: 'gpt-5.1-codex' },
|
||||
{ id: 'gpt-5.1' },
|
||||
{ id: 'gpt-5-codex' },
|
||||
{ id: 'gpt-5' },
|
||||
{ id: 'gpt-5.1-codex-mini' },
|
||||
{ id: 'gpt-5-codex-mini' },
|
||||
],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
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: 'plus',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
errorCode: null,
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: true,
|
||||
modelCount: 11,
|
||||
});
|
||||
expect(result.modelsPreview).toEqual([
|
||||
'gpt-5.4',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.2',
|
||||
'gpt-5.1-codex-max',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.1',
|
||||
'gpt-5-codex',
|
||||
'gpt-5',
|
||||
'gpt-5.1-codex-mini',
|
||||
]);
|
||||
expect(getModelsMock).not.toHaveBeenCalled();
|
||||
expect(undiciFetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://chatgpt.com/backend-api/codex/models?client_version=1.0.0');
|
||||
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
'Chatgpt-Account-Id': 'chatgpt-account-123',
|
||||
Originator: 'codex_cli_rs',
|
||||
}),
|
||||
});
|
||||
|
||||
const rows = await db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, account.id))
|
||||
.all();
|
||||
const modelNames = rows.map((row) => row.modelName);
|
||||
expect(modelNames.sort()).toEqual([
|
||||
'gpt-5',
|
||||
'gpt-5-codex',
|
||||
'gpt-5-codex-mini',
|
||||
'gpt-5.1',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.1-codex-max',
|
||||
'gpt-5.1-codex-mini',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks codex oauth account abnormal when upstream cloud discovery fails', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels'));
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ error: 'forbidden' }),
|
||||
text: async () => 'forbidden',
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'codex-team-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: 'team-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-team',
|
||||
email: 'team-user@example.com',
|
||||
planType: 'team',
|
||||
},
|
||||
}),
|
||||
}).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 result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'failed',
|
||||
errorCode: 'unauthorized',
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: false,
|
||||
modelCount: 0,
|
||||
});
|
||||
expect(getModelsMock).not.toHaveBeenCalled();
|
||||
expect(undiciFetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const rows = await db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, account.id))
|
||||
.all();
|
||||
expect(rows).toEqual([]);
|
||||
|
||||
const latest = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.get();
|
||||
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
||||
expect(parsed.oauth).toMatchObject({
|
||||
provider: 'codex',
|
||||
modelDiscoveryStatus: 'abnormal',
|
||||
});
|
||||
expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403');
|
||||
expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,59 @@ describe('rebuildTokenRoutesFromAvailability', () => {
|
||||
expect(channels[0]?.manualOverride).toBe(false);
|
||||
});
|
||||
|
||||
it('creates an exact route with an account-direct channel for oauth model availability', 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',
|
||||
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,
|
||||
latencyMs: 320,
|
||||
checkedAt: '2026-03-17T00:00:00.000Z',
|
||||
}).run();
|
||||
|
||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||
|
||||
expect(rebuild.models).toBe(1);
|
||||
|
||||
const route = await db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-5.2-codex'))
|
||||
.get();
|
||||
expect(route).toBeDefined();
|
||||
|
||||
const channels = await db.select().from(schema.routeChannels)
|
||||
.where(and(
|
||||
eq(schema.routeChannels.routeId, route!.id),
|
||||
eq(schema.routeChannels.accountId, account.id),
|
||||
))
|
||||
.all();
|
||||
|
||||
expect(channels).toHaveLength(1);
|
||||
expect(channels[0]?.tokenId ?? null).toBeNull();
|
||||
expect(channels[0]?.manualOverride).toBe(false);
|
||||
});
|
||||
|
||||
it('removes stale exact routes and keeps wildcard routes on rebuild', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-1',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { fetch } from 'undici';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import {
|
||||
@@ -8,14 +9,22 @@ import {
|
||||
isMaskedTokenValue,
|
||||
isUsableAccountToken,
|
||||
} from './accountTokenService.js';
|
||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import {
|
||||
getCredentialModeFromExtraConfig,
|
||||
mergeAccountExtraConfig,
|
||||
resolvePlatformUserId,
|
||||
supportsDirectAccountRoutingConnection,
|
||||
} from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
||||
import { withSiteRecordProxyRequestInit } from './siteProxy.js';
|
||||
import { buildCodexOauthInfo, getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
|
||||
|
||||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||
const CODEX_MODELS_CLIENT_VERSION = '1.0.0';
|
||||
|
||||
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
||||
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
|
||||
@@ -120,6 +129,86 @@ function normalizeModels(models: string[]): string[] {
|
||||
return normalizedModels;
|
||||
}
|
||||
|
||||
function buildCodexModelsEndpoint(baseUrl: string): string {
|
||||
const normalized = (baseUrl || '').replace(/\/+$/, '');
|
||||
return `${normalized}/models?client_version=${encodeURIComponent(CODEX_MODELS_CLIENT_VERSION)}`;
|
||||
}
|
||||
|
||||
function extractCodexModelIds(payload: unknown): string[] {
|
||||
const collection = (() => {
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const record = payload as Record<string, unknown>;
|
||||
if (Array.isArray(record.models)) return record.models;
|
||||
if (Array.isArray(record.data)) return record.data;
|
||||
if (Array.isArray(record.items)) return record.items;
|
||||
return [];
|
||||
})();
|
||||
|
||||
return collection.flatMap((item) => {
|
||||
if (typeof item === 'string') return [item];
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
|
||||
const record = item as Record<string, unknown>;
|
||||
const id = typeof record.id === 'string'
|
||||
? record.id
|
||||
: (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : ''));
|
||||
return id ? [id] : [];
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCodexOauthModelDiscoveryState(input: {
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
checkedAt: string;
|
||||
status: 'healthy' | 'abnormal';
|
||||
lastModelSyncError?: string;
|
||||
lastDiscoveredModels?: string[];
|
||||
}) {
|
||||
if (!isCodexPlatform(input.account)) return input.account.extraConfig || null;
|
||||
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
|
||||
oauth: buildCodexOauthInfo(input.account.extraConfig, {
|
||||
modelDiscoveryStatus: input.status,
|
||||
lastModelSyncAt: input.checkedAt,
|
||||
lastModelSyncError: input.lastModelSyncError,
|
||||
lastDiscoveredModels: input.lastDiscoveredModels ?? [],
|
||||
}),
|
||||
});
|
||||
await db.update(schema.accounts).set({
|
||||
extraConfig,
|
||||
updatedAt: input.checkedAt,
|
||||
}).where(eq(schema.accounts.id, input.account.id)).run();
|
||||
input.account.extraConfig = extraConfig;
|
||||
return extraConfig;
|
||||
}
|
||||
|
||||
async function discoverCodexModelsFromCloud(input: {
|
||||
site: typeof schema.sites.$inferSelect;
|
||||
account: typeof schema.accounts.$inferSelect;
|
||||
}): Promise<string[]> {
|
||||
const accessToken = (input.account.accessToken || '').trim();
|
||||
if (!accessToken) {
|
||||
throw new Error('codex oauth access token missing');
|
||||
}
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(input.account.extraConfig);
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
Originator: 'codex_cli_rs',
|
||||
};
|
||||
if (oauth?.accountId) {
|
||||
headers['Chatgpt-Account-Id'] = oauth.accountId;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildCodexModelsEndpoint(input.site.url),
|
||||
withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`);
|
||||
}
|
||||
return normalizeModels(extractCodexModelIds(await response.json()));
|
||||
}
|
||||
|
||||
function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
if (!normalized) return false;
|
||||
@@ -252,6 +341,76 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
||||
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
||||
}
|
||||
|
||||
if (isCodexPlatform(account)) {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const codexModels = await withTimeout(
|
||||
() => discoverCodexModelsFromCloud({ site, account }),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`codex model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
);
|
||||
if (codexModels.length === 0) {
|
||||
throw new Error('未获取到可用模型');
|
||||
}
|
||||
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
codexModels.map((modelName) => ({
|
||||
accountId,
|
||||
modelName,
|
||||
available: true,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
checkedAt,
|
||||
})),
|
||||
).run();
|
||||
await updateCodexOauthModelDiscoveryState({
|
||||
account,
|
||||
checkedAt,
|
||||
status: 'healthy',
|
||||
lastDiscoveredModels: codexModels,
|
||||
});
|
||||
await setAccountRuntimeHealth(accountId, {
|
||||
state: 'healthy',
|
||||
reason: 'Codex 云端模型探测成功',
|
||||
source: 'model-discovery',
|
||||
checkedAt,
|
||||
});
|
||||
return buildSuccessfulRefreshResult({
|
||||
accountId,
|
||||
modelCount: codexModels.length,
|
||||
modelsPreview: codexModels.slice(0, 10),
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: true,
|
||||
discoveredApiToken: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed';
|
||||
const errorCode = classifyModelDiscoveryError(rawMessage);
|
||||
const errorMessage = `Codex 模型获取失败(${rawMessage})`;
|
||||
await updateCodexOauthModelDiscoveryState({
|
||||
account,
|
||||
checkedAt,
|
||||
status: 'abnormal',
|
||||
lastModelSyncError: errorMessage,
|
||||
lastDiscoveredModels: [],
|
||||
});
|
||||
await setAccountRuntimeHealth(account.id, {
|
||||
state: 'unhealthy',
|
||||
reason: errorMessage,
|
||||
source: 'model-discovery',
|
||||
checkedAt,
|
||||
});
|
||||
return buildFailedRefreshResult({
|
||||
accountId,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: false,
|
||||
discoveredApiToken: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
let discoveredApiToken: string | null = null;
|
||||
|
||||
@@ -513,8 +672,7 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
}
|
||||
|
||||
for (const row of accountRows) {
|
||||
if (!isApiKeyConnection(row.accounts)) continue;
|
||||
if (!(row.accounts.apiToken || '').trim()) continue;
|
||||
if (!supportsDirectAccountRoutingConnection(row.accounts)) continue;
|
||||
addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { schema } from '../../db/index.js';
|
||||
|
||||
type ParsedOauthInfo = {
|
||||
provider?: unknown;
|
||||
accountId?: unknown;
|
||||
email?: unknown;
|
||||
planType?: unknown;
|
||||
tokenExpiresAt?: unknown;
|
||||
refreshToken?: unknown;
|
||||
idToken?: unknown;
|
||||
modelDiscoveryStatus?: unknown;
|
||||
lastModelSyncAt?: unknown;
|
||||
lastModelSyncError?: unknown;
|
||||
lastDiscoveredModels?: unknown;
|
||||
};
|
||||
|
||||
type ParsedExtraConfig = {
|
||||
oauth?: ParsedOauthInfo;
|
||||
};
|
||||
|
||||
export type CodexOauthInfo = {
|
||||
provider: 'codex';
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
planType?: string;
|
||||
tokenExpiresAt?: number;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
modelDiscoveryStatus?: 'healthy' | 'abnormal';
|
||||
lastModelSyncAt?: string;
|
||||
lastModelSyncError?: string;
|
||||
lastDiscoveredModels?: string[];
|
||||
};
|
||||
|
||||
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
|
||||
if (!extraConfig) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(extraConfig) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
return parsed as ParsedExtraConfig;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function asPositiveInteger(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asIsoDateTime(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const normalized = value
|
||||
.map((item) => asTrimmedString(item))
|
||||
.filter((item): item is string => !!item);
|
||||
return normalized.length > 0 ? normalized : [];
|
||||
}
|
||||
|
||||
function asModelDiscoveryStatus(value: unknown): 'healthy' | 'abnormal' | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'healthy') return 'healthy';
|
||||
if (normalized === 'abnormal') return 'abnormal';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCodexOauthInfoFromExtraConfig(extraConfig?: string | null): CodexOauthInfo | null {
|
||||
const parsed = parseExtraConfig(extraConfig).oauth;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const provider = asTrimmedString(parsed.provider);
|
||||
if (provider !== 'codex') return null;
|
||||
return {
|
||||
provider: 'codex',
|
||||
accountId: asTrimmedString(parsed.accountId),
|
||||
email: asTrimmedString(parsed.email),
|
||||
planType: asTrimmedString(parsed.planType),
|
||||
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
|
||||
refreshToken: asTrimmedString(parsed.refreshToken),
|
||||
idToken: asTrimmedString(parsed.idToken),
|
||||
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
|
||||
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
|
||||
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
|
||||
lastDiscoveredModels: asStringArray(parsed.lastDiscoveredModels),
|
||||
};
|
||||
}
|
||||
|
||||
export function isCodexPlatform(account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined): boolean {
|
||||
const extraConfig = typeof account === 'string' || account == null
|
||||
? account
|
||||
: account.extraConfig;
|
||||
return getCodexOauthInfoFromExtraConfig(extraConfig)?.provider === 'codex';
|
||||
}
|
||||
|
||||
export function buildCodexOauthInfo(
|
||||
extraConfig?: string | null,
|
||||
patch: Partial<CodexOauthInfo> = {},
|
||||
): CodexOauthInfo {
|
||||
const current = getCodexOauthInfoFromExtraConfig(extraConfig);
|
||||
return {
|
||||
provider: 'codex',
|
||||
...(current || {}),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { fetch } from 'undici';
|
||||
import { createPkceChallenge } from './sessionStore.js';
|
||||
|
||||
export const CODEX_OAUTH_PROVIDER = 'codex';
|
||||
export const CODEX_AUTH_URL = 'https://auth.openai.com/oauth/authorize';
|
||||
export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||
export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
export const CODEX_CALLBACK_PATH = '/api/oauth/callback/codex';
|
||||
export const CODEX_LOOPBACK_CALLBACK_PATH = '/auth/callback';
|
||||
export const CODEX_LOOPBACK_CALLBACK_PORT = 1455;
|
||||
export const CODEX_LOOPBACK_REDIRECT_URI = `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}${CODEX_LOOPBACK_CALLBACK_PATH}`;
|
||||
export const CODEX_UPSTREAM_BASE_URL = 'https://chatgpt.com/backend-api/codex';
|
||||
|
||||
type CodexJwtClaims = {
|
||||
email?: unknown;
|
||||
'https://api.openai.com/auth'?: {
|
||||
chatgpt_account_id?: unknown;
|
||||
chatgpt_plan_type?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type CodexTokenExchangeResult = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
idToken: string;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
planType?: string;
|
||||
tokenExpiresAt: number;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function parseJwtClaims(token: string): CodexJwtClaims | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown;
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
||||
return payload as CodexJwtClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexAuthorizationUrl(input: {
|
||||
state: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
}): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
response_type: 'code',
|
||||
redirect_uri: input.redirectUri,
|
||||
scope: 'openid email profile offline_access',
|
||||
state: input.state,
|
||||
code_challenge: createPkceChallenge(input.codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
prompt: 'login',
|
||||
id_token_add_organizations: 'true',
|
||||
codex_cli_simplified_flow: 'true',
|
||||
});
|
||||
return `${CODEX_AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('codex token exchange returned invalid payload');
|
||||
}
|
||||
const body = payload as Record<string, unknown>;
|
||||
const accessToken = asTrimmedString(body.access_token);
|
||||
const refreshToken = asTrimmedString(body.refresh_token);
|
||||
const idToken = asTrimmedString(body.id_token);
|
||||
const expiresIn = typeof body.expires_in === 'number' && Number.isFinite(body.expires_in)
|
||||
? Math.trunc(body.expires_in)
|
||||
: (typeof body.expires_in === 'string' ? Number.parseInt(body.expires_in.trim(), 10) : NaN);
|
||||
if (!accessToken || !refreshToken || !idToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
||||
throw new Error('codex token exchange response missing required fields');
|
||||
}
|
||||
const claims = parseJwtClaims(idToken);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
accountId: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_account_id),
|
||||
email: asTrimmedString(claims?.email),
|
||||
planType: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_plan_type),
|
||||
tokenExpiresAt: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeCodexToken(form: URLSearchParams): Promise<CodexTokenExchangeResult> {
|
||||
const response = await fetch(CODEX_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: form.toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `codex token exchange failed with status ${response.status}`);
|
||||
}
|
||||
return parseTokenResponsePayload(await response.json());
|
||||
}
|
||||
|
||||
export async function exchangeCodexAuthorizationCode(input: {
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}): Promise<CodexTokenExchangeResult> {
|
||||
return exchangeCodexToken(new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
code_verifier: input.codeVerifier,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function refreshCodexTokens(refreshToken: string): Promise<CodexTokenExchangeResult> {
|
||||
return exchangeCodexToken(new URLSearchParams({
|
||||
client_id: CODEX_CLIENT_ID,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
scope: 'openid profile email',
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
startCodexLoopbackCallbackServer,
|
||||
stopCodexLoopbackCallbackServer,
|
||||
} from './localCallbackServer.js';
|
||||
|
||||
describe('codex loopback callback server', () => {
|
||||
afterEach(async () => {
|
||||
await stopCodexLoopbackCallbackServer();
|
||||
});
|
||||
|
||||
it('accepts oauth callback requests and closes the popup on success', async () => {
|
||||
const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 }));
|
||||
const started = await startCodexLoopbackCallbackServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
callbackHandler,
|
||||
});
|
||||
|
||||
const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`);
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(callbackHandler).toHaveBeenCalledWith({
|
||||
provider: 'codex',
|
||||
state: 'test-state',
|
||||
code: 'test-code',
|
||||
error: undefined,
|
||||
});
|
||||
expect(body).toContain('window.close()');
|
||||
});
|
||||
|
||||
it('renders an error page when oauth completion fails', async () => {
|
||||
const callbackHandler = vi.fn(async () => {
|
||||
throw new Error('oauth failed');
|
||||
});
|
||||
const started = await startCodexLoopbackCallbackServer({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
callbackHandler,
|
||||
});
|
||||
|
||||
const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`);
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(body).toContain('oauth failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { type AddressInfo } from 'node:net';
|
||||
import {
|
||||
CODEX_LOOPBACK_CALLBACK_PATH,
|
||||
CODEX_LOOPBACK_CALLBACK_PORT,
|
||||
CODEX_OAUTH_PROVIDER,
|
||||
} from './codexProvider.js';
|
||||
import { handleOauthCallback } from './service.js';
|
||||
|
||||
type CallbackHandler = typeof handleOauthCallback;
|
||||
|
||||
type StartCodexLoopbackCallbackServerOptions = {
|
||||
port?: number;
|
||||
host?: string;
|
||||
callbackHandler?: CallbackHandler;
|
||||
};
|
||||
|
||||
export type CodexLoopbackCallbackServerState = {
|
||||
attempted: boolean;
|
||||
ready: boolean;
|
||||
host?: string;
|
||||
port: number;
|
||||
origin: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: CodexLoopbackCallbackServerState = {
|
||||
attempted: false,
|
||||
ready: false,
|
||||
port: CODEX_LOOPBACK_CALLBACK_PORT,
|
||||
origin: `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}`,
|
||||
};
|
||||
|
||||
let callbackServer: Server | null = null;
|
||||
let callbackServerState: CodexLoopbackCallbackServerState = { ...DEFAULT_STATE };
|
||||
let startPromise: Promise<CodexLoopbackCallbackServerState> | null = null;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderCompletionPage(message: string): string {
|
||||
const safeMessage = escapeHtml(message);
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Codex OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>window.close();</script>
|
||||
${safeMessage}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function respondHtml(response: ServerResponse, statusCode: number, message: string) {
|
||||
response.writeHead(statusCode, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
response.end(renderCompletionPage(message));
|
||||
}
|
||||
|
||||
async function handleCallbackRequest(
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
callbackHandler: CallbackHandler,
|
||||
) {
|
||||
if (request.method !== 'GET') {
|
||||
response.writeHead(405, { Allow: 'GET' });
|
||||
response.end('Method not allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(request.url || '/', 'http://localhost');
|
||||
if (requestUrl.pathname !== CODEX_LOOPBACK_CALLBACK_PATH) {
|
||||
response.writeHead(404);
|
||||
response.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await callbackHandler({
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
state: requestUrl.searchParams.get('state') || '',
|
||||
code: requestUrl.searchParams.get('code') || undefined,
|
||||
error: requestUrl.searchParams.get('error') || undefined,
|
||||
});
|
||||
respondHtml(response, 200, 'OAuth authorization succeeded. You can close this window.');
|
||||
} catch (error: any) {
|
||||
respondHtml(response, 500, `OAuth authorization failed: ${error?.message || 'unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOrigin(host: string | undefined, port: number): string {
|
||||
if (!host || host === '::' || host === '0.0.0.0') {
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
if (host.includes(':') && !host.startsWith('[')) {
|
||||
return `http://[${host}]:${port}`;
|
||||
}
|
||||
return `http://${host}:${port}`;
|
||||
}
|
||||
|
||||
export function getCodexLoopbackCallbackServerState(): CodexLoopbackCallbackServerState {
|
||||
return { ...callbackServerState };
|
||||
}
|
||||
|
||||
export async function startCodexLoopbackCallbackServer(
|
||||
options: StartCodexLoopbackCallbackServerOptions = {},
|
||||
): Promise<CodexLoopbackCallbackServerState> {
|
||||
if (callbackServer) {
|
||||
return getCodexLoopbackCallbackServerState();
|
||||
}
|
||||
if (startPromise) {
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
const callbackHandler = options.callbackHandler || handleOauthCallback;
|
||||
const requestedPort = options.port ?? CODEX_LOOPBACK_CALLBACK_PORT;
|
||||
const requestedHost = options.host;
|
||||
|
||||
startPromise = new Promise<CodexLoopbackCallbackServerState>((resolve, reject) => {
|
||||
const server = createServer((request, response) => {
|
||||
void handleCallbackRequest(request, response, callbackHandler);
|
||||
});
|
||||
|
||||
const finalizeFailure = (error: Error) => {
|
||||
callbackServerState = {
|
||||
attempted: true,
|
||||
ready: false,
|
||||
host: requestedHost,
|
||||
port: requestedPort,
|
||||
origin: normalizeOrigin(requestedHost, requestedPort),
|
||||
error: error.message || 'failed to start codex oauth callback server',
|
||||
};
|
||||
callbackServer = null;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
server.once('error', (error) => {
|
||||
finalizeFailure(error as Error);
|
||||
});
|
||||
|
||||
server.listen(requestedPort, requestedHost, () => {
|
||||
server.removeAllListeners('error');
|
||||
callbackServer = server;
|
||||
const address = server.address() as AddressInfo | null;
|
||||
const port = address?.port || requestedPort;
|
||||
const host = address?.address || requestedHost;
|
||||
callbackServerState = {
|
||||
attempted: true,
|
||||
ready: true,
|
||||
host,
|
||||
port,
|
||||
origin: normalizeOrigin(host, port),
|
||||
};
|
||||
resolve(getCodexLoopbackCallbackServerState());
|
||||
});
|
||||
}).finally(() => {
|
||||
startPromise = null;
|
||||
});
|
||||
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
export async function stopCodexLoopbackCallbackServer(): Promise<void> {
|
||||
const server = callbackServer;
|
||||
callbackServer = null;
|
||||
callbackServerState = { ...DEFAULT_STATE };
|
||||
|
||||
if (!server) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
||||
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js';
|
||||
import {
|
||||
buildCodexAuthorizationUrl,
|
||||
CODEX_LOOPBACK_REDIRECT_URI,
|
||||
CODEX_OAUTH_PROVIDER,
|
||||
CODEX_UPSTREAM_BASE_URL,
|
||||
exchangeCodexAuthorizationCode,
|
||||
refreshCodexTokens,
|
||||
type CodexTokenExchangeResult,
|
||||
} from './codexProvider.js';
|
||||
import {
|
||||
createOauthSession,
|
||||
getOauthSession,
|
||||
markOauthSessionError,
|
||||
markOauthSessionSuccess,
|
||||
} from './sessionStore.js';
|
||||
import { getCodexOauthInfoFromExtraConfig } from './codexAccount.js';
|
||||
import { getCodexLoopbackCallbackServerState } from './localCallbackServer.js';
|
||||
|
||||
type OAuthProviderMetadata = {
|
||||
provider: string;
|
||||
label: string;
|
||||
platform: string;
|
||||
enabled: boolean;
|
||||
loginType: 'oauth';
|
||||
};
|
||||
|
||||
const OAUTH_PROVIDER_LIST: OAuthProviderMetadata[] = [
|
||||
{
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
label: 'Codex',
|
||||
platform: 'codex',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
},
|
||||
];
|
||||
|
||||
const CODEX_SITE_NAME = 'ChatGPT Codex OAuth';
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Record<string, unknown> | undefined, key: string): string | undefined {
|
||||
if (!headers) return undefined;
|
||||
const loweredKey = key.toLowerCase();
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.toLowerCase() !== loweredKey) continue;
|
||||
if (typeof rawValue === 'string') {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
if (Array.isArray(rawValue)) {
|
||||
for (const item of rawValue) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildUsername(exchange: CodexTokenExchangeResult): string {
|
||||
return exchange.email || exchange.accountId || 'codex-user';
|
||||
}
|
||||
|
||||
async function getNextAccountSortOrder(): Promise<number> {
|
||||
const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
|
||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
async function getNextSiteSortOrder(): Promise<number> {
|
||||
const rows = await db.select({ sortOrder: schema.sites.sortOrder }).from(schema.sites).all();
|
||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
async function ensureCodexSite() {
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
const existing = sites.find((site) => (
|
||||
String(site.platform || '').trim().toLowerCase() === 'codex'
|
||||
&& String(site.url || '').trim() === CODEX_UPSTREAM_BASE_URL
|
||||
));
|
||||
if (existing) return existing;
|
||||
|
||||
return db.insert(schema.sites).values({
|
||||
name: CODEX_SITE_NAME,
|
||||
url: CODEX_UPSTREAM_BASE_URL,
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
useSystemProxy: false,
|
||||
isPinned: false,
|
||||
globalWeight: 1,
|
||||
sortOrder: await getNextSiteSortOrder(),
|
||||
}).returning().get();
|
||||
}
|
||||
|
||||
function matchesCodexIdentity(
|
||||
account: typeof schema.accounts.$inferSelect,
|
||||
exchange: CodexTokenExchangeResult,
|
||||
): boolean {
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth || oauth.provider !== CODEX_OAUTH_PROVIDER) return false;
|
||||
if (oauth.accountId && exchange.accountId) return oauth.accountId === exchange.accountId;
|
||||
if (oauth.email && exchange.email) return oauth.email === exchange.email;
|
||||
const username = asNonEmptyString(account.username);
|
||||
if (username && exchange.email) return username === exchange.email;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function findExistingCodexAccount(exchange: CodexTokenExchangeResult, rebindAccountId?: number) {
|
||||
if (typeof rebindAccountId === 'number' && rebindAccountId > 0) {
|
||||
return db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, rebindAccountId))
|
||||
.get();
|
||||
}
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
return accounts.find((account) => matchesCodexIdentity(account, exchange)) || null;
|
||||
}
|
||||
|
||||
async function upsertCodexAccount(exchange: CodexTokenExchangeResult, rebindAccountId?: number) {
|
||||
const site = await ensureCodexSite();
|
||||
const existing = await findExistingCodexAccount(exchange, rebindAccountId);
|
||||
const username = buildUsername(exchange);
|
||||
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
accountId: exchange.accountId,
|
||||
email: exchange.email,
|
||||
planType: exchange.planType,
|
||||
refreshToken: exchange.refreshToken,
|
||||
idToken: exchange.idToken,
|
||||
tokenExpiresAt: exchange.tokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db.update(schema.accounts).set({
|
||||
siteId: site.id,
|
||||
username,
|
||||
accessToken: exchange.accessToken,
|
||||
apiToken: null,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
extraConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, existing.id)).run();
|
||||
return {
|
||||
account: await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(),
|
||||
site,
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const created = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username,
|
||||
accessToken: exchange.accessToken,
|
||||
apiToken: null,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: await getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
return { account: created, site, created: true };
|
||||
}
|
||||
|
||||
export function listOauthProviders() {
|
||||
return OAUTH_PROVIDER_LIST;
|
||||
}
|
||||
|
||||
export function startOauthProviderFlow(input: {
|
||||
provider: string;
|
||||
redirectOrigin: string;
|
||||
rebindAccountId?: number;
|
||||
}) {
|
||||
if (input.provider !== CODEX_OAUTH_PROVIDER) {
|
||||
throw new Error(`unsupported oauth provider: ${input.provider}`);
|
||||
}
|
||||
const callbackServerState = getCodexLoopbackCallbackServerState();
|
||||
if (callbackServerState.attempted && !callbackServerState.ready) {
|
||||
throw new Error(`codex oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
|
||||
}
|
||||
const redirectUri = CODEX_LOOPBACK_REDIRECT_URI;
|
||||
const session = createOauthSession({
|
||||
provider: input.provider,
|
||||
redirectUri,
|
||||
rebindAccountId: input.rebindAccountId,
|
||||
});
|
||||
return {
|
||||
provider: input.provider,
|
||||
state: session.state,
|
||||
authorizationUrl: buildCodexAuthorizationUrl({
|
||||
state: session.state,
|
||||
redirectUri,
|
||||
codeVerifier: session.codeVerifier,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOauthSessionStatus(state: string) {
|
||||
const session = getOauthSession(state);
|
||||
if (!session) return null;
|
||||
return {
|
||||
provider: session.provider,
|
||||
state: session.state,
|
||||
status: session.status,
|
||||
accountId: session.accountId,
|
||||
siteId: session.siteId,
|
||||
error: session.error,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleOauthCallback(input: {
|
||||
provider: string;
|
||||
state: string;
|
||||
code?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
const session = getOauthSession(input.state);
|
||||
if (!session || session.provider !== input.provider) {
|
||||
throw new Error('oauth session not found or provider mismatch');
|
||||
}
|
||||
if (input.error) {
|
||||
markOauthSessionError(input.state, input.error);
|
||||
throw new Error(input.error);
|
||||
}
|
||||
const code = asNonEmptyString(input.code);
|
||||
if (!code) {
|
||||
markOauthSessionError(input.state, 'missing oauth code');
|
||||
throw new Error('missing oauth code');
|
||||
}
|
||||
|
||||
const exchange = await exchangeCodexAuthorizationCode({
|
||||
code,
|
||||
codeVerifier: session.codeVerifier,
|
||||
redirectUri: session.redirectUri,
|
||||
});
|
||||
const { account, site, created } = await upsertCodexAccount(exchange, session.rebindAccountId) as Awaited<ReturnType<typeof upsertCodexAccount>> & { created: boolean };
|
||||
if (!account) {
|
||||
markOauthSessionError(input.state, 'failed to persist oauth account');
|
||||
throw new Error('failed to persist oauth account');
|
||||
}
|
||||
|
||||
const refreshResult = await refreshModelsForAccount(account.id);
|
||||
if (refreshResult.status !== 'success') {
|
||||
if (created) {
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run();
|
||||
}
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
const errorMessage = refreshResult.errorMessage || 'codex model discovery failed';
|
||||
markOauthSessionError(input.state, errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
markOauthSessionSuccess(input.state, {
|
||||
accountId: account.id,
|
||||
siteId: site.id,
|
||||
});
|
||||
return { accountId: account.id, siteId: site.id };
|
||||
}
|
||||
|
||||
export async function listOauthConnections() {
|
||||
const rows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.all();
|
||||
const modelRows = await db.select({
|
||||
accountId: schema.modelAvailability.accountId,
|
||||
modelName: schema.modelAvailability.modelName,
|
||||
}).from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.available, true))
|
||||
.all();
|
||||
const modelMap = new Map<number, string[]>();
|
||||
for (const row of modelRows) {
|
||||
if (typeof row.accountId !== 'number') continue;
|
||||
const list = modelMap.get(row.accountId) || [];
|
||||
list.push(row.modelName);
|
||||
modelMap.set(row.accountId, list);
|
||||
}
|
||||
const routeChannelRows = await db.select({
|
||||
accountId: schema.routeChannels.accountId,
|
||||
}).from(schema.routeChannels).all();
|
||||
const routeChannelCountByAccount = new Map<number, number>();
|
||||
for (const row of routeChannelRows) {
|
||||
const current = routeChannelCountByAccount.get(row.accountId) || 0;
|
||||
routeChannelCountByAccount.set(row.accountId, current + 1);
|
||||
}
|
||||
|
||||
return rows
|
||||
.filter((row) => getCodexOauthInfoFromExtraConfig(row.accounts.extraConfig)?.provider === CODEX_OAUTH_PROVIDER)
|
||||
.map((row) => {
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
|
||||
const models = modelMap.get(row.accounts.id) || [];
|
||||
const status = (
|
||||
oauth.modelDiscoveryStatus === 'abnormal'
|
||||
|| row.accounts.status !== 'active'
|
||||
|| row.sites.status !== 'active'
|
||||
) ? 'abnormal' : 'healthy';
|
||||
return {
|
||||
accountId: row.accounts.id,
|
||||
siteId: row.sites.id,
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
username: row.accounts.username,
|
||||
email: oauth.email,
|
||||
accountKey: oauth.accountId,
|
||||
planType: oauth.planType,
|
||||
modelCount: models.length,
|
||||
modelsPreview: models.slice(0, 10),
|
||||
status,
|
||||
routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0,
|
||||
lastModelSyncAt: oauth.lastModelSyncAt,
|
||||
lastModelSyncError: oauth.lastModelSyncError,
|
||||
site: {
|
||||
id: row.sites.id,
|
||||
name: row.sites.name,
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOauthConnection(accountId: number) {
|
||||
const account = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run();
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function startOauthRebindFlow(accountId: number, redirectOrigin: string) {
|
||||
const account = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
return startOauthProviderFlow({
|
||||
provider: oauth.provider,
|
||||
redirectOrigin,
|
||||
rebindAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCodexOauthProviderHeaders(input: {
|
||||
extraConfig?: string | null;
|
||||
downstreamHeaders?: Record<string, unknown>;
|
||||
}) {
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(input.extraConfig);
|
||||
if (!oauth) return {};
|
||||
|
||||
const accountId = getHeaderValue(input.downstreamHeaders, 'chatgpt-account-id') || oauth.accountId;
|
||||
const originator = getHeaderValue(input.downstreamHeaders, 'originator') || 'codex_cli_rs';
|
||||
const headers: Record<string, string> = {
|
||||
Originator: originator,
|
||||
};
|
||||
if (accountId) {
|
||||
headers['Chatgpt-Account-Id'] = accountId;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function refreshCodexOauthAccessToken(accountId: number) {
|
||||
const account = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
if (!account) {
|
||||
throw new Error('codex oauth account not found');
|
||||
}
|
||||
const oauth = getCodexOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth?.refreshToken) {
|
||||
throw new Error('codex oauth refresh token missing');
|
||||
}
|
||||
|
||||
const refreshed = await refreshCodexTokens(oauth.refreshToken);
|
||||
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: CODEX_OAUTH_PROVIDER,
|
||||
accountId: refreshed.accountId || oauth.accountId,
|
||||
email: refreshed.email || oauth.email,
|
||||
planType: refreshed.planType || oauth.planType,
|
||||
refreshToken: refreshed.refreshToken,
|
||||
idToken: refreshed.idToken,
|
||||
tokenExpiresAt: refreshed.tokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await db.update(schema.accounts).set({
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig,
|
||||
status: 'active',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, accountId)).run();
|
||||
|
||||
return {
|
||||
accountId,
|
||||
accessToken: refreshed.accessToken,
|
||||
accountKey: refreshed.accountId || oauth.accountId,
|
||||
extraConfig,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
export type OAuthSessionStatus = 'pending' | 'success' | 'error';
|
||||
|
||||
export type OAuthSessionRecord = {
|
||||
provider: string;
|
||||
state: string;
|
||||
status: OAuthSessionStatus;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string;
|
||||
accountId?: number;
|
||||
siteId?: number;
|
||||
error?: string;
|
||||
rebindAccountId?: number;
|
||||
};
|
||||
|
||||
const SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const oauthSessions = new Map<string, OAuthSessionRecord>();
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function toBase64Url(input: Buffer): string {
|
||||
return input.toString('base64url');
|
||||
}
|
||||
|
||||
function pruneExpiredSessions(nowMs = Date.now()) {
|
||||
for (const [state, session] of oauthSessions.entries()) {
|
||||
if (Date.parse(session.expiresAt) <= nowMs) {
|
||||
oauthSessions.delete(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPkceVerifier(): string {
|
||||
return toBase64Url(randomBytes(48));
|
||||
}
|
||||
|
||||
export function createPkceChallenge(codeVerifier: string): string {
|
||||
return createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
}
|
||||
|
||||
export function createOauthSession(input: {
|
||||
provider: string;
|
||||
redirectUri: string;
|
||||
rebindAccountId?: number;
|
||||
}): OAuthSessionRecord {
|
||||
pruneExpiredSessions();
|
||||
const state = toBase64Url(randomBytes(24));
|
||||
const codeVerifier = createPkceVerifier();
|
||||
const createdAt = nowIso();
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
||||
const record: OAuthSessionRecord = {
|
||||
provider: input.provider,
|
||||
state,
|
||||
status: 'pending',
|
||||
codeVerifier,
|
||||
redirectUri: input.redirectUri,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
expiresAt,
|
||||
rebindAccountId: input.rebindAccountId,
|
||||
};
|
||||
oauthSessions.set(state, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function getOauthSession(state: string): OAuthSessionRecord | null {
|
||||
pruneExpiredSessions();
|
||||
return oauthSessions.get(state) || null;
|
||||
}
|
||||
|
||||
export function markOauthSessionSuccess(
|
||||
state: string,
|
||||
patch: { accountId: number; siteId: number },
|
||||
): OAuthSessionRecord | null {
|
||||
const existing = getOauthSession(state);
|
||||
if (!existing) return null;
|
||||
const next: OAuthSessionRecord = {
|
||||
...existing,
|
||||
status: 'success',
|
||||
updatedAt: nowIso(),
|
||||
accountId: patch.accountId,
|
||||
siteId: patch.siteId,
|
||||
error: undefined,
|
||||
};
|
||||
oauthSessions.set(state, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function markOauthSessionError(state: string, error: string): OAuthSessionRecord | null {
|
||||
const existing = getOauthSession(state);
|
||||
if (!existing) return null;
|
||||
const next: OAuthSessionRecord = {
|
||||
...existing,
|
||||
status: 'error',
|
||||
updatedAt: nowIso(),
|
||||
error: error.trim() || 'OAuth failed',
|
||||
};
|
||||
oauthSessions.set(state, next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export class CodexAdapter extends BasePlatformAdapter {
|
||||
readonly platformName = 'codex';
|
||||
|
||||
async detect(url: string): Promise<boolean> {
|
||||
const normalized = normalizeBaseUrl(url).toLowerCase();
|
||||
return normalized.includes('chatgpt.com/backend-api/codex');
|
||||
}
|
||||
|
||||
override async login(_baseUrl: string, _username: string, _password: string) {
|
||||
return { success: false as const, message: 'codex oauth login is managed via OAuth flow' };
|
||||
}
|
||||
|
||||
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return { success: false, message: 'codex oauth connections do not support checkin' };
|
||||
}
|
||||
|
||||
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||
return { balance: 0, used: 0, quota: 0 };
|
||||
}
|
||||
|
||||
async getModels(_baseUrl: string, _token: string): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,11 @@ describe('getAdapter platform aliases', () => {
|
||||
expect(getAdapter('gemini')?.platformName).toBe('gemini');
|
||||
});
|
||||
|
||||
it('supports dedicated codex adapter and aliases', () => {
|
||||
expect(getAdapter('codex')?.platformName).toBe('codex');
|
||||
expect(getAdapter('chatgpt-codex')?.platformName).toBe('codex');
|
||||
});
|
||||
|
||||
it('detects anyrouter URL before generic new-api adapter', async () => {
|
||||
const adapter = await detectPlatform('https://anyrouter.top');
|
||||
expect(adapter?.platformName).toBe('anyrouter');
|
||||
|
||||
@@ -7,6 +7,7 @@ import { OneHubAdapter } from './oneHub.js';
|
||||
import { DoneHubAdapter } from './doneHub.js';
|
||||
import { Sub2ApiAdapter } from './sub2api.js';
|
||||
import { OpenAiAdapter } from './openai.js';
|
||||
import { CodexAdapter } from './codex.js';
|
||||
import { ClaudeAdapter } from './claude.js';
|
||||
import { GeminiAdapter } from './gemini.js';
|
||||
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||
@@ -15,6 +16,7 @@ import { detectPlatformByTitle } from './titleHint.js';
|
||||
const adapters: PlatformAdapter[] = [
|
||||
// Specific forks before generic adapters for better auto-detection.
|
||||
new OpenAiAdapter(),
|
||||
new CodexAdapter(),
|
||||
new ClaudeAdapter(),
|
||||
new GeminiAdapter(),
|
||||
new CliProxyApiAdapter(),
|
||||
@@ -49,6 +51,9 @@ const platformAliases: Record<string, string> = {
|
||||
sub2api: 'sub2api',
|
||||
// Official upstream APIs
|
||||
openai: 'openai',
|
||||
codex: 'codex',
|
||||
'chatgpt-codex': 'codex',
|
||||
'chatgpt codex': 'codex',
|
||||
anthropic: 'claude',
|
||||
claude: 'claude',
|
||||
gemini: 'gemini',
|
||||
@@ -83,6 +88,7 @@ function detectPlatformByUrlHint(url: string): string | undefined {
|
||||
|
||||
// Official upstream endpoints.
|
||||
if (normalized.includes('api.openai.com')) return 'openai';
|
||||
if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex';
|
||||
if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude';
|
||||
if (
|
||||
normalized.includes('generativelanguage.googleapis.com')
|
||||
|
||||
@@ -119,4 +119,49 @@ describe('TokenRouter site status guard', () => {
|
||||
const selected = await new TokenRouter().selectChannel('gpt-4.1-mini');
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('uses codex oauth access token when no api token is present', 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: 'plus',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
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 selected = await new TokenRouter().selectChannel('gpt-5.2-codex');
|
||||
expect(selected).not.toBeNull();
|
||||
expect(selected?.tokenValue).toBe('oauth-access-token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from './routeRoutingStrategy.js';
|
||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||
import { isUsableAccountToken } from './accountTokenService.js';
|
||||
import { isCodexPlatform } from './oauth/codexAccount.js';
|
||||
|
||||
interface RouteMatch {
|
||||
route: typeof schema.tokenRoutes.$inferSelect;
|
||||
@@ -1042,7 +1043,14 @@ export class TokenRouter {
|
||||
}
|
||||
|
||||
const fallback = candidate.account.apiToken?.trim();
|
||||
return fallback || null;
|
||||
if (fallback) return fallback;
|
||||
|
||||
if ((candidate.site?.platform || '').trim().toLowerCase() === 'codex' || isCodexPlatform(candidate.account)) {
|
||||
const accessToken = candidate.account.accessToken?.trim();
|
||||
return accessToken || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getCandidateEligibilityReasons(
|
||||
|
||||
@@ -21,4 +21,12 @@ describe('App sidebar config', () => {
|
||||
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
|
||||
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
|
||||
});
|
||||
|
||||
it('adds standalone OAuth 管理 navigation entry', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("{ to: '/oauth', label: 'OAuth 管理'");
|
||||
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
|
||||
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ const Models = lazy(() => import('./pages/Models.js'));
|
||||
const About = lazy(() => import('./pages/About.js'));
|
||||
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
||||
|
||||
type ThemeMode = 'system' | 'light' | 'dark';
|
||||
|
||||
@@ -328,6 +329,7 @@ const sidebarGroups = [
|
||||
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
|
||||
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
|
||||
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
|
||||
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
|
||||
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
|
||||
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
||||
@@ -763,6 +765,7 @@ function AppShell() {
|
||||
<Route path="/" element={<Dashboard adminName={displayName} />} />
|
||||
<Route path="/sites" element={<Sites />} />
|
||||
<Route path="/accounts" element={<Accounts />} />
|
||||
<Route path="/oauth" element={<OAuthManagement />} />
|
||||
<Route path="/tokens" element={<Tokens />} />
|
||||
<Route path="/checkin" element={<CheckinLog />} />
|
||||
<Route path="/routes" element={<TokenRoutes />} />
|
||||
|
||||
@@ -246,6 +246,40 @@ export type ProxyLogsResponse = {
|
||||
summary: ProxyLogsSummary;
|
||||
};
|
||||
|
||||
export type OAuthProviderInfo = {
|
||||
provider: string;
|
||||
label: string;
|
||||
platform: string;
|
||||
enabled: boolean;
|
||||
loginType: 'oauth';
|
||||
};
|
||||
|
||||
export type OAuthSessionInfo = {
|
||||
provider: string;
|
||||
state: string;
|
||||
status: 'pending' | 'success' | 'error';
|
||||
accountId?: number;
|
||||
siteId?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type OAuthConnectionInfo = {
|
||||
accountId: number;
|
||||
siteId: number;
|
||||
provider: string;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
accountKey?: string | null;
|
||||
planType?: string | null;
|
||||
modelCount: number;
|
||||
modelsPreview: string[];
|
||||
status: 'healthy' | 'abnormal';
|
||||
routeChannelCount?: number;
|
||||
lastModelSyncAt?: string | null;
|
||||
lastModelSyncError?: string | null;
|
||||
site?: { id: number; name: string; url: string; platform: string } | null;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: () => request('/api/sites'),
|
||||
@@ -357,6 +391,22 @@ export const api = {
|
||||
// Search
|
||||
search: (query: string) => request('/api/search', { method: 'POST', body: JSON.stringify({ query, limit: 20 }) }),
|
||||
|
||||
// OAuth
|
||||
getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>,
|
||||
startOAuthProvider: (provider: string, data?: { accountId?: number }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
||||
getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise<OAuthSessionInfo>,
|
||||
getOAuthConnections: () => request('/api/oauth/connections') as Promise<{ items: OAuthConnectionInfo[] }>,
|
||||
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
||||
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
|
||||
method: 'DELETE',
|
||||
}) as Promise<{ success: true }>,
|
||||
|
||||
// Events
|
||||
getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`),
|
||||
getEventCount: () => request('/api/events/count'),
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import OAuthManagement from './OAuthManagement.js';
|
||||
|
||||
const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getOAuthProviders: vi.fn(),
|
||||
getOAuthConnections: vi.fn(),
|
||||
startOAuthProvider: vi.fn(),
|
||||
getOAuthSession: vi.fn(),
|
||||
rebindOAuthConnection: vi.fn(),
|
||||
deleteOAuthConnection: vi.fn(),
|
||||
},
|
||||
openMock: vi.fn(),
|
||||
focusMock: vi.fn(),
|
||||
confirmMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: any): string {
|
||||
const children = node?.children || [];
|
||||
return children.map((child: any) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('OAuthManagement page', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
openMock.mockReturnValue({ focus: focusMock });
|
||||
confirmMock.mockReturnValue(true);
|
||||
vi.stubGlobal('window', {
|
||||
open: openMock,
|
||||
confirm: confirmMock,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
} as unknown as Window & typeof globalThis);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders available oauth providers and existing oauth connections', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'plus',
|
||||
modelCount: 3,
|
||||
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('OAuth 管理');
|
||||
expect(text).toContain('Codex');
|
||||
expect(text).toContain('codex-user@example.com');
|
||||
expect(text).toContain('plus');
|
||||
expect(text).toContain('3 个模型');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('starts oauth, opens popup, polls status, and refreshes connection list after success', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections
|
||||
.mockResolvedValueOnce({ items: [] })
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'plus',
|
||||
modelCount: 3,
|
||||
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.startOAuthProvider.mockResolvedValue({
|
||||
provider: 'codex',
|
||||
state: 'oauth-state-123',
|
||||
authorizationUrl: 'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
|
||||
});
|
||||
apiMock.getOAuthSession
|
||||
.mockResolvedValueOnce({
|
||||
provider: 'codex',
|
||||
state: 'oauth-state-123',
|
||||
status: 'pending',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
provider: 'codex',
|
||||
state: 'oauth-state-123',
|
||||
status: 'success',
|
||||
accountId: 7,
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const startButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('连接 Codex')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await startButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex');
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
|
||||
'oauth-codex',
|
||||
expect.stringContaining('width=540'),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getOAuthSession).toHaveBeenCalledWith('oauth-state-123');
|
||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('授权成功');
|
||||
expect(text).toContain('codex-user@example.com');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
accountId: 7,
|
||||
provider: 'codex',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'team',
|
||||
modelCount: 11,
|
||||
modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'],
|
||||
status: 'abnormal',
|
||||
routeChannelCount: 1,
|
||||
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
|
||||
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ items: [] });
|
||||
apiMock.deleteOAuthConnection.mockResolvedValue({ success: true });
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('异常');
|
||||
expect(text).toContain('1 条路由');
|
||||
expect(text).toContain('Codex 模型获取失败');
|
||||
|
||||
const deleteButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('删除连接')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await deleteButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
expect(apiMock.deleteOAuthConnection).toHaveBeenCalledWith(7);
|
||||
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
|
||||
type ActiveSession = {
|
||||
provider: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
function openOAuthPopup(provider: string, authorizationUrl: string) {
|
||||
if (typeof window === 'undefined' || typeof window.open !== 'function') return;
|
||||
const popup = window.open(
|
||||
authorizationUrl,
|
||||
`oauth-${provider}`,
|
||||
'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes',
|
||||
);
|
||||
if (popup && typeof popup.focus === 'function') {
|
||||
popup.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConnectionStatusLabel(status?: string): string {
|
||||
return status === 'abnormal' ? '异常' : '正常';
|
||||
}
|
||||
|
||||
export default function OAuthManagement() {
|
||||
const [providers, setProviders] = useState<OAuthProviderInfo[]>([]);
|
||||
const [connections, setConnections] = useState<OAuthConnectionInfo[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
||||
const [sessionMessage, setSessionMessage] = useState('');
|
||||
const [actionLoadingKey, setActionLoadingKey] = useState('');
|
||||
|
||||
const loadConnections = async () => {
|
||||
const response = await api.getOAuthConnections();
|
||||
setConnections(Array.isArray(response?.items) ? response.items : []);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [providersResponse] = await Promise.all([
|
||||
api.getOAuthProviders(),
|
||||
loadConnections(),
|
||||
]);
|
||||
setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []);
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const session = await api.getOAuthSession(activeSession.state);
|
||||
if (cancelled) return;
|
||||
|
||||
if (session.status === 'pending') {
|
||||
setSessionMessage('等待授权完成');
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.status === 'success') {
|
||||
setSessionMessage('授权成功');
|
||||
await loadConnections();
|
||||
setActiveSession(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionMessage(`授权失败:${session.error || '未知错误'}`);
|
||||
setActiveSession(null);
|
||||
} catch (error: any) {
|
||||
if (cancelled) return;
|
||||
setSessionMessage(error?.message || 'OAuth 会话状态查询失败');
|
||||
setActiveSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [activeSession]);
|
||||
|
||||
const handleStart = async (provider: string, accountId?: number) => {
|
||||
const actionKey = `start:${provider}:${accountId || 0}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
try {
|
||||
const started = accountId
|
||||
? await api.rebindOAuthConnection(accountId)
|
||||
: await api.startOAuthProvider(provider);
|
||||
setSessionMessage('等待授权完成');
|
||||
setActiveSession({
|
||||
provider: started.provider,
|
||||
state: started.state,
|
||||
});
|
||||
openOAuthPopup(provider, started.authorizationUrl);
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '无法启动 OAuth 授权');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (accountId: number) => {
|
||||
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
||||
const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?');
|
||||
if (!confirmed) return;
|
||||
}
|
||||
const actionKey = `delete:${accountId}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
try {
|
||||
await api.deleteOAuthConnection(accountId);
|
||||
setSessionMessage('连接已删除');
|
||||
await loadConnections();
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '删除 OAuth 连接失败');
|
||||
} finally {
|
||||
setActionLoadingKey('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">OAuth 管理</div>
|
||||
<div className="page-subtitle">统一管理需要浏览器授权的上游连接。首版支持 Codex。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionMessage && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>{sessionMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{providers.map((provider) => {
|
||||
const actionKey = `start:${provider.provider}:0`;
|
||||
return (
|
||||
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{provider.platform}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleStart(provider.provider)}
|
||||
disabled={actionLoadingKey === actionKey}
|
||||
>
|
||||
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{loaded && providers.length === 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>当前没有可用的 OAuth Provider。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>已连接账号</div>
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{connections.map((connection) => {
|
||||
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
|
||||
const deleteActionKey = `delete:${connection.accountId}`;
|
||||
return (
|
||||
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{connection.email || connection.username || connection.provider}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
|
||||
</div>
|
||||
{connection.lastModelSyncAt && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
最近同步: {connection.lastModelSyncAt}
|
||||
</div>
|
||||
)}
|
||||
{connection.lastModelSyncError && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: 4 }}>
|
||||
{connection.lastModelSyncError}
|
||||
</div>
|
||||
)}
|
||||
{connection.modelsPreview.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
|
||||
{connection.modelsPreview.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleStart(connection.provider, connection.accountId)}
|
||||
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
|
||||
>
|
||||
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => handleDelete(connection.accountId)}
|
||||
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
|
||||
>
|
||||
{actionLoadingKey === deleteActionKey ? '删除中...' : '删除连接'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{loaded && connections.length === 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>还没有 OAuth 连接。</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,7 @@ const platformColors: Record<string, string> = {
|
||||
'done-hub': 'badge-muted',
|
||||
sub2api: 'badge-muted',
|
||||
openai: 'badge-success',
|
||||
codex: 'badge-success',
|
||||
claude: 'badge-warning',
|
||||
gemini: 'badge-info',
|
||||
cliproxyapi: 'badge-info',
|
||||
@@ -67,6 +68,7 @@ const SITE_PLATFORM_OPTIONS = [
|
||||
{ value: 'done-hub', label: 'done-hub' },
|
||||
{ value: 'sub2api', label: 'sub2api' },
|
||||
{ value: 'openai', label: 'openai' },
|
||||
{ value: 'codex', label: 'codex' },
|
||||
{ value: 'claude', label: 'claude' },
|
||||
{ value: 'gemini', label: 'gemini' },
|
||||
{ value: 'cliproxyapi', label: 'cliproxyapi' },
|
||||
@@ -290,11 +292,16 @@ export default function Sites() {
|
||||
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
|
||||
? created.platform.trim()
|
||||
: payload.platform;
|
||||
const initialSegment = resolveInitialConnectionSegment(createdPlatform);
|
||||
const params = new URLSearchParams({
|
||||
create: '1',
|
||||
siteId: String(createdSiteId),
|
||||
});
|
||||
if (String(createdPlatform || '').trim().toLowerCase() === 'codex') {
|
||||
params.set('provider', 'codex');
|
||||
navigate(`/oauth?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
const initialSegment = resolveInitialConnectionSegment(createdPlatform);
|
||||
if (initialSegment === 'apikey') {
|
||||
params.set('segment', 'apikey');
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ async function createSiteAndCollectLocation(createdSite: { id: number; platform?
|
||||
<Routes>
|
||||
<Route path="/sites" element={<Sites />} />
|
||||
<Route path="/accounts" element={<LocationProbe />} />
|
||||
<Route path="/oauth" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
@@ -124,4 +125,13 @@ describe('Sites create redirect', () => {
|
||||
expect(rendered).toContain('create=1');
|
||||
expect(rendered).toContain('siteId=22');
|
||||
});
|
||||
|
||||
it('redirects a codex site to oauth management instead of legacy account import', async () => {
|
||||
const rendered = await createSiteAndCollectLocation({ id: 23, platform: 'codex' });
|
||||
|
||||
expect(rendered).toContain('/oauth?');
|
||||
expect(rendered).toContain('provider=codex');
|
||||
expect(rendered).toContain('create=1');
|
||||
expect(rendered).toContain('siteId=23');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,7 +473,7 @@ function RouteCardInner({
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
|
||||
{readOnlyRoute ? tr('暂无通道,先补齐令牌后再重建路由。') : tr('暂无通道')}
|
||||
{readOnlyRoute ? tr('暂无通道,先补齐连接配置后再重建路由。') : tr('暂无通道')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -346,7 +346,7 @@ describe('TokenRoutes grouped source models', () => {
|
||||
const expandedText = collectText(root.root);
|
||||
expect(expandedText).toContain('待注册站点');
|
||||
expect(expandedText).toContain('Wong');
|
||||
expect(expandedText).toContain('暂无通道,先补齐令牌后再重建路由。');
|
||||
expect(expandedText).toContain('暂无通道,先补齐连接配置后再重建路由。');
|
||||
expect(expandedText).not.toContain('添加通道');
|
||||
expect(expandedText).not.toContain('删除路由');
|
||||
expect(expandedText).not.toContain('编辑群组');
|
||||
|
||||
Reference in New Issue
Block a user