@blockrun/franklin 3.8.30 → 3.8.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -428,7 +428,7 @@ src/
428
428
  Start with **zero dollars**. Franklin defaults to free NVIDIA models that need no wallet funding.
429
429
 
430
430
  ```bash
431
- franklin --model nvidia/nemotron-ultra-253b
431
+ franklin --model nvidia/qwen3-next-80b-a3b-thinking
432
432
  ```
433
433
 
434
434
  When you fund the wallet, Franklin gets more purchasing power: Sonnet, Opus, GPT, Gemini, Grok, and paid tools like Exa, DALL-E, and CoinGecko Pro.
@@ -187,7 +187,9 @@ Your training data is frozen in the past. Live-world questions MUST be answered
187
187
  - "Please check Yahoo Finance / Google Finance / Bloomberg / your broker / etc."
188
188
  - Any variant of "go look it up yourself" when TradingMarket / ExaAnswer / WebSearch would resolve it.
189
189
 
190
- If you find yourself about to emit one of these, stop and call the tool instead. If you don't know which ticker the user means, call ExaSearch or AskUser — never deflect.`;
190
+ If you find yourself about to emit one of these, stop and call the tool instead. If you don't know which ticker the user means, call ExaSearch or AskUser — never deflect.
191
+
192
+ **Media generation (ImageGen / VideoGen).** Pass just the user's descriptive prompt and the output path — do NOT pass \`model\`. The harness picks the right model for the requested style + budget and surfaces a cost proposal through AskUser before spending. Only pass \`model\` explicitly if the user named one specifically.`;
191
193
  }
