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

@@ -1,13 +1,13 @@
import { builds, createDb, eq, mcpServers } from '@bmm/db';
import { GeneratorSpec } from '@bmm/types';
import { Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { GeneratorSpec } from '@bmm/types';
import { builds, createDb, eq, mcpServers } from '@bmm/db';
import { config } from './config.js';
import { generateSpec } from './lib/claude.js';
import { renderServerCode } from './lib/render.js';
import { dockerBuild, prepareBuildContext, staticCheck } from './lib/build.js';
import { generateSpec } from './lib/claude.js';
import { allocatePort, deployContainer, dockerAvailable, stopContainer } from './lib/deploy.js';
import { emitDone, emitError, emitLog, emitStatus } from './lib/emit.js';
import { renderServerCode } from './lib/render.js';
const db = createDb();
const connection = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null });
@@ -57,12 +57,18 @@ export const worker = new Worker<JobData>(
const oldContainerId = priorState?.containerId ?? null;
try {
await db.update(builds).set({ status: 'generating', startedAt: new Date() }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'generating', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await db
.update(builds)
.set({ status: 'generating', startedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'generating', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'generating');
let spec: GeneratorSpec | null = null;
let source: 'claude' | 'mock' | 'cached' = 'mock';
let source: 'claude' | 'glm' | 'mock' | 'cached' = 'mock';
if (previewId) {
spec = await loadCachedSpec(previewId);
@@ -87,7 +93,10 @@ export const worker = new Worker<JobData>(
let generatedCode: string;
const prebuilt = previewId ? await loadPrebuiltCode(previewId) : null;
if (prebuilt) {
await log('info', `Using pre-rendered template code (${prebuilt.length} chars) — skipping render`);
await log(
'info',
`Using pre-rendered template code (${prebuilt.length} chars) — skipping render`,
);
generatedCode = prebuilt;
} else {
generatedCode = renderServerCode(spec);
@@ -98,11 +107,20 @@ export const worker = new Worker<JobData>(
.where(eq(builds.id, buildId));
await db.update(builds).set({ status: 'building' }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'building', toolsSchema: spec.tools, updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await db
.update(mcpServers)
.set({ status: 'building', toolsSchema: spec.tools, updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'building');
await log('info', 'Preparing build context...');
const { contextDir, imageTag } = await prepareBuildContext(serverId, version, slug, generatedCode, spec);
const { contextDir, imageTag } = await prepareBuildContext(
serverId,
version,
slug,
generatedCode,
spec,
);
await log('info', `Build context at ${contextDir}`);
await log('info', 'Running static checks...');
@@ -112,8 +130,14 @@ export const worker = new Worker<JobData>(
const hasDocker = await dockerAvailable();
if (!hasDocker) {
await log('warn', 'Docker not available — skipping build/deploy. Server marked draft.');
await db.update(builds).set({ status: 'failed', errorMessage: 'docker_unavailable', finishedAt: new Date() }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'failed', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await db
.update(builds)
.set({ status: 'failed', errorMessage: 'docker_unavailable', finishedAt: new Date() })
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'failed', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitDone(buildId, 'failed', serverId, null);
return;
}
@@ -125,7 +149,10 @@ export const worker = new Worker<JobData>(
await log('info', 'Image built.');
await db.update(builds).set({ status: 'deploying' }).where(eq(builds.id, buildId));
await db.update(mcpServers).set({ status: 'deploying', updatedAt: new Date() }).where(eq(mcpServers.id, serverId));
await db
.update(mcpServers)
.set({ status: 'deploying', updatedAt: new Date() })
.where(eq(mcpServers.id, serverId));
await emitStatus(buildId, 'deploying');
const port = await allocatePort();
@@ -140,7 +167,10 @@ export const worker = new Worker<JobData>(
};
const handle = await deployContainer({ serverId, slug, hostPort: port, imageTag, envVars });
await log('info', `Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`);
await log(
'info',
`Container ${handle.containerId.slice(0, 12)} running at ${handle.publicUrl}`,
);
await db
.update(builds)
@@ -148,7 +178,12 @@ export const worker = new Worker<JobData>(
.where(eq(builds.id, buildId));
await db
.update(mcpServers)
.set({ status: 'live', currentVersion: version, publicUrl: handle.publicUrl, updatedAt: new Date() })
.set({
status: 'live',
currentVersion: version,
publicUrl: handle.publicUrl,
updatedAt: new Date(),
})
.where(eq(mcpServers.id, serverId));
// Rolling deploy: the new container is live — now retire the previous one.