switchroom 0.16.4 → 0.16.6

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.
@@ -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,
@@ -3317,6 +3318,23 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
3317
3318
  * Idempotent: a second purge is a no-op `.delete()` on a key already
3318
3319
  * gone — handlers that already purge elsewhere are unharmed.
3319
3320
  */
3321
+ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
3322
+ try {
3323
+ const rec =
3324
+ JSON.stringify({
3325
+ ts: Math.floor(endedAt / 1000),
3326
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? 'unknown',
3327
+ duration_ms: turn.startedAt > 0 ? endedAt - turn.startedAt : 0,
3328
+ tools: turn.toolCallCount ?? 0,
3329
+ status: turn.finalAnswerDelivered ? 'complete' : 'no_reply',
3330
+ turn_id: turn.turnId,
3331
+ }) + '\n'
3332
+ appendFileSync('/state/agent/turns.jsonl', rec)
3333
+ } catch {
3334
+ // best-effort — never let metrics emission break turn teardown
3335
+ }
3336
+ }
3337
+
3320
3338
  function endCurrentTurnAtomic(turn: CurrentTurn): void {
3321
3339
  // PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
3322
3340
  // guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
@@ -3331,9 +3349,11 @@ function endCurrentTurnAtomic(turn: CurrentTurn): void {
3331
3349
  // Status-surface observability: one line at every turn CLEAR (with how far
3332
3350
  // the turn got), plus a DEGRADED warning when the turn did tool work but the
3333
3351
  // live feed never opened because its sends failed (the resume-400 signature).
3352
+ const turnEndedAt = Date.now()
3334
3353
  process.stderr.write(
3335
- `telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, Date.now())}\n`,
3354
+ `telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
3336
3355
  )
3356
+ emitTurnRecord(turn, turnEndedAt)
3337
3357
  const degraded = detectStatusSurfaceDegraded(turn)
3338
3358
  if (degraded != null) {
3339
3359
  process.stderr.write(
@@ -20702,6 +20722,11 @@ bot.on('callback_query:data', async ctx => {
20702
20722
  // drive behind the pane lock. A callback can only be answered once, so
20703
20723
  // the rich result (what was set / why it failed) is conveyed by the
20704
20724
  // message edit — which now ALWAYS keeps the menu buttons.
20725
+ // Header rows are section labels — informational, no model switch.
20726
+ if (data === MODEL_CALLBACK_HEADER) {
20727
+ await ctx.answerCallbackQuery({ text: 'Tap a model in this section to switch' }).catch(() => {})
20728
+ return
20729
+ }
20705
20730
  await ctx.answerCallbackQuery({ text: 'Switching…' }).catch(() => {})
20706
20731
  try {
20707
20732
  const outcome = await handleModelMenuCallback(data, modelDeps)
@@ -233,6 +233,55 @@ export interface ModelMenuReply {
233
233
  export const MODEL_CALLBACK_PREFIX = 'mdl:'
234
234
  const MODEL_CALLBACK_SELECT = 'mdl:s:'
235
235
  export const MODEL_CALLBACK_REFRESH = 'mdl:r'
236
+ /** Callback prefix for sr-* (LiteLLM non-Anthropic) model selection. */
237
+ export const MODEL_CALLBACK_SR = 'mdl:sr:'
238
+ /** Callback for section-header rows — shows an informational toast, no action. */
239
+ export const MODEL_CALLBACK_HEADER = 'mdl:h'
240
+
241
+ /**
242
+ * Friendly display names for sr-* synthetic model names. An sr-* model in
243
+ * LiteLLM has no entry in `model_group_settings.*.forward_client_headers_to_llm_api`
244
+ * so the Anthropic OAuth credential is NEVER forwarded — safe to route to
245
+ * OpenRouter. Names here are display-only; the raw `sr-*` id is what gets
246
+ * injected into the agent's session. See reference/rfcs/litellm-max-subscription-invariants.md § I6.
247
+ */
248
+ export const SR_MODEL_LABELS: Record<string, string> = {
249
+ 'sr-gemini-2.5-pro': 'Gemini 2.5 Pro',
250
+ 'sr-gemini-2.5-flash': 'Gemini 2.5 Flash',
251
+ 'sr-deepseek-r1': 'DeepSeek R1',
252
+ 'sr-deepseek-v3': 'DeepSeek V3',
253
+ 'sr-glm-5': 'GLM-5',
254
+ }
255
+
256
+ function srFriendlyLabel(srName: string): string {
257
+ return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
258
+ }
259
+
260
+ /**
261
+ * Split picker-discovered options into native Claude options and sr-*
262
+ * (LiteLLM non-Anthropic) options. Options with "/" in the label or
263
+ * other non-native prefixes (e.g., "openrouter/...", "gpt-4") are
264
+ * silently dropped — they're internal LiteLLM routing paths, not
265
+ * user-facing switching targets.
266
+ */
267
+ export function classifyDiscoveredOptions(options: ModelPickerOption[]): {
268
+ claude: ModelPickerOption[]
269
+ sr: ModelPickerOption[]
270
+ } {
271
+ return {
272
+ // Native Claude picker labels start with an uppercase letter (e.g.
273
+ // "Default (recommended)", "Opus", "Sonnet") or with "claude-" for full
274
+ // model IDs. This excludes sr-* names, internal routing paths
275
+ // ("openrouter/..."), and non-Claude models exposed by GATEWAY_MODEL_DISCOVERY
276
+ // ("gpt-4", "gpt-4o", "voyage-law-2", etc.) — those are LiteLLM internals
277
+ // not meant as user-facing switching targets.
278
+ claude: options.filter(
279
+ (o) => !o.label.startsWith('sr-') && !o.label.includes('/') &&
280
+ (/^[A-Z]/.test(o.label) || o.label.startsWith('claude-')),
281
+ ),
282
+ sr: options.filter((o) => o.label.startsWith('sr-')),
283
+ }
284
+ }
236
285
 
237
286
  export function modelSelectCallbackData(label: string): string {
238
287
  // Identity is the label's hash, not its index — a tap re-discovers
@@ -249,15 +298,38 @@ function busyReply(deps: Pick<ModelMenuDeps, 'escapeHtml'>): ModelMenuReply {
249
298
  }
250
299
  }
251
300
 
252
- function menuKeyboard(options: ModelPickerOption[]): ModelMenuKeyboardButton[][] {
253
- // One option per row (labels + ✔ render cleanly at full width on
254
- // mobile), refresh on a trailing row.
255
- const rows: ModelMenuKeyboardButton[][] = options.map((o) => [
256
- {
301
+ function headerRow(label: string): ModelMenuKeyboardButton[] {
302
+ return [{ text: label, callback_data: MODEL_CALLBACK_HEADER }]
303
+ }
304
+
305
+ function menuKeyboard(
306
+ claudeOptions: ModelPickerOption[],
307
+ srOptions: ModelPickerOption[],
308
+ ): ModelMenuKeyboardButton[][] {
309
+ const hasBothGroups = claudeOptions.length > 0 && srOptions.length > 0
310
+ const rows: ModelMenuKeyboardButton[][] = []
311
+
312
+ if (hasBothGroups) rows.push(headerRow('── Claude (Max / Pro subscription) ──'))
313
+ for (const o of claudeOptions) {
314
+ rows.push([{
257
315
  text: o.current ? `✅ ${o.label}` : o.label,
258
316
  callback_data: modelSelectCallbackData(o.label),
259
- },
260
- ])
317
+ }])
318
+ }
319
+
320
+ // sr-* models are non-Anthropic (routed via LiteLLM → OpenRouter).
321
+ // Selection uses text-inject rather than cursor-nav — more reliable
322
+ // when the picker has many models (GATEWAY_MODEL_DISCOVERY=1).
323
+ if (srOptions.length > 0) {
324
+ rows.push(headerRow('── OpenRouter / external ──'))
325
+ for (const o of srOptions) {
326
+ rows.push([{
327
+ text: `🌐 ${srFriendlyLabel(o.label)}`,
328
+ callback_data: `${MODEL_CALLBACK_SR}${o.label}`,
329
+ }])
330
+ }
331
+ }
332
+
261
333
  rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
262
334
  return rows
263
335
  }
@@ -292,7 +364,8 @@ export async function buildModelMenu(
292
364
  // or a prior session switch). Labelling the ✔ row "Now:" was misleading —
293
365
  // it could read "Opus 4.8" while the live session is on Fable. Call it what
294
366
  // it is, and tell the operator a switch applies to the live session.
295
- const current = discovered.options.find((o) => o.current)
367
+ const { claude: claudeOptions, sr: srOptions } = classifyDiscoveredOptions(discovered.options)
368
+ const current = claudeOptions.find((o) => o.current)
296
369
  const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
297
370
  if (discovered.dismissFailed) {
298
371
  lines.push('⚠️ <i>The picker may still be open on the agent pane — check it before switching.</i>')
@@ -305,9 +378,12 @@ export async function buildModelMenu(
305
378
  }
306
379
  if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
307
380
  lines.push('', 'Tap a model to switch the <b>live session</b>:')
381
+ if (srOptions.length > 0) {
382
+ lines.push('Claude models use your Max/Pro subscription. 🌐 models are billed separately via OpenRouter.')
383
+ }
308
384
  lines.push(PERSIST_NOTE)
309
385
 
310
- return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(discovered.options) }
386
+ return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(claudeOptions, srOptions) }
311
387
  }
312
388
 
313
389
  export interface ModelCallbackOutcome {
@@ -346,6 +422,61 @@ export async function handleModelMenuCallback(
346
422
  if (data === MODEL_CALLBACK_REFRESH) {
347
423
  return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
348
424
  }
425
+
426
+ if (data === MODEL_CALLBACK_HEADER) {
427
+ // Section-header row — the gateway handles this with a direct answerCallbackQuery
428
+ // before calling this function, so this branch is dead in practice. Guard
429
+ // for callers that skip gateway.ts (tests, future refactors).
430
+ return { answer: 'Tap a model in this section to switch', reply: { text: '', html: true }, toastOnly: true }
431
+ }
432
+
433
+ // sr-* model tap: text-inject `/model sr-<name>` rather than cursor-nav.
434
+ // Text-inject is more reliable when the picker has many models; sr-* names
435
+ // are safe (no entry in model_group_settings → no OAuth forwarding). See I6.
436
+ if (data.startsWith(MODEL_CALLBACK_SR)) {
437
+ const srName = data.slice(MODEL_CALLBACK_SR.length)
438
+ if (!isValidModelArg(srName)) {
439
+ return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
440
+ }
441
+ if (deps.isBusy()) {
442
+ return {
443
+ answer: '⏳ Agent is mid-turn — tap again when it’s idle',
444
+ reply: busyReply(deps),
445
+ toastOnly: true,
446
+ }
447
+ }
448
+ let srResult: InjectResult
449
+ try {
450
+ srResult = await deps.inject(deps.getAgentName(), `/model ${srName}`)
451
+ } catch (err) {
452
+ const msg = err instanceof Error ? err.message : String(err)
453
+ return {
454
+ answer: 'Switch failed',
455
+ reply: await menuWithBanner(deps, `❌ Switch to <b>${deps.escapeHtml(srName)}</b> failed: ${deps.escapeHtml(msg)}`),
456
+ }
457
+ }
458
+ if (srResult.outcome === 'ok') {
459
+ const friendlyName = srFriendlyLabel(srName)
460
+ const confirmation =
461
+ srResult.output
462
+ .split('\n')
463
+ .map((l) => l.trim())
464
+ .find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`
465
+ return {
466
+ answer: confirmation,
467
+ reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(confirmation)}`),
468
+ selectedModel: srName,
469
+ }
470
+ }
471
+ return {
472
+ answer: 'Switch failed',
473
+ reply: await menuWithBanner(
474
+ deps,
475
+ `❌ Switch to <b>${deps.escapeHtml(srFriendlyLabel(srName))}</b> failed — agent may be mid-turn`,
476
+ ),
477
+ }
478
+ }
479
+
349
480
  if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
350
481
  return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
351
482
  }
@@ -253,7 +253,11 @@ import {
253
253
  handleModelMenuCallback,
254
254
  modelSelectCallbackData,
255
255
  sessionModelFromConfirmation,
256
+ classifyDiscoveredOptions,
256
257
  MODEL_CALLBACK_REFRESH,
258
+ MODEL_CALLBACK_HEADER,
259
+ MODEL_CALLBACK_SR,
260
+ SR_MODEL_LABELS,
257
261
  type ModelMenuDeps,
258
262
  } from "../gateway/model-command.js";
259
263
  import { labelTag } from "../../src/agents/model-picker.js";
@@ -422,3 +426,161 @@ describe("sessionModelFromConfirmation", () => {
422
426
  expect(out.reply.keyboard).toBeDefined();
423
427
  });
424
428
  });
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Ship D — sr-* (LiteLLM non-Anthropic) model support
432
+ // ---------------------------------------------------------------------------
433
+
434
+ const OPTIONS_WITH_SR = [
435
+ { index: 1, label: "Default (recommended)", detail: "Opus 4.8 with 1M context", current: false },
436
+ { index: 2, label: "Sonnet", detail: "Sonnet 4.6", current: true },
437
+ { index: 3, label: "sr-gemini-2.5-pro", detail: "", current: false },
438
+ { index: 4, label: "sr-deepseek-r1", detail: "", current: false },
439
+ // internal path — should be filtered out
440
+ { index: 5, label: "openrouter/google/gemini-2.5-pro", detail: "", current: false },
441
+ // bare OpenAI models from GATEWAY_MODEL_DISCOVERY — should also be filtered out
442
+ { index: 6, label: "gpt-4", detail: "", current: false },
443
+ { index: 7, label: "gpt-4o", detail: "", current: false },
444
+ { index: 8, label: "voyage-law-2", detail: "", current: false },
445
+ // full claude ID — should be in claude bucket
446
+ { index: 9, label: "claude-opus-4-8", detail: "", current: false },
447
+ ];
448
+
449
+ describe("classifyDiscoveredOptions", () => {
450
+ it("puts native Claude options in claude, sr-* in sr, drops others", () => {
451
+ const { claude, sr } = classifyDiscoveredOptions(OPTIONS_WITH_SR);
452
+ expect(claude.map((o) => o.label)).toEqual([
453
+ "Default (recommended)", "Sonnet", "claude-opus-4-8",
454
+ ]);
455
+ expect(sr.map((o) => o.label)).toEqual(["sr-gemini-2.5-pro", "sr-deepseek-r1"]);
456
+ // openrouter/*, gpt-*, voyage-* not present in either bucket
457
+ const all = [...claude, ...sr];
458
+ expect(all.find((o) => o.label.includes("openrouter"))).toBeUndefined();
459
+ expect(all.find((o) => o.label.startsWith("gpt-"))).toBeUndefined();
460
+ expect(all.find((o) => o.label.startsWith("voyage-"))).toBeUndefined();
461
+ });
462
+
463
+ it("handles a list with no sr-* models", () => {
464
+ const { claude, sr } = classifyDiscoveredOptions(OPTIONS);
465
+ expect(claude).toHaveLength(3);
466
+ expect(sr).toHaveLength(0);
467
+ });
468
+ });
469
+
470
+ describe("SR_MODEL_LABELS", () => {
471
+ it("has friendly names for the standard sr-* models", () => {
472
+ expect(SR_MODEL_LABELS["sr-gemini-2.5-pro"]).toBe("Gemini 2.5 Pro");
473
+ expect(SR_MODEL_LABELS["sr-deepseek-r1"]).toBe("DeepSeek R1");
474
+ });
475
+ });
476
+
477
+ describe("buildModelMenu — with sr-* models", () => {
478
+ function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
479
+ return makeMenuDeps({
480
+ discover: async () => ({
481
+ ok: true as const,
482
+ options: OPTIONS_WITH_SR,
483
+ currentLabel: "Sonnet",
484
+ }),
485
+ ...overrides,
486
+ });
487
+ }
488
+
489
+ it("shows 🌐 buttons for sr-* models, normal buttons for claude models", async () => {
490
+ const { deps } = makeMenuDepsWithSr();
491
+ const menu = await buildModelMenu(deps);
492
+ expect(menu.keyboard).toBeDefined();
493
+ const allButtons = menu.keyboard!.flat();
494
+ // 🌐 buttons for sr-*
495
+ expect(allButtons.find((b) => b.text === "🌐 Gemini 2.5 Pro")).toBeDefined();
496
+ expect(allButtons.find((b) => b.text === "🌐 DeepSeek R1")).toBeDefined();
497
+ // Regular buttons for Claude models
498
+ expect(allButtons.find((b) => b.text === "Default (recommended)")).toBeDefined();
499
+ // openrouter/* not shown at all
500
+ expect(allButtons.find((b) => b.text.includes("openrouter"))).toBeUndefined();
501
+ });
502
+
503
+ it("sr-* buttons use mdl:sr: callback prefix", async () => {
504
+ const { deps } = makeMenuDepsWithSr();
505
+ const menu = await buildModelMenu(deps);
506
+ const srButton = menu.keyboard!.flat().find((b) => b.text === "🌐 Gemini 2.5 Pro");
507
+ expect(srButton?.callback_data).toBe(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`);
508
+ });
509
+
510
+ it("shows section header rows when both claude and sr-* models present", async () => {
511
+ const { deps } = makeMenuDepsWithSr();
512
+ const menu = await buildModelMenu(deps);
513
+ const allButtons = menu.keyboard!.flat();
514
+ const headers = allButtons.filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
515
+ expect(headers.length).toBe(2);
516
+ expect(headers[0].text).toContain("Claude");
517
+ expect(headers[1].text).toContain("OpenRouter");
518
+ });
519
+
520
+ it("no section headers when only claude models (no sr-*)", async () => {
521
+ const { deps } = makeMenuDeps();
522
+ const menu = await buildModelMenu(deps);
523
+ const allButtons = (menu.keyboard ?? []).flat();
524
+ const headers = allButtons.filter((b) => b.callback_data === MODEL_CALLBACK_HEADER);
525
+ expect(headers.length).toBe(0);
526
+ });
527
+
528
+ it("header-row tap returns toastOnly without inject or model change", async () => {
529
+ const { deps, injectCalls } = makeMenuDepsWithSr();
530
+ const out = await handleModelMenuCallback(MODEL_CALLBACK_HEADER, deps);
531
+ expect(out.toastOnly).toBe(true);
532
+ expect(out.selectedModel).toBeUndefined();
533
+ expect(injectCalls).toHaveLength(0);
534
+ });
535
+
536
+ it("shows subscription/OpenRouter legend when sr-* models are present", async () => {
537
+ const { deps } = makeMenuDepsWithSr();
538
+ const menu = await buildModelMenu(deps);
539
+ expect(menu.text).toContain("Max/Pro subscription");
540
+ expect(menu.text).toContain("OpenRouter");
541
+ });
542
+
543
+ it("no legend when no sr-* models in picker", async () => {
544
+ const { deps } = makeMenuDeps();
545
+ const menu = await buildModelMenu(deps);
546
+ expect(menu.text).not.toContain("OpenRouter");
547
+ });
548
+ });
549
+
550
+ describe("handleModelMenuCallback — sr-* selection", () => {
551
+ function makeMenuDepsWithSr(overrides: Partial<ModelMenuDeps> = {}) {
552
+ return makeMenuDeps({
553
+ discover: async () => ({
554
+ ok: true as const,
555
+ options: OPTIONS_WITH_SR,
556
+ currentLabel: "Sonnet",
557
+ }),
558
+ ...overrides,
559
+ });
560
+ }
561
+
562
+ it("sr-* tap uses inject path, not cursor nav", async () => {
563
+ const { deps, calls, injectCalls } = makeMenuDepsWithSr();
564
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
565
+ // inject was called with the raw /model command
566
+ expect(injectCalls).toContainEqual({ agent: "klanker", command: "/model sr-gemini-2.5-pro" });
567
+ // select (cursor nav) was NOT called
568
+ expect(calls.select).toHaveLength(0);
569
+ expect(out.answer).toContain("Set model to sonnet");
570
+ expect(out.selectedModel).toBe("sr-gemini-2.5-pro");
571
+ expect(out.reply.keyboard).toBeDefined();
572
+ });
573
+
574
+ it("sr-* tap while busy returns toast-only with no inject", async () => {
575
+ const { deps, injectCalls } = makeMenuDepsWithSr({ isBusy: () => true });
576
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}sr-gemini-2.5-pro`, deps);
577
+ expect(out.toastOnly).toBe(true);
578
+ expect(injectCalls).toHaveLength(0);
579
+ });
580
+
581
+ it("rejects malformed sr-* callback data", async () => {
582
+ const { deps } = makeMenuDepsWithSr();
583
+ const out = await handleModelMenuCallback(`${MODEL_CALLBACK_SR}bad name with spaces`, deps);
584
+ expect(out.answer).toBe("Invalid model name");
585
+ });
586
+ });