feat(providers): enhance provider configuration handling and serialization

This commit is contained in:
LTbinglingfeng
2026-05-24 00:51:09 +08:00
parent 57a30634fc
commit a44bcd3337
+339 -20
View File
@@ -6,21 +6,286 @@ import { apiClient } from './client';
import {
normalizeGeminiKeyConfig,
normalizeOpenAIProvider,
normalizeProviderKeyConfig
normalizeProviderKeyConfig,
} from './transformers';
import type {
GeminiKeyConfig,
OpenAIProviderConfig,
ProviderKeyConfig,
ApiKeyEntry,
ModelAlias
ModelAlias,
} from '@/types';
const serializeHeaders = (headers?: Record<string, string>) => (headers && Object.keys(headers).length ? headers : undefined);
const serializeHeaders = (headers?: Record<string, string>) =>
headers && Object.keys(headers).length ? headers : undefined;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const RESPONSE_ONLY_FIELDS = ['auth-index', 'authIndex', 'auth_index'] as const;
const PROVIDER_KEY_FIELDS = [
'api-key',
'apiKey',
'priority',
'prefix',
'base-url',
'baseUrl',
'base_url',
'websockets',
'proxy-url',
'proxyUrl',
'proxy_url',
'headers',
'models',
'excluded-models',
'excludedModels',
'excluded_models',
'cloak',
] as const;
const GEMINI_KEY_FIELDS = PROVIDER_KEY_FIELDS.filter(
(field) => field !== 'websockets' && field !== 'cloak'
);
const VERTEX_KEY_FIELDS = GEMINI_KEY_FIELDS;
const OPENAI_PROVIDER_FIELDS = [
'name',
'priority',
'disabled',
'prefix',
'base-url',
'baseUrl',
'base_url',
'api-key-entries',
'apiKeyEntries',
'api_key_entries',
'api-keys',
'apiKeys',
'api_keys',
'headers',
'models',
'test-model',
'testModel',
'test_model',
] as const;
const MODEL_ALIAS_FIELDS = [
'name',
'id',
'model',
'alias',
'display_name',
'displayName',
'priority',
'test-model',
'testModel',
'test_model',
] as const;
const API_KEY_ENTRY_FIELDS = [
'api-key',
'apiKey',
'key',
'proxy-url',
'proxyUrl',
'proxy_url',
] as const;
const CLOAK_FIELDS = [
'mode',
'strict-mode',
'strictMode',
'strict_mode',
'sensitive-words',
'sensitiveWords',
'sensitive_words',
] as const;
const RAW_SECTION_ALIASES: Record<string, readonly string[]> = {
'gemini-api-key': ['gemini-api-key', 'geminiApiKey', 'geminiApiKeys'],
'codex-api-key': ['codex-api-key', 'codexApiKey', 'codexApiKeys'],
'claude-api-key': ['claude-api-key', 'claudeApiKey', 'claudeApiKeys'],
'vertex-api-key': ['vertex-api-key', 'vertexApiKey', 'vertexApiKeys'],
'openai-compatibility': ['openai-compatibility', 'openaiCompatibility', 'openAICompatibility'],
};
const getStringField = (record: Record<string, unknown>, keys: readonly string[]) => {
for (const key of keys) {
const value = record[key];
if (value === undefined || value === null) continue;
const text = String(value).trim();
if (text) return text;
}
return '';
};
const providerKeyIdentity = (record: Record<string, unknown>) => {
const apiKey = getStringField(record, ['api-key', 'apiKey']);
if (!apiKey) return '';
const baseUrl = getStringField(record, ['base-url', 'baseUrl', 'base_url']);
return `${apiKey}\u0000${baseUrl}`;
};
const openAIProviderIdentity = (record: Record<string, unknown>) =>
getStringField(record, ['name', 'id']);
const modelIdentity = (record: Record<string, unknown>) =>
getStringField(record, ['name', 'id', 'model']);
const apiKeyEntryIdentity = (record: Record<string, unknown>) =>
getStringField(record, ['api-key', 'apiKey', 'key']);
const cloneWithoutKnownFields = (
raw: unknown,
knownFields: readonly string[]
): Record<string, unknown> => {
const next: Record<string, unknown> = isRecord(raw) ? { ...raw } : {};
[...knownFields, ...RESPONSE_ONLY_FIELDS].forEach((field) => {
delete next[field];
});
return next;
};
const mergeKnownFields = (
raw: unknown,
payload: Record<string, unknown>,
knownFields: readonly string[]
) => {
const next = cloneWithoutKnownFields(raw, knownFields);
Object.entries(payload).forEach(([key, value]) => {
if (value !== undefined) {
next[key] = value;
}
});
return next;
};
const findRawRecord = (
rawRecords: Array<Record<string, unknown> | undefined>,
usedIndexes: Set<number>,
payload: Record<string, unknown>,
index: number,
getIdentity: (record: Record<string, unknown>) => string
) => {
const identity = getIdentity(payload);
if (identity) {
for (let i = 0; i < rawRecords.length; i += 1) {
const candidate = rawRecords[i];
if (!candidate || usedIndexes.has(i)) continue;
if (getIdentity(candidate) === identity) {
usedIndexes.add(i);
return candidate;
}
}
}
const fallback = rawRecords[index];
if (fallback && !usedIndexes.has(index)) {
usedIndexes.add(index);
return fallback;
}
return undefined;
};
const mergeKnownRecordList = (
rawItems: unknown,
payloadItems: Record<string, unknown>[],
knownFields: readonly string[],
getIdentity: (record: Record<string, unknown>) => string
) => {
const rawRecords = Array.isArray(rawItems)
? rawItems.map((item) => (isRecord(item) ? item : undefined))
: [];
const usedIndexes = new Set<number>();
return payloadItems.map((payload, index) => {
const raw = findRawRecord(rawRecords, usedIndexes, payload, index, getIdentity);
return mergeKnownFields(raw, payload, knownFields);
});
};
const getRawSectionList = (rawConfig: unknown, section: string) => {
if (!isRecord(rawConfig)) return [];
const aliases = RAW_SECTION_ALIASES[section] ?? [section];
for (const alias of aliases) {
const value = rawConfig[alias];
if (Array.isArray(value)) return value;
}
return [];
};
const mergeModelPayloads = (raw: unknown, models: unknown) =>
Array.isArray(models)
? mergeKnownRecordList(
isRecord(raw) ? raw.models : undefined,
models.filter(isRecord),
MODEL_ALIAS_FIELDS,
modelIdentity
)
: undefined;
const mergeProviderKeyPayload = (
raw: unknown,
payload: Record<string, unknown>,
knownFields: readonly string[]
) => {
const next = mergeKnownFields(raw, payload, knownFields);
const models = mergeModelPayloads(raw, payload.models);
if (models) next.models = models;
if (isRecord(payload.cloak)) {
next.cloak = mergeKnownFields(
isRecord(raw) ? raw.cloak : undefined,
payload.cloak,
CLOAK_FIELDS
);
}
return next;
};
const mergeOpenAIProviderPayload = (raw: unknown, payload: Record<string, unknown>) => {
const next = mergeKnownFields(raw, payload, OPENAI_PROVIDER_FIELDS);
const rawApiKeyEntries = isRecord(raw)
? (raw['api-key-entries'] ?? raw.apiKeyEntries)
: undefined;
const apiKeyEntries = payload['api-key-entries'];
if (Array.isArray(apiKeyEntries)) {
next['api-key-entries'] = mergeKnownRecordList(
rawApiKeyEntries,
apiKeyEntries.filter(isRecord),
API_KEY_ENTRY_FIELDS,
apiKeyEntryIdentity
);
}
const models = mergeModelPayloads(raw, payload.models);
if (models) next.models = models;
return next;
};
const buildPreservedList = async <T>(
section: string,
configs: T[],
serialize: (item: T) => Record<string, unknown>,
mergePayload: (raw: unknown, payload: Record<string, unknown>) => Record<string, unknown>,
getIdentity: (record: Record<string, unknown>) => string
) => {
// These PUT endpoints replace entire backend slices. Merge over the current
// raw config first so backend-only fields survive UI saves and toggles.
const rawConfig = await apiClient.get('/config');
const rawItems = getRawSectionList(rawConfig, section);
const payloads = configs.map((item) => serialize(item));
const rawRecords = Array.isArray(rawItems)
? rawItems.map((item) => (isRecord(item) ? item : undefined))
: [];
const usedIndexes = new Set<number>();
return payloads.map((payload, index) => {
const raw = findRawRecord(rawRecords, usedIndexes, payload, index, getIdentity);
return mergePayload(raw, payload);
});
};
const extractArrayPayload = (data: unknown, key: string): unknown[] => {
if (Array.isArray(data)) return data;
if (!isRecord(data)) return [];
@@ -81,7 +346,8 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
const cloakPayload: Record<string, unknown> = {};
const mode = config.cloak.mode?.trim();
if (mode) cloakPayload.mode = mode;
if (config.cloak.strictMode !== undefined) cloakPayload['strict-mode'] = config.cloak.strictMode;
if (config.cloak.strictMode !== undefined)
cloakPayload['strict-mode'] = config.cloak.strictMode;
if (config.cloak.sensitiveWords && config.cloak.sensitiveWords.length) {
cloakPayload['sensitive-words'] = config.cloak.sensitiveWords;
}
@@ -142,7 +408,7 @@ const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => {
'base-url': provider.baseUrl,
'api-key-entries': Array.isArray(provider.apiKeyEntries)
? provider.apiKeyEntries.map((entry) => serializeApiKeyEntry(entry))
: []
: [],
};
if (provider.prefix?.trim()) payload.prefix = provider.prefix.trim();
if (provider.disabled !== undefined) payload.disabled = provider.disabled;
@@ -162,8 +428,17 @@ export const providersApi = {
return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[];
},
saveGeminiKeys: (configs: GeminiKeyConfig[]) =>
apiClient.put('/gemini-api-key', configs.map((item) => serializeGeminiKey(item))),
saveGeminiKeys: async (configs: GeminiKeyConfig[]) =>
apiClient.put(
'/gemini-api-key',
await buildPreservedList(
'gemini-api-key',
configs,
serializeGeminiKey,
(raw, payload) => mergeProviderKeyPayload(raw, payload, GEMINI_KEY_FIELDS),
providerKeyIdentity
)
),
updateGeminiKey: (index: number, value: GeminiKeyConfig) =>
apiClient.patch('/gemini-api-key', { index, value: serializeGeminiKey(value) }),
@@ -174,11 +449,22 @@ export const providersApi = {
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/codex-api-key');
const list = extractArrayPayload(data, 'codex-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
return list
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
},
saveCodexConfigs: (configs: ProviderKeyConfig[]) =>
apiClient.put('/codex-api-key', configs.map((item) => serializeProviderKey(item))),
saveCodexConfigs: async (configs: ProviderKeyConfig[]) =>
apiClient.put(
'/codex-api-key',
await buildPreservedList(
'codex-api-key',
configs,
serializeProviderKey,
(raw, payload) => mergeProviderKeyPayload(raw, payload, PROVIDER_KEY_FIELDS),
providerKeyIdentity
)
),
updateCodexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/codex-api-key', { index, value: serializeProviderKey(value) }),
@@ -189,11 +475,22 @@ export const providersApi = {
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/claude-api-key');
const list = extractArrayPayload(data, 'claude-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
return list
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
},
saveClaudeConfigs: (configs: ProviderKeyConfig[]) =>
apiClient.put('/claude-api-key', configs.map((item) => serializeProviderKey(item))),
saveClaudeConfigs: async (configs: ProviderKeyConfig[]) =>
apiClient.put(
'/claude-api-key',
await buildPreservedList(
'claude-api-key',
configs,
serializeProviderKey,
(raw, payload) => mergeProviderKeyPayload(raw, payload, PROVIDER_KEY_FIELDS),
providerKeyIdentity
)
),
updateClaudeConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/claude-api-key', { index, value: serializeProviderKey(value) }),
@@ -204,11 +501,22 @@ export const providersApi = {
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
const list = extractArrayPayload(data, 'vertex-api-key');
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
return list
.map((item) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
},
saveVertexConfigs: (configs: ProviderKeyConfig[]) =>
apiClient.put('/vertex-api-key', configs.map((item) => serializeVertexKey(item))),
saveVertexConfigs: async (configs: ProviderKeyConfig[]) =>
apiClient.put(
'/vertex-api-key',
await buildPreservedList(
'vertex-api-key',
configs,
serializeVertexKey,
(raw, payload) => mergeProviderKeyPayload(raw, payload, VERTEX_KEY_FIELDS),
providerKeyIdentity
)
),
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
@@ -219,11 +527,22 @@ export const providersApi = {
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility');
const list = extractArrayPayload(data, 'openai-compatibility');
return list.map((item) => normalizeOpenAIProvider(item)).filter(Boolean) as OpenAIProviderConfig[];
return list
.map((item) => normalizeOpenAIProvider(item))
.filter(Boolean) as OpenAIProviderConfig[];
},
saveOpenAIProviders: (providers: OpenAIProviderConfig[]) =>
apiClient.put('/openai-compatibility', providers.map((item) => serializeOpenAIProvider(item))),
saveOpenAIProviders: async (providers: OpenAIProviderConfig[]) =>
apiClient.put(
'/openai-compatibility',
await buildPreservedList(
'openai-compatibility',
providers,
serializeOpenAIProvider,
mergeOpenAIProviderPayload,
openAIProviderIdentity
)
),
updateOpenAIProvider: (index: number, value: OpenAIProviderConfig) =>
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
@@ -232,5 +551,5 @@ export const providersApi = {
apiClient.patch('/openai-compatibility', { index, value: { disabled } }),
deleteOpenAIProvider: (name: string) =>
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`)
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`),
};