mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(providers): enhance provider configuration handling and serialization
This commit is contained in:
+339
-20
@@ -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)}`),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user