192
194
  function getTokenEfficiencySection() {
193
195
  return `# Token Efficiency
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Media router — one LLM call that picks which image/video model fits
3
+ * the user's request, with alternatives and cost estimates, so the agent
4
+ * can show a clean AskUser proposal before spending.
5
+ *
6
+ * Principle (matches turn-analyzer): harness orchestrates, free model
7
+ * decides. No keyword-to-model mapping in TypeScript. Classifier reads
8
+ * the prompt + the current gateway catalog (pulled dynamically), picks
9
+ * one recommended model plus a cheaper + a premium alternative, and
10
+ * explains the choice in one sentence.
11
+ *
12
+ * Cost estimates come from `gateway-models.ts` — always dynamic,
13
+ * margin-adjusted.
14
+ */
15
+ import type { ModelClient } from './llm.js';
16
+ export type MediaKind = 'image' | 'video';
17
+ export type MediaStyle = 'photoreal' | 'illustration' | 'anime' | 'logo' | 'concept' | 'other';
18
+ export type MediaPriority = 'cost' | 'quality' | 'balanced';
19
+ export interface MediaChoice {
20
+ model: string;
21
+ estimatedCostUsd: number;
22
+ rationale: string;
23
+ }
24
+ export interface MediaProposal {
25
+ kind: MediaKind;
26
+ quantity: number;
27
+ durationSeconds?: number;
28
+ maxDurationSeconds?: number;
29
+ recommended: MediaChoice;
30
+ cheaper?: MediaChoice;
31
+ premium?: MediaChoice;
32
+ intent: {
33
+ style: MediaStyle;
34
+ priority: MediaPriority;
35
+ };
36
+ totalCostUsd: number;
37
+ }
38
+ export declare function clearMediaRouterCache(): void;
39
+ export interface AnalyzeMediaOpts {
40
+ kind: MediaKind;
41
+ prompt: string;
42
+ client: ModelClient;
43
+ quantity?: number;
44
+ durationSeconds?: number;
45
+ signal?: AbortSignal;
46
+ }
47
+ /**
48
+ * Pick the best model + alternatives for this media request. Returns null
49
+ * on any failure path (classifier timeout, parse error, empty catalog) so
50
+ * the caller can fall back to its old hardcoded default rather than
51
+ * blocking the user.
52
+ */
53
+ export declare function analyzeMediaRequest(opts: AnalyzeMediaOpts): Promise<MediaProposal | null>;
54
+ /**
55
+ * Render a proposal as the user-facing AskUser question. Layout matches
56
+ * the spec from v3.8.31 planning: recommended first with • bullet,
57
+ * alternatives below with ○ bullets, prices include the 5% margin note.
58
+ */
59
+ export declare function renderProposalForAskUser(p: MediaProposal, userPrompt: string): {
60
+ question: string;
61
+ options: Array<{
62
+ id: string;
63
+ label: string;
64
+ }>;
65
+ };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Media router — one LLM call that picks which image/video model fits
3
+ * the user's request, with alternatives and cost estimates, so the agent
4
+ * can show a clean AskUser proposal before spending.
5
+ *
6
+ * Principle (matches turn-analyzer): harness orchestrates, free model
7
+ * decides. No keyword-to-model mapping in TypeScript. Classifier reads
8
+ * the prompt + the current gateway catalog (pulled dynamically), picks
9
+ * one recommended model plus a cheaper + a premium alternative, and
10
+ * explains the choice in one sentence.
11
+ *
12
+ * Cost estimates come from `gateway-models.ts` — always dynamic,
13
+ * margin-adjusted.
14
+ */
15
+ import { getModelsByCategory, estimateCostUsd, defaultDurationSeconds, maxDurationSeconds, } from '../gateway-models.js';
16
+ // ─── Classifier ─────────────────────────────────────────────────────────
17
+ const CLASSIFIER_MODEL = process.env.FRANKLIN_MEDIA_ROUTER_MODEL || 'nvidia/llama-4-maverick';
18
+ const TIMEOUT_MS = 3_500; // slightly more lenient than the turn-analyzer — we're asking for JSON with reasoning
19
+ const MAX_TOKENS = 256;
20
+ function buildSystemPrompt(kind, catalog) {
21
+ const catalogLines = catalog.map(m => {
22
+ const p = m.pricing;
23
+ const price = kind === 'image'
24
+ ? `$${(p.per_image ?? 0).toFixed(4)}/image`
25
+ : `$${(p.per_second ?? 0).toFixed(2)}/s (default ${p.default_duration_seconds ?? 8}s, max ${p.max_duration_seconds ?? 8}s)`;
26
+ return ` - ${m.id} · ${price} · ${m.description || m.name}`;
27
+ }).join('\n');
28
+ return `You pick the best ${kind} model for a user's Franklin request. Output ONE LINE of compact JSON. No markdown, no code fences, no explanation.
29
+
30
+ ## Catalog (${catalog.length} available ${kind} models)
31
+ ${catalogLines}
32
+
33
+ ## Output schema
34
+
35
+ {"style":"photoreal|illustration|anime|logo|concept|other",
36
+ "priority":"cost|quality|balanced",
37
+ "recommended":{"model":"<id from catalog>","rationale":"<one sentence, <=140 chars>"},
38
+ "cheaper":{"model":"<id from catalog | null>","rationale":"<one sentence>"},
39
+ "premium":{"model":"<id from catalog | null>","rationale":"<one sentence>"}}
40
+
41
+ Rules:
42
+ - recommended is always set to an id from the catalog.
43
+ - cheaper / premium may be null if no strictly cheaper / better option exists.
44
+ - Never invent a model id. Use EXACTLY one of the catalog ids.
45
+ - Match style → model: anime/illustration prefers CogView, photoreal prefers Nano Banana Pro / Grok Imagine Pro, budget-conscious picks cheapest-acceptable.
46
+ - One sentence rationale, user-visible.
47
+
48
+ Examples:
49
+
50
+ Input: "a photo of a cat on Mars, photoreal"
51
+ Output: {"style":"photoreal","priority":"balanced","recommended":{"model":"google/nano-banana-pro","rationale":"Photoreal scenes — Nano Banana Pro has strong realism at moderate cost."},"cheaper":{"model":"google/nano-banana","rationale":"Same family, lower cost, slightly less detail."},"premium":{"model":"openai/gpt-image-2","rationale":"Best photoreal fidelity when budget allows."}}
52
+
53
+ Input: "赛博朋克风格的动漫角色"
54
+ Output: {"style":"anime","priority":"balanced","recommended":{"model":"zai/cogview-4","rationale":"CogView-4 specializes in stylized/anime imagery."},"cheaper":{"model":"google/nano-banana","rationale":"Cheaper but less stylized."},"premium":{"model":"xai/grok-imagine-image-pro","rationale":"Premium detail for complex scenes."}}
55
+
56
+ Input: "a 10-second cinematic drone shot over Tokyo at night"
57
+ Output: {"style":"concept","priority":"quality","recommended":{"model":"bytedance/seedance-2.0","rationale":"Seedance 2.0 delivers the best cinematic quality."},"cheaper":{"model":"bytedance/seedance-2.0-fast","rationale":"Faster + cheaper, minor quality trade-off."},"premium":{"model":null,"rationale":"2.0 is already the top tier."}}
58
+
59
+ Output JSON only, single line.`;
60
+ }
61
+ const cache = new Map();
62
+ const CACHE_TTL_MS = 30_000;
63
+ const CACHE_MAX = 32;
64
+ function hashKey(parts) {
65
+ const s = parts.join('|');
66
+ let h = 0;
67
+ for (let i = 0; i < s.length; i++)
68
+ h = ((h << 5) - h + s.charCodeAt(i)) | 0;
69
+ return String(h);
70
+ }
71
+ export function clearMediaRouterCache() { cache.clear(); }
72
+ // ─── Parser ─────────────────────────────────────────────────────────────
73
+ const VALID_STYLES = new Set(['photoreal', 'illustration', 'anime', 'logo', 'concept', 'other']);
74
+ const VALID_PRIORITIES = new Set(['cost', 'quality', 'balanced']);
75
+ function validateChoice(raw, catalog) {
76
+ if (!raw || typeof raw !== 'object')
77
+ return null;
78
+ const id = typeof raw.model === 'string' ? raw.model : '';
79
+ const model = catalog.get(id);
80
+ if (!model)
81
+ return null;
82
+ const rationale = typeof raw.rationale === 'string' ? raw.rationale.slice(0, 240) : '';
83
+ return { model, rationale };
84
+ }
85
+ /**
86
+ * Pick the best model + alternatives for this media request. Returns null
87
+ * on any failure path (classifier timeout, parse error, empty catalog) so
88
+ * the caller can fall back to its old hardcoded default rather than
89
+ * blocking the user.
90
+ */
91
+ export async function analyzeMediaRequest(opts) {
92
+ if (process.env.FRANKLIN_NO_MEDIA_ROUTER === '1')
93
+ return null;
94
+ const { kind, prompt, client } = opts;
95
+ if (!prompt || prompt.trim().length === 0)
96
+ return null;
97
+ // Pull catalog first — if the gateway doesn't have any models in this
98
+ // category, there's nothing to recommend.
99
+ const catalog = await getModelsByCategory(kind).catch(() => []);
100
+ if (catalog.length === 0)
101
+ return null;
102
+ // Cache check — classifier output is stable for a given prompt + catalog
103
+ // version, so re-asking within 30s is waste.
104
+ const quantity = Math.max(1, Math.floor(opts.quantity ?? 1));
105
+ const key = hashKey([kind, prompt.trim().slice(0, 500), String(quantity), String(opts.durationSeconds ?? '')]);
106
+ const hit = cache.get(key);
107
+ if (hit && hit.expiresAt > Date.now())
108
+ return hit.value;
109
+ // Call the classifier.
110
+ const catalogMap = new Map(catalog.map(m => [m.id, m]));
111
+ const system = buildSystemPrompt(kind, catalog);
112
+ const ctrl = new AbortController();
113
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
114
+ const signal = opts.signal ? combineSignals([opts.signal, ctrl.signal]) : ctrl.signal;
115
+ let raw = '';
116
+ try {
117
+ const response = await client.complete({
118
+ model: CLASSIFIER_MODEL,
119
+ system,
120
+ messages: [{ role: 'user', content: prompt.slice(0, 1000) }],
121
+ tools: [],
122
+ max_tokens: MAX_TOKENS,
123
+ }, signal);
124
+ for (const part of response.content) {
125
+ if (typeof part === 'object' && part.type === 'text' && part.text)
126
+ raw += part.text;
127
+ }
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ finally {
133
+ clearTimeout(timer);
134
+ }
135
+ // Parse one-line JSON (may be wrapped in stray text).
136
+ const match = raw.match(/\{[\s\S]*\}/);
137
+ if (!match)
138
+ return null;
139
+ let parsed;
140
+ try {
141
+ parsed = JSON.parse(match[0]);
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ const style = typeof parsed.style === 'string' && VALID_STYLES.has(parsed.style)
147
+ ? parsed.style : 'other';
148
+ const priority = typeof parsed.priority === 'string' && VALID_PRIORITIES.has(parsed.priority)
149
+ ? parsed.priority : 'balanced';
150
+ const rec = validateChoice(parsed.recommended, catalogMap);
151
+ if (!rec)
152
+ return null;
153
+ const cheaperChoice = validateChoice(parsed.cheaper, catalogMap);
154
+ const premiumChoice = validateChoice(parsed.premium, catalogMap);
155
+ // Build proposal with live cost estimates.
156
+ const durationSeconds = kind === 'video'
157
+ ? (opts.durationSeconds ?? defaultDurationSeconds(rec.model))
158
+ : undefined;
159
+ const maxDur = kind === 'video' ? (maxDurationSeconds(rec.model) ?? undefined) : undefined;
160
+ const toChoice = (c) => {
161
+ if (!c || c.model.id === rec.model.id)
162
+ return undefined;
163
+ return {
164
+ model: c.model.id,
165
+ estimatedCostUsd: estimateCostUsd(c.model, { quantity, duration_seconds: durationSeconds }),
166
+ rationale: c.rationale,
167
+ };
168
+ };
169
+ const recommended = {
170
+ model: rec.model.id,
171
+ estimatedCostUsd: estimateCostUsd(rec.model, { quantity, duration_seconds: durationSeconds }),
172
+ rationale: rec.rationale,
173
+ };
174
+ const proposal = {
175
+ kind,
176
+ quantity,
177
+ durationSeconds,
178
+ maxDurationSeconds: maxDur,
179
+ recommended,
180
+ cheaper: toChoice(cheaperChoice),
181
+ premium: toChoice(premiumChoice),
182
+ intent: { style, priority },
183
+ totalCostUsd: recommended.estimatedCostUsd,
184
+ };
185
+ // Evict oldest if bounded
186
+ if (cache.size >= CACHE_MAX) {
187
+ const firstKey = cache.keys().next().value;
188
+ if (firstKey)
189
+ cache.delete(firstKey);
190
+ }
191
+ cache.set(key, { value: proposal, expiresAt: Date.now() + CACHE_TTL_MS });
192
+ return proposal;
193
+ }
194
+ // ─── Presentation ───────────────────────────────────────────────────────
195
+ /**
196
+ * Render a proposal as the user-facing AskUser question. Layout matches
197
+ * the spec from v3.8.31 planning: recommended first with • bullet,
198
+ * alternatives below with ○ bullets, prices include the 5% margin note.
199
+ */
200
+ export function renderProposalForAskUser(p, userPrompt) {
201
+ const lines = [];
202
+ lines.push(`*Media generation proposal*`);
203
+ lines.push('');
204
+ lines.push(`Prompt: "${userPrompt.trim().slice(0, 200)}"`);
205
+ if (p.kind === 'video' && p.durationSeconds) {
206
+ const maxNote = p.maxDurationSeconds ? ` (max ${p.maxDurationSeconds}s)` : '';
207
+ lines.push(`Duration: ${p.durationSeconds}s${maxNote}`);
208
+ }
209
+ else if (p.kind === 'image' && p.quantity > 1) {
210
+ lines.push(`Quantity: ${p.quantity} images`);
211
+ }
212
+ lines.push('');
213
+ lines.push(` ● Recommended ${p.recommended.model.padEnd(32)} ~${formatUsd(p.recommended.estimatedCostUsd)} ${p.recommended.rationale}`);
214
+ if (p.cheaper) {
215
+ lines.push(` ○ Cheaper ${p.cheaper.model.padEnd(32)} ~${formatUsd(p.cheaper.estimatedCostUsd)} ${p.cheaper.rationale}`);
216
+ }
217
+ if (p.premium) {
218
+ lines.push(` ○ Premium ${p.premium.model.padEnd(32)} ~${formatUsd(p.premium.estimatedCostUsd)} ${p.premium.rationale}`);
219
+ }
220
+ lines.push('');
221
+ lines.push(` (prices include the 5% gateway fee)`);
222
+ const options = [];
223
+ options.push({ id: 'recommended', label: `Continue with ${p.recommended.model}` });
224
+ if (p.cheaper)
225
+ options.push({ id: 'cheaper', label: `Use cheaper (${p.cheaper.model})` });
226
+ if (p.premium)
227
+ options.push({ id: 'premium', label: `Use premium (${p.premium.model})` });
228
+ options.push({ id: 'cancel', label: 'Cancel (no charge)' });
229
+ return { question: lines.join('\n'), options };
230
+ }
231
+ function formatUsd(n) {
232
+ if (!Number.isFinite(n) || n <= 0)
233
+ return '$0.00';
234
+ if (n < 0.01)
235
+ return `$${n.toFixed(4)}`;
236
+ if (n < 1)
237
+ return `$${n.toFixed(3)}`;
238
+ return `$${n.toFixed(2)}`;
239
+ }
240
+ function combineSignals(signals) {
241
+ const ctrl = new AbortController();
242
+ for (const s of signals) {
243
+ if (s.aborted) {
244
+ ctrl.abort();
245
+ break;
246
+ }
247
+ s.addEventListener('abort', () => ctrl.abort(), { once: true });
248
+ }
249
+ return ctrl.signal;
250
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Dynamic model catalog from BlockRun Gateway.
3
+ *
4
+ * Pulls GET /api/v1/models once on first use, caches for 5 minutes, and
5
+ * exposes estimators + category filters. This replaces the hardcoded
6
+ * pricing/model tables Franklin used to carry — adding a new model or
7
+ * changing a price on BlockRun's side no longer requires a Franklin
8
+ * release. Gateway is the single source of truth.
9
+ *
10
+ * Per gateway team (2026-04-22): every model returns `billing_mode` and
11
+ * a mode-specific `pricing` object. Dispatch on billing_mode to compute
12
+ * an estimated charge. x402 adds a fixed 5% margin on top of base price,
13
+ * so actual charge = base * 1.05 (confirmed against a live 402 response
14
+ * on seedance-2.0-fast: 5s × $0.15 × 1.05 = $0.7875).
15
+ */
16
+ export type BillingMode = 'paid' | 'free' | 'flat' | 'per_image' | 'per_second' | 'per_track';
17
+ export interface PaidPricing {
18
+ input: number;
19
+ output: number;
20
+ }
21
+ export interface FlatPricing {
22
+ flat: number;
23
+ }
24
+ export interface PerImagePricing {
25
+ per_image: number;
26
+ }
27
+ export interface PerSecondPricing {
28
+ per_second: number;
29
+ default_duration_seconds?: number;
30
+ max_duration_seconds?: number;
31
+ }
32
+ export interface PerTrackPricing {
33
+ per_track: number;
34
+ }
35
+ export type ModelPricing = PaidPricing | FlatPricing | PerImagePricing | PerSecondPricing | PerTrackPricing;
36
+ export interface GatewayModel {
37
+ id: string;
38
+ name: string;
39
+ description?: string;
40
+ owned_by?: string;
41
+ billing_mode: BillingMode;
42
+ categories: string[];
43
+ context_window?: number;
44
+ max_output?: number;
45
+ pricing: ModelPricing;
46
+ }
47
+ /** Test / reset helper. */
48
+ export declare function clearGatewayModelsCache(): void;
49
+ /**
50
+ * Fetch the model catalog, honoring the 5-minute cache. Concurrent callers
51
+ * during a cold cache share a single in-flight promise so we don't stampede
52
+ * the gateway at process start.
53
+ */
54
+ export declare function getGatewayModels(): Promise<GatewayModel[]>;
55
+ /** Return models filtered to a specific category (e.g. 'image', 'video', 'music'). */
56
+ export declare function getModelsByCategory(category: string): Promise<GatewayModel[]>;
57
+ /** Find a single model by ID, or null if it's not in the current catalog. */
58
+ export declare function findModel(id: string): Promise<GatewayModel | null>;
59
+ /** x402 gateway's fixed margin percentage applied on top of the base price. */
60
+ export declare const GATEWAY_MARGIN = 1.05;
61
+ export interface EstimateContext {
62
+ /** Number of images (per_image). Default 1. */
63
+ quantity?: number;
64
+ /** Clip length in seconds (per_second). Falls back to model's default_duration_seconds, then 8. */
65
+ duration_seconds?: number;
66
+ }
67
+ /**
68
+ * Estimated USD charge to generate one response from this model under the
69
+ * given context. Includes the 5% gateway margin. Returns 0 for free and
70
+ * token-metered (paid) models where a pre-call estimate isn't meaningful.
71
+ */
72
+ export declare function estimateCostUsd(model: GatewayModel, ctx?: EstimateContext): number;
73
+ /** Effective default duration for a per_second model (falls back to 8s). */
74
+ export declare function defaultDurationSeconds(model: GatewayModel): number;
75
+ /** Max duration the gateway will accept for a per_second model. */
76
+ export declare function maxDurationSeconds(model: GatewayModel): number | null;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Dynamic model catalog from BlockRun Gateway.
3
+ *
4
+ * Pulls GET /api/v1/models once on first use, caches for 5 minutes, and
5
+ * exposes estimators + category filters. This replaces the hardcoded
6
+ * pricing/model tables Franklin used to carry — adding a new model or
7
+ * changing a price on BlockRun's side no longer requires a Franklin
8
+ * release. Gateway is the single source of truth.
9
+ *
10
+ * Per gateway team (2026-04-22): every model returns `billing_mode` and
11
+ * a mode-specific `pricing` object. Dispatch on billing_mode to compute
12
+ * an estimated charge. x402 adds a fixed 5% margin on top of base price,
13
+ * so actual charge = base * 1.05 (confirmed against a live 402 response
14
+ * on seedance-2.0-fast: 5s × $0.15 × 1.05 = $0.7875).
15
+ */
16
+ import { loadChain, API_URLS, USER_AGENT } from './config.js';
17
+ // ─── Cache ──────────────────────────────────────────────────────────────
18
+ const CACHE_TTL_MS = 5 * 60_000; // 5 min — gateway rotates models, but not often
19
+ const FETCH_TIMEOUT_MS = 4_000; // one-shot on init; don't let a slow gateway hang startup
20
+ let cache = null;
21
+ let inflight = null;
22
+ /** Test / reset helper. */
23
+ export function clearGatewayModelsCache() {
24
+ cache = null;
25
+ inflight = null;
26
+ }
27
+ // ─── Fetch ──────────────────────────────────────────────────────────────
28
+ async function doFetch() {
29
+ const chain = loadChain();
30
+ const base = API_URLS[chain].replace(/\/api$/, '');
31
+ // The schema/JSON gate: without ?format=json the gateway returns a
32
+ // typed schema placeholder instead of the data envelope. Documented
33
+ // quirk across other endpoints too.
34
+ const url = `${base}/api/v1/models?format=json`;
35
+ const ctrl = new AbortController();
36
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
37
+ try {
38
+ const res = await fetch(url, {
39
+ signal: ctrl.signal,
40
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
41
+ });
42
+ if (!res.ok)
43
+ throw new Error(`Gateway models list returned HTTP ${res.status}`);
44
+ const body = (await res.json());
45
+ if (!Array.isArray(body.data))
46
+ throw new Error('Gateway models list missing data[]');
47
+ return body.data;
48
+ }
49
+ finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
53
+ /**
54
+ * Fetch the model catalog, honoring the 5-minute cache. Concurrent callers
55
+ * during a cold cache share a single in-flight promise so we don't stampede
56
+ * the gateway at process start.
57
+ */
58
+ export async function getGatewayModels() {
59
+ if (cache && cache.expiresAt > Date.now())
60
+ return cache.models;
61
+ if (inflight)
62
+ return inflight;
63
+ inflight = doFetch()
64
+ .then(models => {
65
+ cache = { models, expiresAt: Date.now() + CACHE_TTL_MS };
66
+ return models;
67
+ })
68
+ .catch(err => {
69
+ // On failure, keep the last good cache if we have one (serve stale
70
+ // rather than break the agent). Only hard-fail cold start.
71
+ if (cache)
72
+ return cache.models;
73
+ throw err;
74
+ })
75
+ .finally(() => { inflight = null; });
76
+ return inflight;
77
+ }
78
+ /** Return models filtered to a specific category (e.g. 'image', 'video', 'music'). */
79
+ export async function getModelsByCategory(category) {
80
+ const all = await getGatewayModels();
81
+ return all.filter(m => Array.isArray(m.categories) && m.categories.includes(category));
82
+ }
83
+ /** Find a single model by ID, or null if it's not in the current catalog. */
84
+ export async function findModel(id) {
85
+ const all = await getGatewayModels();
86
+ return all.find(m => m.id === id) ?? null;
87
+ }
88
+ // ─── Cost estimation ────────────────────────────────────────────────────
89
+ /** x402 gateway's fixed margin percentage applied on top of the base price. */
90
+ export const GATEWAY_MARGIN = 1.05;
91
+ /**
92
+ * Estimated USD charge to generate one response from this model under the
93
+ * given context. Includes the 5% gateway margin. Returns 0 for free and
94
+ * token-metered (paid) models where a pre-call estimate isn't meaningful.
95
+ */
96
+ export function estimateCostUsd(model, ctx = {}) {
97
+ const p = model.pricing;
98
+ let base = 0;
99
+ switch (model.billing_mode) {
100
+ case 'per_image':
101
+ base = (p.per_image ?? 0) * (ctx.quantity ?? 1);
102
+ break;
103
+ case 'per_second': {
104
+ const dur = ctx.duration_seconds ?? p.default_duration_seconds ?? 8;
105
+ base = (p.per_second ?? 0) * dur;
106
+ break;
107
+ }
108
+ case 'per_track':
109
+ base = p.per_track ?? 0;
110
+ break;
111
+ case 'flat':
112
+ base = p.flat ?? 0;
113
+ break;
114
+ case 'free':
115
+ base = 0;
116
+ break;
117
+ case 'paid':
118
+ // Token-metered — no pre-call estimate possible without counting
119
+ // the exact request/response tokens. Return 0 so the caller shows
120
+ // "~tokens" instead of a made-up number.
121
+ base = 0;
122
+ break;
123
+ }
124
+ return +(base * GATEWAY_MARGIN).toFixed(6);
125
+ }
126
+ /** Effective default duration for a per_second model (falls back to 8s). */
127
+ export function defaultDurationSeconds(model) {
128
+ if (model.billing_mode !== 'per_second')
129
+ return 8;
130
+ const p = model.pricing;
131
+ return p.default_duration_seconds ?? 8;
132
+ }
133
+ /** Max duration the gateway will accept for a per_second model. */
134
+ export function maxDurationSeconds(model) {
135
+ if (model.billing_mode !== 'per_second')
136
+ return null;
137
+ const p = model.pricing;
138
+ return p.max_duration_seconds ?? null;
139
+ }
@@ -11,9 +11,9 @@ import { listSessions, getSessionFilePath } from './storage.js';
11
11
  function tokenize(text) {
12
12
  return text
13
13
  .toLowerCase()
14
- .replace(/[^\w\s]/g, ' ')
15
- .split(/\s+/)
16
- .filter(t => t.length > 1);
14
+ .replace(/[^\p{L}\p{N}_\s]/gu, ' ')
15
+ .split(/\s+/u)
16
+ .filter(t => t.length > 1 || /[^\x00-\x7F]/.test(t));
17
17
  }
18
18
  function parseQuery(query) {
19
19
  const phrases = [];
@@ -158,10 +158,7 @@ export function loadSessionHistory(sessionId) {
158
158
  return [];
159
159
  }
160
160
  }
161
- /**
162
- * List all saved sessions, newest first.
163
- */
164
- export function listSessions() {
161
+ function readSessionMetas(includeGhosts = false) {
165
162
  const sessionsDir = getSessionsDir();
166
163
  try {
167
164
  const files = fs.readdirSync(sessionsDir)
@@ -174,14 +171,19 @@ export function listSessions() {
174
171
  }
175
172
  catch { /* skip corrupted */ }
176
173
  }
177
- // Filter out ghost sessions (0 messages)
178
- const filtered = metas.filter(m => m.messageCount > 0);
179
- return filtered.sort((a, b) => b.updatedAt - a.updatedAt);
174
+ const visible = includeGhosts ? metas : metas.filter(m => m.messageCount > 0);
175
+ return visible.sort((a, b) => b.updatedAt - a.updatedAt);
180
176
  }
181
177
  catch {
182
178
  return [];
183
179
  }
184
180
  }
181
+ /**
182
+ * List all saved sessions, newest first.
183
+ */
184
+ export function listSessions() {
185
+ return readSessionMetas(false);
186
+ }
185
187
  /**
186
188
  * Find the latest saved session tagged with a given channel (e.g.
187
189
  * `telegram:12345`). Used by non-CLI drivers to resume across process
@@ -198,25 +200,26 @@ export function findLatestSessionByChannel(channel) {
198
200
  * Accepts optional activeSessionId to protect from deletion.
199
201
  */
200
202
  export function pruneOldSessions(activeSessionId) {
201
- const sessions = listSessions();
202
- if (sessions.length <= MAX_SESSIONS)
203
- return;
204
- const toDelete = sessions
205
- .slice(MAX_SESSIONS)
206
- .filter(s => s.id !== activeSessionId); // Never delete active session
207
- for (const s of toDelete) {
208
- try {
209
- fs.unlinkSync(sessionPath(s.id));
210
- }
211
- catch { /* ok */ }
212
- try {
213
- fs.unlinkSync(metaPath(s.id));
203
+ const sessions = readSessionMetas(false);
204
+ const allSessions = readSessionMetas(true);
205
+ if (sessions.length > MAX_SESSIONS) {
206
+ const toDelete = sessions
207
+ .slice(MAX_SESSIONS)
208
+ .filter(s => s.id !== activeSessionId); // Never delete active session
209
+ for (const s of toDelete) {
210
+ try {
211
+ fs.unlinkSync(sessionPath(s.id));
212
+ }
213
+ catch { /* ok */ }
214
+ try {
215
+ fs.unlinkSync(metaPath(s.id));
216
+ }
217
+ catch { /* ok */ }
214
218
  }
215
- catch { /* ok */ }
216
219
  }
217
220
  // Also clean up ghost sessions (0 messages, older than 5 minutes)
218
221
  const fiveMinAgo = Date.now() - 5 * 60 * 1000;
219
- for (const s of sessions) {
222
+ for (const s of allSessions) {
220
223
  if (s.id === activeSessionId)
221
224
  continue;
222
225
  if (s.messageCount === 0 && s.createdAt < fiveMinAgo) {
@@ -7,15 +7,60 @@ import path from 'node:path';
7
7
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
8
8
  import { loadChain, API_URLS, VERSION } from '../config.js';
9
9
  import { checkImageBudget, recordImageAsset } from '../content/record-image.js';
10
+ import { ModelClient } from '../agent/llm.js';
11
+ import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
10
12
  function buildExecute(deps) {
11
13
  return async function execute(input, ctx) {
12
14
  const { prompt, output_path, size, model, contentId } = input;
13
15
  if (!prompt) {
14
16
  return { output: 'Error: prompt is required', isError: true };
15
17
  }
16
- // ── Content pre-flight: refuse BEFORE paying if budget can't cover this ──
17
- const imageModel = model || 'openai/gpt-image-1';
18
+ // ── Media router + AskUser flow ────────────────────────────────────
19
+ // If the caller explicitly named a model, or the env auto-approves, or
20
+ // no AskUser bridge exists (batch / --prompt mode), skip the proposal
21
+ // step and use the old default. Otherwise: classifier picks a fitting
22
+ // model, cost preview goes to AskUser, user chooses or cancels.
23
+ let imageModel = model || 'openai/gpt-image-1';
18
24
  const imageSize = size || '1024x1024';
25
+ const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
26
+ if (!model && !autoApprove && ctx.onAskUser) {
27
+ try {
28
+ const chain = loadChain();
29
+ const client = new ModelClient({ apiUrl: API_URLS[chain], chain });
30
+ const proposal = await analyzeMediaRequest({
31
+ kind: 'image',
32
+ prompt,
33
+ quantity: 1,
34
+ client,
35
+ signal: ctx.abortSignal,
36
+ });
37
+ if (proposal) {
38
+ const { question, options } = renderProposalForAskUser(proposal, prompt);
39
+ const labels = options.map(o => o.label);
40
+ const answer = await ctx.onAskUser(question, labels);
41
+ // Map the user's returned label back to an option id
42
+ const chosen = options.find(o => o.label === answer) ?? { id: 'cancel' };
43
+ switch (chosen.id) {
44
+ case 'cheaper':
45
+ imageModel = proposal.cheaper?.model ?? proposal.recommended.model;
46
+ break;
47
+ case 'premium':
48
+ imageModel = proposal.premium?.model ?? proposal.recommended.model;
49
+ break;
50
+ case 'cancel':
51
+ return {
52
+ output: `## Image generation cancelled\n\nNo USDC was spent. Ask again when ready, or pass an explicit \`model\` to skip the confirmation step.`,
53
+ };
54
+ case 'recommended':
55
+ default:
56
+ imageModel = proposal.recommended.model;
57
+ }
58
+ }
59
+ }
60
+ catch {
61
+ // Router / AskUser failed — fall back to default model silently.
62
+ }
63
+ }
19
64
  if (contentId && deps.library) {
20
65
  const decision = checkImageBudget(deps.library, contentId, imageModel, imageSize);
21
66
  if (!decision.ok) {
@@ -11,6 +11,8 @@ import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
13
13
  import { loadChain, API_URLS, VERSION } from '../config.js';
14
+ import { ModelClient } from '../agent/llm.js';
15
+ import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
14
16
  const DEFAULT_MODEL = 'xai/grok-imagine-video';
15
17
  const DEFAULT_DURATION = 8;
16
18
  const PRICE_PER_SECOND_USD = 0.05;
@@ -26,8 +28,51 @@ function buildExecute(deps) {
26
28
  const { prompt, output_path, model, image_url, duration_seconds, contentId } = input;
27
29
  if (!prompt)
28
30
  return { output: 'Error: prompt is required', isError: true };
29
- const videoModel = model || DEFAULT_MODEL;
30
- const duration = duration_seconds ?? DEFAULT_DURATION;
31
+ let videoModel = model || DEFAULT_MODEL;
32
+ let duration = duration_seconds ?? DEFAULT_DURATION;
33
+ // ── Media router + AskUser flow (video bills per second, always ask) ──
34
+ const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
35
+ if (!model && !autoApprove && ctx.onAskUser) {
36
+ try {
37
+ const chain = loadChain();
38
+ const client = new ModelClient({ apiUrl: API_URLS[chain], chain });
39
+ const proposal = await analyzeMediaRequest({
40
+ kind: 'video',
41
+ prompt,
42
+ durationSeconds: duration_seconds,
43
+ client,
44
+ signal: ctx.abortSignal,
45
+ });
46
+ if (proposal) {
47
+ const { question, options } = renderProposalForAskUser(proposal, prompt);
48
+ const labels = options.map(o => o.label);
49
+ const answer = await ctx.onAskUser(question, labels);
50
+ const chosen = options.find(o => o.label === answer) ?? { id: 'cancel' };
51
+ switch (chosen.id) {
52
+ case 'cheaper':
53
+ videoModel = proposal.cheaper?.model ?? proposal.recommended.model;
54
+ break;
55
+ case 'premium':
56
+ videoModel = proposal.premium?.model ?? proposal.recommended.model;
57
+ break;
58
+ case 'cancel':
59
+ return {
60
+ output: `## Video generation cancelled\n\nNo USDC was spent.`,
61
+ };
62
+ case 'recommended':
63
+ default:
64
+ videoModel = proposal.recommended.model;
65
+ }
66
+ // Use the proposal's duration — the router honored the user's
67
+ // duration_seconds or filled in the model's default.
68
+ if (proposal.durationSeconds)
69
+ duration = proposal.durationSeconds;
70
+ }
71
+ }
72
+ catch {
73
+ // Router / AskUser failed — fall through to legacy default.
74
+ }
75
+ }
31
76
  const estCost = estimateVideoCostUsd(duration);
32
77
  if (contentId && deps.library) {
33
78
  const content = deps.library.get(contentId);
package/dist/ui/app.js CHANGED
@@ -614,7 +614,16 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
614
614
  break;
615
615
  }
616
616
  case 'usage': {
617
- setCurrentModel(event.model);
617
+ // DO NOT setCurrentModel(event.model) here. currentModel
618
+ // represents the user's selection (e.g. 'blockrun/auto'),
619
+ // not what the router resolved for this specific turn. The
620
+ // per-turn resolved model is already captured in
621
+ // turnModelRef (rendered in the turn-summary line below
622
+ // each response) and in onModelChange('system') when the
623
+ // loop itself decides to swap (empty-response / 402 fallback).
624
+ // Overriding currentModel from every usage event made the
625
+ // status bar permanently show the last resolved model and
626
+ // create a false impression that auto mode was stuck.
618
627
  setTurnTokens(prev => ({
619
628
  input: prev.input + event.inputTokens,
620
629
  output: prev.output + event.outputTokens,
@@ -3,6 +3,15 @@
3
3
  * Lists recent sessions (newest first) and returns the selected ID.
4
4
  */
5
5
  import { type SessionMeta } from '../session/storage.js';
6
+ type SessionPickerSelection = {
7
+ kind: 'cancel';
8
+ } | {
9
+ kind: 'selected';
10
+ id: string;
11
+ } | {
12
+ kind: 'invalid';
13
+ message: string;
14
+ };
6
15
  /**
7
16
  * Resolve a user-provided session identifier to a full session ID.
8
17
  * Supports exact match and unambiguous prefix match (minimum 8 chars).
@@ -16,6 +25,7 @@ export declare function resolveSessionIdInput(input: string): {
16
25
  error: 'not-found' | 'ambiguous';
17
26
  candidates: SessionMeta[];
18
27
  };
28
+ export declare function resolvePickerSelection(input: string, shown: SessionMeta[], sessions: SessionMeta[]): SessionPickerSelection;
19
29
  /**
20
30
  * Find the most recent session for a given working directory.
21
31
  * Returns null if none exists.
@@ -28,3 +38,4 @@ export declare function findLatestSessionForDir(workDir: string): SessionMeta |
28
38
  export declare function pickSession(opts?: {
29
39
  workDir?: string;
30
40
  }): Promise<string | null>;
41
+ export {};
@@ -61,6 +61,33 @@ export function resolveSessionIdInput(input) {
61
61
  }
62
62
  return { ok: false, error: 'not-found', candidates: [] };
63
63
  }
64
+ export function resolvePickerSelection(input, shown, sessions) {
65
+ const trimmed = input.trim();
66
+ if (!trimmed)
67
+ return { kind: 'cancel' };
68
+ const num = parseInt(trimmed, 10);
69
+ if (!isNaN(num) && num >= 1 && num <= shown.length) {
70
+ return { kind: 'selected', id: shown[num - 1].id };
71
+ }
72
+ const exact = sessions.find((s) => s.id === trimmed);
73
+ if (exact)
74
+ return { kind: 'selected', id: exact.id };
75
+ const resolved = resolveSessionIdInput(trimmed);
76
+ if (resolved.ok) {
77
+ return { kind: 'selected', id: resolved.id };
78
+ }
79
+ if (resolved.error === 'ambiguous') {
80
+ const preview = resolved.candidates
81
+ .slice(0, 3)
82
+ .map((candidate) => candidate.id)
83
+ .join(', ');
84
+ return {
85
+ kind: 'invalid',
86
+ message: `Ambiguous session prefix: ${trimmed}${preview ? ` (${preview}${resolved.candidates.length > 3 ? ', …' : ''})` : ''}`,
87
+ };
88
+ }
89
+ return { kind: 'invalid', message: `No session found matching: ${trimmed}` };
90
+ }
64
91
  /**
65
92
  * Find the most recent session for a given working directory.
66
93
  * Returns null if none exists.
@@ -109,21 +136,23 @@ export async function pickSession(opts = {}) {
109
136
  terminal: process.stdin.isTTY ?? false,
110
137
  });
111
138
  return new Promise((resolve) => {
112
- rl.question(chalk.bold(' session> '), (answer) => {
113
- rl.close();
114
- const trimmed = answer.trim();
115
- if (!trimmed) {
116
- resolve(null);
117
- return;
118
- }
119
- const num = parseInt(trimmed, 10);
120
- if (!isNaN(num) && num >= 1 && num <= shown.length) {
121
- resolve(shown[num - 1].id);
122
- return;
123
- }
124
- // Allow raw ID as well
125
- const match = sessions.find(s => s.id === trimmed || s.id.startsWith(trimmed));
126
- resolve(match ? match.id : null);
127
- });
139
+ const prompt = () => {
140
+ rl.question(chalk.bold(' session> '), (answer) => {
141
+ const selection = resolvePickerSelection(answer, shown, sessions);
142
+ if (selection.kind === 'cancel') {
143
+ rl.close();
144
+ resolve(null);
145
+ return;
146
+ }
147
+ if (selection.kind === 'selected') {
148
+ rl.close();
149
+ resolve(selection.id);
150
+ return;
151
+ }
152
+ console.error(chalk.yellow(` ${selection.message}`));
153
+ prompt();
154
+ });
155
+ };
156
+ prompt();
128
157
  });
129
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.30",
3
+ "version": "3.8.32",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {