feat: tiered LLM (GLM free / Claude paid) + rate limits + quota enforcement
All checks were successful
Deploy to Production / deploy (push) Successful in 53s
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:
@@ -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: '30–60 seconds' },
|
||||
pro: { name: 'Claude Haiku 4.5', estimate: '10–20 seconds' },
|
||||
team: { name: 'Claude Sonnet 4.6', estimate: '15–40 seconds' },
|
||||
enterprise: { name: 'Claude Sonnet 4.6', estimate: '15–40 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 15–40 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>
|
||||
|
||||
@@ -14,9 +14,12 @@ const TIERS = [
|
||||
price: '€0',
|
||||
tag: 'Forever free',
|
||||
description: 'For trying things out and shipping single-user tools.',
|
||||
model: 'Open-tier AI',
|
||||
modelDetail: 'Free-tier model · ~30-60s analyze',
|
||||
features: [
|
||||
'1 MCP server',
|
||||
'100,000 tool calls / month',
|
||||
'5 prompt analyses / day',
|
||||
'BuildMyMCP subdomain',
|
||||
'Community support',
|
||||
],
|
||||
@@ -28,9 +31,12 @@ const TIERS = [
|
||||
price: '€49',
|
||||
tag: '/ month',
|
||||
description: 'For solo founders and small teams shipping production tools.',
|
||||
model: 'Claude Haiku 4.5',
|
||||
modelDetail: 'Anthropic · ~10-20s analyze',
|
||||
features: [
|
||||
'5 MCP servers',
|
||||
'1M tool calls / month',
|
||||
'40 prompt analyses / day',
|
||||
'Custom domain',
|
||||
'Priority build queue',
|
||||
'Email support, 1 business-day SLA',
|
||||
@@ -41,12 +47,15 @@ const TIERS = [
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
price: '€149',
|
||||
price: '€199',
|
||||
tag: '/ month',
|
||||
description: 'For teams with RBAC, audit, and 99.9% SLA needs.',
|
||||
model: 'Claude Sonnet 4.6',
|
||||
modelDetail: "Anthropic's flagship",
|
||||
features: [
|
||||
'25 MCP servers',
|
||||
'10M tool calls / month',
|
||||
'150 prompt analyses / day',
|
||||
'RBAC + extended audit log',
|
||||
'99.9% uptime SLA',
|
||||
'Shared Slack channel support',
|
||||
@@ -56,9 +65,11 @@ const TIERS = [
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '€499+',
|
||||
price: '€999+',
|
||||
tag: '/ month',
|
||||
description: 'For organizations bringing their own cloud, SSO and dedicated infra.',
|
||||
model: 'Sonnet + Opus on build',
|
||||
modelDetail: 'EU data-residency option',
|
||||
features: [
|
||||
'Unlimited servers',
|
||||
'BYOC (AWS, GCP, Azure, Hetzner)',
|
||||
@@ -122,6 +133,13 @@ export default function Pricing() {
|
||||
<p className="mt-2 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
||||
{t.description}
|
||||
</p>
|
||||
<div className="mt-3 rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 py-1.5">
|
||||
<div className="text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
AI model
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12.5px] font-medium text-[--color-fg]">{t.model}</div>
|
||||
<div className="text-[10.5px] text-[--color-fg-subtle]">{t.modelDetail}</div>
|
||||
</div>
|
||||
<ul className="mt-4 space-y-1.5 text-[12.5px] text-[--color-fg-muted]">
|
||||
{t.features.map((f) => (
|
||||
<li key={f}>— {f}</li>
|
||||
|
||||
@@ -36,11 +36,21 @@ const SECTIONS = [
|
||||
{
|
||||
h: 'Subprocessors',
|
||||
p: [
|
||||
"Anthropic (generation) — only the prompt text you send. Anthropic's data-retention policy applies.",
|
||||
'Hetzner (compute).',
|
||||
'Backblaze (encrypted backups).',
|
||||
'Stripe (billing).',
|
||||
'Cloudflare (DNS + DDoS).',
|
||||
"Anthropic, USA (Claude AI — used for prompt analysis and code generation on Pro / Team / Enterprise tiers). Only the prompt text and resulting spec are sent. Anthropic's data-retention policy applies.",
|
||||
'Zhipu AI, China (GLM model — used for prompt analysis on the free Hobby tier only). Only the prompt text and resulting spec are sent. Upgrade to a paid tier to keep all AI processing within Anthropic (US).',
|
||||
'Hetzner, Germany (compute).',
|
||||
'Backblaze, EU (encrypted backups).',
|
||||
'Stripe, Ireland (billing).',
|
||||
'Cloudflare (DNS + DDoS protection).',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: 'AI processing per tier',
|
||||
p: [
|
||||
'Hobby (free): prompts are sent to Zhipu AI (GLM, China) for analysis. Choose a paid tier if your prompts contain data that must not leave the EU/US.',
|
||||
'Pro: prompts are sent to Anthropic (Claude Haiku 4.5, USA).',
|
||||
'Team: prompts are sent to Anthropic (Claude Sonnet 4.6, USA).',
|
||||
'Enterprise: Anthropic (Claude Sonnet + Opus, USA) with EU-data-residency opt-in available on request.',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,7 +40,11 @@ const PILLARS = [
|
||||
},
|
||||
{
|
||||
title: 'Rate limiting',
|
||||
body: 'Default 100 requests/min/IP per tool, enforced at the Traefik layer before traffic ever reaches your container.',
|
||||
body: 'Default 100 requests/min/IP per tool, enforced at the Traefik layer before traffic ever reaches your container. Daily preview + build caps per tier protect against runaway LLM spend.',
|
||||
},
|
||||
{
|
||||
title: 'AI provider by tier — transparent',
|
||||
body: "Hobby (free) tier uses Zhipu's GLM model (servers in China) for prompt analysis — chosen for cost so we can offer a real free tier. Pro, Team and Enterprise use Anthropic Claude (US). Enterprise can request EU-only data residency. The provider is shown live in the wizard so you always know where your prompt is going.",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user