@blockrun/franklin 3.15.50 → 3.15.52

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/dist/agent/llm.js CHANGED
@@ -431,11 +431,64 @@ export class ModelClient {
431
431
  if (!response.ok) {
432
432
  const errorBody = await response.text().catch(() => 'unknown error');
433
433
  const message = extractApiErrorMessage(errorBody);
434
- yield {
435
- kind: 'error',
436
- payload: { status: response.status, message },
437
- };
438
- return;
434
+ // Runtime tool_choice retry. The static allowlist at line ~35
435
+ // catches the case where the request goes directly to a model
436
+ // whose name contains `deepseek-reasoner` / `openai/o1` /
437
+ // `openai/o3`. But the gateway sometimes ALIASES a different
438
+ // model name to a reasoner backend — verified 2026-05-04 in a
439
+ // live session: a request for `deepseek/deepseek-v4-pro`
440
+ // returned `400 Invalid request: 400 deepseek-reasoner does not
441
+ // support this tool_choice`, because the gateway routed v4-pro
442
+ // to a deepseek-reasoner upstream. The static allowlist can't
443
+ // know that. Catch the error, drop tool_choice, re-fire once.
444
+ // No payment re-sign needed — original 402 already settled, and
445
+ // the gateway treats this as the same logical request.
446
+ const lc = message.toLowerCase();
447
+ const looksLikeToolChoiceReject = response.status === 400 &&
448
+ lc.includes('tool_choice') &&
449
+ (lc.includes('not support') || lc.includes('unsupported') || lc.includes('does not support'));
450
+ if (looksLikeToolChoiceReject && requestPayload['tool_choice'] !== undefined) {
451
+ delete requestPayload['tool_choice'];
452
+ const retryBody = JSON.stringify(requestPayload);
453
+ if (this.debug) {
454
+ console.error(`[franklin] tool_choice rejected by upstream; retrying without it (model=${request.model})`);
455
+ }
456
+ response = await withAbortableTimeout(() => fetch(endpoint, {
457
+ method: 'POST',
458
+ headers,
459
+ body: retryBody,
460
+ signal: requestController.signal,
461
+ }), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
462
+ if (response.status === 402) {
463
+ const paymentHeader = await this.signPayment(response);
464
+ if (!paymentHeader) {
465
+ yield { kind: 'error', payload: { message: 'Payment signing failed' } };
466
+ return;
467
+ }
468
+ response = await withAbortableTimeout(() => fetch(endpoint, {
469
+ method: 'POST',
470
+ headers: { ...headers, ...paymentHeader },
471
+ body: retryBody,
472
+ signal: requestController.signal,
473
+ }), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
474
+ }
475
+ if (!response.ok) {
476
+ const retryBodyText = await response.text().catch(() => 'unknown error');
477
+ yield {
478
+ kind: 'error',
479
+ payload: { status: response.status, message: extractApiErrorMessage(retryBodyText) },
480
+ };
481
+ return;
482
+ }
483
+ // Successful retry — fall through to SSE parsing below.
484
+ }
485
+ else {
486
+ yield {
487
+ kind: 'error',
488
+ payload: { status: response.status, message },
489
+ };
490
+ return;
491
+ }
439
492
  }
440
493
  // Parse SSE stream
441
494
  yield* this.parseSSEStream(response, requestController, streamTimeoutMs, request.model);
@@ -23,6 +23,7 @@ import path from 'node:path';
23
23
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
24
24
  import { loadChain, API_URLS, VERSION } from '../config.js';
25
25
  import { logger } from '../logger.js';
26
+ import { resolveReferenceImage } from './imagegen.js';
26
27
  import { ModelClient } from '../agent/llm.js';
27
28
  import { analyzeMediaRequest, renderProposalForAskUser } from '../agent/media-router.js';
28
29
  import { recordUsage } from '../stats/tracker.js';
@@ -46,6 +47,28 @@ function buildExecute(deps) {
46
47
  const { output_path, model, image_url, duration_seconds, contentId } = rawInput;
47
48
  if (!rawInput.prompt)
48
49
  return { output: 'Error: prompt is required', isError: true };
50
+ // Resolve image_url before sending. The gateway requires a URL (http(s)
51
+ // or data: URI), but agents naturally pass a local file path —
52
+ // verified 2026-05-04 in a live session: agent passed
53
+ // `/Users/.../keyframe.png` and the gateway returned
54
+ // `400 Invalid request body: invalid_format url path: image_url`.
55
+ // ImageGen already had `resolveReferenceImage` for the same problem;
56
+ // sharing the helper keeps the contract consistent across both tools
57
+ // (local path → base64 data URI; http(s) URL → fetched + inlined;
58
+ // data: URI → passes through). On any resolution failure, surface
59
+ // the message instead of letting the gateway 400 bubble back.
60
+ let resolvedImageUrl;
61
+ if (image_url) {
62
+ try {
63
+ resolvedImageUrl = await resolveReferenceImage(image_url, ctx.workingDir);
64
+ }
65
+ catch (err) {
66
+ return {
67
+ output: `Could not resolve image_url ${JSON.stringify(image_url)}: ${err.message}`,
68
+ isError: true,
69
+ };
70
+ }
71
+ }
49
72
  // One-shot refinement opt-out: leading `///` tells Franklin "don't
50
73
  // refine this prompt." Strip the prefix and pass skipRefine through.
51
74
  let prompt = rawInput.prompt;
@@ -131,7 +154,7 @@ function buildExecute(deps) {
131
154
  const body = JSON.stringify({
132
155
  model: videoModel,
133
156
  prompt: chosenPrompt,
134
- ...(image_url ? { image_url } : {}),
157
+ ...(resolvedImageUrl ? { image_url: resolvedImageUrl } : {}),
135
158
  ...(duration_seconds ? { duration_seconds } : {}),
136
159
  });
137
160
  const headers = {
@@ -440,7 +463,7 @@ export function createVideoGenCapability(deps = {}) {
440
463
  prompt: { type: 'string', description: 'Text description of the video to generate' },
441
464
  output_path: { type: 'string', description: 'Where to save the MP4. Default: generated-<timestamp>.mp4 in working directory' },
442
465
  model: { type: 'string', description: 'Video model. Default: xai/grok-imagine-video' },
443
- image_url: { type: 'string', description: 'Optional seed image URL (image-to-video)' },
466
+ image_url: { type: 'string', description: 'Optional seed image (image-to-video). Accepts http(s) URL, data: URI, or local file path — local paths get inlined as base64 data URIs automatically.' },
444
467
  duration_seconds: { type: 'number', description: 'Duration billed for. Default depends on model (8s for grok-imagine-video).' },
445
468
  contentId: { type: 'string', description: 'Optional Content id to attach and budget against.' },
446
469
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.50",
3
+ "version": "3.15.52",
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": {