switchroom 0.16.6 → 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.6";
51678
- var COMMIT_SHA = "925f5798";
51677
+ var VERSION = "0.16.7";
51678
+ var COMMIT_SHA = "696eea91";
51679
51679
 
51680
51680
  // src/cli/resolve-version.ts
51681
51681
  function readPackageVersion() {
@@ -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.6";
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.6",
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
@@ -46156,9 +46156,10 @@ function menuKeyboard(claudeOptions, srOptions) {
46156
46156
  async function buildModelMenu(deps) {
46157
46157
  if (deps.isBusy())
46158
46158
  return busyReply(deps);
46159
- const [discovered, quota] = await Promise.all([
46159
+ const [discovered, quota, srNames] = await Promise.all([
46160
46160
  deps.discover(deps.getAgentName()),
46161
- deps.getQuotaBrief().catch(() => null)
46161
+ deps.getQuotaBrief().catch(() => null),
46162
+ deps.discoverSrModels().catch(() => [])
46162
46163
  ]);
46163
46164
  if (!discovered.ok) {
46164
46165
  const v1 = await handleModelCommand({ kind: "show" }, deps);
@@ -46168,7 +46169,8 @@ async function buildModelMenu(deps) {
46168
46169
  html: true
46169
46170
  };
46170
46171
  }
46171
- 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 }));
46172
46174
  const current = claudeOptions.find((o) => o.current);
46173
46175
  const lines = [`<b>Model \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`];
46174
46176
  if (discovered.dismissFailed) {
@@ -56011,10 +56013,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56011
56013
  }
56012
56014
 
56013
56015
  // ../src/build-info.ts
56014
- var VERSION = "0.16.6";
56015
- var COMMIT_SHA = "925f5798";
56016
- var COMMIT_DATE = "2026-06-28T03:53:56Z";
56017
- var LATEST_PR = 2611;
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;
56018
56020
  var COMMITS_AHEAD_OF_TAG = 0;
56019
56021
 
56020
56022
  // gateway/boot-version.ts
@@ -65208,6 +65210,27 @@ bot.command("clear", async (ctx) => {
65208
65210
  function buildModelDeps() {
65209
65211
  return {
65210
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
+ },
65211
65234
  select: (a, label) => selectModel(a, label),
65212
65235
  isBusy: () => currentTurn !== null,
65213
65236
  getAgentName: getMyAgentName,
@@ -15981,6 +15981,27 @@ bot.command('clear', async ctx => {
15981
15981
  function buildModelDeps(): ModelMenuDeps & ModelCommandDeps {
15982
15982
  return {
15983
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
+ },
15984
16005
  select: (a, label) => selectModel(a, label),
15985
16006
  isBusy: () => currentTurn !== null,
15986
16007
  getAgentName: getMyAgentName,
@@ -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
 
@@ -344,9 +349,10 @@ export async function buildModelMenu(
344
349
  ): Promise<ModelMenuReply> {
345
350
  if (deps.isBusy()) return busyReply(deps)
346
351
 
347
- const [discovered, quota] = await Promise.all([
352
+ const [discovered, quota, srNames] = await Promise.all([
348
353
  deps.discover(deps.getAgentName()),
349
354
  deps.getQuotaBrief().catch(() => null),
355
+ deps.discoverSrModels().catch(() => [] as string[]),
350
356
  ])
351
357
 
352
358
  if (!discovered.ok) {
@@ -364,7 +370,10 @@ export async function buildModelMenu(
364
370
  // or a prior session switch). Labelling the ✔ row "Now:" was misleading —
365
371
  // it could read "Opus 4.8" while the live session is on Fable. Call it what
366
372
  // it is, and tell the operator a switch applies to the live session.
367
- 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 }))
368
377
  const current = claudeOptions.find((o) => o.current)
369
378
  const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
370
379
  if (discovered.dismissFailed) {
@@ -283,6 +283,7 @@ function makeMenuDeps(overrides: Partial<ModelMenuDeps> = {}) {
283
283
  },
284
284
  isBusy: () => false,
285
285
  getQuotaBrief: async () => "29% / 5h · 33% / 7d",
286
+ discoverSrModels: async () => [],
286
287
  ...overrides,
287
288
  };
288
289
  return { deps, calls, injectCalls: base.calls };
@@ -475,13 +476,10 @@ describe("SR_MODEL_LABELS", () => {
475
476
  });
476
477
 
477
478
  describe("buildModelMenu — with sr-* models", () => {
479
+ // sr-* models now come from discoverSrModels (LiteLLM), not the claude picker.
478
480
  function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
479
481
  return makeMenuDeps({
480
- discover: async () => ({
481
- ok: true as const,
482
- options: OPTIONS_WITH_SR,
483
- currentLabel: "Sonnet",
484
- }),
482
+ discoverSrModels: async () => ["sr-gemini-2.5-pro", "sr-deepseek-r1"],
485
483
  ...overrides,
486
484
  });
487
485
  }
@@ -550,11 +548,7 @@ describe("buildModelMenu — with sr-* models", () => {
550
548
  describe("handleModelMenuCallback — sr-* selection", () => {
551
549
  function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
552
550
  return makeMenuDeps({
553
- discover: async () => ({
554
- ok: true as const,
555
- options: OPTIONS_WITH_SR,
556
- currentLabel: "Sonnet",
557
- }),
551
+ discoverSrModels: async () => ["sr-gemini-2.5-pro", "sr-deepseek-r1"],
558
552
  ...overrides,
559
553
  });
560
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
+ });