@oh-my-pi/pi-coding-agent 15.10.9 → 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 (352) hide show
  1. package/CHANGELOG.md +117 -0
  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 +20 -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 -16
  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/debug/terminal-info.d.ts +0 -1
  25. package/dist/types/eval/backend.d.ts +0 -2
  26. package/dist/types/eval/idle-timeout.d.ts +0 -4
  27. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  28. package/dist/types/export/html/template.generated.d.ts +1 -1
  29. package/dist/types/extensibility/extensions/types.d.ts +3 -3
  30. package/dist/types/hindsight/mental-models.d.ts +17 -8
  31. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  32. package/dist/types/internal-urls/types.d.ts +1 -1
  33. package/dist/types/lsp/edits.d.ts +9 -0
  34. package/dist/types/lsp/index.d.ts +2 -2
  35. package/dist/types/lsp/types.d.ts +2 -0
  36. package/dist/types/lsp/utils.d.ts +3 -0
  37. package/dist/types/mcp/json-rpc.d.ts +5 -0
  38. package/dist/types/mnemopi/state.d.ts +11 -1
  39. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  40. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  41. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  42. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  43. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  44. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  45. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  46. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  47. package/dist/types/modes/components/footer.d.ts +1 -1
  48. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  49. package/dist/types/modes/components/hook-input.d.ts +4 -0
  50. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  51. package/dist/types/modes/components/model-selector.d.ts +1 -1
  52. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  53. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  54. package/dist/types/modes/components/session-selector.d.ts +1 -1
  55. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  56. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  57. package/dist/types/modes/components/transcript-container.d.ts +31 -26
  58. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  59. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  60. package/dist/types/modes/components/user-message.d.ts +2 -1
  61. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  62. package/dist/types/modes/components/welcome.d.ts +19 -3
  63. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  64. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  65. package/dist/types/modes/interactive-mode.d.ts +1 -1
  66. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  67. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  68. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  69. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  70. package/dist/types/modes/types.d.ts +2 -1
  71. package/dist/types/session/agent-session.d.ts +1 -1
  72. package/dist/types/session/auth-broker-config.d.ts +4 -0
  73. package/dist/types/session/session-manager.d.ts +1 -1
  74. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  75. package/dist/types/ssh/connection-manager.d.ts +8 -0
  76. package/dist/types/task/discovery.d.ts +1 -2
  77. package/dist/types/task/parallel.d.ts +2 -2
  78. package/dist/types/task/worktree.d.ts +2 -0
  79. package/dist/types/tiny/title-client.d.ts +1 -1
  80. package/dist/types/tools/ask.d.ts +4 -0
  81. package/dist/types/tools/conflict-detect.d.ts +16 -0
  82. package/dist/types/tools/github-cache.d.ts +7 -0
  83. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  84. package/dist/types/tools/todo.d.ts +2 -0
  85. package/dist/types/tui/output-block.d.ts +3 -3
  86. package/dist/types/utils/changelog.d.ts +8 -0
  87. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  88. package/dist/types/web/scrapers/types.d.ts +12 -0
  89. package/dist/types/web/search/providers/codex.d.ts +1 -1
  90. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  91. package/examples/extensions/tools.ts +5 -4
  92. package/package.json +14 -11
  93. package/scripts/build-binary.ts +18 -23
  94. package/scripts/bundle-dist.ts +81 -0
  95. package/scripts/{dev-launch → omp} +1 -1
  96. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  97. package/src/async/job-manager.ts +57 -3
  98. package/src/autoresearch/dashboard.ts +1 -1
  99. package/src/autoresearch/prompt-setup.md +6 -6
  100. package/src/autoresearch/prompt.md +6 -6
  101. package/src/capability/fs.ts +10 -0
  102. package/src/cli/args.ts +1 -1
  103. package/src/cli/auth-gateway-cli.ts +1 -3
  104. package/src/cli/dry-balance-cli.ts +1 -1
  105. package/src/cli/gallery-cli.ts +1 -1
  106. package/src/cli/gallery-fixtures/fs.ts +1 -1
  107. package/src/cli/gallery-fixtures/types.ts +5 -1
  108. package/src/cli/list-models.ts +7 -12
  109. package/src/cli/usage-cli.ts +603 -0
  110. package/src/cli-commands.ts +1 -0
  111. package/src/cli.ts +69 -5
  112. package/src/commands/complete.ts +1 -1
  113. package/src/commands/launch.ts +1 -1
  114. package/src/commands/read.ts +6 -3
  115. package/src/commands/usage.ts +35 -0
  116. package/src/commit/agentic/agent.ts +1 -1
  117. package/src/commit/model-selection.ts +1 -1
  118. package/src/config/append-only-context-mode.ts +6 -12
  119. package/src/config/model-discovery.ts +554 -0
  120. package/src/config/model-registry.ts +308 -1025
  121. package/src/config/model-resolver.ts +113 -156
  122. package/src/config/model-roles.ts +74 -0
  123. package/src/config/models-config-schema.ts +57 -8
  124. package/src/config/models-config.ts +129 -0
  125. package/src/config/settings-schema.ts +18 -14
  126. package/src/config/settings.ts +37 -1
  127. package/src/dap/client.ts +124 -37
  128. package/src/dap/session.ts +259 -158
  129. package/src/debug/log-viewer.ts +1 -1
  130. package/src/debug/raw-sse.ts +1 -1
  131. package/src/debug/terminal-info.ts +0 -3
  132. package/src/edit/diff.ts +95 -18
  133. package/src/edit/hashline/block-resolver.ts +20 -1
  134. package/src/edit/hashline/diff.ts +36 -1
  135. package/src/edit/hashline/execute.ts +8 -2
  136. package/src/edit/index.ts +16 -1
  137. package/src/edit/modes/patch.ts +52 -0
  138. package/src/edit/modes/replace.ts +56 -22
  139. package/src/edit/notebook.ts +22 -2
  140. package/src/edit/renderer.ts +36 -10
  141. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  142. package/src/eval/backend.ts +0 -2
  143. package/src/eval/completion-bridge.ts +2 -1
  144. package/src/eval/idle-timeout.ts +2 -9
  145. package/src/eval/js/context-manager.ts +6 -8
  146. package/src/eval/js/executor.ts +6 -2
  147. package/src/eval/js/index.ts +0 -2
  148. package/src/eval/js/shared/helpers.ts +5 -6
  149. package/src/eval/js/shared/local-module-loader.ts +1 -1
  150. package/src/eval/js/shared/prelude.txt +62 -1
  151. package/src/eval/js/shared/rewrite-imports.ts +49 -23
  152. package/src/eval/js/shared/runtime.ts +1 -1
  153. package/src/eval/py/index.ts +0 -2
  154. package/src/eval/py/kernel.ts +19 -0
  155. package/src/eval/py/runner.py +107 -3
  156. package/src/exec/bash-executor.ts +3 -1
  157. package/src/export/html/template.generated.ts +1 -1
  158. package/src/export/html/template.js +3 -1
  159. package/src/extensibility/extensions/types.ts +3 -2
  160. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  161. package/src/hindsight/mental-models.ts +59 -12
  162. package/src/hindsight/state.ts +6 -1
  163. package/src/internal-urls/artifact-protocol.ts +11 -2
  164. package/src/internal-urls/docs-index.generated.ts +10 -10
  165. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  166. package/src/internal-urls/router.ts +1 -1
  167. package/src/internal-urls/types.ts +1 -1
  168. package/src/lib/xai-http.ts +1 -1
  169. package/src/lsp/client.ts +118 -38
  170. package/src/lsp/clients/biome-client.ts +101 -39
  171. package/src/lsp/edits.ts +143 -95
  172. package/src/lsp/index.ts +31 -22
  173. package/src/lsp/render.ts +1 -1
  174. package/src/lsp/types.ts +2 -0
  175. package/src/lsp/utils.ts +28 -10
  176. package/src/main.ts +165 -17
  177. package/src/mcp/json-rpc.ts +35 -5
  178. package/src/mcp/transports/stdio.ts +7 -1
  179. package/src/memories/index.ts +2 -1
  180. package/src/mnemopi/backend.ts +25 -3
  181. package/src/mnemopi/state.ts +38 -2
  182. package/src/modes/components/agent-dashboard.ts +10 -7
  183. package/src/modes/components/assistant-message.ts +19 -13
  184. package/src/modes/components/bash-execution.ts +1 -1
  185. package/src/modes/components/copy-selector.ts +1 -1
  186. package/src/modes/components/diff.ts +13 -2
  187. package/src/modes/components/dynamic-border.ts +12 -3
  188. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  189. package/src/modes/components/extensions/extension-list.ts +1 -1
  190. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  191. package/src/modes/components/footer.ts +1 -1
  192. package/src/modes/components/history-search.ts +1 -1
  193. package/src/modes/components/hook-editor.ts +8 -0
  194. package/src/modes/components/hook-input.ts +8 -0
  195. package/src/modes/components/hook-selector.ts +2 -2
  196. package/src/modes/components/model-selector.ts +66 -54
  197. package/src/modes/components/plan-review-overlay.ts +1 -1
  198. package/src/modes/components/session-observer-overlay.ts +2 -2
  199. package/src/modes/components/session-selector.ts +1 -1
  200. package/src/modes/components/settings-selector.ts +5 -1
  201. package/src/modes/components/status-line/component.ts +1 -1
  202. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  203. package/src/modes/components/transcript-container.ts +373 -141
  204. package/src/modes/components/tree-selector.ts +3 -3
  205. package/src/modes/components/user-message-selector.ts +1 -1
  206. package/src/modes/components/user-message.ts +17 -5
  207. package/src/modes/components/visual-truncate.ts +1 -1
  208. package/src/modes/components/welcome.ts +108 -26
  209. package/src/modes/controllers/command-controller.ts +10 -3
  210. package/src/modes/controllers/event-controller.ts +73 -49
  211. package/src/modes/controllers/input-controller.ts +5 -5
  212. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  213. package/src/modes/controllers/selector-controller.ts +1 -5
  214. package/src/modes/controllers/streaming-reveal.ts +85 -18
  215. package/src/modes/interactive-mode.ts +5 -19
  216. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  217. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  218. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  219. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  220. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  221. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  222. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  223. package/src/modes/types.ts +2 -1
  224. package/src/prompts/agents/explore.md +2 -2
  225. package/src/prompts/agents/librarian.md +1 -2
  226. package/src/prompts/agents/oracle.md +1 -1
  227. package/src/prompts/agents/plan.md +5 -5
  228. package/src/prompts/agents/task.md +5 -5
  229. package/src/prompts/ci-green-request.md +5 -7
  230. package/src/prompts/goals/goal-budget-limit.md +2 -2
  231. package/src/prompts/goals/goal-continuation.md +4 -4
  232. package/src/prompts/goals/goal-mode-active.md +1 -1
  233. package/src/prompts/memories/read-path.md +1 -1
  234. package/src/prompts/memories/stage_one_system.md +2 -2
  235. package/src/prompts/review-custom-request.md +1 -1
  236. package/src/prompts/system/agent-creation-architect.md +2 -2
  237. package/src/prompts/system/auto-continue.md +1 -1
  238. package/src/prompts/system/background-tan-dispatch.md +1 -1
  239. package/src/prompts/system/btw-user.md +2 -2
  240. package/src/prompts/system/commit-message-system.md +13 -1
  241. package/src/prompts/system/custom-system-prompt.md +1 -1
  242. package/src/prompts/system/eager-todo.md +2 -2
  243. package/src/prompts/system/irc-incoming.md +1 -1
  244. package/src/prompts/system/manual-continue.md +1 -1
  245. package/src/prompts/system/omfg-user.md +3 -4
  246. package/src/prompts/system/orchestrate-notice.md +9 -9
  247. package/src/prompts/system/plan-mode-active.md +4 -4
  248. package/src/prompts/system/plan-mode-subagent.md +4 -5
  249. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  250. package/src/prompts/system/project-prompt.md +2 -2
  251. package/src/prompts/system/subagent-system-prompt.md +4 -4
  252. package/src/prompts/system/system-prompt.md +15 -26
  253. package/src/prompts/system/title-system.md +2 -2
  254. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  255. package/src/prompts/system/workflow-notice.md +1 -1
  256. package/src/prompts/tools/ast-edit.md +1 -1
  257. package/src/prompts/tools/ast-grep.md +2 -2
  258. package/src/prompts/tools/bash.md +8 -10
  259. package/src/prompts/tools/browser.md +7 -7
  260. package/src/prompts/tools/debug.md +1 -1
  261. package/src/prompts/tools/eval.md +3 -3
  262. package/src/prompts/tools/find.md +0 -1
  263. package/src/prompts/tools/github.md +8 -7
  264. package/src/prompts/tools/goal.md +1 -1
  265. package/src/prompts/tools/image-gen.md +1 -1
  266. package/src/prompts/tools/inspect-image-system.md +1 -1
  267. package/src/prompts/tools/irc.md +15 -15
  268. package/src/prompts/tools/lsp.md +2 -2
  269. package/src/prompts/tools/patch.md +2 -2
  270. package/src/prompts/tools/read.md +3 -4
  271. package/src/prompts/tools/recall.md +1 -1
  272. package/src/prompts/tools/reflect.md +1 -1
  273. package/src/prompts/tools/render-mermaid.md +2 -2
  274. package/src/prompts/tools/replace.md +4 -10
  275. package/src/prompts/tools/rewind.md +2 -2
  276. package/src/prompts/tools/search-tool-bm25.md +1 -9
  277. package/src/prompts/tools/search.md +0 -1
  278. package/src/prompts/tools/ssh.md +0 -4
  279. package/src/prompts/tools/task.md +2 -3
  280. package/src/prompts/tools/todo.md +6 -2
  281. package/src/sdk.ts +23 -10
  282. package/src/session/agent-session.ts +44 -10
  283. package/src/session/auth-broker-config.ts +30 -1
  284. package/src/session/session-manager.ts +2 -2
  285. package/src/session/streaming-output.ts +23 -2
  286. package/src/slash-commands/builtin-registry.ts +20 -0
  287. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  288. package/src/ssh/connection-manager.ts +27 -0
  289. package/src/task/commands.ts +2 -1
  290. package/src/task/discovery.ts +17 -24
  291. package/src/task/executor.ts +61 -53
  292. package/src/task/index.ts +137 -60
  293. package/src/task/parallel.ts +3 -3
  294. package/src/task/render.ts +2 -2
  295. package/src/task/worktree.ts +64 -56
  296. package/src/thinking.ts +2 -1
  297. package/src/tiny/title-client.ts +32 -14
  298. package/src/tools/archive-reader.ts +30 -2
  299. package/src/tools/ask.ts +104 -21
  300. package/src/tools/ast-edit.ts +25 -5
  301. package/src/tools/auto-generated-guard.ts +20 -3
  302. package/src/tools/bash-interactive.ts +27 -7
  303. package/src/tools/bash.ts +54 -13
  304. package/src/tools/browser/launch.ts +11 -2
  305. package/src/tools/browser/readable.ts +19 -2
  306. package/src/tools/browser/registry.ts +4 -1
  307. package/src/tools/browser/render.ts +2 -2
  308. package/src/tools/browser/tab-supervisor.ts +55 -16
  309. package/src/tools/conflict-detect.ts +50 -4
  310. package/src/tools/debug.ts +1 -1
  311. package/src/tools/eval-render.ts +5 -5
  312. package/src/tools/eval.ts +0 -2
  313. package/src/tools/fetch.ts +33 -10
  314. package/src/tools/gh-cache-invalidation.ts +63 -8
  315. package/src/tools/gh-renderer.ts +1 -1
  316. package/src/tools/gh.ts +172 -29
  317. package/src/tools/github-cache.ts +70 -6
  318. package/src/tools/image-gen.ts +3 -9
  319. package/src/tools/irc.ts +5 -1
  320. package/src/tools/job.ts +1 -1
  321. package/src/tools/read.ts +202 -61
  322. package/src/tools/render-utils.ts +3 -3
  323. package/src/tools/resolve.ts +1 -1
  324. package/src/tools/search.ts +92 -29
  325. package/src/tools/sqlite-reader.ts +17 -5
  326. package/src/tools/ssh.ts +8 -8
  327. package/src/tools/todo.ts +51 -12
  328. package/src/tools/write.ts +118 -18
  329. package/src/tui/output-block.ts +4 -4
  330. package/src/utils/changelog.ts +27 -1
  331. package/src/utils/file-mentions.ts +2 -1
  332. package/src/web/scrapers/arxiv.ts +1 -1
  333. package/src/web/scrapers/go-pkg.ts +1 -1
  334. package/src/web/scrapers/iacr.ts +1 -1
  335. package/src/web/scrapers/readthedocs.ts +1 -1
  336. package/src/web/scrapers/twitter.ts +2 -1
  337. package/src/web/scrapers/types.ts +87 -8
  338. package/src/web/scrapers/wikipedia.ts +1 -1
  339. package/src/web/scrapers/youtube.ts +6 -1
  340. package/src/web/search/index.ts +1 -1
  341. package/src/web/search/providers/anthropic.ts +8 -2
  342. package/src/web/search/providers/codex.ts +2 -1
  343. package/src/web/search/providers/gemini.ts +2 -3
  344. package/src/web/search/render.ts +8 -6
  345. package/dist/types/config/model-equivalence.d.ts +0 -24
  346. package/dist/types/config/model-id-affixes.d.ts +0 -12
  347. package/dist/types/config/model-provider-priority.d.ts +0 -1
  348. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  349. package/src/config/model-equivalence.ts +0 -875
  350. package/src/config/model-id-affixes.ts +0 -81
  351. package/src/config/model-provider-priority.ts +0 -56
  352. 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 {
@@ -428,6 +207,12 @@ export interface CanonicalModelQueryOptions {
428
207
  candidates?: readonly Model<Api>[];
429
208
  }
430
209
 
210
+ /** A canonical record (with query-filtered variants) plus the variant model selected for it. */
211
+ export interface CanonicalModelSelection {
212
+ record: CanonicalModelRecord;
213
+ model: Model<Api>;
214
+ }
215
+
431
216
  /** Result of loading custom models from models.json */
432
217
  interface CustomModelsResult {
433
218
  models?: CustomModelOverlay[];
@@ -441,17 +226,6 @@ interface CustomModelsResult {
441
226
  found: boolean;
442
227
  }
443
228
 
444
- type OllamaDiscoveredModelMetadata = {
445
- reasoning: boolean;
446
- input: ("text" | "image")[];
447
- contextWindow?: number;
448
- };
449
-
450
- type LlamaCppDiscoveredServerMetadata = {
451
- contextWindow?: number;
452
- input?: ("text" | "image")[];
453
- };
454
-
455
229
  /**
456
230
  * Resolve an API key config value to an actual key.
457
231
  * Checks environment variable first, then treats as literal.
@@ -462,59 +236,6 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined {
462
236
  return keyConfig;
463
237
  }
464
238
 
465
- function toPositiveNumberOrUndefined(value: unknown): number | undefined {
466
- if (typeof value === "number" && Number.isFinite(value) && value > 0) {
467
- return value;
468
- }
469
- if (typeof value === "string" && value.trim()) {
470
- const parsed = Number(value);
471
- if (Number.isFinite(parsed) && parsed > 0) {
472
- return parsed;
473
- }
474
- }
475
- return undefined;
476
- }
477
-
478
- function extractOllamaContextWindow(payload: Record<string, unknown>): number | undefined {
479
- const modelInfo = payload.model_info;
480
- if (isRecord(modelInfo)) {
481
- for (const [key, value] of Object.entries(modelInfo)) {
482
- if (key === "context_length" || key.endsWith(".context_length")) {
483
- const contextWindow = toPositiveNumberOrUndefined(value);
484
- if (contextWindow !== undefined) {
485
- return contextWindow;
486
- }
487
- }
488
- }
489
- }
490
-
491
- const parameters = payload.parameters;
492
- if (typeof parameters !== "string") {
493
- return undefined;
494
- }
495
- const match = parameters.match(/(?:^|\n)\s*num_ctx\s+(\d+)\s*(?:$|\n)/m);
496
- return match ? toPositiveNumberOrUndefined(match[1]) : undefined;
497
- }
498
-
499
- function extractLlamaCppContextWindow(payload: Record<string, unknown>): number | undefined {
500
- const generationSettings = payload.default_generation_settings;
501
- if (isRecord(generationSettings)) {
502
- const contextWindow = toPositiveNumberOrUndefined(generationSettings.n_ctx);
503
- if (contextWindow !== undefined) {
504
- return contextWindow;
505
- }
506
- }
507
- return toPositiveNumberOrUndefined(payload.n_ctx);
508
- }
509
-
510
- function extractLlamaCppInputCapabilities(payload: Record<string, unknown>): ("text" | "image")[] | undefined {
511
- const modalities = payload.modalities;
512
- if (!isRecord(modalities)) {
513
- return undefined;
514
- }
515
- return modalities.vision === true ? ["text", "image"] : ["text"];
516
- }
517
-
518
239
  function extractGoogleOAuthToken(value: string | undefined): string | undefined {
519
240
  if (!isAuthenticated(value)) return undefined;
520
241
  try {
@@ -573,73 +294,99 @@ function mergeCompat<TBase extends object, TOverride extends object>(
573
294
  return merged as TBase & TOverride;
574
295
  }
575
296
 
576
- function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
577
- const result = { ...model };
578
- if (override.name !== undefined) result.name = override.name;
579
- if (override.reasoning !== undefined) result.reasoning = override.reasoning;
580
- if (override.thinking !== undefined) result.thinking = override.thinking as ThinkingConfig;
581
- if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
582
- if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow;
583
- if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
584
- if (override.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = override.omitMaxOutputTokens;
585
- if (override.contextPromotionTarget !== undefined) result.contextPromotionTarget = override.contextPromotionTarget;
586
- if (override.premiumMultiplier !== undefined) result.premiumMultiplier = override.premiumMultiplier;
587
- if (override.cost) {
588
- result.cost = {
589
- input: override.cost.input ?? model.cost.input,
590
- output: override.cost.output ?? model.cost.output,
591
- cacheRead: override.cost.cacheRead ?? model.cost.cacheRead,
592
- cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite,
593
- };
594
- }
595
- if (override.headers) {
596
- result.headers = { ...model.headers, ...override.headers };
597
- }
598
- result.compat = mergeCompat(model.compat, override.compat);
599
- 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>;
600
304
  }
601
305
 
602
- interface CustomModelDefinitionLike {
603
- 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 {
604
312
  name?: string;
605
- api?: Api;
606
- baseUrl?: string;
607
313
  reasoning?: boolean;
608
314
  thinking?: ThinkingConfig;
609
315
  input?: ("text" | "image")[];
610
- cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
316
+ cost?: Partial<Model<Api>["cost"]>;
611
317
  contextWindow?: number;
612
318
  maxTokens?: number;
613
319
  omitMaxOutputTokens?: boolean;
614
320
  headers?: Record<string, string>;
615
- compat?: Model<Api>["compat"];
321
+ compat?: ModelSpec<Api>["compat"];
616
322
  contextPromotionTarget?: string;
617
323
  premiumMultiplier?: number;
618
324
  }
619
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
+
620
378
  interface CustomModelBuildOptions {
621
379
  useDefaults: boolean;
622
380
  }
623
381
 
624
- type CustomModelOverlay = {
382
+ interface CustomModelOverlay extends ModelPatch {
625
383
  id: string;
626
384
  provider: string;
627
385
  api: Api;
628
386
  baseUrl: string;
629
- name?: string;
630
- reasoning?: boolean;
631
- thinking?: ThinkingConfig;
632
- input?: ("text" | "image")[];
633
- cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
634
- contextWindow?: number;
635
- maxTokens?: number;
636
- omitMaxOutputTokens?: boolean;
637
- headers?: Record<string, string>;
638
- compat?: Model<Api>["compat"];
639
- contextPromotionTarget?: string;
640
- premiumMultiplier?: number;
387
+ cost?: Model<Api>["cost"];
641
388
  isOAuth?: boolean;
642
- };
389
+ }
643
390
 
644
391
  function mergeCustomModelHeaders(
645
392
  providerHeaders: Record<string, string> | undefined,
@@ -686,7 +433,7 @@ function buildCustomModelOverlay(
686
433
  providerHeaders: Record<string, string> | undefined,
687
434
  providerApiKey: string | undefined,
688
435
  authHeader: boolean | undefined,
689
- providerCompat: Model<Api>["compat"] | undefined,
436
+ providerCompat: ModelSpec<Api>["compat"] | undefined,
690
437
  providerAuth: ProviderAuthMode | undefined,
691
438
  modelDef: CustomModelDefinitionLike,
692
439
  ): CustomModelOverlay | undefined {
@@ -699,8 +446,8 @@ function buildCustomModelOverlay(
699
446
  baseUrl: modelDef.baseUrl ?? providerBaseUrl,
700
447
  name: modelDef.name,
701
448
  reasoning: modelDef.reasoning,
702
- thinking: modelDef.thinking as ThinkingConfig | undefined,
703
- input: modelDef.input as ("text" | "image")[] | undefined,
449
+ thinking: modelDef.thinking,
450
+ input: modelDef.input,
704
451
  cost: modelDef.cost,
705
452
  contextWindow: modelDef.contextWindow,
706
453
  maxTokens: modelDef.maxTokens,
@@ -713,125 +460,6 @@ function buildCustomModelOverlay(
713
460
  };
714
461
  }
715
462
 
716
- // Custom provider entries often front a known upstream model through a local proxy.
717
- // Use bundled metadata for missing pricing/capability fields, but keep the custom transport.
718
- function shouldReplaceCustomReference(existing: Model<Api> | undefined, candidate: Model<Api>): boolean {
719
- if (!existing) return true;
720
- if (candidate.contextWindow !== existing.contextWindow) {
721
- return candidate.contextWindow > existing.contextWindow;
722
- }
723
- if (candidate.maxTokens !== existing.maxTokens) {
724
- return candidate.maxTokens > existing.maxTokens;
725
- }
726
- const existingHasCachePricing = existing.cost.cacheRead > 0 || existing.cost.cacheWrite > 0;
727
- const candidateHasCachePricing = candidate.cost.cacheRead > 0 || candidate.cost.cacheWrite > 0;
728
- if (candidateHasCachePricing !== existingHasCachePricing) {
729
- return candidateHasCachePricing;
730
- }
731
- return existing.provider !== "openai" && candidate.provider === "openai";
732
- }
733
-
734
- function normalizeCustomReferenceKey(value: string): string {
735
- return value.trim().toLowerCase();
736
- }
737
-
738
- function buildCustomReferenceMap(): Map<string, Model<Api>> {
739
- const references = new Map<string, Model<Api>>();
740
- for (const provider of getBundledProviders()) {
741
- for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0])) {
742
- const candidate = model as Model<Api>;
743
- const key = normalizeCustomReferenceKey(candidate.id);
744
- if (shouldReplaceCustomReference(references.get(key), candidate)) {
745
- references.set(key, candidate);
746
- }
747
- }
748
- }
749
- return references;
750
- }
751
-
752
- function buildCustomReferenceSuffixAliasMap(exactReferences: ReadonlyMap<string, Model<Api>>): Map<string, Model<Api>> {
753
- const aliases = new Map<string, Model<Api>>();
754
- for (const reference of exactReferences.values()) {
755
- const slashIndex = reference.id.lastIndexOf("/");
756
- if (slashIndex === -1) {
757
- continue;
758
- }
759
- const suffix = reference.id.slice(slashIndex + 1);
760
- const alias = getLongestModelLikeIdSegment(suffix);
761
- if (!alias) {
762
- continue;
763
- }
764
- if (shouldReplaceCustomReference(aliases.get(alias), reference)) {
765
- aliases.set(alias, reference);
766
- }
767
- }
768
- return aliases;
769
- }
770
-
771
- const customReferenceMap = buildCustomReferenceMap();
772
- const customReferenceSuffixAliasMap = buildCustomReferenceSuffixAliasMap(customReferenceMap);
773
-
774
- const CUSTOM_REFERENCE_TRAILING_MARKER_PATTERN =
775
- /[-:](?:thinking|customtools|high|low|medium|minimal|xhigh|free|cloud|exacto|nitro|original|optimized|nvfp4|fp8|fp4|bf16|int8|int4|search)$/i;
776
-
777
- function stripCustomReferenceTrailingMarker(candidate: string): string | undefined {
778
- const match = CUSTOM_REFERENCE_TRAILING_MARKER_PATTERN.exec(candidate);
779
- return match ? candidate.slice(0, match.index) : undefined;
780
- }
781
-
782
- function getCustomReferenceCandidateIds(modelId: string): string[] {
783
- const candidates = new Set<string>();
784
- const queue = [modelId];
785
- for (let index = 0; index < queue.length; index += 1) {
786
- const candidate = queue[index]?.trim();
787
- if (!candidate || candidates.has(candidate)) continue;
788
- candidates.add(candidate);
789
-
790
- for (const stripped of getBracketStrippedModelIdCandidates(candidate)) {
791
- queue.push(stripped);
792
- }
793
- for (const segment of getModelLikeIdSegments(candidate)) {
794
- queue.push(segment);
795
- }
796
-
797
- for (const suffix of [":cloud", "-cloud"] as const) {
798
- if (candidate.toLowerCase().endsWith(suffix)) {
799
- queue.push(candidate.slice(0, -suffix.length));
800
- }
801
- }
802
-
803
- const slashIndex = candidate.lastIndexOf("/");
804
- if (slashIndex !== -1) {
805
- queue.push(candidate.slice(slashIndex + 1));
806
- }
807
-
808
- const colonToDash = candidate.replace(/:/g, "-");
809
- if (colonToDash !== candidate) {
810
- queue.push(colonToDash);
811
- }
812
-
813
- const lowercased = candidate.toLowerCase();
814
- if (lowercased !== candidate) {
815
- queue.push(lowercased);
816
- }
817
-
818
- const strippedMarker = stripCustomReferenceTrailingMarker(candidate);
819
- if (strippedMarker) {
820
- queue.push(strippedMarker);
821
- }
822
- }
823
- return [...candidates];
824
- }
825
-
826
- function resolveCustomModelReference(modelId: string): Model<Api> | undefined {
827
- for (const candidate of getCustomReferenceCandidateIds(modelId)) {
828
- const key = normalizeCustomReferenceKey(candidate);
829
- const reference = customReferenceMap.get(key) ?? customReferenceSuffixAliasMap.get(key);
830
- if (reference) return reference;
831
- }
832
- return undefined;
833
- }
834
-
835
463
  function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay {
836
464
  if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) {
837
465
  return model;
@@ -841,13 +469,15 @@ function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomMo
841
469
 
842
470
  function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model<Api> {
843
471
  const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : model;
844
- const reference = options.useDefaults ? resolveCustomModelReference(resolvedModel.id) : undefined;
472
+ const reference = options.useDefaults
473
+ ? resolveModelReference(resolvedModel.id, getBundledModelReferenceIndex())
474
+ : undefined;
845
475
  const cost =
846
476
  resolvedModel.cost ??
847
477
  reference?.cost ??
848
478
  (options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
849
479
  const input = resolvedModel.input ?? reference?.input ?? (options.useDefaults ? ["text"] : undefined);
850
- return enrichModelThinking({
480
+ return buildModel({
851
481
  id: resolvedModel.id,
852
482
  name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
853
483
  api: resolvedModel.api,
@@ -862,11 +492,11 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
862
492
  maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : undefined),
863
493
  headers: resolvedModel.headers,
864
494
  omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens,
865
- compat: mergeCompat(reference?.compat, resolvedModel.compat),
495
+ compat: mergeCompat(reference?.compatConfig, resolvedModel.compat),
866
496
  contextPromotionTarget: resolvedModel.contextPromotionTarget,
867
497
  premiumMultiplier: resolvedModel.premiumMultiplier,
868
498
  isOAuth: resolvedModel.isOAuth,
869
- } as Model<Api>);
499
+ } as ModelSpec<Api>);
870
500
  }
871
501
 
872
502
  function normalizeSuppressedSelector(selector: string): string {
@@ -1127,84 +757,46 @@ export class ModelRegistry {
1127
757
  return models.map(m => {
1128
758
  if (!providerOverride) return m;
1129
759
  const withTransportOverride = this.#applyProviderTransportOverride(m, providerOverride);
1130
- return {
760
+ return buildModel({
1131
761
  ...withTransportOverride,
1132
- compat: mergeCompat(m.compat, providerOverride.compat),
1133
- };
762
+ compat: mergeCompat(m.compatConfig, providerOverride.compat),
763
+ } as ModelSpec<Api>);
1134
764
  });
1135
765
  });
1136
766
  }
1137
767
 
1138
768
  #mergeResolvedModels(baseModels: Model<Api>[], replacementModels: Model<Api>[]): Model<Api>[] {
1139
- const merged = [...baseModels];
1140
- const indexByKey = new Map<string, number>();
1141
- for (let i = 0; i < merged.length; i += 1) {
1142
- const m = merged[i];
1143
- indexByKey.set(`${m.provider}\u0000${m.id}`, i);
1144
- }
1145
- for (const replacementModel of replacementModels) {
1146
- const key = `${replacementModel.provider}\u0000${replacementModel.id}`;
1147
- const existingIndex = indexByKey.get(key);
1148
- if (existingIndex !== undefined) {
1149
- const existing = merged[existingIndex];
1150
- merged[existingIndex] = {
1151
- ...replacementModel,
1152
- contextWindow:
1153
- replacementModel.contextWindow === UNK_CONTEXT_WINDOW
1154
- ? existing.contextWindow
1155
- : replacementModel.contextWindow,
1156
- maxTokens:
1157
- replacementModel.maxTokens === UNK_MAX_TOKENS ? existing.maxTokens : replacementModel.maxTokens,
1158
- };
1159
- } else {
1160
- merged.push(replacementModel);
1161
- indexByKey.set(key, merged.length - 1);
1162
- }
1163
- }
1164
- 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
+ });
1165
780
  }
1166
781
 
1167
782
  /** Merge custom models with built-in, replacing by provider+id match */
1168
783
  #mergeCustomModels(builtInModels: Model<Api>[], customModels: CustomModelOverlay[]): Model<Api>[] {
1169
- const merged = [...builtInModels];
1170
- const indexByKey = new Map<string, number>();
1171
- for (let i = 0; i < merged.length; i += 1) {
1172
- const m = merged[i];
1173
- indexByKey.set(`${m.provider}\u0000${m.id}`, i);
1174
- }
1175
- for (const customModel of customModels) {
1176
- const key = `${customModel.provider}\u0000${customModel.id}`;
1177
- const existingIndex = indexByKey.get(key);
1178
- if (existingIndex !== undefined) {
1179
- const existingModel = merged[existingIndex];
1180
- 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
+ {
1181
790
  ...existingModel,
1182
791
  id: customModel.id,
1183
792
  provider: customModel.provider,
1184
793
  api: customModel.api,
1185
794
  baseUrl: customModel.baseUrl,
1186
- name: customModel.name ?? existingModel.name,
1187
- reasoning: customModel.reasoning ?? existingModel.reasoning,
1188
- thinking: customModel.thinking ?? existingModel.thinking,
1189
- input: customModel.input ?? existingModel.input,
1190
- cost: customModel.cost ?? existingModel.cost,
1191
- contextWindow: customModel.contextWindow ?? existingModel.contextWindow,
1192
- maxTokens: customModel.maxTokens ?? existingModel.maxTokens,
1193
- omitMaxOutputTokens: customModel.omitMaxOutputTokens ?? existingModel.omitMaxOutputTokens,
1194
- // Same-id custom definitions replace bundled transport behavior. Provider-level
1195
- // headers/compat were already folded into customModel during parsing; do not
1196
- // re-merge bundled transport metadata here.
1197
- headers: customModel.headers,
1198
- compat: customModel.compat,
1199
- contextPromotionTarget: customModel.contextPromotionTarget ?? existingModel.contextPromotionTarget,
1200
- premiumMultiplier: customModel.premiumMultiplier ?? existingModel.premiumMultiplier,
1201
- } as Model<Api>);
1202
- } else {
1203
- merged.push(finalizeCustomModel(customModel, { useDefaults: true }));
1204
- indexByKey.set(key, merged.length - 1);
1205
- }
1206
- }
1207
- return merged;
795
+ },
796
+ customModel,
797
+ "replace",
798
+ );
799
+ });
1208
800
  }
