@bankr/cli 0.3.13 → 0.3.32

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.
@@ -5,7 +5,13 @@ import { spawn } from "child_process";
5
5
  import { CLI_USER_AGENT, getApiUrl, getLlmKey, getLlmUrl, requireApiKey, } from "../lib/config.js";
6
6
  import { getBalances } from "../lib/api.js";
7
7
  import * as output from "../lib/output.js";
8
+ import { getErrorMessage } from "../lib/errors.js";
9
+ import { isHelpRequest } from "../lib/passthrough.js";
8
10
  const ERR_LLM_NOT_ENABLED = "LLM Gateway not enabled on this API key. Enable at bankr.bot/api-keys";
11
+ /** True when the model serves chat completions (image-only models don't). */
12
+ function supportsChat(m) {
13
+ return (m.output ?? ["text"]).includes("text");
14
+ }
9
15
  /**
10
16
  * Fallback model catalog — used when the gateway is unreachable or unauthenticated.
11
17
  * resolveModels() fetches live data from GET /v1/models; this list is the offline safety net.
@@ -14,6 +20,15 @@ const IMAGE_INPUT = ["text", "image"];
14
20
  const TEXT_INPUT = ["text"];
15
21
  const GATEWAY_MODELS = [
16
22
  // Claude
23
+ {
24
+ id: "claude-fable-5.1",
25
+ name: "Claude Fable 5.1",
26
+ owned_by: "anthropic",
27
+ contextWindow: 1000000,
28
+ maxTokens: 128000,
29
+ input: IMAGE_INPUT,
30
+ cost: { input: 10.0, output: 50.0, cacheRead: 0.25, cacheWrite: 12.5 },
31
+ },
17
32
  {
18
33
  id: "claude-fable-5",
19
34
  name: "Claude Fable 5",
@@ -23,6 +38,15 @@ const GATEWAY_MODELS = [
23
38
  input: IMAGE_INPUT,
24
39
  cost: { input: 10.0, output: 50.0, cacheRead: 1.0, cacheWrite: 12.5 },
25
40
  },
41
+ {
42
+ id: "claude-opus-5",
43
+ name: "Claude Opus 5",
44
+ owned_by: "anthropic",
45
+ contextWindow: 1000000,
46
+ maxTokens: 128000,
47
+ input: IMAGE_INPUT,
48
+ cost: { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25 },
49
+ },
26
50
  {
27
51
  id: "claude-opus-4.8",
28
52
  name: "Claude Opus 4.8",
@@ -59,6 +83,15 @@ const GATEWAY_MODELS = [
59
83
  input: IMAGE_INPUT,
60
84
  cost: { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25 },
61
85
  },
86
+ {
87
+ id: "claude-sonnet-5",
88
+ name: "Claude Sonnet 5",
89
+ owned_by: "anthropic",
90
+ contextWindow: 1000000,
91
+ maxTokens: 128000,
92
+ input: IMAGE_INPUT,
93
+ cost: { input: 2.0, output: 10.0, cacheRead: 0.2, cacheWrite: 2.5 },
94
+ },
62
95
  {
63
96
  id: "claude-sonnet-4.6",
64
97
  name: "Claude Sonnet 4.6",
@@ -88,22 +121,49 @@ const GATEWAY_MODELS = [
88
121
  },
89
122
  // Gemini
90
123
  {
91
- id: "gemini-3.1-pro",
92
- name: "Gemini 3.1 Pro",
124
+ id: "gemini-3.8-flash",
125
+ name: "Gemini 3.8 Flash",
93
126
  owned_by: "google",
94
127
  contextWindow: 1048576,
95
128
  maxTokens: 65536,
96
129
  input: IMAGE_INPUT,
97
- cost: { input: 2.0, output: 12.0, cacheRead: 0.2, cacheWrite: 0.375 },
130
+ cost: { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.04167 },
98
131
  },
99
132
  {
100
- id: "gemini-3.1-flash-lite",
101
- name: "Gemini 3.1 Flash Lite",
133
+ id: "gemini-3.7-flash",
134
+ name: "Gemini 3.7 Flash",
102
135
  owned_by: "google",
103
136
  contextWindow: 1048576,
104
137
  maxTokens: 65536,
105
138
  input: IMAGE_INPUT,
106
- cost: { input: 0.25, output: 1.5, cacheRead: 0.025, cacheWrite: 0.08333 },
139
+ cost: { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.04167 },
140
+ },
141
+ {
142
+ id: "gemini-3.6-flash",
143
+ name: "Gemini 3.6 Flash",
144
+ owned_by: "google",
145
+ contextWindow: 1048576,
146
+ maxTokens: 65536,
147
+ input: IMAGE_INPUT,
148
+ cost: { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0.04167 },
149
+ },
150
+ {
151
+ id: "gemini-3.5-flash",
152
+ name: "Gemini 3.5 Flash",
153
+ owned_by: "google",
154
+ contextWindow: 1048576,
155
+ maxTokens: 65536,
156
+ input: IMAGE_INPUT,
157
+ cost: { input: 1.5, output: 9.0, cacheRead: 0.15, cacheWrite: 0.08333 },
158
+ },
159
+ {
160
+ id: "gemini-3.1-pro",
161
+ name: "Gemini 3.1 Pro",
162
+ owned_by: "google",
163
+ contextWindow: 1048576,
164
+ maxTokens: 65536,
165
+ input: IMAGE_INPUT,
166
+ cost: { input: 2.0, output: 12.0, cacheRead: 0.2, cacheWrite: 0.375 },
107
167
  },
108
168
  {
109
169
  id: "gemini-3-flash",
@@ -132,7 +192,74 @@ const GATEWAY_MODELS = [
132
192
  input: IMAGE_INPUT,
133
193
  cost: { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0.0833 },
134
194
  },
195
+ // Gemma
196
+ {
197
+ id: "gemma-4-31b-it",
198
+ name: "Gemma 4 31B IT",
199
+ owned_by: "google",
200
+ contextWindow: 262144,
201
+ maxTokens: 131072,
202
+ input: IMAGE_INPUT,
203
+ cost: { input: 0.14, output: 0.4, cacheRead: 0, cacheWrite: 0 },
204
+ },
205
+ {
206
+ id: "gemma-4-26b-a4b-it",
207
+ name: "Gemma 4 26B A4B IT",
208
+ owned_by: "google",
209
+ contextWindow: 262144,
210
+ maxTokens: 262144,
211
+ input: IMAGE_INPUT,
212
+ cost: { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 },
213
+ },
135
214
  // OpenAI
215
+ {
216
+ id: "gpt-6-astra",
217
+ name: "GPT 6 Astra",
218
+ owned_by: "openai",
219
+ contextWindow: 1050000,
220
+ maxTokens: 128000,
221
+ input: IMAGE_INPUT,
222
+ cost: { input: 10.0, output: 50.0, cacheRead: 1.0, cacheWrite: 12.5 },
223
+ },
224
+ {
225
+ id: "gpt-5.6-sol",
226
+ name: "GPT 5.6 Sol",
227
+ owned_by: "openai",
228
+ contextWindow: 1050000,
229
+ maxTokens: 128000,
230
+ input: IMAGE_INPUT,
231
+ cost: { input: 4.0, output: 20.0, cacheRead: 0.4, cacheWrite: 5.0 },
232
+ },
233
+ {
234
+ id: "gpt-5.6-terra",
235
+ name: "GPT 5.6 Terra",
236
+ owned_by: "openai",
237
+ contextWindow: 1050000,
238
+ maxTokens: 128000,
239
+ input: IMAGE_INPUT,
240
+ cost: { input: 2.0, output: 12.0, cacheRead: 0.2, cacheWrite: 2.5 },
241
+ },
242
+ {
243
+ id: "gpt-5.6-luna",
244
+ name: "GPT 5.6 Luna",
245
+ owned_by: "openai",
246
+ contextWindow: 1050000,
247
+ maxTokens: 128000,
248
+ input: IMAGE_INPUT,
249
+ cost: { input: 0.2, output: 1.2, cacheRead: 0.02, cacheWrite: 0.25 },
250
+ },
251
+ {
252
+ id: "gpt-image-2",
253
+ name: "GPT Image 2",
254
+ owned_by: "openai",
255
+ contextWindow: 32000,
256
+ maxTokens: 16384,
257
+ input: TEXT_INPUT,
258
+ output: ["image"],
259
+ // Image model — served via /v1/images/generations; output rate is per
260
+ // million image tokens.
261
+ cost: { input: 5.0, output: 30.0, cacheRead: 0, cacheWrite: 0 },
262
+ },
136
263
  {
137
264
  id: "gpt-5.5",
138
265
  name: "GPT 5.5",
@@ -206,6 +333,33 @@ const GATEWAY_MODELS = [
206
333
  cost: { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0 },
207
334
  },
208
335
  // Other providers
336
+ {
337
+ id: "grok-4.6",
338
+ name: "Grok 4.6",
339
+ owned_by: "x-ai",
340
+ contextWindow: 500000,
341
+ maxTokens: 500000,
342
+ input: IMAGE_INPUT,
343
+ cost: { input: 2.0, output: 6.0, cacheRead: 0.5, cacheWrite: 0 },
344
+ },
345
+ {
346
+ id: "grok-4.5",
347
+ name: "Grok 4.5",
348
+ owned_by: "x-ai",
349
+ contextWindow: 500000,
350
+ maxTokens: 500000,
351
+ input: IMAGE_INPUT,
352
+ cost: { input: 2.0, output: 6.0, cacheRead: 0.3, cacheWrite: 0 },
353
+ },
354
+ {
355
+ id: "grok-4.20",
356
+ name: "Grok 4.20",
357
+ owned_by: "x-ai",
358
+ contextWindow: 2000000,
359
+ maxTokens: 2000000,
360
+ input: IMAGE_INPUT,
361
+ cost: { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
362
+ },
209
363
  {
210
364
  id: "grok-4.3",
211
365
  name: "Grok 4.3",
@@ -223,6 +377,39 @@ const GATEWAY_MODELS = [
223
377
  maxTokens: 30000,
224
378
  input: TEXT_INPUT,
225
379
  cost: { input: 0.2, output: 0.5, cacheRead: 0.05, cacheWrite: 0 },
380
+ // Hard-deprecated in the seed — the gateway 410s it. Without this the
381
+ // offline catalog would advertise it as active.
382
+ deprecation: {
383
+ replaced_by: "grok-4.3",
384
+ removed_at: "2026-06-17T00:00:00.000Z",
385
+ },
386
+ },
387
+ {
388
+ id: "deepseek-v4-pro-0813",
389
+ name: "DeepSeek V4 Pro 0813",
390
+ owned_by: "deepseek",
391
+ contextWindow: 1048576,
392
+ maxTokens: 384000,
393
+ input: TEXT_INPUT,
394
+ cost: { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 },
395
+ },
396
+ {
397
+ id: "deepseek-v4-pro",
398
+ name: "DeepSeek V4 Pro",
399
+ owned_by: "deepseek",
400
+ contextWindow: 1048576,
401
+ maxTokens: 384000,
402
+ input: TEXT_INPUT,
403
+ cost: { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
404
+ },
405
+ {
406
+ id: "deepseek-v4-flash",
407
+ name: "DeepSeek V4 Flash",
408
+ owned_by: "deepseek",
409
+ contextWindow: 1048576,
410
+ maxTokens: 384000,
411
+ input: TEXT_INPUT,
412
+ cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
226
413
  },
227
414
  {
228
415
  id: "deepseek-v3.2",
@@ -233,6 +420,42 @@ const GATEWAY_MODELS = [
233
420
  input: TEXT_INPUT,
234
421
  cost: { input: 0.26, output: 0.38, cacheRead: 0.13, cacheWrite: 0 },
235
422
  },
423
+ {
424
+ id: "qwen3.8-max",
425
+ name: "Qwen3.8 Max",
426
+ owned_by: "qwen",
427
+ contextWindow: 1000000,
428
+ maxTokens: 131072,
429
+ input: IMAGE_INPUT,
430
+ cost: { input: 2.0, output: 6.0, cacheRead: 0.25, cacheWrite: 2.5 },
431
+ },
432
+ {
433
+ id: "qwen3.8-flash",
434
+ name: "Qwen3.8 Flash",
435
+ owned_by: "qwen",
436
+ contextWindow: 1000000,
437
+ maxTokens: 131072,
438
+ input: IMAGE_INPUT,
439
+ cost: { input: 0.15, output: 0.47, cacheRead: 0.016, cacheWrite: 0.2 },
440
+ },
441
+ {
442
+ id: "qwen3.7-max",
443
+ name: "Qwen3.7 Max",
444
+ owned_by: "qwen",
445
+ contextWindow: 1000000,
446
+ maxTokens: 65536,
447
+ input: TEXT_INPUT,
448
+ cost: { input: 2.5, output: 7.5, cacheRead: 0.25, cacheWrite: 0 },
449
+ },
450
+ {
451
+ id: "qwen3.7-flash",
452
+ name: "Qwen3.7 Flash",
453
+ owned_by: "qwen",
454
+ contextWindow: 1000000,
455
+ maxTokens: 65536,
456
+ input: IMAGE_INPUT,
457
+ cost: { input: 0.1, output: 0.4, cacheRead: 0.02, cacheWrite: 0.125 },
458
+ },
236
459
  {
237
460
  id: "qwen3-coder",
238
461
  name: "Qwen3 Coder",
@@ -240,7 +463,7 @@ const GATEWAY_MODELS = [
240
463
  contextWindow: 262144,
241
464
  maxTokens: 65536,
242
465
  input: TEXT_INPUT,
243
- cost: { input: 0.12, output: 0.75, cacheRead: 0.06, cacheWrite: 0 },
466
+ cost: { input: 0.3, output: 1.5, cacheRead: 0.06, cacheWrite: 0 },
244
467
  },
245
468
  {
246
469
  id: "qwen3.7-plus",
@@ -258,7 +481,7 @@ const GATEWAY_MODELS = [
258
481
  contextWindow: 1000000,
259
482
  maxTokens: 65536,
260
483
  input: TEXT_INPUT,
261
- cost: { input: 0.25, output: 1.5, cacheRead: 0, cacheWrite: 0 },
484
+ cost: { input: 0.1875, output: 1.125, cacheRead: 0, cacheWrite: 0.234375 },
262
485
  },
263
486
  {
264
487
  id: "qwen3.5-plus",
@@ -276,7 +499,16 @@ const GATEWAY_MODELS = [
276
499
  contextWindow: 1000000,
277
500
  maxTokens: 65536,
278
501
  input: TEXT_INPUT,
279
- cost: { input: 0.1, output: 0.4, cacheRead: 0, cacheWrite: 0 },
502
+ cost: { input: 0.065, output: 0.26, cacheRead: 0, cacheWrite: 0 },
503
+ },
504
+ {
505
+ id: "kimi-k3",
506
+ name: "Kimi K3",
507
+ owned_by: "moonshotai",
508
+ contextWindow: 1048576,
509
+ maxTokens: 131072,
510
+ input: IMAGE_INPUT,
511
+ cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 0 },
280
512
  },
281
513
  {
282
514
  id: "kimi-k2.7-code",
@@ -303,7 +535,16 @@ const GATEWAY_MODELS = [
303
535
  contextWindow: 262144,
304
536
  maxTokens: 65535,
305
537
  input: TEXT_INPUT,
306
- cost: { input: 0.45, output: 2.2, cacheRead: 0.225, cacheWrite: 0 },
538
+ cost: { input: 0.6, output: 3.0, cacheRead: 0.1, cacheWrite: 0 },
539
+ },
540
+ {
541
+ id: "minimax-m3",
542
+ name: "MiniMax M3",
543
+ owned_by: "minimax",
544
+ contextWindow: 524288,
545
+ maxTokens: 524288,
546
+ input: IMAGE_INPUT,
547
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
307
548
  },
308
549
  {
309
550
  id: "minimax-m2.5",
@@ -332,6 +573,42 @@ const GATEWAY_MODELS = [
332
573
  input: TEXT_INPUT,
333
574
  cost: { input: 0.6, output: 2.4, cacheRead: 0.06, cacheWrite: 0 },
334
575
  },
576
+ {
577
+ id: "glm-5.3-flash",
578
+ name: "GLM-5.3 Flash",
579
+ owned_by: "z-ai",
580
+ contextWindow: 1048576,
581
+ maxTokens: 131072,
582
+ input: IMAGE_INPUT,
583
+ cost: { input: 0.15, output: 0.5, cacheRead: 0.03, cacheWrite: 0 },
584
+ },
585
+ {
586
+ id: "glm-5.3",
587
+ name: "GLM-5.3",
588
+ owned_by: "z-ai",
589
+ contextWindow: 1048576,
590
+ maxTokens: 131072,
591
+ input: TEXT_INPUT,
592
+ cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
593
+ },
594
+ {
595
+ id: "glm-5.2",
596
+ name: "GLM-5.2",
597
+ owned_by: "z-ai",
598
+ contextWindow: 1048576,
599
+ maxTokens: 131072,
600
+ input: TEXT_INPUT,
601
+ cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
602
+ },
603
+ {
604
+ id: "glm-5.1",
605
+ name: "GLM-5.1",
606
+ owned_by: "z-ai",
607
+ contextWindow: 202800,
608
+ maxTokens: 202800,
609
+ input: TEXT_INPUT,
610
+ cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
611
+ },
335
612
  {
336
613
  id: "glm-5",
337
614
  name: "GLM-5",
@@ -339,7 +616,7 @@ const GATEWAY_MODELS = [
339
616
  contextWindow: 202752,
340
617
  maxTokens: 131072,
341
618
  input: TEXT_INPUT,
342
- cost: { input: 0.72, output: 2.3, cacheRead: 0, cacheWrite: 0 },
619
+ cost: { input: 1.0, output: 3.2, cacheRead: 0.2, cacheWrite: 0 },
343
620
  },
344
621
  {
345
622
  id: "glm-5-turbo",
@@ -373,6 +650,9 @@ async function resolveModels() {
373
650
  contextWindow: m.context_window ?? fallback?.contextWindow ?? 128000,
374
651
  maxTokens: m.max_output_tokens ?? fallback?.maxTokens ?? 32768,
375
652
  input: m.input_modalities ?? fallback?.input ?? TEXT_INPUT,
653
+ ...((m.output_modalities ?? fallback?.output) && {
654
+ output: m.output_modalities ?? fallback?.output,
655
+ }),
376
656
  cost: m.pricing
377
657
  ? {
378
658
  input: m.pricing.input,
@@ -388,6 +668,7 @@ async function resolveModels() {
388
668
  }),
389
669
  ...(m.deprecation && { deprecation: m.deprecation }),
390
670
  ...(m.private && { private: true }),
671
+ ...(m.zdr && { zdr: true }),
391
672
  };
392
673
  });
393
674
  return { models, live: true };
@@ -426,53 +707,148 @@ function installConfigFile(configFile, toolName, merge) {
426
707
  output.success(`Bankr provider installed to ${configFile}`);
427
708
  }
428
709
  /* ───────────────────────────── bankr llm models ───────────────────────────── */
