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

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 (345) hide show
  1. package/CHANGELOG.md +95 -4
  2. package/dist/cli.js +23087 -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 +1 -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/commands/launch.d.ts +1 -1
  11. package/dist/types/commands/read.d.ts +1 -1
  12. package/dist/types/commands/usage.d.ts +25 -0
  13. package/dist/types/config/append-only-context-mode.d.ts +2 -1
  14. package/dist/types/config/model-discovery.d.ts +55 -0
  15. package/dist/types/config/model-registry.d.ts +7 -219
  16. package/dist/types/config/model-resolver.d.ts +16 -10
  17. package/dist/types/config/model-roles.d.ts +28 -0
  18. package/dist/types/config/models-config-schema.d.ts +523 -42
  19. package/dist/types/config/models-config.d.ts +385 -0
  20. package/dist/types/config/settings-schema.d.ts +12 -7
  21. package/dist/types/config/settings.d.ts +1 -1
  22. package/dist/types/debug/log-viewer.d.ts +1 -1
  23. package/dist/types/debug/raw-sse.d.ts +1 -1
  24. package/dist/types/eval/backend.d.ts +0 -2
  25. package/dist/types/eval/idle-timeout.d.ts +0 -4
  26. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  27. package/dist/types/export/html/template.generated.d.ts +1 -1
  28. package/dist/types/extensibility/extensions/types.d.ts +3 -3
  29. package/dist/types/hindsight/mental-models.d.ts +17 -8
  30. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  31. package/dist/types/internal-urls/types.d.ts +1 -1
  32. package/dist/types/lsp/edits.d.ts +9 -0
  33. package/dist/types/lsp/index.d.ts +2 -2
  34. package/dist/types/lsp/types.d.ts +2 -0
  35. package/dist/types/lsp/utils.d.ts +3 -0
  36. package/dist/types/mcp/json-rpc.d.ts +5 -0
  37. package/dist/types/mnemopi/state.d.ts +11 -1
  38. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  39. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  40. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  41. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  42. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  43. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  44. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  45. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  46. package/dist/types/modes/components/footer.d.ts +1 -1
  47. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  48. package/dist/types/modes/components/hook-input.d.ts +4 -0
  49. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +1 -1
  51. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  52. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  53. package/dist/types/modes/components/session-selector.d.ts +1 -1
  54. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  55. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  56. package/dist/types/modes/components/transcript-container.d.ts +25 -6
  57. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  58. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  59. package/dist/types/modes/components/user-message.d.ts +2 -1
  60. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  61. package/dist/types/modes/components/welcome.d.ts +19 -3
  62. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  63. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  64. package/dist/types/modes/interactive-mode.d.ts +1 -1
  65. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  66. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  67. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  68. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  69. package/dist/types/modes/types.d.ts +2 -1
  70. package/dist/types/session/agent-session.d.ts +1 -1
  71. package/dist/types/session/auth-broker-config.d.ts +4 -0
  72. package/dist/types/session/session-manager.d.ts +1 -1
  73. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  74. package/dist/types/ssh/connection-manager.d.ts +8 -0
  75. package/dist/types/task/parallel.d.ts +2 -2
  76. package/dist/types/task/worktree.d.ts +2 -0
  77. package/dist/types/tools/ask.d.ts +4 -0
  78. package/dist/types/tools/conflict-detect.d.ts +16 -0
  79. package/dist/types/tools/github-cache.d.ts +7 -0
  80. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  81. package/dist/types/tui/output-block.d.ts +3 -3
  82. package/dist/types/utils/changelog.d.ts +8 -0
  83. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  84. package/dist/types/web/scrapers/types.d.ts +12 -0
  85. package/dist/types/web/search/providers/codex.d.ts +1 -1
  86. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  87. package/examples/extensions/tools.ts +5 -4
  88. package/package.json +14 -11
  89. package/scripts/build-binary.ts +18 -23
  90. package/scripts/bundle-dist.ts +81 -0
  91. package/scripts/{dev-launch → omp} +1 -1
  92. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  93. package/src/async/job-manager.ts +57 -3
  94. package/src/autoresearch/dashboard.ts +1 -1
  95. package/src/autoresearch/prompt-setup.md +6 -6
  96. package/src/autoresearch/prompt.md +6 -6
  97. package/src/capability/fs.ts +10 -0
  98. package/src/cli/args.ts +1 -1
  99. package/src/cli/auth-gateway-cli.ts +1 -3
  100. package/src/cli/dry-balance-cli.ts +1 -1
  101. package/src/cli/gallery-cli.ts +1 -1
  102. package/src/cli/gallery-fixtures/fs.ts +1 -1
  103. package/src/cli/gallery-fixtures/types.ts +5 -1
  104. package/src/cli/list-models.ts +2 -1
  105. package/src/cli/usage-cli.ts +603 -0
  106. package/src/cli-commands.ts +1 -0
  107. package/src/cli.ts +69 -5
  108. package/src/commands/complete.ts +1 -1
  109. package/src/commands/launch.ts +1 -1
  110. package/src/commands/read.ts +6 -3
  111. package/src/commands/usage.ts +35 -0
  112. package/src/commit/agentic/agent.ts +1 -1
  113. package/src/commit/model-selection.ts +1 -1
  114. package/src/config/append-only-context-mode.ts +6 -12
  115. package/src/config/model-discovery.ts +554 -0
  116. package/src/config/model-registry.ts +231 -1019
  117. package/src/config/model-resolver.ts +113 -156
  118. package/src/config/model-roles.ts +74 -0
  119. package/src/config/models-config-schema.ts +57 -8
  120. package/src/config/models-config.ts +129 -0
  121. package/src/config/settings-schema.ts +18 -4
  122. package/src/config/settings.ts +37 -1
  123. package/src/dap/client.ts +124 -37
  124. package/src/dap/session.ts +259 -158
  125. package/src/debug/log-viewer.ts +1 -1
  126. package/src/debug/raw-sse.ts +1 -1
  127. package/src/edit/diff.ts +47 -3
  128. package/src/edit/hashline/block-resolver.ts +20 -1
  129. package/src/edit/hashline/diff.ts +36 -1
  130. package/src/edit/hashline/execute.ts +8 -2
  131. package/src/edit/index.ts +16 -1
  132. package/src/edit/modes/patch.ts +52 -0
  133. package/src/edit/modes/replace.ts +56 -22
  134. package/src/edit/notebook.ts +22 -2
  135. package/src/edit/renderer.ts +36 -10
  136. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  137. package/src/eval/backend.ts +0 -2
  138. package/src/eval/completion-bridge.ts +2 -1
  139. package/src/eval/idle-timeout.ts +2 -9
  140. package/src/eval/js/context-manager.ts +6 -8
  141. package/src/eval/js/executor.ts +6 -2
  142. package/src/eval/js/index.ts +0 -2
  143. package/src/eval/js/shared/helpers.ts +5 -6
  144. package/src/eval/js/shared/local-module-loader.ts +1 -1
  145. package/src/eval/js/shared/prelude.txt +62 -1
  146. package/src/eval/js/shared/rewrite-imports.ts +40 -22
  147. package/src/eval/js/shared/runtime.ts +1 -1
  148. package/src/eval/py/index.ts +0 -2
  149. package/src/eval/py/kernel.ts +19 -0
  150. package/src/eval/py/runner.py +107 -3
  151. package/src/exec/bash-executor.ts +3 -1
  152. package/src/export/html/template.generated.ts +1 -1
  153. package/src/export/html/template.js +3 -1
  154. package/src/extensibility/extensions/types.ts +3 -2
  155. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  156. package/src/hindsight/mental-models.ts +59 -12
  157. package/src/hindsight/state.ts +6 -1
  158. package/src/internal-urls/artifact-protocol.ts +11 -2
  159. package/src/internal-urls/docs-index.generated.ts +8 -8
  160. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  161. package/src/internal-urls/router.ts +1 -1
  162. package/src/internal-urls/types.ts +1 -1
  163. package/src/lib/xai-http.ts +1 -1
  164. package/src/lsp/client.ts +118 -38
  165. package/src/lsp/clients/biome-client.ts +101 -39
  166. package/src/lsp/edits.ts +143 -95
  167. package/src/lsp/index.ts +31 -22
  168. package/src/lsp/render.ts +1 -1
  169. package/src/lsp/types.ts +2 -0
  170. package/src/lsp/utils.ts +28 -10
  171. package/src/main.ts +165 -17
  172. package/src/mcp/json-rpc.ts +35 -5
  173. package/src/mcp/transports/stdio.ts +7 -1
  174. package/src/memories/index.ts +2 -1
  175. package/src/mnemopi/backend.ts +25 -3
  176. package/src/mnemopi/state.ts +38 -2
  177. package/src/modes/components/agent-dashboard.ts +10 -7
  178. package/src/modes/components/assistant-message.ts +19 -13
  179. package/src/modes/components/bash-execution.ts +1 -1
  180. package/src/modes/components/copy-selector.ts +1 -1
  181. package/src/modes/components/diff.ts +13 -2
  182. package/src/modes/components/dynamic-border.ts +12 -3
  183. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  184. package/src/modes/components/extensions/extension-list.ts +1 -1
  185. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  186. package/src/modes/components/footer.ts +1 -1
  187. package/src/modes/components/history-search.ts +1 -1
  188. package/src/modes/components/hook-editor.ts +8 -0
  189. package/src/modes/components/hook-input.ts +8 -0
  190. package/src/modes/components/hook-selector.ts +2 -2
  191. package/src/modes/components/model-selector.ts +4 -2
  192. package/src/modes/components/plan-review-overlay.ts +1 -1
  193. package/src/modes/components/session-observer-overlay.ts +2 -2
  194. package/src/modes/components/session-selector.ts +1 -1
  195. package/src/modes/components/settings-selector.ts +5 -1
  196. package/src/modes/components/status-line/component.ts +1 -1
  197. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  198. package/src/modes/components/transcript-container.ts +258 -53
  199. package/src/modes/components/tree-selector.ts +3 -3
  200. package/src/modes/components/user-message-selector.ts +1 -1
  201. package/src/modes/components/user-message.ts +17 -5
  202. package/src/modes/components/visual-truncate.ts +1 -1
  203. package/src/modes/components/welcome.ts +108 -26
  204. package/src/modes/controllers/command-controller.ts +10 -3
  205. package/src/modes/controllers/event-controller.ts +73 -4
  206. package/src/modes/controllers/input-controller.ts +1 -1
  207. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  208. package/src/modes/controllers/selector-controller.ts +1 -1
  209. package/src/modes/controllers/streaming-reveal.ts +85 -18
  210. package/src/modes/interactive-mode.ts +3 -9
  211. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  212. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  213. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  214. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  215. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  216. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  217. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  218. package/src/modes/types.ts +2 -1
  219. package/src/prompts/agents/explore.md +2 -2
  220. package/src/prompts/agents/librarian.md +1 -2
  221. package/src/prompts/agents/oracle.md +1 -1
  222. package/src/prompts/agents/plan.md +5 -5
  223. package/src/prompts/agents/task.md +5 -5
  224. package/src/prompts/ci-green-request.md +5 -7
  225. package/src/prompts/goals/goal-budget-limit.md +2 -2
  226. package/src/prompts/goals/goal-continuation.md +4 -4
  227. package/src/prompts/goals/goal-mode-active.md +1 -1
  228. package/src/prompts/memories/read-path.md +1 -1
  229. package/src/prompts/memories/stage_one_system.md +2 -2
  230. package/src/prompts/review-custom-request.md +1 -1
  231. package/src/prompts/system/agent-creation-architect.md +2 -2
  232. package/src/prompts/system/auto-continue.md +1 -1
  233. package/src/prompts/system/background-tan-dispatch.md +1 -1
  234. package/src/prompts/system/btw-user.md +2 -2
  235. package/src/prompts/system/commit-message-system.md +13 -1
  236. package/src/prompts/system/custom-system-prompt.md +1 -1
  237. package/src/prompts/system/eager-todo.md +2 -2
  238. package/src/prompts/system/irc-incoming.md +1 -1
  239. package/src/prompts/system/manual-continue.md +1 -1
  240. package/src/prompts/system/omfg-user.md +3 -4
  241. package/src/prompts/system/orchestrate-notice.md +9 -9
  242. package/src/prompts/system/plan-mode-active.md +4 -4
  243. package/src/prompts/system/plan-mode-subagent.md +4 -5
  244. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  245. package/src/prompts/system/project-prompt.md +2 -2
  246. package/src/prompts/system/subagent-system-prompt.md +4 -4
  247. package/src/prompts/system/system-prompt.md +13 -24
  248. package/src/prompts/system/title-system.md +2 -2
  249. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  250. package/src/prompts/system/workflow-notice.md +1 -1
  251. package/src/prompts/tools/ast-edit.md +1 -1
  252. package/src/prompts/tools/ast-grep.md +2 -2
  253. package/src/prompts/tools/bash.md +5 -7
  254. package/src/prompts/tools/browser.md +7 -7
  255. package/src/prompts/tools/debug.md +1 -1
  256. package/src/prompts/tools/eval.md +3 -3
  257. package/src/prompts/tools/find.md +0 -1
  258. package/src/prompts/tools/github.md +8 -7
  259. package/src/prompts/tools/goal.md +1 -1
  260. package/src/prompts/tools/image-gen.md +1 -1
  261. package/src/prompts/tools/inspect-image-system.md +1 -1
  262. package/src/prompts/tools/irc.md +15 -15
  263. package/src/prompts/tools/lsp.md +2 -2
  264. package/src/prompts/tools/patch.md +2 -2
  265. package/src/prompts/tools/read.md +3 -4
  266. package/src/prompts/tools/recall.md +1 -1
  267. package/src/prompts/tools/reflect.md +1 -1
  268. package/src/prompts/tools/render-mermaid.md +2 -2
  269. package/src/prompts/tools/replace.md +4 -10
  270. package/src/prompts/tools/rewind.md +2 -2
  271. package/src/prompts/tools/search-tool-bm25.md +1 -9
  272. package/src/prompts/tools/search.md +0 -1
  273. package/src/prompts/tools/ssh.md +0 -4
  274. package/src/prompts/tools/task.md +2 -3
  275. package/src/prompts/tools/todo.md +1 -1
  276. package/src/sdk.ts +23 -10
  277. package/src/session/agent-session.ts +44 -10
  278. package/src/session/auth-broker-config.ts +30 -1
  279. package/src/session/session-manager.ts +2 -2
  280. package/src/session/streaming-output.ts +23 -2
  281. package/src/slash-commands/builtin-registry.ts +20 -0
  282. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  283. package/src/ssh/connection-manager.ts +27 -0
  284. package/src/task/commands.ts +2 -1
  285. package/src/task/executor.ts +61 -53
  286. package/src/task/index.ts +137 -60
  287. package/src/task/parallel.ts +3 -3
  288. package/src/task/render.ts +2 -2
  289. package/src/task/worktree.ts +64 -56
  290. package/src/thinking.ts +2 -1
  291. package/src/tiny/title-client.ts +26 -11
  292. package/src/tools/archive-reader.ts +30 -2
  293. package/src/tools/ask.ts +104 -21
  294. package/src/tools/ast-edit.ts +25 -5
  295. package/src/tools/auto-generated-guard.ts +20 -3
  296. package/src/tools/bash-interactive.ts +27 -7
  297. package/src/tools/bash.ts +54 -13
  298. package/src/tools/browser/launch.ts +11 -2
  299. package/src/tools/browser/readable.ts +19 -2
  300. package/src/tools/browser/registry.ts +4 -1
  301. package/src/tools/browser/render.ts +2 -2
  302. package/src/tools/browser/tab-supervisor.ts +55 -16
  303. package/src/tools/conflict-detect.ts +50 -4
  304. package/src/tools/debug.ts +1 -1
  305. package/src/tools/eval-render.ts +5 -5
  306. package/src/tools/eval.ts +0 -2
  307. package/src/tools/fetch.ts +33 -10
  308. package/src/tools/gh-cache-invalidation.ts +63 -8
  309. package/src/tools/gh-renderer.ts +1 -1
  310. package/src/tools/gh.ts +172 -29
  311. package/src/tools/github-cache.ts +70 -6
  312. package/src/tools/image-gen.ts +3 -9
  313. package/src/tools/irc.ts +5 -1
  314. package/src/tools/job.ts +1 -1
  315. package/src/tools/read.ts +202 -61
  316. package/src/tools/render-utils.ts +3 -3
  317. package/src/tools/resolve.ts +1 -1
  318. package/src/tools/search.ts +92 -29
  319. package/src/tools/sqlite-reader.ts +17 -5
  320. package/src/tools/ssh.ts +8 -8
  321. package/src/tools/todo.ts +38 -8
  322. package/src/tools/write.ts +118 -18
  323. package/src/tui/output-block.ts +4 -4
  324. package/src/utils/changelog.ts +27 -1
  325. package/src/utils/file-mentions.ts +2 -1
  326. package/src/web/scrapers/arxiv.ts +1 -1
  327. package/src/web/scrapers/go-pkg.ts +1 -1
  328. package/src/web/scrapers/iacr.ts +1 -1
  329. package/src/web/scrapers/readthedocs.ts +1 -1
  330. package/src/web/scrapers/twitter.ts +2 -1
  331. package/src/web/scrapers/types.ts +87 -8
  332. package/src/web/scrapers/wikipedia.ts +1 -1
  333. package/src/web/scrapers/youtube.ts +6 -1
  334. package/src/web/search/index.ts +1 -1
  335. package/src/web/search/providers/codex.ts +2 -1
  336. package/src/web/search/providers/gemini.ts +2 -3
  337. package/src/web/search/render.ts +8 -6
  338. package/dist/types/config/model-equivalence.d.ts +0 -24
  339. package/dist/types/config/model-id-affixes.d.ts +0 -12
  340. package/dist/types/config/model-provider-priority.d.ts +0 -1
  341. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  342. package/src/config/model-equivalence.ts +0 -875
  343. package/src/config/model-id-affixes.ts +0 -81
  344. package/src/config/model-provider-priority.ts +0 -56
  345. package/src/exec/idle-timeout-watchdog.ts +0 -126
