Files
EasyCLIProxyAPI/src/services/authFileSettings.ts
T
lzt404 c89e2fb020 feat(config): support CLIProxyAPI v8.0.10 template settings
- Add structured editors for request, OAuth, plugin, and concurrency settings
- Expand advanced configuration for all eight provider families and credentials
- Preserve YAML comments, secrets, unknown fields, and explicit overrides
- Add validation, conflict detection, default restoration, and restart notices
- Improve responsive layouts and localize configuration controls
- Fix provider health checks and extend mocks and integration tests
2026-10-04 17:31:52 +08:00

148 lines
6.6 KiB
TypeScript

import { getCurrentLocale, translate } from '../i18n';
import { isRecord, managementApi } from './managementApi';
import { normalizeAuthFilePriorityInput } from './authFiles';
import { authFileExcludedRulesFromPayload } from './oauthModelSettings';
import { normalizeOAuthExcludedRules } from './oauthModels';
import { credentialAdvancedShape } from './credentialAdvancedSettings';
import { validateStructuredValueText } from './structuredConfig';
import { templateText } from '../i18n/templateConfig';
import { sameTemplateValue } from './templateConfig';
export type BooleanOverride = '' | 'true' | 'false';
export type AuthFileSettingsDraft = {
prefix: string;
proxy_url: string;
priority: string;
weight: string;
disable_cooling: BooleanOverride;
websockets: BooleanOverride;
excluded_models: string;
headers: string;
note: string;
advanced: Record<string, unknown>;
};
const fail = (key: 'metadata' | 'headers' | 'weight' | 'priority') => {
throw new Error(translate(getCurrentLocale(), `authFiles.settings.invalid.${key}`));
};
const override = (value: unknown, allowNumber = false): BooleanOverride => {
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (allowNumber && typeof value === 'number' && Number.isFinite(value)) return value !== 0 ? 'true' : 'false';
if (typeof value === 'string') {
if (['1', 't', 'T', 'TRUE', 'true', 'True'].includes(value.trim())) return 'true';
if (['0', 'f', 'F', 'FALSE', 'false', 'False'].includes(value.trim())) return 'false';
}
return '';
};
const headersFromText = (text: string): Record<string, string> => {
let value: unknown;
try { value = JSON.parse(text.trim() || '{}'); } catch { return fail('headers'); }
if (!isRecord(value)) return fail('headers');
const names = new Set<string>();
const result: Record<string, string> = Object.create(null);
for (const [key, item] of Object.entries(value)) {
const name = key.trim();
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(name) || typeof item !== 'string'
|| /[\r\n\0]/.test(item) || names.has(name.toLowerCase())) return fail('headers');
names.add(name.toLowerCase());
if (item.trim()) result[name] = item.trim();
}
return result;
};
export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsDraft => {
let metadata = payload;
if (typeof metadata === 'string') {
try { metadata = JSON.parse(metadata); } catch { return fail('metadata'); }
}
if (!isRecord(metadata)) return fail('metadata');
const read = (key: string, legacy?: string) => Object.prototype.hasOwnProperty.call(metadata, key)
? metadata[key] : legacy ? metadata[legacy] : undefined;
const text = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? String(value) : '';
const headers = metadata.headers == null ? {} : metadata.headers;
if (!isRecord(headers) || Object.values(headers).some((value) => typeof value !== 'string')) return fail('headers');
return {
prefix: text(metadata.prefix),
proxy_url: text(read('proxy_url', 'proxy-url')),
priority: text(metadata.priority),
weight: text(metadata.weight),
disable_cooling: override(read('disable_cooling', 'disable-cooling'), true),
websockets: override(read('websockets', 'websocket')),
excluded_models: authFileExcludedRulesFromPayload(metadata).join('\n'),
headers: JSON.stringify(headers, null, 2),
note: text(metadata.note),
advanced: Object.fromEntries(Object.keys(credentialAdvancedShape.fields ?? {}).flatMap(key => {
const value = read(key, key.replace(/_/g, '-'));
return value === undefined ? [] : [[key, value]];
})),
};
};
export const buildAuthFileSettingsPatch = (
original: AuthFileSettingsDraft,
draft: AuthFileSettingsDraft,
): Record<string, unknown> => {
const patch: Record<string, unknown> = {};
for (const key of ['prefix', 'proxy_url', 'note'] as const) {
if (draft[key].trim() !== original[key].trim()) patch[key] = draft[key].trim();
}
if (draft.priority !== original.priority) {
const value = normalizeAuthFilePriorityInput(draft.priority);
if (value === null) return fail('priority');
if (value !== normalizeAuthFilePriorityInput(original.priority)) patch.priority = value;
}
if (draft.weight !== original.weight) {
const text = draft.weight.trim();
const value = text ? Number(text) : null;
if (value !== null && (!/^-?\d+$/.test(text) || !Number.isSafeInteger(value) || value > 1_000_000)) return fail('weight');
const normalized = value === null ? null : Math.max(0, value);
const previous = original.weight.trim() ? Math.max(0, Number(original.weight)) : null;
if (normalized !== previous) patch.weight = normalized;
}
for (const key of ['disable_cooling', 'websockets'] as const) {
if (draft[key] !== original[key]) patch[key] = draft[key] === '' ? null : draft[key] === 'true';
}
const rules = (text: string) => normalizeOAuthExcludedRules(text.split(/\r?\n/));
const nextRules = rules(draft.excluded_models);
if (JSON.stringify([...nextRules].sort()) !== JSON.stringify(rules(original.excluded_models).sort())) {
patch.excluded_models = nextRules;
}
if (draft.headers !== original.headers) {
const previous = headersFromText(original.headers);
const next = headersFromText(draft.headers);
const headers: Record<string, string> = Object.create(null);
for (const name of new Set([...Object.keys(previous), ...Object.keys(next)])) {
if (previous[name] !== next[name]) headers[name] = next[name] ?? '';
}
if (Object.keys(headers).length) patch.headers = headers;
}
for (const [key, shape] of Object.entries(credentialAdvancedShape.fields ?? {})) {
const next = draft.advanced[key];
if (sameTemplateValue(next, original.advanced[key])) continue;
const error = validateStructuredValueText(shape, next, key);
if (error) throw new Error(templateText(error, getCurrentLocale()));
patch[key] = next ?? null;
}
return patch;
};
type SettingsApi = {
get: (path: string, query: Record<string, string>) => Promise<unknown>;
patch: (path: string, body: Record<string, unknown>) => Promise<unknown>;
};
export const loadAuthFileSettings = async (name: string, api: SettingsApi = managementApi) =>
authFileSettingsFromPayload(await api.get('/auth-files/download', { name }));
export const saveAuthFileSettings = async (
name: string, original: AuthFileSettingsDraft, draft: AuthFileSettingsDraft,
api: SettingsApi = managementApi,
) => {
const patch = buildAuthFileSettingsPatch(original, draft);
if (!Object.keys(patch).length) return false;
await api.patch('/auth-files/fields', { name, ...patch });
return true;
};