710
+ /**
711
+ * The tier filters. Both flags read the gateway's own capability flags from
712
+ * /v1/models, so they track the live registry rather than a hardcoded list.
713
+ */
714
+ const TIER_VIEWS = {
715
+ private: {
716
+ title: "Bankr LLM Gateway — Private Models",
717
+ match: (m) => m.private === true,
718
+ empty: "No models currently offer a private (TEE) compute environment.",
719
+ countSuffix: " support :private",
720
+ },
721
+ zdr: {
722
+ title: "Bankr LLM Gateway — Zero-Retention Models",
723
+ match: (m) => m.zdr === true,
724
+ empty: "No models currently guarantee zero data retention.",
725
+ countSuffix: " support :zdr",
726
+ },
727
+ };
429
728
  export async function modelsCommand(opts = {}) {
430
729
  const spin = output.spinner("Fetching models…");
431
730
  const { models, live } = await resolveModels();
432
731
  spin.stop();
433
- // `private` is the gateway's own "supports :private" flag from
434
- // /v1/models (driven by each model's NEAR slot + NEARAI_API_KEY), so this
435
- // tracks the live registry rather than a hardcoded CLI list.
436
- const shown = opts.private ? models.filter((m) => m.private) : models;
437
- output.brandBold(opts.private
438
- ? "Bankr LLM Gateway — Private Models"
439
- : "Bankr LLM Gateway — Available Models");
732
+ // `--private` wins when both are passed: private is the narrower set (every
733
+ // private model is also ZDR), so it's the more specific of the two requests.
734
+ const view = opts.private
735
+ ? TIER_VIEWS.private
736
+ : opts.zdr
737
+ ? TIER_VIEWS.zdr
738
+ : undefined;
739
+ const shown = view ? models.filter(view.match) : models;
740
+ output.brandBold(view?.title ?? "Bankr LLM Gateway — Available Models");
440
741
  console.log();
441
- if (opts.private && shown.length === 0) {
742
+ if (view && shown.length === 0) {
442
743
  output.warn(live
443
- ? "No models currently offer a private (TEE) compute environment."
444
- : "Private status needs a live gateway — set BANKR_LLM_KEY (and BANKR_LLM_URL).");
744
+ ? view.empty
745
+ : "Tier status needs a live gateway — set BANKR_LLM_KEY (and BANKR_LLM_URL).");
445
746
  return;
446
747
  }
447
- const COL = { id: 24, name: 24, provider: 12 };
448
- const hasNotes = shown.some((m) => m.deprecation || m.private);
449
- console.log(` ${output.fmt.brandBold("Model ID".padEnd(COL.id))} ${output.fmt.brandBold("Name".padEnd(COL.name))} ${output.fmt.brandBold("Provider".padEnd(COL.provider))}${hasNotes ? ` ${output.fmt.brandBold("Notes")}` : ""}`);
450
- console.log(output.fmt.dim(` ${"─".repeat(COL.id + COL.name + COL.provider + (hasNotes ? 24 : 2))}`));
451
- for (const m of shown) {
452
- let notes = "";
748
+ // Widths are sized to the longest real value (`minimax-m2.7-highspeed` /
749
+ // `MiniMax M2.7 Highspeed` are both 22), keeping the table under ~90 columns
750
+ // once the tier list is included.
751
+ const COL = { id: 23, name: 23, provider: 11, privacy: 28 };
752
+ const isImageGen = (m) => m.output?.includes("image") === true;
753
+ /**
754
+ * Every tier this model can serve, not just the strongest.
755
+ *
756
+ * The tiers nest, so a private model is also reachable at zdr and standard —
757
+ * listing only the top one made the ladder look like three separate offers
758
+ * and hid that `glm-5.2` (no suffix) is a perfectly valid standard request.
759
+ * Read straight off the flags rather than inferring `zdr` from `private`, so
760
+ * the column reports what the gateway actually advertises.
761
+ */
762
+ const supportedTiers = (m) => {
763
+ // Each tier is painted individually. Colouring the joined string instead
764
+ // made "standard" grey on a standard-only row and green on a ZDR one — the
765
+ // same word changing colour purely because of what followed it.
766
+ const tiers = [
767
+ // The baseline, on every row, so it recedes rather than competing.
768
+ { label: "standard", paint: output.fmt.dim },
769
+ ];
770
+ if (m.zdr)
771
+ tiers.push({ label: "zdr", paint: output.fmt.success });
772
+ if (m.private) {
773
+ // Distinct from zdr: they are two rungs, not one capability.
774
+ tiers.push({ label: "private (tee)", paint: output.fmt.magic });
775
+ }
776
+ return tiers;
777
+ };
778
+ /**
779
+ * Pad from the PLAIN text — ANSI escapes have zero display width but count
780
+ * toward `.length`, so padding the painted string misaligns every coloured
781
+ * row against the header.
782
+ */
783
+ const tierCell = (m) => {
784
+ const tiers = supportedTiers(m);
785
+ const plain = tiers.map((t) => t.label).join(", ");
786
+ const painted = tiers
787
+ .map((t) => t.paint(t.label))
788
+ .join(output.fmt.dim(", "));
789
+ return painted + " ".repeat(Math.max(0, COL.privacy - plain.length));
790
+ };
791
+ /**
792
+ * Everything that is NOT a privacy tier. Kept in its own column because
793
+ * sharing one cell forced an `else if` chain where a deprecated ZDR model
794
+ * showed only "deprecated" and its tier silently disappeared.
795
+ */
796
+ const modelNotes = (m) => {
797
+ const parts = [];
453
798
  if (m.deprecation) {
454
- notes = m.deprecation.replaced_by
799
+ parts.push(m.deprecation.replaced_by
455
800
  ? `deprecated → ${m.deprecation.replaced_by}`
456
- : "deprecated";
801
+ : "deprecated");
457
802
  }
458
- else if (m.private) {
459
- notes = "Private (TEE)";
460
- }
461
- console.log(` ${output.fmt.brand(m.id.padEnd(COL.id))} ${m.name.padEnd(COL.name)} ${output.fmt.dim(m.owned_by.padEnd(COL.provider))} ${notes ? output.fmt.dim(notes) : ""}`);
803
+ if (isImageGen(m))
804
+ parts.push("Image gen (/v1/images/generations)");
805
+ return parts.join(" · ");
806
+ };
807
+ const showNotes = shown.some((m) => modelNotes(m) !== "");
808
+ const line = (cells) => console.log(` ${cells.join(" ")}`.trimEnd());
809
+ line([
810
+ output.fmt.brandBold("Model ID".padEnd(COL.id)),
811
+ output.fmt.brandBold("Name".padEnd(COL.name)),
812
+ output.fmt.brandBold("Provider".padEnd(COL.provider)),
813
+ output.fmt.brandBold("Privacy Tier".padEnd(COL.privacy)),
814
+ ...(showNotes ? [output.fmt.brandBold("Notes")] : []),
815
+ ]);
816
+ // Measured, not guessed: a fixed fudge factor for the last column left the
817
+ // rule hanging well past the header once the tier list widened it.
818
+ const notesWidth = showNotes
819
+ ? Math.max(...shown.map((m) => modelNotes(m).length))
820
+ : 0;
821
+ const rule = COL.id +
822
+ COL.name +
823
+ COL.provider +
824
+ COL.privacy +
825
+ 3 +
826
+ (showNotes ? notesWidth + 1 : 0);
827
+ console.log(output.fmt.dim(` ${"─".repeat(rule)}`));
828
+ for (const m of shown) {
829
+ line([
830
+ output.fmt.brand(m.id.padEnd(COL.id)),
831
+ m.name.padEnd(COL.name),
832
+ output.fmt.dim(m.owned_by.padEnd(COL.provider)),
833
+ tierCell(m),
834
+ ...(showNotes ? [output.fmt.dim(modelNotes(m))] : []),
835
+ ]);
462
836
  }
463
837
  console.log();
464
838
  output.dim(` Gateway: ${getLlmUrl()}`);
465
- output.dim(` ${shown.length} model${shown.length === 1 ? "" : "s"}${opts.private ? " support :private" : " available"}${live ? "" : " (cached)"}`);
466
- if (!opts.private && shown.some((m) => m.private)) {
839
+ output.dim(` ${shown.length} model${shown.length === 1 ? "" : "s"}${view?.countSuffix ?? " available"}${live ? "" : " (cached)"}`);
840
+ if (!view && shown.some((m) => m.private)) {
467
841
  output.dim(" Private (TEE) — append :private to the model id (e.g. glm-5.2:private). List: bankr llm models --private");
468
842
  }
843
+ if (!view && shown.some((m) => m.zdr)) {
844
+ output.dim(" Zero data retention — append :zdr to the model id (e.g. glm-5.2:zdr), or turn it on account-wide under Settings in the web terminal. List: bankr llm models --zdr");
845
+ }
469
846
  }
470
847
  /* ─────────────────────── bankr llm credits ─────────────────────────────────── */
471
848
  export async function creditsCommand() {
472
849
  const llmKey = getLlmKey();
473
850
  if (!llmKey) {
474
- output.error("Not authenticated. Run `bankr login` first.");
475
- process.exit(1);
851
+ output.fatal("Not authenticated. Run `bankr login` first.");
476
852
  }
477
853
  const llmUrl = getLlmUrl();
478
854
  const spin = output.spinner("Fetching credit balance…");
@@ -491,12 +867,10 @@ export async function creditsCommand() {
491
867
  return;
492
868
  }
493
869
  if (res.status === 401 || res.status === 403) {
494
- output.error("Authentication failed. Check your API key or run: bankr login");
495
- process.exit(1);
870
+ output.fatal("Authentication failed. Check your API key or run: bankr login");
496
871
  }
497
872
  if (!res.ok) {
498
- output.error(`Failed to fetch credits (HTTP ${res.status})`);
499
- process.exit(1);
873
+ output.fatal(`Failed to fetch credits (HTTP ${res.status})`);
500
874
  }
501
875
  const body = (await res.json());
502
876
  output.brandBold("Bankr LLM Gateway — Credits");
@@ -507,8 +881,7 @@ export async function creditsCommand() {
507
881
  }
508
882
  catch (err) {
509
883
  spin.stop();
510
- output.error(`Failed to fetch credits: ${err.message}`);
511
- process.exit(1);
884
+ output.fatal(`Failed to fetch credits: ${getErrorMessage(err)}`);
512
885
  }
513
886
  }
514
887
  /* ─────────────────────── bankr llm credits add ──────────────────────────── */
@@ -560,7 +933,7 @@ function resolveTokenAcrossChains(input, balances) {
560
933
  symbol: nativeSym,
561
934
  name: nativeSym,
562
935
  decimals: 18,
563
- balance: parseFloat(chainBal.nativeBalance || "0"),
936
+ balance: chainBal.nativeBalance || "0",
564
937
  balanceUsd: nativeUsd,
565
938
  });
566
939
  }
