feat: tiered LLM (GLM free / Claude paid) + rate limits + quota enforcement
All checks were successful
Deploy to Production / deploy (push) Successful in 53s

The free tier was hemorrhaging Anthropic cost with no abuse cap (no rate
limit on /preview, Opus default in the build worker, 5-min cache TTL that
made cache-miss the common case). This switches free users to GLM, paid
users to Claude tiers, and tightens every leak found in the audit.

Backend:
- @bmm/llm: GLM provider via Zhipu's OpenAI-compatible endpoint, pickPreviewModel
  + pickBuildModel helpers, plan-aware ModelChoice
- preview-cache TTL 5min -> 24h (kills the cache-miss path)
- /v1/servers/preview: picks model from caller's plan, returns model name to UI
- /v1/servers POST: enforces SERVER_LIMITS per plan (402), rate-limits builds
- daily rate-limit on preview (5/40/150/1000) and build (3/20/100/500)
- /v1/auth/me returns plan so the wizard can show the right model name
- generator worker: GLM default, Anthropic Sonnet fallback if GLM errors

Frontend:
- Wizard fetches plan, shows "<model> is drafting the tool spec" pre-emptively,
  upgrade hint for hobby users, friendly errors for 402 / 429
- Pricing page: AI-model line per tier (Open-tier / Haiku / Sonnet / Opus),
  Team €149 -> €199, Enterprise €499 -> €999, daily-preview limit per tier
- Privacy + Security: explicit subprocessor disclosure for Anthropic (US) /
  Zhipu (CN) and which tier uses which

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi
2026-05-23 23:50:00 +02:00
parent 66128c73d8
commit bc174c1302
14 changed files with 537 additions and 58 deletions

View File

@@ -13,6 +13,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { getOrgPlan } from '../lib/plan.js';
import { sendSms, smsConfigured } from '../lib/sms.js';
const SESSION_COOKIE = 'bmm_session';
@@ -128,7 +129,10 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const token = req.cookies[SESSION_COOKIE];
const session = await getSession(token);
if (!session) return reply.code(401).send({ error: 'unauthorized' });
return reply.send({ user: session });
// Plan is on the org, not the session — look it up fresh so a Stripe
// upgrade is reflected without forcing a re-login.
const plan = await getOrgPlan(session.orgId);
return reply.send({ user: { ...session, plan } });
});
app.post('/v1/auth/admin/login', async (req, reply) => {

View File

@@ -11,7 +11,13 @@ import {
sql,
templates,
} from '@bmm/db';
import { BannedPatternError, SpecTimeoutError, SpecValidationError, generateSpec } from '@bmm/llm';
import {
BannedPatternError,
SpecTimeoutError,
SpecValidationError,
generateSpec,
pickPreviewModel,
} from '@bmm/llm';
import {
BuildEvent,
CreateServerInput,
@@ -26,8 +32,10 @@ import { config } from '../config.js';
import { audit } from '../lib/audit.js';
import { encryptSecret } from '../lib/crypto.js';
import { stopContainer } from '../lib/docker.js';
import { SERVER_LIMITS, getOrgPlan } from '../lib/plan.js';
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
import { getBuildQueue } from '../lib/queue.js';
import { BUILD_DAILY_LIMIT, PREVIEW_DAILY_LIMIT, checkDailyLimit } from '../lib/rate-limit.js';
import { buildChannel, getSubscriber } from '../lib/redis.js';
import { requireAuth } from '../plugins/session.js';
import { getForkRefTemplate } from './templates.js';
@@ -46,26 +54,47 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
});
app.post('/v1/servers/preview', { preHandler: requireAuth }, async (req, reply) => {
const user = req.user!;
const parsed = PreviewInput.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
}
const plan = await getOrgPlan(user.orgId);
// Daily preview rate-limit per user. Free is tight (5/day) because every
// preview is a paid LLM call; paid tiers have headroom for real iteration.
const rl = await checkDailyLimit('preview', user.userId, PREVIEW_DAILY_LIMIT[plan]);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily preview limit reached for plan "${plan}" (${PREVIEW_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
plan,
limit: PREVIEW_DAILY_LIMIT[plan],
resetIn: rl.resetIn,
});
}
const choice = pickPreviewModel(plan);
try {
const { spec, source } = await generateSpec(parsed.data.prompt, {
provider: choice.provider,
apiKey: config.ANTHROPIC_API_KEY,
// Preview generates the spec synchronously inside an HTTP request that
// sits behind Cloudflare's edge timeout. Haiku 4.5 (~200 tok/s — a full
// 8k-token spec in ~40s) is the only model fast enough; Sonnet and Opus
// overran the proxy cap, which reached the browser as a CORS error. The
// hard 60s timeout guarantees a clean 504 before the proxy gives up.
model: 'claude-haiku-4-5-20251001',
timeoutMs: 60_000,
glmApiKey: config.GLM_API_KEY,
model: choice.model,
maxTokens: choice.maxTokens,
timeoutMs: choice.timeoutMs,
maxRetries: 0,
});
const previewId = await cacheSpec(spec);
return reply.send({
previewId,
source,
plan,
modelDisplayName: choice.displayName,
modelBadge: choice.displayBadge,
upgradeHint: plan === 'hobby',
spec: {
name: spec.name,
description: spec.description,
@@ -112,6 +141,37 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
templateId,
} = parsed.data;
// ---- Plan enforcement (must happen before any DB write) ----
const plan = await getOrgPlan(user.orgId);
// Daily build rate-limit.
const rl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[plan]);
if (!rl.ok) {
return reply.code(429).send({
error: 'rate_limited',
detail: `Daily build limit reached for plan "${plan}" (${BUILD_DAILY_LIMIT[plan]}/day). Resets in ${Math.ceil(rl.resetIn / 3600)}h.`,
plan,
limit: BUILD_DAILY_LIMIT[plan],
resetIn: rl.resetIn,
});
}
// Server-count quota. Counted via SQL (not cached) so race risk is tiny.
const [serverCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(mcpServers)
.where(eq(mcpServers.orgId, user.orgId));
const existingCount = serverCountRow?.count ?? 0;
if (existingCount >= SERVER_LIMITS[plan]) {
return reply.code(402).send({
error: 'plan_limit_reached',
detail: `Plan "${plan}" allows ${SERVER_LIMITS[plan]} server(s); you have ${existingCount}. Upgrade to add more.`,
plan,
limit: SERVER_LIMITS[plan],
current: existingCount,
});
}
// ---- Template-fork validation ----
// templateId is user-controlled. To prevent fork_count manipulation + garbage
// template_id rows, the user MUST have hit POST /v1/templates/:slug/fork,