@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.
- package/dist/agent/loop.js +14 -3
- package/dist/commands/config.d.ts +6 -4
- package/dist/proxy/server.js +156 -24
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
|
513
|
+
return TURN_SPEND_DEFAULT_USD;
|
|
504
514
|
const parsed = Number(raw);
|
|
505
515
|
if (!Number.isFinite(parsed))
|
|
506
|
-
return
|
|
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
|
|
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 $
|
|
11
|
-
* e.g. "0.5" or "2". Set to "0" to disable the cap.
|
|
12
|
-
* stops a turn the moment cumulative cost crosses this
|
|
13
|
-
* preventing a runaway model + tool combo from draining the
|
|
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;
|
package/dist/proxy/server.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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