Files
Cli-Proxy-API-Management-Ce…/src/services/api/providers.ts
T
LTbinglingfeng cd44dca9c0 refactor(utils): share isRecord and getErrorMessage helpers
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.
2026-06-13 02:11:21 +08:00

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))}`),
};