switchroom 0.16.5 → 0.16.7

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.
@@ -51674,8 +51674,8 @@ import { existsSync, readFileSync } from "node:fs";
51674
51674
  import { dirname, join } from "node:path";
51675
51675
 
51676
51676
  // src/build-info.ts
51677
- var VERSION = "0.16.5";
51678
- var COMMIT_SHA = "6821d14b";
51677
+ var VERSION = "0.16.7";
51678
+ var COMMIT_SHA = "696eea91";
51679
51679
 
51680
51680
  // src/cli/resolve-version.ts
51681
51681
  function readPackageVersion() {
@@ -69841,7 +69841,7 @@ x-litellm-tags: service:hindsight`);
69841
69841
  `--pids-limit=${HINDSIGHT_DEFAULT_PIDS_LIMIT}`,
69842
69842
  `--shm-size=${HINDSIGHT_DEFAULT_SHM_SIZE}`,
69843
69843
  "--health-cmd",
69844
- HINDSIGHT_HEALTHCHECK_CMD,
69844
+ litellm ? `python3 -c 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:${apiPort}/health",timeout=4).getcode()==200 else 1)'` : HINDSIGHT_HEALTHCHECK_CMD,
69845
69845
  "--health-interval",
69846
69846
  "30s",
69847
69847
  "--health-timeout",
@@ -22587,7 +22587,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22587
22587
  import { dirname as dirname4, join as join2 } from "node:path";
22588
22588
 
22589
22589
  // src/build-info.ts
22590
- var VERSION = "0.16.5";
22590
+ var VERSION = "0.16.7";
22591
22591
 
22592
22592
  // src/cli/resolve-version.ts
22593
22593
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.5",
3
+ "version": "0.16.7",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,6 +86,39 @@ CRON_APPEND_PROMPT="You are the cheap background cron worker for {{name}}. You r
86
86
  # (no --continue) — low context by construction. cd into the workspace so
87
87
  # claude's project key matches the pre-seeded trust state (.claude-cron).
88
88
  cd "{{agentDir}}" || exit 1
89
+
90
+ # LiteLLM routing for the cron session — mirrors start.sh's boot block.
91
+ # IMPORTANT: the vault key is provisioned under the BASE agent name ({{name}}),
92
+ # NOT the cron identity ({{name}}-cron). Use the Handlebars template var so the
93
+ # lookup is a compile-time literal — no fragile runtime suffix-stripping.
94
+ # Attribution headers carry $SWITCHROOM_AGENT_NAME (= {{name}}-cron) so LiteLLM
95
+ # can distinguish cron spend from main-session spend per agent.
96
+ # FAIL-OPEN: missing key OR unreachable proxy → strip routing env, fall back to
97
+ # direct OAuth. An outage must never take the cron session dark.
98
+ if [ -n "$SWITCHROOM_LITELLM" ] && command -v switchroom >/dev/null 2>&1; then
99
+ sr_ll_key="$(switchroom vault get "litellm/{{name}}/api-key" 2>/dev/null || true)"
100
+ sr_ll_ok=""
101
+ if [ -z "$sr_ll_key" ]; then
102
+ echo "litellm: no virtual key for cron '{{name}}' — falling back to direct OAuth (no tracking/guardrail)" >&2
103
+ elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ] \
104
+ && ! curl -fsS -m 5 -o /dev/null "${ANTHROPIC_BASE_URL%/}/health/liveliness" 2>/dev/null; then
105
+ echo "litellm: proxy unreachable at $ANTHROPIC_BASE_URL — falling back to direct OAuth (no tracking/guardrail this session)" >&2
106
+ else
107
+ sr_ll_ok="1"
108
+ fi
109
+ if [ -n "$sr_ll_ok" ]; then
110
+ export ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: Bearer $sr_ll_key
111
+ x-litellm-customer-id: $SWITCHROOM_AGENT_NAME
112
+ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:-default}"
113
+ export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
114
+ else
115
+ # Fail-open: drop every routing var so the claude CLI talks to Anthropic
116
+ # directly on its OAuth credential (subscription path), unproxied.
117
+ unset ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL SWITCHROOM_LITELLM
118
+ fi
119
+ unset sr_ll_key sr_ll_ok
120
+ fi
121
+
89
122
  # Create the cron tmux session DETACHED (-d), not in attach mode. This is a
