diff --git a/src/core/budget/budget-tracker.ts b/src/core/budget/budget-tracker.ts index d5bf44e21..c0b692892 100644 --- a/src/core/budget/budget-tracker.ts +++ b/src/core/budget/budget-tracker.ts @@ -32,6 +32,7 @@ import { mkdirSync, appendFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { gbrainPath } from '../config.ts'; import { ANTHROPIC_PRICING, type ModelPricing } from '../anthropic-pricing.ts'; +import { canonicalLookup } from '../model-pricing.ts'; import { EMBEDDING_PRICING, lookupEmbeddingPrice } from '../embedding-pricing.ts'; import { splitProviderModelId } from '../model-id.ts'; import { isoWeekFilename, resolveAuditDir } from '../audit-week-file.ts'; @@ -194,6 +195,15 @@ function lookupPricing(modelId: string, kind: BudgetKind): ModelPricing | null { const tailHit = ANTHROPIC_PRICING[modelTail]; if (tailHit) return tailHit; } + // Non-Anthropic chat models (openai:, google:, deepseek:, ...) live in the + // canonical pricing table, not the Anthropic-only ANTHROPIC_PRICING view. + // Without this fall-through every non-Anthropic chat model returns null, + // and under a cost cap reserve() throws BudgetExhausted(no_pricing) BEFORE + // the provider call — silently zeroing extraction for those providers. + // OpenRouter-prefixed ids still MISS by design (canonicalLookup's contract: + // OpenRouter markup ≠ native pricing). + const canonical = canonicalLookup(modelId); + if (canonical) return canonical; // v0.40.6.1: zero-price local-inference rerank providers so the budget // tracker's TX2 hard-fail doesn't trip on `llama-server-reranker:` // under `--max-cost`. Only the rerank kind — chat/embed already have diff --git a/test/core/budget/budget-tracker.test.ts b/test/core/budget/budget-tracker.test.ts index 9dd236bb4..f61b77e37 100644 --- a/test/core/budget/budget-tracker.test.ts +++ b/test/core/budget/budget-tracker.test.ts @@ -170,6 +170,37 @@ describe('BudgetTracker.reserve', () => { ).not.toThrow(); }); + test('non-Anthropic chat model under --max-cost prices via the canonical table (no no_pricing throw)', () => { + // Pre-fix: chat lookup consulted only ANTHROPIC_PRICING, so any + // openai:/google:/deepseek: chat model hit TX2 no_pricing BEFORE the + // provider call whenever a cost cap was set (takeover of #2127). + const t = new BudgetTracker({ maxCostUsd: 10.0, label: 'test', auditPath }); + for (const modelId of ['openai:gpt-5', 'google:gemini-2.0-flash', 'deepseek:deepseek-chat']) { + expect(() => + t.reserve({ + modelId, + estimatedInputTokens: 100, + maxOutputTokens: 100, + kind: 'chat', + }), + ).not.toThrow(); + } + }); + + test('openrouter-prefixed chat model still TX2 no_pricing-fails under cap (canonical MISS contract)', () => { + // canonicalLookup intentionally misses openrouter:* ids (markup != native + // pricing); the budget gate must stay fail-closed for them. + const t = new BudgetTracker({ maxCostUsd: 10.0, label: 'test', auditPath }); + expect(() => + t.reserve({ + modelId: 'openrouter:anthropic/claude-sonnet-4-6', + estimatedInputTokens: 100, + maxOutputTokens: 100, + kind: 'chat', + }), + ).toThrow(BudgetExhausted); + }); + test('no cap + unknown pricing: warns once per process, no throw', () => { const t = new BudgetTracker({ label: 'test', auditPath }); expect(() =>