From aade71027e02dfb1624e24a892bb830891533283 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 19 Oct 2026 23:57:32 -0500 Subject: [PATCH] refactor(free): port Franklin's free-models module and drop dead free ids Franklin-Trading still hard-coded free ids that left the gateway catalog in the 2026-08-30 pool rotation (nemotron-nano-9b-v2, nemotron-nano-12b-v2-vl, mistral-nemotron) and earlier (qwen3-coder-480b, llama-4-maverick) across ~15 call sites. The gateway answered for them with a substitute, so the fork never got the model it named. - Add src/free-models.ts + free-models.constants.ts from Franklin (FREE_DEFAULT_MODEL, FREE_PREFERENCE_ORDER, freeChain, isFreeModelId, freeVisionModel, FREE_POOL_CONTEXT_FLOOR). isFreeModelId reads the live gateway cache plus the static sets; the fork has no model-catalog dep. - Replace the literals in subagent, moa, evaluator, verification, turn-analyzer, compact, llm, media-router, brain/extract, learnings/extractor, telegram, proxy fallback, plugin-sdk workflow and the picker with the shared default / chain. - router: free profile, getFallbackChain and pickFreeFallback use the live free chain; quarantine list comes from free-models; the tier classifier follows FREE_DEFAULT_MODEL with a budget a reasoning model can answer inside, and reads the last tier word. - vision: no free id is promised vision by default (nano-12b-v2-vl left the catalog; nano-omni is refused images on /v1/messages). A free model is never swapped to a paid vision sibling in the loop or the proxy, and a free proxy request never falls back to a paid rung. - isFreeModelId replaces the startsWith('nvidia/') heuristic in the subagent spend gate, /model paid warning and compaction picker. - The analyzer and media-router budgets are raised for reasoning models. - nvidia/deepseek-v4-flash and nvidia/deepseek-v4-pro are not in any free set or routing order (both 400 at the gateway). - tests: free routing pins updated; new guards that no runtime default names a retired free id. --- src/agent/commands.ts | 4 +- src/agent/compact.ts | 7 +- src/agent/evaluator.ts | 3 +- src/agent/llm.ts | 9 +- src/agent/loop.ts | 71 ++++-- src/agent/media-router.ts | 14 +- src/agent/tokens.ts | 27 ++- src/agent/turn-analyzer.ts | 19 +- src/agent/verification.ts | 3 +- src/brain/extract.ts | 8 +- src/commands/telegram.ts | 3 +- src/free-models.constants.ts | 38 +++ src/free-models.ts | 440 +++++++++++++++++++++++++++++++++++ src/learnings/extractor.ts | 5 +- src/plugin-sdk/workflow.ts | 9 +- src/pricing.ts | 2 +- src/proxy/fallback.ts | 14 +- src/proxy/server.ts | 16 +- src/router/index.ts | 189 +++++++++------ src/router/vision.ts | 45 +++- src/tools/moa.ts | 11 +- src/tools/subagent.ts | 11 +- src/ui/model-picker.ts | 12 +- test/local.mjs | 162 ++++++++----- 24 files changed, 906 insertions(+), 216 deletions(-) create mode 100644 src/free-models.constants.ts create mode 100644 src/free-models.ts diff --git a/src/agent/commands.ts b/src/agent/commands.ts index 4b9df04..88aa616 100644 --- a/src/agent/commands.ts +++ b/src/agent/commands.ts @@ -16,6 +16,7 @@ import { estimateHistoryTokens, getAnchoredTokenCount, getContextWindow, resetTo import { forceCompact } from './compact.js'; import { getStatsSummary } from '../stats/tracker.js'; import { resolveModel } from '../ui/model-picker.js'; +import { isFreeModelId } from '../free-models.js'; import type { ModelClient } from './llm.js'; import type { AgentConfig, Dialogue, StreamEvent } from './types.js'; import { @@ -772,8 +773,7 @@ export async function handleSlashCommand( ctx.config.baseModel = newModel; // Update recovery target so loop doesn't reset ctx.config.onModelChange?.(newModel, 'user'); // Warn when switching from free to paid so users know charges start now - const isFree = (m: string) => m.startsWith('nvidia/') || m === 'blockrun/free'; - const paidWarning = !isFree(newModel) + const paidWarning = !isFreeModelId(newModel) ? ` ⚠️ (paid — charges from your wallet per call)` : ''; ctx.onEvent({ kind: 'text_delta', text: `Model → **${newModel}**${paidWarning}\n` }); diff --git a/src/agent/compact.ts b/src/agent/compact.ts index 26a194b..88c8cac 100644 --- a/src/agent/compact.ts +++ b/src/agent/compact.ts @@ -13,6 +13,7 @@ import { getCompactionThreshold, COMPACTION_SUMMARY_RESERVE, } from './tokens.js'; +import { FREE_DEFAULT_MODEL, isFreeModelId } from '../free-models.js'; /** Max files to restore after compaction */ const POST_COMPACT_MAX_FILES = 5; @@ -538,13 +539,13 @@ function formatCompactSummary(raw: string): string { /** * Pick a cheaper/faster model for compaction to save cost. - * If the primary model is free (NVIDIA), compaction also stays free + * If the primary model is free (isFreeModelId), compaction also stays free * so users don't get silent charges when their context fills up. */ function pickCompactionModel(primaryModel: string): string { // Free parent → free compaction (no silent charge) - if (primaryModel.startsWith('nvidia/') || primaryModel === 'blockrun/free') { - return 'nvidia/nemotron-nano-9b-v2'; + if (isFreeModelId(primaryModel)) { + return FREE_DEFAULT_MODEL; } // Use cheapest capable model for summarization to save cost // Tier down: opus/pro → sonnet, sonnet → haiku, everything else → flash (cheapest capable) diff --git a/src/agent/evaluator.ts b/src/agent/evaluator.ts index b8ccd2a..87a61bc 100644 --- a/src/agent/evaluator.ts +++ b/src/agent/evaluator.ts @@ -24,6 +24,7 @@ import type { CapabilityHandler, Dialogue } from './types.js'; import { ModelClient } from './llm.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; // ─── Evaluator system prompt ───────────────────────────────────────────── // @@ -270,7 +271,7 @@ export function parseGroundingResponse(raw: string): GroundingResult { * choice so both quality gates have the same cost profile. Override via * `FRANKLIN_EVALUATOR_MODEL` to experiment with accuracy/cost trade-offs. */ export function evaluatorModel(): string { - return process.env.FRANKLIN_EVALUATOR_MODEL || 'nvidia/nemotron-nano-9b-v2'; + return process.env.FRANKLIN_EVALUATOR_MODEL || FREE_DEFAULT_MODEL; } // ─── Run grounding check ───────────────────────────────────────────────── diff --git a/src/agent/llm.ts b/src/agent/llm.ts index c77595b..8677267 100644 --- a/src/agent/llm.ts +++ b/src/agent/llm.ts @@ -28,6 +28,7 @@ import type { } from './types.js'; import { ThinkTagStripper } from './think-tag-stripper.js'; import { isNemotronProseModel, stripNemotronProse } from './nemotron-prose-stripper.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; // Reasoning-tier models the gateway routes to that reject `tool_choice` // outright. Pattern: OpenAI o1/o3 family + DeepSeek's reasoner variant. @@ -465,14 +466,14 @@ export class ModelClient { // Static fallback when the router module isn't loadable. Defaults to a // FREE model so users aren't silently charged. The unknown-key path also - // falls through to qwen, so legacy `blockrun/eco` / `blockrun/premium` + // falls through to the free default, so legacy `blockrun/eco` / `blockrun/premium` // strings (now retired routing profiles) end up at the same place // without needing dedicated entries. const FALLBACKS: Record = { - 'blockrun/auto': 'nvidia/qwen3-coder-480b', - 'blockrun/free': 'nvidia/qwen3-coder-480b', + 'blockrun/auto': FREE_DEFAULT_MODEL, + 'blockrun/free': FREE_DEFAULT_MODEL, }; - return FALLBACKS[model] || 'nvidia/qwen3-coder-480b'; + return FALLBACKS[model] || FREE_DEFAULT_MODEL; } /** diff --git a/src/agent/loop.ts b/src/agent/loop.ts index 7e7f967..4aaa490 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -30,6 +30,7 @@ import { estimateCost, OPUS_PRICING } from '../pricing.js'; import { maybeMidSessionExtract } from '../learnings/extractor.js'; import { extractMentions, buildEntityContext, loadEntities } from '../brain/store.js'; import { routeRequest, parseRoutingProfile, getFallbackChain, pickFreeFallback, isVisionModel, messageNeedsVision, pickVisionSibling, markModelUnavailable } from '../router/index.js'; +import { freeVisionModel, isFreeModelId } from '../free-models.js'; import type { Tier, RoutingProfile, RoutingResult } from '../router/index.js'; import { recordOutcome } from '../router/local-elo.js'; import { shouldPlan, getPlanningPrompt, getExecutorModel, isExecutorStuck, toolCallSignature } from './planner.js'; @@ -531,10 +532,10 @@ function formatModelSwitch( */ export function isWeakModel(model: string): boolean { const m = model.toLowerCase(); - // NVIDIA-hosted open models have been observed confabulating tool calls. - // `blockrun/free` resolves to an NVIDIA model before the API call, so - // catching the `nvidia/` prefix also catches the free-profile path. - if (m.startsWith('nvidia/')) return true; + // Free-tier open models have been observed confabulating tool calls. + // isFreeModelId covers the non-NVIDIA free ids (poolside, cohere) too; + // the prefix check keeps paid NVIDIA-hosted SKUs nagged as before. + if (isFreeModelId(model) || m.startsWith('nvidia/')) return true; if (m.includes('nemotron-ultra')) return true; if (m.includes('qwen3-coder')) return true; // GLM-4* is weak; GLM-5+ is capable enough to skip the nag. @@ -1321,11 +1322,27 @@ export async function interactiveSession( lastRoutedModel = routing.model; lastRoutedCategory = routing.category || ''; if (loopCount === 1) { - const visionTag = turnNeedsVision ? ' 👁️' : ''; + // A free-profile vision turn is routed to a TEXT model on purpose: + // the gateway has no free model that honours an image (see + // router/vision.ts). Say so instead of showing the 👁️ tag over a + // model that will answer from the text stub alone — and never fall + // back to a paid model, which the free profile must not do. + const visionUnavailable = routing.signals.includes('free-vision-unavailable'); + const visionTag = turnNeedsVision && !visionUnavailable ? ' 👁️' : ''; onEvent({ kind: 'text_delta', text: `*Auto → ${routing.model}${visionTag}*\n\n`, }); + if (visionUnavailable) { + onEvent({ + kind: 'text_delta', + text: + '*⚠️ No free vision model is available — the free vision ids drop the ' + + 'image on about half of all calls and answer anyway. This turn answers ' + + 'from text only, so treat anything said about the image as a guess. ' + + 'Switch to a paid model (e.g. `/model haiku`) to send it.*\n\n', + }); + } } } else if (turnNeedsVision && !isVisionModel(resolvedModel)) { // ── Manual-mode guard ── @@ -1337,13 +1354,39 @@ export async function interactiveSession( // overridden). Always emit a visible notice so the user knows their // pick was overridden and why. const original = resolvedModel; - const visionSwap = pickVisionSibling(original); - resolvedModel = visionSwap; - config.model = visionSwap; - onEvent({ - kind: 'text_delta', - text: `*⚠️ ${original} can't see images — using ${visionSwap} for this turn.*\n\n`, - }); + // A free-tier model must NEVER be swapped for a paid one. There is no + // free vision model (router/vision.ts), so pickVisionSibling would + // return the paid default and start charging a user who explicitly + // chose the free tier — silently, mid-turn. Say so and stay free. + if (isFreeModelId(original)) { + const freeVision = freeVisionModel(); + if (freeVision) { + resolvedModel = freeVision; + config.model = freeVision; + onEvent({ + kind: 'text_delta', + text: `*⚠️ ${original} can't see images — using ${freeVision} for this turn (still free).*\n\n`, + }); + } else { + onEvent({ + kind: 'text_delta', + text: + `*⚠️ ${original} can't see images, and no free vision model ` + + 'is reliable right now — the free vision ids drop the image on ' + + 'about half of all calls and answer anyway. Answering from ' + + 'text only, so treat anything said about the image as a guess; ' + + 'switch to a paid model (e.g. `/model haiku`) to send it.*\n\n', + }); + } + } else { + const visionSwap = pickVisionSibling(original); + resolvedModel = visionSwap; + config.model = visionSwap; + onEvent({ + kind: 'text_delta', + text: `*⚠️ ${original} can't see images — using ${visionSwap} for this turn.*\n\n`, + }); + } } // Update token estimation model for more accurate byte-per-token ratio @@ -1461,7 +1504,9 @@ export async function interactiveSession( const hasTools = responseParts.some(p => p.type === 'tool_use'); const hasThinking = responseParts.some(p => p.type === 'thinking'); if (!hasText && !hasTools && !hasThinking) { - const EMPTY_FALLBACK_MODELS = ['nvidia/qwen3-coder-480b', 'nvidia/llama-4-maverick', 'zai/glm-5.1']; + // The live free chain (src/free-models.ts). The literals that used to + // sit here (qwen3-coder-480b, llama-4-maverick) left the catalog. + const EMPTY_FALLBACK_MODELS = getFallbackChain('SIMPLE', 'free'); const nextModel = EMPTY_FALLBACK_MODELS.find(m => m !== config.model && !turnFailedModels.has(m)); if (nextModel && recoveryAttempts < 2) { recoveryAttempts++; diff --git a/src/agent/media-router.ts b/src/agent/media-router.ts index 1c6b44f..26439bd 100644 --- a/src/agent/media-router.ts +++ b/src/agent/media-router.ts @@ -21,6 +21,7 @@ import { maxDurationSeconds, type GatewayModel, } from '../gateway-models.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; // ─── Types ────────────────────────────────────────────────────────────── @@ -60,9 +61,16 @@ export interface MediaProposal { // ─── Classifier ───────────────────────────────────────────────────────── -const CLASSIFIER_MODEL = process.env.FRANKLIN_MEDIA_ROUTER_MODEL || 'nvidia/llama-4-maverick'; -const TIMEOUT_MS = 3_500; // slightly more lenient than the turn-analyzer — we're asking for JSON with reasoning -const MAX_TOKENS = 384; // bumped from 256 to leave room for refined_prompt (≤500 chars) + refinement_summary (≤80) +// llama-4-maverick held this slot until it left the gateway catalog +// (2026-07-14); the free pool answered for it with a substitute. Follows +// FREE_DEFAULT_MODEL now. That is a reasoning model, so the budget has to +// cover its thinking AND the JSON (refined_prompt ≤500 chars + +// refinement_summary ≤80) — the 384-token / 3.5s budget sized for maverick +// would truncate it mid-thought (see the TRUNCATION note in +// src/free-models.ts) and the parse would fail silently. +const CLASSIFIER_MODEL = process.env.FRANKLIN_MEDIA_ROUTER_MODEL || FREE_DEFAULT_MODEL; +const TIMEOUT_MS = 10_000; +const MAX_TOKENS = 2_048; const REFINED_MAX_CHARS = 500; const REFINEMENT_SUMMARY_MAX_CHARS = 80; diff --git a/src/agent/tokens.ts b/src/agent/tokens.ts index 684c114..fffe099 100644 --- a/src/agent/tokens.ts +++ b/src/agent/tokens.ts @@ -6,6 +6,7 @@ import type { Dialogue, ContentPart, UserContentPart } from './types.js'; import { peekGatewayModel, warmGatewayModelsCache } from '../gateway-models.js'; +import { FREE_POOL_CONTEXT_FLOOR } from '../free-models.js'; const DEFAULT_BYTES_PER_TOKEN = 4; @@ -314,17 +315,33 @@ const MODEL_CONTEXT_WINDOWS: Record = { 'moonshot/kimi-k2.5': 128_000, 'minimax/minimax-m3': 1_000_000, 'minimax/minimax-m2.7': 128_000, - // NVIDIA-hosted free tier (refreshed 2026-08-12 to match live /api/v1/models). - // Dead ids (qwen3-next, qwen3.5-122b, seed-oss, maverick, mistral-large) are - // kept so legacy session records still get a sane window. + // Free tier (ported from Franklin's src/free-models.ts, 2026-10-20). + // + // Every current free id is pinned to FREE_POOL_CONTEXT_FLOOR rather than its + // catalogued window, deliberately: the free tier is a pool, and a request + // for a 1M-context id can be served by a 131K substitute under load with no + // signal to the client — sizing compaction off 1M would build a prompt the + // substitute cannot accept. The catalogued windows are recorded in + // free-models.ts (FREE_MODEL_CONTEXT_WINDOWS) for display. + // + // Dead and substituted ids are kept so legacy session records still get a + // sane window. + 'nvidia/nemotron-3-ultra-550b': FREE_POOL_CONTEXT_FLOOR, // catalogued 1M; pool floor applies + 'nvidia/nemotron-3.5-lightning': FREE_POOL_CONTEXT_FLOOR, // catalogued 1M; pool floor applies + 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning': FREE_POOL_CONTEXT_FLOOR, + 'nvidia/muse-glimmer-30b': FREE_POOL_CONTEXT_FLOOR, + 'nvidia/llama-3.2-11b-vision': 128_000, + 'cohere/north-mini-code': FREE_POOL_CONTEXT_FLOOR, + 'poolside/laguna-xs-2.1': FREE_POOL_CONTEXT_FLOOR, + 'nvidia/nemotron-3-nano-30b': FREE_POOL_CONTEXT_FLOOR, // delisted 2026-09-08 'nvidia/qwen3-next-80b-a3b-instruct': 262_144, // NVIDIA EOL 410, 2026-07-27 'nvidia/qwen3.5-122b-a10b': 131_072, - 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning': 256_000, 'nvidia/mistral-nemotron': 131_072, 'nvidia/step-3.7-flash': 131_072, 'nvidia/seed-oss-36b': 131_072, - 'nvidia/nemotron-nano-9b-v2': 131_072, // current free default + 'nvidia/nemotron-nano-9b-v2': 131_072, 'nvidia/nemotron-nano-12b-v2-vl': 131_072, + 'nvidia/nemotron-3-super-120b-a12b-free': 131_072, 'nvidia/llama-4-maverick': 131_072, 'nvidia/mistral-large-3-675b': 131_072, // Qwen (paid) — Max tier is 1M ctx; the generic `qwen` fallback below is diff --git a/src/agent/turn-analyzer.ts b/src/agent/turn-analyzer.ts index 6d8f64b..2cc90ff 100644 --- a/src/agent/turn-analyzer.ts +++ b/src/agent/turn-analyzer.ts @@ -30,6 +30,7 @@ import type { ModelClient } from './llm.js'; import type { MarketCode } from '../trading/providers/standard-models.js'; import type { Tier } from '../router/index.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; // ─── Types ────────────────────────────────────────────────────────────── @@ -73,8 +74,15 @@ const CONSERVATIVE_DEFAULT: TurnAnalysis = { const MAX_CURRENT_CHARS = 800; const MAX_PREV_REPLY_CHARS = 300; const MAX_GOAL_CHARS = 200; -const TIMEOUT_MS = 2_500; -const MAX_ANALYZER_TOKENS = 128; +// 2.5s did not leave a reasoning model room to finish and emit its JSON; the +// abort landed mid-thought and the analyzer silently degraded. See +// MAX_ANALYZER_TOKENS below. +const TIMEOUT_MS = 8_000; +// Raised from 128 (mirrors Franklin, 2026-08-31). The free pool is reasoning +// models now: cut off mid-thought they emit a partial trace instead of the +// single-line JSON this parser needs, and a parse failure here is SILENT. +// Costs nothing at $0. +const MAX_ANALYZER_TOKENS = 1_024; const CACHE_TTL_MS = 30_000; const CACHE_MAX_SIZE = 64; @@ -83,11 +91,10 @@ const CACHE_MAX_SIZE = 64; // Design: one compact prompt, a few precise examples, instruct the model to // emit a single-line JSON. The backbone must produce plain-text structured // output under tight max_tokens (thinking-first models leave text empty). -// Nemotron Nano 9B took over 2026-08-12: qwen3-next-80b-a3b-instruct hit -// NVIDIA's EOL (410) and mistral-nemotron is DEGRADED upstream (stream calls -// 400). Nano 9B verifiably serves itself — verified live through the binary. +// Follows FREE_DEFAULT_MODEL (src/free-models.ts). nemotron-nano-9b-v2, which +// held this slot, left the gateway catalog in the 2026-08-30 pool rotation. -const ANALYZER_MODEL_DEFAULT = process.env.FRANKLIN_ANALYZER_MODEL || 'nvidia/nemotron-nano-9b-v2'; +const ANALYZER_MODEL_DEFAULT = process.env.FRANKLIN_ANALYZER_MODEL || FREE_DEFAULT_MODEL; const ANALYZER_SYSTEM = `You analyze ONE user message for Franklin's routing + prefetch harness. Output ONE LINE of compact JSON — no explanation, no markdown, no code fences. diff --git a/src/agent/verification.ts b/src/agent/verification.ts index 80cf543..0d8b039 100644 --- a/src/agent/verification.ts +++ b/src/agent/verification.ts @@ -11,6 +11,7 @@ import type { CapabilityHandler, Dialogue } from './types.js'; import { ModelClient } from './llm.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; // ─── Verification System Prompt ─────────────────────────────────────────── @@ -154,7 +155,7 @@ export async function runVerification( config.onEvent?.({ kind: 'text_delta', text: '\n*Verifying...*\n' }); // Use agent-tested free model for verification. - const verificationModel = 'nvidia/nemotron-nano-9b-v2'; + const verificationModel = FREE_DEFAULT_MODEL; try { // Simple single-turn verification call diff --git a/src/brain/extract.ts b/src/brain/extract.ts index 8bf6e30..cdabd25 100644 --- a/src/brain/extract.ts +++ b/src/brain/extract.ts @@ -5,6 +5,7 @@ import { ModelClient } from '../agent/llm.js'; import type { Dialogue } from '../agent/types.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; import type { Entity, BrainExtraction, EntityType } from './types.js'; import { loadEntities, saveEntities, upsertEntity, @@ -14,12 +15,13 @@ import { // Last entry is the free safety net. nvidia/nemotron-super-49b held it until // 2026-08-20, by which point it had left the catalog entirely — the free pool // answers for it with a substitute, so the "free fallback" was neither the -// model named nor reliably shaped. nemotron-nano-9b-v2 is the current free -// default and verifiably serves itself. +// model named nor reliably shaped. The free rung follows FREE_DEFAULT_MODEL +// (src/free-models.ts) — nemotron-nano-9b-v2 held it until it, too, left the +// catalog in the 2026-08-30 pool rotation. const EXTRACTION_MODELS = [ 'google/gemini-2.5-flash-lite', 'google/gemini-2.5-flash', - 'nvidia/nemotron-nano-9b-v2', + FREE_DEFAULT_MODEL, ]; const VALID_TYPES = new Set(['person', 'project', 'company', 'product', 'concept']); diff --git a/src/commands/telegram.ts b/src/commands/telegram.ts index 89f8456..b189525 100644 --- a/src/commands/telegram.ts +++ b/src/commands/telegram.ts @@ -16,6 +16,7 @@ import { loadConfig } from './config.js'; import { runTelegramBot } from '../channel/telegram.js'; import { findLatestSessionByChannel } from '../session/storage.js'; import type { AgentConfig } from '../agent/types.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; interface TelegramCommandOptions { model?: string; @@ -59,7 +60,7 @@ export async function telegramCommand(opts: TelegramCommandOptions): Promise= 1200 +]; + +/** Context budget for ANY free-tier request. See free-models.ts. */ +export const FREE_POOL_CONTEXT_FLOOR = 131_072; diff --git a/src/free-models.ts b/src/free-models.ts new file mode 100644 index 0000000..8ad6106 --- /dev/null +++ b/src/free-models.ts @@ -0,0 +1,440 @@ +/** + * Single source of truth for the BlockRun free tier. + * + * Ported from Franklin (BlockRunAI/Franklin src/free-models.ts) on + * 2026-10-20. The measurement history below is Franklin's and is kept as-is; + * it is the evidence for the ordering this fork now shares. + * + * WHY THIS FILE EXISTS + * -------------------- + * The free default used to be a bare string literal repeated 63 times across + * 21 files. Every time NVIDIA rotated its free pool — 2026-06-07, 07-11, + * 07-14, 08-12, 08-19, 08-29 — someone had to find and edit all 63. That is + * the sixth rotation in three months, and each one shipped with a few stale + * copies left behind. One constant, imported everywhere, ends that. + * + * WHAT THE 2026-08-30 PROBE FOUND + * ------------------------------- + * Every free id Franklin named had left the Base catalog, and the gateway was + * answering for them with a different model: + * + * nvidia/nemotron-nano-9b-v2 -> nvidia/nemotron-3-nano-30b + * nvidia/mistral-nemotron -> nvidia/nemotron-3-super-120b-a12b-free + * nvidia/nemotron-nano-12b-v2-vl -> nvidia/nemotron-3-nano-omni-... + * nvidia/step-3.7-flash -> nvidia/nemotron-3.5-lightning + * + * That redirection is DELIBERATE upstream, not a gateway bug: the free tier + * sits on NVIDIA's on-demand NIM tier, which EOLs models (410), pulls + * deployments (404) and lets cold ones hang, so blockrun runs a self-healing + * circuit breaker (src/lib/free-health.ts) that routes around dead free + * models at runtime. It replaced a hand-maintained redirect list precisely + * because that list was always stale. The substitution is also disclosed — + * the response's `model` field names whatever actually served the call. + * + * So the failure was ours: Franklin named ids that no longer exist and never + * read that field back. What follows is about not doing that again. + * + * Two findings shape everything below, and BOTH were invisible until the + * probe ran against the endpoint and the chain Franklin actually uses. + * + * 1. THE CHAINS DRIFT. When this landed, Base listed 7 free models and Solana + * listed exactly ONE — nemotron-3-nano-omni — and 400'd ("Unknown model") + * on the other six. Solana caught up later the same day (both list 7 as of + * 2026-08-31), but the gap was real for hours and will recur: the two + * gateways deploy independently. A default picked from the Base catalog is + * a hard failure on a Solana user's first turn during such a window, which + * is why FREE_DEFAULT_MODEL is the id that survived on BOTH chains and the + * Base-only models are a runtime upgrade rather than a committed literal. + * + * 2. SUBSTITUTION IS ENDPOINT-SPECIFIC. The pooling above reproduces on + * POST /api/v1/chat/completions. On POST /api/v1/messages — the endpoint + * Franklin's ModelClient uses — every id answered as ITSELF across + * repeated calls on both chains, with no leaked chain-of-thought. So + * "which id serves itself" has a different answer per endpoint, and a + * probe is only evidence for the path it was run against. + * + * What this file does about it: + * + * 1. One constant, imported everywhere, instead of 63 string literals. + * 2. A chain-safe static default, upgraded from the LIVE catalog at runtime + * (freeChain / resolveFreeModel), so a Base user gets the stronger model + * and a Solana user gets one that exists. + * 3. Context sized by the pool floor, not the catalogued window + * (FREE_POOL_CONTEXT_FLOOR). + * 4. No free vision — see freeVisionModel(). + */ + +import { peekGatewayModel } from './gateway-models.js'; +import { + FREE_DEFAULT_MODEL, + FREE_POOL_CONTEXT_FLOOR, + FREE_PREFERENCE_ORDER, +} from './free-models.constants.js'; + +// Re-exported so every consumer keeps importing from ONE place. The constants +// live in a zero-import leaf only so the public plugin SDK can read them +// without pulling core's filesystem-touching config module in behind them. +export { FREE_DEFAULT_MODEL, FREE_POOL_CONTEXT_FLOOR, FREE_PREFERENCE_ORDER }; + +/** + * The free model Franklin asks for by default. + * + * nemotron-3-nano-omni is chosen for AVAILABILITY, not for being the best: it + * is the only free id present in both the Base and the Solana catalogs. Every + * other free model is Base-only and 400s on Solana, and Franklin ships with + * either chain selectable. Base users are upgraded to the stronger model at + * runtime by freeChain() — see FREE_PREFERENCE_ORDER. + * + * Verified on POST /api/v1/messages (Franklin's own endpoint) on both chains: + * answers as itself, no leaked reasoning, tool calls work. + */ +// (defined in ./free-models.constants.ts, re-exported above) + +/** + * Free models in preference order, best first. + * + * FREE_DEFAULT_MODEL is the id that survived on both chains through the + * 2026-08-30 rotation window; the rest were Base-only during it. freeChain() + * filters this against the live catalog for the CURRENT chain, so the list can + * name ids one gateway lacks without breaking the other. + * + * Ordering rationale — AVAILABILITY FIRST, quality second. Measured on + * /v1/messages, Base, max_tokens 300, 5 calls each (2026-08-31): + * + * nemotron-3-nano-30b 5/5 healthy, 0.8-2.9s + * nemotron-3-nano-omni 2/5 healthy, three HTTP 503s + * nemotron-3-ultra-550b 0/5 healthy — every call timed out at 40s + * + * - nemotron-3-nano-30b leads on Base. It is the pool's own backing model, + * which is why it is the one id that never comes back as a substitute and + * the one that stays up when the rest of the pool is congested. + * - nemotron-3-ultra-550b is NOT in this list, and that reverses an earlier + * revision which had it leading. It gives the best answers when it answers + * — the only free model to name Aerodrome for "three Base-chain DEXes" + * while the others hallucinated Uniswap forks — but a model you cannot + * reach is not a better model. The andy session measured it independently + * at 1/3 and 1/5, with NVIDIA congestion relayed as HTTP 200; here it was + * 0/5 with hard timeouts. It stays reachable by explicit pin + * (`/model ultra-550b`), it just cannot lead a chain. + * - the two non-NVIDIA rungs are last on purpose: they are what keeps the + * chain alive through an NVIDIA-wide outage instead of it being four names + * for one upstream. + * + * 2026-10-20: nemotron-3-nano-30b, the Base leader in the table above, was + * delisted on 2026-09-08 and has left this list (see LEGACY_FREE_MODEL_IDS). + * The measurements stay as the record of why it led. + * + * Not listed, and why: + * - nvidia/muse-glimmer-30b — joined the free pool 2026-10-02 and has not + * been measured on /v1/messages, the bar every rung above had to clear. + * A reasoning model that spends a small max_tokens budget entirely on + * reasoning (gateway probe). Reachable by explicit pin until measured. + * - nvidia/nemotron-3.5-lightning — LATENCY, and only latency. Measured + * 10.9s / 12.2s / 22.5s for a one-sentence answer at max_tokens 800, + * against ~0.8-3s for the rest of the pool. Its name is a lie. (An + * earlier revision of this file quarantined it for leaking reasoning + * prose; that was wrong — see TRUNCATION below.) + * - nvidia/llama-3.2-11b-vision — catalogued as vision, cannot accept an + * image (see freeVisionModel), and is otherwise the weakest text model in + * the pool. + * + * TRUNCATION: THE FREE POOL NEEDS ROOM TO THINK + * --------------------------------------------- + * Every free model here is a reasoning model, and a tight max_tokens makes + * them look broken in two different ways that are the SAME bug. Measured on + * chat/completions, 2026-08-31: + * + * nemotron-3-nano-30b, "what does approve() do": + * max_tokens 20 -> finish_reason "length", content is truncated thinking + * ("Okay, the user is asking about...") + * max_tokens 120 -> finish_reason "length", still truncated thinking + * max_tokens 300 -> finish_reason "stop", content is a clean answer and + * the thinking sits in reasoning_content + * + * cohere/north-mini-code, "name three Base-chain DEXes": + * max_tokens 200 -> EMPTY stream, 0 content chunks, 6 of 8 calls + * max_tokens 2000 -> 6 of 6 clean, finish_reason "stop" + * + * So "leaks chain-of-thought into content" and "returns an empty stream" are + * both the budget running out before the model stops reasoning. Neither is a + * property of the model, and neither is worth a workaround — give the call + * room. This is why the router's tier classifier no longer asks for a + * one-word answer in 8 tokens (see router/index.ts). + * + * The andy session traced the mechanism further, and it changes what the + * discriminator is. Probed three ways at max_tokens 20: the NIM upstream + * returns a clean `content: "OK"` with finish_reason "stop", while sol's + * /chat/completions returns the thinking in `content` with finish_reason + * "length". The upstream separates the fields correctly; the GATEWAY's + * extractor falls back to reasoning text when the answer field is empty. So + * raising max_tokens does not fix the bug, it stops triggering it, and + * finish_reason "length" is the real discriminator — treat any truncated + * free-tier reply as suspect regardless of budget. + * + * One claim of theirs did NOT reproduce here: they reported /v1/messages + * leaking on nano-omni at a small budget. Probed at max_tokens 20, 60 and 300 + * on both chains, /v1/messages came back clean 12 of 12, with proper + * thinking+text block separation and stop_reason "end_turn". Recording the + * disagreement rather than picking a winner — if it resurfaces, the block + * separation is the thing to check. + * + * Credit: the andy session, which re-measured across budgets, then retracted + * its own retraction with the three-way upstream probe, and the clawrouter + * session, which nearly shipped a textual extractor for a model + * that returns a clean structured tool_calls array once given room. + */ +// (defined in ./free-models.constants.ts, re-exported above) + +/** + * Context budget for ANY free-tier request, regardless of the catalogued + * window of the id we asked for. + * + * The strongest free model is catalogued at 1M, but the free tier is a pool: + * on chat/completions a request for it can be served by the 131K + * nemotron-3-nano-30b with no warning, and the chain-safe default is 256K. + * Sizing compaction off 1M would build a prompt the substitute cannot accept, + * and it would fail mid-session on a user who never chose the substitute. + * 131_072 is the floor of every model in FREE_PREFERENCE_ORDER. The one free + * id below it (llama-3.2-11b-vision, 128_000) is vision-only and never routed + * as a text rung, so it is sized from its own catalog entry in tokens.ts. + */ +// (defined in ./free-models.constants.ts, re-exported above) + +/** + * Catalogued free ids Franklin will not route to, with the reason each was + * rejected in the 2026-08-30 probe. These are real, billable-at-$0 ids — they + * are excluded on behaviour, not availability. + */ +export const QUARANTINED_FREE_MODELS: readonly string[] = [ + // Nothing is quarantined right now. + // + // llama-3.2-11b-vision was here until the gateway's image_url fix deployed + // to Solana; it is now the free VISION model there (5/5 on a valid 64x64 + // PNG) and is reached through freeVisionModel(), not through the text + // chain. It stays out of FREE_PREFERENCE_ORDER because it is the weakest + // text model in the pool, which is a ranking, not a ban. + // + // Released, deliberately, though an earlier revision quarantined both — the + // evidence was a measurement artifact, see the TRUNCATION note above: + // nvidia/nemotron-3.5-lightning — the "CoT leak" was max_tokens truncation. + // It is merely slow, which keeps it out of FREE_PREFERENCE_ORDER but is + // no reason to refuse to route to it if a user asks. + // cohere/north-mini-code — the "empty stream" was the same truncation, and + // it is a cascade rung other BlockRun components depend on. +]; + +/** + * Free ids Franklin used to name. All are gone from /api/v1/models; the + * gateway answers for most of them by substitution. Kept DELIBERATELY: this + * list is what lets isFreeModelId() report an old session's model as $0 and + * keep a stale pin on the free side of every paid-approval gate. It is never + * routed to — FREE_PREFERENCE_ORDER is the routing list. + */ +export const LEGACY_FREE_MODEL_IDS: readonly string[] = [ + // Delisted 2026-09-08 (NVIDIA deprovisioned it; gateway marks it + // unavailable and redirects to nano-omni). Led the free chain until then. + 'nvidia/nemotron-3-nano-30b', + 'nvidia/nemotron-nano-9b-v2', + 'nvidia/mistral-nemotron', + 'nvidia/nemotron-nano-12b-v2-vl', + 'nvidia/step-3.7-flash', + 'nvidia/nemotron-3-super-120b', + 'nvidia/nemotron-3-super-120b-a12b-free', +]; + +/** + * Every free id (billing_mode "free") in the live catalog as of 2026-10-20 — + * identical on Base and Solana — mapped to its catalogued context window. + * Used for metadata display and as the cold-cache answer to isFreeModelId(); + * NOT for sizing a request — use FREE_POOL_CONTEXT_FLOOR for that. + */ +export const FREE_MODEL_CONTEXT_WINDOWS: Readonly> = { + 'nvidia/nemotron-3-ultra-550b': 1_000_000, + 'nvidia/nemotron-3.5-lightning': 1_000_000, + 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning': 256_000, + 'cohere/north-mini-code': 256_000, + 'poolside/laguna-xs-2.1': 131_072, + 'nvidia/muse-glimmer-30b': 131_072, + 'nvidia/llama-3.2-11b-vision': 128_000, +}; + +/** + * The free model that can accept an image, or null when this chain has none. + * + * Chain-aware because the gateways are at different deploy states, not + * because the models differ. The original defect was gateway-side: both + * NVIDIA paths dropped every `image_url` unconditionally under a stale + * comment, which is why it presented as a confident wrong answer with no + * error. The allow-list fix reached Solana first. + * + * FOUR measurement passes, each of which reversed the previous conclusion. + * The history stays because the reversals are the lesson: + * + * 1. 2x2 PNG, both ids fail. INVALID — images under 10px per side + * are rejected upstream, so the fixture proved nothing. + * 2. 64x64, 5 calls, llama 5/5 sol. Shipped. Too small a sample. + * 3. 64x64, 10 calls, llama 5/10. Reverted. A real bad window: the + * gateway fix was still settling. Fixture validated, so not a repeat + * of mistake 1. + * 4. 64x64, 30 calls across both RE-ENABLED. See below. + * HTTP paths and three runs. + * + * Pass 4, validated fixture (`sips` decodes it, and every PNG chunk CRC + * checks against zlib.crc32 — `file` reports dimensions from the header and + * will happily accept a corrupt image, which is how a broken fixture + * masquerades as a broken product): + * + * sol llama-3.2-11b-vision /v1/messages 10/10 sees, 0/10 substituted + * sol llama-3.2-11b-vision /chat/completions 10/10 sees, 0/10 substituted + * sol llama-3.2-11b-vision /v1/messages (repeat) 10/10 sees, 0/10 substituted + * base llama-3.2-11b-vision /v1/messages 0/10 sees, 0/10 substituted + * sol nemotron-3-nano-omni /v1/messages 6/10 sees, 0/10 substituted + * sol nemotron-3-nano-omni /chat/completions 4/10 sees, 7/10 SUBSTITUTED + * + * Re-probed 2026-08-31 after the gateway's vision-routing fix: nano-omni now + * returns an explicit 400 on image input on /v1/messages ("served text-only on + * NVIDIA"), refused BEFORE payment. The silent-drop mode is gone on our path — + * a loud refusal is the outcome this module wanted all along. llama-3.2-11b-vision + * re-verified 6/6 in the same window, so the bare-call number still holds. + * + * So Solana gets llama-3.2-11b-vision and Base honestly reports none. On + * Base the model serves itself and simply cannot see — 0 substitutions, so + * this is the missing allow-list fix, not a cascade problem. + * + * nano-omni is NEVER used for vision despite its catalog tag. On + * chat/completions it is substituted 7 times in 10 by nemotron-3-nano-30b, + * a model with no vision at all (the andy session captured the same thing + * 3/3), and even unsubstituted it only sees the image 6 times in 10. + * + * ACCEPTANCE BAR for changing any of this — all four are required, and each + * one exists because skipping it produced a wrong answer above: + * - a fixture >=64x64 that a real decoder accepts and whose CRCs verify; + * - at least 10 calls (5 is inside the noise); + * - assert the served `model` echoes the id you requested; + * - assert the answer is actually right, not merely non-empty. + * + * AND YET THIS RETURNS NULL. Pass 5 measured the thing that actually + * matters — the whole feature, end to end through the binary, not the API + * call — and it is 1 in 5: + * + * bare vision call, validated fixture 30/30 + * end-to-end `franklin --model free` 1/5 + * + * llama-3.2-11b-vision is reliable as a vision CALL and too weak to drive an + * agent turn. Given the full tool set it re-called `Read` on the same path + * until the repeat guard fired. Given no tools after the image was in + * history (the one-shot path below, which is implemented and stays) it + * stopped looping and instead ended the turn after `Read` without producing + * an answer at all. So the blocker is no longer the gateway — it is + * Franklin's own wiring, which hands a vision turn to a weak model by + * swapping the AGENT model for the whole turn. + * + * A capability that works 30/30 in isolation and 1/5 in the product is not a + * capability. By the rule this file already applies to nano-omni — an + * intermittent confident wrong answer is worse than declining, because the + * caller cannot tell which half they got — Franklin declines and says the + * image was not seen. + * + * THE FIX, for whoever picks this up: do not swap the agent model. Make the + * vision turn a dedicated one-shot side-call — inline the image, ask the + * vision model directly, feed the answer back as context, and let the normal + * agent model finish the turn. The 30/30 says that call will work. (In + * Franklin the groundwork — one-shot tool suppression in agent/loop.ts — is in + * place; this fork has not ported it, so an override here gets the plain + * model swap.) + * + * FRANKLIN_FREE_VISION_MODEL overrides ('none' disables), so the whole path + * above can be exercised without a code change. + */ +export function freeVisionModel(): string | null { + const override = process.env.FRANKLIN_FREE_VISION_MODEL; + if (!override || override === 'none') return null; + // The override is NOT trusted blindly. Every caller treats this return value + // as free — router/vision.ts calls it vision-capable, the agent loop swaps a + // free-tier user onto it and prints "(still free)", the proxy does the same, + // and the router reports savings: 1.0. Handing back an unvalidated id would + // let this env var route a free-tier turn onto a PAID model while telling the + // user it costs nothing, which is the invariant this module exists to hold. + // Unknown-on-a-cold-cache fails CLOSED (decline vision) rather than open. + return isFreeModelId(override) ? override : null; +} + + +/** + * Is this id billed at $0? + * + * Replaces the `m.startsWith('nvidia/')` heuristic that was spread across the + * subagent tool, the /model command and the compaction picker. That heuristic + * was wrong in both directions once the pool rotated: + * + * - nvidia/kimi-k2.5 is a PAID model ($0.55/$2.50 per 1M). Treating it as + * free meant `/model kimi-k2.5` skipped the "charges from your wallet" + * warning, and a free-tier parent could hand compaction a paid model. + * - poolside/laguna-xs-2.1 and cohere/north-mini-code are free and carry no + * `nvidia/` prefix, so they were treated as paid. + * + * The live gateway catalog is consulted first (it is authoritative and picks + * up new free ids without a release); the static sets answer when the cache + * is cold. (Ported from Franklin, which also consults its bundled + * @blockrun/model-catalog snapshot; this fork has no such dependency.) + */ +export function isFreeModelId(id: string | undefined | null): boolean { + // ORDER MATTERS. `!id` is true for '' as well as null/undefined, so the + // empty-string check has to come first or it is unreachable — which is + // exactly the bug this ordering fixes. '' means "no parent model registered + // yet", and tools/subagent.ts reads that through this predicate to decide + // whether a PAID sub-agent spawn needs the user's approval. Returning false + // for '' silently skipped that gate and let a paid spawn charge the wallet + // with no prompt and no fail-closed path. + if (id === '' || id === 'blockrun/free') return true; + if (!id) return false; + const entry = peekGatewayModel(id); + if (entry) return entry.billing_mode === 'free'; + if (id in FREE_MODEL_CONTEXT_WINDOWS) return true; + return LEGACY_FREE_MODEL_IDS.includes(id); +} + +/** + * The free chain to try right now, best first, filtered to what the CURRENT + * chain's gateway actually lists. + * + * peekGatewayModel reads the cached catalog and never triggers a fetch, so + * this is safe on the hot path and in sync callers. When the cache is cold + * there is no evidence either way, and the answer is the one id that exists + * on both chains — guessing the Base-only model there would 400 every Solana + * user's first turn, which is the exact failure this function exists to + * prevent. + * + * `exclude` drops rungs that already failed this turn. + */ +export function freeChain(exclude: ReadonlySet = new Set()): string[] { + const candidates = FREE_PREFERENCE_ORDER.filter(id => !exclude.has(id)); + const confirmed = candidates.filter( + id => peekGatewayModel(id)?.billing_mode === 'free', + ); + if (confirmed.length > 0) return confirmed; + // No catalog evidence — cold cache (every turn-1 decision, and the whole + // session if the fetch fails, since gateway-models swallows that error). + // + // Prefer the id that survived on both gateways. But if the caller has + // already excluded it, fall through to the remaining candidates instead of + // returning nothing: an earlier revision returned [] here, which collapsed + // the free recovery chain from three rungs to ONE precisely when it was + // needed — a free user got a single rescue attempt on a 402 or rate limit + // and then the turn died. There is no evidence against the other rungs + // either; asking and being told "unknown model" beats not asking. + const preferred = candidates.filter(id => id === FREE_DEFAULT_MODEL); + return preferred.length > 0 ? preferred : candidates; +} + +/** + * Pick the single free model to request right now. See freeChain(). + * + * Returns undefined when every candidate is excluded — it must NEVER hand back + * a model the caller listed in `exclude`. The previous `?? FREE_DEFAULT_MODEL` + * fallback did exactly that on a cold cache, which would spin a retry loop on + * a model the caller had just watched fail. + */ +export function resolveFreeModel(exclude: ReadonlySet = new Set()): string | undefined { + return freeChain(exclude)[0]; +} diff --git a/src/learnings/extractor.ts b/src/learnings/extractor.ts index 68b67ba..a2ba691 100644 --- a/src/learnings/extractor.ts +++ b/src/learnings/extractor.ts @@ -11,12 +11,13 @@ import type { Dialogue, ContentPart } from '../agent/types.js'; import type { ExtractionResult, LearningCategory } from './types.js'; import { loadLearnings, mergeLearning, saveLearnings, loadSkills, saveSkill } from './store.js'; import type { Skill } from './types.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; // Free models for learning extraction — JSON extraction is simple enough. // Ordered by reliability: try the best free model first, fall back to others. const EXTRACTION_MODELS = [ - 'nvidia/nemotron-nano-9b-v2', // Serves itself on every path (verified live 2026-08-12) - 'nvidia/mistral-nemotron', // Fallback: DEGRADED upstream but non-stream calls ride the gateway fallback + FREE_DEFAULT_MODEL, // Free lead — see src/free-models.ts + 'poolside/laguna-xs-2.1', // Different provider, so the fallback survives an NVIDIA-wide outage ]; const VALID_CATEGORIES = new Set([ diff --git a/src/plugin-sdk/workflow.ts b/src/plugin-sdk/workflow.ts index 3c13574..4dd7277 100644 --- a/src/plugin-sdk/workflow.ts +++ b/src/plugin-sdk/workflow.ts @@ -7,6 +7,9 @@ import type { SearchResult } from './search.js'; import type { ChannelMessage } from './channel.js'; +// Zero-import leaf: naming the free default must not drag core's config +// module (and its module-load filesystem reads) into the public SDK. +import { FREE_DEFAULT_MODEL } from '../free-models.constants.js'; // ─── Model Tiers ────────────────────────────────────────────────────────── @@ -15,14 +18,14 @@ export type ModelTier = 'free' | 'cheap' | 'premium' | 'none'; /** Maps tier names to actual model identifiers */ export interface ModelTierConfig { - free: string; // e.g. "nvidia/nemotron-nano-9b-v2" + free: string; // resolved from FREE_DEFAULT_MODEL in src/free-models.ts cheap: string; // e.g. "zai/glm-5.1" premium: string; // e.g. "anthropic/claude-sonnet-5" } export const DEFAULT_MODEL_TIERS: ModelTierConfig = { - free: 'nvidia/nemotron-nano-9b-v2', - cheap: 'nvidia/nemotron-nano-9b-v2', // Free by default; opt-in to paid flat-rate via 'zai/glm-5.1'. + free: FREE_DEFAULT_MODEL, + cheap: FREE_DEFAULT_MODEL, // Free by default; opt-in to paid flat-rate via 'zai/glm-5.1'. premium: 'anthropic/claude-sonnet-5', }; diff --git a/src/pricing.ts b/src/pricing.ts index 3915f00..ab05c76 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -10,7 +10,7 @@ export const MODEL_PRICING: Record !ROUTING_PROFILES.has(m)); + // Free start model → free-only chain. Ends the walk rather than reaching for + // a paid rung; the caller gets the failure instead of a surprise charge. + if (isFreeModelId(startModel)) { + return [startModel, ...safeChain.filter(m => m !== startModel && isFreeModelId(m))]; + } + const index = safeChain.indexOf(startModel); if (index >= 0) { return safeChain.slice(index); diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 3f3bb19..9ab9a78 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -33,6 +33,7 @@ import { type RoutingProfile, } from '../router/index.js'; import { classifyAgentError } from '../agent/error-classifier.js'; +import { freeVisionModel, isFreeModelId } from '../free-models.js'; import { estimateCost } from '../pricing.js'; import { getMaxOutputTokens } from '../agent/optimize.js'; import { VERSION } from '../config.js'; @@ -352,7 +353,7 @@ export function createProxy(options: ProxyOptions): http.Server { // Model override logic: // - Native Anthropic-format IDs (e.g. "claude-sonnet-4-6-20250514") // don't contain "/" — these MUST be replaced with currentModel. - // - BlockRun model IDs always contain "/" (e.g. "blockrun/auto", "nvidia/mistral-nemotron") + // - BlockRun model IDs always contain "/" (e.g. "blockrun/auto", "anthropic/claude-sonnet-5") // — these should be passed through as-is. // - If --model CLI flag is set, always override regardless. if (options.modelOverride) { @@ -449,12 +450,21 @@ export function createProxy(options: ProxyOptions): http.Server { // sending the image would tokenize as base64 text and produce // a hallucinated answer. Same swap policy as the agent loop's // interactive path so behavior is consistent across surfaces. + // + // Exception: a FREE model is never swapped for a paid one. There + // is no free vision model (router/vision.ts), so the sibling + // lookup returns the paid default — swapping would start + // charging a user who explicitly chose the free tier. const original = requestModel; - const visionSwap = pickVisionSibling(original); + const visionSwap = isFreeModelId(original) + ? (freeVisionModel() ?? original) + : pickVisionSibling(original); parsed.model = visionSwap; requestModel = visionSwap; logger.warn( - `[franklin] 👁️ Vision swap: ${original} can't see images → ${visionSwap}` + visionSwap === original + ? `[franklin] 👁️ ${original} can't see images and no free vision model is available — answering from text only` + : `[franklin] 👁️ Vision swap: ${original} can't see images → ${visionSwap}` ); } diff --git a/src/router/index.ts b/src/router/index.ts index 19f5ad0..dc453a3 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -26,6 +26,12 @@ import { selectModel } from './selector.js'; import type { LearnedWeights } from './selector.js'; import { computeLocalElo, blendElo } from './local-elo.js'; import { isVisionModel, pickVisionSibling } from './vision.js'; +import { + FREE_DEFAULT_MODEL, + QUARANTINED_FREE_MODELS, + freeChain, + freeVisionModel, +} from '../free-models.js'; export { isVisionModel, messageNeedsVision, messagesNeedVision, pickVisionSibling } from './vision.js'; @@ -121,7 +127,7 @@ const SHARED_MODEL_PRICING = new Map( // route to, because the free pool answers for them with a substitute // (the response's `model` field names a different model) and leaks // thinking prose into content. Same "never promise a model the user -// doesn't get" rule the picker applies; see FREE_MODELS_BY_CATEGORY. +// doesn't get" rule the picker applies; see src/free-models.ts. // 3. Runtime observations — markModelUnavailable() is called by the agent // loop when the gateway rejects a routed model id (400 "Unknown model", // 404, 410 — see AgentErrorInfo.modelUnavailable). Process-scoped: the @@ -137,10 +143,13 @@ const KNOWN_UNAVAILABLE_MODELS: readonly string[] = [ ]; const QUARANTINED_MODELS: readonly string[] = [ - // Catalogued free id; live probe 2026-08-29 answered as - // nvidia/nemotron-3-super-120b with the reasoning trace duplicated into - // `content`. The core uses it as the free backstop rung of every Auto - // chain — Franklin's chains end one rung earlier instead. + // Catalogued free ids Franklin refuses to route to. The list and the per-id + // evidence live in src/free-models.ts so the picker, the router and the + // chains all quarantine the same set. + ...QUARANTINED_FREE_MODELS, + // Substituted out of the catalog entirely on 2026-08-30 (a 2026-08-29 probe + // answered as nemotron-3-super-120b with the reasoning trace in `content`); + // the core still used it as the free backstop rung of every Auto chain. 'nvidia/step-3.7-flash', ]; @@ -536,8 +545,14 @@ function classicRouteRequest( // nemotron-3-nano-omni is the replacement: live-probed on this exact prompt // shape it returns "MEDIUM" and nothing else, and it verifiably serves itself // rather than riding a pooled substitute. -const CLASSIFIER_MODEL = process.env.FRANKLIN_ROUTER_MODEL || 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning'; -const CLASSIFIER_TIMEOUT_MS = 2_500; +// +// 2026-08-30 (ported from Franklin): re-probed at max_tokens 8, every free id +// now reasons out loud before answering, so the verdict never arrived inside +// the budget. The classifier now follows FREE_DEFAULT_MODEL, gets a budget +// the verdict can arrive in, and reads the LAST tier word on the last line. +const CLASSIFIER_MODEL = process.env.FRANKLIN_ROUTER_MODEL || FREE_DEFAULT_MODEL; +const CLASSIFIER_TIMEOUT_MS = 8_000; +const CLASSIFIER_MAX_TOKENS = 1_024; const CLASSIFIER_SYSTEM = `You classify a user's message into ONE routing tier for a CLI agent. Reply with EXACTLY ONE WORD from the allowed set. No explanation, no punctuation, no quotes. @@ -558,8 +573,29 @@ export type TierClassifier = (prompt: string) => Promise; * the caller can fall back to keyword classification. */ function parseTierWord(reply: string): Tier | null { - const m = reply.trim().toUpperCase().match(/\b(SIMPLE|MEDIUM|COMPLEX|REASONING)\b/); - return m ? (m[1] as Tier) : null; + const text = reply.trim().toUpperCase(); + if (!text) return null; + const TIER = /\b(SIMPLE|MEDIUM|COMPLEX|REASONING)\b/g; + + // Preferred: the reply is (or ends on) a bare tier word. A model that + // followed the instruction lands here. + const lastLine = text.split(/\n+/).filter(Boolean).pop() ?? ''; + const bare = lastLine.match(/^[^A-Z]*\b(SIMPLE|MEDIUM|COMPLEX|REASONING)\b[^A-Z]*$/); + if (bare) return bare[1] as Tier; + + // Otherwise take the last tier word ON THE LAST LINE, not on the whole blob. + // Whole-blob last-match was wrong in a way that costs money: CLASSIFIER_SYSTEM + // itself ends "SIMPLE or MEDIUM or COMPLEX or REASONING", so any reply that + // restates its instructions — the documented failure this parser exists to + // survive — matched REASONING, the most expensive tier. The classified text + // is also untrusted user content, so a prompt ending in the word REASONING + // could push its own tier up. Failing toward the cheap end is the safe + // direction; returning null (keyword routing) is safer still. + const onLastLine = lastLine.match(TIER); + if (onLastLine && onLastLine.length > 0) { + return onLastLine[onLastLine.length - 1] as Tier; + } + return null; } /** @@ -597,7 +633,7 @@ export async function llmClassifyRequest(prompt: string): Promise { system: CLASSIFIER_SYSTEM, messages: [{ role: 'user', content: prompt.slice(0, 2000) }], tools: [], - max_tokens: 8, + max_tokens: CLASSIFIER_MAX_TOKENS, }, ctrl.signal, ); @@ -676,15 +712,21 @@ export function resolveTierToModel( needsVision = false, ): RoutingResult { // Free profile short-circuits — everything routes to a single free model. - // Vision turns go to nemotron-nano-12b-v2-vl: the one vision-capable free - // model that verifiably serves itself (the 30B omni line is pooled onto a - // text-only substitute upstream, verified live 2026-08-12). + // + // Vision turns go to the chain's free vision model when it has one. Where + // it doesn't, they get the TEXT model plus a `free-vision-unavailable` + // signal that callers surface — the free profile never falls back to a paid + // model. See freeVisionModel() for the per-chain measurements. + // (nemotron-nano-12b-v2-vl, the old free vision id, left the catalog.) if (profile === 'free') { + const freeVision = needsVision ? freeVisionModel() : null; return { - model: needsVision ? 'nvidia/nemotron-nano-12b-v2-vl' : 'nvidia/nemotron-nano-9b-v2', + model: freeVision ?? liveFreeChain()[0] ?? FREE_DEFAULT_MODEL, tier: 'SIMPLE', confidence: 1.0, - signals: needsVision ? ['free-profile', 'free-vision'] : ['free-profile'], + signals: needsVision + ? ['free-profile', freeVision ? 'free-vision' : 'free-vision-unavailable'] + : ['free-profile'], savings: 1.0, }; } @@ -717,16 +759,19 @@ export function routeRequest( ): RoutingResult { const normalizedContext = normalizeRoutingContext(context); - // Free profile — always use free model (vision turns get the free VL model; - // see resolveTierToModel for the rationale). + // Free profile — always use a free model. Vision turns go to the chain's + // free vision model where one exists (see resolveTierToModel). if (profile === 'free') { + const freeVision = normalizedContext.needsVision ? freeVisionModel() : null; return { - model: normalizedContext.needsVision ? 'nvidia/nemotron-nano-12b-v2-vl' : 'nvidia/nemotron-nano-9b-v2', + model: freeVision ?? liveFreeChain()[0] ?? FREE_DEFAULT_MODEL, tier: 'SIMPLE', confidence: 1.0, - signals: normalizedContext.needsVision ? ['free-profile', 'free-vision'] : ['free-profile'], + signals: normalizedContext.needsVision + ? ['free-profile', freeVision ? 'free-vision' : 'free-vision-unavailable'] + : ['free-profile'], savings: 1.0, - candidates: FREE_MODELS_BY_CATEGORY.chat, + candidates: freeModelsForCategory(), }; } @@ -874,7 +919,7 @@ export function getFallbackChain( tier: Tier, profile: RoutingProfile = 'auto' ): string[] { - if (profile === 'free') return FREE_MODELS_BY_CATEGORY.chat; + if (profile === 'free') return freeModelsForCategory(); const config = AUTO_TIERS[tier]; return [config.primary, ...config.fallback]; } @@ -885,58 +930,49 @@ export function getFallbackChain( // mid-turn (402 payment, rate-limit) and we need a zero-cost replacement // to keep the user moving without waiting for funding. // -// The lists are ordered: best-fit free model first, then degraded fallbacks. -// llama-4-maverick leads every category — it's the only reliably-healthy free -// model and covers chat / coding / reasoning. deepseek-v4-flash (1M ctx) is the -// secondary; it occasionally times out on the NVIDIA NIM upstream, so it sits -// behind maverick rather than leading. -// 2026-06-07: nvidia/glm-4.7 dropped from every chain — NVIDIA NIM hung, the -// gateway redirected it to a now-dead model, so routing to it just wasted a slot. -// 2026-07-11: nvidia/deepseek-v4-flash removed — the gateway no longer serves -// it (410). Free tier is now led by nvidia/qwen3-next-80b-a3b-instruct (cleanest -// free instruction-follower — no thinking leak / markdown fences, verified live). -// 2026-07-14: nvidia/llama-4-maverick replaced by nvidia/mistral-nemotron as the -// different-family fallback. Maverick left the gateway catalog, and the free -// pool silently substitutes for it — a live probe showed both maverick and -// qwen3-next answering as `nvidia/nemotron-3-super-120b-a12b-free`. Two chain -// entries backed by the same pooled model is fake resilience: the fallback -// couldn't rescue a turn the primary had already failed. mistral-nemotron -// verifiably serves itself, so the chain is genuinely two families again. -// 2026-08-12: nvidia/qwen3-next-80b-a3b-instruct removed — NVIDIA EOL'd it -// (410 on 2026-07-27; the gateway hid it and rides its calls on a fallback). -// nemotron-nano-9b-v2 promoted to lead: it is the only free text model that -// verifiably serves ITSELF on the streaming path Franklin uses (verified live -// through the binary today). mistral-nemotron is DEGRADED at NVIDIA — stream -// calls 400 ("DEGRADED function cannot be invoked") and non-stream calls ride -// the gateway's disclosed fallback. -// 2026-08-19: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning promoted to -// second. When it was last probed it came back served as gpt-oss-120b — the -// pooled-substitute trap this comment warns about — but NVIDIA has since -// fixed it: it now answers as ITSELF on both the streaming and non-streaming -// paths, and it returns clean single-word output under tight max_tokens (it -// is also the router's classifier for that reason). mistral-nemotron drops to -// third: still DEGRADED at NVIDIA, kept as a genuinely different family for -// the case where both Nemotron nano builds are down. The catalog's fifth free -// id, step-3.7-flash, stays out of every chain — it leaks thinking prose into -// content AND comes back served by nemotron-3-super-120b (re-probed -// 2026-08-29, unchanged). It is also QUARANTINED for the shared Router above, -// which would otherwise use it as the free backstop rung of every Auto chain. -const OMNI = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning'; - -const FREE_MODELS_BY_CATEGORY: Record = { - coding: ['nvidia/nemotron-nano-9b-v2', OMNI, 'nvidia/mistral-nemotron'], - trading: ['nvidia/nemotron-nano-9b-v2', OMNI, 'nvidia/mistral-nemotron'], - research: ['nvidia/nemotron-nano-9b-v2', OMNI, 'nvidia/mistral-nemotron'], - reasoning: ['nvidia/nemotron-nano-9b-v2', OMNI, 'nvidia/mistral-nemotron'], - chat: ['nvidia/nemotron-nano-9b-v2', OMNI, 'nvidia/mistral-nemotron'], - creative: ['nvidia/nemotron-nano-9b-v2', OMNI, 'nvidia/mistral-nemotron'], -}; +// The ids, the ordering and the evidence behind both live in +// src/free-models.ts — this file only maps categories onto that chain. The +// chain is deliberately short and identical across categories: the 2026-08-30 +// probe established that the free pool collapses every NVIDIA id onto one +// backing model under load, so per-category NVIDIA rungs were three names for +// the same upstream — fake resilience that could never rescue a turn the +// first rung had already failed. The one real axis of resilience left is +// provider, which is why the last rung is poolside rather than NVIDIA. +// +// Rotation history (kept short on purpose — the per-id post-mortems moved to +// free-models.ts): glm-4.7 dropped 2026-06-07, deepseek-v4-flash 2026-07-11, +// llama-4-maverick 2026-07-14, qwen3-next 2026-08-12, and on 2026-08-30 the +// gateway rotated the entire pool at once. + +// Every category shares one chain. The ids, the ordering and the evidence +// live in src/free-models.ts; freeChain() filters them against the live +// catalog for the CURRENT chain, because the Solana gateway lists only one of +// them and 400s on the rest. It is a function, not a constant, for that +// reason — a frozen array captured at module load would pin whichever answer +// was true before the catalog cache warmed. +function freeModelsForCategory(): string[] { + return liveFreeChain(); +} -const DEFAULT_FREE_CHAIN: string[] = [ - 'nvidia/nemotron-nano-9b-v2', - OMNI, - 'nvidia/mistral-nemotron', -]; +/** + * freeChain() filtered by the runtime kill-switch. + * + * freeChain lives in free-models.ts, which cannot import this module (the + * dependency runs the other way), so it knows nothing about the ids + * markModelUnavailable() has observed the gateway REJECT with 400 / 404 / 410. + * Without this filter a dead free rung was re-selected on every turn — the + * per-turn `alreadyFailed` set is cleared at the top of each turn, so the + * kill-switch had no effect on the free profile at all. Given this branch + * exists because the free pool rotates ids without notice, that is the exact + * failure mode it has to survive. + */ +function liveFreeChain(alreadyFailed: ReadonlySet = new Set()): string[] { + const live = freeChain(alreadyFailed).filter(m => !isModelUnavailable(m)); + // Every rung observed dead: fall back to the unfiltered chain rather than + // refusing to route. A stale kill-switch entry must not strand a free user + // with no model at all. + return live.length > 0 ? live : freeChain(alreadyFailed); +} /** * Pick the next free model to try given the question category and which @@ -944,12 +980,15 @@ const DEFAULT_FREE_CHAIN: string[] = [ * candidate has been exhausted (caller should surface an error to user). */ export function pickFreeFallback( + /** @deprecated Ignored — the free chain no longer varies by category. */ category: string, alreadyFailed: Set ): string | undefined { - const chain = (FREE_MODELS_BY_CATEGORY as Record)[category] - ?? DEFAULT_FREE_CHAIN; - return chain.find(m => !alreadyFailed.has(m)); + // Category is accepted for API compatibility; the chain no longer varies by + // it (see freeModelsForCategory). Marked deprecated so a new caller doesn't + // assume passing one changes the answer. + void category; + return liveFreeChain(alreadyFailed)[0]; } /** diff --git a/src/router/vision.ts b/src/router/vision.ts index e604b0a..ef3f6ac 100644 --- a/src/router/vision.ts +++ b/src/router/vision.ts @@ -19,6 +19,8 @@ * `Image file: ` description string. Expensive AND wrong. */ +import { freeVisionModel } from '../free-models.js'; + const VISION_MODELS = new Set([ // Anthropic — native vision across the line 'anthropic/claude-fable-5', @@ -95,21 +97,44 @@ const VISION_MODELS = new Set([ // Z.AI — GLM-5.3 Flash is the one GLM SKU the catalog tags as vision // (2026-08-29 sync); GLM-5.3 / 5.2 / 5.1 are text + reasoning only. 'zai/glm-5.3-flash', - // NVIDIA inference — Nemotron Nano VL is multimodal; deepseek/qwen-coder are - // not. Llama 4 Maverick dropped 2026-07-14: it left the gateway catalog, and - // listing it here contradicted routeRequest()'s own "maverick is text-only" - // note — the free profile would route a vision turn to a text-only model. - 'nvidia/nemotron-nano-12b-v2-vl', - // Nemotron 3 Nano Omni accepts text, images, video and audio, and now serves - // itself (2026-08-19 probe) — it is the strongest free vision option in the - // catalog on ChartQA / DocVQA / MMMU. - 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning', + // ── The free vision model is NOT here, on purpose ── + // isVisionModel() consults freeVisionModel() instead of this static set, + // because that answer depends on gateway deploy state and an env override + // rather than on a fixed capability. See the note below and free-models.ts. + // + // ── Why no free vision id is listed (2026-08-31) ── + // Listing an id here is a promise isVisionModel() callers act on: the agent + // loop and the proxy both use it to decide whether an image can be sent at + // all. Neither free vision id earns that promise, for two DIFFERENT reasons: + // + // nemotron-3-nano-omni — the gateway now REFUSES image input for this + // id on /v1/messages with an explicit 400 and no + // charge ("served text-only on NVIDIA"). Before + // that it silently saw the image 6/10 and was + // substituted 7/10 on /chat/completions by a + // model with no vision at all. The loud refusal + // is the better failure and needs no guard here. + // llama-3.2-11b-vision — reads the image reliably as a bare call + // (30/30) but only 1/5 end-to-end through the + // agent loop, which is Franklin's own wiring + // problem, not the model's. + // + // Full measurement history and the acceptance bar for re-adding either: + // freeVisionModel() in src/free-models.ts. + // + // nvidia/nemotron-nano-12b-v2-vl held this slot until it left the catalog on + // 2026-08-30; Llama 4 Maverick before that (dropped 2026-07-14). ]); /** Does this concrete gateway model accept image input? */ export function isVisionModel(modelId: string | undefined | null): boolean { if (!modelId) return false; - return VISION_MODELS.has(modelId); + if (VISION_MODELS.has(modelId)) return true; + // The free tier's vision model is resolved at runtime, not fixed: it depends + // on gateway deploy state and on FRANKLIN_FREE_VISION_MODEL. freeVisionModel() + // returns null when no free model can be trusted with an image, and this + // stays false there. + return modelId === freeVisionModel(); } /** Lower-cased copy used for prefix family matching in pickVisionSibling. */ diff --git a/src/tools/moa.ts b/src/tools/moa.ts index 2d029b6..0546ba4 100644 --- a/src/tools/moa.ts +++ b/src/tools/moa.ts @@ -13,19 +13,24 @@ import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js'; import { ModelClient } from '../agent/llm.js'; +import { FREE_DEFAULT_MODEL, freeChain } from '../free-models.js'; // ─── Configuration ──────────────────────────────────────────────────────── /** Reference models — diverse, cheap/free models for parallel queries. */ const REFERENCE_MODELS = [ - 'nvidia/nemotron-nano-9b-v2', // Free, serves itself on every path (qwen3-next EOL'd 2026-08-12) - 'nvidia/mistral-nemotron', // Free second voice (DEGRADED upstream; non-stream rides gateway fallback) + // Free voices come from the live chain rather than literals (ported from + // Franklin's src/free-models.ts): the two ids that used to sit here, + // nemotron-nano-9b-v2 and mistral-nemotron, left the gateway catalog in the + // 2026-08-30 pool rotation and were answered by a substitute. freeChain() + // also keeps the two voices on different PROVIDERS where the catalog allows. + ...freeChain().slice(0, 2), 'google/gemini-2.5-flash', // Fast, cheap 'deepseek/deepseek-chat', // Cheap, good reasoning ]; /** Aggregator model — free by default. Users explicitly pass `aggregator` to upgrade. */ -const AGGREGATOR_MODEL = 'nvidia/nemotron-nano-9b-v2'; +const AGGREGATOR_MODEL = FREE_DEFAULT_MODEL; /** Max tokens per reference response. */ const REFERENCE_MAX_TOKENS = 4096; diff --git a/src/tools/subagent.ts b/src/tools/subagent.ts index fc86829..9d2f586 100644 --- a/src/tools/subagent.ts +++ b/src/tools/subagent.ts @@ -3,6 +3,7 @@ */ import { ModelClient } from '../agent/llm.js'; +import { FREE_DEFAULT_MODEL, isFreeModelId } from '../free-models.js'; import { assembleInstructions } from '../agent/context.js'; import type { CapabilityHandler, @@ -26,10 +27,10 @@ interface SubAgentInput { model?: string; } -// Heuristic: which model IDs are free? -function isFreeModel(m: string): boolean { - return m.startsWith('nvidia/') || m === 'blockrun/free' || m === ''; -} +// Which model IDs are billed at $0? See src/free-models.ts — the old +// `startsWith('nvidia/')` heuristic mis-classified paid nvidia SKUs as free +// and the poolside/cohere free ids as paid. +const isFreeModel = isFreeModelId; async function execute(input: Record, ctx: ExecutionScope): Promise { const { prompt, description, model } = input as unknown as SubAgentInput; @@ -39,7 +40,7 @@ async function execute(input: Record, ctx: ExecutionScope): Pro } // Resolve which model the sub-agent will actually run on - const subModel = model || registeredParentModel || 'nvidia/nemotron-nano-9b-v2'; + const subModel = model || registeredParentModel || FREE_DEFAULT_MODEL; // Cost gate: if parent is free but sub-agent wants paid, ask user first. // Prevents silent charges when the agent decides to spawn a more capable sub-agent. diff --git a/src/ui/model-picker.ts b/src/ui/model-picker.ts index 08e0b39..ddc58ad 100644 --- a/src/ui/model-picker.ts +++ b/src/ui/model-picker.ts @@ -6,15 +6,11 @@ import readline from 'node:readline'; import chalk from 'chalk'; import { getGatewayModels, type GatewayModel } from '../gateway-models.js'; +import { FREE_DEFAULT_MODEL } from '../free-models.js'; -/** - * The free model every free alias resolves to — the same id Franklin's - * FREE_DEFAULT_MODEL names. Nemotron 3 Nano Omni is the free id that stayed - * on BOTH the Base and Solana gateways through every free-pool rotation, so a - * bare `free` does not 400 on either chain. nemotron-nano-9b-v2 / nemotron-nano-12b-v2-vl / mistral-nemotron, - * which held this slot before, are gone from /v1/models. - */ -export const FREE_DEFAULT_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning'; +// The free model every free alias resolves to now lives in src/free-models.ts +// (one constant, imported everywhere). Re-exported for existing importers. +export { FREE_DEFAULT_MODEL }; // ─── Model Shortcuts (same as proxy) ─────────────────────────────────────── diff --git a/test/local.mjs b/test/local.mjs index 4145d52..dad8f36 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -5393,16 +5393,17 @@ test('free routing profile stays free across router entry points', async () => { for (const prompt of prompts) { const routed = routeRequest(prompt, 'free'); - assert.equal(routed.model, 'nvidia/nemotron-nano-9b-v2', `routeRequest free drifted for prompt: ${prompt}`); + assert.equal(routed.model, 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning', `routeRequest free drifted for prompt: ${prompt}`); assert.equal(routed.tier, 'SIMPLE'); assert.deepEqual(routed.signals, ['free-profile']); } - // Vision turns on the free profile go to the free VL model — the only - // vision-capable free id that verifiably serves itself (2026-08-12). + // Vision turns on the free profile: no free model is trusted with an image + // (freeVisionModel() in src/free-models.ts), so the turn stays on the free + // TEXT model and says so — it never falls back to a paid vision model. const visionRouted = routeRequest('what is in this image?', 'free', true); - assert.equal(visionRouted.model, 'nvidia/nemotron-nano-12b-v2-vl'); - assert.deepEqual(visionRouted.signals, ['free-profile', 'free-vision']); + assert.equal(visionRouted.model, 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning'); + assert.deepEqual(visionRouted.signals, ['free-profile', 'free-vision-unavailable']); let classifierCalled = false; const asyncRouted = await routeRequestAsync('prove this theorem step by step', 'free', async () => { @@ -5410,7 +5411,7 @@ test('free routing profile stays free across router entry points', async () => { return 'REASONING'; }); assert.equal(classifierCalled, false, 'free profile should not spend a classifier call'); - assert.equal(asyncRouted.model, 'nvidia/nemotron-nano-9b-v2'); + assert.equal(asyncRouted.model, 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning'); assert.deepEqual(asyncRouted.signals, ['free-profile']); // Free chain expanded 2026-05-03: was a single-element chain that just @@ -5423,17 +5424,15 @@ test('free routing profile stays free across router entry points', async () => { // a paid model (a free/empty session silently charging the wallet). This set // membership is the guard; keep it all-free. const FREE_GATEWAY_MODELS = new Set([ - 'nvidia/mistral-nemotron', - 'nvidia/nemotron-nano-9b-v2', - 'nvidia/nemotron-nano-12b-v2-vl', - // Added 2026-08-19 with the chain promotion. Billed $0 by the gateway and - // live-probed serving itself; the guard's job is to keep a PAID id out of - // the free chain, not to freeze the roster. + // Refreshed 2026-10-20 with the free-models.ts port. The guard's job is to + // keep a PAID id out of the free chain, not to freeze the roster. 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning', + 'poolside/laguna-xs-2.1', + 'cohere/north-mini-code', ]); for (const tier of ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']) { const resolved = resolveTierToModel(tier, 'free'); - assert.equal(resolved.model, 'nvidia/nemotron-nano-9b-v2', `resolveTierToModel free drifted for ${tier}`); + assert.equal(resolved.model, 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning', `resolveTierToModel free drifted for ${tier}`); const chain = getFallbackChain(tier, 'free'); assert.ok(Array.isArray(chain) && chain.length > 0, `free fallback chain empty for ${tier}`); for (const m of chain) { @@ -7291,61 +7290,89 @@ test('enforceRetention is a no-op when audit log is small', async () => { // category. pickFreeFallback selects from per-category chains so trading / // research / chat get general-purpose free models first. -test('pickFreeFallback: coding category prefers nemotron-nano-9b-v2 first', async () => { - // Refreshed 2026-08-12: nemotron-nano-9b-v2 leads every category (the one - // free model that serves itself on the streaming path); mistral-nemotron is - // the secondary (DEGRADED upstream, non-stream rides the gateway fallback). - // qwen3-next-80b-a3b-instruct hit NVIDIA's EOL (410). +test('pickFreeFallback: every category starts on the free default', async () => { + // The free chain no longer varies by category (src/free-models.ts): the + // per-category NVIDIA rungs were names for one pooled upstream. On a cold + // gateway cache the chain leads with the id that exists on both chains. const { pickFreeFallback } = await import('../dist/router/index.js'); - const pick = pickFreeFallback('coding', new Set()); - assert.equal(pick, 'nvidia/nemotron-nano-9b-v2'); -}); - -test('pickFreeFallback: trading category skips coder, picks the general workhorse', async () => { - const { pickFreeFallback } = await import('../dist/router/index.js'); - const pick = pickFreeFallback('trading', new Set()); - assert.equal(pick, 'nvidia/nemotron-nano-9b-v2', 'trading should not start with a coder model'); - assert.notEqual(pick, 'nvidia/qwen3-coder-480b'); -}); - -test('pickFreeFallback: research / chat / creative also skip coder first', async () => { - const { pickFreeFallback } = await import('../dist/router/index.js'); - for (const cat of ['research', 'chat', 'creative', 'reasoning']) { + const { FREE_DEFAULT_MODEL } = await import('../dist/free-models.js'); + for (const cat of ['coding', 'trading', 'research', 'chat', 'creative', 'reasoning', '']) { const pick = pickFreeFallback(cat, new Set()); - assert.notEqual(pick, 'nvidia/qwen3-coder-480b', - `${cat} should not start with a coder model, got ${pick}`); + assert.equal(pick, FREE_DEFAULT_MODEL, `${cat || '(none)'} should start on the free default`); + assert.notEqual(pick, 'nvidia/qwen3-coder-480b'); } }); test('pickFreeFallback: respects alreadyFailed set', async () => { const { pickFreeFallback } = await import('../dist/router/index.js'); - // Coding starts with nemotron-nano-9b-v2. After it fails, next is the - // nano-omni secondary (2026-08-19 refresh — it started serving itself again, - // so it displaced the still-degraded mistral-nemotron, which slid to third). - const failed = new Set(['nvidia/nemotron-nano-9b-v2']); - const pick = pickFreeFallback('coding', failed); - assert.notEqual(pick, 'nvidia/nemotron-nano-9b-v2'); - assert.equal(pick, 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning', - `after nemotron-nano-9b-v2 fails, coding should fall to nano-omni, got ${pick}`); -}); - -test('pickFreeFallback: unknown category uses default chain (general model first)', async () => { - const { pickFreeFallback } = await import('../dist/router/index.js'); - const pick = pickFreeFallback('', new Set()); - // Default chain leads with a general-purpose model, not qwen3-coder. - assert.notEqual(pick, 'nvidia/qwen3-coder-480b'); - assert.ok(typeof pick === 'string' && pick.length > 0); + const { FREE_DEFAULT_MODEL } = await import('../dist/free-models.js'); + const { __primeGatewayModelsCache, clearGatewayModelsCache } = + await import('../dist/gateway-models.js'); + const freeEntry = (id) => ({ + id, name: id, billing_mode: 'free', categories: ['chat'], + pricing: { input: 0, output: 0 }, + }); + // try/finally: the gateway cache is a module singleton shared by every test. + try { + __primeGatewayModelsCache([ + freeEntry(FREE_DEFAULT_MODEL), + freeEntry('poolside/laguna-xs-2.1'), + freeEntry('cohere/north-mini-code'), + ]); + assert.equal(pickFreeFallback('coding', new Set()), FREE_DEFAULT_MODEL); + const pick = pickFreeFallback('coding', new Set([FREE_DEFAULT_MODEL])); + assert.equal(pick, 'poolside/laguna-xs-2.1', + `after the default fails, coding should fall to the next live rung, got ${pick}`); + + // Cold cache: the chain must NOT collapse to a single rung. + clearGatewayModelsCache(); + assert.equal(pickFreeFallback('coding', new Set()), FREE_DEFAULT_MODEL); + const coldSecond = pickFreeFallback('coding', new Set([FREE_DEFAULT_MODEL])); + assert.ok(coldSecond && coldSecond !== FREE_DEFAULT_MODEL, + `cold cache must still offer a second rung, got ${coldSecond}`); + } finally { + clearGatewayModelsCache(); + } }); test('pickFreeFallback: returns undefined when every candidate failed', async () => { const { pickFreeFallback } = await import('../dist/router/index.js'); - const failed = new Set([ - 'nvidia/mistral-nemotron', - 'nvidia/nemotron-nano-9b-v2', - 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning', - ]); - const pick = pickFreeFallback('trading', failed); - assert.equal(pick, undefined); + const { FREE_PREFERENCE_ORDER, resolveFreeModel } = await import('../dist/free-models.js'); + const everything = new Set(FREE_PREFERENCE_ORDER); + assert.equal(pickFreeFallback('trading', everything), undefined); + assert.equal(resolveFreeModel(everything), undefined, + 'resolveFreeModel must never return an excluded model'); +}); + +test('free-models port: no runtime default names a retired free id', async () => { + // The ids below left /api/v1/models in the 2026-08-30 pool rotation (or + // earlier) and the gateway answers for them with a substitute. Before the + // port they were hard-coded across ~15 call sites. + const { readFileSync, readdirSync, statSync } = await import('node:fs'); + const { join } = await import('node:path'); + const retired = ['nvidia/nemotron-nano-9b-v2', 'nvidia/nemotron-nano-12b-v2-vl', 'nvidia/mistral-nemotron', + 'nvidia/qwen3-coder-480b', 'nvidia/llama-4-maverick', 'nvidia/deepseek-v4-flash', 'nvidia/deepseek-v4-pro']; + // Files whose job is to remember retired ids (legacy pricing / windows / the + // retirement record itself). + const allowed = new Set(['pricing.ts', 'tokens.ts', 'free-models.ts']); + const root = new URL('../src/', import.meta.url).pathname; + const walk = (dir) => readdirSync(dir).flatMap((n) => { + const p = join(dir, n); + return statSync(p).isDirectory() ? walk(p) : p.endsWith('.ts') ? [p] : []; + }); + for (const file of walk(root)) { + if (allowed.has(file.split('/').pop())) continue; + const code = readFileSync(file, 'utf8').split('\n').filter((l) => !/^\s*(\/\/|\*)/.test(l)).join('\n'); + for (const id of retired) { + assert.ok(!code.includes(`'${id}'`), `${file} still names retired free id ${id} in code`); + } + } + const { FREE_PREFERENCE_ORDER, FREE_MODEL_CONTEXT_WINDOWS, isFreeModelId } = await import('../dist/free-models.js'); + for (const id of ['nvidia/deepseek-v4-flash', 'nvidia/deepseek-v4-pro']) { + assert.ok(!FREE_PREFERENCE_ORDER.includes(id)); + assert.ok(!(id in FREE_MODEL_CONTEXT_WINDOWS)); + assert.equal(isFreeModelId(id), false); + } }); // ── TradingSignal data sufficiency ──────────────────────────────────────── @@ -8874,10 +8901,15 @@ test('vision helpers: isVisionModel allowlist matches curated set', async () => 'xai/grok-4-0709', 'moonshot/kimi-k3', 'zai/glm-5.3-flash', - 'nvidia/nemotron-nano-12b-v2-vl', ]) { assert.equal(isVisionModel(m), true, `${m} should be vision-capable`); } + // No free id is a vision model by default (freeVisionModel() in + // src/free-models.ts): nano-12b-v2-vl left the catalog, and nano-omni is + // refused image input by the gateway on /v1/messages. + for (const m of ['nvidia/nemotron-nano-12b-v2-vl', 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning']) { + assert.equal(isVisionModel(m), false, `${m} must not be promised vision`); + } // Text-only — should all return false. Includes the failure modes the // user reported: deepseek family is entirely text-only; grok-4.1 fast @@ -9399,13 +9431,17 @@ test('router kill-switch: per-call unavailableModels context is honored', async test('router kill-switch: the free profile is not affected by the shared Router set', async () => { const { routeRequest, markModelUnavailable, resetUnavailableModels } = await import('../dist/router/index.js'); resetUnavailableModels(); + const { FREE_DEFAULT_MODEL, QUARANTINED_FREE_MODELS } = await import('../dist/free-models.js'); const r = routeRequest('hello', 'free'); - assert.equal(r.model, 'nvidia/nemotron-nano-9b-v2'); + assert.equal(r.model, FREE_DEFAULT_MODEL); assert.ok(!r.candidates.includes('nvidia/step-3.7-flash'), 'quarantined free id must not be in the free chain'); - markModelUnavailable('nvidia/nemotron-nano-9b-v2'); - // The free chain is Franklin's own (pickFreeFallback walks it with the - // per-turn failed set); the kill-switch only feeds the shared Router. - assert.equal(routeRequest('hello', 'free').model, 'nvidia/nemotron-nano-9b-v2'); + for (const q of QUARANTINED_FREE_MODELS) { + assert.ok(!r.candidates.includes(q), `quarantined free id ${q} must not be in the free chain`); + } + markModelUnavailable(FREE_DEFAULT_MODEL); + // A stale kill-switch entry must not strand a free user with no model: when + // every rung is marked dead the unfiltered chain is used. + assert.equal(routeRequest('hello', 'free').model, FREE_DEFAULT_MODEL); resetUnavailableModels(); });