arisa 4.3.2 → 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.2",
3
+ "version": "4.3.3",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,6 +8,7 @@ import { appendArisaAgentsFile, arisaAgentsFile, arisaInstallDir, buildAgentRunt
8
8
  import { withTimeout } from "./prompt-timeout.js";
9
9
  import { buildPiToolPolicy, getCoreCodingTools } from "./core-tools.js";
10
10
  import { createSystemShellTool } from "./system-shell-tool.js";
11
+ import { clampModelThinkingLevel } from "./pi-runtime.js";
11
12
  import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
12
13
 
13
14
  const piValidationTimeoutMs = 60_000;
@@ -172,6 +173,11 @@ export class AgentManager {
172
173
  if (this.sessions.has(sessionKey)) {
173
174
  const existing = this.sessions.get(sessionKey);
174
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
+ }
175
181
  this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
176
182
  return existing;
177
183
  }
@@ -189,6 +195,7 @@ export class AgentManager {
189
195
  if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
190
196
  throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
191
197
  }
198
+ const thinkingLevel = clampModelThinkingLevel(model, modelSelection.thinkingLevel);
192
199
 
193
200
  const policy = buildPiToolPolicy({
194
201
  config: this.config,
@@ -201,7 +208,7 @@ export class AgentManager {
201
208
  modelSelection.sessionRevision
202
209
  );
203
210
  const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
204
- 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}`);
205
212
  const customTools = [
206
213
  ...this.createTools(telegram, chatId, policy),
207
214
  createSystemShellTool({ workspaceDir: policy.workspaceDir, shell: policy.shell })
@@ -217,6 +224,7 @@ export class AgentManager {
217
224
  authStorage,
218
225
  modelRegistry,
219
226
  model,
227
+ thinkingLevel,
220
228
  tools: policy.tools,
221
229
  excludeTools: policy.excludeTools,
222
230
  customTools,
@@ -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
 
@@ -2,26 +2,42 @@ function chatKey(chatId) {
2
2
  return String(chatId);
3
3
  }
4
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
+
5
13
  export function resolveChatModelSelection(config, chatId) {
6
14
  const selection = config.pi.chatModels?.[chatKey(chatId)];
7
15
  if (!selection || selection.provider !== config.pi.provider) {
8
16
  return {
9
17
  provider: config.pi.provider,
10
18
  model: config.pi.model,
19
+ thinkingLevel: config.pi.thinkingLevel,
11
20
  sessionRevision: 0
12
21
  };
13
22
  }
14
- if (!Number.isSafeInteger(selection.sessionRevision) || selection.sessionRevision <= 0) {
15
- throw new Error(`Invalid model session revision for chat ${chatId}`);
16
- }
17
- return selection;
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
+ };
18
30
  }
19
31
 
20
32
  export function resolveChatModel(config, chatId) {
21
33
  return resolveChatModelSelection(config, chatId).model;
22
34
  }
23
35
 
24
- export function selectChatModel(config, chatId, model) {
36
+ export function resolveChatThinkingLevel(config, chatId) {
37
+ return resolveChatModelSelection(config, chatId).thinkingLevel;
38
+ }
39
+
40
+ export function selectChatModel(config, chatId, model, { thinkingLevel } = {}) {
25
41
  if (model.provider !== config.pi.provider) {
26
42
  throw new Error(`Cannot select model from provider ${model.provider}; active provider is ${config.pi.provider}`);
27
43
  }
@@ -31,6 +47,19 @@ export function selectChatModel(config, chatId, model) {
31
47
  config.pi.chatModels[key] = {
32
48
  provider: model.provider,
33
49
  model: model.id,
50
+ thinkingLevel,
34
51
  sessionRevision
35
52
  };
36
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
+ }
@@ -61,6 +61,47 @@ export function formatPiModelOption(model) {
61
61
  return capabilities ? `${model.id} [${capabilities}]` : model.id;
62
62
  }
63
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
+
64
105
  export function findPiModel({ provider, model, apiKey } = {}) {
65
106
  const runtime = createPiRuntime({ provider, apiKey });
66
107
  return {
@@ -18,6 +18,10 @@ export const telegramConfigDefaults = Object.freeze({
18
18
  modelPickerPageSize: 8
19
19
  });
20
20
 
21
+ export const piConfigDefaults = Object.freeze({
22
+ thinkingLevel: "medium"
23
+ });
24
+
21
25
  export function applyConfigDefaults(config) {
22
26
  return {
23
27
  ...config,
@@ -25,6 +29,10 @@ export function applyConfigDefaults(config) {
25
29
  ...telegramConfigDefaults,
26
30
  ...(config.telegram || {})
27
31
  },
32
+ pi: {
33
+ ...piConfigDefaults,
34
+ ...(config.pi || {})
35
+ },
28
36
  daemons: {
29
37
  ...daemonConfigDefaults,
30
38
  ...(config.daemons || {})
@@ -3,12 +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 { buildModelPicker, parseModelPickerAction } from "./model-picker.js";
6
+ import { buildEffortPicker, buildModelPicker, parseEffortPickerAction, parseModelPickerAction } from "./model-picker.js";
7
7
  import { renderTelegramHtml } from "./text-format.js";
8
8
  import { buildPiAuthRecoveryBlockedMessage, buildPiAuthTelegramMessage, getErrorMessage, getPiAuthIssue, getPiAuthStatus } from "../../core/agent/auth-flow.js";
9
9
  import { createPiOAuthLogin } from "../../core/agent/pi-auth-login.js";
10
- import { resolveChatModel, selectChatModel } from "../../core/agent/model-selection.js";
11
- import { createPiRuntime, listProviderModels } from "../../core/agent/pi-runtime.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";
12
12
  import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
13
13
 
14
14
  const slowPromptNoticeMs = 300_000;
@@ -426,6 +426,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
426
426
  provider: config.pi.provider,
427
427
  models: getProviderModels(),
428
428
  selectedModelId: resolveChatModel(config, ctx.chat.id),
429
+ selectedThinkingLevel: resolveChatThinkingLevel(config, ctx.chat.id),
429
430
  page,
430
431
  pageSize: config.telegram.modelPickerPageSize
431
432
  });
@@ -437,11 +438,42 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
437
438
  return ctx.reply(picker.text, extra);
438
439
  }
439
440
 
440
- async function persistChatModel(chatId, model) {
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) {
441
472
  const key = chatKey(chatId);
442
473
  const hadSelections = Boolean(config.pi.chatModels);
443
474
  const previousSelection = config.pi.chatModels?.[key];
444
- selectChatModel(config, chatId, model);
475
+ const level = clampModelThinkingLevel(model, thinkingLevel ?? resolveChatThinkingLevel(config, chatId));
476
+ selectChatModel(config, chatId, model, { thinkingLevel: level });
445
477
  try {
446
478
  await saveConfig(config);
447
479
  } catch (error) {
@@ -454,6 +486,27 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
454
486
  throw error;
455
487
  }
456
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;
457
510
  }
458
511
 
459
512
  async function buildIncomingPrompt(ctx) {
@@ -736,6 +789,12 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
736
789
  await showModelPicker(ctx);
737
790
  });
738
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
+
739
798
  bot.command("auth", async (ctx) => {
740
799
  const auth = await authorizeChat({ config, chatId: ctx.chat.id, saveConfig, chatMeta: getIncomingChatMeta(ctx) });
741
800
  if (!auth.ok) return;
@@ -770,7 +829,9 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
770
829
  });
771
830
 
772
831
  bot.on("callback_query:data", async (ctx, next) => {
773
- const action = parseModelPickerAction(ctx.callbackQuery.data);
832
+ const modelAction = parseModelPickerAction(ctx.callbackQuery.data);
833
+ const effortAction = modelAction ? null : parseEffortPickerAction(ctx.callbackQuery.data);
834
+ const action = modelAction || effortAction;
774
835
  if (!action) return next();
775
836
  if (action.type === "noop") {
776
837
  await ctx.answerCallbackQuery();
@@ -792,39 +853,140 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
792
853
 
793
854
  if (getChatState(ctx.chat.id).processing) {
794
855
  await ctx.answerCallbackQuery({
795
- text: "Wait for the current response before changing models.",
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.",
796
859
  show_alert: true
797
860
  });
798
861
  return;
799
862
  }
800
863
 
801
864
  const models = getProviderModels();
802
- const model = models[action.value];
803
- if (!model) {
804
- await ctx.answerCallbackQuery({
805
- text: "This model list is no longer current. Run /model again.",
806
- show_alert: true
807
- });
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}.` });
808
900
  return;