1209
801
 
1210
802
  #loadCachedStandardProviderModels(): { models: Model<Api>[]; authoritativeFreshProviders: Set<string> } {
@@ -1230,8 +822,13 @@ export class ModelRegistry {
1230
822
  ? models.map(model => this.#applyProviderTransportOverride(model, providerOverride))
1231
823
  : models;
1232
824
  const withCompat = providerOverride?.compat
1233
- ? withTransport.map(model => ({ ...model, compat: mergeCompat(model.compat, providerOverride.compat) }))
1234
- : 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));
1235
832
  cachedModels.push(...this.#applyProviderModelOverrides(providerId, withCompat));
1236
833
  }
1237
834
  return { models: cachedModels, authoritativeFreshProviders };
@@ -1255,7 +852,10 @@ export class ModelRegistry {
1255
852
  providerConfig.provider,
1256
853
  this.#normalizeDiscoverableModels(
1257
854
  providerConfig,
1258
- this.#applyProviderCompat(providerConfig.compat, cache.models),
855
+ this.#applyProviderCompat(
856
+ providerConfig.compat,
857
+ cache.models.map(model => buildModel(model)),
858
+ ),
1259
859
  ),
1260
860
  );
1261
861
  cachedModels.push(...models);
@@ -1271,9 +871,11 @@ export class ModelRegistry {
1271
871
  return cachedModels;
1272
872
  }
