@blockrun/franklin 3.8.39 → 3.8.41

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 CHANGED
@@ -69,6 +69,18 @@ franklin balance # show address + USDC balance
69
69
 
70
70
  That's it. Zero signup, zero credit card, zero phone verification. Send **$5 of USDC** to the wallet and you've unlocked every frontier model and every paid tool in the BlockRun gateway.
71
71
 
72
+ ### Prefer a GUI? Try Franklin for VS Code
73
+
74
+ The same agent ships as a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=blockrun.franklin-vscode) — chat panel, model picker, wallet balance, image / video generation, inline diff cards — all driven by the wallet you already funded for the CLI.
75
+
76
+ ```
77
+ VS Code → Extensions (Cmd+Shift+X / Ctrl+Shift+X)
78
+ → search "Franklin" → Install
79
+ → click the Franklin icon in the Activity Bar
80
+ ```
81
+
82
+ Free models work immediately. Paid models, image gen, and video gen activate the moment your wallet has USDC. The CLI and the extension share the same `~/.blockrun/` config and session history, so jumping between terminal and VS Code is seamless.
83
+
72
84
  ---
73
85
 
74
86
  ## YOPO
@@ -0,0 +1,17 @@
1
+ import type { Dialogue } from './types.js';
2
+ /**
3
+ * Cap on how many times a single user turn can auto-continue after a
4
+ * stream-timeout. One is enough: if the first chunked attempt also times
5
+ * out, the model isn't going to figure it out by recursion — fall through
6
+ * to the normal error path so the user can intervene.
7
+ */
8
+ export declare const MAX_AUTO_CONTINUATIONS_PER_TURN = 1;
9
+ export declare function isAutoContinuationDisabled(): boolean;
10
+ /**
11
+ * Built when a model call times out at the streaming layer on a task
12
+ * that's too big to complete in one turn (multi-file scaffolds,
13
+ * dashboard builds, etc.). Pushed into history before re-firing so the
14
+ * model treats the next attempt as a single narrow chunk rather than
15
+ * retrying the whole job and timing out again.
16
+ */
17
+ export declare function buildContinuationPrompt(): Dialogue;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cap on how many times a single user turn can auto-continue after a
3
+ * stream-timeout. One is enough: if the first chunked attempt also times
4
+ * out, the model isn't going to figure it out by recursion — fall through
5
+ * to the normal error path so the user can intervene.
6
+ */
7
+ export const MAX_AUTO_CONTINUATIONS_PER_TURN = 1;
8
+ export function isAutoContinuationDisabled() {
9
+ return process.env.FRANKLIN_NO_AUTO_CONTINUE === '1';
10
+ }
11
+ /**
12
+ * Built when a model call times out at the streaming layer on a task
13
+ * that's too big to complete in one turn (multi-file scaffolds,
14
+ * dashboard builds, etc.). Pushed into history before re-firing so the
15
+ * model treats the next attempt as a single narrow chunk rather than
16
+ * retrying the whole job and timing out again.
17
+ */
18
+ export function buildContinuationPrompt() {
19
+ return {
20
+ role: 'user',
21
+ content: [
22
+ 'Your previous attempt timed out at the streaming layer — the task is too large for a single streaming turn.',
23
+ '',
24
+ 'DO:',
25
+ '- Pick ONE narrowly scoped next step (one file, one component, one logical chunk).',
26
+ '- Complete just that step in this response.',
27
+ '- Save work via Write/Edit and stop. The user will continue from there.',
28
+ '',
29
+ 'DO NOT:',
30
+ '- Re-attempt the entire original task in one shot.',
31
+ '- Make more than 3-4 tool calls before producing a result.',
32
+ '- Plan the whole multi-stage job — execute one chunk now.',
33
+ ].join('\n'),
34
+ };
35
+ }
@@ -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';
@@ -28,6 +29,8 @@ import { shouldVerify, runVerification } from './verification.js';
28
29
  import { shouldCheckGrounding, checkGrounding, renderGroundingFollowup, buildGroundingRetryInstruction, extractMissingToolNames, } from './evaluator.js';
