@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 +2 -2
- package/dist/agent/loop.js +19 -1
- package/dist/commands/config.d.ts +7 -0
- package/dist/commands/config.js +1 -0
- package/dist/index.js +0 -0
- package/dist/proxy/server.js +23 -143
- package/dist/tools/imagegen.js +14 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
23
|
+
90_000);
|
|
24
24
|
}
|
|
25
25
|
function linkAbortSignal(parent, child) {
|
|
26
26
|
if (!parent)
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/commands/config.js
CHANGED
package/dist/index.js
CHANGED
|
File without changes
|
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 { 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
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
}
|
|
589
|
+
});
|
|
710
590
|
}
|
|
711
591
|
// ======================================================================
|
|
712
592
|
// Solana payment handler
|
|
713
593
|
// ======================================================================
|
|
714
|
-
async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress
|
|
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
|
|
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
|
-
}
|
|
616
|
+
});
|
|
737
617
|
}
|
|
738
618
|
export function classifyRequest(body) {
|
|
739
619
|
try {
|
package/dist/tools/imagegen.js
CHANGED
|
@@ -215,7 +215,14 @@ function buildExecute(deps) {
|
|
|
215
215
|
'User-Agent': `franklin/${VERSION}`,
|
|
216
216
|
};
|
|
217
217
|
const controller = new AbortController();
|
|
218
|
-
|
|
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 {
|
|
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