diff --git a/src/app/api/__tests__/popiserverModels.test.ts b/src/app/api/__tests__/popiserverModels.test.ts index 42e47970..5c3a7032 100644 --- a/src/app/api/__tests__/popiserverModels.test.ts +++ b/src/app/api/__tests__/popiserverModels.test.ts @@ -84,6 +84,7 @@ describe("popiserver model helpers", () => { code: "speech-2.8-hd", name: "Speech 2.8 HD", aiModelCodeAlias: "speech-2.8-hd", + billingMethod: 1, categories: [{ taskSubType: 301 }], }); @@ -94,6 +95,7 @@ describe("popiserver model helpers", () => { aiModelId: 30, aiModelCode: "speech-2.8-hd", aiModelCodeAlias: "speech-2.8-hd", + billingMethod: 1, }); }); }); diff --git a/src/app/api/_popiserverModels.ts b/src/app/api/_popiserverModels.ts index 2ff82f9c..016dd543 100644 --- a/src/app/api/_popiserverModels.ts +++ b/src/app/api/_popiserverModels.ts @@ -199,6 +199,7 @@ export function popiModelToProviderModel(model: PopiAIModel): ProviderModel { duration: model.duration, videoRatio: model.videoRatio, resolution: model.resolution, + billingMethod: model.billingMethod, isDefault: model.isDefault, isSupportImages: model.isSupportImages, isSupportVideos: model.isSupportVideos, diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 557c9c4e..00cec16a 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -24,7 +24,7 @@ import { NextRequest, NextResponse } from "next/server"; import { ProviderType } from "@/types"; -import { ModelParameter, ModelInput } from "@/lib/providers/types"; +import { ModelParameter, ModelInput, ProviderModel } from "@/lib/providers/types"; import { getCachedWaveSpeedSchema, setCachedWaveSpeedSchema, @@ -37,6 +37,7 @@ import { buildPopiModelInputs, buildPopiModelParameters, fetchPopiModelDetail, + popiModelToProviderModel, } from "@/app/api/_popiserverModels"; // Cache for model schemas (10 minute TTL) @@ -125,6 +126,7 @@ interface SchemaSuccessResponse { success: true; parameters: ModelParameter[]; inputs: ModelInput[]; + model?: ProviderModel; cached: boolean; } @@ -1615,6 +1617,7 @@ export async function GET( success: true, parameters: buildPopiModelParameters(model), inputs: buildPopiModelInputs(model), + model: popiModelToProviderModel(model), cached: false, }); } catch (error) { diff --git a/src/components/FloatingActionBar.tsx b/src/components/FloatingActionBar.tsx index 52231c19..fd8914e9 100644 --- a/src/components/FloatingActionBar.tsx +++ b/src/components/FloatingActionBar.tsx @@ -166,6 +166,7 @@ function GenerateComboButton() { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); const addNode = useWorkflowStore((state) => state.addNode); + const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const { screenToFlowPosition } = useReactFlow(); useEffect(() => { @@ -191,7 +192,8 @@ function GenerateComboButton() { y: center.y + Math.random() * 100 - 50, }); - addNode(type, position, await buildDefaultGenerationNodeData(type)); + const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type)); + selectSingleNode(nodeId); setIsOpen(false); }; @@ -286,6 +288,7 @@ function AllNodesMenu() { const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); const addNode = useWorkflowStore((state) => state.addNode); + const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const { screenToFlowPosition } = useReactFlow(); useEffect(() => { @@ -311,9 +314,10 @@ function AllNodesMenu() { y: center.y + Math.random() * 100 - 50, }); - addNode(type, position, await buildDefaultGenerationNodeData(type)); + const nodeId = addNode(type, position, await buildDefaultGenerationNodeData(type)); + selectSingleNode(nodeId); setIsOpen(false); - }, [addNode, screenToFlowPosition]); + }, [addNode, screenToFlowPosition, selectSingleNode]); const handleDragStart = useCallback((event: React.DragEvent, type: NodeType) => { event.dataTransfer.setData("application/node-type", type); diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index f18b99a0..88d76e45 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -2340,7 +2340,8 @@ export function WorkflowCanvas() { x: event.clientX, y: event.clientY, }); - addNode(nodeType, position, await buildDefaultGenerationNodeData(nodeType)); + const nodeId = addNode(nodeType, position, await buildDefaultGenerationNodeData(nodeType)); + selectSingleNode(nodeId); return; } diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index cfa9f53c..7c874e91 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -650,6 +650,7 @@ describe("GenerationComposer", () => { aiModelCodeAlias: "gpt-image-2-all", type: 1, subType: 102, + billingMethod: 1, }, }, }), @@ -679,6 +680,40 @@ describe("GenerationComposer", () => { }); }); + it("does not show PopiServer task price estimates for token-billed models", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + throw new Error(`Unexpected fetch: ${String(input)}`); + }); + vi.stubGlobal("fetch", fetchMock); + useWorkflowStore.setState({ + nodes: [ + imageNode("img-1", true, { + selectedModel: { + provider: "popiserver", + modelId: "32", + displayName: "Token Billed Image", + capabilities: ["text-to-image"], + metadata: { + aiModelId: 32, + aiModelCode: "token-image-model", + aiModelCodeAlias: "token-image-model", + type: 1, + subType: 102, + billingMethod: 2, + }, + }, + }), + ], + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Generate" })).toBeInTheDocument(); + }); + expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith("/api/points/calculate-task-price"))).toBe(false); + }); + it("uses the Seedance estimate endpoint for PopiServer Seedance models", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); diff --git a/src/components/__tests__/ModelParameters.test.tsx b/src/components/__tests__/ModelParameters.test.tsx index dc458e8d..b1dc1a4b 100644 --- a/src/components/__tests__/ModelParameters.test.tsx +++ b/src/components/__tests__/ModelParameters.test.tsx @@ -95,6 +95,29 @@ describe("ModelParameters", () => { expect(onParametersChange).toHaveBeenCalledWith({ max_images: 2 }); }); + it("replaces stale enum parameter values with the current schema default", async () => { + const onParametersChange = vi.fn(); + render( + + ); + + await waitFor(() => { + expect(onParametersChange).toHaveBeenCalledWith({ resolution: "4K" }); + }); + }); + it("renders number validation and boolean defaults", () => { render( 0) return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(Math.trunc(value)); + return null; +} + +function getSelectedModelIdentifiers(model: SelectedModel): Set { + return new Set( + [ + model.modelId, + model.displayName, + identifierFromUnknown(model.metadata?.aiModelId), + identifierFromUnknown(model.metadata?.aiModelCode), + identifierFromUnknown(model.metadata?.aiModelCodeAlias), + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + ); +} + +function providerModelMatchesSelectedModel(model: ProviderModel, selectedModel: SelectedModel): boolean { + if (model.provider !== selectedModel.provider) return false; + const identifiers = getSelectedModelIdentifiers(selectedModel); + return [ + model.id, + model.name, + identifierFromUnknown(model.metadata?.aiModelId), + identifierFromUnknown(model.metadata?.aiModelCode), + identifierFromUnknown(model.metadata?.aiModelCodeAlias), + ] + .filter((value): value is string => Boolean(value)) + .some((value) => identifiers.has(value.trim().toLowerCase())); +} + +function findProviderModelForSelectedModel(models: ProviderModel[], selectedModel: SelectedModel): ProviderModel | null { + return models.find((model) => providerModelMatchesSelectedModel(model, selectedModel)) ?? null; +} + +function selectedModelNeedsPopiserverMetadataLookup(model: SelectedModel): boolean { + if (model.provider !== "popiserver") return false; + const billingMethod = finiteIntegerFromUnknown(model.metadata?.billingMethod); + if (billingMethod === null) return true; + if (billingMethod !== 1) return false; + const aiModelId = finiteIntegerFromUnknown(model.metadata?.aiModelId) ?? finiteIntegerFromUnknown(model.modelId); + return !aiModelId; +} + function isSameSelectedModel(a: SelectedModel | undefined, b: SelectedModel | undefined): boolean { return Boolean(a?.provider && b?.provider && a.provider === b.provider && a.modelId === b.modelId); } @@ -393,6 +442,10 @@ function getPopiserverModelName(model: SelectedModel): string { ); } +function isPopiserverPerRunBillingModel(model: SelectedModel): boolean { + return model.provider === "popiserver" && finiteIntegerFromUnknown(model.metadata?.billingMethod) === 1; +} + function buildSeedanceVideoEstimatePayload( draft: ComposerDraft, inputVideoDurationSeconds: number @@ -456,6 +509,7 @@ function buildPopiserverTaskPricePayload( ): PopiserverTaskPricePayload | null { const model = draft.selectedModel; if (model.provider !== "popiserver") return null; + if (!isPopiserverPerRunBillingModel(model)) return null; const aiModelId = finiteIntegerFromUnknown(model.metadata?.aiModelId) ?? finiteIntegerFromUnknown(model.modelId); if (!aiModelId) return null; @@ -906,6 +960,8 @@ export function GenerationComposer() { const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs); + const popiModels = useModelStore((state) => state.all); + const refreshPopiModels = useModelStore((state) => state.refreshPopiModels); const { newApiWGBaseUrl } = useProviderApiKeys(); const { screenToFlowPosition } = useReactFlow(); const { x: viewportX, y: viewportY, zoom } = useViewport(); @@ -1053,6 +1109,79 @@ export function GenerationComposer() { ? JSON.stringify(popiserverTaskPricePayload) : null; + useEffect(() => { + const selectedModel = draft.selectedModel; + if (!selectedModelNeedsPopiserverMetadataLookup(selectedModel)) return; + + let cancelled = false; + const applyResolvedModel = (models: ProviderModel[]): boolean => { + const matchedModel = findProviderModelForSelectedModel(models, selectedModel); + if (!matchedModel || finiteIntegerFromUnknown(matchedModel.metadata?.billingMethod) === null) { + return false; + } + + const resolvedModel = selectedModelFromProvider(matchedModel); + setDraft((current) => { + if ( + !isSameSelectedModel(current.selectedModel, selectedModel) || + !selectedModelNeedsPopiserverMetadataLookup(current.selectedModel) + ) { + return current; + } + + const next = { + ...current, + selectedModel: { + ...resolvedModel, + capabilities: current.selectedModel.capabilities.length > 0 + ? current.selectedModel.capabilities + : resolvedModel.capabilities, + }, + }; + draftRef.current = next; + return next; + }); + + const activeContext = contextRef.current; + const nodeModel = activeContext.node?.data && "selectedModel" in activeContext.node.data + ? (activeContext.node.data.selectedModel as SelectedModel | undefined) + : undefined; + if ( + activeContext.mode === "node-edit" && + activeContext.node && + isSameSelectedModel(nodeModel, selectedModel) && + nodeModel && + selectedModelNeedsPopiserverMetadataLookup(nodeModel) + ) { + updateNodeData(activeContext.node.id, { + selectedModel: { + ...resolvedModel, + capabilities: nodeModel.capabilities.length > 0 ? nodeModel.capabilities : resolvedModel.capabilities, + }, + }); + } + return true; + }; + + if (applyResolvedModel(popiModels)) return; + + void refreshPopiModels().then((models) => { + if (!cancelled) applyResolvedModel(models); + }); + + return () => { + cancelled = true; + }; + }, [ + draft.selectedModel.displayName, + draft.selectedModel.metadata, + draft.selectedModel.modelId, + draft.selectedModel.provider, + popiModels, + refreshPopiModels, + updateNodeData, + ]); + useEffect(() => { const selectedModel = draft.selectedModel; if ( diff --git a/src/components/nodes/GenerateAudioNode.tsx b/src/components/nodes/GenerateAudioNode.tsx index daafbf01..82b80595 100644 --- a/src/components/nodes/GenerateAudioNode.tsx +++ b/src/components/nodes/GenerateAudioNode.tsx @@ -23,7 +23,7 @@ import { HandleLabel } from "./HandleLabel"; import { isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode"; import { useI18n } from "@/i18n"; import { formatUserFacingError } from "@/utils/userFacingErrors"; -import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection"; +import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection"; import { useModelStore } from "@/store/modelStore"; type GenerateAudioNodeType = Node; @@ -136,10 +136,13 @@ export function GenerateAudioNode({ id, data, selected }: NodeProps model.id === nodeData.selectedModel?.modelId) ), refreshKey: nodeData.modelSchemaRequestId, - onLoaded: ({ inputs, parameters }) => { + onLoaded: ({ inputs, parameters, model }) => { updateNodeData(id, { inputSchema: inputs, parameterSchema: parameters, + ...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model) + ? { selectedModel: toSelectedModel(model) } + : {}), modelSchemaError: null, }); }, diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index df95de25..41ef9f35 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -26,7 +26,7 @@ import { useI18n } from "@/i18n"; import { MediaPreviewModal } from "@/components/MediaPreviewModal"; import { createGeminiLegacyImageModel } from "@/store/utils/defaultImageModel"; import { getProviderDisplayName, isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode"; -import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection"; +import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection"; import { useModelStore } from "@/store/modelStore"; import { isBrowserFileSystemPath, @@ -514,10 +514,13 @@ export function GenerateImageNode({ id, data, selected }: NodeProps { + onLoaded: ({ inputs, parameters, model }) => { updateNodeData(id, { inputSchema: inputs, parameterSchema: parameters, + ...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model) + ? { selectedModel: toSelectedModel(model) } + : {}), modelSchemaError: null, }); }, diff --git a/src/components/nodes/GenerateVideoNode.tsx b/src/components/nodes/GenerateVideoNode.tsx index 16537bda..9a7dcc04 100644 --- a/src/components/nodes/GenerateVideoNode.tsx +++ b/src/components/nodes/GenerateVideoNode.tsx @@ -26,7 +26,7 @@ import { HandleLabel } from "./HandleLabel"; import { useI18n } from "@/i18n"; import { MediaPreviewModal } from "@/components/MediaPreviewModal"; import { getProviderDisplayName, isPopiProviderMode, POPI_PROVIDER_ID } from "@/lib/providerMode"; -import { chooseModelFromList, toSelectedModel } from "@/utils/modelSelection"; +import { chooseModelFromList, shouldUpdateSelectedModelFromProviderModel, toSelectedModel } from "@/utils/modelSelection"; import { useModelStore } from "@/store/modelStore"; import { formatUserFacingError } from "@/utils/userFacingErrors"; import { @@ -343,10 +343,13 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps { + onLoaded: ({ inputs, parameters, model }) => { updateNodeData(id, { inputSchema: inputs, parameterSchema: parameters, + ...(model && shouldUpdateSelectedModelFromProviderModel(nodeData.selectedModel, model) + ? { selectedModel: toSelectedModel(model) } + : {}), modelSchemaError: null, }); }, diff --git a/src/components/nodes/ModelParameters.tsx b/src/components/nodes/ModelParameters.tsx index 145c6917..43709ecf 100644 --- a/src/components/nodes/ModelParameters.tsx +++ b/src/components/nodes/ModelParameters.tsx @@ -85,8 +85,15 @@ function ModelParametersInner({ const defaults: Record = {}; let hasNewDefaults = false; for (const param of visibleSchema) { - if (param.default !== undefined && baseParameters[param.name] === undefined) { - defaults[param.name] = param.default; + const currentValue = baseParameters[param.name]; + const enumValues = Array.isArray(param.enum) ? param.enum : []; + const hasInvalidEnumValue = + enumValues.length > 0 && + currentValue !== undefined && + !enumValues.some((item) => String(item) === String(currentValue)); + const defaultValue = param.default ?? enumValues[0]; + if ((currentValue === undefined || hasInvalidEnumValue) && defaultValue !== undefined) { + defaults[param.name] = defaultValue; hasNewDefaults = true; } } diff --git a/src/hooks/useSelectedModelSchema.ts b/src/hooks/useSelectedModelSchema.ts index 4b06970f..3d83268c 100644 --- a/src/hooks/useSelectedModelSchema.ts +++ b/src/hooks/useSelectedModelSchema.ts @@ -2,12 +2,13 @@ import { useEffect, useRef, useState } from "react"; import { ModelInputDef, SelectedModel } from "@/types"; -import { ModelParameter } from "@/lib/providers/types"; +import { ModelParameter, ProviderModel } from "@/lib/providers/types"; import { useProviderApiKeys } from "@/store/workflowStore"; interface ModelSchemaResult { parameters: ModelParameter[]; inputs: ModelInputDef[]; + model?: ProviderModel; } interface UseSelectedModelSchemaOptions { @@ -89,6 +90,7 @@ export function useSelectedModelSchema({ const nextSchema = { parameters: data.parameters || [], inputs: data.inputs || [], + model: data.model, }; setParameters(nextSchema.parameters); setInputs(nextSchema.inputs); diff --git a/src/utils/modelSelection.ts b/src/utils/modelSelection.ts index bc64c696..8c4e7997 100644 --- a/src/utils/modelSelection.ts +++ b/src/utils/modelSelection.ts @@ -11,6 +11,25 @@ export function toSelectedModel(model: ProviderModel): SelectedModel { }; } +function hasValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ""; +} + +export function shouldUpdateSelectedModelFromProviderModel( + current: SelectedModel | undefined, + model: ProviderModel +): boolean { + if (!current) return true; + if (current.provider !== model.provider || current.modelId !== model.id) return true; + if (model.provider !== "popiserver") return false; + + const currentMetadata = current.metadata ?? {}; + const nextMetadata = model.metadata ?? {}; + return ["billingMethod", "aiModelId", "type", "subType", "aiModelCode", "aiModelCodeAlias"].some( + (key) => !hasValue(currentMetadata[key]) && hasValue(nextMetadata[key]) + ); +} + export function chooseModelFromList( models: ProviderModel[], preferred?: SelectedModel