@blockrun/franklin 3.8.29 → 3.8.31

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
+ }
@@ -274,6 +274,29 @@ a:hover { text-decoration:underline; }
274
274
  .empty { color:var(--text-dim); text-align:center; padding:56px 24px; font-size:13px; }
275
275
 
276
276
  /* ── Wallet page ── */
277
+ .chain-switcher {
278
+ display:inline-flex; padding:3px; gap:2px;
279
+ background:oklch(0 0 0 / 35%); border:1px solid var(--border);
280
+ border-radius:10px; margin-bottom:14px;
281
+ }
282
+ .chain-switcher button {
283
+ font-family:var(--mono); font-size:12px; font-weight:600;
284
+ letter-spacing:0.6px; text-transform:uppercase;
285
+ padding:7px 18px; border-radius:7px;
286
+ background:transparent; border:none; color:var(--text-muted);
287
+ cursor:pointer; transition:all .15s ease;
288
+ }
289
+ .chain-switcher button:hover:not(.active):not(:disabled) {
290
+ color:var(--text); background:oklch(1 0 0 / 5%);
291
+ }
292
+ .chain-switcher button.active {
293
+ background:var(--brand); color:#fff;
294
+ }
295
+ .chain-switcher button:disabled { opacity:0.5; cursor:wait; }
296
+ .chain-switcher-note {
297
+ margin-left:10px; font-size:12px; color:var(--text-dim);
298
+ font-style:italic;
299
+ }
277
300
  .wallet-grid { display:grid; grid-template-columns:1.1fr 1fr; gap:14px; }
278
301
  .wallet-grid .card { display:flex; flex-direction:column; gap:10px; }
279
302
  .wallet-receive { grid-row:span 2; align-items:flex-start; }
@@ -472,8 +495,14 @@ a:hover { text-decoration:underline; }
472
495
  <div class="tab" id="tab-wallet">
473
496
  <div class="content-header">
474
497
  <h2>Wallet</h2>
475
- <p>Receive USDC, back up your key, or import an existing wallet</p>
498
+ <p>Receive USDC, back up your key, or switch chains</p>
499
+ </div>
500
+
501
+ <div class="chain-switcher" role="tablist" aria-label="Payment chain">
502
+ <button type="button" data-chain="base" id="chain-btn-base" role="tab">Base</button>
503
+ <button type="button" data-chain="solana" id="chain-btn-solana" role="tab">Solana</button>
476
504
  </div>
505
+ <span class="chain-switcher-note" id="chain-switcher-note"></span>
477
506
 
478
507
  <div class="wallet-grid">
479
508
  <div class="card wallet-receive">
