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')), []); +});