fix(models): 模型目录 - 保留人工能力并按声明发送视频分辨率

This commit is contained in:
ddCat
2026-08-17 12:56:51 +08:00
parent 050a95c1d5
commit c2352fd9b6
11 changed files with 218 additions and 48 deletions

View File

@@ -1,4 +1,4 @@
import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { modelProtocolCapability, protocolForModelCatalog, type ModelProtocol } from "@/lib/model-protocols";
import type { ModelChannel } from "@/stores/use-config-store";
@@ -66,31 +66,32 @@ export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: Ch
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;
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
const inferredCapability = modelProtocolCapability(inferredProtocol) || item.modelType;
if (existing) {
const protocol = inferredProtocol || existing.protocol;
const capability = inferredCapability || existing.capability;
const capabilityChanged = capability !== existing.capability;
const patchCapabilityConfig = hasCatalogCapabilityConfig(item) && (capability === "image" || capability === "video");
const capabilityConfig = patchCapabilityConfig
? catalogCapabilityConfig(item, protocol || channel.interfaceType, capability, capabilityChanged ? undefined : existing.capabilityConfig, false)
: capabilityChanged
? undefined
: existing.capabilityConfig;
next.push({
...existing,
...(item.displayName ? { displayName: item.displayName } : {}),
capability,
protocol,
...(replaceCapabilityConfig ? { capabilityConfig } : {}),
...(inferredProtocol ? { protocol: inferredProtocol } : {}),
...(patchCapabilityConfig || capabilityChanged ? { capabilityConfig } : {}),
});
continue;
}
const capability = inferredCapability || modelProtocolCapability(channel.interfaceType);
const protocol = inferredProtocol || protocolTemplateForNewCatalogModel(capability, channel.interfaceType);
if (!protocol || !capability) continue;
const capabilityConfig = capability === "image" || capability === "video" ? catalogCapabilityConfig(item, protocol, capability, undefined, true) : undefined;
next.push({
model: item.id,
...(item.displayName ? { displayName: item.displayName } : {}),
@@ -104,12 +105,21 @@ export function mergeFetchedChannelModelCosts(channel: ModelChannel, catalog: Ch
return next;
}
function hasCatalogCapabilityMetadata(item: ChannelModelCatalogItem) {
return Boolean(item.modelType || item.supportedEndpointTypes?.length || item.defaultParameters || item.options || item.supportsImages !== undefined || item.minImages !== undefined || item.maxImages !== undefined);
function protocolTemplateForNewCatalogModel(capability: ChannelModelCost["capability"] | undefined, channelProtocol: ModelProtocol | undefined): ModelProtocol | undefined {
if (!capability) return channelProtocol;
if (modelProtocolCapability(channelProtocol) === capability) return channelProtocol;
const templates: Record<ChannelModelCost["capability"], ModelProtocol> = { text: "chat-completion", image: "openai-image", video: "newapi", audio: "openai-audio" };
return templates[capability];
}
function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol, capability: "image" | "video"): ModelCapabilityConfig {
const config = defaultModelCapabilityConfig(protocol, item.id);
function hasCatalogCapabilityConfig(item: ChannelModelCatalogItem) {
return Boolean(item.defaultParameters || item.options || item.supportsImages !== undefined || item.minImages !== undefined || item.maxImages !== undefined);
}
function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelProtocol | undefined, capability: "image" | "video", existing: ModelCapabilityConfig | undefined, isNew: boolean): ModelCapabilityConfig {
const fallback = defaultModelCapabilityConfig(protocol, item.id);
const existingProfile = capability === "image" ? existing?.image : existing?.video;
const config = structuredClone(existingProfile ? existing! : fallback);
if (capability === "image") {
if (item.supportsImages === false) config.image!.references.maxImages = 0;
else if (item.maxImages !== undefined) config.image!.references.maxImages = item.maxImages;
@@ -117,6 +127,10 @@ function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelP
}
const video = config.video!;
if (isNew) {
video.resolutions = [];
video.defaultResolution = "";
}
const durations = uniqueNumbers(optionValues(item.options?.durationSeconds));
const defaultDuration = positiveNumber(item.defaultParameters?.durationSeconds);
if (durations.length) {
@@ -138,15 +152,26 @@ function catalogCapabilityConfig(item: ChannelModelCatalogItem, protocol: ModelP
// 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 (resolutions.length || defaultResolution) {
video.resolutions = resolutions.length ? resolutions : [defaultResolution];
video.defaultResolution = video.resolutions.includes(defaultResolution) ? defaultResolution : video.resolutions[0] || "";
}
if (item.supportsImages === false) {
if (item.supportsImages === false || item.maxImages === 0) {
video.references.minImages = 0;
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;
if (!video.operations.length) video.operations = ["text_to_video"];
video.defaultOperation = video.operations.includes(video.defaultOperation) ? video.defaultOperation : video.operations[0]!;
} else {
if (item.maxImages !== undefined) video.references.maxImages = item.maxImages;
if (item.minImages !== undefined) video.references.minImages = item.minImages;
if (video.references.minImages > 0) {
video.references.maxImages = Math.max(video.references.minImages, video.references.maxImages);
video.operations = video.operations.filter((operation) => operation !== "text_to_video");
if (!video.operations.includes("image_to_video")) video.operations.unshift("image_to_video");
video.defaultOperation = "image_to_video";
}
}
return config;
}

View File

@@ -35,6 +35,7 @@ export type ImageCapabilityConfig = {
export type VideoCapabilityConfig = {
references: {
promptMaxChars: number;
minImages: number;
maxImages: number;
maxImageBytes: number;
maxVideos: number;
@@ -133,6 +134,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
const video: VideoCapabilityConfig = {
references: {
promptMaxChars: 1000,
minImages: 0,
maxImages: 9,
maxImageBytes: 30 * 1024 * 1024,
maxVideos: 0,
@@ -189,9 +191,11 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
const channel = config.channels.find((item) => item.id === channelId) || config.channels.find((item) => item.models.includes(modelName));
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
return cost?.capabilityConfig
? { ...fallback, ...cost.capabilityConfig, image: cost.capabilityConfig.image || fallback.image, video: cost.capabilityConfig.video || fallback.video }
: fallback;
if (!cost?.capabilityConfig) return fallback;
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 };
}
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
@@ -234,6 +238,8 @@ export function videoResolutionRequest(profile: VideoCapabilityConfig, value: st
const candidates = [requested];
if (/^\d+$/.test(requested)) candidates.push(`${requested}p`);
if (requested === "low") candidates.push("480p");
if (requested === "2k") candidates.push("1440p");
if (requested === "1440" || requested === "1440p") candidates.push("2k");
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()]));

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[] = [], modelType?: string): ModelProtocol | undefined {
export function protocolForModelCatalog(endpointTypes: 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,10 +102,6 @@ export function protocolForModelCatalog(endpointTypes: string[] = [], modelType?
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;
}