fix(studio): keep AI provider and model details private
This commit is contained in:
1 parent
5aec369054
commit
6d0c47fba7
5 files changed
+65
-51
No files matched your search
+16
-16
@@ -18,23 +18,21 @@ type FailureKind =
|
|||||||
| "response"
|
| "response"
|
||||||
| "request";
|
| "request";
|
||||||
const failureMessages: Record<FailureKind, string> = {
|
const failureMessages: Record<FailureKind, string> = {
|
||||||
auth: "APIMart rejected the server API key. Check APIMART_API_KEY and its permissions, then restart the app.",
|
auth: "AI generation is unavailable due to a server configuration issue. Contact the site administrator.",
|
||||||
quota:
|
quota:
|
||||||
"APIMart reports insufficient credit or quota. Check the provider account balance and limits.",
|
"AI generation has reached its service usage limit. Try again later or contact the site administrator.",
|
||||||
model:
|
model:
|
||||||
"APIMart could not access the configured model. Check that this model is enabled for the server API key.",
|
"AI generation is unavailable due to a server configuration issue. Contact the site administrator.",
|
||||||
rate: "APIMart is rate limiting requests. Wait a minute and generate again.",
|
rate: "AI generation is rate limiting requests. Wait a minute and generate again.",
|
||||||
unavailable:
|
unavailable: "AI generation is temporarily unavailable. Try again shortly.",
|
||||||
"APIMart is temporarily unavailable. Try generating again shortly.",
|
network: "The server could not reach the AI service. Try again shortly.",
|
||||||
network:
|
|
||||||
"The server could not reach APIMart. Check the VM's outbound HTTPS connection and try again.",
|
|
||||||
truncated:
|
truncated:
|
||||||
"APIMart stopped before completing all design alternatives. Try a shorter brief or generate again.",
|
"AI generation stopped before completing all design alternatives. Try a shorter brief or generate again.",
|
||||||
filtered: "APIMart declined this generation. Revise the brief and try again.",
|
filtered:
|
||||||
response:
|
"This generation could not be completed. Revise the brief and try again.",
|
||||||
"APIMart returned an empty or unreadable completion. Try generating again.",
|
response: "AI generation returned an empty or unreadable result. Try again.",
|
||||||
request:
|
request:
|
||||||
"APIMart rejected the completion request. Check the provider's model configuration.",
|
"AI generation could not process this request. Try again or contact the site administrator.",
|
||||||
};
|
};
|
||||||
class APIMartFailure extends Error {
|
class APIMartFailure extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -202,8 +200,7 @@ function synchronousStudioAI(
|
|||||||
return json({ error: "Same-origin requests only." }, 403);
|
return json({ error: "Same-origin requests only." }, 403);
|
||||||
if (url.pathname.endsWith("/generation") && request.method === "GET")
|
if (url.pathname.endsWith("/generation") && request.method === "GET")
|
||||||
return json({
|
return json({
|
||||||
provider: "APIMart",
|
provider: "AI",
|
||||||
model,
|
|
||||||
templateVersion: 1,
|
templateVersion: 1,
|
||||||
delivery: "polling",
|
delivery: "polling",
|
||||||
maxAttempts: 2,
|
maxAttempts: 2,
|
||||||
@@ -291,7 +288,7 @@ function synchronousStudioAI(
|
|||||||
code: "WRN-STUDIO-PROPOSAL",
|
code: "WRN-STUDIO-PROPOSAL",
|
||||||
path: "$",
|
path: "$",
|
||||||
message:
|
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.",
|
"AI generation returned a design that did not meet the schema after both attempts were used. Generate again. Your draft and previous alternatives are unchanged.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -317,6 +314,9 @@ function synchronousStudioAI(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// Public responses retain useful provenance without exposing infrastructure choices.
|
||||||
|
result.provenance.provider = "AI";
|
||||||
|
delete result.provenance.model;
|
||||||
return json(result);
|
return json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
+2
-13
@@ -18,19 +18,8 @@ export const studioHelpScript = `(() => {
|
|||||||
});
|
});
|
||||||
})();`;
|
})();`;
|
||||||
|
|
||||||
export function addStudioGuide(
|
export function addStudioGuide(html: string, configured: boolean) {
|
||||||
html: string,
|
const guide = `<details open class="help"><summary><strong>How to use Design Studio</strong></summary><ol><li><strong>Build manually.</strong> Open Manual design to choose colors, fonts, corner radii, spacing and shadows. Switch light/dark modes, select Update preview to save settings, then export. No AI is required.</li><li><strong>Describe your website for AI.</strong> Open Guided design, enter your product and audience, then choose colors, shapes and spacing with Next.</li><li><strong>Generate and compare.</strong> Click Generate three directions. The first valid alternative appears in the large preview. Review another to compare, then choose Use reviewed proposal to save it. Show current draft returns without accepting.</li><li><strong>Refine it.</strong> Try “make buttons square and use blue as the primary color” under Edit with words. Lock decisions you want to keep.</li><li><strong>Use it in your app.</strong> Open Review & apply and export Theme config or Compiled CSS. This website previews and exports designs; it does not change your deployed app automatically.</li></ol><p>${configured ? "AI design assistance is available. Your brief and current design are sent for processing only when you request generation." : "AI design assistance is currently unavailable. You can still use Manual design, presets and supported rule-based edits."}</p></details>`;
|
||||||
configured: boolean,
|
|
||||||
model = "gpt-5-mini",
|
|
||||||
) {
|
|
||||||
const safeModel = model.replace(
|
|
||||||
/[&<>"']/g,
|
|
||||||
(char) =>
|
|
||||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
||||||
char
|
|
||||||
]!,
|
|
||||||
);
|
|
||||||
const guide = `<details open class="help"><summary><strong>How to use Design Studio</strong></summary><ol><li><strong>Build manually.</strong> Open Manual design to choose colors, fonts, corner radii, spacing and shadows. Switch light/dark modes, select Update preview to save settings, then export. No AI is required.</li><li><strong>Describe your website for AI.</strong> Open Guided design, enter your product and audience, then choose colors, shapes and spacing with Next.</li><li><strong>Generate and compare.</strong> Click Generate three directions. The first valid alternative appears in the large preview. Review another to compare, then choose Use reviewed proposal to save it. Show current draft returns without accepting.</li><li><strong>Refine it.</strong> Try “make buttons square and use blue as the primary color” under Edit with words. Lock decisions you want to keep.</li><li><strong>Use it in your app.</strong> Open Review & apply and export Theme config or Compiled CSS. This website previews and exports designs; it does not change your deployed app automatically.</li></ol><p>${configured ? `APIMart AI is enabled with ${safeModel}. Generation sends your brief and current design to APIMart only when you request it.` : "AI is not configured. Local presets and supported rule-based edits still work. Set APIMART_API_KEY and APIMART_MODEL in the server .env and restart to enable AI."}</p></details>`;
|
|
||||||
return html
|
return html
|
||||||
.replace(
|
.replace(
|
||||||
'<script type="module"',
|
'<script type="module"',
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ const designStudio: Middleware = async (ctx, next) => {
|
|||||||
addStudioGuide(
|
addStudioGuide(
|
||||||
await response.text(),
|
await response.text(),
|
||||||
!!process.env.APIMART_API_KEY?.trim(),
|
!!process.env.APIMART_API_KEY?.trim(),
|
||||||
process.env.APIMART_MODEL?.trim() || APIMART_MODEL,
|
|
||||||
),
|
),
|
||||||
{ status: response.status, headers },
|
{ status: response.status, headers },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ try {
|
|||||||
!(await response.clone().text()).includes("How to use Design Studio")
|
!(await response.clone().text()).includes("How to use Design Studio")
|
||||||
)
|
)
|
||||||
throw Error("Studio's usage guide was not included in the compiled app.");
|
throw Error("Studio's usage guide was not included in the compiled app.");
|
||||||
|
if (
|
||||||
|
path.startsWith("/design-studio/") &&
|
||||||
|
/apimart|gpt-5-mini|gpt-6-luna/i.test(await response.clone().text())
|
||||||
|
)
|
||||||
|
throw Error("Public Studio content exposed private AI configuration.");
|
||||||
const policy = response.headers.get("content-security-policy") ?? "";
|
const policy = response.headers.get("content-security-policy") ?? "";
|
||||||
if (path === "/" && !policy.includes("require-trusted-types-for"))
|
if (path === "/" && !policy.includes("require-trusted-types-for"))
|
||||||
throw Error(
|
throw Error(
|
||||||
@@ -103,9 +108,8 @@ try {
|
|||||||
).json();
|
).json();
|
||||||
if (
|
if (
|
||||||
process.env.APIMART_API_KEY?.trim() &&
|
process.env.APIMART_API_KEY?.trim() &&
|
||||||
(generation.provider !== "APIMart" ||
|
(generation.provider !== "AI" ||
|
||||||
generation.model !==
|
generation.model !== undefined ||
|
||||||
(process.env.APIMART_MODEL?.trim() || "gpt-5-mini") ||
|
|
||||||
generation.delivery !== "polling" ||
|
generation.delivery !== "polling" ||
|
||||||
generation.maxAttempts !== 2 ||
|
generation.maxAttempts !== 2 ||
|
||||||
generation.maxOutputTokens !== 8192 ||
|
generation.maxOutputTokens !== 8192 ||
|
||||||
|
|||||||
+40
-18
@@ -86,8 +86,9 @@ test("AI proposals use engine validation, preserve draft source, locks and revis
|
|||||||
const response = await handler(await request());
|
const response = await handler(await request());
|
||||||
const batch = await response!.json();
|
const batch = await response!.json();
|
||||||
expect(batch.alternatives).toHaveLength(3);
|
expect(batch.alternatives).toHaveLength(3);
|
||||||
expect(batch.provenance.provider).toBe("APIMart");
|
expect(batch.provenance.provider).toBe("AI");
|
||||||
expect(batch.provenance.model).toBe("gpt-5-mini");
|
expect(batch.provenance.model).toBeUndefined();
|
||||||
|
expect(JSON.stringify(batch)).not.toMatch(/APIMart|gpt-5-mini|custom-model/i);
|
||||||
expect(JSON.stringify(system)).toBe(original);
|
expect(JSON.stringify(system)).toBe(original);
|
||||||
const locked = await handler(
|
const locked = await handler(
|
||||||
await request(await input(["/tokens/shape.control"])),
|
await request(await input(["/tokens/shape.control"])),
|
||||||
@@ -114,7 +115,7 @@ test("metadata and errors never contain the key or raw upstream response", async
|
|||||||
new Request("https://components.example/design-studio/api/generation"),
|
new Request("https://components.example/design-studio/api/generation"),
|
||||||
);
|
);
|
||||||
const text = await meta!.text();
|
const text = await meta!.text();
|
||||||
expect(text).toContain("gpt-5-mini");
|
expect(text).not.toMatch(/apimart|gpt-5-mini/i);
|
||||||
expect(JSON.parse(text).timeoutMs).toBe(AI_TIMEOUT_MS);
|
expect(JSON.parse(text).timeoutMs).toBe(AI_TIMEOUT_MS);
|
||||||
expect(AI_TIMEOUT_MS).toBe(60_000);
|
expect(AI_TIMEOUT_MS).toBe(60_000);
|
||||||
expect(text).not.toContain("test-secret");
|
expect(text).not.toContain("test-secret");
|
||||||
@@ -122,6 +123,7 @@ test("metadata and errors never contain the key or raw upstream response", async
|
|||||||
const body = await failure!.text();
|
const body = await failure!.text();
|
||||||
expect(body).not.toContain("test-secret");
|
expect(body).not.toContain("test-secret");
|
||||||
expect(body).not.toContain("upstream credentials");
|
expect(body).not.toContain("upstream credentials");
|
||||||
|
expect(body).not.toMatch(/APIMart|APIMART_API_KEY|gpt-5-mini/i);
|
||||||
expect(body).toContain("WRN-STUDIO-PROVIDER");
|
expect(body).toContain("WRN-STUDIO-PROVIDER");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,7 +141,7 @@ test("a completion beyond the old 20-second deadline produces three validated al
|
|||||||
expect(response!.status).toBe(200);
|
expect(response!.status).toBe(200);
|
||||||
const batch = await response!.json();
|
const batch = await response!.json();
|
||||||
expect(batch.alternatives).toHaveLength(3);
|
expect(batch.alternatives).toHaveLength(3);
|
||||||
expect(batch.provenance.provider).toBe("APIMart");
|
expect(batch.provenance.provider).toBe("AI");
|
||||||
expect(JSON.stringify(system)).toBe(original);
|
expect(JSON.stringify(system)).toBe(original);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
@@ -186,9 +188,9 @@ test("same-origin checks, body bounds and local fallback remain enforced", async
|
|||||||
'<head><script type="module"></script></head><div id="diagnostics"></div>',
|
'<head><script type="module"></script></head><div id="diagnostics"></div>',
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
).toContain("APIMART_API_KEY");
|
).not.toMatch(/APIMART|gpt-/i);
|
||||||
expect(addStudioGuide('<div id="diagnostics"></div>', true)).toContain(
|
expect(addStudioGuide('<div id="diagnostics"></div>', true)).toContain(
|
||||||
"APIMart AI is enabled",
|
"AI design assistance is available",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,9 +227,9 @@ test("temporary APIMart failures retry once and return three validated designs",
|
|||||||
|
|
||||||
test("authentication and quota failures provide actionable redacted diagnostics without retrying", async () => {
|
test("authentication and quota failures provide actionable redacted diagnostics without retrying", async () => {
|
||||||
for (const [status, code, hint] of [
|
for (const [status, code, hint] of [
|
||||||
[401, "invalid_api_key", "server API key"],
|
[401, "invalid_api_key", "server configuration issue"],
|
||||||
[402, "insufficient_quota", "credit or quota"],
|
[402, "insufficient_quota", "service usage limit"],
|
||||||
[404, "model_not_found", "configured model"],
|
[404, "model_not_found", "server configuration issue"],
|
||||||
] as const) {
|
] as const) {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const handler = studioAI(
|
const handler = studioAI(
|
||||||
@@ -296,7 +298,7 @@ test("network and repeated rate failures remain bounded and describe the actual
|
|||||||
const body = await (await handler(await request()))!.text();
|
const body = await (await handler(await request()))!.text();
|
||||||
expect(calls).toBe(2);
|
expect(calls).toBe(2);
|
||||||
expect(body).toContain(
|
expect(body).toContain(
|
||||||
network ? "could not reach APIMart" : "rate limiting",
|
network ? "could not reach the AI service" : "rate limiting",
|
||||||
);
|
);
|
||||||
expect(body).not.toContain("test-secret");
|
expect(body).not.toContain("test-secret");
|
||||||
}
|
}
|
||||||
@@ -421,14 +423,34 @@ test("the configured model is used consistently for metadata and upstream reques
|
|||||||
const metadata = await (await handler(
|
const metadata = await (await handler(
|
||||||
new Request("https://components.example/design-studio/api/generation"),
|
new Request("https://components.example/design-studio/api/generation"),
|
||||||
))!.json();
|
))!.json();
|
||||||
expect(metadata.model).toBe("custom-model");
|
expect(metadata.model).toBeUndefined();
|
||||||
const batch = await (await handler(await request()))!.json();
|
const batch = await (await handler(await request()))!.json();
|
||||||
expect(selected).toBe("custom-model");
|
expect(selected).toBe("custom-model");
|
||||||
expect(batch.provenance.model).toBe("custom-model");
|
expect(batch.provenance.model).toBeUndefined();
|
||||||
expect(
|
expect(JSON.stringify(batch)).not.toMatch(/APIMart|gpt-5-mini|custom-model/i);
|
||||||
addStudioGuide('<div id="diagnostics"></div>', true, "custom-model"),
|
expect(addStudioGuide('<div id="diagnostics"></div>', true)).not.toMatch(
|
||||||
).toContain("custom-model");
|
/APIMart|custom-model|gpt-/i,
|
||||||
expect(
|
);
|
||||||
addStudioGuide('<div id="diagnostics"></div>', true, "<script>"),
|
});
|
||||||
).not.toContain("with <script>");
|
|
||||||
|
test("all public AI failure categories hide provider and model configuration", async () => {
|
||||||
|
for (const status of [400, 401, 402, 403, 404, 429, 500]) {
|
||||||
|
const handler = studioAI(
|
||||||
|
"test-secret",
|
||||||
|
mockFetch(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: { message: "APIMart custom-secret-model" },
|
||||||
|
}),
|
||||||
|
{ status },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
"custom-secret-model",
|
||||||
|
);
|
||||||
|
const response = await handler(await request());
|
||||||
|
expect(await response!.text()).not.toMatch(
|
||||||
|
/APIMart|custom-secret-model|test-secret/i,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user