The transferred dev snapshot was not publication-ready yet: full tests still failed because worktree-local docs resolution assumed a repo-root node_modules tree, some protocol expectations no longer matched the refactored request bridges, oauth/account-token route tests reused stale modules after resetting DATA_DIR, and model discovery still fired unawaited default-token writes. This follow-up makes the moved dev branch self-consistent and green again by stabilizing module/test isolation, awaiting token mirroring writes, and aligning narrow tests with the current transformer and request-builder contracts. Constraint: The fix set had to stay narrow and unblock the new dev branch without reworking the broader transferred refactor Rejected: Revert the transferred snapshot | would lose the user's requested dev-branch continuation point Confidence: medium Scope-risk: moderate Reversibility: clean Directive: If more DATA_DIR-scoped API test files start flaking under shared workers, reset modules before importing singleton db/config modules instead of relying on process env mutation alone Tested: git diff --check; npm run typecheck; npm run repo:drift-check; npm test Not-tested: Live OAuth/provider flows outside the mocked test harness
1311 lines
44 KiB
TypeScript
1311 lines
44 KiB
TypeScript
import { and, eq } from 'drizzle-orm';
|
||
import { db, schema } from '../db/index.js';
|
||
import { getInsertedRowId } from '../db/insertHelpers.js';
|
||
import { getAdapter } from './platforms/index.js';
|
||
import {
|
||
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||
ensureDefaultTokenForAccount,
|
||
getPreferredAccountToken,
|
||
isMaskedTokenValue,
|
||
isUsableAccountToken,
|
||
} from './accountTokenService.js';
|
||
import {
|
||
getCredentialModeFromExtraConfig,
|
||
mergeAccountExtraConfig,
|
||
resolveProxyUrlFromExtraConfig,
|
||
requiresManagedAccountTokens,
|
||
resolvePlatformUserId,
|
||
supportsDirectAccountRoutingConnection,
|
||
} from './accountExtraConfig.js';
|
||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||
import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js';
|
||
import { config } from '../config.js';
|
||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
||
import { withAccountProxyOverride } from './siteProxy.js';
|
||
import { isCodexPlatform } from './oauth/codexAccount.js';
|
||
import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||
import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js';
|
||
import { listEnabledOauthRouteUnitsWithMembers } from './oauth/routeUnitService.js';
|
||
import { requireSiteApiBaseUrl } from './siteApiEndpointService.js';
|
||
import {
|
||
discoverAntigravityModelsFromCloud,
|
||
discoverClaudeModelsFromCloud,
|
||
discoverCodexModelsFromCloud,
|
||
validateGeminiCliOauthConnection,
|
||
} from './platformDiscoveryRegistry.js';
|
||
|
||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||
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',
|
||
];
|
||
let inFlightRefreshModelsAndRebuildRoutes: Promise<{
|
||
refresh: ModelRefreshResult[];
|
||
rebuild: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>>;
|
||
}> | null = null;
|
||
|
||
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;
|
||
|
||
type ModelDiscoveryAccountRow = typeof schema.accounts.$inferSelect;
|
||
const REFRESHED_OAUTH_ACCOUNT = Symbol('refreshedOauthAccount');
|
||
|
||
function throwWithRefreshedOauthAccount(error: unknown, account: ModelDiscoveryAccountRow): never {
|
||
if (error && typeof error === 'object') {
|
||
Object.defineProperty(error, REFRESHED_OAUTH_ACCOUNT, {
|
||
value: account,
|
||
configurable: true,
|
||
});
|
||
throw error;
|
||
}
|
||
|
||
const wrapped = new Error(String(error || 'oauth model discovery failed'));
|
||
Object.defineProperty(wrapped, REFRESHED_OAUTH_ACCOUNT, {
|
||
value: account,
|
||
configurable: true,
|
||
});
|
||
throw wrapped;
|
||
}
|
||
|
||
function getRefreshedOauthAccountFromError(error: unknown): ModelDiscoveryAccountRow | null {
|
||
if (!error || typeof error !== 'object') return null;
|
||
return (
|
||
(error as Record<symbol, ModelDiscoveryAccountRow | undefined>)[REFRESHED_OAUTH_ACCOUNT]
|
||
|| null
|
||
);
|
||
}
|
||
|
||
function looksLikeHtmlJsonParseError(message: string): boolean {
|
||
const lowered = String(message || '').trim().toLowerCase();
|
||
return (
|
||
lowered.includes('unexpected token')
|
||
&& lowered.includes('not valid json')
|
||
&& (lowered.includes('<html') || lowered.includes('<script'))
|
||
);
|
||
}
|
||
|
||
function looksLikeShieldChallenge(message: string): boolean {
|
||
const lowered = String(message || '').trim().toLowerCase();
|
||
return (
|
||
lowered.includes('acw_sc__v2')
|
||
|| lowered.includes('var arg1')
|
||
|| lowered.includes('captcha')
|
||
|| lowered.includes('challenge')
|
||
|| lowered.includes('cloudflare tunnel error')
|
||
);
|
||
}
|
||
|
||
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
|
||
const lowered = message.toLowerCase();
|
||
if (lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('请求超时')) return 'timeout';
|
||
if (lowered.includes('http 401') || lowered.includes('http 403')
|
||
|| lowered.includes('unauthorized') || lowered.includes('invalid')
|
||
|| lowered.includes('无权') || lowered.includes('未提供令牌')) return 'unauthorized';
|
||
return 'unknown';
|
||
}
|
||
|
||
function buildModelFailureMessage(code: ModelRefreshErrorCode, fallback?: string, platform?: string | null) {
|
||
const raw = String(fallback || '').trim();
|
||
if (looksLikeHtmlJsonParseError(raw) || looksLikeShieldChallenge(raw)) {
|
||
const normalizedPlatform = String(platform || '').trim().toLowerCase();
|
||
if (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter') {
|
||
return '模型获取失败:站点返回了防护页面,请在目标站点创建 API Key 后再同步模型';
|
||
}
|
||
return '模型获取失败:站点返回了网页而不是 JSON 响应';
|
||
}
|
||
if (code === 'timeout') return '模型获取失败(请求超时)';
|
||
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
|
||
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
|
||
return fallback || '模型获取失败';
|
||
}
|
||
|
||
function isSiteDisabled(status?: string | null): boolean {
|
||
return (status || 'active') === 'disabled';
|
||
}
|
||
|
||
function normalizeModels(models: string[]): string[] {
|
||
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;
|
||
}
|
||
|
||
async function updateOauthModelDiscoveryState(input: {
|
||
account: typeof schema.accounts.$inferSelect;
|
||
checkedAt: string;
|
||
status: 'healthy' | 'abnormal';
|
||
lastModelSyncError?: string;
|
||
lastDiscoveredModels?: string[];
|
||
}) {
|
||
const oauth = getOauthInfoFromAccount(input.account);
|
||
if (!oauth) return input.account.extraConfig || null;
|
||
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
|
||
oauth: buildStoredOauthStateFromAccount(input.account, {
|
||
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;
|
||
}
|
||
|
||
function isExactModelPattern(modelPattern: string): boolean {
|
||
const normalized = modelPattern.trim();
|
||
if (!normalized) return false;
|
||
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||
return !/[\*\?]/.test(normalized);
|
||
}
|
||
|
||
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||
try {
|
||
return await Promise.race([
|
||
fn(),
|
||
new Promise<T>((_, reject) => {
|
||
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
function shouldRetryModelDiscoveryWithOauthRefresh(error: unknown): boolean {
|
||
const message = ((error as { message?: string })?.message || '').toLowerCase();
|
||
return message.includes('http 401')
|
||
|| message.includes('unauthorized')
|
||
|| message.includes('unauthenticated');
|
||
}
|
||
|
||
async function retryOauthModelDiscoveryWithRefresh<T>(input: {
|
||
account: ModelDiscoveryAccountRow;
|
||
attempt: (account: ModelDiscoveryAccountRow) => Promise<T>;
|
||
}): Promise<{ result: T; account: ModelDiscoveryAccountRow }> {
|
||
let discoveryAccount = input.account;
|
||
|
||
try {
|
||
return {
|
||
result: await input.attempt(discoveryAccount),
|
||
account: discoveryAccount,
|
||
};
|
||
} catch (error) {
|
||
if (!shouldRetryModelDiscoveryWithOauthRefresh(error)) {
|
||
throw error;
|
||
}
|
||
|
||
await refreshOauthAccessTokenSingleflight(discoveryAccount.id);
|
||
const refreshedAccount = await db.select().from(schema.accounts)
|
||
.where(eq(schema.accounts.id, discoveryAccount.id))
|
||
.get();
|
||
if (!refreshedAccount) {
|
||
throw error;
|
||
}
|
||
|
||
discoveryAccount = refreshedAccount;
|
||
try {
|
||
return {
|
||
result: await input.attempt(discoveryAccount),
|
||
account: discoveryAccount,
|
||
};
|
||
} catch (retryError) {
|
||
throwWithRefreshedOauthAccount(retryError, discoveryAccount);
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function refreshModelsForAccount(
|
||
accountId: number,
|
||
options?: { allowInactive?: boolean },
|
||
): 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 buildAccountNotFoundRefreshResult(accountId);
|
||
}
|
||
|
||
const account = row.accounts;
|
||
const site = row.sites;
|
||
const oauth = getOauthInfoFromAccount(account);
|
||
const adapter = getAdapter(site.platform);
|
||
const accountProxyUrl = resolveProxyUrlFromExtraConfig(account.extraConfig);
|
||
|
||
const restoreAvailabilityOnFailure = options?.allowInactive === true;
|
||
const previousAccountTokens = restoreAvailabilityOnFailure
|
||
? await db.select()
|
||
.from(schema.accountTokens)
|
||
.where(eq(schema.accountTokens.accountId, accountId))
|
||
.all()
|
||
: [];
|
||
const previousModelAvailability = restoreAvailabilityOnFailure
|
||
? await db.select()
|
||
.from(schema.modelAvailability)
|
||
.where(and(
|
||
eq(schema.modelAvailability.accountId, accountId),
|
||
eq(schema.modelAvailability.isManual, false),
|
||
))
|
||
.all()
|
||
: [];
|
||
const previousTokenModelAvailability = restoreAvailabilityOnFailure
|
||
? (await Promise.all(previousAccountTokens.map(async (token) => db.select()
|
||
.from(schema.tokenModelAvailability)
|
||
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
|
||
.all()))).flat()
|
||
: [];
|
||
|
||
const clearExistingAvailability = async () => {
|
||
await db.delete(schema.modelAvailability)
|
||
.where(and(
|
||
eq(schema.modelAvailability.accountId, accountId),
|
||
eq(schema.modelAvailability.isManual, false),
|
||
))
|
||
.run();
|
||
|
||
const currentAccountTokens = await db.select({ id: schema.accountTokens.id })
|
||
.from(schema.accountTokens)
|
||
.where(eq(schema.accountTokens.accountId, accountId))
|
||
.all();
|
||
|
||
for (const token of currentAccountTokens) {
|
||
await db.delete(schema.tokenModelAvailability)
|
||
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
|
||
.run();
|
||
}
|
||
};
|
||
|
||
const restorePreviousAvailability = async () => {
|
||
if (!restoreAvailabilityOnFailure) return;
|
||
await clearExistingAvailability();
|
||
if (previousModelAvailability.length > 0) {
|
||
await db.insert(schema.modelAvailability).values(
|
||
previousModelAvailability.map(({ id: _id, ...row }) => row),
|
||
).run();
|
||
}
|
||
if (previousTokenModelAvailability.length > 0) {
|
||
await db.insert(schema.tokenModelAvailability).values(
|
||
previousTokenModelAvailability.map(({ id: _id, ...row }) => row),
|
||
).run();
|
||
}
|
||
};
|
||
|
||
await clearExistingAvailability();
|
||
|
||
// Collect manual model names so discovered models that collide are skipped (unique index).
|
||
const manualModelNames = new Set(
|
||
(await db.select({ modelName: schema.modelAvailability.modelName })
|
||
.from(schema.modelAvailability)
|
||
.where(and(
|
||
eq(schema.modelAvailability.accountId, accountId),
|
||
eq(schema.modelAvailability.isManual, true),
|
||
))
|
||
.all()
|
||
).map((r) => r.modelName.toLowerCase()),
|
||
);
|
||
|
||
if (isSiteDisabled(site.status)) {
|
||
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
|
||
}
|
||
|
||
if (account.status !== 'active' && !options?.allowInactive) {
|
||
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
||
}
|
||
|
||
if (oauth?.provider === 'codex') {
|
||
const checkedAt = new Date().toISOString();
|
||
const startedAt = Date.now();
|
||
let discoveryAccount = account;
|
||
try {
|
||
const { result: codexModels, account: refreshedAccount } = await retryOauthModelDiscoveryWithRefresh({
|
||
account,
|
||
attempt: async (candidateAccount) => withTimeout(
|
||
() => withAccountProxyOverride(accountProxyUrl,
|
||
() => discoverCodexModelsFromCloud({ site, account: candidateAccount })),
|
||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||
`codex model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||
),
|
||
});
|
||
discoveryAccount = refreshedAccount;
|
||
if (codexModels.length === 0) {
|
||
throw new Error('未获取到可用模型');
|
||
}
|
||
|
||
const newCodexModels = codexModels.filter((m) => !manualModelNames.has(m.toLowerCase()));
|
||
if (newCodexModels.length > 0) {
|
||
await db.insert(schema.modelAvailability).values(
|
||
newCodexModels.map((modelName) => ({
|
||
accountId,
|
||
modelName,
|
||
available: true,
|
||
latencyMs: Date.now() - startedAt,
|
||
checkedAt,
|
||
})),
|
||
).run();
|
||
}
|
||
await updateOauthModelDiscoveryState({
|
||
account: discoveryAccount,
|
||
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) {
|
||
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
|
||
const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed';
|
||
const errorCode = classifyModelDiscoveryError(rawMessage);
|
||
const errorMessage = `Codex 模型获取失败(${rawMessage})`;
|
||
await updateOauthModelDiscoveryState({
|
||
account: discoveryAccount,
|
||
checkedAt,
|
||
status: 'abnormal',
|
||
lastModelSyncError: errorMessage,
|
||
lastDiscoveredModels: [],
|
||
});
|
||
await setAccountRuntimeHealth(account.id, {
|
||
state: 'unhealthy',
|
||
reason: errorMessage,
|
||
source: 'model-discovery',
|
||
checkedAt,
|
||
});
|
||
await restorePreviousAvailability();
|
||
return buildFailedRefreshResult({
|
||
accountId,
|
||
errorCode,
|
||
errorMessage,
|
||
tokenScanned: 0,
|
||
discoveredByCredential: false,
|
||
discoveredApiToken: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (oauth?.provider === 'claude') {
|
||
const checkedAt = new Date().toISOString();
|
||
const startedAt = Date.now();
|
||
let discoveryAccount = account;
|
||
try {
|
||
const { result: claudeModels, account: refreshedAccount } = await retryOauthModelDiscoveryWithRefresh({
|
||
account,
|
||
attempt: async (candidateAccount) => withTimeout(
|
||
() => withAccountProxyOverride(accountProxyUrl,
|
||
() => discoverClaudeModelsFromCloud({ site, account: candidateAccount })),
|
||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||
`claude oauth model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||
),
|
||
});
|
||
discoveryAccount = refreshedAccount;
|
||
if (claudeModels.length === 0) {
|
||
throw new Error('未获取到可用模型');
|
||
}
|
||
const newClaudeModels = claudeModels.filter((m) => !manualModelNames.has(m.toLowerCase()));
|
||
if (newClaudeModels.length > 0) {
|
||
await db.insert(schema.modelAvailability).values(
|
||
newClaudeModels.map((modelName) => ({
|
||
accountId,
|
||
modelName,
|
||
available: true,
|
||
latencyMs: Date.now() - startedAt,
|
||
checkedAt,
|
||
})),
|
||
).run();
|
||
}
|
||
await updateOauthModelDiscoveryState({
|
||
account: discoveryAccount,
|
||
checkedAt,
|
||
status: 'healthy',
|
||
lastDiscoveredModels: claudeModels,
|
||
});
|
||
await setAccountRuntimeHealth(accountId, {
|
||
state: 'healthy',
|
||
reason: 'Claude OAuth 模型探测成功',
|
||
source: 'model-discovery',
|
||
checkedAt,
|
||
});
|
||
return buildSuccessfulRefreshResult({
|
||
accountId,
|
||
modelCount: claudeModels.length,
|
||
modelsPreview: claudeModels.slice(0, 10),
|
||
tokenScanned: 0,
|
||
discoveredByCredential: true,
|
||
discoveredApiToken: false,
|
||
});
|
||
} catch (err) {
|
||
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
|
||
const rawMessage = (err as { message?: string })?.message || 'claude oauth model discovery failed';
|
||
const errorCode = classifyModelDiscoveryError(rawMessage);
|
||
const errorMessage = `Claude OAuth 模型获取失败(${rawMessage})`;
|
||
await updateOauthModelDiscoveryState({
|
||
account: discoveryAccount,
|
||
checkedAt,
|
||
status: 'abnormal',
|
||
lastModelSyncError: errorMessage,
|
||
lastDiscoveredModels: [],
|
||
});
|
||
await setAccountRuntimeHealth(account.id, {
|
||
state: 'unhealthy',
|
||
reason: errorMessage,
|
||
source: 'model-discovery',
|
||
checkedAt,
|
||
});
|
||
await restorePreviousAvailability();
|
||
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();
|
||
let discoveryAccount = account;
|
||
try {
|
||
try {
|
||
await withTimeout(
|
||
() => withAccountProxyOverride(accountProxyUrl,
|
||
() => validateGeminiCliOauthConnection({ site, account: discoveryAccount })),
|
||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||
);
|
||
} catch (error) {
|
||
if (!shouldRetryModelDiscoveryWithOauthRefresh(error)) {
|
||
throw error;
|
||
}
|
||
const refreshed = await refreshOauthAccessTokenSingleflight(discoveryAccount.id);
|
||
if (!refreshed?.extraConfig) {
|
||
throw error;
|
||
}
|
||
discoveryAccount = {
|
||
...discoveryAccount,
|
||
accessToken: refreshed.accessToken,
|
||
extraConfig: refreshed.extraConfig,
|
||
};
|
||
await withTimeout(
|
||
() => withAccountProxyOverride(accountProxyUrl,
|
||
() => validateGeminiCliOauthConnection({ site, account: discoveryAccount })),
|
||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||
);
|
||
}
|
||
const newGeminiModels = GEMINI_CLI_STATIC_MODELS.filter((m) => !manualModelNames.has(m.toLowerCase()));
|
||
if (newGeminiModels.length > 0) {
|
||
await db.insert(schema.modelAvailability).values(
|
||
newGeminiModels.map((modelName) => ({
|
||
accountId,
|
||
modelName,
|
||
available: true,
|
||
latencyMs: Date.now() - startedAt,
|
||
checkedAt,
|
||
})),
|
||
).run();
|
||
}
|
||
await updateOauthModelDiscoveryState({
|
||
account: discoveryAccount,
|
||
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: discoveryAccount,
|
||
checkedAt,
|
||
status: 'abnormal',
|
||
lastModelSyncError: errorMessage,
|
||
lastDiscoveredModels: [],
|
||
});
|
||
await setAccountRuntimeHealth(account.id, {
|
||
state: 'unhealthy',
|
||
reason: errorMessage,
|
||
source: 'model-discovery',
|
||
checkedAt,
|
||
});
|
||
await restorePreviousAvailability();
|
||
return buildFailedRefreshResult({
|
||
accountId,
|
||
errorCode,
|
||
errorMessage,
|
||
tokenScanned: 0,
|
||
discoveredByCredential: false,
|
||
discoveredApiToken: false,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (oauth?.provider === 'antigravity') {
|
||
const checkedAt = new Date().toISOString();
|
||
const startedAt = Date.now();
|
||
let discoveryAccount = account;
|
||
try {
|
||
const { result: antigravityModels, account: refreshedAccount } = await retryOauthModelDiscoveryWithRefresh({
|
||
account,
|
||
attempt: async (candidateAccount) => withTimeout(
|
||
() => withAccountProxyOverride(accountProxyUrl,
|
||
() => discoverAntigravityModelsFromCloud({ site, account: candidateAccount })),
|
||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||
`antigravity model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||
),
|
||
});
|
||
discoveryAccount = refreshedAccount;
|
||
if (antigravityModels.length === 0) {
|
||
throw new Error('未获取到可用模型');
|
||
}
|
||
|
||
const newAntigravityModels = antigravityModels.filter((m) => !manualModelNames.has(m.toLowerCase()));
|
||
if (newAntigravityModels.length > 0) {
|
||
await db.insert(schema.modelAvailability).values(
|
||
newAntigravityModels.map((modelName) => ({
|
||
accountId,
|
||
modelName,
|
||
available: true,
|
||
latencyMs: Date.now() - startedAt,
|
||
checkedAt,
|
||
})),
|
||
).run();
|
||
}
|
||
await updateOauthModelDiscoveryState({
|
||
account: discoveryAccount,
|
||
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) {
|
||
discoveryAccount = getRefreshedOauthAccountFromError(err) || discoveryAccount;
|
||
const rawMessage = (err as { message?: string })?.message || 'antigravity model discovery failed';
|
||
const errorCode = classifyModelDiscoveryError(rawMessage);
|
||
const errorMessage = `Antigravity 模型获取失败(${rawMessage})`;
|
||
await updateOauthModelDiscoveryState({
|
||
account: discoveryAccount,
|
||
checkedAt,
|
||
status: 'abnormal',
|
||
lastModelSyncError: errorMessage,
|
||
lastDiscoveredModels: [],
|
||
});
|
||
await setAccountRuntimeHealth(account.id, {
|
||
state: 'unhealthy',
|
||
reason: errorMessage,
|
||
source: 'model-discovery',
|
||
checkedAt,
|
||
});
|
||
await restorePreviousAvailability();
|
||
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);
|
||
let discoveredApiToken: string | null = null;
|
||
|
||
if (!account.apiToken && account.accessToken) {
|
||
try {
|
||
discoveredApiToken = await withTimeout(
|
||
() => withAccountProxyOverride(accountProxyUrl,
|
||
() => adapter.getApiToken(site.url, account.accessToken, platformUserId)),
|
||
API_TOKEN_DISCOVERY_TIMEOUT_MS,
|
||
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||
);
|
||
if (discoveredApiToken && !isMaskedTokenValue(discoveredApiToken)) {
|
||
await 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 { }
|
||
}
|
||
|
||
const usesManagedTokens = requiresManagedAccountTokens(account);
|
||
let enabledTokens = usesManagedTokens
|
||
? await db.select()
|
||
.from(schema.accountTokens)
|
||
.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 (usesManagedTokens && enabledTokens.length === 0) {
|
||
const fallback = discoveredApiToken || account.apiToken || null;
|
||
if (fallback) {
|
||
await 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),
|
||
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
|
||
))
|
||
.all();
|
||
enabledTokens = enabledTokens.filter(isUsableAccountToken);
|
||
}
|
||
}
|
||
|
||
let aiBaseUrl: string;
|
||
try {
|
||
aiBaseUrl = await requireSiteApiBaseUrl(site);
|
||
} catch (err) {
|
||
const rawMessage = (err as { message?: string })?.message || '模型获取失败';
|
||
const errorCode = classifyModelDiscoveryError(rawMessage);
|
||
const errorMessage = rawMessage;
|
||
await setAccountRuntimeHealth(account.id, {
|
||
state: 'unhealthy',
|
||
reason: errorMessage,
|
||
source: 'model-discovery',
|
||
checkedAt: new Date().toISOString(),
|
||
});
|
||
await restorePreviousAvailability();
|
||
return buildFailedRefreshResult({
|
||
accountId,
|
||
errorCode,
|
||
errorMessage,
|
||
tokenScanned: 0,
|
||
discoveredByCredential: false,
|
||
discoveredApiToken: !!discoveredApiToken,
|
||
});
|
||
}
|
||
|
||
const accountModels = new Map<string, string>(); // lowercase key → original name (first-wins)
|
||
const modelLatency = new Map<string, number | null>();
|
||
let scannedTokenCount = 0;
|
||
let discoveredByCredential = false;
|
||
const attemptedCredentials = new Set<string>();
|
||
const failureMessages: string[] = [];
|
||
const recordFailure = (err: unknown) => {
|
||
const message = (err as { message?: string })?.message || String(err || '');
|
||
if (message) failureMessages.push(message);
|
||
};
|
||
|
||
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
|
||
for (const modelName of models) {
|
||
const key = modelName.toLowerCase();
|
||
if (!accountModels.has(key)) accountModels.set(key, modelName);
|
||
const prev = modelLatency.get(key);
|
||
if (prev === undefined || prev === null) {
|
||
modelLatency.set(key, latencyMs);
|
||
continue;
|
||
}
|
||
if (latencyMs === null) continue;
|
||
if (latencyMs < prev) modelLatency.set(key, latencyMs);
|
||
}
|
||
};
|
||
|
||
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);
|
||
|
||
const startedAt = Date.now();
|
||
let models: string[] = [];
|
||
try {
|
||
models = normalizeModels(
|
||
await withTimeout(
|
||
() => withAccountProxyOverride(accountProxyUrl,
|
||
() => adapter.getModels(aiBaseUrl, credential, platformUserId)),
|
||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||
),
|
||
);
|
||
} catch (err) {
|
||
recordFailure(err);
|
||
models = [];
|
||
}
|
||
if (models.length === 0) return;
|
||
discoveredByCredential = true;
|
||
const latencyMs = Date.now() - startedAt;
|
||
mergeDiscoveredModels(models, latencyMs);
|
||
};
|
||
|
||
// Prefer account-level credential discovery so model availability does not rely on managed tokens.
|
||
await discoverModelsWithCredential(account.apiToken);
|
||
await discoverModelsWithCredential(discoveredApiToken);
|
||
await discoverModelsWithCredential(account.accessToken);
|
||
|
||
for (const token of enabledTokens) {
|
||
const startedAt = Date.now();
|
||
let models: string[] = [];
|
||
|
||
try {
|
||
models = normalizeModels(
|
||
await withTimeout(
|
||
() => withAccountProxyOverride(accountProxyUrl,
|
||
() => adapter.getModels(aiBaseUrl, token.token, platformUserId)),
|
||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||
),
|
||
);
|
||
} catch (err) {
|
||
recordFailure(err);
|
||
models = [];
|
||
}
|
||
|
||
if (models.length === 0) continue;
|
||
|
||
const latencyMs = Date.now() - startedAt;
|
||
const checkedAt = new Date().toISOString();
|
||
|
||
await db.insert(schema.tokenModelAvailability).values(
|
||
models.map((modelName) => ({
|
||
tokenId: token.id,
|
||
modelName,
|
||
available: true,
|
||
latencyMs,
|
||
checkedAt,
|
||
})),
|
||
).run();
|
||
|
||
scannedTokenCount++;
|
||
mergeDiscoveredModels(models, latencyMs);
|
||
}
|
||
|
||
if (accountModels.size === 0) {
|
||
const firstMessage = failureMessages[0] || '';
|
||
const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
|
||
const errorMessage = buildModelFailureMessage(errorCode, firstMessage, site.platform);
|
||
await setAccountRuntimeHealth(account.id, {
|
||
state: 'unhealthy',
|
||
reason: errorMessage,
|
||
source: 'model-discovery',
|
||
checkedAt: new Date().toISOString(),
|
||
});
|
||
await restorePreviousAvailability();
|
||
return buildFailedRefreshResult({
|
||
accountId,
|
||
errorCode,
|
||
errorMessage,
|
||
tokenScanned: scannedTokenCount,
|
||
discoveredByCredential,
|
||
discoveredApiToken: !!discoveredApiToken,
|
||
});
|
||
}
|
||
|
||
const checkedAt = new Date().toISOString();
|
||
const newAccountModels = Array.from(accountModels.values()).filter((m) => !manualModelNames.has(m.toLowerCase()));
|
||
if (newAccountModels.length > 0) {
|
||
await db.insert(schema.modelAvailability).values(
|
||
newAccountModels.map((modelName) => ({
|
||
accountId: account.id,
|
||
modelName,
|
||
available: true,
|
||
latencyMs: modelLatency.get(modelName.toLowerCase()) ?? null,
|
||
checkedAt,
|
||
})),
|
||
).run();
|
||
}
|
||
|
||
await setAccountRuntimeHealth(account.id, {
|
||
state: 'healthy',
|
||
reason: '模型探测成功',
|
||
source: 'model-discovery',
|
||
checkedAt,
|
||
});
|
||
|
||
const modelsPreview = Array.from(accountModels.values()).slice(0, 10);
|
||
return buildSuccessfulRefreshResult({
|
||
accountId,
|
||
modelCount: accountModels.size,
|
||
modelsPreview,
|
||
tokenScanned: scannedTokenCount,
|
||
discoveredByCredential,
|
||
discoveredApiToken: !!discoveredApiToken,
|
||
});
|
||
}
|
||
|
||
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: 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)));
|
||
results.push(...batchResults);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
export async function rebuildTokenRoutesFromAvailability() {
|
||
const tokenRows = await db.select().from(schema.tokenModelAvailability)
|
||
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||
.where(
|
||
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)
|
||
&& requiresManagedAccountTokens(row.accounts)
|
||
));
|
||
|
||
const accountRows = await db.select().from(schema.modelAvailability)
|
||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||
.where(
|
||
and(
|
||
eq(schema.modelAvailability.available, true),
|
||
eq(schema.accounts.status, 'active'),
|
||
eq(schema.sites.status, 'active'),
|
||
),
|
||
)
|
||
.all();
|
||
|
||
// Load site-level disabled models
|
||
const disabledModelRows = await db.select().from(schema.siteDisabledModels).all();
|
||
const disabledModelsBySite = new Map<number, Set<string>>();
|
||
for (const row of disabledModelRows) {
|
||
if (!disabledModelsBySite.has(row.siteId)) disabledModelsBySite.set(row.siteId, new Set());
|
||
disabledModelsBySite.get(row.siteId)!.add(row.modelName.toLowerCase());
|
||
}
|
||
|
||
function isModelDisabledForSite(siteId: number, modelName: string): boolean {
|
||
const disabled = disabledModelsBySite.get(siteId);
|
||
return !!disabled && disabled.has(modelName.toLowerCase());
|
||
}
|
||
|
||
// Load global brand filter
|
||
const blockedBrandRules = getBlockedBrandRules(config.globalBlockedBrands);
|
||
|
||
// Load global allowed models whitelist
|
||
const globalAllowedModels = new Set(
|
||
config.globalAllowedModels.map((m) => m.toLowerCase().trim()).filter(Boolean),
|
||
);
|
||
|
||
function isModelAllowedByWhitelist(modelName: string): boolean {
|
||
// If whitelist is empty, allow all models (backward compatible)
|
||
if (globalAllowedModels.size === 0) return true;
|
||
// Check if model is in whitelist (case-insensitive)
|
||
return globalAllowedModels.has(modelName.toLowerCase().trim());
|
||
}
|
||
|
||
const enabledOauthRouteUnits = await listEnabledOauthRouteUnitsWithMembers();
|
||
const routeUnitByAccountId = new Map<number, {
|
||
routeUnitId: number;
|
||
representativeAccountId: number;
|
||
}>();
|
||
for (const routeUnit of enabledOauthRouteUnits) {
|
||
const representativeAccountId = routeUnit.members[0]?.account.id;
|
||
if (!representativeAccountId) continue;
|
||
for (const member of routeUnit.members) {
|
||
routeUnitByAccountId.set(member.account.id, {
|
||
routeUnitId: routeUnit.unit.id,
|
||
representativeAccountId,
|
||
});
|
||
}
|
||
}
|
||
|
||
const modelCandidates = new Map<string, Map<string, {
|
||
accountId: number;
|
||
tokenId: number | null;
|
||
oauthRouteUnitId: number | null;
|
||
}>>();
|
||
const buildCandidateKey = (input: {
|
||
accountId: number;
|
||
tokenId: number | null;
|
||
oauthRouteUnitId: number | null;
|
||
}) => (
|
||
input.oauthRouteUnitId
|
||
? `route-unit:${input.oauthRouteUnitId}`
|
||
: `${input.accountId}:${input.tokenId ?? 'account'}`
|
||
);
|
||
const buildChannelKey = (channel: typeof schema.routeChannels.$inferSelect) => (
|
||
channel.oauthRouteUnitId
|
||
? `route-unit:${channel.oauthRouteUnitId}`
|
||
: `${channel.accountId}:${channel.tokenId ?? 'account'}`
|
||
);
|
||
const addModelCandidate = (
|
||
modelNameRaw: string | null | undefined,
|
||
accountId: number,
|
||
tokenId: number | null,
|
||
siteId: number,
|
||
oauthRouteUnitId: number | null = null,
|
||
) => {
|
||
const modelName = (modelNameRaw || '').trim();
|
||
if (!modelName) return;
|
||
if (!isModelAllowedByWhitelist(modelName)) return;
|
||
if (isModelDisabledForSite(siteId, modelName)) return;
|
||
if (blockedBrandRules.length > 0 && isModelBlockedByBrand(modelName, blockedBrandRules)) return;
|
||
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
|
||
const candidate = { accountId, tokenId, oauthRouteUnitId };
|
||
modelCandidates.get(modelName)!.set(buildCandidateKey(candidate), candidate);
|
||
};
|
||
|
||
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 (!supportsDirectAccountRoutingConnection(row.accounts)) continue;
|
||
const routeUnit = routeUnitByAccountId.get(row.accounts.id);
|
||
if (routeUnit) {
|
||
addModelCandidate(
|
||
row.model_availability.modelName,
|
||
routeUnit.representativeAccountId,
|
||
null,
|
||
row.accounts.siteId,
|
||
routeUnit.routeUnitId,
|
||
);
|
||
continue;
|
||
}
|
||
addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId);
|
||
}
|
||
|
||
const routes = await db.select().from(schema.tokenRoutes).all();
|
||
const channels = await db.select().from(schema.routeChannels).all();
|
||
|
||
let createdRoutes = 0;
|
||
let createdChannels = 0;
|
||
let removedChannels = 0;
|
||
let removedRoutes = 0;
|
||
|
||
for (const [modelName, candidateMap] of modelCandidates.entries()) {
|
||
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,
|
||
enabled: true,
|
||
}).run();
|
||
const insertedId = getInsertedRowId(inserted);
|
||
route = insertedId != null
|
||
? await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, insertedId)).get()
|
||
: undefined;
|
||
if (!route) continue;
|
||
routes.push(route);
|
||
createdRoutes++;
|
||
}
|
||
|
||
const routeChannels = channels.filter((channel) => channel.routeId === route.id);
|
||
const desiredKeys = new Set(Array.from(candidateMap.keys()));
|
||
|
||
for (const [candidateKey, candidate] of candidateMap.entries()) {
|
||
const exists = routeChannels.some((channel) => buildChannelKey(channel) === candidateKey);
|
||
if (exists) continue;
|
||
|
||
const inserted = await db.insert(schema.routeChannels).values({
|
||
routeId: route.id,
|
||
accountId: candidate.accountId,
|
||
tokenId: candidate.tokenId,
|
||
oauthRouteUnitId: candidate.oauthRouteUnitId,
|
||
priority: 0,
|
||
weight: 10,
|
||
enabled: true,
|
||
manualOverride: false,
|
||
}).run();
|
||
const insertedId = getInsertedRowId(inserted);
|
||
if (insertedId == null) continue;
|
||
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedId)).get();
|
||
if (!created) continue;
|
||
channels.push(created);
|
||
createdChannels++;
|
||
desiredKeys.add(candidateKey);
|
||
}
|
||
|
||
for (const channel of routeChannels) {
|
||
const channelKey = buildChannelKey(channel);
|
||
if (desiredKeys.has(channelKey)) {
|
||
continue;
|
||
}
|
||
|
||
if (!channel.tokenId) {
|
||
const preferred = await getPreferredAccountToken(channel.accountId);
|
||
if (preferred && desiredKeys.has(`${channel.accountId}:${preferred.id}`)) {
|
||
await db.update(schema.routeChannels)
|
||
.set({ tokenId: preferred.id })
|
||
.where(eq(schema.routeChannels.id, channel.id))
|
||
.run();
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (!channel.manualOverride) {
|
||
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
|
||
removedChannels++;
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
|
||
if (routeChannelCount > 0) {
|
||
removedChannels += routeChannelCount;
|
||
}
|
||
|
||
const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes;
|
||
if (deleted > 0) {
|
||
removedRoutes += deleted;
|
||
}
|
||
}
|
||
|
||
if (createdRoutes > 0 || createdChannels > 0 || removedChannels > 0 || removedRoutes > 0) {
|
||
await clearAllRouteDecisionSnapshots();
|
||
}
|
||
|
||
invalidateTokenRouterCache();
|
||
|
||
return {
|
||
models: modelCandidates.size,
|
||
createdRoutes,
|
||
createdChannels,
|
||
removedChannels,
|
||
removedRoutes,
|
||
};
|
||
}
|
||
|
||
async function runRefreshModelsAndRebuildRoutes() {
|
||
const refresh = await refreshModelsForAllActiveAccounts();
|
||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||
return { refresh, rebuild };
|
||
}
|
||
|
||
export async function refreshModelsAndRebuildRoutes() {
|
||
if (inFlightRefreshModelsAndRebuildRoutes) {
|
||
return inFlightRefreshModelsAndRebuildRoutes;
|
||
}
|
||
|
||
inFlightRefreshModelsAndRebuildRoutes = (async () => {
|
||
try {
|
||
return await runRefreshModelsAndRebuildRoutes();
|
||
} finally {
|
||
inFlightRefreshModelsAndRebuildRoutes = null;
|
||
}
|
||
})();
|
||
|
||
return inFlightRefreshModelsAndRebuildRoutes;
|
||
}
|