fix(models): 模型目录 - 识别公共视频能力并按声明发送分辨率
This commit is contained in:
221
web/src/lib/channel-model-catalog.ts
Normal file
221
web/src/lib/channel-model-catalog.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
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;
|
||||
}
|
||||
@@ -228,6 +228,22 @@ export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { sec
|
||||
return { seconds: String(duration), ratio, resolution };
|
||||
}
|
||||
|
||||
export function videoResolutionRequest(profile: VideoCapabilityConfig, value: string | undefined) {
|
||||
const requested = String(value || "").trim().toLowerCase();
|
||||
if (!requested || requested === "auto" || requested === "default" || requested === "medium" || requested === "high") return undefined;
|
||||
const candidates = [requested];
|
||||
if (/^\d+$/.test(requested)) candidates.push(`${requested}p`);
|
||||
if (requested === "low") candidates.push("480p");
|
||||
if (requested === "4k") candidates.push("2160p");
|
||||
if (requested === "2160" || requested === "2160p") candidates.push("4k");
|
||||
const supported = new Map(profile.resolutions.map((resolution) => [resolution.trim().toLowerCase(), resolution.trim()]));
|
||||
for (const candidate of candidates) {
|
||||
const match = supported.get(candidate);
|
||||
if (match) return match;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeRangeDuration(profile: VideoCapabilityConfig, value: number) {
|
||||
const min = profile.duration.min || 1;
|
||||
const max = profile.duration.max || min;
|
||||
|
||||
@@ -92,7 +92,7 @@ export function modelProtocolSupportsTokenBilling(capability?: string, protocol?
|
||||
return capability === "text" || (capability === "video" && protocol === "volcengine-ark-video");
|
||||
}
|
||||
|
||||
export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProtocol | undefined {
|
||||
export function protocolForModelCatalog(endpointTypes: string[] = [], modelType?: string): ModelProtocol | undefined {
|
||||
const normalized = new Set(endpointTypes.map((value) => value.trim().toLowerCase()));
|
||||
if (normalized.has("openai-chat") || normalized.has("chat-completion") || normalized.has("chat")) return "chat-completion";
|
||||
if (normalized.has("openai-response") || normalized.has("responses")) return "openai-response";
|
||||
@@ -102,6 +102,10 @@ export function protocolForModelCatalog(endpointTypes: string[] = []): ModelProt
|
||||
if (normalized.has("grok-image")) return "grok-image";
|
||||
if (normalized.has("gemini-veo") || normalized.has("gemini-video") || normalized.has("veo")) return "gemini-veo";
|
||||
if (normalized.has("volcengine-ark-video") || normalized.has("volcengine-ark")) return "volcengine-ark-video";
|
||||
if (modelType?.trim().toLowerCase() === "video") return "newapi";
|
||||
if (modelType?.trim().toLowerCase() === "image") return "openai-image";
|
||||
if (modelType?.trim().toLowerCase() === "audio") return "openai-audio";
|
||||
if (modelType?.trim().toLowerCase() === "text") return "chat-completion";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user