809
901
  }
810
902
 
811
- const currentModelId = resolveChatModel(config, ctx.chat.id);
812
- if (model.id === currentModelId) {
813
- await ctx.answerCallbackQuery({ text: `Already using ${model.id}.` });
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}.` });
814
946
  return;
815
947
  }
816
948
 
817
- await persistChatModel(ctx.chat.id, model);
818
- await ctx.api.editMessageText(
819
- ctx.chat.id,
820
- ctx.callbackQuery.message.message_id,
821
- `Model changed to ${model.provider}/${model.id}.\nA new chat context will start with your next message.`
822
- );
823
- await ctx.answerCallbackQuery({ text: `Using ${model.id}.` });
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
+ }
824
986
  } catch (error) {
825
987
  logger?.error("telegram", `model selection failed for chat ${ctx.chat.id}: ${getErrorMessage(error)}`);
826
988
  await ctx.answerCallbackQuery({
827
- text: "Could not change the model.",
989
+ text: "Could not change the model or effort.",
828
990
  show_alert: true
829
991
  }).catch(() => {});
830
992
  }
@@ -867,6 +1029,7 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
867
1029
  await bot.api.setMyCommands([
868
1030
  { command: "new", description: "Start a new chat context" },
869
1031
  { command: "model", description: "Choose the model for this chat" },
1032
+ { command: "effort", description: "Choose reasoning effort for this chat" },
870
1033
  { command: "auth", description: "Show Pi authentication status" }
871
1034
  ]);
872
1035
  if (!taskTimer) {
@@ -11,15 +11,59 @@ export function parseModelPickerAction(data) {
11
11
  };
12
12
  }
13
13
 
14
- export function buildModelPicker({ provider, models, selectedModelId, page, pageSize }) {
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 }) {
15
35
  if (!models.length) {
16
36
  throw new Error(`No models available for provider ${provider}`);
17
37
  }
18
38
  const items = models.map((model) => ({
19
39
  text: `${model.id === selectedModelId ? "✓ " : ""}${formatPiModelOption(model)}`
20
40
  }));
41
+ const effortLine = selectedThinkingLevel ? `\nEffort: ${selectedThinkingLevel}` : "";
21
42
  return {
22
- text: `Current model: ${provider}/${selectedModelId}\nSelect a model for this chat:`,
43
+ text: `Current model: ${provider}/${selectedModelId}${effortLine}\nSelect a model for this chat:`,
23
44
  replyMarkup: buildPagedInlineKeyboard("model", items, { page, pageSize })
24
45
  };
25
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
+ }
@@ -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), {
@@ -1,33 +1,52 @@
1
1
  import assert from "node:assert/strict";
2
2
  import path from "node:path";
3
3
  import test from "node:test";
4
- import { resolveChatModel, selectChatModel } from "../src/core/agent/model-selection.js";
5
- import { applyConfigDefaults, telegramConfigDefaults } from "../src/core/config/config-defaults.js";
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";
6
17
  import { getChatPiSessionsDir } from "../src/runtime/paths.js";
7
- import { buildModelPicker, parseModelPickerAction } from "../src/transport/telegram/model-picker.js";
18
+ import {
19
+ buildEffortPicker,
20
+ buildModelPicker,
21
+ parseEffortPickerAction,
22
+ parseModelPickerAction
23
+ } from "../src/transport/telegram/model-picker.js";
8
24
 
9
25
  function createConfig() {
10
- return {
26
+ return applyConfigDefaults({
11
27
  telegram: {},
12
28
  pi: {
13
29
  provider: "openai-codex",
14
30
  model: "gpt-default"
15
31
  }
16
- };
32
+ });
17
33
  }
18
34
 
19
35
  test("resolves the default model until a chat selects one", () => {
20
36
  const config = createConfig();
21
37
 
22
38
  assert.equal(resolveChatModel(config, 123), "gpt-default");
39
+ assert.equal(resolveChatThinkingLevel(config, 123), piConfigDefaults.thinkingLevel);
23
40
 
24
- selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-selected" });
41
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-selected" }, { thinkingLevel: "high" });
25
42
 
26
43
  assert.equal(resolveChatModel(config, 123), "gpt-selected");
44
+ assert.equal(resolveChatThinkingLevel(config, 123), "high");
27
45
  assert.equal(resolveChatModel(config, 456), "gpt-default");
28
46
  assert.deepEqual(config.pi.chatModels["123"], {
29
47
  provider: "openai-codex",
30
48
  model: "gpt-selected",
49
+ thinkingLevel: "high",
31
50
  sessionRevision: 1
32
51
  });
33
52
  });
@@ -35,8 +54,8 @@ test("resolves the default model until a chat selects one", () => {
35
54
  test("starts a distinct persisted Pi session revision on every model change", () => {
36
55
  const config = createConfig();
37
56
 
38
- selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" });
39
- selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-b" });
57
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-a" }, { thinkingLevel: "medium" });
58
+ selectChatModel(config, 123, { provider: "openai-codex", id: "gpt-b" }, { thinkingLevel: "high" });
40
59
 
41
60
  assert.equal(config.pi.chatModels["123"].sessionRevision, 2);
42
61
  assert.equal(
@@ -45,13 +64,28 @@ test("starts a distinct persisted Pi session revision on every model change", ()
45
64
  );
46
65
  });
47
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
+
48
81
  test("ignores a chat selection from a different active provider", () => {
49
82
  const config = createConfig();
50
83
  config.pi.chatModels = {
51
- 123: { provider: "anthropic", model: "claude-selected" }
84
+ 123: { provider: "anthropic", model: "claude-selected", thinkingLevel: "high", sessionRevision: 1 }
52
85
  };
53
86
 
54
87
  assert.equal(resolveChatModel(config, 123), "gpt-default");
88
+ assert.equal(resolveChatThinkingLevel(config, 123), piConfigDefaults.thinkingLevel);
55
89
  });
56
90
 
57
91
  test("rejects selecting a model outside the active provider", () => {
@@ -74,26 +108,88 @@ test("builds a paged model picker and marks the current model", () => {
74
108
  provider: "openai-codex",
75
109
  models,
76
110
  selectedModelId: "gpt-b",
111
+ selectedThinkingLevel: "high",
77
112
  page: 0,
78
113
  pageSize: 2
79
114
  });
80
115
 
81
116
  assert.match(picker.text, /openai-codex\/gpt-b/);
117
+ assert.match(picker.text, /Effort: high/);
82
118
  assert.equal(picker.replyMarkup.inline_keyboard[0][0].callback_data, "model:0");
83
119
  assert.match(picker.replyMarkup.inline_keyboard[1][0].text, /^✓ gpt-b \[reasoning, image\]$/);
84
120
  assert.equal(picker.replyMarkup.inline_keyboard[2][1].callback_data, "model-page:1");
85
121
  });
86
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
+
87
145
  test("parses only model picker callback data", () => {
88
146
  assert.deepEqual(parseModelPickerAction("model:12"), { type: "select", value: 12 });
89
147
  assert.deepEqual(parseModelPickerAction("model-page:2"), { type: "page", value: 2 });
90
148
  assert.deepEqual(parseModelPickerAction("noop:page"), { type: "noop", value: null });
91
149
  assert.equal(parseModelPickerAction("provider:1"), null);
92
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);
93
163
  });
94
164
 
95
- test("centralizes the model picker page size in Telegram config defaults", () => {
96
- const config = applyConfigDefaults(createConfig());
165
+ test("centralizes picker defaults in config", () => {
166
+ const config = applyConfigDefaults({
167
+ telegram: {},
168
+ pi: { provider: "openai-codex", model: "gpt-default" }
169
+ });
97
170
 
98
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);
99
195
  });