@@ -1,9 +1,16 @@
1
1
  import * as path from "node:path";
2
2
  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";
3
+ import type { Api, Context, Model, ModelSpec, SimpleStreamOptions, ThinkingConfig } from "@oh-my-pi/pi-ai/types";
4
+ import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
5
+ import { buildModel } from "@oh-my-pi/pi-catalog/build";
6
+ import { isVertexExpressOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
7
+ import { readModelCache } from "@oh-my-pi/pi-catalog/model-cache";
8
+ import {
9
+ createModelManager,
10
+ type ModelManagerOptions,
11
+ type ModelRefreshStrategy,
12
+ } from "@oh-my-pi/pi-catalog/model-manager";
13
+ import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/models";
7
14
  import {
8
15
  googleAntigravityModelManagerOptions,
9
16
  googleGeminiCliModelManagerOptions,
@@ -11,79 +18,12 @@ import {
11
18
  PROVIDER_DESCRIPTORS,
12
19
  UNK_CONTEXT_WINDOW,
13
20
  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";
21
+ } from "@oh-my-pi/pi-catalog/provider-models";
17
22
 
18
23
  // Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
19
24
  // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
20
25
  const DEFAULT_LOCAL_TOKEN = "lm-studio-local";
21
26
 
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
27
  const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [
88
28
  "google-antigravity",
89
29
  "google-gemini-cli",
@@ -98,35 +38,37 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
98
38
  import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
99
39
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
100
40
  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
41
  import {
108
42
  buildCanonicalModelIndex,
43
+ buildCanonicalModelOrder,
44
+ buildModelProviderPriorityRank,
109
45
  type CanonicalModelIndex,
110
46
  type CanonicalModelRecord,
111
47
  type CanonicalModelVariant,
48
+ type CanonicalVariantPreferences,
112
49
  formatCanonicalVariantSelector,
50
+ getBundledCanonicalReferenceData,
51
+ getBundledModelReferenceIndex,
113
52
  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";
53
+ resolveCanonicalVariant,
54
+ resolveModelReference,
55
+ } from "@oh-my-pi/pi-catalog/identity";
56
+ import { isRecord, logger } from "@oh-my-pi/pi-utils";
57
+ import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
58
+ import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
59
+ import { type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver";
60
+ import type { ConfigError, ConfigFile } from "./config-file";
122
61
  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";
62
+ DISCOVERY_DEFAULT_MAX_TOKENS,
63
+ type DiscoveryContext,
64
+ type DiscoveryProviderConfig,
65
+ discoverModelsByProviderType,
66
+ getImplicitOllamaBaseUrl,
67
+ getOllamaContextLengthOverride,
68
+ } from "./model-discovery";
69
+ import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config";
70
+ import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema";
71
+ import { settings } from "./settings";
130
72
 
131
73
  export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig };
132
74
 
@@ -136,196 +78,13 @@ export function isAuthenticated(apiKey: string | undefined | null): apiKey is st
136
78
  return Boolean(apiKey) && apiKey !== kNoAuth;
137
79
  }
138
80
 
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
81
  /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
323
82
  interface ProviderOverride {
324
83
  baseUrl?: string;
325
84
  headers?: Record<string, string>;
326
85
  apiKey?: string;
327
86
  authHeader?: boolean;
328
- compat?: Model<Api>["compat"];
87
+ compat?: ModelSpec<Api>["compat"];
329
88
  transport?: Model<Api>["transport"];
330
89
  }
331
90
 
@@ -351,19 +110,21 @@ export function mergeDiscoveredModel<TApi extends Api>(
351
110
  providerOverride?: Pick<ProviderOverride, "baseUrl" | "headers" | "transport">,
352
111
  ): Model<TApi> {
353
112
  if (existing) {
354
- return {
113
+ return buildModel({
355
114
  ...model,
356
115
  baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
357
116
  headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
358
- };
117
+ compat: model.compatConfig,
118
+ } as ModelSpec<TApi>);
359
119
  }
360
120
  if (providerOverride) {
361
- return {
121
+ return buildModel({
362
122
  ...model,
363
123
  baseUrl: providerOverride.baseUrl ?? model.baseUrl,
364
124
  headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers,
365
125
  ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
366
- };
126
+ compat: model.compatConfig,
127
+ } as ModelSpec<TApi>);
367
128
  }
368
129
  return model;
369
130
  }
@@ -378,7 +139,7 @@ function isAuthoritativeProjectCatalogModel(model: Model<Api>): boolean {
378
139
  return (
379
140
  model.provider === "google-vertex" &&
380
141
  model.api === "openai-completions" &&
381
- model.baseUrl.includes("/endpoints/openapi")
142
+ isVertexExpressOpenAIUrl(model.baseUrl)
382
143
  );
383
144
  }
384
145
 
@@ -396,14 +157,32 @@ function dropProviderModels(models: readonly Model<Api>[], providers: ReadonlySe
396
157
  return models.filter(model => !providers.has(model.provider));
397
158
  }
398
159
 
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;
160
+ /**
161
+ * Merge `incoming` entries into a copy of `base`, keyed by `provider`+`id`.
162
+ * Matches are replaced with `combine(existing, entry)`; new entries are
163
+ * appended as `combine(undefined, entry)`.
164
+ */
165
+ function mergeByModelKey<T extends { provider: string; id: string }>(
166
+ base: readonly Model<Api>[],
167
+ incoming: readonly T[],
168
+ combine: (existing: Model<Api> | undefined, entry: T) => Model<Api>,
169
+ ): Model<Api>[] {
170
+ const merged = [...base];
171
+ const indexByKey = new Map<string, number>();
172
+ for (let i = 0; i < merged.length; i += 1) {
173
+ indexByKey.set(`${merged[i].provider}\u0000${merged[i].id}`, i);
174
+ }
175
+ for (const entry of incoming) {
176
+ const key = `${entry.provider}\u0000${entry.id}`;
177
+ const existingIndex = indexByKey.get(key);
178
+ if (existingIndex !== undefined) {
179
+ merged[existingIndex] = combine(merged[existingIndex], entry);
180
+ } else {
181
+ merged.push(combine(undefined, entry));
182
+ indexByKey.set(key, merged.length - 1);
183
+ }
184
+ }
185
+ return merged;
407
186
  }
408
187
 
409
188
  interface BuiltInDiscoveryResult {
@@ -447,17 +226,6 @@ interface CustomModelsResult {
447
226
  found: boolean;
448
227
  }
449
228
 
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
229
  /**
462
230
  * Resolve an API key config value to an actual key.
463
231
  * Checks environment variable first, then treats as literal.
@@ -468,59 +236,6 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
468
236
  return keyConfig;
469
237
  }
470
238
 
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;
482
- }
483
-
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") {
499
- return undefined;
500
- }
501
- const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
502
- return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
503
- }
504
-
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);
514
- }
515
-
516
- function extractLlamaCppInputCapabilities(payload: Record<string, unknown>): ("text" | "image")[] | undefined {
517
- const modalities = payload.modalities;
518
- if (!isRecord(modalities)) {
519
- return undefined;
520
- }
521
- return modalities.vision === true ? ["text", "image"] : ["text"];
522
- }
523
-
524
239
  function extractGoogleOAuthToken(value: string | undefined): string | undefined {
525
240
  if (!isAuthenticated(value)) return undefined;
526
241
  try {
@@ -579,73 +294,99 @@ function mergeCompat<TBase extends object, TOverride extends object>(
579
294
  return merged as TBase & TOverride;
580
295
  }
581
296
 
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);
297
+ /**
298
+ * Project a built model back to spec shape for the model-manager/cache
299
+ * boundary: sparse compat comes from `compatConfig`, never from the resolved
300
+ * record.
301
+ */
302
+ function toModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
303
+ return { ...model, compat: model.compatConfig } as ModelSpec<TApi>;
606
304
  }
607
305
 
608
- interface CustomModelDefinitionLike {
609
- id: string;
306
+ /**
307
+ * The patchable subset of `Model` fields shared by `modelOverrides` entries,
308
+ * custom model definitions, and parsed custom-model overlays. `undefined`
309
+ * always means "leave the base value alone".
310
+ */
311
+ interface ModelPatch {
610
312
  name?: string;
611
- api?: Api;
612
- baseUrl?: string;
613
313
  reasoning?: boolean;
614
314
  thinking?: ThinkingConfig;
615
315
  input?: ("text" | "image")[];
616
- cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
316
+ cost?: Partial<Model<Api>["cost"]>;
617
317
  contextWindow?: number;
618
318
  maxTokens?: number;
619
319
  omitMaxOutputTokens?: boolean;
620
320
  headers?: Record<string, string>;
621
- compat?: Model<Api>["compat"];
321
+ compat?: ModelSpec<Api>["compat"];
622
322
  contextPromotionTarget?: string;
623
323
  premiumMultiplier?: number;
624
324
  }
625
325
 
326
+ /**
327
+ * How a patch treats the base model's transport metadata (headers/compat):
328
+ * - `merge`: fold the patch into the base's (modelOverrides semantics).
329
+ * - `replace`: the patch owns transport wholesale — same-id custom definitions
330
+ * already folded provider-level headers/compat in during parsing, so bundled
331
+ * transport metadata must not be re-merged (see `#mergeCustomModels`).
332
+ */
333
+ type ModelTransportPolicy = "merge" | "replace";
334
+
335
+ function applyModelPatch(base: Model<Api>, patch: ModelPatch, transport: ModelTransportPolicy): Model<Api> {
336
+ const result = { ...base };
337
+ if (patch.name !== undefined) result.name = patch.name;
338
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
339
+ if (patch.thinking !== undefined) result.thinking = patch.thinking;
340
+ if (patch.input !== undefined) result.input = patch.input;
341
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
342
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
343
+ if (patch.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = patch.omitMaxOutputTokens;
344
+ if (patch.contextPromotionTarget !== undefined) result.contextPromotionTarget = patch.contextPromotionTarget;
345
+ if (patch.premiumMultiplier !== undefined) result.premiumMultiplier = patch.premiumMultiplier;
346
+ if (patch.cost) {
347
+ result.cost = {
348
+ input: patch.cost.input ?? base.cost.input,
349
+ output: patch.cost.output ?? base.cost.output,
350
+ cacheRead: patch.cost.cacheRead ?? base.cost.cacheRead,
351
+ cacheWrite: patch.cost.cacheWrite ?? base.cost.cacheWrite,
352
+ };
353
+ }
354
+ let compat: ModelSpec<Api>["compat"];
355
+ if (transport === "merge") {
356
+ if (patch.headers) {
357
+ result.headers = { ...base.headers, ...patch.headers };
358
+ }
359
+ compat = mergeCompat(base.compatConfig, patch.compat);
360
+ } else {
361
+ result.headers = patch.headers;
362
+ compat = patch.compat;
363
+ }
364
+ return buildModel({ ...result, compat } as ModelSpec<Api>);
365
+ }
366
+
367
+ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
368
+ return applyModelPatch(model, override as ModelPatch, "merge");
369
+ }
370
+
371
+ interface CustomModelDefinitionLike extends ModelPatch {
372
+ id: string;
373
+ api?: Api;
374
+ baseUrl?: string;
375
+ cost?: Model<Api>["cost"];
376
+ }
377
+
626
378
  interface CustomModelBuildOptions {
627
379
  useDefaults: boolean;
628
380
  }
629
381
 
630
- type CustomModelOverlay = {
382
+ interface CustomModelOverlay extends ModelPatch {
631
383
  id: string;
632
384
  provider: string;
633
385
  api: Api;
634
386
  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;
387
+ cost?: Model<Api>["cost"];
647
388
  isOAuth?: boolean;
648
- };
389
+ }
649
390
 
650
391
  function mergeCustomModelHeaders(
651
392
  providerHeaders: Record<string, string> | undefined,
@@ -692,7 +433,7 @@ function buildCustomModelOverlay(
692
433
  providerHeaders: Record<string, string> | undefined,
693
434
  providerApiKey: string | undefined,
694
435
  authHeader: boolean | undefined,
695
- providerCompat: Model<Api>["compat"] | undefined,
436
+ providerCompat: ModelSpec<Api>["compat"] | undefined,
696
437
  providerAuth: ProviderAuthMode | undefined,
697
438
  modelDef: CustomModelDefinitionLike,
698
439
  ): CustomModelOverlay | undefined {
@@ -705,8 +446,8 @@ function buildCustomModelOverlay(
705
446
  baseUrl: modelDef.baseUrl ?? providerBaseUrl,
706
447
  name: modelDef.name,
707
448
  reasoning: modelDef.reasoning,
708
- thinking: modelDef.thinking as ThinkingConfig | undefined,
709
- input: modelDef.input as ("text" | "image")[] | undefined,
449
+ thinking: modelDef.thinking,
450
+ input: modelDef.input,
710
451
  cost: modelDef.cost,
711
452
  contextWindow: modelDef.contextWindow,
712
453
  maxTokens: modelDef.maxTokens,
@@ -719,125 +460,6 @@ function buildCustomModelOverlay(
719
460
  };
720
461
  }
721
462
 
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
463
  function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay {
842
464
  if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) {
843
465
  return model;
@@ -847,13 +469,15 @@ function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomMo
847
469
 
848
470
  function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model<Api> {
849
471
  const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : model;
850
- const reference = options.useDefaults ? resolveCustomModelReference(resolvedModel.id) : undefined;
472
+ const reference = options.useDefaults
473
+ ? resolveModelReference(resolvedModel.id, getBundledModelReferenceIndex())
474
+ : undefined;
851
475
  const cost =
852
476
  resolvedModel.cost ??
853
477
  reference?.cost ??
854
478
  (options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
855
479
  const input = resolvedModel.input ?? reference?.input ?? (options.useDefaults ? ["text"] : undefined);
856
- return enrichModelThinking({
480
+ return buildModel({
857
481
  id: resolvedModel.id,
858
482
  name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
859
483
  api: resolvedModel.api,
@@ -868,11 +492,11 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
868
492
  maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : undefined),
869
493
  headers: resolvedModel.headers,
870
494
  omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens,
871
- compat: mergeCompat(reference?.compat, resolvedModel.compat),
495
+ compat: mergeCompat(reference?.compatConfig, resolvedModel.compat),
872
496
  contextPromotionTarget: resolvedModel.contextPromotionTarget,
873
497
  premiumMultiplier: resolvedModel.premiumMultiplier,
874
498
  isOAuth: resolvedModel.isOAuth,
875
- } as Model<Api>);
499
+ } as ModelSpec<Api>);
876
500
  }
877
501
 
878
502
  function normalizeSuppressedSelector(selector: string): string {
@@ -1133,84 +757,46 @@ export class ModelRegistry {
1133
757
  return models.map(m => {
1134
758
  if (!providerOverride) return m;
1135
759
  const withTransportOverride = this.#applyProviderTransportOverride(m, providerOverride);
1136
- return {
760
+ return buildModel({
1137
761
  ...withTransportOverride,
1138
- compat: mergeCompat(m.compat, providerOverride.compat),
1139
- };
762
+ compat: mergeCompat(m.compatConfig, providerOverride.compat),
763
+ } as ModelSpec<Api>);
1140
764
  });
1141
765
  });
1142
766
  }
1143
767
 
1144
768
  #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;
769
+ return mergeByModelKey(baseModels, replacementModels, (existing, replacementModel) => {
770
+ if (!existing) return replacementModel;
771
+ return {
772
+ ...replacementModel,
773
+ contextWindow:
774
+ replacementModel.contextWindow === UNK_CONTEXT_WINDOW
775
+ ? existing.contextWindow
776
+ : replacementModel.contextWindow,
777
+ maxTokens: replacementModel.maxTokens === UNK_MAX_TOKENS ? existing.maxTokens : replacementModel.maxTokens,
778
+ };
779
+ });
1171
780
  }
1172
781
 
1173
782
  /** Merge custom models with built-in, replacing by provider+id match */
1174
783
  #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({
784
+ return mergeByModelKey(builtInModels, customModels, (existingModel, customModel) => {
785
+ if (!existingModel) return finalizeCustomModel(customModel, { useDefaults: true });
786
+ // Same-id custom definitions replace bundled transport behavior, so the
787
+ // patch is applied with the `replace` transport policy.
788
+ return applyModelPatch(
789
+ {
1187
790
  ...existingModel,
1188
791
  id: customModel.id,
1189
792
  provider: customModel.provider,
1190
793
  api: customModel.api,
1191
794
  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;
795
+ },
796
+ customModel,
797
+ "replace",
798
+ );
799
+ });
1214
800
  }
1215
801
 
1216
802
  #loadCachedStandardProviderModels(): { models: Model<Api>[]; authoritativeFreshProviders: Set<string> } {
@@ -1236,8 +822,13 @@ export class ModelRegistry {
1236
822
  ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride))
1237
823
  : models;
1238
824
  const withCompat = providerOverride?.compat
1239
- ? withTransport.map(model => ({ ...model, compat: mergeCompat(model.compat, providerOverride.compat) }))
1240
- : withTransport;
825
+ ? withTransport.map(model =>
826
+ buildModel({
827
+ ...model,
828
+ compat: mergeCompat(model.compat, providerOverride.compat),
829
+ } as ModelSpec<Api>),
830
+ )
831
+ : withTransport.map(model => buildModel(model));
1241
832
  cachedModels.push(...this.#applyProviderModelOverrides(providerId, withCompat));
1242
833
  }
1243
834
  return { models: cachedModels, authoritativeFreshProviders };
@@ -1261,7 +852,10 @@ export class ModelRegistry {
1261
852
  providerConfig.provider,
1262
853
  this.#normalizeDiscoverableModels(
1263
854
  providerConfig,
1264
- this.#applyProviderCompat(providerConfig.compat, cache.models),
855
+ this.#applyProviderCompat(
856
+ providerConfig.compat,
857
+ cache.models.map(model => buildModel(model)),
858
+ ),
1265
859
  ),