@@ -603,8 +976,7 @@ export async function creditsAddCommand(amount, opts) {
603
976
  // Validate amount
604
977
  const amountUsd = Number(amount);
605
978
  if (Number.isNaN(amountUsd) || amountUsd < 1 || amountUsd > 1000) {
606
- output.error("Amount must be between $1 and $1,000");
607
- process.exit(1);
979
+ output.fatal("Amount must be between $1 and $1,000");
608
980
  }
609
981
  // Fetch current balance for display (skip when --yes/--not-interactive since user won't see it)
610
982
  let currentBalance = "unknown";
@@ -627,7 +999,7 @@ export async function creditsAddCommand(amount, opts) {
627
999
  catch (err) {
628
1000
  balSpin.stop();
629
1001
  // Non-fatal — proceed without balance display
630
- output.dim(` (Could not fetch balance: ${err.message})`);
1002
+ output.dim(` (Could not fetch balance: ${getErrorMessage(err)})`);
631
1003
  }
632
1004
  }
633
1005
  // When --token is passed, the chain holding the highest USD balance of
@@ -642,11 +1014,10 @@ export async function creditsAddCommand(amount, opts) {
642
1014
  }
643
1015
  catch (err) {
644
1016
  resolveSpin.stop();
645
- output.warn(`Could not fetch balances: ${err.message}`);
1017
+ output.warn(`Could not fetch balances: ${getErrorMessage(err)}`);
646
1018
  }
647
1019
  if (!resolved) {
648
- output.error(`Token "${opts.token}" not found in your wallet on any supported chain.`);
649
- process.exit(1);
1020
+ output.fatal(`Token "${opts.token}" not found in your wallet on any supported chain.`);
650
1021
  }
651
1022
  }
