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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user