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

@@ -45,18 +45,138 @@ const BANNED_PATTERNS = [
/disregard\s+(the\s+)?(above|previous)/i,
];
// ──────────────────────────────────────────────────────────────────────────
// Plan-aware model selection
// ──────────────────────────────────────────────────────────────────────────
export type Plan = 'hobby' | 'pro' | 'team' | 'enterprise';
export type Purpose = 'preview' | 'build';
export type Provider = 'anthropic' | 'glm';
export type DisplayBadge = 'open-tier' | 'claude-haiku' | 'claude-sonnet' | 'claude-opus';
export interface ModelChoice {
provider: Provider;
model: string;
maxTokens: number;
timeoutMs: number;
/** User-facing model name shown in the wizard + previews. */
displayName: string;
displayBadge: DisplayBadge;
}
/**
* Preview runs synchronously inside an HTTP request behind Cloudflare's
* ~100s edge cap. Each tier's (model + max_tokens + timeout) is bounded to
* fit. Hobby uses GLM as the cost lever; paid tiers escalate to Claude — the
* visible quality/speed jump *is* the upgrade pitch.
*
* Measured token rates: glm-4-plus ~58 tok/s (3500 tok ≈ 60s) ·
* Claude Haiku 4.5 ~200 tok/s (8192 tok ≈ 41s) · Claude Sonnet 4.6 ~80 tok/s.
*/
const PREVIEW_MODELS: Record<Plan, ModelChoice> = {
hobby: {
provider: 'glm',
model: 'glm-4-plus',
maxTokens: 3500,
timeoutMs: 65_000,
displayName: 'Open-tier AI',
displayBadge: 'open-tier',
},
pro: {
provider: 'anthropic',
model: 'claude-haiku-4-5-20251001',
maxTokens: 8192,
timeoutMs: 60_000,
displayName: 'Claude Haiku 4.5',
displayBadge: 'claude-haiku',
},
team: {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
maxTokens: 8192,
timeoutMs: 60_000,
displayName: 'Claude Sonnet 4.6',
displayBadge: 'claude-sonnet',
},
enterprise: {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
maxTokens: 8192,
timeoutMs: 60_000,
displayName: 'Claude Sonnet 4.6',
displayBadge: 'claude-sonnet',
},
};
/**
* Build worker runs async via BullMQ — no proxy timeout. With the 24h preview
* cache TTL cache-misses are rare, so GLM as the default keeps that rare path
* cheap; Enterprise gets Opus as a premium-quality promise.
*/
const BUILD_MODELS: Record<Plan, ModelChoice> = {
hobby: {
provider: 'glm',
model: 'glm-4.5',
maxTokens: 8192,
timeoutMs: 180_000,
displayName: 'Open-tier AI',
displayBadge: 'open-tier',
},
pro: {
provider: 'glm',
model: 'glm-4.5',
maxTokens: 8192,
timeoutMs: 180_000,
displayName: 'Open-tier AI',
displayBadge: 'open-tier',
},
team: {
provider: 'glm',
model: 'glm-4.5',
maxTokens: 8192,
timeoutMs: 180_000,
displayName: 'Open-tier AI',
displayBadge: 'open-tier',
},
enterprise: {
provider: 'anthropic',
model: 'claude-opus-4-7',
maxTokens: 8192,
timeoutMs: 600_000,
displayName: 'Claude Opus 4.7',
displayBadge: 'claude-opus',
},
};
export function pickPreviewModel(plan: Plan): ModelChoice {
return PREVIEW_MODELS[plan];
}
export function pickBuildModel(plan: Plan): ModelChoice {
return BUILD_MODELS[plan];
}
// ──────────────────────────────────────────────────────────────────────────
// Generation API
// ──────────────────────────────────────────────────────────────────────────
export interface GenerationResult {
spec: GeneratorSpecT;
source: 'claude' | 'mock';
source: 'claude' | 'glm' | 'mock';
}
export interface GenerateOptions {
/** 'anthropic' (default) or 'glm'. */
provider?: Provider;
/** Anthropic API key — required if provider === 'anthropic'. */
apiKey?: string;
/** Zhipu (GLM) API key — required if provider === 'glm'. */
glmApiKey?: string;
model?: string;
maxTokens?: number;
/** Per-attempt request timeout in ms. Omit to use the SDK default. */
/** Per-attempt request timeout in ms. */
timeoutMs?: number;
/** SDK retry count. Omit to use the SDK default. */
/** SDK retry count. Anthropic only. */
maxRetries?: number;
}
@@ -64,9 +184,40 @@ export async function generateSpec(
prompt: string,
opts: GenerateOptions = {},
): Promise<GenerationResult> {
const provider = opts.provider ?? 'anthropic';
if (provider === 'glm') {
if (!opts.glmApiKey) return { spec: mockSpec(prompt), source: 'mock' };
return generateWithGlm(prompt, {
apiKey: opts.glmApiKey,
model: opts.model ?? 'glm-4-plus',
maxTokens: opts.maxTokens ?? 4096,
timeoutMs: opts.timeoutMs,
});
}
if (!opts.apiKey) {
return { spec: mockSpec(prompt), source: 'mock' };
}
return generateWithAnthropic(prompt, {
apiKey: opts.apiKey,
model: opts.model ?? 'claude-opus-4-7',
maxTokens: opts.maxTokens ?? 8192,
timeoutMs: opts.timeoutMs,
maxRetries: opts.maxRetries,
});
}
async function generateWithAnthropic(
prompt: string,
opts: {
apiKey: string;
model: string;
maxTokens: number;
timeoutMs?: number;
maxRetries?: number;
},
): Promise<GenerationResult> {
const client = new Anthropic({ apiKey: opts.apiKey });
const requestOptions: { timeout?: number; maxRetries?: number } = {};
if (opts.timeoutMs !== undefined) requestOptions.timeout = opts.timeoutMs;
@@ -75,35 +226,81 @@ export async function generateSpec(
const response = await client.messages
.create(
{
model: opts.model ?? 'claude-opus-4-7',
max_tokens: opts.maxTokens ?? 8192,
model: opts.model,
max_tokens: opts.maxTokens,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: prompt }],
},
requestOptions,
)
.catch((err: unknown) => {
// A per-attempt timeout surfaces as APIConnectionTimeoutError once the
// SDK exhausts retries. Map it to a typed error so the API layer returns
// a clean 504 instead of letting the edge proxy time out headerless.
if (err instanceof Anthropic.APIConnectionTimeoutError) {
throw new SpecTimeoutError('spec generation exceeded the time budget');
}
throw err;
});
const text = response.content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map((b) => b.text)
.join('');
const json = extractJson(text);
const parsed = GeneratorSpec.safeParse(json);
if (!parsed.success) {
throw new SpecValidationError(parsed.error.message);
}
if (!parsed.success) throw new SpecValidationError(parsed.error.message);
scanForInjection(parsed.data);
return { spec: parsed.data, source: 'claude' };
}
const GLM_ENDPOINT = 'https://open.bigmodel.cn/api/paas/v4/chat/completions';
async function generateWithGlm(
prompt: string,
opts: { apiKey: string; model: string; maxTokens: number; timeoutMs?: number },
): Promise<GenerationResult> {
const controller = new AbortController();
const timer = opts.timeoutMs ? setTimeout(() => controller.abort(), opts.timeoutMs) : null;
let res: Response;
try {
res = await fetch(GLM_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${opts.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: opts.model,
max_tokens: opts.maxTokens,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: prompt },
],
}),
signal: controller.signal,
});
} catch (err) {
if ((err as { name?: string }).name === 'AbortError') {
throw new SpecTimeoutError('glm spec generation exceeded the time budget');
}
throw err;
} finally {
if (timer) clearTimeout(timer);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`glm_api_${res.status}: ${body.slice(0, 200)}`);
}
const data = (await res.json()) as {
choices?: Array<{ message?: { content?: string }; finish_reason?: string }>;
};
const content = data.choices?.[0]?.message?.content;
if (!content) throw new SpecValidationError('glm_empty_response');
const json = extractJson(content);
const parsed = GeneratorSpec.safeParse(json);
if (!parsed.success) throw new SpecValidationError(parsed.error.message);
scanForInjection(parsed.data);
return { spec: parsed.data, source: 'glm' };
}
export class SpecValidationError extends Error {
override readonly name = 'SpecValidationError';
}
@@ -141,7 +338,7 @@ function scanForInjection(spec: GeneratorSpecT): void {
export function mockSpec(prompt: string): GeneratorSpecT {
return {
name: 'Echo MCP',
description: `Mock server (no ANTHROPIC_API_KEY). Prompt was: ${prompt.slice(0, 200)}`,
description: `Mock server (no LLM key). Prompt was: ${prompt.slice(0, 200)}`,
tools: [
{
name: 'echo',