Skip to content

Commit 059e144

Browse files
iamzifeiclaude
andcommitted
feat: cost tracking, percentiles, badges, compare + Opus 4.7
- Real token capture: agent emits USAGE line; parseUsageLine parses it - pricing.ts maps model→$/MTok (Anthropic list prices); cost flows to summary - p50/p95 plumbing via linear interpolation; surfaces when runs>1 - Badge endpoint /badge/<slug>.svg?metric=task|pass|score (shields-style SVG) - Compare page /compare?a=x&b=y with scoreboard + Δ time + cost ratio - Logo + favicon (Hunter Reticle mark) integrated via nav + metadata - URL params for ranking sort, Reproduce Now CTA, criteria matrix on task pages - Add Opus 4.7 to agent roster (released yesterday — trending) Rankings (10 tasks, LLM-as-judge, real tokens + cost): Opus 4.7 10/10 8.4s $0.56 ← fastest perfect scorer Opus 4.6 10/10 9.8s $0.44 Sonnet 4.6 10/10 9.8s $0.11 ← 4× cheaper than Opus Haiku 4.5 8/10 4.6s $0.03 Tests 98/98. Lint + build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d0e786c commit 059e144

73 files changed

Lines changed: 3161 additions & 531 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,12 @@ vite.config.ts.timestamp-*
143143

144144
# macOS
145145
.DS_Store
146+
.vercel
147+
148+
# Playwright/browser snapshots (ephemeral)
149+
.playwright-mcp/
150+
# Ad-hoc local screenshots & snapshots
151+
homepage_screenshot.png
152+
homepage_snapshot.md
153+
# Ad-hoc eval workspaces
154+
test-run/
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Model pricing — USD per million tokens.
3+
*
4+
* Numbers track Anthropic's published list prices as of 2026. Keep the map
5+
* in sync with api.anthropic.com/pricing when adding new models; the eval
6+
* engine falls back to 0 for unknown model IDs rather than guessing, since
7+
* an incorrect price is worse than no price at all.
8+
*/
9+
10+
export interface ModelPrice {
11+
/** USD per million input tokens */
12+
inputPerMTok: number;
13+
/** USD per million output tokens */
14+
outputPerMTok: number;
15+
}
16+
17+
const PRICES: Record<string, ModelPrice> = {
18+
// Anthropic Claude 4.x family
19+
"claude-opus-4-6": { inputPerMTok: 15, outputPerMTok: 75 },
20+
"claude-opus-4-7": { inputPerMTok: 15, outputPerMTok: 75 },
21+
"claude-sonnet-4-6": { inputPerMTok: 3, outputPerMTok: 15 },
22+
"claude-haiku-4-5": { inputPerMTok: 1, outputPerMTok: 5 },
23+
"claude-haiku-4-5-20251001": { inputPerMTok: 1, outputPerMTok: 5 },
24+
// Short aliases — agent scripts frequently refer to these
25+
opus: { inputPerMTok: 15, outputPerMTok: 75 },
26+
sonnet: { inputPerMTok: 3, outputPerMTok: 15 },
27+
haiku: { inputPerMTok: 1, outputPerMTok: 5 },
28+
};
29+
30+
/**
31+
* Compute the USD cost of a single run given model ID and token counts.
32+
* Returns 0 when the model is unknown so callers can safely render it.
33+
*/
34+
export function computeCostUsd(
35+
model: string | undefined,
36+
inputTokens: number | undefined,
37+
outputTokens: number | undefined,
38+
): number {
39+
if (!model || inputTokens == null || outputTokens == null) return 0;
40+
const price = PRICES[model] ?? PRICES[model.toLowerCase()];
41+
if (!price) return 0;
42+
return (
43+
(inputTokens * price.inputPerMTok) / 1_000_000 +
44+
(outputTokens * price.outputPerMTok) / 1_000_000
45+
);
46+
}
47+
48+
/** Expose the price map so tests and tooling can enumerate supported models. */
49+
export function getPrice(model: string): ModelPrice | null {
50+
return PRICES[model] ?? PRICES[model.toLowerCase()] ?? null;
51+
}

