Merge branch 'main' into feat/account-level-proxy

# Conflicts:
#	src/server/routes/api/accountTokens.ts
#	src/server/routes/proxy/chat.ts
#	src/server/routes/proxy/completions.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/endpointFlow.ts
#	src/server/routes/proxy/images.ts
#	src/server/routes/proxy/responses.ts
#	src/server/services/accountExtraConfig.test.ts
#	src/server/services/modelService.ts
This commit is contained in:
Hureru
2026-03-18 23:42:21 +08:00
199 changed files with 27415 additions and 1901 deletions
@@ -8,6 +8,8 @@ import {
mergeAccountExtraConfig,
normalizeCredentialMode,
resolvePlatformUserId,
requiresManagedAccountTokens,
supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js';
describe('accountExtraConfig', () => {
@@ -82,4 +84,40 @@ describe('accountExtraConfig', () => {
expect(getProxyUrlFromExtraConfig(undefined)).toBeNull();
expect(getProxyUrlFromExtraConfig('invalid-json')).toBeNull();
});
it('treats auto-mode api token connections as direct-account routable', () => {
expect(supportsDirectAccountRoutingConnection({
accessToken: '',
apiToken: 'sk-demo',
extraConfig: null,
})).toBe(true);
expect(requiresManagedAccountTokens({
accessToken: '',
apiToken: 'sk-demo',
extraConfig: null,
})).toBe(false);
});
it('treats oauth and session connections as non-managed-token direct routes only when intended', () => {
expect(supportsDirectAccountRoutingConnection({
accessToken: 'oauth-access-token',
apiToken: null,
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe(true);
expect(requiresManagedAccountTokens({
accessToken: 'oauth-access-token',
apiToken: null,
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe(false);
expect(supportsDirectAccountRoutingConnection({
accessToken: 'session-token',
apiToken: 'sk-default',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(false);
expect(requiresManagedAccountTokens({
accessToken: 'session-token',
apiToken: 'sk-default',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(true);
});
});
+47
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;
@@ -82,6 +86,49 @@ 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 (hasOauthProvider(account.extraConfig)) {
return hasCredentialValue(account.accessToken) || hasCredentialValue(account.apiToken);
}
if (credentialMode === 'apikey') {
return hasCredentialValue(account.apiToken);
}
if (credentialMode === 'session') {
return false;
}
if (hasCredentialValue(account.accessToken)) return false;
return hasCredentialValue(account.apiToken);
}
export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean {
const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig);
if (hasOauthProvider(account.extraConfig)) return false;
if (credentialMode === 'apikey') return false;
if (credentialMode === 'session') return true;
if (hasCredentialValue(account.apiToken) && !hasCredentialValue(account.accessToken)) return false;
return true;
}
export type ManagedSub2ApiAuth = {
refreshToken: string;
tokenExpiresAt?: number;
+121 -13
View File
@@ -9,6 +9,14 @@ type UpstreamApiToken = {
tokenGroup?: string | null;
};
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
export const ACCOUNT_TOKEN_VALUE_STATUS_READY = 'ready' as const;
export const ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING = 'masked_pending' as const;
export type AccountTokenValueStatus =
| typeof ACCOUNT_TOKEN_VALUE_STATUS_READY
| typeof ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string {
if (!token) return '';
const value = token.trim();
@@ -52,6 +60,45 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean {
return value.includes('*') || value.includes('•');
}
function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus {
const normalized = (value || '').trim().toLowerCase();
return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
}
export function resolveAccountTokenValueStatus(
value: Pick<AccountTokenRow, 'token' | 'valueStatus'> | string | null | undefined,
): AccountTokenValueStatus {
if (typeof value === 'string' || value == null) {
return normalizeTokenValueStatus(value);
}
const explicit = normalizeTokenValueStatus(value.valueStatus);
if (explicit === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
return ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
}
return isMaskedTokenValue(value.token)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
}
export function isReadyAccountToken(token: Pick<AccountTokenRow, 'token' | 'valueStatus'> | null | undefined): boolean {
if (!token) return false;
return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_READY
&& !isMaskedTokenValue(token.token);
}
export function isMaskedPendingAccountToken(token: Pick<AccountTokenRow, 'token' | 'valueStatus'> | null | undefined): boolean {
if (!token) return false;
return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING;
}
export function isUsableAccountToken(token: AccountTokenRow | null | undefined): boolean {
if (!token) return false;
return token.enabled === true && isReadyAccountToken(token);
}
function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null {
const explicit = (value || '').trim();
if (explicit.length > 0) return explicit;
@@ -66,6 +113,15 @@ function normalizeTokenGroup(value: string | null | undefined, tokenName?: strin
return name;
}
function sameTokenGroup(
leftGroup: string | null | undefined,
leftName: string | null | undefined,
rightGroup: string | null | undefined,
rightName: string | null | undefined,
): boolean {
return normalizeTokenGroup(leftGroup, leftName) === normalizeTokenGroup(rightGroup, rightName);
}
async function updateAccountApiToken(accountId: number, tokenValue: string | null) {
await db.update(schema.accounts)
.set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() })
@@ -85,9 +141,10 @@ export async function getPreferredAccountToken(accountId: number) {
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true)))
.all();
if (tokens.length === 0) return null;
const usableTokens = tokens.filter(isUsableAccountToken);
if (usableTokens.length === 0) return null;
const preferred = tokens.find((t) => t.isDefault) || tokens[0];
const preferred = usableTokens.find((t) => t.isDefault) || usableTokens[0];
return preferred;
}
@@ -98,6 +155,7 @@ export async function ensureDefaultTokenForAccount(
): Promise<number | null> {
const normalizedToken = normalizeTokenValue(tokenValue);
if (!normalizedToken) return null;
if (isMaskedTokenValue(normalizedToken)) return null;
const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default';
const now = new Date().toISOString();
@@ -114,6 +172,7 @@ export async function ensureDefaultTokenForAccount(
name: normalizeTokenName(options?.name, tokens.length + 1),
token: normalizedToken,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: options?.source || 'manual',
enabled: options?.enabled ?? true,
isDefault: true,
@@ -131,6 +190,7 @@ export async function ensureDefaultTokenForAccount(
.set({
name: options?.name ? normalizeTokenName(options.name) : target.name,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: options?.source || target.source || 'manual',
enabled: options?.enabled ?? target.enabled,
isDefault: true,
@@ -151,7 +211,7 @@ export async function ensureDefaultTokenForAccount(
export async function setDefaultToken(tokenId: number): Promise<boolean> {
const target = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!target) return false;
if (!target || !isUsableAccountToken(target)) return false;
const now = new Date().toISOString();
await db.update(schema.accountTokens)
@@ -174,7 +234,7 @@ export async function repairDefaultToken(accountId: number) {
.where(eq(schema.accountTokens.accountId, accountId))
.all();
const enabled = tokens.filter((t) => t.enabled);
const enabled = tokens.filter(isUsableAccountToken);
if (enabled.length === 0) {
await updateAccountApiToken(accountId, null);
return null;
@@ -206,27 +266,30 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
let created = 0;
let updated = 0;
let maskedSkipped = 0;
let maskedPending = 0;
const pendingTokenIds: number[] = [];
let index = existing.length + 1;
for (const upstream of upstreamTokens) {
const tokenValue = normalizeTokenValue(upstream.key);
if (!tokenValue) continue;
if (isMaskedTokenValue(tokenValue)) {
maskedSkipped++;
continue;
}
const tokenName = normalizeTokenName(upstream.name, index);
const enabled = upstream.enabled ?? true;
const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName);
const nextValueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
const byToken = existing.find((row) => row.token === tokenValue);
const byToken = existing.find((row) => (
row.token === tokenValue
&& resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY
));
if (byToken) {
await db.update(schema.accountTokens)
.set({
name: tokenName,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: 'sync',
enabled,
updatedAt: now,
@@ -235,6 +298,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
.run();
byToken.name = tokenName;
byToken.tokenGroup = tokenGroup;
byToken.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY;
byToken.enabled = enabled;
byToken.source = 'sync';
byToken.updatedAt = now;
@@ -242,14 +306,52 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
continue;
}
const matchingPlaceholder = existing.find((row) => (
isMaskedPendingAccountToken(row)
&& row.name === tokenName
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
));
if (matchingPlaceholder) {
const nextEnabled = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false;
await db.update(schema.accountTokens)
.set({
name: tokenName,
token: tokenValue,
tokenGroup,
valueStatus: nextValueStatus,
source: 'sync',
enabled: nextEnabled,
isDefault: false,
updatedAt: now,
})
.where(eq(schema.accountTokens.id, matchingPlaceholder.id))
.run();
matchingPlaceholder.name = tokenName;
matchingPlaceholder.token = tokenValue;
matchingPlaceholder.tokenGroup = tokenGroup;
matchingPlaceholder.valueStatus = nextValueStatus;
matchingPlaceholder.source = 'sync';
matchingPlaceholder.enabled = nextEnabled;
matchingPlaceholder.isDefault = false;
matchingPlaceholder.updatedAt = now;
updated++;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
maskedPending++;
pendingTokenIds.push(matchingPlaceholder.id);
}
continue;
}
const inserted = await db.insert(schema.accountTokens)
.values({
accountId,
name: tokenName,
token: tokenValue,
tokenGroup,
valueStatus: nextValueStatus,
source: 'sync',
enabled,
enabled: nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false,
isDefault: false,
createdAt: now,
updatedAt: now,
@@ -263,6 +365,10 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
existing.push(createdRow);
created++;
index++;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
maskedPending++;
pendingTokenIds.push(createdRow.id);
}
}
const repaired = await repairDefaultToken(accountId);
@@ -270,7 +376,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
return {
created,
updated,
maskedSkipped,
maskedPending,
pendingTokenIds,
total: existing.length,
defaultTokenId: repaired?.id || null,
};
@@ -292,6 +399,7 @@ export async function listTokensWithRelations(accountId?: number) {
const { token, ...tokenMeta } = row.account_tokens;
return {
...tokenMeta,
valueStatus: resolveAccountTokenValueStatus(row.account_tokens),
tokenMasked: maskToken(token, row.sites.platform),
account: {
id: row.accounts.id,
+107
View File
@@ -28,6 +28,7 @@ describe('backupService', () => {
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.routeGroupSources).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
@@ -68,6 +69,9 @@ describe('backupService', () => {
username: 'roundtrip-user',
accessToken: 'session-token',
apiToken: 'api-token',
oauthProvider: 'codex',
oauthAccountKey: 'roundtrip-account-key',
oauthProjectId: 'roundtrip-project-id',
balance: 12.3,
balanceUsed: 4.5,
quota: 99.9,
@@ -93,16 +97,34 @@ describe('backupService', () => {
updatedAt: now,
}).returning().get();
const sourceRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-source-*',
displayName: 'gpt-source',
routeMode: 'pattern',
enabled: true,
createdAt: now,
updatedAt: now,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-*',
displayName: 'gpt-route',
displayIcon: 'icon-gpt',
modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }),
routeMode: 'explicit_group',
decisionSnapshot: JSON.stringify({ channelIds: [1, 2] }),
decisionRefreshedAt: now,
routingStrategy: 'round_robin',
enabled: true,
createdAt: now,
updatedAt: now,
}).returning().get();
await db.insert(schema.routeGroupSources).values({
groupRouteId: route.id,
sourceRouteId: sourceRoute.id,
}).run();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
@@ -141,9 +163,18 @@ describe('backupService', () => {
expect(restoredAccount?.isPinned).toBe(true);
expect(restoredAccount?.sortOrder).toBe(7);
expect(restoredAccount?.oauthProvider).toBe('codex');
expect(restoredAccount?.oauthAccountKey).toBe('roundtrip-account-key');
expect(restoredAccount?.oauthProjectId).toBe('roundtrip-project-id');
expect(restoredRoute?.displayName).toBe('gpt-route');
expect(restoredRoute?.displayIcon).toBe('icon-gpt');
expect(restoredRoute?.routeMode).toBe('explicit_group');
expect(restoredRoute?.decisionSnapshot).toBe('{"channelIds":[1,2]}');
expect(restoredRoute?.decisionRefreshedAt).toBe(now);
expect(restoredRoute?.routingStrategy).toBe('round_robin');
const restoredGroupSource = await db.select().from(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, route.id)).get();
expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id);
expect(restoredChannel?.sourceModel).toBe('gpt-4o');
});
@@ -203,4 +234,80 @@ describe('backupService', () => {
expect(accounts[0].username).toBe('legacy-user');
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
});
it('backfills oauth columns from extraConfig when importing older backups', async () => {
const payload = {
timestamp: Date.now(),
accounts: {
sites: [
{
id: 1,
name: 'codex-site',
url: 'https://codex.example.com',
platform: 'chatgpt-account',
proxyUrl: null,
status: 'active',
isPinned: false,
sortOrder: 0,
apiKey: null,
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
externalCheckinUrl: null,
useSystemProxy: false,
globalWeight: 1,
customHeaders: null,
},
],
accounts: [
{
id: 10,
siteId: 1,
username: 'oauth-user',
accessToken: 'oauth-access-token',
apiToken: null,
balance: 0,
balanceUsed: 0,
quota: 0,
unitCost: null,
valueScore: 0,
status: 'active',
isPinned: false,
sortOrder: 0,
checkinEnabled: true,
lastCheckinAt: null,
lastBalanceRefresh: null,
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
accountId: 'oauth-user@example.com',
accountKey: 'oauth-user@example.com',
projectId: 'oauth-project-id',
refreshToken: 'oauth-refresh-token',
},
}),
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
},
],
accountTokens: [],
tokenRoutes: [],
routeChannels: [],
},
} as Record<string, unknown>;
const result = await backupService.importBackup(payload);
expect(result.allImported).toBe(true);
expect(result.sections.accounts).toBe(true);
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, 10)).get();
expect(restoredAccount?.oauthProvider).toBe('gemini-cli');
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
});
});
+44
View File
@@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm';
import cron from 'node-cron';
import { db, schema } from '../db/index.js';
import { upsertSetting } from '../db/upsertSetting.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
const BACKUP_VERSION = '2.0';
@@ -12,6 +13,7 @@ type AccountRow = typeof schema.accounts.$inferSelect;
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
type RouteChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect;
interface AccountsBackupSection {
sites: SiteRow[];
@@ -19,6 +21,7 @@ interface AccountsBackupSection {
accountTokens: AccountTokenRow[];
tokenRoutes: TokenRouteRow[];
routeChannels: RouteChannelRow[];
routeGroupSources: RouteGroupSourceRow[];
}
interface PreferencesBackupSection {
@@ -104,6 +107,18 @@ function normalizeLegacyQuota(raw: unknown): number {
return value;
}
function resolveImportedOauthColumns(row: Pick<AccountRow, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' | 'extraConfig'>) {
const oauth = getOauthInfoFromExtraConfig(row.extraConfig);
const oauthProvider = row.oauthProvider || oauth?.provider || null;
const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null;
const oauthProjectId = row.oauthProjectId || oauth?.projectId || null;
return {
oauthProvider,
oauthAccountKey,
oauthProjectId,
};
}
function normalizeLegacyPlatform(raw: string): string {
const value = raw.trim().toLowerCase();
if (!value) return 'new-api';
@@ -215,6 +230,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
username,
accessToken: accountAccessToken,
apiToken,
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
balance: importedBalance,
balanceUsed: importedUsed,
quota: importedQuota > 0 ? importedQuota : importedBalance,
@@ -238,6 +256,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
name: 'default',
token: apiToken,
tokenGroup: 'default',
valueStatus: 'ready',
source: 'legacy',
enabled: true,
isDefault: true,
@@ -253,6 +272,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources: [],
};
}
@@ -331,6 +351,7 @@ async function exportAccountsSection(): Promise<AccountsBackupSection> {
const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all();
const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all();
const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all();
const routeGroupSources = await db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all();
return {
sites,
@@ -338,6 +359,7 @@ async function exportAccountsSection(): Promise<AccountsBackupSection> {
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
};
}
@@ -388,6 +410,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null;
const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null;
const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null;
const routeGroupSources = Array.isArray(input.routeGroupSources)
? input.routeGroupSources as RouteGroupSourceRow[]
: [];
if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null;
@@ -397,6 +422,7 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
accountTokens,
tokenRoutes,
routeChannels,
routeGroupSources,
};
}
@@ -460,6 +486,7 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
await db.transaction(async (tx) => {
await tx.delete(schema.routeChannels).run();
await tx.delete(schema.routeGroupSources).run();
await tx.delete(schema.tokenRoutes).run();
await tx.delete(schema.tokenModelAvailability).run();
await tx.delete(schema.modelAvailability).run();
@@ -490,12 +517,16 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
}
for (const row of section.accounts) {
const oauthColumns = resolveImportedOauthColumns(row);
await tx.insert(schema.accounts).values({
id: row.id,
siteId: row.siteId,
username: row.username,
accessToken: row.accessToken,
apiToken: row.apiToken,
oauthProvider: oauthColumns.oauthProvider,
oauthAccountKey: oauthColumns.oauthAccountKey,
oauthProjectId: oauthColumns.oauthProjectId,
balance: row.balance,
balanceUsed: row.balanceUsed,
quota: row.quota,
@@ -520,6 +551,7 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
name: row.name,
token: row.token,
tokenGroup: row.tokenGroup ?? null,
valueStatus: row.valueStatus ?? 'ready',
source: row.source,
enabled: row.enabled,
isDefault: row.isDefault,
@@ -535,12 +567,24 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
displayName: row.displayName ?? null,
displayIcon: row.displayIcon ?? null,
modelMapping: row.modelMapping,
routeMode: row.routeMode ?? 'pattern',
decisionSnapshot: row.decisionSnapshot ?? null,
decisionRefreshedAt: row.decisionRefreshedAt ?? null,
routingStrategy: row.routingStrategy ?? 'weighted',
enabled: row.enabled,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}).run();
}
for (const row of section.routeGroupSources || []) {
await tx.insert(schema.routeGroupSources).values({
id: row.id,
groupRouteId: row.groupRouteId,
sourceRouteId: row.sourceRouteId,
}).run();
}
for (const row of section.routeChannels) {
await tx.insert(schema.routeChannels).values({
id: row.id,
@@ -225,7 +225,20 @@ describe('databaseMigrationService', () => {
checkinLogs: [],
modelAvailability: [],
tokenModelAvailability: [],
tokenRoutes: [],
tokenRoutes: [{
id: 10,
modelPattern: 'claude-opus-4-6',
displayName: 'claude-opus-4-6',
displayIcon: 'icon-claude',
modelMapping: null,
routeMode: 'explicit_group',
decisionSnapshot: '{"channels":[1]}',
decisionRefreshedAt: '2026-03-14T01:30:00.000Z',
routingStrategy: 'round_robin',
enabled: true,
createdAt: '2026-03-14T00:00:00.000Z',
updatedAt: '2026-03-14T01:00:00.000Z',
}],
routeChannels: [],
proxyLogs: [],
proxyVideoTasks: [{
@@ -260,6 +273,11 @@ describe('databaseMigrationService', () => {
updatedAt: '2026-03-14T01:00:00.000Z',
deletedAt: null,
}],
routeGroupSources: [{
id: 9,
groupRouteId: 12,
sourceRouteId: 13,
}],
downstreamApiKeys: [],
events: [],
},
@@ -271,5 +289,10 @@ describe('databaseMigrationService', () => {
expect(statements.some((statement) => statement.table === 'site_disabled_models')).toBe(true);
expect(statements.some((statement) => statement.table === 'proxy_video_tasks')).toBe(true);
expect(statements.some((statement) => statement.table === 'proxy_files')).toBe(true);
expect(statements.some((statement) => statement.table === 'route_group_sources')).toBe(true);
const tokenRouteStatement = statements.find((statement) => statement.table === 'token_routes');
const routeModeIndex = tokenRouteStatement?.columns.indexOf('route_mode') ?? -1;
expect(routeModeIndex).toBeGreaterThanOrEqual(0);
expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group');
});
});
@@ -36,6 +36,7 @@ type BackupSnapshot = {
tokenModelAvailability: Array<Record<string, unknown>>;
tokenRoutes: Array<Record<string, unknown>>;
routeChannels: Array<Record<string, unknown>>;
routeGroupSources: Array<Record<string, unknown>>;
proxyLogs: Array<Record<string, unknown>>;
proxyVideoTasks: Array<Record<string, unknown>>;
proxyFiles: Array<Record<string, unknown>>;
@@ -60,6 +61,7 @@ export interface DatabaseMigrationSummary {
accountTokens: number;
tokenRoutes: number;
routeChannels: number;
routeGroupSources: number;
checkinLogs: number;
modelAvailability: number;
tokenModelAvailability: number;
@@ -218,6 +220,7 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array<Record<string, unknown>>,
tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array<Record<string, unknown>>,
routeChannels: await db.select().from(schema.routeChannels).all() as Array<Record<string, unknown>>,
routeGroupSources: await db.select().from(schema.routeGroupSources).all() as Array<Record<string, unknown>>,
proxyLogs: await db.select().from(schema.proxyLogs).all() as Array<Record<string, unknown>>,
proxyVideoTasks: await db.select().from(schema.proxyVideoTasks).all() as Array<Record<string, unknown>>,
proxyFiles: await db.select().from(schema.proxyFiles).all() as Array<Record<string, unknown>>,
@@ -245,6 +248,7 @@ async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise
async function clearTargetData(client: SqlClient): Promise<void> {
const tables = [
'route_channels',
'route_group_sources',
'token_model_availability',
'model_availability',
'checkin_logs',
@@ -336,13 +340,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.accountTokens) {
statements.push({
table: 'account_tokens',
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
columns: ['id', 'account_id', 'name', 'token', 'token_group', 'value_status', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNumber(row.accountId, 0),
asNullableString(row.name),
asNullableString(row.token),
asNullableString(row.tokenGroup),
asNullableString((row as { valueStatus?: unknown }).valueStatus) ?? 'ready',
asNullableString(row.source) ?? 'manual',
asBoolean(row.enabled, true),
asBoolean(row.isDefault, false),
@@ -400,13 +405,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.tokenRoutes) {
statements.push({
table: 'token_routes',
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.modelPattern),
asNullableString(row.displayName),
asNullableString(row.displayIcon),
asNullableString(row.modelMapping),
asNullableString(row.routeMode) ?? 'pattern',
asNullableString(row.decisionSnapshot),
asNullableString(row.decisionRefreshedAt),
asNullableString(row.routingStrategy),
@@ -445,15 +451,28 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
});
}
for (const row of (snapshot.accounts.routeGroupSources || [])) {
statements.push({
table: 'route_group_sources',
columns: ['id', 'group_route_id', 'source_route_id'],
values: [
asNumber(row.id, 0),
asNumber(row.groupRouteId, 0),
asNumber(row.sourceRouteId, 0),
],
});
}
for (const row of snapshot.accounts.proxyLogs) {
statements.push({
table: 'proxy_logs',
columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'],
columns: ['id', 'route_id', 'channel_id', 'account_id', 'downstream_api_key_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'],
values: [
asNumber(row.id, 0),
asNumber(row.routeId, null),
asNumber(row.channelId, null),
asNumber(row.accountId, null),
asNumber((row as any).downstreamApiKeyId ?? (row as any).downstream_api_key_id, null),
asNullableString(row.modelRequested),
asNullableString(row.modelActual),
asNullableString(row.status),
@@ -606,6 +625,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
'token_model_availability',
'token_routes',
'route_channels',
'route_group_sources',
'proxy_logs',
'proxy_video_tasks',
'proxy_files',
@@ -613,9 +633,9 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
'events',
];
for (const table of tables) {
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`);
}
}
}
export async function bootstrapRuntimeDatabaseSchema(input: Pick<NormalizedDatabaseMigrationInput, 'dialect' | 'connectionString' | 'ssl'>): Promise<void> {
const client = await createClient({
@@ -678,6 +698,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length,
events: snapshot.accounts.events.length,
settings: snapshot.preferences.settings.length,
routeGroupSources: snapshot.accounts.routeGroupSources.length,
},
};
}
@@ -12,6 +12,8 @@ export type DownstreamApiKeyPolicyView = {
key: string;
keyMasked: string;
description: string | null;
groupName: string | null;
tags: string[];
enabled: boolean;
expiresAt: string | null;
maxCost: number | null;
@@ -97,6 +99,34 @@ function parseJson(value: string | null | undefined): unknown {
}
}
export function normalizeGroupNameInput(input: unknown): string | null {
if (typeof input !== 'string') return null;
const value = input.trim();
if (!value) return null;
return value.slice(0, 64);
}
export function normalizeTagsInput(input: unknown): string[] {
const rawValues = Array.isArray(input)
? input
: (typeof input === 'string' ? input.split(/[\r\n,,]+/g) : []);
const tags: string[] = [];
const seen = new Set<string>();
for (const raw of rawValues) {
const value = String(raw || '').trim();
if (!value) continue;
const normalized = value.slice(0, 32);
const dedupeKey = normalized.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
tags.push(normalized);
if (tags.length >= 20) break;
}
return tags;
}
export function normalizeSupportedModelsInput(input: unknown): string[] {
if (Array.isArray(input)) {
return input
@@ -226,6 +256,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
key: row.key,
keyMasked: maskSecret(row.key),
description: row.description || null,
groupName: normalizeGroupNameInput(row.groupName),
tags: normalizeTagsInput(parseJson(row.tags)),
enabled: !!row.enabled,
expiresAt: row.expiresAt || null,
maxCost: row.maxCost ?? null,
@@ -385,6 +417,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
name?: unknown;
key?: unknown;
description?: unknown;
groupName?: unknown;
tags?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
@@ -398,6 +432,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
const description = typeof input.description === 'string'
? input.description.trim()
: '';
const groupName = normalizeGroupNameInput(input.groupName);
const tags = normalizeTagsInput(input.tags);
const enabled = input.enabled === undefined ? true : !!input.enabled;
const expiresAtRaw = typeof input.expiresAt === 'string'
@@ -422,6 +458,8 @@ export function normalizeDownstreamApiKeyPayload(input: {
name,
key,
description: description || null,
groupName,
tags,
enabled,
expiresAt,
maxCost,
@@ -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();
@@ -100,6 +106,42 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(tokenRows).toHaveLength(0);
});
it('deduplicates discovered model names before writing availability rows', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockResolvedValue(['? ', '?', 'GPT-4.1', 'gpt-4.1']);
const site = await db.insert(schema.sites).values({
name: 'site-dedupe',
url: 'https://site-dedupe.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'dedupe-user',
accessToken: 'session-token',
apiToken: null,
status: 'active',
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
modelCount: 2,
modelsPreview: ['?', 'GPT-4.1'],
});
const rows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']);
});
it('marks runtime health unhealthy when model discovery fails', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token'));
@@ -216,4 +258,314 @@ describe('refreshModelsForAccount credential discovery', () => {
reason: 'adapter_or_status',
});
});
it('does not scan masked_pending placeholders as token credentials', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
token === 'sk-mask***tail' ? ['gpt-5.2-codex'] : []
));
const site = await db.insert(schema.sites).values({
name: 'site-placeholder',
url: 'https://site-placeholder.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'placeholder-user',
accessToken: '',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
}).returning().get();
const placeholder = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: true,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'failed',
tokenScanned: 0,
});
const placeholderModels = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, placeholder.id))
.all();
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');
});
it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
undiciFetchMock
.mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({ error: 'unavailable' }),
text: async () => 'unavailable',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
models: {
'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' },
'claude-sonnet-4-5-20250929': { displayName: 'Claude Sonnet 4.5' },
},
}),
text: async () => JSON.stringify({ ok: true }),
});
const site = await db.insert(schema.sites).values({
name: 'antigravity-site',
url: 'https://cloudcode-pa.googleapis.com',
platform: 'antigravity',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'antigravity-user@example.com',
accessToken: 'antigravity-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'antigravity',
email: 'antigravity-user@example.com',
projectId: 'project-demo',
},
}),
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
errorCode: null,
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
modelCount: 2,
modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'],
});
expect(getModelsMock).not.toHaveBeenCalled();
expect(undiciFetchMock).toHaveBeenCalledTimes(2);
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels');
expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels');
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer antigravity-access-token',
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
}),
});
const discoveryHeaders = undiciFetchMock.mock.calls[0]?.[1]?.headers as Record<string, string>;
expect(discoveryHeaders).not.toHaveProperty('X-Goog-Api-Client');
expect(discoveryHeaders).not.toHaveProperty('Client-Metadata');
expect(JSON.parse(String(undiciFetchMock.mock.calls[0]?.[1]?.body || '{}'))).toEqual({
project: 'project-demo',
});
const rows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(rows.map((row) => row.modelName).sort()).toEqual([
'claude-sonnet-4-5-20250929',
'gemini-3-pro-preview',
]);
});
});
+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',
+734 -58
View File
@@ -1,18 +1,122 @@
import { and, eq } from 'drizzle-orm';
import { fetch } from 'undici';
import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js';
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
ensureDefaultTokenForAccount,
getPreferredAccountToken,
isMaskedTokenValue,
isUsableAccountToken,
} from './accountTokenService.js';
import {
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
mergeAccountExtraConfig,
resolvePlatformUserId,
supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { withAccountProxyOverride } from './siteProxy.js';
import { withAccountProxyOverride, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
import {
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_MODELS_USER_AGENT,
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_UPSTREAM_BASE_URL,
} from './oauth/antigravityProvider.js';
import {
GEMINI_CLI_GOOGLE_API_CLIENT,
GEMINI_CLI_REQUIRED_SERVICE,
GEMINI_CLI_USER_AGENT,
} from './oauth/geminiCliProvider.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';
const CLAUDE_STATIC_MODELS = [
'claude-haiku-4-5-20251001',
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-6',
'claude-opus-4-6',
'claude-opus-4-5-20251101',
'claude-opus-4-1-20250805',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-3-7-sonnet-20250219',
'claude-3-5-haiku-20241022',
];
const GEMINI_CLI_STATIC_MODELS = [
'gemini-2.5-pro',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-3-pro-preview',
'gemini-3.1-pro-preview',
'gemini-3-flash-preview',
'gemini-3.1-flash-lite-preview',
];
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
export type ModelRefreshAccountNotFoundResult = {
accountId: number;
refreshed: false;
status: 'failed';
errorCode: 'account_not_found';
errorMessage: '账号不存在';
modelCount: 0;
modelsPreview: string[];
reason: 'account_not_found';
};
export type ModelRefreshSkippedResult = {
accountId: number;
refreshed: false;
status: 'skipped';
errorCode: ModelRefreshSkipCode;
errorMessage: string;
modelCount: 0;
modelsPreview: string[];
reason: ModelRefreshSkipCode;
};
export type ModelRefreshFailureResult = {
accountId: number;
refreshed: true;
status: 'failed';
errorCode: ModelRefreshErrorCode;
errorMessage: string;
modelCount: 0;
modelsPreview: string[];
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
};
export type ModelRefreshSuccessResult = {
accountId: number;
refreshed: true;
status: 'success';
errorCode: null;
errorMessage: '';
modelCount: number;
modelsPreview: string[];
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
};
export type ModelRefreshResult =
| ModelRefreshAccountNotFoundResult
| ModelRefreshSkippedResult
| ModelRefreshFailureResult
| ModelRefreshSuccessResult;
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
const lowered = message.toLowerCase();
@@ -41,14 +145,253 @@ function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boole
}
function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
const normalizedModels: string[] = [];
const seen = new Set<string>();
for (const rawModel of models) {
if (typeof rawModel !== 'string') continue;
const modelName = rawModel.trim();
if (!modelName) continue;
// Keep app/database behavior stable across SQLite/MySQL by deduping with a
// case-insensitive key after trimming whitespace.
const dedupeKey = modelName.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
normalizedModels.push(modelName);
}
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] : [];
});
}
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function extractAntigravityModelIds(payload: unknown): string[] {
if (!payload || typeof payload !== 'object') return [];
const record = payload as { models?: unknown };
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
}
if (!Array.isArray(record.models)) return [];
return record.models.flatMap((item) => {
if (typeof item === 'string') {
const trimmed = item.trim();
return trimmed ? [trimmed] : [];
}
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
const value = item as { id?: unknown; name?: unknown };
const id = typeof value.id === 'string'
? value.id.trim()
: (typeof value.name === 'string' ? value.name.trim() : '');
return id ? [id] : [];
});
}
function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] {
const seen = new Set<string>();
return [
siteUrl,
ANTIGRAVITY_UPSTREAM_BASE_URL,
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
].flatMap((candidate) => {
const normalized = normalizeBaseUrl(candidate);
if (!normalized || seen.has(normalized)) return [];
seen.add(normalized);
return [normalized];
});
}
async function updateOauthModelDiscoveryState(input: {
account: typeof schema.accounts.$inferSelect;
checkedAt: string;
status: 'healthy' | 'abnormal';
lastModelSyncError?: string;
lastDiscoveredModels?: string[];
}) {
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
if (!oauth) return input.account.extraConfig || null;
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
oauth: buildOauthInfo(input.account.extraConfig, {
provider: oauth.provider,
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();
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()));
}
async function validateClaudeOauthConnection(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<void> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('claude oauth access token missing');
}
const response = await fetch(
`${input.site.url.replace(/\/+$/, '')}/v1/models`,
withSiteRecordProxyRequestInit(input.site, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
},
}),
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth validation failed'}`);
}
}
async function validateGeminiCliOauthConnection(input: {
account: typeof schema.accounts.$inferSelect;
}): Promise<void> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('gemini cli oauth access token missing');
}
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
const projectId = (oauth?.projectId || '').trim();
if (!projectId) {
throw new Error('gemini cli oauth project id missing');
}
const response = await fetch(
`https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
},
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`);
}
const payload = await response.json() as { state?: unknown };
if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') {
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
}
}
async function discoverAntigravityModelsFromCloud(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
}): Promise<string[]> {
const accessToken = (input.account.accessToken || '').trim();
if (!accessToken) {
throw new Error('antigravity oauth access token missing');
}
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
const requestBody = oauth?.projectId ? { project: oauth.projectId } : {};
let lastError = '';
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
const response = await fetch(
`${baseUrl}/v1internal:fetchAvailableModels`,
withSiteRecordProxyRequestInit(input.site, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
},
body: JSON.stringify(requestBody),
}),
);
if (!response.ok) {
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
continue;
}
const payload = await response.json();
const models = normalizeModels(extractAntigravityModelIds(payload));
if (models.length > 0) {
return models;
}
lastError = '未获取到可用模型';
}
throw new Error(lastError || '未获取到可用模型');
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
return !/[\*\?]/.test(normalized);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
@@ -65,27 +408,93 @@ async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMe
}
}
export async function refreshModelsForAccount(accountId: number) {
function buildAccountNotFoundRefreshResult(accountId: number): ModelRefreshAccountNotFoundResult {
return {
accountId,
refreshed: false,
status: 'failed',
errorCode: 'account_not_found',
errorMessage: '账号不存在',
modelCount: 0,
modelsPreview: [],
reason: 'account_not_found',
};
}
function buildSkippedRefreshResult(
accountId: number,
code: ModelRefreshSkipCode,
errorMessage: string,
): ModelRefreshSkippedResult {
return {
accountId,
refreshed: false,
status: 'skipped',
errorCode: code,
errorMessage,
modelCount: 0,
modelsPreview: [],
reason: code,
};
}
function buildFailedRefreshResult(input: {
accountId: number;
errorCode: ModelRefreshErrorCode;
errorMessage: string;
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
}): ModelRefreshFailureResult {
return {
accountId: input.accountId,
refreshed: true,
status: 'failed',
errorCode: input.errorCode,
errorMessage: input.errorMessage,
modelCount: 0,
modelsPreview: [],
tokenScanned: input.tokenScanned,
discoveredByCredential: input.discoveredByCredential,
discoveredApiToken: input.discoveredApiToken,
};
}
function buildSuccessfulRefreshResult(input: {
accountId: number;
modelCount: number;
modelsPreview: string[];
tokenScanned: number;
discoveredByCredential: boolean;
discoveredApiToken: boolean;
}): ModelRefreshSuccessResult {
return {
accountId: input.accountId,
refreshed: true,
status: 'success',
errorCode: null,
errorMessage: '',
modelCount: input.modelCount,
modelsPreview: input.modelsPreview,
tokenScanned: input.tokenScanned,
discoveredByCredential: input.discoveredByCredential,
discoveredApiToken: input.discoveredApiToken,
};
}
export async function refreshModelsForAccount(accountId: number): Promise<ModelRefreshResult> {
const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!row) {
return {
accountId,
refreshed: false,
status: 'failed',
errorCode: 'account_not_found',
errorMessage: '账号不存在',
modelCount: 0,
modelsPreview: [],
reason: 'account_not_found',
};
return buildAccountNotFoundRefreshResult(accountId);
}
const account = row.accounts;
const site = row.sites;
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
const adapter = getAdapter(site.platform);
const accountTokens = await db.select()
@@ -104,29 +513,287 @@ export async function refreshModelsForAccount(accountId: number) {
}
if (isSiteDisabled(site.status)) {
return {
accountId,
refreshed: false,
status: 'skipped',
errorCode: 'site_disabled',
errorMessage: '站点已禁用',
modelCount: 0,
modelsPreview: [],
reason: 'site_disabled',
};
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
}
if (!adapter || account.status !== 'active') {
return {
accountId,
refreshed: false,
status: 'skipped',
errorCode: 'adapter_or_status',
errorMessage: '平台不可用或账号未激活',
modelCount: 0,
modelsPreview: [],
reason: 'adapter_or_status',
};
if (account.status !== 'active') {
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
}
if (oauth?.provider === 'codex') {
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 updateOauthModelDiscoveryState({
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 updateOauthModelDiscoveryState({
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,
});
}
}
if (oauth?.provider === 'claude') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
await withTimeout(
() => validateClaudeOauthConnection({ site, account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`claude oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
await db.insert(schema.modelAvailability).values(
CLAUDE_STATIC_MODELS.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: CLAUDE_STATIC_MODELS,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Claude OAuth 健康探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: CLAUDE_STATIC_MODELS.length,
modelsPreview: CLAUDE_STATIC_MODELS.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'claude oauth validation failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Claude 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
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,
});
}
}
if (oauth?.provider === 'gemini-cli') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
await withTimeout(
() => validateGeminiCliOauthConnection({ account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
await db.insert(schema.modelAvailability).values(
GEMINI_CLI_STATIC_MODELS.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: GEMINI_CLI_STATIC_MODELS,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Gemini CLI OAuth 健康探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: GEMINI_CLI_STATIC_MODELS.length,
modelsPreview: GEMINI_CLI_STATIC_MODELS.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'gemini cli oauth validation failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Gemini CLI 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
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,
});
}
}
if (oauth?.provider === 'antigravity') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
const antigravityModels = await withTimeout(
() => discoverAntigravityModelsFromCloud({ site, account }),
MODEL_DISCOVERY_TIMEOUT_MS,
`antigravity model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (antigravityModels.length === 0) {
throw new Error('未获取到可用模型');
}
await db.insert(schema.modelAvailability).values(
antigravityModels.map((modelName) => ({
accountId,
modelName,
available: true,
latencyMs: Date.now() - startedAt,
checkedAt,
})),
).run();
await updateOauthModelDiscoveryState({
account,
checkedAt,
status: 'healthy',
lastDiscoveredModels: antigravityModels,
});
await setAccountRuntimeHealth(accountId, {
state: 'healthy',
reason: 'Antigravity OAuth 健康探测成功',
source: 'model-discovery',
checkedAt,
});
return buildSuccessfulRefreshResult({
accountId,
modelCount: antigravityModels.length,
modelsPreview: antigravityModels.slice(0, 10),
tokenScanned: 0,
discoveredByCredential: true,
discoveredApiToken: false,
});
} catch (err) {
const rawMessage = (err as { message?: string })?.message || 'antigravity model discovery failed';
const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Antigravity 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({
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,
});
}
}
if (!adapter) {
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
@@ -141,20 +808,27 @@ export async function refreshModelsForAccount(accountId: number) {
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (discoveredApiToken) {
if (discoveredApiToken && !isMaskedTokenValue(discoveredApiToken)) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
} else {
discoveredApiToken = null;
}
} catch { }
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
enabledTokens = enabledTokens.filter(isUsableAccountToken);
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
@@ -163,8 +837,13 @@ export async function refreshModelsForAccount(accountId: number) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.where(and(
eq(schema.accountTokens.accountId, account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all();
enabledTokens = enabledTokens.filter(isUsableAccountToken);
}
}
@@ -195,6 +874,7 @@ export async function refreshModelsForAccount(accountId: number) {
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (isMaskedTokenValue(credential)) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
@@ -271,18 +951,14 @@ export async function refreshModelsForAccount(accountId: number) {
source: 'model-discovery',
checkedAt: new Date().toISOString(),
});
return {
return buildFailedRefreshResult({
accountId,
refreshed: true,
status: 'failed',
errorCode,
errorMessage,
modelCount: 0,
modelsPreview: [],
tokenScanned: scannedTokenCount,
discoveredByCredential,
discoveredApiToken: !!discoveredApiToken,
};
});
}
const checkedAt = new Date().toISOString();
@@ -304,26 +980,22 @@ export async function refreshModelsForAccount(accountId: number) {
});
const modelsPreview = Array.from(accountModels).slice(0, 10);
return {
return buildSuccessfulRefreshResult({
accountId,
refreshed: true,
status: 'success',
errorCode: null,
errorMessage: '',
modelCount: accountModels.size,
modelsPreview,
tokenScanned: scannedTokenCount,
discoveredByCredential,
discoveredApiToken: !!discoveredApiToken,
};
});
}
async function refreshModelsForAllActiveAccounts() {
async function refreshModelsForAllActiveAccounts(): Promise<ModelRefreshResult[]> {
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all();
const results: any[] = [];
const results: ModelRefreshResult[] = [];
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
@@ -341,11 +1013,13 @@ export async function rebuildTokenRoutesFromAvailability() {
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens));
const accountRows = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -382,13 +1056,12 @@ export async function rebuildTokenRoutesFromAvailability() {
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
};
for (const row of tokenRows) {
for (const row of usableTokenRows) {
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId);
}
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);
}
@@ -401,7 +1074,7 @@ export async function rebuildTokenRoutesFromAvailability() {
let removedRoutes = 0;
for (const [modelName, candidateMap] of modelCandidates.entries()) {
let route = routes.find((r) => r.modelPattern === modelName);
let route = routes.find((r) => (r.routeMode || 'pattern') !== 'explicit_group' && r.modelPattern === modelName);
if (!route) {
const inserted = await db.insert(schema.tokenRoutes).values({
modelPattern: modelName,
@@ -470,6 +1143,9 @@ export async function rebuildTokenRoutesFromAvailability() {
const latestModelNames = new Set<string>(Array.from(modelCandidates.keys()));
for (const route of routes) {
if ((route.routeMode || 'pattern') === 'explicit_group') {
continue;
}
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
continue;
@@ -0,0 +1,291 @@
import { fetch } from 'undici';
import type { OAuthProviderDefinition } from './providers.js';
export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity';
export const ANTIGRAVITY_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
export const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token';
export const ANTIGRAVITY_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
export const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
export const ANTIGRAVITY_CLIENT_SECRET = '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';
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PORT = 51121;
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PATH = '/oauth-callback';
export const ANTIGRAVITY_LOOPBACK_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_LOOPBACK_CALLBACK_PORT}${ANTIGRAVITY_LOOPBACK_CALLBACK_PATH}`;
export const ANTIGRAVITY_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com';
export const ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.googleapis.com';
export const ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.sandbox.googleapis.com';
export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1';
export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1';
export const ANTIGRAVITY_MODELS_USER_AGENT = 'antigravity/1.19.6 darwin/arm64';
export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
export const ANTIGRAVITY_INTERNAL_API_VERSION = 'v1internal';
export const ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS = 2_000;
export const ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS = 5;
const ANTIGRAVITY_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs',
];
type AntigravityOAuthTokenPayload = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
scope?: unknown;
expiry?: unknown;
};
type AntigravityLoadCodeAssistPayload = {
cloudaicompanionProject?: unknown;
allowedTiers?: unknown;
};
type AntigravityOnboardUserPayload = {
done?: unknown;
response?: unknown;
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefined {
if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) {
return Date.now() + Math.trunc(payload.expires_in) * 1000;
}
if (typeof payload.expires_in === 'string') {
const parsed = Number.parseInt(payload.expires_in.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Date.now() + parsed * 1000;
}
}
if (typeof payload.expiry === 'string') {
const parsed = Date.parse(payload.expiry);
if (!Number.isNaN(parsed)) return parsed;
}
return undefined;
}
function buildAntigravityMetadata() {
return {
ideType: 'ANTIGRAVITY',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
};
}
function extractAntigravityProjectId(value: unknown): string | undefined {
if (typeof value === 'string') {
return asTrimmedString(value);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return asTrimmedString((value as { id?: unknown }).id);
}
return undefined;
}
function extractAntigravityDefaultTierId(payload: AntigravityLoadCodeAssistPayload): string {
const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : [];
for (const rawTier of allowedTiers) {
if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue;
const tier = rawTier as { id?: unknown; isDefault?: unknown };
if (tier.isDefault === true) {
const tierId = asTrimmedString(tier.id);
if (tierId) return tierId;
}
}
return 'legacy-tier';
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function callAntigravityInternalApi<T>(
accessToken: string,
method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>,
): Promise<T | undefined> {
const response = await fetch(
`${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': ANTIGRAVITY_USER_AGENT,
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
},
body: JSON.stringify(body),
},
);
if (!response.ok) return undefined;
return response.json() as Promise<T>;
}
async function postAntigravityToken(body: URLSearchParams) {
const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `antigravity token exchange failed with status ${response.status}`);
}
const payload = await response.json() as AntigravityOAuthTokenPayload;
const accessToken = asTrimmedString(payload.access_token);
if (!accessToken) {
throw new Error('antigravity token exchange response missing access token');
}
return {
accessToken,
refreshToken: asTrimmedString(payload.refresh_token),
tokenExpiresAt: parseExpiresAt(payload),
providerData: {
tokenType: asTrimmedString(payload.token_type),
scope: asTrimmedString(payload.scope),
},
};
}
async function fetchAntigravityUserEmail(accessToken: string): Promise<string | undefined> {
const response = await fetch(ANTIGRAVITY_USERINFO_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) return undefined;
const payload = await response.json() as { email?: unknown };
return asTrimmedString(payload.email);
}
async function fetchAntigravityProjectId(accessToken: string): Promise<string | undefined> {
const metadata = buildAntigravityMetadata();
const payload = await callAntigravityInternalApi<AntigravityLoadCodeAssistPayload>(
accessToken,
'loadCodeAssist',
{ metadata },
);
if (!payload) return undefined;
const discoveredFromLoad = extractAntigravityProjectId(payload.cloudaicompanionProject);
if (discoveredFromLoad) {
return discoveredFromLoad;
}
const tierId = extractAntigravityDefaultTierId(payload);
for (let attempt = 0; attempt < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardPayload = await callAntigravityInternalApi<AntigravityOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
},
);
if (!onboardPayload) return undefined;
if (onboardPayload.done === true) {
const response = (
onboardPayload.response
&& typeof onboardPayload.response === 'object'
&& !Array.isArray(onboardPayload.response)
)
? onboardPayload.response as { cloudaicompanionProject?: unknown }
: undefined;
return extractAntigravityProjectId(response?.cloudaicompanionProject);
}
if ((attempt + 1) < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS) {
await sleep(ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS);
}
}
return undefined;
}
export const antigravityOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: ANTIGRAVITY_OAUTH_PROVIDER,
label: 'Antigravity',
platform: 'antigravity',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: false,
},
site: {
name: 'Google Antigravity OAuth',
url: ANTIGRAVITY_UPSTREAM_BASE_URL,
platform: 'antigravity',
},
loopback: {
host: '127.0.0.1',
port: ANTIGRAVITY_LOOPBACK_CALLBACK_PORT,
path: ANTIGRAVITY_LOOPBACK_CALLBACK_PATH,
redirectUri: ANTIGRAVITY_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: async ({ state, redirectUri }) => {
const params = new URLSearchParams({
client_id: ANTIGRAVITY_CLIENT_ID,
redirect_uri: redirectUri,
response_type: 'code',
access_type: 'offline',
prompt: 'consent',
scope: ANTIGRAVITY_SCOPES.join(' '),
state,
});
return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, redirectUri }) => {
const token = await postAntigravityToken(new URLSearchParams({
code,
client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}));
const email = await fetchAntigravityUserEmail(token.accessToken);
const projectId = await fetchAntigravityProjectId(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId,
};
},
refreshAccessToken: async ({ refreshToken, oauth }) => {
const token = await postAntigravityToken(new URLSearchParams({
client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}));
const email = await fetchAntigravityUserEmail(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken),
};
},
buildProxyHeaders: () => ({
'User-Agent': ANTIGRAVITY_USER_AGENT,
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
}),
};
+152
View File
@@ -0,0 +1,152 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import { createPkceChallenge } from './sessionStore.js';
import type { OAuthProviderDefinition } from './providers.js';
export const CLAUDE_OAUTH_PROVIDER = 'claude';
export const CLAUDE_AUTH_URL = 'https://claude.ai/oauth/authorize';
export const CLAUDE_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token';
export const CLAUDE_CLIENT_ID = config.claudeClientId;
export const CLAUDE_LOOPBACK_CALLBACK_PORT = 54545;
export const CLAUDE_LOOPBACK_CALLBACK_PATH = '/callback';
export const CLAUDE_LOOPBACK_REDIRECT_URI = `http://localhost:${CLAUDE_LOOPBACK_CALLBACK_PORT}${CLAUDE_LOOPBACK_CALLBACK_PATH}`;
export const CLAUDE_UPSTREAM_BASE_URL = 'https://api.anthropic.com';
export const CLAUDE_DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
function requireClaudeClientId(): string {
if (!CLAUDE_CLIENT_ID) {
throw new Error('CLAUDE_CLIENT_ID is not configured');
}
return CLAUDE_CLIENT_ID;
}
type ClaudeTokenResponse = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
organization?: {
uuid?: unknown;
name?: unknown;
};
account?: {
uuid?: unknown;
email_address?: unknown;
};
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseExpiresAt(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Date.now() + Math.trunc(value) * 1000;
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Date.now() + parsed * 1000;
}
}
return undefined;
}
function parseClaudeTokenPayload(payload: unknown) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('claude token exchange returned invalid payload');
}
const body = payload as ClaudeTokenResponse;
const accessToken = asTrimmedString(body.access_token);
if (!accessToken) {
throw new Error('claude token exchange response missing access token');
}
return {
accessToken,
refreshToken: asTrimmedString(body.refresh_token),
tokenExpiresAt: parseExpiresAt(body.expires_in),
email: asTrimmedString(body.account?.email_address),
accountId: asTrimmedString(body.account?.uuid),
accountKey: asTrimmedString(body.account?.uuid) || asTrimmedString(body.account?.email_address),
providerData: {
organizationId: asTrimmedString(body.organization?.uuid),
organizationName: asTrimmedString(body.organization?.name),
},
};
}
async function postClaudeToken(body: Record<string, unknown>) {
const response = await fetch(CLAUDE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `claude token exchange failed with status ${response.status}`);
}
return parseClaudeTokenPayload(await response.json());
}
export const claudeOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: CLAUDE_OAUTH_PROVIDER,
label: 'Claude',
platform: 'claude',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'Anthropic Claude OAuth',
url: CLAUDE_UPSTREAM_BASE_URL,
platform: 'claude',
},
loopback: {
host: '127.0.0.1',
port: CLAUDE_LOOPBACK_CALLBACK_PORT,
path: CLAUDE_LOOPBACK_CALLBACK_PATH,
redirectUri: CLAUDE_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: async ({ state, redirectUri, codeVerifier }) => {
const params = new URLSearchParams({
code: 'true',
client_id: requireClaudeClientId(),
response_type: 'code',
redirect_uri: redirectUri,
scope: 'org:create_api_key user:profile user:inference',
code_challenge: await createPkceChallenge(codeVerifier),
code_challenge_method: 'S256',
state,
});
return `${CLAUDE_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier }) => {
return postClaudeToken({
code,
state,
grant_type: 'authorization_code',
client_id: requireClaudeClientId(),
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
},
refreshAccessToken: async ({ refreshToken }) => {
return postClaudeToken({
client_id: requireClaudeClientId(),
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
},
buildProxyHeaders: () => ({
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
}),
};
+30
View File
@@ -0,0 +1,30 @@
import { schema } from '../../db/index.js';
import {
buildOauthInfo,
getOauthInfoFromExtraConfig,
isOauthProvider,
type OauthInfo,
} from './oauthAccount.js';
export type CodexOauthInfo = OauthInfo & {
provider: 'codex';
};
export function getCodexOauthInfoFromExtraConfig(extraConfig?: string | null): CodexOauthInfo | null {
const oauth = getOauthInfoFromExtraConfig(extraConfig);
if (!oauth || oauth.provider !== 'codex') return null;
return oauth as CodexOauthInfo;
}
export function isCodexPlatform(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
): boolean {
return isOauthProvider(account, 'codex');
}
export function buildCodexOauthInfo(
extraConfig?: string | null,
patch: Partial<CodexOauthInfo> = {},
): CodexOauthInfo {
return buildOauthInfo(extraConfig, { provider: 'codex', ...patch }) as CodexOauthInfo;
}
+233
View File
@@ -0,0 +1,233 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import { createPkceChallenge } from './sessionStore.js';
import type { OAuthProviderDefinition } from './providers.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 = config.codexClientId;
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';
function requireCodexClientId(): string {
if (!CODEX_CLIENT_ID) {
throw new Error('CODEX_CLIENT_ID is not configured');
}
return CODEX_CLIENT_ID;
}
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 async function buildCodexAuthorizationUrl(input: {
state: string;
redirectUri: string;
codeVerifier: string;
}): Promise<string> {
const params = new URLSearchParams({
client_id: requireCodexClientId(),
response_type: 'code',
redirect_uri: input.redirectUri,
scope: 'openid email profile offline_access',
state: input.state,
code_challenge: await 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: requireCodexClientId(),
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: requireCodexClientId(),
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope: 'openid profile email',
}));
}
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;
}
export const codexOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: CODEX_OAUTH_PROVIDER,
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'ChatGPT Codex OAuth',
url: CODEX_UPSTREAM_BASE_URL,
platform: 'codex',
},
loopback: {
host: '127.0.0.1',
port: CODEX_LOOPBACK_CALLBACK_PORT,
path: CODEX_LOOPBACK_CALLBACK_PATH,
redirectUri: CODEX_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({
state,
redirectUri,
codeVerifier,
}),
exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier }) => {
const exchange = await exchangeCodexAuthorizationCode({
code,
redirectUri,
codeVerifier,
});
return {
accessToken: exchange.accessToken,
refreshToken: exchange.refreshToken,
tokenExpiresAt: exchange.tokenExpiresAt,
email: exchange.email,
accountId: exchange.accountId,
accountKey: exchange.accountId,
planType: exchange.planType,
idToken: exchange.idToken,
};
},
refreshAccessToken: async ({ refreshToken }) => {
const refreshed = await refreshCodexTokens(refreshToken);
return {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
tokenExpiresAt: refreshed.tokenExpiresAt,
email: refreshed.email,
accountId: refreshed.accountId,
accountKey: refreshed.accountId,
planType: refreshed.planType,
idToken: refreshed.idToken,
};
},
buildProxyHeaders: ({ oauth, downstreamHeaders }) => {
const accountId = getHeaderValue(downstreamHeaders, 'chatgpt-account-id') || oauth.accountId || oauth.accountKey;
const originator = getHeaderValue(downstreamHeaders, 'originator') || 'codex_cli_rs';
const headers: Record<string, string> = {
Originator: originator,
};
if (accountId) {
headers['Chatgpt-Account-Id'] = accountId;
}
return headers;
},
};
@@ -0,0 +1,470 @@
import { fetch } from 'undici';
import { config } from '../../config.js';
import type { OAuthProviderDefinition } from './providers.js';
export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli';
export const GEMINI_CLI_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
export const GEMINI_CLI_TOKEN_URL = 'https://oauth2.googleapis.com/token';
export const GEMINI_CLI_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
export const GEMINI_CLI_PROJECTS_URL = 'https://cloudresourcemanager.googleapis.com/v1/projects';
export const GEMINI_CLI_SERVICE_USAGE_URL = 'https://serviceusage.googleapis.com/v1';
export const GEMINI_CLI_CLIENT_ID = config.geminiCliClientId;
export const GEMINI_CLI_CLIENT_SECRET = config.geminiCliClientSecret;
export const GEMINI_CLI_LOOPBACK_CALLBACK_PORT = 8085;
export const GEMINI_CLI_LOOPBACK_CALLBACK_PATH = '/oauth2callback';
export const GEMINI_CLI_LOOPBACK_REDIRECT_URI = `http://localhost:${GEMINI_CLI_LOOPBACK_CALLBACK_PORT}${GEMINI_CLI_LOOPBACK_CALLBACK_PATH}`;
export const GEMINI_CLI_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com';
export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0';
export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)';
export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com';
export const GEMINI_CLI_INTERNAL_API_VERSION = 'v1internal';
export const GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS = 2_000;
export const GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS = 15;
export const GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS = 5_000;
export const GEMINI_CLI_ONBOARD_MAX_ATTEMPTS = 6;
function requireGeminiCliOAuthConfig() {
if (!GEMINI_CLI_CLIENT_ID) {
throw new Error('GEMINI_CLI_CLIENT_ID is not configured');
}
if (!GEMINI_CLI_CLIENT_SECRET) {
throw new Error('GEMINI_CLI_CLIENT_SECRET is not configured');
}
return {
clientId: GEMINI_CLI_CLIENT_ID,
clientSecret: GEMINI_CLI_CLIENT_SECRET,
};
}
const GEMINI_CLI_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
type GeminiOAuthTokenPayload = {
access_token?: unknown;
refresh_token?: unknown;
token_type?: unknown;
expires_in?: unknown;
scope?: unknown;
expiry?: unknown;
};
type GeminiLoadCodeAssistPayload = {
cloudaicompanionProject?: unknown;
allowedTiers?: unknown;
};
type GeminiOnboardUserPayload = {
done?: unknown;
response?: unknown;
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | undefined {
if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) {
return Date.now() + Math.trunc(payload.expires_in) * 1000;
}
if (typeof payload.expires_in === 'string') {
const parsed = Number.parseInt(payload.expires_in.trim(), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return Date.now() + parsed * 1000;
}
}
if (typeof payload.expiry === 'string') {
const parsed = Date.parse(payload.expiry);
if (!Number.isNaN(parsed)) return parsed;
}
return undefined;
}
function buildGeminiCliMetadata() {
return {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
};
}
function extractGeminiProjectId(value: unknown): string | undefined {
if (typeof value === 'string') {
return asTrimmedString(value);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
return asTrimmedString((value as { id?: unknown }).id);
}
return undefined;
}
function extractGeminiDefaultTierId(payload: GeminiLoadCodeAssistPayload): string {
const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : [];
for (const rawTier of allowedTiers) {
if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue;
const tier = rawTier as { id?: unknown; isDefault?: unknown };
if (tier.isDefault === true) {
const tierId = asTrimmedString(tier.id);
if (tierId) return tierId;
}
}
return 'legacy-tier';
}
function isGeminiFreeUserProject(input: {
requestedProjectId?: string;
tierId: string;
}) {
const projectId = (input.requestedProjectId || '').trim();
const tierId = input.tierId.trim();
return projectId.startsWith('gen-lang-client-')
|| tierId.toUpperCase() === 'FREE'
|| tierId.toUpperCase() === 'LEGACY';
}
function isSameGeminiProjectId(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
function extractGeminiServiceErrorMessage(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return '';
try {
const parsed = JSON.parse(trimmed) as {
error?: { message?: unknown };
};
return asTrimmedString(parsed.error?.message) || trimmed;
} catch {
return trimmed;
}
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function postGeminiToken(body: URLSearchParams) {
const response = await fetch(GEMINI_CLI_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: body.toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `gemini token exchange failed with status ${response.status}`);
}
const payload = await response.json() as GeminiOAuthTokenPayload;
const accessToken = asTrimmedString(payload.access_token);
if (!accessToken) {
throw new Error('gemini token exchange response missing access token');
}
return {
accessToken,
refreshToken: asTrimmedString(payload.refresh_token),
tokenExpiresAt: parseExpiresAt(payload),
providerData: {
tokenType: asTrimmedString(payload.token_type),
scope: asTrimmedString(payload.scope),
},
};
}
async function fetchGeminiUserEmail(accessToken: string): Promise<string | undefined> {
const response = await fetch(GEMINI_CLI_USERINFO_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) return undefined;
const payload = await response.json() as { email?: unknown };
return asTrimmedString(payload.email);
}
async function callGeminiCliInternalApi<T>(
accessToken: string,
method: 'loadCodeAssist' | 'onboardUser',
body: Record<string, unknown>,
): Promise<T> {
const response = await fetch(
`${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
body: JSON.stringify(body),
},
);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `api request failed with status ${response.status}`);
}
return response.json() as Promise<T>;
}
async function fetchGcpProjects(accessToken: string): Promise<string[]> {
const response = await fetch(GEMINI_CLI_PROJECTS_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `project list request failed with status ${response.status}`);
}
const payload = await response.json() as { projects?: Array<{ projectId?: unknown }> };
return (payload.projects || [])
.map((project) => asTrimmedString(project.projectId))
.filter((projectId): projectId is string => !!projectId);
}
async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise<void> {
const checkResponse = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
},
);
if (checkResponse.ok) {
const payload = await checkResponse.json() as { state?: unknown };
const state = asTrimmedString(payload.state);
if ((state || '').toUpperCase() === 'ENABLED') {
return;
}
}
const response = await fetch(
`${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
},
body: '{}',
},
);
const text = await response.text().catch(() => '');
if (response.ok) {
return;
}
if (response.status === 400 && extractGeminiServiceErrorMessage(text).toLowerCase().includes('already enabled')) {
return;
}
throw new Error(`project activation required: ${extractGeminiServiceErrorMessage(text) || `HTTP ${response.status}`}`);
}
async function performGeminiCliSetup(
accessToken: string,
requestedProjectId: string,
): Promise<string> {
const trimmedRequest = requestedProjectId.trim();
const explicitProject = !!trimmedRequest;
const metadata = buildGeminiCliMetadata();
const loadResponse = await callGeminiCliInternalApi<GeminiLoadCodeAssistPayload>(
accessToken,
'loadCodeAssist',
explicitProject
? {
metadata,
cloudaicompanionProject: trimmedRequest,
}
: { metadata },
);
const tierId = extractGeminiDefaultTierId(loadResponse);
let projectId = trimmedRequest || extractGeminiProjectId(loadResponse.cloudaicompanionProject) || '';
if (!projectId) {
for (let attempt = 0; attempt < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardResponse = await callGeminiCliInternalApi<GeminiOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
},
);
if (onboardResponse.done === true) {
const response = (
onboardResponse.response
&& typeof onboardResponse.response === 'object'
&& !Array.isArray(onboardResponse.response)
)
? onboardResponse.response as { cloudaicompanionProject?: unknown }
: undefined;
projectId = extractGeminiProjectId(response?.cloudaicompanionProject) || '';
break;
}
if ((attempt + 1) < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS) {
await sleep(GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS);
}
}
}
if (!projectId) {
throw new Error('gemini cli: project selection required');
}
let finalProjectId = projectId;
for (let attempt = 0; attempt < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS; attempt += 1) {
const onboardResponse = await callGeminiCliInternalApi<GeminiOnboardUserPayload>(
accessToken,
'onboardUser',
{
tierId,
metadata,
cloudaicompanionProject: projectId,
},
);
if (onboardResponse.done === true) {
const response = (
onboardResponse.response
&& typeof onboardResponse.response === 'object'
&& !Array.isArray(onboardResponse.response)
)
? onboardResponse.response as { cloudaicompanionProject?: unknown }
: undefined;
const responseProjectId = extractGeminiProjectId(response?.cloudaicompanionProject) || '';
if (responseProjectId) {
if (explicitProject && !isSameGeminiProjectId(responseProjectId, projectId)) {
finalProjectId = isGeminiFreeUserProject({ requestedProjectId: projectId, tierId })
? responseProjectId
: projectId;
} else {
finalProjectId = responseProjectId;
}
}
return finalProjectId || projectId;
}
if ((attempt + 1) < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS) {
await sleep(GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS);
}
}
if (finalProjectId) {
return finalProjectId;
}
throw new Error('gemini cli: onboarding timed out');
}
async function ensureGeminiProjectAndOnboard(
accessToken: string,
requestedProjectId?: string,
): Promise<string> {
const explicitProject = asTrimmedString(requestedProjectId);
if (explicitProject) {
return performGeminiCliSetup(accessToken, explicitProject);
}
const projects = await fetchGcpProjects(accessToken);
const firstProject = projects[0];
if (!firstProject) {
throw new Error('no Google Cloud projects available for this account');
}
return performGeminiCliSetup(accessToken, firstProject);
}
export const geminiCliOauthProvider: OAuthProviderDefinition = {
metadata: {
provider: GEMINI_CLI_OAUTH_PROVIDER,
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
site: {
name: 'Google Gemini CLI OAuth',
url: GEMINI_CLI_UPSTREAM_BASE_URL,
platform: 'gemini-cli',
},
loopback: {
host: '127.0.0.1',
port: GEMINI_CLI_LOOPBACK_CALLBACK_PORT,
path: GEMINI_CLI_LOOPBACK_CALLBACK_PATH,
redirectUri: GEMINI_CLI_LOOPBACK_REDIRECT_URI,
},
buildAuthorizationUrl: async ({ state, redirectUri }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const params = new URLSearchParams({
client_id: oauthConfig.clientId,
redirect_uri: redirectUri,
response_type: 'code',
access_type: 'offline',
prompt: 'consent',
scope: GEMINI_CLI_SCOPES.join(' '),
state,
});
return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`;
},
exchangeAuthorizationCode: async ({ code, redirectUri, projectId }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const token = await postGeminiToken(new URLSearchParams({
code,
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}));
const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId);
await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId);
const email = await fetchGeminiUserEmail(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId: resolvedProjectId,
};
},
refreshAccessToken: async ({ refreshToken, oauth }) => {
const oauthConfig = requireGeminiCliOAuthConfig();
const token = await postGeminiToken(new URLSearchParams({
client_id: oauthConfig.clientId,
client_secret: oauthConfig.clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}));
const nextProjectId = oauth?.projectId
? oauth.projectId
: await ensureGeminiProjectAndOnboard(token.accessToken);
await checkCloudAIAPIEnabled(token.accessToken, nextProjectId);
const email = await fetchGeminiUserEmail(token.accessToken);
return {
...token,
email,
accountKey: email,
accountId: email,
projectId: nextProjectId,
};
},
buildProxyHeaders: () => ({
'User-Agent': GEMINI_CLI_USER_AGENT,
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
}),
};
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
startOAuthLoopbackCallbackServer,
stopOAuthLoopbackCallbackServers,
} from './localCallbackServer.js';
describe('oauth loopback callback server', () => {
afterEach(async () => {
await stopOAuthLoopbackCallbackServers();
});
it('accepts codex oauth callback requests and closes the popup on success', async () => {
const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 }));
const started = await startOAuthLoopbackCallbackServer('codex', {
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 a stable error page when oauth completion fails', async () => {
const callbackHandler = vi.fn(async () => {
throw new Error('oauth failed');
});
const started = await startOAuthLoopbackCallbackServer('claude', {
callbackHandler,
});
const response = await fetch(`${started.origin}/callback?state=test-state&code=test-code`);
const body = await response.text();
expect(response.status).toBe(500);
expect(body).toContain('OAuth authorization failed');
expect(body).not.toContain('oauth failed');
});
});
@@ -0,0 +1,245 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { type AddressInfo } from 'node:net';
import { getOAuthProviderDefinition, listOAuthProviderDefinitions } from './providers.js';
import { handleOauthCallback } from './service.js';
type CallbackHandler = typeof handleOauthCallback;
type StartOAuthLoopbackCallbackServerOptions = {
host?: string;
port?: number;
callbackHandler?: CallbackHandler;
};
export type OAuthLoopbackCallbackServerState = {
provider: string;
attempted: boolean;
ready: boolean;
host?: string;
port: number;
path: string;
origin: string;
redirectUri: string;
error?: string;
};
const servers = new Map<string, Server>();
const states = new Map<string, OAuthLoopbackCallbackServerState>();
const startPromises = new Map<string, Promise<OAuthLoopbackCallbackServerState>>();
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>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));
}
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}`;
}
function createDefaultState(provider: string): OAuthLoopbackCallbackServerState {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${provider}`);
}
return {
provider,
attempted: false,
ready: false,
host: definition.loopback.host,
port: definition.loopback.port,
path: definition.loopback.path,
origin: normalizeOrigin(definition.loopback.host, definition.loopback.port),
redirectUri: definition.loopback.redirectUri,
};
}
async function handleCallbackRequest(
provider: string,
request: IncomingMessage,
response: ServerResponse,
callbackHandler: CallbackHandler,
) {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
response.writeHead(404);
response.end('Not found');
return;
}
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 !== definition.loopback.path) {
response.writeHead(404);
response.end('Not found');
return;
}
try {
await callbackHandler({
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 {
respondHtml(response, 500, 'OAuth authorization failed. Return to metapi and review the server logs.');
}
}
export function getOAuthLoopbackCallbackServerState(provider: string): OAuthLoopbackCallbackServerState {
return { ...(states.get(provider) || createDefaultState(provider)) };
}
export function getOAuthLoopbackCallbackServerStates(): OAuthLoopbackCallbackServerState[] {
return listOAuthProviderDefinitions().map((provider) => getOAuthLoopbackCallbackServerState(provider.metadata.provider));
}
export async function startOAuthLoopbackCallbackServer(
provider: string,
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState> {
const definition = getOAuthProviderDefinition(provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${provider}`);
}
if (servers.has(provider)) {
return getOAuthLoopbackCallbackServerState(provider);
}
const existingStart = startPromises.get(provider);
if (existingStart) {
return existingStart;
}
const callbackHandler = options.callbackHandler || handleOauthCallback;
const host = options.host || definition.loopback.host;
const port = options.port ?? definition.loopback.port;
const { path, redirectUri } = definition.loopback;
const startPromise = new Promise<OAuthLoopbackCallbackServerState>((resolve, reject) => {
const server = createServer((request, response) => {
void handleCallbackRequest(provider, request, response, callbackHandler);
});
const finalizeFailure = (error: Error) => {
const failedState: OAuthLoopbackCallbackServerState = {
provider,
attempted: true,
ready: false,
host,
port,
path,
origin: normalizeOrigin(host, port),
redirectUri,
error: error.message || `failed to start ${provider} oauth callback server`,
};
states.set(provider, failedState);
servers.delete(provider);
reject(error);
};
const onStartupError = (error: Error) => {
finalizeFailure(error);
};
server.once('error', onStartupError);
server.listen(port, host, () => {
server.off('error', onStartupError);
servers.set(provider, server);
const address = server.address() as AddressInfo | null;
const listeningPort = address?.port || port;
const listeningHost = address?.address || host;
const nextState: OAuthLoopbackCallbackServerState = {
provider,
attempted: true,
ready: true,
host: listeningHost,
port: listeningPort,
path,
origin: normalizeOrigin(listeningHost, listeningPort),
redirectUri,
};
states.set(provider, nextState);
resolve(getOAuthLoopbackCallbackServerState(provider));
});
}).finally(() => {
startPromises.delete(provider);
});
startPromises.set(provider, startPromise);
return startPromise;
}
export async function startOAuthLoopbackCallbackServers(
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState[]> {
const results = await Promise.allSettled(
listOAuthProviderDefinitions().map((provider) =>
startOAuthLoopbackCallbackServer(provider.metadata.provider, options)),
);
return results.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
return getOAuthLoopbackCallbackServerState(listOAuthProviderDefinitions()[index]!.metadata.provider);
});
}
export async function stopOAuthLoopbackCallbackServers(): Promise<void> {
const activeServers = Array.from(servers.entries());
servers.clear();
states.clear();
startPromises.clear();
await Promise.all(activeServers.map(async ([provider, server]) => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}).catch(() => undefined);
states.set(provider, createDefaultState(provider));
}));
}
export async function startCodexLoopbackCallbackServer(
options: StartOAuthLoopbackCallbackServerOptions = {},
): Promise<OAuthLoopbackCallbackServerState> {
return startOAuthLoopbackCallbackServer('codex', options);
}
export async function stopCodexLoopbackCallbackServer(): Promise<void> {
await stopOAuthLoopbackCallbackServers();
}
+166
View File
@@ -0,0 +1,166 @@
import { schema } from '../../db/index.js';
import type { OauthQuotaSnapshot } from './quotaTypes.js';
type ParsedOauthInfo = {
provider?: unknown;
accountId?: unknown;
accountKey?: unknown;
email?: unknown;
planType?: unknown;
projectId?: unknown;
tokenExpiresAt?: unknown;
refreshToken?: unknown;
idToken?: unknown;
providerData?: unknown;
quota?: unknown;
modelDiscoveryStatus?: unknown;
lastModelSyncAt?: unknown;
lastModelSyncError?: unknown;
lastDiscoveredModels?: unknown;
};
type ParsedExtraConfig = {
oauth?: ParsedOauthInfo;
};
export type OauthModelDiscoveryStatus = 'healthy' | 'abnormal';
export type OauthInfo = {
provider: string;
accountId?: string;
accountKey?: string;
email?: string;
planType?: string;
projectId?: string;
tokenExpiresAt?: number;
refreshToken?: string;
idToken?: string;
providerData?: Record<string, unknown>;
quota?: OauthQuotaSnapshot;
modelDiscoveryStatus?: OauthModelDiscoveryStatus;
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;
return value
.map((item) => asTrimmedString(item))
.filter((item): item is string => !!item);
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function asQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as OauthQuotaSnapshot;
}
function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | 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 getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
const parsed = parseExtraConfig(extraConfig).oauth;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const provider = asTrimmedString(parsed.provider);
if (!provider) return null;
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
return {
provider,
accountId: asTrimmedString(parsed.accountId) || accountKey,
accountKey,
email: asTrimmedString(parsed.email),
planType: asTrimmedString(parsed.planType),
projectId: asTrimmedString(parsed.projectId),
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
refreshToken: asTrimmedString(parsed.refreshToken),
idToken: asTrimmedString(parsed.idToken),
providerData: asRecord(parsed.providerData),
quota: asQuotaSnapshot(parsed.quota),
modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus),
lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt),
lastModelSyncError: asTrimmedString(parsed.lastModelSyncError),
lastDiscoveredModels: asStringArray(parsed.lastDiscoveredModels),
};
}
export function buildOauthInfo(
extraConfig?: string | null,
patch: Partial<OauthInfo> = {},
): OauthInfo {
const provider = patch.provider || getOauthInfoFromExtraConfig(extraConfig)?.provider;
if (!provider) {
throw new Error('oauth provider is required');
}
const current = getOauthInfoFromExtraConfig(extraConfig);
const next: OauthInfo = {
provider,
...(current || {}),
...patch,
};
if (!next.accountKey && next.accountId) {
next.accountKey = next.accountId;
}
if (!next.accountId && next.accountKey) {
next.accountId = next.accountKey;
}
return next;
}
export function isOauthProvider(
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
provider?: string,
): boolean {
const extraConfig = typeof account === 'string' || account == null
? account
: account.extraConfig;
const oauth = getOauthInfoFromExtraConfig(extraConfig);
if (!oauth) return false;
if (!provider) return true;
return oauth.provider === provider;
}
+100
View File
@@ -0,0 +1,100 @@
import { codexOauthProvider } from './codexProvider.js';
import { claudeOauthProvider } from './claudeProvider.js';
import { geminiCliOauthProvider } from './geminiCliProvider.js';
import { antigravityOauthProvider } from './antigravityProvider.js';
export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli' | 'antigravity';
export type OAuthProviderMetadata = {
provider: OAuthProviderId;
label: string;
platform: string;
enabled: boolean;
loginType: 'oauth';
requiresProjectId: boolean;
supportsDirectAccountRouting: boolean;
supportsCloudValidation: boolean;
supportsNativeProxy: boolean;
};
export type OAuthProviderExchangeResult = {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number;
email?: string;
accountKey?: string;
accountId?: string;
planType?: string;
projectId?: string;
idToken?: string;
providerData?: Record<string, unknown>;
};
export type OAuthProviderRefreshResult = OAuthProviderExchangeResult;
export type OAuthProviderProxyHeaderInput = {
oauth: {
provider: string;
accountKey?: string;
accountId?: string;
projectId?: string;
providerData?: Record<string, unknown>;
};
downstreamHeaders?: Record<string, unknown>;
};
export interface OAuthProviderDefinition {
metadata: OAuthProviderMetadata;
site: {
name: string;
url: string;
platform: string;
};
loopback: {
host: string;
port: number;
path: string;
redirectUri: string;
};
buildAuthorizationUrl(input: {
state: string;
redirectUri: string;
codeVerifier: string;
projectId?: string;
}): Promise<string>;
resolveRedirectUri?(input: {
requestOrigin?: string;
}): string;
exchangeAuthorizationCode(input: {
code: string;
state: string;
redirectUri: string;
codeVerifier: string;
projectId?: string;
}): Promise<OAuthProviderExchangeResult>;
refreshAccessToken(input: {
refreshToken: string;
oauth?: {
projectId?: string;
providerData?: Record<string, unknown>;
};
}): Promise<OAuthProviderRefreshResult>;
buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record<string, string>;
}
const PROVIDERS: OAuthProviderDefinition[] = [
codexOauthProvider,
claudeOauthProvider,
geminiCliOauthProvider,
antigravityOauthProvider,
];
const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const));
export function listOAuthProviderDefinitions(): OAuthProviderDefinition[] {
return PROVIDERS.slice();
}
export function getOAuthProviderDefinition(provider: string): OAuthProviderDefinition | undefined {
return PROVIDER_BY_ID.get(provider as OAuthProviderId);
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import {
buildQuotaSnapshotFromOauthInfo,
parseCodexQuotaResetHint,
} from './quota.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 quota snapshot helpers', () => {
it('builds a codex quota snapshot from stored id_token claims and reset hint', () => {
const snapshot = buildQuotaSnapshotFromOauthInfo({
provider: 'codex',
planType: 'plus',
idToken: buildJwt({
email: 'codex-user@example.com',
'https://api.openai.com/auth': {
chatgpt_account_id: 'chatgpt-account-123',
chatgpt_plan_type: 'plus',
chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z',
chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z',
},
}),
quota: {
status: 'supported',
source: 'reverse_engineered',
lastSyncAt: '2026-03-18T01:00:00.000Z',
lastLimitResetAt: '2026-03-18T05:00:00.000Z',
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
},
},
},
});
expect(snapshot).toEqual({
status: 'supported',
source: 'reverse_engineered',
lastSyncAt: '2026-03-18T01:00:00.000Z',
lastLimitResetAt: '2026-03-18T05:00:00.000Z',
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
subscription: {
planType: 'plus',
activeStart: '2026-03-01T00:00:00.000Z',
activeUntil: '2026-04-01T00:00:00.000Z',
},
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
},
},
});
});
it('returns unsupported snapshots for non-codex providers', () => {
const snapshot = buildQuotaSnapshotFromOauthInfo({
provider: 'antigravity',
planType: 'pro',
});
expect(snapshot).toEqual({
status: 'unsupported',
source: 'official',
providerMessage: 'official quota windows are not exposed for antigravity oauth',
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is unavailable for this provider',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is unavailable for this provider',
},
},
});
});
it('parses codex usage_limit_reached reset hints', () => {
const reset = parseCodexQuotaResetHint(429, JSON.stringify({
error: {
type: 'usage_limit_reached',
resets_at: 1773800400,
},
}));
expect(reset).toEqual({
resetAt: '2026-03-18T02:20:00.000Z',
message: 'codex usage_limit_reached reset hint observed from upstream',
});
});
});
+259
View File
@@ -0,0 +1,259 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig, type OauthInfo } from './oauthAccount.js';
import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js';
type CodexJwtClaims = {
'https://api.openai.com/auth'?: {
chatgpt_plan_type?: unknown;
chatgpt_subscription_active_start?: unknown;
chatgpt_subscription_active_until?: unknown;
};
};
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || 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 parseCodexJwtClaims(idToken?: string): CodexJwtClaims | null {
if (!idToken) return null;
const parts = idToken.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;
}
}
function buildUnsupportedWindow(message: string): OauthQuotaWindowSnapshot {
return { supported: false, message };
}
function buildCodexUnsupportedWindows(): OauthQuotaSnapshot['windows'] {
return {
fiveHour: buildUnsupportedWindow('official 5h quota window is not exposed by current codex oauth artifacts'),
sevenDay: buildUnsupportedWindow('official 7d quota window is not exposed by current codex oauth artifacts'),
};
}
function buildProviderUnsupportedSnapshot(provider: string): OauthQuotaSnapshot {
return {
status: 'unsupported',
source: 'official',
providerMessage: `official quota windows are not exposed for ${provider} oauth`,
windows: {
fiveHour: buildUnsupportedWindow('official 5h quota window is unavailable for this provider'),
sevenDay: buildUnsupportedWindow('official 7d quota window is unavailable for this provider'),
},
};
}
function normalizeStoredWindow(value: unknown): OauthQuotaWindowSnapshot | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const supported = typeof raw.supported === 'boolean' ? raw.supported : undefined;
if (supported === undefined) return undefined;
const pickNumber = (field: string) => {
const item = raw[field];
return typeof item === 'number' && Number.isFinite(item) ? item : undefined;
};
const normalized: OauthQuotaWindowSnapshot = {
supported,
};
const limit = pickNumber('limit');
const used = pickNumber('used');
const remaining = pickNumber('remaining');
const resetAt = asIsoDateTime(raw.resetAt);
const message = asTrimmedString(raw.message);
if (limit !== undefined) normalized.limit = limit;
if (used !== undefined) normalized.used = used;
if (remaining !== undefined) normalized.remaining = remaining;
if (resetAt) normalized.resetAt = resetAt;
if (message) normalized.message = message;
return normalized;
}
function normalizeStoredQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const status = raw.status === 'supported' || raw.status === 'unsupported' || raw.status === 'error'
? raw.status
: undefined;
const source = raw.source === 'official' || raw.source === 'reverse_engineered'
? raw.source
: undefined;
const windowsRaw = raw.windows;
if (!status || !source || !windowsRaw || typeof windowsRaw !== 'object' || Array.isArray(windowsRaw)) {
return undefined;
}
const windowsObject = windowsRaw as Record<string, unknown>;
const fiveHour = normalizeStoredWindow(windowsObject.fiveHour);
const sevenDay = normalizeStoredWindow(windowsObject.sevenDay);
if (!fiveHour || !sevenDay) return undefined;
const subscriptionRaw = raw.subscription;
const subscription = subscriptionRaw && typeof subscriptionRaw === 'object' && !Array.isArray(subscriptionRaw)
? {
planType: asTrimmedString((subscriptionRaw as Record<string, unknown>).planType),
activeStart: asIsoDateTime((subscriptionRaw as Record<string, unknown>).activeStart),
activeUntil: asIsoDateTime((subscriptionRaw as Record<string, unknown>).activeUntil),
}
: undefined;
return {
status,
source,
...(asIsoDateTime(raw.lastSyncAt) ? { lastSyncAt: asIsoDateTime(raw.lastSyncAt)! } : {}),
...(asTrimmedString(raw.lastError) ? { lastError: asTrimmedString(raw.lastError)! } : {}),
...(asTrimmedString(raw.providerMessage) ? { providerMessage: asTrimmedString(raw.providerMessage)! } : {}),
...(subscription && (subscription.planType || subscription.activeStart || subscription.activeUntil)
? { subscription }
: {}),
windows: { fiveHour, sevenDay },
...(asIsoDateTime(raw.lastLimitResetAt) ? { lastLimitResetAt: asIsoDateTime(raw.lastLimitResetAt)! } : {}),
};
}
function buildStoredCodexSnapshot(oauth: Pick<OauthInfo, 'planType' | 'idToken' | 'quota'>): OauthQuotaSnapshot {
const claims = parseCodexJwtClaims(oauth.idToken);
const authClaims = claims?.['https://api.openai.com/auth'];
const storedQuota = normalizeStoredQuotaSnapshot(oauth.quota);
const subscription = {
planType: asTrimmedString(authClaims?.chatgpt_plan_type) || oauth.planType,
activeStart: asIsoDateTime(authClaims?.chatgpt_subscription_active_start),
activeUntil: asIsoDateTime(authClaims?.chatgpt_subscription_active_until),
};
return {
status: storedQuota?.status || 'supported',
source: storedQuota?.source || 'reverse_engineered',
...(storedQuota?.lastSyncAt ? { lastSyncAt: storedQuota.lastSyncAt } : {}),
...(storedQuota?.lastError ? { lastError: storedQuota.lastError } : {}),
providerMessage: storedQuota?.providerMessage || 'current codex oauth signals do not expose stable 5h/7d remaining values',
...((subscription.planType || subscription.activeStart || subscription.activeUntil) ? { subscription } : {}),
windows: storedQuota?.windows || buildCodexUnsupportedWindows(),
...(storedQuota?.lastLimitResetAt ? { lastLimitResetAt: storedQuota.lastLimitResetAt } : {}),
};
}
export function buildQuotaSnapshotFromOauthInfo(oauth: Pick<OauthInfo, 'provider' | 'planType' | 'idToken' | 'quota'>): OauthQuotaSnapshot {
if (oauth.provider === 'codex') {
return buildStoredCodexSnapshot(oauth);
}
return buildProviderUnsupportedSnapshot(oauth.provider);
}
export function parseCodexQuotaResetHint(
statusCode: number,
errorBody: string | null | undefined,
nowMs = Date.now(),
): { resetAt: string; message: string } | null {
if (statusCode !== 429 || !errorBody) return null;
try {
const parsed = JSON.parse(errorBody) as Record<string, any>;
const error = parsed?.error;
if (!error || typeof error !== 'object' || error.type !== 'usage_limit_reached') {
return null;
}
if (typeof error.resets_at === 'number' && Number.isFinite(error.resets_at) && error.resets_at > 0) {
return {
resetAt: new Date(error.resets_at * 1000).toISOString(),
message: 'codex usage_limit_reached reset hint observed from upstream',
};
}
if (typeof error.resets_in_seconds === 'number' && Number.isFinite(error.resets_in_seconds) && error.resets_in_seconds > 0) {
return {
resetAt: new Date(nowMs + error.resets_in_seconds * 1000).toISOString(),
message: 'codex usage_limit_reached reset hint observed from upstream',
};
}
} catch {
return null;
}
return null;
}
async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnapshot) {
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 = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, {
oauth: buildOauthInfo(account.extraConfig, {
quota: snapshot,
}),
});
await db.update(schema.accounts).set({
extraConfig: nextExtraConfig,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, accountId)).run();
return snapshot;
}
export async function refreshOauthQuotaSnapshot(accountId: number): Promise<OauthQuotaSnapshot> {
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 = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
const baseSnapshot = buildQuotaSnapshotFromOauthInfo(oauth);
const snapshot: OauthQuotaSnapshot = {
...baseSnapshot,
lastSyncAt: new Date().toISOString(),
...(baseSnapshot.status === 'error' ? {} : { lastError: undefined }),
};
return persistQuotaSnapshot(accountId, snapshot);
}
export async function recordOauthQuotaResetHint(input: {
accountId: number;
statusCode: number;
errorText?: string | null;
}): Promise<OauthQuotaSnapshot | null> {
const resetHint = parseCodexQuotaResetHint(input.statusCode, input.errorText);
if (!resetHint) return null;
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get();
if (!account) return null;
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth || oauth.provider !== 'codex') return null;
const baseSnapshot = buildQuotaSnapshotFromOauthInfo({
...oauth,
quota: {
...normalizeStoredQuotaSnapshot(oauth.quota),
status: 'supported',
source: 'reverse_engineered',
lastLimitResetAt: resetHint.resetAt,
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
windows: normalizeStoredQuotaSnapshot(oauth.quota)?.windows || buildCodexUnsupportedWindows(),
},
});
return persistQuotaSnapshot(input.accountId, {
...baseSnapshot,
lastSyncAt: new Date().toISOString(),
lastLimitResetAt: resetHint.resetAt,
});
}
+26
View File
@@ -0,0 +1,26 @@
export type OauthQuotaWindowSnapshot = {
supported: boolean;
limit?: number | null;
used?: number | null;
remaining?: number | null;
resetAt?: string | null;
message?: string;
};
export type OauthQuotaSnapshot = {
status: 'supported' | 'unsupported' | 'error';
source: 'official' | 'reverse_engineered';
lastSyncAt?: string;
lastError?: string;
providerMessage?: string;
subscription?: {
planType?: string;
activeStart?: string;
activeUntil?: string;
};
windows: {
fiveHour: OauthQuotaWindowSnapshot;
sevenDay: OauthQuotaWindowSnapshot;
};
lastLimitResetAt?: string;
};
@@ -0,0 +1,16 @@
import { refreshOauthAccessToken } from './service.js';
const refreshInFlight = new Map<number, Promise<Awaited<ReturnType<typeof refreshOauthAccessToken>>>>();
export async function refreshOauthAccessTokenSingleflight(accountId: number) {
const existing = refreshInFlight.get(accountId);
if (existing) {
return existing;
}
const promise = refreshOauthAccessToken(accountId).finally(() => {
refreshInFlight.delete(accountId);
});
refreshInFlight.set(accountId, promise);
return promise;
}
+655
View File
@@ -0,0 +1,655 @@
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js';
import {
createOauthSession,
getOauthSession,
markOauthSessionError,
markOauthSessionSuccess,
} from './sessionStore.js';
import { getOAuthLoopbackCallbackServerState } from './localCallbackServer.js';
import {
getOAuthProviderDefinition,
listOAuthProviderDefinitions,
type OAuthProviderDefinition,
} from './providers.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
import { buildCodexOauthInfo } from './codexAccount.js';
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
const MANUAL_CALLBACK_DELAY_MS = 15_000;
type OAuthStartInstructions = {
redirectUri: string;
callbackPort: number;
callbackPath: string;
manualCallbackDelayMs: number;
sshTunnelCommand?: string;
sshTunnelKeyCommand?: string;
};
function isLoopbackHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return normalized === 'localhost'
|| normalized === '127.0.0.1'
|| normalized === '::1'
|| normalized === '[::1]';
}
function resolveSshTunnelHost(requestOrigin?: string): string | undefined {
if (!requestOrigin) return undefined;
try {
const parsed = new URL(requestOrigin);
if (!parsed.hostname || isLoopbackHost(parsed.hostname)) {
return undefined;
}
return parsed.hostname;
} catch {
return undefined;
}
}
function buildLoopbackInstructions(
definition: OAuthProviderDefinition,
requestOrigin?: string,
): OAuthStartInstructions {
const sshHost = resolveSshTunnelHost(requestOrigin);
return {
redirectUri: definition.loopback.redirectUri,
callbackPort: definition.loopback.port,
callbackPath: definition.loopback.path,
manualCallbackDelayMs: MANUAL_CALLBACK_DELAY_MS,
sshTunnelCommand: sshHost
? `ssh -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22`
: undefined,
sshTunnelKeyCommand: sshHost
? `ssh -i <path_to_your_key> -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22`
: undefined,
};
}
function parseManualCallbackUrl(input: {
callbackUrl: string;
provider: string;
}) {
const raw = asNonEmptyString(input.callbackUrl);
if (!raw) {
throw new Error('invalid oauth callback url');
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error('invalid oauth callback url');
}
const state = asNonEmptyString(parsed.searchParams.get('state'));
const code = asNonEmptyString(parsed.searchParams.get('code'));
const error = asNonEmptyString(parsed.searchParams.get('error'));
const errorDescription = asNonEmptyString(parsed.searchParams.get('error_description'));
if (!state || (!code && !error)) {
throw new Error('invalid oauth callback url');
}
return {
state,
code,
error: error
? (errorDescription ? `${error}: ${errorDescription}` : error)
: undefined,
};
}
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function buildUsername(input: {
email?: string;
accountKey?: string;
provider: string;
}) {
return input.email || input.accountKey || `${input.provider}-user`;
}
async function getNextAccountSortOrder(): Promise<number> {
const row = await db.select({
maxSortOrder: sql<number>`COALESCE(MAX(${schema.accounts.sortOrder}), -1)`,
}).from(schema.accounts).get();
return (row?.maxSortOrder ?? -1) + 1;
}
async function getNextSiteSortOrder(): Promise<number> {
const row = await db.select({
maxSortOrder: sql<number>`COALESCE(MAX(${schema.sites.sortOrder}), -1)`,
}).from(schema.sites).get();
return (row?.maxSortOrder ?? -1) + 1;
}
async function ensureOauthSite(definition: OAuthProviderDefinition) {
const existing = await db.select().from(schema.sites).where(and(
eq(schema.sites.platform, definition.site.platform),
eq(schema.sites.url, definition.site.url),
)).get();
if (existing) return existing;
return db.insert(schema.sites).values({
name: definition.site.name,
url: definition.site.url,
platform: definition.site.platform,
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: await getNextSiteSortOrder(),
}).returning().get();
}
async function findExistingOauthAccount(input: {
provider: string;
accountKey?: string;
email?: string;
projectId?: string;
rebindAccountId?: number;
}) {
if (typeof input.rebindAccountId === 'number' && input.rebindAccountId > 0) {
return db.select().from(schema.accounts)
.where(eq(schema.accounts.id, input.rebindAccountId))
.get();
}
const accountKey = asNonEmptyString(input.accountKey);
const email = asNonEmptyString(input.email);
const projectId = asNonEmptyString(input.projectId);
if (accountKey) {
const byKey = await db.select().from(schema.accounts).where(and(
eq(schema.accounts.oauthProvider, input.provider),
eq(schema.accounts.oauthAccountKey, accountKey),
projectId
? eq(schema.accounts.oauthProjectId, projectId)
: or(isNull(schema.accounts.oauthProjectId), eq(schema.accounts.oauthProjectId, '')),
)).get();
if (byKey) return byKey;
}
if (!accountKey && email) {
const byEmail = await db.select().from(schema.accounts).where(and(
eq(schema.accounts.oauthProvider, input.provider),
eq(schema.accounts.username, email),
)).get();
if (byEmail) return byEmail;
}
return null;
}
async function upsertOauthAccount(input: {
definition: OAuthProviderDefinition;
exchange: {
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: number;
email?: string;
accountKey?: string;
accountId?: string;
planType?: string;
projectId?: string;
idToken?: string;
providerData?: Record<string, unknown>;
};
rebindAccountId?: number;
}) {
const site = await ensureOauthSite(input.definition);
const existing = await findExistingOauthAccount({
provider: input.definition.metadata.provider,
accountKey: input.exchange.accountKey || input.exchange.accountId,
email: input.exchange.email,
projectId: input.exchange.projectId,
rebindAccountId: input.rebindAccountId,
});
const username = buildUsername({
email: input.exchange.email,
accountKey: input.exchange.accountKey || input.exchange.accountId,
provider: input.definition.metadata.provider,
});
const oauth = buildOauthInfo(existing?.extraConfig, {
provider: input.definition.metadata.provider,
accountId: input.exchange.accountId || input.exchange.accountKey,
accountKey: input.exchange.accountKey || input.exchange.accountId,
email: input.exchange.email,
planType: input.exchange.planType,
projectId: input.exchange.projectId,
refreshToken: input.exchange.refreshToken,
tokenExpiresAt: input.exchange.tokenExpiresAt,
idToken: input.exchange.idToken,
providerData: input.exchange.providerData,
});
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
credentialMode: 'session',
oauth,
});
if (existing) {
await db.update(schema.accounts).set({
siteId: site.id,
username,
accessToken: input.exchange.accessToken,
apiToken: null,
checkinEnabled: false,
status: 'active',
oauthProvider: input.definition.metadata.provider,
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
oauthProjectId: oauth.projectId || null,
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: input.exchange.accessToken,
apiToken: null,
checkinEnabled: false,
status: 'active',
oauthProvider: input.definition.metadata.provider,
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
oauthProjectId: oauth.projectId || null,
extraConfig,
isPinned: false,
sortOrder: await getNextAccountSortOrder(),
}).returning().get();
return { account: created, site, created: true };
}
export function listOauthProviders() {
return listOAuthProviderDefinitions().map((definition) => {
const state = getOAuthLoopbackCallbackServerState(definition.metadata.provider);
return {
...definition.metadata,
enabled: state.ready || !state.attempted,
};
});
}
export async function startOauthProviderFlow(input: {
provider: string;
rebindAccountId?: number;
projectId?: string;
requestOrigin?: string;
}) {
const definition = getOAuthProviderDefinition(input.provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${input.provider}`);
}
const redirectUri = definition.loopback.redirectUri;
const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider);
if (callbackServerState.attempted && !callbackServerState.ready) {
throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`);
}
const session = createOauthSession({
provider: input.provider,
redirectUri,
rebindAccountId: input.rebindAccountId,
projectId: input.projectId,
});
return {
provider: input.provider,
state: session.state,
authorizationUrl: await definition.buildAuthorizationUrl({
state: session.state,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
projectId: session.projectId,
}),
instructions: buildLoopbackInstructions(definition, input.requestOrigin),
};
}
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');
}
const definition = getOAuthProviderDefinition(input.provider);
if (!definition) {
markOauthSessionError(input.state, `unsupported oauth provider: ${input.provider}`);
throw new Error(`unsupported oauth provider: ${input.provider}`);
}
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');
}
try {
const exchange = await definition.exchangeAuthorizationCode({
code,
state: input.state,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
projectId: session.projectId,
});
const { account, site, created } = await upsertOauthAccount({
definition,
exchange,
rebindAccountId: session.rebindAccountId,
});
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 || `${input.provider} 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 };
} catch (error) {
const message = (
error instanceof Error
? (error.message || error.name)
: String(error || 'OAuth failed')
).trim() || 'OAuth failed';
markOauthSessionError(input.state, message);
throw error;
}
}
export async function submitOauthManualCallback(input: {
state: string;
callbackUrl: string;
}) {
const session = getOauthSession(input.state);
if (!session) {
throw new Error('oauth session not found');
}
const parsed = parseManualCallbackUrl({
callbackUrl: input.callbackUrl,
provider: session.provider,
});
if (parsed.state !== input.state) {
throw new Error('oauth callback state mismatch');
}
await handleOauthCallback({
provider: session.provider,
state: parsed.state,
code: parsed.code,
error: parsed.error,
});
return { success: true };
}
export async function listOauthConnections(options: {
limit?: number;
offset?: number;
} = {}) {
const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
const offset = Math.max(0, Math.trunc(options.offset ?? 0));
const totalRow = await db.select({
count: sql<number>`COUNT(*)`,
}).from(schema.accounts)
.where(sql`${schema.accounts.oauthProvider} IS NOT NULL`)
.get();
const total = totalRow?.count ?? 0;
const rows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(sql`${schema.accounts.oauthProvider} IS NOT NULL`)
.orderBy(desc(schema.accounts.id))
.limit(limit)
.offset(offset)
.all();
const accountIds = rows.map((row) => row.accounts.id);
if (accountIds.length <= 0) {
return { items: [], total, limit, offset };
}
const modelRows = await db.select({
accountId: schema.modelAvailability.accountId,
modelName: schema.modelAvailability.modelName,
}).from(schema.modelAvailability)
.where(and(
inArray(schema.modelAvailability.accountId, accountIds),
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,
count: sql<number>`COUNT(*)`,
}).from(schema.routeChannels)
.where(inArray(schema.routeChannels.accountId, accountIds))
.groupBy(schema.routeChannels.accountId)
.all();
const routeChannelCountByAccount = new Map<number, number>();
for (const row of routeChannelRows) {
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
}
const items = rows.map((row) => {
const oauth = getOauthInfoFromExtraConfig(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: oauth.provider,
username: row.accounts.username,
email: oauth.email,
accountKey: oauth.accountKey || oauth.accountId,
planType: oauth.planType,
projectId: oauth.projectId,
modelCount: models.length,
modelsPreview: models.slice(0, 10),
quota: buildQuotaSnapshotFromOauthInfo(oauth),
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,
},
};
});
return { items, total, limit, offset };
}
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 = getOauthInfoFromExtraConfig(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 refreshOauthConnectionQuota(accountId: number) {
const quota = await refreshOauthQuotaSnapshot(accountId);
return { success: true, quota };
}
export async function startOauthRebindFlow(accountId: number, requestOrigin?: 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 = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth) {
throw new Error('account is not managed by oauth');
}
return startOauthProviderFlow({
provider: oauth.provider,
rebindAccountId: accountId,
projectId: oauth.projectId,
requestOrigin,
});
}
export function buildOauthProviderHeaders(input: {
extraConfig?: string | null;
downstreamHeaders?: Record<string, unknown>;
}) {
const oauth = getOauthInfoFromExtraConfig(input.extraConfig);
if (!oauth) return {};
const definition = getOAuthProviderDefinition(oauth.provider);
if (!definition?.buildProxyHeaders) return {};
return definition.buildProxyHeaders({
oauth,
downstreamHeaders: input.downstreamHeaders,
});
}
export function buildCodexOauthProviderHeaders(input: {
extraConfig?: string | null;
downstreamHeaders?: Record<string, unknown>;
}) {
const oauth = buildCodexOauthInfo(input.extraConfig);
const definition = getOAuthProviderDefinition('codex');
return definition?.buildProxyHeaders?.({
oauth,
downstreamHeaders: input.downstreamHeaders,
}) || {};
}
export async function refreshOauthAccessToken(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 = getOauthInfoFromExtraConfig(account.extraConfig);
if (!oauth?.refreshToken) {
throw new Error('oauth refresh token missing');
}
const definition = getOAuthProviderDefinition(oauth.provider);
if (!definition) {
throw new Error(`unsupported oauth provider: ${oauth.provider}`);
}
const refreshed = await definition.refreshAccessToken({
refreshToken: oauth.refreshToken,
oauth: {
projectId: oauth.projectId,
providerData: oauth.providerData,
},
});
const nextOauth = buildOauthInfo(account.extraConfig, {
provider: oauth.provider,
accountId: refreshed.accountId || oauth.accountId,
accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId,
email: refreshed.email || oauth.email,
planType: refreshed.planType || oauth.planType,
projectId: refreshed.projectId || oauth.projectId,
refreshToken: refreshed.refreshToken || oauth.refreshToken,
tokenExpiresAt: refreshed.tokenExpiresAt || oauth.tokenExpiresAt,
idToken: refreshed.idToken || oauth.idToken,
providerData: {
...(oauth.providerData || {}),
...(refreshed.providerData || {}),
},
});
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
credentialMode: 'session',
oauth: nextOauth,
});
await db.update(schema.accounts).set({
accessToken: refreshed.accessToken,
oauthProvider: oauth.provider,
oauthAccountKey: nextOauth.accountKey || nextOauth.accountId || null,
oauthProjectId: nextOauth.projectId || null,
extraConfig,
status: 'active',
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, accountId)).run();
return {
accountId,
accessToken: refreshed.accessToken,
accountKey: nextOauth.accountKey || nextOauth.accountId,
extraConfig,
};
}
export async function refreshCodexOauthAccessToken(accountId: number) {
return refreshOauthAccessToken(accountId);
}
export type { OAuthProviderMetadata };
+152
View File
@@ -0,0 +1,152 @@
import { randomBytes, webcrypto } 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;
projectId?: string;
};
export interface OAuthSessionStore {
create(input: {
provider: string;
redirectUri: string;
rebindAccountId?: number;
projectId?: string;
}): OAuthSessionRecord;
get(state: string): OAuthSessionRecord | null;
markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null;
markError(state: string, error: string): OAuthSessionRecord | null;
}
const SESSION_TTL_MS = 10 * 60 * 1000;
function nowIso(): string {
return new Date().toISOString();
}
function toBase64Url(input: Buffer): string {
return input.toString('base64url');
}
function createPkceVerifier(): string {
return toBase64Url(randomBytes(48));
}
export async function createPkceChallenge(codeVerifier: string): Promise<string> {
const digest = await webcrypto.subtle.digest('SHA-256', Buffer.from(codeVerifier, 'utf8'));
return Buffer.from(digest).toString('base64url');
}
class MemoryOAuthSessionStore implements OAuthSessionStore {
private readonly sessions = new Map<string, OAuthSessionRecord>();
private pruneExpiredSessions(nowMs = Date.now()) {
for (const [state, session] of this.sessions.entries()) {
if (Date.parse(session.expiresAt) <= nowMs) {
this.sessions.delete(state);
}
}
}
create(input: {
provider: string;
redirectUri: string;
rebindAccountId?: number;
projectId?: string;
}): OAuthSessionRecord {
this.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,
projectId: input.projectId,
};
this.sessions.set(state, record);
return record;
}
get(state: string): OAuthSessionRecord | null {
this.pruneExpiredSessions();
return this.sessions.get(state) || null;
}
markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null {
const existing = this.get(state);
if (!existing) return null;
const next: OAuthSessionRecord = {
...existing,
status: 'success',
updatedAt: nowIso(),
accountId: patch.accountId,
siteId: patch.siteId,
error: undefined,
};
this.sessions.set(state, next);
return next;
}
markError(state: string, error: string): OAuthSessionRecord | null {
const existing = this.get(state);
if (!existing) return null;
const next: OAuthSessionRecord = {
...existing,
status: 'error',
updatedAt: nowIso(),
error: error.trim() || 'OAuth failed',
};
this.sessions.set(state, next);
return next;
}
}
let oauthSessionStore: OAuthSessionStore = new MemoryOAuthSessionStore();
export function setOauthSessionStore(store: OAuthSessionStore) {
oauthSessionStore = store;
}
export function createOauthSession(input: {
provider: string;
redirectUri: string;
rebindAccountId?: number;
projectId?: string;
}): OAuthSessionRecord {
return oauthSessionStore.create(input);
}
export function getOauthSession(state: string): OAuthSessionRecord | null {
return oauthSessionStore.get(state);
}
export function markOauthSessionSuccess(
state: string,
patch: { accountId: number; siteId: number },
): OAuthSessionRecord | null {
return oauthSessionStore.markSuccess(state, patch);
}
export function markOauthSessionError(state: string, error: string): OAuthSessionRecord | null {
return oauthSessionStore.markError(state, error);
}
@@ -0,0 +1,79 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
import {
ANTIGRAVITY_CLIENT_METADATA,
ANTIGRAVITY_GOOGLE_API_CLIENT,
ANTIGRAVITY_UPSTREAM_BASE_URL,
ANTIGRAVITY_USER_AGENT,
} from '../oauth/antigravityProvider.js';
function normalizeBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, '');
}
function extractAntigravityModelNames(payload: unknown): string[] {
if (!payload || typeof payload !== 'object') return [];
const record = payload as { models?: unknown };
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
}
if (Array.isArray(record.models)) {
return record.models.flatMap((item) => {
if (typeof item === 'string') return item.trim() ? [item.trim()] : [];
if (!item || typeof item !== 'object') return [];
const id = typeof (item as { id?: unknown }).id === 'string'
? (item as { id: string }).id.trim()
: (typeof (item as { name?: unknown }).name === 'string'
? (item as { name: string }).name.trim()
: '');
return id ? [id] : [];
});
}
return [];
}
export class AntigravityAdapter extends BasePlatformAdapter {
readonly platformName = 'antigravity';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return normalized.includes('antigravity');
}
override async login(_baseUrl: string, _username: string, _password: string) {
return { success: false, message: 'login endpoint not supported' };
}
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
return null;
}
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
return { success: false, message: 'checkin endpoint not supported' };
}
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
return { balance: 0, used: 0, quota: 0 };
}
async getModels(baseUrl: string, accessToken: string): Promise<string[]> {
try {
const payload = await this.fetchJson<{ models?: unknown }>(
`${normalizeBaseUrl(baseUrl || ANTIGRAVITY_UPSTREAM_BASE_URL)}/v1internal:fetchAvailableModels`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'User-Agent': ANTIGRAVITY_USER_AGENT,
'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT,
'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA,
},
body: JSON.stringify({}),
},
);
return extractAntigravityModelNames(payload);
} catch {
return [];
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
function normalizeBaseUrl(baseUrl: string): string {
let normalized = (baseUrl || '').trim();
while (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
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 [];
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string
}
export class GeminiAdapter extends BasePlatformAdapter {
readonly platformName = 'gemini';
readonly platformName: string = 'gemini';
async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
@@ -0,0 +1,10 @@
import { GeminiAdapter } from './gemini.js';
export class GeminiCliAdapter extends GeminiAdapter {
override readonly platformName = 'gemini-cli';
override async detect(url: string): Promise<boolean> {
const normalized = (url || '').toLowerCase();
return normalized.includes('cloudcode-pa.googleapis.com');
}
}
+36 -11
View File
@@ -7,19 +7,34 @@ async function withHttpServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
run: (baseUrl: string) => Promise<void>,
) {
const server = createServer(handler);
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const { port } = server.address() as AddressInfo;
const baseUrl = `http://127.0.0.1:${port}`;
try {
await run(baseUrl);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => (err ? reject(err) : resolve()));
// Avoid flakiness: CPA uses port 8317 by convention, and our platform detection
// includes a fast-path for localhost:8317. Random ephemeral ports can collide.
for (let attempt = 0; attempt < 5; attempt += 1) {
const server = createServer(handler);
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const { port } = server.address() as AddressInfo;
if (port === 8317) {
await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => (err ? reject(err) : resolve()));
});
continue;
}
const baseUrl = `http://127.0.0.1:${port}`;
try {
await run(baseUrl);
return;
} finally {
await new Promise<void>((resolve, reject) => {
server.close((err?: Error) => (err ? reject(err) : resolve()));
});
}
}
throw new Error('withHttpServer: unable to allocate a test port that avoids 8317');
}
describe('getAdapter platform aliases', () => {
@@ -43,6 +58,16 @@ describe('getAdapter platform aliases', () => {
expect(getAdapter('gemini')?.platformName).toBe('gemini');
});
it('supports antigravity adapter aliases', () => {
expect(getAdapter('antigravity')?.platformName).toBe('antigravity');
expect(getAdapter('anti-gravity')?.platformName).toBe('antigravity');
});
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');
+14
View File
@@ -7,16 +7,22 @@ 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 { GeminiCliAdapter } from './geminiCli.js';
import { AntigravityAdapter } from './antigravity.js';
import { CliProxyApiAdapter } from './cliproxyapi.js';
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 GeminiCliAdapter(),
new AntigravityAdapter(),
new CliProxyApiAdapter(),
new AnyRouterAdapter(),
new DoneHubAdapter(),
@@ -49,9 +55,15 @@ 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',
'gemini-cli': 'gemini-cli',
antigravity: 'antigravity',
'anti-gravity': 'antigravity',
google: 'gemini',
// CLIProxyAPI aliases
cliproxyapi: 'cliproxyapi',
@@ -83,6 +95,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')
@@ -91,6 +104,7 @@ function detectPlatformByUrlHint(url: string): string | undefined {
) {
return 'gemini';
}
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
// NewAPI-family forks and common aliases.
if (normalized.includes('anyrouter')) return 'anyrouter';
+14 -4
View File
@@ -44,10 +44,7 @@ function extractHtmlTitle(html: string): string {
return match[1].replace(/\s+/g, ' ').trim();
}
export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatform | undefined> {
const base = normalizeBaseUrl(url);
if (!base) return undefined;
async function detectPlatformByTitleOnce(base: string): Promise<TitleHintPlatform | undefined> {
try {
const { fetch } = await import('undici');
const res = await fetch(`${base}/`, {
@@ -71,3 +68,16 @@ export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatf
return undefined;
}
}
export async function detectPlatformByTitle(url: string): Promise<TitleHintPlatform | undefined> {
const base = normalizeBaseUrl(url);
if (!base) return undefined;
const first = await detectPlatformByTitleOnce(base);
if (first) return first;
// Under heavy parallel test load, local title probes can occasionally race
// with just-started ephemeral HTTP servers. Retry once before giving up.
await new Promise((resolve) => setTimeout(resolve, 50));
return detectPlatformByTitleOnce(base);
}
+37
View File
@@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
hasProxyLogBillingDetailsColumnMock,
hasProxyLogDownstreamApiKeyIdColumnMock,
dbInsertMock,
dbInsertValuesMock,
dbInsertRunMock,
proxyLogsSchema,
} = vi.hoisted(() => ({
hasProxyLogBillingDetailsColumnMock: vi.fn(),
hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(),
dbInsertMock: vi.fn(),
dbInsertValuesMock: vi.fn(),
dbInsertRunMock: vi.fn(),
@@ -40,6 +42,7 @@ vi.mock('../db/index.js', () => ({
proxyLogs: proxyLogsSchema,
},
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
}));
import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
@@ -47,9 +50,12 @@ import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
describe('proxyLogStore', () => {
beforeEach(() => {
hasProxyLogBillingDetailsColumnMock.mockReset();
hasProxyLogDownstreamApiKeyIdColumnMock.mockReset();
dbInsertMock.mockReset();
dbInsertValuesMock.mockReset();
dbInsertRunMock.mockReset();
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false);
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false);
dbInsertMock.mockReturnValue({
values: (...args: unknown[]) => dbInsertValuesMock(...args),
@@ -95,4 +101,35 @@ describe('proxyLogStore', () => {
});
expect(dbInsertValuesMock.mock.calls[1][0].billingDetails).toBeUndefined();
});
it('falls back to base values when both billing details and downstream key columns are missing', async () => {
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true);
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(true);
dbInsertRunMock
.mockRejectedValueOnce(new Error('column proxy_logs.billing_details does not exist'))
.mockRejectedValueOnce(new Error('column proxy_logs.downstream_api_key_id does not exist'))
.mockResolvedValueOnce(undefined);
await insertProxyLog({
modelRequested: 'gpt-5',
billingDetails: { total: 1 },
downstreamApiKeyId: 12,
});
expect(dbInsertValuesMock).toHaveBeenCalledTimes(3);
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
modelRequested: 'gpt-5',
billingDetails: JSON.stringify({ total: 1 }),
downstreamApiKeyId: 12,
});
expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({
modelRequested: 'gpt-5',
downstreamApiKeyId: 12,
});
expect(dbInsertValuesMock.mock.calls[2][0]).toMatchObject({
modelRequested: 'gpt-5',
});
expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined();
});
});
+47 -4
View File
@@ -1,9 +1,10 @@
import { db, schema, hasProxyLogBillingDetailsColumn } from '../db/index.js';
import { db, schema, hasProxyLogBillingDetailsColumn, hasProxyLogDownstreamApiKeyIdColumn } from '../db/index.js';
export type ProxyLogInsertInput = {
routeId?: number | null;
channelId?: number | null;
accountId?: number | null;
downstreamApiKeyId?: number | null;
modelRequested?: string | null;
modelActual?: string | null;
status?: string | null;
@@ -25,6 +26,7 @@ function buildProxyLogBaseSelectFields() {
routeId: schema.proxyLogs.routeId,
channelId: schema.proxyLogs.channelId,
accountId: schema.proxyLogs.accountId,
downstreamApiKeyId: schema.proxyLogs.downstreamApiKeyId,
modelRequested: schema.proxyLogs.modelRequested,
modelActual: schema.proxyLogs.modelActual,
status: schema.proxyLogs.status,
@@ -108,6 +110,20 @@ export function isMissingBillingDetailsColumnError(error: unknown): boolean {
);
}
export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean {
const message = typeof error === 'object' && error && 'message' in error
? String((error as { message?: unknown }).message || '')
: String(error || '');
const lowered = message.toLowerCase();
return lowered.includes('downstream_api_key_id')
&& (
lowered.includes('does not exist')
|| lowered.includes('unknown column')
|| lowered.includes('no such column')
|| lowered.includes('has no column named')
);
}
export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void> {
const baseValues = {
routeId: input.routeId ?? null,
@@ -131,16 +147,43 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
: JSON.stringify(input.billingDetails);
const includeBillingDetails = serializedBillingDetails !== null
&& await hasProxyLogBillingDetailsColumn();
const includeDownstreamApiKeyId = input.downstreamApiKeyId != null
&& await hasProxyLogDownstreamApiKeyIdColumn();
try {
await db.insert(schema.proxyLogs).values(
includeBillingDetails
? { ...baseValues, billingDetails: serializedBillingDetails }
includeBillingDetails || includeDownstreamApiKeyId
? {
...baseValues,
...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
}
: baseValues,
).run();
} catch (error) {
if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
await db.insert(schema.proxyLogs).values(baseValues).run();
try {
await db.insert(schema.proxyLogs).values(
includeDownstreamApiKeyId
? { ...baseValues, downstreamApiKeyId: input.downstreamApiKeyId }
: baseValues,
).run();
} catch (fallbackError) {
if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(fallbackError)) {
await db.insert(schema.proxyLogs).values(baseValues).run();
return;
}
throw fallbackError;
}
return;
}
if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) {
await db.insert(schema.proxyLogs).values(
includeBillingDetails
? { ...baseValues, billingDetails: serializedBillingDetails }
: baseValues,
).run();
return;
}
throw error;
@@ -154,4 +154,31 @@ describe('TokenRouter patterns and model mapping', () => {
expect(decision.actualModel).toBe('claude-opus-4-5');
expect(exposedModels).toContain('claude-opus-4-6');
});
it('prefers an exact route over a colliding group display-name alias', async () => {
await createRouteWithSingleChannel(
're:^claude-(opus|sonnet)-4-5$',
undefined,
{
displayName: 'claude-opus-4-6',
sourceModel: 'claude-opus-4-5',
},
);
const exact = await createRouteWithSingleChannel(
'claude-opus-4-6',
undefined,
{
sourceModel: 'claude-opus-4-6',
},
);
const router = new TokenRouter();
const selected = await router.selectChannel('claude-opus-4-6');
const decision = await router.explainSelection('claude-opus-4-6');
expect(selected).toBeTruthy();
expect(selected?.channel.id).toBe(exact.channel.id);
expect(selected?.actualModel).toBe('claude-opus-4-6');
expect(decision.actualModel).toBe('claude-opus-4-6');
});
});
@@ -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');
});
});
+6 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { filterRecentlyFailedCandidates, isChannelRecentlyFailed } from './tokenRouter.js';
import { filterRecentlyFailedCandidates, isChannelRecentlyFailed, matchesModelPattern, parseRegexModelPattern } from './tokenRouter.js';
type Candidate = {
channel: {
@@ -10,6 +10,11 @@ type Candidate = {
};
describe('filterRecentlyFailedCandidates', () => {
it('rejects unsafe nested-quantifier regex route patterns', () => {
expect(parseRegexModelPattern('re:^(a+)+$')).toBeNull();
expect(matchesModelPattern('aaaa', 're:^(a+)+$')).toBe(false);
});
it('uses a short default recent-failure window', () => {
const nowMs = Date.now();
expect(isChannelRecentlyFailed({
+320 -77
View File
@@ -1,5 +1,5 @@
import { eq } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { eq, inArray } from 'drizzle-orm';
import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js';
import { config } from '../config.js';
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
@@ -9,10 +9,12 @@ import {
type RouteRoutingStrategy,
} from './routeRoutingStrategy.js';
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
import { isUsableAccountToken } from './accountTokenService.js';
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
interface RouteMatch {
route: typeof schema.tokenRoutes.$inferSelect;
channels: Array<{
interface RouteMatch {
route: RouteRow;
channels: Array<{
channel: typeof schema.routeChannels.$inferSelect;
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
@@ -41,6 +43,7 @@ const FAILURE_BACKOFF_BASE_SEC = 15;
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
const ROUND_ROBIN_FAILURE_THRESHOLD = 3;
const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const;
const MAX_ROUTE_REGEX_BODY_LENGTH = 256;
function fibonacciNumber(index: number): number {
if (index <= 2) return 1;
@@ -64,8 +67,12 @@ function resolveRoundRobinCooldownSec(level: number): number {
return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0;
}
type RouteRow = typeof schema.tokenRoutes.$inferSelect;
type ChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteMode = 'pattern' | 'explicit_group';
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
routeMode: RouteMode;
sourceRouteIds: number[];
};
type ChannelRow = typeof schema.routeChannels.$inferSelect;
type RouteCacheSnapshot = {
loadedAt: number;
@@ -94,34 +101,71 @@ function isCacheFresh(loadedAt: number, nowMs: number): boolean {
}
async function loadEnabledRoutes(nowMs = Date.now()): Promise<RouteRow[]> {
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
return routeCacheSnapshot.routes;
}
const routes = await db.select().from(schema.tokenRoutes)
if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) {
return routeCacheSnapshot.routes;
}
const rawRoutes = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
routeCacheSnapshot = {
loadedAt: nowMs,
routes,
const explicitGroupRouteIds = rawRoutes
.filter((route) => normalizeRouteMode(route.routeMode) === 'explicit_group')
.map((route) => route.id);
const sourceRows = explicitGroupRouteIds.length > 0
? await db.select().from(schema.routeGroupSources)
.where(inArray(schema.routeGroupSources.groupRouteId, explicitGroupRouteIds))
.all()
: [];
const sourceIdsByRouteId = new Map<number, number[]>();
for (const row of sourceRows) {
if (!sourceIdsByRouteId.has(row.groupRouteId)) {
sourceIdsByRouteId.set(row.groupRouteId, []);
}
sourceIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId);
}
const routes = rawRoutes.map((route) => ({
...route,
routeMode: normalizeRouteMode(route.routeMode),
sourceRouteIds: Array.from(new Set(sourceIdsByRouteId.get(route.id) ?? [])),
}));
routeCacheSnapshot = {
loadedAt: nowMs,
routes,
};
return routes;
}
async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise<RouteMatch> {
const cached = routeMatchCache.get(route.id);
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
return cached.match;
}
const channels = await db
.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(eq(schema.routeChannels.routeId, route.id))
.all();
const cached = routeMatchCache.get(route.id);
if (cached && isCacheFresh(cached.loadedAt, nowMs)) {
return cached.match;
}
const routeIds = (() => {
if (!isExplicitGroupRoute(route)) {
return [route.id];
}
return Array.from(new Set(route.sourceRouteIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0)));
})();
const enabledSourceRouteIds = isExplicitGroupRoute(route)
? (await loadEnabledRoutes(nowMs))
.filter((item) => (
routeIds.includes(item.id)
&& !isExplicitGroupRoute(item)
&& isExactRouteModelPattern(item.modelPattern)
))
.map((item) => item.id)
: routeIds;
const channels = enabledSourceRouteIds.length > 0
? await db
.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, enabledSourceRouteIds))
.all()
: [];
const mapped = channels.map((row) => ({
channel: row.route_channels,
@@ -242,24 +286,135 @@ type CostSignal = {
source: 'observed' | 'configured' | 'catalog' | 'fallback';
};
export function isRegexModelPattern(pattern: string): boolean {
return pattern.trim().toLowerCase().startsWith('re:');
export function isRegexModelPattern(pattern: string): boolean {
return pattern.trim().toLowerCase().startsWith('re:');
}
function readRegexQuantifierLength(pattern: string, startIndex: number): number {
const ch = pattern[startIndex];
if (ch === '*' || ch === '+' || ch === '?') return 1;
if (ch !== '{') return 0;
let index = startIndex + 1;
let sawDigit = false;
while (index < pattern.length && /\d/.test(pattern[index])) {
sawDigit = true;
index += 1;
}
if (!sawDigit) return 0;
if (pattern[index] === ',') {
index += 1;
while (index < pattern.length && /\d/.test(pattern[index])) {
index += 1;
}
}
if (pattern[index] !== '}') return 0;
return index - startIndex + 1;
}
function isSafeRegexPatternBody(body: string): boolean {
if (!body || body.length > MAX_ROUTE_REGEX_BODY_LENGTH) return false;
if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) return false;
if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(?<!') || body.includes('(?<')) {
return false;
}
if (/(^|[^\\])\\[1-9]/.test(body)) {
return false;
}
const groupStack: Array<{ hasInnerQuantifier: boolean; hasAlternation: boolean }> = [];
let escaped = false;
let inCharClass = false;
for (let index = 0; index < body.length; index += 1) {
const ch = body[index];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (inCharClass) {
if (ch === ']') inCharClass = false;
continue;
}
if (ch === '[') {
inCharClass = true;
continue;
}
if (ch === '(') {
if (body[index + 1] === '?') {
if (body[index + 2] !== ':') {
return false;
}
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
index += 2;
continue;
}
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
continue;
}
if (ch === '|') {
if (groupStack.length > 0) {
groupStack[groupStack.length - 1].hasAlternation = true;
}
continue;
}
if (ch === ')') {
const group = groupStack.pop();
if (!group) return false;
const quantifierLength = readRegexQuantifierLength(body, index + 1);
if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) {
return false;
}
const parent = groupStack[groupStack.length - 1];
if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) {
parent.hasInnerQuantifier = true;
}
continue;
}
const quantifierLength = readRegexQuantifierLength(body, index);
if (quantifierLength > 0) {
if (groupStack.length > 0) {
groupStack[groupStack.length - 1].hasInnerQuantifier = true;
}
index += quantifierLength - 1;
}
}
return !escaped && !inCharClass && groupStack.length === 0;
}
export function parseRegexModelPattern(pattern: string): RegExp | null {
if (!isRegexModelPattern(pattern)) return null;
const body = pattern.trim().slice(3).trim();
if (!body) return null;
if (!isSafeRegexPatternBody(body)) return null;
try {
return new RegExp(body);
} catch {
return null;
}
}
export function parseRegexModelPattern(pattern: string): RegExp | null {
if (!isRegexModelPattern(pattern)) return null;
const body = pattern.trim().slice(3).trim();
if (!body) return null;
try {
return new RegExp(body);
} catch {
return null;
}
}
export function matchesModelPattern(model: string, pattern: string): boolean {
const normalizedPattern = (pattern || '').trim();
if (!normalizedPattern) return false;
export function matchesModelPattern(model: string, pattern: string): boolean {
const normalizedPattern = (pattern || '').trim();
if (!normalizedPattern) return false;
if (normalizedPattern === model) return true;
@@ -268,25 +423,88 @@ export function matchesModelPattern(model: string, pattern: string): boolean {
return !!re && re.test(model);
}
return minimatch(model, normalizedPattern);
}
return minimatch(model, normalizedPattern);
}
function isExactRouteModelPattern(pattern: string): boolean {
const normalizedPattern = (pattern || '').trim();
if (!normalizedPattern) return false;
if (isRegexModelPattern(normalizedPattern)) return false;
return !/[\*\?\[]/.test(normalizedPattern);
}
function normalizeRouteMode(routeMode: string | null | undefined): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
return (displayName || '').trim();
}
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
return (displayName || '').trim();
}
function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean {
const alias = normalizeRouteDisplayName(displayName);
return !!alias && alias === model;
}
function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean {
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
}
function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string {
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
}
function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean {
const alias = normalizeRouteDisplayName(displayName);
return !!alias && alias === model;
}
function matchesRouteRequestModel(model: string, route: RouteRow): boolean {
if (isExplicitGroupRoute(route)) {
return isRouteDisplayNameMatch(model, route.displayName);
}
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
}
function getExposedModelNameForRoute(route: RouteRow): string {
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
}
function hasCustomDisplayName(route: Pick<RouteRow, 'modelPattern' | 'displayName'>): boolean {
const displayName = normalizeRouteDisplayName(route.displayName);
const modelPattern = (route.modelPattern || '').trim();
return !!displayName && displayName !== modelPattern;
}
function buildVisibleEnabledRoutes(routes: RouteRow[]): RouteRow[] {
const exactModelNames = new Set(
routes
.filter((route) => !isExplicitGroupRoute(route) && isExactRouteModelPattern(route.modelPattern))
.map((route) => (route.modelPattern || '').trim())
.filter(Boolean),
);
const coveringGroups = routes.filter((route) => (
route.enabled
&& (
(isExplicitGroupRoute(route) && normalizeRouteDisplayName(route.displayName).length > 0 && route.sourceRouteIds.length > 0)
|| (!isExplicitGroupRoute(route) && !isExactRouteModelPattern(route.modelPattern) && hasCustomDisplayName(route))
)
));
if (coveringGroups.length === 0) return routes;
return routes.filter((route) => {
if (isExplicitGroupRoute(route)) {
return normalizeRouteDisplayName(route.displayName).length > 0;
}
if (!isExactRouteModelPattern(route.modelPattern)) return true;
if (hasCustomDisplayName(route)) return true;
const exactModel = (route.modelPattern || '').trim();
if (!exactModel) return true;
return !coveringGroups.some((groupRoute) => {
if (groupRoute.id === route.id) return false;
const groupDisplayName = normalizeRouteDisplayName(groupRoute.displayName);
if (!groupDisplayName || exactModelNames.has(groupDisplayName)) return false;
if (isExplicitGroupRoute(groupRoute)) {
return groupRoute.sourceRouteIds.includes(route.id);
}
return matchesModelPattern(exactModel, groupRoute.modelPattern);
});
});
}
function normalizeModelAlias(modelName: string): string {
const normalized = (modelName || '').trim().toLowerCase();
@@ -358,10 +576,10 @@ function normalizeChannelSourceModel(channelSourceModel: string | null | undefin
function resolveActualModelForSelectedChannel(
requestedModel: string,
route: typeof schema.tokenRoutes.$inferSelect,
route: RouteRow,
mappedModel: string,
channelSourceModel: string | null | undefined,
): string {
): string {
const sourceModel = normalizeChannelSourceModel(channelSourceModel);
if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) {
return sourceModel;
@@ -369,7 +587,7 @@ function resolveActualModelForSelectedChannel(
return mappedModel;
}
function resolveRouteStrategy(route: typeof schema.tokenRoutes.$inferSelect): RouteRoutingStrategy {
function resolveRouteStrategy(route: RouteRow): RouteRoutingStrategy {
return normalizeRouteRoutingStrategy(route.routingStrategy);
}
@@ -441,6 +659,17 @@ export class TokenRouter {
if (!match) return null;
return await this.selectFromMatch(match, requestedModel, downstreamPolicy);
}
async previewSelectedChannel(
requestedModel: string,
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
): Promise<SelectedChannel | null> {
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
const match = await this.findRoute(requestedModel, downstreamPolicy);
if (!match) return null;
return await this.selectFromMatch(match, requestedModel, downstreamPolicy, [], false);
}
/**
* Select next channel for failover (exclude already-tried channels).
@@ -884,7 +1113,7 @@ export class TokenRouter {
*/
async getAvailableModels(): Promise<string[]> {
const routes = await loadEnabledRoutes();
const exposed = routes
const exposed = buildVisibleEnabledRoutes(routes)
.map((route) => getExposedModelNameForRoute(route).trim())
.filter((name) => name.length > 0);
return Array.from(new Set(exposed));
@@ -897,6 +1126,7 @@ export class TokenRouter {
requestedModel: string,
downstreamPolicy: DownstreamRoutingPolicy,
excludeChannelIds: number[] = [],
recordSelection = true,
): Promise<SelectedChannel | null> {
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
@@ -922,7 +1152,9 @@ export class TokenRouter {
const tokenValue = this.resolveChannelTokenValue(selected);
if (!tokenValue) return null;
await this.recordChannelSelection(selected.channel.id);
if (recordSelection) {
await this.recordChannelSelection(selected.channel.id);
}
const actualModel = resolveActualModelForSelectedChannel(
requestedModel,
@@ -993,12 +1225,16 @@ export class TokenRouter {
routes = routes.filter((route) => allowSet.has(route.id));
}
// Find matching route by model pattern or display alias.
const matchedRoute = routes.find((r) => {
return matchesRouteRequestModel(model, r);
});
if (!matchedRoute) return null;
const matchedRoute = routes.find((route) => (
!isExplicitGroupRoute(route)
&& isExactRouteModelPattern(route.modelPattern)
&& (route.modelPattern || '').trim() === model
))
|| routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName))
|| routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern));
if (!matchedRoute) return null;
return await this.loadRouteMatch(matchedRoute);
}
@@ -1014,7 +1250,7 @@ export class TokenRouter {
return await this.loadRouteMatch(route);
}
private async loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): Promise<RouteMatch> {
private async loadRouteMatch(route: RouteRow): Promise<RouteMatch> {
return await loadRouteMatch(route);
}
@@ -1023,16 +1259,23 @@ export class TokenRouter {
account: typeof schema.accounts.$inferSelect;
site?: typeof schema.sites.$inferSelect | null;
token: typeof schema.accountTokens.$inferSelect | null;
}): string | null {
}): string | null {
if (candidate.channel.tokenId) {
if (!candidate.token) return null;
if (!candidate.token.enabled) return null;
if (!isUsableAccountToken(candidate.token)) return null;
const token = candidate.token.token?.trim();
return token ? token : null;
}
const fallback = candidate.account.apiToken?.trim();
return fallback || null;
if (fallback) return fallback;
if (getOauthInfoFromExtraConfig(candidate.account.extraConfig)) {
const accessToken = candidate.account.accessToken?.trim();
return accessToken || null;
}
return null;
}
private getCandidateEligibilityReasons(