fix(models): 模型目录 - 识别公共视频能力并按声明发送分辨率

This commit is contained in:
Codex
2026-08-16 22:27:29 +08:00
parent 6cdece9e04
commit f8cbcd1b6f
19 changed files with 792 additions and 51 deletions

View 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;
}

View File

@@ -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;

View File

@@ -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;
}