90
123
  # SUPERVISED BACKGROUND sidecar (start.sh forks it with `&`) with no controlling
91
124
  # TTY — the MAIN session owns the container's foreground TTY. The attach flag
@@ -46101,6 +46101,7 @@ var MODEL_CALLBACK_PREFIX = "mdl:";
46101
46101
  var MODEL_CALLBACK_SELECT = "mdl:s:";
46102
46102
  var MODEL_CALLBACK_REFRESH = "mdl:r";
46103
46103
  var MODEL_CALLBACK_SR = "mdl:sr:";
46104
+ var MODEL_CALLBACK_HEADER = "mdl:h";
46104
46105
  var SR_MODEL_LABELS = {
46105
46106
  "sr-gemini-2.5-pro": "Gemini 2.5 Pro",
46106
46107
  "sr-gemini-2.5-flash": "Gemini 2.5 Flash",
@@ -46126,20 +46127,28 @@ function busyReply(deps) {
46126
46127
  html: true
46127
46128
  };
46128
46129
  }
46130
+ function headerRow(label) {
46131
+ return [{ text: label, callback_data: MODEL_CALLBACK_HEADER }];
46132
+ }
46129
46133
  function menuKeyboard(claudeOptions, srOptions) {
46130
- const rows = claudeOptions.map((o) => [
46131
- {
46134
+ const hasBothGroups = claudeOptions.length > 0 && srOptions.length > 0;
46135
+ const rows = [];
46136
+ if (hasBothGroups)
46137
+ rows.push(headerRow("\u2500\u2500 Claude (Max / Pro subscription) \u2500\u2500"));
46138
+ for (const o of claudeOptions) {
46139
+ rows.push([{
46132
46140
  text: o.current ? `\u2705 ${o.label}` : o.label,
46133
46141
  callback_data: modelSelectCallbackData(o.label)
46134
- }
46135
- ]);
46136
- for (const o of srOptions) {
46137
- rows.push([
46138
- {
46142
+ }]);
46143
+ }
46144
+ if (srOptions.length > 0) {
46145
+ rows.push(headerRow("\u2500\u2500 OpenRouter / external \u2500\u2500"));
46146
+ for (const o of srOptions) {
46147
+ rows.push([{
46139
46148
  text: `\uD83C\uDF10 ${srFriendlyLabel(o.label)}`,
46140
46149
  callback_data: `${MODEL_CALLBACK_SR}${o.label}`
46141
- }
46142
- ]);
46150
+ }]);
46151
+ }
46143
46152
  }
46144
46153
  rows.push([{ text: "\uD83D\uDD04 Refresh", callback_data: MODEL_CALLBACK_REFRESH }]);
46145
46154
  return rows;
@@ -46147,9 +46156,10 @@ function menuKeyboard(claudeOptions, srOptions) {
46147
46156
  async function buildModelMenu(deps) {
46148
46157
  if (deps.isBusy())
46149
46158
  return busyReply(deps);
46150
- const [discovered, quota] = await Promise.all([
46159
+ const [discovered, quota, srNames] = await Promise.all([
46151
46160
  deps.discover(deps.getAgentName()),
46152
- deps.getQuotaBrief().catch(() => null)
46161
+ deps.getQuotaBrief().catch(() => null),
46162
+ deps.discoverSrModels().catch(() => [])
46153
46163
  ]);
46154
46164
  if (!discovered.ok) {
46155
46165
  const v1 = await handleModelCommand({ kind: "show" }, deps);
@@ -46159,7 +46169,8 @@ async function buildModelMenu(deps) {
46159
46169
  html: true
46160
46170
  };
46161
46171
  }
46162
- const { claude: claudeOptions, sr: srOptions } = classifyDiscoveredOptions(discovered.options);
46172
+ const { claude: claudeOptions } = classifyDiscoveredOptions(discovered.options);
46173
+ const srOptions = srNames.map((name, i) => ({ index: i, label: name, detail: "", current: false }));
46163
46174
  const current = claudeOptions.find((o) => o.current);
46164
46175
  const lines = [`<b>Model \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`];
46165
46176
  if (discovered.dismissFailed) {
@@ -46174,8 +46185,9 @@ async function buildModelMenu(deps) {
46174
46185
  if (quota)
46175
46186
  lines.push(`Quota: ${deps.escapeHtml(quota)}`);
46176
46187
  lines.push("", "Tap a model to switch the <b>live session</b>:");
46177
- if (srOptions.length > 0)
46178
- lines.push("\uD83C\uDF10 = non-Anthropic via LiteLLM (session only)");
46188
+ if (srOptions.length > 0) {
46189
+ lines.push("Claude models use your Max/Pro subscription. \uD83C\uDF10 models are billed separately via OpenRouter.");
46190
+ }
46179
46191
  lines.push(PERSIST_NOTE);
46180
46192
  return { text: lines.join(`
46181
46193
  `), html: true, keyboard: menuKeyboard(claudeOptions, srOptions) };
@@ -46184,6 +46196,9 @@ async function handleModelMenuCallback(data, deps) {
46184
46196
  if (data === MODEL_CALLBACK_REFRESH) {
46185
46197
  return { answer: "Refreshed", reply: await buildModelMenu(deps) };
46186
46198
  }
46199
+ if (data === MODEL_CALLBACK_HEADER) {
46200
+ return { answer: "Tap a model in this section to switch", reply: { text: "", html: true }, toastOnly: true };
46201
+ }
46187
46202
  if (data.startsWith(MODEL_CALLBACK_SR)) {
46188
46203
  const srName = data.slice(MODEL_CALLBACK_SR.length);
46189
46204
  if (!isValidModelArg(srName)) {
@@ -55998,10 +56013,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
55998
56013
  }
55999
56014
 
56000
56015
  // ../src/build-info.ts
56001
- var VERSION = "0.16.5";
56002
- var COMMIT_SHA = "6821d14b";
56003
- var COMMIT_DATE = "2026-06-28T02:45:06Z";
56004
- var LATEST_PR = 2608;
56016
+ var VERSION = "0.16.7";
56017
+ var COMMIT_SHA = "696eea91";
56018
+ var COMMIT_DATE = "2026-06-28T04:56:27Z";
56019
+ var LATEST_PR = 2615;
56005
56020
  var COMMITS_AHEAD_OF_TAG = 0;
56006
56021
 
56007
56022
  // gateway/boot-version.ts
@@ -65195,6 +65210,27 @@ bot.command("clear", async (ctx) => {
65195
65210
  function buildModelDeps() {
65196
65211
  return {
65197
65212
  discover: (a) => discoverModels(a),
65213
+ discoverSrModels: async () => {
65214
+ const base = process.env.ANTHROPIC_BASE_URL;
65215
+ const headers = process.env.ANTHROPIC_CUSTOM_HEADERS;
65216
+ if (!base || !headers)
65217
+ return [];
65218
+ const keyMatch = headers.match(/x-litellm-api-key:\s*Bearer\s*(\S+)/);
65219
+ if (!keyMatch)
65220
+ return [];
65221
+ try {
65222
+ const res = await fetch(`${base.replace(/\/$/, "")}/model/info`, {
65223
+ headers: { Authorization: `Bearer ${keyMatch[1]}` },
65224
+ signal: AbortSignal.timeout(5000)
65225
+ });
65226
+ if (!res.ok)
65227
+ return [];
65228
+ const data = await res.json();
65229
+ return (data.data ?? []).map((m) => m.model_name).filter((n) => n.startsWith("sr-")).sort();
65230
+ } catch {
65231
+ return [];
65232
+ }
65233
+ },
65198
65234
  select: (a, label) => selectModel(a, label),
65199
65235
  isBusy: () => currentTurn !== null,
65200
65236
  getAgentName: getMyAgentName,
@@ -68141,6 +68177,10 @@ bot.on("callback_query:data", async (ctx) => {
68141
68177
  await ctx.answerCallbackQuery({ text: "\u23F3 Agent is mid-turn \u2014 tap again when it\u2019s idle", show_alert: false }).catch(() => {});
68142
68178
  return;
68143
68179
  }
68180
+ if (data === MODEL_CALLBACK_HEADER) {
68181
+ await ctx.answerCallbackQuery({ text: "Tap a model in this section to switch" }).catch(() => {});
68182
+ return;
68183
+ }
68144
68184
  await ctx.answerCallbackQuery({ text: "Switching\u2026" }).catch(() => {});
68145
68185
  try {
68146
68186
  const outcome = await handleModelMenuCallback(data, modelDeps);
@@ -276,6 +276,7 @@ import {
276
276
  buildModelMenu,
277
277
  handleModelMenuCallback,
278
278
  MODEL_CALLBACK_PREFIX,
279
+ MODEL_CALLBACK_HEADER,
279
280
  type ModelMenuDeps,
280
281
  type ModelCommandDeps,
281
282
  type ModelMenuReply,
@@ -15980,6 +15981,27 @@ bot.command('clear', async ctx => {
15980
15981
  function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
15981
15982
  return {
15982
15983
  discover: (a) => discoverModels(a),
15984
+ discoverSrModels: async () => {
15985
+ const base = process.env.ANTHROPIC_BASE_URL
15986
+ const headers = process.env.ANTHROPIC_CUSTOM_HEADERS
15987
+ if (!base || !headers) return []
15988
+ const keyMatch = headers.match(/x-litellm-api-key:\s*Bearer\s*(\S+)/)
15989
+ if (!keyMatch) return []
15990
+ try {
15991
+ const res = await fetch(`${base.replace(/\/$/, '')}/model/info`, {
15992
+ headers: { Authorization: `Bearer ${keyMatch[1]}` },
15993
+ signal: AbortSignal.timeout(5000),
15994
+ })
15995
+ if (!res.ok) return []
15996
+ const data = (await res.json()) as { data?: Array<{ model_name: string }> }
15997
+ return (data.data ?? [])
15998
+ .map((m) => m.model_name)
15999
+ .filter((n) => n.startsWith('sr-'))
16000
+ .sort()
16001
+ } catch {
16002
+ return []
16003
+ }
16004
+ },
15983
16005
  select: (a, label) => selectModel(a, label),
15984
16006
  isBusy: () => currentTurn !== null,
15985
16007
  getAgentName: getMyAgentName,
@@ -20721,6 +20743,11 @@ bot.on('callback_query:data', async ctx => {
20721
20743
  // drive behind the pane lock. A callback can only be answered once, so
20722
20744
  // the rich result (what was set / why it failed) is conveyed by the
20723
20745
  // message edit — which now ALWAYS keeps the menu buttons.
20746
+ // Header rows are section labels — informational, no model switch.
20747
+ if (data === MODEL_CALLBACK_HEADER) {
20748
+ await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
20749
+ return
20750
+ }
20724
20751
  await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
20725
20752
  try {
20726
20753
  const outcome = await handleModelMenuCallback(data, modelDeps)
@@ -214,6 +214,11 @@ export interface ModelMenuDeps {
214
214
  getAgentName: () => string
215
215
  /** One-line quota summary (e.g. "29% / 5h · 33% / 7d") or null. */
216
216
  getQuotaBrief: () => Promise<string | null>
217
+ /**
218
+ * Fetch sr-* model names available via LiteLLM for this agent.
219
+ * Returns [] when LiteLLM is not configured or the probe fails.
220
+ */
221
+ discoverSrModels: () => Promise<string[]>
217
222
  escapeHtml: (s: string) => string
218
223
  }
219
224
 
@@ -235,6 +240,8 @@ const MODEL_CALLBACK_SELECT = 'mdl:s:'
235
240
  export const MODEL_CALLBACK_REFRESH = 'mdl:r'
236
241
  /** Callback prefix for sr-* (LiteLLM non-Anthropic) model selection. */
237
242
  export const MODEL_CALLBACK_SR = 'mdl:sr:'
243
+ /** Callback for section-header rows — shows an informational toast, no action. */
244
+ export const MODEL_CALLBACK_HEADER = 'mdl:h'
238
245
 
239
246
  /**
240
247
  * Friendly display names for sr-* synthetic model names. An sr-* model in
@@ -296,29 +303,38 @@ function busyReply(deps: Pick<ModelMenuDeps, 'escapeHtml'>): ModelMenuReply {
296
303
  }
297
304
  }
298
305
 
306
+ function headerRow(label: string): ModelMenuKeyboardButton[] {
307
+ return [{ text: label, callback_data: MODEL_CALLBACK_HEADER }]
308
+ }
309
+
299
310
  function menuKeyboard(
300
311
  claudeOptions: ModelPickerOption[],
301
312
  srOptions: ModelPickerOption[],
302
313
  ): ModelMenuKeyboardButton[][] {
303
- // One option per row (labels + render cleanly at full width on
304
- // mobile), refresh on a trailing row.
305
- const rows: ModelMenuKeyboardButton[][] = claudeOptions.map((o) => [
306
- {
314
+ const hasBothGroups = claudeOptions.length > 0 && srOptions.length > 0
315
+ const rows: ModelMenuKeyboardButton[][] = []
316
+
317
+ if (hasBothGroups) rows.push(headerRow('── Claude (Max / Pro subscription) ──'))
318
+ for (const o of claudeOptions) {
319
+ rows.push([{
307
320
  text: o.current ? `✅ ${o.label}` : o.label,
308
321
  callback_data: modelSelectCallbackData(o.label),
309
- },
310
- ])
322
+ }])
323
+ }
324
+
311
325
  // sr-* models are non-Anthropic (routed via LiteLLM → OpenRouter).
312
326
  // Selection uses text-inject rather than cursor-nav — more reliable
313
327
  // when the picker has many models (GATEWAY_MODEL_DISCOVERY=1).
314
- for (const o of srOptions) {
315
- rows.push([
316
- {
328
+ if (srOptions.length > 0) {
329
+ rows.push(headerRow('── OpenRouter / external ──'))
330
+ for (const o of srOptions) {
331
+ rows.push([{
317
332
  text: `🌐 ${srFriendlyLabel(o.label)}`,
318
333
  callback_data: `${MODEL_CALLBACK_SR}${o.label}`,
319
- },
320
- ])
334
+ }])
335
+ }
321
336
  }
337
+
322
338
  rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
323
339
  return rows
324
340
  }
@@ -333,9 +349,10 @@ export async function buildModelMenu(
333
349
  ): Promise<ModelMenuReply> {
334
350
  if (deps.isBusy()) return busyReply(deps)
335
351
 
336
- const [discovered, quota] = await Promise.all([
352
+ const [discovered, quota, srNames] = await Promise.all([
337
353
  deps.discover(deps.getAgentName()),
338
354
  deps.getQuotaBrief().catch(() => null),
355
+ deps.discoverSrModels().catch(() => [] as string[]),
339
356
  ])
340
357
 
341
358
  if (!discovered.ok) {
@@ -353,7 +370,10 @@ export async function buildModelMenu(
353
370
  // or a prior session switch). Labelling the ✔ row "Now:" was misleading —
354
371
  // it could read "Opus 4.8" while the live session is on Fable. Call it what
355
372
  // it is, and tell the operator a switch applies to the live session.
356
- const { claude: claudeOptions, sr: srOptions } = classifyDiscoveredOptions(discovered.options)
373
+ // sr-* models come from LiteLLM (/model/info via discoverSrModels), not the
374
+ // claude picker — the CLI only knows Anthropic models.
375
+ const { claude: claudeOptions } = classifyDiscoveredOptions(discovered.options)
376
+ const srOptions: ModelPickerOption[] = srNames.map((name, i) => ({ index: i, label: name, detail: '', current: false }))
357
377
  const current = claudeOptions.find((o) => o.current)
358
378
  const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
359
379
  if (discovered.dismissFailed) {
@@ -367,7 +387,9 @@ export async function buildModelMenu(
367
387
  }
368
388
  if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
369
389
  lines.push('', 'Tap a model to switch the <b>live session</b>:')
370
- if (srOptions.length > 0) lines.push('🌐 = non-Anthropic via LiteLLM (session only)')
390
+ if (srOptions.length > 0) {
391
+ lines.push('Claude models use your Max/Pro subscription. 🌐 models are billed separately via OpenRouter.')
392
+ }
371
393
  lines.push(PERSIST_NOTE)
372
394
 
373
395
  return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(claudeOptions, srOptions) }
@@ -410,6 +432,13 @@ export async function handleModelMenuCallback(
410
432
  return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
411
433
  }
412
434
 
435
+ if (data === MODEL_CALLBACK_HEADER) {
436
+ // Section-header row — the gateway handles this with a direct answerCallbackQuery
437
+ // before calling this function, so this branch is dead in practice. Guard
438
+ // for callers that skip gateway.ts (tests, future refactors).
439
+ return { answer: 'Tap a model in this section to switch', reply: { text: '', html: true }, toastOnly: true }
440
+ }
441
+
413
442
  // sr-* model tap: text-inject `/model sr-<name>` rather than cursor-nav.
414
443
  // Text-inject is more reliable when the picker has many models; sr-* names
415
444
  // are safe (no entry in model_group_settings → no OAuth forwarding). See I6.
@@ -255,6 +255,7 @@ import {
255
255
  sessionModelFromConfirmation,
256
256
  classifyDiscoveredOptions,
257
257
  MODEL_CALLBACK_REFRESH,
258
+ MODEL_CALLBACK_HEADER,
258
259
  MODEL_CALLBACK_SR,
259
260
  SR_MODEL_LABELS,
260
261
  type ModelMenuDeps,
@@ -282,6 +283,7 @@ function makeMenuDeps(overrides: Partial<ModelMenuDeps> = {}) {
282
283
  },
283
284
  isBusy: () => false,
284
285
  getQuotaBrief: async () => "29% / 5h · 33% / 7d",
286
+ discoverSrModels: async () => [],
285
287
  ...overrides,
286
288
  };
287
289
  return { deps, calls, injectCalls: base.calls };
@@ -474,13 +476,10 @@ describe("SR_MODEL_LABELS", () => {
474
476
  });
475
477
 
476
478
  describe("buildModelMenu — with sr-* models", () => {
479
+ // sr-* models now come from discoverSrModels (LiteLLM), not the claude picker.
477
480
  function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
478
481
  return makeMenuDeps({
479
- discover: async () => ({
480
- ok: true as const,
481
- options: OPTIONS_WITH_SR,
482
- currentLabel: "Sonnet",
483
- }),
482
+ discoverSrModels: async () => ["sr-gemini-2.5-pro", "sr-deepseek-r1"],
484
483
  ...overrides,
485
484
  });
486
485
  }
@@ -506,27 +505,50 @@ describe("buildModelMenu — with sr-* models", () => {
506
505
  expect(srButton?.callback_data).toBe(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`);
507
506
  });
508
507
 
509
- it("shows 🌐 = non-Anthropic legend when sr-* models are present", async () => {
508
+ it("shows section header rows when both claude and sr-* models present", async () => {
510
509
  const { deps } = makeMenuDepsWithSr();
511
510
  const menu = await buildModelMenu(deps);
512
- expect(menu.text).toContain("🌐 = non-Anthropic");
511
+ const allButtons = menu.keyboard!.flat();
512
+ const headers = allButtons.filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
513
+ expect(headers.length).toBe(2);
514
+ expect(headers[0].text).toContain("Claude");
515
+ expect(headers[1].text).toContain("OpenRouter");
516
+ });
517
+
518
+ it("no section headers when only claude models (no sr-*)", async () => {
519
+ const { deps } = makeMenuDeps();
520
+ const menu = await buildModelMenu(deps);
521
+ const allButtons = (menu.keyboard ?? []).flat();
522
+ const headers = allButtons.filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
523
+ expect(headers.length).toBe(0);
524
+ });
525
+
526
+ it("header-row tap returns toastOnly without inject or model change", async () => {
527
+ const { deps, injectCalls } = makeMenuDepsWithSr();
528
+ const out = await handleModelMenuCallback(MODEL_CALLBACK_HEADER, deps);
529
+ expect(out.toastOnly).toBe(true);
530
+ expect(out.selectedModel).toBeUndefined();
531
+ expect(injectCalls).toHaveLength(0);
532
+ });
533
+
534
+ it("shows subscription/OpenRouter legend when sr-* models are present", async () => {
535
+ const { deps } = makeMenuDepsWithSr();
536
+ const menu = await buildModelMenu(deps);
537
+ expect(menu.text).toContain("Max/Pro subscription");
538
+ expect(menu.text).toContain("OpenRouter");
513
539
  });
514
540
 
515
541
  it("no legend when no sr-* models in picker", async () => {
516
542
  const { deps } = makeMenuDeps();
517
543
  const menu = await buildModelMenu(deps);
518
- expect(menu.text).not.toContain("🌐 = non-Anthropic");
544
+ expect(menu.text).not.toContain("OpenRouter");
519
545
  });
520
546
  });
521
547
 
522
548
  describe("handleModelMenuCallback — sr-* selection", () => {
523
549
  function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
524
550
  return makeMenuDeps({
525
- discover: async () => ({
526
- ok: true as const,
527
- options: OPTIONS_WITH_SR,
528
- currentLabel: "Sonnet",
529
- }),
551
+ discoverSrModels: async () => ["sr-gemini-2.5-pro", "sr-deepseek-r1"],
530
552
  ...overrides,
531
553
  });
532
554
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * UAT — /model with LiteLLM sr-* (OpenRouter) model switching + spend tracking.
3
+ *
4
+ * Covers:
5
+ * 1. /model menu shows section headers ("Claude (Max / Pro subscription)" and
6
+ * "OpenRouter / external") when sr-* models are available.
7
+ * 2. Tapping an sr-* button switches the live session (text-inject path,
8
+ * not cursor-nav) and the confirmation banner appears.
9
+ * 3. After switching, the agent replies on the new model, and LiteLLM
10
+ * spend logs show agent:test-harness attribution.
11
+ * 4. Session resets to the configured model on restart (out of scope for
12
+ * this test — asserted in jtbd-always-on-after-restart-dm).
13
+ *
14
+ * Self-skips green when:
15
+ * - SWITCHROOM_UAT_DRIVER_SESSION is not set (no Telegram driver)
16
+ * - LiteLLM admin key unavailable (SWITCHROOM_UAT_LITELLM_ADMIN_KEY unset)
17
+ * - No sr-* buttons found in the menu (agent not LiteLLM-enabled or no
18
+ * sr-* models registered in LiteLLM)
19
+ */
20
+
21
+ import { describe, expect, it } from "vitest";
22
+ import { spinUp } from "../harness.js";
23
+
24
+ const AGENT = "test-harness";
25
+ const LITELLM_URL = process.env.SWITCHROOM_UAT_LITELLM_URL ?? "http://127.0.0.1:4010";
26
+ const LITELLM_ADMIN_KEY = process.env.SWITCHROOM_UAT_LITELLM_ADMIN_KEY ?? "";
27
+
28
+ async function getLiteLLMSpendForAgent(agent: string): Promise<number> {
29
+ if (!LITELLM_ADMIN_KEY) return -1;
30
+ const res = await fetch(`${LITELLM_URL}/spend/tags`, {
31
+ headers: { Authorization: `Bearer ${LITELLM_ADMIN_KEY}` },
32
+ }).catch(() => null);
33
+ if (!res?.ok) return -1;
34
+ const tags: Array<{ individual_request_tag: string; log_count: number }> = await res.json();
35
+ return tags.find((t) => t.individual_request_tag === `agent:${agent}`)?.log_count ?? 0;
36
+ }
37
+
38
+ describe("uat: /model sr-* LiteLLM routing — section headers + session switch + spend attribution", () => {
39
+ it(
40
+ "menu shows Claude/OpenRouter section headers, sr-* tap switches session and LiteLLM logs the request",
41
+ async () => {
42
+ const sc = await spinUp({ agent: AGENT });
43
+ try {
44
+ await sc.sendDM("/model");
45
+ const menu = await sc.expectMessage(/Default \(new sessions\):/i, {
46
+ from: "bot",
47
+ timeout: 30_000,
48
+ });
49
+
50
+ // ── 1. Section headers ──────────────────────────────────────────
51
+ const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
52
+ const flat = (kb ?? []).flat().filter((b) => b.callbackData);
53
+
54
+ const claudeHeader = flat.find(
55
+ (b) => b.text.includes("Claude") && b.text.includes("subscription") && b.callbackData === "mdl:h",
56
+ );
57
+ const openrouterHeader = flat.find(
58
+ (b) => b.text.includes("OpenRouter") && b.callbackData === "mdl:h",
59
+ );
60
+ const srButton = flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
61
+
62
+ if (!srButton) {
63
+ console.log("No sr-* buttons in menu — agent not LiteLLM-enabled or no sr-* models registered. Skipping.");
64
+ return;
65
+ }
66
+
67
+ expect(claudeHeader, "Claude (Max / Pro subscription) header row").toBeDefined();
68
+ expect(openrouterHeader, "OpenRouter / external header row").toBeDefined();
69
+ expect(menu.text).toContain("Max/Pro subscription");
70
+ expect(menu.text).toContain("OpenRouter");
71
+
72
+ // ── 2. sr-* switch ─────────────────────────────────────────────
73
+ const spendBefore = await getLiteLLMSpendForAgent(AGENT);
74
+ const srName = srButton.callbackData!.replace("mdl:sr:", "");
75
+
76
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, srButton.callbackData!);
77
+ // Allow text-inject + claude's /model response to propagate
78
+ await new Promise((r) => setTimeout(r, 8_000));
79
+
80
+ const afterMenu = await sc.driver.getMessage(sc.botUserId, menu.messageId);
81
+ expect(afterMenu?.text ?? "", "confirmation banner after sr-* tap").toMatch(
82
+ /Set model to|Switched|session/i,
83
+ );
84
+ // Card must keep its buttons (#2270 — no dead card)
85
+ const kbAfter = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
86
+ expect((kbAfter ?? []).flat().length, "menu keeps buttons after sr-* tap").toBeGreaterThan(0);
87
+
88
+ // ── 3. Send a quick message to generate a LiteLLM-routed turn ──
89
+ await sc.sendDM("Just reply with the word OK.");
90
+ await sc.expectMessage(/ok/i, { from: "bot", timeout: 60_000 });
91
+
92
+ // ── 4. LiteLLM spend attribution ────────────────────────────────
93
+ if (spendBefore >= 0) {
94
+ // Give LiteLLM a moment to flush the log
95
+ await new Promise((r) => setTimeout(r, 3_000));
96
+ const spendAfter = await getLiteLLMSpendForAgent(AGENT);
97
+ expect(spendAfter, `agent:${AGENT} log_count increased after turn`).toBeGreaterThan(spendBefore);
98
+ }
99
+
100
+ // ── Restore: switch back to configured model ────────────────────
101
+ const currentKb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
102
+ const restoreBtn = (currentKb ?? []).flat().find(
103
+ (b) => b.callbackData?.startsWith("mdl:s:") && /Default|Sonnet|claude-sonnet/i.test(b.text),
104
+ );
105
+ if (restoreBtn?.callbackData) {
106
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, restoreBtn.callbackData);
107
+ await new Promise((r) => setTimeout(r, 4_000));
108
+ }
109
+
110
+ console.log(`✅ sr-* model switch (${srName}) verified end-to-end through LiteLLM`);
111
+ } finally {
112
+ await sc.tearDown();
113
+ }
114
+ },
115
+ 180_000,
116
+ );
117
+
118
+ it(
119
+ "header row tap shows toast without switching model or opening picker",
120
+ async () => {
121
+ const sc = await spinUp({ agent: AGENT });
122
+ try {
123
+ await sc.sendDM("/model");
124
+ const menu = await sc.expectMessage(/Default \(new sessions\):/i, {
125
+ from: "bot",
126
+ timeout: 30_000,
127
+ });
128
+ const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
129
+ const flat = (kb ?? []).flat();
130
+ const headerBtn = flat.find((b) => b.callbackData === "mdl:h");
131
+ if (!headerBtn) {
132
+ console.log("No header row — agent not LiteLLM-enabled. Skipping.");
133
+ return;
134
+ }
135
+ // Pressing the header should NOT change the menu text
136
+ const textBefore = menu.text;
137
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, "mdl:h");
138
+ await new Promise((r) => setTimeout(r, 3_000));
139
+ const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);
140
+ expect(after?.text ?? "").toBe(textBefore);
141
+ } finally {
142
+ await sc.tearDown();
143
+ }
144
+ },
145
+ 60_000,
146
+ );
147
+ });