fix(studio): recover transient AI failures and report provider diagnostics

This commit is contained in:
Clintchiz committed 2026-10-19 19:42:55 +05:30
1 parent 98b3c5ff2f
commit e8c0f06d8f
4 files changed
+320 -41

No files matched your search

+1 -1
View File
@@ -141,7 +141,7 @@ AI generation sends the current design, brief and allowed component capabilities
to APIMart when you request generation. The engine validates proposals and locks
before offering them for review. Failed requests leave the draft unchanged.
Requests allow two concurrent generations and ten starts per minute per server
process, with two attempts, 4096 output tokens per attempt and a 60-second total deadline.
process, with two total upstream requests (including repairs and temporary-failure retries), 8192 output tokens per attempt and a 60-second total deadline.
Without APIMART_API_KEY, generation uses local rules and reports that in the UI;
local rules do not interpret arbitrary product descriptions. Drafts remain in
browser storage; export before clearing browser data or moving to another origin.
+197 -39
View File
@@ -5,6 +5,68 @@ import type {
export const APIMART_MODEL = "gpt-6-luna";
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 gpt-6-luna. 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,
@@ -12,44 +74,66 @@ export function apimartProvider(
return {
name: "APIMart",
async generate(prompt, options = {}) {
const 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",
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: APIMART_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 },
],
}),
},
body: JSON.stringify({
model: APIMART_MODEL,
stream: false,
max_tokens: options.maxTokens ?? 4096,
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)
throw Error(`APIMart request failed (${response.status}).`);
const raw = await readBounded(response, 200_000);
const result = JSON.parse(raw);
const choice = result.choices?.[0];
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 Error("APIMart output was truncated.");
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 Error("APIMart returned no design proposal.");
throw new APIMartFailure("response");
return {
value: choice.message.content,
provider: "APIMart",
@@ -117,7 +201,7 @@ export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
model: APIMART_MODEL,
templateVersion: 1,
maxAttempts: 2,
maxOutputTokens: 4096,
maxOutputTokens: AI_OUTPUT_TOKENS,
timeoutMs: AI_TIMEOUT_MS,
});
if (!url.pathname.endsWith("/generate") || request.method !== "POST")
@@ -148,12 +232,85 @@ export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
const { generateDesigns } = (await import(
module
)) as typeof import("@wrnexus/design-studio");
const result = await generateDesigns(input, {
provider: apimartProvider(apiKey, transport),
model: APIMART_MODEL,
signal: request.signal,
timeoutMs: AI_TIMEOUT_MS,
});
let calls = 0;
let exhausted = false;
let failure: APIMartFailure | undefined;
const upstream = apimartProvider(apiKey, transport);
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: APIMART_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 =
@@ -169,7 +326,8 @@ export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
return json({ error: "Request is too large." }, 413);
if (
request.signal.aborted ||
(error instanceof Error && error.name === "AbortError")
(error instanceof Error &&
["AbortError", "TimeoutError"].includes(error.name))
)
return json(
{
+2
View File
@@ -97,6 +97,8 @@ try {
process.env.APIMART_API_KEY?.trim() &&
(generation.provider !== "APIMart" ||
generation.model !== "gpt-6-luna" ||
generation.maxAttempts !== 2 ||
generation.maxOutputTokens !== 8192 ||
generation.timeoutMs !== 60_000)
)
throw Error(
+120 -1
View File
@@ -3,6 +3,7 @@ import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
import { designRevision, DEFAULT_DESIGN_BRIEF } from "@wrnexus/design-studio";
import {
AI_TIMEOUT_MS,
AI_OUTPUT_TOKENS,
apimartProvider,
studioAI,
} from "../app/lib/studio-ai.ts";
@@ -57,7 +58,7 @@ test("APIMart uses the specified model, bearer authentication, non-streaming JSO
const body = JSON.parse(init?.body as string);
expect(body.model).toBe("gpt-6-luna");
expect(body.stream).toBe(false);
expect(body.max_tokens).toBe(4096);
expect(body.max_tokens).toBe(AI_OUTPUT_TOKENS);
expect(body.messages).toEqual([
{ role: "system", content: "rules" },
{ role: "user", content: "brief" },
@@ -200,3 +201,121 @@ test("a public AI endpoint enforces a bounded generation rate", async () => {
expect((await handler(await request()))!.status).toBe(200);
expect((await handler(await request()))!.status).toBe(429);
});
test("temporary APIMart failures retry once and return three validated designs", async () => {
let calls = 0;
const handler = studioAI(
"test-secret",
mockFetch(async (_url, init) => {
calls++;
expect(JSON.parse(init!.body as string).max_tokens).toBe(8192);
return calls === 1
? new Response("sensitive upstream context test-secret", {
status: 503,
})
: completion();
}),
);
const batch = await (await handler(await request()))!.json();
expect(calls).toBe(2);
expect(batch.alternatives).toHaveLength(3);
expect(batch.provenance.attempts).toBe(2);
expect(batch.provenance.maxOutputTokens).toBe(8192);
});
test("authentication and quota failures provide actionable redacted diagnostics without retrying", async () => {
for (const [status, code, hint] of [
[401, "invalid_api_key", "server API key"],
[402, "insufficient_quota", "credit or quota"],
[404, "model_not_found", "gpt-6-luna"],
] as const) {
let calls = 0;
const handler = studioAI(
"test-secret",
mockFetch(async () => {
calls++;
return Response.json(
{ error: { code, message: "test-secret private brief" } },
{ status },
);
}),
);
const body = await (await handler(await request()))!.text();
expect(calls).toBe(1);
expect(body).toContain(hint);
expect(body).not.toContain("test-secret");
expect(body).not.toContain("private brief");
}
});
test("truncated and empty completions retry but never exceed two total upstream calls", async () => {
for (const kind of ["length", "empty", "invalid-then-outage"]) {
let calls = 0;
const handler = studioAI(
"test-secret",
mockFetch(async () => {
calls++;
if (kind === "invalid-then-outage")
return calls === 1
? Response.json({
choices: [{ message: { content: "invalid JSON" } }],
})
: new Response("private upstream body", { status: 502 });
return calls === 1
? Response.json({
choices: [
{
finish_reason: kind === "length" ? "length" : "stop",
message: { content: "" },
},
],
})
: completion();
}),
);
const batch = await (await handler(await request()))!.json();
expect(calls).toBe(2);
expect(batch.provenance.attempts).toBe(2);
expect(batch.alternatives).toHaveLength(
kind === "invalid-then-outage" ? 0 : 3,
);
}
});
test("network and repeated rate failures remain bounded and describe the actual failure category", async () => {
for (const network of [true, false]) {
let calls = 0;
const handler = studioAI(
"test-secret",
mockFetch(async () => {
calls++;
if (network) throw Error("private test-secret TLS details");
return new Response("private test-secret", { status: 429 });
}),
);
const body = await (await handler(await request()))!.text();
expect(calls).toBe(2);
expect(body).toContain(
network ? "could not reach APIMart" : "rate limiting",
);
expect(body).not.toContain("test-secret");
}
});
test("a retry followed by an invalid design does not spend a third request or mislabel a schema error", async () => {
let calls = 0;
const handler = studioAI(
"test-secret",
mockFetch(async () => {
calls++;
return calls === 1
? new Response("unavailable", { status: 503 })
: Response.json({ choices: [{ message: { content: "not JSON" } }] });
}),
);
const batch = await (await handler(await request()))!.json();
expect(calls).toBe(2);
expect(batch.alternatives).toHaveLength(0);
expect(batch.diagnostics[0].code).toBe("WRN-STUDIO-PROPOSAL");
expect(batch.diagnostics[0].message).toContain("schema");
});