fix(preview): stop spec generation timing out behind the edge proxy
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
All checks were successful
Deploy to Production / deploy (push) Successful in 50s
The /v1/servers/preview route ran claude-opus-4-7 synchronously; full spec generation routinely exceeded Cloudflare's ~100s proxy cap, so the browser received a headerless 524 and reported it as a CORS failure. - preview now uses claude-sonnet-4-6 with a 45s per-attempt timeout and one retry — comfortably inside the proxy budget - generateSpec maps an exhausted timeout to SpecTimeoutError; the route returns a clean 504 (with CORS headers) instead of a stalled connection - analyze step: live elapsed-seconds counter as freeze-proof, plus a reduced-motion exception so the loading spinner keeps spinning (a status indicator, which WCAG exempts from reduced-motion) - textarea resize grip restyled to dark theme (light hatch on dark square) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d0d5668d8
commit
e198d44e1e
@ -1,25 +1,36 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { and, builds, buildLogs, createDb, desc, eq, mcpServers, secrets, sql, templates } from '@bmm/db';
|
|
||||||
import { getSession } from '@bmm/auth';
|
import { getSession } from '@bmm/auth';
|
||||||
import { stopContainer } from '../lib/docker.js';
|
|
||||||
import {
|
import {
|
||||||
CreateServerInput,
|
and,
|
||||||
IterateServerInput,
|
buildLogs,
|
||||||
|
builds,
|
||||||
|
createDb,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
mcpServers,
|
||||||
|
secrets,
|
||||||
|
sql,
|
||||||
|
templates,
|
||||||
|
} from '@bmm/db';
|
||||||
|
import { BannedPatternError, SpecTimeoutError, SpecValidationError, generateSpec } from '@bmm/llm';
|
||||||
|
import {
|
||||||
BuildEvent,
|
BuildEvent,
|
||||||
PreviewInput,
|
CreateServerInput,
|
||||||
GeneratorSpec,
|
GeneratorSpec,
|
||||||
|
IterateServerInput,
|
||||||
|
PreviewInput,
|
||||||
type SpecEdit,
|
type SpecEdit,
|
||||||
} from '@bmm/types';
|
} from '@bmm/types';
|
||||||
import { generateSpec, SpecValidationError, BannedPatternError } from '@bmm/llm';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { audit } from '../lib/audit.js';
|
||||||
|
import { encryptSecret } from '../lib/crypto.js';
|
||||||
|
import { stopContainer } from '../lib/docker.js';
|
||||||
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
|
import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js';
|
||||||
import { requireAuth } from '../plugins/session.js';
|
|
||||||
import { getBuildQueue } from '../lib/queue.js';
|
import { getBuildQueue } from '../lib/queue.js';
|
||||||
import { buildChannel, getSubscriber } from '../lib/redis.js';
|
import { buildChannel, getSubscriber } from '../lib/redis.js';
|
||||||
import { encryptSecret } from '../lib/crypto.js';
|
import { requireAuth } from '../plugins/session.js';
|
||||||
import { audit } from '../lib/audit.js';
|
|
||||||
import { getForkRefTemplate } from './templates.js';
|
import { getForkRefTemplate } from './templates.js';
|
||||||
import { config } from '../config.js';
|
|
||||||
|
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
|
|
||||||
@ -42,7 +53,11 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const { spec, source } = await generateSpec(parsed.data.prompt, {
|
const { spec, source } = await generateSpec(parsed.data.prompt, {
|
||||||
apiKey: config.ANTHROPIC_API_KEY,
|
apiKey: config.ANTHROPIC_API_KEY,
|
||||||
model: 'claude-opus-4-7',
|
// Sonnet 4.6 drafts the spec well inside Cloudflare's ~100s proxy cap;
|
||||||
|
// Opus routinely exceeded it, which reached the browser as a CORS error.
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
timeoutMs: 45_000,
|
||||||
|
maxRetries: 1,
|
||||||
});
|
});
|
||||||
const previewId = await cacheSpec(spec);
|
const previewId = await cacheSpec(spec);
|
||||||
return reply.send({
|
return reply.send({
|
||||||
@ -67,6 +82,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (err instanceof BannedPatternError) {
|
if (err instanceof BannedPatternError) {
|
||||||
return reply.code(422).send({ error: 'banned_pattern', detail: err.message });
|
return reply.code(422).send({ error: 'banned_pattern', detail: err.message });
|
||||||
}
|
}
|
||||||
|
if (err instanceof SpecTimeoutError) {
|
||||||
|
return reply.code(504).send({
|
||||||
|
error: 'preview_timeout',
|
||||||
|
detail: 'Spec generation took too long. Try a shorter, more specific prompt.',
|
||||||
|
});
|
||||||
|
}
|
||||||
app.log.error(err);
|
app.log.error(err);
|
||||||
return reply.code(500).send({ error: 'preview_failed', detail: (err as Error).message });
|
return reply.code(500).send({ error: 'preview_failed', detail: (err as Error).message });
|
||||||
}
|
}
|
||||||
@ -78,7 +99,15 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
const { name, slug, prompt, secrets: secretValues, previewId, specEdit, templateId } = parsed.data;
|
const {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
prompt,
|
||||||
|
secrets: secretValues,
|
||||||
|
previewId,
|
||||||
|
specEdit,
|
||||||
|
templateId,
|
||||||
|
} = parsed.data;
|
||||||
|
|
||||||
// ---- Template-fork validation ----
|
// ---- Template-fork validation ----
|
||||||
// templateId is user-controlled. To prevent fork_count manipulation + garbage
|
// templateId is user-controlled. To prevent fork_count manipulation + garbage
|
||||||
@ -401,7 +430,10 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const result = await stopContainer(server.containerId);
|
const result = await stopContainer(server.containerId);
|
||||||
containerStopped = result.ok;
|
containerStopped = result.ok;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
app.log.warn({ containerId: server.containerId, detail: result.detail }, 'delete: stop failed');
|
app.log.warn(
|
||||||
|
{ containerId: server.containerId, detail: result.detail },
|
||||||
|
'delete: stop failed',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
|
await db.delete(mcpServers).where(eq(mcpServers.id, server.id));
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Suspense, useEffect, useState } from 'react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { apiFetch } from '@/lib/api';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input, Label, Textarea } from '@/components/input';
|
|
||||||
import { StreamingLogs } from '@/components/streaming-logs';
|
|
||||||
import { InstallSnippets } from '@/components/install-snippets';
|
|
||||||
import { CodeBlock } from '@/components/code-block';
|
import { CodeBlock } from '@/components/code-block';
|
||||||
|
import { Input, Label, Textarea } from '@/components/input';
|
||||||
|
import { InstallSnippets } from '@/components/install-snippets';
|
||||||
|
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 { Loader2, RotateCcw, X } from 'lucide-react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
|
|
||||||
const EXAMPLE_PROMPTS = [
|
const EXAMPLE_PROMPTS = [
|
||||||
{
|
{
|
||||||
@ -85,6 +85,7 @@ function specToEditable(spec: PreviewResponse['spec']): EditableSpec {
|
|||||||
function NewServerPageInner() {
|
function NewServerPageInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [step, setStep] = useState<Step>('prompt');
|
const [step, setStep] = useState<Step>('prompt');
|
||||||
|
const [elapsedSec, setElapsedSec] = useState(0);
|
||||||
|
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@ -105,7 +106,11 @@ function NewServerPageInner() {
|
|||||||
const templateSlug = searchParams.get('template');
|
const templateSlug = searchParams.get('template');
|
||||||
|
|
||||||
const trySlug = (n: string) =>
|
const trySlug = (n: string) =>
|
||||||
n.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
n
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 32);
|
||||||
|
|
||||||
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
|
// Fork-from-template flow: skip Step 1, jump straight to Step 2 with the template's spec
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -189,6 +194,19 @@ function NewServerPageInner() {
|
|||||||
}
|
}
|
||||||
}, [preview, editable]);
|
}, [preview, editable]);
|
||||||
|
|
||||||
|
// Live elapsed counter for the analyze step — a value that ticks every
|
||||||
|
// second is unambiguous proof the page is alive, even when CSS animation is
|
||||||
|
// suppressed (e.g. the OS "reduce motion" setting).
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== 'analyzing') return;
|
||||||
|
setElapsedSec(0);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setElapsedSec(Math.floor((Date.now() - startedAt) / 1000));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
async function analyze() {
|
async function analyze() {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (prompt.trim().length < 10) {
|
if (prompt.trim().length < 10) {
|
||||||
@ -323,22 +341,19 @@ function NewServerPageInner() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>(
|
const res = await apiFetch<{ server: { id: string }; build: { id: string } }>('/v1/servers', {
|
||||||
'/v1/servers',
|
method: 'POST',
|
||||||
{
|
body: JSON.stringify({
|
||||||
method: 'POST',
|
name,
|
||||||
body: JSON.stringify({
|
slug,
|
||||||
name,
|
prompt,
|
||||||
slug,
|
secrets: filledSecrets,
|
||||||
prompt,
|
previewId: preview.previewId,
|
||||||
secrets: filledSecrets,
|
// Don't send specEdit when forking — the template's spec + pre-rendered code
|
||||||
previewId: preview.previewId,
|
// are already in the Redis cache. Edits would invalidate the impls.
|
||||||
// Don't send specEdit when forking — the template's spec + pre-rendered code
|
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
|
||||||
// are already in the Redis cache. Edits would invalidate the impls.
|
}),
|
||||||
...(forkedTemplateId ? { templateId: forkedTemplateId } : { specEdit }),
|
});
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setBuildId(res.build.id);
|
setBuildId(res.build.id);
|
||||||
setServerId(res.server.id);
|
setServerId(res.server.id);
|
||||||
setStep('building');
|
setStep('building');
|
||||||
@ -412,7 +427,9 @@ function NewServerPageInner() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="slug" hint="becomes subdomain / id">Slug</Label>
|
<Label htmlFor="slug" hint="becomes subdomain / id">
|
||||||
|
Slug
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="slug"
|
id="slug"
|
||||||
value={slug}
|
value={slug}
|
||||||
@ -437,10 +454,13 @@ function NewServerPageInner() {
|
|||||||
|
|
||||||
{step === 'analyzing' && (
|
{step === 'analyzing' && (
|
||||||
<div className="mt-10 panel p-8 text-center">
|
<div className="mt-10 panel p-8 text-center">
|
||||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
|
<Loader2 className="mx-auto animate-spin text-[--color-accent]" size={22} />
|
||||||
<p className="mt-4 text-[13px]">Analyzing your prompt…</p>
|
<p className="mt-4 text-[13px]">Analyzing your prompt…</p>
|
||||||
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
|
<p className="mt-1 text-[12px] text-[--color-fg-subtle]">
|
||||||
Claude Opus 4.7 is parsing the spec. Usually 20–40 seconds.
|
Claude Sonnet 4.6 is drafting the tool spec. Usually 20–40 seconds.
|
||||||
|
</p>
|
||||||
|
<p className="mono mt-3 text-[11px] tabular-nums text-[--color-fg-muted]">
|
||||||
|
{elapsedSec}s elapsed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -490,7 +510,9 @@ function NewServerPageInner() {
|
|||||||
)}
|
)}
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-[14px] font-semibold tracking-tight">Confirm what we'll build</h2>
|
<h2 className="text-[14px] font-semibold tracking-tight">
|
||||||
|
Confirm what we'll build
|
||||||
|
</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{editsDirty && (
|
{editsDirty && (
|
||||||
<button
|
<button
|
||||||
@ -508,9 +530,9 @@ function NewServerPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
|
<p className="mt-1 text-[12.5px] text-[--color-fg-muted]">{preview.spec.description}</p>
|
||||||
<p className="mt-3 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
|
<p className="mt-3 text-[11.5px] leading-relaxed text-[--color-fg-subtle]">
|
||||||
Edit tool names, descriptions or input schemas inline. Renaming parameters may
|
Edit tool names, descriptions or input schemas inline. Renaming parameters may require
|
||||||
require an <span className="mono">Iterate</span> after build to update the
|
an <span className="mono">Iterate</span> after build to update the implementation —
|
||||||
implementation — the existing impl references the original names.
|
the existing impl references the original names.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -560,9 +582,7 @@ function NewServerPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[13px] font-semibold tracking-tight">
|
<h3 className="text-[13px] font-semibold tracking-tight">Credentials we need</h3>
|
||||||
Credentials we need
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
||||||
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
|
AES-256-GCM encrypted at rest, injected as env vars at runtime. Remove if your
|
||||||
implementation doesn't actually use one.
|
implementation doesn't actually use one.
|
||||||
@ -625,12 +645,7 @@ function NewServerPageInner() {
|
|||||||
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
|
<Button variant="ghost" size="md" onClick={() => setStep('prompt')}>
|
||||||
← Back
|
← Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="primary" size="md" onClick={build} disabled={Boolean(hasSchemaErrors)}>
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
onClick={build}
|
|
||||||
disabled={Boolean(hasSchemaErrors)}
|
|
||||||
>
|
|
||||||
Build server →
|
Build server →
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -754,9 +769,7 @@ function SharePanel({
|
|||||||
}) {
|
}) {
|
||||||
const [share, setShare] = useState(true);
|
const [share, setShare] = useState(true);
|
||||||
const [category, setCategory] = useState('other');
|
const [category, setCategory] = useState('other');
|
||||||
const [shortDescription, setShortDescription] = useState(
|
const [shortDescription, setShortDescription] = useState(defaultShortDescription.slice(0, 280));
|
||||||
defaultShortDescription.slice(0, 280),
|
|
||||||
);
|
|
||||||
const [hints, setHints] = useState<Record<string, string>>(() =>
|
const [hints, setHints] = useState<Record<string, string>>(() =>
|
||||||
Object.fromEntries(secretKeys.map((k) => [k, ''])),
|
Object.fromEntries(secretKeys.map((k) => [k, ''])),
|
||||||
);
|
);
|
||||||
@ -832,8 +845,8 @@ function SharePanel({
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
<p className="mt-1 text-[12px] leading-relaxed text-[--color-fg-muted]">
|
||||||
Your secrets stay private — they are never copied into a template. But your{' '}
|
Your secrets stay private — they are never copied into a template. But your{' '}
|
||||||
<span className="text-[--color-fg]">generated code becomes publicly viewable</span>{' '}
|
<span className="text-[--color-fg]">generated code becomes publicly viewable</span> so
|
||||||
so others can audit it before forking. Unshare anytime.
|
others can audit it before forking. Unshare anytime.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -868,9 +881,7 @@ function SharePanel({
|
|||||||
|
|
||||||
{secretKeys.length > 0 && (
|
{secretKeys.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label hint="optional — helps forkers know what to paste">
|
<Label hint="optional — helps forkers know what to paste">Credential hints</Label>
|
||||||
Credential hints
|
|
||||||
</Label>
|
|
||||||
{secretKeys.map((k) => (
|
{secretKeys.map((k) => (
|
||||||
<div key={k} className="grid grid-cols-[180px_1fr] gap-2">
|
<div key={k} className="grid grid-cols-[180px_1fr] gap-2">
|
||||||
<div className="mono flex h-8 items-center rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[12px] text-[--color-fg-muted]">
|
<div className="mono flex h-8 items-center rounded-md border border-[--color-border] bg-[--color-bg-subtle] px-2.5 text-[12px] text-[--color-fg-muted]">
|
||||||
@ -892,12 +903,7 @@ function SharePanel({
|
|||||||
<p className="text-[11px] text-[--color-fg-subtle]">
|
<p className="text-[11px] text-[--color-fg-subtle]">
|
||||||
Published code is re-scanned for banned patterns and hardcoded secrets.
|
Published code is re-scanned for banned patterns and hardcoded secrets.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button variant="primary" size="md" onClick={publish} disabled={state === 'submitting'}>
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
onClick={publish}
|
|
||||||
disabled={state === 'submitting'}
|
|
||||||
>
|
|
||||||
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
|
{state === 'submitting' ? 'Publishing…' : 'Publish to marketplace'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -87,6 +87,17 @@
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-fg-subtle);
|
background: var(--color-fg-subtle);
|
||||||
}
|
}
|
||||||
|
/* Textarea resize grip — light hatching on a dark square so it matches the
|
||||||
|
theme instead of the OS default (light square with dark hatching). */
|
||||||
|
textarea::-webkit-resizer {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
var(--color-fg-muted) 0 1px,
|
||||||
|
transparent 1px 4px
|
||||||
|
);
|
||||||
|
border-bottom-right-radius: var(--radius-md);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
@ -115,6 +126,16 @@
|
|||||||
transition-duration: 0.001ms !important;
|
transition-duration: 0.001ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* A loading spinner reports system status — WCAG treats status indicators as
|
||||||
|
an exception to reduced-motion, so keep it spinning after the rule above
|
||||||
|
has damped every decorative animation. Same layer + higher specificity
|
||||||
|
than the universal selector, so this !important wins. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-spin {
|
||||||
|
animation-duration: 1s !important;
|
||||||
|
animation-iteration-count: infinite !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-dot {
|
@keyframes pulse-dot {
|
||||||
|
|||||||
@ -54,19 +54,43 @@ export interface GenerateOptions {
|
|||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
|
/** Per-attempt request timeout in ms. Omit to use the SDK default. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** SDK retry count. Omit to use the SDK default. */
|
||||||
|
maxRetries?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateSpec(prompt: string, opts: GenerateOptions = {}): Promise<GenerationResult> {
|
export async function generateSpec(
|
||||||
|
prompt: string,
|
||||||
|
opts: GenerateOptions = {},
|
||||||
|
): Promise<GenerationResult> {
|
||||||
if (!opts.apiKey) {
|
if (!opts.apiKey) {
|
||||||
return { spec: mockSpec(prompt), source: 'mock' };
|
return { spec: mockSpec(prompt), source: 'mock' };
|
||||||
}
|
}
|
||||||
const client = new Anthropic({ apiKey: opts.apiKey });
|
const client = new Anthropic({ apiKey: opts.apiKey });
|
||||||
const response = await client.messages.create({
|
const requestOptions: { timeout?: number; maxRetries?: number } = {};
|
||||||
model: opts.model ?? 'claude-opus-4-7',
|
if (opts.timeoutMs !== undefined) requestOptions.timeout = opts.timeoutMs;
|
||||||
max_tokens: opts.maxTokens ?? 8192,
|
if (opts.maxRetries !== undefined) requestOptions.maxRetries = opts.maxRetries;
|
||||||
system: SYSTEM_PROMPT,
|
|
||||||
messages: [{ role: 'user', content: prompt }],
|
const response = await client.messages
|
||||||
});
|
.create(
|
||||||
|
{
|
||||||
|
model: opts.model ?? 'claude-opus-4-7',
|
||||||
|
max_tokens: opts.maxTokens ?? 8192,
|
||||||
|
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
|
const text = response.content
|
||||||
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
|
||||||
.map((b) => b.text)
|
.map((b) => b.text)
|
||||||
@ -88,6 +112,10 @@ export class BannedPatternError extends Error {
|
|||||||
override readonly name = 'BannedPatternError';
|
override readonly name = 'BannedPatternError';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SpecTimeoutError extends Error {
|
||||||
|
override readonly name = 'SpecTimeoutError';
|
||||||
|
}
|
||||||
|
|
||||||
function extractJson(text: string): unknown {
|
function extractJson(text: string): unknown {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user