@oh-my-pi/pi-coding-agent 15.10.10 → 15.10.12

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.
Files changed (415) hide show
  1. package/CHANGELOG.md +142 -7
  2. package/dist/cli.js +23108 -0
  3. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  4. package/dist/types/async/job-manager.d.ts +18 -0
  5. package/dist/types/cli/args.d.ts +2 -1
  6. package/dist/types/cli/dry-balance-cli.d.ts +1 -1
  7. package/dist/types/cli/gallery-cli.d.ts +1 -1
  8. package/dist/types/cli/gallery-fixtures/types.d.ts +1 -1
  9. package/dist/types/cli/usage-cli.d.ts +72 -0
  10. package/dist/types/cli-commands.d.ts +12 -0
  11. package/dist/types/commands/launch.d.ts +5 -1
  12. package/dist/types/commands/read.d.ts +1 -1
  13. package/dist/types/commands/usage.d.ts +25 -0
  14. package/dist/types/config/api-key-resolver.d.ts +3 -0
  15. package/dist/types/config/append-only-context-mode.d.ts +2 -1
  16. package/dist/types/config/model-discovery.d.ts +55 -0
  17. package/dist/types/config/model-registry.d.ts +8 -219
  18. package/dist/types/config/model-resolver.d.ts +34 -10
  19. package/dist/types/config/model-roles.d.ts +28 -0
  20. package/dist/types/config/models-config-schema.d.ts +523 -42
  21. package/dist/types/config/models-config.d.ts +385 -0
  22. package/dist/types/config/settings-schema.d.ts +41 -8
  23. package/dist/types/config/settings.d.ts +8 -1
  24. package/dist/types/debug/log-viewer.d.ts +1 -1
  25. package/dist/types/debug/raw-sse.d.ts +1 -1
  26. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  27. package/dist/types/eval/backend.d.ts +0 -2
  28. package/dist/types/eval/idle-timeout.d.ts +0 -4
  29. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  30. package/dist/types/eval/py/executor.d.ts +5 -0
  31. package/dist/types/eval/py/kernel.d.ts +6 -1
  32. package/dist/types/eval/py/runtime.d.ts +9 -0
  33. package/dist/types/exec/bash-executor.d.ts +2 -0
  34. package/dist/types/export/html/template.generated.d.ts +1 -1
  35. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  36. package/dist/types/extensibility/extensions/types.d.ts +6 -3
  37. package/dist/types/hindsight/mental-models.d.ts +17 -8
  38. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  39. package/dist/types/internal-urls/types.d.ts +1 -1
  40. package/dist/types/lsp/edits.d.ts +9 -0
  41. package/dist/types/lsp/index.d.ts +2 -2
  42. package/dist/types/lsp/types.d.ts +2 -0
  43. package/dist/types/lsp/utils.d.ts +3 -0
  44. package/dist/types/mcp/json-rpc.d.ts +5 -0
  45. package/dist/types/memory-backend/index.d.ts +1 -0
  46. package/dist/types/memory-backend/runtime.d.ts +4 -0
  47. package/dist/types/memory-backend/types.d.ts +66 -1
  48. package/dist/types/mnemopi/state.d.ts +11 -1
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  51. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  52. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  53. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  54. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  55. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  56. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  57. package/dist/types/modes/components/footer.d.ts +1 -1
  58. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  59. package/dist/types/modes/components/hook-input.d.ts +4 -0
  60. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  61. package/dist/types/modes/components/model-selector.d.ts +1 -1
  62. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  63. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  64. package/dist/types/modes/components/session-selector.d.ts +1 -1
  65. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  66. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  67. package/dist/types/modes/components/transcript-container.d.ts +25 -6
  68. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  69. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  70. package/dist/types/modes/components/user-message.d.ts +2 -1
  71. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  72. package/dist/types/modes/components/welcome.d.ts +19 -3
  73. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  74. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  75. package/dist/types/modes/index.d.ts +3 -3
  76. package/dist/types/modes/interactive-mode.d.ts +8 -3
  77. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  78. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  79. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  80. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  81. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  82. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  83. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  84. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  85. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  86. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  87. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  88. package/dist/types/modes/types.d.ts +4 -1
  89. package/dist/types/secrets/index.d.ts +1 -1
  90. package/dist/types/secrets/obfuscator.d.ts +8 -2
  91. package/dist/types/session/agent-session.d.ts +15 -3
  92. package/dist/types/session/auth-broker-config.d.ts +4 -0
  93. package/dist/types/session/session-manager.d.ts +1 -1
  94. package/dist/types/session/streaming-output.d.ts +23 -0
  95. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  96. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  97. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  98. package/dist/types/slash-commands/types.d.ts +1 -1
  99. package/dist/types/ssh/connection-manager.d.ts +8 -0
  100. package/dist/types/system-prompt.d.ts +2 -0
  101. package/dist/types/task/executor.d.ts +1 -0
  102. package/dist/types/task/index.d.ts +2 -2
  103. package/dist/types/task/parallel.d.ts +2 -2
  104. package/dist/types/task/types.d.ts +8 -0
  105. package/dist/types/task/worktree.d.ts +2 -0
  106. package/dist/types/thinking.d.ts +4 -0
  107. package/dist/types/tiny/title-client.d.ts +11 -0
  108. package/dist/types/tiny/title-protocol.d.ts +1 -0
  109. package/dist/types/tools/ask.d.ts +4 -0
  110. package/dist/types/tools/conflict-detect.d.ts +16 -0
  111. package/dist/types/tools/github-cache.d.ts +7 -0
  112. package/dist/types/tools/index.d.ts +6 -0
  113. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  114. package/dist/types/tui/output-block.d.ts +3 -3
  115. package/dist/types/utils/changelog.d.ts +8 -0
  116. package/dist/types/utils/git.d.ts +15 -2
  117. package/dist/types/utils/title-generator.d.ts +3 -2
  118. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  119. package/dist/types/web/scrapers/types.d.ts +12 -0
  120. package/dist/types/web/search/providers/codex.d.ts +1 -1
  121. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  122. package/examples/extensions/tools.ts +5 -4
  123. package/package.json +14 -11
  124. package/scripts/build-binary.ts +18 -23
  125. package/scripts/bundle-dist.ts +81 -0
  126. package/scripts/{dev-launch → omp} +1 -1
  127. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  128. package/src/async/job-manager.ts +57 -3
  129. package/src/auto-thinking/classifier.ts +1 -0
  130. package/src/autoresearch/dashboard.ts +1 -1
  131. package/src/autoresearch/prompt-setup.md +6 -6
  132. package/src/autoresearch/prompt.md +6 -6
  133. package/src/capability/fs.ts +10 -0
  134. package/src/cli/args.ts +4 -1
  135. package/src/cli/auth-gateway-cli.ts +1 -3
  136. package/src/cli/dry-balance-cli.ts +1 -1
  137. package/src/cli/gallery-cli.ts +1 -1
  138. package/src/cli/gallery-fixtures/fs.ts +1 -1
  139. package/src/cli/gallery-fixtures/types.ts +5 -1
  140. package/src/cli/list-models.ts +2 -1
  141. package/src/cli/usage-cli.ts +603 -0
  142. package/src/cli-commands.ts +30 -0
  143. package/src/cli.ts +76 -13
  144. package/src/commands/complete.ts +1 -1
  145. package/src/commands/launch.ts +5 -1
  146. package/src/commands/read.ts +6 -3
  147. package/src/commands/usage.ts +35 -0
  148. package/src/commit/agentic/agent.ts +1 -1
  149. package/src/commit/model-selection.ts +4 -3
  150. package/src/config/api-key-resolver.ts +8 -6
  151. package/src/config/append-only-context-mode.ts +6 -12
  152. package/src/config/model-discovery.ts +554 -0
  153. package/src/config/model-registry.ts +320 -1041
  154. package/src/config/model-resolver.ts +173 -156
  155. package/src/config/model-roles.ts +74 -0
  156. package/src/config/models-config-schema.ts +57 -8
  157. package/src/config/models-config.ts +129 -0
  158. package/src/config/settings-schema.ts +61 -19
  159. package/src/config/settings.ts +98 -4
  160. package/src/dap/client.ts +124 -37
  161. package/src/dap/session.ts +259 -158
  162. package/src/debug/log-viewer.ts +1 -1
  163. package/src/debug/raw-sse.ts +1 -1
  164. package/src/edit/diff.ts +47 -3
  165. package/src/edit/hashline/block-resolver.ts +20 -1
  166. package/src/edit/hashline/diff.ts +36 -1
  167. package/src/edit/hashline/execute.ts +47 -4
  168. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  169. package/src/edit/index.ts +16 -1
  170. package/src/edit/modes/patch.ts +52 -0
  171. package/src/edit/modes/replace.ts +56 -22
  172. package/src/edit/notebook.ts +22 -2
  173. package/src/edit/renderer.ts +36 -10
  174. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  175. package/src/eval/backend.ts +0 -2
  176. package/src/eval/completion-bridge.ts +3 -1
  177. package/src/eval/idle-timeout.ts +2 -9
  178. package/src/eval/js/context-manager.ts +6 -8
  179. package/src/eval/js/executor.ts +6 -2
  180. package/src/eval/js/index.ts +0 -2
  181. package/src/eval/js/shared/helpers.ts +5 -6
  182. package/src/eval/js/shared/local-module-loader.ts +1 -1
  183. package/src/eval/js/shared/prelude.txt +62 -1
  184. package/src/eval/js/shared/rewrite-imports.ts +40 -22
  185. package/src/eval/js/shared/runtime.ts +1 -1
  186. package/src/eval/py/executor.ts +29 -7
  187. package/src/eval/py/index.ts +6 -3
  188. package/src/eval/py/kernel.ts +43 -4
  189. package/src/eval/py/runner.py +107 -3
  190. package/src/eval/py/runtime.ts +37 -0
  191. package/src/exec/bash-executor.ts +85 -4
  192. package/src/export/html/template.generated.ts +1 -1
  193. package/src/export/html/template.js +3 -1
  194. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  195. package/src/extensibility/extensions/runner.ts +6 -1
  196. package/src/extensibility/extensions/types.ts +6 -2
  197. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  198. package/src/hindsight/bank.ts +17 -2
  199. package/src/hindsight/mental-models.ts +59 -12
  200. package/src/hindsight/state.ts +6 -1
  201. package/src/internal-urls/artifact-protocol.ts +11 -2
  202. package/src/internal-urls/docs-index.generated.ts +11 -11
  203. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  204. package/src/internal-urls/router.ts +1 -1
  205. package/src/internal-urls/types.ts +1 -1
  206. package/src/lib/xai-http.ts +1 -1
  207. package/src/lsp/client.ts +118 -38
  208. package/src/lsp/clients/biome-client.ts +101 -39
  209. package/src/lsp/edits.ts +143 -95
  210. package/src/lsp/index.ts +31 -22
  211. package/src/lsp/render.ts +1 -1
  212. package/src/lsp/types.ts +2 -0
  213. package/src/lsp/utils.ts +28 -10
  214. package/src/main.ts +183 -23
  215. package/src/mcp/json-rpc.ts +35 -5
  216. package/src/mcp/transports/stdio.ts +7 -1
  217. package/src/memories/index.ts +4 -1
  218. package/src/memory-backend/index.ts +1 -0
  219. package/src/memory-backend/local-backend.ts +9 -0
  220. package/src/memory-backend/off-backend.ts +9 -0
  221. package/src/memory-backend/runtime.ts +66 -0
  222. package/src/memory-backend/types.ts +81 -1
  223. package/src/mnemopi/backend.ts +176 -7
  224. package/src/mnemopi/state.ts +38 -2
  225. package/src/modes/acp/acp-agent.ts +119 -11
  226. package/src/modes/components/agent-dashboard.ts +10 -7
  227. package/src/modes/components/assistant-message.ts +32 -28
  228. package/src/modes/components/bash-execution.ts +1 -1
  229. package/src/modes/components/copy-selector.ts +1 -1
  230. package/src/modes/components/diff.ts +13 -2
  231. package/src/modes/components/dynamic-border.ts +12 -3
  232. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  233. package/src/modes/components/extensions/extension-list.ts +1 -1
  234. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  235. package/src/modes/components/footer.ts +4 -2
  236. package/src/modes/components/history-search.ts +1 -1
  237. package/src/modes/components/hook-editor.ts +8 -0
  238. package/src/modes/components/hook-input.ts +8 -0
  239. package/src/modes/components/hook-selector.ts +2 -2
  240. package/src/modes/components/model-selector.ts +4 -2
  241. package/src/modes/components/plan-review-overlay.ts +1 -1
  242. package/src/modes/components/session-observer-overlay.ts +2 -2
  243. package/src/modes/components/session-selector.ts +1 -1
  244. package/src/modes/components/settings-selector.ts +5 -1
  245. package/src/modes/components/status-line/component.ts +119 -35
  246. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  247. package/src/modes/components/transcript-container.ts +258 -53
  248. package/src/modes/components/tree-selector.ts +3 -3
  249. package/src/modes/components/user-message-selector.ts +1 -1
  250. package/src/modes/components/user-message.ts +17 -5
  251. package/src/modes/components/visual-truncate.ts +1 -1
  252. package/src/modes/components/welcome.ts +108 -26
  253. package/src/modes/controllers/command-controller.ts +11 -4
  254. package/src/modes/controllers/event-controller.ts +73 -4
  255. package/src/modes/controllers/input-controller.ts +2 -1
  256. package/src/modes/controllers/mcp-command-controller.ts +39 -4
  257. package/src/modes/controllers/selector-controller.ts +1 -1
  258. package/src/modes/controllers/streaming-reveal.ts +85 -18
  259. package/src/modes/index.ts +3 -21
  260. package/src/modes/interactive-mode.ts +42 -18
  261. package/src/modes/oauth-manual-input.ts +30 -3
  262. package/src/modes/rpc/rpc-client.ts +154 -3
  263. package/src/modes/rpc/rpc-mode.ts +97 -12
  264. package/src/modes/rpc/rpc-subagents.ts +265 -0
  265. package/src/modes/rpc/rpc-types.ts +81 -1
  266. package/src/modes/setup-wizard/index.ts +12 -2
  267. package/src/modes/setup-wizard/lazy.ts +16 -0
  268. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  269. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  270. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  271. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  272. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  273. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  274. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  275. package/src/modes/types.ts +4 -1
  276. package/src/prompts/agents/explore.md +2 -2
  277. package/src/prompts/agents/librarian.md +1 -2
  278. package/src/prompts/agents/oracle.md +1 -1
  279. package/src/prompts/agents/plan.md +5 -5
  280. package/src/prompts/agents/task.md +5 -5
  281. package/src/prompts/ci-green-request.md +5 -7
  282. package/src/prompts/goals/goal-budget-limit.md +2 -2
  283. package/src/prompts/goals/goal-continuation.md +4 -4
  284. package/src/prompts/goals/goal-mode-active.md +1 -1
  285. package/src/prompts/memories/read-path.md +1 -1
  286. package/src/prompts/memories/stage_one_system.md +2 -2
  287. package/src/prompts/review-custom-request.md +1 -1
  288. package/src/prompts/system/agent-creation-architect.md +2 -2
  289. package/src/prompts/system/auto-continue.md +1 -1
  290. package/src/prompts/system/background-tan-dispatch.md +1 -1
  291. package/src/prompts/system/btw-user.md +2 -2
  292. package/src/prompts/system/commit-message-system.md +13 -1
  293. package/src/prompts/system/custom-system-prompt.md +1 -1
  294. package/src/prompts/system/eager-todo.md +2 -2
  295. package/src/prompts/system/irc-incoming.md +1 -1
  296. package/src/prompts/system/manual-continue.md +1 -1
  297. package/src/prompts/system/omfg-user.md +3 -4
  298. package/src/prompts/system/orchestrate-notice.md +9 -9
  299. package/src/prompts/system/plan-mode-active.md +4 -4
  300. package/src/prompts/system/plan-mode-subagent.md +4 -5
  301. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  302. package/src/prompts/system/project-prompt.md +2 -2
  303. package/src/prompts/system/subagent-system-prompt.md +4 -4
  304. package/src/prompts/system/system-prompt.md +13 -24
  305. package/src/prompts/system/title-system.md +2 -2
  306. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  307. package/src/prompts/system/workflow-notice.md +1 -1
  308. package/src/prompts/tools/ast-edit.md +1 -1
  309. package/src/prompts/tools/ast-grep.md +2 -2
  310. package/src/prompts/tools/bash.md +5 -7
  311. package/src/prompts/tools/browser.md +7 -7
  312. package/src/prompts/tools/debug.md +1 -1
  313. package/src/prompts/tools/eval.md +3 -3
  314. package/src/prompts/tools/find.md +0 -1
  315. package/src/prompts/tools/github.md +8 -7
  316. package/src/prompts/tools/goal.md +1 -1
  317. package/src/prompts/tools/image-gen.md +1 -1
  318. package/src/prompts/tools/inspect-image-system.md +1 -1
  319. package/src/prompts/tools/irc.md +15 -15
  320. package/src/prompts/tools/lsp.md +2 -2
  321. package/src/prompts/tools/patch.md +2 -2
  322. package/src/prompts/tools/read.md +3 -4
  323. package/src/prompts/tools/recall.md +1 -1
  324. package/src/prompts/tools/reflect.md +1 -1
  325. package/src/prompts/tools/render-mermaid.md +2 -2
  326. package/src/prompts/tools/replace.md +4 -10
  327. package/src/prompts/tools/rewind.md +2 -2
  328. package/src/prompts/tools/search-tool-bm25.md +1 -9
  329. package/src/prompts/tools/search.md +0 -1
  330. package/src/prompts/tools/ssh.md +0 -4
  331. package/src/prompts/tools/task.md +2 -3
  332. package/src/prompts/tools/todo.md +1 -1
  333. package/src/sdk.ts +31 -11
  334. package/src/secrets/index.ts +8 -1
  335. package/src/secrets/obfuscator.ts +39 -18
  336. package/src/session/agent-session.ts +223 -64
  337. package/src/session/auth-broker-config.ts +30 -1
  338. package/src/session/session-manager.ts +2 -2
  339. package/src/session/streaming-output.ts +188 -11
  340. package/src/slash-commands/acp-builtins.ts +24 -0
  341. package/src/slash-commands/builtin-registry.ts +40 -0
  342. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  343. package/src/slash-commands/types.ts +1 -1
  344. package/src/ssh/connection-manager.ts +27 -0
  345. package/src/system-prompt.ts +14 -0
  346. package/src/task/commands.ts +2 -1
  347. package/src/task/executor.ts +74 -65
  348. package/src/task/index.ts +146 -68
  349. package/src/task/parallel.ts +3 -3
  350. package/src/task/render.ts +20 -5
  351. package/src/task/types.ts +9 -0
  352. package/src/task/worktree.ts +64 -56
  353. package/src/thinking.ts +9 -1
  354. package/src/tiny/title-client.ts +60 -16
  355. package/src/tiny/title-protocol.ts +1 -1
  356. package/src/tiny/worker.ts +6 -4
  357. package/src/tools/archive-reader.ts +30 -2
  358. package/src/tools/ask.ts +104 -21
  359. package/src/tools/ast-edit.ts +25 -5
  360. package/src/tools/auto-generated-guard.ts +20 -3
  361. package/src/tools/bash-interactive.ts +27 -7
  362. package/src/tools/bash.ts +100 -18
  363. package/src/tools/browser/launch.ts +11 -2
  364. package/src/tools/browser/readable.ts +19 -2
  365. package/src/tools/browser/registry.ts +4 -1
  366. package/src/tools/browser/render.ts +2 -2
  367. package/src/tools/browser/tab-supervisor.ts +55 -16
  368. package/src/tools/conflict-detect.ts +50 -4
  369. package/src/tools/debug.ts +1 -1
  370. package/src/tools/eval-render.ts +5 -5
  371. package/src/tools/eval.ts +0 -2
  372. package/src/tools/fetch.ts +33 -10
  373. package/src/tools/gh-cache-invalidation.ts +63 -8
  374. package/src/tools/gh-renderer.ts +1 -1
  375. package/src/tools/gh.ts +172 -29
  376. package/src/tools/github-cache.ts +70 -6
  377. package/src/tools/image-gen.ts +14 -13
  378. package/src/tools/index.ts +13 -1
  379. package/src/tools/inspect-image.ts +1 -0
  380. package/src/tools/irc.ts +5 -1
  381. package/src/tools/job.ts +1 -1
  382. package/src/tools/read.ts +202 -61
  383. package/src/tools/render-utils.ts +3 -3
  384. package/src/tools/resolve.ts +1 -1
  385. package/src/tools/search.ts +92 -29
  386. package/src/tools/sqlite-reader.ts +17 -5
  387. package/src/tools/ssh.ts +8 -8
  388. package/src/tools/todo.ts +38 -8
  389. package/src/tools/write.ts +118 -18
  390. package/src/tui/output-block.ts +4 -4
  391. package/src/utils/changelog.ts +27 -1
  392. package/src/utils/commit-message-generator.ts +1 -0
  393. package/src/utils/file-mentions.ts +2 -1
  394. package/src/utils/git.ts +267 -13
  395. package/src/utils/title-generator.ts +24 -5
  396. package/src/web/scrapers/arxiv.ts +1 -1
  397. package/src/web/scrapers/go-pkg.ts +1 -1
  398. package/src/web/scrapers/iacr.ts +1 -1
  399. package/src/web/scrapers/readthedocs.ts +1 -1
  400. package/src/web/scrapers/twitter.ts +2 -1
  401. package/src/web/scrapers/types.ts +87 -8
  402. package/src/web/scrapers/wikipedia.ts +1 -1
  403. package/src/web/scrapers/youtube.ts +6 -1
  404. package/src/web/search/index.ts +1 -1
  405. package/src/web/search/providers/codex.ts +2 -1
  406. package/src/web/search/providers/gemini.ts +2 -3
  407. package/src/web/search/render.ts +8 -6
  408. package/dist/types/config/model-equivalence.d.ts +0 -24
  409. package/dist/types/config/model-id-affixes.d.ts +0 -12
  410. package/dist/types/config/model-provider-priority.d.ts +0 -1
  411. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  412. package/src/config/model-equivalence.ts +0 -875
  413. package/src/config/model-id-affixes.ts +0 -81
  414. package/src/config/model-provider-priority.ts +0 -56
  415. package/src/exec/idle-timeout-watchdog.ts +0 -126
