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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KV43bDJpgyMPovT2CHAoba
This commit is contained in:
Andres Tuul
2026-08-08 20:28:04 +03:00
co-authored by Claude Opus 5
parent 39416e3cdd
commit 5e6c8f9e1f
5 changed files with 48 additions and 2 deletions
@@ -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 |
## ベストプラクティス
@@ -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 |
## 最佳实践
+15 -2
View File
@@ -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')) {
+1
View File
@@ -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
+30
View File
@@ -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 };