@blockrun/franklin 3.15.49 → 3.15.51
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 +58 -5
- package/dist/ui/app.js +7 -1
- package/dist/ui/ask-user-answer.d.ts +24 -0
- package/dist/ui/ask-user-answer.js +34 -0
- package/package.json +1 -1
package/dist/agent/llm.js
CHANGED
|
@@ -431,11 +431,64 @@ export class ModelClient {
|
|
|
431
431
|
if (!response.ok) {
|
|
432
432
|
const errorBody = await response.text().catch(() => 'unknown error');
|
|
433
433
|
const message = extractApiErrorMessage(errorBody);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
434
|
+
// Runtime tool_choice retry. The static allowlist at line ~35
|
|
435
|
+
// catches the case where the request goes directly to a model
|
|
436
|
+
// whose name contains `deepseek-reasoner` / `openai/o1` /
|
|
437
|
+
// `openai/o3`. But the gateway sometimes ALIASES a different
|
|
438
|
+
// model name to a reasoner backend — verified 2026-05-04 in a
|
|
439
|
+
// live session: a request for `deepseek/deepseek-v4-pro`
|
|
440
|
+
// returned `400 Invalid request: 400 deepseek-reasoner does not
|
|
441
|
+
// support this tool_choice`, because the gateway routed v4-pro
|
|
442
|
+
// to a deepseek-reasoner upstream. The static allowlist can't
|
|
443
|
+
// know that. Catch the error, drop tool_choice, re-fire once.
|
|
444
|
+
// No payment re-sign needed — original 402 already settled, and
|
|
445
|
+
// the gateway treats this as the same logical request.
|
|
446
|
+
const lc = message.toLowerCase();
|
|
447
|
+
const looksLikeToolChoiceReject = response.status === 400 &&
|
|
448
|
+
lc.includes('tool_choice') &&
|
|
449
|
+
(lc.includes('not support') || lc.includes('unsupported') || lc.includes('does not support'));
|
|
450
|
+
if (looksLikeToolChoiceReject && requestPayload['tool_choice'] !== undefined) {
|
|
451
|
+
delete requestPayload['tool_choice'];
|
|
452
|
+
const retryBody = JSON.stringify(requestPayload);
|
|
453
|
+
if (this.debug) {
|
|
454
|
+
console.error(`[franklin] tool_choice rejected by upstream; retrying without it (model=${request.model})`);
|
|
455
|
+
}
|
|
456
|
+
response = await withAbortableTimeout(() => fetch(endpoint, {
|
|
457
|
+
method: 'POST',
|
|
458
|
+
headers,
|
|
459
|
+
body: retryBody,
|
|
460
|
+
signal: requestController.signal,
|
|
461
|
+
}), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
|
|
462
|
+
if (response.status === 402) {
|
|
463
|
+
const paymentHeader = await this.signPayment(response);
|
|
464
|
+
if (!paymentHeader) {
|
|
465
|
+
yield { kind: 'error', payload: { message: 'Payment signing failed' } };
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
response = await withAbortableTimeout(() => fetch(endpoint, {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
headers: { ...headers, ...paymentHeader },
|
|
471
|
+
body: retryBody,
|
|
472
|
+
signal: requestController.signal,
|
|
473
|
+
}), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
|
|
474
|
+
}
|
|
475
|
+
if (!response.ok) {
|
|
476
|
+
const retryBodyText = await response.text().catch(() => 'unknown error');
|
|
477
|
+
yield {
|
|
478
|
+
kind: 'error',
|
|
479
|
+
payload: { status: response.status, message: extractApiErrorMessage(retryBodyText) },
|
|
480
|
+
};
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
// Successful retry — fall through to SSE parsing below.
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
yield {
|
|
487
|
+
kind: 'error',
|
|
488
|
+
payload: { status: response.status, message },
|
|
489
|
+
};
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
439
492
|
}
|
|
440
493
|
// Parse SSE stream
|
|
441
494
|
yield* this.parseSSEStream(response, requestController, streamTimeoutMs, request.model);
|
package/dist/ui/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-pi
|
|
|
14
14
|
import { estimateCost } from '../pricing.js';
|
|
15
15
|
import { formatTokens, shortModelName } from '../stats/format.js';
|
|
16
16
|
import { mouse, forceDisableMouseTracking } from './mouse.js';
|
|
17
|
+
import { resolveAskUserAnswer } from './ask-user-answer.js';
|
|
17
18
|
// ─── Full-width input box ──────────────────────────────────────────────────
|
|
18
19
|
const DISABLE_AUTO_WRAP = '\x1b[?7l';
|
|
19
20
|
const ENABLE_AUTO_WRAP = '\x1b[?7h';
|
|
@@ -827,7 +828,12 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
827
828
|
? _jsxs(Text, { color: r.ctxPct >= 80 ? 'red' : r.ctxPct >= 50 ? 'yellow' : undefined, dimColor: r.ctxPct < 50, children: [" \u00B7 ctx ", r.ctxPct, "%"] })
|
|
828
829
|
: ''] }) }))] }, r.key));
|
|
829
830
|
} }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "red", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ACTION REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "yellow", children: "\u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: ["\u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "magenta", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ANSWER REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "cyan", children: "\u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: ["\u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => {
|
|
830
|
-
|
|
831
|
+
// resolveAskUserAnswer translates "1" / "2" / ... into the
|
|
832
|
+
// matching label string when the dialog showed a numbered
|
|
833
|
+
// option list. Without it, every onAskUser caller's
|
|
834
|
+
// exact-label match fails for digit answers and silently
|
|
835
|
+
// falls through to the default branch (typically cancel).
|
|
836
|
+
const answer = resolveAskUserAnswer(val, askUserRequest.options);
|
|
831
837
|
const r = askUserRequest.resolve;
|
|
832
838
|
setAskUserRequest(null);
|
|
833
839
|
setAskUserInput('');
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a user-typed AskUser answer against the option list.
|
|
3
|
+
*
|
|
4
|
+
* The TUI renders option labels as a numbered list ("1. X", "2. Y", …),
|
|
5
|
+
* so users naturally type the digit. Every tool-side onAskUser caller
|
|
6
|
+
* (videogen.ts:113, modal.ts:371, jupiter.ts:368, zerox-base.ts:453,
|
|
7
|
+
* zerox-gasless.ts:446) does an exact-string match against the full
|
|
8
|
+
* label, so a bare "1" silently falls through to the caller's default
|
|
9
|
+
* branch — which is typically "cancel".
|
|
10
|
+
*
|
|
11
|
+
* Verified 2026-05-04 in a live session: user typed "1" twice in a
|
|
12
|
+
* VideoGen flow, both invocations returned "Video generation cancelled
|
|
13
|
+
* (No USDC was spent)" even though the wallet had $94.72 and the
|
|
14
|
+
* Content budget had $2.00 untouched.
|
|
15
|
+
*
|
|
16
|
+
* Translation rules:
|
|
17
|
+
* - "" → "(no response)" (preserve the existing empty-input fallback)
|
|
18
|
+
* - "<digit>" with options.length > 0 and 1 ≤ digit ≤ options.length
|
|
19
|
+
* → the matching label string
|
|
20
|
+
* - anything else → the trimmed input verbatim (callers that match
|
|
21
|
+
* against label can still get a literal answer when the user types
|
|
22
|
+
* it out, and free-form text questions still work the same way)
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveAskUserAnswer(raw: string, options: readonly string[] | undefined): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a user-typed AskUser answer against the option list.
|
|
3
|
+
*
|
|
4
|
+
* The TUI renders option labels as a numbered list ("1. X", "2. Y", …),
|
|
5
|
+
* so users naturally type the digit. Every tool-side onAskUser caller
|
|
6
|
+
* (videogen.ts:113, modal.ts:371, jupiter.ts:368, zerox-base.ts:453,
|
|
7
|
+
* zerox-gasless.ts:446) does an exact-string match against the full
|
|
8
|
+
* label, so a bare "1" silently falls through to the caller's default
|
|
9
|
+
* branch — which is typically "cancel".
|
|
10
|
+
*
|
|
11
|
+
* Verified 2026-05-04 in a live session: user typed "1" twice in a
|
|
12
|
+
* VideoGen flow, both invocations returned "Video generation cancelled
|
|
13
|
+
* (No USDC was spent)" even though the wallet had $94.72 and the
|
|
14
|
+
* Content budget had $2.00 untouched.
|
|
15
|
+
*
|
|
16
|
+
* Translation rules:
|
|
17
|
+
* - "" → "(no response)" (preserve the existing empty-input fallback)
|
|
18
|
+
* - "<digit>" with options.length > 0 and 1 ≤ digit ≤ options.length
|
|
19
|
+
* → the matching label string
|
|
20
|
+
* - anything else → the trimmed input verbatim (callers that match
|
|
21
|
+
* against label can still get a literal answer when the user types
|
|
22
|
+
* it out, and free-form text questions still work the same way)
|
|
23
|
+
*/
|
|
24
|
+
export function resolveAskUserAnswer(raw, options) {
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
return '(no response)';
|
|
28
|
+
if (options && options.length > 0 && /^\d+$/.test(trimmed)) {
|
|
29
|
+
const idx = parseInt(trimmed, 10) - 1;
|
|
30
|
+
if (idx >= 0 && idx < options.length)
|
|
31
|
+
return options[idx];
|
|
32
|
+
}
|
|
33
|
+
return trimmed;
|
|
34
|
+
}
|
package/package.json
CHANGED