@blockrun/franklin 3.8.36 → 3.8.38

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.
@@ -31,7 +31,9 @@ export interface GroundingResult {
31
31
  }
32
32
  /**
33
33
  * Decide whether this turn warrants a grounding check. Principles:
34
- * - Non-trivial user input (not a greeting, not a slash command)
34
+ * - Non-trivial user input (not a greeting, not a slash command), OR
35
+ * the assistant answer contains specific factual claims (numbers + units,
36
+ * currency, dates, times) regardless of input length
35
37
  * - Non-trivial assistant text output (not just a tool-result echo)
36
38
  *
37
39
  * Intentionally NOT gating on tool-type (read vs write) — the whole point
@@ -69,5 +71,17 @@ export declare function renderGroundingFollowup(result: GroundingResult): string
69
71
  * Intentionally terse: the agent already has the original question in
70
72
  * history; we only need to name the gap + the tools to use.
71
73
  */
74
+ /**
75
+ * Pull the tool names the evaluator suggested out of its issue lines.
76
+ * Issue lines look like:
77
+ * Claim: "..." → missing tool: WebSearch
78
+ * Refusal: "..." → should have called: TradingMarket
79
+ * ... → missing tool: WebSearch (or any distance calculation tool)
80
+ *
81
+ * Returns first-token-of-each-comma/pipe-segment names, deduplicated.
82
+ * Used by both the retry instruction (to name them in prose) and the
83
+ * loop's tool_choice selection (to pin the next request to a tool).
84
+ */
85
+ export declare function extractMissingToolNames(result: GroundingResult): string[];
72
86
  export declare function buildGroundingRetryInstruction(result: GroundingResult, originalUserQuestion: string): string;
73
87
  export type { CapabilityHandler };
@@ -71,11 +71,19 @@ If not GROUNDED, list each issue on its own line starting with "- " and the tool
71
71
 
