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:
@@ -4,13 +4,14 @@ const Env = z.object({
|
||||
DATABASE_URL: z.string(),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
GLM_API_KEY: z.string().optional(),
|
||||
RUNNER_HOST: z.string().default('localhost'),
|
||||
RUNNER_PORT_RANGE_START: z.coerce.number().default(4100),
|
||||
RUNNER_PORT_RANGE_END: z.coerce.number().default(4999),
|
||||
CONTROL_PLANE_URL: z.string().default('http://host.docker.internal:4000'),
|
||||
CONTROL_PLANE_PUBLIC_URL: z.string().default('http://localhost:4000'),
|
||||
OAUTH_ISSUER: z.string().optional(),
|
||||
MODEL_GENERATE: z.string().default('claude-opus-4-7'),
|
||||
MODEL_GENERATE: z.string().default('glm-4.5'),
|
||||
MODEL_FIX: z.string().default('claude-haiku-4-5-20251001'),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
import { generateSpec as sharedGenerate, type GenerationResult } from '@bmm/llm';
|
||||
import { type GenerationResult, generateSpec as sharedGenerate } from '@bmm/llm';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export type { GenerationResult };
|
||||
|
||||
/**
|
||||
* Build-worker spec generation (cache-miss path). Runs async in a BullMQ
|
||||
* worker — no proxy timeout. Defaults to GLM to keep this rare path cheap;
|
||||
* falls back to Anthropic Sonnet on GLM failure so a temporary outage at one
|
||||
* provider doesn't break builds.
|
||||
*/
|
||||
export async function generateSpec(prompt: string): Promise<GenerationResult> {
|
||||
if (config.GLM_API_KEY) {
|
||||
try {
|
||||
return await sharedGenerate(prompt, {
|
||||
provider: 'glm',
|
||||
glmApiKey: config.GLM_API_KEY,
|
||||
model: config.MODEL_GENERATE,
|
||||
maxTokens: 8192,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[generator] GLM failed, falling back to Anthropic Sonnet:',
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!config.ANTHROPIC_API_KEY) {
|
||||
// No keys at all → @bmm/llm returns mockSpec, which keeps builds working
|
||||
// in dev without any provider configured.
|
||||
return sharedGenerate(prompt, { provider: 'anthropic' });
|
||||
}
|
||||
return sharedGenerate(prompt, {
|
||||
provider: 'anthropic',
|
||||
apiKey: config.ANTHROPIC_API_KEY,
|
||||
model: config.MODEL_GENERATE,
|
||||
model: 'claude-sonnet-4-6',
|
||||
maxTokens: 8192,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user