@blockrun/franklin 3.8.35 → 3.8.36
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/commands.js +1 -1
- package/dist/agent/compact.js +1 -1
- package/dist/agent/loop.js +19 -0
- package/dist/agent/optimize.js +1 -0
- package/dist/agent/permissions.js +10 -1
- package/dist/agent/tokens.js +4 -0
- package/dist/agent/types.d.ts +22 -1
- package/dist/commands/balance.js +1 -1
- package/dist/commands/daemon.js +23 -16
- package/dist/commands/plugin.d.ts +1 -1
- package/dist/commands/plugin.js +10 -10
- package/dist/commands/stats.d.ts +1 -1
- package/dist/commands/stats.js +2 -2
- package/dist/index.js +2 -2
- package/dist/panel/server.js +7 -6
- package/dist/plugin-sdk/index.d.ts +2 -2
- package/dist/plugin-sdk/index.js +2 -2
- package/dist/plugin-sdk/plugin.d.ts +4 -4
- package/dist/plugins/registry.d.ts +3 -3
- package/dist/plugins/registry.js +6 -6
- package/dist/pricing.js +1 -0
- package/dist/proxy/server.js +5 -3
- package/dist/router/index.js +3 -3
- package/dist/session/storage.js +2 -2
- package/dist/tools/imagegen.d.ts +14 -0
- package/dist/tools/imagegen.js +154 -22
- package/dist/tools/read.js +29 -2
- package/dist/tools/videogen.d.ts +14 -3
- package/dist/tools/videogen.js +161 -28
- package/dist/tools/webhook.js +2 -1
- package/dist/trading/providers/coingecko/client.js +2 -1
- package/dist/ui/app.js +12 -12
- package/dist/ui/model-picker.js +7 -4
- package/dist/wallet/index.d.ts +17 -0
- package/dist/wallet/index.js +22 -0
- package/package.json +7 -5
package/dist/tools/imagegen.js
CHANGED
|
@@ -9,13 +9,89 @@ import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
|
9
9
|
import { checkImageBudget, recordImageAsset } from '../content/record-image.js';
|
|
10
10
|
import { ModelClient } from '../agent/llm.js';
|
|
11
11
|
import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
|
|
12
|
+
import { recordUsage } from '../stats/tracker.js';
|
|
13
|
+
import { findModel, estimateCostUsd } from '../gateway-models.js';
|
|
14
|
+
/**
|
|
15
|
+
* Models that accept a reference image via /v1/images/image2image. Currently
|
|
16
|
+
* limited to OpenAI's edit endpoint — Gemini Nano Banana Pro and Grok Imagine
|
|
17
|
+
* Image Pro need gateway-side support before they can be wired in here.
|
|
18
|
+
*/
|
|
19
|
+
export const EDIT_SUPPORTED_MODELS = new Set([
|
|
20
|
+
'openai/gpt-image-1',
|
|
21
|
+
'openai/gpt-image-2',
|
|
22
|
+
]);
|
|
23
|
+
export const REFERENCE_IMAGE_MAX_BYTES = 4_000_000;
|
|
24
|
+
/**
|
|
25
|
+
* Normalize a reference image into a base64 data URI for the gateway. The
|
|
26
|
+
* /v1/images/image2image endpoint validates `image` against /^data:image\//,
|
|
27
|
+
* so http(s) URLs and local paths both have to be inlined client-side before
|
|
28
|
+
* posting. Already-formed data URIs pass through.
|
|
29
|
+
*/
|
|
30
|
+
export async function resolveReferenceImage(input, workingDir) {
|
|
31
|
+
if (input.startsWith('data:image/'))
|
|
32
|
+
return input;
|
|
33
|
+
if (/^https?:\/\//i.test(input)) {
|
|
34
|
+
const ctrl = new AbortController();
|
|
35
|
+
const timeout = setTimeout(() => ctrl.abort(), 30_000);
|
|
36
|
+
try {
|
|
37
|
+
const resp = await fetch(input, { signal: ctrl.signal });
|
|
38
|
+
if (!resp.ok) {
|
|
39
|
+
throw new Error(`Reference image fetch failed: ${resp.status} ${resp.statusText}`);
|
|
40
|
+
}
|
|
41
|
+
const contentType = (resp.headers.get('content-type') || '').toLowerCase().split(';')[0].trim();
|
|
42
|
+
if (!contentType.startsWith('image/')) {
|
|
43
|
+
throw new Error(`Reference image URL returned non-image content-type: ${contentType || '(none)'}`);
|
|
44
|
+
}
|
|
45
|
+
const buf = Buffer.from(await resp.arrayBuffer());
|
|
46
|
+
if (buf.byteLength > REFERENCE_IMAGE_MAX_BYTES) {
|
|
47
|
+
throw new Error(`Reference image too large: ${(buf.byteLength / 1_000_000).toFixed(1)}MB > ${(REFERENCE_IMAGE_MAX_BYTES / 1_000_000).toFixed(1)}MB cap.`);
|
|
48
|
+
}
|
|
49
|
+
return `data:${contentType};base64,${buf.toString('base64')}`;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Treat as local file path.
|
|
56
|
+
const resolved = path.isAbsolute(input) ? input : path.resolve(workingDir, input);
|
|
57
|
+
const stat = fs.statSync(resolved);
|
|
58
|
+
if (stat.size > REFERENCE_IMAGE_MAX_BYTES) {
|
|
59
|
+
throw new Error(`Reference image too large: ${(stat.size / 1_000_000).toFixed(1)}MB > ${(REFERENCE_IMAGE_MAX_BYTES / 1_000_000).toFixed(1)}MB cap. Resize or crop first.`);
|
|
60
|
+
}
|
|
61
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
62
|
+
const mimeMap = {
|
|
63
|
+
'.png': 'image/png',
|
|
64
|
+
'.jpg': 'image/jpeg',
|
|
65
|
+
'.jpeg': 'image/jpeg',
|
|
66
|
+
'.gif': 'image/gif',
|
|
67
|
+
'.webp': 'image/webp',
|
|
68
|
+
};
|
|
69
|
+
const mime = mimeMap[ext];
|
|
70
|
+
if (!mime) {
|
|
71
|
+
throw new Error(`Unsupported reference image extension ${ext || '(none)'}. Use .png/.jpg/.jpeg/.gif/.webp.`);
|
|
72
|
+
}
|
|
73
|
+
const bytes = fs.readFileSync(resolved);
|
|
74
|
+
return `data:${mime};base64,${bytes.toString('base64')}`;
|
|
75
|
+
}
|
|
12
76
|
function buildExecute(deps) {
|
|
13
77
|
return async function execute(input, ctx) {
|
|
14
78
|
const rawInput = input;
|
|
15
|
-
const { output_path, size, model, contentId } = rawInput;
|
|
79
|
+
const { output_path, size, model, contentId, image_url } = rawInput;
|
|
16
80
|
if (!rawInput.prompt) {
|
|
17
81
|
return { output: 'Error: prompt is required', isError: true };
|
|
18
82
|
}
|
|
83
|
+
// Resolve the reference image (if any) before any paid call so we fail
|
|
84
|
+
// cheaply on bad paths / oversize attachments. Holds the resolved data URI
|
|
85
|
+
// / http URL that gets posted to /v1/images/image2image.
|
|
86
|
+
let referenceImage;
|
|
87
|
+
if (image_url) {
|
|
88
|
+
try {
|
|
89
|
+
referenceImage = await resolveReferenceImage(image_url, ctx.workingDir);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return { output: `Error: ${err.message}`, isError: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
19
95
|
// One-shot refinement opt-out: leading `///` tells Franklin "don't
|
|
20
96
|
// refine this prompt, I wrote it the way I want it." Strip the prefix
|
|
21
97
|
// and pass skipRefine through to the router.
|
|
@@ -31,11 +107,26 @@ function buildExecute(deps) {
|
|
|
31
107
|
// step and use the old default. Otherwise: classifier picks a fitting
|
|
32
108
|
// model + rewrites the prompt, the preview goes to AskUser, user
|
|
33
109
|
// chooses or cancels.
|
|
34
|
-
|
|
110
|
+
// Reference-image mode forces an edit-capable model. If the caller named
|
|
111
|
+
// an unsupported one, fail loudly so we don't silently downgrade their
|
|
112
|
+
// request to text-only generation.
|
|
113
|
+
if (referenceImage && model && !EDIT_SUPPORTED_MODELS.has(model)) {
|
|
114
|
+
return {
|
|
115
|
+
output: `Error: model ${model} does not support reference images. ` +
|
|
116
|
+
`Use one of: ${[...EDIT_SUPPORTED_MODELS].join(', ')}.`,
|
|
117
|
+
isError: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
let imageModel = model || (referenceImage ? 'openai/gpt-image-2' : 'openai/gpt-image-1');
|
|
35
121
|
const imageSize = size || '1024x1024';
|
|
36
122
|
let chosenPrompt = prompt;
|
|
123
|
+
// Skip the proposal flow when a reference image is set: the media router
|
|
124
|
+
// doesn't know which models support image-to-image, so its suggestions
|
|
125
|
+
// would frequently be unusable (text-only models). Default to gpt-image-1
|
|
126
|
+
// for now; a future router upgrade can pick between the four edit-capable
|
|
127
|
+
// models based on the prompt.
|
|
37
128
|
const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1';
|
|
38
|
-
if (!model && !autoApprove && ctx.onAskUser) {
|
|
129
|
+
if (!model && !autoApprove && ctx.onAskUser && !referenceImage) {
|
|
39
130
|
try {
|
|
40
131
|
const chain = loadChain();
|
|
41
132
|
const client = new ModelClient({ apiUrl: API_URLS[chain], chain });
|
|
@@ -95,18 +186,30 @@ function buildExecute(deps) {
|
|
|
95
186
|
}
|
|
96
187
|
const chain = loadChain();
|
|
97
188
|
const apiUrl = API_URLS[chain];
|
|
98
|
-
|
|
189
|
+
// Reference-image mode hits the dedicated /v1/images/image2image endpoint;
|
|
190
|
+
// otherwise stay on text-to-image generations.
|
|
191
|
+
const endpoint = referenceImage
|
|
192
|
+
? `${apiUrl}/v1/images/image2image`
|
|
193
|
+
: `${apiUrl}/v1/images/generations`;
|
|
99
194
|
// Default output path
|
|
100
195
|
const outPath = output_path
|
|
101
196
|
? (path.isAbsolute(output_path) ? output_path : path.resolve(ctx.workingDir, output_path))
|
|
102
197
|
: path.resolve(ctx.workingDir, `generated-${Date.now()}.png`);
|
|
103
|
-
const body = JSON.stringify(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
198
|
+
const body = JSON.stringify(referenceImage
|
|
199
|
+
? {
|
|
200
|
+
model: imageModel,
|
|
201
|
+
prompt: chosenPrompt,
|
|
202
|
+
image: referenceImage,
|
|
203
|
+
size: imageSize,
|
|
204
|
+
n: 1,
|
|
205
|
+
}
|
|
206
|
+
: {
|
|
207
|
+
model: imageModel,
|
|
208
|
+
prompt: chosenPrompt,
|
|
209
|
+
n: 1,
|
|
210
|
+
size: imageSize,
|
|
211
|
+
response_format: 'b64_json',
|
|
212
|
+
});
|
|
110
213
|
const headers = {
|
|
111
214
|
'Content-Type': 'application/json',
|
|
112
215
|
'User-Agent': `franklin/${VERSION}`,
|
|
@@ -125,7 +228,7 @@ function buildExecute(deps) {
|
|
|
125
228
|
if (response.status === 402) {
|
|
126
229
|
const paymentHeaders = await signPayment(response, chain, endpoint);
|
|
127
230
|
if (!paymentHeaders) {
|
|
128
|
-
return { output: 'Payment failed. Check wallet balance with:
|
|
231
|
+
return { output: 'Payment failed. Check wallet balance with: franklin balance', isError: true };
|
|
129
232
|
}
|
|
130
233
|
response = await fetch(endpoint, {
|
|
131
234
|
method: 'POST',
|
|
@@ -143,12 +246,23 @@ function buildExecute(deps) {
|
|
|
143
246
|
if (!imageData) {
|
|
144
247
|
return { output: 'No image data returned from API', isError: true };
|
|
145
248
|
}
|
|
146
|
-
// Save image
|
|
249
|
+
// Save image. The /v1/images/image2image endpoint returns Gemini results
|
|
250
|
+
// as a data URI in `url`, so decode those locally instead of going through
|
|
251
|
+
// fetch — saves a network round-trip and avoids data:-URI fetch quirks.
|
|
147
252
|
if (imageData.b64_json) {
|
|
148
253
|
const buffer = Buffer.from(imageData.b64_json, 'base64');
|
|
149
254
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
150
255
|
fs.writeFileSync(outPath, buffer);
|
|
151
256
|
}
|
|
257
|
+
else if (imageData.url && imageData.url.startsWith('data:')) {
|
|
258
|
+
const match = imageData.url.match(/^data:[^;]+;base64,(.+)$/);
|
|
259
|
+
if (!match) {
|
|
260
|
+
return { output: 'Malformed data URI in response', isError: true };
|
|
261
|
+
}
|
|
262
|
+
const buffer = Buffer.from(match[1], 'base64');
|
|
263
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
264
|
+
fs.writeFileSync(outPath, buffer);
|
|
265
|
+
}
|
|
152
266
|
else if (imageData.url) {
|
|
153
267
|
// Download from URL (with 30s timeout)
|
|
154
268
|
const dlCtrl = new AbortController();
|
|
@@ -165,6 +279,20 @@ function buildExecute(deps) {
|
|
|
165
279
|
const fileSize = fs.statSync(outPath).size;
|
|
166
280
|
const sizeKB = (fileSize / 1024).toFixed(1);
|
|
167
281
|
const revisedPrompt = imageData.revised_prompt ? `\nRevised prompt: ${imageData.revised_prompt}` : '';
|
|
282
|
+
// Stats: record this generation so it shows up in `franklin insights`
|
|
283
|
+
// alongside chat spend. Before this, media generations bypassed
|
|
284
|
+
// recordUsage entirely (only LLM chat calls were tracked), so the
|
|
285
|
+
// insights panel under-reported total spend and never surfaced
|
|
286
|
+
// image-generation models in its "top models" list. Fire-and-forget —
|
|
287
|
+
// stats write must not fail a user-visible generation.
|
|
288
|
+
void (async () => {
|
|
289
|
+
try {
|
|
290
|
+
const m = await findModel(imageModel);
|
|
291
|
+
const estCost = m ? estimateCostUsd(m, { quantity: 1 }) : 0;
|
|
292
|
+
recordUsage(imageModel, 0, 0, estCost, 0);
|
|
293
|
+
}
|
|
294
|
+
catch { /* ignore stats errors */ }
|
|
295
|
+
})();
|
|
168
296
|
let contentSummary = '';
|
|
169
297
|
if (contentId && deps.library) {
|
|
170
298
|
const rec = recordImageAsset(deps.library, {
|
|
@@ -225,7 +353,7 @@ async function signPayment(response, chain, endpoint) {
|
|
|
225
353
|
const feePayer = details.extra?.feePayer || details.recipient;
|
|
226
354
|
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
227
355
|
resourceUrl: details.resource?.url || endpoint,
|
|
228
|
-
resourceDescription: details.resource?.description || '
|
|
356
|
+
resourceDescription: details.resource?.description || 'Franklin image generation',
|
|
229
357
|
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
230
358
|
extra: details.extra,
|
|
231
359
|
});
|
|
@@ -237,7 +365,7 @@ async function signPayment(response, chain, endpoint) {
|
|
|
237
365
|
const details = extractPaymentDetails(paymentRequired);
|
|
238
366
|
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
239
367
|
resourceUrl: details.resource?.url || endpoint,
|
|
240
|
-
resourceDescription: details.resource?.description || '
|
|
368
|
+
resourceDescription: details.resource?.description || 'Franklin image generation',
|
|
241
369
|
maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
|
|
242
370
|
extra: details.extra,
|
|
243
371
|
});
|
|
@@ -272,13 +400,16 @@ export function createImageGenCapability(deps = {}) {
|
|
|
272
400
|
return {
|
|
273
401
|
spec: {
|
|
274
402
|
name: 'ImageGen',
|
|
275
|
-
description: "Generate an image from a text prompt
|
|
276
|
-
"
|
|
277
|
-
"
|
|
278
|
-
"
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
"
|
|
403
|
+
description: "Generate an image from a text prompt — optionally with a reference " +
|
|
404
|
+
"image for style transfer / character consistency / edits. Costs USDC " +
|
|
405
|
+
"from the user's wallet — confirm before generating. Saves to a local " +
|
|
406
|
+
"file. Default size: 1024x1024. Do NOT call repeatedly to iterate on " +
|
|
407
|
+
"style — ask the user first. Pass contentId to attach the result to " +
|
|
408
|
+
"an existing Content piece: the content's budget is checked BEFORE " +
|
|
409
|
+
"paying, and on success the image is recorded as an asset with its " +
|
|
410
|
+
"estimated cost. Skipping contentId generates a one-off image with no " +
|
|
411
|
+
"budget tracking. When image_url is set, only edit-capable models " +
|
|
412
|
+
"(openai/gpt-image-1, openai/gpt-image-2) are accepted.",
|
|
282
413
|
input_schema: {
|
|
283
414
|
type: 'object',
|
|
284
415
|
properties: {
|
|
@@ -286,6 +417,7 @@ export function createImageGenCapability(deps = {}) {
|
|
|
286
417
|
output_path: { type: 'string', description: 'Where to save the image. Default: generated-<timestamp>.png in working directory' },
|
|
287
418
|
size: { type: 'string', description: 'Image size: 1024x1024, 1792x1024, or 1024x1792. Default: 1024x1024' },
|
|
288
419
|
model: { type: 'string', description: 'Image model to use. Default: openai/gpt-image-1' },
|
|
420
|
+
image_url: { type: 'string', description: 'Optional reference image (image-to-image / style transfer). Accepts an http(s) URL, a data URI, or a local file path. Only works with edit-capable models.' },
|
|
289
421
|
contentId: { type: 'string', description: 'Optional Content id to attach this generation to. Pre-flight budget check + auto-record on success.' },
|
|
290
422
|
},
|
|
291
423
|
required: ['prompt'],
|
package/dist/tools/read.js
CHANGED
|
@@ -84,7 +84,34 @@ async function execute(input, ctx) {
|
|
|
84
84
|
// (some binaries have no extension: `.env.enc`, `.data`, compiled tools
|
|
85
85
|
// without suffixes, etc. Content sniff catches those.)
|
|
86
86
|
const ext = path.extname(resolved).toLowerCase();
|
|
87
|
-
|
|
87
|
+
// Image extensions → load as vision content so models with vision (Sonnet,
|
|
88
|
+
// GPT-4o, Gemini) actually see the bytes instead of a "Binary file" stub.
|
|
89
|
+
// The agent loop wraps `images` into tool_result.content for provider APIs.
|
|
90
|
+
const IMAGE_MEDIA_TYPES = {
|
|
91
|
+
'.png': 'image/png',
|
|
92
|
+
'.jpg': 'image/jpeg',
|
|
93
|
+
'.jpeg': 'image/jpeg',
|
|
94
|
+
'.gif': 'image/gif',
|
|
95
|
+
'.webp': 'image/webp',
|
|
96
|
+
};
|
|
97
|
+
if (IMAGE_MEDIA_TYPES[ext]) {
|
|
98
|
+
const sizeStr = stat.size >= 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
|
|
99
|
+
// Anthropic accepts up to 5MB base64; cap raw bytes at ~3.75MB to be safe.
|
|
100
|
+
const IMAGE_MAX_BYTES = 3_750_000;
|
|
101
|
+
if (stat.size > IMAGE_MAX_BYTES) {
|
|
102
|
+
return {
|
|
103
|
+
output: `Image file: ${resolved} (${ext}, ${sizeStr}). Too large to inline for vision (>${Math.round(IMAGE_MAX_BYTES / 1_000_000)}MB). Resize or crop first.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const bytes = fs.readFileSync(resolved);
|
|
107
|
+
const base64 = bytes.toString('base64');
|
|
108
|
+
fileReadTracker.set(resolved, { mtimeMs: stat.mtimeMs, readAt: Date.now() });
|
|
109
|
+
return {
|
|
110
|
+
output: `Image file: ${resolved} (${ext}, ${sizeStr}). Rendered below for vision-capable models.`,
|
|
111
|
+
images: [{ mediaType: IMAGE_MEDIA_TYPES[ext], base64 }],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const binaryExts = new Set(['.ico', '.bmp', '.pdf', '.zip', '.tar', '.gz', '.woff', '.woff2', '.ttf', '.eot', '.mp3', '.mp4', '.wav', '.avi', '.mov', '.exe', '.dll', '.so', '.dylib']);
|
|
88
115
|
if (binaryExts.has(ext)) {
|
|
89
116
|
const sizeStr = stat.size >= 1024 ? `${(stat.size / 1024).toFixed(1)}KB` : `${stat.size}B`;
|
|
90
117
|
return { output: `Binary file: ${resolved} (${ext}, ${sizeStr}). Cannot display contents.` };
|
|
@@ -163,7 +190,7 @@ Usage:
|
|
|
163
190
|
- This tool can only read files, not directories. To list a directory, use Glob or ls via Bash.
|
|
164
191
|
- If you read a file that exists but has empty contents you will receive a warning.
|
|
165
192
|
- Reads over 2MB are rejected — use offset/limit to read portions.
|
|
166
|
-
-
|
|
193
|
+
- Image files (.png, .jpg, .jpeg, .gif, .webp) are loaded as vision content — vision-capable models see the actual image. Other binary files (PDFs, archives, fonts) cannot be displayed.
|
|
167
194
|
- You will regularly be asked to read screenshots or images. If the user provides a path, ALWAYS use this tool to view it.
|
|
168
195
|
|
|
169
196
|
IMPORTANT: Always use Read instead of cat, head, or tail via Bash. This tool provides line numbers and integrates with Edit's read-before-edit enforcement.`,
|
package/dist/tools/videogen.d.ts
CHANGED
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
* /v1/videos/generations endpoint. Uses x402 payment (Base or Solana).
|
|
4
4
|
*
|
|
5
5
|
* Default model `xai/grok-imagine-video` returns an 8-second clip for ~$0.42.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Seedance 2.0 (bytedance/seedance-2.0 and -fast) runs longer — up to a few
|
|
7
|
+
* minutes for a 10s clip.
|
|
8
|
+
*
|
|
9
|
+
* Flow (async since blockrun@654cd35):
|
|
10
|
+
* 1. POST /v1/videos/generations with signed x-payment header. The server
|
|
11
|
+
* verifies payment (does NOT settle), submits the upstream job, and
|
|
12
|
+
* returns 202 { id, poll_url, status: "queued" }.
|
|
13
|
+
* 2. GET the poll_url with the SAME x-payment header every ~5s until
|
|
14
|
+
* status=completed. On the first completed poll the server backs up
|
|
15
|
+
* the MP4 to GCS, settles payment, and returns the video URL.
|
|
16
|
+
* 3. Download the MP4 and write it locally.
|
|
17
|
+
*
|
|
18
|
+
* If the upstream job fails, the server returns status=failed and no USDC
|
|
19
|
+
* is ever transferred. If the client never polls, no charge either.
|
|
9
20
|
*/
|
|
10
21
|
import type { CapabilityHandler } from '../agent/types.js';
|
|
11
22
|
import type { ContentLibrary } from '../content/library.js';
|
package/dist/tools/videogen.js
CHANGED
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
* /v1/videos/generations endpoint. Uses x402 payment (Base or Solana).
|
|
4
4
|
*
|
|
5
5
|
* Default model `xai/grok-imagine-video` returns an 8-second clip for ~$0.42.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Seedance 2.0 (bytedance/seedance-2.0 and -fast) runs longer — up to a few
|
|
7
|
+
* minutes for a 10s clip.
|
|
8
|
+
*
|
|
9
|
+
* Flow (async since blockrun@654cd35):
|
|
10
|
+
* 1. POST /v1/videos/generations with signed x-payment header. The server
|
|
11
|
+
* verifies payment (does NOT settle), submits the upstream job, and
|
|
12
|
+
* returns 202 { id, poll_url, status: "queued" }.
|
|
13
|
+
* 2. GET the poll_url with the SAME x-payment header every ~5s until
|
|
14
|
+
* status=completed. On the first completed poll the server backs up
|
|
15
|
+
* the MP4 to GCS, settles payment, and returns the video URL.
|
|
16
|
+
* 3. Download the MP4 and write it locally.
|
|
17
|
+
*
|
|
18
|
+
* If the upstream job fails, the server returns status=failed and no USDC
|
|
19
|
+
* is ever transferred. If the client never polls, no charge either.
|
|
9
20
|
*/
|
|
10
21
|
import fs from 'node:fs';
|
|
11
22
|
import path from 'node:path';
|
|
@@ -13,12 +24,17 @@ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, creat
|
|
|
13
24
|
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
14
25
|
import { ModelClient } from '../agent/llm.js';
|
|
15
26
|
import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
|
|
27
|
+
import { recordUsage } from '../stats/tracker.js';
|
|
28
|
+
import { findModel, estimateCostUsd } from '../gateway-models.js';
|
|
16
29
|
const DEFAULT_MODEL = 'xai/grok-imagine-video';
|
|
17
30
|
const DEFAULT_DURATION = 8;
|
|
18
31
|
const PRICE_PER_SECOND_USD = 0.05;
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
32
|
+
// POST submit is fast (~3-20s). Generation is async upstream (60-300s for
|
|
33
|
+
// Seedance, 20-90s for Grok). We poll until completed, then download. The
|
|
34
|
+
// server signs authorizations for 600s — keep the overall budget below that.
|
|
35
|
+
const SUBMIT_TIMEOUT_MS = 30_000;
|
|
36
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
37
|
+
const POLL_MAX_WAIT_MS = 480_000; // 8 min — covers Seedance worst case
|
|
22
38
|
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
23
39
|
function estimateVideoCostUsd(durationSeconds = DEFAULT_DURATION) {
|
|
24
40
|
return Math.max(1, durationSeconds) * PRICE_PER_SECOND_USD;
|
|
@@ -121,26 +137,32 @@ function buildExecute(deps) {
|
|
|
121
137
|
'Content-Type': 'application/json',
|
|
122
138
|
'User-Agent': `franklin/${VERSION}`,
|
|
123
139
|
};
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
140
|
+
const onAbort = (ctrl) => () => ctrl.abort();
|
|
141
|
+
// Phase 1: submit the job. First POST triggers a 402; we sign and retry.
|
|
142
|
+
// The signed paymentHeaders must be reused on every GET poll — the server
|
|
143
|
+
// uses the authorization to verify identity on each poll and settles on
|
|
144
|
+
// the first completed response.
|
|
145
|
+
const submitCtrl = new AbortController();
|
|
146
|
+
const submitTimeout = setTimeout(() => submitCtrl.abort(), SUBMIT_TIMEOUT_MS);
|
|
147
|
+
const submitAbort = onAbort(submitCtrl);
|
|
148
|
+
ctx.abortSignal.addEventListener('abort', submitAbort, { once: true });
|
|
149
|
+
let paymentHeaders = null;
|
|
150
|
+
let submitResult;
|
|
129
151
|
try {
|
|
130
152
|
let response = await fetch(endpoint, {
|
|
131
153
|
method: 'POST',
|
|
132
|
-
signal:
|
|
154
|
+
signal: submitCtrl.signal,
|
|
133
155
|
headers,
|
|
134
156
|
body,
|
|
135
157
|
});
|
|
136
158
|
if (response.status === 402) {
|
|
137
|
-
|
|
159
|
+
paymentHeaders = await signPayment(response, chain, endpoint);
|
|
138
160
|
if (!paymentHeaders) {
|
|
139
161
|
return { output: 'Payment failed. Check wallet balance with: franklin balance', isError: true };
|
|
140
162
|
}
|
|
141
163
|
response = await fetch(endpoint, {
|
|
142
164
|
method: 'POST',
|
|
143
|
-
signal:
|
|
165
|
+
signal: submitCtrl.signal,
|
|
144
166
|
headers: { ...headers, ...paymentHeaders },
|
|
145
167
|
body,
|
|
146
168
|
});
|
|
@@ -148,22 +170,71 @@ function buildExecute(deps) {
|
|
|
148
170
|
if (!response.ok) {
|
|
149
171
|
const errText = await response.text().catch(() => '');
|
|
150
172
|
return {
|
|
151
|
-
output: `Video
|
|
173
|
+
output: `Video submit failed (${response.status}): ${errText.slice(0, 300)}`,
|
|
152
174
|
isError: true,
|
|
153
175
|
};
|
|
154
176
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
177
|
+
submitResult = await response.json();
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
const msg = err.message || '';
|
|
181
|
+
if (msg.includes('abort')) {
|
|
182
|
+
return {
|
|
183
|
+
output: `Video submit timed out or was aborted after ${Math.round(SUBMIT_TIMEOUT_MS / 1000)}s.`,
|
|
184
|
+
isError: true,
|
|
185
|
+
};
|
|
159
186
|
}
|
|
187
|
+
return { output: `Error submitting video job: ${msg}`, isError: true };
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
clearTimeout(submitTimeout);
|
|
191
|
+
ctx.abortSignal.removeEventListener('abort', submitAbort);
|
|
192
|
+
}
|
|
193
|
+
if (!submitResult.poll_url || !paymentHeaders) {
|
|
194
|
+
return { output: 'API did not return a poll_url for the video job', isError: true };
|
|
195
|
+
}
|
|
196
|
+
// Phase 2: poll GET /v1/videos/generations/{id} with the SAME signed
|
|
197
|
+
// x-payment header until the job completes. Server settles on the first
|
|
198
|
+
// completed poll and returns the backed-up video URL.
|
|
199
|
+
const origin = new URL(apiUrl).origin;
|
|
200
|
+
const pollEndpoint = submitResult.poll_url.startsWith('http')
|
|
201
|
+
? submitResult.poll_url
|
|
202
|
+
: `${origin}${submitResult.poll_url}`;
|
|
203
|
+
const outcome = await pollUntilReady(pollEndpoint, { ...headers, ...paymentHeaders }, ctx.abortSignal);
|
|
204
|
+
if (outcome.kind === 'timed_out') {
|
|
205
|
+
return {
|
|
206
|
+
output: `Video generation did not complete within ${Math.round(POLL_MAX_WAIT_MS / 1000)}s. ` +
|
|
207
|
+
`No USDC was charged (settlement only fires on completion).`,
|
|
208
|
+
isError: true,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (outcome.kind === 'failed') {
|
|
212
|
+
return {
|
|
213
|
+
output: `Video generation failed upstream: ${outcome.error ?? 'unknown error'}. No USDC was charged.`,
|
|
214
|
+
isError: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const videoData = outcome.data;
|
|
218
|
+
const videoUrl = videoData.url;
|
|
219
|
+
if (!videoUrl) {
|
|
220
|
+
return { output: 'No video URL returned from API', isError: true };
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
160
223
|
// Download the MP4
|
|
161
224
|
const dlCtrl = new AbortController();
|
|
162
225
|
const dlTimeout = setTimeout(() => dlCtrl.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
163
|
-
const
|
|
164
|
-
|
|
226
|
+
const dlAbort = onAbort(dlCtrl);
|
|
227
|
+
ctx.abortSignal.addEventListener('abort', dlAbort, { once: true });
|
|
228
|
+
let vidResp;
|
|
229
|
+
try {
|
|
230
|
+
vidResp = await fetch(videoUrl, { signal: dlCtrl.signal });
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
clearTimeout(dlTimeout);
|
|
234
|
+
ctx.abortSignal.removeEventListener('abort', dlAbort);
|
|
235
|
+
}
|
|
165
236
|
if (!vidResp.ok) {
|
|
166
|
-
return { output: `Video fetched URL but download failed (${vidResp.status}): ${
|
|
237
|
+
return { output: `Video fetched URL but download failed (${vidResp.status}): ${videoUrl}`, isError: true };
|
|
167
238
|
}
|
|
168
239
|
const buffer = Buffer.from(await vidResp.arrayBuffer());
|
|
169
240
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
@@ -171,6 +242,21 @@ function buildExecute(deps) {
|
|
|
171
242
|
const fileSize = fs.statSync(outPath).size;
|
|
172
243
|
const sizeMB = (fileSize / 1_048_576).toFixed(1);
|
|
173
244
|
const dur = videoData.duration_seconds ?? duration;
|
|
245
|
+
// Stats: record this generation so it shows up in `franklin insights`
|
|
246
|
+
// alongside chat spend. Before this, media generations bypassed
|
|
247
|
+
// recordUsage entirely, so the insights panel under-reported total
|
|
248
|
+
// spend and never surfaced video models in its "top models" list.
|
|
249
|
+
// Prefer the live gateway price when the model is in the catalog;
|
|
250
|
+
// fall back to the legacy $0.05/s estimate otherwise. Fire-and-
|
|
251
|
+
// forget — stats write must not fail a user-visible generation.
|
|
252
|
+
void (async () => {
|
|
253
|
+
try {
|
|
254
|
+
const m = await findModel(videoModel);
|
|
255
|
+
const estCost = m ? estimateCostUsd(m, { duration_seconds: dur }) : estimateVideoCostUsd(dur);
|
|
256
|
+
recordUsage(videoModel, 0, 0, estCost, 0);
|
|
257
|
+
}
|
|
258
|
+
catch { /* ignore stats errors */ }
|
|
259
|
+
})();
|
|
174
260
|
let contentSummary = '';
|
|
175
261
|
if (contentId && deps.library) {
|
|
176
262
|
const rec = deps.library.addAsset(contentId, {
|
|
@@ -208,18 +294,63 @@ function buildExecute(deps) {
|
|
|
208
294
|
const msg = err.message || '';
|
|
209
295
|
if (msg.includes('abort')) {
|
|
210
296
|
return {
|
|
211
|
-
output: `Video
|
|
297
|
+
output: `Video download timed out or was aborted after ${Math.round(DOWNLOAD_TIMEOUT_MS / 1000)}s.`,
|
|
212
298
|
isError: true,
|
|
213
299
|
};
|
|
214
300
|
}
|
|
215
301
|
return { output: `Error: ${msg}`, isError: true };
|
|
216
302
|
}
|
|
217
|
-
finally {
|
|
218
|
-
clearTimeout(timeout);
|
|
219
|
-
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
220
|
-
}
|
|
221
303
|
};
|
|
222
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Poll the GET /v1/videos/generations/{id} endpoint until the job reaches a
|
|
307
|
+
* terminal state. Reuses the caller's signed x-payment header verbatim on
|
|
308
|
+
* every request — the server verifies the same authorization each poll and
|
|
309
|
+
* settles on the first completed response.
|
|
310
|
+
*/
|
|
311
|
+
async function pollUntilReady(pollEndpoint, headers, userAbort) {
|
|
312
|
+
const deadline = Date.now() + POLL_MAX_WAIT_MS;
|
|
313
|
+
while (Date.now() < deadline) {
|
|
314
|
+
if (userAbort.aborted)
|
|
315
|
+
throw new Error('aborted');
|
|
316
|
+
const resp = await fetch(pollEndpoint, { method: 'GET', headers, signal: userAbort });
|
|
317
|
+
// 202 = still queued/in_progress; 200 = completed or failed.
|
|
318
|
+
if (resp.status === 202 || resp.status === 200) {
|
|
319
|
+
const body = (await resp.json().catch(() => ({})));
|
|
320
|
+
if (body.status === 'completed' && body.data?.[0]?.url) {
|
|
321
|
+
return { kind: 'completed', data: body.data[0] };
|
|
322
|
+
}
|
|
323
|
+
if (body.status === 'failed') {
|
|
324
|
+
return { kind: 'failed', error: body.error };
|
|
325
|
+
}
|
|
326
|
+
// queued / in_progress — sleep and try again.
|
|
327
|
+
}
|
|
328
|
+
else if (resp.status === 429 || resp.status >= 500) {
|
|
329
|
+
// Transient — back off briefly. Fall through to the sleep below.
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
const text = await resp.text().catch(() => '');
|
|
333
|
+
throw new Error(`Poll failed (${resp.status}): ${text.slice(0, 300)}`);
|
|
334
|
+
}
|
|
335
|
+
await sleep(POLL_INTERVAL_MS, userAbort);
|
|
336
|
+
}
|
|
337
|
+
return { kind: 'timed_out' };
|
|
338
|
+
}
|
|
339
|
+
function sleep(ms, signal) {
|
|
340
|
+
return new Promise((resolve, reject) => {
|
|
341
|
+
if (signal.aborted)
|
|
342
|
+
return reject(new Error('aborted'));
|
|
343
|
+
const t = setTimeout(() => {
|
|
344
|
+
signal.removeEventListener('abort', onAbort);
|
|
345
|
+
resolve();
|
|
346
|
+
}, ms);
|
|
347
|
+
const onAbort = () => {
|
|
348
|
+
clearTimeout(t);
|
|
349
|
+
reject(new Error('aborted'));
|
|
350
|
+
};
|
|
351
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
352
|
+
});
|
|
353
|
+
}
|
|
223
354
|
// ─── Payment ───────────────────────────────────────────────────────────────
|
|
224
355
|
async function signPayment(response, chain, endpoint) {
|
|
225
356
|
try {
|
|
@@ -235,7 +366,9 @@ async function signPayment(response, chain, endpoint) {
|
|
|
235
366
|
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
236
367
|
resourceUrl: details.resource?.url || endpoint,
|
|
237
368
|
resourceDescription: details.resource?.description || 'Franklin video generation',
|
|
238
|
-
|
|
369
|
+
// Video poll can take up to 8 min; honor the server's advertised
|
|
370
|
+
// value (blockrun sends 600s) and fall back to 600 not 300.
|
|
371
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 600,
|
|
239
372
|
extra: details.extra,
|
|
240
373
|
});
|
|
241
374
|
return { 'PAYMENT-SIGNATURE': payload };
|
package/dist/tools/webhook.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* session.
|
|
16
16
|
*/
|
|
17
17
|
import { isIP } from 'node:net';
|
|
18
|
+
import { VERSION } from '../config.js';
|
|
18
19
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
19
20
|
const MAX_BODY_BYTES = 512 * 1024; // 512 KB is generous for a chat push.
|
|
20
21
|
function isPrivateHost(hostname) {
|
|
@@ -101,7 +102,7 @@ async function execute(input, ctx) {
|
|
|
101
102
|
}
|
|
102
103
|
const finalHeaders = {
|
|
103
104
|
'Content-Type': contentType,
|
|
104
|
-
'User-Agent':
|
|
105
|
+
'User-Agent': `franklin/${VERSION} (webhook)`,
|
|
105
106
|
...(headers ?? {}),
|
|
106
107
|
};
|
|
107
108
|
const ctrl = new AbortController();
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* cooldown, user-agent, timeout, and in-memory cache.
|
|
7
7
|
*/
|
|
8
8
|
import { recordFetch } from '../telemetry.js';
|
|
9
|
+
import { VERSION } from '../../../config.js';
|
|
9
10
|
const BASE = 'https://api.coingecko.com/api/v3';
|
|
10
|
-
const UA =
|
|
11
|
+
const UA = `franklin/${VERSION} (trading)`;
|
|
11
12
|
const TIMEOUT_MS = 10_000;
|
|
12
13
|
// Ticker → CoinGecko slug. Not exhaustive; unknown tickers fall through to
|
|
13
14
|
// lowercase and let CoinGecko either accept the slug or 404.
|