diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 113f30a..2eed2fa 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -256,7 +256,7 @@ export type RoutingMode = keyof typeof MODEL_TIERS; * promise "still free when it returns". * * What that check cannot cover, and does not pretend to: a member the catalogue - * does not list at all. Eight of these are in that state on both gateways today, + * does not list at all. Twenty-three of these are in that state on both gateways today, * and delisting is not death — gpt-oss-120b is the gateway's own free fallback * and answers for itself while absent from GET /v1/models. The sweep names each * one as UNVERIFIED rather than counting it as checked; the only way to settle @@ -288,6 +288,32 @@ export const FREE_CHAT_MODELS: ReadonlySet = new Set([ "nvidia/nemotron-3.5-lightning", // itself on sol only; super-120b on Base "nvidia/muse-glimmer-30b", // now listed on both; itself on sol only "nvidia/gemma-4-31b", // delisted on both; redirected to super-120b (gateway MODEL_REDIRECTS) + // Retired/hidden NVIDIA ids the ACCOUNT rail still prices at $0 + // (blockrun.ai/api/pricing, billingMode "free", 2026-10-20) and the wallet + // gateways redirect onto a $0 model (MODEL_REDIRECTS / fallbackModel). All + // fifteen kept here answered 200 with no payment header on BOTH chains on + // 2026-10-20, served by nemotron-3-super-120b, gpt-oss-20b, nano-omni or + // llama-3.2-11b-vision. Not routed — an explicit call just reserves $0 + // instead of the $5/$30 default (verify:prices flagged each one). + "nvidia/nemotron-3-super-120b-a12b-free", + "nvidia/qwen3.5-397b-a17b", + "nvidia/nemotron-ultra-253b", + "nvidia/nemotron-super-49b", + "nvidia/deepseek-v3.2", + // NOT nvidia/deepseek-v4-pro or nvidia/deepseek-v4-flash: both are being + // removed from the gateways (blockrun#848 for -flash, live on blockrun.ai; + // -pro approved 2026-10-20) and then answer an unknown-model 400 — a $0 + // reserve for an id that no longer exists would only hide that. + "nvidia/nemotron-3-nano-omni-nim", + "nvidia/mistral-large-3-675b", + "nvidia/qwen3-coder-480b", + "nvidia/devstral-2-123b", + "nvidia/glm-4.7", + "nvidia/llama-4-maverick", + "nvidia/qwen3-next-80b-a3b-instruct", + "nvidia/qwen3.5-122b-a10b", + "nvidia/seed-oss-36b", + "nvidia/mistral-small-4-119b", ]); /** @@ -589,19 +615,30 @@ export const GATEWAY_CHARS_PER_TOKEN_OBSERVED = 2.08; * chat-anthropic.ts already normalised bare -> prefixed for the LEDGER; this is * the same normalisation for the GATE, which is where the money decision is. * Post-slash segments are unique across the catalog (pinned by a test), so the - * mapping is unambiguous. + * mapping is unambiguous — with one deliberate exception class: a $0 NVIDIA + * mirror whose bare name is also a PAID model's (nvidia/deepseek-v4-pro vs + * deepseek/deepseek-v4-pro was the case, until the mirror was retired on + * 2026-10-20; none remain). There the paid id must own the bare spelling: + * mapping it to the free mirror would reserve $0 for a paid call, the gate + * bypass this file exists to prevent. Free ids go in FIRST so any paid id + * with the same bare name overwrites them. */ const BARE_TO_PREFIXED: Record = Object.fromEntries( [ + ...FREE_CHAT_MODELS, ...Object.keys(CHAT_PRICE_PER_MTOKEN), ...Object.keys(LONG_CONTEXT_PRICE_PER_MTOKEN), ...Object.values(MODEL_TIERS).flat(), - ...FREE_CHAT_MODELS, ] .filter((id: string) => id.includes("/")) .map((id: string) => [id.slice(id.indexOf("/") + 1), id]), ); +/** $0 ids whose bare spelling belongs to a paid model (see BARE_TO_PREFIXED). */ +export const FREE_SHADOWED_BY_PAID: ReadonlySet = new Set( + [...FREE_CHAT_MODELS].filter((id) => BARE_TO_PREFIXED[id.slice(id.indexOf("/") + 1)] !== id), +); + export function canonicalChatModel(model: string): string { const id = model.trim(); if (Object.hasOwn(CHAT_PRICE_PER_MTOKEN, id)) return id; diff --git a/test/chat.test.ts b/test/chat.test.ts index 9779227..9b1b785 100644 --- a/test/chat.test.ts +++ b/test/chat.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { estimateChatCost, freeTierTruncationNote } from "../src/tools/chat.js"; import { handleAnthropicNative, anthropicCallCost } from "../src/tools/chat-anthropic.js"; -import { MODEL_TIERS, CHAT_PRICE_PER_MTOKEN, DEFAULT_CHAT_PRICE, FREE_CHAT_MODELS } from "../src/utils/constants.js"; +import { MODEL_TIERS, CHAT_PRICE_PER_MTOKEN, DEFAULT_CHAT_PRICE, FREE_CHAT_MODELS, FREE_SHADOWED_BY_PAID } from "../src/utils/constants.js"; import { withTxFee } from "../src/utils/tx-fee.js"; import type { BudgetState } from "../src/types.js"; @@ -203,8 +203,14 @@ test("every MODEL_TIERS.free entry is in FREE_CHAT_MODELS, and every member rese } for (const m of FREE_CHAT_MODELS) { assert.equal(estimateChatCost(1024, undefined, m, undefined, 600 * 1024), 0, m); - // The bare spelling is a real, chargeable-or-free id too (see BARE_TO_PREFIXED). - assert.equal(estimateChatCost(1024, undefined, m.slice(m.indexOf("/") + 1), undefined, 600 * 1024), 0, `bare ${m}`); + // The bare spelling is a real, chargeable-or-free id too (see BARE_TO_PREFIXED) + // — unless a PAID model owns it, in which case it must reserve that model. + const bare = m.slice(m.indexOf("/") + 1); + if (FREE_SHADOWED_BY_PAID.has(m)) { + assert.ok(estimateChatCost(1024, undefined, bare, undefined, 600 * 1024) > 0, `bare ${m} belongs to a paid model`); + } else { + assert.equal(estimateChatCost(1024, undefined, bare, undefined, 600 * 1024), 0, `bare ${m}`); + } } }); @@ -366,8 +372,46 @@ test("every catalog id has a unique vendor-less segment — the mapping cannot b if (!byBare.has(bare)) byBare.set(bare, new Set()); byBare.get(bare)!.add(id); } - const clashes = [...byBare.entries()].filter(([, set]) => set.size > 1); + // The one tolerated clash: a $0 NVIDIA mirror sharing a PAID model's name. + // The paid id owns the bare spelling (FREE_SHADOWED_BY_PAID), so the clash + // can only over-reserve, never reserve $0 for a paid call. + const clashes = [...byBare.entries()].filter(([, set]) => { + if (set.size < 2) return false; + const paid = [...set].filter((id) => !FREE_CHAT_MODELS.has(id)); + const free = [...set].filter((id) => FREE_CHAT_MODELS.has(id)); + return !(paid.length === 1 && free.every((id) => FREE_SHADOWED_BY_PAID.has(id))); + }); assert.deepEqual(clashes, [], `two vendors ship the same model name: ${JSON.stringify(clashes)}`); + // nvidia/deepseek-v4-pro was the one case until it was retired (2026-10-20). + assert.deepEqual([...FREE_SHADOWED_BY_PAID], []); +}); + +// The account sheet (blockrun.ai/api/pricing) prices these hidden NVIDIA ids +// at $0 and both wallet gateways answer them unpaid (2026-10-20); before this +// an explicit call reserved the $5/$30 default and an exhausted budget refused it. +test("account-sheet $0 NVIDIA ids reserve nothing, and are not routed", () => { + const ids = [ + "nvidia/nemotron-3-super-120b-a12b-free", "nvidia/qwen3.5-397b-a17b", "nvidia/nemotron-ultra-253b", + "nvidia/nemotron-super-49b", "nvidia/deepseek-v3.2", + "nvidia/nemotron-3-nano-omni-nim", "nvidia/mistral-large-3-675b", "nvidia/qwen3-coder-480b", + "nvidia/devstral-2-123b", "nvidia/glm-4.7", "nvidia/llama-4-maverick", "nvidia/qwen3-next-80b-a3b-instruct", + "nvidia/qwen3.5-122b-a10b", "nvidia/seed-oss-36b", "nvidia/mistral-small-4-119b", + ]; + for (const id of ids) { + assert.equal(estimateChatCost(1024, undefined, id, undefined, 100_000), 0, id); + assert.ok(!(MODEL_TIERS.free as readonly string[]).includes(id), `${id} must not join the routing order`); + } + // Retired from the gateways (unknown-model 400 since 2026-10-20): not free. + for (const gone of ["nvidia/deepseek-v4-pro", "nvidia/deepseek-v4-flash", "deepseek-v4-flash"]) { + assert.ok(!FREE_CHAT_MODELS.has(gone), gone); + assert.ok(estimateChatCost(1024, undefined, gone, undefined, 100_000) > 0, gone); + } + // The bare name of the paid DeepSeek still reserves the paid rate. + assert.equal( + estimateChatCost(1024, undefined, "deepseek-v4-pro", undefined, 100_000), + estimateChatCost(1024, undefined, "deepseek/deepseek-v4-pro", undefined, 100_000), + ); + assert.ok(estimateChatCost(1024, undefined, "deepseek-v4-pro", undefined, 100_000) > 0); }); // ── Two flagship ids landed above the default, and nothing noticed ──