@blockrun/franklin 3.8.38 → 3.8.40

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
@@ -15,12 +15,12 @@ function parseTimeoutEnv(name) {
15
15
  function getModelRequestTimeoutMs() {
16
16
  return (parseTimeoutEnv('FRANKLIN_MODEL_REQUEST_TIMEOUT_MS') ??
17
17
  parseTimeoutEnv('FRANKLIN_MODEL_IDLE_TIMEOUT_MS') ??
18
- 8_000);
18
+ 45_000);
19
19
  }
20
20
  function getModelStreamIdleTimeoutMs() {
21
21
  return (parseTimeoutEnv('FRANKLIN_MODEL_STREAM_IDLE_TIMEOUT_MS') ??
22
22
  parseTimeoutEnv('FRANKLIN_MODEL_IDLE_TIMEOUT_MS') ??
23
- 25_000);
23
+ 90_000);
24
24
  }
25
25
  function linkAbortSignal(parent, child) {
26
26
  if (!parent)
@@ -16,6 +16,7 @@ import { resetToolSessionState } from '../tools/index.js';
16
16
  import { CORE_TOOL_NAMES, dynamicToolsEnabled } from '../tools/tool-categories.js';
17
17
  import { createActivateToolCapability } from '../tools/activate.js';
18
18
  import { recordUsage } from '../stats/tracker.js';
19
+ import { loadConfig } from '../commands/config.js';
19
20
  import { recordSessionUsage } from '../stats/session-tracker.js';
20
21
  import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
21
22
  import { estimateCost, OPUS_PRICING } from '../pricing.js';
@@ -487,7 +488,24 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
487
488
  let consecutiveTinyResponses = 0; // Count of consecutive calls with <10 output tokens
488
489
  const MAX_TINY_RESPONSES = 2; // Break after N tiny responses — if 2 calls return near-empty, something is wrong
489
490
  let turnSpend = 0; // Cost spent this user turn (USD)
490
- const MAX_TURN_SPEND_USD = 0.25; // Hard circuit breaker per user message (lowered — user wallets are real money)
491
+ // Hard circuit breaker per user message — defends user wallets against
492
+ // a runaway model+tool combo on a single prompt. User-overridable via
493
+ // `franklin config set max-turn-spend-usd <number>`. Explicit "0" or a
494
+ // negative number disables the cap; a non-numeric / unparseable value
495
+ // is treated as a typo and falls back to the safe default rather than
496
+ // silently removing the wallet guard.
497
+ const turnSpendCap = (() => {
498
+ const raw = loadConfig()['max-turn-spend-usd'];
499
+ if (raw == null)
500
+ return 0.25;
501
+ const parsed = Number(raw);
502
+ if (!Number.isFinite(parsed))
503
+ return 0.25; // typo → keep default
504
+ if (parsed <= 0)
505
+ return Infinity; // explicit opt-out
506
+ return parsed;
507
+ })();
508
+ const MAX_TURN_SPEND_USD = turnSpendCap;
491
509
  // ── Turn analysis (one classifier call, drives routing + prefetch) ──
492
510
  // Single LLM pass that answers every routing-adjacent question the
493
511
  // harness needs BEFORE the main model runs: tier, ticker intent,
@@ -6,6 +6,13 @@ export interface AppConfig {
6
6
  'smart-routing'?: string;
7
7
  'permission-mode'?: string;
8
8
  'max-turns'?: string;
9
+ /**
10
+ * Hard per-turn spend ceiling in USD (default $0.25). Numeric string,
11
+ * e.g. "0.5" or "2". Set to "0" to disable the cap. The agent loop
12
+ * stops a turn the moment cumulative cost crosses this threshold,
13
+ * preventing a runaway model + tool combo from draining the wallet.
14
+ */
15
+ 'max-turn-spend-usd'?: string;
9
16
  'auto-compact'?: string;
10
17
  'session-save'?: string;
11
18
  'debug'?: string;
@@ -12,6 +12,7 @@ const VALID_KEYS = [
12
12
  'smart-routing',
13
13
  'permission-mode',
14
14
  'max-turns',
15
+ 'max-turn-spend-usd',
15
16
  'auto-compact',
16
17
  'session-save',
17
18
  'debug',
package/dist/index.js CHANGED
File without changes
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
6
6
  import { recordUsage } from '../stats/tracker.js';
7
7
  import { appendAudit } from '../stats/audit.js';
8
- import { buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
8
+ import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
9
9
  import { routeRequest, parseRoutingProfile, } from '../router/index.js';
10
10
  import { estimateCost } from '../pricing.js';
11
11
  import { VERSION } from '../config.js';
@@ -41,57 +41,6 @@ function log(...args) {
41
41
  catch { /* ignore */ }
42
42
  }
43
43
  const DEFAULT_MAX_TOKENS = 4096;
44
- const DEFAULT_PROXY_REQUEST_TIMEOUT_MS = 45_000;
45
- const DEFAULT_PROXY_STREAM_TIMEOUT_MS = 5 * 60 * 1000;
46
- function parseTimeoutEnv(name, fallback) {
47
- const raw = process.env[name];
48
- if (!raw)
49
- return fallback;
50
- const parsed = Number.parseInt(raw, 10);
51
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
52
- }
53
- function getProxyRequestTimeoutMs() {
54
- return parseTimeoutEnv('FRANKLIN_PROXY_REQUEST_TIMEOUT_MS', DEFAULT_PROXY_REQUEST_TIMEOUT_MS);
55
- }
56
- function getProxyStreamTimeoutMs() {
57
- return parseTimeoutEnv('FRANKLIN_PROXY_STREAM_TIMEOUT_MS', DEFAULT_PROXY_STREAM_TIMEOUT_MS);
58
- }
59
- function createProxyTimeoutError(label, timeoutMs) {
60
- return new Error(`${label} timed out after ${timeoutMs}ms`);
61
- }
62
- async function fetchWithTimeout(url, init, timeoutMs, label) {
63
- if (timeoutMs <= 0)
64
- return fetch(url, init);
65
- const controller = new AbortController();
66
- const timeoutError = createProxyTimeoutError(label, timeoutMs);
67
- const timeout = setTimeout(() => {
68
- try {
69
- controller.abort(timeoutError);
70
- }
71
- catch { /* ignore */ }
72
- }, timeoutMs);
73
- try {
74
- return await fetch(url, { ...init, signal: controller.signal });
75
- }
76
- catch (err) {
77
- if (controller.signal.aborted)
78
- throw timeoutError;
79
- throw err;
80
- }
81
- finally {
82
- clearTimeout(timeout);
83
- }
84
- }
85
- function replaceModelInBody(body, model) {
86
- try {
87
- const parsed = JSON.parse(body);
88
- parsed.model = model;
89
- return JSON.stringify(parsed);
90
- }
91
- catch {
92
- return body;
93
- }
94
- }
95
44
  // Per-model last output tokens for adaptive max_tokens (avoids cross-request pollution)
96
45
  const MAX_TRACKED_MODELS = 50;
97
46
  const lastOutputByModel = new Map();
@@ -420,21 +369,13 @@ export function createProxy(options) {
420
369
  };
421
370
  let response;
422
371
  let finalModel = requestModel;
423
- const requestTimeoutMs = getProxyRequestTimeoutMs();
424
372
  // Use fallback chain if enabled
425
373
  if (fallbackEnabled && body && requestPath.includes('messages')) {
426
374
  const fallbackConfig = {
427
375
  ...DEFAULT_FALLBACK_CONFIG,
428
376
  chain: buildFallbackChain(requestModel),
429
377
  };
430
- const result = await fetchWithPaymentFallback(targetUrl, requestInit, body, fallbackConfig, {
431
- method: req.method || 'POST',
432
- headers,
433
- chain,
434
- baseWallet,
435
- solanaWallet,
436
- timeoutMs: requestTimeoutMs,
437
- }, (failedModel, status, nextModel) => {
378
+ const result = await fetchWithFallback(targetUrl, requestInit, body, fallbackConfig, (failedModel, status, nextModel) => {
438
379
  log(`⚠️ ${failedModel} returned ${status}, falling back to ${nextModel}`);
439
380
  });
440
381
  response = result.response;
@@ -447,14 +388,20 @@ export function createProxy(options) {
447
388
  }
448
389
  }
449
390
  else {
450
- response = await fetchModelAttempt(targetUrl, requestInit, body, requestModel, {
451
- method: req.method || 'POST',
452
- headers,
453
- chain,
454
- baseWallet,
455
- solanaWallet,
456
- timeoutMs: requestTimeoutMs,
457
- });
391
+ // Direct fetch without fallback (with timeout)
392
+ const directCtrl = new AbortController();
393
+ const directTimeout = setTimeout(() => directCtrl.abort(), 120_000); // 2min
394
+ response = await fetch(targetUrl, { ...requestInit, signal: directCtrl.signal });
395
+ clearTimeout(directTimeout);
396
+ }
397
+ // Handle 402 payment — body now has the correct model after fallback
398
+ if (response.status === 402) {
399
+ if (chain === 'solana' && solanaWallet) {
400
+ response = await handleSolanaPayment(response, targetUrl, req.method || 'POST', headers, body, solanaWallet.privateKey, solanaWallet.address);
401
+ }
402
+ else if (baseWallet) {
403
+ response = await handleBasePayment(response, targetUrl, req.method || 'POST', headers, body, baseWallet.privateKey, baseWallet.address);
404
+ }
458
405
  }
459
406
  const responseHeaders = {};
460
407
  response.headers.forEach((v, k) => {
@@ -505,7 +452,7 @@ export function createProxy(options) {
505
452
  const decoder = new TextDecoder();
506
453
  let fullResponse = '';
507
454
  const STREAM_CAP = 5_000_000; // 5MB cap on accumulated stream
508
- const STREAM_TIMEOUT_MS = getProxyStreamTimeoutMs();
455
+ const STREAM_TIMEOUT_MS = 5 * 60 * 1000; // 5 min timeout for entire stream
509
456
  const streamDeadline = Date.now() + STREAM_TIMEOUT_MS;
510
457
  const pump = async () => {
511
458
  while (true) {
@@ -616,77 +563,10 @@ export function createProxy(options) {
616
563
  });
617
564
  return server;
618
565
  }
619
- async function fetchModelAttempt(url, init, body, model, payment) {
620
- let response = await fetchWithTimeout(url, { ...init, body: body || undefined }, payment.timeoutMs, `Proxy request for ${model}`);
621
- if (response.status !== 402)
622
- return response;
623
- if (payment.chain === 'solana' && payment.solanaWallet) {
624
- return handleSolanaPayment(response, url, payment.method, payment.headers, body, payment.solanaWallet.privateKey, payment.solanaWallet.address, payment.timeoutMs, model);
625
- }
626
- if (payment.baseWallet) {
627
- return handleBasePayment(response, url, payment.method, payment.headers, body, payment.baseWallet.privateKey, payment.baseWallet.address, payment.timeoutMs, model);
628
- }
629
- return response;
630
- }
631
- /**
632
- * Try each fallback model as a full x402 attempt:
633
- * unpaid 402 probe, payment signing, then the paid provider call. The older
634
- * flow only applied fallback to the probe, which meant a slow paid call could
635
- * hang Franklin until the outer client gave up.
636
- */
637
- async function fetchWithPaymentFallback(url, init, originalBody, config, payment, onFallback) {
638
- const failedModels = [];
639
- let attempts = 0;
640
- for (let i = 0; i < config.chain.length && attempts < config.maxRetries; i++) {
641
- const model = config.chain[i];
642
- const body = replaceModelInBody(originalBody, model);
643
- try {
644
- attempts++;
645
- const response = await fetchModelAttempt(url, init, body, model, payment);
646
- if (!config.retryOn.includes(response.status)) {
647
- return {
648
- response,
649
- modelUsed: model,
650
- bodyUsed: body,
651
- fallbackUsed: i > 0,
652
- attemptsCount: attempts,
653
- failedModels,
654
- };
655
- }
656
- try {
657
- await response.body?.cancel();
658
- }
659
- catch { /* ignore */ }
660
- failedModels.push(model);
661
- const nextModel = config.chain[i + 1];
662
- if (nextModel && onFallback) {
663
- onFallback(model, response.status, nextModel);
664
- }
665
- if (i < config.chain.length - 1) {
666
- await sleep(config.retryDelayMs);
667
- }
668
- }
669
- catch (err) {
670
- failedModels.push(model);
671
- const nextModel = config.chain[i + 1];
672
- if (nextModel && onFallback) {
673
- onFallback(model, 0, nextModel);
674
- }
675
- log(`[fallback] ${model} request error: ${err instanceof Error ? err.message : String(err)}`);
676
- if (i < config.chain.length - 1) {
677
- await sleep(config.retryDelayMs);
678
- }
679
- }
680
- }
681
- throw new Error(`All models in fallback chain failed: ${failedModels.join(', ')}`);
682
- }
683
- function sleep(ms) {
684
- return new Promise((resolve) => setTimeout(resolve, ms));
685
- }
686
566
  // ======================================================================
687
567
  // Base (EIP-712) payment handler
688
568
  // ======================================================================
689
- async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
569
+ async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress) {
690
570
  const paymentHeader = await extractPaymentHeader(response);
691
571
  if (!paymentHeader) {
692
572
  throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
@@ -699,19 +579,19 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
699
579
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
700
580
  extra: details.extra,
701
581
  });
702
- return fetchWithTimeout(url, {
582
+ return fetch(url, {
703
583
  method,
704
584
  headers: {
705
585
  ...headers,
706
586
  'PAYMENT-SIGNATURE': paymentPayload,
707
587
  },
708
588
  body: body || undefined,
709
- }, timeoutMs, `Paid proxy request for ${model}`);
589
+ });
710
590
  }
711
591
  // ======================================================================
712
592
  // Solana payment handler
713
593
  // ======================================================================
714
- async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
594
+ async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress) {
715
595
  const paymentHeader = await extractPaymentHeader(response);
716
596
  if (!paymentHeader) {
717
597
  throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
@@ -726,14 +606,14 @@ async function handleSolanaPayment(response, url, method, headers, body, private
726
606
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
727
607
  extra: details.extra,
728
608
  });
729
- return fetchWithTimeout(url, {
609
+ return fetch(url, {
730
610
  method,
731
611
  headers: {
732
612
  ...headers,
733
613
  'PAYMENT-SIGNATURE': paymentPayload,
734
614
  },
735
615
  body: body || undefined,
736
- }, timeoutMs, `Paid proxy request for ${model}`);
616
+ });
737
617
  }
738
618
  export function classifyRequest(body) {
739
619
  try {
@@ -215,7 +215,14 @@ function buildExecute(deps) {
215
215
  'User-Agent': `franklin/${VERSION}`,
216
216
  };
217
217
  const controller = new AbortController();
218
- const timeout = setTimeout(() => controller.abort(), 60_000); // 60s timeout
218
+ // Reference-image mode (gpt-image-2 edits) is meaningfully slower than
219
+ // pure text-to-image: the model is reasoning-driven and the request
220
+ // body carries a few MB of base64. The shared 60s budget has to cover
221
+ // both x402 retry attempts plus the actual generation, which made
222
+ // image-to-image effectively always time out. Image-to-image gets 3
223
+ // minutes; text-to-image keeps the original 60s.
224
+ const timeoutMs = referenceImage ? 180_000 : 60_000;
225
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
219
226
  try {
220
227
  // First request — will get 402
221
228
  let response = await fetch(endpoint, {
@@ -330,7 +337,12 @@ function buildExecute(deps) {
330
337
  catch (err) {
331
338
  const msg = err.message || '';
332
339
  if (msg.includes('abort')) {
333
- return { output: 'Image generation timed out (60s limit). Try a simpler prompt.', isError: true };
340
+ return {
341
+ output: referenceImage
342
+ ? 'Image-to-image timed out (180s limit). The reference image may be too large or the model under load — try a smaller image or simpler prompt.'
343
+ : 'Image generation timed out (60s limit). Try a simpler prompt.',
344
+ isError: true,
345
+ };
334
346
  }
335
347
  return { output: `Error: ${msg}`, isError: true };
336
348
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.38",
3
+ "version": "3.8.40",
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": {