29
30
  import { augmentUserMessage, prefetchForIntent } from './intent-prefetch.js';
30
31
  import { analyzeTurn } from './turn-analyzer.js';
32
+ import { evaluateTimeoutRetry } from './retry-policy.js';
33
+ import { MAX_AUTO_CONTINUATIONS_PER_TURN, buildContinuationPrompt, isAutoContinuationDisabled, } from './continuation.js';
31
34
  import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, loadSessionHistory, loadSessionMeta, } from '../session/storage.js';
32
35
  /**
33
36
  * Atomically replace all elements in a history array.
@@ -451,6 +454,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
451
454
  onAbortReady?.(() => abort.abort());
452
455
  let loopCount = 0;
453
456
  let recoveryAttempts = 0;
457
+ let autoContinuationCount = 0;
454
458
  const MAX_RECOVERY_ATTEMPTS = 5;
455
459
  let compactFailures = 0;
456
460
  let maxTokensOverride;
@@ -487,7 +491,24 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
487
491
  let consecutiveTinyResponses = 0; // Count of consecutive calls with <10 output tokens
488
492
  const MAX_TINY_RESPONSES = 2; // Break after N tiny responses — if 2 calls return near-empty, something is wrong
489
493
  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)
494
+ // Hard circuit breaker per user message — defends user wallets against
495
+ // a runaway model+tool combo on a single prompt. User-overridable via
496
+ // `franklin config set max-turn-spend-usd <number>`. Explicit "0" or a
497
+ // negative number disables the cap; a non-numeric / unparseable value
498
+ // is treated as a typo and falls back to the safe default rather than
499
+ // silently removing the wallet guard.
500
+ const turnSpendCap = (() => {
501
+ const raw = loadConfig()['max-turn-spend-usd'];
502
+ if (raw == null)
503
+ return 0.25;
504
+ const parsed = Number(raw);
505
+ if (!Number.isFinite(parsed))
506
+ return 0.25; // typo → keep default
507
+ if (parsed <= 0)
508
+ return Infinity; // explicit opt-out
509
+ return parsed;
510
+ })();
511
+ const MAX_TURN_SPEND_USD = turnSpendCap;
491
512
  // ── Turn analysis (one classifier call, drives routing + prefetch) ──
492
513
  // Single LLM pass that answers every routing-adjacent question the
493
514
  // harness needs BEFORE the main model runs: tier, ticker intent,
@@ -879,6 +900,52 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
879
900
  // ── Transient error recovery (network, rate limit, server errors) ──
880
901
  // Respect per-error maxRetries (e.g., 529/overloaded gets only 3 retries)
881
902
  const effectiveMaxRetries = classified.maxRetries ?? MAX_RECOVERY_ATTEMPTS;
903
+ if (classified.category === 'timeout' && recoveryAttempts < effectiveMaxRetries) {
904
+ const retryDecision = evaluateTimeoutRetry(history, resolvedModel);
905
+ if (!retryDecision.retry) {
906
+ // Before surfacing the timeout error, try auto-continuation:
907
+ // for tasks too big to finish in one streaming turn (multi-file
908
+ // scaffolds, dashboard builds), inject a chunking-instruction
909
+ // prompt and let the model take one narrow next step. Capped at
910
+ // MAX_AUTO_CONTINUATIONS_PER_TURN — if the chunked attempt also
911
+ // times out, fall through to the normal error path.
912
+ if (!isAutoContinuationDisabled() &&
913
+ autoContinuationCount < MAX_AUTO_CONTINUATIONS_PER_TURN) {
914
+ autoContinuationCount++;
915
+ recoveryAttempts++;
916
+ const continuationPrompt = buildContinuationPrompt();
917
+ history.push(continuationPrompt);
918
+ persistSessionMessage(continuationPrompt);
919
+ if (config.debug) {
920
+ console.error(`[franklin] Stream timeout on ${resolvedModel} — auto-continuing with chunked-task prompt`);
921
+ }
922
+ onEvent({
923
+ kind: 'text_delta',
924
+ text: '\n*Task too big for one streaming turn — auto-continuing with a smaller chunk...*\n',
925
+ });
926
+ lastSessionActivity = Date.now();
927
+ continue;
928
+ }
929
+ const tokenText = retryDecision.estimatedInputTokens.toLocaleString();
930
+ const costText = retryDecision.estimatedReplayCostUsd > 0
931
+ ? ` and at least $${retryDecision.estimatedReplayCostUsd.toFixed(4)} in input charges`
932
+ : '';
933
+ if (config.debug) {
934
+ console.error(`[franklin] Timeout retry skipped for ${resolvedModel}: ` +
935
+ `~${tokenText} input tokens, replayCost=$${retryDecision.estimatedReplayCostUsd.toFixed(4)}`);
936
+ }
937
+ onEvent({
938
+ kind: 'turn_done',
939
+ reason: 'error',
940
+ error: `[${classified.label}] ${errMsg}\n` +
941
+ `Tip: Automatic retry skipped to avoid re-sending ~${tokenText} input tokens${costText}. ` +
942
+ 'Use /retry if you want to run another full attempt.',
943
+ });
944
+ lastSessionActivity = Date.now();
945
+ persistSessionMeta();
946
+ break;
947
+ }
948
+ }
882
949
  if (classified.isTransient && recoveryAttempts < effectiveMaxRetries) {
883
950
  recoveryAttempts++;
884
951
  const backoffMs = getBackoffDelay(recoveryAttempts);
@@ -0,0 +1,19 @@
1
+ import type { Dialogue } from './types.js';
2
+ export declare const TIMEOUT_RETRY_INPUT_TOKEN_LIMIT = 20000;
3
+ export declare const TIMEOUT_RETRY_MIN_REPLAY_COST_LIMIT_USD = 0.05;
4
+ export type TimeoutRetrySkipReason = 'estimated_cost' | 'input_tokens';
5
+ export interface TimeoutRetryDecision {
6
+ retry: boolean;
7
+ estimatedInputTokens: number;
8
+ estimatedReplayCostUsd: number;
9
+ reason?: TimeoutRetrySkipReason;
10
+ }
11
+ /**
12
+ * A timeout retry re-sends the entire conversation. For long paid contexts,
13
+ * that can cost more than the original useful work and hit the turn budget
14
+ * before the model gets another chance to finish.
15
+ */
16
+ export declare function evaluateTimeoutRetry(history: Dialogue[], model: string, opts?: {
17
+ inputTokenLimit?: number;
18
+ minReplayCostLimitUsd?: number;
19
+ }): TimeoutRetryDecision;
@@ -0,0 +1,36 @@
1
+ import { estimateCost } from '../pricing.js';
2
+ import { estimateHistoryTokens } from './tokens.js';
3
+ export const TIMEOUT_RETRY_INPUT_TOKEN_LIMIT = 20_000;
4
+ export const TIMEOUT_RETRY_MIN_REPLAY_COST_LIMIT_USD = 0.05;
5
+ /**
6
+ * A timeout retry re-sends the entire conversation. For long paid contexts,
7
+ * that can cost more than the original useful work and hit the turn budget
8
+ * before the model gets another chance to finish.
9
+ */
10
+ export function evaluateTimeoutRetry(history, model, opts) {
11
+ const inputTokenLimit = opts?.inputTokenLimit ?? TIMEOUT_RETRY_INPUT_TOKEN_LIMIT;
12
+ const minReplayCostLimitUsd = opts?.minReplayCostLimitUsd ?? TIMEOUT_RETRY_MIN_REPLAY_COST_LIMIT_USD;
13
+ const estimatedInputTokens = estimateHistoryTokens(history);
14
+ const estimatedReplayCostUsd = estimateCost(model, estimatedInputTokens, 0, 1);
15
+ if (estimatedReplayCostUsd > minReplayCostLimitUsd) {
16
+ return {
17
+ retry: false,
18
+ estimatedInputTokens,
19
+ estimatedReplayCostUsd,
20
+ reason: 'estimated_cost',
21
+ };
22
+ }
23
+ if (estimatedInputTokens > inputTokenLimit) {
24
+ return {
25
+ retry: false,
26
+ estimatedInputTokens,
27
+ estimatedReplayCostUsd,
28
+ reason: 'input_tokens',
29
+ };
30
+ }
31
+ return {
32
+ retry: true,
33
+ estimatedInputTokens,
34
+ estimatedReplayCostUsd,
35
+ };
36
+ }
@@ -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
  }
