feat(platform): 模型管理与后台能力 - 新增逻辑模型路由和响应拦截

This commit is contained in:
ddCat committed 2026-08-18 20:42:25 +08:00
1 parent 0002cdd2cb
commit 8c5f56a551
89 files changed
+21451 -1170

No files matched your search

+85 -14
View File
@@ -8,6 +8,13 @@ const controlTheme = {
solidHoverBg: "#303030",
solidActiveBg: "#404040",
solidFg: "#ffffff",
checkBg: "#171717",
checkHoverBg: "#303030",
checkActiveBg: "#404040",
checkFg: "#ffffff",
switchCheckedBg: "#171717",
switchCheckedHoverBg: "#303030",
switchCheckedHandle: "#ffffff",
selectedBg: "rgba(17, 17, 17, 0.09)",
selectedHoverBg: "rgba(17, 17, 17, 0.13)",
selectedActiveBg: "rgba(17, 17, 17, 0.16)",
@@ -33,6 +40,13 @@ const controlTheme = {
solidHoverBg: "#ffffff",
solidActiveBg: "#e5e5e5",
solidFg: "#171717",
checkBg: "#f5f5f5",
checkHoverBg: "#ffffff",
checkActiveBg: "#e5e5e5",
checkFg: "#131313",
switchCheckedBg: "#f5f5f5",
switchCheckedHoverBg: "#ffffff",
switchCheckedHandle: "#131313",
selectedBg: "rgba(255, 255, 255, 0.12)",
selectedHoverBg: "rgba(255, 255, 255, 0.16)",
selectedActiveBg: "rgba(255, 255, 255, 0.2)",
@@ -103,7 +117,7 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
colorPrimaryHover: color.solidHoverBg,
colorPrimaryActive: color.solidActiveBg,
primaryColor: color.solidFg,
defaultBg: color.controlSurface,
defaultBg: "transparent",
defaultColor: color.selectedFg,
defaultBorderColor: color.controlBorder,
defaultHoverBg: color.selectedBg,
@@ -131,9 +145,9 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
Switch: {
handleBg: color.switchOffHandle,
handleShadow: dark ? "0 1px 4px rgba(0, 0, 0, 0.42)" : "0 1px 2px rgba(0, 0, 0, 0.2)",
colorPrimary: color.solidBg,
colorPrimaryActive: color.solidActiveBg,
colorPrimaryHover: color.solidHoverBg,
colorPrimary: color.switchCheckedBg,
colorPrimaryActive: color.switchCheckedBg,
colorPrimaryHover: color.switchCheckedHoverBg,
colorTextQuaternary: color.switchOffBg,
colorTextTertiary: color.switchOffHoverBg,
controlOutline: color.controlFocus,
@@ -142,9 +156,9 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
colorBgContainer: color.controlSurface,
colorBgContainerDisabled: color.controlDisabledBg,
colorBorder: color.controlBorder,
colorPrimary: color.solidBg,
colorPrimaryActive: color.solidActiveBg,
colorPrimaryHover: color.solidHoverBg,
colorPrimary: color.checkBg,
colorPrimaryActive: color.checkActiveBg,
colorPrimaryHover: color.checkHoverBg,
controlOutline: color.controlFocus,
},
Radio: {
@@ -154,16 +168,16 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
buttonCheckedBg: color.selectedBg,
buttonCheckedBgDisabled: color.controlDisabledBg,
buttonCheckedColorDisabled: color.controlDisabledFg,
buttonSolidCheckedColor: color.solidFg,
buttonSolidCheckedActiveBg: color.solidActiveBg,
buttonSolidCheckedBg: color.solidBg,
buttonSolidCheckedHoverBg: color.solidHoverBg,
buttonSolidCheckedColor: color.checkFg,
buttonSolidCheckedActiveBg: color.checkActiveBg,
buttonSolidCheckedBg: color.checkBg,
buttonSolidCheckedHoverBg: color.checkHoverBg,
colorBgContainer: color.controlSurface,
colorBgContainerDisabled: color.controlDisabledBg,
colorBorder: color.controlBorder,
colorPrimary: color.solidBg,
colorPrimaryActive: color.solidActiveBg,
colorPrimaryHover: color.solidHoverBg,
colorPrimary: color.checkBg,
colorPrimaryActive: color.checkActiveBg,
colorPrimaryHover: color.checkHoverBg,
controlOutline: color.controlFocus,
},
Menu: {
@@ -241,3 +255,60 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
},
};
}
/**
* 管理后台使用更高密度的控件节奏;只在 AdminShell 内挂载,避免改变创作端的视觉契约。
*/
export function getAdminAntThemeConfig(dark: boolean): ThemeConfig {
const base = getAntThemeConfig(dark);
const mutedForeground = dark ? "rgba(250, 250, 250, 0.58)" : "rgba(23, 23, 23, 0.58)";
return {
...base,
token: {
...base.token,
borderRadius: 6,
borderRadiusLG: 8,
colorBgContainer: "var(--color-surface)",
colorBorder: "var(--color-border)",
fontFamily: "var(--font-sans)",
padding: 12,
paddingSM: 8,
fontSize: 13,
},
components: {
...base.components,
Card: {
...base.components?.Card,
boxShadow: "none",
boxShadowTertiary: "none",
},
Drawer: {
...base.components?.Drawer,
colorBgElevated: "var(--color-surface)",
},
Form: {
...base.components?.Form,
itemMarginBottom: 16,
},
Menu: {
...base.components?.Menu,
itemHeight: 34,
itemSelectedBg: "transparent",
},
Table: {
...base.components?.Table,
cellPaddingBlock: 8,
cellPaddingBlockMD: 8,
cellPaddingBlockSM: 6,
cellPaddingInline: 12,
cellPaddingInlineMD: 12,
cellPaddingInlineSM: 8,
headerBg: "transparent",
headerColor: mutedForeground,
headerSplitColor: "transparent",
borderColor: "var(--color-border)",
},
},
};
}
+33 -22
View File
@@ -1,5 +1,5 @@
import { type GenerationTask } from "@/services/api/task-center";
import { backendProviderConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task";
import { backendProviderConfig, logicalModelIDForConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task";
import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
@@ -8,7 +8,7 @@ import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
import { isSeedanceVideoConfig } from "@/lib/seedance-video";
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
import { resolveCompatibleModel, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
import { modelRequestOptions, resolveCompatibleModel, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
@@ -85,7 +85,7 @@ export function assertCanvasImageReferenceLimit(config: AiConfig, referenceImage
if (error) throw new Error(error);
}
export { backendProviderConfig };
export { backendProviderConfig, logicalModelIDForConfig };
const generationOperationLocks = new Map<string, Promise<unknown>>();
@@ -375,35 +375,46 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
const preferredModel = storedModel || resolveCanvasGenerationModel(config, defaultModel, mode) || fallbackModel;
const imageSize = mode === "image" ? node?.metadata?.size || config.size || defaultConfig.size : undefined;
// 先合并节点上的实时选择,再做兼容性匹配。否则路由只看到全局默认值,节点改过的时长、分辨率或布尔能力无法参与分流。
const requestedConfig: AiConfig = {
...config,
quality: node?.metadata?.quality ?? config.quality ?? defaultConfig.quality,
size: node?.metadata?.size ?? config.size ?? defaultConfig.size,
transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground,
videoSeconds: normalizeVideoDuration(node?.metadata?.seconds ?? config.videoSeconds ?? defaultConfig.videoSeconds),
vquality: normalizeVideoResolution(node?.metadata?.vquality ?? config.vquality ?? defaultConfig.vquality),
videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark,
audioVoice: node?.metadata?.audioVoice ?? config.audioVoice ?? defaultConfig.audioVoice,
audioFormat: node?.metadata?.audioFormat ?? config.audioFormat ?? defaultConfig.audioFormat,
audioSpeed: node?.metadata?.audioSpeed ?? config.audioSpeed ?? defaultConfig.audioSpeed,
audioInstructions: node?.metadata?.audioInstructions ?? config.audioInstructions ?? defaultConfig.audioInstructions,
count: String(node?.metadata?.count ?? (mode === "image" ? config.canvasImageCount || config.count || defaultConfig.count : config.count || defaultConfig.count)),
};
const imageSize = mode === "image" ? requestedConfig.size : undefined;
// 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型,
// 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。
const baseRequirements = requirements?.capability ? requirements : { capability: mode };
const liveOptions = modelRequestOptions(requestedConfig, mode);
const baseRequirements = requirements?.capability
? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } }
: { capability: mode, options: liveOptions };
const model = resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel;
const imageProfile = mode === "image" ? modelCapabilityConfigFor(config, model).image! : undefined;
const normalizedImage = imageProfile
? normalizeImageValue(imageProfile, {
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
transparentBackground: node?.metadata?.transparentBackground || config.transparentBackground,
count: String(node?.metadata?.count || config.canvasImageCount || config.count || defaultConfig.count),
quality: requestedConfig.quality,
size: requestedConfig.size,
transparentBackground: requestedConfig.transparentBackground,
count: requestedConfig.count,
})
: undefined;
return {
...config,
...requestedConfig,
model,
quality: normalizedImage?.quality || node?.metadata?.quality || config.quality || defaultConfig.quality,
size: normalizedImage?.size || node?.metadata?.size || config.size || defaultConfig.size,
transparentBackground: normalizedImage?.transparentBackground || ((node?.metadata?.transparentBackground || config.transparentBackground) === "true" ? "true" : "false"),
videoSeconds: normalizeVideoDuration(node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds),
vquality: normalizeVideoResolution(node?.metadata?.vquality || config.vquality || defaultConfig.vquality),
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
count: normalizedImage?.count || String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
quality: normalizedImage?.quality || requestedConfig.quality,
size: normalizedImage?.size || requestedConfig.size,
transparentBackground: normalizedImage?.transparentBackground || (requestedConfig.transparentBackground === "true" ? "true" : "false"),
count: normalizedImage?.count || requestedConfig.count,
};
}
+20 -2
View File
@@ -2,10 +2,21 @@ import type { ModelProtocol } from "@/lib/model-protocols";
export type ModelCapabilityConfig = {
version: number;
text?: TextCapabilityConfig;
image?: ImageCapabilityConfig;
video?: VideoCapabilityConfig;
};
export type TextCapabilityConfig = {
references: {
promptMaxChars: number;
maxImages: number;
maxImageBytes: number;
maxVideos: number;
maxVideoBytes: number;
};
};
export type ImageSizeParameter = "none" | "size" | "aspect_ratio";
export type ImageCapabilityConfig = {
@@ -131,6 +142,10 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = "
}
export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = ""): ModelCapabilityConfig {
const text: TextCapabilityConfig = {
// 文本模型的视觉能力必须由管理员明确开启,不能根据模型名猜测。
references: { promptMaxChars: 32000, maxImages: 0, maxImageBytes: 0, maxVideos: 0, maxVideoBytes: 0 },
};
const video: VideoCapabilityConfig = {
references: {
promptMaxChars: 1000,
@@ -181,7 +196,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
video.resolutions = ["1080p"];
video.defaultResolution = "1080p";
}
return { version: 1, image: defaultImageCapabilityConfig(protocol, model), video };
return { version: 1, text, image: defaultImageCapabilityConfig(protocol, model), video };
}
export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; models: string[]; modelCosts?: Array<{ model: string; capabilityConfig?: ModelCapabilityConfig; protocol?: ModelProtocol }> }> }, model: string) {
@@ -192,10 +207,13 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
if (!cost?.capabilityConfig) return fallback;
const text = cost.capabilityConfig.text
? { ...fallback.text!, ...cost.capabilityConfig.text, references: { ...fallback.text!.references, ...cost.capabilityConfig.text.references } }
: fallback.text;
const video = cost.capabilityConfig.video
? { ...fallback.video!, ...cost.capabilityConfig.video, references: { ...fallback.video!.references, ...cost.capabilityConfig.video.references } }
: fallback.video;
return { ...fallback, ...cost.capabilityConfig, image: cost.capabilityConfig.image || fallback.image, video };
return { ...fallback, ...cost.capabilityConfig, text, image: cost.capabilityConfig.image || fallback.image, video };
}
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
+78
View File
@@ -15,6 +15,7 @@ export type ModelRequirements = {
videoOperation?: string;
videoSeconds?: string;
imageSize?: string;
options?: Record<string, unknown>;
};
export type DisplayModelGroup = {
@@ -53,6 +54,18 @@ export function modelCompatibilityError(config: AiConfig, model: string, require
if (!capability) return "";
const input = requirements?.input;
const visualInputCount = input ? input.imageCount + input.characterCount : 0;
const channel = resolveModelChannel(config, model);
const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
const logicalSpecs = logicalCost?.logicalCapabilityProfiles?.length ? logicalCost.logicalCapabilityProfiles : logicalCost?.logicalCapabilitySpec ? [logicalCost.logicalCapabilitySpec] : [];
if (logicalSpecs.length) {
const publicOptionNames = logicalCost?.logicalCapabilitySpec?.options || {};
const logicalRequirements = {
...requirements,
options: Object.fromEntries(Object.entries(requirements.options || {}).filter(([name]) => Boolean(publicOptionNames[name]))),
};
const errors = logicalSpecs.map((spec) => logicalModelCompatibilityError(spec, logicalRequirements, visualInputCount));
return errors.some((error) => !error) ? "" : errors[0] || "当前输入不受支持";
}
if (capability === "image") {
const image = modelCapabilityConfigFor(config, model).image!;
@@ -87,6 +100,71 @@ export function modelCompatibilityError(config: AiConfig, model: string, require
return input.imageCount > 0 || input.videoCount > 0 || input.audioCount > 0 ? "音频模型只接受文本或单个角色卡输入" : "";
}
export function modelRequestOptions(config: AiConfig, capability: ModelCapability) {
switch (capability) {
case "image":
return { size: config.size, quality: config.quality, transparentBackground: config.transparentBackground === "true", count: Number(config.count) };
case "video":
return { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" };
case "audio":
return { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed) };
default:
return {};
}
}
function logicalModelCompatibilityError(spec: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalCapabilitySpec"]>, requirements: ModelRequirements, visualInputCount: number) {
if (requirements.capability && spec.capability !== requirements.capability) return "不支持当前生成类型";
const input = requirements.input;
const counts: Record<string, number> = {
text: 0,
image: visualInputCount,
video: input?.videoCount || 0,
audio: input?.audioCount || 0,
};
for (const [kind, count] of Object.entries(counts)) {
const constraint = spec.inputs?.[kind];
if (!constraint && count > 0) return `不支持${kind}输入`;
if (constraint && (count < constraint.min || count > constraint.max)) return `${kind}输入需为 ${constraint.min}-${constraint.max} 个`;
}
const operation = requirements.capability === "video" && input ? resolveVideoOperation(input, requirements.videoOperation) : requirements.videoOperation;
if (operation && spec.operations?.length && !spec.operations.includes(operation)) return "不支持当前生成模式";
const options = { ...requirements.options, ...(requirements.videoSeconds ? { videoSeconds: requirements.videoSeconds } : {}), ...(requirements.imageSize ? { size: requirements.imageSize } : {}) };
for (const [name, value] of Object.entries(options)) {
if (value === undefined || value === null || value === "") continue;
const constraint = spec.options?.[name];
if (!constraint || !logicalOptionMatches(constraint, value)) return logicalOptionError(name);
}
return "";
}
function logicalOptionMatches(constraint: { values?: unknown[]; min?: number; max?: number; step?: number }, value: unknown) {
if (constraint.values?.length) return constraint.values.some((candidate) => String(candidate).toLowerCase() === String(value).toLowerCase());
const numeric = Number(value);
if (!Number.isFinite(numeric)) return false;
if (constraint.min !== undefined && numeric < constraint.min) return false;
if (constraint.max !== undefined && numeric > constraint.max) return false;
if (constraint.step !== undefined && constraint.min !== undefined) return Math.abs((numeric - constraint.min) / constraint.step - Math.round((numeric - constraint.min) / constraint.step)) < 1e-9;
return true;
}
function logicalOptionError(name: string) {
const label: Record<string, string> = {
size: "尺寸",
quality: "质量",
transparentBackground: "透明背景",
count: "输出数量",
videoSeconds: "时长",
vquality: "分辨率",
videoGenerateAudio: "同步音频",
videoWatermark: "水印设置",
audioVoice: "音色",
audioFormat: "音频格式",
audioSpeed: "语速",
};
return `不支持当前${label[name] || name}`;
}
export function compatibleModelInGroup(config: AiConfig, models: string[], requirements?: ModelRequirements, preferred?: string) {
const compatible = models.filter((model) => !modelCompatibilityError(config, model, requirements));
if (!compatible.length) return "";
+100 -7
View File
@@ -1,10 +1,12 @@
import { getFeatureAvailability, getSystemChannels, type AuthSessionPayload } from "@/services/api/auth";
import { getFeatureAvailability, type AuthSessionPayload } from "@/services/api/auth";
import { listLogicalModels, type CapabilitySpec, type OptionConstraint, type PublicLogicalModel } from "@/services/api/logical-models";
import { localForageStorage } from "@/lib/localforage-storage";
import { appQueryClient } from "@/lib/query-client";
import { scopedLocalStorage, setActiveUserScope } from "@/lib/user-scope";
import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
import { CONFIG_STORE_KEY, defaultConfig, normalizeConfigSnapshot, useConfigStore } from "@/stores/use-config-store";
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelChannel } from "@/stores/use-config-store";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useUserStore } from "@/stores/use-user-store";
import { installRemoteUserDataAutoSync, resetRemoteUserDataSync, syncRemoteUserData, withRemoteUserDataSyncPaused } from "@/services/user-data-sync";
import { withGenerationConsumersPaused } from "@/services/generation-consumer-lifecycle";
@@ -41,7 +43,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
const initialSystemConfig = {
...defaultConfig,
channels: payload.systemChannels || [],
channels: managedModelChannels(payload.logicalModels || []),
imageModels: undefined,
videoModels: undefined,
textModels: undefined,
@@ -49,7 +51,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
};
useConfigStore.getState().replaceConfig(normalizeConfigSnapshot({ config: initialSystemConfig }).config);
} else {
useConfigStore.getState().mergeSystemChannels(payload.systemChannels || []);
useConfigStore.getState().mergeSystemChannels(managedModelChannels(payload.logicalModels || []));
}
installRemoteUserDataAutoSync();
if (payload.user?.id) await syncRemoteUserData(payload.user.id);
@@ -60,9 +62,100 @@ export async function applyUserSession(payload: AuthSessionPayload) {
}
export async function refreshSystemChannels() {
// 系统模型由后端统一维护,后台变更后只刷新这一层,避免重跑整套用户数据同步。
const payload = await getSystemChannels();
useConfigStore.getState().mergeSystemChannels(payload.channels || []);
// 创作端只刷新公开前台模型;供应渠道目录仅管理员页面可见。
const logicalPayload = await listLogicalModels();
useConfigStore.getState().mergeSystemChannels(managedModelChannels(logicalPayload.models || []));
}
function managedModelChannels(models: PublicLogicalModel[]) {
const availableModels = models.filter((item) => item.available);
if (!availableModels.length) return [];
const managed: ModelChannel = {
id: PUBLIC_MODEL_CATALOG_ID,
name: "平台模型",
baseUrl: "/api",
apiKey: "system",
apiFormat: "openai",
scope: "system",
enabled: true,
models: availableModels.map((item) => item.id),
modelCosts: availableModels.map((item) => ({
model: item.id,
displayName: item.name,
description: item.description,
icon: item.icon,
capability: item.capability,
pricePolicy: item.pricePolicy,
billingMode: item.billingMode,
unitPriceMicrocredits: item.unitPriceMicrocredits,
inputTokenPriceMicrocredits: item.inputPriceMicrocredits,
outputTokenPriceMicrocredits: item.outputPriceMicrocredits,
cachedTokenPriceMicrocredits: item.cachedPriceMicrocredits,
capabilityConfig: projectLogicalCapability(item.capabilitySpec, item.defaultOptions),
logicalModelId: item.id,
logicalCapabilitySpec: item.capabilitySpec,
logicalCapabilityProfiles: item.capabilityProfiles,
defaultOptions: item.defaultOptions,
})),
};
return [managed];
}
function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string, unknown>): ModelCapabilityConfig {
const projected = defaultModelCapabilityConfig();
if (spec.capability === "image" && projected.image) {
projected.image.references.maxImages = spec.inputs?.image?.max ?? 0;
projected.image.references.maskSupported = (spec.inputs?.mask?.max ?? 0) > 0;
projected.image.size = { parameter: "none", values: [], default: "auto", allowCustom: false };
projected.image.quality = { supported: false, values: [], default: "auto" };
projected.image.transparentBackground = { supported: false, default: false };
applyStringOption(spec.options?.size || spec.options?.aspectRatio, defaults.size, (values, initial) => {
projected.image!.size = { parameter: "size", values, default: initial, allowCustom: false };
});
applyStringOption(spec.options?.quality, defaults.quality, (values, initial) => {
projected.image!.quality = { supported: true, values, default: initial };
});
projected.image.maxOutputs = maxNumericOption(spec.options?.count, 1);
projected.image.transparentBackground = booleanOption(spec.options?.transparentBackground, defaults.transparentBackground);
}
if (spec.capability === "video" && projected.video) {
projected.video.references.minImages = spec.inputs?.image?.min ?? 0;
projected.video.references.maxImages = spec.inputs?.image?.max ?? 0;
projected.video.references.maxVideos = spec.inputs?.video?.max ?? 0;
projected.video.references.maxAudios = spec.inputs?.audio?.max ?? 0;
projected.video.operations = spec.operations || [];
projected.video.defaultOperation = spec.operations?.[0] || "";
const duration = spec.options?.videoSeconds || spec.options?.duration;
if (duration?.values?.length) projected.video.duration = { selection: "enum", values: duration.values.map(Number).filter(Number.isFinite), default: Number(defaults.videoSeconds ?? duration.values[0]) };
else if (duration?.min !== undefined && duration.max !== undefined) projected.video.duration = { selection: "range", min: duration.min, max: duration.max, step: duration.step || 1, default: Number(defaults.videoSeconds ?? duration.min) };
projected.video.ratios = stringValues(spec.options?.size || spec.options?.aspectRatio);
projected.video.defaultRatio = String(defaults.size ?? projected.video.ratios[0] ?? "");
projected.video.resolutions = stringValues(spec.options?.vquality || spec.options?.resolution);
projected.video.defaultResolution = String(defaults.vquality ?? projected.video.resolutions[0] ?? "");
projected.video.generateAudio = booleanOption(spec.options?.videoGenerateAudio, defaults.videoGenerateAudio);
projected.video.watermark = booleanOption(spec.options?.videoWatermark, defaults.videoWatermark);
}
return projected;
}
function applyStringOption(option: OptionConstraint | undefined, fallback: unknown, apply: (values: string[], initial: string) => void) {
const values = stringValues(option);
if (values.length) apply(values, String(fallback ?? values[0]));
}
function stringValues(option?: OptionConstraint) {
return (option?.values || []).map(String);
}
function maxNumericOption(option: OptionConstraint | undefined, fallback: number) {
if (option?.max !== undefined) return option.max;
const values = (option?.values || []).map(Number).filter(Number.isFinite);
return values.length ? Math.max(...values) : fallback;
}
function booleanOption(option: OptionConstraint | undefined, fallback: unknown) {
const supported = (option?.values || []).some((value) => value === true || value === "true");
return { supported, default: supported && String(fallback) === "true" };
}
export async function refreshFeatureAvailability() {