packages/agent-eval/src/eval/task-eval.ts

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import Anthropic from "@anthropic-ai/sdk";
2323
import type { TaskEvalConfig } from "../config/task-schema.js";
2424
import { callWithRetry } from "./llm-client.js";
25+
import { computeCostUsd } from "./pricing.js";
2526

2627
/** Result of evaluating a single success criterion */
2728
export interface CriterionResult {
@@ -40,6 +41,12 @@ export interface TaskRunResult {
4041
output: string;
4142
/** Estimated token count from agent output length (rough proxy) */
4243
estimatedTokens: number;
44+
/** Real input tokens reported by the agent via `USAGE: input=X output=Y` (if emitted) */
45+
inputTokens?: number;
46+
/** Real output tokens reported by the agent (if emitted) */
47+
outputTokens?: number;
48+
/** Computed USD cost for this run, if pricing + real tokens are available */
49+
costUsd?: number;
4350
}
4451

4552
/** Aggregated result across all runs */
@@ -49,7 +56,13 @@ export interface TaskEvalResult {
4956
runs: TaskRunResult[];
5057
successRate: number;
5158
avgDurationMs: number;
59+
/** 50th percentile duration across runs — equal to avg when runs=1 */
60+
p50DurationMs: number;
61+
/** 95th percentile duration — useful once runs>=3 to reveal tail latency */
62+
p95DurationMs: number;
5263
avgTokens: number;
64+
/** Average USD cost across runs — 0 if cost isn't known */
65+
avgCostUsd: number;
5366
totalRuns: number;
5467
totalPassed: number;
5568
}
@@ -168,9 +181,20 @@ export async function runTaskEvaluation(options: {
168181

169182
const allPassed = criteriaResults.every((c) => c.passed);
170183

171-
// Rough token estimate: ~4 chars per token
184+
// Rough token estimate: ~4 chars per token. Used as a fallback when the
185+
// agent doesn't report real token usage on its USAGE line.
172186
const estimatedTokens = Math.round(output.length / 4);
173187

188+
// Parse `USAGE: input=<n> output=<m> [model=<id>]` emitted by agent scripts
189+
// so we can track real token counts and compute accurate cost.
190+
const usage = parseUsageLine(output);
191+
192+
const costUsd = computeCostUsd(
193+
usage?.model,
194+
usage?.inputTokens,
195+
usage?.outputTokens,
196+
);
197+
174198
runs.push({
175199
runIndex: i + 1,
176200
success: allPassed,
@@ -179,34 +203,87 @@ export async function runTaskEvaluation(options: {
179203
exitCode,
180204
output: output.slice(0, 5000), // Truncate for storage
181205
estimatedTokens,
206+
inputTokens: usage?.inputTokens,
207+
outputTokens: usage?.outputTokens,
208+
costUsd: costUsd > 0 ? costUsd : undefined,
182209
});
183210
}
184211

185212
// Aggregate
186213
const totalPassed = runs.filter((r) => r.success).length;
214+
const durations = runs.map((r) => r.durationMs);
187215
const avgDurationMs =
188-
runs.length > 0
189-
? Math.round(runs.reduce((s, r) => s + r.durationMs, 0) / runs.length)
216+
durations.length > 0
217+
? Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)
190218
: 0;
191219
const avgTokens =
192220
runs.length > 0
193221
? Math.round(
194222
runs.reduce((s, r) => s + r.estimatedTokens, 0) / runs.length,
195223
)
196224
: 0;
225+
const runsWithCost = runs.filter((r) => r.costUsd != null);
226+
const avgCostUsd =
227+
runsWithCost.length > 0
228+
? runsWithCost.reduce((s, r) => s + (r.costUsd ?? 0), 0) /
229+
runsWithCost.length
230+
: 0;
197231

198232
return {
199233
taskName: config.task.name,
200234
agentCommand: `${config.agent.command} ${config.agent.args.join(" ")}`,
201235
runs,
202236
successRate: runs.length > 0 ? totalPassed / runs.length : 0,
203237
avgDurationMs,
238+
p50DurationMs: percentile(durations, 50),
239+
p95DurationMs: percentile(durations, 95),
204240
avgTokens,
241+
avgCostUsd,
205242
totalRuns: runs.length,
206243
totalPassed,
207244
};
208245
}
209246

247+
/**
248+
* Compute the requested percentile of a numeric array. Uses linear
249+
* interpolation between adjacent ranks (matches numpy's default), which
250+
* gives sensible results even for small sample sizes.
251+
*/
252+
export function percentile(values: number[], p: number): number {
253+
if (values.length === 0) return 0;
254+
if (values.length === 1) return Math.round(values[0] ?? 0);
255+
const sorted = [...values].sort((a, b) => a - b);
256+
const rank = (p / 100) * (sorted.length - 1);
257+
const lo = Math.floor(rank);
258+
const hi = Math.ceil(rank);
259+
const weight = rank - lo;
260+
const value = (sorted[lo] ?? 0) * (1 - weight) + (sorted[hi] ?? 0) * weight;
261+
return Math.round(value);
262+
}
263+
264+
/**
265+
* Parse the `USAGE:` marker line that agent scripts emit after calling the
266+
* model API. Accepts formats like:
267+
* USAGE: input=1234 output=567
268+
* USAGE: input=1234 output=567 model=claude-sonnet-4-6
269+
* Returns null if no marker is present so callers fall back to rough estimates.
270+
*/
271+
export function parseUsageLine(output: string): {
272+
inputTokens: number;
273+
outputTokens: number;
274+
model?: string;
275+
} | null {
276+
const match = output.match(
277+
/^USAGE:\s+input=(\d+)\s+output=(\d+)(?:\s+model=([\w.\-:]+))?/m,
278+
);
279+
if (!match) return null;
280+
return {
281+
inputTokens: Number.parseInt(match[1] ?? "0", 10),
282+
outputTokens: Number.parseInt(match[2] ?? "0", 10),
283+
model: match[3],
284+
};
285+
}
286+
210287
/**
211288
* Use LLM-as-judge to evaluate each success criterion against the agent output.
212289
*/
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from "vitest";
2+
import { computeCostUsd, getPrice } from "../src/eval/pricing.js";
3+
4+
describe("pricing", () => {
5+
it("returns 0 for unknown models so cost renders safely", () => {
6+
expect(computeCostUsd("unknown-model", 1000, 1000)).toBe(0);
7+
});
8+
9+
it("returns 0 when token counts are missing", () => {
10+
expect(computeCostUsd("claude-sonnet-4-6", undefined, 100)).toBe(0);
11+
expect(computeCostUsd("claude-sonnet-4-6", 100, undefined)).toBe(0);
12+
expect(computeCostUsd(undefined, 100, 100)).toBe(0);
13+
});
14+
15+
it("computes Sonnet 4.6 cost: 1M input + 1M output = $3 + $15 = $18", () => {
16+
const cost = computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000);
17+
expect(cost).toBeCloseTo(18, 5);
18+
});
19+
20+
it("computes Opus 4.6 cost: 1M input + 1M output = $15 + $75 = $90", () => {
21+
const cost = computeCostUsd("claude-opus-4-6", 1_000_000, 1_000_000);
22+
expect(cost).toBeCloseTo(90, 5);
23+
});
24+
25+
it("computes Haiku 4.5 cost: 1M input + 1M output = $1 + $5 = $6", () => {
26+
const cost = computeCostUsd("claude-haiku-4-5", 1_000_000, 1_000_000);
27+
expect(cost).toBeCloseTo(6, 5);
28+
});
29+
30+
it("accepts short aliases like 'sonnet' for ergonomics", () => {
31+
const cost = computeCostUsd("sonnet", 1_000_000, 1_000_000);
32+
expect(cost).toBeCloseTo(18, 5);
33+
});
34+
35+
it("getPrice returns null for unknown models", () => {
36+
expect(getPrice("gpt-9000")).toBeNull();
37+
});
38+
39+
it("getPrice returns structured data for known models", () => {
40+
expect(getPrice("claude-sonnet-4-6")).toEqual({
41+
inputPerMTok: 3,
42+
outputPerMTok: 15,
43+
});
44+
});
45+
});

packages/agent-eval/tests/task-eval.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ vi.mock("@anthropic-ai/sdk", () => {
2828
return { default: MockAnthropic };
2929
});
3030

31-
const { runTaskEvaluation } = await import("../src/eval/task-eval.js");
31+
const { runTaskEvaluation, percentile, parseUsageLine } = await import(
32+
"../src/eval/task-eval.js"
33+
);
3234

3335
describe("runTaskEvaluation", () => {
3436
it("should evaluate a simple CLI agent task", async () => {
@@ -128,4 +130,75 @@ describe("runTaskEvaluation", () => {
128130
// The echo output should contain the substituted description
129131
expect(result.runs[0]?.output).toContain("Create a greeting");
130132
});
133+
134+
it("populates p50/p95 duration fields", async () => {
135+
const result = await runTaskEvaluation({
136+
config: {
137+
task: {
138+
name: "Percentile test",
139+
description: "Checks duration percentiles exist",
140+
success_criteria: ["Task completed"],
141+
},
142+
agent: {
143+
type: "cli",
144+
command: "echo",
145+
args: ["done"],
146+
},
147+
eval: { timeout: 10, runs: 3 },
148+
},
149+
apiKey: "test-key",
150+
});
151+
152+
expect(result.p50DurationMs).toBeGreaterThanOrEqual(0);
153+
expect(result.p95DurationMs).toBeGreaterThanOrEqual(result.p50DurationMs);
154+
// avgCostUsd is 0 when the agent doesn't emit a USAGE line (like `echo`)
155+
expect(result.avgCostUsd).toBe(0);
156+
});
157+
});
158+
159+
describe("percentile", () => {
160+
it("returns 0 for empty arrays", () => {
161+
expect(percentile([], 50)).toBe(0);
162+
});
163+
164+
it("returns the single value when only one sample", () => {
165+
expect(percentile([42], 95)).toBe(42);
166+
});
167+
168+
it("computes p50 of [1,2,3,4,5] as 3", () => {
169+
expect(percentile([1, 2, 3, 4, 5], 50)).toBe(3);
170+
});
171+
172+
it("interpolates p95 of [10,20,30,40,50] near the top", () => {
173+
// rank = 0.95 * 4 = 3.8 → interpolates between 40 and 50 → 48
174+
expect(percentile([10, 20, 30, 40, 50], 95)).toBe(48);
175+
});
176+
177+
it("handles unsorted input", () => {
178+
expect(percentile([5, 1, 4, 2, 3], 50)).toBe(3);
179+
});
180+
});
181+
182+
describe("parseUsageLine", () => {
183+
it("returns null when no USAGE line is present", () => {
184+
expect(parseUsageLine("Task completed.\nCreated: foo.js")).toBeNull();
185+
});
186+
187+
it("parses input/output tokens", () => {
188+
const parsed = parseUsageLine(
189+
"Created: foo.js\nUSAGE: input=1234 output=567",
190+
);
191+
expect(parsed).toEqual({ inputTokens: 1234, outputTokens: 567 });
192+
});
193+
194+
it("parses input/output tokens + model", () => {
195+
const parsed = parseUsageLine(
196+
"Created: foo.js\nUSAGE: input=1234 output=567 model=claude-sonnet-4-6",
197+
);
198+
expect(parsed).toEqual({
199+
inputTokens: 1234,
200+
outputTokens: 567,
201+
model: "claude-sonnet-4-6",
202+
});
203+
});
131204
});

packages/web/public/favicon.svg

Lines changed: 15 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)