@blockrun/franklin 3.8.41 → 3.8.43

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.
@@ -497,13 +497,23 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
497
497
  // negative number disables the cap; a non-numeric / unparseable value
498
498
  // is treated as a typo and falls back to the safe default rather than
499
499
  // silently removing the wallet guard.
500
+ //
501
+ // Default raised from $0.25 → $1.00 in v3.8.42 — the original ceiling
502
+ // dated from when Franklin was mostly chat. Real workloads (multi-stage
503
+ // dashboard scaffolds on sonnet, image-to-image edits, research-heavy
504
+ // turns) routinely land in the $0.20–$0.80 range on a single legit
505
+ // prompt. $1.00 is still meaningful as a runaway-protection guardrail
506
+ // (catches the kind of failure v3.8.41's retry-policy was built for)
507
+ // but doesn't impose a friction tax on every multi-stage task. Users
508
+ // who liked the old ceiling can opt back in via the config.
509
+ const TURN_SPEND_DEFAULT_USD = 1.0;
500
510
  const turnSpendCap = (() => {
501
511
  const raw = loadConfig()['max-turn-spend-usd'];
502
512
  if (raw == null)
503
- return 0.25;
513
+ return TURN_SPEND_DEFAULT_USD;
504
514
  const parsed = Number(raw);
505
515
  if (!Number.isFinite(parsed))
506
- return 0.25; // typo → keep default
516
+ return TURN_SPEND_DEFAULT_USD; // typo → keep default
507
517
  if (parsed <= 0)
508
518
  return Infinity; // explicit opt-out
509
519
  return parsed;
@@ -1022,7 +1032,8 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1022
1032
  if (turnSpend > MAX_TURN_SPEND_USD) {
1023
1033
  onEvent({
1024
1034
  kind: 'text_delta',
1025
- text: `\n\n⚠️ Turn spend limit reached ($${turnSpend.toFixed(3)} > $${MAX_TURN_SPEND_USD}). Stopping to protect your wallet. Try again with a clearer prompt or a different model.\n`,
1035
+ text: `\n\n⚠️ Turn spend limit reached ($${turnSpend.toFixed(3)} > $${MAX_TURN_SPEND_USD}). Stopping to protect your wallet.\n` +
1036
+ `Raise the cap with \`franklin config set max-turn-spend-usd 2.0\` (or \`0\` to disable), then \`/retry\`.\n`,
1026
1037
  });
1027
1038
  onEvent({ kind: 'turn_done', reason: 'budget' });
1028
1039
  break;
@@ -7,10 +7,12 @@ export interface AppConfig {
7
7
  'permission-mode'?: string;
8
8
  'max-turns'?: string;
9
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.
10
+ * Hard per-turn spend ceiling in USD (default $1.00 as of v3.8.42).
11
+ * Numeric string, e.g. "0.5" or "2". Set to "0" to disable the cap.
12
+ * The agent loop stops a turn the moment cumulative cost crosses this
13
+ * threshold, preventing a runaway model + tool combo from draining the
14
+ * wallet. Earlier versions used $0.25, which routinely fired on legit
15
+ * multi-stage tasks (dashboard scaffolds, image-to-image edits).
14
16
  */
15
17
  'max-turn-spend-usd'?: string;
16
18
  'auto-compact'?: string;
@@ -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 { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
8
+ import { 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,6 +41,57 @@ 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
+ }
44
95
  // Per-model last output tokens for adaptive max_tokens (avoids cross-request pollution)
45
96
  const MAX_TRACKED_MODELS = 50;
46
97
  const lastOutputByModel = new Map();
@@ -62,10 +113,12 @@ const MODEL_SHORTCUTS = {
62
113
  // Anthropic
63
114
  sonnet: 'anthropic/claude-sonnet-4.6',
64
115
  claude: 'anthropic/claude-sonnet-4.6',
116
+ 'sonnet-4.6': 'anthropic/claude-sonnet-4.6',
65
117
  opus: 'anthropic/claude-opus-4.7',
66
118
  'opus-4.7': 'anthropic/claude-opus-4.7',
67
119
  'opus-4.6': 'anthropic/claude-opus-4.6',
68
- haiku: 'anthropic/claude-haiku-4.5',
120
+ haiku: 'anthropic/claude-haiku-4.5-20251001',
121
+ 'haiku-4.5': 'anthropic/claude-haiku-4.5-20251001',
69
122
  // OpenAI
70
123
  // `gpt` / `gpt5` / `gpt-5` follow the gateway's flagship — currently 5.5.
71
124
  gpt: 'openai/gpt-5.5',
@@ -87,12 +140,16 @@ const MODEL_SHORTCUTS = {
87
140
  o1: 'openai/o1',
88
141
  // Google
89
142
  gemini: 'google/gemini-2.5-pro',
143
+ 'gemini-2.5': 'google/gemini-2.5-pro',
90
144
  flash: 'google/gemini-2.5-flash',
91
145
  'gemini-3': 'google/gemini-3.1-pro',
146
+ 'gemini-3.1': 'google/gemini-3.1-pro',
92
147
  // xAI
93
148
  grok: 'xai/grok-3',
149
+ 'grok-3': 'xai/grok-3',
94
150
  'grok-4': 'xai/grok-4-0709',
95
151
  'grok-fast': 'xai/grok-4-1-fast-reasoning',
152
+ 'grok-4.1': 'xai/grok-4-1-fast-reasoning',
96
153
  // DeepSeek
97
154
  deepseek: 'deepseek/deepseek-chat',
98
155
  r1: 'deepseek/deepseek-reasoner',
@@ -111,9 +168,15 @@ const MODEL_SHORTCUTS = {
111
168
  devstral: 'nvidia/qwen3-coder-480b',
112
169
  // Minimax
113
170
  minimax: 'minimax/minimax-m2.7',
171
+ 'm2.7': 'minimax/minimax-m2.7',
114
172
  // Others
115
173
  glm: 'zai/glm-5.1',
174
+ 'glm-turbo': 'zai/glm-5-turbo',
175
+ 'glm5': 'zai/glm-5.1',
116
176
  kimi: 'moonshot/kimi-k2.6',
177
+ 'k2.6': 'moonshot/kimi-k2.6',
178
+ 'kimi-k2.5': 'moonshot/kimi-k2.5',
179
+ 'k2.5': 'moonshot/kimi-k2.5',
117
180
  };
118
181
  // Model pricing now uses shared source from src/pricing.ts
119
182
  function detectModelSwitch(parsed) {
@@ -369,13 +432,21 @@ export function createProxy(options) {
369
432
  };
370
433
  let response;
371
434
  let finalModel = requestModel;
435
+ const requestTimeoutMs = getProxyRequestTimeoutMs();
372
436
  // Use fallback chain if enabled
373
437
  if (fallbackEnabled && body && requestPath.includes('messages')) {
374
438
  const fallbackConfig = {
375
439
  ...DEFAULT_FALLBACK_CONFIG,
376
440
  chain: buildFallbackChain(requestModel),
377
441
  };
378
- const result = await fetchWithFallback(targetUrl, requestInit, body, fallbackConfig, (failedModel, status, nextModel) => {
442
+ const result = await fetchWithPaymentFallback(targetUrl, requestInit, body, fallbackConfig, {
443
+ method: req.method || 'POST',
444
+ headers,
445
+ chain,
446
+ baseWallet,
447
+ solanaWallet,
448
+ timeoutMs: requestTimeoutMs,
449
+ }, (failedModel, status, nextModel) => {
379
450
  log(`⚠️ ${failedModel} returned ${status}, falling back to ${nextModel}`);
380
451
  });
381
452
  response = result.response;
@@ -388,20 +459,14 @@ export function createProxy(options) {
388
459
  }
389
460
  }
390
461
  else {
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
- }
462
+ response = await fetchModelAttempt(targetUrl, requestInit, body, requestModel, {
463
+ method: req.method || 'POST',
464
+ headers,
465
+ chain,
466
+ baseWallet,
467
+ solanaWallet,
468
+ timeoutMs: requestTimeoutMs,
469
+ });
405
470
  }
406
471
  const responseHeaders = {};
407
472
  response.headers.forEach((v, k) => {
@@ -452,7 +517,7 @@ export function createProxy(options) {
452
517
  const decoder = new TextDecoder();
453
518
  let fullResponse = '';
454
519
  const STREAM_CAP = 5_000_000; // 5MB cap on accumulated stream
455
- const STREAM_TIMEOUT_MS = 5 * 60 * 1000; // 5 min timeout for entire stream
520
+ const STREAM_TIMEOUT_MS = getProxyStreamTimeoutMs();
456
521
  const streamDeadline = Date.now() + STREAM_TIMEOUT_MS;
457
522
  const pump = async () => {
458
523
  while (true) {
@@ -563,10 +628,77 @@ export function createProxy(options) {
563
628
  });
564
629
  return server;
565
630
  }
631
+ async function fetchModelAttempt(url, init, body, model, payment) {
632
+ let response = await fetchWithTimeout(url, { ...init, body: body || undefined }, payment.timeoutMs, `Proxy request for ${model}`);
633
+ if (response.status !== 402)
634
+ return response;
635
+ if (payment.chain === 'solana' && payment.solanaWallet) {
636
+ return handleSolanaPayment(response, url, payment.method, payment.headers, body, payment.solanaWallet.privateKey, payment.solanaWallet.address, payment.timeoutMs, model);
637
+ }
638
+ if (payment.baseWallet) {
639
+ return handleBasePayment(response, url, payment.method, payment.headers, body, payment.baseWallet.privateKey, payment.baseWallet.address, payment.timeoutMs, model);
640
+ }
641
+ return response;
642
+ }
643
+ /**
644
+ * Try each fallback model as a full x402 attempt:
645
+ * unpaid 402 probe, payment signing, then the paid provider call. The older
646
+ * flow only applied fallback to the probe, which meant a slow paid call could
647
+ * hang Franklin until the outer client gave up.
648
+ */
649
+ async function fetchWithPaymentFallback(url, init, originalBody, config, payment, onFallback) {
650
+ const failedModels = [];
651
+ let attempts = 0;
652
+ for (let i = 0; i < config.chain.length && attempts < config.maxRetries; i++) {
653
+ const model = config.chain[i];
654
+ const body = replaceModelInBody(originalBody, model);
655
+ try {
656
+ attempts++;
657
+ const response = await fetchModelAttempt(url, init, body, model, payment);
658
+ if (!config.retryOn.includes(response.status)) {
659
+ return {
660
+ response,
661
+ modelUsed: model,
662
+ bodyUsed: body,
663
+ fallbackUsed: i > 0,
664
+ attemptsCount: attempts,
665
+ failedModels,
666
+ };
667
+ }
668
+ try {
669
+ await response.body?.cancel();
670
+ }
671
+ catch { /* ignore */ }
672
+ failedModels.push(model);
673
+ const nextModel = config.chain[i + 1];
674
+ if (nextModel && onFallback) {
675
+ onFallback(model, response.status, nextModel);
676
+ }
677
+ if (i < config.chain.length - 1) {
678
+ await sleep(config.retryDelayMs);
679
+ }
680
+ }
681
+ catch (err) {
682
+ failedModels.push(model);
683
+ const nextModel = config.chain[i + 1];
684
+ if (nextModel && onFallback) {
685
+ onFallback(model, 0, nextModel);
686
+ }
687
+ log(`[fallback] ${model} request error: ${err instanceof Error ? err.message : String(err)}`);
688
+ if (i < config.chain.length - 1) {
689
+ await sleep(config.retryDelayMs);
690
+ }
691
+ }
692
+ }
693
+ throw new Error(`All models in fallback chain failed: ${failedModels.join(', ')}`);
694
+ }
695
+ function sleep(ms) {
696
+ return new Promise((resolve) => setTimeout(resolve, ms));
697
+ }
566
698
  // ======================================================================
567
699
  // Base (EIP-712) payment handler
568
700
  // ======================================================================
569
- async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress) {
701
+ async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
570
702
  const paymentHeader = await extractPaymentHeader(response);
571
703
  if (!paymentHeader) {
572
704
  throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
@@ -579,19 +711,19 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
579
711
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
580
712
  extra: details.extra,
581
713
  });
582
- return fetch(url, {
714
+ return fetchWithTimeout(url, {
583
715
  method,
584
716
  headers: {
585
717
  ...headers,
586
718
  'PAYMENT-SIGNATURE': paymentPayload,
587
719
  },
588
720
  body: body || undefined,
589
- });
721
+ }, timeoutMs, `Paid proxy request for ${model}`);
590
722
  }
591
723
  // ======================================================================
592
724
  // Solana payment handler
593
725
  // ======================================================================
594
- async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress) {
726
+ async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
595
727
  const paymentHeader = await extractPaymentHeader(response);
596
728
  if (!paymentHeader) {
597
729
  throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
@@ -606,14 +738,14 @@ async function handleSolanaPayment(response, url, method, headers, body, private
606
738
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
607
739
  extra: details.extra,
608
740
  });
609
- return fetch(url, {
741
+ return fetchWithTimeout(url, {
610
742
  method,
611
743
  headers: {
612
744
  ...headers,
613
745
  'PAYMENT-SIGNATURE': paymentPayload,
614
746
  },
615
747
  body: body || undefined,
616
- });
748
+ }, timeoutMs, `Paid proxy request for ${model}`);
617
749
  }
618
750
  export function classifyRequest(body) {
619
751
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.41",
3
+ "version": "3.8.43",
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": {