Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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` });
Expand Down
7 changes: 4 additions & 3 deletions src/agent/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/agent/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────
//
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────
Expand Down
9 changes: 5 additions & 4 deletions src/agent/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<string, string> = {
'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;
}

/**
Expand Down
71 changes: 58 additions & 13 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ──
Expand All @@ -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
Expand Down Expand Up @@ -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++;
Expand Down
14 changes: 11 additions & 3 deletions src/agent/media-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
maxDurationSeconds,
type GatewayModel,
} from '../gateway-models.js';
import { FREE_DEFAULT_MODEL } from '../free-models.js';

// ─── Types ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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;

Expand Down
27 changes: 22 additions & 5 deletions src/agent/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -314,17 +315,33 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
'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
Expand Down
19 changes: 13 additions & 6 deletions src/agent/turn-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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;

Expand All @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion src/agent/verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────

Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/brain/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<EntityType>(['person', 'project', 'company', 'product', 'concept']);
Expand Down
3 changes: 2 additions & 1 deletion src/commands/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,7 +60,7 @@ export async function telegramCommand(opts: TelegramCommandOptions): Promise<voi
const model =
opts.model ||
config['default-model'] ||
'nvidia/nemotron-nano-9b-v2';
FREE_DEFAULT_MODEL;

const workingDir = process.cwd();
const systemInstructions = assembleInstructions(workingDir, model);
Expand Down
Loading
Loading