@@ -1,9 +1,17 @@
1
+ import { execSync } from "node:child_process";
1
2
  import * as path from "node:path";
2
3
  import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry";
3
- import { readModelCache } from "@oh-my-pi/pi-ai/model-cache";
4
- import { createModelManager, type ModelManagerOptions, type ModelRefreshStrategy } from "@oh-my-pi/pi-ai/model-manager";
5
- import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
6
- import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-ai/models";
4
+ import type { Api, Context, Model, ModelSpec, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types";
5
+ import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
6
+ import { buildModel } from "@oh-my-pi/pi-catalog/build";
7
+ import { isVertexExpressOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
8
+ import { readModelCache } from "@oh-my-pi/pi-catalog/model-cache";
9
+ import {
10
+ createModelManager,
11
+ type ModelManagerOptions,
12
+ type ModelRefreshStrategy,
13
+ } from "@oh-my-pi/pi-catalog/model-manager";
14
+ import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/models";
7
15
  import {
8
16
  googleAntigravityModelManagerOptions,
9
17
  googleGeminiCliModelManagerOptions,
@@ -11,79 +19,12 @@ import {
11
19
  PROVIDER_DESCRIPTORS,
12
20
  UNK_CONTEXT_WINDOW,
13
21
  UNK_MAX_TOKENS,
14
- } from "@oh-my-pi/pi-ai/provider-models";
15
- import type { Api, Context, Model, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types";
16
- import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
22
+ } from "@oh-my-pi/pi-catalog/provider-models";
17
23
 
18
24
  // Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
19
25
  // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
20
26
  const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
21
27
 
22
- // Default cap on `max_tokens` for auto-discovered models that do not advertise
23
- // their own output limit (OpenAI-models-list, Ollama, llama.cpp, new-api/
24
- // one-api proxies). 32K matches the upper end of what mainstream
25
- // OpenAI-compatible providers (DeepSeek, MiMo, OpenRouter, etc.) actually
26
- // accept and keeps `min(contextWindow, …)` honoring smaller local windows.
27
- // Conservative caps below this caused providers to drop the connection
28
- // mid-stream when models hit the cap on legitimate large tool calls (see
29
- // issue #1528: `write` payloads >~5KB on deepseek-v4-pro surfaced as
30
- // "socket connection was closed unexpectedly").
31
- const DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
32
-
33
- const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
34
- const OLLAMA_HOST_DEFAULT_PORT = "11434";
35
-
36
- function normalizeOllamaHostEnv(value: string | undefined): string | undefined {
37
- const trimmed = value?.trim();
38
- if (!trimmed) return undefined;
39
- const candidate = trimmed.includes("://")
40
- ? trimmed
41
- : trimmed.startsWith("//")
42
- ? `http:${trimmed}`
43
- : trimmed.startsWith(":")
44
- ? `http://127.0.0.1${trimmed}`
45
- : `http://${trimmed}`;
46
- try {
47
- const parsed = new URL(candidate);
48
- if (!parsed.hostname || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
49
- return undefined;
50
- }
51
- if (!parsed.port && parsed.protocol === "http:") {
52
- parsed.port = OLLAMA_HOST_DEFAULT_PORT;
53
- }
54
- return `${parsed.protocol}//${parsed.host}`;
55
- } catch {
56
- return undefined;
57
- }
58
- }
59
-
60
- function getImplicitOllamaBaseUrl(): string {
61
- const baseUrl = Bun.env.OLLAMA_BASE_URL?.trim();
62
- return baseUrl || normalizeOllamaHostEnv(Bun.env.OLLAMA_HOST) || DEFAULT_OLLAMA_BASE_URL;
63
- }
64
-
65
- function getOllamaContextLengthOverride(): number | undefined {
66
- const value = Bun.env.OLLAMA_CONTEXT_LENGTH?.trim();
67
- if (!value) return undefined;
68
- const parsed = Number(value);
69
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
70
- }
71
-
72
- // Anthropic-safe variant of the discovery cap. The Anthropic stream converter
73
- // in `packages/ai/src/providers/anthropic.ts` derives the request limit as
74
- // `(model.maxTokens / 3) | 0`, so the 32K default would surface as 10,922
75
- // requested output tokens — above the 8,192 hard cap on classic Claude 3.x
76
- // Sonnet/Haiku/Opus endpoints. Discovered models routed through
77
- // `anthropic-messages` (proxy `supported_endpoint_types: ["anthropic"]` or a
78
- // custom provider with `api: anthropic-messages` + openai-models-list
79
- // discovery) fall back to this conservative value.
80
- const DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC = 8_192;
81
-
82
- /** Routes discovered-model `maxTokens` defaults around Anthropic's 3× output divisor. */
83
- function discoveryDefaultMaxTokens(api: Api | undefined): number {
84
- return api === "anthropic-messages" ? DISCOVERY_DEFAULT_MAX_TOKENS_ANTHROPIC : DISCOVERY_DEFAULT_MAX_TOKENS;
85
- }
86
-
87
28
  const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [
88
29
  "google-antigravity",
89
30
  "google-gemini-cli",
@@ -98,35 +39,37 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
98
39
  import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
99
40
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
100
41
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
101
- import { isRecord, logger } from "@oh-my-pi/pi-utils";
102
- import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
103
- import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
104
- import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
105
- import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
106
- import { type ConfigError, ConfigFile } from "./config-file";
107
42
  import {
108
43
  buildCanonicalModelIndex,
44
+ buildCanonicalModelOrder,
45
+ buildModelProviderPriorityRank,
109
46
  type CanonicalModelIndex,
110
47
  type CanonicalModelRecord,
111
48
  type CanonicalModelVariant,
49
+ type CanonicalVariantPreferences,
112
50
  formatCanonicalVariantSelector,
51
+ getBundledCanonicalReferenceData,
52
+ getBundledModelReferenceIndex,
113
53
  type ModelEquivalenceConfig,
114
- } from "./model-equivalence";
115
- import {
116
- getBracketStrippedModelIdCandidates,
117
- getLongestModelLikeIdSegment,
118
- getModelLikeIdSegments,
119
- stripBracketedModelIdAffixes,
120
- } from "./model-id-affixes";
121
- import { buildModelProviderPriorityRank } from "./model-provider-priority";
54
+ resolveCanonicalVariant,
55
+ resolveModelReference,
56
+ } from "@oh-my-pi/pi-catalog/identity";
57
+ import { isRecord, logger } from "@oh-my-pi/pi-utils";
58
+ import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
59
+ import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
60
+ import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
61
+ import type { ConfigError, ConfigFile } from "./config-file";
122
62
  import {
123
- type ModelOverride,
124
- type ModelsConfig,
125
- ModelsConfigSchema,
126
- type ProviderAuthMode,
127
- type ProviderDiscovery,
128
- } from "./models-config-schema";
129
- import { type Settings, settings } from "./settings";
63
+ DISCOVERY_DEFAULT_MAX_TOKENS,
64
+ type DiscoveryContext,
65
+ type DiscoveryProviderConfig,
66
+ discoverModelsByProviderType,
67
+ getImplicitOllamaBaseUrl,
68
+ getOllamaContextLengthOverride,
69
+ } from "./model-discovery";
70
+ import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config";
71
+ import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema";
72
+ import { settings } from "./settings";
130
73
 
131
74
  export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig };
132
75
 
@@ -136,196 +79,13 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st
136
79
  return Boolean(apiKey) && apiKey !== kNoAuth;
137
80
  }
