From 1ecc30850b0f22ea3318424f482e6c98a5e891e0 Mon Sep 17 00:00:00 2001 From: Frank Liu Date: Sun, 11 Oct 2026 00:17:27 -0700 Subject: [PATCH 1/3] Add generation environment and source --- .gitattributes | 2 + .github/workflows/ci.yml | 20 ++++ .gitignore | 7 ++ .prettierrc.json | 5 + README.md | 61 +++++++--- assets/gemini-3.8-flash.jpg | Bin 24147 -> 0 bytes assets/gpt-6-astra.jpg | Bin 21921 -> 0 bytes assets/grok-4.6.jpg | Bin 20028 -> 0 bytes package.json | 15 +++ src/generation.js | 57 ++++++++++ src/model-call.js | 217 ++++++++++++++++++++++++++++++++++++ src/prompt.js | 23 ++++ src/tasks.js | 25 +++++ test/generation.test.js | 177 +++++++++++++++++++++++++++++ test/prompt.test.js | 31 ++++++ tools/3jsbench.js | 133 ++++++++++++++++++++++ 16 files changed, 759 insertions(+), 14 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierrc.json delete mode 100644 assets/gemini-3.8-flash.jpg delete mode 100644 assets/gpt-6-astra.jpg delete mode 100644 assets/grok-4.6.jpg create mode 100644 package.json create mode 100644 src/generation.js create mode 100644 src/model-call.js create mode 100644 src/prompt.js create mode 100644 src/tasks.js create mode 100644 test/generation.test.js create mode 100644 test/prompt.test.js create mode 100755 tools/3jsbench.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9bdf215 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Task prompts are sent byte for byte; never convert their line endings. +tasks/** text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6dcc355 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22.12.0' + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f16145 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +# Generated attempts and prompts are outputs, not source. +runs/ +prompts/ +.DS_Store +.env +.env.* diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..dbe9d39 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "printWidth": 110, + "singleQuote": true, + "arrowParens": "avoid" +} diff --git a/README.md b/README.md index 767f725..7157794 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,54 @@ # 3JSBench -3JSBench evaluates LLMs on procedural text-to-3D generation of [Three.js](https://github.com/mrdoob/three.js) objects. +3JSBench evaluates LLMs on procedural text-to-3D generation of [Three.js](https://github.com/mrdoob/three.js) objects. Each task is a short brief, such as: > a cozy italian restaurant storefront with the restaurant's name on the sign above the door, warm lit letters over a striped awning -| | GPT-6 Astra | Gemini 3.8 Flash | Grok 4.6 | -| :--- | :---: | :---: | :---: | -| | GPT-6 Astra | Gemini 3.8 Flash | Grok 4.6 | -| Restaurant name is readable | ✓ | ✗ | ✗ | -| Awning is level or slopes down outward | ✓ | ✓ | ✗ | -| Awning is connected | ✓ | ✗ | ✗ | -| Sign touches the building facade | ✓ | ✓ | ✓ | -| Awning and sign don't intersect | ✓ | ✓ | ✓ | -| No flickering surfaces | ✓ | ✗ | ✗ | -| **Result** | **Pass** (16/16) | **Fail** (11/16) | **Fail** (12/16) | +Website: [3jsbench.com](https://3jsbench.com) -`tasks//prompt.md` holds the brief for each of the 100 tasks. The grader -source is coming soon. +This repository holds the 100 tasks and the code that generates attempts. We +will release the full benchmark soon, including the grader that renders each +attempt, maps its parts and runs the checks. -Website: [3jsbench.com](https://3jsbench.com) +- `tasks//prompt.md`: the brief. +- [`src/`](src/) and [`tools/3jsbench.js`](tools/3jsbench.js): generation. + +## Generate + +Requires Node 22.12+. There are no package dependencies. + +```sh +node tools/3jsbench.js generate --model vendor/model-name --out runs/my-model +``` + +This calls any OpenRouter model id with `OPENROUTER_API_KEY` and makes three +attempts per task by default. To generate with your own model, give the base +URL of an OpenAI-compatible server: + +```sh +node tools/3jsbench.js generate --model my-model --base-url https://my-server/v1 --out runs/my-model +``` + +Each attempt folder, `runs////`, holds the request, every +response, the parsed completion (served model and provider, tokens, cost) and +the reply, saved verbatim as `asset.ts`. To send the requests yourself, +`node tools/3jsbench.js prompts --out prompts/` writes each task's exact +messages. Run `node tools/3jsbench.js --help` for all options. + +## The request + +Each attempt is one request and one reply, with no tools and no second turn. +Every model gets the same system prompt ([`src/prompt.js`](src/prompt.js)), +then the task's brief as the user message: + +> Write a Three.js 0.184.0 module that builds the object in the brief. Default-export a function that takes no arguments and returns a new THREE.Group. Each call must return a new copy of the same object. Import only from "three", and do not load textures, files or other external resources. Use at most 1,000 meshes and 200,000 triangles in total, counting each instance separately. +Y is up, +Z is the front and +X is to the right. Unless the brief says otherwise, the lowest point is at y=0. Add a floor or other scenery only if the brief asks for it. Return only complete raw TypeScript, saved verbatim as asset.ts: no Markdown, explanation or patch. + +| Setting | Value | +| --- | --- | +| Reasoning | high | +| Output budget | 64,000 tokens | +| Temperature | not set | +| Route | OpenRouter, no fallbacks, data collection denied; `--provider` requires one provider | +| Retries | up to 3 sends, only when the provider did not process the request (rate limit, unavailable gateway, connection refused) | + +An empty or truncated reply is still saved as the attempt. diff --git a/assets/gemini-3.8-flash.jpg b/assets/gemini-3.8-flash.jpg deleted file mode 100644 index d2aadd829b95918cb5f74e8fc9cfcd00f18f0902..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24147 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 IpZXvF*;x(0wEzGB diff --git a/assets/gpt-6-astra.jpg b/assets/gpt-6-astra.jpg deleted file mode 100644 index 1d22134de39a780d8f128421908bf2819d68a15b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21921 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

Av_mH$tcaPaTrIEDP@`Vy>L?OdK&9; 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.12.0" + }, + "scripts": { + "test": "node --test \"test/**/*.test.js\"" + } +} diff --git a/src/generation.js b/src/generation.js new file mode 100644 index 0000000..34efed2 --- /dev/null +++ b/src/generation.js @@ -0,0 +1,57 @@ +// How an attempt is generated: one request, one reply, no tools and no repair +// turn. Every model is asked the same request (prompt.js) at high reasoning +// with the same token budget. The settings are written beside the attempts +// they produced. +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { PROMPT_SHA256, requestMessages } from './prompt.js'; +import { callModel } from './model-call.js'; + +/** An OpenAI-compatible server's base URL, such as https://host/v1: http(s), no credentials, query or fragment. */ +function validBaseUrl(value) { + let url; + try { + url = new URL(value); + } catch { + throw new Error('--base-url must be an http(s) URL'); + } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || /[?#]/.test(value)) + throw new Error('--base-url must be an http(s) URL without credentials, query or fragment'); + return url.href.replace(/\/+$/, ''); +} + +/** + * The settings for one model. Through OpenRouter (the default), `provider` + * requires one backend, so every attempt of a model is served by the same one; + * otherwise OpenRouter chooses per request. With `baseUrl`, the request goes + * directly to an OpenAI-compatible server. + */ +export function generationSettings({ model, provider = null, baseUrl = null }) { + if (typeof model !== 'string' || !model.trim()) throw new Error('--model is required'); + if (baseUrl !== null && provider !== null) throw new Error('--provider applies only to OpenRouter'); + if (provider !== null && !/^[a-z0-9][a-z0-9-]*$/.test(provider)) + throw new Error('--provider must be an OpenRouter provider slug'); + return { + model, + api: baseUrl !== null ? 'openai-compatible' : 'openrouter', + ...(baseUrl !== null ? { baseUrl: validBaseUrl(baseUrl) } : {}), + ...(provider !== null ? { provider } : {}), + reasoning: 'high', + maxTokens: 64_000, + promptSha256: PROMPT_SHA256, + // No local deadline: a request that timed out may still have been billed. + transport: { attempts: 3, maxResponseBytes: 8 * 1024 * 1024 }, + }; +} + +// Failures that are the model's own: it replied with nothing, or its reply was +// cut off. Its reply is still the attempt. Any other failure produced no reply. +const isModelFailure = reason => reason === 'empty-output' || String(reason).startsWith('incomplete-output:'); + +/** Generates one attempt into `dir`. The reply is saved verbatim as asset.ts. */ +export async function generateAttempt({ task, settings, dir, call = callModel }) { + const completion = await call({ settings, messages: requestMessages(task), dir }); + const replied = completion.ok || isModelFailure(completion.reason); + if (replied) await writeFile(path.join(dir, 'asset.ts'), completion.source ?? '', { flag: 'wx' }); + return { replied, reason: completion.reason }; +} diff --git a/src/model-call.js b/src/model-call.js new file mode 100644 index 0000000..dad43ca --- /dev/null +++ b/src/model-call.js @@ -0,0 +1,217 @@ +// One model call: the request body, the transport and its retry rule, and the +// reading of the reply. Every call leaves its request, each response and the +// parsed completion in the attempt directory. +import { createHash } from 'node:crypto'; +import http from 'node:http'; +import https from 'node:https'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; +// An OpenAI-compatible server (a lab's own, vLLM, SGLang, ...) has its own key +// variable, so a key set for another service is never sent to it. The key is +// optional, since local servers often need none. +const KEY_ENV = { openrouter: 'OPENROUTER_API_KEY', 'openai-compatible': 'THREEJSBENCH_ENDPOINT_KEY' }; +const compatible = settings => settings.api === 'openai-compatible'; +const urlOf = settings => (compatible(settings) ? settings.baseUrl + '/chat/completions' : OPENROUTER_URL); +// Finish reasons that mean the model ended its reply. TGI names a natural end eos_token or stop_sequence. +const stops = settings => (compatible(settings) ? ['stop', 'eos_token', 'stop_sequence'] : ['stop']); +const loopback = host => host === 'localhost' || host === '[::1]' || /^127(\.\d{1,3}){3}$/.test(host); +const providerSlug = name => + String(name) + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); +const sha256 = text => createHash('sha256').update(text).digest('hex'); + +/** The JSON body sent for `messages` under generation settings (generation.js). */ +export function requestBody(settings, messages) { + if (compatible(settings)) + // A plain Chat Completions request: no routing block and no usage extension. + return { + model: settings.model, + messages, + max_completion_tokens: settings.maxTokens, + reasoning_effort: settings.reasoning, + stream: false, + }; + return { + model: settings.model, + messages, + max_tokens: settings.maxTokens, + reasoning: { effort: settings.reasoning }, + provider: { + ...(settings.provider ? { only: [settings.provider] } : {}), + allow_fallbacks: false, + require_parameters: true, + data_collection: 'deny', + }, + usage: { include: true }, + stream: false, + }; +} + +function transport(body, { url, key, maxResponseBytes }) { + return new Promise((resolve, reject) => { + const request = (new URL(url).protocol === 'http:' ? http : https).request(url, { + method: 'POST', + headers: { + ...(key ? { Authorization: 'Bearer ' + key } : {}), + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }); + request.on('error', error => reject(new Error(error.code ?? error.message))); + request.on('response', response => { + const chunks = []; + let bytes = 0; + response.on('data', chunk => { + bytes += chunk.length; + if (bytes > maxResponseBytes) request.destroy(new Error('response-byte-limit')); + else chunks.push(chunk); + }); + response.on('error', error => reject(new Error(error.code ?? 'response-error'))); + response.on('end', () => + resolve({ + status: response.statusCode, + text: Buffer.concat(chunks).toString('utf8'), + requestId: response.headers['x-request-id'] ?? null, + }), + ); + }); + request.end(body); + }); +} + +// OpenRouter reports the cost in USD. +const callCost = usage => + typeof usage?.cost === 'number' && Number.isFinite(usage.cost) ? usage.cost : null; + +/** The completion read from a 200 response body: the reply, who served it, and why it failed if it did. */ +export function parseCompletion(body, settings) { + const choice = body.choices?.[0]; + const usage = body.usage ?? null, + finishReason = choice?.finish_reason ?? null; + const completion = { + source: typeof choice?.message?.content === 'string' ? choice.message.content : '', + completionId: body.id ?? null, + model: body.model ?? null, + // OpenRouter names the backend it routed to; another server records its host. + provider: compatible(settings) ? new URL(settings.baseUrl).host : (body.provider ?? null), + finishReason, + usage, + reasoningTokens: usage?.completion_tokens_details?.reasoning_tokens ?? null, + cost: callCost(usage), + }; + let reason = null; + const error = body.error ?? choice?.error; + if (error) { + completion.providerError = { + code: typeof error.code === 'number' || typeof error.code === 'string' ? error.code : null, + message: typeof error.message === 'string' ? error.message.slice(0, 500) : 'Provider error', + }; + reason = 'provider-error'; + } else if ( + compatible(settings) && + choice && + typeof choice.message?.content !== 'string' && + typeof finishReason === 'string' && + !stops(settings).includes(finishReason) + ) + // vLLM with a reasoning parser returns no content when the budget runs out + // mid-reasoning: the model's truncation, not a malformed response. + reason = 'incomplete-output:' + finishReason; + else if (!choice || typeof choice.message?.content !== 'string') reason = 'invalid-provider-response'; + else if (!choice.message.content.trim()) reason = 'empty-output'; + else if ( + settings.provider && + (typeof completion.provider !== 'string' || + providerSlug(completion.provider) !== providerSlug(settings.provider)) + ) + reason = 'served-provider-mismatch-or-missing'; + // An OpenAI-compatible server may name its model differently; the served name is saved, not checked. + else if (!compatible(settings) && completion.model !== settings.model) + reason = 'served-model-mismatch-or-missing'; + else if (!stops(settings).includes(finishReason)) reason = 'incomplete-output:' + finishReason; + return { ...completion, ok: reason === null, reason }; +} + +const writeJson = (file, value) => + writeFile(file, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); + +/** + * Sends one request and returns its completion. `dir` must not exist yet: a call + * the provider may have accepted is never sent again by a rerun. The request is + * re-sent only when the provider provably did not process it: rate limiting, an + * unavailable gateway, or a connection that never reached it. + */ +export async function callModel({ + settings, + messages, + dir, + key = process.env[KEY_ENV[settings.api]], + send = transport, + delay = ms => new Promise(resolve => setTimeout(resolve, ms)), +}) { + if (!key && !compatible(settings)) throw new Error(KEY_ENV[settings.api] + ' is required'); + const url = urlOf(settings), + target = new URL(url); + if (key && target.protocol === 'http:' && !loopback(target.hostname)) + throw new Error( + `Refusing to send ${KEY_ENV[settings.api]} over plain http to ${target.host}; use https or a loopback address`, + ); + await mkdir(dir); + const body = requestBody(settings, messages), + text = JSON.stringify(body), + requestSha256 = sha256(text), + calls = []; + await writeJson(path.join(dir, 'request.json'), body); + for (let attempt = 1; attempt <= settings.transport.attempts; attempt++) { + const start = new Date().toISOString(), + clock = performance.now(); + let response = null, + error = null; + try { + response = await send(text, { url, key, maxResponseBytes: settings.transport.maxResponseBytes }); + } catch (e) { + error = String(e.message).slice(0, 200); + } + const call = { + attempt, + start, + durationMs: Math.round(performance.now() - clock), + requestSha256, + status: response?.status ?? null, + requestId: response?.requestId ?? null, + transportError: error, + }; + if (response) { + await writeFile(path.join(dir, `response-${attempt}.json`), response.text, { flag: 'wx', mode: 0o600 }); + call.responseSha256 = sha256(response.text); + } + calls.push(call); + if (response?.status === 200) { + let completion; + try { + completion = parseCompletion(JSON.parse(response.text), settings); + } catch { + completion = { ok: false, reason: 'invalid-provider-json' }; + } + const result = { ...completion, requestSha256, calls }; + await writeJson(path.join(dir, 'completion.json'), result); + return result; + } + const unreached = error !== null && /ECONNREFUSED|ENOTFOUND|EAI_AGAIN/.test(error); + const retryable = unreached || [429, 502, 503, 504].includes(response?.status); + if (!retryable || attempt === settings.transport.attempts) { + const result = { + ok: false, + reason: error ? 'transport-failure' : 'http-' + response.status, + requestSha256, + calls, + }; + await writeJson(path.join(dir, 'completion.json'), result); + return result; + } + await delay(Math.min(5000, 1000 * 2 ** (attempt - 1))); + } +} diff --git a/src/prompt.js b/src/prompt.js new file mode 100644 index 0000000..fe08b5b --- /dev/null +++ b/src/prompt.js @@ -0,0 +1,23 @@ +// The request a model is sent for a task: one system prompt shared by every +// task, then the task's brief alone. The prompt text is pinned by hash in +// test/prompt.test.js; changing it changes what every model is asked. +import { createHash } from 'node:crypto'; + +export const SYSTEM_PROMPT = + 'Write a Three.js 0.184.0 module that builds the object in the brief. Default-export a function that takes ' + + 'no arguments and returns a new THREE.Group. Each call must return a new copy of the same object. Import only ' + + 'from "three", and do not load textures, files or other external resources. Use at most 1,000 meshes and ' + + '200,000 triangles in total, counting each instance separately. +Y is up, +Z is the front and +X is to the ' + + 'right. Unless the brief says otherwise, the lowest point is at y=0. Add a floor or other scenery only if the ' + + 'brief asks for it. Return only complete raw TypeScript, saved verbatim as asset.ts: no Markdown, explanation ' + + 'or patch.'; + +export const PROMPT_SHA256 = createHash('sha256').update(SYSTEM_PROMPT).digest('hex'); + +/** The exact messages for a task from loadTask. */ +export function requestMessages(task) { + return [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: [{ type: 'text', text: task.prompt }] }, + ]; +} diff --git a/src/tasks.js b/src/tasks.js new file mode 100644 index 0000000..1e42026 --- /dev/null +++ b/src/tasks.js @@ -0,0 +1,25 @@ +// A task is one folder, tasks//, holding prompt.md: the brief the model +// is given, plus exactly one final LF. +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const TASKS_DIR = fileURLToPath(new URL('../tasks/', import.meta.url)); + +/** Task folder names, sorted. */ +export async function listTasks(dir = TASKS_DIR) { + const entries = await readdir(dir, { withFileTypes: true }); + return entries + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort(); +} + +/** One task, by folder name: { id, prompt }. */ +export async function loadTask(name, dir = TASKS_DIR) { + if (!(await listTasks(dir)).includes(name)) throw new Error('Unknown task: ' + name); + const text = await readFile(path.join(dir, name, 'prompt.md'), 'utf8'); + if (text.includes('\r') || !text.endsWith('\n') || text.endsWith('\n\n') || !text.trim()) + throw new Error(`${name}/prompt.md must be nonempty, end with exactly one LF and contain no CR`); + return { id: name, prompt: text.slice(0, -1) }; +} diff --git a/test/generation.test.js b/test/generation.test.js new file mode 100644 index 0000000..01239d7 --- /dev/null +++ b/test/generation.test.js @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { loadTask } from '../src/tasks.js'; +import { requestMessages } from '../src/prompt.js'; +import { generateAttempt, generationSettings } from '../src/generation.js'; +import { callModel, parseCompletion, requestBody } from '../src/model-call.js'; + +const routed = generationSettings({ model: 'vendor/model', provider: 'vendor' }); +const lab = generationSettings({ model: 'my-model', baseUrl: 'https://models.example/v1/' }); +const scratch = async () => path.join(await mkdtemp(path.join(tmpdir(), '3jsbench-')), 'attempt'); +const noDelay = async () => {}; + +const completion = (content, extra = {}) => ({ + id: 'gen-1', + model: 'vendor/model', + provider: 'Vendor', + choices: [{ message: { content }, finish_reason: 'stop' }], + usage: { cost: 0.5, completion_tokens_details: { reasoning_tokens: 100 } }, + ...extra, +}); +const reply = (status, body) => ({ status, text: JSON.stringify(body), requestId: 'req' }); + +test('every model is asked at high reasoning with the same budget', () => { + assert.deepEqual(routed, { + model: 'vendor/model', + api: 'openrouter', + provider: 'vendor', + reasoning: 'high', + maxTokens: 64_000, + promptSha256: '4877dcbae3c85c37078fae7a2b6a7a87060550e5855eeed0eeee26296b4359c6', + transport: { attempts: 3, maxResponseBytes: 8 * 1024 * 1024 }, + }); + const body = requestBody(routed, []); + assert.deepEqual(body.provider, { + only: ['vendor'], + allow_fallbacks: false, + require_parameters: true, + data_collection: 'deny', + }); + assert.deepEqual(body.reasoning, { effort: 'high' }); + assert.equal(body.max_tokens, 64_000); + assert.equal('temperature' in body, false); + // Without --provider, OpenRouter chooses the provider, still without fallbacks. + assert.deepEqual(requestBody(generationSettings({ model: 'vendor/model' }), []).provider, { + allow_fallbacks: false, + require_parameters: true, + data_collection: 'deny', + }); +}); + +test('an OpenAI-compatible server gets a plain Chat Completions request', () => { + assert.equal(lab.baseUrl, 'https://models.example/v1'); + assert.equal(lab.provider, undefined); + assert.deepEqual(requestBody(lab, []), { + model: 'my-model', + messages: [], + max_completion_tokens: 64_000, + reasoning_effort: 'high', + stream: false, + }); + assert.throws(() => generationSettings({ model: 'm', baseUrl: 'https://u:p@host/v1' }), /credentials/); + assert.throws(() => generationSettings({ model: 'm', baseUrl: 'ftp://host/v1' }), /http/); + assert.throws( + () => generationSettings({ model: 'm', baseUrl: 'https://host/v1', provider: 'x' }), + /OpenRouter/, + ); + assert.throws(() => generationSettings({ model: 'm', provider: 'Bad Slug' }), /slug/); + assert.throws(() => generationSettings({}), /--model/); +}); + +test('a reply is accepted only from the requested model on the required provider', () => { + assert.equal(parseCompletion(completion('code'), routed).ok, true); + assert.equal(parseCompletion(completion(' '), routed).reason, 'empty-output'); + assert.equal( + parseCompletion(completion('code', { provider: 'Google' }), routed).reason, + 'served-provider-mismatch-or-missing', + ); + assert.equal( + parseCompletion(completion('code', { model: 'vendor/other' }), routed).reason, + 'served-model-mismatch-or-missing', + ); + const cut = completion('code'); + cut.choices[0].finish_reason = 'length'; + assert.equal(parseCompletion(cut, routed).reason, 'incomplete-output:length'); + assert.equal(parseCompletion({ error: { code: 400, message: 'bad' } }, routed).reason, 'provider-error'); + assert.equal(parseCompletion({ choices: [] }, routed).reason, 'invalid-provider-response'); + // An OpenAI-compatible server may name its model differently; the name is saved, not checked. + const served = parseCompletion(completion('code', { model: 'my-model-2026', provider: undefined }), lab); + assert.equal(served.ok, true); + assert.equal(served.model, 'my-model-2026'); +}); + +test('the request is re-sent only when the provider provably did not process it', async () => { + const sent = []; + const sequence = responses => async body => { + sent.push(body); + const next = responses.shift(); + if (next instanceof Error) throw next; + return next; + }; + const retried = await callModel({ + settings: routed, + messages: [], + dir: await scratch(), + key: 'k', + delay: noDelay, + send: sequence([new Error('ECONNREFUSED'), reply(429, {}), reply(200, completion('code'))]), + }); + assert.equal(retried.ok, true); + assert.equal(retried.calls.length, 3); + assert.equal(new Set(sent).size, 1); + + for (const failure of [reply(500, {}), new Error('ECONNRESET')]) { + const result = await callModel({ + settings: routed, + messages: [], + dir: await scratch(), + key: 'k', + delay: noDelay, + send: sequence([failure, reply(200, completion('code'))]), + }); + assert.equal(result.ok, false); + assert.equal(result.calls.length, 1); + } +}); + +test('a call leaves its request, responses and completion, and never reuses a directory', async () => { + const dir = await scratch(); + const send = async () => reply(200, completion('code')); + await callModel({ settings: routed, messages: [], dir, key: 'k', send }); + assert.deepEqual((await readdir(dir)).sort(), ['completion.json', 'request.json', 'response-1.json']); + await assert.rejects(callModel({ settings: routed, messages: [], dir, key: 'k', send }), /EEXIST/); +}); + +test('keys are required for OpenRouter and never sent over plain http to a remote host', async () => { + await assert.rejects( + callModel({ settings: routed, messages: [], dir: await scratch(), key: '' }), + /OPENROUTER_API_KEY/, + ); + const remote = generationSettings({ model: 'm', baseUrl: 'http://models.example/v1' }); + await assert.rejects( + callModel({ settings: remote, messages: [], dir: await scratch(), key: 'k' }), + /plain http/, + ); + const local = generationSettings({ model: 'm', baseUrl: 'http://127.0.0.1:8000/v1' }); + const send = async () => reply(200, completion('code', { model: 'm' })); + assert.equal( + (await callModel({ settings: local, messages: [], dir: await scratch(), key: 'k', send })).ok, + true, + ); +}); + +test('the reply is saved verbatim, including an empty or truncated one; no reply saves nothing', async () => { + const task = await loadTask('spiral-staircase'); + const fenced = '```ts\nexport default () => null;\n```'; + for (const [result, saved] of [ + [{ ok: true, reason: null, source: fenced }, fenced], + [{ ok: false, reason: 'empty-output', source: '' }, ''], + [{ ok: false, reason: 'incomplete-output:length', source: 'export default' }, 'export default'], + [{ ok: false, reason: 'http-500' }, null], + [{ ok: false, reason: 'served-model-mismatch-or-missing', source: 'code' }, null], + ]) { + const dir = await scratch(); + const call = async ({ messages, dir: target }) => { + assert.deepEqual(messages, requestMessages(task)); + await mkdir(target); + return result; + }; + const { replied } = await generateAttempt({ task, settings: routed, dir, call }); + assert.equal(replied, saved !== null, result.reason ?? 'ok'); + const text = await readFile(path.join(dir, 'asset.ts'), 'utf8').catch(() => null); + assert.equal(text, saved); + } +}); diff --git a/test/prompt.test.js b/test/prompt.test.js new file mode 100644 index 0000000..85b4539 --- /dev/null +++ b/test/prompt.test.js @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { listTasks, loadTask } from '../src/tasks.js'; +import { PROMPT_SHA256, SYSTEM_PROMPT, requestMessages } from '../src/prompt.js'; + +test('the system prompt is pinned', () => { + assert.equal(PROMPT_SHA256, '4877dcbae3c85c37078fae7a2b6a7a87060550e5855eeed0eeee26296b4359c6'); + assert.equal(createHash('sha256').update(SYSTEM_PROMPT).digest('hex'), PROMPT_SHA256); +}); + +test('the user message is the brief alone', async () => { + const task = await loadTask('spiral-staircase'); + assert.deepEqual(requestMessages(task), [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: [{ type: 'text', text: task.prompt }] }, + ]); +}); + +test('every task loads, with a brief ending in no newline', async () => { + const names = await listTasks(); + assert.equal(names.length, 100); + for (const name of names) { + const task = await loadTask(name); + assert.ok(task.prompt.trim() && !task.prompt.endsWith('\n'), name); + } +}); + +test('an unknown task is refused', async () => { + await assert.rejects(loadTask('../tasks'), /Unknown task/); +}); diff --git a/tools/3jsbench.js b/tools/3jsbench.js new file mode 100755 index 0000000..ec43a98 --- /dev/null +++ b/tools/3jsbench.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// 3jsbench command line: list the tasks, write their exact requests, generate attempts. +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { parseArgs } from 'node:util'; +import { listTasks, loadTask } from '../src/tasks.js'; +import { requestMessages } from '../src/prompt.js'; +import { generateAttempt, generationSettings } from '../src/generation.js'; + +const HELP = `3jsbench: generate Three.js programs for the 3JSBench tasks. + + 3jsbench tasks + 3jsbench prompts --out DIR + 3jsbench generate --model MODEL --out DIR [--tasks all|a,b,...] [--attempts 3] [--concurrency 4] + [--provider SLUG | --base-url URL] + +prompts writes each task's exact request (system and user messages) to DIR/.json. +generate sends each request to MODEL and saves the reply verbatim as DIR///asset.ts, +beside the request, every response and the parsed completion. DIR/generation.json records the +settings. Rerunning generate skips finished attempts and never re-sends a request that may have +reached the provider; delete an attempt folder to send its request again. + +generate calls MODEL through OpenRouter (OPENROUTER_API_KEY); --provider requires one OpenRouter +provider. With --base-url, it calls an OpenAI-compatible server directly, with the +optional key THREEJSBENCH_ENDPOINT_KEY, never sent over plain http except to a loopback address.`; + +const OPTIONS = { + out: { type: 'string' }, + model: { type: 'string' }, + tasks: { type: 'string', default: 'all' }, + attempts: { type: 'string', default: '3' }, + concurrency: { type: 'string', default: '4' }, + provider: { type: 'string' }, + 'base-url': { type: 'string' }, + help: { type: 'boolean', short: 'h', default: false }, +}; + +function count(value, flag) { + const n = Number(value); + if (!Number.isInteger(n) || n < 1) throw new Error(flag + ' must be a positive integer'); + return n; +} + +async function selectTasks(list) { + const names = list === 'all' ? await listTasks() : list.split(','); + return Promise.all(names.map(name => loadTask(name))); +} + +const exists = file => + access(file).then( + () => true, + () => false, + ); + +/** Runs `work` over `items`, at most `limit` at a time. */ +async function pool(items, limit, work) { + let next = 0; + const worker = async () => { + while (next < items.length) await work(items[next++]); + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); +} + +/** The settings a run directory records; a rerun must use the same ones. */ +async function recordSettings(out, settings) { + const file = path.join(out, 'generation.json'), + text = JSON.stringify(settings, null, 2) + '\n'; + await mkdir(out, { recursive: true }); + if (!(await exists(file))) return writeFile(file, text, { flag: 'wx' }); + if ((await readFile(file, 'utf8')) !== text) + throw new Error(`${file} records other settings; use another --out`); +} + +async function generate(v) { + if (!v.out) throw new Error('--out is required'); + const settings = generationSettings({ + model: v.model, + provider: v.provider ?? null, + baseUrl: v['base-url'] ?? null, + }); + const tasks = await selectTasks(v.tasks); + const attempts = count(v.attempts, '--attempts'), + concurrency = count(v.concurrency, '--concurrency'); + await recordSettings(v.out, settings); + const jobs = tasks.flatMap(task => + Array.from({ length: attempts }, (_, n) => ({ task, dir: path.join(v.out, task.id, String(n + 1)) })), + ); + const tally = { replied: 0, unanswered: 0, unfinished: 0 }; + await pool(jobs, concurrency, async ({ task, dir }) => { + const name = path.relative(v.out, dir); + if (await exists(path.join(dir, 'completion.json'))) + return tally[(await exists(path.join(dir, 'asset.ts'))) ? 'replied' : 'unanswered']++; + if (await exists(dir)) { + // A call was sent but never finished; it may have been billed. + console.error(`${name}: a previous call did not finish and may have reached the provider`); + return tally.unfinished++; + } + await mkdir(path.dirname(dir), { recursive: true }); + const { replied, reason } = await generateAttempt({ task, settings, dir }); + tally[replied ? 'replied' : 'unanswered']++; + console.error(`${name}: ${replied ? 'reply saved' : 'no reply'}${reason ? ` (${reason})` : ''}`); + }); + const unfinished = tally.unfinished ? `, ${tally.unfinished} unfinished` : ''; + console.log( + `${jobs.length} attempts: ${tally.replied} with a reply, ${tally.unanswered} without${unfinished}.`, + ); + if (tally.unanswered || tally.unfinished) + console.log('To send a request again, delete its attempt folder and rerun.'); +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + const { values: v } = parseArgs({ args, options: OPTIONS, strict: true }); + if (!command || v.help) return console.log(HELP); + if (command === 'tasks') return console.log((await listTasks()).join('\n')); + if (command === 'prompts') { + if (!v.out) throw new Error('--out is required'); + await mkdir(v.out, { recursive: true }); + for (const task of await selectTasks('all')) + await writeFile( + path.join(v.out, task.id + '.json'), + JSON.stringify({ messages: requestMessages(task) }, null, 2) + '\n', + ); + return; + } + if (command === 'generate') return generate(v); + throw new Error('Unknown command: ' + command + '\n\n' + HELP); +} + +main().catch(error => { + console.error(error.message); + process.exitCode = 1; +}); From e80b4704e13b7f4f61ee25d6570b077f2e8e7f30 Mon Sep 17 00:00:00 2001 From: Frank Liu Date: Sun, 11 Oct 2026 00:20:31 -0700 Subject: [PATCH 2/3] Restore interrupted replies, refuse changed requests and repeated tasks --- src/generation.js | 39 ++++++++++++++++++++++++++++++++++----- test/generation.test.js | 24 ++++++++++++++++++++++-- tools/3jsbench.js | 8 +++++--- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/generation.js b/src/generation.js index 34efed2..b6d732a 100644 --- a/src/generation.js +++ b/src/generation.js @@ -2,7 +2,7 @@ // turn. Every model is asked the same request (prompt.js) at high reasoning // with the same token budget. The settings are written beside the attempts // they produced. -import { writeFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { PROMPT_SHA256, requestMessages } from './prompt.js'; import { callModel } from './model-call.js'; @@ -48,10 +48,39 @@ export function generationSettings({ model, provider = null, baseUrl = null }) { // cut off. Its reply is still the attempt. Any other failure produced no reply. const isModelFailure = reason => reason === 'empty-output' || String(reason).startsWith('incomplete-output:'); -/** Generates one attempt into `dir`. The reply is saved verbatim as asset.ts. */ -export async function generateAttempt({ task, settings, dir, call = callModel }) { - const completion = await call({ settings, messages: requestMessages(task), dir }); +/** Saves a completion's reply verbatim as asset.ts, unless it is already saved. */ +async function saveReply(dir, completion) { const replied = completion.ok || isModelFailure(completion.reason); - if (replied) await writeFile(path.join(dir, 'asset.ts'), completion.source ?? '', { flag: 'wx' }); + if (replied) + await writeFile(path.join(dir, 'asset.ts'), completion.source ?? '', { flag: 'wx', mode: 0o600 }).catch( + error => { + if (error.code !== 'EEXIST') throw error; + }, + ); return { replied, reason: completion.reason }; } + +/** Generates one attempt into `dir`, which must not exist yet. */ +export async function generateAttempt({ task, settings, dir, call = callModel }) { + return saveReply(dir, await call({ settings, messages: requestMessages(task), dir })); +} + +/** + * An attempt a previous run finished in `dir`, read from its saved files without + * sending anything; null if it has no completion. A reply a run saved in + * completion.json but was stopped before writing to asset.ts is written now. An + * attempt sent with a different request, such as an edited brief, is refused. + */ +export async function savedAttempt({ task, dir }) { + let completion; + try { + completion = JSON.parse(await readFile(path.join(dir, 'completion.json'), 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + const request = JSON.parse(await readFile(path.join(dir, 'request.json'), 'utf8')); + if (JSON.stringify(request.messages) !== JSON.stringify(requestMessages(task))) + throw new Error(`${dir} was sent a different request; use another --out`); + return saveReply(dir, completion); +} diff --git a/test/generation.test.js b/test/generation.test.js index 01239d7..1f5d529 100644 --- a/test/generation.test.js +++ b/test/generation.test.js @@ -1,11 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, readdir } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { loadTask } from '../src/tasks.js'; import { requestMessages } from '../src/prompt.js'; -import { generateAttempt, generationSettings } from '../src/generation.js'; +import { generateAttempt, generationSettings, savedAttempt } from '../src/generation.js'; import { callModel, parseCompletion, requestBody } from '../src/model-call.js'; const routed = generationSettings({ model: 'vendor/model', provider: 'vendor' }); @@ -175,3 +175,23 @@ test('the reply is saved verbatim, including an empty or truncated one; no reply assert.equal(text, saved); } }); + +test('a rerun restores a reply saved before an interruption, and refuses a changed request', async () => { + const task = await loadTask('spiral-staircase'); + const dir = await scratch(); + assert.equal(await savedAttempt({ task, dir }), null); + const send = async () => reply(200, completion('export default () => null;')); + await callModel({ settings: routed, messages: requestMessages(task), dir, key: 'k', send }); + // The run stopped after completion.json and before asset.ts. + assert.deepEqual(await savedAttempt({ task, dir }), { replied: true, reason: null }); + assert.equal(await readFile(path.join(dir, 'asset.ts'), 'utf8'), 'export default () => null;'); + assert.equal((await stat(path.join(dir, 'asset.ts'))).mode & 0o777, 0o600); + // A second rerun leaves the saved reply as it is. + await writeFile(path.join(dir, 'asset.ts'), 'edited'); + await savedAttempt({ task, dir }); + assert.equal(await readFile(path.join(dir, 'asset.ts'), 'utf8'), 'edited'); + await assert.rejects( + savedAttempt({ task: { ...task, prompt: 'another brief' }, dir }), + /different request/, + ); +}); diff --git a/tools/3jsbench.js b/tools/3jsbench.js index ec43a98..f9a2396 100755 --- a/tools/3jsbench.js +++ b/tools/3jsbench.js @@ -5,7 +5,7 @@ import path from 'node:path'; import { parseArgs } from 'node:util'; import { listTasks, loadTask } from '../src/tasks.js'; import { requestMessages } from '../src/prompt.js'; -import { generateAttempt, generationSettings } from '../src/generation.js'; +import { generateAttempt, generationSettings, savedAttempt } from '../src/generation.js'; const HELP = `3jsbench: generate Three.js programs for the 3JSBench tasks. @@ -43,6 +43,7 @@ function count(value, flag) { async function selectTasks(list) { const names = list === 'all' ? await listTasks() : list.split(','); + if (new Set(names).size !== names.length) throw new Error('--tasks names a task twice'); return Promise.all(names.map(name => loadTask(name))); } @@ -88,8 +89,8 @@ async function generate(v) { const tally = { replied: 0, unanswered: 0, unfinished: 0 }; await pool(jobs, concurrency, async ({ task, dir }) => { const name = path.relative(v.out, dir); - if (await exists(path.join(dir, 'completion.json'))) - return tally[(await exists(path.join(dir, 'asset.ts'))) ? 'replied' : 'unanswered']++; + const saved = await savedAttempt({ task, dir }); + if (saved) return tally[saved.replied ? 'replied' : 'unanswered']++; if (await exists(dir)) { // A call was sent but never finished; it may have been billed. console.error(`${name}: a previous call did not finish and may have reached the provider`); @@ -110,6 +111,7 @@ async function generate(v) { async function main() { const [command, ...args] = process.argv.slice(2); + if (command === '--help' || command === '-h') return console.log(HELP); const { values: v } = parseArgs({ args, options: OPTIONS, strict: true }); if (!command || v.help) return console.log(HELP); if (command === 'tasks') return console.log((await listTasks()).join('\n')); From e1d4e8c63fc80e35760dbca3c3716eec665de26e Mon Sep 17 00:00:00 2001 From: Frank Liu Date: Sun, 11 Oct 2026 00:22:30 -0700 Subject: [PATCH 3/3] Write completion.json whole or not at all --- src/model-call.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/model-call.js b/src/model-call.js index dad43ca..3692db2 100644 --- a/src/model-call.js +++ b/src/model-call.js @@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'; import http from 'node:http'; import https from 'node:https'; -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, rename, writeFile } from 'node:fs/promises'; import path from 'node:path'; const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; @@ -138,6 +138,13 @@ export function parseCompletion(body, settings) { const writeJson = (file, value) => writeFile(file, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); +// Written whole or not at all, so a rerun never reads a partial completion. +async function writeCompletion(dir, value) { + const file = path.join(dir, 'completion.json'); + await writeJson(file + '.partial', value); + await rename(file + '.partial', file); +} + /** * Sends one request and returns its completion. `dir` must not exist yet: a call * the provider may have accepted is never sent again by a rerun. The request is @@ -197,7 +204,7 @@ export async function callModel({ completion = { ok: false, reason: 'invalid-provider-json' }; } const result = { ...completion, requestSha256, calls }; - await writeJson(path.join(dir, 'completion.json'), result); + await writeCompletion(dir, result); return result; } const unreached = error !== null && /ECONNREFUSED|ENOTFOUND|EAI_AGAIN/.test(error); @@ -209,7 +216,7 @@ export async function callModel({ requestSha256, calls, }; - await writeJson(path.join(dir, 'completion.json'), result); + await writeCompletion(dir, result); return result; } await delay(Math.min(5000, 1000 * 2 ** (attempt - 1)));