arisa 4.3.0 → 4.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "4.3.0",
3
+ "version": "4.3.3",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3,10 +3,12 @@ import { readFile, stat, unlink } from "node:fs/promises";
3
3
  import { createAgentSession, DefaultResourceLoader, SessionManager, defineTool } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
+ import { resolveChatModelSelection } from "./model-selection.js";
6
7
  import { appendArisaAgentsFile, arisaAgentsFile, arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
7
8
  import { withTimeout } from "./prompt-timeout.js";
8
9
  import { buildPiToolPolicy, getCoreCodingTools } from "./core-tools.js";
9
10
  import { createSystemShellTool } from "./system-shell-tool.js";
11
+ import { clampModelThinkingLevel } from "./pi-runtime.js";
10
12
  import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
11
13
 
12
14
  const piValidationTimeoutMs = 60_000;
@@ -126,9 +128,9 @@ export class AgentManager {
126
128
  this.sessions.delete(String(chatId));
127
129
  }
128
130
 
129
- createSessionManager(chatId, workspaceDir = arisaInstallDir) {
131
+ createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
130
132
  const sessionKey = String(chatId);
131
- const sessionDir = getChatPiSessionsDir(sessionKey);
133
+ const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
132
134
  if (this.pendingNewSessions.has(sessionKey)) {
133
135
  this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
134
136
  return { sessionManager: SessionManager.create(workspaceDir, sessionDir), isNewSession: true };
@@ -165,14 +167,21 @@ export class AgentManager {
165
167
 
166
168
  async getSessionContext(chatId, telegram) {
167
169
  const sessionKey = String(chatId);
168
- const effectiveModelId = this.config.pi.model;
170
+ const modelSelection = resolveChatModelSelection(this.config, sessionKey);
171
+ const effectiveModelId = modelSelection.model;
172
+ const effectiveModelKey = `${modelSelection.provider}/${effectiveModelId}@${modelSelection.sessionRevision}`;
169
173
  if (this.sessions.has(sessionKey)) {
170
174
  const existing = this.sessions.get(sessionKey);
171
- if (existing?.modelId === effectiveModelId) {
175
+ if (existing?.modelKey === effectiveModelKey) {
176
+ const desiredThinkingLevel = clampModelThinkingLevel(existing.session.model, modelSelection.thinkingLevel);
177
+ if (existing.session.thinkingLevel !== desiredThinkingLevel) {
178
+ this.logger?.log("agent", `updating effort for chat ${sessionKey}: ${existing.session.thinkingLevel} -> ${desiredThinkingLevel}`);
179
+ existing.session.setThinkingLevel(desiredThinkingLevel);
180
+ }
172
181
  this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
173
182
  return existing;
174
183
  }
175
- this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelId || "unknown"} -> ${effectiveModelId}; recreating session`);
184
+ this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelKey || "unknown"} -> ${effectiveModelKey}; recreating session`);
176
185
  this.sessions.delete(sessionKey);
177
186
  this.pendingNewSessions.add(sessionKey);
178
187
  }
@@ -186,15 +195,20 @@ export class AgentManager {
186
195
  if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
187
196
  throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
188
197
  }
198
+ const thinkingLevel = clampModelThinkingLevel(model, modelSelection.thinkingLevel);
189
199
 
190
200
  const policy = buildPiToolPolicy({
191
201
  config: this.config,
192
202
  customToolNames: [...arisaToolNames, "system_shell"]
193
203
  });
194
204
  await assertDirectory(policy.workspaceDir, "pi.workspaceDir");
195
- const { sessionManager, isNewSession } = this.createSessionManager(sessionKey, policy.workspaceDir);
205
+ const { sessionManager, isNewSession } = this.createSessionManager(
206
+ sessionKey,
207
+ policy.workspaceDir,
208
+ modelSelection.sessionRevision
209
+ );
196
210
  const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
197
- this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId}`);
211
+ this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId} effort ${thinkingLevel}`);
198
212
  const customTools = [
199
213
  ...this.createTools(telegram, chatId, policy),
200
214
  createSystemShellTool({ workspaceDir: policy.workspaceDir, shell: policy.shell })
@@ -210,6 +224,7 @@ export class AgentManager {
210
224
  authStorage,
211
225
  modelRegistry,
212
226
  model,
227
+ thinkingLevel,
213
228
  tools: policy.tools,
214
229
  excludeTools: policy.excludeTools,
215
230
  customTools,
@@ -224,7 +239,7 @@ export class AgentManager {
224
239
  })}`);
225
240
  }
226
241
 
227
- const ctx = { session, modelId: effectiveModelId };
242
+ const ctx = { session, modelId: effectiveModelId, modelKey: effectiveModelKey };
228
243
  this.sessions.set(sessionKey, ctx);
229
244
  if (isNewSession) {
230
245
  this.pendingNewSessions.delete(sessionKey);
@@ -9,6 +9,7 @@ const authInvalidatedPatterns = [
9
9
 
10
10
  const missingAuthPatterns = [
11
11
  /no auth found/i,
12
+ /no api key(?: found)? for/i,
12
13
  /auth(?:entication)? .*missing/i
13
14
  ];
14
15
 
@@ -0,0 +1,65 @@
1
+ function chatKey(chatId) {
2
+ return String(chatId);
3
+ }
4
+
5
+ function normalizeSessionRevision(sessionRevision) {
6
+ if (sessionRevision == null) return 0;
7
+ if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
8
+ throw new Error("Invalid model session revision");
9
+ }
10
+ return sessionRevision;
11
+ }
12
+
13
+ export function resolveChatModelSelection(config, chatId) {
14
+ const selection = config.pi.chatModels?.[chatKey(chatId)];
15
+ if (!selection || selection.provider !== config.pi.provider) {
16
+ return {
17
+ provider: config.pi.provider,
18
+ model: config.pi.model,
19
+ thinkingLevel: config.pi.thinkingLevel,
20
+ sessionRevision: 0
21
+ };
22
+ }
23
+ const sessionRevision = normalizeSessionRevision(selection.sessionRevision);
24
+ return {
25
+ provider: selection.provider,
26
+ model: selection.model,
27
+ thinkingLevel: selection.thinkingLevel ?? config.pi.thinkingLevel,
28
+ sessionRevision
29
+ };
30
+ }
31
+
32
+ export function resolveChatModel(config, chatId) {
33
+ return resolveChatModelSelection(config, chatId).model;
34
+ }
35
+
36
+ export function resolveChatThinkingLevel(config, chatId) {
37
+ return resolveChatModelSelection(config, chatId).thinkingLevel;
38
+ }
39
+
40
+ export function selectChatModel(config, chatId, model, { thinkingLevel } = {}) {
41
+ if (model.provider !== config.pi.provider) {
42
+ throw new Error(`Cannot select model from provider ${model.provider}; active provider is ${config.pi.provider}`);
43
+ }
44
+ config.pi.chatModels ||= {};
45
+ const key = chatKey(chatId);
46
+ const sessionRevision = (config.pi.chatModels[key]?.sessionRevision || 0) + 1;
47
+ config.pi.chatModels[key] = {
48
+ provider: model.provider,
49
+ model: model.id,
50
+ thinkingLevel,
51
+ sessionRevision
52
+ };
53
+ }
54
+
55
+ export function selectChatThinkingLevel(config, chatId, thinkingLevel) {
56
+ config.pi.chatModels ||= {};
57
+ const key = chatKey(chatId);
58
+ const current = resolveChatModelSelection(config, chatId);
59
+ config.pi.chatModels[key] = {
60
+ provider: current.provider,
61
+ model: current.model,
62
+ thinkingLevel,
63
+ sessionRevision: current.sessionRevision
64
+ };
65
+ }
@@ -53,6 +53,55 @@ export function listProviderModels(provider, runtime = createPiRuntime()) {
53
53
  .sort((a, b) => compareText(a.name || a.id, b.name || b.id));
54
54
  }
55
55
 
56
+ export function formatPiModelOption(model) {
57
+ const capabilities = [
58
+ model.reasoning ? "reasoning" : null,
59
+ model.input?.includes("image") ? "image" : null
60
+ ].filter(Boolean).join(", ");
61
+ return capabilities ? `${model.id} [${capabilities}]` : model.id;
62
+ }
63
+
64
+ /** Mirrors @earendil-works/pi-ai getSupportedThinkingLevels for the active model. */
65
+ const EXTENDED_THINKING_LEVELS = Object.freeze([
66
+ "off",
67
+ "minimal",
68
+ "low",
69
+ "medium",
70
+ "high",
71
+ "xhigh",
72
+ "max"
73
+ ]);
74
+
75
+ export function listModelThinkingLevels(model) {
76
+ if (!model?.reasoning) return ["off"];
77
+ return EXTENDED_THINKING_LEVELS.filter((level) => {
78
+ const mapped = model.thinkingLevelMap?.[level];
79
+ if (mapped === null) return false;
80
+ if (level === "xhigh" || level === "max") return mapped !== undefined;
81
+ return true;
82
+ });
83
+ }
84
+
85
+ export function clampModelThinkingLevel(model, level) {
86
+ const availableLevels = listModelThinkingLevels(model);
87
+ if (availableLevels.includes(level)) return level;
88
+ const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);
89
+ if (requestedIndex === -1) return availableLevels[0] ?? "off";
90
+ for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {
91
+ const candidate = EXTENDED_THINKING_LEVELS[i];
92
+ if (availableLevels.includes(candidate)) return candidate;
93
+ }
94
+ for (let i = requestedIndex - 1; i >= 0; i--) {
95
+ const candidate = EXTENDED_THINKING_LEVELS[i];
96
+ if (availableLevels.includes(candidate)) return candidate;
97
+ }
98
+ return availableLevels[0] ?? "off";
99
+ }
100
+
101
+ export function modelSupportsThinking(model) {
102
+ return listModelThinkingLevels(model).some((level) => level !== "off");
103
+ }
104
+
56
105
  export function findPiModel({ provider, model, apiKey } = {}) {
57
106
  const runtime = createPiRuntime({ provider, apiKey });
58
107
  return {
@@ -14,9 +14,25 @@ export const daemonConfigDefaults = Object.freeze({
14
14
  queuePollIntervalMs: 250
15
15
  });
16
16
 
17
+ export const telegramConfigDefaults = Object.freeze({
18
+ modelPickerPageSize: 8
19
+ });
20
+
21
+ export const piConfigDefaults = Object.freeze({
22
+ thinkingLevel: "medium"
23
+ });
24
+
17
25
  export function applyConfigDefaults(config) {
18
26
  return {
19
27
  ...config,
28
+ telegram: {
29
+ ...telegramConfigDefaults,
30
+ ...(config.telegram || {})
31
+ },
32
+ pi: {
33
+ ...piConfigDefaults,
34
+ ...(config.pi || {})
35
+ },
20
36
  daemons: {
21
37
  ...daemonConfigDefaults,
22
38
  ...(config.daemons || {})
@@ -337,11 +337,16 @@ async function listChatDaemonRecords() {
337
337
  const records = [];
338
338
  for (const chatEntry of chatEntries) {
339
339
  if (!chatEntry.isDirectory()) continue;
340
+ let scope;
341
+ try {
342
+ scope = normalizeDaemonScope({ type: "chat", chatId: chatEntry.name });
343
+ } catch {
344
+ continue;
345
+ }
340
346
  const toolsRoot = path.join(chatsDir, chatEntry.name, "state", "tools");
341
347
  const toolEntries = await readdir(toolsRoot, { withFileTypes: true }).catch(() => []);
342
348
  for (const toolEntry of toolEntries) {
343
349
  if (!toolEntry.isDirectory()) continue;
344
- const scope = { type: "chat", chatId: chatEntry.name };
345
350
  const meta = await readJson(daemonPaths({ toolName: toolEntry.name, scope }).metaFile, null);
346
351
  if (meta?.toolName && meta?.entryPath) records.push(meta);
347
352
  }
@@ -5,9 +5,10 @@ import { stdin as input, stdout as output } from "node:process";
5
5
  import { spawn } from "node:child_process";
6
6
  import { Bot } from "grammy";
7
7
  import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
8
- import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
9
- import { applyConfigDefaults } from "../core/config/config-defaults.js";
8
+ import { createPiRuntime, formatPiModelOption, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
9
+ import { applyConfigDefaults, telegramConfigDefaults } from "../core/config/config-defaults.js";
10
10
  import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
11
+ import { buildPagedInlineKeyboard } from "../transport/telegram/paged-inline-keyboard.js";
11
12
  import { configFile, ensureArisaHome } from "./paths.js";
12
13
 
13
14
  const ARISA_BANNER = [
@@ -100,30 +101,6 @@ function parseYesNo(value, fallback = true) {
100
101
  return null;
101
102
  }
102
103
 
103
- function buildPagedInlineKeyboard(action, items, { page = 0, pageSize = 8 } = {}) {
104
- const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
105
- const currentPage = Math.max(0, Math.min(pageCount - 1, page));
106
- const startIndex = currentPage * pageSize;
107
- const rows = items.slice(startIndex, startIndex + pageSize).map((item, index) => ([{
108
- text: item.text,
109
- callback_data: `${action}:${startIndex + index}`
110
- }]));
111
-
112
- if (pageCount > 1) {
113
- const navigation = [];
114
- if (currentPage > 0) {
115
- navigation.push({ text: "Previous", callback_data: `${action}-page:${currentPage - 1}` });
116
- }
117
- navigation.push({ text: `${currentPage + 1}/${pageCount}`, callback_data: "noop:page" });
118
- if (currentPage < pageCount - 1) {
119
- navigation.push({ text: "Next", callback_data: `${action}-page:${currentPage + 1}` });
120
- }
121
- rows.push(navigation);
122
- }
123
-
124
- return { inline_keyboard: rows };
125
- }
126
-
127
104
  function getIncomingChatMeta(ctx) {
128
105
  return {
129
106
  languageCode: ctx.from?.language_code || "",
@@ -138,11 +115,6 @@ function formatProviderOption(item) {
138
115
  return `${item.provider} (${item.modelCount} models, ${authLabel})`;
139
116
  }
140
117
 
141
- function formatModelOption(model) {
142
- const capabilities = [model.reasoning ? "reasoning" : null, model.input?.includes("image") ? "image" : null].filter(Boolean).join(", ");
143
- return capabilities ? `${model.id} [${capabilities}]` : model.id;
144
- }
145
-
146
118
  function selectPiLoginOption(options = []) {
147
119
  return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
148
120
  || options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
@@ -215,7 +187,7 @@ async function collectCliBootstrapChoices({ telegramApiKey, rl, ask }) {
215
187
  const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, runtime));
216
188
  console.log(`\nAvailable models for ${selectedProvider.provider}:`);
217
189
  models.forEach((model, index) => {
218
- console.log(`${index + 1}. ${formatModelOption(model)}`);
190
+ console.log(`${index + 1}. ${formatPiModelOption(model)}`);
219
191
  });
220
192
 
221
193
  const selectedModel = selectByIndex(models, await ask("Select Pi model by number", "1"));
@@ -326,14 +298,20 @@ async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo }) {
326
298
  const askProvider = async (ctx = null, page = 0) => {
327
299
  state = "provider";
328
300
  await showSetupPrompt(ctx, "Select the Pi provider Arisa should use:", {
329
- reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), { page })
301
+ reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), {
302
+ page,
303
+ pageSize: telegramConfigDefaults.modelPickerPageSize
304
+ })
330
305
  });
331
306
  };
332
307
 
333
308
  const askModel = async (ctx = null, page = 0) => {
334
309
  state = "model";
335
310
  const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
336
- const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatModelOption(model) })), { page });
311
+ const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatPiModelOption(model) })), {
312
+ page,
313
+ pageSize: telegramConfigDefaults.modelPickerPageSize
314
+ });
337
315
  keyboard.inline_keyboard.push([{ text: "Back to providers", callback_data: "back:provider" }]);
338
316
  await showSetupPrompt(ctx, `Select the model for ${selectedProvider.provider}:`, {
339
317
  reply_markup: keyboard
@@ -72,8 +72,14 @@ export function getDaemonInstanceDir(toolName, scope = { type: "global" }) {
72
72
  : path.join(getChatToolStateDir(normalized.chatId, toolName), "daemon");
73
73
  }
74
74
 
75
- export function getChatPiSessionsDir(chatId) {
76
- return path.join(getChatDir(chatId), "state", "pi-sessions");
75
+ export function getChatPiSessionsDir(chatId, sessionRevision = 0) {
76
+ if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
77
+ throw new Error(`Invalid Pi session revision: ${sessionRevision}`);
78
+ }
79
+ const sessionsDir = path.join(getChatDir(chatId), "state", "pi-sessions");
80
+ return sessionRevision === 0
81
+ ? sessionsDir
82
+ : path.join(sessionsDir, String(sessionRevision));
77
83
  }
78
84
 
79
85
  export function getToolDir(toolName) {
@@ -3,9 +3,12 @@ import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
4
  import { captureIncomingArtifact, formatLocationText } from "./media.js";
5
5
  import { buildDeviceCodeTelegramMessage } from "./device-code-message.js";
6
+ import { buildEffortPicker, buildModelPicker, parseEffortPickerAction, parseModelPickerAction } from "./model-picker.js";
6
7
  import { renderTelegramHtml } from "./text-format.js";
7
8
  import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
8
9
  import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
10
+ import { resolveChatModel, resolveChatThinkingLevel, selectChatModel, selectChatThinkingLevel } from "../../core/agent/model-selection.js";
11
+ import { clampModelThinkingLevel, createPiRuntime, listModelThinkingLevels, listProviderModels, modelSupportsThinking } from "../../core/agent/pi-runtime.js";
9
12
  import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
10
13
 
11
14
  const slowPromptNoticeMs = 300_000;
@@ -410,6 +413,102 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
410
413
  return perChatState.get(chatId);
411
414
  }
412
415
 
416
+ function getProviderModels() {
417
+ const runtime = createPiRuntime({
418
+ provider: config.pi.provider,
419
+ apiKey: config.pi.apiKey
420
+ });
421
+ return listProviderModels(config.pi.provider, runtime);
422
+ }
423
+
424
+ async function showModelPicker(ctx, page = 0) {
425
+ const picker = buildModelPicker({
426
+ provider: config.pi.provider,
427
+ models: getProviderModels(),
428
+ selectedModelId: resolveChatModel(config, ctx.chat.id),
429
+ selectedThinkingLevel: resolveChatThinkingLevel(config, ctx.chat.id),
430
+ page,
431
+ pageSize: config.telegram.modelPickerPageSize
432
+ });
433
+ const extra = { reply_markup: picker.replyMarkup };
434
+ const messageId = ctx.callbackQuery?.message?.message_id;
435
+ if (messageId) {
436
+ return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
437
+ }
438
+ return ctx.reply(picker.text, extra);
439
+ }
440
+
441
+ async function showEffortPicker(ctx, { model, modelIndex, selectedThinkingLevel } = {}) {
442
+ const models = getProviderModels();
443
+ const resolvedModel = model || models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
444
+ if (!resolvedModel) {
445
+ throw new Error(`Model not found for provider ${config.pi.provider}`);
446
+ }
447
+ if (!modelSupportsThinking(resolvedModel)) {
448
+ const text = `${resolvedModel.provider}/${resolvedModel.id} does not support effort levels.`;
449
+ if (ctx.callbackQuery?.message?.message_id) {
450
+ return ctx.api.editMessageText(ctx.chat.id, ctx.callbackQuery.message.message_id, text);
451
+ }
452
+ return ctx.reply(text);
453
+ }
454
+ const levels = listModelThinkingLevels(resolvedModel);
455
+ const picker = buildEffortPicker({
456
+ provider: resolvedModel.provider,
457
+ modelId: resolvedModel.id,
458
+ levels,
459
+ selectedThinkingLevel: selectedThinkingLevel
460
+ ?? clampModelThinkingLevel(resolvedModel, resolveChatThinkingLevel(config, ctx.chat.id)),
461
+ modelIndex
462
+ });
463
+ const extra = { reply_markup: picker.replyMarkup };
464
+ const messageId = ctx.callbackQuery?.message?.message_id;
465
+ if (messageId) {
466
+ return ctx.api.editMessageText(ctx.chat.id, messageId, picker.text, extra);
467
+ }
468
+ return ctx.reply(picker.text, extra);
469
+ }
470
+
471
+ async function persistChatModel(chatId, model, thinkingLevel) {
472
+ const key = chatKey(chatId);
473
+ const hadSelections = Boolean(config.pi.chatModels);
474
+ const previousSelection = config.pi.chatModels?.[key];
475
+ const level = clampModelThinkingLevel(model, thinkingLevel ?? resolveChatThinkingLevel(config, chatId));
476
+ selectChatModel(config, chatId, model, { thinkingLevel: level });
477
+ try {
478
+ await saveConfig(config);
479
+ } catch (error) {
480
+ if (previousSelection) {
481
+ config.pi.chatModels[key] = previousSelection;
482
+ } else {
483
+ delete config.pi.chatModels[key];
484
+ if (!hadSelections) delete config.pi.chatModels;
485
+ }
486
+ throw error;
487
+ }
488
+ agentManager.resetSession(chatId);
489
+ return level;
490
+ }
491
+
492
+ async function persistChatEffort(chatId, model, thinkingLevel) {
493
+ const key = chatKey(chatId);
494
+ const hadSelections = Boolean(config.pi.chatModels);
495
+ const previousSelection = config.pi.chatModels?.[key];
496
+ const level = clampModelThinkingLevel(model, thinkingLevel);
497
+ selectChatThinkingLevel(config, chatId, level);
498
+ try {
499
+ await saveConfig(config);
500
+ } catch (error) {
501
+ if (previousSelection) {
502
+ config.pi.chatModels[key] = previousSelection;
503
+ } else {
504
+ delete config.pi.chatModels[key];
505
+ if (!hadSelections) delete config.pi.chatModels;
506
+ }
507
+ throw error;
508
+ }
509
+ return level;
510
+ }
511
+
413
512
  async function buildIncomingPrompt(ctx) {
414
513
  const chatId = ctx.chat.id;
415
514
  logger?.log("telegram", `message ${ctx.msg.message_id} in chat ${chatId}`);
@@ -684,6 +783,18 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
684
783
  await handleNewCommand(ctx);
685
784
  });
686
785
 
786
+ bot.command("model", async (ctx) => {
787
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
788
+ if (!auth.ok) return;
789
+ await showModelPicker(ctx);
790
+ });
791
+
792
+ bot.command("effort", async (ctx) => {
793
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
794
+ if (!auth.ok) return;
795
+ await showEffortPicker(ctx);
796
+ });
797
+
687
798
  bot.command("auth", async (ctx) => {
688
799
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
689
800
  if (!auth.ok) return;
@@ -717,6 +828,170 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
717
828
  }
718
829
  });
719
830
 
831
+ bot.on("callback_query:data", async (ctx, next) => {
832
+ const modelAction = parseModelPickerAction(ctx.callbackQuery.data);
833
+ const effortAction = modelAction ? null : parseEffortPickerAction(ctx.callbackQuery.data);
834
+ const action = modelAction || effortAction;
835
+ if (!action) return next();
836
+ if (action.type === "noop") {
837
+ await ctx.answerCallbackQuery();
838
+ return;
839
+ }
840
+
841
+ const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig });
842
+ if (!auth.ok) {
843
+ await ctx.answerCallbackQuery({ text: "This chat is not authorized.", show_alert: true });
844
+ return;
845
+ }
846
+
847
+ try {
848
+ if (action.type === "page") {
849
+ await showModelPicker(ctx, action.value);
850
+ await ctx.answerCallbackQuery();
851
+ return;
852
+ }
853
+
854
+ if (getChatState(ctx.chat.id).processing) {
855
+ await ctx.answerCallbackQuery({
856
+ text: action.type === "effort" || action.type === "model-effort"
857
+ ? "Wait for the current response before changing effort."
858
+ : "Wait for the current response before changing models.",
859
+ show_alert: true
860
+ });
861
+ return;
862
+ }
863
+
864
+ const models = getProviderModels();
865
+
866
+ if (action.type === "select") {
867
+ const model = models[action.value];
868
+ if (!model) {
869
+ await ctx.answerCallbackQuery({
870
+ text: "This model list is no longer current. Run /model again.",
871
+ show_alert: true
872
+ });
873
+ return;
874
+ }
875
+
876
+ if (modelSupportsThinking(model)) {
877
+ await showEffortPicker(ctx, {
878
+ model,
879
+ modelIndex: action.value,
880
+ selectedThinkingLevel: clampModelThinkingLevel(model, resolveChatThinkingLevel(config, ctx.chat.id))
881
+ });
882
+ await ctx.answerCallbackQuery({ text: `Choose effort for ${model.id}.` });
883
+ return;
884
+ }
885
+
886
+ const currentModelId = resolveChatModel(config, ctx.chat.id);
887
+ const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
888
+ if (model.id === currentModelId && currentEffort === "off") {
889
+ await ctx.answerCallbackQuery({ text: `Already using ${model.id}.` });
890
+ return;
891
+ }
892
+
893
+ await persistChatModel(ctx.chat.id, model, "off");
894
+ await ctx.api.editMessageText(
895
+ ctx.chat.id,
896
+ ctx.callbackQuery.message.message_id,
897
+ `Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
898
+ );
899
+ await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
900
+ return;
901
+ }
902
+
903
+ if (action.type === "model-effort") {
904
+ const model = models[action.modelIndex];
905
+ if (!model) {
906
+ await ctx.answerCallbackQuery({
907
+ text: "This model list is no longer current. Run /model again.",
908
+ show_alert: true
909
+ });
910
+ return;
911
+ }
912
+ const levels = listModelThinkingLevels(model);
913
+ if (!levels.includes(action.level)) {
914
+ await ctx.answerCallbackQuery({
915
+ text: "That effort level is not available for this model.",
916
+ show_alert: true
917
+ });
918
+ return;
919
+ }
920
+
921
+ const currentModelId = resolveChatModel(config, ctx.chat.id);
922
+ const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
923
+ if (model.id === currentModelId && action.level === currentEffort) {
924
+ await ctx.answerCallbackQuery({ text: `Already using ${model.id} at ${action.level}.` });
925
+ return;
926
+ }
927
+
928
+ if (model.id === currentModelId) {
929
+ await persistChatEffort(ctx.chat.id, model, action.level);
930
+ await ctx.api.editMessageText(
931
+ ctx.chat.id,
932
+ ctx.callbackQuery.message.message_id,
933
+ `Effort set to ${action.level} for ${model.provider}/${model.id}.`
934
+ );
935
+ await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
936
+ return;
937
+ }
938
+
939
+ await persistChatModel(ctx.chat.id, model, action.level);
940
+ await ctx.api.editMessageText(
941
+ ctx.chat.id,
942
+ ctx.callbackQuery.message.message_id,
943
+ `Model changed to ${model.provider}/${model.id} (effort: ${action.level}).\nA new chat context will start with your next message.`
944
+ );
945
+ await ctx.answerCallbackQuery({ text: `Using ${model.id} / ${action.level}.` });
946
+ return;
947
+ }
948
+
949
+ if (action.type === "effort") {
950
+ const model = models.find((item) => item.id === resolveChatModel(config, ctx.chat.id));
951
+ if (!model) {
952
+ await ctx.answerCallbackQuery({
953
+ text: "Current model is unavailable. Run /model again.",
954
+ show_alert: true
955
+ });
956
+ return;
957
+ }
958
+ if (!modelSupportsThinking(model)) {
959
+ await ctx.answerCallbackQuery({
960
+ text: "This model does not support effort levels.",
961
+ show_alert: true
962
+ });
963
+ return;
964
+ }
965
+ const levels = listModelThinkingLevels(model);
966
+ if (!levels.includes(action.level)) {
967
+ await ctx.answerCallbackQuery({
968
+ text: "That effort level is not available for this model.",
969
+ show_alert: true
970
+ });
971
+ return;
972
+ }
973
+ const currentEffort = resolveChatThinkingLevel(config, ctx.chat.id);
974
+ if (action.level === currentEffort) {
975
+ await ctx.answerCallbackQuery({ text: `Already using effort ${action.level}.` });
976
+ return;
977
+ }
978
+ await persistChatEffort(ctx.chat.id, model, action.level);
979
+ await ctx.api.editMessageText(
980
+ ctx.chat.id,
981
+ ctx.callbackQuery.message.message_id,
982
+ `Effort set to ${action.level} for ${model.provider}/${model.id}.`
983
+ );
984
+ await ctx.answerCallbackQuery({ text: `Effort: ${action.level}.` });
985
+ }
986
+ } catch (error) {
987
+ logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
988
+ await ctx.answerCallbackQuery({
989
+ text: "Could not change the model or effort.",
990
+ show_alert: true
991
+ }).catch(() => {});
992
+ }
993
+ });
994
+
720
995
  bot.on("message", async (ctx) => {
721
996
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
722
997
  if (!auth.ok) return;
@@ -753,6 +1028,8 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
753
1028
  config.telegram.chatMeta ||= {};
754
1029
  await bot.api.setMyCommands([
755
1030
  { command: "new", description: "Start a new chat context" },
1031
+ { command: "model", description: "Choose the model for this chat" },
1032
+ { command: "effort", description: "Choose reasoning effort for this chat" },
756
1033
  { command: "auth", description: "Show Pi authentication status" }
757
1034
  ]);
758
1035
  if (!taskTimer) {
@@ -0,0 +1,69 @@
1
+ import { formatPiModelOption } from "../../core/agent/pi-runtime.js";
2
+ import { buildPagedInlineKeyboard } from "./paged-inline-keyboard.js";
3
+
4
+ export function parseModelPickerAction(data) {
5
+ if (data === "noop:page") return { type: "noop", value: null };
6
+ const match = /^(model|model-page):(\d+)$/.exec(String(data || ""));
7
+ if (!match) return null;
8
+ return {
9
+ type: match[1] === "model" ? "select" : "page",
10
+ value: Number(match[2])
11
+ };
12
+ }
13
+
14
+ export function parseEffortPickerAction(data) {
15
+ if (data === "noop:page") return { type: "noop", value: null };
16
+ const modelEffort = /^model-effort:(\d+):([a-z]+)$/.exec(String(data || ""));
17
+ if (modelEffort) {
18
+ return {
19
+ type: "model-effort",
20
+ modelIndex: Number(modelEffort[1]),
21
+ level: modelEffort[2]
22
+ };
23
+ }
24
+ const effort = /^effort:([a-z]+)$/.exec(String(data || ""));
25
+ if (effort) {
26
+ return {
27
+ type: "effort",
28
+ level: effort[1]
29
+ };
30
+ }
31
+ return null;
32
+ }
33
+
34
+ export function buildModelPicker({ provider, models, selectedModelId, selectedThinkingLevel, page, pageSize }) {
35
+ if (!models.length) {
36
+ throw new Error(`No models available for provider ${provider}`);
37
+ }
38
+ const items = models.map((model) => ({
39
+ text: `${model.id === selectedModelId ? "✓ " : ""}${formatPiModelOption(model)}`
40
+ }));
41
+ const effortLine = selectedThinkingLevel ? `\nEffort: ${selectedThinkingLevel}` : "";
42
+ return {
43
+ text: `Current model: ${provider}/${selectedModelId}${effortLine}\nSelect a model for this chat:`,
44
+ replyMarkup: buildPagedInlineKeyboard("model", items, { page, pageSize })
45
+ };
46
+ }
47
+
48
+ export function buildEffortPicker({
49
+ provider,
50
+ modelId,
51
+ levels,
52
+ selectedThinkingLevel,
53
+ modelIndex
54
+ }) {
55
+ if (!levels.length) {
56
+ throw new Error(`No effort levels available for ${provider}/${modelId}`);
57
+ }
58
+ const rows = levels.map((level) => ([{
59
+ text: `${level === selectedThinkingLevel ? "✓ " : ""}${level}`,
60
+ callback_data: modelIndex == null ? `effort:${level}` : `model-effort:${modelIndex}:${level}`
61
+ }]));
62
+ const scope = modelIndex == null
63
+ ? `Current model: ${provider}/${modelId}\nSelect effort for this chat:`
64
+ : `Model: ${provider}/${modelId}\nSelect effort:`;
65
+ return {
66
+ text: scope,
67
+ replyMarkup: { inline_keyboard: rows }
68
+ };
69
+ }
@@ -0,0 +1,30 @@
1
+ function requirePositiveInteger(value, name) {
2
+ if (!Number.isInteger(value) || value <= 0) {
3
+ throw new Error(`${name} must be a positive integer`);
4
+ }
5
+ }
6
+
7
+ export function buildPagedInlineKeyboard(action, items, { page = 0, pageSize }) {
8
+ requirePositiveInteger(pageSize, "pageSize");
9
+ const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
10
+ const currentPage = Math.max(0, Math.min(pageCount - 1, page));
11
+ const startIndex = currentPage * pageSize;
12
+ const rows = items.slice(startIndex, startIndex + pageSize).map((item, index) => ([{
13
+ text: item.text,
14
+ callback_data: `${action}:${startIndex + index}`
15
+ }]));
16
+
17
+ if (pageCount > 1) {
18
+ const navigation = [];
19
+ if (currentPage > 0) {
20
+ navigation.push({ text: "Previous", callback_data: `${action}-page:${currentPage - 1}` });
21
+ }
22
+ navigation.push({ text: `${currentPage + 1}/${pageCount}`, callback_data: "noop:page" });
23
+ if (currentPage < pageCount - 1) {
24
+ navigation.push({ text: "Next", callback_data: `${action}-page:${currentPage + 1}` });
25
+ }
26
+ rows.push(navigation);
27
+ }
28
+
29
+ return { inline_keyboard: rows };
30
+ }
@@ -25,6 +25,8 @@ test("classifies invalidated Pi authentication tokens", () => {
25
25
  test("classifies missing Pi authentication", () => {
26
26
  for (const error of [
27
27
  new Error("No auth found for provider"),
28
+ new Error("No API key for provider: openai-codex"),
29
+ new Error('No API key found for "openai-codex"'),
28
30
  new Error("authentication credentials are missing")
29
31
  ]) {
30
32
  assert.deepEqual(getPiAuthIssue(error), {
@@ -116,6 +116,38 @@ test("isolates daemon process files and context by chat scope", async () => {
116
116
  await Promise.all([first.stop(), second.stop()]);
117
117
  });
118
118
 
119
+ test("supervisor ignores invalid chat directories and recovers valid daemons", async () => {
120
+ const invalidChatDir = path.join(homeDir, "chats", "24137857-c513-4f53-b39d-1b28f51ebbb6");
121
+ await mkdir(path.join(invalidChatDir, "state", "tools", "orphaned-tool"), { recursive: true });
122
+
123
+ const runtime = runtimeFor({ type: "chat", chatId: "202" }, {
124
+ autoStart: true,
125
+ startupContext: { health: "ok" }
126
+ });
127
+ let supervisor;
128
+
129
+ try {
130
+ await runtime.submit({ value: "before" }, { timeoutMs: 1_000 });
131
+ const oldPid = await runtime.getPid();
132
+ process.kill(oldPid, "SIGKILL");
133
+ await waitFor(() => !isProcessAlive(oldPid));
134
+
135
+ supervisor = createToolProcessSupervisor({ policy });
136
+ await supervisor.start();
137
+
138
+ const newPid = await waitFor(async () => {
139
+ const pid = await runtime.getPid();
140
+ const status = await readJson(runtime.paths.statusFile, {});
141
+ return pid && pid !== oldPid && status.state === "ready" ? pid : null;
142
+ });
143
+ assert.notEqual(newPid, oldPid);
144
+ } finally {
145
+ await supervisor?.stop();
146
+ await runtime.stop().catch(() => {});
147
+ await rm(invalidChatDir, { recursive: true, force: true });
148
+ }
149
+ });
150
+
119
151
  test("rejects stale readiness even when the pid is alive", async () => {
120
152
  const status = {
121
153
  state: "ready",
@@ -0,0 +1,195 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import {
5
+ resolveChatModel,
6
+ resolveChatModelSelection,
7
+ resolveChatThinkingLevel,
8
+ selectChatModel,
9
+ selectChatThinkingLevel
10
+ } from "../src/core/agent/model-selection.js";
11
+ import { applyConfigDefaults, piConfigDefaults, telegramConfigDefaults } from "../src/core/config/config-defaults.js";
12
+ import {
13
+ clampModelThinkingLevel,
14
+ listModelThinkingLevels,
15
+ modelSupportsThinking
16
+ } from "../src/core/agent/pi-runtime.js";
17
+ import { getChatPiSessionsDir } from "../src/runtime/paths.js";
18
+ import {
19
+ buildEffortPicker,
20
+ buildModelPicker,
21
+ parseEffortPickerAction,
22
+ parseModelPickerAction
23
+ } from "../src/transport/telegram/model-picker.js";
24
+
25
+ function createConfig() {
26
+ return applyConfigDefaults({
27
+ telegram: {},
28
+ pi: {
29
+ provider: "openai-codex",
30
+ model: "gpt-default"
31
+ }
32
+ });
33
+ }
34
+
35
+ test("resolves the default model until a chat selects one", () => {
36
+ const config = createConfig();
37
+
38
+ assert.equal(resolveChatModel(config, 123), "gpt-default");
39
+ assert.equal(resolveChatThinkingLevel(config, 123), piConfigDefaults.thinkingLevel);
40
+
41
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-selected" }, { thinkingLevel: "high" });
42
+
43
+ assert.equal(resolveChatModel(config, 123), "gpt-selected");
44
+ assert.equal(resolveChatThinkingLevel(config, 123), "high");
45
+ assert.equal(resolveChatModel(config, 456), "gpt-default");
46
+ assert.deepEqual(config.pi.chatModels["123"], {
47
+ provider: "openai-codex",
48
+ model: "gpt-selected",
49
+ thinkingLevel: "high",
50
+ sessionRevision: 1
51
+ });
52
+ });
53
+
54
+ test("starts a distinct persisted Pi session revision on every model change", () => {
55
+ const config = createConfig();
56
+
57
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" }, { thinkingLevel: "medium" });
58
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-b" }, { thinkingLevel: "high" });
59
+
60
+ assert.equal(config.pi.chatModels["123"].sessionRevision, 2);
61
+ assert.equal(
62
+ getChatPiSessionsDir(123, config.pi.chatModels["123"].sessionRevision),
63
+ path.join(getChatPiSessionsDir(123), "2")
64
+ );
65
+ });
66
+
67
+ test("updates effort without bumping the session revision", () => {
68
+ const config = createConfig();
69
+
70
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" }, { thinkingLevel: "medium" });
71
+ selectChatThinkingLevel(config, 123, "high");
72
+
73
+ assert.deepEqual(resolveChatModelSelection(config, 123), {
74
+ provider: "openai-codex",
75
+ model: "gpt-a",
76
+ thinkingLevel: "high",
77
+ sessionRevision: 1
78
+ });
79
+ });
80
+
81
+ test("ignores a chat selection from a different active provider", () => {
82
+ const config = createConfig();
83
+ config.pi.chatModels = {
84
+ 123: { provider: "anthropic", model: "claude-selected", thinkingLevel: "high", sessionRevision: 1 }
85
+ };
86
+
87
+ assert.equal(resolveChatModel(config, 123), "gpt-default");
88
+ assert.equal(resolveChatThinkingLevel(config, 123), piConfigDefaults.thinkingLevel);
89
+ });
90
+
91
+ test("rejects selecting a model outside the active provider", () => {
92
+ const config = createConfig();
93
+
94
+ assert.throws(
95
+ () => selectChatModel(config, 123, { provider: "anthropic", id: "claude-selected" }),
96
+ /active provider is openai-codex/
97
+ );
98
+ });
99
+
100
+ test("builds a paged model picker and marks the current model", () => {
101
+ const models = [
102
+ { provider: "openai-codex", id: "gpt-a", reasoning: false, input: ["text"] },
103
+ { provider: "openai-codex", id: "gpt-b", reasoning: true, input: ["text", "image"] },
104
+ { provider: "openai-codex", id: "gpt-c", reasoning: false, input: ["text"] }
105
+ ];
106
+
107
+ const picker = buildModelPicker({
108
+ provider: "openai-codex",
109
+ models,
110
+ selectedModelId: "gpt-b",
111
+ selectedThinkingLevel: "high",
112
+ page: 0,
113
+ pageSize: 2
114
+ });
115
+
116
+ assert.match(picker.text, /openai-codex\/gpt-b/);
117
+ assert.match(picker.text, /Effort: high/);
118
+ assert.equal(picker.replyMarkup.inline_keyboard[0][0].callback_data, "model:0");
119
+ assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ gpt-b \[reasoning, image\]$/);
120
+ assert.equal(picker.replyMarkup.inline_keyboard[2][1].callback_data, "model-page:1");
121
+ });
122
+
123
+ test("builds an effort picker for the current model or pending model choice", () => {
124
+ const current = buildEffortPicker({
125
+ provider: "openai-codex",
126
+ modelId: "gpt-b",
127
+ levels: ["off", "low", "medium", "high"],
128
+ selectedThinkingLevel: "medium"
129
+ });
130
+ assert.match(current.text, /Current model: openai-codex\/gpt-b/);
131
+ assert.equal(current.replyMarkup.inline_keyboard[1][0].callback_data, "effort:low");
132
+ assert.match(current.replyMarkup.inline_keyboard[2][0].text, /^✓ medium$/);
133
+
134
+ const pending = buildEffortPicker({
135
+ provider: "openai-codex",
136
+ modelId: "gpt-b",
137
+ levels: ["off", "high"],
138
+ selectedThinkingLevel: "high",
139
+ modelIndex: 4
140
+ });
141
+ assert.match(pending.text, /^Model: openai-codex\/gpt-b/);
142
+ assert.equal(pending.replyMarkup.inline_keyboard[1][0].callback_data, "model-effort:4:high");
143
+ });
144
+
145
+ test("parses only model picker callback data", () => {
146
+ assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
147
+ assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
148
+ assert.deepEqual(parseModelPickerAction("noop:page"), { type: "noop", value: null });
149
+ assert.equal(parseModelPickerAction("provider:1"), null);
150
+ assert.equal(parseModelPickerAction("model:-1"), null);
151
+ assert.equal(parseModelPickerAction("effort:high"), null);
152
+ });
153
+
154
+ test("parses effort picker callback data", () => {
155
+ assert.deepEqual(parseEffortPickerAction("effort:high"), { type: "effort", level: "high" });
156
+ assert.deepEqual(parseEffortPickerAction("model-effort:3:medium"), {
157
+ type: "model-effort",
158
+ modelIndex: 3,
159
+ level: "medium"
160
+ });
161
+ assert.deepEqual(parseEffortPickerAction("noop:page"), { type: "noop", value: null });
162
+ assert.equal(parseEffortPickerAction("model:1"), null);
163
+ });
164
+
165
+ test("centralizes picker defaults in config", () => {
166
+ const config = applyConfigDefaults({
167
+ telegram: {},
168
+ pi: { provider: "openai-codex", model: "gpt-default" }
169
+ });
170
+
171
+ assert.equal(config.telegram.modelPickerPageSize, telegramConfigDefaults.modelPickerPageSize);
172
+ assert.equal(config.pi.thinkingLevel, piConfigDefaults.thinkingLevel);
173
+ });
174
+
175
+ test("lists and clamps thinking levels from model capabilities", () => {
176
+ const reasoning = {
177
+ reasoning: true,
178
+ thinkingLevelMap: { xhigh: "xhigh", minimal: "low", max: null }
179
+ };
180
+ assert.deepEqual(listModelThinkingLevels(reasoning), [
181
+ "off",
182
+ "minimal",
183
+ "low",
184
+ "medium",
185
+ "high",
186
+ "xhigh"
187
+ ]);
188
+ assert.equal(clampModelThinkingLevel(reasoning, "max"), "xhigh");
189
+ assert.equal(modelSupportsThinking(reasoning), true);
190
+
191
+ const plain = { reasoning: false };
192
+ assert.deepEqual(listModelThinkingLevels(plain), ["off"]);
193
+ assert.equal(clampModelThinkingLevel(plain, "high"), "off");
194
+ assert.equal(modelSupportsThinking(plain), false);
195
+ });