1273
873
 
1274
- #applyProviderCompat(compat: Model<Api>["compat"] | undefined, models: Model<Api>[]): Model<Api>[] {
874
+ #applyProviderCompat(compat: ModelSpec<Api>["compat"] | undefined, models: Model<Api>[]): Model<Api>[] {
1275
875
  if (!compat) return models;
1276
- 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
+ );
1277
879
  }
1278
880
 
1279
881
  #normalizeDiscoverableModels(providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
@@ -1283,7 +885,14 @@ export class ModelRegistry {
1283
885
 
1284
886
  const contextLengthOverride = getOllamaContextLengthOverride();
1285
887
  return models.map(model => {
1286
- 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;
1287
896
  if (contextLengthOverride === undefined) {
1288
897
  return normalized;
1289
898
  }
@@ -1506,17 +1115,20 @@ export class ModelRegistry {
1506
1115
  models: cached?.models.map(model => model.id) ?? [],
1507
1116
  });
1508
1117
  this.#lastDiscoveryWarnings.delete(providerConfig.provider);
1509
- return cached?.models ?? [];
1118
+ return cached ? cached.models.map(model => buildModel(model)) : [];
1510
1119
  }
1511
1120
  }
1512
1121
 
1513
1122
  const providerId = providerConfig.provider;
