From ece61e0ba58a65bf8c4e563149fdaeac5c683fe0 Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sun, 23 Aug 2026 13:53:45 +0900 Subject: [PATCH 1/5] Add an LLM relay module for AI scenario generation drawtonomy generates scenarios in the browser, but browsers cannot call the AI vendors directly: CORS policy differs per vendor and some refuse browser-origin requests. This adds a small forwarding module so the page can post to one same-origin route instead. Supports Anthropic, OpenAI and Gemini. Each vendor's HTTP shape is described in one table, so adding or fixing a vendor touches a single place. The caller's API key travels per request in a header, is used only to build the outgoing call, and is never written to disk, added to the environment, or logged. Gemini takes its key in a header rather than the ?key= query parameter so it cannot land in an access log. Tests use a stub fetch, so they need no network and no key. --- .../drawtonomy-dev-server/bin/llmRelay.mjs | 364 ++++++++++++++++++ .../test/llmRelay.test.mjs | 299 ++++++++++++++ 2 files changed, 663 insertions(+) create mode 100644 packages/drawtonomy-dev-server/bin/llmRelay.mjs create mode 100644 packages/drawtonomy-dev-server/test/llmRelay.test.mjs diff --git a/packages/drawtonomy-dev-server/bin/llmRelay.mjs b/packages/drawtonomy-dev-server/bin/llmRelay.mjs new file mode 100644 index 0000000..cf13f52 --- /dev/null +++ b/packages/drawtonomy-dev-server/bin/llmRelay.mjs @@ -0,0 +1,364 @@ +// 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 + +/** 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(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/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') +}) From 0f475fecb744e8dbcb41880c016c571a0a80feb4 Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sun, 23 Aug 2026 13:53:54 +0900 Subject: [PATCH 2/5] Serve the LLM relay route alongside the static files The relay answers for its own path and reports back when it did not, so the existing static serving and SPA fallback are reached unchanged for every other request. --- packages/drawtonomy-dev-server/bin/serve.js | 17 ++++++++++++++++- packages/drawtonomy-dev-server/bin/serve.mjs | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) 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() From 3144883a8ac6e72e8042b81f5ed5fd535e039e29 Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sun, 23 Aug 2026 13:53:54 +0900 Subject: [PATCH 3/5] Document bring-your-own-key setup and release 0.2.0 README gains a section naming the three supported providers, where to get a key, and what the server does and does not do with it. --- packages/drawtonomy-dev-server/README.md | 25 +++++++++++++++++++++ packages/drawtonomy-dev-server/package.json | 5 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/drawtonomy-dev-server/README.md b/packages/drawtonomy-dev-server/README.md index fdaadb0..9cacfe2 100644 --- a/packages/drawtonomy-dev-server/README.md +++ b/packages/drawtonomy-dev-server/README.md @@ -39,6 +39,31 @@ 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 | 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" From e8c9fac4eaa831c8999fcf816a46f6015b36a0ce Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sun, 23 Aug 2026 14:13:39 +0900 Subject: [PATCH 4/5] Allow pointing the relay at a local stand-in vendor DRAWTONOMY_LLM_BASE_URL swaps the host of every vendor URL while keeping the path and headers, so a stand-in server sees exactly what a vendor would. This makes it possible to develop the relay, or exercise the whole generation flow, without a real key and without spending on API calls. --- .../drawtonomy-dev-server/bin/llmRelay.mjs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/drawtonomy-dev-server/bin/llmRelay.mjs b/packages/drawtonomy-dev-server/bin/llmRelay.mjs index cf13f52..21f3c87 100644 --- a/packages/drawtonomy-dev-server/bin/llmRelay.mjs +++ b/packages/drawtonomy-dev-server/bin/llmRelay.mjs @@ -37,6 +37,26 @@ 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 @@ -234,7 +254,7 @@ export async function callVendor(args) { const timer = setTimeout(() => controller.abort(), timeoutMs) let response try { - response = await fetchImpl(vendor.endpoint(model), { + response = await fetchImpl(withBaseOverride(vendor.endpoint(model)), { method: 'POST', headers: vendor.headers(apiKey), body, From 243e34e29f48778899feb5e12265ac3beba3b495 Mon Sep 17 00:00:00 2001 From: kosuke55 Date: Sun, 23 Aug 2026 14:13:55 +0900 Subject: [PATCH 5/5] Document DRAWTONOMY_LLM_BASE_URL in the options table --- packages/drawtonomy-dev-server/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/drawtonomy-dev-server/README.md b/packages/drawtonomy-dev-server/README.md index 9cacfe2..8a8451e 100644 --- a/packages/drawtonomy-dev-server/README.md +++ b/packages/drawtonomy-dev-server/README.md @@ -70,6 +70,7 @@ the file serving — do not expose this server to a network you do not control. |---------------------|---------|-------------| | `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