@blockrun/franklin 3.8.30 → 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 +1 -1
- package/dist/agent/context.js +3 -1
- package/dist/agent/media-router.d.ts +65 -0
- package/dist/agent/media-router.js +250 -0
- package/dist/gateway-models.d.ts +76 -0
- package/dist/gateway-models.js +139 -0
- package/dist/tools/imagegen.js +47 -2
- package/dist/tools/videogen.js +47 -2
- package/package.json +1 -1
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/
|
|
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.
|
package/dist/agent/context.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/imagegen.js
CHANGED
|
@@ -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
|
-
// ──
|
|
17
|
-
|
|
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) {
|
package/dist/tools/videogen.js
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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