fix: recognize BFL reference image support

This commit is contained in:
134 2026-06-30 14:38:37 +08:00
parent d001dba00c
commit e7cd3d6e73
6 changed files with 119 additions and 14 deletions

View File

@ -1,6 +1,7 @@
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image"; import { isFalGptImage2EditModelId } from "@/lib/ai/providers/fal-image";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { convertMessagesWithImages } from "./image-media"; import { convertMessagesWithImages } from "./image-media";
@ -370,7 +371,10 @@ export async function generateImageWithFailoverCandidate(params: {
const { candidate, messages, imageParams, fallbackImageParams } = params; const { candidate, messages, imageParams, fallbackImageParams } = params;
const providerType = String(candidate.provider?.type || ""); const providerType = String(candidate.provider?.type || "");
const candidateRuntimeModelId = resolveImageModelRuntimeId(candidate.model); const candidateRuntimeModelId = resolveImageModelRuntimeId(candidate.model);
if (chatMessagesHaveImageInput(messages) && candidate.model?.supportsVision === false) { if (
chatMessagesHaveImageInput(messages) &&
modelLacksReferenceImageInputSupport(candidate.model, candidate.provider)
) {
throw new Error( throw new Error(
`备用模型不支持参考图/图生图输入:${candidate.model.displayName || candidate.model.id}` `备用模型不支持参考图/图生图输入:${candidate.model.displayName || candidate.model.id}`
); );

View File

@ -14,6 +14,10 @@ import {
getPointsSystemConfig, getPointsSystemConfig,
getSiteSettings, getSiteSettings,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import {
modelLacksReferenceImageInputSupport,
modelSupportsReferenceImageInput,
} from "@/lib/ai/reference-image-capability";
import { getImageToolCreditCost } from "@/lib/points/config"; import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
@ -217,7 +221,10 @@ function findModelSelectionEntry(
function isScaleAdjustDedicatedDetailModel( function isScaleAdjustDedicatedDetailModel(
entry: AiModelWithProviderForSelection | null | undefined entry: AiModelWithProviderForSelection | null | undefined
) { ) {
if (!entry?.model?.supportsImageGen || entry.model.supportsVision === false) { if (
!entry?.model?.supportsImageGen ||
modelLacksReferenceImageInputSupport(entry.model, entry.provider)
) {
return false; return false;
} }
const label = `${entry.model.displayName || ""} ${entry.model.modelId || ""}` const label = `${entry.model.displayName || ""} ${entry.model.modelId || ""}`
@ -237,7 +244,7 @@ function isHealthyImageModelEntry(
) { ) {
return ( return (
Boolean(entry?.model?.supportsImageGen) && Boolean(entry?.model?.supportsImageGen) &&
entry?.model?.supportsVision !== false && modelSupportsReferenceImageInput(entry?.model, entry?.provider) &&
entry?.model?.healthStatus === "healthy" entry?.model?.healthStatus === "healthy"
); );
} }

View File

@ -35,6 +35,7 @@ import {
import { acquireCanvasImageModelSlot } from "@/lib/canvas/image-concurrency"; import { acquireCanvasImageModelSlot } from "@/lib/canvas/image-concurrency";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime"; import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
import { modelSupportsReferenceImageInput } from "@/lib/ai/reference-image-capability";
import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints"; import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints";
// 请求体 Schema // 请求体 Schema
@ -905,7 +906,7 @@ function buildSceneFusionCandidatePool(
id: String(entry.model.id), id: String(entry.model.id),
modelId: String(entry.model.modelId || ""), modelId: String(entry.model.modelId || ""),
displayName: String(entry.model.displayName || entry.model.modelId || entry.model.id), displayName: String(entry.model.displayName || entry.model.modelId || entry.model.id),
supportsVision: Boolean(entry.model.supportsVision), supportsVision: modelSupportsReferenceImageInput(entry.model, entry.provider),
imageGenConfig: (entry.model.imageGenConfig || null) as SceneFusionImageGenConfig | null, imageGenConfig: (entry.model.imageGenConfig || null) as SceneFusionImageGenConfig | null,
excludeFromFailover: Boolean(entry.model.excludeFromFailover), excludeFromFailover: Boolean(entry.model.excludeFromFailover),
defaultParams: (entry.model.defaultParams as Record<string, any> | null) || null, defaultParams: (entry.model.defaultParams as Record<string, any> | null) || null,

View File

@ -10,6 +10,7 @@ import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
isVideoModelDescriptor, isVideoModelDescriptor,
} from "@/lib/ai/model-classification"; } from "@/lib/ai/model-classification";
import { isKnownReferenceImageModel } from "@/lib/ai/reference-image-capability";
type EnabledModelWithProvider = Awaited< type EnabledModelWithProvider = Awaited<
ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]> ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]>
@ -31,8 +32,13 @@ function hasVisionCapability(model: {
supportsVision?: boolean | null; supportsVision?: boolean | null;
capabilities?: unknown; capabilities?: unknown;
defaultParams?: unknown; defaultParams?: unknown;
}) { modelId?: unknown;
return Boolean(model.supportsVision) || isAiModelMultimodal(model); }, providerType?: string | null) {
return (
Boolean(model.supportsVision) ||
isKnownReferenceImageModel(model, { type: providerType }) ||
isAiModelMultimodal(model)
);
} }
const CHAT_PROVIDER_TYPES = [ const CHAT_PROVIDER_TYPES = [
@ -86,7 +92,7 @@ function isConversationalCandidate(
const matchesPreferredProvider = CHAT_PROVIDER_TYPES.includes( const matchesPreferredProvider = CHAT_PROVIDER_TYPES.includes(
providerType as (typeof CHAT_PROVIDER_TYPES)[number], providerType as (typeof CHAT_PROVIDER_TYPES)[number],
); );
const hasVision = hasVisionCapability(item.model); const hasVision = hasVisionCapability(item.model, providerType);
if (matchesPreferredProvider) { if (matchesPreferredProvider) {
return requireVision ? hasVision : true; return requireVision ? hasVision : true;
@ -120,7 +126,7 @@ function matchesIntentRequirements(
if (params.intent === "vision") { if (params.intent === "vision") {
return ( return (
!isVideoProviderType(String(item.provider.type || ""), item.model) && !isVideoProviderType(String(item.provider.type || ""), item.model) &&
hasVisionCapability(item.model) hasVisionCapability(item.model, item.provider.type)
); );
} }
@ -128,7 +134,7 @@ function matchesIntentRequirements(
return isVideoProviderType(String(item.provider.type || ""), item.model); return isVideoProviderType(String(item.provider.type || ""), item.model);
} }
if (params.requireVision && !hasVisionCapability(item.model)) return false; if (params.requireVision && !hasVisionCapability(item.model, item.provider.type)) return false;
if ( if (
params.requireImageGen && params.requireImageGen &&
!isImageGenerationModelDescriptor({ !isImageGenerationModelDescriptor({
@ -271,7 +277,9 @@ export async function routeToModel(params: {
if (configuredModel) { if (configuredModel) {
const isImageIntent = intent === "image_edit" || intent === "image_gen"; const isImageIntent = intent === "image_edit" || intent === "image_gen";
const matchesVision = !config.requireVision || hasVisionCapability(configuredModel.model); const matchesVision =
!config.requireVision ||
hasVisionCapability(configuredModel.model, configuredModel.provider.type);
const matchesImageGen = !config.requireImageGen || configuredModel.model.supportsImageGen; const matchesImageGen = !config.requireImageGen || configuredModel.model.supportsImageGen;
const allowImageIntentWithImageGenOnly = const allowImageIntentWithImageGenOnly =
isImageIntent && configuredModel.model.supportsImageGen; isImageIntent && configuredModel.model.supportsImageGen;
@ -347,7 +355,8 @@ export async function routeToModel(params: {
!item.model.isSubModel && !item.model.isSubModel &&
item.model.isEnabled && item.model.isEnabled &&
!isVideoProviderType(String(item.provider.type || ""), item.model) && !isVideoProviderType(String(item.provider.type || ""), item.model) &&
(item.model.supportsImageGen || hasVisionCapability(item.model)) (item.model.supportsImageGen ||
hasVisionCapability(item.model, item.provider.type))
); );
}); });

View File

@ -0,0 +1,72 @@
type ModelDescriptor = {
modelId?: unknown;
supportsVision?: boolean | null;
capabilities?: unknown;
defaultParams?: unknown;
};
type ProviderDescriptor = {
type?: unknown;
};
const BFL_REFERENCE_IMAGE_MODEL_REGEX =
/^(?:bfl\/)?(?:flux-2-(?:max|flex|pro(?:-preview)?|klein-(?:4b|9b)(?:-preview)?)|flux-kontext-(?:pro|max))$/i;
function readStringField(source: unknown, key: string) {
if (!source || typeof source !== "object" || !(key in source)) return "";
return String((source as Record<string, unknown>)[key] || "").trim();
}
function normalizeBflModelId(value: unknown) {
const trimmed = String(value || "").trim();
if (!trimmed) return "";
return trimmed
.replace(/^https?:\/\/[^/]+/i, "")
.replace(/^\/?(?:bfl\/)?v1\//i, "")
.replace(/^\/+/, "")
.replace(/[?#].*$/, "");
}
export function isKnownReferenceImageModel(
model: ModelDescriptor | null | undefined,
provider?: ProviderDescriptor | null
) {
const providerType = String(provider?.type || "").trim();
if (providerType !== "bfl-image") return false;
const candidates = [
model?.modelId,
readStringField(model?.defaultParams, "endpoint"),
readStringField(model?.defaultParams, "submitEndpoint"),
readStringField(model?.capabilities, "endpoint"),
];
const capabilitiesEndpoints =
model?.capabilities &&
typeof model.capabilities === "object" &&
"endpoints" in model.capabilities
? (model.capabilities as { endpoints?: unknown }).endpoints
: undefined;
candidates.push(readStringField(capabilitiesEndpoints, "submit"));
return candidates
.map(normalizeBflModelId)
.some((modelId) => BFL_REFERENCE_IMAGE_MODEL_REGEX.test(modelId));
}
export function modelLacksReferenceImageInputSupport(
model: ModelDescriptor | null | undefined,
provider?: ProviderDescriptor | null
) {
if (isKnownReferenceImageModel(model, provider)) return false;
return model?.supportsVision === false;
}
export function modelSupportsReferenceImageInput(
model: ModelDescriptor | null | undefined,
provider?: ProviderDescriptor | null
) {
return !modelLacksReferenceImageInputSupport(model, provider);
}

View File

@ -16,6 +16,7 @@ import { createRunway } from "@/lib/ai/providers/runway";
import { createKrea } from "@/lib/ai/providers/krea"; import { createKrea } from "@/lib/ai/providers/krea";
import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision"; import { createVolcanoVision } from "@/lib/ai/providers/volcano-vision";
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime"; import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
import { modelLacksReferenceImageInputSupport } from "@/lib/ai/reference-image-capability";
import { filterReachableRemoteUrls } from "@/lib/net/url-access"; import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import { import {
acquireCanvasImageModelSlot, acquireCanvasImageModelSlot,
@ -1766,7 +1767,10 @@ function findCanvasFailoverModel(params: {
return false; return false;
} }
if (!item.model.supportsImageGen) return false; if (!item.model.supportsImageGen) return false;
if (params.requiresImageInput && item.model.supportsVision === false) { if (
params.requiresImageInput &&
modelLacksReferenceImageInputSupport(item.model, item.provider)
) {
return false; return false;
} }
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(item.provider.type)) return false; if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(item.provider.type)) return false;
@ -1884,7 +1888,10 @@ export async function generateCanvasImage(
let provider = selectedModel.provider; let provider = selectedModel.provider;
if (requiresImageInput && selectedModel.model.supportsVision === false) { if (
requiresImageInput &&
modelLacksReferenceImageInputSupport(selectedModel.model, selectedModel.provider)
) {
if (request.allowFailover === false) { if (request.allowFailover === false) {
throw new Error("当前图片模型不支持参考图/图生图,请选择支持图像输入的生图模型。"); throw new Error("当前图片模型不支持参考图/图生图,请选择支持图像输入的生图模型。");
} }
@ -1925,7 +1932,12 @@ export async function generateCanvasImage(
for (const candidate of imageModels) { for (const candidate of imageModels) {
const candidateProvider = candidate.provider; const candidateProvider = candidate.provider;
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(candidateProvider.type)) continue; if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(candidateProvider.type)) continue;
if (requiresImageInput && candidate.model.supportsVision === false) continue; if (
requiresImageInput &&
modelLacksReferenceImageInputSupport(candidate.model, candidate.provider)
) {
continue;
}
fallbackModel = candidate; fallbackModel = candidate;
fallbackProvider = candidateProvider; fallbackProvider = candidateProvider;
break; break;