@@ -841,6 +870,14 @@ async function loadWallet() {
841
870
  document.getElementById('wallet-balance-big').textContent = usdBig(w.balance) + ' USDC';
842
871
  document.getElementById('wallet-chain-pill').textContent = w.chain || '—';
843
872
 
873
+ // Chain switcher — highlight active button
874
+ const baseBtn = document.getElementById('chain-btn-base');
875
+ const solanaBtn = document.getElementById('chain-btn-solana');
876
+ if (baseBtn && solanaBtn) {
877
+ baseBtn.classList.toggle('active', w.chain === 'base');
878
+ solanaBtn.classList.toggle('active', w.chain === 'solana');
879
+ }
880
+
844
881
  // QR via server — never leak address to third parties
845
882
  const qrBox = document.getElementById('wallet-qr');
846
883
  const hint = document.getElementById('wallet-qr-hint');
@@ -848,7 +885,7 @@ async function loadWallet() {
848
885
  const svg = await fetch('/api/wallet/qr?data=' + encodeURIComponent(addr)).then(r => r.ok ? r.text() : null);
849
886
  qrBox.innerHTML = svg || '';
850
887
  hint.textContent = w.chain === 'solana'
851
- ? 'Scan to send USDC (Solana) to this address.'
888
+ ? 'Scan to send USDC (Solana SPL) to this address.'
852
889
  : 'Scan to send USDC on Base to this address.';
853
890
  } else {
854
891
  qrBox.innerHTML = '';
@@ -856,6 +893,48 @@ async function loadWallet() {
856
893
  }
857
894
  }
858
895
 
896
+ // Chain switcher — click "Base" or "Solana" to flip payment chain.
897
+ // Creates a wallet on the target chain if one does not exist yet.
898
+ // Note: a currently-running franklin agent reads its chain at startup,
899
+ // so a mid-session switch only affects the next agent invocation.
900
+ ['chain-btn-base', 'chain-btn-solana'].forEach((id) => {
901
+ const btn = document.getElementById(id);
902
+ if (!btn) return;
903
+ btn.addEventListener('click', async () => {
904
+ const target = btn.getAttribute('data-chain');
905
+ const note = document.getElementById('chain-switcher-note');
906
+ const baseBtn = document.getElementById('chain-btn-base');
907
+ const solanaBtn = document.getElementById('chain-btn-solana');
908
+ // Skip if already active
909
+ if (btn.classList.contains('active')) return;
910
+ baseBtn.disabled = true;
911
+ solanaBtn.disabled = true;
912
+ note.textContent = 'Switching to ' + target + '…';
913
+ try {
914
+ const r = await fetch('/api/chain', {
915
+ method: 'POST',
916
+ headers: { 'Content-Type': 'application/json' },
917
+ body: JSON.stringify({ chain: target }),
918
+ });
919
+ const data = await r.json().catch(() => ({}));
920
+ if (!r.ok || !data.ok) {
921
+ note.textContent = 'Error: ' + (data.error || r.statusText);
922
+ return;
923
+ }
924
+ note.textContent = 'Switched to ' + target + ' · restart Franklin to use this chain';
925
+ await loadWallet();
926
+ // Sidebar balance + address also refresh
927
+ document.getElementById('sidebar-balance').textContent = usdBig(data.balance) + ' USDC';
928
+ document.getElementById('sidebar-addr').textContent = (data.address || '').slice(0, 6) + '…' + (data.address || '').slice(-4);
929
+ } catch (err) {
930
+ note.textContent = 'Error: ' + (err && err.message ? err.message : 'network error');
931
+ } finally {
932
+ baseBtn.disabled = false;
933
+ solanaBtn.disabled = false;
934
+ }
935
+ });
936
+ });
937
+
859
938
  // Copy button
860
939
  document.getElementById('wallet-copy-btn').addEventListener('click', async () => {
861
940
  const addr = document.getElementById('wallet-address-full').textContent;
@@ -6,7 +6,7 @@
6
6
  import http from 'node:http';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { BLOCKRUN_DIR, loadChain } from '../config.js';
9
+ import { BLOCKRUN_DIR, loadChain, saveChain } from '../config.js';
10
10
  import { getStatsSummary } from '../stats/tracker.js';
11
11
  import { generateInsights } from '../stats/insights.js';
12
12
  import { listSessions, loadSessionHistory } from '../session/storage.js';
@@ -313,6 +313,53 @@ export function createPanelServer(port) {
313
313
  }
314
314
  return;
315
315
  }
316
+ // ─── Chain switch (loopback only) ───────────────────────────────
317
+ // Switches the active payment chain (base ↔ solana) for subsequent
318
+ // Franklin runs. Writes ~/.blockrun/payment-chain, then ensures a
319
+ // wallet exists on the target chain (creates if missing). Returns
320
+ // the new wallet address + balance so the UI can re-render without
321
+ // a follow-up round trip.
322
+ //
323
+ // NOTE: a currently-running `franklin` agent reads the chain once
324
+ // at startup. The Panel switch takes effect immediately for Panel
325
+ // reads and for the *next* agent invocation, but won't flip chain
326
+ // mid-session for an already-running agent. UI copy makes this clear.
327
+ if (p === '/api/chain' && req.method === 'POST') {
328
+ if (!isLoopback(req)) {
329
+ json(res, { error: 'forbidden' }, 403);
330
+ return;
331
+ }
332
+ try {
333
+ const raw = await readBody(req);
334
+ const body = JSON.parse(raw);
335
+ const target = body.chain;
336
+ if (target !== 'base' && target !== 'solana') {
337
+ json(res, { error: 'chain must be "base" or "solana"' }, 400);
338
+ return;
339
+ }
340
+ saveChain(target);
341
+ // Creates-or-loads the wallet on the target chain.
342
+ let address = '';
343
+ let balance = 0;
344
+ if (target === 'solana') {
345
+ const { setupAgentSolanaWallet } = await import('@blockrun/llm');
346
+ const client = await setupAgentSolanaWallet({ silent: true });
347
+ address = await client.getWalletAddress();
348
+ balance = await client.getBalance();
349
+ }
350
+ else {
351
+ const { setupAgentWallet } = await import('@blockrun/llm');
352
+ const client = setupAgentWallet({ silent: true });
353
+ address = client.getWalletAddress();
354
+ balance = await client.getBalance();
355
+ }
356
+ json(res, { ok: true, chain: target, address, balance });
357
+ }
358
+ catch (err) {
359
+ json(res, { error: err.message }, 500);
360
+ }
361
+ return;
362
+ }
316
363
  if (p === '/api/markets') {
317
364
  // Snapshot of every active data provider for the Markets panel:
318
365
  // pipeline wiring (which endpoint serves which asset class), live
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.29",
3
+ "version": "3.8.31",
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": {