mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
cd44dca9c0
isRecord was declared locally in 15 modules (with two divergent shapes) and getErrorMessage in 7. Move a single canonical pair into utils/helpers and import it everywhere. The shared isRecord excludes arrays; the call sites that previously allowed them only read named properties, so behavior is unchanged.
491 lines
16 KiB
TypeScript
491 lines
16 KiB
TypeScript
/**
|
|
* 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<string, string>) =>
|
|
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<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']);
|
|
if (!apiKey) return '';
|
|
const baseUrl = getStringField(record, ['base-url']);
|
|
return `${apiKey}\u0000${baseUrl}`;
|
|
};
|
|
|
|
const openAIProviderIdentity = (record: Record<string, unknown>) =>
|
|
getStringField(record, ['name']);
|
|
|
|
const modelIdentity = (record: Record<string, unknown>) => getStringField(record, ['name']);
|
|
|
|
const apiKeyEntryIdentity = (record: Record<string, unknown>) =>
|
|
getStringField(record, ['api-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,
|
|
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<string, unknown>[],
|
|
knownFields: readonly string[],
|
|
getIdentity: (record: Record<string, unknown>) => string,
|
|
fallbackByIndex = true
|
|
) => {
|
|
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,
|
|
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<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'] : 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 (!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<string, unknown> = { 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<string, unknown> = { 'api-key': entry.apiKey };
|
|
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
|
|
return payload;
|
|
};
|
|
|
|
const serializeProviderKey = (config: ProviderKeyConfig) => {
|
|
const payload: Record<string, unknown> = { '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<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.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<string, unknown> = { '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<string, unknown> = { '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<string, unknown> = {
|
|
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<GeminiKeyConfig[]> {
|
|
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<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[];
|
|
},
|
|
|
|
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<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[];
|
|
},
|
|
|
|
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<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[];
|
|
},
|
|
|
|
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<OpenAIProviderConfig[]> {
|
|
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))}`),
|
|
};
|