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.
- package/dist/cli/switchroom.js +3 -3
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +33 -0
- package/telegram-plugin/dist/gateway/gateway.js +58 -18
- package/telegram-plugin/gateway/gateway.ts +27 -0
- package/telegram-plugin/gateway/model-command.ts +43 -14
- package/telegram-plugin/tests/model-command.test.ts +35 -13
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +147 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -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.
|
|
51678
|
-
var COMMIT_SHA = "
|
|
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.
|
|
22590
|
+
var VERSION = "0.16.7";
|
|
22591
22591
|
|
|
22592
22592
|
// src/cli/resolve-version.ts
|
|
22593
22593
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
56002
|
-
var COMMIT_SHA = "
|
|
56003
|
-
var COMMIT_DATE = "2026-06-
|
|
56004
|
-
var LATEST_PR =
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
+
});
|