From 5e6c8f9e1f35cd58da05b9e5cfd1dec183ad7921 Mon Sep 17 00:00:00 2001 From: Andres Tuul Date: Sat, 8 Aug 2026 20:28:04 +0300 Subject: [PATCH] fix(lib): reject invalid token counts and document the Fable/Mythos tier `estimateCost` used `inputTokens` and `outputTokens` without validation. A negative count yielded a negative cost, and a non-finite one yielded NaN. That matters here specifically because this module backs the cost-aware-llm-pipeline budget skill: `NaN > budget` is false, so a corrupt token count silently passes the budget check it exists to enforce. It now throws a RangeError naming the offending field. The unknown-model fallback to sonnet rates deliberately stays fail-open: that degrades an estimate, whereas these inputs corrupt one. Separately, the estimator prices Fable and Mythos at $10/$50 per million tokens, but all three pricing tables (the skill and its ja-JP and zh-CN translations) listed only Haiku, Sonnet, and Opus, so a reader could not budget for two supported model families. Added the missing row to each. 16 of the new assertions fail against the previous behaviour (23 passed, 16 failed) and all pass with the guard (39 passed). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KV43bDJpgyMPovT2CHAoba --- .../skills/cost-aware-llm-pipeline/SKILL.md | 1 + .../skills/cost-aware-llm-pipeline/SKILL.md | 1 + scripts/lib/cost-estimate.js | 17 +++++++++-- skills/cost-aware-llm-pipeline/SKILL.md | 1 + tests/lib/cost-estimate.test.js | 30 +++++++++++++++++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/docs/ja-JP/skills/cost-aware-llm-pipeline/SKILL.md b/docs/ja-JP/skills/cost-aware-llm-pipeline/SKILL.md index adb4b532c..196361e1a 100644 --- a/docs/ja-JP/skills/cost-aware-llm-pipeline/SKILL.md +++ b/docs/ja-JP/skills/cost-aware-llm-pipeline/SKILL.md @@ -158,6 +158,7 @@ def process(text: str, config: Config, tracker: CostTracker) -> tuple[Result, Co | Haiku 4.5 | $1.00 | $5.00 | 1x | | Sonnet 4.6 | $3.00 | $15.00 | 3x | | Opus 4.5 | $5.00 | $25.00 | 5x | +| Fable 5 / Mythos 5 | $10.00 | $50.00 | 10x | ## ベストプラクティス diff --git a/docs/zh-CN/skills/cost-aware-llm-pipeline/SKILL.md b/docs/zh-CN/skills/cost-aware-llm-pipeline/SKILL.md index 396171570..f54f31297 100644 --- a/docs/zh-CN/skills/cost-aware-llm-pipeline/SKILL.md +++ b/docs/zh-CN/skills/cost-aware-llm-pipeline/SKILL.md @@ -158,6 +158,7 @@ def process(text: str, config: Config, tracker: CostTracker) -> tuple[Result, Co | Haiku 4.5 | $1.00 | $5.00 | 1x | | Sonnet 4.6 | $3.00 | $15.00 | 3x | | Opus 4.5 | $5.00 | $25.00 | 5x | +| Fable 5 / Mythos 5 | $10.00 | $50.00 | 10x | ## 最佳实践 diff --git a/scripts/lib/cost-estimate.js b/scripts/lib/cost-estimate.js index 0dfa9c834..cc7d3fdd7 100644 --- a/scripts/lib/cost-estimate.js +++ b/scripts/lib/cost-estimate.js @@ -48,11 +48,24 @@ const LEGACY_HAIKU_RE = /3-5-haiku|haiku-3-5/; * Estimate USD cost from token counts. * @param {string} model - Model name (may contain "haiku", "sonnet", "opus", * "fable" or "mythos"); anything else is priced at sonnet rates. - * @param {number} inputTokens - * @param {number} outputTokens + * @param {number} inputTokens - Finite, non-negative. + * @param {number} outputTokens - Finite, non-negative. * @returns {number} Estimated cost in USD (rounded to 6 decimal places) + * @throws {RangeError} If either token count is negative or non-finite. */ function estimateCost(model, inputTokens, outputTokens) { + // Callers use this to decide whether a call fits a budget, and both bad + // inputs defeat that check silently rather than loudly: a negative count + // yields a negative cost, and a non-finite one yields NaN, for which every + // `cost > budget` comparison is false. An unpriceable model still falls + // back to sonnet rates on purpose — that degrades an estimate; this + // corrupts one. + for (const [name, value] of [['inputTokens', inputTokens], ['outputTokens', outputTokens]]) { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError(`${name} must be a finite non-negative number, got ${value}`); + } + } + const normalized = String(model || '').toLowerCase(); let rates = RATE_TABLE.sonnet; if (normalized.includes('haiku')) { diff --git a/skills/cost-aware-llm-pipeline/SKILL.md b/skills/cost-aware-llm-pipeline/SKILL.md index 63b10f6b3..34945844c 100644 --- a/skills/cost-aware-llm-pipeline/SKILL.md +++ b/skills/cost-aware-llm-pipeline/SKILL.md @@ -159,6 +159,7 @@ def process(text: str, config: Config, tracker: CostTracker) -> tuple[Result, Co | Haiku 4.5 | $1.00 | $5.00 | 1x | | Sonnet 4.6 | $3.00 | $15.00 | 3x | | Opus 4.5 | $5.00 | $25.00 | 5x | +| Fable 5 / Mythos 5 | $10.00 | $50.00 | 10x | ## Best Practices diff --git a/tests/lib/cost-estimate.test.js b/tests/lib/cost-estimate.test.js index 2aa60770f..ce5a08193 100644 --- a/tests/lib/cost-estimate.test.js +++ b/tests/lib/cost-estimate.test.js @@ -172,6 +172,36 @@ function runTests() { passed++; else failed++; + // A negative count yields a negative cost and a non-finite one yields NaN, + // and `NaN > budget` is false — so bad input defeats a budget check silently + // rather than loudly. Pin the throw so a regression fails here. + for (const bad of [-1, -0.5, NaN, Infinity, -Infinity, undefined, null, '100']) { + if ( + test(`rejects ${String(bad)} as an input token count`, () => { + assert.throws(() => estimateCost('sonnet', bad, 100), RangeError); + }) + ) + passed++; + else failed++; + + if ( + test(`rejects ${String(bad)} as an output token count`, () => { + assert.throws(() => estimateCost('sonnet', 100, bad), RangeError); + }) + ) + passed++; + else failed++; + } + + if ( + test('accepts zero and fractional token counts', () => { + assert.strictEqual(estimateCost('sonnet', 0, 0), 0); + assert.strictEqual(estimateCost('sonnet', 500_000, 0), 1.5); + }) + ) + passed++; + else failed++; + // Summary console.log(`\nResults: ${passed} passed, ${failed} failed\n`); return { passed, failed };