1514
1123
  let discoveryError: string | undefined;
1515
- const fetchDynamicModels = async (): Promise<readonly Model<Api>[] | null> => {
1124
+ const fetchDynamicModels = async (): Promise<readonly ModelSpec<Api>[] | null> => {
1516
1125
  try {
1517
- const models = await this.#discoverModelsByProviderType(providerConfig);
1126
+ const models = this.#applyProviderModelOverrides(
1127
+ providerId,
1128
+ await discoverModelsByProviderType(providerConfig, this.#discoveryContext()),
1129
+ );
1518
1130
  this.#lastDiscoveryWarnings.delete(providerId);
1519
- return models;
1131
+ return models.map(toModelSpec);
1520
1132
  } catch (error) {
1521
1133
  discoveryError = error instanceof Error ? error.message : String(error);
1522
1134
  return null;
@@ -1563,18 +1175,14 @@ export class ModelRegistry {
1563
1175
  );
1564
1176
  }
1565
1177
 
1566
- #discoverModelsByProviderType(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1567
- switch (providerConfig.discovery.type) {
1568
- case "ollama":
1569
- return this.#discoverOllamaModels(providerConfig);
1570
- case "llama.cpp":
1571
- return this.#discoverLlamaCppModels(providerConfig);
1572
- case "lm-studio":
1573
- case "openai-models-list":
1574
- return this.#discoverOpenAIModelsList(providerConfig);
1575
- case "proxy":
1576
- return this.#discoverProxyModels(providerConfig);
1577
- }
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
+ };
1578
1186
  }
