From a44bcd3337458ce9e2727eab2891085d90ba37b4 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Sun, 24 May 2026 00:51:09 +0800 Subject: [PATCH] feat(providers): enhance provider configuration handling and serialization --- src/services/api/providers.ts | 359 ++++++++++++++++++++++++++++++++-- 1 file changed, 339 insertions(+), 20 deletions(-) diff --git a/src/services/api/providers.ts b/src/services/api/providers.ts index b2fad9e..3d9ee43 100644 --- a/src/services/api/providers.ts +++ b/src/services/api/providers.ts @@ -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) => (headers && Object.keys(headers).length ? headers : undefined); +const serializeHeaders = (headers?: Record) => + headers && Object.keys(headers).length ? headers : undefined; const isRecord = (value: unknown): value is Record => 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 = { + '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, 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) => { + 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) => + getStringField(record, ['name', 'id']); + +const modelIdentity = (record: Record) => + getStringField(record, ['name', 'id', 'model']); + +const apiKeyEntryIdentity = (record: Record) => + getStringField(record, ['api-key', 'apiKey', 'key']); + +const cloneWithoutKnownFields = ( + raw: unknown, + knownFields: readonly string[] +): Record => { + const next: Record = isRecord(raw) ? { ...raw } : {}; + [...knownFields, ...RESPONSE_ONLY_FIELDS].forEach((field) => { + delete next[field]; + }); + return next; +}; + +const mergeKnownFields = ( + raw: unknown, + payload: Record, + 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 | undefined>, + usedIndexes: Set, + payload: Record, + index: number, + getIdentity: (record: Record) => 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[], + knownFields: readonly string[], + getIdentity: (record: Record) => string +) => { + const rawRecords = Array.isArray(rawItems) + ? rawItems.map((item) => (isRecord(item) ? item : undefined)) + : []; + const usedIndexes = new Set(); + + 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, + 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) => { + 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 ( + section: string, + configs: T[], + serialize: (item: T) => Record, + mergePayload: (raw: unknown, payload: Record) => Record, + getIdentity: (record: Record) => 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(); + + 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 = {}; 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 { 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 { 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 { 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 { 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)}`), };