@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 +12 -0
- package/dist/agent/continuation.d.ts +17 -0
- package/dist/agent/continuation.js +35 -0
- package/dist/agent/loop.js +68 -1
- package/dist/agent/retry-policy.d.ts +19 -0
- package/dist/agent/retry-policy.js +36 -0
- 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/dist/ui/model-picker.js +9 -0
- package/package.json +3 -2
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
|
+
}
|
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';
|
|
@@ -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
|
-
|
|
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;
|
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/dist/ui/model-picker.js
CHANGED
|
@@ -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.
|
|
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",
|