1579
1187
 
1580
1188
  #warnProviderDiscoveryFailure(providerConfig: DiscoveryProviderConfig, error: string): void {
@@ -1726,361 +1334,6 @@ export class ModelRegistry {
1726
1334
  }
1727
1335
  }
1728
1336
 
1729
- async #discoverOllamaModelMetadata(
1730
- endpoint: string,
1731
- modelId: string,
1732
- headers: Record<string, string> | undefined,
1733
- ): Promise<OllamaDiscoveredModelMetadata | null> {
1734
- const showUrl = `${endpoint}/api/show`;
1735
- try {
1736
- const response = await this.#fetch(showUrl, {
1737
- method: "POST",
1738
- headers: { ...(headers ?? {}), "Content-Type": "application/json" },
1739
- body: JSON.stringify({ model: modelId }),
1740
- signal: AbortSignal.timeout(150),
1741
- });
1742
- if (!response.ok) {
1743
- return null;
1744
- }
1745
- const payload = (await response.json()) as unknown;
1746
- if (!isRecord(payload)) {
1747
- return null;
1748
- }
1749
- const contextWindow = extractOllamaContextWindow(payload);
1750
- const capabilities = payload.capabilities;
1751
- if (Array.isArray(capabilities)) {
1752
- const normalized = new Set(
1753
- capabilities.flatMap(capability => (typeof capability === "string" ? [capability.toLowerCase()] : [])),
1754
- );
1755
- const supportsVision = normalized.has("vision") || normalized.has("image");
1756
- return {
1757
- reasoning: normalized.has("thinking"),
1758
- input: supportsVision ? ["text", "image"] : ["text"],
1759
- contextWindow,
1760
- };
1761
- }
1762
- if (!isRecord(capabilities)) {
1763
- return {
1764
- reasoning: false,
1765
- input: ["text"],
1766
- contextWindow,
1767
- };
1768
- }
1769
- const supportsVision = capabilities.vision === true || capabilities.image === true;
1770
- return {
1771
- reasoning: capabilities.thinking === true,
1772
- input: supportsVision ? ["text", "image"] : ["text"],
1773
- contextWindow,
1774
- };
1775
- } catch {
1776
- return null;
1777
- }
1778
- }
1779
-
1780
- async #discoverOllamaModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1781
- const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl);
1782
- const tagsUrl = `${endpoint}/api/tags`;
1783
- const headers = { ...(providerConfig.headers ?? {}) };
1784
- const response = await this.#fetch(tagsUrl, {
1785
- headers,
1786
- signal: AbortSignal.timeout(250),
1787
- });
1788
- if (!response.ok) {
1789
- throw new Error(`HTTP ${response.status} from ${tagsUrl}`);
1790
- }
1791
- const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> };
1792
- const entries = (payload.models ?? []).flatMap(item => {
1793
- const id = item.model || item.name;
1794
- return id ? [{ id, name: item.name || id }] : [];
1795
- });
1796
- const metadataById = new Map(
1797
- await Promise.all(
1798
- entries.map(
1799
- async entry => [entry.id, await this.#discoverOllamaModelMetadata(endpoint, entry.id, headers)] as const,
1800
- ),
1801
- ),
1802
- );
1803
- const discovered = entries.map(entry => {
1804
- const metadata = metadataById.get(entry.id);
1805
- return enrichModelThinking({
1806
- id: entry.id,
1807
- name: entry.name,
1808
- api: providerConfig.api,
1809
- provider: providerConfig.provider,
1810
- baseUrl: `${endpoint}/v1`,
1811
- reasoning: metadata?.reasoning ?? false,
1812
- input: metadata?.input ?? ["text"],
1813
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1814
- contextWindow: metadata?.contextWindow ?? 128000,
1815
- maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, DISCOVERY_DEFAULT_MAX_TOKENS),
1816
- headers: providerConfig.headers,
1817
- });
1818
- });
1819
- return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
1820
- }
1821
-
1822
- async #discoverLlamaCppServerMetadata(
1823
- baseUrl: string,
1824
- headers: Record<string, string> | undefined,
1825
- ): Promise<LlamaCppDiscoveredServerMetadata | null> {
1826
- const propsUrl = `${this.#toLlamaCppNativeBaseUrl(baseUrl)}/props`;
1827
- try {
1828
- const response = await this.#fetch(propsUrl, {
1829
- headers,
1830
- signal: AbortSignal.timeout(150),
1831
- });
1832
- if (!response.ok) {
1833
- return null;
1834
- }
1835
- const payload = (await response.json()) as unknown;
1836
- if (!isRecord(payload)) {
1837
- return null;
1838
- }
1839
- return {
1840
- contextWindow: extractLlamaCppContextWindow(payload),
1841
- input: extractLlamaCppInputCapabilities(payload),
1842
- };
1843
- } catch {
1844
- return null;
1845
- }
1846
- }
1847
-
1848
- async #discoverLlamaCppModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1849
- const baseUrl = this.#normalizeLlamaCppBaseUrl(providerConfig.baseUrl);
1850
- const modelsUrl = `${baseUrl}/models`;
1851
-
1852
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
1853
- const apiKey = await this.authStorage.getApiKey(providerConfig.provider);
1854
- if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
1855
- headers.Authorization = `Bearer ${apiKey}`;
1856
- }
1857
-
1858
- const [response, serverMetadata] = await Promise.all([
1859
- this.#fetch(modelsUrl, {
1860
- headers,
1861
- signal: AbortSignal.timeout(250),
1862
- }),
1863
- this.#discoverLlamaCppServerMetadata(baseUrl, headers),
1864
- ]);
1865
- if (!response.ok) {
1866
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
1867
- }
1868
- const payload = (await response.json()) as { data?: Array<{ id: string }> };
1869
- const models = payload.data ?? [];
1870
- const discovered: Model<Api>[] = [];
1871
- for (const item of models) {
1872
- const id = item.id;
1873
- if (!id) continue;
1874
- discovered.push(
1875
- enrichModelThinking({
1876
- id,
1877
- name: id,
1878
- api: providerConfig.api,
1879
- provider: providerConfig.provider,
1880
- baseUrl,
1881
- reasoning: false,
1882
- input: serverMetadata?.input ?? ["text"],
1883
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1884
- contextWindow: serverMetadata?.contextWindow ?? 128000,
1885
- maxTokens: Math.min(
1886
- serverMetadata?.contextWindow ?? Number.POSITIVE_INFINITY,
1887
- DISCOVERY_DEFAULT_MAX_TOKENS,
1888
- ),
1889
- headers,
1890
- compat: {
1891
- supportsStore: false,
1892
- supportsDeveloperRole: false,
1893
- supportsReasoningEffort: false,
1894
- },
1895
- }),
1896
- );
1897
- }
1898
- return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
1899
- }
1900
-
1901
- async #discoverOpenAIModelsList(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1902
- const baseUrl = this.#normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl);
1903
- const modelsUrl = `${baseUrl}/models`;
1904
-
1905
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
1906
- const apiKey = await this.authStorage.getApiKey(providerConfig.provider);
1907
- if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
1908
- headers.Authorization = `Bearer ${apiKey}`;
1909
- }
1910
-
1911
- const response = await this.#fetch(modelsUrl, {
1912
- headers,
1913
- signal: AbortSignal.timeout(10_000),
1914
- });
1915
- if (!response.ok) {
1916
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
1917
- }
1918
- const payload = (await response.json()) as { data?: Array<{ id: string }> };
1919
- const models = payload.data ?? [];
1920
- const discovered: Model<Api>[] = [];
1921
- for (const item of models) {
1922
- const id = item.id;
1923
- if (!id) continue;
1924
- discovered.push(
1925
- enrichModelThinking({
1926
- id,
1927
- name: id,
1928
- api: providerConfig.api,
1929
- provider: providerConfig.provider,
1930
- baseUrl,
1931
- reasoning: false,
1932
- input: ["text"],
1933
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1934
- contextWindow: 128000,
1935
- maxTokens: discoveryDefaultMaxTokens(providerConfig.api),
1936
- headers,
1937
- compat: {
1938
- supportsStore: false,
1939
- supportsDeveloperRole: false,
1940
- supportsReasoningEffort: false,
1941
- },
1942
- }),
1943
- );
1944
- }
1945
- return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
1946
- }
1947
-
1948
- /**
1949
- * Discover models from an Anthropic+OpenAI-compatible reseller proxy that
1950
- * exposes both `/v1/messages` and `/v1/chat/completions`, advertising each
1951
- * model's wire capabilities through `supported_endpoint_types` on
1952
- * `GET /v1/models` (new-api / one-api-style proxies).
1953
- *
1954
- * Routing per model:
1955
- * supported_endpoint_types: ["anthropic", ...] -> api: "anthropic-messages"
1956
- * supported_endpoint_types: ["openai"] -> api: "openai-completions"
1957
- * missing / neither -> provider-level api fallback
1958
- *
1959
- * Anthropic models share the same baseUrl; the Anthropic SDK strips a
1960
- * trailing `/v1` itself before appending `/v1/messages`, so the discovery
1961
- * URL (which ends in `/v1`) round-trips correctly.
1962
- */
1963
- async #discoverProxyModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
1964
- const baseUrl = this.#normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl);
1965
- const modelsUrl = `${baseUrl}/models`;
1966
-
1967
- const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
1968
- const apiKey = await this.authStorage.getApiKey(providerConfig.provider);
1969
- if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
1970
- headers.Authorization = `Bearer ${apiKey}`;
1971
- }
1972
-
1973
- const response = await this.#fetch(modelsUrl, {
1974
- headers,
1975
- signal: AbortSignal.timeout(10_000),
1976
- });
1977
- if (!response.ok) {
1978
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
1979
- }
1980
- const payload = (await response.json()) as {
1981
- data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[] }>;
1982
- };
1983
- const items = payload.data ?? [];
1984
- const discovered: Model<Api>[] = [];
1985
- for (const item of items) {
1986
- const id = item.id;
1987
- if (!id) continue;
1988
- const endpoints = item.supported_endpoint_types ?? [];
1989
- const api: Api | undefined = endpoints.includes("anthropic")
1990
- ? "anthropic-messages"
1991
- : endpoints.includes("openai")
1992
- ? "openai-completions"
1993
- : providerConfig.api;
1994
- if (!api) continue;
1995
- const isAnthropic = api === "anthropic-messages";
1996
- const reference = resolveCustomModelReference(id);
1997
- const discoveryName = typeof item.name === "string" ? item.name.trim() : "";
1998
- const displayName =
1999
- reference?.name ??
2000
- (discoveryName && discoveryName !== id ? discoveryName : undefined) ??
2001
- stripBracketedModelIdAffixes(id) ??
2002
- id;
2003
- discovered.push(
2004
- enrichModelThinking({
2005
- id,
2006
- name: displayName,
2007
- api,
2008
- provider: providerConfig.provider,
2009
- baseUrl,
2010
- reasoning: reference?.reasoning ?? false,
2011
- thinking: reference?.thinking,
2012
- input: reference?.input ?? ["text"],
2013
- // Proxy pricing is provider-specific and usually does not match
2014
- // upstream bundled catalogs, so keep costs local-unknown even when
2015
- // we successfully recover the upstream model identity.
2016
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2017
- contextWindow: reference?.contextWindow ?? 128000,
2018
- maxTokens: reference?.maxTokens ?? discoveryDefaultMaxTokens(api),
2019
- headers,
2020
- // OpenAI-compat fields are no-ops on anthropic models; the
2021
- // Anthropic SDK ignores them. Provider-level disableStrictTools
2022
- // flows in via #applyProviderCompat for the third-party-Anthropic
2023
- // path. Cross-wire bundled compat is intentionally not copied:
2024
- // request-shaping fields are provider-wire specific.
2025
- compat: isAnthropic
2026
- ? undefined
2027
- : {
2028
- supportsStore: false,
2029
- supportsDeveloperRole: false,
2030
- supportsReasoningEffort: false,
2031
- },
2032
- }),
2033
- );
2034
- }
2035
- return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
2036
- }
2037
-
2038
- #normalizeLlamaCppBaseUrl(baseUrl?: string): string {
2039
- const defaultBaseUrl = "http://127.0.0.1:8080";
2040
- const raw = baseUrl || defaultBaseUrl;
2041
- try {
2042
- const parsed = new URL(raw);
2043
- const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2044
- return `${parsed.protocol}//${parsed.host}${trimmedPath}`;
2045
- } catch {
2046
- return raw;
2047
- }
2048
- }
2049
-
2050
- #toLlamaCppNativeBaseUrl(baseUrl: string): string {
2051
- try {
2052
- const parsed = new URL(baseUrl);
2053
- const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2054
- parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath.slice(0, -3) || "/" : trimmedPath || "/";
2055
- const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2056
- return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
2057
- } catch {
2058
- return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
2059
- }
2060
- }
2061
-
2062
- #normalizeOpenAIModelsListBaseUrl(baseUrl?: string): string {
2063
- const defaultBaseUrl = "http://127.0.0.1:1234/v1";
2064
- const raw = baseUrl || defaultBaseUrl;
2065
- try {
2066
- const parsed = new URL(raw);
2067
- const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2068
- parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath || "/v1" : `${trimmedPath}/v1`;
2069
- return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2070
- } catch {
2071
- return raw;
2072
- }
2073
- }
2074
- #normalizeOllamaBaseUrl(baseUrl?: string): string {
2075
- const raw = baseUrl || DEFAULT_OLLAMA_BASE_URL;
2076
- try {
2077
- const parsed = new URL(raw);
2078
- return `${parsed.protocol}//${parsed.host}`;
2079
- } catch {
2080
- return DEFAULT_OLLAMA_BASE_URL;
2081
- }
2082
- }
2083
-
2084
1337
  #applyProviderModelOverrides(provider: string, models: Model<Api>[]): Model<Api>[] {
