/** * AI 提供商相关 API */ import { apiClient } from './client'; import { isRecord } from '@/utils/helpers'; import { normalizeGeminiKeyConfig, normalizeOpenAIProvider, normalizeProviderKeyConfig, } from './transformers'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig, ApiKeyEntry, ModelAlias, } from '@/types'; const serializeHeaders = (headers?: Record) => headers && Object.keys(headers).length ? headers : undefined; const RESPONSE_ONLY_FIELDS = ['auth-index'] as const; const PROVIDER_KEY_FIELDS = [ 'api-key', 'priority', 'prefix', 'base-url', 'websockets', 'proxy-url', 'headers', 'models', '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', 'api-key-entries', 'headers', 'models', 'test-model', ] as const; const MODEL_ALIAS_FIELDS = ['name', 'alias', 'priority', 'test-model'] as const; const API_KEY_ENTRY_FIELDS = ['api-key', 'proxy-url'] as const; const CLOAK_FIELDS = ['mode', 'strict-mode', 'sensitive-words'] as const; 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']); if (!apiKey) return ''; const baseUrl = getStringField(record, ['base-url']); return `${apiKey}\u0000${baseUrl}`; }; const openAIProviderIdentity = (record: Record) => getStringField(record, ['name']); const modelIdentity = (record: Record) => getStringField(record, ['name']); const apiKeyEntryIdentity = (record: Record) => getStringField(record, ['api-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, fallbackByIndex = true ) => { 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; } } } if (fallbackByIndex) { 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, fallbackByIndex = true ) => { 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, fallbackByIndex ); return mergeKnownFields(raw, payload, knownFields); }); }; const getRawSectionList = (rawConfig: unknown, section: string): unknown[] => { if (!isRecord(rawConfig)) return []; const value = rawConfig[section]; return Array.isArray(value) ? value : []; }; const mergeModelPayloads = (raw: unknown, models: unknown) => Array.isArray(models) ? mergeKnownRecordList( isRecord(raw) ? raw.models : undefined, models.filter(isRecord), MODEL_ALIAS_FIELDS, modelIdentity, false ) : 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'] : 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 (!isRecord(data)) return []; const list = data[key]; return Array.isArray(list) ? list : []; }; const buildProviderDeleteQuery = (apiKey: string, baseUrl?: string) => { const params = new URLSearchParams(); params.set('api-key', apiKey.trim()); params.set('base-url', (baseUrl ?? '').trim()); return `?${params.toString()}`; }; const serializeModelAliases = (models?: ModelAlias[]) => Array.isArray(models) ? models .map((model) => { if (!model?.name) return null; const payload: Record = { name: model.name }; if (model.alias && model.alias !== model.name) { payload.alias = model.alias; } if (model.priority !== undefined) { payload.priority = model.priority; } if (model.testModel) { payload['test-model'] = model.testModel; } return payload; }) .filter(Boolean) : undefined; const serializeApiKeyEntry = (entry: ApiKeyEntry) => { const payload: Record = { 'api-key': entry.apiKey }; if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl; return payload; }; const serializeProviderKey = (config: ProviderKeyConfig) => { const payload: Record = { 'api-key': config.apiKey }; if (config.priority !== undefined) payload.priority = config.priority; if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); if (config.baseUrl) payload['base-url'] = config.baseUrl; if (config.websockets !== undefined) payload.websockets = config.websockets; if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl; const headers = serializeHeaders(config.headers); if (headers) payload.headers = headers; const models = serializeModelAliases(config.models); if (models && models.length) payload.models = models; if (config.excludedModels && config.excludedModels.length) { payload['excluded-models'] = config.excludedModels; } if (config.cloak) { 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.sensitiveWords && config.cloak.sensitiveWords.length) { cloakPayload['sensitive-words'] = config.cloak.sensitiveWords; } if (Object.keys(cloakPayload).length) { payload.cloak = cloakPayload; } } return payload; }; const serializeVertexModelAliases = (models?: ModelAlias[]) => Array.isArray(models) ? models .map((model) => { const name = typeof model?.name === 'string' ? model.name.trim() : ''; const alias = typeof model?.alias === 'string' ? model.alias.trim() : ''; if (!name || !alias) return null; return { name, alias }; }) .filter(Boolean) : undefined; const serializeVertexKey = (config: ProviderKeyConfig) => { const payload: Record = { 'api-key': config.apiKey }; if (config.priority !== undefined) payload.priority = config.priority; if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); if (config.baseUrl) payload['base-url'] = config.baseUrl; if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl; const headers = serializeHeaders(config.headers); if (headers) payload.headers = headers; const models = serializeVertexModelAliases(config.models); if (models && models.length) payload.models = models; if (config.excludedModels && config.excludedModels.length) { payload['excluded-models'] = config.excludedModels; } return payload; }; const serializeGeminiKey = (config: GeminiKeyConfig) => { const payload: Record = { 'api-key': config.apiKey }; if (config.priority !== undefined) payload.priority = config.priority; if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); if (config.baseUrl) payload['base-url'] = config.baseUrl; if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl; const headers = serializeHeaders(config.headers); if (headers) payload.headers = headers; const models = serializeModelAliases(config.models); if (models && models.length) payload.models = models; if (config.excludedModels && config.excludedModels.length) { payload['excluded-models'] = config.excludedModels; } return payload; }; const serializeOpenAIProvider = (provider: OpenAIProviderConfig) => { const payload: Record = { name: provider.name, '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; const headers = serializeHeaders(provider.headers); if (headers) payload.headers = headers; const models = serializeModelAliases(provider.models); if (models && models.length) payload.models = models; if (provider.priority !== undefined) payload.priority = provider.priority; if (provider.testModel) payload['test-model'] = provider.testModel; return payload; }; export const providersApi = { async getGeminiKeys(): Promise { const data = await apiClient.get('/gemini-api-key'); const list = extractArrayPayload(data, 'gemini-api-key'); return list.map((item) => normalizeGeminiKeyConfig(item)).filter(Boolean) as GeminiKeyConfig[]; }, 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 ) ), deleteGeminiKey: (apiKey: string, baseUrl?: string) => apiClient.delete(`/gemini-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`), 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[]; }, 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 ) ), deleteCodexConfig: (apiKey: string, baseUrl?: string) => apiClient.delete(`/codex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`), 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[]; }, 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 ) ), deleteClaudeConfig: (apiKey: string, baseUrl?: string) => apiClient.delete(`/claude-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`), 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[]; }, 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 ) ), deleteVertexConfig: (apiKey: string, baseUrl?: string) => apiClient.delete(`/vertex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`), 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[]; }, saveOpenAIProviders: async (providers: OpenAIProviderConfig[]) => apiClient.put( '/openai-compatibility', await buildPreservedList( 'openai-compatibility', providers, serializeOpenAIProvider, mergeOpenAIProviderPayload, openAIProviderIdentity ) ), updateOpenAIProviderDisabled: (index: number, disabled: boolean) => apiClient.patch('/openai-compatibility', { index, value: { disabled } }), deleteOpenAIProvider: (index: number) => apiClient.delete(`/openai-compatibility?index=${encodeURIComponent(String(index))}`), };