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

@@ -7,6 +7,7 @@ import { StreamingLogs } from '@/components/streaming-logs';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { Loader2, RotateCcw, X } from 'lucide-react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
@@ -41,9 +42,15 @@ interface PreviewTool {
inputSchema: Record<string, unknown>;
}
type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
interface PreviewResponse {
previewId: string;
source: 'claude' | 'mock';
source: 'claude' | 'glm' | 'mock';
plan?: Plan;
modelDisplayName?: string;
modelBadge?: 'open-tier' | 'claude-haiku' | 'claude-sonnet' | 'claude-opus';
upgradeHint?: boolean;
spec: {
name: string;
description?: string;
@@ -53,6 +60,13 @@ interface PreviewResponse {
};
}
const PREVIEW_MODEL_BY_PLAN: Record<Plan, { name: string; estimate: string }> = {
hobby: { name: 'Open-tier AI', estimate: '3060 seconds' },
pro: { name: 'Claude Haiku 4.5', estimate: '1020 seconds' },
team: { name: 'Claude Sonnet 4.6', estimate: '1540 seconds' },
enterprise: { name: 'Claude Sonnet 4.6', estimate: '1540 seconds' },
};
interface EditableTool {
name: string;
description: string;
@@ -86,6 +100,7 @@ function NewServerPageInner() {
const router = useRouter();
const [step, setStep] = useState<Step>('prompt');
const [elapsedSec, setElapsedSec] = useState(0);
const [userPlan, setUserPlan] = useState<Plan | null>(null);
const [prompt, setPrompt] = useState('');
const [name, setName] = useState('');
@@ -207,6 +222,14 @@ function NewServerPageInner() {
return () => clearInterval(id);
}, [step]);
// Plan determines which model the preview will use — we display its name
// *before* the request so the user knows what they're waiting for.
useEffect(() => {
apiFetch<{ user: { plan?: Plan } }>('/v1/auth/me')
.then((r) => setUserPlan(r.user.plan ?? 'hobby'))
.catch(() => setUserPlan('hobby'));
}, []);
async function analyze() {
setError(null);
if (prompt.trim().length < 10) {
@@ -358,13 +381,23 @@ function NewServerPageInner() {
setServerId(res.server.id);
setStep('building');
} catch (e) {
const detail = (e as { detail?: { error?: string; detail?: unknown } }).detail;
const detail = (e as { detail?: { error?: string; detail?: string } }).detail;
const code = detail?.error;
setError(
code === 'slug_taken'
? `The slug "${slug}" is already used by one of your servers — change the Slug field above.`
: (code ?? (e as Error).message),
);
if (code === 'slug_taken') {
setError(
`The slug "${slug}" is already used by one of your servers — change the Slug field above.`,
);
return;
}
if (code === 'plan_limit_reached') {
setError(`${detail?.detail ?? 'Plan limit reached.'} See /pricing to upgrade.`);
return;
}
if (code === 'rate_limited') {
setError(detail?.detail ?? 'Daily build limit reached — try again tomorrow or upgrade.');
return;
}
setError(detail?.detail ?? code ?? (e as Error).message);
}
}
@@ -457,8 +490,18 @@ function NewServerPageInner() {
<Loader2 className="mx-auto animate-spin text-[--color-accent]" size={22} />
<p className="mt-4 text-[13px]">Analyzing your prompt</p>
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
Claude is drafting the tool spec. Usually 1540 seconds.
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).name} is
drafting the tool spec. Usually{' '}
{(userPlan ? PREVIEW_MODEL_BY_PLAN[userPlan] : PREVIEW_MODEL_BY_PLAN.hobby).estimate}.
</p>
{userPlan === 'hobby' && (
<p className="mt-2 text-[11px] text-[--color-fg-muted]">
<Link href="/pricing" className="text-[--color-accent] hover:underline">
Upgrade to Pro
</Link>{' '}
for ~3× faster analysis with Claude Haiku.
</p>
)}
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
{elapsedSec}s elapsed
</p>
@@ -524,7 +567,7 @@ function NewServerPageInner() {
</button>
)}
<span className="mono text-[10.5px] text-[--color-fg-subtle]">
spec via {preview.source}
drafted with {preview.modelDisplayName ?? preview.source}
</span>
</div>
</div>