1266
860
  );
1267
861
  cachedModels.push(...models);
@@ -1277,9 +871,11 @@ export class ModelRegistry {
1277
871
  return cachedModels;
1278
872
  }
1279
873
 
1280
- #applyProviderCompat(compat: Model<Api>["compat"] | undefined, models: Model<Api>[]): Model<Api>[] {
874
+ #applyProviderCompat(compat: ModelSpec<Api>["compat"] | undefined, models: Model<Api>[]): Model<Api>[] {
1281
875
  if (!compat) return models;
1282
- return models.map(model => ({ ...model, compat: mergeCompat(model.compat, compat) }));
876
+ return models.map(model =>
877
+ buildModel({ ...model, compat: mergeCompat(model.compatConfig, compat) } as ModelSpec<Api>),
878
+ );
1283
879
  }
1284
880
 
1285
881
  #normalizeDiscoverableModels(providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
@@ -1289,7 +885,14 @@ export class ModelRegistry {
1289
885
 
1290
886
  const contextLengthOverride = getOllamaContextLengthOverride();
1291
887
  return models.map(model => {
1292
- const normalized = model.api === "openai-completions" ? { ...model, api: "openai-responses" as const } : model;
888
+ const normalized =
889
+ model.api === "openai-completions"
890
+ ? buildModel({
891
+ ...model,
892
+ api: "openai-responses" as const,
893
+ compat: model.compatConfig,
894
+ } as ModelSpec<Api>)
895
+ : model;
1293
896
  if (contextLengthOverride === undefined) {
1294
897
  return normalized;
1295
898
  }
@@ -1512,17 +1115,20 @@ export class ModelRegistry {
1512
1115
  models: cached?.models.map(model => model.id) ?? [],
1513
1116
  });
1514
1117
  this.#lastDiscoveryWarnings.delete(providerConfig.provider);
1515
- return cached?.models ?? [];
1118
+ return cached ? cached.models.map(model => buildModel(model)) : [];
1516
1119
  }
1517
1120
  }
