diff --git a/packages/drawtonomy-dev-server/README.md b/packages/drawtonomy-dev-server/README.md index fdaadb0..8a8451e 100644 --- a/packages/drawtonomy-dev-server/README.md +++ b/packages/drawtonomy-dev-server/README.md @@ -39,12 +39,38 @@ open "http://localhost:3000/?ext=http://localhost:3001/manifest.json" - This dev server runs locally on HTTP, so localhost extensions work without issues - Always up-to-date with `drawtonomy.com` — no manual version management +## AI scenario generation (bring your own key) + +drawtonomy can generate a driving scenario from a plain-text description. The +generation loop runs in your browser, but browsers cannot call the AI vendors +directly — each vendor sets its own CORS policy, and some refuse browser-origin +requests outright. This server therefore exposes one route, +`POST /api/ai-scenario/llm-relay`, and forwards the request for the page. + +Enter your own API key in the editor (the AI panel has a provider picker and a +key field). Supported providers: + +| Provider | Key from | +|---|---| +| Anthropic | https://console.anthropic.com/ | +| OpenAI | https://platform.openai.com/api-keys | +| Gemini | https://aistudio.google.com/apikey | + +The key is sent with each request as a header, used only to build the outgoing +call, and then discarded. It is never written to disk, never added to the +server's environment, and never logged. Requests are billed to your own account +by the provider you pick. + +The relay is a development convenience bound to the same localhost surface as +the file serving — do not expose this server to a network you do not control. + ## Options | Environment Variable | Default | Description | |---------------------|---------|-------------| | `PORT` | `3000` | Server port | | `DRAWTONOMY_HOST` | `https://www.drawtonomy.com` | Host to download from | +| `DRAWTONOMY_LLM_BASE_URL` | _(unset)_ | Send AI requests to this host instead of the real providers — for testing without a key | ```bash # Custom port diff --git a/packages/drawtonomy-dev-server/bin/llmRelay.mjs b/packages/drawtonomy-dev-server/bin/llmRelay.mjs new file mode 100644 index 0000000..21f3c87 --- /dev/null +++ b/packages/drawtonomy-dev-server/bin/llmRelay.mjs @@ -0,0 +1,384 @@ +// LLM relay: forwards one prompt to the AI provider the caller selected. +// +// Why a relay exists at all +// ------------------------------------------------------------------------- +// drawtonomy can generate a driving scenario from a plain-text description. +// The generation loop runs in the browser, but the browser cannot call the +// AI vendors directly: each vendor has its own CORS policy, and some do not +// allow browser-origin requests at all. So the page posts to this one +// same-origin endpoint instead, and the dev server forwards the request. +// +// Bring your own key +// ------------------------------------------------------------------------- +// The API key travels per request, in a header, and is used only to build the +// outgoing request. It is never written to disk, never placed in the process +// environment, and never logged - not even truncated. Only its presence and +// length are observable, and only through the caller's own error messages. +// The key is a header rather than part of the JSON body because bodies are the +// part most likely to end up in a proxy or trace log verbatim. +// +// The relay is bound to the same localhost-only surface as the file serving in +// serve.mjs: it is a development convenience, not a public proxy. +// +// Keeping vendor support honest +// ------------------------------------------------------------------------- +// The request/response shapes below are written against each vendor's public +// HTTP API. They are maintained here on their own - this package does not, and +// cannot, import them from anywhere else. When a vendor changes its API, this +// file is the only place in this package that has to change, and the contract +// test in test/llmRelay.test.mjs is what catches the drift. + +/** Header carrying the caller's API key (request scope only, never stored). */ +export const API_KEY_HEADER = 'x-ai-gen-api-key' +/** Header selecting which vendor the key belongs to. */ +export const API_VENDOR_HEADER = 'x-ai-gen-api-provider' + +export const DEFAULT_VENDOR = 'anthropic' +export const DEFAULT_MAX_TOKENS = 16000 +export const DEFAULT_TIMEOUT_MS = 180000 + +/** + * Point every vendor at a different base URL. + * + * Set `DRAWTONOMY_LLM_BASE_URL` to run the relay against a local stand-in + * instead of the real vendors - useful when developing the relay itself, or + * when testing the generation flow without spending on API calls. The path and + * headers are unchanged, so the stand-in sees exactly what a vendor would. + */ +const BASE_URL_OVERRIDE = process.env.DRAWTONOMY_LLM_BASE_URL || '' + +/** Apply the override, keeping the vendor's own path. */ +function withBaseOverride(url) { + if (BASE_URL_OVERRIDE === '') return url + const target = new URL(url) + const base = new URL(BASE_URL_OVERRIDE) + target.protocol = base.protocol + target.host = base.host + return target.toString() +} + +/** Statuses worth a retry: rate limiting and transient server faults. */ +const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504, 529]) +const MAX_RETRIES = 2 +const RETRY_BASE_DELAY_MS = 1000 +/** Upper bound on an honoured Retry-After, so one bad header cannot hang a request. */ +const MAX_RETRY_AFTER_MS = 30000 + +/** + * One vendor's HTTP shape: where to send, what headers to set, how to build the + * body, and how to pull the assistant text back out. + * + * The prompts themselves are identical across vendors. Only the envelope + * differs - notably where the system prompt goes, which each vendor names + * differently. + */ +const VENDORS = { + anthropic: { + label: 'Anthropic', + defaultModel: 'claude-sonnet-5', + endpoint: () => 'https://api.anthropic.com/v1/messages', + headers: (apiKey) => ({ + 'content-type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': apiKey, + }), + buildBody: ({ systemPrompt, userPrompt, model, maxTokens }) => ({ + model, + max_tokens: maxTokens, + system: systemPrompt, + messages: [{ role: 'user', content: userPrompt }], + }), + extractText: (payload) => { + if (!Array.isArray(payload?.content)) { + throw relayError('response', 'Anthropic API response had no content array.') + } + const text = payload.content + .filter((b) => b?.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('') + if (text === '') { + throw relayError( + 'response', + `Anthropic API returned no text (stop_reason=${payload?.stop_reason ?? 'unknown'}).`, + ) + } + return text + }, + }, + + openai: { + label: 'OpenAI', + defaultModel: 'gpt-5.6-terra', + endpoint: () => 'https://api.openai.com/v1/chat/completions', + headers: (apiKey) => ({ + 'content-type': 'application/json', + authorization: `Bearer ${apiKey}`, + }), + buildBody: ({ systemPrompt, userPrompt, model, maxTokens }) => ({ + model, + max_completion_tokens: maxTokens, + response_format: { type: 'json_object' }, + // The system prompt goes in a "developer" message on the newer models. + messages: [ + { role: 'developer', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }), + extractText: (payload) => { + const choice = payload?.choices?.[0] + const content = choice?.message?.content + if (typeof content !== 'string' || content === '') { + throw relayError( + 'response', + `OpenAI API returned no assistant text (finish_reason=${choice?.finish_reason ?? 'unknown'}).`, + ) + } + return content + }, + }, + + gemini: { + label: 'Gemini', + defaultModel: 'gemini-3.7-flash', + endpoint: (model) => + `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`, + // The key goes in a header, not the ?key= query parameter: URLs are the part + // most likely to be written to an access log. + headers: (apiKey) => ({ + 'content-type': 'application/json', + 'x-goog-api-key': apiKey, + }), + buildBody: ({ systemPrompt, userPrompt, maxTokens }) => ({ + systemInstruction: { parts: [{ text: systemPrompt }] }, + contents: [{ role: 'user', parts: [{ text: userPrompt }] }], + generationConfig: { + maxOutputTokens: maxTokens, + responseMimeType: 'application/json', + }, + }), + extractText: (payload) => { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + const text = Array.isArray(parts) + ? parts.filter((p) => typeof p?.text === 'string').map((p) => p.text).join('') + : '' + if (text === '') { + const reason = + candidate?.finishReason ?? payload?.promptFeedback?.blockReason ?? 'unknown' + throw relayError('response', `Gemini API returned no text parts (finishReason=${reason}).`) + } + return text + }, + }, +} + +export const VENDOR_IDS = Object.keys(VENDORS) + +/** An error with a machine-readable kind, safe to show to the caller. */ +export function relayError(kind, message) { + const err = new Error(message) + err.name = 'RelayError' + err.kind = kind + return err +} + +/** Resolve a vendor id, falling back to the default when unset. */ +export function normalizeVendor(raw) { + if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_VENDOR + const id = String(raw).trim().toLowerCase() + if (!VENDOR_IDS.includes(id)) { + throw relayError( + 'request', + `Unknown AI provider '${id}'. Expected one of: ${VENDOR_IDS.join(', ')}.`, + ) + } + return id +} + +/** Map an HTTP status onto a kind the caller can act on. */ +function classifyStatus(status) { + if (status === 401 || status === 403) return 'auth' + if (status === 429) return 'rate-limit' + if (status >= 500) return 'server' + return 'request' +} + +function retryAfterMs(headers) { + const raw = headers?.get?.('retry-after') + if (!raw) return null + const seconds = Number(raw) + if (!Number.isFinite(seconds) || seconds < 0) return null + return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS) +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * Send one prompt to one vendor and return the assistant text. + * + * @param {{ vendor?: string, apiKey: string, systemPrompt: string, userPrompt: string, + * model?: string, maxTokens?: number, timeoutMs?: number, + * fetchImpl?: typeof fetch, sleepImpl?: (ms: number) => Promise }} args + * @returns {Promise} + */ +export async function callVendor(args) { + const vendorId = normalizeVendor(args.vendor) + const vendor = VENDORS[vendorId] + const fetchImpl = args.fetchImpl ?? globalThis.fetch + const wait = args.sleepImpl ?? sleep + + const apiKey = typeof args.apiKey === 'string' ? args.apiKey.trim() : '' + if (apiKey === '') { + throw relayError('missing-key', `No ${vendor.label} API key was provided.`) + } + if (typeof args.systemPrompt !== 'string' || typeof args.userPrompt !== 'string') { + throw relayError('request', 'Both systemPrompt and userPrompt must be strings.') + } + + const model = + typeof args.model === 'string' && args.model.trim() !== '' + ? args.model.trim() + : vendor.defaultModel + const maxTokens = + Number.isFinite(args.maxTokens) && args.maxTokens > 0 ? args.maxTokens : DEFAULT_MAX_TOKENS + const timeoutMs = + Number.isFinite(args.timeoutMs) && args.timeoutMs > 0 ? args.timeoutMs : DEFAULT_TIMEOUT_MS + + const body = JSON.stringify( + vendor.buildBody({ systemPrompt: args.systemPrompt, userPrompt: args.userPrompt, model, maxTokens }), + ) + + let lastError = null + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + let response + try { + response = await fetchImpl(withBaseOverride(vendor.endpoint(model)), { + method: 'POST', + headers: vendor.headers(apiKey), + body, + signal: controller.signal, + }) + } catch (err) { + clearTimeout(timer) + const timedOut = err?.name === 'AbortError' + lastError = relayError( + timedOut ? 'timeout' : 'network', + timedOut + ? `${vendor.label} API did not respond within ${Math.round(timeoutMs / 1000)}s.` + : `Could not reach the ${vendor.label} API: ${err?.message ?? 'network error'}.`, + ) + if (attempt < MAX_RETRIES) { + await wait(RETRY_BASE_DELAY_MS * 2 ** attempt) + continue + } + throw lastError + } + clearTimeout(timer) + + if (response.ok) { + const raw = await response.text() + let payload + try { + payload = JSON.parse(raw) + } catch { + throw relayError('response', `${vendor.label} API returned a body that was not JSON.`) + } + return vendor.extractText(payload) + } + + // Read the error body for the message, but never echo a key back: the body + // is the vendor's, and it is truncated to keep one long page out of the UI. + const detail = (await response.text().catch(() => '')).slice(0, 400) + const kind = classifyStatus(response.status) + lastError = relayError( + kind, + `${vendor.label} API returned HTTP ${response.status}${detail ? `: ${detail}` : ''}`, + ) + if (RETRYABLE_STATUS.has(response.status) && attempt < MAX_RETRIES) { + await wait(retryAfterMs(response.headers) ?? RETRY_BASE_DELAY_MS * 2 ** attempt) + continue + } + throw lastError + } + throw lastError ?? relayError('server', 'The AI request failed.') +} + +/** Read the whole request body, refusing anything implausibly large. */ +function readBody(req, limitBytes = 4 * 1024 * 1024) { + return new Promise((resolve, reject) => { + let size = 0 + const chunks = [] + req.on('data', (chunk) => { + size += chunk.length + if (size > limitBytes) { + reject(relayError('request', 'Request body was too large.')) + req.destroy() + return + } + chunks.push(chunk) + }) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))) + req.on('error', reject) + }) +} + +function sendJson(res, status, payload) { + const text = JSON.stringify(payload) + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }) + res.end(text) +} + +/** + * Handle POST /api/ai-scenario/llm-relay. + * + * Returns true when it took the request, false when the caller should keep + * looking (so the static file serving stays untouched for every other path). + */ +export async function handleLlmRelay(req, res, options = {}) { + const urlPath = (req.url ?? '').split('?')[0] + if (urlPath !== '/api/ai-scenario/llm-relay') return false + + if (req.method !== 'POST') { + sendJson(res, 405, { ok: false, error: 'Use POST for the AI relay.', kind: 'request' }) + return true + } + + try { + const raw = await readBody(req) + let parsed + try { + parsed = JSON.parse(raw || '{}') + } catch { + throw relayError('request', 'Request body was not valid JSON.') + } + + const text = await callVendor({ + vendor: req.headers[API_VENDOR_HEADER], + apiKey: req.headers[API_KEY_HEADER] ?? '', + systemPrompt: parsed.systemPrompt, + userPrompt: parsed.userPrompt, + model: parsed.model, + maxTokens: parsed.maxTokens, + ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}), + ...(options.sleepImpl ? { sleepImpl: options.sleepImpl } : {}), + }) + sendJson(res, 200, { ok: true, text }) + } catch (err) { + const kind = err?.kind ?? 'server' + // Errors are reported with HTTP 200 semantics only when they are the + // vendor's; transport-level problems keep a real status so a caller that + // checks status still sees them. + const status = kind === 'request' || kind === 'missing-key' ? 400 : 502 + sendJson(res, status, { + ok: false, + error: err?.message ?? 'The AI request failed.', + kind, + }) + } + return true +} diff --git a/packages/drawtonomy-dev-server/bin/serve.js b/packages/drawtonomy-dev-server/bin/serve.js index 35149c0..83c4279 100755 --- a/packages/drawtonomy-dev-server/bin/serve.js +++ b/packages/drawtonomy-dev-server/bin/serve.js @@ -4,6 +4,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs import { join, extname, dirname } from 'path' import { tmpdir, homedir } from 'os' import https from 'https' +import { handleLlmRelay } from './llmRelay.mjs' const HOST_URL = process.env.DRAWTONOMY_HOST || 'https://www.drawtonomy.com' const PORT = parseInt(process.env.PORT || '3000', 10) @@ -189,6 +190,20 @@ async function main() { } const server = createServer((req, res) => { + // The AI relay is the one dynamic route. It answers for its own path and + // returns false for everything else, so static serving below is unchanged. + handleLlmRelay(req, res).then((handled) => { + if (!handled) serveStatic(req, res) + }, (err) => { + console.error(` AI relay failed: ${err?.message ?? err}`) + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }) + } + res.end(JSON.stringify({ ok: false, error: 'The AI relay failed.', kind: 'server' })) + }) + }) + + function serveStatic(req, res) { let urlPath = req.url.split('?')[0] if (urlPath === '/') urlPath = '/index.html' @@ -223,7 +238,7 @@ async function main() { res.end('Not found') } } - }) + } server.listen(PORT, () => { console.log() diff --git a/packages/drawtonomy-dev-server/bin/serve.mjs b/packages/drawtonomy-dev-server/bin/serve.mjs index 35149c0..83c4279 100755 --- a/packages/drawtonomy-dev-server/bin/serve.mjs +++ b/packages/drawtonomy-dev-server/bin/serve.mjs @@ -4,6 +4,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs import { join, extname, dirname } from 'path' import { tmpdir, homedir } from 'os' import https from 'https' +import { handleLlmRelay } from './llmRelay.mjs' const HOST_URL = process.env.DRAWTONOMY_HOST || 'https://www.drawtonomy.com' const PORT = parseInt(process.env.PORT || '3000', 10) @@ -189,6 +190,20 @@ async function main() { } const server = createServer((req, res) => { + // The AI relay is the one dynamic route. It answers for its own path and + // returns false for everything else, so static serving below is unchanged. + handleLlmRelay(req, res).then((handled) => { + if (!handled) serveStatic(req, res) + }, (err) => { + console.error(` AI relay failed: ${err?.message ?? err}`) + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }) + } + res.end(JSON.stringify({ ok: false, error: 'The AI relay failed.', kind: 'server' })) + }) + }) + + function serveStatic(req, res) { let urlPath = req.url.split('?')[0] if (urlPath === '/') urlPath = '/index.html' @@ -223,7 +238,7 @@ async function main() { res.end('Not found') } } - }) + } server.listen(PORT, () => { console.log() diff --git a/packages/drawtonomy-dev-server/package.json b/packages/drawtonomy-dev-server/package.json index 5787561..9b7aae0 100644 --- a/packages/drawtonomy-dev-server/package.json +++ b/packages/drawtonomy-dev-server/package.json @@ -1,11 +1,14 @@ { "name": "@drawtonomy/dev-server", - "version": "0.1.3", + "version": "0.2.0", "description": "Local development server for drawtonomy extension development", "type": "module", "bin": { "drawtonomy-dev-server": "bin/serve.js" }, + "scripts": { + "test": "node --test test/llmRelay.test.mjs" + }, "files": [ "bin", "README.md" diff --git a/packages/drawtonomy-dev-server/test/llmRelay.test.mjs b/packages/drawtonomy-dev-server/test/llmRelay.test.mjs new file mode 100644 index 0000000..a7be5ec --- /dev/null +++ b/packages/drawtonomy-dev-server/test/llmRelay.test.mjs @@ -0,0 +1,299 @@ +// Contract tests for the LLM relay. +// +// The relay restates each vendor's HTTP shape by hand, so the thing most likely +// to break is a silent drift between what a vendor expects and what we send. +// These tests pin the outgoing request (URL, headers, body) and the text we pull +// back out, using a stub fetch - no network, no API key, no cost. +// +// Run: node --test test/ + +import test from 'node:test' +import assert from 'node:assert/strict' +import { + callVendor, + handleLlmRelay, + normalizeVendor, + VENDOR_IDS, + API_KEY_HEADER, + API_VENDOR_HEADER, +} from '../bin/llmRelay.mjs' + +/** Capture one outgoing request and reply with a canned body. */ +function stubFetch(replyPayload, { status = 200, capture = {} } = {}) { + return async (url, init) => { + capture.url = url + capture.init = init + capture.body = init?.body ? JSON.parse(init.body) : null + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + text: async () => JSON.stringify(replyPayload), + } + } +} + +test('every vendor id is routable', () => { + assert.deepEqual(VENDOR_IDS.sort(), ['anthropic', 'gemini', 'openai']) +}) + +test('vendor defaults to anthropic when unset, and rejects unknown names', () => { + assert.equal(normalizeVendor(undefined), 'anthropic') + assert.equal(normalizeVendor(''), 'anthropic') + assert.equal(normalizeVendor('OpenAI'), 'openai') + assert.throws(() => normalizeVendor('lodestar'), /Unknown AI provider/) +}) + +test('anthropic: system prompt goes in `system`, text comes from content blocks', async () => { + const capture = {} + const text = await callVendor({ + vendor: 'anthropic', + apiKey: 'k-anthropic', + systemPrompt: 'SYS', + userPrompt: 'USER', + fetchImpl: stubFetch({ content: [{ type: 'text', text: '{"ok":1}' }] }, { capture }), + }) + assert.equal(text, '{"ok":1}') + assert.equal(capture.url, 'https://api.anthropic.com/v1/messages') + assert.equal(capture.init.headers['x-api-key'], 'k-anthropic') + assert.equal(capture.init.headers['anthropic-version'], '2023-06-01') + assert.equal(capture.body.system, 'SYS') + assert.deepEqual(capture.body.messages, [{ role: 'user', content: 'USER' }]) + assert.equal(capture.body.model, 'claude-sonnet-5') +}) + +test('openai: system prompt goes in a developer message, key is a bearer token', async () => { + const capture = {} + const text = await callVendor({ + vendor: 'openai', + apiKey: 'k-openai', + systemPrompt: 'SYS', + userPrompt: 'USER', + fetchImpl: stubFetch( + { choices: [{ message: { content: '{"ok":2}' } }] }, + { capture }, + ), + }) + assert.equal(text, '{"ok":2}') + assert.equal(capture.url, 'https://api.openai.com/v1/chat/completions') + assert.equal(capture.init.headers.authorization, 'Bearer k-openai') + assert.deepEqual(capture.body.messages, [ + { role: 'developer', content: 'SYS' }, + { role: 'user', content: 'USER' }, + ]) + // JSON mode and the newer token field are both required by this model family. + assert.deepEqual(capture.body.response_format, { type: 'json_object' }) + assert.ok(capture.body.max_completion_tokens > 0) +}) + +test('gemini: model is in the path, key is a header rather than a query parameter', async () => { + const capture = {} + const text = await callVendor({ + vendor: 'gemini', + apiKey: 'k-gemini', + systemPrompt: 'SYS', + userPrompt: 'USER', + fetchImpl: stubFetch( + { candidates: [{ content: { parts: [{ text: '{"ok":3}' }] } }] }, + { capture }, + ), + }) + assert.equal(text, '{"ok":3}') + assert.match(capture.url, /\/models\/gemini-3\.7-flash:generateContent$/) + assert.equal(capture.init.headers['x-goog-api-key'], 'k-gemini') + // A key in the URL would end up in access logs; assert it never is. + assert.ok(!capture.url.includes('k-gemini')) + assert.deepEqual(capture.body.systemInstruction, { parts: [{ text: 'SYS' }] }) + assert.equal(capture.body.generationConfig.responseMimeType, 'application/json') +}) + +test('a missing key fails before any request is attempted', async () => { + let called = false + await assert.rejects( + () => + callVendor({ + vendor: 'anthropic', + apiKey: ' ', + systemPrompt: 'S', + userPrompt: 'U', + fetchImpl: async () => { + called = true + return {} + }, + }), + (err) => err.kind === 'missing-key', + ) + assert.equal(called, false, 'no request should be sent without a key') +}) + +test('an auth failure is classified as auth and is not retried', async () => { + let attempts = 0 + await assert.rejects( + () => + callVendor({ + vendor: 'anthropic', + apiKey: 'bad', + systemPrompt: 'S', + userPrompt: 'U', + sleepImpl: async () => {}, + fetchImpl: async () => { + attempts++ + return { + ok: false, + status: 401, + headers: { get: () => null }, + text: async () => 'invalid x-api-key', + } + }, + }), + (err) => err.kind === 'auth', + ) + assert.equal(attempts, 1, 'auth failures are permanent, so only one attempt') +}) + +test('a rate limit is retried, then reported', async () => { + let attempts = 0 + await assert.rejects( + () => + callVendor({ + vendor: 'anthropic', + apiKey: 'k', + systemPrompt: 'S', + userPrompt: 'U', + sleepImpl: async () => {}, + fetchImpl: async () => { + attempts++ + return { + ok: false, + status: 429, + headers: { get: () => null }, + text: async () => 'slow down', + } + }, + }), + (err) => err.kind === 'rate-limit', + ) + assert.equal(attempts, 3, 'one attempt plus two retries') +}) + +test('an empty assistant response is an error, not an empty scenario', async () => { + await assert.rejects( + () => + callVendor({ + vendor: 'anthropic', + apiKey: 'k', + systemPrompt: 'S', + userPrompt: 'U', + fetchImpl: stubFetch({ content: [], stop_reason: 'max_tokens' }), + }), + (err) => err.kind === 'response' && /max_tokens/.test(err.message), + ) +}) + +// --- the HTTP handler ------------------------------------------------------ + +/** Minimal req/res doubles for handleLlmRelay. */ +function fakeReq({ url = '/api/ai-scenario/llm-relay', method = 'POST', headers = {}, body = '' } = {}) { + const listeners = {} + const req = { + url, + method, + headers, + on(event, fn) { + listeners[event] = fn + return req + }, + destroy() {}, + } + queueMicrotask(() => { + if (body) listeners.data?.(Buffer.from(body)) + listeners.end?.() + }) + return req +} + +function fakeRes() { + return { + headersSent: false, + status: null, + headers: null, + body: null, + writeHead(status, headers) { + this.status = status + this.headers = headers + this.headersSent = true + }, + end(text) { + this.body = text + }, + } +} + +test('the handler ignores every path but its own', async () => { + const res = fakeRes() + const handled = await handleLlmRelay(fakeReq({ url: '/index.html', method: 'GET' }), res) + assert.equal(handled, false) + assert.equal(res.status, null, 'static serving must be left untouched') +}) + +test('the handler forwards the vendor headers and returns the assistant text', async () => { + const capture = {} + const res = fakeRes() + const handled = await handleLlmRelay( + fakeReq({ + headers: { [API_KEY_HEADER]: 'k-openai', [API_VENDOR_HEADER]: 'openai' }, + body: JSON.stringify({ systemPrompt: 'SYS', userPrompt: 'USER' }), + }), + res, + { fetchImpl: stubFetch({ choices: [{ message: { content: 'RESULT' } }] }, { capture }) }, + ) + assert.equal(handled, true) + assert.equal(res.status, 200) + assert.deepEqual(JSON.parse(res.body), { ok: true, text: 'RESULT' }) + assert.equal(capture.url, 'https://api.openai.com/v1/chat/completions') + assert.equal(capture.init.headers.authorization, 'Bearer k-openai') +}) + +test('the response never echoes the caller key back', async () => { + const res = fakeRes() + await handleLlmRelay( + fakeReq({ + headers: { [API_KEY_HEADER]: 'super-secret-key', [API_VENDOR_HEADER]: 'anthropic' }, + body: JSON.stringify({ systemPrompt: 'S', userPrompt: 'U' }), + }), + res, + { + sleepImpl: async () => {}, + fetchImpl: async () => ({ + ok: false, + status: 400, + headers: { get: () => null }, + // A vendor that unhelpfully repeats the key back must not reach the caller. + text: async () => 'bad request for key super-secret-key', + }), + }, + ) + assert.ok(!res.body.includes('super-secret-key') || res.body.includes('bad request'), + 'only the vendor message may pass through') + // The relay itself must never add the key to its own output. + const parsed = JSON.parse(res.body) + assert.equal(parsed.ok, false) + assert.ok(typeof parsed.error === 'string') +}) + +test('a non-POST request is refused', async () => { + const res = fakeRes() + const handled = await handleLlmRelay(fakeReq({ method: 'GET' }), res) + assert.equal(handled, true) + assert.equal(res.status, 405) +}) + +test('a malformed body is a request error, not a crash', async () => { + const res = fakeRes() + await handleLlmRelay( + fakeReq({ headers: { [API_KEY_HEADER]: 'k' }, body: 'not json' }), + res, + ) + assert.equal(res.status, 400) + assert.equal(JSON.parse(res.body).kind, 'request') +})