@gajae-code/coding-agent 0.18.11 → 0.19.0

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 (462) hide show
  1. package/CHANGELOG.md +760 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/capability/rule.d.ts +7 -2
  7. package/dist/types/cli/accounts-cli.d.ts +2 -2
  8. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  9. package/dist/types/cli/args.d.ts +7 -2
  10. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  11. package/dist/types/cli/customize-doctor.d.ts +4 -3
  12. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  13. package/dist/types/cli/install-activation.d.ts +10 -4
  14. package/dist/types/cli/public-command-errors.d.ts +2 -0
  15. package/dist/types/cli/root-flags.d.ts +0 -12
  16. package/dist/types/cli/update-cli.d.ts +21 -3
  17. package/dist/types/cli-main.d.ts +0 -6
  18. package/dist/types/cli-ordinary.d.ts +2 -2
  19. package/dist/types/commands/accounts.d.ts +2 -1
  20. package/dist/types/commands/launch.d.ts +0 -6
  21. package/dist/types/commands/migrate.d.ts +1 -1
  22. package/dist/types/commands/sdk.d.ts +13 -0
  23. package/dist/types/commit/changelog/index.d.ts +1 -0
  24. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  25. package/dist/types/config/autorouting-tier-map.d.ts +53 -1
  26. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  27. package/dist/types/config/config-hot-reload.d.ts +44 -0
  28. package/dist/types/config/file-lock.d.ts +52 -3
  29. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  30. package/dist/types/config/model-profile-activation.d.ts +79 -9
  31. package/dist/types/config/model-registry.d.ts +72 -8
  32. package/dist/types/config/model-resolver.d.ts +7 -4
  33. package/dist/types/config/models-config-schema.d.ts +10 -0
  34. package/dist/types/config/settings-schema.d.ts +57 -4
  35. package/dist/types/config/settings.d.ts +33 -1
  36. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  37. package/dist/types/customization/import.d.ts +2 -0
  38. package/dist/types/customization/types.d.ts +1 -1
  39. package/dist/types/dap/config.d.ts +8 -4
  40. package/dist/types/dap/session.d.ts +11 -2
  41. package/dist/types/debug/system-info.d.ts +6 -1
  42. package/dist/types/discovery/helpers.d.ts +7 -2
  43. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  44. package/dist/types/edit/index.d.ts +5 -0
  45. package/dist/types/edit/modes/patch.d.ts +14 -0
  46. package/dist/types/edit/modes/replace.d.ts +3 -0
  47. package/dist/types/eval/py/runtime.d.ts +9 -0
  48. package/dist/types/exec/bash-executor.d.ts +12 -0
  49. package/dist/types/export/ttsr.d.ts +19 -0
  50. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  51. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  52. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  53. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  54. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  55. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  56. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  57. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  58. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  59. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  60. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  61. package/dist/types/hindsight/content.d.ts +5 -6
  62. package/dist/types/internal-urls/types.d.ts +2 -0
  63. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  64. package/dist/types/main.d.ts +23 -18
  65. package/dist/types/migrate/action-planner.d.ts +10 -1
  66. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  67. package/dist/types/migrate/adapters/index.d.ts +42 -1
  68. package/dist/types/migrate/executor.d.ts +3 -1
  69. package/dist/types/migrate/hook-module.d.ts +23 -0
  70. package/dist/types/migrate/report.d.ts +2 -1
  71. package/dist/types/migrate/types.d.ts +82 -3
  72. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  73. package/dist/types/modes/components/model-selector.d.ts +43 -0
  74. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  75. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  76. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  77. package/dist/types/modes/theme/theme.d.ts +1 -0
  78. package/dist/types/modes/utils/host-status.d.ts +15 -11
  79. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  80. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  81. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  82. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  83. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  84. package/dist/types/sdk/broker/broker-exit.d.ts +3 -3
  85. package/dist/types/sdk/broker/broker.d.ts +29 -2
  86. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  87. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  88. package/dist/types/sdk/broker/ensure.d.ts +141 -9
  89. package/dist/types/sdk/broker/hop.d.ts +45 -0
  90. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  91. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  92. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  93. package/dist/types/sdk/broker/session-index.d.ts +6 -1
  94. package/dist/types/sdk/broker/transport.d.ts +2 -0
  95. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  96. package/dist/types/sdk/bus/index.d.ts +44 -0
  97. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  98. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -1
  99. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  100. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  101. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  102. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  103. package/dist/types/sdk/host/events.d.ts +4 -0
  104. package/dist/types/sdk/host/host.d.ts +2 -1
  105. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +40 -8
  107. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  109. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  111. package/dist/types/sdk/prompt-status.d.ts +11 -4
  112. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  113. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  114. package/dist/types/sdk/session-list.d.ts +1 -1
  115. package/dist/types/session/agent-session.d.ts +107 -9
  116. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  117. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  118. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  119. package/dist/types/session/messages.d.ts +5 -1
  120. package/dist/types/session/session-manager.d.ts +29 -3
  121. package/dist/types/session/startup-auth-config.d.ts +24 -2
  122. package/dist/types/session/streaming-output.d.ts +11 -0
  123. package/dist/types/session/terminal-abort.d.ts +3 -1
  124. package/dist/types/session-import/service.d.ts +1 -1
  125. package/dist/types/skill-state/workflow-mutation-guard.d.ts +20 -4
  126. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  127. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  128. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  129. package/dist/types/system-prompt.d.ts +3 -2
  130. package/dist/types/task/executor.d.ts +19 -2
  131. package/dist/types/task/role-body.d.ts +11 -0
  132. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  133. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  134. package/dist/types/tools/awaiting-input.d.ts +28 -0
  135. package/dist/types/tools/bash-allowed-prefixes.d.ts +17 -0
  136. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  137. package/dist/types/tools/bash.d.ts +7 -0
  138. package/dist/types/tools/descriptors.d.ts +15 -2
  139. package/dist/types/tools/fetch.d.ts +7 -1
  140. package/dist/types/tools/index.d.ts +2 -0
  141. package/dist/types/tools/irc.d.ts +5 -3
  142. package/dist/types/tools/python.d.ts +1 -1
  143. package/dist/types/tools/report-issue.d.ts +41 -0
  144. package/dist/types/tools/subagent.d.ts +23 -0
  145. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  146. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  147. package/dist/types/tools/write.d.ts +2 -0
  148. package/dist/types/utils/file-mentions.d.ts +5 -0
  149. package/dist/types/utils/image-limits.d.ts +7 -0
  150. package/dist/types/utils/image-loading.d.ts +0 -1
  151. package/dist/types/utils/open.d.ts +0 -1
  152. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  153. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  154. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  155. package/dist/types/utils/title-generator.d.ts +13 -3
  156. package/dist/types/web/scrapers/types.d.ts +8 -1
  157. package/dist/types/web/search/providers/codex.d.ts +6 -7
  158. package/dist/types/web/search/providers/insane.d.ts +7 -1
  159. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  160. package/package.json +10 -9
  161. package/scripts/acp-conformance-agent.ts +12 -2
  162. package/scripts/build-binary.ts +2 -1
  163. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  164. package/scripts/generate-docs-index.ts +8 -1
  165. package/scripts/generate-sdk-operation-inventory.ts +38 -4
  166. package/src/async/job-manager.ts +356 -56
  167. package/src/async/result-formatting.ts +40 -0
  168. package/src/autoresearch/git.ts +9 -2
  169. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  170. package/src/capability/fs.ts +58 -22
  171. package/src/capability/index.ts +2 -2
  172. package/src/capability/rule.ts +11 -2
  173. package/src/cli/accounts-cli.ts +176 -8
  174. package/src/cli/append-system-prompt.ts +12 -0
  175. package/src/cli/args.ts +11 -7
  176. package/src/cli/auth-broker-cli.ts +21 -11
  177. package/src/cli/customize-doctor-precedence.ts +31 -0
  178. package/src/cli/customize-doctor.ts +49 -39
  179. package/src/cli/doctor/install-repairs.ts +35 -5
  180. package/src/cli/fast-help.ts +1 -3
  181. package/src/cli/install-activation.ts +47 -4
  182. package/src/cli/migrate-cli.ts +19 -6
  183. package/src/cli/public-command-errors.ts +6 -0
  184. package/src/cli/root-flags.ts +2 -4
  185. package/src/cli/stats-cli.ts +9 -5
  186. package/src/cli/update-cli.ts +471 -13
  187. package/src/cli-ordinary.ts +7 -2
  188. package/src/commands/accounts.ts +10 -5
  189. package/src/commands/harness.ts +25 -15
  190. package/src/commands/migrate.ts +3 -3
  191. package/src/commands/sdk.ts +277 -50
  192. package/src/commands/ultragoal.ts +16 -8
  193. package/src/commit/agentic/agent.ts +6 -6
  194. package/src/commit/analysis/conventional.ts +14 -5
  195. package/src/commit/changelog/index.ts +39 -1
  196. package/src/commit/map-reduce/index.ts +7 -7
  197. package/src/commit/message.ts +10 -3
  198. package/src/commit/pipeline.ts +12 -12
  199. package/src/config/autorouting-tier-map.ts +50 -1
  200. package/src/config/config-file.ts +106 -3
  201. package/src/config/config-hot-reload-trust.ts +148 -0
  202. package/src/config/config-hot-reload.ts +742 -0
  203. package/src/config/file-lock.ts +1615 -328
  204. package/src/config/keybindings.ts +154 -7
  205. package/src/config/model-bindings-applier.ts +85 -47
  206. package/src/config/model-discovery-manager.ts +10 -0
  207. package/src/config/model-profile-activation.ts +889 -148
  208. package/src/config/model-registry.ts +1706 -205
  209. package/src/config/model-resolver.ts +52 -22
  210. package/src/config/models-config-schema.ts +6 -0
  211. package/src/config/settings-schema.ts +73 -4
  212. package/src/config/settings.ts +460 -24
  213. package/src/coordinator-mcp/policy.ts +26 -1
  214. package/src/coordinator-mcp/server.ts +37 -2
  215. package/src/crash/sanitize.ts +7 -1
  216. package/src/customization/import.ts +279 -8
  217. package/src/customization/inventory.ts +24 -8
  218. package/src/customization/types.ts +1 -0
  219. package/src/dap/client.ts +3 -2
  220. package/src/dap/config.ts +31 -11
  221. package/src/dap/defaults.json +1 -1
  222. package/src/dap/session.ts +161 -36
  223. package/src/debug/crash-diagnostics.ts +89 -6
  224. package/src/debug/report-bundle.ts +90 -2
  225. package/src/debug/system-info.ts +8 -3
  226. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  227. package/src/discovery/agents-md.ts +1 -1
  228. package/src/discovery/builtin.ts +4 -3
  229. package/src/discovery/helpers.ts +25 -16
  230. package/src/discovery/mcp-compat.ts +120 -17
  231. package/src/edit/apply-patch/index.ts +12 -1
  232. package/src/edit/index.ts +204 -22
  233. package/src/edit/modes/patch.ts +214 -8
  234. package/src/edit/modes/replace.ts +136 -0
  235. package/src/eval/js/context-manager.ts +29 -3
  236. package/src/eval/js/worker-entry.ts +51 -18
  237. package/src/eval/py/executor.ts +14 -4
  238. package/src/eval/py/runner.py +144 -5
  239. package/src/eval/py/runtime.ts +44 -4
  240. package/src/exec/bash-executor.ts +81 -4
  241. package/src/exec/bash-shell-guardian.ts +4 -1
  242. package/src/exec/bash-shell-supervisor.ts +8 -4
  243. package/src/exec/isolated-shell.ts +5 -1
  244. package/src/exec/non-interactive-env.ts +0 -1
  245. package/src/export/html/index.ts +86 -6
  246. package/src/export/ttsr.ts +176 -1
  247. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  248. package/src/extensibility/extensions/loader.ts +10 -1
  249. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  250. package/src/extensibility/extensions/types.ts +5 -1
  251. package/src/extensibility/hooks/loader.ts +10 -2
  252. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  253. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  254. package/src/extensibility/slash-commands.ts +5 -0
  255. package/src/gjc-runtime/gc-runtime.ts +37 -7
  256. package/src/gjc-runtime/launch-tmux.ts +30 -15
  257. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  258. package/src/gjc-runtime/launch-worktree.ts +101 -14
  259. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  260. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  261. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  262. package/src/gjc-runtime/session-resolution.ts +38 -6
  263. package/src/gjc-runtime/session-state-lock.ts +59 -14
  264. package/src/gjc-runtime/state-runtime.ts +23 -19
  265. package/src/gjc-runtime/state-writer.ts +109 -5
  266. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  267. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  268. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  269. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  270. package/src/harness-control-plane/finalize.ts +11 -0
  271. package/src/harness-control-plane/receipt-spool.ts +105 -28
  272. package/src/harness-control-plane/storage.ts +124 -6
  273. package/src/hindsight/content.ts +116 -16
  274. package/src/hindsight/mental-models.ts +22 -10
  275. package/src/hindsight/state.ts +7 -4
  276. package/src/hooks/native-skill-hook.ts +17 -12
  277. package/src/internal-urls/artifact-protocol.ts +3 -1
  278. package/src/internal-urls/docs-index.generated.ts +23 -22
  279. package/src/internal-urls/local-protocol.ts +167 -8
  280. package/src/internal-urls/types.ts +2 -0
  281. package/src/lsp/client.ts +2 -1
  282. package/src/lsp/clients/biome-client.ts +2 -0
  283. package/src/lsp/clients/swiftlint-client.ts +2 -0
  284. package/src/lsp/index.ts +29 -0
  285. package/src/lsp/workspace-path.ts +23 -0
  286. package/src/main.ts +330 -92
  287. package/src/memories/index.ts +25 -2
  288. package/src/migrate/action-planner.ts +574 -2
  289. package/src/migrate/adapters/claude-code.ts +162 -5
  290. package/src/migrate/adapters/codex.ts +77 -7
  291. package/src/migrate/adapters/index.ts +258 -5
  292. package/src/migrate/adapters/opencode.ts +10 -1
  293. package/src/migrate/executor.ts +222 -7
  294. package/src/migrate/hook-module.ts +193 -0
  295. package/src/migrate/report.ts +63 -2
  296. package/src/migrate/types.ts +84 -4
  297. package/src/modes/DESIGN.md +51 -0
  298. package/src/modes/acp/acp-agent.ts +10 -1
  299. package/src/modes/acp/permission-mode.ts +18 -1
  300. package/src/modes/components/agent-dashboard.ts +20 -4
  301. package/src/modes/components/assistant-message.ts +55 -1
  302. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  303. package/src/modes/components/customization/import-wizard.ts +29 -3
  304. package/src/modes/components/model-selector.ts +157 -103
  305. package/src/modes/components/settings-selector.ts +103 -37
  306. package/src/modes/components/welcome.ts +3 -1
  307. package/src/modes/controllers/command-controller.ts +300 -9
  308. package/src/modes/controllers/event-controller.ts +1 -1
  309. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  310. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  311. package/src/modes/controllers/input-controller.ts +2 -0
  312. package/src/modes/controllers/plan-mode-controller.ts +66 -11
  313. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  314. package/src/modes/controllers/selector-controller.ts +76 -7
  315. package/src/modes/interactive-mode.ts +123 -4
  316. package/src/modes/prompt-suggestion-controller.ts +1 -1
  317. package/src/modes/runtime-init.ts +5 -1
  318. package/src/modes/theme/theme.ts +15 -0
  319. package/src/modes/utils/host-status.ts +21 -10
  320. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  321. package/src/prompts/system/irc-incoming.md +3 -1
  322. package/src/prompts/tools/debug.md +1 -1
  323. package/src/prompts/tools/irc.md +1 -1
  324. package/src/prompts/tools/read.md +1 -1
  325. package/src/prompts/tools/report-issue.md +13 -0
  326. package/src/prompts/tools/task.md +1 -1
  327. package/src/runtime-credential-selector.ts +2 -2
  328. package/src/runtime-mcp/content-limits.ts +7 -0
  329. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  330. package/src/runtime-mcp/manager.ts +40 -3
  331. package/src/runtime-mcp/oauth-flow.ts +32 -6
  332. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  333. package/src/runtime-mcp/project-host-values.ts +12 -8
  334. package/src/runtime-mcp/render.ts +16 -1
  335. package/src/runtime-mcp/tool-bridge.ts +194 -21
  336. package/src/runtime-mcp/transports/stdio.ts +2 -1
  337. package/src/sdk/broker/broker-exit.ts +10 -5
  338. package/src/sdk/broker/broker.ts +484 -85
  339. package/src/sdk/broker/daemon-entry.ts +338 -116
  340. package/src/sdk/broker/doctor-restart.ts +2 -1
  341. package/src/sdk/broker/ensure.ts +1214 -268
  342. package/src/sdk/broker/hop.ts +286 -0
  343. package/src/sdk/broker/lifecycle-ledger.ts +38 -11
  344. package/src/sdk/broker/lifecycle.ts +102 -6
  345. package/src/sdk/broker/lock-host-identity.ts +29 -0
  346. package/src/sdk/broker/session-index.ts +91 -16
  347. package/src/sdk/broker/transport.ts +17 -1
  348. package/src/sdk/bus/chat-command-policy.ts +16 -3
  349. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  350. package/src/sdk/bus/index.ts +460 -35
  351. package/src/sdk/bus/reconciliation-store.ts +201 -8
  352. package/src/sdk/bus/session-control.ts +2 -0
  353. package/src/sdk/bus/slack-daemon.ts +105 -9
  354. package/src/sdk/bus/telegram-daemon-contract.ts +9 -1
  355. package/src/sdk/bus/telegram-daemon.ts +10 -2
  356. package/src/sdk/bus/threaded-render.ts +20 -1
  357. package/src/sdk/cli/session-cli.ts +23 -6
  358. package/src/sdk/host/control/dispatch.ts +13 -1
  359. package/src/sdk/host/control/operations.ts +10 -1
  360. package/src/sdk/host/events.ts +67 -4
  361. package/src/sdk/host/host.ts +8 -2
  362. package/src/sdk/host/prompt-image-upload.ts +438 -0
  363. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  364. package/src/sdk/host/session-runtime.test.ts +1737 -129
  365. package/src/sdk/host/session-runtime.ts +684 -83
  366. package/src/sdk/host/surface-policy.ts +4 -0
  367. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  368. package/src/sdk/host/websocket-transport.ts +19 -7
  369. package/src/sdk/lifecycle/service.ts +15 -1
  370. package/src/sdk/prompt-deadline-flush.ts +78 -5
  371. package/src/sdk/prompt-deadline-manager.ts +68 -14
  372. package/src/sdk/prompt-status.ts +6 -4
  373. package/src/sdk/protocol/adapter-validation.ts +81 -12
  374. package/src/sdk/protocol/operation-inventory.generated.json +261 -2
  375. package/src/sdk/protocol/operation-registry.ts +33 -3
  376. package/src/sdk/reconciliation-extensions.ts +1 -0
  377. package/src/sdk/session-list.ts +1 -0
  378. package/src/sdk/session.ts +237 -97
  379. package/src/session/agent-session.ts +2726 -479
  380. package/src/session/history-storage.ts +182 -12
  381. package/src/session/internal/managed-session-scope.ts +173 -57
  382. package/src/session/internal/managed-session-storage.ts +85 -12
  383. package/src/session/internal/native-publish-outcome.ts +5 -1
  384. package/src/session/messages.ts +47 -6
  385. package/src/session/session-manager.ts +326 -19
  386. package/src/session/startup-auth-config.ts +116 -8
  387. package/src/session/streaming-output.ts +262 -4
  388. package/src/session/terminal-abort.ts +14 -2
  389. package/src/session-import/redact.ts +53 -2
  390. package/src/session-import/service.ts +1 -0
  391. package/src/skill-state/workflow-mutation-guard.ts +2145 -206
  392. package/src/slash-commands/builtin-registry.ts +21 -6
  393. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  394. package/src/slash-commands/helpers/mcp.ts +11 -3
  395. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  396. package/src/ssh/ssh-executor.ts +9 -2
  397. package/src/stt/recorder.ts +26 -9
  398. package/src/system-prompt.ts +61 -16
  399. package/src/task/executor.ts +115 -20
  400. package/src/task/index.ts +389 -175
  401. package/src/task/role-body.ts +84 -0
  402. package/src/task/token-log-mode.test.ts +162 -0
  403. package/src/task/token-log.ts +91 -2
  404. package/src/tools/archive-reader.ts +650 -37
  405. package/src/tools/ask.ts +1 -0
  406. package/src/tools/ast-edit-write-path.ts +64 -0
  407. package/src/tools/ast-edit.ts +18 -1
  408. package/src/tools/ast-grep.ts +7 -2
  409. package/src/tools/atomic-file-write.ts +46 -15
  410. package/src/tools/auto-generated-guard.ts +15 -3
  411. package/src/tools/awaiting-input.ts +114 -0
  412. package/src/tools/bash-allowed-prefixes.ts +214 -6
  413. package/src/tools/bash-interactive.ts +14 -2
  414. package/src/tools/bash-skill-urls.ts +58 -5
  415. package/src/tools/bash.ts +113 -26
  416. package/src/tools/browser/tab-worker.ts +39 -13
  417. package/src/tools/browser.ts +17 -7
  418. package/src/tools/cron.ts +10 -1
  419. package/src/tools/debug.ts +16 -8
  420. package/src/tools/descriptors.test.ts +44 -0
  421. package/src/tools/descriptors.ts +50 -6
  422. package/src/tools/eval.ts +15 -0
  423. package/src/tools/fetch.ts +93 -4
  424. package/src/tools/gh.ts +27 -12
  425. package/src/tools/image-gen.ts +20 -6
  426. package/src/tools/index.ts +2 -0
  427. package/src/tools/irc.ts +5 -3
  428. package/src/tools/monitor.ts +9 -1
  429. package/src/tools/output-meta.ts +74 -5
  430. package/src/tools/python.ts +1 -1
  431. package/src/tools/read.ts +26 -37
  432. package/src/tools/recipe/runner.ts +23 -12
  433. package/src/tools/render-utils.ts +2 -2
  434. package/src/tools/report-issue.ts +184 -0
  435. package/src/tools/search.ts +3 -2
  436. package/src/tools/ssh.ts +274 -8
  437. package/src/tools/subagent-render.ts +114 -12
  438. package/src/tools/subagent.ts +118 -5
  439. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  440. package/src/tools/todo-write.ts +41 -3
  441. package/src/tools/tool-catalog.generated.ts +32 -4
  442. package/src/tools/ultragoal-ask-guard.ts +13 -1
  443. package/src/tools/write.ts +42 -3
  444. package/src/tui/tree-list.ts +15 -1
  445. package/src/utils/commit-message-generator.ts +12 -4
  446. package/src/utils/external-editor.ts +21 -7
  447. package/src/utils/file-mentions.ts +75 -7
  448. package/src/utils/git.ts +38 -7
  449. package/src/utils/image-limits.ts +8 -0
  450. package/src/utils/image-loading.ts +1 -1
  451. package/src/utils/open.ts +73 -4
  452. package/src/utils/pasted-image-loading.ts +9 -8
  453. package/src/utils/prompt-suggestion.ts +5 -4
  454. package/src/utils/shell-snapshot.ts +9 -1
  455. package/src/utils/title-generator.ts +72 -22
  456. package/src/web/scrapers/index.ts +3 -1
  457. package/src/web/scrapers/types.ts +8 -0
  458. package/src/web/scrapers/youtube.ts +3 -2
  459. package/src/web/search/index.ts +2 -1
  460. package/src/web/search/providers/codex.ts +29 -32
  461. package/src/web/search/providers/insane.ts +91 -28
  462. package/src/web/search/providers/searxng.ts +62 -10
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import * as crypto from "node:crypto";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as path from "node:path";
@@ -5,6 +6,7 @@ import { isDeepStrictEqual } from "node:util";
5
6
  import {
6
7
  type Api,
7
8
  type AssistantMessageEventStream,
9
+ type AuthApiKeyOptions,
8
10
  type AuthCredentialSelector,
9
11
  applyFinalCodexGpt56ContextCap,
10
12
  applyGeneratedModelPolicies,
@@ -43,6 +45,7 @@ import {
43
45
  } from "@gajae-code/ai/core";
44
46
  import { fetchModelsDevPayload } from "@gajae-code/ai/provider-models/openai-compat";
45
47
  import { isDirectXaiReasoningEffortModel } from "@gajae-code/ai/providers/openai-completions-compat";
48
+ import { getAntigravityDiscoveryUserAgent } from "@gajae-code/ai/utils/discovery/antigravity";
46
49
  import {
47
50
  detectDiscoveredApiFamily,
48
51
  isSafeCatalogModelId,
@@ -52,6 +55,7 @@ import {
52
55
  } from "@gajae-code/ai/utils/discovery/openai-compatible";
53
56
  // Internal-only: registerFinalizedModelClone is not part of the public @gajae-code/ai surface
54
57
  import { registerFinalizedModelClone } from "@gajae-code/ai/utils/trusted-model-clone";
58
+ import { JSONC, YAML } from "bun";
55
59
 
56
60
  // Sentinels for local-only OAuth tokens — declared inline to avoid loading provider
57
61
  // modules at startup. Must match the provider OAuth modules.
@@ -77,13 +81,15 @@ import {
77
81
  projectActiveProviderDescriptors,
78
82
  } from "../sdk/providers";
79
83
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
84
+ import { writeExplicitOwnerOnlyTextSync } from "../session/session-storage";
80
85
  import type { ActiveSearchModelContext, WebSearchMode } from "../web/search/types";
81
86
  import { type BillingPath, deriveBillingPath } from "./billing-path";
82
- import { ConfigError, ConfigFile } from "./config-file";
87
+ import { ConfigError, ConfigFile, type LoadResult } from "./config-file";
83
88
  import { isAuthenticated, kNoAuth } from "./model-auth";
84
89
  import { type ConfiguredModelBindings, ModelBindingsApplier } from "./model-bindings-applier";
85
90
  import { ModelDiscoveryManager, type ProviderDiscoveryState } from "./model-discovery-manager";
86
91
  import {
92
+ type AcceptedModelPresetRegistry,
87
93
  loadAcceptedModelPresetProfiles,
88
94
  type ModelPresetRegistryDependencies,
89
95
  type ModelPresetRegistryRefreshResult,
@@ -449,6 +455,20 @@ function assertRequestTransformSupportedForKnownProvider(providerName: string, s
449
455
  }
450
456
  }
451
457
 
458
+ function assertStreamIdleTimeoutSupportedForKnownProvider(providerName: string, source: string): void {
459
+ const apis = getKnownProviderApis(providerName);
460
+ if (apis.size === 0) {
461
+ throw new Error(
462
+ `Provider ${providerName}: ${source} requires an OpenAI-compatible "api" when the provider is not built in.`,
463
+ );
464
+ }
465
+ if (![...apis].some(isRequestTransformApi)) {
466
+ throw new Error(
467
+ `Provider ${providerName}: ${source} is only supported with openai-completions or openai-responses APIs.`,
468
+ );
469
+ }
470
+ }
471
+
452
472
  function assertRequestTransformSupportedForModelApi(
453
473
  providerName: string,
454
474
  modelId: string,
@@ -463,8 +483,54 @@ function assertRequestTransformSupportedForModelApi(
463
483
  }
464
484
 
465
485
  function getKnownProviderModelApi(providerName: string, modelId: string): Api | undefined {
466
- return getBundledModels(providerName as Parameters<typeof getBundledModels>[0]).find(model => model.id === modelId)
467
- ?.api as Api | undefined;
486
+ return getBundledModels(providerName as Parameters<typeof getBundledModels>[0]).find(
487
+ model => model.id.toLowerCase() === modelId.toLowerCase(),
488
+ )?.api as Api | undefined;
489
+ }
490
+
491
+ function resolveModelApiOverrideFromDiscovery(
492
+ providerApi: Api,
493
+ discovery: ProviderDiscovery,
494
+ modelId: string,
495
+ entry?: { id?: unknown; owned_by?: unknown },
496
+ allowModelIdDetection = true,
497
+ ): Api | undefined {
498
+ // Explicit per-prefix routing always wins over model-id detection.
499
+ const normalizedModelId = modelId.toLowerCase();
500
+ let matchedPrefixLength = -1;
501
+ let prefixApi: Api | undefined;
502
+ for (const [prefix, routedApi] of Object.entries(discovery.apiByModelPrefix ?? {})) {
503
+ const normalizedPrefix = prefix.toLowerCase();
504
+ if (normalizedModelId.startsWith(normalizedPrefix) && normalizedPrefix.length > matchedPrefixLength) {
505
+ prefixApi = routedApi;
506
+ matchedPrefixLength = normalizedPrefix.length;
507
+ }
508
+ }
509
+ if (prefixApi !== undefined) return prefixApi;
510
+
511
+ // An OpenAI-shaped models list can advertise Anthropic models that must use
512
+ // the Anthropic transport even when the provider's default is OpenAI-family.
513
+ if (discovery.type === "openai-models-list") {
514
+ let candidateEntry = entry;
515
+ if (candidateEntry === undefined && allowModelIdDetection) {
516
+ candidateEntry = { id: modelId };
517
+ }
518
+ const detected = candidateEntry === undefined ? undefined : detectDiscoveredApiFamily(candidateEntry);
519
+ if (detected === "anthropic-messages") return "anthropic-messages";
520
+ if (detected === "openai-completions") {
521
+ return isOpenAIFamilyApi(providerApi) ? providerApi : "openai-completions";
522
+ }
523
+ }
524
+ return undefined;
525
+ }
526
+
527
+ function resolveModelApiFromDiscovery(
528
+ providerApi: Api,
529
+ discovery: ProviderDiscovery,
530
+ modelId: string,
531
+ entry?: { id?: unknown; owned_by?: unknown },
532
+ ): Api {
533
+ return resolveModelApiOverrideFromDiscovery(providerApi, discovery, modelId, entry) ?? providerApi;
468
534
  }
469
535
 
470
536
  function isCanonicalOpenAIAffinityBaseUrl(baseUrl: string | undefined): boolean {
@@ -513,6 +579,7 @@ interface ProviderValidationModel {
513
579
  maxTokens?: number;
514
580
  compat?: Model<Api>["compat"];
515
581
  requestTransform?: ModelRequestTransform;
582
+ streamIdleTimeoutMs?: number;
516
583
  }
517
584
 
518
585
  interface ProviderValidationConfig {
@@ -521,6 +588,7 @@ interface ProviderValidationConfig {
521
588
  apiKey?: string;
522
589
  apiKeyEnv?: string;
523
590
  api?: Api;
591
+ transport?: Model<Api>["transport"];
524
592
  auth?: ProviderAuthMode;
525
593
  oauthConfigured?: boolean;
526
594
  discovery?: ProviderDiscovery;
@@ -528,6 +596,7 @@ interface ProviderValidationConfig {
528
596
  requestTransform?: ModelRequestTransform;
529
597
  disableStrictTools?: boolean;
530
598
  cacheRetention?: CacheRetention;
599
+ streamIdleTimeoutMs?: number;
531
600
  openaiCompat?: { baseUrl: string; apiKey?: string; apiKeyEnv?: string };
532
601
  modelOverrides?: Record<string, unknown>;
533
602
  models: ProviderValidationModel[];
@@ -543,7 +612,20 @@ function validateProviderConfiguration(
543
612
  mode: ProviderValidationMode,
544
613
  ): void {
545
614
  const hasProviderApi = !!config.api;
615
+ const configuredApi = config.api ?? (config.openaiCompat ? "openai-completions" : undefined);
546
616
  const models = config.models;
617
+ if (
618
+ config.transport === "pi-native" &&
619
+ (config.streamIdleTimeoutMs !== undefined ||
620
+ models.some(model => model.streamIdleTimeoutMs !== undefined) ||
621
+ Object.values(config.modelOverrides ?? {}).some(
622
+ override => (override as ModelOverride).streamIdleTimeoutMs !== undefined,
623
+ ))
624
+ ) {
625
+ throw new Error(
626
+ `Provider ${providerName}: "streamIdleTimeoutMs" is not supported with "transport: pi-native"; configure it on the auth-gateway's upstream model.`,
627
+ );
628
+ }
547
629
 
548
630
  if (models.length === 0) {
549
631
  if (mode === "models-config") {
@@ -557,12 +639,13 @@ function validateProviderConfiguration(
557
639
  !config.disableStrictTools &&
558
640
  !config.requestTransform &&
559
641
  !config.cacheRetention &&
642
+ config.streamIdleTimeoutMs === undefined &&
560
643
  !config.openaiCompat &&
561
644
  !hasModelOverrides &&
562
645
  !config.discovery
563
646
  ) {
564
647
  throw new Error(
565
- `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "requestTransform", "cacheRetention", "disableStrictTools", "modelOverrides", "discovery", "openaiCompat", or "models"`,
648
+ `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "requestTransform", "cacheRetention", "streamIdleTimeoutMs", "disableStrictTools", "modelOverrides", "discovery", "openaiCompat", or "models"`,
566
649
  );
567
650
  }
568
651
  }
@@ -623,10 +706,55 @@ function validateProviderConfiguration(
623
706
  }
624
707
  for (const [modelId, rawOverride] of Object.entries(config.modelOverrides ?? {})) {
625
708
  const override = rawOverride as ModelOverride;
709
+ const configuredModel = models.find(model => model.id.toLowerCase() === modelId.toLowerCase());
710
+ const discoveryProviderApi =
711
+ (config.api as Api | undefined) ??
712
+ dominantOpenAIFamilyModelApi(models) ??
713
+ (config.openaiCompat ? "openai-completions" : undefined);
714
+ const implicitDiscoveryBaseUrl = config.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName);
715
+ const implicitDiscovery: ProviderDiscovery | undefined =
716
+ !config.discovery &&
717
+ (config.openaiCompat !== undefined ||
718
+ (providerName !== "vllm" &&
719
+ providerName !== "sglang" &&
720
+ discoveryProviderApi !== undefined &&
721
+ isOpenAIFamilyApi(discoveryProviderApi) &&
722
+ implicitDiscoveryBaseUrl !== undefined))
723
+ ? { type: "openai-models-list" }
724
+ : undefined;
725
+ const effectiveDiscovery = config.discovery ?? implicitDiscovery;
726
+ const discoveryApi =
727
+ configuredModel || !effectiveDiscovery || !discoveryProviderApi
728
+ ? undefined
729
+ : resolveModelApiOverrideFromDiscovery(discoveryProviderApi, effectiveDiscovery, modelId);
730
+ const discoveryTimeoutApi =
731
+ configuredModel || !effectiveDiscovery || !discoveryProviderApi
732
+ ? undefined
733
+ : resolveModelApiOverrideFromDiscovery(discoveryProviderApi, effectiveDiscovery, modelId, undefined, false);
734
+ const bundledModelApi = getKnownProviderModelApi(providerName, modelId);
735
+ // An OpenAI models-list row may override both its ID-based family and the
736
+ // provider default via `owned_by`. Defer ambiguous timeout validation until
737
+ // discovery supplies that metadata; applying the override validates the
738
+ // resolved model API before it can take effect.
739
+ const deferTimeoutValidation =
740
+ !configuredModel &&
741
+ effectiveDiscovery?.type === "openai-models-list" &&
742
+ discoveryTimeoutApi === undefined &&
743
+ bundledModelApi === undefined;
626
744
  const effectiveApi =
627
- models.find(model => model.id === modelId)?.api ??
628
- config.api ??
629
- getKnownProviderModelApi(providerName, modelId);
745
+ configuredModel?.api ??
746
+ (configuredModel ? config.api : undefined) ??
747
+ discoveryApi ??
748
+ bundledModelApi ??
749
+ config.api;
750
+ const effectiveTimeoutApi =
751
+ configuredModel?.api ??
752
+ (configuredModel ? config.api : undefined) ??
753
+ discoveryTimeoutApi ??
754
+ bundledModelApi ??
755
+ (deferTimeoutValidation
756
+ ? undefined
757
+ : (config.api ?? (config.openaiCompat ? "openai-completions" : undefined)));
630
758
  if (override.compat?.supportsResponsesSessionAffinity !== undefined) {
631
759
  assertResponsesSessionAffinitySupported(
632
760
  providerName,
@@ -635,6 +763,21 @@ function validateProviderConfiguration(
635
763
  `modelOverrides ${modelId} "compat.supportsResponsesSessionAffinity"`,
636
764
  );
637
765
  }
766
+ if (override.streamIdleTimeoutMs !== undefined) {
767
+ if (effectiveTimeoutApi) {
768
+ assertRequestTransformSupportedForModelApi(
769
+ providerName,
770
+ modelId,
771
+ effectiveTimeoutApi,
772
+ 'modelOverrides "streamIdleTimeoutMs"',
773
+ );
774
+ } else if (!deferTimeoutValidation) {
775
+ assertRequestTransformSupportedForKnownProvider(
776
+ providerName,
777
+ `modelOverrides ${modelId} "streamIdleTimeoutMs"`,
778
+ );
779
+ }
780
+ }
638
781
  if (!override.requestTransform) continue;
639
782
  if (effectiveApi) {
640
783
  assertRequestTransformSupportedForModelApi(
@@ -657,6 +800,54 @@ function validateProviderConfiguration(
657
800
  assertRequestTransformSupportedForKnownProvider(providerName, '"requestTransform"');
658
801
  }
659
802
  }
803
+ if (config.streamIdleTimeoutMs !== undefined) {
804
+ const bundledApis = getKnownProviderApis(providerName);
805
+ const hasCompatibleBundledApi = [...bundledApis].some(isRequestTransformApi);
806
+ const hasCompatibleModelApi = models.some(model => {
807
+ const api = model.api ?? config.api;
808
+ return api !== undefined && isRequestTransformApi(api);
809
+ });
810
+ const hasCompatibleDiscoveryApi =
811
+ config.openaiCompat !== undefined ||
812
+ (config.discovery !== undefined &&
813
+ ((configuredApi !== undefined && isRequestTransformApi(configuredApi)) ||
814
+ Object.values(config.discovery.apiByModelPrefix ?? {}).some(isRequestTransformApi)));
815
+ const implicitDiscoveryApi =
816
+ (config.api as Api | undefined) ??
817
+ dominantOpenAIFamilyModelApi(models) ??
818
+ (config.openaiCompat ? "openai-completions" : undefined);
819
+ const implicitDiscoveryBaseUrl = config.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName);
820
+ const hasCompatibleImplicitDiscoveryApi =
821
+ !config.discovery &&
822
+ (config.openaiCompat !== undefined ||
823
+ (providerName !== "vllm" &&
824
+ providerName !== "sglang" &&
825
+ implicitDiscoveryApi !== undefined &&
826
+ isOpenAIFamilyApi(implicitDiscoveryApi) &&
827
+ implicitDiscoveryBaseUrl !== undefined));
828
+ const hasCompatibleDiscoveryRoute = hasCompatibleDiscoveryApi || hasCompatibleImplicitDiscoveryApi;
829
+ if (models.length > 0) {
830
+ if (!hasCompatibleModelApi && !hasCompatibleBundledApi && !hasCompatibleDiscoveryRoute) {
831
+ throw new Error(
832
+ `Provider ${providerName}: "streamIdleTimeoutMs" is only supported with openai-completions or openai-responses APIs.`,
833
+ );
834
+ }
835
+ } else {
836
+ if (!hasCompatibleBundledApi && !hasCompatibleDiscoveryRoute) {
837
+ if (bundledApis.size > 0) {
838
+ assertStreamIdleTimeoutSupportedForKnownProvider(providerName, '"streamIdleTimeoutMs"');
839
+ } else if (configuredApi) {
840
+ if (!isRequestTransformApi(configuredApi)) {
841
+ throw new Error(
842
+ `Provider ${providerName}: "streamIdleTimeoutMs" is only supported with openai-completions or openai-responses APIs.`,
843
+ );
844
+ }
845
+ } else {
846
+ assertStreamIdleTimeoutSupportedForKnownProvider(providerName, '"streamIdleTimeoutMs"');
847
+ }
848
+ }
849
+ }
850
+ }
660
851
 
661
852
  for (const modelDef of models) {
662
853
  if (!hasProviderApi && !modelDef.api) {
@@ -670,6 +861,14 @@ function validateProviderConfiguration(
670
861
  throw new Error(`Provider ${providerName}: model missing "id"`);
671
862
  }
672
863
  const effectiveApi = modelDef.api ?? config.api;
864
+ if (modelDef.streamIdleTimeoutMs !== undefined && effectiveApi) {
865
+ assertRequestTransformSupportedForModelApi(
866
+ providerName,
867
+ modelDef.id,
868
+ effectiveApi,
869
+ 'model "streamIdleTimeoutMs"',
870
+ );
871
+ }
673
872
  const modelCompat = modelDef.compat;
674
873
  if (modelCompat && "supportsResponsesSessionAffinity" in modelCompat) {
675
874
  assertResponsesSessionAffinitySupported(
@@ -706,34 +905,83 @@ function validateProviderConfiguration(
706
905
  }
707
906
  }
708
907
 
908
+ function validateModelsConfig(config: ModelsConfig): void {
909
+ for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
910
+ validateProviderConfiguration(
911
+ providerName,
912
+ {
913
+ baseUrl: providerConfig.baseUrl,
914
+ headers: providerConfig.headers,
915
+ apiKey: providerConfig.apiKey,
916
+ apiKeyEnv: providerConfig.apiKeyEnv,
917
+ api: providerConfig.api as Api | undefined,
918
+ transport: providerConfig.transport,
919
+ auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode,
920
+ discovery: providerConfig.discovery as ProviderDiscovery | undefined,
921
+ compat: providerConfig.compat,
922
+ requestTransform: providerConfig.requestTransform,
923
+ disableStrictTools: providerConfig.disableStrictTools,
924
+ cacheRetention: providerConfig.cacheRetention,
925
+ streamIdleTimeoutMs: providerConfig.streamIdleTimeoutMs,
926
+ openaiCompat: providerConfig.openaiCompat,
927
+ modelOverrides: providerConfig.modelOverrides,
928
+ models: (providerConfig.models ?? []) as ProviderValidationModel[],
929
+ },
930
+ "models-config",
931
+ );
932
+ }
933
+ }
934
+
709
935
  export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsConfigSchema).withValidation(
710
936
  "models",
711
- config => {
712
- for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
713
- validateProviderConfiguration(
714
- providerName,
715
- {
716
- baseUrl: providerConfig.baseUrl,
717
- headers: providerConfig.headers,
718
- apiKey: providerConfig.apiKey,
719
- apiKeyEnv: providerConfig.apiKeyEnv,
720
- api: providerConfig.api as Api | undefined,
721
- auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode,
722
- discovery: providerConfig.discovery as ProviderDiscovery | undefined,
723
- compat: providerConfig.compat,
724
- requestTransform: providerConfig.requestTransform,
725
- disableStrictTools: providerConfig.disableStrictTools,
726
- cacheRetention: providerConfig.cacheRetention,
727
- openaiCompat: providerConfig.openaiCompat,
728
- modelOverrides: providerConfig.modelOverrides,
729
- models: (providerConfig.models ?? []) as ProviderValidationModel[],
730
- },
731
- "models-config",
732
- );
733
- }
734
- },
937
+ validateModelsConfig,
735
938
  );
736
939
 
940
+ function parseModelsConfigSnapshot(snapshot: { path: string; text: string | null }): LoadResult<ModelsConfig> {
941
+ if (snapshot.text === null) return { status: "not-found" };
942
+ try {
943
+ const content = snapshot.text.trim();
944
+ let raw: unknown;
945
+ if (snapshot.path.endsWith(".json") || snapshot.path.endsWith(".jsonc")) {
946
+ raw = JSONC.parse(content);
947
+ } else if (snapshot.path.endsWith(".yml") || snapshot.path.endsWith(".yaml")) {
948
+ raw = YAML.parse(content);
949
+ } else {
950
+ throw new Error(`Invalid config file path: ${snapshot.path}`);
951
+ }
952
+ const parsed = ModelsConfigSchema.safeParse(raw);
953
+ if (!parsed.success) {
954
+ return {
955
+ status: "error",
956
+ error: new ConfigError(
957
+ "models",
958
+ parsed.error.issues.slice(0, 50).map(issue => ({
959
+ instancePath: issue.path.length === 0 ? "" : `/${issue.path.map(String).join("/")}`,
960
+ message: issue.message,
961
+ })),
962
+ ),
963
+ };
964
+ }
965
+ try {
966
+ validateModelsConfig(parsed.data);
967
+ } catch (error) {
968
+ return {
969
+ status: "error",
970
+ error:
971
+ error instanceof ConfigError
972
+ ? error
973
+ : new ConfigError("models", undefined, { err: error, stage: "Validate(models)" }),
974
+ };
975
+ }
976
+ return { status: "ok", value: parsed.data };
977
+ } catch (error) {
978
+ return {
979
+ status: "error",
980
+ error: new ConfigError("models", undefined, { err: error, stage: "Unexpected" }),
981
+ };
982
+ }
983
+ }
984
+
737
985
  /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
738
986
  interface ProviderOverride {
739
987
  api?: Api;
@@ -745,6 +993,7 @@ interface ProviderOverride {
745
993
  transport?: Model<Api>["transport"];
746
994
  requestTransform?: ModelRequestTransform;
747
995
  cacheRetention?: CacheRetention;
996
+ streamIdleTimeoutMs?: number;
748
997
  isOAuth?: boolean;
749
998
  }
750
999
 
@@ -778,6 +1027,7 @@ function registryModelMetadataWithoutApiSpecificFields(model: Model<Api>): Parti
778
1027
  contextWindow: model.contextWindow,
779
1028
  maxTokens: model.maxTokens,
780
1029
  };
1030
+ if (model.streamIdleTimeoutMs !== undefined) metadata.streamIdleTimeoutMs = model.streamIdleTimeoutMs;
781
1031
  if (model.longContextPricing !== undefined) metadata.longContextPricing = model.longContextPricing;
782
1032
  if (model.premiumMultiplier !== undefined) metadata.premiumMultiplier = model.premiumMultiplier;
783
1033
  if (model.priority !== undefined) metadata.priority = model.priority;
@@ -866,7 +1116,8 @@ function filterMaterializedRegistryProfiles(
866
1116
  type SpecialProviderDescriptor = {
867
1117
  providerId: string;
868
1118
  resolveKey: (value: string | undefined) => string | undefined;
869
- createOptions: (key: string) => ModelManagerOptions<Api>;
1119
+ resolveProjectId?: (key: string, rawValue: string | undefined) => string | undefined;
1120
+ createOptions: (key: string, projectId?: string) => ModelManagerOptions<Api>;
870
1121
  };
871
1122
 
872
1123
  const PROVIDER_BASE_URL_ENV_ALIASES: Record<string, readonly string[]> = {
@@ -941,10 +1192,15 @@ export function mergeDiscoveredModel<TApi extends Api>(
941
1192
  existing: Model<Api> | undefined,
942
1193
  providerOverride?: Pick<
943
1194
  ProviderOverride,
944
- "baseUrl" | "headers" | "transport" | "requestTransform" | "cacheRetention"
1195
+ "baseUrl" | "headers" | "transport" | "requestTransform" | "cacheRetention" | "streamIdleTimeoutMs"
945
1196
  >,
946
1197
  ): Model<TApi> {
947
1198
  if (existing) {
1199
+ const streamIdleTimeoutMs =
1200
+ model.streamIdleTimeoutMs ??
1201
+ (isRequestTransformApi(model.api)
1202
+ ? (existing.streamIdleTimeoutMs ?? providerOverride?.streamIdleTimeoutMs)
1203
+ : undefined);
948
1204
  return {
949
1205
  ...model,
950
1206
  baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
@@ -954,15 +1210,20 @@ export function mergeDiscoveredModel<TApi extends Api>(
954
1210
  providerOverride?.requestTransform,
955
1211
  ),
956
1212
  cacheRetention: model.cacheRetention ?? existing.cacheRetention ?? providerOverride?.cacheRetention,
1213
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
957
1214
  };
958
1215
  }
959
1216
  if (providerOverride) {
1217
+ const streamIdleTimeoutMs =
1218
+ model.streamIdleTimeoutMs ??
1219
+ (isRequestTransformApi(model.api) ? providerOverride.streamIdleTimeoutMs : undefined);
960
1220
  return {
961
1221
  ...model,
962
1222
  baseUrl: providerOverride.baseUrl ?? model.baseUrl,
963
1223
  headers: mergeCaseInsensitiveHeaders(model.headers, providerOverride.headers),
964
1224
  ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
965
1225
  requestTransform: mergeRequestTransform(model.requestTransform, providerOverride.requestTransform),
1226
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
966
1227
  };
967
1228
  }
968
1229
  return model;
@@ -1038,17 +1299,33 @@ function fingerprintConfiguredDiscoveryRequestShape(
1038
1299
  return crypto.createHash("sha256").update("gajae:model-discovery-provenance\0").update(context).digest("hex");
1039
1300
  }
1040
1301
 
1041
- function fingerprintDescriptorDiscoveryProvenance(authEvidence: string, endpoint: string): string | undefined {
1302
+ function fingerprintDescriptorDiscoveryProvenance(
1303
+ providerId: string,
1304
+ authEvidence: string,
1305
+ endpoint: string,
1306
+ projectId?: string,
1307
+ ): string | undefined {
1042
1308
  try {
1043
1309
  const parsed = new URL(endpoint);
1044
1310
  if (parsed.username || parsed.password || parsed.search) return undefined;
1045
1311
  } catch {
1046
1312
  return undefined;
1047
1313
  }
1314
+ const discoveryIdentity =
1315
+ providerId === "google-antigravity" || providerId === "google-gemini-cli"
1316
+ ? getAntigravityDiscoveryUserAgent()
1317
+ : undefined;
1048
1318
  return crypto
1049
1319
  .createHash("sha256")
1050
1320
  .update("gajae:model-discovery-provenance\0")
1051
- .update(JSON.stringify({ authEvidence, endpoint }))
1321
+ .update(
1322
+ JSON.stringify({
1323
+ authEvidence,
1324
+ endpoint,
1325
+ ...(projectId !== undefined ? { projectId } : {}),
1326
+ ...(discoveryIdentity !== undefined ? { discoveryIdentity } : {}),
1327
+ }),
1328
+ )
1052
1329
  .digest("hex");
1053
1330
  }
1054
1331
 
@@ -1185,6 +1462,48 @@ function getOAuthCredentialsForProvider(authStorage: AuthStorage, provider: stri
1185
1462
  return entries.filter((entry): entry is OAuthCredential => entry.type === "oauth");
1186
1463
  }
1187
1464
 
1465
+ /** Internal pure parser shared by discovery; exported for regression coverage. */
1466
+ export function extractGoogleOAuthProjectId(value: string | undefined, accessToken: string): string | undefined {
1467
+ if (!value) return undefined;
1468
+ try {
1469
+ const parsed = JSON.parse(value) as { token?: unknown; projectId?: unknown; project_id?: unknown };
1470
+ if (typeof parsed.token !== "string" || parsed.token.trim() !== accessToken) return undefined;
1471
+ const projectId = typeof parsed.projectId === "string" ? parsed.projectId : parsed.project_id;
1472
+ return typeof projectId === "string" ? projectId.trim() || undefined : undefined;
1473
+ } catch {
1474
+ return undefined;
1475
+ }
1476
+ }
1477
+
1478
+ function resolveGoogleOAuthProjectId(
1479
+ authStorage: AuthStorage,
1480
+ provider: string,
1481
+ accessToken: string,
1482
+ rawValue?: string,
1483
+ sessionId?: string,
1484
+ owner?: object,
1485
+ ): string | undefined {
1486
+ const configuredProjectId = extractGoogleOAuthProjectId(rawValue, accessToken);
1487
+ if (authStorage.getEffectiveCredentialType(provider, sessionId, owner ? { owner } : undefined) !== "oauth") {
1488
+ return configuredProjectId;
1489
+ }
1490
+ const selector = authStorage.resolveEffectiveCredentialSelector(provider, sessionId);
1491
+ if (selector) {
1492
+ const selectedCredential = authStorage.getOAuthCredential(provider, sessionId, owner ? { owner } : undefined);
1493
+ if (selectedCredential?.access !== accessToken) return configuredProjectId;
1494
+ return selectedCredential.projectId?.trim() || configuredProjectId;
1495
+ }
1496
+ const matchingCredentials = getOAuthCredentialsForProvider(authStorage, provider).filter(
1497
+ credential => credential.access === accessToken,
1498
+ );
1499
+ if (matchingCredentials.length === 0) return configuredProjectId;
1500
+ const projectId = matchingCredentials[0]?.projectId?.trim();
1501
+ if (matchingCredentials.some(credential => (credential.projectId?.trim() ?? "") !== (projectId ?? ""))) {
1502
+ return configuredProjectId;
1503
+ }
1504
+ return projectId || configuredProjectId;
1505
+ }
1506
+
1188
1507
  function resolveOAuthAccountIdForAccessToken(
1189
1508
  authStorage: AuthStorage,
1190
1509
  provider: string,
@@ -1278,6 +1597,20 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
1278
1597
  if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
1279
1598
  if (override.output !== undefined) result.output = override.output as ("text" | "image")[];
1280
1599
  if (override.cacheRetention !== undefined) result.cacheRetention = override.cacheRetention;
1600
+ if (override.streamIdleTimeoutMs !== undefined) {
1601
+ if (isRequestTransformApi(model.api)) {
1602
+ result.streamIdleTimeoutMs = override.streamIdleTimeoutMs;
1603
+ } else {
1604
+ // Discovery ownership can reveal an incompatible route after config
1605
+ // validation, including while hydrating a cached model at startup.
1606
+ // Reject only this override so the otherwise-valid model stays usable.
1607
+ logger.warn("model stream idle timeout override ignored for incompatible API", {
1608
+ model: model.id,
1609
+ provider: model.provider,
1610
+ api: model.api,
1611
+ });
1612
+ }
1613
+ }
1281
1614
  const contextWindowOverride = toPositiveNumberOrUndefined(override.contextWindow);
1282
1615
  if (contextWindowOverride !== undefined) {
1283
1616
  result.contextWindow = contextWindowOverride;
@@ -1367,6 +1700,7 @@ interface CustomModelDefinitionLike {
1367
1700
  wireModelId?: string;
1368
1701
  requestTransform?: ModelRequestTransform;
1369
1702
  cacheRetention?: CacheRetention;
1703
+ streamIdleTimeoutMs?: number;
1370
1704
  }
1371
1705
 
1372
1706
  interface CustomModelBuildOptions {
@@ -1394,6 +1728,7 @@ type CustomModelOverlay = {
1394
1728
  wireModelId?: string;
1395
1729
  requestTransform?: ModelRequestTransform;
1396
1730
  cacheRetention?: CacheRetention;
1731
+ streamIdleTimeoutMs?: number;
1397
1732
  transport?: Model<Api>["transport"];
1398
1733
  isOAuth?: boolean;
1399
1734
  };
@@ -1508,9 +1843,13 @@ function buildCustomModelOverlay(
1508
1843
  providerCacheRetention: CacheRetention | undefined,
1509
1844
  providerTransport: Model<Api>["transport"] | undefined,
1510
1845
  modelDef: CustomModelDefinitionLike,
1846
+ providerStreamIdleTimeoutMs?: number,
1511
1847
  ): CustomModelOverlay | undefined {
1512
1848
  const api = modelDef.api ?? providerApi;
1513
1849
  if (!api) return undefined;
1850
+ const streamIdleTimeoutMs = isRequestTransformApi(api)
1851
+ ? (modelDef.streamIdleTimeoutMs ?? providerStreamIdleTimeoutMs)
1852
+ : undefined;
1514
1853
  return {
1515
1854
  id: modelDef.id,
1516
1855
  provider: providerName,
@@ -1535,6 +1874,7 @@ function buildCustomModelOverlay(
1535
1874
  contextPromotionTarget: modelDef.contextPromotionTarget,
1536
1875
  premiumMultiplier: modelDef.premiumMultiplier,
1537
1876
  cacheRetention: modelDef.cacheRetention ?? providerCacheRetention,
1877
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
1538
1878
  transport: providerTransport,
1539
1879
  isOAuth: resolveCustomModelIsOAuth(api, providerAuth),
1540
1880
  };
@@ -1671,6 +2011,9 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
1671
2011
  wireModelId: resolvedModel.wireModelId,
1672
2012
  requestTransform: resolvedModel.requestTransform,
1673
2013
  cacheRetention: resolvedModel.cacheRetention ?? reference?.cacheRetention,
2014
+ ...(resolvedModel.streamIdleTimeoutMs !== undefined
2015
+ ? { streamIdleTimeoutMs: resolvedModel.streamIdleTimeoutMs }
2016
+ : {}),
1674
2017
  transport: resolvedModel.transport,
1675
2018
  premiumMultiplier: resolvedModel.premiumMultiplier,
1676
2019
  isOAuth: resolvedModel.isOAuth,
@@ -1727,6 +2070,12 @@ interface ModelManagerDiscoveryOptions {
1727
2070
  apiKey: string | undefined;
1728
2071
  endpoint: string;
1729
2072
  endpointContainsUserinfo: boolean;
2073
+ projectId?: string;
2074
+ isCurrentContext?: () => boolean;
2075
+ }
2076
+
2077
+ interface GoogleSessionDiscoveryCatalog {
2078
+ models: Model<Api>[];
1730
2079
  }
1731
2080
 
1732
2081
  interface ConfiguredDiscoveryResult {
@@ -1742,6 +2091,201 @@ interface ConfiguredDiscoveryResult {
1742
2091
 
1743
2092
  type ProviderRefreshFence = { providerId: string; generation: number } | { generations: ReadonlyMap<string, number> };
1744
2093
 
2094
+ interface StagedConfigApiKey {
2095
+ readonly apiKey: string;
2096
+ readonly envSourced: boolean;
2097
+ // The staged AuthStorage adapter needs only the first two fields; registry-owned entries
2098
+ // also track whether the key came from config, runtime registration, or both.
2099
+ readonly configSourced?: boolean;
2100
+ readonly runtimeSourced?: boolean;
2101
+ }
2102
+
2103
+ interface StagedAuthStorageState {
2104
+ readonly authStorage: AuthStorage;
2105
+ readonly configApiKeys: Map<object, Map<string, StagedConfigApiKey>>;
2106
+ }
2107
+
2108
+ function createStagedAuthStorage(base: AuthStorage): StagedAuthStorageState {
2109
+ const configApiKeys = new Map<object, Map<string, StagedConfigApiKey>>();
2110
+ const fallbackResolvers = new Map<object, (provider: string) => string | undefined>();
2111
+ const lookupOwner = {};
2112
+ const getConfigApiKey = (provider: string, owner: object | undefined) =>
2113
+ (owner && configApiKeys.get(owner)?.get(resolveOAuthStorageProvider(provider))) || undefined;
2114
+ const peekApiKey = async (
2115
+ provider: string,
2116
+ options?: AuthApiKeyOptions & { sessionId?: string },
2117
+ ): Promise<string | undefined> => {
2118
+ if (base.hasRuntimeApiKey(provider)) return base.peekApiKey(provider, options);
2119
+ const configured = getConfigApiKey(provider, options?.owner);
2120
+ if (
2121
+ configured &&
2122
+ base.resolveEffectiveCredentialSelector(provider, options?.sessionId, options?.credentialSelector)
2123
+ ) {
2124
+ throw new Error(
2125
+ `Credential selector cannot be used for ${provider} while a config API key override is active`,
2126
+ );
2127
+ }
2128
+ if (configured && !configured.envSourced) return configured.apiKey;
2129
+ if (configured?.envSourced) {
2130
+ const credentialType = base.getEffectiveCredentialType(provider, options?.sessionId, {
2131
+ owner: lookupOwner,
2132
+ credentialSelector: options?.credentialSelector,
2133
+ });
2134
+ if (credentialType !== "api_key") return configured.apiKey;
2135
+ }
2136
+ const storedKey = await base.peekApiKey(provider, { ...options, owner: lookupOwner });
2137
+ if (storedKey !== undefined) return storedKey;
2138
+ if (configured?.envSourced) return configured.apiKey;
2139
+ return (options?.owner && fallbackResolvers.get(options.owner)?.(provider)) ?? undefined;
2140
+ };
2141
+ const staged = new Proxy(base, {
2142
+ get(target, property) {
2143
+ if (property === "setConfigApiKey") {
2144
+ return (provider: string, apiKey: string, options: { envSourced?: boolean; owner?: object } = {}) => {
2145
+ if (!options.owner) return;
2146
+ const keys = configApiKeys.get(options.owner) ?? new Map<string, StagedConfigApiKey>();
2147
+ keys.set(resolveOAuthStorageProvider(provider), {
2148
+ apiKey,
2149
+ envSourced: options.envSourced === true,
2150
+ });
2151
+ configApiKeys.set(options.owner, keys);
2152
+ };
2153
+ }
2154
+ if (property === "removeConfigApiKey") {
2155
+ return (provider: string, owner?: object) => {
2156
+ if (owner) configApiKeys.get(owner)?.delete(resolveOAuthStorageProvider(provider));
2157
+ };
2158
+ }
2159
+ if (property === "clearConfigApiKeys") {
2160
+ return (owner?: object) => {
2161
+ if (owner) configApiKeys.delete(owner);
2162
+ else configApiKeys.clear();
2163
+ };
2164
+ }
2165
+ if (property === "setFallbackResolver") {
2166
+ return (resolver: (provider: string) => string | undefined, owner?: object) => {
2167
+ if (!owner) return () => {};
2168
+ fallbackResolvers.set(owner, resolver);
2169
+ return () => {
2170
+ if (fallbackResolvers.get(owner) === resolver) fallbackResolvers.delete(owner);
2171
+ };
2172
+ };
2173
+ }
2174
+ if (property === "onGenerationChanged") return () => () => {};
2175
+ if (property === "peekApiKey") return peekApiKey;
2176
+ if (property === "getApiKey")
2177
+ return (provider: string, sessionId?: string, options?: AuthApiKeyOptions) =>
2178
+ peekApiKey(provider, { ...options, sessionId });
2179
+ if (property === "hasConfigApiKey") {
2180
+ return (provider: string, owner?: object) =>
2181
+ getConfigApiKey(provider, owner) !== undefined || base.hasConfigApiKey(provider, lookupOwner);
2182
+ }
2183
+ if (property === "hasAuth") {
2184
+ return (provider: string, sessionId?: string, options?: { owner?: object }) => {
2185
+ const configured = getConfigApiKey(provider, options?.owner);
2186
+ if (configured && base.resolveEffectiveCredentialSelector(provider, sessionId)) return false;
2187
+ return configured !== undefined || base.hasAuth(provider, sessionId, { owner: lookupOwner });
2188
+ };
2189
+ }
2190
+ if (property === "hasUsableAuth") {
2191
+ return (
2192
+ provider: string,
2193
+ options?: { owner?: object; sessionId?: string; credentialSelector?: AuthCredentialSelector },
2194
+ ) => {
2195
+ const configured = getConfigApiKey(provider, options?.owner);
2196
+ if (
2197
+ configured &&
2198
+ base.resolveEffectiveCredentialSelector(provider, options?.sessionId, options?.credentialSelector)
2199
+ ) {
2200
+ return false;
2201
+ }
2202
+ return (
2203
+ configured !== undefined ||
2204
+ base.hasUsableAuth(provider, {
2205
+ owner: lookupOwner,
2206
+ sessionId: options?.sessionId,
2207
+ credentialSelector: options?.credentialSelector,
2208
+ })
2209
+ );
2210
+ };
2211
+ }
2212
+ if (property === "getEffectiveCredentialType") {
2213
+ return (
2214
+ provider: string,
2215
+ sessionId?: string,
2216
+ options?: { owner?: object; credentialSelector?: AuthCredentialSelector },
2217
+ ) => {
2218
+ if (base.hasRuntimeApiKey(provider))
2219
+ return base.getEffectiveCredentialType(provider, sessionId, options);
2220
+ const configured = getConfigApiKey(provider, options?.owner);
2221
+ if (
2222
+ configured &&
2223
+ base.resolveEffectiveCredentialSelector(provider, sessionId, options?.credentialSelector)
2224
+ ) {
2225
+ return undefined;
2226
+ }
2227
+ if (
2228
+ configured &&
2229
+ !(configured.envSourced && base.getSessionCredentialType(provider, sessionId) === "api_key")
2230
+ ) {
2231
+ return "api_key";
2232
+ }
2233
+ return base.getEffectiveCredentialType(provider, sessionId, {
2234
+ owner: lookupOwner,
2235
+ credentialSelector: options?.credentialSelector,
2236
+ });
2237
+ };
2238
+ }
2239
+ if (property === "getProviderEvidenceGeneration") {
2240
+ return (provider: string, resolvedApiKey?: string, owner?: object) => {
2241
+ const configured = getConfigApiKey(provider, owner);
2242
+ const effectiveApiKey = base.hasRuntimeApiKey(provider)
2243
+ ? resolvedApiKey
2244
+ : configured && !configured.envSourced
2245
+ ? configured.apiKey
2246
+ : (resolvedApiKey ?? configured?.apiKey);
2247
+ return base.getProviderEvidenceGeneration(
2248
+ provider,
2249
+ effectiveApiKey,
2250
+ lookupOwner,
2251
+ configured ? { apiKey: configured.apiKey } : undefined,
2252
+ );
2253
+ };
2254
+ }
2255
+ const value = Reflect.get(target, property, target);
2256
+ return typeof value === "function" ? value.bind(target) : value;
2257
+ },
2258
+ }) as AuthStorage;
2259
+ return { authStorage: staged, configApiKeys };
2260
+ }
2261
+
2262
+ export interface ModelsConfigReloadCandidate {
2263
+ readonly valid: boolean;
2264
+ readonly changed: boolean;
2265
+ readonly diagnostics: { readonly valid: boolean; readonly errors: readonly unknown[] };
2266
+ readonly registry: ModelRegistry;
2267
+ isCurrent(): boolean;
2268
+ commit(): void;
2269
+ rollback(): void;
2270
+ finalize(): void;
2271
+ }
2272
+
2273
+ interface ModelRegistryInternalOptions {
2274
+ readonly configFile: ConfigFile<ModelsConfig>;
2275
+ readonly configSource: LoadResult<ModelsConfig>;
2276
+ readonly runtimeSource: ModelRegistry;
2277
+ readonly acceptedPresets: AcceptedModelPresetRegistry;
2278
+ }
2279
+
2280
+ interface RegistryLeaseWaiter {
2281
+ readonly kind: "consumer" | "publication";
2282
+ readonly signal?: AbortSignal;
2283
+ readonly resolve: (release: () => void) => void;
2284
+ readonly reject: (reason: unknown) => void;
2285
+ settled: boolean;
2286
+ abort?: () => void;
2287
+ }
2288
+
1745
2289
  /**
1746
2290
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
1747
2291
  */
@@ -1754,9 +2298,11 @@ export class ModelRegistry {
1754
2298
  bySelector: new Map(),
1755
2299
  aliases: new Map(),
1756
2300
  };
1757
- #availableModelsCache: Model<Api>[] | undefined;
1758
- #availableModelsDisabledProviders: string | undefined;
1759
- #availableModelsEnvFingerprint: string | undefined;
2301
+ #availableModelsCache = new Map<
2302
+ string,
2303
+ { models: Model<Api>[]; disabledProviders: string; envFingerprint: string }
2304
+ >();
2305
+ #googleSessionDiscoveryCatalogs = new Map<string, Map<string, GoogleSessionDiscoveryCatalog>>();
1760
2306
  #sessionCanonicalVariants = new Map<string, string>();
1761
2307
  #customProviderApiKeys: Map<string, string> = new Map();
1762
2308
  #customProviderApiKeyEnvNames: Map<string, string> = new Map();
@@ -1776,6 +2322,7 @@ export class ModelRegistry {
1776
2322
  modelIds: ReadonlySet<string>;
1777
2323
  authGeneration: string;
1778
2324
  endpoint: string;
2325
+ projectId?: string;
1779
2326
  }
1780
2327
  >();
1781
2328
  #descriptorDiscoveryGenerations = new Map<string, number>();
@@ -1793,9 +2340,14 @@ export class ModelRegistry {
1793
2340
  #modelProfiles: Map<string, ModelProfileDefinition> = mergeModelProfiles();
1794
2341
  #configError: ConfigError | undefined = undefined;
1795
2342
  #modelsConfigFile: ConfigFile<ModelsConfig>;
2343
+ #modelsConfigSource: LoadResult<ModelsConfig> | undefined;
2344
+ #acceptedConfigValue: ModelsConfig | undefined;
2345
+ #acceptedModelsConfigPresent = false;
2346
+ #acceptedPresets: AcceptedModelPresetRegistry | undefined;
1796
2347
  #settings: Pick<Settings, "get" | "getGlobal">;
1797
2348
  #ownershipSettings: Pick<Settings, "commitAtomicBatchWithCurrent"> | undefined;
1798
- readonly #authStorageConfigOwner: object = {};
2349
+ readonly #authStorageConfigOwner: object;
2350
+ #ownedConfigApiKeys = new Map<string, StagedConfigApiKey>();
1799
2351
  #disposeAuthStorageFallbackResolver: (() => void) | undefined;
1800
2352
  #lastStaticLoadMtime: number | null = null;
1801
2353
  #lastStaticLoadEnvironmentFingerprint: string | undefined;
@@ -1812,7 +2364,7 @@ export class ModelRegistry {
1812
2364
  #cacheDbPath?: string;
1813
2365
  #suppressedSelectors: Map<string, number> = new Map();
1814
2366
  #selectorCircuits: Map<string, SelectorCircuit> = new Map();
1815
- #backgroundRefresh?: Promise<void>;
2367
+ #backgroundRefreshes: Map<string, Promise<void>> = new Map();
1816
2368
  #catalogMutationTail: Promise<void> = Promise.resolve();
1817
2369
  #pendingCatalogMutations = 0;
1818
2370
  #catalogRefreshGeneration = 0;
@@ -1837,6 +2389,11 @@ export class ModelRegistry {
1837
2389
  #configuredApiKeyEnvNames: Set<string> = new Set();
1838
2390
  #optionalAuthPreflightGenerations = new Map<string, number>();
1839
2391
  #optionalAuthPreflightEpoch = 0;
2392
+ #stagedReloadCandidate = false;
2393
+ #leaseWaiters: RegistryLeaseWaiter[] = [];
2394
+ #activeConsumerLeases = 0;
2395
+ #publicationFenceHeld = false;
2396
+ #consumerContext = new AsyncLocalStorage<{ active: boolean }>();
1840
2397
 
1841
2398
  /**
1842
2399
  * @param authStorage - Auth storage for API key resolution
@@ -1846,15 +2403,22 @@ export class ModelRegistry {
1846
2403
  modelsPath?: string,
1847
2404
  registrySettings?: Pick<Settings, "get" | "getGlobal"> & Partial<Pick<Settings, "commitAtomicBatchWithCurrent">>,
1848
2405
  modelPresetRegistryDependencies: ModelPresetRegistryDependencies = {},
2406
+ internalOptions?: ModelRegistryInternalOptions,
1849
2407
  ) {
2408
+ this.#authStorageConfigOwner = internalOptions ? internalOptions.runtimeSource.#authStorageConfigOwner : {};
1850
2409
  this.#settings = registrySettings ?? settings;
1851
- this.#ownershipSettings = registrySettings
1852
- ? typeof registrySettings.commitAtomicBatchWithCurrent === "function"
1853
- ? (registrySettings as Pick<Settings, "commitAtomicBatchWithCurrent">)
1854
- : undefined
1855
- : settings;
2410
+ this.#ownershipSettings = internalOptions
2411
+ ? undefined
2412
+ : registrySettings
2413
+ ? typeof registrySettings.commitAtomicBatchWithCurrent === "function"
2414
+ ? (registrySettings as Pick<Settings, "commitAtomicBatchWithCurrent">)
2415
+ : undefined
2416
+ : settings;
1856
2417
  const configuredAgentDir = path.resolve(modelPresetRegistryDependencies.agentDir ?? getAgentDir());
1857
- this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
2418
+ this.#modelsConfigFile = internalOptions?.configFile ?? ModelsConfigFile.relocate(modelsPath);
2419
+ this.#modelsConfigSource = internalOptions?.configSource;
2420
+ this.#acceptedPresets = internalOptions?.acceptedPresets;
2421
+ this.#stagedReloadCandidate = internalOptions !== undefined;
1858
2422
  this.#modelPresetRegistryAgentDir = modelPresetRegistryDependencies.agentDir
1859
2423
  ? configuredAgentDir
1860
2424
  : modelsPath && path.isAbsolute(modelsPath)
@@ -1863,6 +2427,10 @@ export class ModelRegistry {
1863
2427
  const { agentDir: _agentDir, ...registryDependencies } = modelPresetRegistryDependencies;
1864
2428
  this.#modelPresetRegistryDependencies = registryDependencies;
1865
2429
  this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
2430
+ if (internalOptions) {
2431
+ this.#copyRuntimeStateFrom(internalOptions.runtimeSource);
2432
+ this.#clearOwnedConfigApiKeys();
2433
+ }
1866
2434
  // Set up fallback resolver for custom provider API keys
1867
2435
  this.#disposeAuthStorageFallbackResolver = this.authStorage.setFallbackResolver(provider => {
1868
2436
  const keyConfig = this.#customProviderApiKeys.get(provider);
@@ -1871,32 +2439,623 @@ export class ModelRegistry {
1871
2439
  this.#unsubscribeAuthGeneration = this.authStorage.onGenerationChanged(() => this.#invalidateAvailableModels());
1872
2440
  // Load models synchronously in constructor
1873
2441
  this.#loadModels();
1874
- this.#cancelModelPresetRegistryRefresh = refreshModelPresetRegistryInBackground(
2442
+ if (internalOptions) this.#reapplyRuntimeAuthConfiguration();
2443
+ if (!internalOptions) {
2444
+ this.#cancelModelPresetRegistryRefresh = refreshModelPresetRegistryInBackground(
2445
+ {
2446
+ ...this.#modelPresetRegistryDependencies,
2447
+ agentDir: this.#modelPresetRegistryAgentDir,
2448
+ knownManifestSha256: this.#loadedModelPresetRegistryManifestSha256,
2449
+ },
2450
+ () => {
2451
+ void this.#enqueueCatalogMutation(() => {
2452
+ if (this.#disposed) return;
2453
+ this.#reloadStaticModels();
2454
+ this.#modelBindingsApplier.apply();
2455
+ this.#notifyCatalogChanged();
2456
+ }).catch(() => undefined);
2457
+ },
2458
+ );
2459
+ }
2460
+ }
2461
+
2462
+ #copyRuntimeStateFrom(source: ModelRegistry): void {
2463
+ this.#sessionCanonicalVariants = new Map(source.#sessionCanonicalVariants);
2464
+ this.#googleSessionDiscoveryCatalogs = new Map(
2465
+ [...source.#googleSessionDiscoveryCatalogs].map(([provider, catalogs]) => [
2466
+ provider,
2467
+ new Map([...catalogs].map(([identity, catalog]) => [identity, { models: [...catalog.models] }])),
2468
+ ]),
2469
+ );
2470
+ this.#suppressedSelectors = new Map(source.#suppressedSelectors);
2471
+ this.#selectorCircuits = new Map(
2472
+ [...source.#selectorCircuits].map(([selector, circuit]) => [selector, structuredClone(circuit)]),
2473
+ );
2474
+ this.#runtimeModelOverlays = [...source.#runtimeModelOverlays];
2475
+ this.#runtimeProviderApiKeys = new Map(source.#runtimeProviderApiKeys);
2476
+ this.#runtimeProviderResolvedApiKeys = new Map(source.#runtimeProviderResolvedApiKeys);
2477
+ this.#runtimeProviderCredentialInstalled = new Set(source.#runtimeProviderCredentialInstalled);
2478
+ this.#runtimeProviderApiKeyEnvNames = new Map(source.#runtimeProviderApiKeyEnvNames);
2479
+ this.#runtimeProviderOverrides = new Map(source.#runtimeProviderOverrides);
2480
+ this.#runtimeProviderAuthHeaders = new Map(source.#runtimeProviderAuthHeaders);
2481
+ this.#generatedAuthHeaderProviders = new Set(source.#generatedAuthHeaderProviders);
2482
+ this.#runtimeProvidersBySource = new Map(
2483
+ [...source.#runtimeProvidersBySource].map(([id, providers]) => [id, new Set(providers)]),
2484
+ );
2485
+ this.#runtimeProviderSourceByName = new Map(source.#runtimeProviderSourceByName);
2486
+ this.#registeredProviderSources = new Set(source.#registeredProviderSources);
2487
+ this.#descriptorDiscoveryEvidence = new Map(source.#descriptorDiscoveryEvidence);
2488
+ this.#descriptorDiscoveryGenerations = new Map(source.#descriptorDiscoveryGenerations);
2489
+ this.#configuredDiscoveryEvidence = new Map(source.#configuredDiscoveryEvidence);
2490
+ this.#providerEvidenceApiKeys = new Map(source.#providerEvidenceApiKeys);
2491
+ this.#credentiallessAuthFallbackProviders = new Map(source.#credentiallessAuthFallbackProviders);
2492
+ this.#optionalAuthPreflightGenerations = new Map(source.#optionalAuthPreflightGenerations);
2493
+ this.#optionalAuthPreflightEpoch = source.#optionalAuthPreflightEpoch;
2494
+ this.#ownedConfigApiKeys = new Map(source.#ownedConfigApiKeys);
2495
+ this.#acceptedPresets = source.#acceptedPresets;
2496
+ this.#loadedModelPresetRegistryManifestSha256 = source.#loadedModelPresetRegistryManifestSha256;
2497
+ }
2498
+
2499
+ #setOwnedConfigApiKey(
2500
+ provider: string,
2501
+ apiKey: string,
2502
+ envSourced = false,
2503
+ source: "config" | "runtime" = "config",
2504
+ ): void {
2505
+ const key = resolveOAuthStorageProvider(provider);
2506
+ const previous = this.#ownedConfigApiKeys.get(key);
2507
+ this.#ownedConfigApiKeys.set(key, {
2508
+ apiKey,
2509
+ envSourced,
2510
+ configSourced: source === "config" || previous?.configSourced === true,
2511
+ runtimeSourced: source === "runtime" || previous?.runtimeSourced === true,
2512
+ });
2513
+ this.authStorage.setConfigApiKey(provider, apiKey, {
2514
+ envSourced,
2515
+ owner: this.#authStorageConfigOwner,
2516
+ });
2517
+ }
2518
+
2519
+ #removeOwnedConfigApiKey(provider: string): void {
2520
+ this.#ownedConfigApiKeys.delete(resolveOAuthStorageProvider(provider));
2521
+ this.authStorage.removeConfigApiKey(provider, this.#authStorageConfigOwner);
2522
+ }
2523
+
2524
+ #clearOwnedConfigApiKeys(): void {
2525
+ this.#ownedConfigApiKeys.clear();
2526
+ this.authStorage.clearConfigApiKeys(this.#authStorageConfigOwner);
2527
+ }
2528
+
2529
+ #reapplyRuntimeAuthConfiguration(): void {
2530
+ for (const [provider, apiKeyConfig] of this.#runtimeProviderApiKeys) {
2531
+ const resolved = this.#runtimeProviderApiKeyEnvNames.has(provider)
2532
+ ? $rotatingCredentialEnv(this.#runtimeProviderApiKeyEnvNames.get(provider)!)
2533
+ : resolveApiKeyConfig(apiKeyConfig);
2534
+ if (!resolved) continue;
2535
+ this.#customProviderApiKeys.set(provider, resolved);
2536
+ this.#runtimeProviderResolvedApiKeys.set(provider, resolved);
2537
+ this.#runtimeProviderCredentialInstalled.add(provider);
2538
+ this.#setOwnedConfigApiKey(provider, resolved, false, "runtime");
2539
+ }
2540
+ }
2541
+
2542
+ #swapReloadableState(other: ModelRegistry): void {
2543
+ [this.#models, other.#models] = [other.#models, this.#models];
2544
+ [this.#canonicalIndex, other.#canonicalIndex] = [other.#canonicalIndex, this.#canonicalIndex];
2545
+ [this.#availableModelsCache, other.#availableModelsCache] = [
2546
+ other.#availableModelsCache,
2547
+ this.#availableModelsCache,
2548
+ ];
2549
+ [this.#googleSessionDiscoveryCatalogs, other.#googleSessionDiscoveryCatalogs] = [
2550
+ other.#googleSessionDiscoveryCatalogs,
2551
+ this.#googleSessionDiscoveryCatalogs,
2552
+ ];
2553
+ [this.#customProviderApiKeys, other.#customProviderApiKeys] = [
2554
+ other.#customProviderApiKeys,
2555
+ this.#customProviderApiKeys,
2556
+ ];
2557
+ [this.#customProviderApiKeyEnvNames, other.#customProviderApiKeyEnvNames] = [
2558
+ other.#customProviderApiKeyEnvNames,
2559
+ this.#customProviderApiKeyEnvNames,
2560
+ ];
2561
+ [this.#customProviderAuthHeaders, other.#customProviderAuthHeaders] = [
2562
+ other.#customProviderAuthHeaders,
2563
+ this.#customProviderAuthHeaders,
2564
+ ];
2565
+ [this.#providerWebSearchModes, other.#providerWebSearchModes] = [
2566
+ other.#providerWebSearchModes,
2567
+ this.#providerWebSearchModes,
2568
+ ];
2569
+ [this.#keylessProviders, other.#keylessProviders] = [other.#keylessProviders, this.#keylessProviders];
2570
+ [this.#optionalAuthProviders, other.#optionalAuthProviders] = [
2571
+ other.#optionalAuthProviders,
2572
+ this.#optionalAuthProviders,
2573
+ ];
2574
+ [this.#credentiallessAuthFallbackProviders, other.#credentiallessAuthFallbackProviders] = [
2575
+ other.#credentiallessAuthFallbackProviders,
2576
+ this.#credentiallessAuthFallbackProviders,
2577
+ ];
2578
+ [this.#providerEvidenceApiKeys, other.#providerEvidenceApiKeys] = [
2579
+ other.#providerEvidenceApiKeys,
2580
+ this.#providerEvidenceApiKeys,
2581
+ ];
2582
+ [this.#providerActivity, other.#providerActivity] = [other.#providerActivity, this.#providerActivity];
2583
+ [this.#configuredProviderIds, other.#configuredProviderIds] = [
2584
+ other.#configuredProviderIds,
2585
+ this.#configuredProviderIds,
2586
+ ];
2587
+ [this.#configuredDiscoveryProviderIds, other.#configuredDiscoveryProviderIds] = [
2588
+ other.#configuredDiscoveryProviderIds,
2589
+ this.#configuredDiscoveryProviderIds,
2590
+ ];
2591
+ [this.#descriptorDiscoveryEvidence, other.#descriptorDiscoveryEvidence] = [
2592
+ other.#descriptorDiscoveryEvidence,
2593
+ this.#descriptorDiscoveryEvidence,
2594
+ ];
2595
+ [this.#descriptorDiscoveryGenerations, other.#descriptorDiscoveryGenerations] = [
2596
+ other.#descriptorDiscoveryGenerations,
2597
+ this.#descriptorDiscoveryGenerations,
2598
+ ];
2599
+ [this.#configuredDiscoveryEvidence, other.#configuredDiscoveryEvidence] = [
2600
+ other.#configuredDiscoveryEvidence,
2601
+ this.#configuredDiscoveryEvidence,
2602
+ ];
2603
+ [this.#discoveryManager, other.#discoveryManager] = [other.#discoveryManager, this.#discoveryManager];
2604
+ [this.#customModelOverlays, other.#customModelOverlays] = [other.#customModelOverlays, this.#customModelOverlays];
2605
+ [this.#providerOverrides, other.#providerOverrides] = [other.#providerOverrides, this.#providerOverrides];
2606
+ [this.#modelOverrides, other.#modelOverrides] = [other.#modelOverrides, this.#modelOverrides];
2607
+ [this.#codexContextWindowOverrides, other.#codexContextWindowOverrides] = [
2608
+ other.#codexContextWindowOverrides,
2609
+ this.#codexContextWindowOverrides,
2610
+ ];
2611
+ [this.#equivalenceConfig, other.#equivalenceConfig] = [other.#equivalenceConfig, this.#equivalenceConfig];
2612
+ [this.#acceptedPresets, other.#acceptedPresets] = [other.#acceptedPresets, this.#acceptedPresets];
2613
+ [this.#modelProfiles, other.#modelProfiles] = [other.#modelProfiles, this.#modelProfiles];
2614
+ [this.#configError, other.#configError] = [other.#configError, this.#configError];
2615
+ [this.#ownedConfigApiKeys, other.#ownedConfigApiKeys] = [other.#ownedConfigApiKeys, this.#ownedConfigApiKeys];
2616
+ [this.#lastStaticLoadMtime, other.#lastStaticLoadMtime] = [other.#lastStaticLoadMtime, this.#lastStaticLoadMtime];
2617
+ [this.#lastStaticLoadEnvironmentFingerprint, other.#lastStaticLoadEnvironmentFingerprint] = [
2618
+ other.#lastStaticLoadEnvironmentFingerprint,
2619
+ this.#lastStaticLoadEnvironmentFingerprint,
2620
+ ];
2621
+ [this.#lastModelPresetRegistryFingerprint, other.#lastModelPresetRegistryFingerprint] = [
2622
+ other.#lastModelPresetRegistryFingerprint,
2623
+ this.#lastModelPresetRegistryFingerprint,
2624
+ ];
2625
+ [this.#loadedModelPresetRegistryManifestSha256, other.#loadedModelPresetRegistryManifestSha256] = [
2626
+ other.#loadedModelPresetRegistryManifestSha256,
2627
+ this.#loadedModelPresetRegistryManifestSha256,
2628
+ ];
2629
+ [this.#staticModelsLoaded, other.#staticModelsLoaded] = [other.#staticModelsLoaded, this.#staticModelsLoaded];
2630
+ [this.#lastDisabledProviderKey, other.#lastDisabledProviderKey] = [
2631
+ other.#lastDisabledProviderKey,
2632
+ this.#lastDisabledProviderKey,
2633
+ ];
2634
+ [this.#runtimeModelOverlays, other.#runtimeModelOverlays] = [
2635
+ other.#runtimeModelOverlays,
2636
+ this.#runtimeModelOverlays,
2637
+ ];
2638
+ [this.#runtimeProviderApiKeys, other.#runtimeProviderApiKeys] = [
2639
+ other.#runtimeProviderApiKeys,
2640
+ this.#runtimeProviderApiKeys,
2641
+ ];
2642
+ [this.#runtimeProviderResolvedApiKeys, other.#runtimeProviderResolvedApiKeys] = [
2643
+ other.#runtimeProviderResolvedApiKeys,
2644
+ this.#runtimeProviderResolvedApiKeys,
2645
+ ];
2646
+ [this.#runtimeProviderCredentialInstalled, other.#runtimeProviderCredentialInstalled] = [
2647
+ other.#runtimeProviderCredentialInstalled,
2648
+ this.#runtimeProviderCredentialInstalled,
2649
+ ];
2650
+ [this.#runtimeProviderApiKeyEnvNames, other.#runtimeProviderApiKeyEnvNames] = [
2651
+ other.#runtimeProviderApiKeyEnvNames,
2652
+ this.#runtimeProviderApiKeyEnvNames,
2653
+ ];
2654
+ [this.#runtimeProviderOverrides, other.#runtimeProviderOverrides] = [
2655
+ other.#runtimeProviderOverrides,
2656
+ this.#runtimeProviderOverrides,
2657
+ ];
2658
+ [this.#runtimeProviderAuthHeaders, other.#runtimeProviderAuthHeaders] = [
2659
+ other.#runtimeProviderAuthHeaders,
2660
+ this.#runtimeProviderAuthHeaders,
2661
+ ];
2662
+ [this.#generatedAuthHeaderProviders, other.#generatedAuthHeaderProviders] = [
2663
+ other.#generatedAuthHeaderProviders,
2664
+ this.#generatedAuthHeaderProviders,
2665
+ ];
2666
+ [this.#generatedAuthHeaders, other.#generatedAuthHeaders] = [
2667
+ other.#generatedAuthHeaders,
2668
+ this.#generatedAuthHeaders,
2669
+ ];
2670
+ [this.#runtimeProvidersBySource, other.#runtimeProvidersBySource] = [
2671
+ other.#runtimeProvidersBySource,
2672
+ this.#runtimeProvidersBySource,
2673
+ ];
2674
+ [this.#runtimeProviderSourceByName, other.#runtimeProviderSourceByName] = [
2675
+ other.#runtimeProviderSourceByName,
2676
+ this.#runtimeProviderSourceByName,
2677
+ ];
2678
+ [this.#registeredProviderSources, other.#registeredProviderSources] = [
2679
+ other.#registeredProviderSources,
2680
+ this.#registeredProviderSources,
2681
+ ];
2682
+ [this.#registryModelKeys, other.#registryModelKeys] = [other.#registryModelKeys, this.#registryModelKeys];
2683
+ [this.#configuredApiKeyEnvNames, other.#configuredApiKeyEnvNames] = [
2684
+ other.#configuredApiKeyEnvNames,
2685
+ this.#configuredApiKeyEnvNames,
2686
+ ];
2687
+ [this.#optionalAuthPreflightGenerations, other.#optionalAuthPreflightGenerations] = [
2688
+ other.#optionalAuthPreflightGenerations,
2689
+ this.#optionalAuthPreflightGenerations,
2690
+ ];
2691
+ [this.#optionalAuthPreflightEpoch, other.#optionalAuthPreflightEpoch] = [
2692
+ other.#optionalAuthPreflightEpoch,
2693
+ this.#optionalAuthPreflightEpoch,
2694
+ ];
2695
+ const previousBindings = this.#modelBindingsApplier.getBindings();
2696
+ this.#modelBindingsApplier.setBindings(other.#modelBindingsApplier.getBindings());
2697
+ other.#modelBindingsApplier.setBindings(previousBindings);
2698
+ this.#modelBindingsApplier.apply();
2699
+ this.#modelsConfigFile.invalidate();
2700
+ this.#invalidateAvailableModels();
2701
+ }
2702
+
2703
+ #reconcileOwnedConfigApiKeys(
2704
+ configs: ReadonlyMap<string, StagedConfigApiKey>,
2705
+ previousConfigs: ReadonlyMap<string, StagedConfigApiKey>,
2706
+ ): void {
2707
+ for (const [provider, previous] of previousConfigs) {
2708
+ const next = configs.get(provider);
2709
+ if (next?.apiKey === previous.apiKey && next.envSourced === previous.envSourced) continue;
2710
+ this.authStorage.removeConfigApiKey(provider, this.#authStorageConfigOwner);
2711
+ }
2712
+ for (const [provider, config] of configs) {
2713
+ const previous = previousConfigs.get(provider);
2714
+ if (previous?.apiKey === config.apiKey && previous.envSourced === config.envSourced) continue;
2715
+ this.authStorage.setConfigApiKey(provider, config.apiKey, {
2716
+ envSourced: config.envSourced,
2717
+ owner: this.#authStorageConfigOwner,
2718
+ });
2719
+ }
2720
+ }
2721
+
2722
+ getModelsConfigPath(): string | undefined {
2723
+ return this.#modelsConfigFile.path();
2724
+ }
2725
+
2726
+ async stageModelsConfigReload(
2727
+ snapshot: {
2728
+ path: string;
2729
+ text: string | null;
2730
+ identity: string;
2731
+ },
2732
+ registrySettings: Pick<Settings, "get" | "getGlobal"> = this.#settings,
2733
+ ): Promise<ModelsConfigReloadCandidate> {
2734
+ if (this.#disposed) throw new Error("Model registry is disposed");
2735
+ const pathMatches = path.resolve(snapshot.path) === path.resolve(this.#modelsConfigFile.path());
2736
+ const source = pathMatches
2737
+ ? parseModelsConfigSnapshot(snapshot)
2738
+ : {
2739
+ status: "error" as const,
2740
+ error: new ConfigError("models", undefined, {
2741
+ err: new Error("Models config path changed while the watcher was active"),
2742
+ stage: "Snapshot",
2743
+ }),
2744
+ };
2745
+ const accepted = source.status === "ok" || (source.status === "not-found" && !this.#acceptedModelsConfigPresent);
2746
+ const diagnostics = {
2747
+ valid: accepted,
2748
+ errors: accepted
2749
+ ? []
2750
+ : [source.status === "error" ? source.error : new Error("Cannot delete an accepted models config")],
2751
+ };
2752
+ const stagedAuthStorage = createStagedAuthStorage(this.authStorage);
2753
+ stagedAuthStorage.configApiKeys.set(this.#authStorageConfigOwner, new Map(this.#ownedConfigApiKeys));
2754
+ const acceptedPresets =
2755
+ this.#acceptedPresets ??
2756
+ loadAcceptedModelPresetProfiles(this.#modelPresetRegistryAgentDir, this.#modelPresetRegistryDependencies);
2757
+ const registry = new ModelRegistry(
2758
+ stagedAuthStorage.authStorage,
2759
+ this.#modelsConfigFile.path(),
2760
+ registrySettings,
2761
+ { ...this.#modelPresetRegistryDependencies, automaticRefresh: false },
1875
2762
  {
1876
- ...this.#modelPresetRegistryDependencies,
1877
- agentDir: this.#modelPresetRegistryAgentDir,
1878
- knownManifestSha256: this.#loadedModelPresetRegistryManifestSha256,
1879
- },
1880
- () => {
1881
- void this.#enqueueCatalogMutation(() => {
1882
- if (this.#disposed) return;
1883
- this.#reloadStaticModels();
1884
- this.#modelBindingsApplier.apply();
1885
- this.#notifyCatalogChanged();
1886
- }).catch(() => undefined);
2763
+ configFile: this.#modelsConfigFile,
2764
+ configSource: source,
2765
+ runtimeSource: this,
2766
+ acceptedPresets,
1887
2767
  },
1888
2768
  );
2769
+ if (registry.getError()) {
2770
+ diagnostics.valid = false;
2771
+ diagnostics.errors.push(registry.getError()!);
2772
+ }
2773
+ for (const [provider, apiKey] of registry.#ownedConfigApiKeys) {
2774
+ if (apiKey.runtimeSourced === true && apiKey.configSourced !== true) continue;
2775
+ if (!this.authStorage.hasAnyCredentialPin(provider)) continue;
2776
+ diagnostics.valid = false;
2777
+ diagnostics.errors.push(
2778
+ new Error(
2779
+ `Cannot configure an API key override for ${provider} while a credential pin is active or unavailable`,
2780
+ ),
2781
+ );
2782
+ }
2783
+ let committed = false;
2784
+ let finished = false;
2785
+ const previousConfigPresent = this.#acceptedModelsConfigPresent;
2786
+ const previousConfigValue = this.#acceptedConfigValue;
2787
+ const nextConfigValue = source.status === "ok" ? source.value : undefined;
2788
+ const changed = !isDeepStrictEqual(previousConfigValue, nextConfigValue);
2789
+ const unchangedProviders = new Set(
2790
+ [...this.#providerActivity.keys()].filter(
2791
+ provider =>
2792
+ isDeepStrictEqual(previousConfigValue?.providers?.[provider], nextConfigValue?.providers?.[provider]) &&
2793
+ isDeepStrictEqual(this.#providerOverrides.get(provider), registry.#providerOverrides.get(provider)) &&
2794
+ isDeepStrictEqual(this.#modelOverrides.get(provider), registry.#modelOverrides.get(provider)) &&
2795
+ this.#customProviderApiKeys.get(provider) === registry.#customProviderApiKeys.get(provider) &&
2796
+ isDeepStrictEqual(this.#ownedConfigApiKeys.get(provider), registry.#ownedConfigApiKeys.get(provider)),
2797
+ ),
2798
+ );
2799
+ const unchangedProviderSnapshots = new Map(
2800
+ [...unchangedProviders].map(provider => [
2801
+ provider,
2802
+ {
2803
+ models: this.#models.filter(model => model.provider === provider),
2804
+ discoveryState: this.#discoveryManager.getState(provider),
2805
+ configuredEvidence: this.#configuredDiscoveryEvidence.get(provider),
2806
+ descriptorEvidence: this.#descriptorDiscoveryEvidence.get(provider),
2807
+ descriptorGeneration: this.#descriptorDiscoveryGenerations.get(provider),
2808
+ providerEvidenceApiKey: this.#providerEvidenceApiKeys.get(provider),
2809
+ credentiallessFallback: this.#credentiallessAuthFallbackProviders.get(provider),
2810
+ optionalAuthPreflightGeneration: this.#optionalAuthPreflightGenerations.get(provider),
2811
+ activity: this.#providerActivity.get(provider),
2812
+ },
2813
+ ]),
2814
+ );
2815
+ const copyProviderEntry = <T>(target: Map<string, T>, source: ReadonlyMap<string, T>, provider: string) => {
2816
+ const value = source.get(provider);
2817
+ if (value === undefined) target.delete(provider);
2818
+ else target.set(provider, value);
2819
+ };
2820
+ const stagedModelKeys = new Set(registry.#models.map(model => `${model.provider}/${model.id}`));
2821
+ for (const model of this.#models) {
2822
+ if (!unchangedProviders.has(model.provider) || stagedModelKeys.has(`${model.provider}/${model.id}`)) continue;
2823
+ registry.#models.push(model);
2824
+ }
2825
+ registry.#discoveryManager.retainStatesFrom(this.#discoveryManager, unchangedProviders);
2826
+ const retainedActivity = new Map(registry.#providerActivity);
2827
+ for (const provider of unchangedProviders) {
2828
+ copyProviderEntry(registry.#configuredDiscoveryEvidence, this.#configuredDiscoveryEvidence, provider);
2829
+ copyProviderEntry(registry.#descriptorDiscoveryEvidence, this.#descriptorDiscoveryEvidence, provider);
2830
+ copyProviderEntry(registry.#descriptorDiscoveryGenerations, this.#descriptorDiscoveryGenerations, provider);
2831
+ copyProviderEntry(registry.#providerEvidenceApiKeys, this.#providerEvidenceApiKeys, provider);
2832
+ copyProviderEntry(retainedActivity, this.#providerActivity, provider);
2833
+ }
2834
+ registry.#providerActivity = retainedActivity;
2835
+ registry.#rebuildCanonicalIndex();
2836
+ const rebaseProviderDiscoveryAfterQueuedRefresh = () => {
2837
+ const changedProviders = new Set(
2838
+ [...unchangedProviders].filter(provider => {
2839
+ const snapshot = unchangedProviderSnapshots.get(provider)!;
2840
+ return (
2841
+ !isDeepStrictEqual(
2842
+ snapshot.models,
2843
+ this.#models.filter(model => model.provider === provider),
2844
+ ) ||
2845
+ !isDeepStrictEqual(snapshot.discoveryState, this.#discoveryManager.getState(provider)) ||
2846
+ !isDeepStrictEqual(snapshot.configuredEvidence, this.#configuredDiscoveryEvidence.get(provider)) ||
2847
+ !isDeepStrictEqual(snapshot.descriptorEvidence, this.#descriptorDiscoveryEvidence.get(provider)) ||
2848
+ snapshot.descriptorGeneration !== this.#descriptorDiscoveryGenerations.get(provider) ||
2849
+ snapshot.providerEvidenceApiKey !== this.#providerEvidenceApiKeys.get(provider) ||
2850
+ snapshot.credentiallessFallback !== this.#credentiallessAuthFallbackProviders.get(provider) ||
2851
+ snapshot.optionalAuthPreflightGeneration !== this.#optionalAuthPreflightGenerations.get(provider) ||
2852
+ !isDeepStrictEqual(snapshot.activity, this.#providerActivity.get(provider))
2853
+ );
2854
+ }),
2855
+ );
2856
+ if (changedProviders.size === 0) return;
2857
+
2858
+ registry.#models = registry.#models.filter(model => !changedProviders.has(model.provider));
2859
+ registry.#models.push(...this.#models.filter(model => changedProviders.has(model.provider)));
2860
+ for (const provider of changedProviders) registry.#discoveryManager.invalidate(provider);
2861
+ registry.#discoveryManager.retainStatesFrom(this.#discoveryManager, changedProviders);
2862
+ const providerActivity = new Map(registry.#providerActivity);
2863
+ for (const provider of changedProviders) {
2864
+ copyProviderEntry(registry.#configuredDiscoveryEvidence, this.#configuredDiscoveryEvidence, provider);
2865
+ copyProviderEntry(registry.#descriptorDiscoveryEvidence, this.#descriptorDiscoveryEvidence, provider);
2866
+ registry.#googleSessionDiscoveryCatalogs.delete(provider);
2867
+ copyProviderEntry(registry.#descriptorDiscoveryGenerations, this.#descriptorDiscoveryGenerations, provider);
2868
+ copyProviderEntry(registry.#providerEvidenceApiKeys, this.#providerEvidenceApiKeys, provider);
2869
+ copyProviderEntry(
2870
+ registry.#credentiallessAuthFallbackProviders,
2871
+ this.#credentiallessAuthFallbackProviders,
2872
+ provider,
2873
+ );
2874
+ copyProviderEntry(
2875
+ registry.#optionalAuthPreflightGenerations,
2876
+ this.#optionalAuthPreflightGenerations,
2877
+ provider,
2878
+ );
2879
+ copyProviderEntry(providerActivity, this.#providerActivity, provider);
2880
+ }
2881
+ registry.#providerActivity = providerActivity;
2882
+ registry.#rebuildCanonicalIndex();
2883
+ };
2884
+ const expectedGeneration = this.#catalogRefreshGeneration;
2885
+ const isCurrent = () => !this.#disposed && this.#catalogRefreshGeneration === expectedGeneration;
2886
+ const commit = () => {
2887
+ if (finished) throw new Error("Models config reload candidate is no longer active");
2888
+ if (!diagnostics.valid) throw new Error("Cannot commit an invalid models config reload candidate");
2889
+ if (committed) return;
2890
+ if (!isCurrent()) {
2891
+ throw new Error("Model catalog changed during configuration preflight");
2892
+ }
2893
+ for (const [provider, apiKey] of registry.#ownedConfigApiKeys) {
2894
+ if (apiKey.runtimeSourced === true && apiKey.configSourced !== true) continue;
2895
+ if (this.authStorage.hasAnyCredentialPin(provider)) {
2896
+ throw new Error(
2897
+ `Cannot configure an API key override for ${provider} while a credential pin is active or unavailable`,
2898
+ );
2899
+ }
2900
+ }
2901
+ rebaseProviderDiscoveryAfterQueuedRefresh();
2902
+ this.#swapReloadableState(registry);
2903
+ try {
2904
+ this.#reconcileOwnedConfigApiKeys(this.#ownedConfigApiKeys, registry.#ownedConfigApiKeys);
2905
+ this.#acceptedModelsConfigPresent = source.status === "ok";
2906
+ this.#acceptedConfigValue = nextConfigValue;
2907
+ // The captured source may precede the current disk revision.
2908
+ this.#lastStaticLoadMtime = null;
2909
+ committed = true;
2910
+ } catch (error) {
2911
+ this.#swapReloadableState(registry);
2912
+ this.#reconcileOwnedConfigApiKeys(this.#ownedConfigApiKeys, registry.#ownedConfigApiKeys);
2913
+ throw error;
2914
+ }
2915
+ };
2916
+ const rollback = () => {
2917
+ if (finished) return;
2918
+ if (committed) {
2919
+ this.#swapReloadableState(registry);
2920
+ this.#reconcileOwnedConfigApiKeys(this.#ownedConfigApiKeys, registry.#ownedConfigApiKeys);
2921
+ this.#acceptedModelsConfigPresent = previousConfigPresent;
2922
+ this.#acceptedConfigValue = previousConfigValue;
2923
+ committed = false;
2924
+ }
2925
+ finished = true;
2926
+ void registry.dispose();
2927
+ };
2928
+ const finalize = () => {
2929
+ if (finished) return;
2930
+ if (!committed) throw new Error("Cannot finalize an unpublished models configuration");
2931
+ finished = true;
2932
+ this.#catalogRefreshGeneration++;
2933
+ this.#notifyCatalogChanged();
2934
+ void registry.dispose();
2935
+ };
2936
+ return { valid: diagnostics.valid, changed, diagnostics, registry, isCurrent, commit, rollback, finalize };
2937
+ }
2938
+
2939
+ #acquireLease(kind: RegistryLeaseWaiter["kind"], signal?: AbortSignal): Promise<() => void> {
2940
+ if (this.#disposed) return Promise.reject(new Error("Model registry is disposed"));
2941
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
2942
+ if (
2943
+ !this.#publicationFenceHeld &&
2944
+ this.#leaseWaiters.length === 0 &&
2945
+ (kind === "consumer" || this.#activeConsumerLeases === 0)
2946
+ ) {
2947
+ return Promise.resolve(this.#grantLease(kind));
2948
+ }
2949
+ const { promise, resolve, reject } = Promise.withResolvers<() => void>();
2950
+ const waiter: RegistryLeaseWaiter = { kind, signal, resolve, reject, settled: false };
2951
+ if (signal) {
2952
+ waiter.abort = () => {
2953
+ if (waiter.settled) return;
2954
+ waiter.settled = true;
2955
+ this.#leaseWaiters = this.#leaseWaiters.filter(item => item !== waiter);
2956
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
2957
+ this.#drainLeaseWaiters();
2958
+ };
2959
+ signal.addEventListener("abort", waiter.abort, { once: true });
2960
+ }
2961
+ this.#leaseWaiters.push(waiter);
2962
+ this.#drainLeaseWaiters();
2963
+ return promise;
2964
+ }
2965
+
2966
+ #grantLease(kind: RegistryLeaseWaiter["kind"]): () => void {
2967
+ if (kind === "publication") this.#publicationFenceHeld = true;
2968
+ else this.#activeConsumerLeases += 1;
2969
+ let released = false;
2970
+ return () => {
2971
+ if (released) return;
2972
+ released = true;
2973
+ if (kind === "publication") this.#publicationFenceHeld = false;
2974
+ else this.#activeConsumerLeases -= 1;
2975
+ this.#drainLeaseWaiters();
2976
+ };
2977
+ }
2978
+
2979
+ #drainLeaseWaiters(): void {
2980
+ if (this.#publicationFenceHeld) return;
2981
+ while (this.#leaseWaiters.length > 0) {
2982
+ const waiter = this.#leaseWaiters[0]!;
2983
+ if (waiter.kind === "publication") {
2984
+ if (this.#activeConsumerLeases > 0) return;
2985
+ this.#leaseWaiters.shift();
2986
+ waiter.settled = true;
2987
+ if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort);
2988
+ waiter.resolve(this.#grantLease("publication"));
2989
+ return;
2990
+ }
2991
+ this.#leaseWaiters.shift();
2992
+ waiter.settled = true;
2993
+ if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort);
2994
+ waiter.resolve(this.#grantLease("consumer"));
2995
+ }
2996
+ }
2997
+
2998
+ async withConsumerLease<T>(body: () => T | Promise<T>, signal?: AbortSignal): Promise<T> {
2999
+ signal?.throwIfAborted();
3000
+ if (this.#consumerContext.getStore()?.active) return await body();
3001
+ const release = await this.#acquireLease("consumer", signal);
3002
+ const owner = { active: true };
3003
+ try {
3004
+ return await this.#consumerContext.run(owner, body);
3005
+ } finally {
3006
+ owner.active = false;
3007
+ release();
3008
+ }
3009
+ }
3010
+
3011
+ async acquirePublicationFence(signal?: AbortSignal): Promise<() => void> {
3012
+ if (this.#consumerContext.getStore()?.active) {
3013
+ throw new Error("Cannot publish configuration while holding a model consumer lease");
3014
+ }
3015
+ const release = await this.#acquireLease("publication", signal);
3016
+ const gate = Promise.withResolvers<void>();
3017
+ const entered = Promise.withResolvers<void>();
3018
+ const barrier = this.#enqueueCatalogMutation(async () => {
3019
+ entered.resolve();
3020
+ await gate.promise;
3021
+ });
3022
+ const cancelled = Promise.withResolvers<void>();
3023
+ const abort = () => cancelled.reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
3024
+ signal?.addEventListener("abort", abort, { once: true });
3025
+ try {
3026
+ if (signal?.aborted) abort();
3027
+ await Promise.race([entered.promise, cancelled.promise]);
3028
+ signal?.throwIfAborted();
3029
+ } catch (error) {
3030
+ gate.resolve();
3031
+ void barrier.finally(release);
3032
+ throw error;
3033
+ } finally {
3034
+ signal?.removeEventListener("abort", abort);
3035
+ }
3036
+ let released = false;
3037
+ return () => {
3038
+ if (released) return;
3039
+ released = true;
3040
+ gate.resolve();
3041
+ void barrier.finally(release);
3042
+ };
1889
3043
  }
1890
3044
 
1891
3045
  dispose(): Promise<void> {
1892
3046
  if (this.#disposePromise) return this.#disposePromise;
1893
3047
  this.#disposed = true;
3048
+ for (const waiter of this.#leaseWaiters.splice(0)) {
3049
+ waiter.settled = true;
3050
+ if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort);
3051
+ waiter.reject(new Error("Model registry is disposed"));
3052
+ }
1894
3053
  this.#catalogRefreshGeneration++;
1895
3054
  const awaitRefreshDisposal = this.#cancelModelPresetRegistryRefresh?.() ?? Promise.resolve();
1896
3055
  this.#cancelModelPresetRegistryRefresh = undefined;
1897
3056
  this.#unsubscribeAuthGeneration?.();
1898
3057
  this.#unsubscribeAuthGeneration = undefined;
1899
- this.authStorage.clearConfigApiKeys(this.#authStorageConfigOwner);
3058
+ this.#clearOwnedConfigApiKeys();
1900
3059
  this.#disposeAuthStorageFallbackResolver?.();
1901
3060
  this.#disposeAuthStorageFallbackResolver = undefined;
1902
3061
  this.#catalogChangeListeners.clear();
@@ -2001,9 +3160,12 @@ export class ModelRegistry {
2001
3160
  }
2002
3161
 
2003
3162
  refreshInBackground(strategy: ModelRefreshStrategy = "online-if-uncached", credentialSessionId?: string): void {
2004
- if (this.#backgroundRefresh) {
2005
- return;
2006
- }
3163
+ // Each credential context has an isolated discovery catalog. Coalescing every
3164
+ // request behind one global promise discarded B's refresh when A was pending.
3165
+ // Equivalent contexts still coalesce; distinct ones queue through the catalog
3166
+ // mutation tail so their overlays remain independently populated.
3167
+ const contextKey = `${strategy}\u0000${credentialSessionId ?? ""}`;
3168
+ if (this.#backgroundRefreshes.has(contextKey)) return;
2007
3169
  const refreshPromise = this.refresh(strategy, credentialSessionId)
2008
3170
  .catch(error => {
2009
3171
  logger.warn("background model refresh failed", {
@@ -2011,11 +3173,11 @@ export class ModelRegistry {
2011
3173
  });
2012
3174
  })
2013
3175
  .finally(() => {
2014
- if (this.#backgroundRefresh === refreshPromise) {
2015
- this.#backgroundRefresh = undefined;
3176
+ if (this.#backgroundRefreshes.get(contextKey) === refreshPromise) {
3177
+ this.#backgroundRefreshes.delete(contextKey);
2016
3178
  }
2017
3179
  });
2018
- this.#backgroundRefresh = refreshPromise;
3180
+ this.#backgroundRefreshes.set(contextKey, refreshPromise);
2019
3181
  }
2020
3182
 
2021
3183
  async refreshProvider(
@@ -2141,6 +3303,11 @@ export class ModelRegistry {
2141
3303
  );
2142
3304
  return JSON.stringify({
2143
3305
  apiKeyEnv: [...this.#configuredApiKeyEnvNames].sort().map(name => [name, Bun.env[name] ?? ""]),
3306
+ credentialPins: this.#stagedReloadCandidate
3307
+ ? []
3308
+ : [...this.#configuredProviderIds]
3309
+ .sort()
3310
+ .map(provider => [provider, this.authStorage.hasAnyCredentialPin(provider)]),
2144
3311
  implicitEndpoints: [
2145
3312
  ["OLLAMA_BASE_URL", Bun.env.OLLAMA_BASE_URL || ""],
2146
3313
  ["LLAMA_CPP_BASE_URL", Bun.env.LLAMA_CPP_BASE_URL || ""],
@@ -2171,7 +3338,7 @@ export class ModelRegistry {
2171
3338
  environmentFingerprint === this.#lastStaticLoadEnvironmentFingerprint &&
2172
3339
  modelPresetRegistryFingerprint === this.#lastModelPresetRegistryFingerprint
2173
3340
  ) {
2174
- // models.json and settings-derived implicit provider state are unchanged.
3341
+ // Config, settings-derived provider state, and effective credential selectors are unchanged.
2175
3342
  return;
2176
3343
  }
2177
3344
  this.#modelsConfigFile.invalidate();
@@ -2189,7 +3356,7 @@ export class ModelRegistry {
2189
3356
  // Drop config-sourced apiKeys from AuthStorage before reload; entries
2190
3357
  // removed from models.yml must actually disappear from the resolver, not
2191
3358
  // linger from the previous parse. The post-load setters below repopulate.
2192
- this.authStorage.clearConfigApiKeys(this.#authStorageConfigOwner);
3359
+ this.#clearOwnedConfigApiKeys();
2193
3360
  // Runtime provider keys are reapplied after #loadModels so they retain
2194
3361
  // registration-time precedence over colliding static provider keys.
2195
3362
  this.#providerOverrides.clear();
@@ -2202,7 +3369,7 @@ export class ModelRegistry {
2202
3369
  const resolved = this.#runtimeProviderApiKeyEnvNames.has(provider)
2203
3370
  ? $rotatingCredentialEnv(this.#runtimeProviderApiKeyEnvNames.get(provider)!)
2204
3371
  : resolveApiKeyConfig(apiKeyConfig);
2205
- if (!resolved) {
3372
+ if (!resolved || this.authStorage.hasAnyCredentialPin(provider)) {
2206
3373
  this.#runtimeProviderCredentialInstalled.delete(provider);
2207
3374
  const authHeader = this.#runtimeProviderAuthHeaders.get(provider);
2208
3375
  if (authHeader === true) {
@@ -2225,7 +3392,7 @@ export class ModelRegistry {
2225
3392
  this.#customProviderApiKeys.set(provider, resolved);
2226
3393
  this.#runtimeProviderResolvedApiKeys.set(provider, resolved);
2227
3394
  this.#runtimeProviderCredentialInstalled.add(provider);
2228
- this.authStorage.setConfigApiKey(provider, resolved, { owner: this.#authStorageConfigOwner });
3395
+ this.#setOwnedConfigApiKey(provider, resolved, false, "runtime");
2229
3396
  const override = this.#runtimeProviderOverrides.get(provider);
2230
3397
  if (override) this.#runtimeProviderOverrides.set(provider, { ...override, apiKey: resolved });
2231
3398
  const authHeader = this.#runtimeProviderAuthHeaders.get(provider);
@@ -2258,11 +3425,11 @@ export class ModelRegistry {
2258
3425
  const resolved = this.#runtimeProviderApiKeyEnvNames.has(provider)
2259
3426
  ? $rotatingCredentialEnv(this.#runtimeProviderApiKeyEnvNames.get(provider)!)
2260
3427
  : resolveApiKeyConfig(apiKeyConfig);
2261
- if (!resolved) continue;
3428
+ if (!resolved || this.authStorage.hasAnyCredentialPin(provider)) continue;
2262
3429
  this.#customProviderApiKeys.set(provider, resolved);
2263
3430
  this.#runtimeProviderResolvedApiKeys.set(provider, resolved);
2264
3431
  this.#runtimeProviderCredentialInstalled.add(provider);
2265
- this.authStorage.setConfigApiKey(provider, resolved, { owner: this.#authStorageConfigOwner });
3432
+ this.#setOwnedConfigApiKey(provider, resolved, false, "runtime");
2266
3433
  }
2267
3434
  this.#lastDisabledProviderKey = disabledProviderKey;
2268
3435
  }
@@ -2298,10 +3465,10 @@ export class ModelRegistry {
2298
3465
  this.#codexContextWindowOverrides = this.#collectCodexContextWindowOverrides();
2299
3466
  this.#equivalenceConfig = equivalence;
2300
3467
  this.#modelBindingsApplier.setBindings(modelBindings);
2301
- const acceptedPresets = loadAcceptedModelPresetProfiles(
2302
- this.#modelPresetRegistryAgentDir,
2303
- this.#modelPresetRegistryDependencies,
2304
- );
3468
+ const acceptedPresets = this.#stagedReloadCandidate
3469
+ ? this.#acceptedPresets!
3470
+ : loadAcceptedModelPresetProfiles(this.#modelPresetRegistryAgentDir, this.#modelPresetRegistryDependencies);
3471
+ this.#acceptedPresets = acceptedPresets;
2305
3472
  const acceptedRegistryError = acceptedPresets.error
2306
3473
  ? new ConfigError("model-preset-registry", undefined, {
2307
3474
  err: new Error(acceptedPresets.error),
@@ -2476,6 +3643,9 @@ export class ModelRegistry {
2476
3643
  const inheritedCompat = explicitTransportMatches
2477
3644
  ? explicitTransport?.compat
2478
3645
  : commonRegistryTransportCompat(transportTemplates);
3646
+ const streamIdleTimeoutMs = isRequestTransformApi(registryModel.api)
3647
+ ? (registryModel.streamIdleTimeoutMs ?? explicitTransport?.streamIdleTimeoutMs)
3648
+ : undefined;
2479
3649
  const hydratedRegistryModel = {
2480
3650
  ...registryModel,
2481
3651
  baseUrl: transportSource.baseUrl!,
@@ -2483,6 +3653,7 @@ export class ModelRegistry {
2483
3653
  transport: transportSource.transport,
2484
3654
  requestTransform: transportSource.requestTransform,
2485
3655
  cacheRetention: transportSource.cacheRetention,
3656
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
2486
3657
  isOAuth: transportSource.isOAuth,
2487
3658
  compat:
2488
3659
  inheritedCompat || registryModel.compat || explicitTransport?.compat
@@ -2498,14 +3669,23 @@ export class ModelRegistry {
2498
3669
  continue;
2499
3670
  }
2500
3671
  const existing = merged[existingIndex]!;
3672
+ const { streamIdleTimeoutMs: existingStreamIdleTimeoutMs, ...existingWithoutStreamIdleTimeout } = existing;
2501
3673
  const effectiveApi = explicitTransport?.api ?? existing.api;
2502
3674
  const registryApiMatchesEffectiveTransport = registryModel.api === effectiveApi;
2503
3675
  const registryMetadata = registryApiMatchesEffectiveTransport
2504
3676
  ? registryModel
2505
3677
  : registryModelMetadataWithoutApiSpecificFields(registryModel);
3678
+ const {
3679
+ streamIdleTimeoutMs: registryMetadataStreamIdleTimeoutMs,
3680
+ ...registryMetadataWithoutStreamIdleTimeout
3681
+ } = registryMetadata;
3682
+ const streamIdleTimeoutMs = isRequestTransformApi(effectiveApi)
3683
+ ? (registryMetadataStreamIdleTimeoutMs ?? existingStreamIdleTimeoutMs)
3684
+ : undefined;
2506
3685
  merged[existingIndex] = {
2507
- ...existing,
2508
- ...registryMetadata,
3686
+ ...existingWithoutStreamIdleTimeout,
3687
+ ...registryMetadataWithoutStreamIdleTimeout,
3688
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
2509
3689
  api: explicitTransport?.api ?? existing.api,
2510
3690
  baseUrl: existing.baseUrl,
2511
3691
  headers: existing.headers,
@@ -2542,9 +3722,14 @@ export class ModelRegistry {
2542
3722
  const existingIndex = indexByKey.get(key);
2543
3723
  if (existingIndex !== undefined) {
2544
3724
  const existingModel = merged[existingIndex];
3725
+ const { streamIdleTimeoutMs: existingStreamIdleTimeoutMs, ...existingModelWithoutStreamIdleTimeout } =
3726
+ existingModel;
2545
3727
  const referenceModel = resolveCustomModelReference(customModel.id);
3728
+ const streamIdleTimeoutMs = isRequestTransformApi(customModel.api)
3729
+ ? (customModel.streamIdleTimeoutMs ?? existingStreamIdleTimeoutMs)
3730
+ : undefined;
2546
3731
  merged[existingIndex] = enrichModelThinking({
2547
- ...existingModel,
3732
+ ...existingModelWithoutStreamIdleTimeout,
2548
3733
  id: customModel.id,
2549
3734
  provider: customModel.provider,
2550
3735
  api: customModel.api,
@@ -2568,6 +3753,7 @@ export class ModelRegistry {
2568
3753
  wireModelId: customModel.wireModelId,
2569
3754
  requestTransform: customModel.requestTransform,
2570
3755
  premiumMultiplier: customModel.premiumMultiplier ?? existingModel.premiumMultiplier,
3756
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
2571
3757
  } as Model<Api>);
2572
3758
  } else {
2573
3759
  merged.push(finalizeCustomModel(customModel, { useDefaults: true }));
@@ -2587,6 +3773,7 @@ export class ModelRegistry {
2587
3773
  }
2588
3774
  const cache = readModelCache<Api>(descriptor.providerId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
2589
3775
  const expectedProvenance = fingerprintDescriptorDiscoveryProvenance(
3776
+ descriptor.providerId,
2590
3777
  authEvidence ?? this.#getProviderEvidenceGeneration(descriptor.providerId),
2591
3778
  this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(descriptor.providerId) ?? ""),
2592
3779
  );
@@ -2740,10 +3927,12 @@ export class ModelRegistry {
2740
3927
  });
2741
3928
  }
2742
3929
  #sanitizeDiscoverableModelsForCache(_providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
2743
- return this.#stripModelBaseUrlQueries(models).map(model => ({
2744
- ...model,
2745
- headers: undefined,
2746
- }));
3930
+ return this.#stripModelBaseUrlQueries(models).map(model => {
3931
+ const cachedModel = { ...model, headers: undefined };
3932
+ // Model idle timeouts come from local config and must be re-derived on cache load.
3933
+ delete cachedModel.streamIdleTimeoutMs;
3934
+ return cachedModel;
3935
+ });
2747
3936
  }
2748
3937
  #stripModelBaseUrlQueries(models: readonly Model<Api>[]): Model<Api>[] {
2749
3938
  return models.map(model => createTrustedStrippedModelClone(model));
@@ -2824,7 +4013,10 @@ export class ModelRegistry {
2824
4013
 
2825
4014
  #loadCustomModels(): CustomModelsResult {
2826
4015
  this.#configuredApiKeyEnvNames.clear();
2827
- const { value, error, status } = this.#modelsConfigFile.tryLoad();
4016
+ const loaded = this.#modelsConfigSource ?? this.#modelsConfigFile.tryLoad();
4017
+ const { value, error, status } = loaded;
4018
+ this.#acceptedConfigValue = status === "ok" ? value : undefined;
4019
+ if (status === "ok") this.#acceptedModelsConfigPresent = true;
2828
4020
 
2829
4021
  if (status === "error") {
2830
4022
  return {
@@ -2859,6 +4051,7 @@ export class ModelRegistry {
2859
4051
  const configuredProviders = new Set(Object.keys(value.providers ?? {}));
2860
4052
 
2861
4053
  for (const [providerName, providerConfig] of providerEntries) {
4054
+ const credentialPinActive = !this.#stagedReloadCandidate && this.authStorage.hasAnyCredentialPin(providerName);
2862
4055
  const authMode = (providerConfig.auth ?? "apiKey") as ProviderAuthMode;
2863
4056
  const isOAuth = resolveCustomModelIsOAuth(
2864
4057
  (providerConfig.api as Api | undefined) ?? "openai-completions",
@@ -2873,9 +4066,11 @@ export class ModelRegistry {
2873
4066
  if (providerConfig.openaiCompat?.apiKey)
2874
4067
  this.#configuredApiKeyEnvNames.add(providerConfig.openaiCompat.apiKey);
2875
4068
  if (providerConfig.webSearch) this.#providerWebSearchModes.set(providerName, providerConfig.webSearch);
2876
- const providerApiKeyConfig = providerConfig.apiKey
2877
- ? resolveApiKeyConfig(providerConfig.apiKey)
2878
- : resolveApiKeyEnvConfig(providerConfig.apiKeyEnv);
4069
+ const providerApiKeyConfig = credentialPinActive
4070
+ ? undefined
4071
+ : providerConfig.apiKey
4072
+ ? resolveApiKeyConfig(providerConfig.apiKey)
4073
+ : resolveApiKeyEnvConfig(providerConfig.apiKeyEnv);
2879
4074
  const localOpenAICompat = providerConfig.openaiCompat;
2880
4075
  const rotatingApiKeyEnv = providerConfig.apiKey
2881
4076
  ? undefined
@@ -2883,18 +4078,21 @@ export class ModelRegistry {
2883
4078
  if (rotatingApiKeyEnv) this.#customProviderApiKeyEnvNames.set(providerName, rotatingApiKeyEnv);
2884
4079
  if (providerConfig.authHeader !== undefined)
2885
4080
  this.#customProviderAuthHeaders.set(providerName, providerConfig.authHeader);
2886
- const localOpenAICompatApiKeyConfig = localOpenAICompat
2887
- ? localOpenAICompat.apiKey
2888
- ? resolveApiKeyConfig(localOpenAICompat.apiKey)
2889
- : resolveApiKeyEnvConfig(localOpenAICompat.apiKeyEnv)
2890
- : undefined;
4081
+ const localOpenAICompatApiKeyConfig =
4082
+ localOpenAICompat && !credentialPinActive
4083
+ ? localOpenAICompat.apiKey
4084
+ ? resolveApiKeyConfig(localOpenAICompat.apiKey)
4085
+ : resolveApiKeyEnvConfig(localOpenAICompat.apiKeyEnv)
4086
+ : undefined;
2891
4087
  if (localOpenAICompat) {
2892
4088
  const localOpenAICompatBaseUrl = normalizeLocalOpenAICompatBaseUrl(localOpenAICompat.baseUrl);
2893
- const localCompatResolvedKey = localOpenAICompat.apiKey
2894
- ? resolveApiKeyConfig(localOpenAICompat.apiKey)
2895
- : localOpenAICompat.apiKeyEnv
2896
- ? resolveApiKeyEnvConfig(localOpenAICompat.apiKeyEnv)
2897
- : undefined;
4089
+ const localCompatResolvedKey = credentialPinActive
4090
+ ? undefined
4091
+ : localOpenAICompat.apiKey
4092
+ ? resolveApiKeyConfig(localOpenAICompat.apiKey)
4093
+ : localOpenAICompat.apiKeyEnv
4094
+ ? resolveApiKeyEnvConfig(localOpenAICompat.apiKeyEnv)
4095
+ : undefined;
2898
4096
  overrides.set(providerName, {
2899
4097
  api: "openai-completions",
2900
4098
  baseUrl: localOpenAICompatBaseUrl,
@@ -2920,10 +4118,7 @@ export class ModelRegistry {
2920
4118
  });
2921
4119
  if (localCompatResolvedKey) {
2922
4120
  this.#customProviderApiKeys.set(providerName, localCompatResolvedKey);
2923
- this.authStorage.setConfigApiKey(providerName, localCompatResolvedKey, {
2924
- envSourced: !localOpenAICompat.apiKey,
2925
- owner: this.#authStorageConfigOwner,
2926
- });
4121
+ this.#setOwnedConfigApiKey(providerName, localCompatResolvedKey, !localOpenAICompat.apiKey);
2927
4122
  } else {
2928
4123
  keylessProviders.add(providerName);
2929
4124
  this.#optionalAuthProviders.add(providerName);
@@ -2940,10 +4135,11 @@ export class ModelRegistry {
2940
4135
  providerConfig.disableStrictTools ||
2941
4136
  providerConfig.requestTransform ||
2942
4137
  providerConfig.transport ||
2943
- providerConfig.cacheRetention
4138
+ providerConfig.cacheRetention ||
4139
+ providerConfig.streamIdleTimeoutMs !== undefined
2944
4140
  ) {
2945
4141
  const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined;
2946
- overrides.set(providerName, {
4142
+ const providerOverride: ProviderOverride = {
2947
4143
  api: providerConfig.api as Api | undefined,
2948
4144
  baseUrl: providerConfig.baseUrl,
2949
4145
  headers: providerConfig.headers,
@@ -2953,8 +4149,14 @@ export class ModelRegistry {
2953
4149
  transport: providerConfig.transport,
2954
4150
  requestTransform: providerConfig.requestTransform,
2955
4151
  cacheRetention: providerConfig.cacheRetention,
4152
+ streamIdleTimeoutMs: providerConfig.streamIdleTimeoutMs,
2956
4153
  isOAuth,
2957
- });
4154
+ };
4155
+ const existingOverride = overrides.get(providerName);
4156
+ overrides.set(
4157
+ providerName,
4158
+ existingOverride ? this.#mergeProviderOverride(existingOverride, providerOverride) : providerOverride,
4159
+ );
2958
4160
  }
2959
4161
 
2960
4162
  if (authMode === "none") {
@@ -3006,22 +4208,20 @@ export class ModelRegistry {
3006
4208
  });
3007
4209
  }
3008
4210
 
3009
- // Store API key for fallback resolver AND register as config override
3010
- // so it wins over OAuth tokens from the broker — when the user pins a
3011
- // bearer in models.yml (e.g. for an auth-gateway baseUrl), that bearer
3012
- // must authenticate the outbound request.
4211
+ // Store API keys for fallback resolution and register config overrides.
4212
+ // A pin in any session suppresses shared config keys so a reload cannot
4213
+ // override another session's selected account.
3013
4214
  if (providerConfig.apiKey || providerConfig.apiKeyEnv) {
3014
- const resolved = providerConfig.apiKey
3015
- ? resolveApiKeyConfig(providerConfig.apiKey)
3016
- : providerConfig.apiKeyEnv
3017
- ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
3018
- : undefined;
4215
+ const resolved = credentialPinActive
4216
+ ? undefined
4217
+ : providerConfig.apiKey
4218
+ ? resolveApiKeyConfig(providerConfig.apiKey)
4219
+ : providerConfig.apiKeyEnv
4220
+ ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
4221
+ : undefined;
3019
4222
  if (resolved) this.#customProviderApiKeys.set(providerName, resolved);
3020
4223
  if (resolved) {
3021
- this.authStorage.setConfigApiKey(providerName, resolved, {
3022
- envSourced: !providerConfig.apiKey,
3023
- owner: this.#authStorageConfigOwner,
3024
- });
4224
+ this.#setOwnedConfigApiKey(providerName, resolved, !providerConfig.apiKey);
3025
4225
  }
3026
4226
  }
3027
4227
 
@@ -3205,8 +4405,15 @@ export class ModelRegistry {
3205
4405
  }
3206
4406
 
3207
4407
  async #writeCheckedModelsConfig(config: ModelsConfig): Promise<void> {
3208
- await fs.mkdir(path.dirname(this.#modelsConfigFile.path()), { recursive: true });
3209
- await Bun.write(this.#modelsConfigFile.path(), Bun.YAML.stringify(config, null, 2));
4408
+ const configPath = this.#modelsConfigFile.path();
4409
+ const parentPath = path.dirname(configPath);
4410
+ await fs.mkdir(parentPath, { recursive: true, mode: 0o700 });
4411
+ // The owner-only writer rejects a symlink in any path component and rejects
4412
+ // "..". Resolve only the parent so an alias directory and a lexical ".."
4413
+ // still reach the same file. The final name stays lexical, so a symlink
4414
+ // at models.yml is still opened with O_NOFOLLOW.
4415
+ const parent = await fs.realpath(parentPath);
4416
+ writeExplicitOwnerOnlyTextSync(path.join(parent, path.basename(configPath)), Bun.YAML.stringify(config, null, 2));
3210
4417
  this.#modelsConfigFile.invalidate();
3211
4418
  this.#reloadStaticModels();
3212
4419
  }
@@ -3236,6 +4443,12 @@ export class ModelRegistry {
3236
4443
  credentialSessionId?: string,
3237
4444
  ): Promise<void> {
3238
4445
  const profileAvailabilityEvidenceBefore = this.#profileAvailabilityEvidenceFingerprint();
4446
+ const googleDescriptorGenerationsBefore = new Map(
4447
+ (["google-antigravity", "google-gemini-cli"] as const).map(provider => [
4448
+ provider,
4449
+ this.#descriptorDiscoveryGenerations.get(provider) ?? 0,
4450
+ ]),
4451
+ );
3239
4452
  const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
3240
4453
  const selectedDiscoverableProviders = (
3241
4454
  providerFilter
@@ -3387,6 +4600,38 @@ export class ModelRegistry {
3387
4600
  return false;
3388
4601
  }
3389
4602
  });
4603
+ let sessionCatalogChanged = false;
4604
+ for (const provider of ["google-antigravity", "google-gemini-cli"] as const) {
4605
+ if (
4606
+ this.authStorage.getEffectiveCredentialType(provider, credentialSessionId, {
4607
+ owner: this.#authStorageConfigOwner,
4608
+ }) !== "oauth"
4609
+ ) {
4610
+ continue;
4611
+ }
4612
+ if (
4613
+ (this.#descriptorDiscoveryGenerations.get(provider) ?? 0) ===
4614
+ googleDescriptorGenerationsBefore.get(provider)
4615
+ ) {
4616
+ continue;
4617
+ }
4618
+ const evidence = this.#descriptorDiscoveryEvidence.get(provider);
4619
+ if (!evidence) continue;
4620
+ const identity = this.#googleDescriptorContextIdentity(
4621
+ provider,
4622
+ evidence.authGeneration,
4623
+ evidence.endpoint,
4624
+ evidence.projectId,
4625
+ );
4626
+ if (!identity || identity !== this.#googleSessionDescriptorIdentity(provider, credentialSessionId)) continue;
4627
+ const catalogs = this.#googleSessionDiscoveryCatalogs.get(provider) ?? new Map();
4628
+ const models = currentBuiltInDiscovered.filter(model => model.provider === provider);
4629
+ const previous = catalogs.get(identity);
4630
+ if (models.length === 0 && evidence.fresh === false && previous !== undefined) continue;
4631
+ if (!isDeepStrictEqual(previous?.models, models)) sessionCatalogChanged = true;
4632
+ catalogs.set(identity, { models });
4633
+ this.#googleSessionDiscoveryCatalogs.set(provider, catalogs);
4634
+ }
3390
4635
  const configuredDiscoveryEvidence = new Map(
3391
4636
  currentConfiguredDiscoveryResults
3392
4637
  .filter(result => result.current)
@@ -3454,6 +4699,10 @@ export class ModelRegistry {
3454
4699
  );
3455
4700
  });
3456
4701
  }
4702
+ if (sessionCatalogChanged) {
4703
+ this.#invalidateAvailableModels();
4704
+ this.#notifyCatalogChanged();
4705
+ }
3457
4706
  if (discovered.length === 0) {
3458
4707
  if (
3459
4708
  clearPublishedModelIds.size > 0 ||
@@ -3466,6 +4715,53 @@ export class ModelRegistry {
3466
4715
  this.#mergeDiscoveredModels(discovered);
3467
4716
  }
3468
4717
 
4718
+ #googleDescriptorContextIdentity(
4719
+ provider: "google-antigravity" | "google-gemini-cli",
4720
+ authGeneration: string,
4721
+ endpoint: string,
4722
+ projectId?: string,
4723
+ ): string | undefined {
4724
+ return fingerprintDescriptorDiscoveryProvenance(provider, authGeneration, endpoint, projectId);
4725
+ }
4726
+
4727
+ #googleSessionDescriptorIdentity(
4728
+ provider: "google-antigravity" | "google-gemini-cli",
4729
+ credentialSessionId?: string,
4730
+ ): string | undefined {
4731
+ if (
4732
+ this.authStorage.getEffectiveCredentialType(provider, credentialSessionId, {
4733
+ owner: this.#authStorageConfigOwner,
4734
+ }) !== "oauth"
4735
+ ) {
4736
+ return undefined;
4737
+ }
4738
+ const credential = this.authStorage.getOAuthCredential(provider, credentialSessionId, {
4739
+ owner: this.#authStorageConfigOwner,
4740
+ });
4741
+ if (!credential) return undefined;
4742
+ const endpoint = this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(provider) ?? "");
4743
+ try {
4744
+ return this.#googleDescriptorContextIdentity(
4745
+ provider,
4746
+ this.#getProviderEvidenceGeneration(provider, credential.access),
4747
+ endpoint,
4748
+ credential.projectId?.trim() || undefined,
4749
+ );
4750
+ } catch {
4751
+ return undefined;
4752
+ }
4753
+ }
4754
+
4755
+ #getGoogleSessionDiscoveryModels(credentialSessionId?: string): Model<Api>[] {
4756
+ const models: Model<Api>[] = [];
4757
+ for (const provider of ["google-antigravity", "google-gemini-cli"] as const) {
4758
+ const identity = this.#googleSessionDescriptorIdentity(provider, credentialSessionId);
4759
+ if (!identity) continue;
4760
+ models.push(...(this.#googleSessionDiscoveryCatalogs.get(provider)?.get(identity)?.models ?? []));
4761
+ }
4762
+ return models;
4763
+ }
4764
+
3469
4765
  #profileAvailabilityEvidenceFingerprint(): string {
3470
4766
  return JSON.stringify({
3471
4767
  descriptor: [...this.#descriptorDiscoveryEvidence.entries()]
@@ -3475,6 +4771,7 @@ export class ModelRegistry {
3475
4771
  evidence.fresh,
3476
4772
  evidence.authGeneration,
3477
4773
  evidence.endpoint,
4774
+ evidence.projectId,
3478
4775
  [...evidence.modelIds].sort(),
3479
4776
  ]),
3480
4777
  configured: [...this.#configuredDiscoveryEvidence.entries()]
@@ -3488,23 +4785,37 @@ export class ModelRegistry {
3488
4785
  });
3489
4786
  }
3490
4787
 
4788
+ #removeDescriptorDiscoveredModels(provider: string, modelIds: ReadonlySet<string>): boolean {
4789
+ const staticModelIds = this.#providerActivity.get(provider)?.staticModelIds;
4790
+ const previousLength = this.#models.length;
4791
+ this.#models = this.#models.filter(model => {
4792
+ if (model.provider !== provider || !modelIds.has(model.id)) return true;
4793
+ return staticModelIds?.has(model.id) === true;
4794
+ });
4795
+ return this.#models.length !== previousLength;
4796
+ }
4797
+
3491
4798
  #mergeDiscoveredModels(discovered: readonly Model<Api>[]): void {
4799
+ this.#models = this.#resolveDiscoveredModelCatalog(this.#models, discovered);
4800
+ this.#rebuildCanonicalIndex();
4801
+ }
4802
+
4803
+ #resolveDiscoveredModelCatalog(baseModels: readonly Model<Api>[], discovered: readonly Model<Api>[]): Model<Api>[] {
3492
4804
  const discoveredModels = this.#applyHardcodedModelPolicies(
3493
4805
  discovered.map(model =>
3494
4806
  mergeDiscoveredModel(
3495
4807
  model,
3496
- this.find(model.provider, model.id),
4808
+ baseModels.find(candidate => candidate.provider === model.provider && candidate.id === model.id),
3497
4809
  this.#resolveProviderOverride(model.provider),
3498
4810
  ),
3499
4811
  ),
3500
4812
  );
3501
- const resolved = this.#mergeResolvedModels(this.#models, discoveredModels);
4813
+ const resolved = this.#mergeResolvedModels([...baseModels], discoveredModels);
3502
4814
  const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
3503
4815
  // Merge runtime extension models so they survive online discovery completion
3504
4816
  const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
3505
4817
  const withModelOverrides = this.#applyModelOverrides(combined, this.#modelOverrides);
3506
- this.#models = this.#finalizeModels(this.#applyRuntimeProviderOverrides(withModelOverrides));
3507
- this.#rebuildCanonicalIndex();
4818
+ return this.#finalizeModels(this.#applyRuntimeProviderOverrides(withModelOverrides));
3508
4819
  }
3509
4820
 
3510
4821
  async #discoverProviderModels(
@@ -3835,19 +5146,39 @@ export class ModelRegistry {
3835
5146
  {
3836
5147
  providerId: "google-antigravity",
3837
5148
  resolveKey: extractGoogleOAuthToken,
3838
- createOptions: oauthToken =>
5149
+ resolveProjectId: (oauthToken, rawValue) =>
5150
+ resolveGoogleOAuthProjectId(
5151
+ this.authStorage,
5152
+ "google-antigravity",
5153
+ oauthToken,
5154
+ rawValue,
5155
+ credentialSessionId,
5156
+ this.#authStorageConfigOwner,
5157
+ ),
5158
+ createOptions: (oauthToken, projectId) =>
3839
5159
  googleAntigravityModelManagerOptions({
3840
5160
  oauthToken,
3841
5161
  endpoint: this.getProviderBaseUrl("google-antigravity"),
5162
+ project: projectId,
3842
5163
  }),
3843
5164
  },
3844
5165
  {
3845
5166
  providerId: "google-gemini-cli",
3846
5167
  resolveKey: extractGoogleOAuthToken,
3847
- createOptions: oauthToken =>
5168
+ resolveProjectId: (oauthToken, rawValue) =>
5169
+ resolveGoogleOAuthProjectId(
5170
+ this.authStorage,
5171
+ "google-gemini-cli",
5172
+ oauthToken,
5173
+ rawValue,
5174
+ credentialSessionId,
5175
+ this.#authStorageConfigOwner,
5176
+ ),
5177
+ createOptions: (oauthToken, projectId) =>
3848
5178
  googleGeminiCliModelManagerOptions({
3849
5179
  oauthToken,
3850
5180
  endpoint: this.getProviderBaseUrl("google-gemini-cli"),
5181
+ project: projectId,
3851
5182
  }),
3852
5183
  },
3853
5184
  {
@@ -3956,7 +5287,11 @@ export class ModelRegistry {
3956
5287
  authGeneration === this.#getProviderEvidenceGeneration(descriptor.providerId, apiKeyValue) &&
3957
5288
  isAuthenticated(key)
3958
5289
  ) {
3959
- const managerOptions = descriptor.createOptions(key);
5290
+ const projectId = descriptor.resolveProjectId?.(key, apiKeyValue);
5291
+ const isCurrentContext = descriptor.resolveProjectId
5292
+ ? () => descriptor.resolveProjectId?.(key, apiKeyValue) === projectId
5293
+ : undefined;
5294
+ const managerOptions = descriptor.createOptions(key, projectId);
3960
5295
  const baseUrl = this.#getProviderBaseUrlForDiscovery(descriptor.providerId) ?? "";
3961
5296
  options.push({
3962
5297
  options: managerOptions,
@@ -3964,6 +5299,8 @@ export class ModelRegistry {
3964
5299
  apiKey: apiKeyValue,
3965
5300
  endpoint: this.#normalizeDiscoveryEvidenceEndpoint(baseUrl),
3966
5301
  endpointContainsUserinfo: this.#urlContainsUserinfo(baseUrl),
5302
+ projectId,
5303
+ ...(isCurrentContext ? { isCurrentContext } : {}),
3967
5304
  });
3968
5305
  }
3969
5306
  }
@@ -3971,12 +5308,28 @@ export class ModelRegistry {
3971
5308
  }
3972
5309
 
3973
5310
  async #discoverWithModelManager(
3974
- { options, authGeneration, apiKey, endpoint, endpointContainsUserinfo }: ModelManagerDiscoveryOptions,
5311
+ {
5312
+ options,
5313
+ authGeneration,
5314
+ apiKey,
5315
+ endpoint,
5316
+ endpointContainsUserinfo,
5317
+ projectId,
5318
+ isCurrentContext,
5319
+ }: ModelManagerDiscoveryOptions,
3975
5320
  strategy: ModelRefreshStrategy,
3976
5321
  providerRefresh?: ProviderRefreshFence,
3977
5322
  ): Promise<Model<Api>[]> {
3978
5323
  const generation = (this.#descriptorDiscoveryGenerations.get(options.providerId) ?? 0) + 1;
3979
5324
  this.#descriptorDiscoveryGenerations.set(options.providerId, generation);
5325
+ const previousEvidence = this.#descriptorDiscoveryEvidence.get(options.providerId);
5326
+ const discoveryContextChanged =
5327
+ previousEvidence !== undefined &&
5328
+ (previousEvidence.authGeneration !== authGeneration ||
5329
+ previousEvidence.endpoint !== endpoint ||
5330
+ previousEvidence.projectId !== projectId);
5331
+ const isGoogleProjectScopedProvider =
5332
+ options.providerId === "google-antigravity" || options.providerId === "google-gemini-cli";
3980
5333
  const canUseCredentialDerivedXiaomiEndpoint =
3981
5334
  options.providerId === "xiaomi" &&
3982
5335
  apiKey?.startsWith("tp-") === true &&
@@ -3986,8 +5339,7 @@ export class ModelRegistry {
3986
5339
  let credentialDerivedEndpoint: string | undefined;
3987
5340
  const reusableCacheProvenance = endpointContainsUserinfo
3988
5341
  ? undefined
3989
- : fingerprintDescriptorDiscoveryProvenance(authGeneration, endpoint);
3990
- if (reusableCacheProvenance === undefined && strategy === "offline") return [];
5342
+ : fingerprintDescriptorDiscoveryProvenance(options.providerId, authGeneration, endpoint, projectId);
3991
5343
  // Signed/userinfo endpoints may still publish a sanitized best-effort row,
3992
5344
  // but each refresh gets a new opaque nonce. The nonce contains no endpoint
3993
5345
  // secret and can never match a previous invocation, so constructor loads,
@@ -3997,6 +5349,7 @@ export class ModelRegistry {
3997
5349
  const isCurrentDiscoveryContext = () =>
3998
5350
  (this.#descriptorDiscoveryGenerations.get(options.providerId) ?? 0) === generation &&
3999
5351
  this.#getProviderEvidenceGeneration(options.providerId, apiKey) === authGeneration &&
5352
+ (isCurrentContext?.() ?? true) &&
4000
5353
  (endpoint ===
4001
5354
  this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(options.providerId) ?? "") ||
4002
5355
  (canUseCredentialDerivedXiaomiEndpoint && credentialDerivedEndpoint !== undefined));
@@ -4017,6 +5370,40 @@ export class ModelRegistry {
4017
5370
  ? strategy !== "online-if-uncached" || isCurrentDiscovery()
4018
5371
  : isCurrentProviderRefresh()));
4019
5372
  try {
5373
+ if (!isCurrentDiscovery()) return [];
5374
+ if (discoveryContextChanged && previousEvidence) {
5375
+ const removedPreviousModels = this.#removeDescriptorDiscoveredModels(
5376
+ options.providerId,
5377
+ previousEvidence.modelIds,
5378
+ );
5379
+ if (removedPreviousModels) {
5380
+ this.#rebuildProviderActivity();
5381
+ this.#rebuildCanonicalIndex();
5382
+ }
5383
+ }
5384
+ if (strategy === "offline") {
5385
+ const cache =
5386
+ reusableCacheProvenance !== undefined && isGoogleProjectScopedProvider
5387
+ ? readModelCache<Api>(options.providerId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath)
5388
+ : null;
5389
+ const hasCurrentProjectCache =
5390
+ cache?.dynamicModelIds !== undefined && cache.dynamicModelProvenance === reusableCacheProvenance;
5391
+ if (reusableCacheProvenance === undefined || (isGoogleProjectScopedProvider && !hasCurrentProjectCache)) {
5392
+ if (discoveryContextChanged) {
5393
+ this.#providerEvidenceApiKeys.set(options.providerId, apiKey);
5394
+ this.#descriptorDiscoveryEvidence.set(options.providerId, {
5395
+ fresh: false,
5396
+ modelIds: new Set(),
5397
+ authGeneration,
5398
+ endpoint,
5399
+ projectId,
5400
+ });
5401
+ this.#rebuildProviderActivity();
5402
+ this.#rebuildCanonicalIndex();
5403
+ }
5404
+ return [];
5405
+ }
5406
+ }
4020
5407
  const manager = createModelManager({
4021
5408
  ...options,
4022
5409
  cacheDbPath: this.#cacheDbPath,
@@ -4048,7 +5435,9 @@ export class ModelRegistry {
4048
5435
  const evidence = this.#descriptorDiscoveryEvidence.get(options.providerId);
4049
5436
  const refreshStrategy =
4050
5437
  strategy === "online-if-uncached" &&
4051
- (evidence?.authGeneration !== authGeneration || evidence.endpoint !== endpoint)
5438
+ (evidence?.authGeneration !== authGeneration ||
5439
+ evidence.endpoint !== endpoint ||
5440
+ evidence.projectId !== projectId)
4052
5441
  ? "online"
4053
5442
  : strategy;
4054
5443
  const result = await manager.refresh(refreshStrategy);
@@ -4063,7 +5452,7 @@ export class ModelRegistry {
4063
5452
  const preservesExistingDescriptorEvidence =
4064
5453
  (result.dynamicModelIds === undefined && !result.fetched && !result.stale) ||
4065
5454
  (result.stale && result.cacheFresh && result.cacheAuthoritative && evidence?.fresh === true) ||
4066
- (strategy === "offline" && result.dynamicModelIds === undefined);
5455
+ (strategy === "offline" && result.dynamicModelIds === undefined && !discoveryContextChanged);
4067
5456
  if (
4068
5457
  isCurrentDiscovery() &&
4069
5458
  !preservesExistingDescriptorEvidence &&
@@ -4071,13 +5460,15 @@ export class ModelRegistry {
4071
5460
  result.dynamicModelIds !== undefined ||
4072
5461
  result.stale ||
4073
5462
  this.#descriptorDiscoveryEvidence.get(options.providerId)?.authGeneration !== authGeneration ||
4074
- this.#descriptorDiscoveryEvidence.get(options.providerId)?.endpoint !== endpoint)
5463
+ this.#descriptorDiscoveryEvidence.get(options.providerId)?.endpoint !== endpoint ||
5464
+ this.#descriptorDiscoveryEvidence.get(options.providerId)?.projectId !== projectId)
4075
5465
  ) {
4076
5466
  this.#descriptorDiscoveryEvidence.set(options.providerId, {
4077
5467
  fresh: result.fetched,
4078
5468
  modelIds: new Set(models.map(model => model.id)),
4079
5469
  authGeneration,
4080
5470
  endpoint: this.#normalizeDiscoveryEvidenceEndpoint(models[0]?.baseUrl ?? endpoint),
5471
+ projectId,
4081
5472
  });
4082
5473
  }
4083
5474
  if (!isCurrentDiscovery()) {
@@ -4103,6 +5494,7 @@ export class ModelRegistry {
4103
5494
  modelIds: new Set(),
4104
5495
  authGeneration,
4105
5496
  endpoint,
5497
+ projectId,
4106
5498
  });
4107
5499
  }
4108
5500
  logger.warn("model discovery failed for provider", {
@@ -4319,36 +5711,7 @@ export class ModelRegistry {
4319
5711
  modelId: string,
4320
5712
  entry?: { id?: unknown; owned_by?: unknown },
4321
5713
  ): Api {
4322
- // 1. Explicit per-prefix routing from models.yml always wins — the user
4323
- // stated the transport for these ids, so never second-guess it.
4324
- let matchedPrefixLength = -1;
4325
- let prefixApi: Api | undefined;
4326
- for (const [prefix, routedApi] of Object.entries(providerConfig.discovery.apiByModelPrefix ?? {})) {
4327
- if (modelId.startsWith(prefix) && prefix.length > matchedPrefixLength) {
4328
- prefixApi = routedApi;
4329
- matchedPrefixLength = prefix.length;
4330
- }
4331
- }
4332
- if (prefixApi !== undefined) return prefixApi;
4333
- // 2. For OpenAI-compatible gateways that can front both OpenAI and
4334
- // Anthropic upstreams (e.g. CLIProxyAPI), auto-detect the wire family
4335
- // per model from the `/v1/models` `owned_by` owner and the model id.
4336
- // A mixed gateway then routes `claude-*` through Anthropic Messages
4337
- // with zero manual config. OpenAI-family models keep the provider's
4338
- // configured OpenAI transport (e.g. `openai-responses` vs
4339
- // `openai-completions`) rather than being forced to chat-completions,
4340
- // because a `/v1/models` list cannot reveal which OpenAI API the
4341
- // gateway expects. Only override to `openai-completions` when the
4342
- // provider default is not itself an OpenAI-family api.
4343
- if (providerConfig.discovery.type === "openai-models-list") {
4344
- const detected = detectDiscoveredApiFamily(entry ?? { id: modelId });
4345
- if (detected === "anthropic-messages") return "anthropic-messages";
4346
- if (detected === "openai-completions") {
4347
- return isOpenAIFamilyApi(providerConfig.api) ? providerConfig.api : "openai-completions";
4348
- }
4349
- }
4350
- // 3. Fall back to the provider-level default api.
4351
- return providerConfig.api;
5714
+ return resolveModelApiFromDiscovery(providerConfig.api, providerConfig.discovery, modelId, entry);
4352
5715
  }
4353
5716
 
4354
5717
  async #discoverModelsDevProvider(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
@@ -4823,6 +6186,7 @@ export class ModelRegistry {
4823
6186
  transport: override.transport ?? baseOverride?.transport,
4824
6187
  requestTransform: mergeRequestTransform(baseOverride?.requestTransform, override.requestTransform),
4825
6188
  cacheRetention: override.cacheRetention ?? baseOverride?.cacheRetention,
6189
+ streamIdleTimeoutMs: override.streamIdleTimeoutMs ?? baseOverride?.streamIdleTimeoutMs,
4826
6190
  isOAuth: override.isOAuth ?? baseOverride?.isOAuth,
4827
6191
  };
4828
6192
  }
@@ -4838,6 +6202,7 @@ export class ModelRegistry {
4838
6202
  | "transport"
4839
6203
  | "requestTransform"
4840
6204
  | "cacheRetention"
6205
+ | "streamIdleTimeoutMs"
4841
6206
  >,
4842
6207
  ): T {
4843
6208
  const overrideHeaders = (
@@ -4872,6 +6237,8 @@ export class ModelRegistry {
4872
6237
  delete overrideHeaders[GENERATED_AUTH_HEADER];
4873
6238
  }
4874
6239
  const headers = mergeAuthHeader(overrideHeaders, canInject, canInject ? override.apiKey : undefined);
6240
+ const streamIdleTimeoutMs =
6241
+ entry.streamIdleTimeoutMs ?? (isRequestTransformApi(entry.api) ? override.streamIdleTimeoutMs : undefined);
4875
6242
  const result = {
4876
6243
  ...entry,
4877
6244
  compat: mergeProviderCompat(entry.compat, override.compat),
@@ -4885,6 +6252,7 @@ export class ModelRegistry {
4885
6252
  override.requestTransform,
4886
6253
  ),
4887
6254
  cacheRetention: entry.cacheRetention ?? override.cacheRetention,
6255
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
4888
6256
  };
4889
6257
  if (!canInject && explicitAuthKey !== undefined) {
4890
6258
  delete (result.headers as Record<string, string> & { [GENERATED_AUTH_HEADER]?: string })[
@@ -5106,9 +6474,7 @@ export class ModelRegistry {
5106
6474
  }
5107
6475
 
5108
6476
  #invalidateAvailableModels(): void {
5109
- this.#availableModelsCache = undefined;
5110
- this.#availableModelsDisabledProviders = undefined;
5111
- this.#availableModelsEnvFingerprint = undefined;
6477
+ this.#availableModelsCache.clear();
5112
6478
  }
5113
6479
 
5114
6480
  #notifyCatalogChanged(): void {
@@ -5143,19 +6509,17 @@ export class ModelRegistry {
5143
6509
  for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
5144
6510
  const modelDefs = providerConfig.models ?? [];
5145
6511
  if (modelDefs.length === 0) continue; // Override-only, no custom models
5146
- if (providerConfig.apiKey || providerConfig.apiKeyEnv) {
5147
- const resolved = providerConfig.apiKey
6512
+ const credentialPinActive = !this.#stagedReloadCandidate && this.authStorage.hasAnyCredentialPin(providerName);
6513
+ const apiKey = credentialPinActive
6514
+ ? undefined
6515
+ : providerConfig.apiKey
5148
6516
  ? resolveApiKeyConfig(providerConfig.apiKey)
5149
6517
  : providerConfig.apiKeyEnv
5150
6518
  ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
5151
6519
  : undefined;
5152
- if (resolved) this.#customProviderApiKeys.set(providerName, resolved);
5153
- if (resolved) {
5154
- this.authStorage.setConfigApiKey(providerName, resolved, {
5155
- envSourced: !providerConfig.apiKey,
5156
- owner: this.#authStorageConfigOwner,
5157
- });
5158
- }
6520
+ if (providerConfig.apiKey || providerConfig.apiKeyEnv) {
6521
+ if (apiKey) this.#customProviderApiKeys.set(providerName, apiKey);
6522
+ if (apiKey) this.#setOwnedConfigApiKey(providerName, apiKey, !providerConfig.apiKey);
5159
6523
  }
5160
6524
  for (const modelDef of modelDefs) {
5161
6525
  const providerCompat = providerConfig.disableStrictTools
@@ -5166,11 +6530,7 @@ export class ModelRegistry {
5166
6530
  providerConfig.baseUrl!,
5167
6531
  providerConfig.api as Api | undefined,
5168
6532
  providerConfig.headers,
5169
- providerConfig.apiKey
5170
- ? resolveApiKeyConfig(providerConfig.apiKey)
5171
- : providerConfig.apiKeyEnv
5172
- ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
5173
- : undefined,
6533
+ apiKey,
5174
6534
  providerConfig.authHeader,
5175
6535
  providerCompat,
5176
6536
  providerConfig.requestTransform,
@@ -5178,6 +6538,7 @@ export class ModelRegistry {
5178
6538
  providerConfig.cacheRetention,
5179
6539
  providerConfig.transport,
5180
6540
  modelDef as CustomModelDefinitionLike,
6541
+ providerConfig.streamIdleTimeoutMs,
5181
6542
  );
5182
6543
  if (!model) continue;
5183
6544
  models.push(model);
@@ -5188,10 +6549,11 @@ export class ModelRegistry {
5188
6549
 
5189
6550
  /**
5190
6551
  * Get all models (embedded + accepted registry + custom).
6552
+ * Supplying a credential session selects only that session's Google OAuth discovery catalog.
5191
6553
  * If models.yml had errors, returns embedded plus accepted registry models.
5192
6554
  */
5193
- getAll(): Model<Api>[] {
5194
- return this.#models;
6555
+ getAll(credentialSessionId?: string): Model<Api>[] {
6556
+ return credentialSessionId === undefined ? this.#models : this.#getCredentialScopedCatalog(credentialSessionId);
5195
6557
  }
5196
6558
 
5197
6559
  /** Provider ids declared in models.yml, including override-only providers. */
@@ -5199,6 +6561,67 @@ export class ModelRegistry {
5199
6561
  return [...this.#configuredProviderIds];
5200
6562
  }
5201
6563
 
6564
+ /** Configured API-key sources in models.yml precedence order, excluding absent environment values. */
6565
+ getProviderConfigApiKeySources(
6566
+ provider: string,
6567
+ ): Array<{ kind: "environment"; name: string; provider: string } | { kind: "models-yml"; provider: string }> {
6568
+ const normalizedProvider = resolveOAuthStorageProvider(provider);
6569
+ const configuredProviders = this.#acceptedConfigValue?.providers;
6570
+ const configuredAliases = Object.entries(configuredProviders ?? {}).filter(
6571
+ ([configuredProvider]) => resolveOAuthStorageProvider(configuredProvider) === normalizedProvider,
6572
+ );
6573
+ const sources: Array<
6574
+ { kind: "environment"; name: string; provider: string } | { kind: "models-yml"; provider: string }
6575
+ > = [];
6576
+ const addSource = (
6577
+ configuredProvider: string,
6578
+ apiKey: string | undefined,
6579
+ apiKeyEnv: string | undefined,
6580
+ ): void => {
6581
+ if (apiKey) {
6582
+ if (Bun.env[apiKey]) sources.push({ kind: "environment", name: apiKey, provider: configuredProvider });
6583
+ else sources.push({ kind: "models-yml", provider: configuredProvider });
6584
+ return;
6585
+ }
6586
+ if (apiKeyEnv && $rotatingCredentialEnv(apiKeyEnv)) {
6587
+ sources.push({ kind: "environment", name: apiKeyEnv, provider: configuredProvider });
6588
+ }
6589
+ };
6590
+ for (const [configuredProvider, configured] of configuredAliases) {
6591
+ addSource(configuredProvider, configured.apiKey, configured.apiKeyEnv);
6592
+ addSource(configuredProvider, configured.openaiCompat?.apiKey, configured.openaiCompat?.apiKeyEnv);
6593
+ }
6594
+ return sources.filter(
6595
+ (source, index) =>
6596
+ source.kind === "models-yml" ||
6597
+ sources.findIndex(
6598
+ candidate =>
6599
+ candidate.kind === "environment" &&
6600
+ candidate.name === source.name &&
6601
+ candidate.provider === source.provider,
6602
+ ) === index,
6603
+ );
6604
+ }
6605
+
6606
+ /** Environment variable name for a provider API key supplied by runtime setup. */
6607
+ getProviderRuntimeApiKeyEnvName(provider: string): string | undefined {
6608
+ const normalizedProvider = resolveOAuthStorageProvider(provider);
6609
+ return (
6610
+ this.#runtimeProviderApiKeyEnvNames.get(provider) ??
6611
+ [...this.#runtimeProviderApiKeyEnvNames].find(
6612
+ ([registeredProvider]) => resolveOAuthStorageProvider(registeredProvider) === normalizedProvider,
6613
+ )?.[1]
6614
+ );
6615
+ }
6616
+
6617
+ /** Whether a runtime extension's provider API key is currently installed in AuthStorage. */
6618
+ hasRuntimeProviderApiKey(provider: string): boolean {
6619
+ const normalizedProvider = resolveOAuthStorageProvider(provider);
6620
+ return [...this.#runtimeProviderCredentialInstalled].some(
6621
+ registeredProvider => resolveOAuthStorageProvider(registeredProvider) === normalizedProvider,
6622
+ );
6623
+ }
6624
+
5202
6625
  /**
5203
6626
  * Whether this registry can serve a provider id from any active source.
5204
6627
  *
@@ -5218,12 +6641,16 @@ export class ModelRegistry {
5218
6641
  );
5219
6642
  }
5220
6643
 
5221
- #isModelAvailable(model: Model<Api>, disabledProviders?: ReadonlySet<string>): boolean {
6644
+ #isModelAvailable(
6645
+ model: Model<Api>,
6646
+ disabledProviders?: ReadonlySet<string>,
6647
+ credentialSessionId?: string,
6648
+ ): boolean {
5222
6649
  const disabled = disabledProviders ?? getDisabledProviderIdsFromSettings(this.#settings);
5223
6650
  return (
5224
6651
  !disabled.has(model.provider) &&
5225
6652
  (this.#keylessProviders.has(model.provider) ||
5226
- this.authStorage.hasAuth(model.provider, undefined, { owner: this.#authStorageConfigOwner }))
6653
+ this.authStorage.hasAuth(model.provider, credentialSessionId, { owner: this.#authStorageConfigOwner }))
5227
6654
  );
5228
6655
  }
5229
6656
  /** Per-query precomputed variant filter inputs; plan fields are authoritative when provided. */
@@ -5658,23 +7085,54 @@ export class ModelRegistry {
5658
7085
  * is authoritative for visibility: live discovery only enriches entries and
5659
7086
  * adds unknown ids, and never removes a bundled model. A provider that cannot
5660
7087
  * serve a model reports its own error when the model is used.
7088
+ * Without a credential session, project-scoped Google OAuth discoveries are omitted.
5661
7089
  */
5662
- getAvailable(): Model<Api>[] {
7090
+ getAvailable(credentialSessionId?: string): Model<Api>[] {
5663
7091
  this.#synchronizeEnvironmentCredentials();
5664
7092
  const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
5665
7093
  const disabledProviderKey = [...disabledProviders].sort().join("\u0000");
5666
7094
  const envFingerprint = envAvailabilityFingerprint(this.#configuredApiKeyEnvNames);
5667
- if (
5668
- this.#availableModelsCache &&
5669
- this.#availableModelsDisabledProviders === disabledProviderKey &&
5670
- this.#availableModelsEnvFingerprint === envFingerprint
5671
- ) {
5672
- return this.#availableModelsCache;
5673
- }
5674
- this.#availableModelsCache = this.#models.filter(model => this.#isModelAvailable(model, disabledProviders));
5675
- this.#availableModelsDisabledProviders = disabledProviderKey;
5676
- this.#availableModelsEnvFingerprint = envFingerprint;
5677
- return this.#availableModelsCache;
7095
+ const cacheKey = credentialSessionId ?? "";
7096
+ const cached = this.#availableModelsCache.get(cacheKey);
7097
+ if (cached?.disabledProviders === disabledProviderKey && cached.envFingerprint === envFingerprint) {
7098
+ return cached.models;
7099
+ }
7100
+ const catalog = this.#getCredentialScopedCatalog(credentialSessionId);
7101
+ const models = catalog.filter(model => this.#isModelAvailable(model, disabledProviders, credentialSessionId));
7102
+ this.#availableModelsCache.set(cacheKey, {
7103
+ models,
7104
+ disabledProviders: disabledProviderKey,
7105
+ envFingerprint,
7106
+ });
7107
+ return models;
7108
+ }
7109
+
7110
+ #getCredentialScopedCatalog(credentialSessionId?: string): Model<Api>[] {
7111
+ const sessionScopedProviders = new Set(
7112
+ (["google-antigravity", "google-gemini-cli"] as const).filter(
7113
+ provider =>
7114
+ this.authStorage.getEffectiveCredentialType(provider, credentialSessionId, {
7115
+ owner: this.#authStorageConfigOwner,
7116
+ }) === "oauth",
7117
+ ),
7118
+ );
7119
+ const commonModels = this.#models.filter(model => {
7120
+ if (model.provider !== "google-antigravity" && model.provider !== "google-gemini-cli") {
7121
+ return true;
7122
+ }
7123
+ if (!sessionScopedProviders.has(model.provider) || this.#discoveryManager.providerIds().has(model.provider))
7124
+ return true;
7125
+ return this.#providerActivity.get(model.provider)?.staticModelIds.has(model.id) === true;
7126
+ });
7127
+ // Commands such as `--list-models` read without a credential session. The
7128
+ // effective (default) credential still has a descriptor identity, so its
7129
+ // discovery overlay must be visible here instead of being dropped.
7130
+ const scopedDiscovered = this.#getGoogleSessionDiscoveryModels(credentialSessionId);
7131
+ const catalog =
7132
+ scopedDiscovered.length > 0
7133
+ ? this.#resolveDiscoveredModelCatalog(commonModels, scopedDiscovered)
7134
+ : commonModels;
7135
+ return catalog;
5678
7136
  }
5679
7137
 
5680
7138
  #synchronizeEnvironmentCredentials(): void {
@@ -5860,8 +7318,9 @@ export class ModelRegistry {
5860
7318
  /**
5861
7319
  * Find a model by provider and ID.
5862
7320
  */
5863
- find(provider: string, modelId: string): Model<Api> | undefined {
5864
- return resolveProviderModelReference(provider, modelId, this.#models);
7321
+ find(provider: string, modelId: string, credentialSessionId?: string): Model<Api> | undefined {
7322
+ const models = credentialSessionId === undefined ? this.#models : this.getAll(credentialSessionId);
7323
+ return resolveProviderModelReference(provider, modelId, models);
5865
7324
  }
5866
7325
 
5867
7326
  /**
@@ -5952,6 +7411,35 @@ export class ModelRegistry {
5952
7411
  return apiKey;
5953
7412
  }
5954
7413
 
7414
+ /** Get a model API key together with the stored row that resolved it, when any. */
7415
+ async getApiKeyWithCredentialRowId(
7416
+ model: Model<Api>,
7417
+ sessionId?: string,
7418
+ options: {
7419
+ credentialSelector?: AuthCredentialSelector;
7420
+ preferredCredentialSelector?: AuthCredentialSelector;
7421
+ signal?: AbortSignal;
7422
+ } = {},
7423
+ ): Promise<{ apiKey: string | undefined; credentialRowId: number | undefined }> {
7424
+ this.#refreshRotatingConfigApiKey(model.provider);
7425
+ let credentialRowId: number | undefined;
7426
+ const apiKey = await this.#getApiKeyOrNoAuth(model.provider, async () => {
7427
+ const resolved = await this.authStorage.getApiKeyWithCredentialRowId(model.provider, sessionId, {
7428
+ baseUrl: model.baseUrl,
7429
+ modelId: model.id,
7430
+ credentialSelector: options.credentialSelector,
7431
+ preferredCredentialSelector: options.preferredCredentialSelector,
7432
+ owner: this.#authStorageConfigOwner,
7433
+ signal: options.signal,
7434
+ });
7435
+ credentialRowId = isVllmNoAuthToken(model.provider, resolved.apiKey) ? undefined : resolved.credentialRowId;
7436
+ return resolved.apiKey;
7437
+ });
7438
+ if (apiKey === kNoAuth) credentialRowId = undefined;
7439
+ this.#applyEffectiveAuthHeader(model, apiKey);
7440
+ return { apiKey, credentialRowId };
7441
+ }
7442
+
5955
7443
  /**
5956
7444
  * Get API key for a provider (e.g., "openai").
5957
7445
  */
@@ -5986,6 +7474,19 @@ export class ModelRegistry {
5986
7474
  return apiKey;
5987
7475
  }
5988
7476
 
7477
+ /** Check credential availability without resolving keys or refreshing OAuth tokens. */
7478
+ hasUsableAuthForProvider(provider: string, sessionId?: string): boolean {
7479
+ return this.authStorage.hasUsableAuth(provider, {
7480
+ owner: this.#authStorageConfigOwner,
7481
+ sessionId,
7482
+ });
7483
+ }
7484
+
7485
+ /** @internal Whether this registry is an unpublished staged reload candidate. */
7486
+ isStagedReloadCandidate(): boolean {
7487
+ return this.#stagedReloadCandidate;
7488
+ }
7489
+
5989
7490
  #refreshRotatingConfigApiKey(provider: string, forceStatic = false): void {
5990
7491
  const runtimeOwned = !forceStatic && this.#runtimeProviderApiKeys.has(provider);
5991
7492
  const envName = runtimeOwned
@@ -5993,6 +7494,7 @@ export class ModelRegistry {
5993
7494
  : this.#customProviderApiKeyEnvNames.get(provider);
5994
7495
  if (!envName) return;
5995
7496
  const resolved = $rotatingCredentialEnv(envName);
7497
+ if (resolved !== undefined && this.authStorage.hasAnyCredentialPin(provider)) return;
5996
7498
  const previous = runtimeOwned
5997
7499
  ? this.#runtimeProviderResolvedApiKeys.get(provider)
5998
7500
  : this.#customProviderApiKeys.get(provider);
@@ -6006,7 +7508,7 @@ export class ModelRegistry {
6006
7508
  return;
6007
7509
  }
6008
7510
  this.#customProviderApiKeys.delete(provider);
6009
- this.authStorage.removeConfigApiKey(provider, this.#authStorageConfigOwner);
7511
+ this.#removeOwnedConfigApiKey(provider);
6010
7512
  } else {
6011
7513
  this.#customProviderApiKeys.set(provider, resolved);
6012
7514
  if (runtimeOwned) {
@@ -6014,12 +7516,9 @@ export class ModelRegistry {
6014
7516
  this.#runtimeProviderCredentialInstalled.add(provider);
6015
7517
  }
6016
7518
  if (runtimeOwned) {
6017
- this.authStorage.setConfigApiKey(provider, resolved, { owner: this.#authStorageConfigOwner });
7519
+ this.#setOwnedConfigApiKey(provider, resolved, false, "runtime");
6018
7520
  } else {
6019
- this.authStorage.setConfigApiKey(provider, resolved, {
6020
- envSourced: true,
6021
- owner: this.#authStorageConfigOwner,
6022
- });
7521
+ this.#setOwnedConfigApiKey(provider, resolved, true);
6023
7522
  }
6024
7523
  }
6025
7524
  const authHeader =
@@ -6166,7 +7665,7 @@ export class ModelRegistry {
6166
7665
  this.#runtimeProviderOverrides.delete(providerName);
6167
7666
  this.#runtimeProviderAuthHeaders.delete(providerName);
6168
7667
  this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName);
6169
- this.authStorage.removeConfigApiKey(providerName, this.#authStorageConfigOwner);
7668
+ this.#removeOwnedConfigApiKey(providerName);
6170
7669
  this.#clearDescriptorDiscoveryEvidence(providerName);
6171
7670
  }
6172
7671
 
@@ -6174,6 +7673,7 @@ export class ModelRegistry {
6174
7673
  * Remove custom API/OAuth registrations for a specific extension source.
6175
7674
  */
6176
7675
  clearSourceRegistrations(sourceId: string): void {
7676
+ this.#catalogRefreshGeneration++;
6177
7677
  unregisterCustomApis(sourceId);
6178
7678
  unregisterOAuthProviders(sourceId);
6179
7679
  const sourceProviders = this.#runtimeProvidersBySource.get(sourceId);
@@ -6217,6 +7717,7 @@ export class ModelRegistry {
6217
7717
  * If provider has oauth: registers OAuth provider for /login support.
6218
7718
  */
6219
7719
  registerProvider(providerName: string, config: ProviderConfigInput, sourceId?: string): void {
7720
+ this.#catalogRefreshGeneration++;
6220
7721
  if (config.streamSimple && !config.api) {
6221
7722
  throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
6222
7723
  }
@@ -6304,7 +7805,7 @@ export class ModelRegistry {
6304
7805
  this.#runtimeProviderResolvedApiKeys.set(providerName, resolved);
6305
7806
  this.#runtimeProviderCredentialInstalled.add(providerName);
6306
7807
  if (config.authHeader !== undefined) this.#runtimeProviderAuthHeaders.set(providerName, config.authHeader);
6307
- this.authStorage.setConfigApiKey(providerName, resolved, { owner: this.#authStorageConfigOwner });
7808
+ this.#setOwnedConfigApiKey(providerName, resolved, false, "runtime");
6308
7809
  }
6309
7810
  if (config.oauth && !config.apiKey && this.#runtimeProviderApiKeys.has(providerName)) {
6310
7811
  const previousApiKey = this.#runtimeProviderResolvedApiKeys.get(providerName);
@@ -6314,7 +7815,7 @@ export class ModelRegistry {
6314
7815
  this.#runtimeProviderApiKeyEnvNames.delete(providerName);
6315
7816
  this.#runtimeProviderAuthHeaders.delete(providerName);
6316
7817
  this.#customProviderApiKeys.delete(providerName);
6317
- this.authStorage.removeConfigApiKey(providerName, this.#authStorageConfigOwner);
7818
+ this.#removeOwnedConfigApiKey(providerName);
6318
7819
  this.#runtimeModelOverlays = this.#runtimeModelOverlays.map(overlay => {
6319
7820
  if (overlay.provider !== providerName) return overlay;
6320
7821
  const headers = { ...(overlay.headers ?? {}) } as Record<string, string> & {