1518
1121
 
1519
1122
  const providerId = providerConfig.provider;
1520
1123
  let discoveryError: string | undefined;
1521
- const fetchDynamicModels = async (): Promise<readonly Model<Api>[] | null> => {
1124
+ const fetchDynamicModels = async (): Promise<readonly ModelSpec<Api>[] | null> => {
1522
1125
  try {
1523
- const models = await this.#discoverModelsByProviderType(providerConfig);
1126
+ const models = this.#applyProviderModelOverrides(
1127
+ providerId,
1128
+ await discoverModelsByProviderType(providerConfig, this.#discoveryContext()),
1129
+ );
1524
1130
  this.#lastDiscoveryWarnings.delete(providerId);
1525
- return models;
1131
+ return models.map(toModelSpec);
1526
1132
  } catch (error) {
1527
1133
  discoveryError = error instanceof Error ? error.message : String(error);
1528
1134
  return null;
@@ -1569,18 +1175,14 @@ export class ModelRegistry {
1569
1175
  );
1570
1176
  }
1571
1177
 
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
- }
1178
+ #discoveryContext(): DiscoveryContext {
1179
+ return {
1180
+ fetch: this.#fetch,
1181
+ getBearerApiKey: async provider => {
1182
+ const apiKey = await this.authStorage.getApiKey(provider);
1183
+ return apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth ? apiKey : undefined;
1184
+ },
1185
+ };
1584
1186
  }
