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
2 changes: 1 addition & 1 deletion src/agent/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ function pickCompactionModel(primaryModel: string): string {
return 'anthropic/claude-sonnet-4.6';
}
if (primaryModel.includes('sonnet') || primaryModel.includes('gpt-5.4') || primaryModel.includes('gpt-5.5') || primaryModel.includes('gemini-2.5-pro')) {
return 'anthropic/claude-haiku-4.5-20251001';
return 'anthropic/claude-haiku-4.5';
}
if (primaryModel.includes('haiku') || primaryModel.includes('mini') || primaryModel.includes('nano')) {
return 'google/gemini-2.5-flash'; // Cheapest capable model
Expand Down
2 changes: 1 addition & 1 deletion src/agent/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ You run on the BlockRun AI Gateway. When the user asks you to "test the BlockRun
- \`GET /.well-known/x402\` — x402 resource list with prices

**LLM (POST, x402-paid)**
- \`POST /v1/chat/completions\` — OpenAI-compatible. Body: \`{ model, messages, stream?, tools?, max_tokens?, temperature? }\`. \`model\` MUST come from \`GET /v1/models\` (real frontier examples on the gateway as of 2026-06: \`anthropic/claude-sonnet-4.6\`, \`anthropic/claude-opus-4.8\`, \`deepseek/deepseek-v4-pro\`, \`zai/glm-5.2\`, \`nvidia/llama-4-maverick\`, \`openai/gpt-5-nano\`). Do NOT invent versions like \`openai/gpt-5.1\` or \`xai/grok-5\` — those don't exist; the gateway 400s with the valid list in the error body, so when in doubt fetch \`GET /v1/models\` first.
- \`POST /v1/chat/completions\` — OpenAI-compatible. Body: \`{ model, messages, stream?, tools?, max_tokens?, temperature? }\`. \`model\` MUST come from \`GET /v1/models\` (real frontier examples on the gateway, verified live 2026-07-14: \`anthropic/claude-sonnet-5\`, \`anthropic/claude-opus-4.8\`, \`deepseek/deepseek-v4-pro\`, \`zai/glm-5.2\`, \`xai/grok-4.5\`, \`nvidia/qwen3-next-80b-a3b-instruct\` (free)). Do NOT invent versions like \`openai/gpt-5.1\` or \`xai/grok-5\` — those don't exist; the gateway 400s with the valid list in the error body, so when in doubt fetch \`GET /v1/models\` first.
- \`POST /v1/messages\` — Anthropic-compatible. Body: \`{ model, messages, max_tokens, system?, tools? }\`.

**Media (POST, x402-paid; GET to poll async jobs)**
Expand Down
4 changes: 2 additions & 2 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1563,7 +1563,7 @@ export async function interactiveSession(
// Free-only recovery chain — a free/empty-response session must NEVER
// fall back to a paid model (would silently charge the wallet). Both
// entries are $0 nvidia models.
const EMPTY_FALLBACK_MODELS = ['nvidia/qwen3-next-80b-a3b-instruct', 'nvidia/llama-4-maverick'];
const EMPTY_FALLBACK_MODELS = ['nvidia/qwen3-next-80b-a3b-instruct', 'nvidia/mistral-nemotron'];
const nextModel = EMPTY_FALLBACK_MODELS.find(m => m !== config.model && !turnFailedModels.has(m));
if (nextModel && recoveryAttempts < 2 && !config.disableModelFallback) {
recoveryAttempts++;
Expand Down Expand Up @@ -1607,7 +1607,7 @@ export async function interactiveSession(
const TOOL_USE_FALLBACK_MODELS = [
'anthropic/claude-haiku-4.5',
'moonshot/kimi-k2.7',
'openai/gpt-5',
'openai/gpt-5.4',
'anthropic/claude-sonnet-4.6',
];
const nextModel = TOOL_USE_FALLBACK_MODELS.find(
Expand Down
2 changes: 1 addition & 1 deletion src/agent/optimize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const MODEL_MAX_OUTPUT: Record<string, number> = {
'anthropic/claude-sonnet-5': 128_000,
'anthropic/claude-sonnet-4.6': 64_000,
'anthropic/claude-sonnet-4.5': 64_000,
'anthropic/claude-haiku-4.5-20251001': 16_384,
'anthropic/claude-haiku-4.5': 16_384,
'openai/gpt-5.6-sol': 128_000,
'openai/gpt-5.6-terra': 128_000,
'openai/gpt-5.6-luna': 128_000,
Expand Down
2 changes: 2 additions & 0 deletions src/agent/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
'anthropic/claude-sonnet-4.5': 200_000,
'anthropic/claude-sonnet-4': 200_000,
'anthropic/claude-haiku-4.5': 200_000,
// Retired 2026-07-14 (gateway 400s on it) — kept so replayed sessions that
// recorded the dated id still resolve a context window.
'anthropic/claude-haiku-4.5-20251001': 200_000,
// OpenAI
// gpt-5.5 advertises 1.05M context at the gateway, but Franklin keeps the
Expand Down
2 changes: 1 addition & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function initCommand(options: { port?: string }) {
ANTHROPIC_MODEL: 'blockrun/auto',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'anthropic/claude-sonnet-4.6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'anthropic/claude-opus-4.8',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'anthropic/claude-haiku-4.5-20251001',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'anthropic/claude-haiku-4.5',
};

fs.mkdirSync(path.dirname(CLAUDE_SETTINGS_FILE), { recursive: true });
Expand Down
235 changes: 183 additions & 52 deletions src/commands/models.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,120 @@
import chalk from 'chalk';
import { loadChain, API_URLS } from '../config.js';
import {
getGatewayModels,
GATEWAY_MARGIN,
type GatewayModel,
type BillingMode,
} from '../gateway-models.js';

interface Model {
id: string;
owned_by: string;
billing_mode: string;
pricing: { input: number; output: number };
/**
* `franklin models` — the live catalog.
*
* Reuses gateway-models.ts rather than re-fetching: it already types every
* billing mode, caches for 5 minutes, and serves stale-on-error.
*
* The old version modelled pricing as `{ input, output }` for every model and
* printed `$0.00/M` for anything that isn't token-metered — so the entire
* image/video/music/speech catalog rendered as free under a "Paid Models"
* heading. The gateway actually bills seven different ways, and each needs its
* own unit; that's what BILLING_GROUPS below encodes.
*/

const RULE_WIDTH = 74;

/**
* Compact USD — keeps sub-cent prices honest ($0.435/M must not round to
* $0.44). Exported for tests.
*/
export function money(n: number): string {
if (n === 0) return '$0';
const decimals = n < 0.01 ? 4 : n < 1 ? 3 : 2;
return `$${n.toFixed(decimals).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '')}`;
}

/**
* 1000000 → 1M, 1048576 → 1M, 1050000 → 1.1M, 262144 → 262K.
* Both 1000000 and 1048576 are "1M context" to a reader — don't render one as
* `1M` and the other as `1.0M` in the same column. Exported for tests.
*/
export function formatContext(n: number | undefined): string {
if (!n) return '';
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
return `${Math.round(n / 1000)}K`;
}

function pricingOf(m: GatewayModel): Record<string, number | undefined> {
return m.pricing as unknown as Record<string, number | undefined>;
}

interface BillingGroup {
mode: BillingMode;
heading: string;
/** Right-hand price/unit string for one model. */
render: (m: GatewayModel) => string;
}

// Ordered as printed. `free` and `paid` are handled separately — free gets its
// own promoted section, and paid is the only one with an Input/Output/Context
// table rather than a single price column.
const BILLING_GROUPS: BillingGroup[] = [
{
mode: 'per_image',
heading: 'Image generation (per image)',
render: m => `${money(pricingOf(m).per_image ?? 0)}/image`,
},
{
mode: 'per_second',
heading: 'Video generation (per second)',
render: m => {
const p = pricingOf(m);
const per = p.per_second ?? 0;
const def = p.default_duration_seconds;
const max = p.max_duration_seconds;
const parts: string[] = [`${money(per)}/sec`];
if (def) parts.push(`${def}s default ≈ ${money(per * def)}`);
if (max) parts.push(`max ${max}s`);
return parts.join(' · ');
},
},
{
mode: 'per_character',
heading: 'Speech / TTS (per 1K characters)',
render: m => {
const p = pricingOf(m);
const s = `${money(p.per_1k_chars ?? 0)}/1K chars`;
return p.max_input_chars ? `${s} · max ${formatContext(p.max_input_chars)} chars` : s;
},
},
{
mode: 'per_track',
heading: 'Music (per track)',
render: m => `${money(pricingOf(m).per_track ?? 0)}/track`,
},
{
mode: 'per_generation',
heading: 'Sound effects (per generation)',
render: m => {
const p = pricingOf(m);
const s = `${money(p.per_generation ?? 0)}/generation`;
return p.max_duration_seconds ? `${s} · max ${p.max_duration_seconds}s` : s;
},
},
{
mode: 'flat',
heading: 'Flat rate (per call)',
render: m => `${money(pricingOf(m).flat ?? 0)}/call`,
},
];

function printSection(heading: string, models: GatewayModel[], render: (m: GatewayModel) => string) {
if (models.length === 0) return;
console.log(chalk.yellow.bold(heading));
console.log(chalk.dim('─'.repeat(RULE_WIDTH)));
for (const m of models) {
console.log(` ${chalk.cyan(m.id.padEnd(44))} ${render(m)}`);
}
console.log('');
}

export async function modelsCommand() {
Expand All @@ -15,64 +124,86 @@ export async function modelsCommand() {
console.log(chalk.bold('Available Models\n'));
console.log(`Chain: ${chalk.magenta(chain)} — ${chalk.dim(apiUrl)}\n`);

let models: GatewayModel[];
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
const response = await fetch(`${apiUrl}/v1/models`, { signal: controller.signal });
clearTimeout(timeout);
if (!response.ok) {
console.log(chalk.red(`Failed to fetch models: ${response.status}`));
return;
models = await getGatewayModels();
} catch (err) {
const msg = err instanceof Error ? err.message : 'unknown error';
if (/fetch|ECONNREFUSED|ENOTFOUND|abort/i.test(msg)) {
console.log(chalk.red(`Cannot reach BlockRun API at ${apiUrl}`));
console.log(chalk.dim('Check your internet connection or try again later.'));
} else {
console.log(chalk.red(`Error: ${msg}`));
}
return;
}

const data = (await response.json()) as { data: Model[] };
if (!data.data || data.data.length === 0) {
console.log(chalk.yellow('No models returned from API.'));
return;
}
const models = data.data
.sort((a: Model, b: Model) => (a.pricing?.input ?? 0) - (b.pricing?.input ?? 0));

const free = models.filter((m: Model) => m.billing_mode === 'free');
const paid = models.filter((m: Model) => m.billing_mode !== 'free');

if (free.length > 0) {
console.log(chalk.green.bold('Free Models (no USDC needed)'));
console.log(chalk.dim('─'.repeat(70)));
for (const m of free) {
console.log(` ${chalk.cyan(m.id)}`);
}
console.log('');
if (models.length === 0) {
console.log(chalk.yellow('No models returned from API.'));
return;
}

// Free first — it's the "no USDC needed" on-ramp and the reason many people
// install Franklin at all.
const free = models.filter(m => m.billing_mode === 'free');
if (free.length > 0) {
console.log(chalk.green.bold('Free Models (no USDC needed)'));
console.log(chalk.dim('─'.repeat(RULE_WIDTH)));
for (const m of free) {
const ctx = formatContext(m.context_window);
console.log(` ${chalk.cyan(m.id.padEnd(44))} ${chalk.dim(ctx ? `${ctx} ctx` : '')}`);
}
console.log('');
}

// Token-metered — the only group with a two-price table.
const paid = models
.filter(m => m.billing_mode === 'paid')
.sort((a, b) => {
const ap = a.pricing as { input?: number };
const bp = b.pricing as { input?: number };
return (ap.input ?? 0) - (bp.input ?? 0);
});

console.log(chalk.yellow.bold('Paid Models'));
console.log(chalk.dim('─'.repeat(70)));
if (paid.length > 0) {
console.log(chalk.yellow.bold('Chat & Reasoning (per 1M tokens)'));
console.log(chalk.dim('─'.repeat(RULE_WIDTH)));
console.log(
chalk.dim(
` ${'Model'.padEnd(35)} ${'Input'.padEnd(12)} ${'Output'.padEnd(12)} Context`
)
chalk.dim(` ${'Model'.padEnd(44)} ${'Input'.padEnd(11)} ${'Output'.padEnd(11)} Context`)
);
console.log(chalk.dim('─'.repeat(70)));

console.log(chalk.dim('─'.repeat(RULE_WIDTH)));
for (const m of paid) {
const input = `$${(m.pricing?.input ?? 0).toFixed(2)}/M`;
const output = `$${(m.pricing?.output ?? 0).toFixed(2)}/M`;
const ctx = '';
const p = pricingOf(m);
const input = `${money(p.input ?? 0)}/M`;
const output = `${money(p.output ?? 0)}/M`;
console.log(
` ${chalk.cyan(m.id.padEnd(35))} ${input.padEnd(12)} ${output.padEnd(12)} ${ctx}`
` ${chalk.cyan(m.id.padEnd(44))} ${input.padEnd(11)} ${output.padEnd(11)} ${chalk.dim(formatContext(m.context_window))}`
);
}
console.log('');
}

console.log(
`\n${chalk.dim(`${models.length} models available. Use:`)} ${chalk.bold('franklin start --model <model-id>')}`
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'unknown error';
if (msg.includes('fetch') || msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
console.log(chalk.red(`Cannot reach BlockRun API at ${apiUrl}`));
console.log(chalk.dim('Check your internet connection or try again later.'));
} else {
console.log(chalk.red(`Error: ${msg}`));
}
for (const group of BILLING_GROUPS) {
const inGroup = models.filter(m => m.billing_mode === group.mode);
printSection(group.heading, inGroup, group.render);
}

// Anything the gateway starts billing a way this build doesn't know about
// must still be listed — silently dropping a model is how the $0.00/M bug
// stayed invisible. Show it with its raw pricing rather than a wrong unit.
const known = new Set<string>(['free', 'paid', ...BILLING_GROUPS.map(g => g.mode)]);
const unknown = models.filter(m => !known.has(m.billing_mode));
printSection(
'Other billing modes (not yet modelled by this Franklin build)',
unknown,
m => chalk.dim(`${m.billing_mode}: ${JSON.stringify(m.pricing)}`)
);

const margin = Math.round((GATEWAY_MARGIN - 1) * 100);
console.log(
chalk.dim(
`${models.length} models available. Prices are gateway list — x402 settlement adds ~${margin}%.`
)
);
console.log(`${chalk.dim('Use:')} ${chalk.bold('franklin start --model <model-id>')}`);
}
34 changes: 32 additions & 2 deletions src/gateway-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@ import { loadChain, API_URLS, USER_AGENT } from './config.js';

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

export type BillingMode = 'paid' | 'free' | 'flat' | 'per_image' | 'per_second' | 'per_track';
export type BillingMode =
| 'paid'
| 'free'
| 'flat'
| 'per_image'
| 'per_second'
| 'per_track'
| 'per_character'
| 'per_generation';

export interface PaidPricing { input: number; output: number; }
export interface FlatPricing { flat: number; }
Expand All @@ -29,13 +37,25 @@ export interface PerSecondPricing {
max_duration_seconds?: number;
}
export interface PerTrackPricing { per_track: number; }
/** ElevenLabs / ByteDance speech — billed per 1K characters of input text. */
export interface PerCharacterPricing {
per_1k_chars: number;
max_input_chars?: number;
}
/** ElevenLabs sound effects — flat charge per generation. */
export interface PerGenerationPricing {
per_generation: number;
max_duration_seconds?: number;
}

export type ModelPricing =
| PaidPricing
| FlatPricing
| PerImagePricing
| PerSecondPricing
| PerTrackPricing;
| PerTrackPricing
| PerCharacterPricing
| PerGenerationPricing;

export interface GatewayModel {
id: string;
Expand Down Expand Up @@ -135,6 +155,8 @@ export interface EstimateContext {
quantity?: number;
/** Clip length in seconds (per_second). Falls back to model's default_duration_seconds, then 8. */
duration_seconds?: number;
/** Input text length (per_character). Required for a meaningful speech estimate. */
characters?: number;
}

/**
Expand All @@ -157,6 +179,14 @@ export function estimateCostUsd(model: GatewayModel, ctx: EstimateContext = {}):
case 'per_track':
base = p.per_track ?? 0;
break;
case 'per_character':
// Priced per 1K characters of input text. Without a length there's no
// meaningful estimate, so fall through to 0 rather than invent one.
base = ((p.per_1k_chars ?? 0) * (ctx.characters ?? 0)) / 1000;
break;
case 'per_generation':
base = p.per_generation ?? 0;
break;
case 'flat':
base = p.flat ?? 0;
break;
Expand Down
2 changes: 1 addition & 1 deletion src/learnings/extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { Skill } from './types.js';
// Ordered by reliability: try the best free model first, fall back to others.
const EXTRACTION_MODELS = [
'nvidia/qwen3-next-80b-a3b-instruct', // Clean JSON output, no thinking leak (verified live)
'nvidia/llama-4-maverick', // Diverse-family free fallback
'nvidia/mistral-nemotron', // Diverse-family free fallback (serves itself; maverick is pooled)
];

const VALID_CATEGORIES = new Set<LearningCategory>([
Expand Down
Loading
Loading