fix benchmark adapters and stdin handling

This commit is contained in:
Mattias Petersson 2026-07-16 09:06:58 +02:00
parent 49964ee712
commit 9c2414016c
7 changed files with 117 additions and 20 deletions

View File

@ -142,6 +142,54 @@ describe('gstack-model-benchmark --dry-run', () => {
}
});
test('Gemini OAuth-only auth is reported as NOT READY with Antigravity remediation', () => {
const oauthHome = fs.mkdtempSync(path.join(os.tmpdir(), 'bench-gemini-oauth-home-'));
try {
const oauthDir = path.join(oauthHome, '.gemini');
fs.mkdirSync(oauthDir, { recursive: true });
fs.writeFileSync(path.join(oauthDir, 'oauth_creds.json'), '{}');
const result = spawnSync('bun', ['run', BIN, '--prompt', 'hi', '--models', 'gemini', '--dry-run'], {
cwd: ROOT,
env: {
PATH: process.env.PATH ?? '',
TERM: process.env.TERM ?? 'xterm',
HOME: oauthHome,
},
encoding: 'utf-8',
timeout: 15000,
});
expect(result.status).toBe(0);
const out = result.stdout?.toString() ?? '';
expect(out).toMatch(/gemini:\s+NOT READY/);
expect(out).toContain('UNSUPPORTED_CLIENT');
expect(out).toContain('`agy` adapter');
expect(out).toContain('GOOGLE_API_KEY');
} finally {
fs.rmSync(oauthHome, { recursive: true, force: true });
}
});
test('Gemini API-key auth remains locally ready', () => {
const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), 'bench-gemini-key-home-'));
try {
const result = spawnSync('bun', ['run', BIN, '--prompt', 'hi', '--models', 'gemini', '--dry-run'], {
cwd: ROOT,
env: {
PATH: process.env.PATH ?? '',
TERM: process.env.TERM ?? 'xterm',
HOME: emptyHome,
GOOGLE_API_KEY: 'offline-readiness-test-key',
},
encoding: 'utf-8',
timeout: 15000,
});
expect(result.status).toBe(0);
expect(result.stdout?.toString() ?? '').toMatch(/gemini:\s+OK/);
} finally {
fs.rmSync(emptyHome, { recursive: true, force: true });
}
});
test('long prompt is truncated in dry-run display', () => {
const longPrompt = 'x'.repeat(200);
const r = run(['--prompt', longPrompt, '--dry-run']);

View File

@ -37,6 +37,13 @@ test('estimateCostUsd applies cached input discount alongside uncached input', (
expect(cost2).toBeCloseTo(8.25, 2);
});
test('estimateCostUsd prices normalized Codex cache reads at the discount', () => {
// Raw Codex usage was 21,155 total input with 9,984 cached. The adapter
// normalizes that to 11,171 uncached + 9,984 cached before pricing.
const cost = estimateCostUsd({ input: 11_171, cached: 9_984, output: 5 }, 'gpt-5.4');
expect(cost).toBeCloseTo(0.030474, 6);
});
test('PRICING table covers the key model families', () => {
expect(PRICING['claude-opus-4-7']).toBeDefined();
expect(PRICING['claude-sonnet-4-6']).toBeDefined();
@ -72,7 +79,7 @@ test('formatTable handles a report with mixed success/error/unavailable entries'
available: true,
result: {
output: 'ok',
tokens: { input: 100, output: 200 },
tokens: { input: 100, cached: 50, output: 200 },
durationMs: 800,
toolCalls: 3,
modelUsed: 'claude-opus-4-7',
@ -107,6 +114,8 @@ test('formatTable handles a report with mixed success/error/unavailable entries'
expect(table).toContain('ERROR auth');
expect(table).toContain('unavailable');
expect(table).toContain('9.2/10');
expect(table).toContain('100+50→200');
expect(formatMarkdown(report)).toContain('100+50→200');
});
test('formatJson produces parseable JSON', () => {

View File

@ -101,21 +101,21 @@ export async function runBenchmark(input: BenchmarkInput): Promise<BenchmarkRepo
}
export function formatTable(report: BenchmarkReport): string {
const header = `Model Latency In→Out Tokens Cost Quality Tool Calls Notes`;
const header = `Model Latency Input (new+cached)→Out Cost Quality Tool Calls Notes`;
const sep = '-'.repeat(header.length);
const rows: string[] = [header, sep];
for (const e of report.entries) {
if (!e.available) {
rows.push(`${pad(e.provider, 20)} ${pad('-', 9)} ${pad('-', 20)} ${pad('-', 10)} ${pad('-', 9)} ${pad('-', 12)} unavailable: ${e.unavailable_reason ?? 'unknown'}`);
rows.push(`${pad(e.provider, 20)} ${pad('-', 9)} ${pad('-', 24)} ${pad('-', 10)} ${pad('-', 9)} ${pad('-', 12)} unavailable: ${e.unavailable_reason ?? 'unknown'}`);
continue;
}
const r = e.result!;
if (r.error) {
rows.push(`${pad(r.modelUsed, 20)} ${pad(msToStr(r.durationMs), 9)} ${pad(`${r.tokens.input}${r.tokens.output}`, 20)} ${pad(fmtCost(e.costUsd), 10)} ${pad('-', 9)} ${pad(String(r.toolCalls), 12)} ERROR ${r.error.code}: ${r.error.reason.slice(0, 40)}`);
rows.push(`${pad(r.modelUsed, 20)} ${pad(msToStr(r.durationMs), 9)} ${pad(fmtTokens(r.tokens), 24)} ${pad(fmtCost(e.costUsd), 10)} ${pad('-', 9)} ${pad(String(r.toolCalls), 12)} ERROR ${r.error.code}: ${r.error.reason.slice(0, 40)}`);
continue;
}
const quality = e.qualityScore !== undefined ? `${e.qualityScore.toFixed(1)}/10` : '-';
rows.push(`${pad(r.modelUsed, 20)} ${pad(msToStr(r.durationMs), 9)} ${pad(`${r.tokens.input}${r.tokens.output}`, 20)} ${pad(fmtCost(e.costUsd), 10)} ${pad(quality, 9)} ${pad(String(r.toolCalls), 12)}`);
rows.push(`${pad(r.modelUsed, 20)} ${pad(msToStr(r.durationMs), 9)} ${pad(fmtTokens(r.tokens), 24)} ${pad(fmtCost(e.costUsd), 10)} ${pad(quality, 9)} ${pad(String(r.toolCalls), 12)}`);
}
return rows.join('\n');
}
@ -132,7 +132,7 @@ export function formatMarkdown(report: BenchmarkReport): string {
`**Workdir:** \`${report.workdir}\``,
`**Total duration:** ${msToStr(report.durationMs)}`,
'',
'| Model | Latency | Tokens (in→out) | Cost | Quality | Tools | Notes |',
'| Model | Latency | Tokens (new+cached→out) | Cost | Quality | Tools | Notes |',
'|-------|---------|-----------------|------|---------|-------|-------|',
];
for (const e of report.entries) {
@ -142,11 +142,11 @@ export function formatMarkdown(report: BenchmarkReport): string {
}
const r = e.result!;
if (r.error) {
lines.push(`| ${r.modelUsed} | ${msToStr(r.durationMs)} | ${r.tokens.input}${r.tokens.output} | ${fmtCost(e.costUsd)} | - | ${r.toolCalls} | ERROR ${r.error.code}: ${r.error.reason.slice(0, 80)} |`);
lines.push(`| ${r.modelUsed} | ${msToStr(r.durationMs)} | ${fmtTokens(r.tokens)} | ${fmtCost(e.costUsd)} | - | ${r.toolCalls} | ERROR ${r.error.code}: ${r.error.reason.slice(0, 80)} |`);
continue;
}
const quality = e.qualityScore !== undefined ? `${e.qualityScore.toFixed(1)}/10` : '-';
lines.push(`| ${r.modelUsed} | ${msToStr(r.durationMs)} | ${r.tokens.input}${r.tokens.output} | ${fmtCost(e.costUsd)} | ${quality} | ${r.toolCalls} | |`);
lines.push(`| ${r.modelUsed} | ${msToStr(r.durationMs)} | ${fmtTokens(r.tokens)} | ${fmtCost(e.costUsd)} | ${quality} | ${r.toolCalls} | |`);
}
return lines.join('\n');
}
@ -160,6 +160,12 @@ function msToStr(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtTokens(tokens: { input: number; output: number; cached?: number }): string {
return tokens.cached === undefined
? `${tokens.input}${tokens.output}`
: `${tokens.input}+${tokens.cached}${tokens.output}`;
}
function fmtCost(usd?: number): string {
if (usd === undefined) return '-';
if (usd < 0.01) return `$${usd.toFixed(4)}`;

View File

@ -25,12 +25,18 @@ export class GeminiAdapter implements ProviderAdapter {
const legacyCfgDir = path.join(os.homedir(), '.config', 'gemini');
const newCfgDir = path.join(os.homedir(), '.gemini');
const newOauth = path.join(newCfgDir, 'oauth_creds.json');
const hasCfg = fs.existsSync(legacyCfgDir) || fs.existsSync(newOauth);
const hasOauth = fs.existsSync(legacyCfgDir) || fs.existsSync(newOauth);
const hasKey = !!process.env.GOOGLE_API_KEY;
if (!hasCfg && !hasKey) {
return { ok: false, reason: 'No Gemini auth found. Log in via `gemini login` or export GOOGLE_API_KEY.' };
if (hasKey) {
return { ok: true };
}
return { ok: true };
if (hasOauth) {
return {
ok: false,
reason: 'Stored Gemini OAuth is not a reliable readiness signal and may fail with UNSUPPORTED_CLIENT. Use the `agy` adapter for Google subscription auth, or export GOOGLE_API_KEY.',
};
}
return { ok: false, reason: 'No Gemini auth found. Use the `agy` adapter for Google subscription auth, or export GOOGLE_API_KEY.' };
}
async run(opts: RunOpts): Promise<RunResult> {

View File

@ -46,6 +46,11 @@ export class GptAdapter implements ProviderAdapter {
timeout: opts.timeoutMs,
encoding: 'utf-8',
maxBuffer: 32 * 1024 * 1024,
// Benchmarks can themselves run under an interactive agent process.
// Never let nested Codex inherit that process's stdin: it may wait for
// additional input instead of executing the prompt passed in `args`.
// Keep stdout/stderr piped so JSONL parsing and error routing still work.
stdio: ['ignore', 'pipe', 'pipe'],
});
const parsed = this.parseJsonl(out);
return {
@ -84,9 +89,10 @@ export class GptAdapter implements ProviderAdapter {
* - turn.completed usage.input_tokens, usage.output_tokens
* - thread.started session id (not used here)
*/
private parseJsonl(raw: string): { output: string; tokens: { input: number; output: number }; toolCalls: number; modelUsed?: string } {
private parseJsonl(raw: string): { output: string; tokens: { input: number; output: number; cached?: number }; toolCalls: number; modelUsed?: string } {
let output = '';
let input = 0;
let cached = 0;
let out = 0;
let toolCalls = 0;
let modelUsed: string | undefined;
@ -103,7 +109,13 @@ export class GptAdapter implements ProviderAdapter {
}
} else if (obj.type === 'turn.completed') {
const u = obj.usage ?? {};
input += u.input_tokens ?? 0;
// Codex reports cached_input_tokens as a subset of input_tokens.
// Normalize to the benchmark contract, where input is uncached-only
// and cached is a separate bucket billed at the cache-read rate.
const turnInput = u.input_tokens ?? 0;
const turnCached = u.cached_input_tokens ?? 0;
input += Math.max(0, turnInput - turnCached);
cached += turnCached;
out += u.output_tokens ?? 0;
if (obj.model) modelUsed = obj.model;
}
@ -111,7 +123,7 @@ export class GptAdapter implements ProviderAdapter {
// skip malformed lines — codex stderr can leak in
}
}
return { output, tokens: { input, output: out }, toolCalls, modelUsed };
return { output, tokens: { input, output: out, cached }, toolCalls, modelUsed };
}
private emptyResult(durationMs: number, error: RunResult['error'], model?: string): RunResult {

View File

@ -88,12 +88,12 @@ export async function runWithSecretSink(opts: SecretSinkOptions): Promise<SinkRe
env,
stdout: 'pipe',
stderr: 'pipe',
stdin: opts.stdin ? 'pipe' : 'ignore',
// Supplying the complete buffer at spawn time is reliable across Bun
// versions and restricted runners. A writable `stdin: 'pipe'` can close
// before buffered bytes reach the child (Bun 1.3.14), producing empty
// input even when write() and end() are called in order.
stdin: opts.stdin === undefined ? 'ignore' : Buffer.from(opts.stdin),
});
if (opts.stdin) {
proc.stdin!.write(opts.stdin);
proc.stdin!.end();
}
const timeoutMs = opts.timeoutMs ?? 10_000;
const timeoutHandle = setTimeout(() => {

View File

@ -56,6 +56,22 @@ test('agy adapter enforces sandboxed plan mode and rejects permission bypass', a
expect(result.error?.reason).toContain('not allowed');
});
test('gpt adapter disables inherited stdin for nested codex', () => {
const source = fs.readFileSync(path.join(import.meta.dir, 'helpers/providers/gpt.ts'), 'utf8');
expect(source).toContain("stdio: ['ignore', 'pipe', 'pipe']");
});
test('gpt adapter normalizes cached input out of Codex total input', () => {
const parseJsonl = (gpt as unknown as {
parseJsonl(raw: string): { tokens: { input: number; output: number; cached?: number } };
}).parseJsonl.bind(gpt);
const parsed = parseJsonl([
JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 21_155, cached_input_tokens: 9_984, output_tokens: 5 } }),
JSON.stringify({ type: 'turn.completed', usage: { input_tokens: 200, cached_input_tokens: 150, output_tokens: 10 } }),
].join('\n'));
expect(parsed.tokens).toEqual({ input: 11_221, cached: 10_134, output: 15 });
});
// Use a temp working directory so provider CLIs can't accidentally touch the repo.
// Created in beforeAll / cleaned in afterAll so concurrent CI runs don't leak.
let workdir: string;