1585
1187
 
1586
1188
  #warnProviderDiscoveryFailure(providerConfig: DiscoveryProviderConfig, error: string): void {
@@ -1732,361 +1334,6 @@ export class ModelRegistry {
1732
1334
  }
1733
1335
  }
1734
1336
 
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
1337
  #applyProviderModelOverrides(provider: string, models: Model<Api>[]): Model<Api>[] {
2091
1338
  const overrides = this.#modelOverrides.get(provider);
2092
1339
  if (!overrides || overrides.size === 0) return models;
@@ -2164,7 +1411,11 @@ export class ModelRegistry {
2164
1411
  this.#rebuildPending = true;
2165
1412
  return;
2166
1413
  }
2167
- this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
1414
+ this.#canonicalIndex = buildCanonicalModelIndex(
1415
+ this.#models,
1416
+ getBundledCanonicalReferenceData(),
1417
+ this.#equivalenceConfig,
1418
+ );
2168
1419
  this.#rebuildPending = false;
2169
1420
  }
2170
1421
 
@@ -2178,7 +1429,11 @@ export class ModelRegistry {
2178
1429
  }
2179
1430
  if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
2180
1431
  this.#rebuildPending = false;
2181
- this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
1432
+ this.#canonicalIndex = buildCanonicalModelIndex(
1433
+ this.#models,
1434
+ getBundledCanonicalReferenceData(),
1435
+ this.#equivalenceConfig,
1436
+ );
2182
1437
  }
