fix(studio): recover transient AI failures and report provider diagnostics
This commit is contained in:
1 parent
98b3c5ff2f
commit
e8c0f06d8f
4 files changed
+320
-41
No files matched your search
@@ -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
|
to APIMart when you request generation. The engine validates proposals and locks
|
||||||
before offering them for review. Failed requests leave the draft unchanged.
|
before offering them for review. Failed requests leave the draft unchanged.
|
||||||
Requests allow two concurrent generations and ten starts per minute per server
|
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;
|
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
|
local rules do not interpret arbitrary product descriptions. Drafts remain in
|
||||||
browser storage; export before clearing browser data or moving to another origin.
|
browser storage; export before clearing browser data or moving to another origin.
|
||||||
|
|||||||
+197
-39
@@ -5,6 +5,68 @@ import type {
|
|||||||
|
|
||||||
export const APIMART_MODEL = "gpt-6-luna";
|
export const APIMART_MODEL = "gpt-6-luna";
|
||||||
export const AI_TIMEOUT_MS = 60_000;
|
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(
|
export function apimartProvider(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
transport: typeof fetch = fetch,
|
transport: typeof fetch = fetch,
|
||||||
@@ -12,44 +74,66 @@ export function apimartProvider(
|
|||||||
return {
|
return {
|
||||||
name: "APIMart",
|
name: "APIMart",
|
||||||
async generate(prompt, options = {}) {
|
async generate(prompt, options = {}) {
|
||||||
const response = await transport(
|
let response: Response;
|
||||||
"https://api.apimart.ai/v1/chat/completions",
|
try {
|
||||||
{
|
response = await transport(
|
||||||
method: "POST",
|
"https://api.apimart.ai/v1/chat/completions",
|
||||||
signal: options.signal,
|
{
|
||||||
redirect: "error",
|
method: "POST",
|
||||||
headers: {
|
signal: options.signal,
|
||||||
authorization: `Bearer ${apiKey}`,
|
redirect: "error",
|
||||||
"content-type": "application/json",
|
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,
|
} catch {
|
||||||
stream: false,
|
options.signal?.throwIfAborted();
|
||||||
max_tokens: options.maxTokens ?? 4096,
|
throw new APIMartFailure("network");
|
||||||
messages: [
|
}
|
||||||
{
|
|
||||||
role: "system",
|
|
||||||
content:
|
|
||||||
options.system ?? "Return valid design proposal JSON only.",
|
|
||||||
},
|
|
||||||
{ role: "user", content: prompt },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Never forward upstream bodies: they may echo credentials or submitted context.
|
// Never forward upstream bodies: they may echo credentials or submitted context.
|
||||||
if (!response.ok)
|
if (!response.ok) {
|
||||||
throw Error(`APIMart request failed (${response.status}).`);
|
// Read a bounded envelope only to recognize documented machine codes.
|
||||||
const raw = await readBounded(response, 200_000);
|
let code: unknown;
|
||||||
const result = JSON.parse(raw);
|
try {
|
||||||
const choice = result.choices?.[0];
|
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")
|
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 (
|
if (
|
||||||
typeof choice?.message?.content !== "string" ||
|
typeof choice?.message?.content !== "string" ||
|
||||||
!choice.message.content.trim()
|
!choice.message.content.trim()
|
||||||
)
|
)
|
||||||
throw Error("APIMart returned no design proposal.");
|
throw new APIMartFailure("response");
|
||||||
return {
|
return {
|
||||||
value: choice.message.content,
|
value: choice.message.content,
|
||||||
provider: "APIMart",
|
provider: "APIMart",
|
||||||
@@ -117,7 +201,7 @@ export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
|
|||||||
model: APIMART_MODEL,
|
model: APIMART_MODEL,
|
||||||
templateVersion: 1,
|
templateVersion: 1,
|
||||||
maxAttempts: 2,
|
maxAttempts: 2,
|
||||||
maxOutputTokens: 4096,
|
maxOutputTokens: AI_OUTPUT_TOKENS,
|
||||||
timeoutMs: AI_TIMEOUT_MS,
|
timeoutMs: AI_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
if (!url.pathname.endsWith("/generate") || request.method !== "POST")
|
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(
|
const { generateDesigns } = (await import(
|
||||||
module
|
module
|
||||||
)) as typeof import("@wrnexus/design-studio");
|
)) as typeof import("@wrnexus/design-studio");
|
||||||
const result = await generateDesigns(input, {
|
let calls = 0;
|
||||||
provider: apimartProvider(apiKey, transport),
|
let exhausted = false;
|
||||||
model: APIMART_MODEL,
|
let failure: APIMartFailure | undefined;
|
||||||
signal: request.signal,
|
const upstream = apimartProvider(apiKey, transport);
|
||||||
timeoutMs: AI_TIMEOUT_MS,
|
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);
|
return json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
@@ -169,7 +326,8 @@ export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
|
|||||||
return json({ error: "Request is too large." }, 413);
|
return json({ error: "Request is too large." }, 413);
|
||||||
if (
|
if (
|
||||||
request.signal.aborted ||
|
request.signal.aborted ||
|
||||||
(error instanceof Error && error.name === "AbortError")
|
(error instanceof Error &&
|
||||||
|
["AbortError", "TimeoutError"].includes(error.name))
|
||||||
)
|
)
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ try {
|
|||||||
process.env.APIMART_API_KEY?.trim() &&
|
process.env.APIMART_API_KEY?.trim() &&
|
||||||
(generation.provider !== "APIMart" ||
|
(generation.provider !== "APIMart" ||
|
||||||
generation.model !== "gpt-6-luna" ||
|
generation.model !== "gpt-6-luna" ||
|
||||||
|
generation.maxAttempts !== 2 ||
|
||||||
|
generation.maxOutputTokens !== 8192 ||
|
||||||
generation.timeoutMs !== 60_000)
|
generation.timeoutMs !== 60_000)
|
||||||
)
|
)
|
||||||
throw Error(
|
throw Error(
|
||||||
|
|||||||
+120
-1
@@ -3,6 +3,7 @@ import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
|
|||||||
import { designRevision, DEFAULT_DESIGN_BRIEF } from "@wrnexus/design-studio";
|
import { designRevision, DEFAULT_DESIGN_BRIEF } from "@wrnexus/design-studio";
|
||||||
import {
|
import {
|
||||||
AI_TIMEOUT_MS,
|
AI_TIMEOUT_MS,
|
||||||
|
AI_OUTPUT_TOKENS,
|
||||||
apimartProvider,
|
apimartProvider,
|
||||||
studioAI,
|
studioAI,
|
||||||
} from "../app/lib/studio-ai.ts";
|
} 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);
|
const body = JSON.parse(init?.body as string);
|
||||||
expect(body.model).toBe("gpt-6-luna");
|
expect(body.model).toBe("gpt-6-luna");
|
||||||
expect(body.stream).toBe(false);
|
expect(body.stream).toBe(false);
|
||||||
expect(body.max_tokens).toBe(4096);
|
expect(body.max_tokens).toBe(AI_OUTPUT_TOKENS);
|
||||||
expect(body.messages).toEqual([
|
expect(body.messages).toEqual([
|
||||||
{ role: "system", content: "rules" },
|
{ role: "system", content: "rules" },
|
||||||
{ role: "user", content: "brief" },
|
{ 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(200);
|
||||||
expect((await handler(await request()))!.status).toBe(429);
|
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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user