From d145cebe75a701c39081c149ca1d91899ee2aaf6 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Tue, 7 Apr 2026 21:48:35 +0200 Subject: [PATCH] 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); + } + }); });