2085
1338
  const overrides = this.#modelOverrides.get(provider);
2086
1339
  if (!overrides || overrides.size === 0) return models;
@@ -2158,7 +1411,11 @@ export class ModelRegistry {
2158
1411
  this.#rebuildPending = true;
2159
1412
  return;
2160
1413
  }
2161
- this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
1414
+ this.#canonicalIndex = buildCanonicalModelIndex(
1415
+ this.#models,
1416
+ getBundledCanonicalReferenceData(),
1417
+ this.#equivalenceConfig,
1418
+ );
2162
1419
  this.#rebuildPending = false;
2163
1420
  }
2164
1421
 
@@ -2172,7 +1429,11 @@ export class ModelRegistry {
2172
1429
  }
2173
1430
  if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
2174
1431
  this.#rebuildPending = false;
2175
- this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
1432
+ this.#canonicalIndex = buildCanonicalModelIndex(
1433
+ this.#models,
1434
+ getBundledCanonicalReferenceData(),
1435
+ this.#equivalenceConfig,
1436
+ );
2176
1437
  }
2177
1438
  }
2178
1439
 
@@ -2217,81 +1478,73 @@ export class ModelRegistry {
2217
1478
  return this.#models;
2218
1479
  }
2219
1480
 
2220
- #isModelAvailable(model: Model<Api>): boolean {
1481
+ /**
1482
+ * Availability predicate with per-provider memoization. Auth lookups
1483
+ * (`authStorage.hasAuth`) and the disabled-provider set are resolved once
1484
+ * per provider instead of once per model, which matters when filtering the
1485
+ * full bundled catalog (thousands of models, ~50 providers).
1486
+ */
1487
+ #createAvailabilityCheck(): (model: Model<Api>) => boolean {
2221
1488
  const disabledProviders = getDisabledProviderIdsFromSettings();