652
1023
  const targetChain = resolved?.chain ?? "base";
@@ -693,22 +1064,18 @@ export async function creditsAddCommand(amount, opts) {
693
1064
  spin.stop();
694
1065
  if (res.status === 402) {
695
1066
  const errBody = (await res.json().catch(() => ({})));
696
- output.error(errBody.error ??
1067
+ output.fatal(errBody.error ??
697
1068
  "Insufficient wallet balance. Add funds to your wallet first.");
698
- process.exit(1);
699
1069
  }
700
1070
  if (res.status === 403) {
701
- output.error(ERR_LLM_NOT_ENABLED);
702
- process.exit(1);
1071
+ output.fatal(ERR_LLM_NOT_ENABLED);
703
1072
  }
704
1073
  if (res.status === 502) {
705
- output.error("Token swap failed. Try USDC or a different token.");
706
- process.exit(1);
1074
+ output.fatal("Token swap failed. Try USDC or a different token.");
707
1075
  }
708
1076
  if (!res.ok) {
709
1077
  const errBody = (await res.json().catch(() => ({})));
710
- output.error(errBody.error ?? `Failed to add credits (HTTP ${res.status})`);
711
- process.exit(1);
1078
+ output.fatal(errBody.error ?? `Failed to add credits (HTTP ${res.status})`);
712
1079
  }
713
1080
  const result = (await res.json());
714
1081
  console.log();
@@ -720,8 +1087,7 @@ export async function creditsAddCommand(amount, opts) {
720
1087
  }
721
1088
  catch (err) {
722
1089
  spin.stop();
723
- output.error(`Failed to add credits: ${err.message}`);
724
- process.exit(1);
1090
+ output.fatal(`Failed to add credits: ${getErrorMessage(err)}`);
725
1091
  }
726
1092
  }
727
1093
  function formatTimeAgo(dateStr) {
@@ -762,12 +1128,10 @@ export async function creditsAutoCommand(opts) {
762
1128
  });
763
1129
  spin.stop();
764
1130
  if (res.status === 403) {
765
- output.error(ERR_LLM_NOT_ENABLED);
766
- process.exit(1);
1131
+ output.fatal(ERR_LLM_NOT_ENABLED);
767
1132
  }
768
1133
  if (!res.ok) {
769
- output.error(`Failed to fetch config (HTTP ${res.status})`);
770
- process.exit(1);
1134
+ output.fatal(`Failed to fetch config (HTTP ${res.status})`);
771
1135
  }
772
1136
  const { config } = (await res.json());
773
1137
  output.brandBold("Bankr LLM Gateway — Auto Top-Up");
@@ -782,15 +1146,13 @@ export async function creditsAutoCommand(opts) {
782
1146
  }
783
1147
  catch (err) {
784
1148
  spin.stop();
785
- output.error(`Failed to fetch config: ${err.message}`);
786
- process.exit(1);
1149
+ output.fatal(`Failed to fetch config: ${getErrorMessage(err)}`);
787
1150
  }
788
1151
  return;
789
1152
  }
