From 11337167ba378caf6e0a30d6e60020c6b6ebb587 Mon Sep 17 00:00:00 2001 From: Mattias Petersson Date: Thu, 16 Jul 2026 09:59:16 +0200 Subject: [PATCH] fix benchmark provider readiness and accounting --- test/benchmark-provider-adapters.test.ts | 47 ++++++++++++++++++++++++ test/benchmark-runner.test.ts | 9 ++++- test/helpers/benchmark-runner.ts | 20 ++++++---- test/helpers/providers/claude.ts | 16 +++++--- test/helpers/providers/errors.ts | 20 ++++++++++ test/helpers/providers/gemini.ts | 46 +++++++++++++++-------- test/helpers/providers/gpt.ts | 33 ++++++++++++----- test/helpers/providers/types.ts | 1 + 8 files changed, 154 insertions(+), 38 deletions(-) create mode 100644 test/benchmark-provider-adapters.test.ts create mode 100644 test/helpers/providers/errors.ts diff --git a/test/benchmark-provider-adapters.test.ts b/test/benchmark-provider-adapters.test.ts new file mode 100644 index 000000000..8e5f2738a --- /dev/null +++ b/test/benchmark-provider-adapters.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { isCapacityError, providerErrorDetail } from './helpers/providers/errors'; +import { GptAdapter } from './helpers/providers/gpt'; +import { geminiAuthReadiness } from './helpers/providers/gemini'; + +describe('benchmark provider hardening', () => { + test('does not treat stored Gemini OAuth as non-interactive readiness', () => { + const oauthOnly = geminiAuthReadiness(true, false); + expect(oauthOnly.ok).toBe(false); + expect(oauthOnly.reason).toContain('UNSUPPORTED_CLIENT'); + expect(geminiAuthReadiness(false, true)).toEqual({ ok: true }); + expect(geminiAuthReadiness(true, true)).toEqual({ ok: true }); + }); + + test('recognizes model-capacity errors without confusing auth or quota failures', () => { + expect(isCapacityError('Selected model is at capacity. Please try a different model')).toBe(true); + expect(isCapacityError('The provider is overloaded right now')).toBe(true); + expect(isCapacityError('Model temporarily unavailable due to high demand')).toBe(true); + expect(isCapacityError('Error 401: login required')).toBe(false); + expect(isCapacityError('429 quota exceeded')).toBe(false); + }); + + test('combines stderr and message without duplicating subprocess output', () => { + const stderr = 'Selected model is at capacity.'; + expect(providerErrorDetail({ stderr: Buffer.from(stderr), message: `Command failed\n${stderr}` })) + .toBe(`Command failed\n${stderr}`); + expect(providerErrorDetail({ stderr: Buffer.from('stderr only'), message: 'message only' })) + .toBe('stderr only\nmessage only'); + }); + + test('Codex ignores inherited stdin and separates cached input tokens', () => { + const source = fs.readFileSync(path.join(import.meta.dir, 'helpers/providers/gpt.ts'), 'utf8'); + expect(source).toContain("stdio: ['ignore', 'pipe', 'pipe']"); + + const gpt = new GptAdapter(); + 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 }); + }); +}); diff --git a/test/benchmark-runner.test.ts b/test/benchmark-runner.test.ts index ecd503ea8..6661d89b1 100644 --- a/test/benchmark-runner.test.ts +++ b/test/benchmark-runner.test.ts @@ -37,6 +37,11 @@ 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', () => { + 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 +77,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 +112,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', () => { diff --git a/test/helpers/benchmark-runner.ts b/test/helpers/benchmark-runner.ts index cbef4107b..60bbf5b78 100644 --- a/test/helpers/benchmark-runner.ts +++ b/test/helpers/benchmark-runner.ts @@ -99,21 +99,21 @@ export async function runBenchmark(input: BenchmarkInput): Promise { @@ -161,17 +174,20 @@ export class GeminiAdapter implements ProviderAdapter { } catch (err: unknown) { const durationMs = Date.now() - start; const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string }; - const stderr = e.stderr?.toString() ?? ''; + const detail = providerErrorDetail(e); if (e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT') { return this.emptyResult(durationMs, { code: 'timeout', reason: `exceeded ${opts.timeoutMs}ms` }, opts.model); } - if (/unauthorized|auth|login|api key|ineligibletier|no longer supported/i.test(stderr)) { - return this.emptyResult(durationMs, { code: 'auth', reason: stderr.slice(0, 400) }, opts.model); + if (/unauthorized|auth|login|api key|ineligibletier|no longer supported/i.test(detail)) { + return this.emptyResult(durationMs, { code: 'auth', reason: detail.slice(0, 400) }, opts.model); } - if (/rate[- ]?limit|429|quota/i.test(stderr)) { - return this.emptyResult(durationMs, { code: 'rate_limit', reason: stderr.slice(0, 400) }, opts.model); + if (/rate[- ]?limit|429|quota/i.test(detail)) { + return this.emptyResult(durationMs, { code: 'rate_limit', reason: detail.slice(0, 400) }, opts.model); } - return this.emptyResult(durationMs, { code: 'unknown', reason: (e.message ?? stderr ?? 'unknown').slice(0, 400) }, opts.model); + if (isCapacityError(detail)) { + return this.emptyResult(durationMs, { code: 'capacity', reason: detail.slice(0, 400) }, opts.model); + } + return this.emptyResult(durationMs, { code: 'unknown', reason: (detail || 'unknown').slice(0, 400) }, opts.model); } } diff --git a/test/helpers/providers/gpt.ts b/test/helpers/providers/gpt.ts index 07757dc2f..23e8bd6de 100644 --- a/test/helpers/providers/gpt.ts +++ b/test/helpers/providers/gpt.ts @@ -4,6 +4,7 @@ import { execFileSync, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { isCapacityError, providerErrorDetail } from './errors'; /** * GPT adapter — wraps the OpenAI `codex` CLI (codex exec with --json output). @@ -46,6 +47,10 @@ export class GptAdapter implements ProviderAdapter { timeout: opts.timeoutMs, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024, + // A benchmark may itself run under an interactive agent. Never let a + // nested Codex process inherit that agent's stdin and wait for more + // input instead of executing the prompt already present in `args`. + stdio: ['ignore', 'pipe', 'pipe'], }); const parsed = this.parseJsonl(out); return { @@ -58,17 +63,20 @@ export class GptAdapter implements ProviderAdapter { } catch (err: unknown) { const durationMs = Date.now() - start; const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string }; - const stderr = e.stderr?.toString() ?? ''; + const detail = providerErrorDetail(e); if (e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT') { return this.emptyResult(durationMs, { code: 'timeout', reason: `exceeded ${opts.timeoutMs}ms` }, opts.model); } - if (/unauthorized|auth|login/i.test(stderr)) { - return this.emptyResult(durationMs, { code: 'auth', reason: stderr.slice(0, 400) }, opts.model); + if (/unauthorized|auth|login/i.test(detail)) { + return this.emptyResult(durationMs, { code: 'auth', reason: detail.slice(0, 400) }, opts.model); } - if (/rate[- ]?limit|429/i.test(stderr)) { - return this.emptyResult(durationMs, { code: 'rate_limit', reason: stderr.slice(0, 400) }, opts.model); + if (/rate[- ]?limit|429/i.test(detail)) { + return this.emptyResult(durationMs, { code: 'rate_limit', reason: detail.slice(0, 400) }, opts.model); } - return this.emptyResult(durationMs, { code: 'unknown', reason: (e.message ?? stderr ?? 'unknown').slice(0, 400) }, opts.model); + if (isCapacityError(detail)) { + return this.emptyResult(durationMs, { code: 'capacity', reason: detail.slice(0, 400) }, opts.model); + } + return this.emptyResult(durationMs, { code: 'unknown', reason: (detail || 'unknown').slice(0, 400) }, opts.model); } } @@ -84,9 +92,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 +112,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: input is uncached-only and + // cached is a disjoint bucket priced 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 +126,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, cached, output: out }, toolCalls, modelUsed }; } private emptyResult(durationMs: number, error: RunResult['error'], model?: string): RunResult { diff --git a/test/helpers/providers/types.ts b/test/helpers/providers/types.ts index 1680d0ceb..ddaa01f09 100644 --- a/test/helpers/providers/types.ts +++ b/test/helpers/providers/types.ts @@ -31,6 +31,7 @@ export type RunError = | 'auth' // Credentials missing or invalid. | 'timeout' // Exceeded timeoutMs. | 'rate_limit' // Provider rate-limited us; backoff exceeded. + | 'capacity' // Selected model/provider is temporarily saturated. | 'binary_missing' // CLI not found on PATH. | 'unknown'; // Catch-all with reason populated.