2222
- return (
2223
- !disabledProviders.has(model.provider) &&
2224
- (this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider))
2225
- );
1489
+ const byProvider = new Map<string, boolean>();
1490
+ return model => {
1491
+ let available = byProvider.get(model.provider);
1492
+ if (available === undefined) {
1493
+ available =
1494
+ !disabledProviders.has(model.provider) &&
1495
+ (this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider));
1496
+ byProvider.set(model.provider, available);
1497
+ }
1498
+ return available;
1499
+ };
1500
+ }
1501
+
1502
+ /**
1503
+ * Build the shared per-query filter state for canonical model queries.
1504
+ * Hoisted out of the per-record loop: building the candidate-selector set
1505
+ * and availability memo once per query instead of once per record is what
1506
+ * keeps `getCanonicalModelSelections` linear instead of O(records × candidates).
1507
+ */
1508
+ #canonicalQueryFilters(options: CanonicalModelQueryOptions | undefined): {
1509
+ candidateKeys: Set<string> | undefined;
1510
+ isAvailable: ((model: Model<Api>) => boolean) | undefined;
1511
+ } {
1512
+ return {
1513
+ candidateKeys: options?.candidates
1514
+ ? new Set(options.candidates.map(candidate => formatCanonicalVariantSelector(candidate)))
1515
+ : undefined,
1516
+ isAvailable: options?.availableOnly ? this.#createAvailabilityCheck() : undefined,
1517
+ };
2226
1518
  }
