import { isRecord, managementApi, readBoolean, readString } from './managementApi'; import { getCurrentLocale, translate } from '../i18n'; export type AuthFileRecord = Record; export type AuthFileSnapshot = Map; export type AuthFileCooldownResetResponse = { status: 'ok'; auth_index: string; models: string[]; }; export const authFileCooldownResetIndex = (file: AuthFileRecord): string | undefined => { const value = file.auth_index ?? file.authIndex; if (typeof value === 'string') return value.trim() || undefined; if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return String(value); return undefined; }; export const resetAuthFileCooldown = async ( authIndex: string, api: { post: (path: string, body: Record) => Promise } = managementApi, ): Promise => { const index = authFileCooldownResetIndex({ auth_index: authIndex }); if (!index) throw new Error(translate(getCurrentLocale(), 'authFiles.cooldown.missingIndex')); const response = await api.post('/routing/cooldown/reset', { auth_index: index }); // Only an acknowledgement for this index confirms the operation. The caller // reloads the list to obtain authoritative health and disabled state afterward. if (!isRecord(response) || response.status !== 'ok' || response.auth_index !== index || (response.models != null && (!Array.isArray(response.models) || response.models.some((model) => typeof model !== 'string')))) { throw new Error(translate(getCurrentLocale(), 'authFiles.cooldown.invalidResponse')); } return { status: 'ok', auth_index: index, models: (response.models ?? []) as string[] }; }; export const authFileName = (file: AuthFileRecord) => readString(file, 'name') || translate(getCurrentLocale(), 'authFiles.unnamed'); /** * Present credentials in the same order used by routing: higher explicit * priority first, then a deterministic case-insensitive filename order. * Return a new array so callers do not mutate the API response or snapshots. */ export const sortAuthFilesByPriority = (files: File[]): File[] => [...files].sort((left, right) => { const priorityDelta = (parseAuthFilePriority(right.priority) ?? 0) - (parseAuthFilePriority(left.priority) ?? 0); if (priorityDelta !== 0) return priorityDelta; return authFileName(left).localeCompare(authFileName(right), undefined, { sensitivity: 'base' }); }); export const isRuntimeOnlyAuthFile = (file: AuthFileRecord) => readBoolean(file, 'runtime_only', 'runtimeOnly'); export const isOAuthCredentialFile = (file: AuthFileRecord): boolean => { if (!readString(file, 'name').toLowerCase().endsWith('.json') || isRuntimeOnlyAuthFile(file)) return false; const kinds = ['account_type', 'auth_kind', 'authKind'].map((key) => readString(file, key).toLowerCase().replace(/[-_]/g, '')); if (kinds.includes('apikey')) return false; const source = readString(file, 'source').toLowerCase(); return !source || source === 'file'; }; export const setOAuthCredentialFileDisabled = async ( file: AuthFileRecord, disabled: boolean, api: { patch: (path: string, body: Record) => Promise } = managementApi, ): Promise => { if (!isOAuthCredentialFile(file)) { throw new Error(translate(getCurrentLocale(), 'authFiles.fileOnly')); } await api.patch('/credentials/status', { name: readString(file, 'name'), disabled }); }; export const parseAuthFilePriority = (value: unknown): number | undefined => { if (typeof value === 'number') { return Number.isSafeInteger(value) ? value : undefined; } if (typeof value !== 'string') return undefined; const normalized = value.trim(); if (!/^-?\d+$/.test(normalized)) return undefined; const priority = Number(normalized); return Number.isSafeInteger(priority) ? priority : undefined; }; export const normalizeAuthFilePriorityInput = (value: string): number | null => { const normalized = value.trim(); if (!normalized) return 0; return parseAuthFilePriority(normalized) ?? null; }; export const normalizeOAuthProvider = (value: string) => { const provider = value.trim().toLowerCase(); if (provider === 'cognition') return 'devin'; if (provider === 'anthropic') return 'claude'; if (provider === 'anti-gravity') return 'antigravity'; if (provider === 'openai') return 'codex'; if (provider === 'muse') return 'meta'; return provider; }; const authFileProvider = (file: AuthFileRecord) => normalizeOAuthProvider(readString(file, 'provider', 'type')); export const oauthModelProvidersFromAuthFiles = (files: AuthFileRecord[]): string[] => [...new Set(files.filter(isOAuthCredentialFile).map(authFileProvider).filter(Boolean))].sort(); export const snapshotAuthFiles = (files: AuthFileRecord[]): AuthFileSnapshot => { const grouped = new Map(); files.forEach((file) => { const name = readString(file, 'name'); if (!name) return; const fingerprints = grouped.get(name) ?? []; fingerprints.push(JSON.stringify(file)); grouped.set(name, fingerprints); }); return new Map(Array.from(grouped, ([name, fingerprints]) => [ name, fingerprints.sort().join('\n'), ])); }; export const changedOAuthAuthFileNames = ( before: AuthFileSnapshot, files: AuthFileRecord[], provider: string, ) => { const expectedProvider = normalizeOAuthProvider(provider); const after = snapshotAuthFiles(files); const names = new Set(); files.forEach((file) => { const name = readString(file, 'name'); if (!name || authFileProvider(file) !== expectedProvider) return; const priority = parseAuthFilePriority(file.priority); if (priority !== undefined && priority !== 0) return; if (before.get(name) !== after.get(name)) names.add(name); }); return Array.from(names); }; const hasMeaningfulValue = (value: unknown) => { if (value === null || value === undefined) return false; if (typeof value === 'string') return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; return true; }; const authFileTimestamp = (file: AuthFileRecord) => { for (const value of [file.modtime, file.updated_at, file.last_refresh]) { if (value === null || value === undefined || value === '') continue; const numeric = typeof value === 'number' ? value : Number(value); if (Number.isFinite(numeric)) return numeric < 1e12 ? numeric * 1000 : numeric; const parsed = new Date(String(value)).getTime(); if (!Number.isNaN(parsed)) return parsed; } return 0; }; const authFilePriority = (file: AuthFileRecord) => { let score = 0; if (readString(file, 'source').toLowerCase() === 'file') score += 32; if (readString(file, 'path')) score += 16; if (!isRuntimeOnlyAuthFile(file)) score += 8; if (!readBoolean(file, 'disabled')) score += 4; if (authFileTimestamp(file) > 0) score += 2; return score; }; const authFileSourceFields = new Set([ 'source', 'path', 'runtime_only', 'runtimeOnly', 'account_type', 'auth_kind', 'authKind', ]); const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => { const sorted = [...entries].sort((left, right) => { const priority = authFilePriority(right) - authFilePriority(left); if (priority !== 0) return priority; const timestamp = authFileTimestamp(right) - authFileTimestamp(left); if (timestamp !== 0) return timestamp; return Object.values(right).filter(hasMeaningfulValue).length - Object.values(left).filter(hasMeaningfulValue).length; }); const merged = { ...sorted[0] }; sorted.slice(1).forEach((entry) => { Object.entries(entry).forEach(([key, value]) => { if (authFileSourceFields.has(key)) return; if (key === 'cooldowns' && Object.prototype.hasOwnProperty.call(merged, key)) return; if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) merged[key] = value; }); }); return merged; }; export const dedupeAuthFiles = (files: AuthFileRecord[]) => { const grouped = new Map(); files.forEach((file, index) => { const key = authFileName(file) || `unnamed-${index}`; const entries = grouped.get(key) ?? []; entries.push(file); grouped.set(key, entries); }); return Array.from(grouped.values()) .flatMap((entries) => { const byAuthIndex = new Map(); entries.forEach((entry) => { const authIndex = authFileCooldownResetIndex(entry); const matches = byAuthIndex.get(authIndex) ?? []; matches.push(entry); byAuthIndex.set(authIndex, matches); }); const knownIndexCount = byAuthIndex.size - (byAuthIndex.has(undefined) ? 1 : 0); // Legacy disk entries can enrich one indexed runtime record. Conflicting // indexes identify different credentials; unindexed entries stay separate. return knownIndexCount <= 1 ? [mergeDuplicateAuthFiles(entries)] : Array.from(byAuthIndex.values()).map(mergeDuplicateAuthFiles); }) .sort((left, right) => { const nameOrder = authFileName(left).localeCompare(authFileName(right), undefined, { sensitivity: 'base' }); return nameOrder || (authFileCooldownResetIndex(left) ?? '') .localeCompare(authFileCooldownResetIndex(right) ?? ''); }); };