switchroom 0.16.6 → 0.16.8
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 +1039 -608
- package/dist/host-control/main.js +5 -5
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +33 -0
- package/profiles/_base/start.sh.hbs +35 -0
- package/telegram-plugin/dist/gateway/gateway.js +33 -8
- package/telegram-plugin/gateway/gateway.ts +21 -0
- package/telegram-plugin/gateway/model-command.ts +11 -2
- package/telegram-plugin/permission-title.ts +5 -0
- package/telegram-plugin/tests/model-command.test.ts +4 -10
- package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +147 -0
|
@@ -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.8";
|
|
22591
22591
|
|
|
22592
22592
|
// src/cli/resolve-version.ts
|
|
22593
22593
|
function readPackageVersion() {
|
|
@@ -22827,7 +22827,7 @@ function parseUpdateResultLine(stdout) {
|
|
|
22827
22827
|
// src/host-control/config-edit-validator.ts
|
|
22828
22828
|
import { mkdtempSync, writeFileSync as writeFileSync2, rmSync as rmSync2, existsSync as existsSync7, readFileSync as readFileSync5 } from "node:fs";
|
|
22829
22829
|
import { tmpdir } from "node:os";
|
|
22830
|
-
import { join as join4, isAbsolute as isAbsolute2, normalize } from "node:path";
|
|
22830
|
+
import { join as join4, isAbsolute as isAbsolute2, normalize, basename as basename2 } from "node:path";
|
|
22831
22831
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
22832
22832
|
import { isDeepStrictEqual } from "node:util";
|
|
22833
22833
|
var MAX_PATCH_BYTES = 1024 * 1024;
|
|
@@ -22847,7 +22847,7 @@ function isTargetPathHeader(headerPath, targetBasename) {
|
|
|
22847
22847
|
const norm = normalize(p);
|
|
22848
22848
|
if (norm.includes("..") || isAbsolute2(norm))
|
|
22849
22849
|
return false;
|
|
22850
|
-
return norm === targetBasename;
|
|
22850
|
+
return norm === targetBasename || basename2(norm) === targetBasename;
|
|
22851
22851
|
}
|
|
22852
22852
|
function validateShape(unifiedDiff, targetPath) {
|
|
22853
22853
|
const byteLen = Buffer.byteLength(unifiedDiff, "utf8");
|
|
@@ -22923,8 +22923,8 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
|
|
|
22923
22923
|
const liveContent = readFileSync5(configPath, "utf8");
|
|
22924
22924
|
const scratchDir = mkdtempSync(join4(tmpdir(), "config-propose-edit-"));
|
|
22925
22925
|
try {
|
|
22926
|
-
const
|
|
22927
|
-
const scratchFile = join4(scratchDir,
|
|
22926
|
+
const basename3 = configPath.split("/").pop() ?? "switchroom.yaml";
|
|
22927
|
+
const scratchFile = join4(scratchDir, basename3);
|
|
22928
22928
|
writeFileSync2(scratchFile, liveContent);
|
|
22929
22929
|
const patchFile = join4(scratchDir, "proposal.patch");
|
|
22930
22930
|
writeFileSync2(patchFile, unifiedDiff);
|
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
|
|
@@ -51,6 +51,41 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
|
|
|
51
51
|
{{/each}}
|
|
52
52
|
{{/if}}
|
|
53
53
|
|
|
54
|
+
# LiteLLM virtual-key fetch for the GATEWAY process (outer-pass hoist).
|
|
55
|
+
# The INNER block below fetches this same key for the `claude` process;
|
|
56
|
+
# the gateway daemon forks HERE (in the outer pass) so it needs
|
|
57
|
+
# ANTHROPIC_CUSTOM_HEADERS exported BEFORE the gateway fork below, or
|
|
58
|
+
# discoverSrModels() returns [] and /model never shows OpenRouter entries.
|
|
59
|
+
#
|
|
60
|
+
# Mirrors the INNER block's fail-open logic exactly:
|
|
61
|
+
# - missing key → log + skip export (gateway talks direct OAuth)
|
|
62
|
+
# - proxy down → log + strip ALL routing env (fail-open)
|
|
63
|
+
# - ANTHROPIC_CUSTOM_HEADERS already set → skip (idempotent)
|
|
64
|
+
#
|
|
65
|
+
# SWITCHROOM_AGENT_NAME is injected by compose env (compose.ts:1816)
|
|
66
|
+
# so it is available here, before the inner-pass export at line ~320.
|
|
67
|
+
if [ -n "$SWITCHROOM_LITELLM" ] && [ -z "$ANTHROPIC_CUSTOM_HEADERS" ] && command -v switchroom >/dev/null 2>&1; then
|
|
68
|
+
sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
|
|
69
|
+
sr_ll_ok=""
|
|
70
|
+
if [ -z "$sr_ll_key" ]; then
|
|
71
|
+
echo "litellm(outer): no virtual key for agent '$SWITCHROOM_AGENT_NAME' — gateway will use direct OAuth (no tracking/guardrail)" >&2
|
|
72
|
+
elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ] \
|
|
73
|
+
&& ! curl -fsS -m 5 -o /dev/null "${ANTHROPIC_BASE_URL%/}/health/liveliness" 2>/dev/null; then
|
|
74
|
+
echo "litellm(outer): proxy unreachable at $ANTHROPIC_BASE_URL — falling back to direct OAuth (no tracking/guardrail this session)" >&2
|
|
75
|
+
else
|
|
76
|
+
sr_ll_ok="1"
|
|
77
|
+
fi
|
|
78
|
+
if [ -n "$sr_ll_ok" ]; then
|
|
79
|
+
export ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: Bearer $sr_ll_key
|
|
80
|
+
x-litellm-customer-id: $SWITCHROOM_AGENT_NAME
|
|
81
|
+
x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:-default}"
|
|
82
|
+
export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
|
|
83
|
+
else
|
|
84
|
+
unset ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL SWITCHROOM_LITELLM
|
|
85
|
+
fi
|
|
86
|
+
unset sr_ll_key sr_ll_ok
|
|
87
|
+
fi
|
|
88
|
+
|
|
54
89
|
# Tiny in-process supervisor: runs cmd in a respawn loop with
|
|
55
90
|
# exponential backoff (1→2→4…→60s cap) and NEVER permanently gives
|
|
56
91
|
# up. Rationale (RFC J / install-validation 2026-05-17): the
|
|
@@ -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
|
|
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) {
|
|
@@ -54801,6 +54803,8 @@ function naturalAction(toolName, inputPreview) {
|
|
|
54801
54803
|
return "update its task list";
|
|
54802
54804
|
case "ExitPlanMode":
|
|
54803
54805
|
return "exit plan mode";
|
|
54806
|
+
case "config_propose_edit":
|
|
54807
|
+
return "edit switchroom config";
|
|
54804
54808
|
default:
|
|
54805
54809
|
return `use ${toolName}`;
|
|
54806
54810
|
}
|
|
@@ -56011,11 +56015,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
56011
56015
|
}
|
|
56012
56016
|
|
|
56013
56017
|
// ../src/build-info.ts
|
|
56014
|
-
var VERSION = "0.16.
|
|
56015
|
-
var COMMIT_SHA = "
|
|
56016
|
-
var COMMIT_DATE = "2026-06-
|
|
56017
|
-
var LATEST_PR =
|
|
56018
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
56018
|
+
var VERSION = "0.16.8";
|
|
56019
|
+
var COMMIT_SHA = "c2668516";
|
|
56020
|
+
var COMMIT_DATE = "2026-06-28T15:28:08+10:00";
|
|
56021
|
+
var LATEST_PR = null;
|
|
56022
|
+
var COMMITS_AHEAD_OF_TAG = 5;
|
|
56019
56023
|
|
|
56020
56024
|
// gateway/boot-version.ts
|
|
56021
56025
|
function formatRelativeAgo(iso) {
|
|
@@ -65208,6 +65212,27 @@ bot.command("clear", async (ctx) => {
|
|
|
65208
65212
|
function buildModelDeps() {
|
|
65209
65213
|
return {
|
|
65210
65214
|
discover: (a) => discoverModels(a),
|
|
65215
|
+
discoverSrModels: async () => {
|
|
65216
|
+
const base = process.env.ANTHROPIC_BASE_URL;
|
|
65217
|
+
const headers = process.env.ANTHROPIC_CUSTOM_HEADERS;
|
|
65218
|
+
if (!base || !headers)
|
|
65219
|
+
return [];
|
|
65220
|
+
const keyMatch = headers.match(/x-litellm-api-key:\s*Bearer\s*(\S+)/);
|
|
65221
|
+
if (!keyMatch)
|
|
65222
|
+
return [];
|
|
65223
|
+
try {
|
|
65224
|
+
const res = await fetch(`${base.replace(/\/$/, "")}/model/info`, {
|
|
65225
|
+
headers: { Authorization: `Bearer ${keyMatch[1]}` },
|
|
65226
|
+
signal: AbortSignal.timeout(5000)
|
|
65227
|
+
});
|
|
65228
|
+
if (!res.ok)
|
|
65229
|
+
return [];
|
|
65230
|
+
const data = await res.json();
|
|
65231
|
+
return (data.data ?? []).map((m) => m.model_name).filter((n) => n.startsWith("sr-")).sort();
|
|
65232
|
+
} catch {
|
|
65233
|
+
return [];
|
|
65234
|
+
}
|
|
65235
|
+
},
|
|
65211
65236
|
select: (a, label) => selectModel(a, label),
|
|
65212
65237
|
isBusy: () => currentTurn !== null,
|
|
65213
65238
|
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
|
-
|
|
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) {
|
|
@@ -212,6 +212,11 @@ export function naturalAction(
|
|
|
212
212
|
return "update its task list";
|
|
213
213
|
case "ExitPlanMode":
|
|
214
214
|
return "exit plan mode";
|
|
215
|
+
// hostd config-edit verb (#2605) — not mcp__-prefixed (it's a direct
|
|
216
|
+
// wire call from the agent-config MCP server), so it falls through to
|
|
217
|
+
// the switch rather than naturalMcpAction. Give it a readable title.
|
|
218
|
+
case "config_propose_edit":
|
|
219
|
+
return "edit switchroom config";
|
|
215
220
|
default:
|
|
216
221
|
return `use ${toolName}`;
|
|
217
222
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|