diff --git a/src/core/budget/budget-tracker.ts b/src/core/budget/budget-tracker.ts index c78736c4c..2bc8f9f1f 100644 --- a/src/core/budget/budget-tracker.ts +++ b/src/core/budget/budget-tracker.ts @@ -156,6 +156,27 @@ const FREE_LOCAL_EMBED_PROVIDERS: ReadonlySet = new Set([ 'llama-server', ]); +/** + * Chat sibling of FREE_LOCAL_EMBED_PROVIDERS / FREE_LOCAL_RERANK_PROVIDERS. + * + * Local inference costs electricity, not tokens, so these providers price at + * $0 rather than TX2 hard-failing. Without this a caller that sets ANY cost cap + * cannot use a local chat model at all: CANONICAL_PRICING has no `ollama:*` + * keys, so `reserve()` throws no_pricing before the first call and every work + * item is skipped with `budget_exhausted: true` at $0 spent. + * + * That is not theoretical — `cycle.extract_atoms` always constructs its tracker + * with `maxCostUsd` (config only accepts `n > 0`, so the cap can't be unset), + * which made `models.dream.extract_atoms: ollama:*` silently extract nothing. + * + * `litellm` is excluded on purpose, matching the embed set: a LiteLLM proxy can + * front a paid provider, so pricing-unknown is the honest state there. + */ +const FREE_LOCAL_CHAT_PROVIDERS: ReadonlySet = new Set([ + 'ollama', + 'llama-server', +]); + /** * Look up `modelId` in the chat or embedding pricing maps. Returns a * per-1M-token price tuple, or null when unknown. @@ -220,6 +241,12 @@ function lookupPricing(modelId: string, kind: BudgetKind): ModelPricing | null { // above is only the bare-keyed Claude view. const canon = canonicalLookup(modelId); if (canon) return canon; + // Local-inference chat providers cost electricity, not tokens. Checked AFTER + // the canonical table so an explicitly-priced local entry, should one ever be + // added, still wins over the blanket zero. + if (kind === 'chat' && providerId && FREE_LOCAL_CHAT_PROVIDERS.has(providerId)) { + return { input: 0, output: 0 }; + } return null; } diff --git a/test/budget/free-local-chat-pricing.test.ts b/test/budget/free-local-chat-pricing.test.ts new file mode 100644 index 000000000..4b6a7f1ef --- /dev/null +++ b/test/budget/free-local-chat-pricing.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect } from 'bun:test'; +import { BudgetTracker, BudgetExhausted } from '../../src/core/budget/budget-tracker.ts'; + +/** + * Regression guard for a silent-zero-yield bug: `cycle.extract_atoms` always + * constructs its BudgetTracker with a cap (config only accepts `n > 0`, so it + * cannot be unset). Local chat models have no CANONICAL_PRICING entry, so TX2 + * hard-failed `no_pricing` before the first call and every page was skipped + * with `budget_exhausted: true` at $0 spent — extraction reported success and + * produced nothing. + */ +const est = (modelId: string, kind: 'chat' | 'embed' | 'rerank' = 'chat') => ({ + modelId, kind, estimatedInputTokens: 12_000, maxOutputTokens: 4096, +}); + +describe('free local chat providers under a cost cap', () => { + test('ollama chat reserves at $0 instead of hard-failing', () => { + const t = new BudgetTracker({ maxCostUsd: 0.3, label: 'test' }); + expect(() => t.reserve(est('ollama:gemma4:26b'))).not.toThrow(); + expect(t.totalSpent).toBe(0); + }); + + test('llama-server chat also reserves at $0', () => { + const t = new BudgetTracker({ maxCostUsd: 0.3, label: 'test' }); + expect(() => t.reserve(est('llama-server:qwen3-32b'))).not.toThrow(); + }); + + test('a genuinely unpriced remote provider still hard-fails (TX2 intact)', () => { + const t = new BudgetTracker({ maxCostUsd: 0.3, label: 'test' }); + let err: unknown; + try { t.reserve(est('some-unknown-vendor:mystery-model')); } catch (e) { err = e; } + expect(err).toBeInstanceOf(BudgetExhausted); + expect((err as BudgetExhausted).reason).toBe('no_pricing'); + }); + + test('litellm is NOT free — a proxy can front a paid provider', () => { + // Mirrors the embed set's deliberate exclusion. Pricing-unknown is the + // honest state for a proxy, so the cap must still hard-fail. + const t = new BudgetTracker({ maxCostUsd: 0.3, label: 'test' }); + expect(() => t.reserve(est('litellm:gpt-5.4'))).toThrow(BudgetExhausted); + }); + + test('priced models are unaffected — real cost still projected', () => { + const t = new BudgetTracker({ maxCostUsd: 0.3, label: 'test' }); + expect(() => t.reserve(est('anthropic:claude-haiku-4-5'))).not.toThrow(); + // And a cap smaller than the projected cost still throws on cost, not pricing. + const tight = new BudgetTracker({ maxCostUsd: 0.000001, label: 'test' }); + let err: unknown; + try { tight.reserve(est('anthropic:claude-opus-4-7')); } catch (e) { err = e; } + expect(err).toBeInstanceOf(BudgetExhausted); + expect((err as BudgetExhausted).reason).toBe('cost'); + }); + + test('embed and rerank paths are untouched by the chat addition', () => { + const t = new BudgetTracker({ maxCostUsd: 0.3, label: 'test' }); + expect(() => t.reserve(est('ollama:nomic-embed-text', 'embed'))).not.toThrow(); + }); +});