138
81
 
139
- export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task";
140
-
141
- export interface ModelRoleInfo {
142
- tag?: string;
143
- name: string;
144
- color?: ThemeColor;
145
- }
146
-
147
- export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
148
- default: { tag: "DEFAULT", name: "Default", color: "success" },
149
- smol: { tag: "SMOL", name: "Fast", color: "warning" },
150
- slow: { tag: "SLOW", name: "Thinking", color: "accent" },
151
- vision: { tag: "VISION", name: "Vision", color: "error" },
152
- plan: { tag: "PLAN", name: "Architect", color: "muted" },
153
- designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
154
- commit: { tag: "COMMIT", name: "Commit", color: "dim" },
155
- task: { tag: "TASK", name: "Subtask", color: "muted" },
156
- };
157
-
158
- export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"];
159
-
160
- /** Alias for ModelRoleInfo - used for both built-in and custom roles */
161
- export type RoleInfo = ModelRoleInfo;
162
-
163
- /**
164
- * Return the canonical set of known roles for selector/carousel UI.
165
- *
166
- * Built-ins always come first. Configured cycle order, model assignments, and
167
- * tag metadata can introduce additional custom roles without requiring duplicate
168
- * entries across settings.
169
- */
170
- export function getKnownRoleIds(settings: Settings): string[] {
171
- const roles = [...MODEL_ROLE_IDS] as string[];
172
- const seen = new Set<string>(roles);
173
- const addRole = (role: string) => {
174
- if (seen.has(role)) return;
175
- seen.add(role);
176
- roles.push(role);
177
- };
178
-
179
- for (const role of settings.get("cycleOrder")) addRole(role);
180
- for (const role of Object.keys(settings.getModelRoles())) addRole(role);
181
- for (const role of Object.keys(settings.get("modelTags"))) addRole(role);
182
-
183
- return roles;
184
- }
185
-
186
- /**
187
- * Get role info for a role name (built-in or custom).
188
- * Configured metadata overrides built-in defaults when present.
189
- */
190
- export function getRoleInfo(role: string, settings: Settings): RoleInfo {
191
- const builtIn = role in MODEL_ROLES ? MODEL_ROLES[role as ModelRole] : undefined;
192
- const configured = settings.get("modelTags")[role];
193
-
194
- if (configured) {
195
- return {
196
- tag: builtIn?.tag,
197
- name: configured.name || builtIn?.name || role,
198
- color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color,
199
- };
200
- }
201
-
202
- if (builtIn) return builtIn;
203
-
204
- return { name: role, color: "muted" };
205
- }
206
-
207
- type ProviderValidationMode = "models-config" | "runtime-register";
208
-
209
- interface ProviderValidationModel {
210
- id: string;
211
- api?: Api;
212
- contextWindow?: number;
213
- maxTokens?: number;
214
- }
215
-
216
- interface ProviderValidationConfig {
217
- baseUrl?: string;
218
- headers?: Record<string, string>;
219
- apiKey?: string;
220
- api?: Api;
221
- auth?: ProviderAuthMode;
222
- oauthConfigured?: boolean;
223
- discovery?: ProviderDiscovery;
224
- compat?: Model<Api>["compat"];
225
- disableStrictTools?: boolean;
226
- modelOverrides?: Record<string, unknown>;
227
- models: ProviderValidationModel[];
228
- }
229
-
230
- function validateProviderConfiguration(
231
- providerName: string,
232
- config: ProviderValidationConfig,
233
- mode: ProviderValidationMode,
234
- ): void {
235
- const hasProviderApi = !!config.api;
236
- const models = config.models;
237
-
238
- if (models.length === 0) {
239
- if (mode === "models-config") {
240
- const hasModelOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
241
- if (
242
- !config.baseUrl &&
243
- !config.headers &&
244
- !config.compat &&
245
- !config.apiKey &&
246
- !config.disableStrictTools &&
247
- !hasModelOverrides &&
248
- !config.discovery
249
- ) {
250
- throw new Error(
251
- `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`,
252
- );
253
- }
254
- }
255
- } else {
256
- if (!config.baseUrl) {
257
- throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
258
- }
259
- const requiresAuth =
260
- mode === "runtime-register"
261
- ? !config.apiKey && !config.oauthConfigured
262
- : !config.apiKey && (config.auth ?? "apiKey") !== "none";
263
- if (requiresAuth) {
264
- throw new Error(
265
- mode === "runtime-register"
266
- ? `Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`
267
- : `Provider ${providerName}: "apiKey" is required when defining custom models unless auth is "none".`,
268
- );
269
- }
270
- }
271
-
272
- if (mode === "models-config" && config.discovery && !config.api && config.discovery.type !== "proxy") {
273
- throw new Error(`Provider ${providerName}: "api" is required when discovery is enabled at provider level.`);
274
- }
275
-
276
- for (const modelDef of models) {
277
- if (!hasProviderApi && !modelDef.api) {
278
- throw new Error(
279
- mode === "runtime-register"
280
- ? `Provider ${providerName}, model ${modelDef.id}: no "api" specified.`
281
- : `Provider ${providerName}, model ${modelDef.id}: no "api" specified. Set at provider or model level.`,
282
- );
283
- }
284
- if (!modelDef.id) {
285
- throw new Error(`Provider ${providerName}: model missing "id"`);
286
- }
287
- if (mode === "models-config") {
288
- if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0) {
289
- throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`);
290
- }
291
- if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0) {
292
- throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`);
293
- }
294
- }
295
- }
296
- }
297
-
298
- export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsConfigSchema).withValidation(
299
- "models",
300
- config => {
301
- for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
302
- validateProviderConfiguration(
303
- providerName,
304
- {
305
- baseUrl: providerConfig.baseUrl,
306
- headers: providerConfig.headers,
307
- apiKey: providerConfig.apiKey,
308
- api: providerConfig.api as Api | undefined,
309
- auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode,
310
- discovery: providerConfig.discovery as ProviderDiscovery | undefined,
311
- compat: providerConfig.compat,
312
- disableStrictTools: providerConfig.disableStrictTools,
313
- modelOverrides: providerConfig.modelOverrides,
314
- models: (providerConfig.models ?? []) as ProviderValidationModel[],
315
- },
316
- "models-config",
317
- );
318
- }
319
- },
320
- );
321
-
322
82
  /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
323
83
  interface ProviderOverride {
324
84
  baseUrl?: string;
325
85
  headers?: Record<string, string>;
326
86
  apiKey?: string;
327
87
  authHeader?: boolean;
328
- compat?: Model<Api>["compat"];
88
+ compat?: ModelSpec<Api>["compat"];
329
89
  transport?: Model<Api>["transport"];
330
90
  }
331
91
 
@@ -351,19 +111,21 @@ export function mergeDiscoveredModel<TApi extends Api>(
351
111
  providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "transport">,
352
112
  ): Model<TApi> {
353
113
  if (existing) {
354
- return {
114
+ return buildModel({
355
115
  ...model,
356
116
  baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
357
117
  headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
358
- };
118
+ compat: model.compatConfig,
119
+ } as ModelSpec<TApi>);
359
120
  }
360
121
  if (providerOverride) {
361
- return {
122
+ return buildModel({
362
123
  ...model,
363
124
  baseUrl: providerOverride.baseUrl ?? model.baseUrl,
364
125
  headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
365
126
  ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
366
- };
127
+ compat: model.compatConfig,
128
+ } as ModelSpec<TApi>);
367
129
  }
368
130
  return model;
369
131
  }
@@ -378,7 +140,7 @@ function isAuthoritativeProjectCatalogModel(model: Model<Api>): boolean {
378
140
  return (
379
141
  model.provider === "google-vertex" &&
380
142
  model.api === "openai-completions" &&
381
- model.baseUrl.includes("/endpoints/openapi")
143
+ isVertexExpressOpenAIUrl(model.baseUrl)
382
144
  );
383
145
  }
