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
+197
-39
@@ -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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user