@blockrun/franklin 3.9.2 → 3.9.4

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.
@@ -90,6 +90,7 @@ export declare function modelHasExtendedThinking(model: string): boolean;
90
90
  * direct unit testing — the happy path hits it only on stream error.
91
91
  */
92
92
  export declare function classifyToolCallFailure(toolName: string, rawInput: string, signal: AbortSignal | undefined, model: string): string;
93
+ export declare function isRoleplayedJsonToolCallText(text: string): boolean;
93
94
  export declare class ModelClient {
94
95
  private apiUrl;
95
96
  private chain;
package/dist/agent/llm.js CHANGED
@@ -170,6 +170,23 @@ export function classifyToolCallFailure(toolName, rawInput, signal, model) {
170
170
  `Preview: ${preview}${rawInput.length > 120 ? '…' : ''} — ` +
171
171
  `this is usually a model output bug; try \`/model <other>\` or retry.]`;
172
172
  }
173
+ export function isRoleplayedJsonToolCallText(text) {
174
+ const trimmed = text.trim();
175
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}'))
176
+ return false;
177
+ try {
178
+ const parsed = JSON.parse(trimmed);
179
+ return (parsed !== null &&
180
+ typeof parsed === 'object' &&
181
+ !Array.isArray(parsed) &&
182
+ parsed.type === 'function' &&
183
+ typeof parsed.name === 'string' &&
184
+ ('parameters' in parsed || 'arguments' in parsed));
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
173
190
  function applyAnthropicPromptCaching(payload, request) {
174
191
  const out = { ...payload };
175
192
  const cacheMarker = { type: 'ephemeral' };
@@ -402,6 +419,7 @@ export class ModelClient {
402
419
  let currentToolId = '';
403
420
  let currentToolName = '';
404
421
  let currentToolInput = '';
422
+ const textEmission = { mode: 'undecided' };
405
423
  // Split inline <think>…</think> emitted by reasoning models (nemotron,
406
424
  // deepseek-r1, qwq, etc.) that use the text field instead of the native
407
425
  // thinking block. Thinking emitted this way is display-only — we don't
@@ -413,6 +431,24 @@ export class ModelClient {
413
431
  // system-prompt guard in loop.ts is responsible for preventing this.
414
432
  // Debug-only because the user already sees the literal text in the UI.
415
433
  let toolCallRoleplayWarned = false;
434
+ const appendText = (text) => {
435
+ if (!text)
436
+ return;
437
+ currentText += text;
438
+ if (textEmission.mode === 'undecided') {
439
+ const trimmed = currentText.trimStart();
440
+ if (!trimmed)
441
+ return;
442
+ textEmission.mode = trimmed.startsWith('{') ? 'hold' : 'stream';
443
+ if (textEmission.mode === 'stream') {
444
+ onStreamDelta?.({ type: 'text', text: currentText });
445
+ }
446
+ return;
447
+ }
448
+ if (textEmission.mode === 'stream') {
449
+ onStreamDelta?.({ type: 'text', text });
450
+ }
451
+ };
416
452
  for await (const chunk of this.streamCompletion(request, signal)) {
417
453
  switch (chunk.kind) {
418
454
  case 'content_block_start': {
@@ -429,6 +465,7 @@ export class ModelClient {
429
465
  }
430
466
  else if (cblock?.type === 'text') {
431
467
  currentText = '';
468
+ textEmission.mode = 'undecided';
432
469
  textStripper = new ThinkTagStripper();
433
470
  }
434
471
  break;
@@ -458,9 +495,7 @@ export class ModelClient {
458
495
  }
459
496
  for (const seg of textStripper.push(raw)) {
460
497
  if (seg.type === 'text') {
461
- currentText += seg.text;
462
- if (seg.text)
463
- onStreamDelta?.({ type: 'text', text: seg.text });
498
+ appendText(seg.text);
464
499
  }
465
500
  else if (seg.text) {
466
501
  onStreamDelta?.({ type: 'thinking', text: seg.text });
@@ -537,20 +572,30 @@ export class ModelClient {
537
572
  // Flush any partial tag held in the stripper
538
573
  for (const seg of textStripper.flush()) {
539
574
  if (seg.type === 'text') {
540
- currentText += seg.text;
541
- if (seg.text)
542
- onStreamDelta?.({ type: 'text', text: seg.text });
575
+ appendText(seg.text);
543
576
  }
544
577
  else if (seg.text) {
545
578
  onStreamDelta?.({ type: 'thinking', text: seg.text });
546
579
  }
547
580
  }
548
581
  if (currentText) {
549
- collected.push({
550
- type: 'text',
551
- text: currentText,
552
- });
582
+ if (textEmission.mode === 'hold' && isRoleplayedJsonToolCallText(currentText)) {
583
+ if (this.debug) {
584
+ console.error(`[franklin] Model ${request.model} emitted a raw JSON function-call object as text. ` +
585
+ 'Treating it as non-productive output so recovery can try another model.');
586
+ }
587
+ }
588
+ else {
589
+ if (textEmission.mode !== 'stream') {
590
+ onStreamDelta?.({ type: 'text', text: currentText });
591
+ }
592
+ collected.push({
593
+ type: 'text',
594
+ text: currentText,
595
+ });
596
+ }
553
597
  currentText = '';
598
+ textEmission.mode = 'undecided';
554
599
  }
555
600
  }
556
601
  break;
@@ -588,16 +633,25 @@ export class ModelClient {
588
633
  // Flush any remaining text (stream ended without content_block_stop)
589
634
  for (const seg of textStripper.flush()) {
590
635
  if (seg.type === 'text') {
591
- currentText += seg.text;
592
- if (seg.text)
593
- onStreamDelta?.({ type: 'text', text: seg.text });
636
+ appendText(seg.text);
594
637
  }
595
638
  else if (seg.text) {
596
639
  onStreamDelta?.({ type: 'thinking', text: seg.text });
597
640
  }
598
641
  }
599
642
  if (currentText) {
600
- collected.push({ type: 'text', text: currentText });
643
+ if (textEmission.mode === 'hold' && isRoleplayedJsonToolCallText(currentText)) {
644
+ if (this.debug) {
645
+ console.error(`[franklin] Model ${request.model} emitted a raw JSON function-call object as text. ` +
646
+ 'Treating it as non-productive output so recovery can try another model.');
647
+ }
648
+ }
649
+ else {
650
+ if (textEmission.mode !== 'stream') {
651
+ onStreamDelta?.({ type: 'text', text: currentText });
652
+ }
653
+ collected.push({ type: 'text', text: currentText });
654
+ }
601
655
  }
602
656
  return { content: collected, usage, stopReason };
603
657
  }
@@ -822,8 +822,10 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
822
822
  '\n\n# Available tools\n' +
823
823
  `You have exactly these tools: ${names}.\n` +
824
824
  'Do not invent other tool names. Do not emit literal "[TOOLCALL]", ' +
825
- '"<tool_call>", or similar tokens in your text — call tools via the ' +
826
- 'proper API only. If no tool fits, explain plainly in prose.';
825
+ '"<tool_call>", raw JSON function-call objects like {"type":"function","name":"Tool","parameters":{}}, ' +
826
+ 'or similar tokens in your text call tools via the proper API only. ' +
827
+ 'If the user asks you to echo a token, marker, or string, echo it as plain text; ' +
828
+ 'do not call Wallet or any other tool unless the user explicitly asks for that tool-backed information.';
827
829
  }
828
830
  // Safety net: handled in llm.ts resolveVirtualModel()
829
831
  // Sanitize: remove orphaned tool results that could confuse the API
@@ -197,6 +197,9 @@ const MODEL_CONTEXT_WINDOWS = {
197
197
  'moonshot/kimi-k2.6': 256_000,
198
198
  'moonshot/kimi-k2.5': 128_000,
199
199
  'minimax/minimax-m2.7': 128_000,
200
+ // NVIDIA-hosted free tier (2026-04-29 V4 Flash + Omni launch)
201
+ 'nvidia/deepseek-v4-flash': 1_000_000,
202
+ 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning': 256_000,
200
203
  };
201
204
  /**
202
205
  * Get the context window size for a model, with a conservative default.
@@ -3,7 +3,7 @@ import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm';
3
3
  import { loadChain, API_URLS } from '../config.js';
4
4
  import { retryFetchBalance } from './balance-retry.js';
5
5
  import { flushStats, loadStats } from '../stats/tracker.js';
6
- import { OPUS_PRICING } from '../pricing.js';
6
+ import { OPUS_PRICING, MODEL_PRICING } from '../pricing.js';
7
7
  import { loadConfig } from './config.js';
8
8
  import { printBanner } from '../banner.js';
9
9
  import { assembleInstructions } from '../agent/context.js';
@@ -126,17 +126,11 @@ export async function startCommand(options) {
126
126
  return;
127
127
  }
128
128
  // Warn when a paid model is active so users know they'll be charged.
129
- // Set members = BlockRun gateway's current live free tier (refreshed 2026-04).
130
- const FREE_MODELS = new Set([
131
- 'nvidia/glm-4.7',
132
- 'nvidia/qwen3-next-80b-a3b-thinking',
133
- 'nvidia/qwen3-coder-480b',
134
- 'nvidia/mistral-small-4-119b',
135
- 'nvidia/llama-4-maverick',
136
- 'nvidia/deepseek-v3.2',
137
- 'blockrun/free',
138
- ]);
139
- if (!FREE_MODELS.has(model)) {
129
+ // Derive "free" from MODEL_PRICING so adding a new free entry there is enough —
130
+ // no second hardcoded list to keep in sync.
131
+ const pricing = MODEL_PRICING[model];
132
+ const isFree = pricing != null && pricing.input === 0 && pricing.output === 0 && (pricing.perCall ?? 0) === 0;
133
+ if (!isFree) {
140
134
  console.log(chalk.yellow(` Model: ${model} (paid — charges from your wallet per call)`));
141
135
  console.log(chalk.dim(` Switch to free with: /model free\n`));
142
136
  }
package/dist/pricing.js CHANGED
@@ -8,7 +8,9 @@ export const MODEL_PRICING = {
8
8
  'blockrun/eco': { input: 0.2, output: 1.0 },
9
9
  'blockrun/premium': { input: 3.0, output: 15.0 },
10
10
  'blockrun/free': { input: 0, output: 0 },
11
- // FREE — BlockRun gateway free tier (refreshed 2026-04)
11
+ // FREE — BlockRun gateway free tier (refreshed 2026-04-29 with V4 Flash + Omni launch)
12
+ 'nvidia/deepseek-v4-flash': { input: 0, output: 0 },
13
+ 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning': { input: 0, output: 0 },
12
14
  'nvidia/glm-4.7': { input: 0, output: 0 },
13
15
  'nvidia/qwen3-next-80b-a3b-thinking': { input: 0, output: 0 },
14
16
  'nvidia/qwen3-coder-480b': { input: 0, output: 0 },
@@ -118,26 +118,26 @@ export const PICKER_CATEGORIES = [
118
118
  ],
119
119
  },
120
120
  {
121
+ // Picker trim (v3.9.3): hide superseded / awkward-middle / niche-premium
122
+ // entries to bring choice paralysis down. Their shortcuts (`opus-4.6`,
123
+ // `gpt-5.4`, `gpt-5.4-pro`, `grok`, `o1`, `o4`, `nano`) all stay live in
124
+ // MODEL_SHORTCUTS, so muscle memory keeps working — they just aren't
125
+ // listed in the visible picker. Same pattern v3.9.0 used to retire dead
126
+ // free-tier entries and v3.9.2 used to retire Kimi K2.5.
121
127
  category: '✨ Premium frontier',
122
128
  models: [
123
129
  { id: 'anthropic/claude-opus-4.7', shortcut: 'opus', label: 'Claude Opus 4.7', price: '$5/$25', highlight: true },
124
130
  { id: 'anthropic/claude-sonnet-4.6', shortcut: 'sonnet', label: 'Claude Sonnet 4.6', price: '$3/$15' },
125
- { id: 'anthropic/claude-opus-4.6', shortcut: 'opus-4.6', label: 'Claude Opus 4.6', price: '$5/$25' },
126
131
  { id: 'openai/gpt-5.5', shortcut: 'gpt', label: 'GPT-5.5', price: '$5/$30', highlight: true },
127
- { id: 'openai/gpt-5.4', shortcut: 'gpt-5.4', label: 'GPT-5.4', price: '$2.5/$15' },
128
- { id: 'openai/gpt-5.4-pro', shortcut: 'gpt-5.4-pro', label: 'GPT-5.4 Pro', price: '$30/$180' },
129
- { id: 'google/gemini-2.5-pro', shortcut: 'gemini', label: 'Gemini 2.5 Pro', price: '$1.25/$10' },
130
132
  { id: 'google/gemini-3.1-pro', shortcut: 'gemini-3', label: 'Gemini 3.1 Pro', price: '$2/$12' },
133
+ { id: 'google/gemini-2.5-pro', shortcut: 'gemini', label: 'Gemini 2.5 Pro', price: '$1.25/$10' },
131
134
  { id: 'xai/grok-4-0709', shortcut: 'grok-4', label: 'Grok 4', price: '$0.2/$1.5' },
132
- { id: 'xai/grok-3', shortcut: 'grok', label: 'Grok 3', price: '$3/$15' },
133
135
  ],
134
136
  },
135
137
  {
136
138
  category: '🔬 Reasoning',
137
139
  models: [
138
140
  { id: 'openai/o3', shortcut: 'o3', label: 'O3', price: '$2/$8' },
139
- { id: 'openai/o4-mini', shortcut: 'o4', label: 'O4 Mini', price: '$1.1/$4.4' },
140
- { id: 'openai/o1', shortcut: 'o1', label: 'O1', price: '$15/$60' },
141
141
  { id: 'openai/gpt-5.3-codex', shortcut: 'codex', label: 'GPT-5.3 Codex', price: '$1.75/$14' },
142
142
  { id: 'deepseek/deepseek-reasoner', shortcut: 'r1', label: 'DeepSeek R1', price: '$0.28/$0.42' },
143
143
  { id: 'xai/grok-4-1-fast-reasoning', shortcut: 'grok-fast', label: 'Grok 4.1 Fast R.', price: '$0.2/$0.5' },
@@ -148,7 +148,6 @@ export const PICKER_CATEGORIES = [
148
148
  models: [
149
149
  { id: 'anthropic/claude-haiku-4.5-20251001', shortcut: 'haiku', label: 'Claude Haiku 4.5', price: '$1/$5' },
150
150
  { id: 'openai/gpt-5-mini', shortcut: 'mini', label: 'GPT-5 Mini', price: '$0.25/$2' },
151
- { id: 'openai/gpt-5-nano', shortcut: 'nano', label: 'GPT-5 Nano', price: '$0.05/$0.4' },
152
151
  { id: 'google/gemini-2.5-flash', shortcut: 'flash', label: 'Gemini 2.5 Flash', price: '$0.3/$2.5' },
153
152
  { id: 'deepseek/deepseek-chat', shortcut: 'deepseek', label: 'DeepSeek V3', price: '$0.28/$0.42' },
154
153
  { id: 'moonshot/kimi-k2.6', shortcut: 'kimi', label: 'Kimi K2.6', price: '$0.95/$4' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.9.2",
3
+ "version": "3.9.4",
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": {