@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.
- package/dist/agent/evaluator.d.ts +15 -1
- package/dist/agent/evaluator.js +55 -8
- package/dist/agent/llm.d.ts +25 -0
- package/dist/agent/llm.js +6 -0
- package/dist/agent/loop.js +29 -2
- package/dist/proxy/server.js +143 -23
- package/package.json +1 -1
|
@@ -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 };
|
package/dist/agent/evaluator.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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('
|
|
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');
|
package/dist/agent/llm.d.ts
CHANGED
|
@@ -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-).
|
package/dist/agent/loop.js
CHANGED
|
@@ -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:
|
|
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
|
}
|
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();
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
}
|
|
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 =
|
|
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
|
|
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
|
|
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