384
146
 
@@ -396,14 +158,32 @@ function dropProviderModels(models: readonly Model<Api>[], providers: ReadonlySe
396
158
  return models.filter(model => !providers.has(model.provider));
397
159
  }
398
160
 
399
- interface DiscoveryProviderConfig {
400
- provider: string;
401
- api: Api;
402
- baseUrl?: string;
403
- headers?: Record<string, string>;
404
- compat?: Model<Api>["compat"];
405
- discovery: ProviderDiscovery;
406
- optional?: boolean;
161
+ /**
162
+ * Merge `incoming` entries into a copy of `base`, keyed by `provider`+`id`.
163
+ * Matches are replaced with `combine(existing, entry)`; new entries are
164
+ * appended as `combine(undefined, entry)`.
165
+ */
166
+ function mergeByModelKey<T extends { provider: string; id: string }>(
167
+ base: readonly Model<Api>[],
168
+ incoming: readonly T[],
169
+ combine: (existing: Model<Api> | undefined, entry: T) => Model<Api>,
170
+ ): Model<Api>[] {
171
+ const merged = [...base];
172
+ const indexByKey = new Map<string, number>();
173
+ for (let i = 0; i < merged.length; i += 1) {
174
+ indexByKey.set(`${merged[i].provider}\u0000${merged[i].id}`, i);
175
+ }
176
+ for (const entry of incoming) {
177
+ const key = `${entry.provider}\u0000${entry.id}`;
178
+ const existingIndex = indexByKey.get(key);
179
+ if (existingIndex !== undefined) {
180
+ merged[existingIndex] = combine(merged[existingIndex], entry);
181
+ } else {
182
+ merged.push(combine(undefined, entry));
183
+ indexByKey.set(key, merged.length - 1);
184
+ }
185
+ }
186
+ return merged;
407
187
  }
408
188
 
409
189
  interface BuiltInDiscoveryResult {
@@ -447,78 +227,50 @@ interface CustomModelsResult {
447
227
  found: boolean;
448
228
  }
449
229
 
450
- type OllamaDiscoveredModelMetadata = {
451
- reasoning: boolean;
452
- input: ("text" | "image")[];
453
- contextWindow?: number;
454
- };
455
-
456
- type LlamaCppDiscoveredServerMetadata = {
457
- contextWindow?: number;
458
- input?: ("text" | "image")[];
459
- };
460
-
461
- /**
462
- * Resolve an API key config value to an actual key.
463
- * Checks environment variable first, then treats as literal.
464
- */
465
- function resolveApiKeyConfig(keyConfig: string): string | undefined {
466
- const envValue = Bun.env[keyConfig];
467
- if (envValue) return envValue;
468
- return keyConfig;
469
- }
230
+ const commandValueCache = new Map<string, string>();
470
231
 
471
- function toPositiveNumberOrUndefined(value: unknown): number | undefined {
472
- if (typeof value === "number" && Number.isFinite(value) && value > 0) {
473
- return value;
474
- }
475
- if (typeof value === "string" && value.trim()) {
476
- const parsed = Number(value);
477
- if (Number.isFinite(parsed) && parsed > 0) {
478
- return parsed;
479
- }
480
- }
481
- return undefined;
232
+ function isCommandConfigValue(valueConfig: string | undefined): valueConfig is string {
233
+ return valueConfig?.startsWith("!") === true;
482
234
  }
483
235
 
484
- function extractOllamaContextWindow(payload: Record<string, unknown>): number | undefined {
485
- const modelInfo = payload.model_info;
486
- if (isRecord(modelInfo)) {
487
- for (const [key, value] of Object.entries(modelInfo)) {
488
- if (key === "context_length" || key.endsWith(".context_length")) {
489
- const contextWindow = toPositiveNumberOrUndefined(value);
490
- if (contextWindow !== undefined) {
491
- return contextWindow;
492
- }
493
- }
494
- }
495
- }
496
-
497
- const parameters = payload.parameters;
498
- if (typeof parameters !== "string") {
236
+ function resolveCommandConfig(command: string): string | undefined {
237
+ const cached = commandValueCache.get(command);
238
+ if (cached !== undefined) return cached;
239
+ try {
240
+ const stdout = execSync(command, { encoding: "utf8", timeout: 10_000, windowsHide: true });
241
+ const trimmed = stdout.trim();
242
+ if (trimmed.length === 0) return undefined;
243
+ commandValueCache.set(command, trimmed);
244
+ return trimmed;
245
+ } catch {
499
246
  return undefined;
500
247
  }
501
- const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
502
- return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
503
248
  }
504
249
 
505
- function extractLlamaCppContextWindow(payload: Record<string, unknown>): number | undefined {
506
- const generationSettings = payload.default_generation_settings;
507
- if (isRecord(generationSettings)) {
508
- const contextWindow = toPositiveNumberOrUndefined(generationSettings.n_ctx);
509
- if (contextWindow !== undefined) {
510
- return contextWindow;
511
- }
512
- }
513
- return toPositiveNumberOrUndefined(payload.n_ctx);
250
+ interface CommandApiKeyResolution {
251
+ configured: boolean;
252
+ value?: string;
253
+ }
254
+ /**
255
+ * Resolve a models.yml secret/config value to an actual value.
256
+ * `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are
257
+ * checked first and the input falls back to a literal value.
258
+ */
259
+ function resolveConfigValue(valueConfig: string): string | undefined {
260
+ if (valueConfig.startsWith("!")) return resolveCommandConfig(valueConfig.slice(1).trim());
261
+ const envValue = Bun.env[valueConfig];
262
+ if (envValue) return envValue;
263
+ return valueConfig;
514
264
  }
515
265
 
516
- function extractLlamaCppInputCapabilities(payload: Record<string, unknown>): ("text" | "image")[] | undefined {
517
- const modalities = payload.modalities;
518
- if (!isRecord(modalities)) {
519
- return undefined;
266
+ function resolveConfigHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
267
+ if (!headers) return undefined;
268
+ const resolved: Record<string, string> = {};
269
+ for (const [key, value] of Object.entries(headers)) {
270
+ const next = resolveConfigValue(value);
271
+ if (next) resolved[key] = next;
520
272
  }
521
- return modalities.vision === true ? ["text", "image"] : ["text"];
273
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
522
274
  }
523
275
 
524
276
  function extractGoogleOAuthToken(value: string | undefined): string | undefined {
@@ -579,73 +331,99 @@ function mergeCompat<TBase extends object, TOverride extends object>(
579
331
  return merged as TBase & TOverride;
580
332
  }
581
333
 
582
- function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
583
- const result = { ...model };
584
- if (override.name !== undefined) result.name = override.name;
585
- if (override.reasoning !== undefined) result.reasoning = override.reasoning;
586
- if (override.thinking !== undefined) result.thinking = override.thinking as ThinkingConfig;
587
- if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
588
- if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow;
589
- if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
590
- if (override.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = override.omitMaxOutputTokens;
591
- if (override.contextPromotionTarget !== undefined) result.contextPromotionTarget = override.contextPromotionTarget;
592
- if (override.premiumMultiplier !== undefined) result.premiumMultiplier = override.premiumMultiplier;
593
- if (override.cost) {
594
- result.cost = {
595
- input: override.cost.input ?? model.cost.input,
596
- output: override.cost.output ?? model.cost.output,
597
- cacheRead: override.cost.cacheRead ?? model.cost.cacheRead,
598
- cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite,
599
- };
600
- }
601
- if (override.headers) {
602
- result.headers = { ...model.headers, ...override.headers };
603
- }
604
- result.compat = mergeCompat(model.compat, override.compat);
605
- return enrichModelThinking(result);
334
+ /**
335
+ * Project a built model back to spec shape for the model-manager/cache
336
+ * boundary: sparse compat comes from `compatConfig`, never from the resolved
337
+ * record.
338
+ */
339
+ function toModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
340
+ return { ...model, compat: model.compatConfig } as ModelSpec<TApi>;
606
341
  }
607
342
 
608
- interface CustomModelDefinitionLike {
609
- id: string;
343
+ /**
344
+ * The patchable subset of `Model` fields shared by `modelOverrides` entries,
345
+ * custom model definitions, and parsed custom-model overlays. `undefined`
346
+ * always means "leave the base value alone".
347
+ */
348
+ interface ModelPatch {
610
349
  name?: string;
611
- api?: Api;
612
- baseUrl?: string;
613
350
  reasoning?: boolean;
614
351
  thinking?: ThinkingConfig;
615
352
  input?: ("text" | "image")[];
616
- cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
353
+ cost?: Partial<Model<Api>["cost"]>;
617
354
  contextWindow?: number;
618
355
  maxTokens?: number;
619
356
  omitMaxOutputTokens?: boolean;
620
357
  headers?: Record<string, string>;
621
- compat?: Model<Api>["compat"];
358
+ compat?: ModelSpec<Api>["compat"];
622
359
  contextPromotionTarget?: string;
623
360
  premiumMultiplier?: number;
624
361
  }
625
362
 
363
+ /**
364
+ * How a patch treats the base model's transport metadata (headers/compat):
365
+ * - `merge`: fold the patch into the base's (modelOverrides semantics).
366
+ * - `replace`: the patch owns transport wholesale — same-id custom definitions
367
+ * already folded provider-level headers/compat in during parsing, so bundled
368
+ * transport metadata must not be re-merged (see `#mergeCustomModels`).
369
+ */
370
+ type ModelTransportPolicy = "merge" | "replace";
371
+
372
+ function applyModelPatch(base: Model<Api>, patch: ModelPatch, transport: ModelTransportPolicy): Model<Api> {
373
+ const result = { ...base };
374
+ if (patch.name !== undefined) result.name = patch.name;
375
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
376
+ if (patch.thinking !== undefined) result.thinking = patch.thinking;
377
+ if (patch.input !== undefined) result.input = patch.input;
378
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
379
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
380
+ if (patch.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = patch.omitMaxOutputTokens;
381
+ if (patch.contextPromotionTarget !== undefined) result.contextPromotionTarget = patch.contextPromotionTarget;
382
+ if (patch.premiumMultiplier !== undefined) result.premiumMultiplier = patch.premiumMultiplier;
383
+ if (patch.cost) {
384
+ result.cost = {
385
+ input: patch.cost.input ?? base.cost.input,
386
+ output: patch.cost.output ?? base.cost.output,
387
+ cacheRead: patch.cost.cacheRead ?? base.cost.cacheRead,
388
+ cacheWrite: patch.cost.cacheWrite ?? base.cost.cacheWrite,
389
+ };
390
+ }
391
+ let compat: ModelSpec<Api>["compat"];
392
+ if (transport === "merge") {
393
+ if (patch.headers) {
394
+ result.headers = { ...base.headers, ...patch.headers };
395
+ }
396
+ compat = mergeCompat(base.compatConfig, patch.compat);
397
+ } else {
398
+ result.headers = patch.headers;
399
+ compat = patch.compat;
400
+ }
401
+ return buildModel({ ...result, compat } as ModelSpec<Api>);
402
+ }
403
+
404
+ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
405
+ return applyModelPatch(model, override as ModelPatch, "merge");
406
+ }
407
+
408
+ interface CustomModelDefinitionLike extends ModelPatch {
409
+ id: string;
410
+ api?: Api;
411
+ baseUrl?: string;
412
+ cost?: Model<Api>["cost"];
413
+ }
414
+
626
415
  interface CustomModelBuildOptions {
627
416
  useDefaults: boolean;
628
417
  }
629
418
 
630
- type CustomModelOverlay = {
419
+ interface CustomModelOverlay extends ModelPatch {
631
420
  id: string;
632
421
  provider: string;
633
422
  api: Api;
634
423
  baseUrl: string;
635
- name?: string;
636
- reasoning?: boolean;
637
- thinking?: ThinkingConfig;
638
- input?: ("text" | "image")[];
639
- cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
640
- contextWindow?: number;
641
- maxTokens?: number;
642
- omitMaxOutputTokens?: boolean;
643
- headers?: Record<string, string>;
644
- compat?: Model<Api>["compat"];
645
- contextPromotionTarget?: string;
646
- premiumMultiplier?: number;
424
+ cost?: Model<Api>["cost"];
647
425
  isOAuth?: boolean;
648
- };
426
+ }
649
427
 
650
428
  function mergeCustomModelHeaders(
651
429
  providerHeaders: Record<string, string> | undefined,
@@ -653,7 +431,8 @@ function mergeCustomModelHeaders(
653
431
  authHeader: boolean | undefined,
654
432
  apiKeyConfig: string | undefined,
655
433
  ): Record<string, string> | undefined {
656
- return mergeAuthHeader({ ...providerHeaders, ...modelHeaders }, authHeader, apiKeyConfig);
434
+ const resolvedModelHeaders = resolveConfigHeaders(modelHeaders);
435
+ return mergeAuthHeader({ ...providerHeaders, ...resolvedModelHeaders }, authHeader, apiKeyConfig);
657
436
  }
658
437
 
659
438
  function mergeAuthHeader(
@@ -665,7 +444,7 @@ function mergeAuthHeader(
665
444
  if (!authHeader || !apiKeyConfig) {
666
445
  return nextHeaders;
667
446
  }
668
- const resolvedKey = resolveApiKeyConfig(apiKeyConfig);
447
+ const resolvedKey = resolveConfigValue(apiKeyConfig);
669
448
  return resolvedKey ? { ...nextHeaders, Authorization: `Bearer ${resolvedKey}` } : nextHeaders;
670
449
  }
671
450
 
@@ -692,7 +471,7 @@ function buildCustomModelOverlay(
692
471
  providerHeaders: Record<string, string> | undefined,
693
472
  providerApiKey: string | undefined,
694
473
  authHeader: boolean | undefined,
695
- providerCompat: Model<Api>["compat"] | undefined,
474
+ providerCompat: ModelSpec<Api>["compat"] | undefined,
696
475
  providerAuth: ProviderAuthMode | undefined,
697
476
  modelDef: CustomModelDefinitionLike,
698
477
  ): CustomModelOverlay | undefined {
@@ -705,8 +484,8 @@ function buildCustomModelOverlay(
705
484
  baseUrl: modelDef.baseUrl ?? providerBaseUrl,
706
485
  name: modelDef.name,
707
486
  reasoning: modelDef.reasoning,
708
- thinking: modelDef.thinking as ThinkingConfig | undefined,
709
- input: modelDef.input as ("text" | "image")[] | undefined,
487
+ thinking: modelDef.thinking,
488
+ input: modelDef.input,
710
489
  cost: modelDef.cost,
711
490
  contextWindow: modelDef.contextWindow,
712
491
  maxTokens: modelDef.maxTokens,
@@ -719,125 +498,6 @@ function buildCustomModelOverlay(
719
498
  };
720
499
  }
721
500
 
722
- // Custom provider entries often front a known upstream model through a local proxy.
723
- // Use bundled metadata for missing pricing/capability fields, but keep the custom transport.
724
- function shouldReplaceCustomReference(existing: Model<Api> | undefined, candidate: Model<Api>): boolean {
725
- if (!existing) return true;
726
- if (candidate.contextWindow !== existing.contextWindow) {
727
- return candidate.contextWindow > existing.contextWindow;
728
- }
729
- if (candidate.maxTokens !== existing.maxTokens) {
730
- return candidate.maxTokens > existing.maxTokens;
731
- }
732
- const existingHasCachePricing = existing.cost.cacheRead > 0 || existing.cost.cacheWrite > 0;
733
- const candidateHasCachePricing = candidate.cost.cacheRead > 0 || candidate.cost.cacheWrite > 0;
734
- if (candidateHasCachePricing !== existingHasCachePricing) {
735
- return candidateHasCachePricing;
736
- }
737
- return existing.provider !== "openai" && candidate.provider === "openai";
738
- }
739
-
740
- function normalizeCustomReferenceKey(value: string): string {
741
- return value.trim().toLowerCase();
742
- }
743
-
744
- function buildCustomReferenceMap(): Map<string, Model<Api>> {
745
- const references = new Map<string, Model<Api>>();
746
- for (const provider of getBundledProviders()) {
747
- for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0])) {
748
- const candidate = model as Model<Api>;
749
- const key = normalizeCustomReferenceKey(candidate.id);
750
- if (shouldReplaceCustomReference(references.get(key), candidate)) {
751
- references.set(key, candidate);
752
- }
753
- }
754
- }
755
- return references;
756
- }
757
-
758
- function buildCustomReferenceSuffixAliasMap(exactReferences: ReadonlyMap<string, Model<Api>>): Map<string, Model<Api>> {
759
- const aliases = new Map<string, Model<Api>>();
760
- for (const reference of exactReferences.values()) {
761
- const slashIndex = reference.id.lastIndexOf("/");
762
- if (slashIndex === -1) {
763
- continue;
764
- }
765
- const suffix = reference.id.slice(slashIndex + 1);
766
- const alias = getLongestModelLikeIdSegment(suffix);
767
- if (!alias) {
768
- continue;
769
- }
770
- if (shouldReplaceCustomReference(aliases.get(alias), reference)) {
771
- aliases.set(alias, reference);
772
- }
773
- }
774
- return aliases;
775
- }
776
-
777
- const customReferenceMap = buildCustomReferenceMap();
778
- const customReferenceSuffixAliasMap = buildCustomReferenceSuffixAliasMap(customReferenceMap);
779
-
780
- const CUSTOM_REFERENCE_TRAILING_MARKER_PATTERN =
781
- /[-:](?:thinking|customtools|high|low|medium|minimal|xhigh|free|cloud|exacto|nitro|original|optimized|nvfp4|fp8|fp4|bf16|int8|int4|search)$/i;
782
-
783
- function stripCustomReferenceTrailingMarker(candidate: string): string | undefined {
784
- const match = CUSTOM_REFERENCE_TRAILING_MARKER_PATTERN.exec(candidate);
785
- return match ? candidate.slice(0, match.index) : undefined;
786
- }
787
-
788
- function getCustomReferenceCandidateIds(modelId: string): string[] {
789
- const candidates = new Set<string>();
790
- const queue = [modelId];
791
- for (let index = 0; index < queue.length; index += 1) {
792
- const candidate = queue[index]?.trim();
793
- if (!candidate || candidates.has(candidate)) continue;
794
- candidates.add(candidate);
795
-
796
- for (const stripped of getBracketStrippedModelIdCandidates(candidate)) {
797
- queue.push(stripped);
798
- }
799
- for (const segment of getModelLikeIdSegments(candidate)) {
800
- queue.push(segment);
801
- }
802
-
803
- for (const suffix of [":cloud", "-cloud"] as const) {
804
- if (candidate.toLowerCase().endsWith(suffix)) {
805
- queue.push(candidate.slice(0, -suffix.length));
806
- }
807
- }
808
-
809
- const slashIndex = candidate.lastIndexOf("/");
810
- if (slashIndex !== -1) {
811
- queue.push(candidate.slice(slashIndex + 1));
812
- }
813
-
814
- const colonToDash = candidate.replace(/:/g, "-");
815
- if (colonToDash !== candidate) {
816
- queue.push(colonToDash);
817
- }
818
-
819
- const lowercased = candidate.toLowerCase();
820
- if (lowercased !== candidate) {
821
- queue.push(lowercased);
822
- }
823
-
824
- const strippedMarker = stripCustomReferenceTrailingMarker(candidate);
825
- if (strippedMarker) {
826
- queue.push(strippedMarker);
827
- }
828
- }
829
- return [...candidates];
830
- }
831
-
832
- function resolveCustomModelReference(modelId: string): Model<Api> | undefined {
833
- for (const candidate of getCustomReferenceCandidateIds(modelId)) {
834
- const key = normalizeCustomReferenceKey(candidate);
835
- const reference = customReferenceMap.get(key) ?? customReferenceSuffixAliasMap.get(key);
836
- if (reference) return reference;
837
- }
838
- return undefined;
839
- }
840
-
841
501
  function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay {
842
502
  if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) {
843
503
  return model;
@@ -847,13 +507,15 @@ function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomMo
847
507
 
848
508
  function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model<Api> {
849
509
  const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : model;
850
- const reference = options.useDefaults ? resolveCustomModelReference(resolvedModel.id) : undefined;
510
+ const reference = options.useDefaults
511
+ ? resolveModelReference(resolvedModel.id, getBundledModelReferenceIndex())
512
+ : undefined;
851
513
  const cost =
852
514
  resolvedModel.cost ??
853
515
  reference?.cost ??
854
516
  (options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
855
517
  const input = resolvedModel.input ?? reference?.input ?? (options.useDefaults ? ["text"] : undefined);
856
- return enrichModelThinking({
518
+ return buildModel({
857
519
  id: resolvedModel.id,
858
520
  name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
859
521
  api: resolvedModel.api,
@@ -868,11 +530,11 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
868
530
  maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : undefined),
869
531
  headers: resolvedModel.headers,
870
532
  omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens,
871
- compat: mergeCompat(reference?.compat, resolvedModel.compat),
533
+ compat: mergeCompat(reference?.compatConfig, resolvedModel.compat),
872
534
  contextPromotionTarget: resolvedModel.contextPromotionTarget,
873
535
  premiumMultiplier: resolvedModel.premiumMultiplier,
874
536
  isOAuth: resolvedModel.isOAuth,
875
- } as Model<Api>);
537
+ } as ModelSpec<Api>);
876
538
  }
877
539
 
878
540
  function normalizeSuppressedSelector(selector: string): string {
@@ -935,6 +597,28 @@ export class ModelRegistry {
935
597
  #rebuildSuspended: number = 0;
936
598
  #fetch: FetchImpl;
937
599
 
600
+ #resolveCommandBackedApiKey(provider: string): CommandApiKeyResolution {
601
+ const keyConfig = this.#customProviderApiKeys.get(provider);
602
+ if (!isCommandConfigValue(keyConfig)) return { configured: false };
603
+ const value = resolveConfigValue(keyConfig);
604
+ if (value) {
605
+ this.authStorage.setConfigApiKey(provider, value);
606
+ return { configured: true, value };
607
+ }
608
+ this.authStorage.removeConfigApiKey(provider);
609
+ return { configured: true };
610
+ }
611
+
612
+ #installProviderApiKey(provider: string, keyConfig: string): void {
613
+ this.#customProviderApiKeys.set(provider, keyConfig);
614
+ const resolved = resolveConfigValue(keyConfig);
615
+ if (resolved) {
616
+ this.authStorage.setConfigApiKey(provider, resolved);
617
+ } else if (isCommandConfigValue(keyConfig)) {
618
+ this.authStorage.removeConfigApiKey(provider);
619
+ }
620
+ }
621
+
938
622
  /**
939
623
  * @param authStorage - Auth storage for API key resolution
940
624
  *
@@ -955,10 +639,8 @@ export class ModelRegistry {
955
639
  // Set up fallback resolver for custom provider API keys
956
640
  this.authStorage.setFallbackResolver(provider => {
957
641
  const keyConfig = this.#customProviderApiKeys.get(provider);
958
- if (keyConfig) {
959
- return resolveApiKeyConfig(keyConfig);
960
- }
961
- return undefined;
642
+ if (!keyConfig) return undefined;
643
+ return resolveConfigValue(keyConfig);
962
644
  });
963
645
  // Load models synchronously in constructor.
964
646
  this.#loadModels();
@@ -1049,7 +731,7 @@ export class ModelRegistry {
1049
731
  // Restore runtime API keys before #loadModels — survives because
1050
732
  // #loadModels only calls .set() on #customProviderApiKeys, never reassigns it.
1051
733
  for (const [k, v] of this.#runtimeProviderApiKeys) {
1052
- this.#customProviderApiKeys.set(k, v);
734
+ this.#installProviderApiKey(k, v);
1053
735
  }
1054
736
  this.#providerOverrides.clear();
1055
737
  this.#modelOverrides.clear();
@@ -1133,84 +815,46 @@ export class ModelRegistry {
1133
815
  return models.map(m => {
1134
816
  if (!providerOverride) return m;
1135
817
  const withTransportOverride = this.#applyProviderTransportOverride(m, providerOverride);
1136
- return {
818
+ return buildModel({
1137
819
  ...withTransportOverride,
1138
- compat: mergeCompat(m.compat, providerOverride.compat),
1139
- };
820
+ compat: mergeCompat(m.compatConfig, providerOverride.compat),
821
+ } as ModelSpec<Api>);
1140
822
  });
1141
823
  });
1142
824
  }
1143
825
 
1144
826
  #mergeResolvedModels(baseModels: Model<Api>[], replacementModels: Model<Api>[]): Model<Api>[] {
1145
- const merged = [...baseModels];
1146
- const indexByKey = new Map<string, number>();
1147
- for (let i = 0; i < merged.length; i += 1) {
1148
- const m = merged[i];
1149
- indexByKey.set(`${m.provider}\u0000${m.id}`, i);
1150
- }
1151
- for (const replacementModel of replacementModels) {
1152
- const key = `${replacementModel.provider}\u0000${replacementModel.id}`;
1153
- const existingIndex = indexByKey.get(key);
1154
- if (existingIndex !== undefined) {
1155
- const existing = merged[existingIndex];
1156
- merged[existingIndex] = {
1157
- ...replacementModel,
1158
- contextWindow:
1159
- replacementModel.contextWindow === UNK_CONTEXT_WINDOW
1160
- ? existing.contextWindow
1161
- : replacementModel.contextWindow,
1162
- maxTokens:
1163
- replacementModel.maxTokens === UNK_MAX_TOKENS ? existing.maxTokens : replacementModel.maxTokens,
1164
- };
1165
- } else {
1166
- merged.push(replacementModel);
1167
- indexByKey.set(key, merged.length - 1);
1168
- }
1169
- }
1170
- return merged;
827
+ return mergeByModelKey(baseModels, replacementModels, (existing, replacementModel) => {
828
+ if (!existing) return replacementModel;
829
+ return {
830
+ ...replacementModel,
831
+ contextWindow:
832
+ replacementModel.contextWindow === UNK_CONTEXT_WINDOW
833
+ ? existing.contextWindow
834
+ : replacementModel.contextWindow,
835
+ maxTokens: replacementModel.maxTokens === UNK_MAX_TOKENS ? existing.maxTokens : replacementModel.maxTokens,
836
+ };
837
+ });
1171
838
  }
1172
839
 
1173
840
  /** Merge custom models with built-in, replacing by provider+id match */
1174
841
  #mergeCustomModels(builtInModels: Model<Api>[], customModels: CustomModelOverlay[]): Model<Api>[] {
1175
- const merged = [...builtInModels];
1176
- const indexByKey = new Map<string, number>();
1177
- for (let i = 0; i < merged.length; i += 1) {
1178
- const m = merged[i];
1179
- indexByKey.set(`${m.provider}\u0000${m.id}`, i);
1180
- }
1181
- for (const customModel of customModels) {
1182
- const key = `${customModel.provider}\u0000${customModel.id}`;
1183
- const existingIndex = indexByKey.get(key);
1184
- if (existingIndex !== undefined) {
1185
- const existingModel = merged[existingIndex];
1186
- merged[existingIndex] = enrichModelThinking({
842
+ return mergeByModelKey(builtInModels, customModels, (existingModel, customModel) => {
843
+ if (!existingModel) return finalizeCustomModel(customModel, { useDefaults: true });
844
+ // Same-id custom definitions replace bundled transport behavior, so the
845
+ // patch is applied with the `replace` transport policy.
846
+ return applyModelPatch(
847
+ {
1187
848
  ...existingModel,
1188
849
  id: customModel.id,
1189
850
  provider: customModel.provider,
1190
851
  api: customModel.api,
1191
852
  baseUrl: customModel.baseUrl,
1192
- name: customModel.name ?? existingModel.name,
1193
- reasoning: customModel.reasoning ?? existingModel.reasoning,
1194
- thinking: customModel.thinking ?? existingModel.thinking,
1195
- input: customModel.input ?? existingModel.input,
1196
- cost: customModel.cost ?? existingModel.cost,
1197
- contextWindow: customModel.contextWindow ?? existingModel.contextWindow,
1198
- maxTokens: customModel.maxTokens ?? existingModel.maxTokens,
1199
- omitMaxOutputTokens: customModel.omitMaxOutputTokens ?? existingModel.omitMaxOutputTokens,
1200
- // Same-id custom definitions replace bundled transport behavior. Provider-level
1201
- // headers/compat were already folded into customModel during parsing; do not
1202
- // re-merge bundled transport metadata here.
1203
- headers: customModel.headers,
1204
- compat: customModel.compat,
1205
- contextPromotionTarget: customModel.contextPromotionTarget ?? existingModel.contextPromotionTarget,
1206
- premiumMultiplier: customModel.premiumMultiplier ?? existingModel.premiumMultiplier,
1207
- } as Model<Api>);
1208
- } else {
1209
- merged.push(finalizeCustomModel(customModel, { useDefaults: true }));
1210
- indexByKey.set(key, merged.length - 1);
1211
- }
1212
- }
1213
- return merged;
853
+ },
854
+ customModel,
855
+ "replace",
856
+ );
857
+ });
1214
858
  }
1215
859
 
1216
860
  #loadCachedStandardProviderModels(): { models: Model<Api>[]; authoritativeFreshProviders: Set<string> } {
@@ -1236,8 +880,13 @@ export class ModelRegistry {
1236
880
  ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride))
1237
881
  : models;
1238
882
  const withCompat = providerOverride?.compat
1239
- ? withTransport.map(model => ({ ...model, compat: mergeCompat(model.compat, providerOverride.compat) }))
1240
- : withTransport;
883
+ ? withTransport.map(model =>
884
+ buildModel({
885
+ ...model,
886
+ compat: mergeCompat(model.compat, providerOverride.compat),
887
+ } as ModelSpec<Api>),
888
+ )
889
+ : withTransport.map(model => buildModel(model));
1241
890
  cachedModels.push(...this.#applyProviderModelOverrides(providerId, withCompat));
1242
891
  }
1243
892
  return { models: cachedModels, authoritativeFreshProviders };
@@ -1261,7 +910,10 @@ export class ModelRegistry {
1261
910
  providerConfig.provider,
1262
911
  this.#normalizeDiscoverableModels(
1263
912
  providerConfig,
1264
- this.#applyProviderCompat(providerConfig.compat, cache.models),
913
+ this.#applyProviderCompat(
914
+ providerConfig.compat,
915
+ cache.models.map(model => buildModel(model)),
916
+ ),
1265
917
  ),
1266
918
  );
1267
919
  cachedModels.push(...models);
@@ -1277,9 +929,11 @@ export class ModelRegistry {
1277
929
  return cachedModels;
1278
930
  }
1279
931
 
1280
- #applyProviderCompat(compat: Model<Api>["compat"] | undefined, models: Model<Api>[]): Model<Api>[] {
932
+ #applyProviderCompat(compat: ModelSpec<Api>["compat"] | undefined, models: Model<Api>[]): Model<Api>[] {
1281
933
  if (!compat) return models;
1282
- return models.map(model => ({ ...model, compat: mergeCompat(model.compat, compat) }));
934
+ return models.map(model =>
935
+ buildModel({ ...model, compat: mergeCompat(model.compatConfig, compat) } as ModelSpec<Api>),
936
+ );
1283
937
  }
1284
938
 
1285
939
  #normalizeDiscoverableModels(providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
@@ -1289,7 +943,14 @@ export class ModelRegistry {
1289
943
 
1290
944
  const contextLengthOverride = getOllamaContextLengthOverride();
1291
945
  return models.map(model => {
1292
- const normalized = model.api === "openai-completions" ? { ...model, api: "openai-responses" as const } : model;
946
+ const normalized =
947
+ model.api === "openai-completions"
948
+ ? buildModel({
949
+ ...model,
950
+ api: "openai-responses" as const,
951
+ compat: model.compatConfig,
952
+ } as ModelSpec<Api>)
953
+ : model;
1293
954
  if (contextLengthOverride === undefined) {
1294
955
  return normalized;
1295
956
  }
@@ -1372,10 +1033,11 @@ export class ModelRegistry {
1372
1033
  const configuredProviders = new Set(Object.keys(value.providers ?? {}));
1373
1034
 
1374
1035
  for (const [providerName, providerConfig] of providerEntries) {
1036
+ const resolvedProviderHeaders = resolveConfigHeaders(providerConfig.headers);
1375
1037
  // Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/transport are present
1376
1038
  if (
1377
1039
  providerConfig.baseUrl ||
1378
- providerConfig.headers ||
1040
+ resolvedProviderHeaders ||
1379
1041
  providerConfig.apiKey ||
1380
1042
  providerConfig.authHeader !== undefined ||
1381
1043
  providerConfig.compat ||
@@ -1385,7 +1047,7 @@ export class ModelRegistry {
1385
1047
  const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined;
1386
1048
  overrides.set(providerName, {
1387
1049
  baseUrl: providerConfig.baseUrl,
1388
- headers: providerConfig.headers,
1050
+ headers: resolvedProviderHeaders,
1389
1051
  apiKey: providerConfig.apiKey,
1390
1052
  authHeader: providerConfig.authHeader,
1391
1053
  compat: mergeCompat(providerConfig.compat, disableStrictCompat),
@@ -1407,7 +1069,7 @@ export class ModelRegistry {
1407
1069
  // fallback for entries that don't advertise one.
1408
1070
  api: (providerConfig.api ?? "openai-completions") as Api,
1409
1071
  baseUrl: providerConfig.baseUrl,
1410
- headers: providerConfig.headers,
1072
+ headers: resolvedProviderHeaders,
1411
1073
  compat: mergeCompat(providerConfig.compat, disableStrictCompat),
1412
1074
  discovery: providerConfig.discovery,
1413
1075
  optional: false,
@@ -1419,16 +1081,17 @@ export class ModelRegistry {
1419
1081
  // bearer in models.yml (e.g. for an auth-gateway baseUrl), that bearer
1420
1082
  // must authenticate the outbound request.
1421
1083
  if (providerConfig.apiKey) {
1422
- this.#customProviderApiKeys.set(providerName, providerConfig.apiKey);
1423
- const resolved = resolveApiKeyConfig(providerConfig.apiKey);
1424
- if (resolved) this.authStorage.setConfigApiKey(providerName, resolved);
1084
+ this.#installProviderApiKey(providerName, providerConfig.apiKey);
1425
1085
  }
1426
1086
 
1427
1087
  // Parse per-model overrides
1428
1088
  if (providerConfig.modelOverrides) {
1429
1089
  const perModel = new Map<string, ModelOverride>();
1430
1090
  for (const [modelId, override] of Object.entries(providerConfig.modelOverrides)) {
1431
- perModel.set(modelId, override);
1091
+ perModel.set(
1092
+ modelId,
1093
+ override.headers ? { ...override, headers: resolveConfigHeaders(override.headers) } : override,
1094
+ );
1432
1095
  }
1433
1096
  allModelOverrides.set(providerName, perModel);
1434
1097
  }
@@ -1512,17 +1175,20 @@ export class ModelRegistry {
1512
1175
  models: cached?.models.map(model => model.id) ?? [],
1513
1176
  });
1514
1177
  this.#lastDiscoveryWarnings.delete(providerConfig.provider);
1515
- return cached?.models ?? [];
1178
+ return cached ? cached.models.map(model => buildModel(model)) : [];
1516
1179
  }
1517
1180
  }
1518
1181
 
1519
1182
  const providerId = providerConfig.provider;
1520
1183
  let discoveryError: string | undefined;
1521
- const fetchDynamicModels = async (): Promise<readonly Model<Api>[] | null> => {
1184
+ const fetchDynamicModels = async (): Promise<readonly ModelSpec<Api>[] | null> => {
1522
1185
  try {
1523
- const models = await this.#discoverModelsByProviderType(providerConfig);
1186
+ const models = this.#applyProviderModelOverrides(
1187
+ providerId,
1188
+ await discoverModelsByProviderType(providerConfig, this.#discoveryContext()),
1189
+ );
1524
1190
  this.#lastDiscoveryWarnings.delete(providerId);
1525
- return models;
1191
+ return models.map(toModelSpec);
1526
1192
  } catch (error) {
1527
1193
  discoveryError = error instanceof Error ? error.message : String(error);
1528
1194
  return null;
@@ -1569,18 +1235,14 @@ export class ModelRegistry {
1569
1235
  );
1570
1236
  }
1571
1237
 
1572
- #discoverModelsByProviderType(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1573
- switch (providerConfig.discovery.type) {
1574
- case "ollama":
1575
- return this.#discoverOllamaModels(providerConfig);
1576
- case "llama.cpp":
1577
- return this.#discoverLlamaCppModels(providerConfig);
1578
- case "lm-studio":
1579
- case "openai-models-list":
1580
- return this.#discoverOpenAIModelsList(providerConfig);
1581
- case "proxy":
1582
- return this.#discoverProxyModels(providerConfig);
1583
- }
1238
+ #discoveryContext(): DiscoveryContext {
1239
+ return {
1240
+ fetch: this.#fetch,
1241
+ getBearerApiKey: async provider => {
1242
+ const apiKey = await this.getApiKeyForProvider(provider);
1243
+ return apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth ? apiKey : undefined;
1244
+ },
1245
+ };
1584
1246
  }
1585
1247
 
1586
1248
  #warnProviderDiscoveryFailure(providerConfig: DiscoveryProviderConfig, error: string): void {
@@ -1732,361 +1394,6 @@ export class ModelRegistry {
1732
1394
  }
1733
1395
  }
1734
1396
 
1735
- async #discoverOllamaModelMetadata(
1736
- endpoint: string,
1737
- modelId: string,
1738
- headers: Record<string, string> | undefined,
1739
- ): Promise<OllamaDiscoveredModelMetadata | null> {
1740
- const showUrl = `${endpoint}/api/show`;
1741
- try {
1742
- const response = await this.#fetch(showUrl, {
1743
- method: "POST",
1744
- headers: { ...(headers ?? {}), "Content-Type": "application/json" },
1745
- body: JSON.stringify({ model: modelId }),
1746
- signal: AbortSignal.timeout(150),
1747
- });
1748
- if (!response.ok) {
1749
- return null;
1750
- }
1751
- const payload = (await response.json()) as unknown;
1752
- if (!isRecord(payload)) {
1753
- return null;
1754
- }
1755
- const contextWindow = extractOllamaContextWindow(payload);
1756
- const capabilities = payload.capabilities;
1757
- if (Array.isArray(capabilities)) {
1758
- const normalized = new Set(
1759
- capabilities.flatMap(capability => (typeof capability === "string" ? [capability.toLowerCase()] : [])),
1760
- );
1761
- const supportsVision = normalized.has("vision") || normalized.has("image");
1762
- return {
1763
- reasoning: normalized.has("thinking"),
1764
- input: supportsVision ? ["text", "image"] : ["text"],
1765
- contextWindow,
1766
- };
1767
- }
1768
- if (!isRecord(capabilities)) {
1769
- return {
1770
- reasoning: false,
1771
- input: ["text"],
1772
- contextWindow,
1773
- };
1774
- }
1775
- const supportsVision = capabilities.vision === true || capabilities.image === true;
1776
- return {
1777
- reasoning: capabilities.thinking === true,
1778
- input: supportsVision ? ["text", "image"] : ["text"],
1779
- contextWindow,
1780
- };
1781
- } catch {
1782
- return null;
1783
- }
1784
- }
1785
-
1786
- async #discoverOllamaModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1787
- const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl);
1788
- const tagsUrl = `${endpoint}/api/tags`;
1789
- const headers = { ...(providerConfig.headers ?? {}) };
1790
- const response = await this.#fetch(tagsUrl, {
1791
- headers,
1792
- signal: AbortSignal.timeout(250),
1793
- });
1794
- if (!response.ok) {
1795
- throw new Error(`HTTP ${response.status} from ${tagsUrl}`);
1796
- }
1797
- const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> };
1798
- const entries = (payload.models ?? []).flatMap(item => {
1799
- const id = item.model || item.name;
1800
- return id ? [{ id, name: item.name || id }] : [];
1801
- });
1802
- const metadataById = new Map(
1803
- await Promise.all(
1804
- entries.map(
1805
- async entry => [entry.id, await this.#discoverOllamaModelMetadata(endpoint, entry.id, headers)] as const,
1806
- ),
1807
- ),
1808
- );
1809
- const discovered = entries.map(entry => {
1810
- const metadata = metadataById.get(entry.id);
1811
- return enrichModelThinking({
1812
- id: entry.id,
1813
- name: entry.name,
1814
- api: providerConfig.api,
1815
- provider: providerConfig.provider,
1816
- baseUrl: `${endpoint}/v1`,
1817
- reasoning: metadata?.reasoning ?? false,
1818
- input: metadata?.input ?? ["text"],
1819
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1820
- contextWindow: metadata?.contextWindow ?? 128000,
1821
- maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, DISCOVERY_DEFAULT_MAX_TOKENS),
1822
- headers: providerConfig.headers,
1823
- });
1824
- });
1825
- return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
1826
- }
1827
-
1828
- async #discoverLlamaCppServerMetadata(
1829
- baseUrl: string,
1830
- headers: Record<string, string> | undefined,
1831
- ): Promise<LlamaCppDiscoveredServerMetadata | null> {
1832
- const propsUrl = `${this.#toLlamaCppNativeBaseUrl(baseUrl)}/props`;
1833
- try {
1834
- const response = await this.#fetch(propsUrl, {
1835
- headers,
1836
- signal: AbortSignal.timeout(150),
1837
- });
1838
- if (!response.ok) {
1839
- return null;
1840
- }
1841
- const payload = (await response.json()) as unknown;
1842
- if (!isRecord(payload)) {
1843
- return null;
1844
- }
1845
- return {
1846
- contextWindow: extractLlamaCppContextWindow(payload),
1847
- input: extractLlamaCppInputCapabilities(payload),
1848
- };
1849
- } catch {
1850
- return null;
1851
- }
1852
- }
1853
-
1854
- async #discoverLlamaCppModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1855
- const baseUrl = this.#normalizeLlamaCppBaseUrl(providerConfig.baseUrl);
1856
- const modelsUrl = `${baseUrl}/models`;
1857
-
1858
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
1859
- const apiKey = await this.authStorage.getApiKey(providerConfig.provider);
1860
- if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
1861
- headers.Authorization = `Bearer ${apiKey}`;
1862
- }
1863
-
1864
- const [response, serverMetadata] = await Promise.all([
1865
- this.#fetch(modelsUrl, {
1866
- headers,
1867
- signal: AbortSignal.timeout(250),
1868
- }),
1869
- this.#discoverLlamaCppServerMetadata(baseUrl, headers),
1870
- ]);
1871
- if (!response.ok) {
1872
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
1873
- }
1874
- const payload = (await response.json()) as { data?: Array<{ id: string }> };
1875
- const models = payload.data ?? [];
1876
- const discovered: Model<Api>[] = [];
1877
- for (const item of models) {
1878
- const id = item.id;
1879
- if (!id) continue;
1880
- discovered.push(
1881
- enrichModelThinking({
1882
- id,
1883
- name: id,
1884
- api: providerConfig.api,
1885
- provider: providerConfig.provider,
1886
- baseUrl,
1887
- reasoning: false,
1888
- input: serverMetadata?.input ?? ["text"],
1889
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1890
- contextWindow: serverMetadata?.contextWindow ?? 128000,
1891
- maxTokens: Math.min(
1892
- serverMetadata?.contextWindow ?? Number.POSITIVE_INFINITY,
1893
- DISCOVERY_DEFAULT_MAX_TOKENS,
1894
- ),
1895
- headers,
1896
- compat: {
1897
- supportsStore: false,
1898
- supportsDeveloperRole: false,
1899
- supportsReasoningEffort: false,
1900
- },
1901
- }),
1902
- );
1903
- }
1904
- return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
1905
- }
1906
-
1907
- async #discoverOpenAIModelsList(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1908
- const baseUrl = this.#normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl);
1909
- const modelsUrl = `${baseUrl}/models`;
1910
-
1911
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
1912
- const apiKey = await this.authStorage.getApiKey(providerConfig.provider);
1913
- if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
1914
- headers.Authorization = `Bearer ${apiKey}`;
1915
- }
1916
-
1917
- const response = await this.#fetch(modelsUrl, {
1918
- headers,
1919
- signal: AbortSignal.timeout(10_000),
1920
- });
1921
- if (!response.ok) {
1922
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
1923
- }
1924
- const payload = (await response.json()) as { data?: Array<{ id: string }> };
1925
- const models = payload.data ?? [];
1926
- const discovered: Model<Api>[] = [];
1927
- for (const item of models) {
1928
- const id = item.id;
1929
- if (!id) continue;
1930
- discovered.push(
1931
- enrichModelThinking({
1932
- id,
1933
- name: id,
1934
- api: providerConfig.api,
1935
- provider: providerConfig.provider,
1936
- baseUrl,
1937
- reasoning: false,
1938
- input: ["text"],
1939
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1940
- contextWindow: 128000,
1941
- maxTokens: discoveryDefaultMaxTokens(providerConfig.api),
1942
- headers,
1943
- compat: {
1944
- supportsStore: false,
1945
- supportsDeveloperRole: false,
1946
- supportsReasoningEffort: false,
1947
- },
1948
- }),
1949
- );
1950
- }
1951
- return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
1952
- }
1953
-
1954
- /**
1955
- * Discover models from an Anthropic+OpenAI-compatible reseller proxy that
1956
- * exposes both `/v1/messages` and `/v1/chat/completions`, advertising each
1957
- * model's wire capabilities through `supported_endpoint_types` on
1958
- * `GET /v1/models` (new-api / one-api-style proxies).
1959
- *
1960
- * Routing per model:
1961
- * supported_endpoint_types: ["anthropic", ...] -> api: "anthropic-messages"
1962
- * supported_endpoint_types: ["openai"] -> api: "openai-completions"
1963
- * missing / neither -> provider-level api fallback
1964
- *
1965
- * Anthropic models share the same baseUrl; the Anthropic SDK strips a
1966
- * trailing `/v1` itself before appending `/v1/messages`, so the discovery
1967
- * URL (which ends in `/v1`) round-trips correctly.
1968
- */
1969
- async #discoverProxyModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1970
- const baseUrl = this.#normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl);
1971
- const modelsUrl = `${baseUrl}/models`;
1972
-
1973
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
1974
- const apiKey = await this.authStorage.getApiKey(providerConfig.provider);
1975
- if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
1976
- headers.Authorization = `Bearer ${apiKey}`;
1977
- }
1978
-
1979
- const response = await this.#fetch(modelsUrl, {
1980
- headers,
1981
- signal: AbortSignal.timeout(10_000),
1982
- });
1983
- if (!response.ok) {
1984
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
1985
- }
1986
- const payload = (await response.json()) as {
1987
- data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[] }>;
1988
- };
1989
- const items = payload.data ?? [];
1990
- const discovered: Model<Api>[] = [];
1991
- for (const item of items) {
1992
- const id = item.id;
1993
- if (!id) continue;
1994
- const endpoints = item.supported_endpoint_types ?? [];
1995
- const api: Api | undefined = endpoints.includes("anthropic")
1996
- ? "anthropic-messages"
1997
- : endpoints.includes("openai")
1998
- ? "openai-completions"
1999
- : providerConfig.api;
2000
- if (!api) continue;
2001
- const isAnthropic = api === "anthropic-messages";
2002
- const reference = resolveCustomModelReference(id);
2003
- const discoveryName = typeof item.name === "string" ? item.name.trim() : "";
2004
- const displayName =
2005
- reference?.name ??
2006
- (discoveryName && discoveryName !== id ? discoveryName : undefined) ??
2007
- stripBracketedModelIdAffixes(id) ??
2008
- id;
2009
- discovered.push(
2010
- enrichModelThinking({
2011
- id,
2012
- name: displayName,
2013
- api,
2014
- provider: providerConfig.provider,
2015
- baseUrl,
2016
- reasoning: reference?.reasoning ?? false,
2017
- thinking: reference?.thinking,
2018
- input: reference?.input ?? ["text"],
2019
- // Proxy pricing is provider-specific and usually does not match
2020
- // upstream bundled catalogs, so keep costs local-unknown even when
2021
- // we successfully recover the upstream model identity.
2022
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2023
- contextWindow: reference?.contextWindow ?? 128000,
2024
- maxTokens: reference?.maxTokens ?? discoveryDefaultMaxTokens(api),
2025
- headers,
2026
- // OpenAI-compat fields are no-ops on anthropic models; the
2027
- // Anthropic SDK ignores them. Provider-level disableStrictTools
2028
- // flows in via #applyProviderCompat for the third-party-Anthropic
2029
- // path. Cross-wire bundled compat is intentionally not copied:
2030
- // request-shaping fields are provider-wire specific.
2031
- compat: isAnthropic
2032
- ? undefined
2033
- : {
2034
- supportsStore: false,
2035
- supportsDeveloperRole: false,
2036
- supportsReasoningEffort: false,
2037
- },
2038
- }),
2039
- );
2040
- }
2041
- return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
2042
- }
2043
-
2044
- #normalizeLlamaCppBaseUrl(baseUrl?: string): string {
2045
- const defaultBaseUrl = "http://127.0.0.1:8080";
2046
- const raw = baseUrl || defaultBaseUrl;
2047
- try {
2048
- const parsed = new URL(raw);
2049
- const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2050
- return `${parsed.protocol}//${parsed.host}${trimmedPath}`;
2051
- } catch {
2052
- return raw;
2053
- }
2054
- }
2055
-
2056
- #toLlamaCppNativeBaseUrl(baseUrl: string): string {
2057
- try {
2058
- const parsed = new URL(baseUrl);
2059
- const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2060
- parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath.slice(0, -3) || "/" : trimmedPath || "/";
2061
- const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2062
- return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
2063
- } catch {
2064
- return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
2065
- }
2066
- }
2067
-
2068
- #normalizeOpenAIModelsListBaseUrl(baseUrl?: string): string {
2069
- const defaultBaseUrl = "http://127.0.0.1:1234/v1";
2070
- const raw = baseUrl || defaultBaseUrl;
2071
- try {
2072
- const parsed = new URL(raw);
2073
- const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2074
- parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath || "/v1" : `${trimmedPath}/v1`;
2075
- return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2076
- } catch {
2077
- return raw;
2078
- }
2079
- }
2080
- #normalizeOllamaBaseUrl(baseUrl?: string): string {
2081
- const raw = baseUrl || DEFAULT_OLLAMA_BASE_URL;
2082
- try {
2083
- const parsed = new URL(raw);
2084
- return `${parsed.protocol}//${parsed.host}`;
2085
- } catch {
2086
- return DEFAULT_OLLAMA_BASE_URL;
2087
- }
2088
- }
2089
-
2090
1397
  #applyProviderModelOverrides(provider: string, models: Model<Api>[]): Model<Api>[] {