72
72
  Empty line between verdict and list. No other text. No preamble. No apology. Be terse.`;
73
73
  // ─── Trigger policy ──────────────────────────────────────────────────────
74
- const MIN_USER_CHARS = 20; // Short inputs are greetings/acks, not questions
74
+ const MIN_USER_CHARS = 3; // "hi"/"ok"/"no" skip; "BTC"/"21044" do not
75
75
  const MIN_ANSWER_CHARS = 50; // Short answers are acks, not factual claims
76
+ // Factual-content patterns: digits paired with units, currency, dates, or
77
+ // percent/temperature/time signs. If the assistant emitted any of these in
78
+ // a >= MIN_ANSWER_CHARS reply, we check grounding regardless of how short
79
+ // the user's input was — a 5-char ZIP code "21044" can elicit a fabricated
80
+ // weather paragraph, and the original user-length gate let that through.
81
+ const FACTUAL_PATTERN = /(\$\s*\d|\d[\d,]*\s*(?:°[CF]?|%|km|mi|miles?|mph|kph|kg|lbs?|ft|in|cm|hours?|hrs?|minutes?|mins?|seconds?|secs?|GB|MB|KB|TB|USD|EUR|CNY|JPY|BTC|ETH|SOL)|\b(?:19|20)\d{2}-\d{1,2}-\d{1,2}\b|\b\d{1,2}:\d{2}\s*(?:AM|PM|am|pm)?\b|\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2}\b)/;
76
82
  /**
77
83
  * Decide whether this turn warrants a grounding check. Principles:
78
- * - Non-trivial user input (not a greeting, not a slash command)
84
+ * - Non-trivial user input (not a greeting, not a slash command), OR
85
+ * the assistant answer contains specific factual claims (numbers + units,
86
+ * currency, dates, times) regardless of input length
79
87
  * - Non-trivial assistant text output (not just a tool-result echo)
80
88
  *
81
89
  * Intentionally NOT gating on tool-type (read vs write) — the whole point
@@ -85,11 +93,17 @@ export function shouldCheckGrounding(userInput, assistantText) {
85
93
  if (process.env.FRANKLIN_NO_EVAL === '1')
86
94
  return false;
87
95
  const ui = userInput.trim();
88
- if (ui.length < MIN_USER_CHARS)
89
- return false;
90
96
  if (ui.startsWith('/'))
91
97
  return false;
92
- if (assistantText.trim().length < MIN_ANSWER_CHARS)
98
+ const at = assistantText.trim();
99
+ if (at.length < MIN_ANSWER_CHARS)
100
+ return false;
101
+ // If the answer looks factual (numbers + units, dates, prices), check
102
+ // even when the user's prompt was a single token. The 21044 zip-code
103
+ // case lived here.
104
+ if (FACTUAL_PATTERN.test(at))
105
+ return true;
106
+ if (ui.length < MIN_USER_CHARS)
93
107
  return false;
94
108
  return true;
95
109
  }
@@ -293,16 +307,49 @@ export function renderGroundingFollowup(result) {
293
307
  * Intentionally terse: the agent already has the original question in
294
308
  * history; we only need to name the gap + the tools to use.
295
309
  */
310
+ /**
311
+ * Pull the tool names the evaluator suggested out of its issue lines.
312
+ * Issue lines look like:
313
+ * Claim: "..." → missing tool: WebSearch
314
+ * Refusal: "..." → should have called: TradingMarket
315
+ * ... → missing tool: WebSearch (or any distance calculation tool)
316
+ *
317
+ * Returns first-token-of-each-comma/pipe-segment names, deduplicated.
318
+ * Used by both the retry instruction (to name them in prose) and the
319
+ * loop's tool_choice selection (to pin the next request to a tool).
320
+ */
321
+ export function extractMissingToolNames(result) {
322
+ const names = new Set();
323
+ for (const issue of result.issues) {
324
+ const m = issue.match(/(?:missing tool|should have called):\s*([A-Za-z][\w| ,/-]*)/i);
325
+ if (!m)
326
+ continue;
327
+ for (const tok of m[1].split(/[|,/]/)) {
328
+ const t = tok.trim().split(/\s+/)[0];
329
+ if (t && t !== '...' && t !== '(or' && t !== '(any')
330
+ names.add(t);
331
+ }
332
+ }
333
+ return Array.from(names);
334
+ }
296
335
  export function buildGroundingRetryInstruction(result, originalUserQuestion) {
336
+ const namedTools = extractMissingToolNames(result);
337
+ const toolList = namedTools.length > 0
338
+ ? namedTools.join(', ')
339
+ : '(see the missing-tool fields in the issues above)';
297
340
  const lines = [
298
- '[GROUNDING CHECK FAILED]',
299
- 'Your previous answer stated facts without calling the relevant tools. Specifically:',
341
+ '[GROUNDING CHECK FAILED — RETRY ROUND]',
342
+ 'Your previous answer stated facts without calling tools. Specifically:',
300
343
  ];
301
344
  for (const issue of result.issues) {
302
345
  lines.push(`- ${issue}`);
303
346
  }
304
347
  lines.push('');
305
- lines.push('Retry: call the missing tools first, then give a concise final answer based on the tool results. Only claim what the tool outputs actually say. If a tool fails, say so rather than falling back to memory.');
348
+ lines.push('## What you must do this round');
349
+ lines.push(`1. **Call these tools first**, before any prose: ${toolList}.`);
350
+ lines.push('2. **Do not write a single factual sentence until the tool results return.** No restatement of the prior answer, no hedging, no "based on general knowledge".');
351
+ lines.push('3. **Do NOT invent source names** (no fake URLs, no fabricated citation domains, no "per Trippy" / "per drivvin.com" — if you cite a source, it must come from a tool result you just ran).');
352
+ lines.push('4. After tools return, write a concise answer that ONLY restates what the tool outputs say. If a result is partial or a tool failed, say so explicitly — do not paper over with memory.');
306
353
  lines.push('');
307
354
  lines.push(`Original user question: ${originalUserQuestion.trim().slice(0, 500)}`);
308
355
  return lines.join('\n');
@@ -5,6 +5,30 @@
5
5
  */
6
6
  import { type Chain } from '../config.js';
7
7
  import type { Dialogue, CapabilityDefinition, ContentPart, CapabilityInvocation } from './types.js';
8
+ /**
9
+ * Anthropic-compatible tool_choice. Forwarded as-is through the proxy and on
10
+ * to the backend (Anthropic / OpenAI / Gemini gateways translate as needed).
11
+ *
12
+ * - `auto` — model decides (default if omitted)
13
+ * - `any` — must call SOME tool, model picks which
14
+ * - `tool` — must call the specifically named tool
15
+ * - `none` — must not call any tool
16
+ *
17
+ * Used by the grounding-retry path in `loop.ts`: when the evaluator catches
18
+ * an ungrounded answer that should have invoked tools, the next round sets
19
+ * `tool_choice` to force tool use rather than relying on a soft instruction
20
+ * the model can defy by fabricating citations.
21
+ */
22
+ export type ToolChoice = {
23
+ type: 'auto';
24
+ } | {
25
+ type: 'any';
26
+ } | {
27
+ type: 'tool';
28
+ name: string;
29
+ } | {
30
+ type: 'none';
31
+ };
8
32
  export interface ModelRequest {
9
33
  model: string;
10
34
  messages: Dialogue[];
@@ -13,6 +37,7 @@ export interface ModelRequest {
13
37
  max_tokens?: number;
14
38
  stream?: boolean;
15
39
  temperature?: number;
40
+ tool_choice?: ToolChoice;
16
41
  }
17
42
  export interface StreamChunk {
18
43
  kind: 'content_block_start' | 'content_block_delta' | 'content_block_stop' | 'message_start' | 'message_delta' | 'message_stop' | 'ping' | 'error';
package/dist/agent/llm.js CHANGED
@@ -273,6 +273,12 @@ export class ModelClient {
273
273
  const isGLM = request.model.startsWith('zai/') || request.model.includes('glm');
274
274
  // Build the request payload, injecting model-specific optimizations
275
275
  let requestPayload = { ...request, stream: true };
276
+ // Safety: tool_choice without tools causes upstream 400. Strip rather
277
+ // than reject so callers don't have to coordinate the two fields.
278
+ if (requestPayload['tool_choice'] !== undefined &&
279
+ (!Array.isArray(requestPayload['tools']) || requestPayload['tools'].length === 0)) {
280
+ delete requestPayload['tool_choice'];
281
+ }
276
282
  // ── GLM-specific optimizations ───────────────────────────────────────────
277
283
  // GLM models work best with temperature=0.8 per official zai spec.
278
284
  // Enable thinking mode only for explicit reasoning variants (-thinking-).
@@ -25,7 +25,7 @@ import { routeRequestAsync, resolveTierToModel, parseRoutingProfile } from '../r
25
25
  import { recordOutcome } from '../router/local-elo.js';
26
26
  import { shouldPlan, getPlanningPrompt, getExecutorModel, isExecutorStuck, toolCallSignature } from './planner.js';
27
27
  import { shouldVerify, runVerification } from './verification.js';
28
- import { shouldCheckGrounding, checkGrounding, renderGroundingFollowup, buildGroundingRetryInstruction, } from './evaluator.js';
28
+ import { shouldCheckGrounding, checkGrounding, renderGroundingFollowup, buildGroundingRetryInstruction, extractMissingToolNames, } from './evaluator.js';
29
29
  import { augmentUserMessage, prefetchForIntent } from './intent-prefetch.js';
30
30
  import { analyzeTurn } from './turn-analyzer.js';
31
31
  import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, loadSessionHistory, loadSessionMeta, } from '../session/storage.js';
@@ -464,6 +464,12 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
464
464
  // decide — avoids pathological loops, caps wall-clock cost.
465
465
  let groundingRetryCount = 0;
466
466
  const MAX_GROUNDING_RETRIES = 1;
467
+ // When the previous round failed grounding and we're retrying, force the
468
+ // model to actually call a tool this round instead of trusting it to
469
+ // comply with a soft instruction. Single-shot — cleared after attached.
470
+ // Set to `{ type: "tool", name: "X" }` if the evaluator named exactly
471
+ // one available tool, else `{ type: "any" }` so the model picks.
472
+ let forceToolChoiceNextRound = null;
467
473
  // ── Plan-then-execute state (per turn) ──
468
474
  let planActive = false;
469
475
  let planPlannerModel = '';
@@ -767,6 +773,11 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
767
773
  if (sanitized.length !== history.length) {
768
774
  replaceHistory(history, sanitized);
769
775
  }
776
+ // Consume any pending forced tool_choice from the previous round's
777
+ // grounding-retry decision. `tool_choice` is dropped automatically in
778
+ // llm.ts if `tools` ended up empty, so it's safe to attach here.
779
+ const callToolChoice = forceToolChoiceNextRound;
780
+ forceToolChoiceNextRound = null;
770
781
  try {
771
782
  const result = await client.complete({
772
783
  model: resolvedModel,
@@ -775,6 +786,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
775
786
  tools: callToolDefs,
776
787
  max_tokens: callMaxTokens,
777
788
  stream: true,
789
+ ...(callToolChoice ? { tool_choice: callToolChoice } : {}),
778
790
  }, abort.signal,
779
791
  // Start concurrent tools as soon as their input is fully received
780
792
  (tool) => streamExec.onToolReceived(tool),
@@ -1144,9 +1156,24 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1144
1156
  const feedbackMsg = { role: 'user', content: retryMsg };
1145
1157
  history.push(feedbackMsg);
1146
1158
  persistSessionMessage(feedbackMsg);
1159
+ // Hard enforcement: set tool_choice so the model can't fabricate
1160
+ // citations in lieu of running tools (the round-2 failure mode
1161
+ // from the Tampa→Miami log). If the evaluator named exactly one
1162
+ // available tool, pin to it; otherwise force "any" tool use.
1163
+ const namedTools = extractMissingToolNames(gResult);
1164
+ const availableNames = new Set(buildCallToolDefs().map(t => t.name));
1165
+ const matched = namedTools.filter(n => availableNames.has(n));
1166
+ if (matched.length === 1) {
1167
+ forceToolChoiceNextRound = { type: 'tool', name: matched[0] };
1168
+ }
1169
+ else if (availableNames.size > 0) {
1170
+ forceToolChoiceNextRound = { type: 'any' };
1171
+ }
1147
1172
  onEvent({
1148
1173
  kind: 'text_delta',
1149
- text: '\n\n*Ungrounded claims detected — retrying with required tool calls...*\n\n',
1174
+ text: forceToolChoiceNextRound
1175
+ ? `\n\n*Ungrounded claims detected — forcing tool use (${forceToolChoiceNextRound.type === 'tool' ? forceToolChoiceNextRound.name : 'any'}) and retrying...*\n\n`
1176
+ : '\n\n*Ungrounded claims detected — retrying with required tool calls...*\n\n',
1150
1177
  });
1151
1178
  continue; // Re-enter outer loop — generator will produce a new response.
1152
1179
  }
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
6
6
  import { recordUsage } from '../stats/tracker.js';
7
7
  import { appendAudit } from '../stats/audit.js';
8
- import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
8
+ import { buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
9
9
  import { routeRequest, parseRoutingProfile, } from '../router/index.js';
10
10
  import { estimateCost } from '../pricing.js';
11
11
  import { VERSION } from '../config.js';
@@ -41,6 +41,57 @@ function log(...args) {
41
41
  catch { /* ignore */ }
42
42
  }
43
43
  const DEFAULT_MAX_TOKENS = 4096;
44
+ const DEFAULT_PROXY_REQUEST_TIMEOUT_MS = 45_000;
45
+ const DEFAULT_PROXY_STREAM_TIMEOUT_MS = 5 * 60 * 1000;
46
+ function parseTimeoutEnv(name, fallback) {
47
+ const raw = process.env[name];
48
+ if (!raw)
49
+ return fallback;
50
+ const parsed = Number.parseInt(raw, 10);
51
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
52
+ }
53
+ function getProxyRequestTimeoutMs() {
54
+ return parseTimeoutEnv('FRANKLIN_PROXY_REQUEST_TIMEOUT_MS', DEFAULT_PROXY_REQUEST_TIMEOUT_MS);
55
+ }
56
+ function getProxyStreamTimeoutMs() {
57
+ return parseTimeoutEnv('FRANKLIN_PROXY_STREAM_TIMEOUT_MS', DEFAULT_PROXY_STREAM_TIMEOUT_MS);
58
+ }
59
+ function createProxyTimeoutError(label, timeoutMs) {
60
+ return new Error(`${label} timed out after ${timeoutMs}ms`);
61
+ }
62
+ async function fetchWithTimeout(url, init, timeoutMs, label) {
63
+ if (timeoutMs <= 0)
64
+ return fetch(url, init);
65
+ const controller = new AbortController();
66
+ const timeoutError = createProxyTimeoutError(label, timeoutMs);
67
+ const timeout = setTimeout(() => {
68
+ try {
69
+ controller.abort(timeoutError);
70
+ }
71
+ catch { /* ignore */ }
72
+ }, timeoutMs);
73
+ try {
74
+ return await fetch(url, { ...init, signal: controller.signal });
75
+ }
76
+ catch (err) {
77
+ if (controller.signal.aborted)
78
+ throw timeoutError;
79
+ throw err;
80
+ }
81
+ finally {
82
+ clearTimeout(timeout);
83
+ }
84
+ }
85
+ function replaceModelInBody(body, model) {
86
+ try {
87
+ const parsed = JSON.parse(body);
88
+ parsed.model = model;
89
+ return JSON.stringify(parsed);
90
+ }
91
+ catch {
92
+ return body;
93
+ }
94
+ }
44
95
  // Per-model last output tokens for adaptive max_tokens (avoids cross-request pollution)
45
96
  const MAX_TRACKED_MODELS = 50;
46
97
  const lastOutputByModel = new Map();
@@ -369,13 +420,21 @@ export function createProxy(options) {
369
420
  };
370
421
  let response;
371
422
  let finalModel = requestModel;
423
+ const requestTimeoutMs = getProxyRequestTimeoutMs();
372
424
  // Use fallback chain if enabled
373
425
  if (fallbackEnabled && body && requestPath.includes('messages')) {
374
426
  const fallbackConfig = {
375
427
  ...DEFAULT_FALLBACK_CONFIG,
376
428
  chain: buildFallbackChain(requestModel),
377
429
  };
378
- const result = await fetchWithFallback(targetUrl, requestInit, body, fallbackConfig, (failedModel, status, nextModel) => {
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) => {
379
438
  log(`⚠️ ${failedModel} returned ${status}, falling back to ${nextModel}`);
380
439
  });
381
440
  response = result.response;
@@ -388,20 +447,14 @@ export function createProxy(options) {
388
447
  }
389
448
  }
390
449
  else {
391
- // Direct fetch without fallback (with timeout)
392
- const directCtrl = new AbortController();
393
- const directTimeout = setTimeout(() => directCtrl.abort(), 120_000); // 2min
394
- response = await fetch(targetUrl, { ...requestInit, signal: directCtrl.signal });
395
- clearTimeout(directTimeout);
396
- }
397
- // Handle 402 payment — body now has the correct model after fallback
398
- if (response.status === 402) {
399
- if (chain === 'solana' && solanaWallet) {
400
- response = await handleSolanaPayment(response, targetUrl, req.method || 'POST', headers, body, solanaWallet.privateKey, solanaWallet.address);
401
- }
402
- else if (baseWallet) {
403
- response = await handleBasePayment(response, targetUrl, req.method || 'POST', headers, body, baseWallet.privateKey, baseWallet.address);
404
- }
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
+ });
405
458
  }
406
459
  const responseHeaders = {};
407
460
  response.headers.forEach((v, k) => {
@@ -452,7 +505,7 @@ export function createProxy(options) {
452
505
  const decoder = new TextDecoder();
453
506
  let fullResponse = '';
454
507
  const STREAM_CAP = 5_000_000; // 5MB cap on accumulated stream
455
- const STREAM_TIMEOUT_MS = 5 * 60 * 1000; // 5 min timeout for entire stream
508
+ const STREAM_TIMEOUT_MS = getProxyStreamTimeoutMs();
456
509
  const streamDeadline = Date.now() + STREAM_TIMEOUT_MS;
457
510
  const pump = async () => {
458
511
  while (true) {
@@ -563,10 +616,77 @@ export function createProxy(options) {
563
616
  });
564
617
  return server;
565
618
  }
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
+ }
566
686
  // ======================================================================
567
687
  // Base (EIP-712) payment handler
568
688
  // ======================================================================
569
- async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress) {
689
+ async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
570
690
  const paymentHeader = await extractPaymentHeader(response);
571
691
  if (!paymentHeader) {
572
692
  throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
@@ -579,19 +699,19 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
579
699
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
580
700
  extra: details.extra,
581
701
  });
582
- return fetch(url, {
702
+ return fetchWithTimeout(url, {
583
703
  method,
584
704
  headers: {
585
705
  ...headers,
586
706
  'PAYMENT-SIGNATURE': paymentPayload,
587
707
  },
588
708
  body: body || undefined,
589
- });
709
+ }, timeoutMs, `Paid proxy request for ${model}`);
590
710
  }
591
711
  // ======================================================================
592
712
  // Solana payment handler
593
713
  // ======================================================================
594
- async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress) {
714
+ async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress, timeoutMs = getProxyRequestTimeoutMs(), model = 'unknown') {
595
715
  const paymentHeader = await extractPaymentHeader(response);
596
716
  if (!paymentHeader) {
597
717
  throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
@@ -606,14 +726,14 @@ async function handleSolanaPayment(response, url, method, headers, body, private
606
726
  maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
607
727
  extra: details.extra,
608
728
  });
609
- return fetch(url, {
729
+ return fetchWithTimeout(url, {
610
730
  method,
611
731
  headers: {
612
732
  ...headers,
613
733
  'PAYMENT-SIGNATURE': paymentPayload,
614
734
  },
615
735
  body: body || undefined,
616
- });
736
+ }, timeoutMs, `Paid proxy request for ${model}`);
617
737
  }
618
738
  export function classifyRequest(body) {
619
739
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.36",
3
+ "version": "3.8.38",
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": {