Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ jobs:
run: node .github/render-wrangler.mjs
- name: Build application
run: npm run build
- name: Verify public chat and classification tools
run: npm run test:e2e:chat
- name: Save chat verification evidence
uses: actions/upload-artifact@v4
with:
name: chat-e2e
path: captures/chat-e2e.json
- name: Verify spending through the built Worker and real Durable Objects
run: npm run test:e2e:spending
- name: Verify classification usage responses
Expand Down
140 changes: 140 additions & 0 deletions e2e/chat.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { Miniflare, convertV4MiniflareOptions, Response as WorkerResponse } from 'miniflare';
import { mkdir, writeFile, readdir } from 'node:fs/promises';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const report = { runtime: 'built Worker and real rate-limiter Durable Object', upstream: 'deterministic provider fixtures; no live inference', results: [] };
let modelCalls = 0, classifications = 0;
const modules = (await readdir('dist/server', { recursive: true })).filter(p => /\.(js|wasm)$/.test(p)).sort((a, b) => a === 'index.js' ? -1 : b === 'index.js' ? 1 : a.localeCompare(b)).map(p => ({ type: p.endsWith('.wasm') ? 'CompiledWasm' : 'ESModule', path: `dist/server/${p}` }));
const sse = delta => new WorkerResponse(`data: ${JSON.stringify({ choices: [{ delta }] })}\n\ndata: [DONE]\n\n`, { headers: { 'content-type': 'text/event-stream' } });
const mf = new Miniflare(convertV4MiniflareOptions({ workers: [{ name: 'chat', modules, modulesRoot: 'dist/server', compatibilityDate: '2026-08-01', compatibilityFlags: ['nodejs_compat'],
durableObjects: { FREE_BUDGET: { className: 'FreeBudget', useSQLite: true }, LIMITER: { className: 'RateLimiter', useSQLite: true } }, kvNamespaces: ['STATS'],
bindings: { SPENDING_ENABLED: 'true', PRIVACY_SALT: 'fixture', SPUR_API_KEY: 'fixture', TYPESAFE_API_KEY: 'fixture', OPENROUTER_API_KEY: 'fixture', ENTERPRISE_API_KEY: 'fixture-enterprise' },
outboundService: async request => {
if (request.url.startsWith('https://api.spur.us/')) return WorkerResponse.json({});
const body = await request.json();
if (request.url.includes('openrouter.ai')) {
modelCalls++;
const last = body.messages.at(-1);
if (last.content === 'fail upstream') return new WorkerResponse('unavailable', { status: 503 });
if (last.content === 'slow reply') await new Promise(resolve => setTimeout(resolve, 1000));
if (last.role === 'user') return sse({ tool_calls: [{ index: 0, id: 'call_1', function: { name: 'classify_texts', arguments: JSON.stringify({ inputs: ['Please refund my invoice'], labels: ['billing', 'support'] }) } }] });
assert.equal(last.role, 'tool');
assert.match(last.content, /billing\t0.90/);
return sse({ content: 'The message is billing (90% confidence).' });
}
assert.ok(request.url.includes('typesafe.ai'), `Unexpected upstream: ${request.url}`);
classifications++;
return WorkerResponse.json({ model: 'jev-1.13.0', usage: { input_tokens: 100, output_tokens: 0 }, answers: Object.fromEntries(Object.entries(body.questions).map(([id, question]) => {
const keys = Object.keys(question.criteria);
return [id, { choice: keys[0], confidence: 0.9, probabilities: { [keys[0]]: 0.9, [keys[1]]: 0.1 } }];
})) });
},
}] }));
const post = (messages, ip = '203.0.113.10', path = '/v1/chat') => mf.dispatchFetch(`https://classifier.dev${path}`, {
method: 'POST', headers: { 'content-type': 'application/json', 'cf-connecting-ip': ip }, body: JSON.stringify({ messages }),
});
const message = content => [{ role: 'user', content }];
const events = async response => (await response.text()).split('\n\n').filter(line => line.startsWith('data: ')).map(line => JSON.parse(line.slice(6)));
try {
await mf.ready;
for (const path of ['/', '/chat', '/docs', '/benchmark']) {
const response = await mf.dispatchFetch(`https://classifier.dev${path}`, { headers: { accept: 'text/html' } });
assert.equal(response.status, 200, path);
const html = await response.text();
assert.match(html, /data-chat-open/);
assert.match(html, /id="chat" aria-label="Chat"/);
if (path === '/chat') assert.match(html, /id="chat" aria-label="Chat">/);
for (const script of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) assert.doesNotThrow(() => new Function(script[1]));
}
report.results.push({ name: 'public chat entry points and executable scripts', status: 'passed' });
assert.equal((await post([])).status, 400);
assert.equal((await post(message('x'.repeat(8001)))).status, 400);
assert.equal((await post(message('x'.repeat(1_000_001)))).status, 413);
assert.equal(modelCalls, 0);
const response = await post(message('Classify this refund request'));
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /text\/event-stream/);
const turn = await events(response);
assert.deepEqual(turn.map(event => event.t), ['tool', 'result', 'text', 'done']);
assert.equal(turn[1].error, undefined);
assert.match(turn[1].text, /billing/);
assert.equal(classifications, 1);
report.results.push({ name: 'anonymous streamed classification through MCP and provider', events: turn });
for (let i = 1; i < 10; i++) {
const next = await post(message('Classify another refund request'));
assert.equal(next.status, 200);
assert.equal((await events(next)).at(-1).t, 'done');
}
const callsBeforeLimit = modelCalls;
const limited = await post(message('One too many'));
assert.equal(limited.status, 429);
assert.ok(Number(limited.headers.get('retry-after')) > 0);
assert.equal(modelCalls, callsBeforeLimit);
report.results.push({ name: 'ten turns per minute; rejected before provider call', status: limited.status });
const failure = await events(await post(message('fail upstream'), '203.0.113.11'));
assert.equal(failure.at(-1).t, 'error');
for (const path of ['/skills', '/v1/skills', '/%76%31/skills', '/chat.md', '/chat/private']) assert.equal((await mf.dispatchFetch(`https://classifier.dev${path}`)).status, 404);
report.results.push({ name: 'upstream error terminates stream; skills remain private', status: 'passed' });
if (process.argv.includes('--browser')) {
const run = promisify(execFile);
const browser = async (...args) => (await run('agent-browser', ['--session', 'restore-chat-e2e', ...args])).stdout;
const check = async condition => browser('eval', `if (!(${condition})) throw new Error('Browser assertion failed: ' + ${JSON.stringify(condition)}); true`);
const capture = async path => {
await browser('wait', '--fn', 'getComputedStyle(document.querySelector("#chat")).transform === "matrix(1, 0, 0, 1, 0, 0)"');
await browser('eval', 'document.fonts.ready.then(() => true)');
await browser('screenshot', path);
};
const url = String(await mf.ready);
await browser('open', url);
await browser('set', 'viewport', '1440', '1000');
await browser('click', '.site-links [data-chat-open]');
await check('!document.querySelector("#chat").hidden');
await capture('captures/desktop-chat.png');
await browser('fill', '#chat textarea', 'Classify: Please refund my invoice. Labels: billing, support.');
await browser('press', 'Enter');
await browser('wait', '--text', 'The message is billing (90% confidence).');
await check('document.querySelector(".chat-tool .nm").textContent === "classify_texts"');
await capture('captures/desktop-conversation.png');
await browser('reload');
await browser('wait', '--text', 'The message is billing (90% confidence).');
await browser('press', 'Escape');
await check('document.querySelector("#chat").hidden');
await browser('press', 'Control+i');
await check('!document.querySelector("#chat").hidden');
await browser('open', new URL('/docs', url).href);
await browser('wait', '--text', 'The message is billing (90% confidence).');
await browser('click', '[data-chat-clear]');
await check('document.querySelector(".chat-msgs").childElementCount === 0');
await browser('fill', '#chat textarea', 'slow reply');
await browser('press', 'Shift+Enter');
await check('document.querySelector("#chat textarea").value.endsWith("\\n")');
await browser('press', 'Enter');
await browser('click', '.chat-send[aria-label="Stop"]');
await browser('wait', '--fn', 'document.querySelector(".chat-send").getAttribute("aria-label") === "Send"');
await browser('fill', '#chat textarea', 'fail upstream');
await browser('press', 'Enter');
await browser('wait', '--text', 'the assistant could not answer; try again');
await browser('click', '[data-chat-close]');
await browser('open', new URL('/chat', url).href);
await check('!document.querySelector("#chat").hidden');
await browser('click', '[data-chat-close]');
await browser('open', url);
await browser('set', 'viewport', '390', '844');
await browser('press', 'Control+i');
await capture('captures/mobile-chat.png');
await check('document.querySelector("#chat").getBoundingClientRect().right <= innerWidth');
await browser('click', '[data-chat-close]');
await browser('click', '.site-menu summary');
await browser('click', '.site-menu-links a[href="/chat"]');
await check('!document.querySelector("#chat").hidden');
await browser('close');
report.results.push({ name: 'desktop/mobile chat, stream, persistence, clear, error, shortcut, mobile menu', status: 'passed', screenshots: ['desktop-chat.png', 'desktop-conversation.png', 'mobile-chat.png'] });
}
} finally {
await mkdir('captures', { recursive: true });
await writeFile('captures/chat-e2e.json', JSON.stringify(report, null, 2));
await mf.dispose();
}
console.log(`${report.results.length} chat E2E scenarios passed; captures/chat-e2e.json`);
2 changes: 1 addition & 1 deletion e2e/spending.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ try {
assert.equal(smart.status, 200); const result = await smart.json();
assert.ok(JSON.stringify(result).includes('gemini'));
report.results.push({ name: 'free smart escalation', status: smart.status, model: 'google/gemini-3.8-flash' });
for (const path of ['/v1/chat', '//v1/chat', '/%76%31/skills', '/skills', '/v1/skills']) {
for (const path of ['/%76%31/skills', '/skills', '/v1/skills']) {
assert.equal((await request('203.0.113.3', {}, path)).status, 404);
}
report.results.push({ name: 'private endpoints and encoded aliases', status: 'all 404 without internal credential' });
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"build:admin": "vite build --config vite.admin.config.ts",
"prebuild": "npm run build:admin",
"predev": "npm run build:admin",
"test:e2e:chat": "node e2e/chat.mjs",
"test:e2e:spending": "node e2e/spending.mjs",
"test:e2e:usage": "node e2e/usage.mjs",
"test:e2e:long-context": "node e2e/long-context.mjs",
Expand Down
3 changes: 2 additions & 1 deletion src/chatui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const CHAT_CSS = `
@media (prefers-reduced-motion:reduce){.chat{transition:none}}
/* The page gives up the panel's width and re-centres in what is left. */
@media (min-width:900px){
.chat{inset-block-start:72px}
.page{transition:padding-right .2s linear}
html[data-chat] .page{padding-right:calc(${CHAT_WIDTH}px + 16px)}
html[data-chat] .dock{right:calc(${CHAT_WIDTH}px + 16px)}
Expand All @@ -65,7 +66,7 @@ export const CHAT_CSS = `
padding:14px 16px 12px;border-bottom:1px solid var(--rule)}
.chat-title{min-width:0;font-weight:600;color:var(--fg)}
.chat-sub{display:block;margin-top:2px;font-size:12px;font-weight:500;color:var(--dim);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
text-wrap:pretty}
.chat-acts{flex:none;gap:0 6px}
.chat-acts .b[hidden]{display:none}
.chat-body{position:relative;flex:1;min-height:0;display:flex;flex-direction:column;overflow-y:auto;
Expand Down
8 changes: 4 additions & 4 deletions src/home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ const navLink = (label: string, href: string, here: string, key: string) =>
const MARK = `<svg class="site-mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="var(--accent)"/><rect x="6" y="9" width="20" height="4" rx="2" fill="var(--ink)"/><rect x="6" y="16" width="11" height="4" rx="2" fill="#765db9"/><rect x="6" y="23" width="6" height="4" rx="2" fill="#765db9"/></svg>`;

const navLinks = (here: string, opensChat = true) =>
`${navLink("Home", "/", here, "home")}${navLink("Benchmark", "/benchmark", here, "benchmark")}${navLink("Docs", "/docs", here, "developers")}${navLink("Pricing", "/pricing", here, "pricing")}`;
`${navLink("Home", "/", here, "home")}${navLink("Benchmark", "/benchmark", here, "benchmark")}${navLink("Docs", "/docs", here, "developers")}${navLink("Pricing", "/pricing", here, "pricing")}<a class="site-link" href="/chat"${here === "chat" ? ' aria-current="page"' : ""}${opensChat ? ' data-chat-open aria-controls="chat" aria-expanded="false"' : ""}>Chat</a>`;

const resourceLinks = (here: string) =>
`${navLink("MCP setup", "/mcp-setup", here, "mcp-setup")}<a class="site-link" href="/openapi.json">OpenAPI</a><a class="site-link" href="/skill.md">Agent skill</a><a class="site-link" href="/llms.txt">llms.txt</a><a class="site-link" href="https://github.com/mrmps/classifier-dev">GitHub</a>`;
Expand Down Expand Up @@ -935,7 +935,7 @@ classify relevant,"not relevant" --review 0.7 &lt; snippets.txt</code> <span c
${FOOT}
</article></main></div>
${subscribeDock()}
`,
${chatPanel(!!o.chat)}`,
script: COPY_SCRIPT + SUBSCRIBE_SCRIPT + WEBMCP_SCRIPT + CHAT_SCRIPT + HL_SCRIPT,
});
}
Expand All @@ -959,7 +959,7 @@ export function docHtml(o: { title: string; desc: string; doc: string; path: str
${renderDoc(o.doc, true, o.swap ?? {})}
${FOOT}
</article></main></div>
`,
${chatPanel()}`,
script: COPY_SCRIPT + CHAT_SCRIPT + HL_SCRIPT,
});
}
Expand All @@ -976,7 +976,7 @@ export function benchmarkHtml(signedIn = false): string {
${renderDoc(BENCHMARK, true)}
${FOOT}
</article></main></div>
`,
${chatPanel()}`,
script: COPY_SCRIPT + CHAT_SCRIPT + HL_SCRIPT,
});
}
19 changes: 14 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1329,7 +1329,12 @@ const worker = {
async fetch(req: Request, env: Env, ctx: ExecutionContext, execution?: ClassificationExecution): Promise<Response> {
const endpoint = new URL(req.url).pathname.replace(/\/+$/, "");
let internalEndpoint = false;
try { internalEndpoint = /^\/(?:v1\/)?(?:chat|skills)(?:\/|\.md$|$)/.test(decodeURIComponent(endpoint).replace(/^\/+/, "/")); } catch { /* Invalid routes are handled below. */ }
let chatEndpoint = false;
try {
const decoded = decodeURIComponent(endpoint).replace(/^\/+/, "/");
chatEndpoint = decoded === "/chat" || decoded === "/v1/chat";
internalEndpoint = !chatEndpoint && /^\/(?:v1\/)?(?:chat|skills)(?:\/|\.md$|$)/.test(decoded);
} catch { /* Invalid routes are handled below. */ }
if (internalEndpoint && !execution?.internal) {
const credential = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
if (!env.INTERNAL_API_KEY || !await secretEquals(credential, env.INTERNAL_API_KEY))
Expand All @@ -1339,7 +1344,7 @@ const worker = {
headers.set("cache-control", "private, no-store");
return new Response(response.body, { status: response.status, headers });
}
if (env.SPENDING_ENABLED === "true" && !execution?.spending) {
if (env.SPENDING_ENABLED === "true" && !execution?.spending && !chatEndpoint) {
try {
req = await boundedRequest(req);
const body = req.method === "POST" ? await req.clone().json().catch(() => undefined) as Record<string, unknown> | undefined : undefined;
Expand Down Expand Up @@ -1441,11 +1446,12 @@ const worker = {
if (!env.OPENROUTER_API_KEY) return json({ error: "chat is not configured", code: "chat_unavailable" }, 503);
let messages;
try {
messages = parseMessages((await readJsonObject(req)).messages);
messages = parseMessages((await readJsonObject(await boundedRequest(req))).messages);
} catch (e) {
if (e instanceof SpendingError) return errorResponse(e);
return json({ error: (e as Error).message, code: "invalid_request" }, 400);
}
const gate = internalEndpoint ? { limited: false, resetIn: undefined } : await limited(env, "smart", ip, CHAT_COST);
const gate = await limited(env, "smart", ip, CHAT_COST);
if (gate.limited) {
return json({ error: `Chat limit reached; try again in ${gate.resetIn ?? 60}s`, code: "rate_limited" }, 429, {
"retry-after": String(gate.resetIn ?? 60),
Expand Down Expand Up @@ -1898,7 +1904,10 @@ const worker = {
if (wantsHtml) return html(homeHtml({ signedIn }), 200, link);
return text(DOCS, 200, { vary: "accept, user-agent", ...link });
}
if (req.method === "GET" && path === "chat") return notFound(req, origin);
if (req.method === "GET" && path === "chat") {
if (wantsHtml) return html(homeHtml({ chat: true, signedIn }), 200, { link: LINKS(origin) });
return text(`The chat is a page: open ${origin}/chat in a browser.\nAgents get the same tools at ${origin}/mcp; the chat itself is POST ${origin}/${API_VERSION}/chat with {"messages": [{"role": "user", "content": "..."}]} and answers as an event stream.\n`);
}

if (req.method === "GET" && (path === "benchmark" || path === "benchmark.md")) {
if (url.searchParams.get("format") === "json" || (/\bapplication\/json\b/.test(accept) && !wantsHtml)) {
Expand Down
10 changes: 5 additions & 5 deletions test/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ const post = (env: Partial<Env>, body: unknown) =>
worker.fetch(
new Request("https://classifier.dev/v1/chat", {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer internal-fixture" },
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
{ LIMITER: limiter, INTERNAL_API_KEY: "internal-fixture", ...env } as Env,
{ LIMITER: limiter, ...env } as Env,
ctx,
);

Expand Down Expand Up @@ -224,14 +224,14 @@ describe("a turn", () => {
expect(ev[ev.length - 1].t).toBe("error");
});

it("internal chat is independent of visitor quotas", async () => {
it("chat is refused when the visitor has used their window", async () => {
const full = {
idFromName: () => "id",
get: () => ({ fetch: async () => Response.json({ limited: true, remaining: 0, scope: "minute", resetIn: 12 }) }),
} as unknown as DurableObjectNamespace;
const res = await post({ OPENROUTER_API_KEY: "k", LIMITER: full }, { messages: [{ role: "user", content: "hi" }] });
expect(res.status).toBe(200);
await res.text();
expect(res.status).toBe(429);
expect(res.headers.get("retry-after")).toBe("12");
});
});

Expand Down
6 changes: 3 additions & 3 deletions tests/public-pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ test("public navigation exposes pricing and the WorkOS entry points", () => {
expect(html).toContain('href="/login">Log in</a>');
expect(html).toContain('href="/auth/sign-up">Sign up</a>');
expect(html).toContain('href="/auth/sign-up">Get started</a>');
expect(html).not.toContain('href="/chat"');
expect(html).toContain('href="/chat"');
expect(html).toContain('href="/openapi.json"');
expect(html).toContain('href="/skill.md"');
expect(html).toContain('href="/llms.txt"');
Expand Down Expand Up @@ -51,12 +51,12 @@ test("the public worker renders authenticated navigation as a cookie variant", a
expect(response.headers.get("Vary")).toContain("cookie");
});

test("public navigation stays above content and does not expose internal chat", () => {
test("chat stays above public navigation", () => {
const headerLayer = Number(/\.site-header\{[^}]*z-index:(\d+)/.exec(HOME_CSS)?.[1]);
const chatLayer = Number(/\.chat\{[^}]*z-index:(\d+)/.exec(CHAT_CSS)?.[1]);
expect(headerLayer).toBeGreaterThan(0);
expect(headerLayer).toBeLessThan(chatLayer);
expect(homeHtml({chat: true})).not.toContain('class="chat" id="chat" aria-label="Chat">');
expect(homeHtml({chat: true})).toContain('class="chat" id="chat" aria-label="Chat">');
});

test("the open mobile menu removes the newsletter dock from view and interaction", () => {
Expand Down
Loading
Loading