feat(ai): enable all image models with server-side tier enforcement

This commit is contained in:
2026-04-07 21:48:35 +02:00
parent 3c161ac9a6
commit 39d435d58e
3 changed files with 89 additions and 2 deletions

View File

@@ -416,6 +416,12 @@ export const generateImage = action({
throw new Error(`Unknown model: ${modelId}`); 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, {}); await ctx.runMutation(internal.credits.checkAbuseLimits, {});
let usageIncremented = false; let usageIncremented = false;
@@ -506,6 +512,14 @@ function isVideoModelAllowedForTier(modelTier: "free" | "starter" | "pro", userT
return tierOrder[userTier] >= tierOrder[modelTier]; 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({ export const setVideoTaskInfo = internalMutation({
args: { args: {
nodeId: v.id("nodes"), nodeId: v.id("nodes"),

View File

@@ -9,10 +9,9 @@ export interface OpenRouterModel {
estimatedCostPerImage: number; // in Euro-Cent (for credit reservation) estimatedCostPerImage: number; // in Euro-Cent (for credit reservation)
/** Gleiche Einheit wie UI „Cr“ / lib/ai-models creditCost */ /** Gleiche Einheit wie UI „Cr“ / lib/ai-models creditCost */
creditCost: number; 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<string, OpenRouterModel> = { export const IMAGE_MODELS: Record<string, OpenRouterModel> = {
"google/gemini-2.5-flash-image": { "google/gemini-2.5-flash-image": {
id: "google/gemini-2.5-flash-image", id: "google/gemini-2.5-flash-image",
@@ -20,6 +19,71 @@ export const IMAGE_MODELS: Record<string, OpenRouterModel> = {
tier: "standard", tier: "standard",
estimatedCostPerImage: 4, // ~€0.04 in Euro-Cent estimatedCostPerImage: 4, // ~€0.04 in Euro-Cent
creditCost: 4, 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",
}, },
}; };

View File

@@ -6,6 +6,7 @@ import {
getAvailableImageModels, getAvailableImageModels,
getModel, getModel,
} from "@/lib/ai-models"; } from "@/lib/ai-models";
import { IMAGE_MODELS as OPENROUTER_MODELS } from "@/convex/openrouter";
describe("ai image models registry", () => { describe("ai image models registry", () => {
it("contains all 9 PRD models in stable order", () => { it("contains all 9 PRD models in stable order", () => {
@@ -32,4 +33,12 @@ describe("ai image models registry", () => {
it("resolves model lookup", () => { it("resolves model lookup", () => {
expect(getModel(DEFAULT_MODEL_ID)?.creditCost).toBeGreaterThan(0); 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);
}
});
}); });