2183
1438
  }
2184
1439
 
@@ -2278,53 +1533,11 @@ export class ModelRegistry {
2278
1533
  });
2279
1534
  }
2280
1535
 
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,
1536
+ #variantPreferences(candidates: readonly Model<Api>[]): CanonicalVariantPreferences {
1537
+ return {
1538
+ modelOrder: buildCanonicalModelOrder(candidates),
1539
+ providerRank: buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings()),
2306
1540
  };
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
1541
  }
2329
1542
 
2330
1543
  getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] {
@@ -2354,15 +1567,14 @@ export class ModelRegistry {
2354
1567
  getCanonicalModelSelections(options?: CanonicalModelQueryOptions): CanonicalModelSelection[] {
2355
1568
  const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
2356
1569
  const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
2357
- const modelOrder = this.#buildModelOrder(candidates);
2358
- const providerRank = this.#providerRank();
1570
+ const preferences = this.#variantPreferences(candidates);
2359
1571
  const selections: CanonicalModelSelection[] = [];
2360
1572
  for (const record of this.#canonicalIndex.records) {
2361
1573
  const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
2362
1574
  if (variants.length === 0) {
2363
1575
  continue;
2364
1576
  }
2365
- const resolved = this.#resolveCanonicalVariant(variants, modelOrder, providerRank);
1577
+ const resolved = resolveCanonicalVariant(variants, preferences);
2366
1578
  if (!resolved) {
2367
1579
  continue;
2368
1580
  }
@@ -2389,7 +1601,7 @@ export class ModelRegistry {
2389
1601
  return undefined;
2390
1602
  }
2391
1603
  const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
2392
- return this.#resolveCanonicalVariant(variants, this.#buildModelOrder(candidates), this.#providerRank())?.model;
1604
+ return resolveCanonicalVariant(variants, this.#variantPreferences(candidates))?.model;
2393
1605
  }
2394
1606
 
2395
1607
  getCanonicalId(model: Model<Api>): string | undefined {
@@ -2698,7 +1910,7 @@ export class ModelRegistry {
2698
1910
  );
2699
1911
  if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true }));
2700
1912
  }
2701
- return results;
1913
+ return results.map(toModelSpec);
2702
1914
  },
2703
1915
  };
2704
1916
  this.#runtimeModelManagers.set(providerName, { options: managerOptions, sourceId: sourceId ?? "" });
@@ -2772,7 +1984,7 @@ export interface ProviderConfigInput {
2772
1984
  api?: Api;
2773
1985
  streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
2774
1986
  headers?: Record<string, string>;
2775
- compat?: Model<Api>["compat"];
1987
+ compat?: ModelSpec<Api>["compat"];
2776
1988
  authHeader?: boolean;
2777
1989
  /** Streaming transport override — see {@link Model.transport}. */
2778
1990
  transport?: Model<Api>["transport"];
@@ -2804,7 +2016,7 @@ export interface ProviderConfigInput {
2804
2016
  contextWindow: number;
2805
2017
  maxTokens: number;
2806
2018
  headers?: Record<string, string>;
2807
- compat?: Model<Api>["compat"];
2019
+ compat?: ModelSpec<Api>["compat"];
2808
2020
  contextPromotionTarget?: string;
2809
2021
  premiumMultiplier?: number;
2810
2022
  }>;