From 3c161ac9a6a79cf4025d8e3c7119e3172e258ad9 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Tue, 7 Apr 2026 21:45:51 +0200 Subject: [PATCH 1/5] feat(ai): add full image model catalog and tier filters --- lib/ai-models.ts | 102 ++++++++++++++++++++++++++++++------ tests/lib/ai-models.test.ts | 35 +++++++++++++ 2 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 tests/lib/ai-models.test.ts diff --git a/lib/ai-models.ts b/lib/ai-models.ts index 246eccf..e64d154 100644 --- a/lib/ai-models.ts +++ b/lib/ai-models.ts @@ -22,23 +22,78 @@ export const IMAGE_MODELS: AiModel[] = [ creditCost: 4, minTier: "free", }, - // Phase 2 — uncomment when model selector UI is ready: - // { - // id: "black-forest-labs/flux.2-klein-4b", - // name: "FLUX.2 Klein", - // tier: "budget", - // description: "Photorealism, fastest Flux", - // estimatedCost: "~€0.02", - // minTier: "free", - // }, - // { - // id: "openai/gpt-5-image", - // name: "GPT-5 Image", - // tier: "premium", - // description: "Best instruction following, text in image", - // estimatedCost: "~€0.15", - // minTier: "starter", - // }, + { + id: "black-forest-labs/flux.2-klein-4b", + name: "FLUX.2 Klein 4B", + tier: "budget", + description: "Low-cost image generation for quick drafts", + estimatedCost: "~€0.02", + creditCost: 2, + minTier: "free", + }, + { + id: "bytedance-seed/seedream-4.5", + name: "Seedream 4.5", + tier: "standard", + description: "Balanced detail and speed for everyday prompts", + estimatedCost: "~€0.05", + creditCost: 5, + minTier: "free", + }, + { + id: "google/gemini-3.1-flash-image-preview", + name: "Gemini 3.1 Flash Image", + tier: "standard", + description: "Fast multimodal image generation", + estimatedCost: "~€0.06", + creditCost: 6, + minTier: "free", + }, + { + id: "openai/gpt-5-image-mini", + name: "GPT-5 Image Mini", + tier: "premium", + description: "Higher-fidelity instruction following", + estimatedCost: "~€0.08", + creditCost: 8, + minTier: "starter", + }, + { + id: "sourceful/riverflow-v2-fast", + name: "Riverflow V2 Fast", + tier: "premium", + description: "Fast premium output for production workflows", + estimatedCost: "~€0.09", + creditCost: 9, + minTier: "starter", + }, + { + id: "sourceful/riverflow-v2-pro", + name: "Riverflow V2 Pro", + tier: "premium", + description: "Pro quality with stronger composition", + estimatedCost: "~€0.12", + creditCost: 12, + minTier: "starter", + }, + { + id: "google/gemini-3-pro-image-preview", + name: "Gemini 3 Pro Image", + tier: "premium", + description: "Advanced quality and prompt adherence", + estimatedCost: "~€0.13", + creditCost: 13, + minTier: "starter", + }, + { + id: "openai/gpt-5-image", + name: "GPT-5 Image", + tier: "premium", + description: "Highest quality and best text rendering", + estimatedCost: "~€0.15", + creditCost: 15, + minTier: "starter", + }, ]; export const DEFAULT_MODEL_ID = "google/gemini-2.5-flash-image"; @@ -46,3 +101,16 @@ export const DEFAULT_MODEL_ID = "google/gemini-2.5-flash-image"; export function getModel(id: string): AiModel | undefined { return IMAGE_MODELS.find((m) => m.id === id); } + +const IMAGE_MODEL_TIER_ORDER: Record = { + free: 0, + starter: 1, + pro: 2, + max: 3, + business: 4, +}; + +export function getAvailableImageModels(tier: AiModel["minTier"]): AiModel[] { + const maxTier = IMAGE_MODEL_TIER_ORDER[tier]; + return IMAGE_MODELS.filter((model) => IMAGE_MODEL_TIER_ORDER[model.minTier] <= maxTier); +} diff --git a/tests/lib/ai-models.test.ts b/tests/lib/ai-models.test.ts new file mode 100644 index 0000000..cad0b27 --- /dev/null +++ b/tests/lib/ai-models.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_MODEL_ID, + IMAGE_MODELS, + getAvailableImageModels, + getModel, +} from "@/lib/ai-models"; + +describe("ai image models registry", () => { + it("contains all 9 PRD models in stable order", () => { + expect(IMAGE_MODELS.map((model) => model.id)).toEqual([ + "google/gemini-2.5-flash-image", + "black-forest-labs/flux.2-klein-4b", + "bytedance-seed/seedream-4.5", + "google/gemini-3.1-flash-image-preview", + "openai/gpt-5-image-mini", + "sourceful/riverflow-v2-fast", + "sourceful/riverflow-v2-pro", + "google/gemini-3-pro-image-preview", + "openai/gpt-5-image", + ]); + expect(DEFAULT_MODEL_ID).toBe("google/gemini-2.5-flash-image"); + }); + + it("filters by subscription tier", () => { + expect(getAvailableImageModels("free").every((model) => model.minTier === "free")).toBe( + true, + ); + }); + + it("resolves model lookup", () => { + expect(getModel(DEFAULT_MODEL_ID)?.creditCost).toBeGreaterThan(0); + }); +}); From 39d435d58ed67274ceab99728dfbaba1f780e164 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Tue, 7 Apr 2026 21:48:35 +0200 Subject: [PATCH 2/5] feat(ai): enable all image models with server-side tier enforcement --- convex/ai.ts | 14 ++++++++ convex/openrouter.ts | 68 +++++++++++++++++++++++++++++++++++-- tests/lib/ai-models.test.ts | 9 +++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/convex/ai.ts b/convex/ai.ts index b01f2c0..20febe1 100644 --- a/convex/ai.ts +++ b/convex/ai.ts @@ -416,6 +416,12 @@ export const generateImage = action({ throw new Error(`Unknown model: ${modelId}`); } + const subscription = await ctx.runQuery(api.credits.getSubscription, {}); + const userTier = normalizePublicTier(subscription?.tier); + if (!isImageModelAllowedForTier(modelConfig.minTier, userTier)) { + throw new Error(`Model ${modelId} requires ${modelConfig.minTier} tier`); + } + await ctx.runMutation(internal.credits.checkAbuseLimits, {}); let usageIncremented = false; @@ -506,6 +512,14 @@ function isVideoModelAllowedForTier(modelTier: "free" | "starter" | "pro", userT return tierOrder[userTier] >= tierOrder[modelTier]; } +function isImageModelAllowedForTier( + minTier: "free" | "starter" | "pro" | "max", + userTier: "free" | "starter" | "pro" | "max" +) { + const tierOrder = { free: 0, starter: 1, pro: 2, max: 3 } as const; + return tierOrder[userTier] >= tierOrder[minTier]; +} + export const setVideoTaskInfo = internalMutation({ args: { nodeId: v.id("nodes"), diff --git a/convex/openrouter.ts b/convex/openrouter.ts index 9ea65ce..b665ac5 100644 --- a/convex/openrouter.ts +++ b/convex/openrouter.ts @@ -9,10 +9,9 @@ export interface OpenRouterModel { estimatedCostPerImage: number; // in Euro-Cent (for credit reservation) /** Gleiche Einheit wie UI „Cr“ / lib/ai-models creditCost */ creditCost: number; + minTier: "free" | "starter" | "pro" | "max"; } -// Phase 1: Gemini 2.5 Flash Image only. -// Add more models here in Phase 2 when the model selector UI is built. export const IMAGE_MODELS: Record = { "google/gemini-2.5-flash-image": { id: "google/gemini-2.5-flash-image", @@ -20,6 +19,71 @@ export const IMAGE_MODELS: Record = { tier: "standard", estimatedCostPerImage: 4, // ~€0.04 in Euro-Cent creditCost: 4, + minTier: "free", + }, + "black-forest-labs/flux.2-klein-4b": { + id: "black-forest-labs/flux.2-klein-4b", + name: "FLUX.2 Klein 4B", + tier: "budget", + estimatedCostPerImage: 2, + creditCost: 2, + minTier: "free", + }, + "bytedance-seed/seedream-4.5": { + id: "bytedance-seed/seedream-4.5", + name: "Seedream 4.5", + tier: "standard", + estimatedCostPerImage: 5, + creditCost: 5, + minTier: "free", + }, + "google/gemini-3.1-flash-image-preview": { + id: "google/gemini-3.1-flash-image-preview", + name: "Gemini 3.1 Flash Image", + tier: "standard", + estimatedCostPerImage: 6, + creditCost: 6, + minTier: "free", + }, + "openai/gpt-5-image-mini": { + id: "openai/gpt-5-image-mini", + name: "GPT-5 Image Mini", + tier: "premium", + estimatedCostPerImage: 8, + creditCost: 8, + minTier: "starter", + }, + "sourceful/riverflow-v2-fast": { + id: "sourceful/riverflow-v2-fast", + name: "Riverflow V2 Fast", + tier: "premium", + estimatedCostPerImage: 9, + creditCost: 9, + minTier: "starter", + }, + "sourceful/riverflow-v2-pro": { + id: "sourceful/riverflow-v2-pro", + name: "Riverflow V2 Pro", + tier: "premium", + estimatedCostPerImage: 12, + creditCost: 12, + minTier: "starter", + }, + "google/gemini-3-pro-image-preview": { + id: "google/gemini-3-pro-image-preview", + name: "Gemini 3 Pro Image", + tier: "premium", + estimatedCostPerImage: 13, + creditCost: 13, + minTier: "starter", + }, + "openai/gpt-5-image": { + id: "openai/gpt-5-image", + name: "GPT-5 Image", + tier: "premium", + estimatedCostPerImage: 15, + creditCost: 15, + minTier: "starter", }, }; diff --git a/tests/lib/ai-models.test.ts b/tests/lib/ai-models.test.ts index cad0b27..3c2c72b 100644 --- a/tests/lib/ai-models.test.ts +++ b/tests/lib/ai-models.test.ts @@ -6,6 +6,7 @@ import { getAvailableImageModels, getModel, } from "@/lib/ai-models"; +import { IMAGE_MODELS as OPENROUTER_MODELS } from "@/convex/openrouter"; describe("ai image models registry", () => { it("contains all 9 PRD models in stable order", () => { @@ -32,4 +33,12 @@ describe("ai image models registry", () => { it("resolves model lookup", () => { expect(getModel(DEFAULT_MODEL_ID)?.creditCost).toBeGreaterThan(0); }); + + it("keeps frontend and backend image model ids and credit costs in sync", () => { + expect(Object.keys(OPENROUTER_MODELS)).toEqual(IMAGE_MODELS.map((model) => model.id)); + + for (const model of IMAGE_MODELS) { + expect(OPENROUTER_MODELS[model.id]?.creditCost).toBe(model.creditCost); + } + }); }); From 91fdd6c1433747589f498ce1674348f1ed9c38b7 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Tue, 7 Apr 2026 23:27:21 +0200 Subject: [PATCH 3/5] feat(canvas): add tier-aware model selector to prompt node --- components/canvas/nodes/prompt-node.tsx | 92 ++++++++- tests/prompt-node.test.ts | 255 ++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 11 deletions(-) create mode 100644 tests/prompt-node.test.ts diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx index 2de5512..2b9ce04 100644 --- a/components/canvas/nodes/prompt-node.tsx +++ b/components/canvas/nodes/prompt-node.tsx @@ -18,7 +18,11 @@ import BaseNodeWrapper from "./base-node-wrapper"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; -import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; +import { + DEFAULT_MODEL_ID, + getAvailableImageModels, + getModel, +} from "@/lib/ai-models"; import { DEFAULT_ASPECT_RATIO, getAiImageNodeOuterSize, @@ -40,6 +44,7 @@ import { Sparkles, Loader2, Coins } from "lucide-react"; import { useRouter } from "next/navigation"; import { toast } from "@/lib/toast"; import { classifyError } from "@/lib/ai-errors"; +import { normalizePublicTier } from "@/lib/tier-credits"; type PromptNodeData = { prompt?: string; @@ -63,6 +68,7 @@ export default function PromptNode({ const { getEdges, getNode } = useReactFlow(); const [prompt, setPrompt] = useState(nodeData.prompt ?? ""); + const [modelId, setModelId] = useState(nodeData.model ?? DEFAULT_MODEL_ID); const [aspectRatio, setAspectRatio] = useState( nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO ); @@ -72,14 +78,20 @@ export default function PromptNode({ const nodes = useStore((store) => store.nodes); const promptRef = useRef(prompt); + const modelIdRef = useRef(modelId); const aspectRatioRef = useRef(aspectRatio); promptRef.current = prompt; + modelIdRef.current = modelId; aspectRatioRef.current = aspectRatio; useEffect(() => { setPrompt(nodeData.prompt ?? ""); }, [nodeData.prompt]); + useEffect(() => { + setModelId(nodeData.model ?? DEFAULT_MODEL_ID); + }, [nodeData.model]); + useEffect(() => { setAspectRatio(nodeData.aspectRatio ?? DEFAULT_ASPECT_RATIO); }, [nodeData.aspectRatio]); @@ -113,7 +125,29 @@ export default function PromptNode({ dataRef.current = data; const balance = useAuthQuery(api.credits.getBalance); - const creditCost = getModel(DEFAULT_MODEL_ID)?.creditCost ?? 4; + const subscription = useAuthQuery(api.credits.getSubscription); + const userTier = normalizePublicTier(subscription?.tier ?? "free"); + const availableModels = useMemo( + () => getAvailableImageModels(userTier), + [userTier], + ); + + useEffect(() => { + if (availableModels.length === 0) { + return; + } + + if (!availableModels.some((model) => model.id === modelId)) { + setModelId(availableModels[0]!.id); + } + }, [availableModels, modelId]); + + const selectedModel = + getModel(modelId) ?? + availableModels[0] ?? + getModel(DEFAULT_MODEL_ID); + const resolvedModelId = selectedModel?.id ?? DEFAULT_MODEL_ID; + const creditCost = selectedModel?.creditCost ?? 4; const availableCredits = balance !== undefined ? balance.balance - balance.reserved : null; @@ -131,12 +165,13 @@ export default function PromptNode({ void _statusMessage; void queueNodeDataUpdate({ nodeId: id as Id<"nodes">, - data: { - ...rest, - prompt: promptRef.current, - aspectRatio: aspectRatioRef.current, - }, - }); + data: { + ...rest, + prompt: promptRef.current, + model: modelIdRef.current, + aspectRatio: aspectRatioRef.current, + }, + }); }, 500); const handlePromptChange = useCallback( @@ -156,6 +191,14 @@ export default function PromptNode({ [debouncedSave] ); + const handleModelChange = useCallback( + (value: string) => { + setModelId(value); + debouncedSave(); + }, + [debouncedSave], + ); + const handleGenerate = useCallback(async () => { if (!effectivePrompt.trim() || isGenerating) return; if (status.isOffline) { @@ -229,8 +272,8 @@ export default function PromptNode({ height: outer.height, data: { prompt: promptToUse, - model: DEFAULT_MODEL_ID, - modelTier: "standard", + model: resolvedModelId, + modelTier: selectedModel?.tier ?? "standard", canvasId, aspectRatio, outputWidth: viewport.width, @@ -249,7 +292,7 @@ export default function PromptNode({ prompt: promptToUse, referenceStorageId, referenceImageUrl, - model: DEFAULT_MODEL_ID, + model: resolvedModelId, aspectRatio, }), { @@ -285,6 +328,7 @@ export default function PromptNode({ prompt, effectivePrompt, aspectRatio, + resolvedModelId, isGenerating, nodeData.canvasId, id, @@ -292,6 +336,7 @@ export default function PromptNode({ getNode, createNodeConnectedFromSource, generateImage, + selectedModel?.tier, creditCost, availableCredits, hasEnoughCredits, @@ -338,6 +383,31 @@ export default function PromptNode({ /> )} +
+ + +
+