Files
WorkRoot-WRNexusJSComponent/app/lib/studio-ai.ts
T

488 lines
15 KiB
TypeScript

import type {
GenerationProvider,
GenerationRequest,
} from "@wrnexus/design-studio";
export const APIMART_MODEL = "gpt-5-mini";
export const AI_TIMEOUT_MS = 60_000;
export const AI_OUTPUT_TOKENS = 8192;
type FailureKind =
| "auth"
| "quota"
| "model"
| "rate"
| "unavailable"
| "network"
| "truncated"
| "filtered"
| "response"
| "request";
const failureMessages: Record<FailureKind, string> = {
auth: "APIMart rejected the server API key. Check APIMART_API_KEY and its permissions, then restart the app.",
quota:
"APIMart reports insufficient credit or quota. Check the provider account balance and limits.",
model:
"APIMart could not access the configured model. Check that this model is enabled for the server API key.",
rate: "APIMart is rate limiting requests. Wait a minute and generate again.",
unavailable:
"APIMart is temporarily unavailable. Try generating again shortly.",
network:
"The server could not reach APIMart. Check the VM's outbound HTTPS connection and try again.",
truncated:
"APIMart stopped before completing all design alternatives. Try a shorter brief or generate again.",
filtered: "APIMart declined this generation. Revise the brief and try again.",
response:
"APIMart returned an empty or unreadable completion. Try generating again.",
request:
"APIMart rejected the completion request. Check the provider's model configuration.",
};
class APIMartFailure extends Error {
constructor(
readonly kind: FailureKind,
readonly status?: number,
) {
super(failureMessages[kind]);
}
get retryable() {
return ["rate", "unavailable", "network", "truncated", "response"].includes(
this.kind,
);
}
}
function failureFor(status: number, code?: unknown) {
// Only recognized codes influence the category; upstream text never leaves this adapter.
const known = typeof code === "string" ? code.toLowerCase() : "";
const kind: FailureKind =
status === 401 || status === 403 || known === "invalid_api_key"
? "auth"
: status === 402 ||
["insufficient_quota", "insufficient_balance"].includes(known)
? "quota"
: status === 404 || known === "model_not_found"
? "model"
: status === 429 || known === "rate_limit_exceeded"
? "rate"
: status >= 500
? "unavailable"
: "request";
return new APIMartFailure(kind, status);
}
export function apimartProvider(
apiKey: string,
transport: typeof fetch = fetch,
model = APIMART_MODEL,
): GenerationProvider {
return {
name: "APIMart",
async generate(prompt, options = {}) {
let response: Response;
try {
response = await transport(
"https://api.apimart.ai/v1/chat/completions",
{
method: "POST",
signal: options.signal,
redirect: "error",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model,
stream: false,
max_tokens: options.maxTokens ?? AI_OUTPUT_TOKENS,
messages: [
{
role: "system",
content:
options.system ?? "Return valid design proposal JSON only.",
},
{ role: "user", content: prompt },
],
}),
},
);
} catch {
options.signal?.throwIfAborted();
throw new APIMartFailure("network");
}
// Never forward upstream bodies: they may echo credentials or submitted context.
if (!response.ok) {
// Read a bounded envelope only to recognize documented machine codes.
let code: unknown;
try {
code = JSON.parse(await readBounded(response, 200_000)).error?.code;
} catch {
/* Ignore raw error text. */
}
throw failureFor(response.status, code);
}
let result;
try {
result = JSON.parse(await readBounded(response, 200_000));
} catch {
options.signal?.throwIfAborted();
throw new APIMartFailure("response");
}
if (result?.error) throw failureFor(400, result.error.code);
const choice = result?.choices?.[0];
if (choice?.finish_reason === "length")
throw new APIMartFailure("truncated");
if (choice?.finish_reason === "content_filter")
throw new APIMartFailure("filtered");
if (
typeof choice?.message?.content !== "string" ||
!choice.message.content.trim()
)
throw new APIMartFailure("response");
return {
value: choice.message.content,
provider: "APIMart",
model,
};
},
};
}
async function readBounded(source: Request | Response, limit: number) {
const reader = source.body?.getReader();
const decoder = new TextDecoder();
let text = "",
bytes = 0;
if (!reader) return text;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > limit) {
await reader.cancel();
throw Error("Request or response is too large.");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
} finally {
reader.releaseLock();
}
}
function synchronousStudioAI(
apiKey?: string,
transport: typeof fetch = fetch,
model = APIMART_MODEL,
) {
let active = 0,
windowStart = Date.now(),
count = 0;
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), {
status,
headers: {
"content-type": "application/json",
"cache-control": "no-store",
},
});
return async (request: Request): Promise<Response | undefined> => {
const url = new URL(request.url);
if (
!apiKey ||
![
"/design-studio/api/generation",
"/design-studio/api/generate",
].includes(url.pathname)
)
return;
const scheme = request.headers.get("x-forwarded-proto");
if (scheme === "https" || scheme === "http") url.protocol = scheme + ":";
if (
request.headers.get("origin") &&
request.headers.get("origin") !== url.origin
)
return json({ error: "Same-origin requests only." }, 403);
if (url.pathname.endsWith("/generation") && request.method === "GET")
return json({
provider: "APIMart",
model,
templateVersion: 1,
delivery: "polling",
maxAttempts: 2,
maxOutputTokens: AI_OUTPUT_TOKENS,
timeoutMs: AI_TIMEOUT_MS,
});
if (!url.pathname.endsWith("/generate") || request.method !== "POST")
return json({ error: "Method not allowed." }, 405);
if (
!(request.headers.get("content-type") ?? "").startsWith(
"application/json",
)
)
return json({ error: "JSON required." }, 415);
if (Date.now() - windowStart >= 60000) {
windowStart = Date.now();
count = 0;
}
if (active >= 2 || count >= 10)
return json(
{ error: "AI generation is busy. Try again in a minute." },
429,
);
active++;
try {
const input = JSON.parse(
await readBounded(request, 2_000_000),
) as GenerationRequest;
count++;
// Preserve the installed engine's validation, locks, revision checks and repair budget.
const module = import.meta.resolve("@wrnexus/design-studio");
const { generateDesigns } = (await import(
module
)) as typeof import("@wrnexus/design-studio");
let calls = 0;
let exhausted = false;
let failure: APIMartFailure | undefined;
const upstream = apimartProvider(apiKey, transport, model);
const provider: GenerationProvider = {
name: upstream.name,
async generate(prompt, options) {
// The repair pass and transport retry share one two-call budget.
if (calls >= 2) {
exhausted = true;
throw failure ?? new APIMartFailure("response");
}
calls++;
failure = undefined;
try {
return await upstream.generate(prompt, options);
} catch (error) {
options?.signal?.throwIfAborted();
failure =
error instanceof APIMartFailure
? error
: new APIMartFailure("response");
throw failure;
}
},
};
const deadline = AbortSignal.any([
request.signal,
AbortSignal.timeout(AI_TIMEOUT_MS),
]);
const generate = () =>
generateDesigns(input, {
provider,
model,
signal: deadline,
timeoutMs: AI_TIMEOUT_MS,
maxOutputTokens: AI_OUTPUT_TOKENS,
});
let result = await generate();
if (
!result.alternatives.length &&
failure?.retryable &&
calls < 2 &&
!deadline.aborted
)
result = await generate();
result.provenance.attempts = calls;
if (exhausted && !failure && !result.alternatives.length) {
result.diagnostics = [
{
code: "WRN-STUDIO-PROPOSAL",
path: "$",
message:
"APIMart returned a design that did not meet the schema after both provider requests were used. Generate again. Your draft and previous alternatives are unchanged.",
},
];
}
if (
failure &&
result.diagnostics.some((d) => d.code === "WRN-STUDIO-PROVIDER")
) {
const reference = crypto.randomUUID();
console.warn(
JSON.stringify({
event: "studio-ai-provider-failure",
reference,
category: failure.kind,
status: failure.status,
attempts: calls,
}),
);
result.diagnostics = [
{
code: "WRN-STUDIO-PROVIDER",
path: "$",
message: `${failure.message} Your draft and previous alternatives are unchanged. Reference: ${reference}`,
},
];
}
return json(result);
} catch (error) {
const message =
error instanceof Error ? error.message : "Invalid generation request.";
if (message.includes("WRN-STUDIO-CONFLICT"))
return json(
{
error: "Your draft changed. Generate again from the current draft.",
},
409,
);
if (message.includes("too large"))
return json({ error: "Request is too large." }, 413);
if (
request.signal.aborted ||
(error instanceof Error &&
["AbortError", "TimeoutError"].includes(error.name))
)
return json(
{
error:
"AI generation timed out or was cancelled. Your draft is unchanged.",
},
408,
);
return json(
{
error:
"Could not generate a design. Check the brief and server AI configuration. Your draft is unchanged.",
},
400,
);
} finally {
active--;
}
};
}
/** Short HTTP requests keep the deployment proxy out of the provider wait. */
export function studioAI(
apiKey?: string,
transport: typeof fetch = fetch,
model = APIMART_MODEL,
) {
const run = synchronousStudioAI(apiKey, transport, model);
type Job = {
status: "pending" | "complete" | "failed" | "cancelled";
controller: AbortController;
expires: number;
result?: unknown;
error?: string;
};
const jobs = new Map<string, Job>();
const json = (value: unknown, status = 200) =>
Response.json(value, { status, headers: { "cache-control": "no-store" } });
return async (request: Request): Promise<Response | undefined> => {
const url = new URL(request.url);
const match =
/^\/design-studio\/api\/generate\/jobs\/([a-f0-9-]{36})$/.exec(
url.pathname,
);
const enqueue =
url.pathname === "/design-studio/api/generate" &&
request.method === "POST" &&
request.headers.get("prefer")?.includes("respond-async");
if (!apiKey || (!match && !enqueue)) return run(request);
const scheme = request.headers.get("x-forwarded-proto");
if (scheme === "http" || scheme === "https") url.protocol = scheme + ":";
if (
request.headers.get("origin") &&
request.headers.get("origin") !== url.origin
)
return json({ error: "Same-origin requests only." }, 403);
for (const [id, job] of jobs)
if (job.expires < Date.now()) {
job.controller.abort();
jobs.delete(id);
}
if (match) {
if (!["GET", "DELETE"].includes(request.method))
return json({ error: "Method not allowed." }, 405);
const job = jobs.get(match[1]!);
if (!job)
return json(
{
error:
"This generation job expired or the server restarted. Your draft is preserved; generate again.",
},
410,
);
if (request.method === "DELETE" && job.status === "pending") {
job.status = "cancelled";
job.error = "Generation cancelled. Your draft is unchanged.";
job.controller.abort();
}
return json({
status: job.status,
...(job.result ? { result: job.result } : {}),
...(job.error ? { error: job.error } : {}),
});
}
if (
!(request.headers.get("content-type") ?? "").startsWith(
"application/json",
)
)
return json({ error: "JSON required." }, 415);
if (
[...jobs.values()].filter((job) => job.status === "pending").length >= 2
)
return json({ error: "AI generation is busy. Try again shortly." }, 429);
let body: string;
try {
body = await readBounded(request, 2_000_000);
JSON.parse(body);
} catch {
return json({ error: "Invalid or oversized generation request." }, 400);
}
// Recheck after reading the body so concurrent uploads cannot bypass the limit.
if (
[...jobs.values()].filter((job) => job.status === "pending").length >= 2
)
return json({ error: "AI generation is busy. Try again shortly." }, 429);
if (jobs.size >= 20) {
const old = [...jobs].find(([, job]) => job.status !== "pending");
if (old) jobs.delete(old[0]);
}
const id = crypto.randomUUID();
const job: Job = {
status: "pending",
controller: new AbortController(),
expires: Date.now() + 5 * 60_000,
};
jobs.set(id, job);
const headers = new Headers(request.headers);
headers.delete("prefer");
const task = new Request(request.url, {
method: "POST",
headers,
body,
signal: job.controller.signal,
});
void run(task)
.then(async (response) => {
if (job.status !== "pending") return;
if (!response) throw Error("Generation is not configured.");
const result = await response.json();
if (response.ok) {
job.status = "complete";
job.result = result;
} else {
job.status = "failed";
job.error =
typeof result.error === "string"
? result.error
: "Generation did not complete.";
}
})
.catch(() => {
if (job.status === "pending") {
job.status = "failed";
job.error =
"Generation could not complete. Your draft is preserved; try again.";
}
});
return json({ job: { id, status: "pending" } }, 202);
};
}