Files
ttstd-infinite-canvas/web/src/lib/channel-model-catalog.ts

222 lines
10 KiB
TypeScript

import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, protocolForModelCatalog, type ModelProtocol } from "@/lib/model-protocols";
import type { ModelChannel } from "@/stores/use-config-store";
export type ChannelModelCatalogOption = { value: string; label?: string };
export type ChannelModelCatalogItem = {
id: string;
displayName?: string;
modelType?: "text" | "image" | "video" | "audio";
supportedEndpointTypes?: string[];
defaultParameters?: {
aspectRatio?: string;
durationSeconds?: string;
resolution?: string;
};
options?: {
aspectRatio?: ChannelModelCatalogOption[];
durationSeconds?: ChannelModelCatalogOption[];
resolution?: ChannelModelCatalogOption[];
};
supportsImages?: boolean;
minImages?: number;
maxImages?: number;
};
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
export function sanitizeChannelModelCatalogItem(value: unknown): ChannelModelCatalogItem | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const id = stringValue(record.id);
if (!id) return null;
const modelType = stringValue(record.modelType).toLowerCase();
const defaultParameters = objectValue(record.defaultParameters);
const options = objectValue(record.options);
const normalizedDefaults = defaultParameters
? {
...(stringValue(defaultParameters.aspectRatio) ? { aspectRatio: stringValue(defaultParameters.aspectRatio) } : {}),
...(stringValue(defaultParameters.durationSeconds) ? { durationSeconds: stringValue(defaultParameters.durationSeconds) } : {}),
...(stringValue(defaultParameters.resolution) ? { resolution: stringValue(defaultParameters.resolution) } : {}),
}
: undefined;
const normalizedOptions = options
? {
...(catalogOptions(options.aspectRatio).length ? { aspectRatio: catalogOptions(options.aspectRatio) } : {}),
...(catalogOptions(options.durationSeconds).length ? { durationSeconds: catalogOptions(options.durationSeconds) } : {}),
...(catalogOptions(options.resolution).length ? { resolution: catalogOptions(options.resolution) } : {}),
}
: undefined;
return compactCatalogItem({
id,
displayName: stringValue(record.displayName),
modelType: ["text", "image", "video", "audio"].includes(modelType) ? (modelType as ChannelModelCatalogItem["modelType"]) : undefined,
supportedEndpointTypes: stringArray(record.supportedEndpointTypes),
defaultParameters: normalizedDefaults,
options: normalizedOptions,
supportsImages: typeof record.supportsImages === "boolean" ? record.supportsImages : undefined,
minImages: nonNegativeInteger(record.minImages),
maxImages: nonNegativeInteger(record.maxImages),
});
}
export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
const next: ChannelModelCost[] = [];
for (const item of catalog) {
const existing = existingByModel.get(item.id);
const catalogBackedCapability = hasCatalogCapabilityMetadata(item);
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes, item.modelType);
if (existing && !catalogBackedCapability && !inferredProtocol) {
next.push({
...existing,
...(item.displayName ? { displayName: item.displayName } : {}),
});
continue;
}
const protocol = inferredProtocol || existing?.protocol || channel.interfaceType;
const capability = modelProtocolCapability(protocol) || item.modelType;
if (!protocol || !capability) continue;
const protocolChanged = Boolean(existing && inferredProtocol && existing.protocol !== inferredProtocol);
const replaceCapabilityConfig = !existing || catalogBackedCapability || protocolChanged;
const capabilityConfig = replaceCapabilityConfig && (capability === "image" || capability === "video")
? (catalogBackedCapability ? catalogCapabilityConfig(item, protocol, capability) : defaultModelCapabilityConfig(protocol, item.id))
: undefined;
if (existing) {
next.push({
...existing,
...(item.displayName ? { displayName: item.displayName } : {}),
capability,
protocol,
...(replaceCapabilityConfig ? { capabilityConfig } : {}),
});
continue;
}
next.push({
model: item.id,
...(item.displayName ? { displayName: item.displayName } : {}),
capability,
protocol,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
...(capabilityConfig ? { capabilityConfig } : {}),
});
}
return next;
}
function hasCatalogCapabilityMetadata(item: ChannelModelCatalogItem) {
return Boolean(
item.modelType ||
item.defaultParameters ||
item.options ||
item.supportsImages !== undefined ||
item.minImages !== undefined ||
item.maxImages !== undefined,
);
}
function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol, capability: "image" | "video"): ModelCapabilityConfig {
const config = defaultModelCapabilityConfig(protocol, item.id);
if (capability === "image") {
if (item.supportsImages === false) config.image!.references.maxImages = 0;
else if (item.maxImages !== undefined) config.image!.references.maxImages = item.maxImages;
return config;
}
const video = config.video!;
const durations = uniqueNumbers(optionValues(item.options?.durationSeconds));
const defaultDuration = positiveNumber(item.defaultParameters?.durationSeconds);
if (durations.length) {
video.duration = { selection: "enum", values: durations, default: durations.includes(defaultDuration) ? defaultDuration : durations[0]! };
} else if (defaultDuration > 0) {
video.duration = { selection: "enum", values: [defaultDuration], default: defaultDuration };
}
const ratios = optionValues(item.options?.aspectRatio);
const defaultRatio = item.defaultParameters?.aspectRatio?.trim() || "";
if (ratios.length) {
video.ratios = ratios;
video.defaultRatio = ratios.includes(defaultRatio) ? defaultRatio : ratios[0]!;
} else if (defaultRatio) {
video.ratios = [defaultRatio];
video.defaultRatio = defaultRatio;
}
// A catalog-backed video may only emit resolution_name when the provider
// explicitly advertises compatible values. An empty list means omit it.
const resolutions = optionValues(item.options?.resolution);
const defaultResolution = item.defaultParameters?.resolution?.trim() || "";
video.resolutions = resolutions;
video.defaultResolution = resolutions.includes(defaultResolution) ? defaultResolution : resolutions[0] || "";
if (item.supportsImages === false) {
video.references.maxImages = 0;
video.operations = video.operations.filter((operation) => operation !== "image_to_video");
video.defaultOperation = "text_to_video";
} else if (item.maxImages !== undefined) {
video.references.maxImages = item.maxImages;
}
return config;
}
function compactCatalogItem(item: ChannelModelCatalogItem): ChannelModelCatalogItem {
const defaultParameters = item.defaultParameters && Object.values(item.defaultParameters).some(Boolean) ? item.defaultParameters : undefined;
const options = item.options && Object.values(item.options).some((values) => values?.length) ? item.options : undefined;
return {
id: item.id,
...(item.displayName ? { displayName: item.displayName } : {}),
...(item.modelType ? { modelType: item.modelType } : {}),
...(item.supportedEndpointTypes?.length ? { supportedEndpointTypes: item.supportedEndpointTypes } : {}),
...(defaultParameters ? { defaultParameters } : {}),
...(options ? { options } : {}),
...(item.supportsImages !== undefined ? { supportsImages: item.supportsImages } : {}),
...(item.minImages !== undefined ? { minImages: item.minImages } : {}),
...(item.maxImages !== undefined ? { maxImages: item.maxImages } : {}),
};
}
function objectValue(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function stringArray(value: unknown) {
return Array.isArray(value) ? Array.from(new Set(value.map(stringValue).filter(Boolean))) : [];
}
function catalogOptions(value: unknown): ChannelModelCatalogOption[] {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const options: ChannelModelCatalogOption[] = [];
for (const raw of value) {
const record = objectValue(raw);
const optionValue = stringValue(record?.value);
if (!optionValue || seen.has(optionValue)) continue;
seen.add(optionValue);
const label = stringValue(record?.label);
options.push({ value: optionValue, ...(label ? { label } : {}) });
}
return options;
}
function optionValues(options: ChannelModelCatalogOption[] | undefined) {
return Array.from(new Set((options || []).map((option) => option.value.trim()).filter(Boolean)));
}
function positiveNumber(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function uniqueNumbers(values: string[]) {
return Array.from(new Set(values.map(positiveNumber).filter((value) => value > 0)));
}
function nonNegativeInteger(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
}