diff --git a/.changeset/sdk-cost-per-model.md b/.changeset/sdk-cost-per-model.md new file mode 100644 index 0000000..0474d02 --- /dev/null +++ b/.changeset/sdk-cost-per-model.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +`fix` computes the agent's cost itself, per model and token class, from Uptide's price table: `report.json` and `--json` list each model's uncached input, cache read, 5-minute and 1-hour cache write and output tokens, the rate applied to each and the cost, so the total can be redone by hand. The Agent SDK's own figure is kept apart as `sdkReportedCostUsd`; `--max-cost` counts the higher of the two, a gap above 5% is printed as a warning, and a model missing from the table uses the SDK's figure, marked unverified. `llm.inputTokens` now counts uncached input only. diff --git a/.changeset/sonnet-cache-read-rate.md b/.changeset/sonnet-cache-read-rate.md new file mode 100644 index 0000000..2359be7 --- /dev/null +++ b/.changeset/sonnet-cache-read-rate.md @@ -0,0 +1,5 @@ +--- +"uptide": patch +--- + +Sonnet 5.5 cache reads are priced at the published $0.10 per million tokens (0.05x input), not $0.20. Every Anthropic rate in the price table was checked against Anthropic's pricing page on 2026-10-20, and a test now pins each one. diff --git a/docs/model-pricing.md b/docs/model-pricing.md index c094dc1..04cbbc4 100644 --- a/docs/model-pricing.md +++ b/docs/model-pricing.md @@ -1,13 +1,15 @@ # Model pricing and budget accounting -Reviewed **2026-10-04**. USD per million tokens, standard text inference. Source of +Reviewed **2026-10-04**; every Anthropic cell re-checked against the +[Anthropic pricing page](https://platform.claude.com/docs/en/about-claude/pricing) on +**2026-10-20**. USD per million tokens, standard text inference. Source of truth in code: `packages/core/src/llm/pricing.ts`. No paid built-in tools, batch, priority processing, explicit prompt caching or service-tier upgrades are requested. | Provider/model | Input | Output | Cached input | Cache write (5m / 1h where applicable) | | --- | ---: | ---: | ---: | ---: | | Anthropic Sonnet 4.6 | 3 | 15 | 0.30 | 3.75 / 6 | -| Anthropic Sonnet 5.5 | 2 | 10 | 0.20 | 2.50 / 4 | +| Anthropic Sonnet 5.5 | 2 | 10 | 0.10 | 2.50 / 4 | | Anthropic Opus 5.5 | 4 | 20 | 0.20 | 5 / 8 | | Anthropic Fable 5.1 | 10 | 50 | 0.25 | 12.50 / 20 | | Anthropic Haiku 4.5 (`20251001`) | 1 | 5 | 0.10 | 1.25 / 2 | @@ -33,10 +35,42 @@ rates end on 2026-12-31; from 2027-01-01 the table automatically uses 1.50 input ## Agent SDK budget -`fix` runs only with the Agent SDK executor. Its reported session cost comes from the SDK; -the package's session receives its budget, and enforcement after each turn can overshoot -by one turn. The reservation model below applies to the patch executor and the OpenAI and -Gemini rates, which remain for Uptide's own evaluations (`UPTIDE_INTERNAL=1`). +`fix` runs only with the Agent SDK executor. The package's session receives its budget, and +the SDK's enforcement after each turn can overshoot by one turn. The reservation model below +applies to the patch executor and the OpenAI and Gemini rates, which remain for Uptide's own +evaluations (`UPTIDE_INTERNAL=1`). + +`llm.costUsd` is computed by Uptide from the table above, so anyone can redo it by hand. +`report.json` and `fix --json` list each model under `llm.models`: + +| Field | Meaning | +| --- | --- | +| `inputTokens` | uncached input | +| `cacheReadTokens` | cache hits and refreshes | +| `cacheWriteTokens` / `cacheWriteHourTokens` | 5-minute / 1-hour cache writes | +| `outputTokens` | output, thinking included | +| `rates` | the USD per million tokens applied to each class | +| `costUsd` | the sum of tokens x rate / 1,000,000, rounded up to the nanodollar | +| `sdkReportedCostUsd` | the SDK's own figure for that model | + +`llm.costUsd` is the total of the rows, and `llm.sdkReportedCostUsd` is the SDK's +`total_cost_usd`, from the SDK's built-in price table, kept apart and never mixed in. +`llm.inputTokens` counts uncached input only, summed over models; before UPT-127 it also +counted cache reads and writes. The per-session records (`llm.tasks`) carry the same fields. + +The SDK gives tokens per model but splits cache writes by TTL only for the main loop: +those 1-hour writes are priced on the session's model. **Assumption:** every cache write by +any other model (a subagent, compaction) is priced as a 5-minute write. Had it been a +1-hour write, the computed cost is low by the difference (on Sonnet 5.5, $1.50 per million +written tokens); the 5% check against the SDK's figure and the `--max-cost` rule below +still apply. + +`--max-cost` counts the higher of the computed and the SDK-reported cost, so a wrong +table never lets a run spend past the limit by Uptide's own accounting. When the two +differ by more than 5% of the higher, the run prints both on the terminal and lists the +line in `llm.warnings` (never in the PR body). A model with no row in the table gets no +guessed rate: its cost is the SDK's figure, the row has no `rates` and is marked +`unverified`, `llm.costUnverified` is set, and the run warns. ## Reservation and actual spend diff --git a/docs/models.md b/docs/models.md index 492b87d..d5d7d95 100644 --- a/docs/models.md +++ b/docs/models.md @@ -96,7 +96,10 @@ it can overshoot by one turn. The summary's `Phases` row and the PR's run details give wall time per phase (analyze, baseline, install, rules, agent, verify, feedback) and the agent's spend in each. -Unknown models use Anthropic's highest listed rates with a warning. See +`llm.costUsd` is computed from Uptide's price table per model and token class; the SDK's +own figure is `llm.sdkReportedCostUsd`, and `--max-cost` counts the higher of the two. +With the SDK, an unknown model's cost is the SDK's figure, marked unverified; with the +patch executor, unknown models use Anthropic's highest listed rates with a warning. See [model pricing](model-pricing.md) for rates, token estimation and the scope of accounting. ## Privacy diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 42d7448..372b48d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1262,6 +1262,8 @@ ${runnable(PRIVACY)}`, `\n${formatFixSummary(report, { color: ui.color, invocation: INVOCATION, suggest, cwd, ms: io.now() - started })}`, ); io.err(whereItRan(report)); + // Cost accounting warnings: the terminal's (and --json's), never the PR body's. + for (const warning of report.llm.warnings ?? []) io.err(`${warning}\n`); // The plan and its warnings, now that no spinner is drawing. if (report.publicationLog && !flags.json) io.err(`\n${report.publicationLog}\n`); if (report.publication?.refused.length) diff --git a/packages/core/src/action/run.ts b/packages/core/src/action/run.ts index 2cf8266..dcef01e 100644 --- a/packages/core/src/action/run.ts +++ b/packages/core/src/action/run.ts @@ -5,6 +5,7 @@ import { isMajorUpgrade, NO_PACK_YET, noPackBlockers } from '../fix/no-pack.js'; import { sdkRequired } from '../fix/sdk-agent.js'; import type { FixReport } from '../fix/types.js'; import type { ListedDependency, ListReport } from '../list/list.js'; +import { chargedUsd } from '../llm/pricing.js'; import { loadUptideConfig } from '../onboarding/node.js'; import { DEFAULT_CONFIG, UPTIDE_CONFIG_FILE, type UptideConfig } from '../onboarding/schema.js'; import { packStatus, registeredPacks } from '../packs/index.js'; @@ -421,7 +422,7 @@ export async function runAction( ); continue; } - budget -= (fixed.llm?.costUsd ?? 0) + (fixed.llm?.unreportedCostUsd ?? 0); + budget -= (fixed.llm ? chargedUsd(fixed.llm) : 0) + (fixed.llm?.unreportedCostUsd ?? 0); let pr: { number: number; url: string } | null = null; let error: string | undefined; // `uptide pr` refuses these too; asking it would only turn the reason into a failure. A diff --git a/packages/core/src/fix/assisted.ts b/packages/core/src/fix/assisted.ts index 383367d..e4e79c9 100644 --- a/packages/core/src/fix/assisted.ts +++ b/packages/core/src/fix/assisted.ts @@ -5,7 +5,7 @@ import { Node, Project, SyntaxKind } from 'ts-morph'; import type { ProgressListener } from '../domain/progress.js'; import { ACCEPTED_KEYS } from '../llm/config.js'; import { CostLimitError, DEFAULT_MAX_COST_USD } from '../llm/fixer.js'; -import { nanos } from '../llm/pricing.js'; +import { addSpend, nanos } from '../llm/pricing.js'; import type { MigrationPack, PackContext } from '../packs/types.js'; import { resetSharedState } from '../shared-state.js'; import { applySitePatch } from './patch.js'; @@ -375,9 +375,7 @@ export async function assist( log.costUsd = response.costUsd ?? 0; log.explanation = response.explanation ?? ''; log.diff = response.diff; - llm.inputTokens += response.inputTokens; - llm.outputTokens += response.outputTokens; - llm.costUsd = (nanos(llm.costUsd) + nanos(response.costUsd ?? 0)) / 1e9; + addSpend(llm, response); if (response.unreportedCostUsd) llm.unreportedCostUsd = (nanos(llm.unreportedCostUsd ?? 0) + nanos(response.unreportedCostUsd)) / 1e9; diff --git a/packages/core/src/fix/feedback.test.ts b/packages/core/src/fix/feedback.test.ts index ae8e2b1..6bbbd10 100644 --- a/packages/core/src/fix/feedback.test.ts +++ b/packages/core/src/fix/feedback.test.ts @@ -101,6 +101,22 @@ it('starts no session the remaining budget is below a typical session for (UPT-1 ); }); +it('counts the higher of computed and SDK-reported cost against --max-cost (UPT-127)', async () => { + for (const [costUsd, sdkReportedCostUsd] of [ + [0.05, 0.1], + [0.1, 0.05], + ] as const) { + const { root, services } = await fixture(); + const runner = vi.fn(async () => ({ ...runResult, costUsd, sdkReportedCostUsd })); + const result = await fix(options(root), { ...services, agentRunner: runner }); + // Either way $0.10 a session counts: the second gets $0.15, and no third starts. + expect(runner.mock.calls.map(([input]) => input.maxBudgetUsd)).toEqual([0.25, 0.15]); + expect(result.llm.feedback?.stopped).toEqual({ remainingUsd: 0.05, typicalUsd: 0.1 }); + expect(result.llm.costUsd).toBeCloseTo(2 * costUsd, 9); + expect(result.llm.sdkReportedCostUsd).toBeCloseTo(2 * sdkReportedCostUsd, 9); + } +}); + it('does not send failures already present at the parent to the agent', async () => { const { root, services } = await fixture(); services.tests = async () => [{ workspace: '.', status: 'failed', output: 'old failure' }]; diff --git a/packages/core/src/fix/feedback.ts b/packages/core/src/fix/feedback.ts index 5ab193f..f043a66 100644 --- a/packages/core/src/fix/feedback.ts +++ b/packages/core/src/fix/feedback.ts @@ -128,6 +128,8 @@ export function feedbackPatchRunner(fixer: Fixer, finding: Finding): TaskRunner unreportedCostUsd, inputTokens: answer.inputTokens, outputTokens: answer.outputTokens, + ...(answer.costUnverified ? { costUnverified: true as const } : {}), + ...(answer.models ? { models: answer.models } : {}), summary: { changed: [...edits.keys()].map((file) => ({ file, diff --git a/packages/core/src/fix/run.ts b/packages/core/src/fix/run.ts index 72df78b..e75572f 100644 --- a/packages/core/src/fix/run.ts +++ b/packages/core/src/fix/run.ts @@ -20,7 +20,7 @@ import { providerFixer, sdkCostEstimate, } from '../llm/fixer.js'; -import { nanos } from '../llm/pricing.js'; +import { addSpend, chargedUsd, nanos } from '../llm/pricing.js'; import { packageChangelog, usedNames } from '../packs/changelog.js'; import { decidedRules, decisionInventory, packDecisions } from '../packs/decisions.js'; import { genericPack } from '../packs/generic.js'; @@ -1269,14 +1269,14 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise t.costUsd > 0); + const sessions = (llm.tasks ?? []).filter((t) => chargedUsd(t) > 0); const typicalUsd = sessions.length - ? sessions.reduce((n, t) => n + t.costUsd, 0) / sessions.length + ? sessions.reduce((n, t) => n + chargedUsd(t), 0) / sessions.length : 0; if (remaining < typicalUsd) { feedbackLimits.stopped = { remainingUsd: remaining, typicalUsd }; @@ -1325,9 +1325,7 @@ async function fixPackage(options: FixOptions, services: FixServices): Promise - records.reduce((n, r) => n + r[key], 0); + const spent: Spend & { costUsd: number } = { inputTokens: 0, outputTokens: 0, costUsd: 0 }; + for (const record of records) addSpend(spent, record); return { provider: 'anthropic', model: input.model, available: true, - inputTokens: sum('inputTokens'), - outputTokens: sum('outputTokens'), - costUsd: sum('costUsd'), + ...spent, unreportedCostUsd: records.reduce((sum, r) => sum + (r.unreportedCostUsd ?? 0), 0), agent: 'sdk', tasks: records, }; } +type SdkResult = Extract; +/** Cost and SDK cost disagree by more than this share before the run says so. */ +export const SDK_COST_TOLERANCE = 0.05; +/** + * The session's spend from the SDK result: tokens per model and class (`modelUsage`), priced + * from pricing.ts. `total_cost_usd` comes from the SDK's own table and is kept apart. + * `modelUsage` has no cache TTL split; `usage.cache_creation` (main loop only) has one, so its + * 1-hour writes go to the session model and every other write is priced as 5-minute. + */ +// ponytail: other models' 1-hour writes price as 5-minute; the 5% check and max() cap cover it. +export function sdkSpend(result: SdkResult, model: string): Spend & { costUsd: number } { + const hour = + (result.usage as { cache_creation?: { ephemeral_1h_input_tokens?: number } }).cache_creation + ?.ephemeral_1h_input_tokens ?? 0; + const warnings: string[] = []; + const models: ModelCost[] = Object.entries(result.modelUsage ?? {}).map(([name, m]) => { + const hourWrites = name === model ? Math.min(hour, m.cacheCreationInputTokens) : 0; + const row = modelCost('anthropic', name, { + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + cacheReadTokens: m.cacheReadInputTokens, + cacheWriteTokens: m.cacheCreationInputTokens - hourWrites, + cacheWriteHourTokens: hourWrites, + }); + if (!row.unverified) return { ...row, sdkReportedCostUsd: m.costUSD }; + // Never guess a rate: the SDK's figure stands, marked unverified. + warnings.push( + `Cost of ${name}: no rate in Uptide's pricing table; using the SDK's $${m.costUSD.toFixed(4)}, unverified.`, + ); + const { rates: _, ...unpriced } = row; + return { ...unpriced, costUsd: m.costUSD, sdkReportedCostUsd: m.costUSD }; + }); + const sdk = result.total_cost_usd; + const unverified = models.some((m) => m.unverified) || (!models.length && sdk > 0); + const costUsd = models.length ? models.reduce((n, m) => n + m.costUsd, 0) : sdk; + if (!models.length && sdk > 0) + warnings.push( + `Cost: the SDK reported no per-model usage; using its $${sdk.toFixed(4)}, unverified.`, + ); + else if (Math.abs(costUsd - sdk) > SDK_COST_TOLERANCE * Math.max(costUsd, sdk)) + warnings.push( + `Cost check: computed $${costUsd.toFixed(4)} from Uptide's pricing table, the SDK reported $${sdk.toFixed(4)}; --max-cost counts the higher.`, + ); + return { + inputTokens: models.reduce((n, m) => n + m.inputTokens, 0), + outputTokens: models.reduce((n, m) => n + m.outputTokens, 0), + costUsd: Math.round(costUsd * 1e9) / 1e9, + sdkReportedCostUsd: sdk, + ...(unverified ? { costUnverified: true as const } : {}), + models, + ...(warnings.length ? { warnings } : {}), + }; +} + const SDK = '@anthropic-ai/claude-agent-sdk'; /** The SDK version Uptide is tested with (core's devDependency), fetched when none is installed. */ export const SDK_VERSION = '0.3.296'; @@ -1053,15 +1104,9 @@ const sdkRunner: TaskRunner = async ({ if (!result) throw err; } if (!result) throw new Error('the SDK session ended without a result'); - const usage = result.usage as unknown as Record; return { turns: result.num_turns, - inputTokens: - (usage.input_tokens ?? 0) + - (usage.cache_read_input_tokens ?? 0) + - (usage.cache_creation_input_tokens ?? 0), - outputTokens: usage.output_tokens ?? 0, - costUsd: result.total_cost_usd, + ...sdkSpend(result, model), durationMs: Date.now() - started, toolCalls, ...(result.subtype !== 'success' diff --git a/packages/core/src/fix/sdk-cost.test.ts b/packages/core/src/fix/sdk-cost.test.ts new file mode 100644 index 0000000..3c27ce8 --- /dev/null +++ b/packages/core/src/fix/sdk-cost.test.ts @@ -0,0 +1,113 @@ +import { expect, it } from 'vitest'; +import { sdkSpend } from './sdk-agent.js'; + +type Result = Parameters[0]; +const usage = (m: Record) => ({ + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 1_000_000, + maxOutputTokens: 64_000, + ...m, +}); +const result = ( + modelUsage: Record>, + total_cost_usd: number, + hour = 0, +) => + ({ + total_cost_usd, + usage: { cache_creation: { ephemeral_1h_input_tokens: hour, ephemeral_5m_input_tokens: 0 } }, + modelUsage, + }) as unknown as Result; + +it('prices a recorded SDK result per model and token class, as computed by hand', () => { + // modelUsage and total_cost_usd of the zod 3 -> 4.6.7 run on consetto/calmcp (bdaef39^), + // 2026-10-20 16:58:27Z-16:59:53Z, Sonnet 5.5 at USD per MTok (pricing.ts): + // input 24 x $2.00 = 48.0 + // cache read 256,402 x $0.10 = 25,640.2 + // cache write 28,281 x $2.50 = 70,702.5 (5-minute; usage reported no 1-hour writes) + // output 6,581 x $10.00 = 65,810.0 + // total 162,200.7 / 1e6 = $0.1622007, the SDK's figure exactly. + const recorded = result( + { + 'claude-sonnet-5-5': usage({ + inputTokens: 24, + outputTokens: 6581, + cacheReadInputTokens: 256_402, + cacheCreationInputTokens: 28_281, + costUSD: 0.1622007, + }), + }, + 0.1622007, + ); + const spend = sdkSpend(recorded, 'claude-sonnet-5-5'); + expect(spend).toEqual({ + inputTokens: 24, + outputTokens: 6581, + costUsd: 0.1622007, + sdkReportedCostUsd: 0.1622007, + models: [ + { + model: 'claude-sonnet-5-5', + inputTokens: 24, + outputTokens: 6581, + cacheReadTokens: 256_402, + cacheWriteTokens: 28_281, + cacheWriteHourTokens: 0, + rates: { input: 2, output: 10, cacheRead: 0.1, cacheWrite: 2.5, cacheWriteHour: 4 }, + costUsd: 0.1622007, + sdkReportedCostUsd: 0.1622007, + }, + ], + }); + // 1-hour writes reported for the main loop move to the 1-hour rate: 1,000 x ($4 - $2.50). + const hour = sdkSpend( + { ...recorded, usage: { cache_creation: { ephemeral_1h_input_tokens: 1000 } } } as never, + 'claude-sonnet-5-5', + ); + expect(hour.models?.[0]).toMatchObject({ cacheWriteTokens: 27_281, cacheWriteHourTokens: 1000 }); + expect(hour.costUsd).toBe(0.1637007); +}); + +it('prices an unknown model at the SDK figure, unverified, with no guessed rate', () => { + const spend = sdkSpend( + result({ 'claude-future-9': usage({ inputTokens: 1000, costUSD: 0.5 }) }, 0.5), + 'claude-future-9', + ); + expect(spend.costUsd).toBe(0.5); + expect(spend.costUnverified).toBe(true); + expect(spend.models?.[0]).not.toHaveProperty('rates'); + expect(spend.models?.[0]).toMatchObject({ unverified: true, costUsd: 0.5 }); + expect(spend.warnings).toEqual([ + "Cost of claude-future-9: no rate in Uptide's pricing table; using the SDK's $0.5000, unverified.", + ]); +}); + +it('warns with both figures when computed and SDK cost differ by more than 5%', () => { + // 1M output tokens on Sonnet 5.5 at $10/MTok = $10; the SDK says $12. + const off = sdkSpend( + result({ 'claude-sonnet-5-5': usage({ outputTokens: 1_000_000, costUSD: 12 }) }, 12), + 'claude-sonnet-5-5', + ); + expect(off.costUsd).toBe(10); + expect(off.sdkReportedCostUsd).toBe(12); + expect(off.warnings).toEqual([ + "Cost check: computed $10.0000 from Uptide's pricing table, the SDK reported $12.0000; --max-cost counts the higher.", + ]); + // $10.40 is within 5% of $10: no warning. + const close = sdkSpend( + result({ 'claude-sonnet-5-5': usage({ outputTokens: 1_000_000, costUSD: 10.4 }) }, 10.4), + 'claude-sonnet-5-5', + ); + expect(close.warnings).toBeUndefined(); +}); + +it('falls back to the SDK total, unverified, when it reports no per-model usage', () => { + const spend = sdkSpend(result({}, 0.02), 'claude-sonnet-5-5'); + expect(spend).toMatchObject({ costUsd: 0.02, sdkReportedCostUsd: 0.02, costUnverified: true }); + expect(spend.warnings?.[0]).toContain('no per-model usage'); +}); diff --git a/packages/core/src/fix/types.ts b/packages/core/src/fix/types.ts index 5d12b83..463e297 100644 --- a/packages/core/src/fix/types.ts +++ b/packages/core/src/fix/types.ts @@ -1,5 +1,6 @@ import type { Finding, ImportersNotAnalyzed, Tier, TypeCoverage } from '../domain/report.js'; import type { CostEstimate } from '../llm/fixer.js'; +import type { Spend } from '../llm/pricing.js'; import type { Provider } from '../llm/types.js'; import type { ApiChangeFacts, Headline, PackContext } from '../packs/types.js'; import type { BaselineHealth } from './baseline.js'; @@ -177,15 +178,13 @@ export interface DependencyEdit { reverted?: true; } /** One SDK session over the package's manual sites, measured, and the gate's verdict. */ -export interface AgentTaskRecord { +export interface AgentTaskRecord extends Spend { id: string; rule: string; files: string[]; sites: number; fixed: number; turns: number; - inputTokens: number; - outputTokens: number; costUsd: number; unreportedCostUsd?: number; durationMs: number; @@ -223,7 +222,7 @@ export interface FixRequest { sharedDiagnostics?: Pick[]; retry?: string; } -export interface FixResponse { +export interface FixResponse extends Spend { responseModel?: string; reservationUsd?: number; failureKind?: LlmFailureKind; @@ -234,9 +233,6 @@ export interface FixResponse { unreportedCostUsd?: number; diff: string; explanation?: string; - inputTokens: number; - outputTokens: number; - costUsd?: number; } export interface Fixer { readonly id: string; @@ -371,13 +367,15 @@ export interface FixReport { /** The repository before the upgrade (`fix/baseline.ts`): unhealthy means compare, not prove. */ baselineHealth?: BaselineHealth; }; - llm: { + /** + * `costUsd` is computed from pricing.ts per model and token class (`models`); the SDK's own + * total is `sdkReportedCostUsd`. `inputTokens` counts uncached input only. + */ + llm: Spend & { provider?: Provider; model?: string; /** Worst-case reservation for calls with no trustworthy usage. Not reported spend. */ unreportedCostUsd?: number; - inputTokens: number; - outputTokens: number; costUsd: number; available: boolean; disabled?: boolean; diff --git a/packages/core/src/llm/fixer.ts b/packages/core/src/llm/fixer.ts index 61b0b9a..b94d277 100644 --- a/packages/core/src/llm/fixer.ts +++ b/packages/core/src/llm/fixer.ts @@ -1,7 +1,7 @@ import type { Fixer, FixRequest, FixResponse } from '../fix/types.js'; import { adapters } from './adapters.js'; import { KEY_ENV, type Selection } from './config.js'; -import { nanos, reserveCost, usageCost } from './pricing.js'; +import { modelCost, nanos, reserveCost, usageCost } from './pricing.js'; import { messages } from './prompt.js'; import type { Call, Provider } from './types.js'; @@ -215,12 +215,21 @@ export function providerFixer( ); const u = result.usage; const billedInput = u.inputTokens + u.cacheWriteTokens + u.cacheWriteHourTokens; + const row = modelCost(selection.provider, selection.model, { + inputTokens: u.inputTokens - u.cachedInputTokens, + outputTokens: u.outputTokens, + cacheReadTokens: u.cachedInputTokens, + cacheWriteTokens: u.cacheWriteTokens, + cacheWriteHourTokens: u.cacheWriteHourTokens, + }); const base = { reservationUsd: reserved, ...(result.model ? { responseModel: result.model } : {}), - inputTokens: billedInput, + inputTokens: row.inputTokens, outputTokens: u.outputTokens, - costUsd: usageCost(selection.provider, selection.model, u), + costUsd: row.costUsd, + ...(row.unverified ? { costUnverified: true as const } : {}), + models: [row], }; if (billedInput > inputBound || u.outputTokens > prepared.maxTokens) return { diff --git a/packages/core/src/llm/pricing.test.ts b/packages/core/src/llm/pricing.test.ts new file mode 100644 index 0000000..a42987f --- /dev/null +++ b/packages/core/src/llm/pricing.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'vitest'; +import { PRICES } from './pricing.js'; + +// One row per model, USD per million tokens: [input, output, cache read, 5m write, 1h write]. +// Anthropic rows: https://platform.claude.com/docs/en/about-claude/pricing, read 2026-10-20. +const ANTHROPIC: Record = { + 'claude-sonnet-4-6': [3, 15, 0.3, 3.75, 6], + 'claude-sonnet-5-5': [2, 10, 0.1, 2.5, 4], + 'claude-opus-5-5': [4, 20, 0.2, 5, 8], + 'claude-fable-5-1': [10, 50, 0.25, 12.5, 20], + 'claude-haiku-4-5-20251001': [1, 5, 0.1, 1.25, 2], +}; + +it('pins every Anthropic rate to the published table', () => { + const actual = Object.fromEntries( + Object.entries(PRICES.anthropic).map(([model, p]) => [ + model, + [p.input, p.output, p.cached, p.write, p.writeHour], + ]), + ); + expect(actual).toEqual(ANTHROPIC); +}); diff --git a/packages/core/src/llm/pricing.ts b/packages/core/src/llm/pricing.ts index 53f0179..18327de 100644 --- a/packages/core/src/llm/pricing.ts +++ b/packages/core/src/llm/pricing.ts @@ -19,16 +19,19 @@ export interface Price { } // USD per million tokens, standard text inference. No built-in paid tools or explicit caching. export const PRICES: Record> = { + // Every Anthropic cell checked 2026-10-20 against https://platform.claude.com/docs/en/about-claude/pricing + // (model table: base input, 5m write, 1h write, cache hits, output). Sonnet/Opus 5.5 cache hits are + // 0.05x input, Fable 5.1 0.025x, the rest 0.1x. pricing.test.ts pins each cell. anthropic: { 'claude-sonnet-4-6': { input: 3, output: 15, cached: 0.3, write: 3.75, writeHour: 6 }, 'claude-sonnet-5-5': { input: 2, output: 10, - cached: 0.2, + cached: 0.1, write: 2.5, writeHour: 4, - source: 'https://platform.claude.com/docs/en/models/sonnet-5-5/overview', - checkedAt: PRICE_DATE, + source: PRICE_SOURCES.anthropic, + checkedAt: '2026-10-20', }, 'claude-opus-5-5': { input: 4, output: 20, cached: 0.2, write: 5, writeHour: 8 }, 'claude-fable-5-1': { input: 10, output: 50, cached: 0.25, write: 12.5, writeHour: 20 }, @@ -144,3 +147,101 @@ export function reserveCost( const inputPrice = Math.max(p.input, p.write ?? 0, p.writeHour ?? 0); return Math.ceil((input * inputPrice + output * p.output) * 1000) / 1e9; } +/** USD per million tokens, by token class, as applied to one model's usage. */ +export interface Rates { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cacheWriteHour: number; +} +/** One model's tokens by class, the rates applied and what they cost: tokens x rate / 1e6. */ +export interface ModelCost { + model: string; + /** Uncached input only: cache reads and writes are counted in their own fields. */ + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + /** 5-minute cache writes. */ + cacheWriteTokens: number; + /** 1-hour cache writes. */ + cacheWriteHourTokens: number; + /** Absent when pricing.ts has no row for the model and the SDK's figure stands in. */ + rates?: Rates; + costUsd: number; + /** The Agent SDK's own figure for this model, from its built-in price table. */ + sdkReportedCostUsd?: number; + /** No row in pricing.ts: `costUsd` is the SDK's figure (SDK) or the highest listed rates (patch). */ + unverified?: true; +} +/** What an agent spent; `costUsd` is computed from pricing.ts, never the SDK's figure. */ +export interface Spend { + /** Uncached input only, summed over models (cache reads and writes are in `models`). */ + inputTokens: number; + outputTokens: number; + costUsd?: number; + /** The Agent SDK's total, kept apart from `costUsd`. */ + sdkReportedCostUsd?: number; + costUnverified?: true; + models?: ModelCost[]; + /** Cost accounting warnings for the run's output, never the PR body. */ + warnings?: string[]; +} +export function modelCost( + provider: Provider, + model: string, + tokens: Omit, +): ModelCost { + const p = priceFor(provider, model); + return { + model, + ...tokens, + rates: { + input: p.input, + output: p.output, + cacheRead: p.cached, + cacheWrite: p.write ?? p.input, + cacheWriteHour: p.writeHour ?? p.input, + }, + costUsd: usageCost(provider, model, { + inputTokens: tokens.inputTokens + tokens.cacheReadTokens, + cachedInputTokens: tokens.cacheReadTokens, + cacheWriteTokens: tokens.cacheWriteTokens, + cacheWriteHourTokens: tokens.cacheWriteHourTokens, + outputTokens: tokens.outputTokens, + }), + ...(p.fallback ? { unverified: true as const } : {}), + }; +} +const addUsd = (a: number | undefined, b: number | undefined) => + a === undefined && b === undefined ? undefined : (nanos(a ?? 0) + nanos(b ?? 0)) / 1e9; +/** Adds `b` into `a`, model by model, in nanodollars so repeated adds do not drift. */ +export function addSpend(a: Spend, b: Spend): void { + a.inputTokens += b.inputTokens; + a.outputTokens += b.outputTokens; + a.costUsd = addUsd(a.costUsd, b.costUsd) ?? 0; + const sdk = addUsd(a.sdkReportedCostUsd, b.sdkReportedCostUsd); + if (sdk !== undefined) a.sdkReportedCostUsd = sdk; + if (b.costUnverified) a.costUnverified = true; + if (b.warnings?.length) a.warnings = [...(a.warnings ?? []), ...b.warnings]; + for (const row of b.models ?? []) { + a.models ??= []; + const same = a.models.find((m) => m.model === row.model); + if (!same) { + a.models.push({ ...row, ...(row.rates ? { rates: { ...row.rates } } : {}) }); + continue; + } + same.inputTokens += row.inputTokens; + same.outputTokens += row.outputTokens; + same.cacheReadTokens += row.cacheReadTokens; + same.cacheWriteTokens += row.cacheWriteTokens; + same.cacheWriteHourTokens += row.cacheWriteHourTokens; + same.costUsd = addUsd(same.costUsd, row.costUsd) ?? 0; + const sdkRow = addUsd(same.sdkReportedCostUsd, row.sdkReportedCostUsd); + if (sdkRow !== undefined) same.sdkReportedCostUsd = sdkRow; + if (row.unverified) same.unverified = true; + } +} +/** What counts against --max-cost: the higher of the computed and the SDK-reported cost. */ +export const chargedUsd = (s: Pick): number => + Math.max(s.costUsd ?? 0, s.sdkReportedCostUsd ?? 0); diff --git a/packages/core/src/llm/providers.test.ts b/packages/core/src/llm/providers.test.ts index e04fe5d..1205ab6 100644 --- a/packages/core/src/llm/providers.test.ts +++ b/packages/core/src/llm/providers.test.ts @@ -38,10 +38,22 @@ for (const provider of PROVIDERS) { const reply = await fixer?.fix(request); expect(reply?.diff).toContain('+++ b/src/a.ts'); expect(reply?.failure).toBeUndefined(); - expect(reply?.inputTokens).toBe(100); expect(reply?.outputTokens).toBe(20); const normalized = required(adapters[provider].parse(recorded(provider)).usage); expect(reply?.costUsd).toBe(usageCost(provider, model, normalized)); + // Uncached input only; each class in the per-model row (UPT-127). + expect(reply?.inputTokens).toBe(normalized.inputTokens - normalized.cachedInputTokens); + expect(reply?.models).toEqual([ + expect.objectContaining({ + model, + inputTokens: normalized.inputTokens - normalized.cachedInputTokens, + cacheReadTokens: normalized.cachedInputTokens, + cacheWriteTokens: normalized.cacheWriteTokens, + cacheWriteHourTokens: normalized.cacheWriteHourTokens, + outputTokens: 20, + costUsd: reply?.costUsd, + }), + ]); const [url, options] = fetcher.mock.calls[0] as unknown as [string, RequestInit]; expect(url).not.toContain('test-only'); expect(JSON.stringify(options.headers)).toContain('test-only'); @@ -341,7 +353,7 @@ it('uses Chat Completions only for an explicit base URL and accounts for returne expect(body.input).toBeUndefined(); expect(body.instructions).toBeUndefined(); expect(reply.failure).toBeUndefined(); - expect(reply.inputTokens).toBe(100); + expect(reply.inputTokens).toBe(90); expect(reply.outputTokens).toBe(20); expect(reply.costUsd).toBeCloseTo((90 * 2 + 10 * 0.1 + 20 * 10) / 1e6, 8); expect(reply.responseModel).toBe('gpt-6.1-sol');