2091
1398
  const overrides = this.#modelOverrides.get(provider);
2092
1399
  if (!overrides || overrides.size === 0) return models;
@@ -2164,7 +1471,11 @@ export class ModelRegistry {
2164
1471
  this.#rebuildPending = true;
2165
1472
  return;
2166
1473
  }
2167
- this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
1474
+ this.#canonicalIndex = buildCanonicalModelIndex(
1475
+ this.#models,
1476
+ getBundledCanonicalReferenceData(),
1477
+ this.#equivalenceConfig,
1478
+ );
2168
1479
  this.#rebuildPending = false;
2169
1480
  }
2170
1481
 
@@ -2178,7 +1489,11 @@ export class ModelRegistry {
2178
1489
  }
2179
1490
  if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
2180
1491
  this.#rebuildPending = false;
2181
- this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
1492
+ this.#canonicalIndex = buildCanonicalModelIndex(
1493
+ this.#models,
1494
+ getBundledCanonicalReferenceData(),
1495
+ this.#equivalenceConfig,
1496
+ );
2182
1497
  }
2183
1498
  }
2184
1499
 
@@ -2188,10 +1503,9 @@ export class ModelRegistry {
2188
1503
  for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
2189
1504
  const modelDefs = providerConfig.models ?? [];
2190
1505
  if (modelDefs.length === 0) continue; // Override-only, no custom models
1506
+ const resolvedProviderHeaders = resolveConfigHeaders(providerConfig.headers);
2191
1507
  if (providerConfig.apiKey) {
2192
- this.#customProviderApiKeys.set(providerName, providerConfig.apiKey);
2193
- const resolved = resolveApiKeyConfig(providerConfig.apiKey);
2194
- if (resolved) this.authStorage.setConfigApiKey(providerName, resolved);
1508
+ this.#installProviderApiKey(providerName, providerConfig.apiKey);
2195
1509
  }
2196
1510
  for (const modelDef of modelDefs) {
2197
1511
  const providerCompat = providerConfig.disableStrictTools
@@ -2201,7 +1515,7 @@ export class ModelRegistry {
2201
1515
  providerName,
2202
1516
  providerConfig.baseUrl!,
2203
1517
  providerConfig.api as Api | undefined,
2204
- providerConfig.headers,
1518
+ resolvedProviderHeaders,
2205
1519
  providerConfig.apiKey,
2206
1520
  providerConfig.authHeader,
2207
1521
  providerCompat,
@@ -2278,53 +1592,11 @@ export class ModelRegistry {
2278
1592
  });
2279
1593
  }
2280
1594
 
2281
- #buildModelOrder(candidates: readonly Model<Api>[]): Map<string, number> {
2282
- const modelOrder = new Map<string, number>();
2283
- for (let index = 0; index < candidates.length; index += 1) {
2284
- modelOrder.set(formatCanonicalVariantSelector(candidates[index]!), index);
2285
- }
2286
- return modelOrder;
2287
- }
2288
-
2289
- #providerRank(): Map<string, number> {
2290
- return buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings());
2291
- }
2292
-
2293
- #resolveCanonicalVariant(
2294
- variants: readonly CanonicalModelVariant[],
2295
- modelOrder: ReadonlyMap<string, number>,
2296
- providerRank: ReadonlyMap<string, number>,
2297
- ): CanonicalModelVariant | undefined {
2298
- if (variants.length === 0) {
2299
- return undefined;
2300
- }
2301
- const sourceRank: Record<CanonicalModelVariant["source"], number> = {
2302
- override: 1,
2303
- bundled: 1,
2304
- heuristic: 2,
2305
- fallback: 3,
1595
+ #variantPreferences(candidates: readonly Model<Api>[]): CanonicalVariantPreferences {
1596
+ return {
1597
+ modelOrder: buildCanonicalModelOrder(candidates),
1598
+ providerRank: buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings()),
2306
1599
  };
2307
- return [...variants].sort((left, right) => {
2308
- const leftProviderRank = providerRank.get(left.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER;
2309
- const rightProviderRank = providerRank.get(right.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER;
2310
- if (leftProviderRank !== rightProviderRank) {
2311
- return leftProviderRank - rightProviderRank;
2312
- }
2313
- const leftExact = left.model.id === left.canonicalId ? 0 : 1;
2314
- const rightExact = right.model.id === right.canonicalId ? 0 : 1;
2315
- if (leftExact !== rightExact) {
2316
- return leftExact - rightExact;
2317
- }
2318
- if (sourceRank[left.source] !== sourceRank[right.source]) {
2319
- return sourceRank[left.source] - sourceRank[right.source];
2320
- }
2321
- if (left.model.id.length !== right.model.id.length) {
2322
- return left.model.id.length - right.model.id.length;
2323
- }
2324
- const leftOrder = modelOrder.get(left.selector) ?? Number.MAX_SAFE_INTEGER;
2325
- const rightOrder = modelOrder.get(right.selector) ?? Number.MAX_SAFE_INTEGER;
2326
- return leftOrder - rightOrder;
2327
- })[0];
2328
1600
  }
2329
1601
 
2330
1602
  getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] {
@@ -2354,15 +1626,14 @@ export class ModelRegistry {
2354
1626
  getCanonicalModelSelections(options?: CanonicalModelQueryOptions): CanonicalModelSelection[] {
2355
1627
  const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
2356
1628
  const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
2357
- const modelOrder = this.#buildModelOrder(candidates);
2358
- const providerRank = this.#providerRank();
1629
+ const preferences = this.#variantPreferences(candidates);
2359
1630
  const selections: CanonicalModelSelection[] = [];
2360
1631
  for (const record of this.#canonicalIndex.records) {
2361
1632
  const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
2362
1633
  if (variants.length === 0) {
2363
1634
  continue;
2364
1635
  }
2365
- const resolved = this.#resolveCanonicalVariant(variants, modelOrder, providerRank);
1636
+ const resolved = resolveCanonicalVariant(variants, preferences);
2366
1637
  if (!resolved) {
2367
1638
  continue;
2368
1639
  }
@@ -2389,7 +1660,7 @@ export class ModelRegistry {
2389
1660
  return undefined;
2390
1661
  }
2391
1662
  const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
2392
- return this.#resolveCanonicalVariant(variants, this.#buildModelOrder(candidates), this.#providerRank())?.model;
1663
+ return resolveCanonicalVariant(variants, this.#variantPreferences(candidates))?.model;
2393
1664
  }
2394
1665
 
2395
1666
  getCanonicalId(model: Model<Api>): string | undefined {
@@ -2414,7 +1685,10 @@ export class ModelRegistry {
2414
1685
  * as providers with stored credentials. See issue #993.
2415
1686
  */
2416
1687
  hasConfiguredAuth(model: Model<Api>): boolean {
2417
- return this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider);
1688
+ const commandKey = this.#resolveCommandBackedApiKey(model.provider);
1689
+ return (
1690
+ commandKey.configured || this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider)
1691
+ );
2418
1692
  }
2419
1693
 
2420
1694
  getDiscoverableProviders(): string[] {
@@ -2446,6 +1720,8 @@ export class ModelRegistry {
2446
1720
  * Get API key for a model.
2447
1721
  */
2448
1722
  async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
1723
+ const commandKey = this.#resolveCommandBackedApiKey(model.provider);
1724
+ if (commandKey.configured) return commandKey.value;
2449
1725
  if (this.#keylessProviders.has(model.provider) && !this.authStorage.hasAuth(model.provider)) {
2450
1726
  return kNoAuth;
2451
1727
  }
@@ -2462,13 +1738,16 @@ export class ModelRegistry {
2462
1738
  async getApiKeyForProvider(
2463
1739
  provider: string,
2464
1740
  sessionId?: string,
2465
- options?: { baseUrl?: string; forceRefresh?: boolean; signal?: AbortSignal },
1741
+ options?: { baseUrl?: string; modelId?: string; forceRefresh?: boolean; signal?: AbortSignal },
2466
1742
  ): Promise<string | undefined> {
1743
+ const commandKey = this.#resolveCommandBackedApiKey(provider);
1744
+ if (commandKey.configured) return commandKey.value;
2467
1745
  if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
2468
1746
  return kNoAuth;
2469
1747
  }
2470
1748
  return this.authStorage.getApiKey(provider, sessionId, {
2471
1749
  baseUrl: options?.baseUrl,
1750
+ modelId: options?.modelId,
2472
1751
  forceRefresh: options?.forceRefresh,
2473
1752
  signal: options?.signal,
2474
1753
  });
@@ -2484,6 +1763,8 @@ export class ModelRegistry {
2484
1763
  }
2485
1764
 
2486
1765
  async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
1766
+ const commandKey = this.#resolveCommandBackedApiKey(provider);
1767
+ if (commandKey.configured) return commandKey.value;
2487
1768
  if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
2488
1769
  return kNoAuth;
2489
1770
  }
@@ -2607,11 +1888,9 @@ export class ModelRegistry {
2607
1888
  }
2608
1889
 
2609
1890
  if (config.apiKey) {
2610
- this.#customProviderApiKeys.set(providerName, config.apiKey);
1891
+ this.#installProviderApiKey(providerName, config.apiKey);
2611
1892
  // Persist runtime API keys so they survive #reloadStaticModels() cycles
2612
1893
  this.#runtimeProviderApiKeys.set(providerName, config.apiKey);
2613
- const resolved = resolveApiKeyConfig(config.apiKey);
2614
- if (resolved) this.authStorage.setConfigApiKey(providerName, resolved);
2615
1894
  }
2616
1895
 
2617
1896
  if (config.models && config.models.length > 0) {
@@ -2680,7 +1959,7 @@ export class ModelRegistry {
2680
1959
  cacheTtlMs: 24 * 60 * 60 * 1000,
2681
1960
  dynamicModelsAuthoritative: true,
2682
1961
  fetchDynamicModels: async () => {
2683
- const apiKey = await this.authStorage.peekApiKey(providerName);
1962
+ const apiKey = await this.#peekApiKeyForProvider(providerName);
2684
1963
  const resolvedKey = isAuthenticated(apiKey) ? apiKey : undefined;
2685
1964
  const modelDefs = await fetcher(resolvedKey);
2686
1965
  const results: Model<Api>[] = [];
@@ -2698,7 +1977,7 @@ export class ModelRegistry {
2698
1977
  );
2699
1978
  if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true }));
2700
1979
  }
2701
- return results;
1980
+ return results.map(toModelSpec);
2702
1981
  },
2703
1982
  };
2704
1983
  this.#runtimeModelManagers.set(providerName, { options: managerOptions, sourceId: sourceId ?? "" });
@@ -2772,7 +2051,7 @@ export interface ProviderConfigInput {
2772
2051
  api?: Api;
2773
2052
  streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
2774
2053
  headers?: Record<string, string>;
2775
- compat?: Model<Api>["compat"];
2054
+ compat?: ModelSpec<Api>["compat"];
2776
2055
  authHeader?: boolean;
2777
2056
  /** Streaming transport override — see {@link Model.transport}. */
2778
2057
  transport?: Model<Api>["transport"];
@@ -2804,7 +2083,7 @@ export interface ProviderConfigInput {
2804
2083
  contextWindow: number;
2805
2084
  maxTokens: number;
2806
2085
  headers?: Record<string, string>;
2807
- compat?: Model<Api>["compat"];
2086
+ compat?: ModelSpec<Api>["compat"];
2808
2087
  contextPromotionTarget?: string;
2809
2088
  premiumMultiplier?: number;
2810
2089
  }>;