2227
1519
 
2228
1520
  #filterCanonicalVariants(
2229
1521
  record: CanonicalModelRecord,
2230
- options: CanonicalModelQueryOptions | undefined,
1522
+ candidateKeys: ReadonlySet<string> | undefined,
1523
+ isAvailable: ((model: Model<Api>) => boolean) | undefined,
2231
1524
  ): CanonicalModelVariant[] {
2232
- const candidateKeys = options?.candidates
2233
- ? new Set(options.candidates.map(candidate => formatCanonicalVariantSelector(candidate)))
2234
- : undefined;
2235
1525
  return record.variants.filter(variant => {
2236
1526
  if (candidateKeys && !candidateKeys.has(variant.selector)) {
2237
1527
  return false;
2238
1528
  }
2239
- if (options?.availableOnly && !this.#isModelAvailable(variant.model)) {
1529
+ if (isAvailable && !isAvailable(variant.model)) {
2240
1530
  return false;
2241
1531
  }
2242
1532
  return true;
2243
1533
  });
2244
1534
  }
2245
1535
 
2246
- #providerRank(): Map<string, number> {
2247
- return buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings());
2248
- }
2249
-
2250
- #resolveCanonicalVariant(
2251
- variants: readonly CanonicalModelVariant[],
2252
- allCandidates: readonly Model<Api>[],
2253
- ): CanonicalModelVariant | undefined {
2254
- if (variants.length === 0) {
2255
- return undefined;
2256
- }
2257
- const providerRank = this.#providerRank();
2258
- const modelOrder = new Map<string, number>();
2259
- for (let index = 0; index < allCandidates.length; index += 1) {
2260
- modelOrder.set(formatCanonicalVariantSelector(allCandidates[index]!), index);
2261
- }
2262
- const sourceRank: Record<CanonicalModelVariant["source"], number> = {
2263
- override: 1,
2264
- bundled: 1,
2265
- heuristic: 2,
2266
- fallback: 3,
1536
+ #variantPreferences(candidates: readonly Model<Api>[]): CanonicalVariantPreferences {
1537
+ return {
1538
+ modelOrder: buildCanonicalModelOrder(candidates),
1539
+ providerRank: buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings()),
2267
1540
  };
2268
- return [...variants].sort((left, right) => {
2269
- const leftProviderRank = providerRank.get(left.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER;
2270
- const rightProviderRank = providerRank.get(right.model.provider.toLowerCase()) ?? Number.MAX_SAFE_INTEGER;
2271
- if (leftProviderRank !== rightProviderRank) {
2272
- return leftProviderRank - rightProviderRank;
2273
- }
2274
- const leftExact = left.model.id === left.canonicalId ? 0 : 1;
2275
- const rightExact = right.model.id === right.canonicalId ? 0 : 1;
2276
- if (leftExact !== rightExact) {
2277
- return leftExact - rightExact;
2278
- }
2279
- if (sourceRank[left.source] !== sourceRank[right.source]) {
2280
- return sourceRank[left.source] - sourceRank[right.source];
2281
- }
2282
- if (left.model.id.length !== right.model.id.length) {
2283
- return left.model.id.length - right.model.id.length;
2284
- }
2285
- const leftOrder = modelOrder.get(left.selector) ?? Number.MAX_SAFE_INTEGER;
2286
- const rightOrder = modelOrder.get(right.selector) ?? Number.MAX_SAFE_INTEGER;
2287
- return leftOrder - rightOrder;
2288
- })[0];
2289
1541
  }
2290
1542
 
2291
1543
  getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] {
1544
+ const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
2292
1545
  const records: CanonicalModelRecord[] = [];
2293
1546
  for (const record of this.#canonicalIndex.records) {
2294
- const variants = this.#filterCanonicalVariants(record, options);
1547
+ const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
2295
1548
  if (variants.length === 0) {
2296
1549
  continue;
2297
1550
  }
@@ -2304,12 +1557,42 @@ export class ModelRegistry {
2304
1557
  return records;
2305
1558
  }
2306
1559
 
1560
+ /**
1561
+ * One-pass equivalent of `getCanonicalModels` + `resolveCanonicalModel` per
1562
+ * record. The per-query state (candidate-selector set, availability memo,
1563
+ * provider rank, candidate order) is built once, so the whole catalog
1564
+ * resolves in O(records + candidates) instead of O(records × candidates).
1565
+ * This is the path the model selector hydrates from synchronously on open.
1566
+ */
1567
+ getCanonicalModelSelections(options?: CanonicalModelQueryOptions): CanonicalModelSelection[] {
1568
+ const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
1569
+ const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
1570
+ const preferences = this.#variantPreferences(candidates);
1571
+ const selections: CanonicalModelSelection[] = [];
1572
+ for (const record of this.#canonicalIndex.records) {
1573
+ const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
1574
+ if (variants.length === 0) {
1575
+ continue;
1576
+ }
1577
+ const resolved = resolveCanonicalVariant(variants, preferences);
1578
+ if (!resolved) {
1579
+ continue;
1580
+ }
1581
+ selections.push({
1582
+ record: { id: record.id, name: record.name, variants },
1583
+ model: resolved.model,
1584
+ });
1585
+ }
1586
+ return selections;
1587
+ }
1588
+
2307
1589
  getCanonicalVariants(canonicalId: string, options?: CanonicalModelQueryOptions): CanonicalModelVariant[] {
2308
1590
  const record = this.#canonicalIndex.byId.get(canonicalId.trim().toLowerCase());
2309
1591
  if (!record) {
2310
1592
  return [];
2311
1593
  }
2312
- return this.#filterCanonicalVariants(record, options);
1594
+ const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
1595
+ return this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
2313
1596
  }
2314
1597
 
2315
1598
  resolveCanonicalModel(canonicalId: string, options?: CanonicalModelQueryOptions): Model<Api> | undefined {
@@ -2318,7 +1601,7 @@ export class ModelRegistry {
2318
1601
  return undefined;
2319
1602
  }
2320
1603
  const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
2321
- return this.#resolveCanonicalVariant(variants, candidates)?.model;
1604
+ return resolveCanonicalVariant(variants, this.#variantPreferences(candidates))?.model;
2322
1605
  }
2323
1606
 
2324
1607
  getCanonicalId(model: Model<Api>): string | undefined {
@@ -2330,7 +1613,7 @@ export class ModelRegistry {
2330
1613
  * This is a fast check that doesn't refresh OAuth tokens.
2331
1614
  */
2332
1615
  getAvailable(): Model<Api>[] {
2333
- return this.#models.filter(model => this.#isModelAvailable(model));
1616
+ return this.#models.filter(this.#createAvailabilityCheck());
2334
1617
  }
2335
1618
 
2336
1619
  /**
@@ -2627,7 +1910,7 @@ export class ModelRegistry {
2627
1910
  );
2628
1911
  if (overlay) results.push(finalizeCustomModel(overlay, { useDefaults: true }));
2629
1912
  }
2630
- return results;
1913
+ return results.map(toModelSpec);
2631
1914
  },
2632
1915
  };
2633
1916
  this.#runtimeModelManagers.set(providerName, { options: managerOptions, sourceId: sourceId ?? "" });
@@ -2701,7 +1984,7 @@ export interface ProviderConfigInput {
2701
1984
  api?: Api;
2702
1985
  streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
2703
1986
  headers?: Record<string, string>;
2704
- compat?: Model<Api>["compat"];
1987
+ compat?: ModelSpec<Api>["compat"];
2705
1988
  authHeader?: boolean;
2706
1989
  /** Streaming transport override — see {@link Model.transport}. */
2707
1990
  transport?: Model<Api>["transport"];
@@ -2733,7 +2016,7 @@ export interface ProviderConfigInput {
2733
2016
  contextWindow: number;
2734
2017
  maxTokens: number;
2735
2018
  headers?: Record<string, string>;
2736
- compat?: Model<Api>["compat"];
2019
+ compat?: ModelSpec<Api>["compat"];
2737
2020
  contextPromotionTarget?: string;
2738
2021
  premiumMultiplier?: number;
2739
2022
  }>;