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

- Add localCallbackServer for handling OAuth callback requests.
- Implement service functions for managing OAuth sessions and accounts.
- Create CodexAdapter for platform-specific interactions.
- Develop OAuthManagement page for managing OAuth connections and providers.
- Add tests for localCallbackServer and OAuthManagement components.
This commit is contained in:
ciat-777
2026-03-17 11:07:25 +08:00
parent c156e0c164
commit 575a15232b
40 changed files with 3219 additions and 32 deletions
+41
View File
@@ -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');
});
});
+53
View File
@@ -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',
+161 -3
View File
@@ -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);
}
+124
View File
@@ -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,
};
}
+133
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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()));
});
}
+427
View File
@@ -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,
};
}
+106
View File
@@ -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;
}
+34
View File
@@ -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');
+6
View File
@@ -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');
});
});
+9 -1
View File
@@ -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(