Files
EasyCLIProxyAPI/src/services/authFiles.ts
T
lzt404 a08f38df7f fix(auth-files): sync quota priority display and credential ordering
Read OAuth priorities in quota cards and sort credentials by descending
priority followed by filename order across OAuth and quota pages.
2026-10-05 16:10:37 +08:00

224 lines
9.2 KiB
TypeScript

import { isRecord, managementApi, readBoolean, readString } from './managementApi';
import { getCurrentLocale, translate } from '../i18n';
export type AuthFileRecord = Record<string, unknown>;
export type AuthFileSnapshot = Map<string, string>;
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<string, unknown>) => Promise<unknown> } = managementApi,
): Promise<AuthFileCooldownResetResponse> => {
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 = <File extends AuthFileRecord>(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<string, unknown>) => Promise<unknown> } = managementApi,
): Promise<void> => {
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<string, string[]>();
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<string>();
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<string, AuthFileRecord[]>();
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<string | undefined, AuthFileRecord[]>();
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) ?? '');
});
};