feat: add APIMart AI generation and guided Studio onboarding
This commit is contained in:
1 parent
9a6a2e0a8d
commit
60467a4c05
9 files changed
+455
-2
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Server only. Never put this key in browser scripts or commit your .env file.
|
||||
APIMART_API_KEY=
|
||||
# Design Studio uses the gpt-6-luna model at https://api.apimart.ai/v1/chat/completions.
|
||||
@@ -106,3 +106,46 @@ A refreshed package-lock.json is retained for npm tooling, but Bun runs the app.
|
||||
After deployment, check `/`, a component playground and `/design-studio/`.
|
||||
Create a draft, generate alternatives, open a preview, export JSON, then reload
|
||||
and confirm draft recovery. Existing Studio drafts on other origins remain there.
|
||||
|
||||
## APIMart AI generation
|
||||
|
||||
Create a `.env` file in the application root on the VM (copy `.env.example`):
|
||||
|
||||
```dotenv
|
||||
APIMART_API_KEY=your_apimart_key
|
||||
```
|
||||
|
||||
Restart the app after setting the key. No key goes in the browser or application
|
||||
configuration. The server calls https://api.apimart.ai/v1/chat/completions with
|
||||
model `gpt-6-luna`, bearer authentication, and `stream: false`, as described in
|
||||
https://docs.apimart.ai/en/api-reference/texts/general/chat-completions.
|
||||
Model access must be available on your APIMart account. No live provider call is
|
||||
part of the normal build or test suite.
|
||||
|
||||
Open `/design-studio/` and follow the in-page guide:
|
||||
|
||||
1. In Guided design, enter what you are building and who will use it. Use Next
|
||||
to choose tone, geometry, spacing, surfaces and optional brand references.
|
||||
2. Generate three directions. Review a direction, inspect its changes, then
|
||||
select Use reviewed proposal to save it as a recoverable draft revision.
|
||||
3. Under Edit with words, request a change, for example “Make the buttons square,
|
||||
use blue as the primary color, and reduce shadows.” Lock colors, geometry or
|
||||
other decisions to keep them unchanged.
|
||||
4. Preview the design at different sizes and in light/dark mode. Use Components
|
||||
for individual recipes and Foundations for exact token values.
|
||||
5. Open Review & apply and export Theme config or Compiled CSS. Integrate one
|
||||
of those outputs into your application's theme/style configuration. This
|
||||
hosted Studio does not write to deployed application files.
|
||||
|
||||
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 20-second 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.
|
||||
|
||||
The complete check includes mocked provider contract/error/lock tests and a
|
||||
compiled-server deployment check. A real APIMart completion requires your key
|
||||
and model access; it is not claimed by the mocked tests.
|
||||
@@ -0,0 +1,190 @@
|
||||
import type {
|
||||
GenerationProvider,
|
||||
GenerationRequest,
|
||||
} from "@wrnexus/design-studio";
|
||||
|
||||
export const APIMART_MODEL = "gpt-6-luna";
|
||||
export function apimartProvider(
|
||||
apiKey: string,
|
||||
transport: typeof fetch = fetch,
|
||||
): GenerationProvider {
|
||||
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",
|
||||
},
|
||||
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 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
// 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 (choice?.finish_reason === "length")
|
||||
throw Error("APIMart output was truncated.");
|
||||
if (
|
||||
typeof choice?.message?.content !== "string" ||
|
||||
!choice.message.content.trim()
|
||||
)
|
||||
throw Error("APIMart returned no design proposal.");
|
||||
return {
|
||||
value: choice.message.content,
|
||||
provider: "APIMart",
|
||||
model: 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();
|
||||
}
|
||||
}
|
||||
|
||||
export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
|
||||
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: APIMART_MODEL,
|
||||
templateVersion: 1,
|
||||
maxAttempts: 2,
|
||||
maxOutputTokens: 4096,
|
||||
timeoutMs: 20000,
|
||||
});
|
||||
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");
|
||||
const result = await generateDesigns(input, {
|
||||
provider: apimartProvider(apiKey, transport),
|
||||
model: APIMART_MODEL,
|
||||
signal: request.signal,
|
||||
});
|
||||
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 && error.name === "AbortError")
|
||||
)
|
||||
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--;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const studioHelpScript = `window.addEventListener("error",function(){var s=document.getElementById("validation-state"),d=document.getElementById("diagnostics");if(s&&s.textContent==="Checking"){s.textContent="Editor could not start";if(d)d.textContent="Reload the page. If this continues, rebuild and restart the site with the latest Studio configuration. Your saved draft has not been deleted.";}});document.addEventListener("DOMContentLoaded",function(){var b=document.querySelector('[data-tab="guided"]');if(b)b.click();});`;
|
||||
|
||||
export function addStudioGuide(html: string, configured: boolean) {
|
||||
const guide = `<details open class="help"><summary><strong>How to use Design Studio</strong></summary><ol><li><strong>Describe your website.</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, review an alternative, then choose Use reviewed proposal.</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 gpt-6-luna. 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 in the server .env and restart to enable gpt-6-luna."}</p></details>`;
|
||||
return html
|
||||
.replace(
|
||||
'<script type="module"',
|
||||
'<script src="./site-help.js"></script><script type="module"',
|
||||
)
|
||||
.replace('<div id="diagnostics"', guide + '<div id="diagnostics"');
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Middleware } from "@wrnexus/core";
|
||||
import type { createStudioHandler } from "@wrnexus/design-studio/server";
|
||||
import { studioAI } from "../lib/studio-ai.ts";
|
||||
import { addStudioGuide, studioHelpScript } from "../lib/studio-guide.ts";
|
||||
|
||||
let studio: ReturnType<typeof createStudioHandler> | undefined;
|
||||
let ai: ReturnType<typeof studioAI> | undefined;
|
||||
|
||||
const designStudio: Middleware = async (ctx, next) => {
|
||||
const url = new URL(ctx.req.url);
|
||||
@@ -11,6 +14,19 @@ const designStudio: Middleware = async (ctx, next) => {
|
||||
headers: { location: "/design-studio/" },
|
||||
});
|
||||
if (!url.pathname.startsWith("/design-studio/")) return next();
|
||||
ai ??= studioAI(process.env.APIMART_API_KEY?.trim());
|
||||
const generated = await ai(ctx.req);
|
||||
if (generated) return generated;
|
||||
if (
|
||||
url.pathname === "/design-studio/site-help.js" &&
|
||||
ctx.req.method === "GET"
|
||||
)
|
||||
return new Response(studioHelpScript, {
|
||||
headers: {
|
||||
"content-type": "text/javascript",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
// Load the installed package at runtime so its assets keep their package-relative URLs.
|
||||
const studioModule = import.meta.resolve("@wrnexus/design-studio/server");
|
||||
studio ??= import(studioModule)
|
||||
@@ -23,7 +39,23 @@ const designStudio: Middleware = async (ctx, next) => {
|
||||
studio = undefined;
|
||||
throw error;
|
||||
});
|
||||
return (await studio)(ctx.req);
|
||||
const response = await (await studio)(ctx.req);
|
||||
if (
|
||||
url.pathname === "/design-studio/" &&
|
||||
response.ok &&
|
||||
ctx.req.method === "GET"
|
||||
) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete("content-length");
|
||||
return new Response(
|
||||
addStudioGuide(
|
||||
await response.text(),
|
||||
!!process.env.APIMART_API_KEY?.trim(),
|
||||
),
|
||||
{ status: response.status, headers },
|
||||
);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export default designStudio;
|
||||
Vendored
+1
-1
@@ -16,7 +16,7 @@ declare namespace WRNexusGenerated {
|
||||
type RouteName = "accessibility" | "advanced.forms" | "base" | "block.library" | "blocks" | "blocks.announcement.bar" | "blocks.ctasection" | "blocks.feature.card" | "blocks.feature.grid" | "blocks.feature.icon.card" | "blocks.footer" | "blocks.hero" | "blocks.hero.actions" | "blocks.marketing.section.header" | "blocks.metric.grid" | "blocks.page.header" | "blocks.section.header" | "blocks.split.hero" | "blocks.stats.bar" | "colors" | "components.accordion" | "components.advanced.select" | "components.alert" | "components.avatar" | "components.avatar.group" | "components.back.to.top" | "components.badge" | "components.blockquote" | "components.breadcrumb" | "components.button" | "components.button.group" | "components.card" | "components.carousel" | "components.chart" | "components.chat.bubble" | "components.checkbox" | "components.clipboard" | "components.collapse" | "components.color.picker" | "components.columns" | "components.combo.box" | "components.confetti" | "components.container" | "components.context.menu" | "components.custom.scrollbar" | "components.data.map" | "components.data.table" | "components.date.picker" | "components.device.frame" | "components.divider" | "components.drag.and.drop" | "components.drawer" | "components.drawflow.canvas" | "components.dropdown" | "components.file.input" | "components.file.upload.progress" | "components.flow.canvas" | "components.grid" | "components.image" | "components.input" | "components.input.group" | "components.input.number" | "components.kbd" | "components.layout.splitter" | "components.legend.indicator" | "components.link" | "components.list" | "components.list.group" | "components.map" | "components.marquee" | "components.mega.menu" | "components.metric.card" | "components.modal" | "components.nav" | "components.navbar" | "components.pagination" | "components.pin.input" | "components.popover" | "components.preference.switcher" | "components.progress" | "components.radio" | "components.range.slider" | "components.rating" | "components.scrollspy" | "components.search.box" | "components.section" | "components.select" | "components.sidebar" | "components.skeleton" | "components.spinner" | "components.stepper" | "components.strong.password" | "components.styled.icon" | "components.switch" | "components.tabs" | "components.text.link" | "components.textarea" | "components.time.picker" | "components.timeline" | "components.toaster" | "components.toggle.count" | "components.toggle.password" | "components.tooltip" | "components.tree.view" | "components.typography" | "components.wysiwyg.editor" | "core" | "dark.mode" | "data" | "docs" | "fonts" | "forms" | "framework.guides" | "index" | "installation" | "integrations" | "layout" | "marketing" | "navigation" | "overlays" | "preview" | "preview.custom.modal" | "preview.profile" | "preview.project.board" | "preview.scheduling" | "preview.sign.in" | "preview.workspace" | "resources" | "sizing" | "tables" | "templates" | "templates.commerce" | "templates.dashboards" | "templates.marketing" | "templates.productivity" | "themes" | "v06.functions" | "v06.imports" | "v06.migration" | "v06.outputs" | "v06.runtime" | "v06.state" | "v06.stores" | "v06.types";
|
||||
type ApiRoute = "/api/teams";
|
||||
type RealtimeRoute = never;
|
||||
type EnvironmentKey = never;
|
||||
type EnvironmentKey = "APIMART_API_KEY";
|
||||
type TranslationKey = never;
|
||||
type QueueName = never;
|
||||
type CacheKey = never;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -32,9 +32,15 @@ try {
|
||||
"/design-studio/",
|
||||
"/design-studio/studio.js",
|
||||
"/design-studio/studio.css",
|
||||
"/design-studio/site-help.js",
|
||||
]) {
|
||||
const response = await fetch(origin + path);
|
||||
if (!response.ok) throw Error(`${path} returned ${response.status}`);
|
||||
if (
|
||||
path === "/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.");
|
||||
const policy = response.headers.get("content-security-policy") ?? "";
|
||||
if (path === "/" && !policy.includes("require-trusted-types-for"))
|
||||
throw Error(
|
||||
@@ -50,6 +56,16 @@ try {
|
||||
const application = await (
|
||||
await fetch(origin + "/design-studio/api/application")
|
||||
).json();
|
||||
const generation = await (
|
||||
await fetch(origin + "/design-studio/api/generation")
|
||||
).json();
|
||||
if (
|
||||
process.env.APIMART_API_KEY?.trim() &&
|
||||
(generation.provider !== "APIMart" || generation.model !== "gpt-6-luna")
|
||||
)
|
||||
throw Error(
|
||||
"The compiled app did not pick up its server-side AI configuration.",
|
||||
);
|
||||
if (application.enabled !== false)
|
||||
throw Error("Hosted Studio must remain export-only.");
|
||||
const preview = await fetch(origin + "/design-studio/api/preview", {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
|
||||
import { designRevision, DEFAULT_DESIGN_BRIEF } from "@wrnexus/design-studio";
|
||||
import { apimartProvider, studioAI } from "../app/lib/studio-ai.ts";
|
||||
import { addStudioGuide } from "../app/lib/studio-guide.ts";
|
||||
|
||||
const proposals = ["2px", "4px", "8px"].map((value, index) => ({
|
||||
name: `Direction ${index + 1}`,
|
||||
rationale: "A different control shape.",
|
||||
patch: [{ op: "set", path: "/tokens/shape.control/value", value }],
|
||||
}));
|
||||
const completion = () =>
|
||||
Response.json({
|
||||
choices: [
|
||||
{
|
||||
finish_reason: "stop",
|
||||
message: { content: JSON.stringify({ alternatives: proposals }) },
|
||||
},
|
||||
],
|
||||
});
|
||||
const mockFetch = (
|
||||
fn: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
|
||||
) => fn as typeof fetch;
|
||||
const system = CORE_DESIGN_SYSTEMS.square;
|
||||
const input = async (locks: string[] = []) => ({
|
||||
system,
|
||||
locks,
|
||||
brief: {
|
||||
...DEFAULT_DESIGN_BRIEF,
|
||||
product: "A website",
|
||||
audience: "Customers",
|
||||
},
|
||||
kind: "directions",
|
||||
baseRevision: await designRevision(system, locks),
|
||||
});
|
||||
const request = async (body?: unknown, origin = "https://components.example") =>
|
||||
new Request("https://components.example/design-studio/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin },
|
||||
body: JSON.stringify(body ?? (await input())),
|
||||
});
|
||||
|
||||
test("APIMart uses the specified model, bearer authentication, non-streaming JSON and cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const provider = apimartProvider(
|
||||
"test-secret",
|
||||
mockFetch(async (url, init) => {
|
||||
expect(String(url)).toBe("https://api.apimart.ai/v1/chat/completions");
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe(
|
||||
"Bearer test-secret",
|
||||
);
|
||||
expect(init?.signal).toBe(controller.signal);
|
||||
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.messages).toEqual([
|
||||
{ role: "system", content: "rules" },
|
||||
{ role: "user", content: "brief" },
|
||||
]);
|
||||
return completion();
|
||||
}),
|
||||
);
|
||||
const result = await provider.generate("brief", {
|
||||
system: "rules",
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(JSON.parse(result.value).alternatives).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("AI proposals use engine validation, preserve draft source, locks and revision checks", async () => {
|
||||
let calls = 0;
|
||||
const handler = studioAI(
|
||||
"test-secret",
|
||||
mockFetch(async () => {
|
||||
calls++;
|
||||
return completion();
|
||||
}),
|
||||
);
|
||||
const original = JSON.stringify(system);
|
||||
const response = await handler(await request());
|
||||
const batch = await response!.json();
|
||||
expect(batch.alternatives).toHaveLength(3);
|
||||
expect(batch.provenance.provider).toBe("APIMart");
|
||||
expect(batch.provenance.model).toBe("gpt-6-luna");
|
||||
expect(JSON.stringify(system)).toBe(original);
|
||||
const locked = await handler(
|
||||
await request(await input(["/tokens/shape.control"])),
|
||||
);
|
||||
expect((await locked!.json()).alternatives).toHaveLength(0);
|
||||
const before = calls;
|
||||
expect(
|
||||
(await handler(
|
||||
await request({ ...(await input()), baseRevision: "a".repeat(64) }),
|
||||
))!.status,
|
||||
).toBe(409);
|
||||
expect(calls).toBe(before);
|
||||
});
|
||||
|
||||
test("metadata and errors never contain the key or raw upstream response", async () => {
|
||||
const handler = studioAI(
|
||||
"test-secret",
|
||||
mockFetch(
|
||||
async () =>
|
||||
new Response("test-secret upstream credentials", { status: 401 }),
|
||||
),
|
||||
);
|
||||
const meta = await handler(
|
||||
new Request("https://components.example/design-studio/api/generation"),
|
||||
);
|
||||
const text = await meta!.text();
|
||||
expect(text).toContain("gpt-6-luna");
|
||||
expect(text).not.toContain("test-secret");
|
||||
const failure = await handler(await request());
|
||||
const body = await failure!.text();
|
||||
expect(body).not.toContain("test-secret");
|
||||
expect(body).not.toContain("upstream credentials");
|
||||
expect(body).toContain("WRN-STUDIO-PROVIDER");
|
||||
});
|
||||
|
||||
test("same-origin checks, body bounds and local fallback remain enforced", async () => {
|
||||
let calls = 0;
|
||||
const handler = studioAI(
|
||||
"test-secret",
|
||||
mockFetch(async () => {
|
||||
calls++;
|
||||
return completion();
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await handler(await request(await input(), "https://other.example")))!
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await handler(await request({ huge: "x".repeat(2_000_001) })))!.status,
|
||||
).toBe(413);
|
||||
expect(calls).toBe(0);
|
||||
expect(await studioAI()(await request())).toBeUndefined();
|
||||
expect(
|
||||
addStudioGuide(
|
||||
'<head><script type="module"></script></head><div id="diagnostics"></div>',
|
||||
false,
|
||||
),
|
||||
).toContain("APIMART_API_KEY");
|
||||
expect(addStudioGuide('<div id="diagnostics"></div>', true)).toContain(
|
||||
"APIMart AI is enabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("a public AI endpoint enforces a bounded generation rate", async () => {
|
||||
const handler = studioAI(
|
||||
"test-secret",
|
||||
mockFetch(async () => completion()),
|
||||
);
|
||||
for (let index = 0; index < 10; index++)
|
||||
expect((await handler(await request()))!.status).toBe(200);
|
||||
expect((await handler(await request()))!.status).toBe(429);
|
||||
});
|
||||
Reference in new issue
Block a user