easyai-ai-gateway/apps/web/src/pages/admin/platform-form.ts

355 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { BaseModelCatalogItem } from '@easyai-ai-gateway/contracts';
import type { PlatformCreateInput, PlatformModelBindingInput } from '../../types';
export const authTypes = [
{ value: 'APIKey', label: 'API Key', description: 'apiKey 字段,兼容 OpenAI / Gemini' },
{ value: 'Token', label: 'Token', description: 'token 字段,适合 Bearer Token 类平台' },
{ value: 'AccessKey-SecretKey', label: 'AccessKey + SecretKey', description: 'accessKey / secretKey 双字段' },
{ value: 'none', label: '无授权', description: '仅用于内网或测试模式' },
];
export interface PlatformWizardForm {
provider: string;
platformKey: string;
name: string;
internalName: string;
baseUrl: string;
authType: string;
credentialsPreview: Record<string, unknown>;
apiKey: string;
token: string;
accessKey: string;
secretKey: string;
pricingRuleSetId: string;
defaultDiscountFactor: string;
priority: string;
retryEnabled: boolean;
retryMaxAttempts: string;
rpmLimit: string;
rpsLimit: string;
tpmLimit: string;
concurrencyLimit: string;
testMode: boolean;
supportBase64Input: boolean;
supportUrlInput: boolean;
modelDiscountFactor: string;
modelDiscountFactors: Record<string, string>;
modelNameMappings: Record<string, string>;
modelOverrideRetry: boolean;
modelRetryEnabled: boolean;
modelRetryMaxAttempts: string;
modelOverrideRateLimit: boolean;
modelRpmLimit: string;
modelRpsLimit: string;
modelTpmLimit: string;
modelConcurrencyLimit: string;
selectionMode: 'all' | 'partial';
selectedModelIds: string[];
}
export interface ProviderConnectionDefaults {
defaultAuthType?: string;
defaultBaseUrl?: string;
}
export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnectionDefaults): PlatformWizardForm {
return {
provider,
platformKey: '',
name: '',
internalName: '',
baseUrl: defaults?.defaultBaseUrl ?? '',
authType: defaults?.defaultAuthType ?? 'APIKey',
credentialsPreview: {},
apiKey: '',
token: '',
accessKey: '',
secretKey: '',
pricingRuleSetId: '',
defaultDiscountFactor: '1',
priority: '100',
retryEnabled: true,
retryMaxAttempts: '2',
rpmLimit: '',
rpsLimit: '',
tpmLimit: '',
concurrencyLimit: '',
testMode: false,
supportBase64Input: true,
supportUrlInput: true,
modelDiscountFactor: '',
modelDiscountFactors: {},
modelNameMappings: {},
modelOverrideRetry: false,
modelRetryEnabled: true,
modelRetryMaxAttempts: '2',
modelOverrideRateLimit: false,
modelRpmLimit: '',
modelRpsLimit: '',
modelTpmLimit: '',
modelConcurrencyLimit: '',
selectionMode: 'partial',
selectedModelIds: [],
};
}
export function applyProviderDefaults(form: PlatformWizardForm, provider: string, defaults?: ProviderConnectionDefaults): PlatformWizardForm {
return {
...form,
provider,
baseUrl: defaults?.defaultBaseUrl ?? '',
authType: defaults?.defaultAuthType ?? 'APIKey',
selectedModelIds: [],
modelDiscountFactors: {},
modelNameMappings: {},
};
}
export function modelsForProvider(models: BaseModelCatalogItem[], provider: string) {
return models.filter((item) => item.providerKey === provider && item.status !== 'hidden');
}
export function selectedModelsForForm(models: BaseModelCatalogItem[], form: PlatformWizardForm) {
const visibleModels = models.filter((item) => item.status !== 'hidden');
if (form.selectionMode === 'all') {
return visibleModels;
}
const selectedIds = new Set(form.selectedModelIds);
return visibleModels.filter((item) => selectedIds.has(item.id));
}
export function platformPayload(form: PlatformWizardForm, options: { preserveEmptyCredentials?: boolean } = {}): PlatformCreateInput {
const credentials = credentialsPayload(form, options);
return {
provider: form.provider,
platformKey: optionalString(form.platformKey),
name: form.name.trim(),
internalName: optionalString(form.internalName),
baseUrl: form.baseUrl.trim(),
authType: form.authType,
credentials,
config: {
testMode: form.testMode,
supportBase64Input: form.supportBase64Input,
supportUrlInput: form.supportUrlInput,
source: 'gateway-admin',
},
retryPolicy: {
enabled: form.retryEnabled,
maxAttempts: form.retryEnabled ? positiveInt(form.retryMaxAttempts, 2) : 1,
},
rateLimitPolicy: rateLimitPolicyPayload(form),
defaultPricingMode: 'inherit_discount',
defaultDiscountFactor: positiveNumber(form.defaultDiscountFactor, 1),
pricingRuleSetId: optionalString(form.pricingRuleSetId),
priority: positiveInt(form.priority, 100),
};
}
export function platformModelPayloads(models: BaseModelCatalogItem[], form: PlatformWizardForm): PlatformModelBindingInput[] {
return selectedModelsForForm(models, form).map((model) => ({
baseModelId: model.id,
canonicalModelKey: model.canonicalModelKey,
modelName: model.providerModelName,
providerModelName: optionalString(form.modelNameMappings[model.id]) ?? model.providerModelName,
modelAlias: stableModelAlias(model),
modelType: baseModelTypes(model),
displayName: stableModelAlias(model) || model.providerModelName,
pricingMode: 'inherit_discount',
discountFactor: optionalPositiveNumber(form.modelDiscountFactors[model.id]) ?? optionalPositiveNumber(form.modelDiscountFactor),
retryPolicy: form.modelOverrideRetry
? { enabled: form.modelRetryEnabled, maxAttempts: form.modelRetryEnabled ? positiveInt(form.modelRetryMaxAttempts, 2) : 1 }
: undefined,
rateLimitPolicy: form.modelOverrideRateLimit ? rateLimitPolicyPayload({
rpmLimit: form.modelRpmLimit,
rpsLimit: form.modelRpsLimit,
tpmLimit: form.modelTpmLimit,
concurrencyLimit: form.modelConcurrencyLimit,
}) : undefined,
runtimePolicyOverride: platformModelRuntimeOverride(form),
}));
}
export function stableModelAlias(model: BaseModelCatalogItem) {
return stripProviderPrefix(
readString(model.modelAlias) ||
readString(model.displayName) ||
readString(model.metadata?.alias) ||
readString(readRecord(model.metadata?.rawModel)?.alias) ||
model.providerModelName ||
model.canonicalModelKey,
model.providerKey,
model.canonicalModelKey,
);
}
export function baseModelTypes(model: BaseModelCatalogItem) {
if (Array.isArray(model.modelType)) return model.modelType.map(String).filter(Boolean);
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
if (Array.isArray(values)) return values.map(String).filter(Boolean);
return [];
}
export function baseModelTypeText(model: BaseModelCatalogItem) {
return baseModelTypes(model).join(', ');
}
export function primaryBaseModelType(model: BaseModelCatalogItem) {
return baseModelTypes(model)[0] ?? 'text_generate';
}
function stripProviderPrefix(value: string, providerKey: string, canonicalModelKey: string) {
const trimmed = value.trim();
if (!trimmed) return trimmed;
const prefix = `${providerKey}:`;
if (providerKey && trimmed.startsWith(prefix)) return trimmed.slice(prefix.length);
if (trimmed === canonicalModelKey) {
const [, alias] = trimmed.split(/:(.*)/s);
return alias || trimmed;
}
return trimmed;
}
function readRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
function readString(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function platformModelRuntimeOverride(form: PlatformWizardForm) {
const override: Record<string, unknown> = {};
if (form.modelOverrideRetry) {
override.retryPolicy = {
enabled: form.modelRetryEnabled,
maxAttempts: form.modelRetryEnabled ? positiveInt(form.modelRetryMaxAttempts, 2) : 1,
};
}
if (form.modelOverrideRateLimit) {
override.rateLimitPolicy = rateLimitPolicyPayload({
rpmLimit: form.modelRpmLimit,
rpsLimit: form.modelRpsLimit,
tpmLimit: form.modelTpmLimit,
concurrencyLimit: form.modelConcurrencyLimit,
});
}
return Object.keys(override).length ? override : undefined;
}
export function validatePlatformForm(form: PlatformWizardForm, selectedCount: number, options: { allowEmptyCredentials?: boolean } = {}): string {
if (!form.provider.trim()) return '请选择模型厂商。';
if (!form.name.trim()) return '请填写平台名称。';
if (!form.baseUrl.trim()) return '请填写 Base URL。';
if (options.allowEmptyCredentials && !hasCredentialInput(form)) {
if (selectedCount === 0) return '请至少添加一个模型。';
return '';
}
if (form.authType === 'APIKey' && !form.apiKey.trim() && !form.testMode) return '请填写 API Key或开启测试模式。';
if (form.authType === 'Token' && !form.token.trim() && !form.testMode) return '请填写 Token或开启测试模式。';
if (form.authType === 'AccessKey-SecretKey' && (!form.accessKey.trim() || !form.secretKey.trim()) && !form.testMode) {
return '请填写 AccessKey 和 SecretKey或开启测试模式。';
}
if (selectedCount === 0) return '请至少添加一个模型。';
return '';
}
export function providerLabel(provider: string) {
return provider || 'provider';
}
function credentialsPayload(form: PlatformWizardForm, options: { preserveEmptyCredentials?: boolean } = {}) {
if (form.authType === 'APIKey') {
return credentialPayloadForFields(form, options, [{ key: 'apiKey', value: form.apiKey, previewKeys: ['apiKey', 'api_key', 'key'] }]);
}
if (form.authType === 'Token') {
return credentialPayloadForFields(form, options, [{ key: 'token', value: form.token, previewKeys: ['token'] }]);
}
if (form.authType === 'AccessKey-SecretKey') {
return credentialPayloadForFields(form, options, [
{ key: 'accessKey', value: form.accessKey, previewKeys: ['accessKey', 'access_key'] },
{ key: 'secretKey', value: form.secretKey, previewKeys: ['secretKey', 'secret_key'] },
]);
}
return {};
}
function credentialPayloadForFields(
form: PlatformWizardForm,
options: { preserveEmptyCredentials?: boolean },
fields: Array<{ key: string; previewKeys: string[]; value: string }>,
) {
const entries = fields.map((field) => ({
key: field.key,
preview: credentialPreviewValue(form.credentialsPreview, ...field.previewKeys),
value: field.value.trim(),
}));
const allUnchanged = entries.every((entry) => entry.preview && entry.value === entry.preview);
if (options.preserveEmptyCredentials && allUnchanged) return undefined;
const allEmpty = entries.every((entry) => !entry.value);
if (allEmpty) return {};
const payloadEntries: Array<[string, string | null]> = [];
for (const entry of entries) {
if (entry.preview && entry.value === entry.preview) continue;
payloadEntries.push([entry.key, entry.value || null]);
}
return Object.fromEntries(payloadEntries);
}
function hasCredentialInput(form: PlatformWizardForm) {
if (form.authType === 'APIKey') return Boolean(form.apiKey.trim());
if (form.authType === 'Token') return Boolean(form.token.trim());
if (form.authType === 'AccessKey-SecretKey') return Boolean(form.accessKey.trim() || form.secretKey.trim());
return true;
}
function credentialPreviewValue(preview: Record<string, unknown> | undefined, ...keys: string[]) {
if (!preview) return '';
for (const key of keys) {
const value = preview[key];
if (typeof value === 'string' && value.trim()) return value;
}
return '';
}
function rateLimitPolicyPayload(form: Pick<PlatformWizardForm, 'rpmLimit' | 'rpsLimit' | 'tpmLimit' | 'concurrencyLimit'>) {
const rules = [
limitRule('rpm', form.rpmLimit),
limitRule('rps', form.rpsLimit, 1),
limitRule('tpm_total', form.tpmLimit),
limitRule('concurrent', form.concurrencyLimit),
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
return rules.length ? { rules } : {};
}
function limitRule(metric: string, value: string, windowSeconds = 60) {
const limit = positiveNumber(value, 0);
if (!limit) return undefined;
return {
metric,
limit,
windowSeconds,
leaseTtlSeconds: metric === 'concurrent' ? 120 : 70,
};
}
function optionalString(value: string | null | undefined) {
const trimmed = value?.trim() ?? '';
return trimmed || undefined;
}
function positiveNumber(value: string, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function optionalPositiveNumber(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function positiveInt(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}