From d5b289fd85c8f922dd7ac4ccb67d5e63954ffeb7 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:34:57 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20modelCost=20=E2=80=94=20because=20the?= =?UTF-8?q?=20same=20paid=20fallback=20was=20found=20in=20three=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One mistake, three codebases, one day. Each was a FALLBACK that began spending the moment the free tier ran dry — the path nobody watches, because it only runs when something else has already broken: botsmann OPENROUTER_DEFAULT_MODEL = anthropic/claude-sonnet-5 (premium) kivvi chain ended at Anthropic; OpenRouter default was google/gemini-2.0-flash-001, the paid twin of a :free id evig meta-llama/llama-3.3-70b-instruct — reads free, bills at 1e-7/token, and its :free sibling has been retired from the catalogue A fallback is a reliability mechanism; paying is a business decision. Wiring the second to the first lets an outage make the decision, at the worst moment, without anyone choosing it. The rule is deliberately narrow and says so. A ROUTED id (vendor/model) is free only with the :free suffix and paid without it — that suffix is the entire difference between free routing and a per-call charge for identical weights. A bare id (llama-3.1-8b-instant) returns "unknown": whether it costs depends on the account tier at that vendor, which no string can answer. Guessing "free" there is the direction that let all three through review, so it is not the default. paidModelsIn() applies it to a whole chain, returning offenders rather than throwing — the caller knows whether a paid link is a bug or a deliberate, opted-in upgrade, and a library that throws on the second case gets routed around. 35 tests green, including the case that keeps this package honest: its own shipped free chain must contain no paid model. --- package.json | 2 +- src/chain.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 3 +++ test/cost.test.js | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 test/cost.test.js diff --git a/package.json b/package.json index f4d2105..f14526e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-ration", - "version": "0.1.0", + "version": "0.2.0", "description": "Stay on free LLM tiers: a multi-vendor fallback chain, rate-limit classification that tells the three kinds of 429 apart, and fair-share rationing of a shared daily pool across users.", "license": "MIT", "author": "Mao Nakamoto", diff --git a/src/chain.ts b/src/chain.ts index 4e7f418..ebe93ea 100644 --- a/src/chain.ts +++ b/src/chain.ts @@ -162,6 +162,51 @@ export function freeChain(prefix = "AI"): Provider[] { ]; } +/** What a model id tells us about who pays. */ +export type CostVerdict = "free" | "paid" | "unknown"; + +/** + * Does this model id cost money? + * + * Exists because the same mistake was found in THREE separate apps on one day, + * each a fallback that silently began spending when the free tier ran dry: + * + * anthropic/claude-sonnet-5 a premium model as the fallback + * google/gemini-2.0-flash-001 the paid twin of a `:free` id + * meta-llama/llama-3.3-70b-instruct reads free; bills at 1e-7/token, + * and its `:free` sibling has been + * retired from the catalogue + * + * The decidable rule is narrow and stated as such. A routed id (`vendor/model`, + * the OpenRouter shape) is FREE only with the `:free` suffix, and PAID without + * it — that suffix is the entire difference between free routing and a per-call + * charge for the same weights. A bare id (`llama-3.1-8b-instant`) says nothing: + * whether it costs depends on the account's tier at that vendor, which no string + * can answer, so it returns "unknown" rather than guessing. + * + * Guessing "free" there would be the dangerous direction — it is what let three + * of these through code review. + */ +export function modelCost(id: string): CostVerdict { + const model = id.trim(); + if (!model) return "unknown"; + // OpenRouter's auto-router across the free catalogue. + if (model === "openrouter/free") return "free"; + if (!model.includes("/")) return "unknown"; + return model.endsWith(":free") ? "free" : "paid"; +} + +/** + * Assert every model in a chain is free, for apps that must never bill. + * + * Returns the offending ids rather than throwing: the caller knows whether a + * paid link is a bug or a deliberate, opted-in upgrade, and a library that + * throws on the second case forces people to route around it. + */ +export function paidModelsIn(chain: Provider[]): string[] { + return chain.flatMap((p) => p.models).filter((m) => modelCost(m) === "paid"); +} + /** * The day's total budget: every provider we hold a key for. * diff --git a/src/index.ts b/src/index.ts index 880237e..133d3af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,9 +21,12 @@ export { type Provider, type Env, type Link, + type CostVerdict, providerModels, withEnvPrefix, freeChain, + modelCost, + paidModelsIn, dayCapacityTokens, usableChain, chainFrom, diff --git a/test/cost.test.js b/test/cost.test.js new file mode 100644 index 0000000..7a6eda1 --- /dev/null +++ b/test/cost.test.js @@ -0,0 +1,37 @@ +/** + * `modelCost` exists because one mistake appeared in THREE apps on one day: + * a fallback that silently began spending the moment the free tier ran dry. + * These cases are the real ids that were found in production config. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { modelCost, paidModelsIn, freeChain } from 'ai-ration'; + +test('the three ids that were actually billing are all caught', () => { + assert.equal(modelCost('anthropic/claude-sonnet-5'), 'paid'); + assert.equal(modelCost('google/gemini-2.0-flash-001'), 'paid'); + // Reads free, bills at 1e-7/token, and its `:free` sibling has been retired. + assert.equal(modelCost('meta-llama/llama-3.3-70b-instruct'), 'paid'); +}); + +test('the `:free` suffix is the whole difference', () => { + assert.equal(modelCost('openai/gpt-oss-20b:free'), 'free'); + assert.equal(modelCost('openai/gpt-oss-20b'), 'paid'); + assert.equal(modelCost('openrouter/free'), 'free', 'the free auto-router'); +}); + +test('a bare vendor id is UNKNOWN, never assumed free', () => { + // Whether `llama-3.1-8b-instant` costs depends on the account tier at Groq — + // no string can answer that. Guessing "free" is the direction that let three + // of these through review, so it must not be the default. + assert.equal(modelCost('llama-3.1-8b-instant'), 'unknown'); + assert.equal(modelCost('llama-3.3-70b-versatile'), 'unknown'); + assert.equal(modelCost(''), 'unknown'); + assert.equal(modelCost(' '), 'unknown'); +}); + +test('the shipped free chain contains no paid model', () => { + // The package would have no standing to flag anyone else's chain otherwise. + assert.deepEqual(paidModelsIn(freeChain('TEST')), []); +});