790
1153
  // POST update config
791
1154
  if (opts.enable && opts.disable) {
792
- output.error("Cannot use --enable and --disable together");
793
- process.exit(1);
1155
+ output.fatal("Cannot use --enable and --disable together");
794
1156
  }
795
1157
  const body = {};
796
1158
  if (opts.disable) {
@@ -806,16 +1168,14 @@ export async function creditsAutoCommand(opts) {
806
1168
  if (opts.amount) {
807
1169
  const amt = Number(opts.amount);
808
1170
  if (Number.isNaN(amt) || amt < 1 || amt > 1000) {
809
- output.error("Amount must be between $1 and $1,000");
810
- process.exit(1);
1171
+ output.fatal("Amount must be between $1 and $1,000");
811
1172
  }
812
1173
  body.amountUsd = amt;
813
1174
  }
814
1175
  if (opts.threshold) {
815
1176
  const thr = Number(opts.threshold);
816
1177
  if (Number.isNaN(thr) || thr < 1 || thr > 500) {
817
- output.error("Threshold must be between $1 and $500");
818
- process.exit(1);
1178
+ output.fatal("Threshold must be between $1 and $500");
819
1179
  }
820
1180
  body.thresholdUsd = thr;
821
1181
  }
@@ -827,8 +1187,7 @@ export async function creditsAutoCommand(opts) {
827
1187
  .map((s) => s.trim())
828
1188
  .filter(Boolean);
829
1189
  if (inputs.length === 0 || inputs.length > 3) {
830
- output.error("Provide 1-3 comma-separated token symbols or addresses");
831
- process.exit(1);
1190
+ output.fatal("Provide 1-3 comma-separated token symbols or addresses");
832
1191
  }
833
1192
  const resolved = [];
834
1193
  // Highest USD balance wins when the same symbol exists on multiple chains.
@@ -859,8 +1218,7 @@ export async function creditsAutoCommand(opts) {
859
1218
  }
860
1219
  }
861
1220
  if (resolved.length === 0) {
862
- output.error("No valid tokens resolved. Check your token symbols.");
863
- process.exit(1);
1221
+ output.fatal("No valid tokens resolved. Check your token symbols.");
864
1222
  }
865
1223
  body.tokens = resolved;
866
1224
  }
@@ -883,13 +1241,11 @@ export async function creditsAutoCommand(opts) {
883
1241
  });
