feat(benchmark): particle-1 and DeepSeek V4.1 Flash awareness, gateway provider, preregistered model panel (GSTA-173)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-30 01:04:59 +00:00
1 parent 53db038c6b
commit d2098217c0
41 files changed
+1179 -38

No files matched your search

+2 -2
View File
@@ -196,12 +196,12 @@ If C: ask for the path. Verify it exists. Use as positional argument.
## Step 2: Choose providers
```bash
"$BIN" --prompt "unused, dry-run" --models claude,gpt,gemini --dry-run
"$BIN" --prompt "unused, dry-run" --models claude,gpt,gemini,particle,deepseek --dry-run
```
Show the dry-run output. The "Adapter availability" section tells the user which providers will actually run (OK) vs skip (NOT READY — remediation hint included).
If ALL three show NOT READY: stop with a clear message — benchmark can't run without at least one authed provider. Suggest `claude login`, `codex login`, or `gemini login` / `export GOOGLE_API_KEY`.
If every provider shows NOT READY: stop with a clear message — benchmark can't run without at least one authed provider. Suggest `claude login`, `codex login`, `gemini login` / `export GOOGLE_API_KEY`, or `export OPENROUTER_API_KEY` for particle and deepseek (they run in Claude Code, so they also need the `claude` CLI).
If at least one is OK: AskUserQuestion:
- **Simplify:** "Which models should we include? The dry-run above showed which are authed. Unauthed ones will be skipped cleanly — they won't abort the batch."
+4 -3
View File
@@ -4,7 +4,8 @@ preamble-tier: 1
version: 1.0.0
description: |
Cross-model benchmark for gstack skills. Runs the same prompt through Claude,
GPT (via Codex CLI), and Gemini side-by-side — compares latency, tokens, cost,
GPT (via Codex CLI), Gemini, and (through OpenRouter in Claude Code) particle-1
and DeepSeek V4.1 Flash side-by-side — compares latency, tokens, cost,
and optionally quality via LLM judge. Answers "which model is actually best
for this skill?" with data instead of vibes. Separate from /benchmark, which
measures web page performance. Use when: "benchmark models", "compare models",
@@ -71,12 +72,12 @@ If C: ask for the path. Verify it exists. Use as positional argument.
## Step 2: Choose providers
```bash
"$BIN" --prompt "unused, dry-run" --models claude,gpt,gemini --dry-run
"$BIN" --prompt "unused, dry-run" --models claude,gpt,gemini,particle,deepseek --dry-run
```
Show the dry-run output. The "Adapter availability" section tells the user which providers will actually run (OK) vs skip (NOT READY — remediation hint included).
If ALL three show NOT READY: stop with a clear message — benchmark can't run without at least one authed provider. Suggest `claude login`, `codex login`, or `gemini login` / `export GOOGLE_API_KEY`.
If every provider shows NOT READY: stop with a clear message — benchmark can't run without at least one authed provider. Suggest `claude login`, `codex login`, `gemini login` / `export GOOGLE_API_KEY`, or `export OPENROUTER_API_KEY` for particle and deepseek (they run in Claude Code, so they also need the `claude` CLI).
If at least one is OK: AskUserQuestion:
- **Simplify:** "Which models should we include? The dry-run above showed which are authed. Unauthed ones will be skipped cleanly — they won't abort the batch."
+9 -16
View File
@@ -7,7 +7,8 @@
* gstack-model-benchmark <skill-or-prompt-file> [options]
*
* Options:
* --models claude,gpt,gemini Comma-separated provider list (default: claude)
* --models claude,gpt,gemini Comma-separated provider list (default: claude); particle and
* deepseek run in Claude Code through OpenRouter (OPENROUTER_API_KEY)
* --prompt "<text>" Inline prompt instead of a file
* --workdir <path> Working dir passed to each CLI (default: cwd)
* --timeout-ms <n> Per-provider timeout (default: 300000)
@@ -28,15 +29,7 @@ import '../lib/conductor-env-shim';
import * as fs from 'fs';
import * as path from 'path';
import { runBenchmark, formatTable, formatJson, formatMarkdown, type BenchmarkInput } from '../test/helpers/benchmark-runner';
import { ClaudeAdapter } from '../test/helpers/providers/claude';
import { GptAdapter } from '../test/helpers/providers/gpt';
import { GeminiAdapter } from '../test/helpers/providers/gemini';
const ADAPTER_FACTORIES = {
claude: () => new ClaudeAdapter(),
gpt: () => new GptAdapter(),
gemini: () => new GeminiAdapter(),
};
import { PROVIDERS, PROVIDER_NAMES, isProviderName, type ProviderName } from '../test/helpers/providers/registry';
type OutputFormat = 'table' | 'json' | 'markdown';
@@ -76,13 +69,13 @@ function positionalArgs(args: string[]): string[] {
return positional;
}
function parseProviders(s: string | undefined): Array<'claude' | 'gpt' | 'gemini'> {
function parseProviders(s: string | undefined): ProviderName[] {
if (!s) return ['claude'];
const seen = new Set<'claude' | 'gpt' | 'gemini'>();
const seen = new Set<ProviderName>();
for (const p of s.split(',').map(x => x.trim()).filter(Boolean)) {
if (p === 'claude' || p === 'gpt' || p === 'gemini') seen.add(p);
if (isProviderName(p)) seen.add(p);
else {
console.error(`WARN: unknown provider '${p}' — skipping. Valid: claude, gpt, gemini.`);
console.error(`WARN: unknown provider '${p}' — skipping. Valid: ${PROVIDER_NAMES.join(', ')}.`);
}
}
return seen.size ? Array.from(seen) : ['claude'];
@@ -163,7 +156,7 @@ async function main(): Promise<void> {
async function dryRunReport(opts: {
prompt: string;
providers: Array<'claude' | 'gpt' | 'gemini'>;
providers: ProviderName[];
workdir: string;
timeoutMs: number;
output: OutputFormat;
@@ -181,7 +174,7 @@ async function dryRunReport(opts: {
lines.push('Adapter availability:');
let authFailures = 0;
for (const name of opts.providers) {
const factory = ADAPTER_FACTORIES[name];
const factory = PROVIDERS[name];
if (!factory) {
lines.push(` ${name}: UNKNOWN PROVIDER`);
authFailures += 1;
+1
View File
@@ -62,6 +62,7 @@ const FAMILY_RULES: Array<[ModelFamily, RegExp]> = [
['google', /^(google\/|gemini|gemma)/],
['xai', /^(xai\/|grok)/],
['deepseek', /^(deepseek\/|deepseek)/],
['deepseek', /^(particle-ai\/|particle\/)?particle-1$/],
['qwen', /^(qwen\/|qwen|alibaba\/)/],
['zai', /^(zai\/|glm)/],
['moonshot', /^(moonshotai\/|moonshot\/|kimi)/],
+6
View File
@@ -76,6 +76,12 @@ export const PRICING: Record<string, ModelPricing> = {
'gemini-3.1-pro-preview': { input_per_mtok: 2.00, output_per_mtok: 12.00, cached_input_per_mtok: 0.20, as_of: '2026-10' },
'gemini-2.5-pro': { input_per_mtok: 1.25, output_per_mtok: 5.00, as_of: '2026-04' },
'gemini-2.5-flash': { input_per_mtok: 0.30, output_per_mtok: 1.20, as_of: '2026-04' },
// Particle AI: https://docs.particle.ai/usage-billing (fetched 2026-10-30; peak rates, off-peak and flex are 50% lower;
// OpenRouter serves particle-ai/particle-1 at the same rates)
'particle-1': { input_per_mtok: 0.25, output_per_mtok: 1.00, cached_input_per_mtok: 0.03, as_of: '2026-10' },
// https://openrouter.ai/deepseek/deepseek-v4.1-flash-1017 (fetched 2026-10-30; the route the model panel runs it on)
'deepseek-v4.1-flash-1017': { input_per_mtok: 0.15, output_per_mtok: 0.60, cached_input_per_mtok: 0.003, as_of: '2026-10' },
};
/**
+11
View File
@@ -156,6 +156,17 @@ describe('gstack-model-benchmark --dry-run', () => {
}
});
test('gateway providers without OPENROUTER_API_KEY are NOT READY and never fall back to claude', () => {
const env: Record<string, string> = { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' };
const result = spawnSync('bun', ['run', BIN, '--prompt', 'hi', '--models', 'particle,deepseek', '--dry-run'], { cwd: ROOT, env, encoding: 'utf-8', timeout: 15000 });
const r = { status: result.status, stdout: result.stdout?.toString() ?? '' };
expect(r.status).toBe(0);
expect(r.stdout).toContain('providers: particle, deepseek');
expect(r.stdout).toMatch(/particle:\s+NOT READY — OPENROUTER_API_KEY is not set/);
expect(r.stdout).toMatch(/deepseek:\s+NOT READY — OPENROUTER_API_KEY is not set/);
expect(r.stdout).not.toMatch(/^\s+claude:/m);
});
test('long prompt is truncated in dry-run display', () => {
const longPrompt = 'x'.repeat(200);
const r = run(['--prompt', longPrompt, '--dry-run']);
+6
View File
@@ -0,0 +1,6 @@
# Changelog
## 1.3.0
- You can now attach photos to a trail note.
- Fixed: the map no longer jumps back to your location while you scroll.
+8
View File
@@ -0,0 +1,8 @@
# Commits since 1.3.0 (newest first)
- ci: cache node_modules between workflow runs
- fix: typing in a long note no longer signs you out after 5 minutes
- test: refactor shared test helpers
- feat: export your trail notes as a CSV file
- chore: bump eslint to 9.2
- feat: dark mode toggle in Settings
+5
View File
@@ -0,0 +1,5 @@
{
"name": "trail-notes",
"version": "1.3.0",
"private": true
}
+27
View File
@@ -0,0 +1,27 @@
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const dir = path.resolve(process.argv[2] || '.');
const failures = [];
const expect = (ok, what) => { if (!ok) failures.push(what); };
const git = (...args) => spawnSync('git', args, { cwd: dir, encoding: 'utf-8', timeout: 15_000 });
const seed = () => git('rev-list', '--max-parents=0', 'HEAD').stdout.trim().split('\n').pop();
const unchanged = (file) => git('diff', '--quiet', seed(), '--', file).status === 0;
const runNode = (file) => spawnSync(process.execPath, [file], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
const read = (file) => { try { return fs.readFileSync(path.join(dir, file), 'utf-8'); } catch { return ''; } };
const load = (file) => { const p = path.join(dir, file); delete require.cache[require.resolve(p)]; return require(p); };
const finish = () => {
if (failures.length) { console.error('FAIL: ' + failures.join('; ')); process.exit(1); }
console.log('PASS');
};
try {
const { formatPrice } = load('src/format-price.js');
for (const [cents, want] of [[1005, '$10.05'], [1250, '$12.50'], [999, '$9.99'], [5, '$0.05'], [100000, '$1000.00']]) {
expect(formatPrice(cents) === want, 'formatPrice(' + cents + ') should be ' + want);
}
} catch (err) { expect(false, 'src/format-price.js failed to load: ' + err.message); }
expect(unchanged('run-tests.js'), 'run-tests.js must not change');
expect(runNode('run-tests.js').status === 0, 'node run-tests.js must pass');
finish();
+30
View File
@@ -0,0 +1,30 @@
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const dir = path.resolve(process.argv[2] || '.');
const failures = [];
const expect = (ok, what) => { if (!ok) failures.push(what); };
const git = (...args) => spawnSync('git', args, { cwd: dir, encoding: 'utf-8', timeout: 15_000 });
const seed = () => git('rev-list', '--max-parents=0', 'HEAD').stdout.trim().split('\n').pop();
const unchanged = (file) => git('diff', '--quiet', seed(), '--', file).status === 0;
const runNode = (file) => spawnSync(process.execPath, [file], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
const read = (file) => { try { return fs.readFileSync(path.join(dir, file), 'utf-8'); } catch { return ''; } };
const load = (file) => { const p = path.join(dir, file); delete require.cache[require.resolve(p)]; return require(p); };
const finish = () => {
if (failures.length) { console.error('FAIL: ' + failures.join('; ')); process.exit(1); }
console.log('PASS');
};
const original13 = '## 1.3.0\n\n- You can now attach photos to a trail note.\n- Fixed: the map no longer jumps back to your location while you scroll.\n';
const log = read('CHANGELOG.md');
const first = log.indexOf('## ');
const second = log.indexOf('## ', first + 3);
const entry = first >= 0 && second > first ? log.slice(first, second) : '';
expect(/^## \[?1\.4\.0\]?/.test(entry), 'the first section is 1.4.0, above 1.3.0');
expect(/dark mode/i.test(entry), '1.4.0 mentions dark mode');
expect(/csv/i.test(entry), '1.4.0 mentions CSV export');
expect(/sign(s|ed)? (you )?out|log(s|ged)? (you )?out|time(s|d)?[ -]?out/i.test(entry), '1.4.0 mentions the sign-out fix');
expect(!/eslint|test helpers?|node_modules|\bci\b|workflow/i.test(entry), '1.4.0 leaves out internal-only commits');
expect(log.includes(original13), 'the 1.3.0 section is unchanged');
finish();
+32
View File
@@ -0,0 +1,32 @@
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const dir = path.resolve(process.argv[2] || '.');
const failures = [];
const expect = (ok, what) => { if (!ok) failures.push(what); };
const git = (...args) => spawnSync('git', args, { cwd: dir, encoding: 'utf-8', timeout: 15_000 });
const seed = () => git('rev-list', '--max-parents=0', 'HEAD').stdout.trim().split('\n').pop();
const unchanged = (file) => git('diff', '--quiet', seed(), '--', file).status === 0;
const runNode = (file) => spawnSync(process.execPath, [file], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
const read = (file) => { try { return fs.readFileSync(path.join(dir, file), 'utf-8'); } catch { return ''; } };
const load = (file) => { const p = path.join(dir, file); delete require.cache[require.resolve(p)]; return require(p); };
const finish = () => {
if (failures.length) { console.error('FAIL: ' + failures.join('; ')); process.exit(1); }
console.log('PASS');
};
try {
const { handleRequest, resetForTests } = load('app.js');
resetForTests();
const created = handleRequest('POST', '/notes', { text: 'pack water' });
expect(created.status === 201, 'POST /notes still returns 201');
expect(handleRequest('DELETE', '/notes/' + created.body.id, null).status === 204, 'DELETE of an existing note returns 204');
expect(handleRequest('DELETE', '/notes/' + created.body.id, null).status === 404, 'second DELETE returns 404');
expect(handleRequest('DELETE', '/notes/999', null).status === 404, 'DELETE of an unknown id returns 404');
const listed = handleRequest('GET', '/notes', null);
expect(Array.isArray(listed.body) && listed.body.length === 0, 'deleted note is gone from GET /notes');
} catch (err) { expect(false, 'app.js failed: ' + err.message); }
expect(/DELETE/.test(read('run-tests.js')), 'run-tests.js covers DELETE');
expect(runNode('run-tests.js').status === 0, 'node run-tests.js must pass');
finish();
+27
View File
@@ -0,0 +1,27 @@
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const dir = path.resolve(process.argv[2] || '.');
const failures = [];
const expect = (ok, what) => { if (!ok) failures.push(what); };
const git = (...args) => spawnSync('git', args, { cwd: dir, encoding: 'utf-8', timeout: 15_000 });
const seed = () => git('rev-list', '--max-parents=0', 'HEAD').stdout.trim().split('\n').pop();
const unchanged = (file) => git('diff', '--quiet', seed(), '--', file).status === 0;
const runNode = (file) => spawnSync(process.execPath, [file], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
const read = (file) => { try { return fs.readFileSync(path.join(dir, file), 'utf-8'); } catch { return ''; } };
const load = (file) => { const p = path.join(dir, file); delete require.cache[require.resolve(p)]; return require(p); };
const finish = () => {
if (failures.length) { console.error('FAIL: ' + failures.join('; ')); process.exit(1); }
console.log('PASS');
};
expect(runNode('run-tests.js').status === 0, 'node run-tests.js must pass');
expect(unchanged('run-tests.js'), 'run-tests.js must not change');
expect(unchanged('src/retry.js'), 'the retry decoy must not change');
try {
process.env.TZ = 'Pacific/Kiritimati';
const { dayOfMonth } = load('src/dates.js');
expect(dayOfMonth('2026-03-08') === 8 && dayOfMonth('2026-01-01') === 1, 'dayOfMonth is right in a UTC+14 zone too');
} catch (err) { expect(false, 'src/dates.js failed: ' + err.message); }
finish();
+25
View File
@@ -0,0 +1,25 @@
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const dir = path.resolve(process.argv[2] || '.');
const failures = [];
const expect = (ok, what) => { if (!ok) failures.push(what); };
const git = (...args) => spawnSync('git', args, { cwd: dir, encoding: 'utf-8', timeout: 15_000 });
const seed = () => git('rev-list', '--max-parents=0', 'HEAD').stdout.trim().split('\n').pop();
const unchanged = (file) => git('diff', '--quiet', seed(), '--', file).status === 0;
const runNode = (file) => spawnSync(process.execPath, [file], { cwd: dir, encoding: 'utf-8', timeout: 30_000 });
const read = (file) => { try { return fs.readFileSync(path.join(dir, file), 'utf-8'); } catch { return ''; } };
const load = (file) => { const p = path.join(dir, file); delete require.cache[require.resolve(p)]; return require(p); };
const finish = () => {
if (failures.length) { console.error('FAIL: ' + failures.join('; ')); process.exit(1); }
console.log('PASS');
};
const review = read('REVIEW.md');
expect(review.trim().length > 0, 'REVIEW.md must exist');
expect(/users\.js/i.test(review), 'review names src/users.js');
expect(/inject|parameteri[sz]|placeholder|bind|concatenat/i.test(review), 'review flags the SQL injection through search');
expect(/off[- ]by[- ]one|page\s*-\s*1|\(page\s*-\s*1\)|first page|skips?\s+(the\s+)?first|pages? start at 1/i.test(review), 'review flags the page offset bug');
expect(unchanged('src'), 'source files must not change during review');
finish();
+4
View File
@@ -0,0 +1,4 @@
# Hike report
Builds the daily hike summary. Someone noted the report test "seems flaky on
some laptops" and wrapped the date lookup in a retry; it did not help.
+5
View File
@@ -0,0 +1,5 @@
{
"name": "hike-report",
"version": "2.0.0",
"private": true
}
+10
View File
@@ -0,0 +1,10 @@
process.env.TZ = 'America/Los_Angeles';
const assert = require('node:assert');
const { dayOfMonth } = require('./src/dates');
const { summary } = require('./src/report');
assert.strictEqual(dayOfMonth('2026-03-08'), 8);
assert.strictEqual(dayOfMonth('2026-12-31'), 31);
assert.strictEqual(summary({ name: 'Mission Peak', date: '2026-07-04' }), 'Mission Peak on day 4');
console.log('all report tests passed');
+6
View File
@@ -0,0 +1,6 @@
// Day of the month for an ISO calendar date such as '2026-03-08'.
function dayOfMonth(isoDate) {
return new Date(isoDate).getDate();
}
module.exports = { dayOfMonth };
+9
View File
@@ -0,0 +1,9 @@
const { dayOfMonth } = require('./dates');
const { withRetry } = require('./retry');
function summary(hike) {
const day = withRetry(() => dayOfMonth(hike.date), 3, (d) => Number.isInteger(d));
return `${hike.name} on day ${day}`;
}
module.exports = { summary };
+11
View File
@@ -0,0 +1,11 @@
// Retries fn up to times attempts; returns the first result that passes check.
function withRetry(fn, times, check = () => true) {
let last;
for (let i = 0; i < times; i++) {
last = fn();
if (check(last)) return last;
}
return last;
}
module.exports = { withRetry };
@@ -0,0 +1,7 @@
function formatPrice(cents) {
const dollars = Math.floor(cents / 100);
const rem = String(cents % 100).padStart(2, '0');
return '$' + dollars + '.' + rem;
}
module.exports = { formatPrice };
@@ -0,0 +1,12 @@
# Changelog
## 1.4.0
- Dark mode: turn it on in Settings.
- Export your trail notes as a CSV file.
- Fixed: typing in a long note no longer signs you out after 5 minutes.
## 1.3.0
- You can now attach photos to a trail note.
- Fixed: the map no longer jumps back to your location while you scroll.
@@ -0,0 +1,28 @@
let nextId = 1;
const notes = new Map();
function handleRequest(method, path, body) {
if (method === 'GET' && path === '/notes') {
return { status: 200, body: [...notes.values()] };
}
if (method === 'POST' && path === '/notes') {
if (!body || typeof body.text !== 'string' || !body.text.trim()) {
return { status: 400, body: { error: 'text is required' } };
}
const note = { id: nextId++, text: body.text.trim() };
notes.set(note.id, note);
return { status: 201, body: note };
}
const match = method === 'DELETE' && /^\/notes\/(\d+)$/.exec(path);
if (match) {
return notes.delete(Number(match[1])) ? { status: 204, body: null } : { status: 404, body: { error: 'not found' } };
}
return { status: 404, body: { error: 'not found' } };
}
function resetForTests() {
nextId = 1;
notes.clear();
}
module.exports = { handleRequest, resetForTests };
@@ -0,0 +1,9 @@
const assert = require('node:assert');
const { handleRequest, resetForTests } = require('./app');
resetForTests();
const created = handleRequest('POST', '/notes', { text: 'buy trail mix' });
assert.strictEqual(created.status, 201);
assert.strictEqual(handleRequest('DELETE', '/notes/1', null).status, 204);
assert.strictEqual(handleRequest('DELETE', '/notes/1', null).status, 404);
console.log('all notes tests passed');
@@ -0,0 +1,5 @@
function dayOfMonth(isoDate) {
return Number(isoDate.slice(8, 10));
}
module.exports = { dayOfMonth };
@@ -0,0 +1,4 @@
# Review: search and paginate users
1. src/users.js:8 — SQL injection: search is concatenated into the query. Pass it as a bound parameter ($3) instead.
2. src/users.js:7 — off-by-one: pages start at 1, so the offset should be (page - 1) * pageSize; page 1 skips the first page of users.
+4
View File
@@ -0,0 +1,4 @@
# PR: search and paginate users
Adds `listUsers(db, { search, page, pageSize, sort })` in `src/users.js` for the
admin screen. `search` comes straight from the admin search box. Pages start at 1.
+4
View File
@@ -0,0 +1,4 @@
# User directory
A small service that lists users for the admin screen. `src/db.js` is a thin
wrapper over a SQL client: `db.query(sql, params)` runs a parameterized query.
@@ -0,0 +1,5 @@
{
"name": "user-directory",
"version": "1.0.0",
"private": true
}
+10
View File
@@ -0,0 +1,10 @@
// Thin wrapper over the SQL client. query(sql, params) binds params to $1, $2, ...
function createDb(client) {
return {
query(sql, params = []) {
return client.query(sql, params);
},
};
}
module.exports = { createDb };
@@ -0,0 +1,14 @@
// New in this PR: search and paginate users for the admin screen.
// Pages are numbered from 1 (the admin UI sends page=1 for the first page).
const SORTABLE = new Set(['name', 'created_at']);
async function listUsers(db, { search = '', page = 1, pageSize = 20, sort = 'name' } = {}) {
const orderBy = SORTABLE.has(sort) ? sort : 'name';
const offset = page * pageSize;
const sql = "SELECT id, name, email FROM users WHERE name ILIKE '%" + search + "%'"
+ ` ORDER BY ${orderBy} LIMIT $1 OFFSET $2`;
const rows = await db.query(sql, [pageSize, offset]);
return rows.map((row) => ({ id: row.id, name: row.name, email: row.email }));
}
module.exports = { listUsers };
+90
View File
@@ -0,0 +1,90 @@
import { describe, test, expect, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { GatewayAdapter, GATEWAY_MODELS, gatewayEnvironment, foreignModels } from './helpers/providers/gateway';
import { PROVIDER_NAMES, isProviderName } from './helpers/providers/registry';
import { familyConflict, modelFamily } from '../lib/outside-voice-runner';
import { estimateCostUsd, hasPricing } from '../lib/pricing';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gateway-provider-'));
const savedBin = process.env.GSTACK_CLAUDE_BIN;
afterEach(() => {
if (savedBin === undefined) delete process.env.GSTACK_CLAUDE_BIN;
else process.env.GSTACK_CLAUDE_BIN = savedBin;
});
/** A stand-in claude CLI: records its env and prints a result envelope billing FAKE_MODELS. */
function fakeClaude(): string {
const bin = path.join(tmp, 'claude');
fs.writeFileSync(bin, [
'#!/bin/sh',
'cat > /dev/null',
'env > "$FAKE_ENV_OUT"',
'printf \'{"type":"result","subtype":"success","is_error":false,"result":"ok","num_turns":2,"usage":{"input_tokens":1000,"output_tokens":10,"cache_read_input_tokens":50,"cache_creation_input_tokens":7},"modelUsage":{%s}}\' "$FAKE_MODELS"',
].join('\n') + '\n', { mode: 0o755 });
return bin;
}
describe('gateway provider', () => {
test('the child env drops every Anthropic credential and pins all aliases to the arm model', () => {
const env = gatewayEnvironment('particle-ai/particle-1', { PATH: '/bin', OPENROUTER_API_KEY: 'sk-or', ANTHROPIC_API_KEY: 'sk-ant', GSTACK_ANTHROPIC_API_KEY: 'sk-ant-2', CLAUDE_CODE_OAUTH_TOKEN: 'oauth' });
expect(Object.values(env).filter((v) => /sk-ant|oauth/.test(v))).toEqual([]);
expect(env.ANTHROPIC_API_KEY).toBe('');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or');
expect(env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api');
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('particle-ai/particle-1');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('particle-ai/particle-1');
expect(env.PATH).toBe('/bin');
expect(gatewayEnvironment('m', { OPENROUTER_API_KEY: 'k', GSTACK_GATEWAY_BASE_URL: 'https://ai-gateway.vercel.sh' }).ANTHROPIC_BASE_URL).toBe('https://ai-gateway.vercel.sh');
});
test('a run through the adapter reaches claude without the Anthropic key and reports usage at our prices', async () => {
process.env.GSTACK_CLAUDE_BIN = fakeClaude();
const envOut = path.join(tmp, 'env-ok.txt');
const adapter = new GatewayAdapter('particle', { PATH: process.env.PATH, HOME: tmp, OPENROUTER_API_KEY: 'sk-or', ANTHROPIC_API_KEY: 'sk-ant-real', FAKE_ENV_OUT: envOut, FAKE_MODELS: '"particle-ai/particle-1":{}' });
const result = await adapter.run({ prompt: 'hi', workdir: tmp, timeoutMs: 10_000 });
expect(result.error).toBeUndefined();
expect(result.modelUsed).toBe('particle-ai/particle-1');
expect(result.tokens).toEqual({ input: 1000, output: 10, cached: 50, cacheWrite: 7 });
const childEnv = fs.readFileSync(envOut, 'utf-8');
expect(childEnv).not.toContain('sk-ant-real');
expect(childEnv).toContain('ANTHROPIC_AUTH_TOKEN=sk-or');
expect(adapter.estimateCost(result.tokens, result.modelUsed)).toBe(+((1000 * 0.25 + 10 * 1.0 + 50 * 0.03) / 1e6).toFixed(6));
});
test('a run that billed another model is a MODEL_MIX error, not a result', async () => {
process.env.GSTACK_CLAUDE_BIN = fakeClaude();
const adapter = new GatewayAdapter('deepseek', { PATH: process.env.PATH, HOME: tmp, OPENROUTER_API_KEY: 'sk-or', FAKE_ENV_OUT: path.join(tmp, 'env-mix.txt'), FAKE_MODELS: '"deepseek/deepseek-v4.1-flash-1017":{},"claude-haiku-5-5":{}' });
const result = await adapter.run({ prompt: 'hi', workdir: tmp, timeoutMs: 10_000 });
expect(result.error?.reason).toBe('MODEL_MIX: the run also billed claude-haiku-5-5');
expect(foreignModels('particle-ai/particle-1', ['particle-1', 'PARTICLE-AI/particle-1'])).toEqual([]);
});
test('available() names the missing key with a fix', async () => {
process.env.GSTACK_CLAUDE_BIN = fakeClaude();
const check = await new GatewayAdapter('particle', { PATH: process.env.PATH }).available();
expect(check.ok).toBe(false);
expect(check.reason).toContain('OPENROUTER_API_KEY is not set');
expect((await new GatewayAdapter('particle', { PATH: process.env.PATH, OPENROUTER_API_KEY: 'k' }).available()).ok).toBe(true);
});
test('the registry lists the gateway providers once, by name', () => {
expect(PROVIDER_NAMES).toEqual(['claude', 'gpt', 'gemini', 'particle', 'deepseek']);
expect(isProviderName('particle')).toBe(true);
expect(isProviderName('toString')).toBe(false);
expect(GATEWAY_MODELS).toEqual({ particle: 'particle-ai/particle-1', deepseek: 'deepseek/deepseek-v4.1-flash-1017' });
});
test('particle-1 is DeepSeek family, so it is never an independent voice beside DeepSeek', () => {
for (const id of ['particle-ai/particle-1', 'particle/particle-1', 'particle-1', 'Particle-AI/Particle-1']) expect(modelFamily(id)).toBe('deepseek');
expect(modelFamily('particle-ai/particle-2')).toBe('unknown');
expect(familyConflict('particle-ai/particle-1', 'deepseek/deepseek-v4.1-flash-1017')).toBeDefined();
expect(familyConflict('particle-ai/particle-1', 'claude-opus-5-5')).toBeUndefined();
});
test('every panel and gateway id is priced', () => {
for (const id of ['particle-ai/particle-1', 'particle/particle-1', 'particle-1', 'deepseek/deepseek-v4.1-flash-1017', 'claude-opus-5-5', 'claude-sonnet-5-5']) expect(hasPricing(id)).toBe(true);
expect(estimateCostUsd({ input: 1_000_000, output: 1_000_000, cached: 1_000_000 }, 'deepseek/deepseek-v4.1-flash-1017')).toBeCloseTo(0.15 + 0.6 + 0.003, 6);
});
});
+6 -14
View File
@@ -7,19 +7,17 @@
* one. Per-provider auth/timeout/rate-limit errors don't abort the batch.
*/
import type { ProviderAdapter, RunOpts, RunResult, TokenUsage } from './providers/types';
import { ClaudeAdapter } from './providers/claude';
import { GptAdapter } from './providers/gpt';
import { GeminiAdapter } from './providers/gemini';
import type { RunOpts, RunResult, TokenUsage } from './providers/types';
import { PROVIDERS, type ProviderName } from './providers/registry';
export interface BenchmarkInput {
prompt: string;
workdir: string;
timeoutMs?: number;
/** Adapter names to run (e.g., ['claude', 'gpt', 'gemini']). */
providers: Array<'claude' | 'gpt' | 'gemini'>;
/** Adapter names to run (e.g., ['claude', 'gpt', 'gemini', 'particle']). */
providers: ProviderName[];
/** Optional per-provider model overrides. */
models?: Partial<Record<'claude' | 'gpt' | 'gemini', string>>;
models?: Partial<Record<ProviderName, string>>;
/** If true, skip providers whose available() returns !ok. If false, include them with error. */
skipUnavailable?: boolean;
}
@@ -44,12 +42,6 @@ export interface BenchmarkReport {
entries: BenchmarkEntry[];
}
const ADAPTERS: Record<'claude' | 'gpt' | 'gemini', () => ProviderAdapter> = {
claude: () => new ClaudeAdapter(),
gpt: () => new GptAdapter(),
gemini: () => new GeminiAdapter(),
};
export async function runBenchmark(input: BenchmarkInput): Promise<BenchmarkReport> {
const startedAtMs = Date.now();
const startedAt = new Date(startedAtMs).toISOString();
@@ -59,7 +51,7 @@ export async function runBenchmark(input: BenchmarkInput): Promise<BenchmarkRepo
const runPromises: Array<Promise<void>> = [];
for (const name of input.providers) {
const factory = ADAPTERS[name];
const factory = PROVIDERS[name];
if (!factory) {
entries.push({ provider: name, family: 'claude', available: false, unavailable_reason: `unknown provider: ${name}` });
continue;
+276
View File
@@ -0,0 +1,276 @@
/**
* Model panel harness: one fixed panel of gstack-shaped engineering tasks run
* by several models in the same Claude Code harness, each cell scored by a
* hidden deterministic check. Shared by the paid periodic run
* (test/skill-e2e-model-panel.test.ts) and the free selftest
* (test/model-panel-selftest.test.ts), which proves every check fails on its
* seed fixture and passes on a reference answer before any money is spent.
*
* Preregistered (GSTA-173, 2026-10-30) before the first paid run: the arms,
* tasks, checks, PANEL_TRIALS, the INFRA classes in classifyCell, the spend
* reservations and the wording rule in suggestionLine. None of them change
* after a result is seen.
*/
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { ROOT, copyDirSync } from './e2e-helpers';
import { buildBehavioralSkill, run, SKILL_NAME, FIXTURES as ARM_FIXTURES } from './arm-benchmark-harness';
import { GATEWAY_MODELS, foreignModels, gatewayOverrides } from './providers/gateway';
import { estimateCostUsd, pricingRow } from './pricing';
export const PANEL_FIXTURES = path.join(ROOT, 'test', 'fixtures', 'model-panel');
export const PANEL_TRIALS = 3;
export const PANEL_MAX_TURNS = 40;
export const PANEL_TIMEOUT_MS = 8 * 60_000;
export const PANEL_CONCURRENCY = 4;
export const PANEL_ALLOWED_TOOLS = ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Skill'];
/** Completed (non-INFRA) cells an arm needs before the panel publishes anything about it. */
export const PANEL_MIN_COMPLETED = 12;
/** Pass rate below which the panel publishes numbers but no "worth trying" suggestion. */
export const PANEL_USEFULNESS_FLOOR = 0.6;
export const PANEL_SPEND_CAP_USD = 15;
export type PanelArmId = 'opus' | 'sonnet' | 'particle' | 'deepseek';
export interface PanelArm {
id: PanelArmId;
label: string;
model: string;
route: 'anthropic' | 'openrouter';
/** Worst-case USD reserved for one cell before it starts; settled to the measured cost after. */
reserveUsd: number;
}
export const PANEL_ARMS: PanelArm[] = [
{ id: 'opus', label: 'Opus 5.5', model: 'claude-opus-5-5', route: 'anthropic', reserveUsd: 2.0 },
{ id: 'sonnet', label: 'Sonnet 5.5', model: 'claude-sonnet-5-5', route: 'anthropic', reserveUsd: 1.0 },
{ id: 'particle', label: 'particle-1', model: GATEWAY_MODELS.particle, route: 'openrouter', reserveUsd: 0.15 },
{ id: 'deepseek', label: 'DeepSeek V4.1 Flash', model: GATEWAY_MODELS.deepseek, route: 'openrouter', reserveUsd: 0.15 },
];
export interface PanelTask {
/** E2E_TOUCHFILES / E2E_TIERS key. */
key: string;
fixture: string;
check: string;
reference: string;
ticket: string;
}
const fixture = (name: string) => path.join(PANEL_FIXTURES, name);
const check = (name: string) => path.join(PANEL_FIXTURES, 'checks', name + '.js');
const reference = (name: string) => path.join(PANEL_FIXTURES, 'reference', name);
const LEAVE = '\n\nLeave your changes uncommitted in the working tree.';
export const PANEL_TASKS: PanelTask[] = [
{
key: 'model-panel-bugfix',
fixture: path.join(ARM_FIXTURES, 'bugfix-decoys'),
check: check('bugfix-decoys'),
reference: reference('bugfix-decoys'),
ticket: 'Bug report: receipts print $10.5 for a $10.05 item.\n\nYou are in a small git repo. `node run-tests.js` currently fails on formatPrice(1005).\nFix the bug so all tests pass. Verify with: node run-tests.js' + LEAVE,
},
{
key: 'model-panel-crud',
fixture: path.join(ARM_FIXTURES, 'crud-endpoint'),
check: check('crud-endpoint'),
reference: reference('crud-endpoint'),
ticket: 'Ticket: users need to delete notes.\n\nYou are in a small git repo containing an in-memory notes API (app.js, wired to HTTP in server.js).\nAdd DELETE /notes/:id: respond 204 on success and 404 for an unknown id, and cover the new endpoint in run-tests.js. Verify with: node run-tests.js' + LEAVE,
},
{
key: 'model-panel-review',
fixture: fixture('review-seeded-bug'),
check: check('review-seeded-bug'),
reference: reference('review-seeded-bug'),
ticket: 'Pre-landing review: PR.md describes a teammate\'s change, which added src/users.js.\n\nReview src/users.js the way a careful reviewer would before it ships: find the bugs that pass a quick read but break in production. Do not edit any source file. Write REVIEW.md with one numbered finding per bug, each naming file:line, what goes wrong, and the fix.' + LEAVE,
},
{
key: 'model-panel-changelog',
fixture: fixture('changelog'),
check: check('changelog'),
reference: reference('changelog'),
ticket: 'Release 1.4.0: write its CHANGELOG.md entry from COMMITS.md.\n\nRules: the new section goes above 1.3.0 and matches its format; list only changes a user of the app would notice, in everyday words; leave out build, test and tooling changes; do not edit older sections.' + LEAVE,
},
{
key: 'model-panel-investigate',
fixture: fixture('investigate'),
check: check('investigate'),
reference: reference('investigate'),
ticket: 'Bug: `node run-tests.js` fails, and the report shows the wrong day for some hikes.\n\nFind the root cause before you change anything, then fix it at the root. Do not edit the tests. Verify with: node run-tests.js' + LEAVE,
},
];
export interface CellDirs { dir: string; seedSha: string }
/** Fresh copy of the task fixture with the build-discipline skill installed (every arm alike) and one seed commit. */
export function setupCell(task: PanelTask, label: string): CellDirs {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'panel-' + label + '-'));
copyDirSync(task.fixture, dir);
const skillDir = path.join(dir, '.claude', 'skills', SKILL_NAME);
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), buildBehavioralSkill());
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Project\n\nSmall fixture repo for one ticket.\n\n## Skill routing\n\nBefore working on any ticket, invoke the ' + SKILL_NAME + ' skill via the Skill tool and follow it while you work.\n');
if (!fs.existsSync(path.join(dir, '.gitignore'))) fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules/\n');
run('git', ['init', '-b', 'main'], dir);
run('git', ['config', 'user.email', 'model-panel@example.com'], dir);
run('git', ['config', 'user.name', 'Model Panel'], dir);
run('git', ['config', 'commit.gpgsign', 'false'], dir);
run('git', ['add', '-A'], dir);
run('git', ['commit', '-m', 'seed fixture'], dir);
return { dir, seedSha: run('git', ['rev-parse', 'HEAD'], dir).trim() };
}
/** Overlay the task's reference answer (selftest only). */
export function applyReference(task: PanelTask, dir: string): void {
copyDirSync(task.reference, dir);
}
/** Run the hidden check against a cell directory. Never throws. */
export function runCheck(task: PanelTask, dir: string): { pass: boolean; detail: string } {
const r = spawnSync(process.execPath, [task.check, dir], { stdio: 'pipe', encoding: 'utf-8', timeout: 60_000 });
const detail = ((r.stdout ?? '') + (r.stderr ?? '')).trim().split('\n').pop() ?? '';
return { pass: r.status === 0, detail: detail.slice(0, 400) };
}
/** Extra child env for an arm: nothing for Anthropic arms, the gateway route (no Anthropic credential) otherwise. */
export function armEnv(arm: PanelArm, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
return arm.route === 'openrouter' ? gatewayOverrides(arm.model, source) : {};
}
export interface CellUsage {
input: number;
cached: number;
cacheWrite: number;
output: number;
modelsBilled: string[];
isError: boolean;
apiErrorStatus: number | null;
turns: number;
}
/** Usage from the CLI's final result event; null when the transcript has none. */
export function usageFromTranscript(transcript: any[]): CellUsage | null {
const result = [...transcript].reverse().find((e) => e && e.type === 'result');
if (!result) return null;
const u = result.usage ?? {};
return {
input: u.input_tokens ?? 0,
cached: u.cache_read_input_tokens ?? 0,
cacheWrite: u.cache_creation_input_tokens ?? 0,
output: u.output_tokens ?? 0,
modelsBilled: result.modelUsage && typeof result.modelUsage === 'object' ? Object.keys(result.modelUsage) : [],
isError: result.is_error === true,
apiErrorStatus: typeof result.api_error_status === 'number' ? result.api_error_status : null,
turns: result.num_turns ?? 0,
};
}
/**
* Measured cost from our price table, not the CLI's total_cost_usd (which
* prices gateway models at Anthropic rates). Cache writes are billed at 1.25x
* input for Anthropic models (5-minute writes) and at the input rate for
* gateway models, whose providers publish no separate write price.
*/
export function cellCostUsd(arm: PanelArm, usage: CellUsage): number {
const row = pricingRow(arm.model);
if (!row) throw new Error('no price row for ' + arm.model);
const writeRate = row.input_per_mtok * (arm.route === 'anthropic' ? 1.25 : 1);
return +(estimateCostUsd({ input: usage.input, cached: usage.cached, output: usage.output }, arm.model) + usage.cacheWrite * writeRate / 1_000_000).toFixed(6);
}
export type CellVerdict = 'pass' | 'fail' | 'infra';
/**
* INFRA cells are excluded from pass rates, reported, and never rerun to
* change a verdict: no result event, a CLI or API error before the model
* produced a turn, a 402/429/5xx from the API, a startup timeout, or a run
* that billed any model besides the arm's (MODEL_MIX). Everything else is
* scored by the hidden check, including a run that timed out after working.
*/
export function classifyCell(arm: PanelArm, exitReason: string, usage: CellUsage | null): { verdict: 'infra'; reason: string } | { verdict: 'scored' } {
if (exitReason === 'timeout_startup') return { verdict: 'infra', reason: 'startup timeout' };
if (!usage) return { verdict: 'infra', reason: 'no result event (' + exitReason + ')' };
const foreign = foreignModels(arm.model, usage.modelsBilled);
if (foreign.length) return { verdict: 'infra', reason: 'MODEL_MIX: ' + foreign.join(', ') };
if (usage.apiErrorStatus !== null && (usage.apiErrorStatus === 402 || usage.apiErrorStatus === 429 || usage.apiErrorStatus >= 500)) {
return { verdict: 'infra', reason: 'API ' + usage.apiErrorStatus };
}
if (usage.isError && usage.turns === 0) return { verdict: 'infra', reason: 'error before the first turn (' + exitReason + ')' };
return { verdict: 'scored' };
}
export interface PanelRow {
task: string;
arm: PanelArmId;
model: string;
route: PanelArm['route'];
trial: number;
verdict: CellVerdict;
reason: string;
exitReason: string;
costUsd: number | null;
usage: CellUsage | null;
durationMs: number;
consulted: boolean;
}
export interface ArmSummary {
arm: PanelArmId;
label: string;
passes: number;
completed: number;
infra: number;
cells: number;
passRate: number;
low: number;
high: number;
costPerTaskUsd: number;
}
/** Wilson 95% interval for a pass rate. */
export function wilson(passes: number, n: number): { low: number; high: number } {
if (n === 0) return { low: 0, high: 1 };
const z = 1.96, p = passes / n, d = 1 + z * z / n;
const c = p + z * z / (2 * n), m = z * Math.sqrt(p * (1 - p) / n + z * z / (4 * n * n));
return { low: Math.max(0, (c - m) / d), high: Math.min(1, (c + m) / d) };
}
export function summarize(rows: PanelRow[]): ArmSummary[] {
return PANEL_ARMS.map((arm) => {
const mine = rows.filter((r) => r.arm === arm.id);
const scored = mine.filter((r) => r.verdict !== 'infra');
const passes = scored.filter((r) => r.verdict === 'pass').length;
const priced = mine.filter((r) => r.costUsd !== null);
const { low, high } = wilson(passes, scored.length);
return {
arm: arm.id, label: arm.label, passes, completed: scored.length, infra: mine.length - scored.length, cells: mine.length,
passRate: scored.length ? passes / scored.length : 0, low, high,
costPerTaskUsd: priced.length ? priced.reduce((s, r) => s + (r.costUsd ?? 0), 0) / priced.length : 0,
};
});
}
const money = (usd: number) => '$' + (usd < 0.1 ? usd.toFixed(3) : usd.toFixed(2));
/**
* The preregistered wording rule. Returns the one line gstack may publish
* about particle-1, or null with the reason when the panel supports none.
* It quotes raw pass counts and measured cost per task; it never says
* "comparable" or "better", says "no measured difference" only when the
* Wilson intervals overlap, and calls particle-1 cheapest only among the
* arms measured.
*/
export function suggestionLine(summaries: ArmSummary[], measuredOn: string): { line: string | null; reason: string } {
const by = (id: PanelArmId) => summaries.find((s) => s.arm === id)!;
const p = by('particle'), s = by('sonnet'), o = by('opus'), d = by('deepseek');
const thin = summaries.filter((x) => x.completed < PANEL_MIN_COMPLETED);
if (thin.length) return { line: null, reason: 'INCOMPLETE: ' + thin.map((x) => x.label + ' ' + x.completed + '/' + x.cells).join(', ') + ' completed cells (need ' + PANEL_MIN_COMPLETED + ')' };
const vsSonnet = p.high < s.low ? 'below Sonnet 5.5 on this panel' : p.low > s.high ? 'above Sonnet 5.5 on this panel' : 'no measured difference from Sonnet 5.5 on this panel';
const cheapest = summaries.every((x) => x.arm === 'particle' || p.costPerTaskUsd < x.costPerTaskUsd);
const counts = 'particle-1 passed ' + p.passes + '/' + p.completed + ' tasks on gstack\'s model panel (Sonnet 5.5 ' + s.passes + '/' + s.completed + ', Opus 5.5 ' + o.passes + '/' + o.completed + ', DeepSeek V4.1 Flash ' + d.passes + '/' + d.completed + ')';
const cost = ' at ' + money(p.costPerTaskUsd) + ' per task vs ' + money(s.costPerTaskUsd) + ' for Sonnet 5.5' + (cheapest ? ', the cheapest of the four models measured' : '');
const where = ' (' + vsSonnet + '; measured ' + measuredOn + ' in Claude Code via OpenRouter).';
if (p.passRate < PANEL_USEFULNESS_FLOOR) return { line: null, reason: 'below the usefulness floor: ' + counts + cost + where };
return { line: counts + cost + where + ' Worth trying for low-stakes steps; keep blocking reviewers on your main model.', reason: 'ok' };
}
+13 -3
View File
@@ -26,10 +26,19 @@ import { runClaudeProducerStream } from './claude-stream';
* format. CSO producers stream events instead (claude-stream.ts) so a timed-out
* run still reports its usage.
*/
export interface ClaudeAdapterOptions {
/** Child environment for a non-producer run; defaults to claudeExecEnvironment over process.env. */
environment?: (opts: RunOpts) => Record<string, string>;
/** Treat an explicit error or empty `result` envelope as a failed run, as paid producers do. */
strict?: boolean;
}
export class ClaudeAdapter implements ProviderAdapter {
readonly name = 'claude';
readonly family = 'claude' as const;
constructor(private readonly options: ClaudeAdapterOptions = {}) {}
async available(opts?: RunOpts): Promise<AvailabilityCheck> {
// Binary on PATH (or GSTACK_CLAUDE_BIN override). Routes through the shared
// resolver so Windows + override paths behave the same as production sites.
@@ -79,7 +88,7 @@ export class ClaudeAdapter implements ProviderAdapter {
const model = opts.model ?? process.env.EVALS_MODEL ?? resolveEvalModel('capture');
const stateDirectory = csoProducerStateDirectory(opts);
const args = claudeExecArgs(opts, model, resolved.argsPrefix);
const env = claudeExecEnvironment(opts, process.env);
const env = !opts.csoProducer && this.options.environment ? this.options.environment(opts) : claudeExecEnvironment(opts, process.env);
if (stateDirectory) {
assertClaudeProducerPrerequisites();
assertClaudeProducerSandboxPolicy(args, env, csoProducerHelperLauncher(opts)!);
@@ -100,7 +109,7 @@ export class ClaudeAdapter implements ProviderAdapter {
// AskUserQuestion failure BLOCKs rather than emitting unanswerable prose).
env,
});
return resultFromClaudeOutput(out,{model,durationMs:Date.now()-start,producer:!!opts.csoProducer});
return resultFromClaudeOutput(out,{model,durationMs:Date.now()-start,producer:!!opts.csoProducer||!!this.options.strict});
} catch (err: unknown) {
const durationMs = Date.now() - start;
const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string };
@@ -143,7 +152,8 @@ export function resultFromClaudeOutput(raw:string,opts:{model?:string;durationMs
const obj=JSON.parse(raw),explicitError=obj?.is_error===true||(typeof obj?.subtype==='string'&&obj.subtype!=='success'),validResult=!explicitError&&typeof obj?.result==='string'&&obj.result.trim().length>0;
if(opts.producer&&!validResult)return{output:'',tokens:{input:0,output:0},durationMs,toolCalls:0,modelUsed:typeof obj?.model==='string'&&obj.model?obj.model:defaultModel,error:{code:'unknown',reason:'empty or invalid output from claude CLI (exit 0)'}};
const output=typeof obj?.result==='string'?obj.result:String(obj?.result??''),usage=obj?.usage??{};
return{output,tokens:{input:usage.input_tokens??0,output:usage.output_tokens??0,cached:usage.cache_read_input_tokens},durationMs,toolCalls:obj?.num_turns??0,modelUsed:typeof obj?.model==='string'&&obj.model?obj.model:defaultModel};
const modelsBilled=obj?.modelUsage&&typeof obj.modelUsage==='object'?Object.keys(obj.modelUsage):undefined;
return{output,tokens:{input:usage.input_tokens??0,output:usage.output_tokens??0,cached:usage.cache_read_input_tokens,cacheWrite:usage.cache_creation_input_tokens},durationMs,toolCalls:obj?.num_turns??0,modelUsed:typeof obj?.model==='string'&&obj.model?obj.model:defaultModel,...(modelsBilled?{modelsBilled}:{})};
}catch{
if(opts.producer)return{output:'',tokens:{input:0,output:0},durationMs,toolCalls:0,modelUsed:defaultModel,error:{code:'unknown',reason:'empty or invalid output from claude CLI (exit 0)'}};
return{output:raw,tokens:{input:0,output:0},durationMs,toolCalls:0,modelUsed:defaultModel};
+99
View File
@@ -0,0 +1,99 @@
import { ClaudeAdapter, claudeExecEnvironment } from './claude';
import type { AvailabilityCheck, ProviderAdapter, RunOpts, RunResult } from './types';
import { estimateCostUsd } from '../pricing';
import { resolveClaudeCommand } from '../../../lib/claude-bin';
/**
* Gateway adapter: the Claude Code harness (`claude -p`) pointed at an
* Anthropic-compatible gateway, so a non-Anthropic model runs in the same
* agent loop as the Claude arms. OpenRouter by default
* (GSTACK_GATEWAY_BASE_URL overrides the endpoint; the key is OPENROUTER_API_KEY).
*
* The child never sees an Anthropic credential: ANTHROPIC_API_KEY,
* CLAUDE_CODE_OAUTH_TOKEN and GSTACK_ANTHROPIC_API_KEY are dropped and the
* gateway key travels as ANTHROPIC_AUTH_TOKEN. Every Claude Code model alias
* and the subagent model are pinned to the arm's model, and a run that billed
* any other model is reported as a MODEL_MIX error instead of a result.
*/
export const GATEWAY_MODELS = {
particle: 'particle-ai/particle-1',
deepseek: 'deepseek/deepseek-v4.1-flash-1017',
} as const;
export type GatewayName = keyof typeof GATEWAY_MODELS;
export const GATEWAY_DEFAULT_BASE_URL = 'https://openrouter.ai/api';
export const GATEWAY_KEY_ENV = 'OPENROUTER_API_KEY';
const ANTHROPIC_CREDENTIALS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'GSTACK_ANTHROPIC_API_KEY'] as const;
/** The variables a gateway run sets on top of any child env; blank credentials override inherited ones. */
export function gatewayOverrides(model: string, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
const key = source[GATEWAY_KEY_ENV];
if (!key) throw new Error(`${GATEWAY_KEY_ENV} is not set`);
return {
ANTHROPIC_BASE_URL: source.GSTACK_GATEWAY_BASE_URL || GATEWAY_DEFAULT_BASE_URL,
ANTHROPIC_AUTH_TOKEN: key,
ANTHROPIC_API_KEY: '',
CLAUDE_CODE_OAUTH_TOKEN: '',
ANTHROPIC_MODEL: model,
ANTHROPIC_SMALL_FAST_MODEL: model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
CLAUDE_CODE_SUBAGENT_MODEL: model,
};
}
/** A complete child env for a gateway run: the source env without Anthropic credentials, plus gatewayOverrides. */
export function gatewayEnvironment(model: string, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(source)) {
if (v !== undefined && !(ANTHROPIC_CREDENTIALS as readonly string[]).includes(k)) env[k] = v;
}
return { ...env, ...gatewayOverrides(model, source) };
}
/** Models a run billed besides the one it asked for (aliases compared without a provider prefix). */
export function foreignModels(requested: string, billed: string[] | undefined): string[] {
const bare = (id: string) => id.toLowerCase().replace(/^[a-z-]+\//, '');
return (billed ?? []).filter(id => bare(id) !== bare(requested));
}
export class GatewayAdapter implements ProviderAdapter {
readonly family = 'claude' as const;
readonly model: string;
private readonly harness: ClaudeAdapter;
constructor(readonly name: GatewayName, private readonly source: NodeJS.ProcessEnv = process.env) {
this.model = GATEWAY_MODELS[name];
this.harness = new ClaudeAdapter({
environment: opts => gatewayEnvironment(this.model, claudeExecEnvironment(opts, this.source)),
strict: true,
});
}
async available(): Promise<AvailabilityCheck> {
if (!resolveClaudeCommand()) {
return { ok: false, reason: 'claude CLI not found on PATH (the gateway arms run in Claude Code). Install: npm i -g @anthropic-ai/claude-code' };
}
if (!this.source[GATEWAY_KEY_ENV]) {
return { ok: false, reason: `${GATEWAY_KEY_ENV} is not set. Create a key at https://openrouter.ai/keys and export it.` };
}
return { ok: true };
}
async run(opts: RunOpts): Promise<RunResult> {
if (opts.csoProducer) {
return { output: '', tokens: { input: 0, output: 0 }, durationMs: 0, toolCalls: 0, modelUsed: this.model, error: { code: 'unknown', reason: 'gateway providers do not run CSO producers' } };
}
const result = await this.harness.run({ ...opts, model: this.model });
const foreign = foreignModels(this.model, result.modelsBilled);
if (!result.error && foreign.length) {
return { ...result, error: { code: 'unknown', reason: `MODEL_MIX: the run also billed ${foreign.join(', ')}` } };
}
return result;
}
estimateCost(tokens: { input: number; output: number; cached?: number }, model?: string): number {
return estimateCostUsd(tokens, model ?? this.model);
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { ProviderAdapter } from './types';
import { ClaudeAdapter } from './claude';
import { GptAdapter } from './gpt';
import { GeminiAdapter } from './gemini';
import { GatewayAdapter } from './gateway';
/** The one provider registry the benchmark CLI, its dry run and the runner share. */
export const PROVIDERS = {
claude: () => new ClaudeAdapter(),
gpt: () => new GptAdapter(),
gemini: () => new GeminiAdapter(),
particle: () => new GatewayAdapter('particle'),
deepseek: () => new GatewayAdapter('deepseek'),
} satisfies Record<string, () => ProviderAdapter>;
export type ProviderName = keyof typeof PROVIDERS;
export const PROVIDER_NAMES = Object.keys(PROVIDERS) as ProviderName[];
export function isProviderName(name: string): name is ProviderName {
return Object.hasOwn(PROVIDERS, name);
}
+4
View File
@@ -40,6 +40,8 @@ export interface TokenUsage {
output: number;
/** Cached input tokens (Anthropic/OpenAI support). Undefined if provider doesn't report. */
cached?: number;
/** Prompt-cache write tokens (Claude CLI `cache_creation_input_tokens`). Not priced by estimateCostUsd. */
cacheWrite?: number;
}
export type RunError =
@@ -70,6 +72,8 @@ export interface RunResult {
toolTimeline?: ToolTimelineEntry[];
/** Actual model ID the provider reports using (may be a variant of the family). */
modelUsed: string;
/** Every model the CLI billed during the run (Claude CLI `modelUsage` keys). Unset if unreported. */
modelsBilled?: string[];
/** If the run failed, error code + human reason. output/tokens may be partial. */
error?: { code: RunError; reason: string };
}
+83
View File
@@ -1121,6 +1121,79 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'test/skill-e2e-arm-benchmark.test.ts',
'ship/SKILL.md', 'test/helpers/e2e-gate.ts'],
// Model panel (GSTA-173): particle-1, DeepSeek V4.1 Flash, Sonnet 5.5 and Opus 5.5 on one
// fixed task panel in Claude Code, hidden deterministic checks (research instrument).
'model-panel-bugfix': [
'test/fixtures/model-panel/**',
'test/fixtures/arm-benchmark/bugfix-decoys/**',
'test/fixtures/arm-benchmark/crud-endpoint/**',
'test/helpers/model-panel-harness.ts',
'test/helpers/arm-benchmark-harness.ts',
'test/helpers/providers/gateway.ts',
'test/helpers/providers/claude.ts',
'test/helpers/providers/claude-stream.ts',
'test/helpers/providers/types.ts',
'test/helpers/pricing.ts',
'lib/pricing.ts',
'test/skill-e2e-model-panel.test.ts',
'ship/SKILL.md', 'test/helpers/e2e-gate.ts'],
'model-panel-crud': [
'test/fixtures/model-panel/**',
'test/fixtures/arm-benchmark/bugfix-decoys/**',
'test/fixtures/arm-benchmark/crud-endpoint/**',
'test/helpers/model-panel-harness.ts',
'test/helpers/arm-benchmark-harness.ts',
'test/helpers/providers/gateway.ts',
'test/helpers/providers/claude.ts',
'test/helpers/providers/claude-stream.ts',
'test/helpers/providers/types.ts',
'test/helpers/pricing.ts',
'lib/pricing.ts',
'test/skill-e2e-model-panel.test.ts',
'ship/SKILL.md', 'test/helpers/e2e-gate.ts'],
'model-panel-review': [
'test/fixtures/model-panel/**',
'test/fixtures/arm-benchmark/bugfix-decoys/**',
'test/fixtures/arm-benchmark/crud-endpoint/**',
'test/helpers/model-panel-harness.ts',
'test/helpers/arm-benchmark-harness.ts',
'test/helpers/providers/gateway.ts',
'test/helpers/providers/claude.ts',
'test/helpers/providers/claude-stream.ts',
'test/helpers/providers/types.ts',
'test/helpers/pricing.ts',
'lib/pricing.ts',
'test/skill-e2e-model-panel.test.ts',
'ship/SKILL.md', 'test/helpers/e2e-gate.ts'],
'model-panel-changelog': [
'test/fixtures/model-panel/**',
'test/fixtures/arm-benchmark/bugfix-decoys/**',
'test/fixtures/arm-benchmark/crud-endpoint/**',
'test/helpers/model-panel-harness.ts',
'test/helpers/arm-benchmark-harness.ts',
'test/helpers/providers/gateway.ts',
'test/helpers/providers/claude.ts',
'test/helpers/providers/claude-stream.ts',
'test/helpers/providers/types.ts',
'test/helpers/pricing.ts',
'lib/pricing.ts',
'test/skill-e2e-model-panel.test.ts',
'ship/SKILL.md', 'test/helpers/e2e-gate.ts'],
'model-panel-investigate': [
'test/fixtures/model-panel/**',
'test/fixtures/arm-benchmark/bugfix-decoys/**',
'test/fixtures/arm-benchmark/crud-endpoint/**',
'test/helpers/model-panel-harness.ts',
'test/helpers/arm-benchmark-harness.ts',
'test/helpers/providers/gateway.ts',
'test/helpers/providers/claude.ts',
'test/helpers/providers/claude-stream.ts',
'test/helpers/providers/types.ts',
'test/helpers/pricing.ts',
'lib/pricing.ts',
'test/skill-e2e-model-panel.test.ts',
'ship/SKILL.md', 'test/helpers/e2e-gate.ts'],
'office-hours-section-loading': [ 'ETHOS.md', 'office-hours/**', 'bin/gstack-office-hours-review', 'lib/office-hours-review.ts', 'lib/fs-atomic.ts', 'lib/spec-review-cap.ts', 'test/helpers/office-hours-formatter-fixture.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/carve-guards.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/office-hours-completion.ts', 'test/helpers/llm-judge.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-office-hours-section-loading.test.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'office-hours-design-draft': [ 'ETHOS.md', 'office-hours/**', 'lib/office-hours-review.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/carve-guards.ts', 'test/helpers/auq-sdk-capture.ts', 'test/helpers/office-hours-completion.ts', 'test/helpers/llm-judge.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-office-hours-design-draft.test.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/auq-native-capture.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/capture-parity-baseline.ts', 'test/helpers/claude-pty-runner.ts', 'test/helpers/pty/**', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/parity-harness.ts', 'test/helpers/size-ledger.ts', 'test/size-ledger/**', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-count-transcript.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/plan-skill-questions.ts', 'test/helpers/pty-screen.ts', 'test/helpers/pty-trust-dialog.ts', 'test/helpers/skill-census.ts'],
'dx-error-contract-review': ['test/skill-e2e-dx-error-contract.test.ts', 'test/fixtures/dx-error-contract-case.ts', 'lib/dx-error-contract.ts', 'scripts/resolvers/dx.ts', 'scripts/resolvers/index.ts', 'scripts/gen-skill-docs.ts', 'plan-devex-review/**', 'bin/gstack-autoplan-snapshot.ts', 'lib/autoplan-prompts.ts', 'lib/autoplan-reconcile.ts', 'lib/coordinator-contract.ts', 'test/helpers/session-runner.ts', 'test/helpers/eval-store.ts', 'test/helpers/eval-budgets.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts'],
@@ -1544,6 +1617,11 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic' | 'marathon'> = {
'arm-benchmark-native-overbuild': 'periodic',
'arm-benchmark-crud-endpoint': 'periodic',
'arm-benchmark-bugfix-decoys': 'periodic',
'model-panel-bugfix': 'periodic',
'model-panel-crud': 'periodic',
'model-panel-review': 'periodic',
'model-panel-changelog': 'periodic',
'model-panel-investigate': 'periodic',
'office-hours-section-loading': 'marathon', // Full startup design/review/approval workflow (1–3 real review rounds, ~20 min)
'office-hours-design-draft': 'periodic', // Same interview through the design-creating Write (~5 min)
'plan-decision-classification': 'periodic',
@@ -1919,6 +1997,11 @@ export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
'arm-benchmark-native-overbuild': 'rule',
'arm-benchmark-crud-endpoint': 'rule',
'arm-benchmark-bugfix-decoys': 'rule',
'model-panel-bugfix': 'rule',
'model-panel-crud': 'rule',
'model-panel-review': 'rule',
'model-panel-changelog': 'rule',
'model-panel-investigate': 'rule',
'office-hours-section-loading': 'rule',
'office-hours-design-draft': 'rule',
'plan-decision-classification': 'rule',
+115
View File
@@ -0,0 +1,115 @@
/**
* Model panel selftest (free, no model, no spend): every hidden check fails on
* its seed fixture and passes on its reference answer, gateway arms carry no
* Anthropic credential, and the INFRA classes, cost math and preregistered
* wording rule behave as written, so the paid panel
* (test/skill-e2e-model-panel.test.ts) never spends on a broken instrument.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import {
PANEL_ARMS, PANEL_TASKS, PANEL_TRIALS, PANEL_MIN_COMPLETED,
setupCell, applyReference, runCheck, armEnv, cellCostUsd, classifyCell, summarize, suggestionLine, wilson, usageFromTranscript,
type CellUsage, type PanelRow, type PanelArmId,
} from './helpers/model-panel-harness';
const arm = (id: PanelArmId) => PANEL_ARMS.find((a) => a.id === id)!;
const usage = (over: Partial<CellUsage> = {}): CellUsage => ({ input: 1000, cached: 0, cacheWrite: 0, output: 100, modelsBilled: [], isError: false, apiErrorStatus: null, turns: 3, ...over });
describe('model panel selftest (free, no API)', () => {
for (const task of PANEL_TASKS) {
test(task.key + ': the hidden check fails on the seed and passes on the reference', () => {
expect(fs.existsSync(task.check)).toBe(true);
const seed = setupCell(task, 'selftest-seed');
const ref = setupCell(task, 'selftest-ref');
try {
expect(runCheck(task, seed.dir).pass).toBe(false);
applyReference(task, ref.dir);
const passed = runCheck(task, ref.dir);
expect(passed.detail).toBe('PASS');
expect(passed.pass).toBe(true);
} finally {
fs.rmSync(seed.dir, { recursive: true, force: true });
fs.rmSync(ref.dir, { recursive: true, force: true });
}
});
}
test('the panel is the preregistered shape: 5 tasks, 4 arms, 3 trials', () => {
expect(PANEL_TASKS.map((t) => t.key)).toEqual(['model-panel-bugfix', 'model-panel-crud', 'model-panel-review', 'model-panel-changelog', 'model-panel-investigate']);
expect(PANEL_ARMS.map((a) => a.model)).toEqual(['claude-opus-5-5', 'claude-sonnet-5-5', 'particle-ai/particle-1', 'deepseek/deepseek-v4.1-flash-1017']);
expect(PANEL_TRIALS).toBe(3);
});
test('gateway arms carry no Anthropic credential and pin every alias; Anthropic arms add nothing', () => {
const source = { OPENROUTER_API_KEY: 'sk-or-test', ANTHROPIC_API_KEY: 'sk-ant-real', CLAUDE_CODE_OAUTH_TOKEN: 'oauth-real' };
const env = armEnv(arm('particle'), source);
expect(env.ANTHROPIC_API_KEY).toBe('');
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('');
expect(Object.values(env)).not.toContain('sk-ant-real');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-test');
expect(env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api');
for (const k of ['ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']) {
expect(env[k]).toBe('particle-ai/particle-1');
}
expect(armEnv(arm('sonnet'), source)).toEqual({});
expect(() => armEnv(arm('deepseek'), {})).toThrow('OPENROUTER_API_KEY');
});
test('INFRA classes: startup timeout, missing result, MODEL_MIX, 402/429/5xx and pre-turn errors; the rest is scored', () => {
const p = arm('particle');
expect(classifyCell(p, 'timeout_startup', null)).toEqual({ verdict: 'infra', reason: 'startup timeout' });
expect(classifyCell(p, 'error', null).verdict).toBe('infra');
expect(classifyCell(p, 'success', usage({ modelsBilled: ['particle-ai/particle-1', 'claude-haiku-5-5'] }))).toEqual({ verdict: 'infra', reason: 'MODEL_MIX: claude-haiku-5-5' });
expect(classifyCell(p, 'success', usage({ modelsBilled: ['particle-1'] }))).toEqual({ verdict: 'scored' });
for (const status of [402, 429, 503]) expect(classifyCell(p, 'error', usage({ isError: true, apiErrorStatus: status })).verdict).toBe('infra');
expect(classifyCell(p, 'error', usage({ isError: true, turns: 0 })).verdict).toBe('infra');
expect(classifyCell(p, 'timeout', usage({ turns: 12 }))).toEqual({ verdict: 'scored' });
expect(classifyCell(p, 'error_max_turns', usage({ isError: true, turns: 40 }))).toEqual({ verdict: 'scored' });
});
test('cost comes from the price table, with cache writes, never the CLI total', () => {
expect(cellCostUsd(arm('particle'), usage({ input: 1_000_000, cached: 1_000_000, output: 1_000_000 }))).toBeCloseTo(0.25 + 0.03 + 1.0, 6);
expect(cellCostUsd(arm('opus'), usage({ input: 0, output: 0, cacheWrite: 1_000_000 }))).toBeCloseTo(4.0 * 1.25, 6);
expect(cellCostUsd(arm('deepseek'), usage({ input: 1_000_000, output: 0 }))).toBeCloseTo(0.15, 6);
const u = usageFromTranscript([{ type: 'assistant' }, { type: 'result', total_cost_usd: 9.99, num_turns: 4, usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 5, cache_creation_input_tokens: 1 }, modelUsage: { 'particle-ai/particle-1': {} } }]);
expect(u).toEqual({ input: 10, cached: 5, cacheWrite: 1, output: 2, modelsBilled: ['particle-ai/particle-1'], isError: false, apiErrorStatus: null, turns: 4 });
expect(usageFromTranscript([{ type: 'assistant' }])).toBeNull();
});
test('wilson intervals', () => {
expect(wilson(0, 0)).toEqual({ low: 0, high: 1 });
const w = wilson(12, 15);
expect(w.low).toBeCloseTo(0.548, 2);
expect(w.high).toBeCloseTo(0.930, 2);
});
const rows = (counts: Record<PanelArmId, [number, number, number]>): PanelRow[] =>
(Object.entries(counts) as Array<[PanelArmId, [number, number, number]]>).flatMap(([id, [passes, fails, infra]]) => {
const a = arm(id);
const cost = { opus: 0.6, sonnet: 0.3, particle: 0.02, deepseek: 0.03 }[id];
const make = (verdict: PanelRow['verdict'], i: number): PanelRow => ({ task: 't', arm: id, model: a.model, route: a.route, trial: i, verdict, reason: '', exitReason: 'success', costUsd: verdict === 'infra' ? null : cost, usage: null, durationMs: 1, consulted: true });
return [...Array(passes).fill('pass'), ...Array(fails).fill('fail'), ...Array(infra).fill('infra')].map((v, i) => make(v, i));
});
test('wording rule: overlap says no measured difference and quotes counts and cost; never "comparable" or "better"', () => {
const s = suggestionLine(summarize(rows({ opus: [14, 1, 0], sonnet: [13, 2, 0], particle: [12, 3, 0], deepseek: [11, 4, 0] })), '2026-10-30');
expect(s.reason).toBe('ok');
expect(s.line).toContain('particle-1 passed 12/15');
expect(s.line).toContain('Sonnet 5.5 13/15');
expect(s.line).toContain('no measured difference from Sonnet 5.5 on this panel');
expect(s.line).toContain('the cheapest of the four models measured');
expect(s.line).not.toMatch(/comparable|better/i);
});
test('wording rule: a clear gap is named, the usefulness floor withholds the suggestion, thin arms are INCOMPLETE', () => {
const floor = suggestionLine(summarize(rows({ opus: [15, 0, 0], sonnet: [15, 0, 0], particle: [6, 9, 0], deepseek: [5, 10, 0] })), '2026-10-30');
expect(floor.line).toBeNull();
expect(floor.reason).toContain('below the usefulness floor');
expect(floor.reason).toContain('below Sonnet 5.5 on this panel');
const thin = suggestionLine(summarize(rows({ opus: [15, 0, 0], sonnet: [15, 0, 0], particle: [8, 2, PANEL_TRIALS * 5 - 10], deepseek: [15, 0, 0] })), '2026-10-30');
expect(thin.line).toBeNull();
expect(thin.reason).toStartWith('INCOMPLETE');
expect(PANEL_MIN_COMPLETED).toBe(12);
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Model panel (periodic, paid): particle-1, its open-weight base DeepSeek V4.1
* Flash, Sonnet 5.5 and Opus 5.5 each run the same five gstack-shaped tasks
* PANEL_TRIALS times in Claude Code. Each cell is scored by a hidden
* deterministic check; gateway arms reach their models through OpenRouter
* with no Anthropic credential in the child env. A research instrument that
* gates nothing: assertions cover the harness, never which model wins.
*
* Spend: every cell reserves its arm's worst case in a shared ledger
* (<out>/spend.json, cap PANEL_SPEND_CAP_USD) before it starts and settles to
* the measured cost after, so a staged run (one task first, then the rest)
* stays under one cap; a refused reservation stops the panel with
* STOP: reason=budget_exhausted and keeps every row already written, and a
* cell that died without usage stays charged at its reservation (unknown).
* Rows append to <out>/rows.jsonl; <out>/summary.json holds the per-arm
* summary and the preregistered suggestion line. <out> is
* GSTACK_MODEL_PANEL_OUT, defaulting beside the eval store.
*/
import { describe, test, expect, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { runSkillTest } from './helpers/session-runner';
import { runId, selectedTests } from './helpers/e2e-helpers';
import { describeE2ETier } from './helpers/e2e-gate';
import { getProjectEvalDir } from './helpers/eval-store';
import { reserve, settle, readLedger, setCap, summarize as spendSummary, renderSpend, SpendCapExceeded } from '../lib/spend-ledger';
import {
PANEL_ARMS, PANEL_TASKS, PANEL_TRIALS, PANEL_MAX_TURNS, PANEL_TIMEOUT_MS, PANEL_CONCURRENCY, PANEL_ALLOWED_TOOLS, PANEL_SPEND_CAP_USD,
setupCell, runCheck, armEnv, usageFromTranscript, cellCostUsd, classifyCell, summarize, suggestionLine,
type PanelArm, type PanelRow, type PanelTask,
} from './helpers/model-panel-harness';
import { SKILL_NAME } from './helpers/arm-benchmark-harness';
const OUT = process.env.GSTACK_MODEL_PANEL_OUT || path.join(path.dirname(getProjectEvalDir()), 'model-panel');
const ROWS = path.join(OUT, 'rows.jsonl');
const SPEND = path.join(OUT, 'spend.json');
let stopped: string | null = null;
function consulted(result: Awaited<ReturnType<typeof runSkillTest>>): boolean {
return result.toolCalls.some((tc) => tc.tool === 'Skill' && String((tc.input as { skill?: unknown })?.skill ?? '').includes(SKILL_NAME));
}
async function runCell(task: PanelTask, arm: PanelArm, trial: number): Promise<PanelRow | null> {
if (stopped) return null;
const attempt = task.key + ':' + arm.id + ':' + trial + ':' + runId;
try {
reserve(SPEND, attempt, arm.reserveUsd, { cap: PANEL_SPEND_CAP_USD, label: arm.model });
} catch (err) {
if (!(err instanceof SpendCapExceeded)) throw err;
stopped = err.message;
process.stderr.write('STOP: reason=budget_exhausted ' + err.message + '\n');
return null;
}
const dirs = setupCell(task, arm.id + '-' + trial);
const started = Date.now();
let settled = false;
try {
const result = await runSkillTest({
prompt: task.ticket,
workingDirectory: dirs.dir,
maxTurns: PANEL_MAX_TURNS,
allowedTools: PANEL_ALLOWED_TOOLS,
timeout: PANEL_TIMEOUT_MS,
testName: task.key + '-' + arm.id + '-t' + trial,
runId,
model: arm.model,
env: armEnv(arm),
});
const usage = usageFromTranscript(result.transcript);
const costUsd = usage ? cellCostUsd(arm, usage) : null;
settle(SPEND, attempt, costUsd ?? 'unknown');
settled = true;
const cls = classifyCell(arm, result.exitReason, usage);
const scored = cls.verdict === 'scored' ? runCheck(task, dirs.dir) : null;
const row: PanelRow = {
task: task.key, arm: arm.id, model: arm.model, route: arm.route, trial,
verdict: scored ? (scored.pass ? 'pass' : 'fail') : 'infra',
reason: scored ? scored.detail : (cls as { reason: string }).reason,
exitReason: result.exitReason, costUsd, usage, durationMs: Date.now() - started, consulted: consulted(result),
};
fs.appendFileSync(ROWS, JSON.stringify({ runId, ...row }) + '\n');
console.log('[model-panel] ' + task.key + ' ' + arm.id + ' t' + trial + ' ' + row.verdict + ' $' + (costUsd ?? 0).toFixed(4) + ' turns=' + (usage?.turns ?? 0) + ' ' + row.reason);
return row;
} finally {
if (!settled) settle(SPEND, attempt, 'unknown');
fs.rmSync(dirs.dir, { recursive: true, force: true });
}
}
async function pool<T>(jobs: Array<() => Promise<T>>, limit: number): Promise<T[]> {
const out: T[] = new Array(jobs.length);
let next = 0;
await Promise.all(Array.from({ length: Math.min(limit, jobs.length) }, async () => {
while (next < jobs.length) {
const i = next++;
out[i] = await jobs[i]();
}
}));
return out;
}
const describePaid = describeE2ETier('periodic');
for (const task of PANEL_TASKS) {
const selected = selectedTests === null || selectedTests.includes(task.key);
(selected ? describePaid : describe.skip)('Model panel: ' + task.key, () => {
test(task.key, async () => {
fs.mkdirSync(OUT, { recursive: true });
if (readLedger(SPEND).cap_usd === null) setCap(SPEND, PANEL_SPEND_CAP_USD);
const jobs = PANEL_ARMS.flatMap((arm) => Array.from({ length: PANEL_TRIALS }, (_, i) => () => runCell(task, arm, i + 1)));
const rows = (await pool(jobs, PANEL_CONCURRENCY)).filter((r): r is PanelRow => r !== null);
expect(stopped, 'panel stopped: ' + stopped).toBeNull();
expect(rows).toHaveLength(PANEL_ARMS.length * PANEL_TRIALS);
for (const row of rows) expect(['pass', 'fail', 'infra']).toContain(row.verdict);
}, Math.ceil((PANEL_ARMS.length * PANEL_TRIALS) / PANEL_CONCURRENCY) * PANEL_TIMEOUT_MS + 300_000);
});
}
afterAll(() => {
if (!fs.existsSync(ROWS)) return;
const rows = fs.readFileSync(ROWS, 'utf-8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l) as PanelRow);
const summaries = summarize(rows);
const suggestion = suggestionLine(summaries, new Date().toISOString().slice(0, 10));
const spend = spendSummary(readLedger(SPEND));
fs.writeFileSync(path.join(OUT, 'summary.json'), JSON.stringify({ rows: rows.length, summaries, suggestion, spend }, null, 2) + '\n');
console.log('\n[model-panel] research instrument; gates nothing.');
for (const s of summaries) {
console.log(' ' + s.label.padEnd(20) + ' ' + s.passes + '/' + s.completed + ' passed (95% ' + (s.low * 100).toFixed(0) + '-' + (s.high * 100).toFixed(0) + '%), infra ' + s.infra + ', $' + s.costPerTaskUsd.toFixed(4) + ' per task');
}
console.log(' suggestion: ' + (suggestion.line ?? 'none (' + suggestion.reason + ')'));
console.log(' ' + renderSpend(spend));
});