@@ -14,10 +14,12 @@ export const MODEL_SHORTCUTS = {
14
14
  // Anthropic
15
15
  sonnet: 'anthropic/claude-sonnet-4.6',
16
16
  claude: 'anthropic/claude-sonnet-4.6',
17
+ 'sonnet-4.6': 'anthropic/claude-sonnet-4.6',
17
18
  opus: 'anthropic/claude-opus-4.7',
18
19
  'opus-4.7': 'anthropic/claude-opus-4.7',
19
20
  'opus-4.6': 'anthropic/claude-opus-4.6',
20
21
  haiku: 'anthropic/claude-haiku-4.5-20251001',
22
+ 'haiku-4.5': 'anthropic/claude-haiku-4.5-20251001',
21
23
  // OpenAI
22
24
  // `gpt` / `gpt5` / `gpt-5` follow the gateway's flagship — currently 5.5.
23
25
  gpt: 'openai/gpt-5.5',
@@ -39,12 +41,16 @@ export const MODEL_SHORTCUTS = {
39
41
  o1: 'openai/o1',
40
42
  // Google
41
43
  gemini: 'google/gemini-2.5-pro',
44
+ 'gemini-2.5': 'google/gemini-2.5-pro',
42
45
  flash: 'google/gemini-2.5-flash',
43
46
  'gemini-3': 'google/gemini-3.1-pro',
47
+ 'gemini-3.1': 'google/gemini-3.1-pro',
44
48
  // xAI
45
49
  grok: 'xai/grok-3',
50
+ 'grok-3': 'xai/grok-3',
46
51
  'grok-4': 'xai/grok-4-0709',
47
52
  'grok-fast': 'xai/grok-4-1-fast-reasoning',
53
+ 'grok-4.1': 'xai/grok-4-1-fast-reasoning',
48
54
  // DeepSeek
49
55
  deepseek: 'deepseek/deepseek-chat',
50
56
  r1: 'deepseek/deepseek-reasoner',
@@ -65,11 +71,14 @@ export const MODEL_SHORTCUTS = {
65
71
  devstral: 'nvidia/qwen3-coder-480b',
66
72
  // Others
67
73
  minimax: 'minimax/minimax-m2.7',
74
+ 'm2.7': 'minimax/minimax-m2.7',
68
75
  glm: 'zai/glm-5.1',
69
76
  'glm-turbo': 'zai/glm-5-turbo',
70
77
  'glm5': 'zai/glm-5.1',
71
78
  kimi: 'moonshot/kimi-k2.6',
79
+ 'k2.6': 'moonshot/kimi-k2.6',
72
80
  'kimi-k2.5': 'moonshot/kimi-k2.5',
81
+ 'k2.5': 'moonshot/kimi-k2.5',
73
82
  };
74
83
  /**
75
84
  * Resolve a model name — supports shortcuts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.39",
3
+ "version": "3.8.41",
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": {
@@ -78,7 +78,8 @@
78
78
  "ink-text-input": "^6.0.0",
79
79
  "playwright-core": "^1.49.1",
80
80
  "qrcode": "^1.5.4",
81
- "react": "^19.2.4"
81
+ "react": "^19.2.4",
82
+ "viem": "^2.48.1"
82
83
  },
83
84
  "devDependencies": {
84
85
  "@types/node": "^22.0.0",