884
1242
  spin.stop();
885
1243
  if (res.status === 403) {
886
- output.error(ERR_LLM_NOT_ENABLED);
887
- process.exit(1);
1244
+ output.fatal(ERR_LLM_NOT_ENABLED);
888
1245
  }
889
1246
  if (!res.ok) {
890
1247
  const errBody = (await res.json().catch(() => ({})));
891
- output.error(errBody.error ?? `Failed to update config (HTTP ${res.status})`);
892
- process.exit(1);
1248
+ output.fatal(errBody.error ?? `Failed to update config (HTTP ${res.status})`);
893
1249
  }
894
1250
  output.success(opts.disable ? "Auto top-up disabled" : "Auto top-up settings updated");
895
1251
  // Show config from POST response (avoids extra GET round-trip)
@@ -907,8 +1263,7 @@ export async function creditsAutoCommand(opts) {
907
1263
  }
908
1264
  catch (err) {
909
1265
  spin.stop();
910
- output.error(`Failed to update config: ${err.message}`);
911
- process.exit(1);
1266
+ output.fatal(`Failed to update config: ${getErrorMessage(err)}`);
912
1267
  }
913
1268
  }
914
1269
  /* ──────────────────────── bankr llm setup openclaw ────────────────────────── */
@@ -916,13 +1271,31 @@ export async function setupOpenclawCommand(opts) {
916
1271
  const llmKey = getLlmKey();
917
1272
  const llmUrl = getLlmUrl();
918
1273
  const { models } = await resolveModels();
919
- const activeModels = models.filter((m) => !m.deprecation);
1274
+ // Coding-tool config lists chat models only — image-only models can't chat.
1275
+ const activeModels = models.filter((m) => !m.deprecation && supportsChat(m));
1276
+ // --images routes OpenClaw's built-in image_generate tool through the gateway.
1277
+ // That tool only uses OpenClaw's known image providers (not custom ones like
1278
+ // `bankr`), so enabling it takes over the `openai` provider slot. OpenClaw's
1279
+ // OpenAI provider expects a `/v1`-suffixed base; normalize so a llmUrl that
1280
+ // already ends in /v1 (or a trailing slash) doesn't produce `/v1/v1`.
1281
+ const imageBaseUrl = `${llmUrl.replace(/\/+$/, "").replace(/\/v1$/, "")}/v1`;
1282
+ const imageProvider = opts.images
1283
+ ? { baseUrl: imageBaseUrl, apiKey: llmKey ?? "${BANKR_API_KEY}" }
1284
+ : null;
1285
+ const imageGenerationModel = opts.images
1286
+ ? { primary: "openai/gpt-image-2", timeoutMs: 180000 }
1287
+ : null;
1288
+ const warnImagesSlot = () => {
1289
+ if (opts.images)
1290
+ output.warn("--images points OpenClaw's `openai` provider at the gateway; image_generate (and any OpenAI-format request) now routes through Bankr. Skip --images if you use real OpenAI in OpenClaw.");
1291
+ };
920
1292
  const providerConfig = {
921
1293
  baseUrl: llmUrl,
922
1294
  apiKey: llmKey ?? "${BANKR_API_KEY}",
923
1295
  api: "openai-completions",
924
- // Private models get both entries: the plain id and a `:private`
925
- // twin that routes through the TEE compute environment — so users can pick either.
1296
+ // Models get a twin entry per privacy tier they support, so users can pick
1297
+ // one from the model dropdown in tools that expose no other configuration.
1298
+ // Base first, then weakest tier to strongest.
926
1299
  models: activeModels.flatMap((m) => {
927
1300
  const base = {
928
1301
  id: m.id,
@@ -933,12 +1306,15 @@ export async function setupOpenclawCommand(opts) {
933
1306
  maxTokens: m.maxTokens,
934
1307
  cost: m.cost,
935
1308
  };
936
- return m.private
937
- ? [
938
- base,
939
- { ...base, id: `${m.id}:private`, name: `${m.name} (Private)` },
940
- ]
941
- : [base];
1309
+ return [
1310
+ base,
1311
+ ...(m.zdr
1312
+ ? [{ ...base, id: `${m.id}:zdr`, name: `${m.name} (ZDR)` }]
1313
+ : []),
1314
+ ...(m.private
1315
+ ? [{ ...base, id: `${m.id}:private`, name: `${m.name} (Private)` }]
1316
+ : []),
1317
+ ];
942
1318
  }),
943
1319
  };
944
1320
  if (opts.install) {
@@ -947,17 +1323,43 @@ export async function setupOpenclawCommand(opts) {
947
1323
  const models = existing.models ?? {};
948
1324
  const providers = models.providers ?? {};
949
1325
  providers.bankr = providerConfig;
1326
+ if (imageProvider) {
1327
+ providers.openai = {
1328
+ ...(providers.openai ?? {}),
1329
+ ...imageProvider,
1330
+ };
1331
+ }
950
1332
  models.providers = providers;
951
1333
  existing.models = models;
1334
+ if (imageGenerationModel) {
1335
+ const agents = existing.agents ?? {};
1336
+ const defaults = agents.defaults ?? {};
1337
+ defaults.imageGenerationModel = imageGenerationModel;
1338
+ agents.defaults = defaults;
1339
+ existing.agents = agents;
1340
+ }
952
1341
  });
953
1342
  warnIfNoKey(llmKey);
1343
+ warnImagesSlot();
954
1344
  return;
955
1345
  }
956
- console.log(JSON.stringify({ models: { providers: { bankr: providerConfig } } }, null, 2));
1346
+ const printed = {
1347
+ models: {
1348
+ providers: {
1349
+ bankr: providerConfig,
1350
+ ...(imageProvider ? { openai: imageProvider } : {}),
1351
+ },
1352
+ },
1353
+ ...(imageGenerationModel
1354
+ ? { agents: { defaults: { imageGenerationModel } } }
1355
+ : {}),
1356
+ };
1357
+ console.log(JSON.stringify(printed, null, 2));
957
1358
  console.log();
958
1359
  if (!llmKey) {
959
1360
  output.warn("No API key set. Replace ${BANKR_API_KEY} with your key or run: bankr login");
960
1361
  }
1362
+ warnImagesSlot();
961
1363
  output.dim("Add to ~/.openclaw/openclaw.json or run: bankr llm setup openclaw --install");
962
1364
  }
