switchroom 0.16.4 → 0.16.5

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