355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
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;
|
||
}
|