963
1365
  /* ──────────────────────── bankr llm setup opencode ─────────────────────────── */
@@ -965,11 +1367,15 @@ export async function setupOpenCodeCommand(opts) {
965
1367
  const llmKey = getLlmKey();
966
1368
  const llmUrl = getLlmUrl();
967
1369
  const { models } = await resolveModels();
968
- const activeModels = models.filter((m) => !m.deprecation);
1370
+ // Coding-tool config lists chat models only — image-only models can't chat.
1371
+ const activeModels = models.filter((m) => !m.deprecation && supportsChat(m));
969
1372
  const modelsObj = {};
970
1373
  for (const m of activeModels) {
971
1374
  modelsObj[m.id] = { name: m.name };
972
- // Private models also expose a `:private` variant (TEE compute environment).
1375
+ // One variant per privacy tier the model supports, weakest to strongest.
1376
+ if (m.zdr) {
1377
+ modelsObj[`${m.id}:zdr`] = { name: `${m.name} (ZDR)` };
1378
+ }
973
1379
  if (m.private) {
974
1380
  modelsObj[`${m.id}:private`] = { name: `${m.name} (Private)` };
975
1381
  }
@@ -1015,7 +1421,7 @@ export async function setupCursorCommand() {
1015
1421
  const { models } = await resolveModels();
1016
1422
  const activeModels = models.filter((m) => !m.deprecation);
1017
1423
  // Pick one model per provider as recommended examples
1018
- const recommendedIds = ["claude-fable-5", "gemini-3.1-pro", "gpt-5.5"];
1424
+ const recommendedIds = ["claude-fable-5.1", "gemini-3.1-pro", "gpt-5.5"];
1019
1425
  const recommended = recommendedIds.filter((id) => activeModels.some((m) => m.id === id));
1020
1426
  output.brandBold("Cursor — Bankr LLM Gateway");
1021
1427
  console.log();
@@ -1061,8 +1467,7 @@ export async function setupClaudeCodeCommand() {
1061
1467
  function requireAuth() {
1062
1468
  const llmKey = getLlmKey();
1063
1469
  if (!llmKey) {
1064
- output.error("Not authenticated. Run `bankr login` first.");
1065
- process.exit(1);
1470
+ output.fatal("Not authenticated. Run `bankr login` first.");
1066
1471
  }
1067
1472
  return llmKey;
1068
1473
  }
@@ -1102,8 +1507,8 @@ function fileContains(filePath, search) {
1102
1507
  * `--model` flag appears honored but the real request is a different
1103
1508
  * model. Any suffix (e.g. `[1m]` context tier) is preserved.
1104
1509
  */
1105
- function toAnthropicModelId(model) {
1106
- return model.replace(/^(claude-(?:opus|sonnet|haiku)-)(\d+)\.(\d+)(.*)$/, "$1$2-$3$4");
1510
+ export function toAnthropicModelId(model) {
1511
+ return model.replace(/^(claude-(?:opus|sonnet|haiku|fable)-)(\d+)\.(\d+)(.*)$/, "$1$2-$3$4");
1107
1512
  }
1108
1513
  function translateClaudeModelArgs(args) {
1109
1514
  const out = [...args];
@@ -1122,6 +1527,9 @@ function translateClaudeModelArgs(args) {
1122
1527
  return out;
1123
1528
  }
1124
1529
  export async function claudeCommand(args) {
1530
+ if (isHelpRequest(args)) {
1531
+ return launchTool("claude", args, {}, "https://docs.anthropic.com/en/docs/claude-code");
1532
+ }
1125
1533
  const llmKey = requireAuth();
1126
1534
  const llmUrl = getLlmUrl();
1127
1535
  const translated = translateClaudeModelArgs(args);
@@ -1130,6 +1538,9 @@ export async function claudeCommand(args) {
1130
1538
  }
1131
1539
  /* ────────────────────────── bankr llm opencode ───────────────────────────── */
1132
1540
  export async function opencodeCommand(args) {
1541
+ if (isHelpRequest(args)) {
1542
+ return launchTool("opencode", args, {}, "https://opencode.ai");
1543
+ }
1133
1544
  const llmKey = requireAuth();
1134
1545
  const llmUrl = getLlmUrl();
1135
1546
  const configFile = join(homedir(), ".config", "opencode", "opencode.json");