Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions test/benchmark-provider-adapters.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
9 changes: 8 additions & 1 deletion test/benchmark-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down
20 changes: 13 additions & 7 deletions test/helpers/benchmark-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,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');
}
Expand All @@ -130,7 +130,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) {
Expand All @@ -140,11 +140,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');
}
Expand All @@ -158,6 +158,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)}`;
Expand Down
16 changes: 10 additions & 6 deletions test/helpers/providers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { resolveClaudeCommand } from '../../../browse/src/claude-bin';
import { isCapacityError, providerErrorDetail } from './errors';

/**
* Claude adapter — wraps the `claude` CLI via claude -p.
Expand Down Expand Up @@ -67,17 +68,20 @@ export class ClaudeAdapter 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);
}
}

Expand Down
20 changes: 20 additions & 0 deletions test/helpers/providers/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface ProviderProcessError {
stderr?: Buffer | string;
message?: string;
}

/** Combine subprocess diagnostics without duplicating identical text. */
export function providerErrorDetail(error: ProviderProcessError): string {
const stderr = typeof error.stderr === 'string'
? error.stderr
: error.stderr?.toString() ?? '';
const message = error.message ?? '';
return stderr && message.includes(stderr)
? message
: [stderr, message].filter(Boolean).join('\n');
}

/** Provider/model saturation is transient and distinct from auth or quota. */
export function isCapacityError(detail: string): boolean {
return /(?:selected\s+)?model\s+is\s+at\s+capacity|\bat\s+capacity\b|\bmodel\s+capacity\b|\boverloaded\b|temporarily unavailable due to (?:high )?(?:load|demand)/i.test(detail);
}
46 changes: 31 additions & 15 deletions test/helpers/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

export type GeminiStreamParse = {
output: string;
Expand Down Expand Up @@ -93,6 +94,25 @@ export function resultFromGeminiStream(
};
}

/** Decide whether local Gemini auth can support a non-interactive benchmark. */
export function geminiAuthReadiness(hasOauth: boolean, hasKey: boolean): AvailabilityCheck {
if (hasKey) {
return { ok: true };
}
if (hasOauth) {
return {
ok: false,
reason:
'Stored Gemini OAuth is not a reliable readiness signal and may fail with UNSUPPORTED_CLIENT. To use an AI Studio key, export GEMINI_API_KEY (or GOOGLE_API_KEY).',
};
}
return {
ok: false,
reason:
'No Gemini auth found. Get an AI Studio key from https://aistudio.google.com/app/apikey, then export GEMINI_API_KEY (or GOOGLE_API_KEY) — personal OAuth free-tier is no longer supported by gemini CLI.',
};
}

/**
* Gemini adapter — wraps the `gemini` CLI.
*
Expand Down Expand Up @@ -120,17 +140,10 @@ 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);
// CLI accepts either name; Google AI Studio keys are usually GEMINI_API_KEY.
const hasKey = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
if (!hasCfg && !hasKey) {
return {
ok: false,
reason:
'No Gemini auth found. Export GEMINI_API_KEY (or GOOGLE_API_KEY) from https://aistudio.google.com/app/apikey — personal OAuth free-tier is no longer supported by gemini CLI.',
};
}
return { ok: true };
return geminiAuthReadiness(hasOauth, hasKey);
}

async run(opts: RunOpts): Promise<RunResult> {
Expand Down Expand Up @@ -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(detail)) {
return this.emptyResult(durationMs, { code: 'rate_limit', 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 (isCapacityError(detail)) {
return this.emptyResult(durationMs, { code: 'capacity', reason: detail.slice(0, 400) }, opts.model);
}
return this.emptyResult(durationMs, { code: 'unknown', reason: (e.message ?? stderr ?? 'unknown').slice(0, 400) }, opts.model);
return this.emptyResult(durationMs, { code: 'unknown', reason: (detail || 'unknown').slice(0, 400) }, opts.model);
}
}

Expand Down
33 changes: 24 additions & 9 deletions test/helpers/providers/gpt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
}

Expand All @@ -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;
Expand All @@ -103,15 +112,21 @@ 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;
}
} catch {
// 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 {
Expand Down
1 change: 1 addition & 0 deletions test/helpers/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down