forked from boos/BoosAPI
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,59 @@ describe('rebuildTokenRoutesFromAvailability', () => {
|
||||
expect(channels[0]?.manualOverride).toBe(false);
|
||||
});
|
||||
|
||||
it('creates an exact route with an account-direct channel for oauth model availability', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'codex-site',
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
platform: 'codex',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'codex-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'team',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'gpt-5.2-codex',
|
||||
available: true,
|
||||
latencyMs: 320,
|
||||
checkedAt: '2026-03-17T00:00:00.000Z',
|
||||
}).run();
|
||||
|
||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||
|
||||
expect(rebuild.models).toBe(1);
|
||||
|
||||
const route = await db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-5.2-codex'))
|
||||
.get();
|
||||
expect(route).toBeDefined();
|
||||
|
||||
const channels = await db.select().from(schema.routeChannels)
|
||||
.where(and(
|
||||
eq(schema.routeChannels.routeId, route!.id),
|
||||
eq(schema.routeChannels.accountId, account.id),
|
||||
))
|
||||
.all();
|
||||
|
||||
expect(channels).toHaveLength(1);
|
||||
expect(channels[0]?.tokenId ?? null).toBeNull();
|
||||
expect(channels[0]?.manualOverride).toBe(false);
|
||||
});
|
||||
|
||||
it('removes stale exact routes and keeps wildcard routes on rebuild', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-1',
|
||||
|
||||
@@ -1,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,
|
||||
}),
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderCompletionPage(message: string): string {
|
||||
const safeMessage = escapeHtml(message);
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user