@gajae-code/coding-agent 0.18.11 → 0.18.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/CHANGELOG.md +293 -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/cli/accounts-cli.d.ts +2 -2
  7. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  8. package/dist/types/cli/install-activation.d.ts +10 -4
  9. package/dist/types/cli/public-command-errors.d.ts +1 -0
  10. package/dist/types/cli/update-cli.d.ts +14 -1
  11. package/dist/types/cli-ordinary.d.ts +2 -2
  12. package/dist/types/commands/accounts.d.ts +2 -1
  13. package/dist/types/commands/migrate.d.ts +1 -1
  14. package/dist/types/commands/sdk.d.ts +5 -0
  15. package/dist/types/commit/changelog/index.d.ts +1 -0
  16. package/dist/types/config/autorouting-tier-map.d.ts +30 -0
  17. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  18. package/dist/types/config/config-hot-reload.d.ts +39 -0
  19. package/dist/types/config/file-lock.d.ts +8 -1
  20. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  21. package/dist/types/config/model-profile-activation.d.ts +62 -6
  22. package/dist/types/config/model-registry.d.ts +56 -5
  23. package/dist/types/config/model-resolver.d.ts +2 -0
  24. package/dist/types/config/models-config-schema.d.ts +10 -0
  25. package/dist/types/config/settings-schema.d.ts +46 -3
  26. package/dist/types/config/settings.d.ts +33 -1
  27. package/dist/types/dap/config.d.ts +8 -4
  28. package/dist/types/debug/system-info.d.ts +6 -1
  29. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  30. package/dist/types/edit/modes/patch.d.ts +5 -0
  31. package/dist/types/edit/modes/replace.d.ts +3 -0
  32. package/dist/types/exec/bash-executor.d.ts +12 -0
  33. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  35. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  36. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  37. package/dist/types/migrate/adapters/index.d.ts +10 -1
  38. package/dist/types/migrate/executor.d.ts +3 -1
  39. package/dist/types/migrate/types.d.ts +15 -3
  40. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  42. package/dist/types/modes/theme/theme.d.ts +1 -0
  43. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  44. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  45. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  46. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  47. package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
  48. package/dist/types/sdk/broker/broker.d.ts +7 -1
  49. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  50. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  51. package/dist/types/sdk/broker/ensure.d.ts +136 -7
  52. package/dist/types/sdk/broker/hop.d.ts +36 -0
  53. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  54. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +4 -1
  56. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  57. package/dist/types/sdk/bus/index.d.ts +23 -0
  58. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  59. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  60. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  61. package/dist/types/sdk/host/session-runtime.d.ts +32 -0
  62. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  63. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  64. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  65. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  66. package/dist/types/sdk/session-list.d.ts +1 -1
  67. package/dist/types/session/agent-session.d.ts +58 -2
  68. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  69. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  70. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  71. package/dist/types/session/messages.d.ts +5 -1
  72. package/dist/types/session/session-manager.d.ts +13 -3
  73. package/dist/types/session/startup-auth-config.d.ts +24 -2
  74. package/dist/types/session/streaming-output.d.ts +11 -0
  75. package/dist/types/session/terminal-abort.d.ts +3 -1
  76. package/dist/types/session-import/service.d.ts +1 -1
  77. package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
  78. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  79. package/dist/types/task/executor.d.ts +19 -2
  80. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  81. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  82. package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
  83. package/dist/types/tools/descriptors.d.ts +2 -0
  84. package/dist/types/tools/fetch.d.ts +1 -1
  85. package/dist/types/tools/index.d.ts +2 -0
  86. package/dist/types/tools/python.d.ts +1 -1
  87. package/dist/types/tools/report-issue.d.ts +41 -0
  88. package/dist/types/tools/subagent.d.ts +23 -0
  89. package/dist/types/web/scrapers/types.d.ts +8 -1
  90. package/dist/types/web/search/providers/insane.d.ts +7 -1
  91. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  92. package/package.json +8 -8
  93. package/scripts/build-binary.ts +2 -1
  94. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  95. package/scripts/generate-docs-index.ts +8 -1
  96. package/scripts/generate-sdk-operation-inventory.ts +22 -4
  97. package/src/async/job-manager.ts +356 -56
  98. package/src/async/result-formatting.ts +40 -0
  99. package/src/autoresearch/git.ts +9 -2
  100. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  101. package/src/capability/fs.ts +27 -19
  102. package/src/capability/index.ts +2 -2
  103. package/src/cli/accounts-cli.ts +176 -8
  104. package/src/cli/auth-broker-cli.ts +21 -11
  105. package/src/cli/doctor/install-repairs.ts +35 -5
  106. package/src/cli/fast-help.ts +1 -1
  107. package/src/cli/install-activation.ts +47 -4
  108. package/src/cli/migrate-cli.ts +7 -5
  109. package/src/cli/public-command-errors.ts +4 -0
  110. package/src/cli/root-flags.ts +2 -2
  111. package/src/cli/stats-cli.ts +9 -5
  112. package/src/cli/update-cli.ts +224 -7
  113. package/src/cli-ordinary.ts +7 -2
  114. package/src/commands/accounts.ts +10 -5
  115. package/src/commands/migrate.ts +3 -3
  116. package/src/commands/sdk.ts +199 -42
  117. package/src/commands/ultragoal.ts +16 -8
  118. package/src/commit/changelog/index.ts +39 -1
  119. package/src/commit/message.ts +10 -3
  120. package/src/config/autorouting-tier-map.ts +30 -0
  121. package/src/config/config-hot-reload-trust.ts +148 -0
  122. package/src/config/config-hot-reload.ts +734 -0
  123. package/src/config/file-lock.ts +216 -56
  124. package/src/config/model-bindings-applier.ts +85 -47
  125. package/src/config/model-discovery-manager.ts +10 -0
  126. package/src/config/model-profile-activation.ts +716 -113
  127. package/src/config/model-registry.ts +1334 -158
  128. package/src/config/model-resolver.ts +3 -1
  129. package/src/config/models-config-schema.ts +6 -0
  130. package/src/config/settings-schema.ts +60 -3
  131. package/src/config/settings.ts +458 -24
  132. package/src/coordinator-mcp/policy.ts +26 -1
  133. package/src/crash/sanitize.ts +4 -0
  134. package/src/customization/import.ts +3 -1
  135. package/src/dap/config.ts +31 -11
  136. package/src/dap/defaults.json +1 -1
  137. package/src/debug/crash-diagnostics.ts +2 -1
  138. package/src/debug/report-bundle.ts +19 -0
  139. package/src/debug/system-info.ts +8 -3
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  141. package/src/discovery/agents-md.ts +1 -1
  142. package/src/discovery/helpers.ts +1 -2
  143. package/src/discovery/mcp-compat.ts +120 -17
  144. package/src/edit/apply-patch/index.ts +12 -1
  145. package/src/edit/index.ts +13 -17
  146. package/src/edit/modes/patch.ts +187 -8
  147. package/src/edit/modes/replace.ts +136 -0
  148. package/src/eval/js/context-manager.ts +29 -3
  149. package/src/eval/js/worker-entry.ts +51 -18
  150. package/src/eval/py/executor.ts +14 -4
  151. package/src/eval/py/runner.py +144 -5
  152. package/src/exec/bash-executor.ts +81 -4
  153. package/src/exec/bash-shell-guardian.ts +4 -1
  154. package/src/exec/bash-shell-supervisor.ts +8 -4
  155. package/src/exec/isolated-shell.ts +5 -1
  156. package/src/export/html/index.ts +46 -6
  157. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  158. package/src/extensibility/extensions/loader.ts +8 -1
  159. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  160. package/src/gjc-runtime/gc-runtime.ts +33 -6
  161. package/src/gjc-runtime/launch-tmux.ts +7 -2
  162. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  163. package/src/gjc-runtime/launch-worktree.ts +101 -14
  164. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  165. package/src/gjc-runtime/session-resolution.ts +38 -6
  166. package/src/gjc-runtime/session-state-lock.ts +59 -14
  167. package/src/gjc-runtime/state-runtime.ts +23 -19
  168. package/src/hooks/native-skill-hook.ts +14 -11
  169. package/src/internal-urls/docs-index.generated.ts +14 -13
  170. package/src/internal-urls/local-protocol.ts +142 -8
  171. package/src/lsp/index.ts +29 -0
  172. package/src/lsp/workspace-path.ts +23 -0
  173. package/src/migrate/action-planner.ts +57 -0
  174. package/src/migrate/adapters/claude-code.ts +23 -4
  175. package/src/migrate/adapters/codex.ts +22 -4
  176. package/src/migrate/adapters/index.ts +22 -4
  177. package/src/migrate/adapters/opencode.ts +6 -1
  178. package/src/migrate/executor.ts +84 -7
  179. package/src/migrate/report.ts +19 -2
  180. package/src/migrate/types.ts +14 -4
  181. package/src/modes/DESIGN.md +51 -0
  182. package/src/modes/acp/permission-mode.ts +18 -1
  183. package/src/modes/components/assistant-message.ts +55 -1
  184. package/src/modes/components/settings-selector.ts +103 -37
  185. package/src/modes/controllers/command-controller.ts +38 -2
  186. package/src/modes/controllers/plan-mode-controller.ts +17 -7
  187. package/src/modes/controllers/selector-controller.ts +62 -6
  188. package/src/modes/interactive-mode.ts +68 -0
  189. package/src/modes/theme/theme.ts +15 -0
  190. package/src/prompts/tools/debug.md +1 -1
  191. package/src/prompts/tools/read.md +1 -1
  192. package/src/prompts/tools/report-issue.md +13 -0
  193. package/src/prompts/tools/task.md +1 -1
  194. package/src/runtime-credential-selector.ts +2 -2
  195. package/src/runtime-mcp/content-limits.ts +7 -0
  196. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  197. package/src/runtime-mcp/manager.ts +32 -0
  198. package/src/runtime-mcp/oauth-flow.ts +32 -6
  199. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  200. package/src/runtime-mcp/render.ts +16 -1
  201. package/src/runtime-mcp/tool-bridge.ts +126 -17
  202. package/src/sdk/broker/broker-exit.ts +8 -5
  203. package/src/sdk/broker/broker.ts +342 -82
  204. package/src/sdk/broker/daemon-entry.ts +338 -116
  205. package/src/sdk/broker/doctor-restart.ts +2 -1
  206. package/src/sdk/broker/ensure.ts +1143 -265
  207. package/src/sdk/broker/hop.ts +274 -0
  208. package/src/sdk/broker/lifecycle-ledger.ts +35 -11
  209. package/src/sdk/broker/lock-host-identity.ts +29 -0
  210. package/src/sdk/broker/session-index.ts +62 -12
  211. package/src/sdk/bus/chat-command-policy.ts +16 -3
  212. package/src/sdk/bus/index.ts +52 -21
  213. package/src/sdk/bus/reconciliation-store.ts +201 -8
  214. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  215. package/src/sdk/cli/session-cli.ts +21 -6
  216. package/src/sdk/host/session-runtime.test.ts +118 -23
  217. package/src/sdk/host/session-runtime.ts +352 -47
  218. package/src/sdk/lifecycle/service.ts +1 -0
  219. package/src/sdk/prompt-deadline-flush.ts +78 -5
  220. package/src/sdk/prompt-deadline-manager.ts +68 -14
  221. package/src/sdk/protocol/adapter-validation.ts +53 -11
  222. package/src/sdk/protocol/operation-inventory.generated.json +101 -2
  223. package/src/sdk/reconciliation-extensions.ts +1 -0
  224. package/src/sdk/session-list.ts +1 -0
  225. package/src/sdk/session.ts +114 -61
  226. package/src/session/agent-session.ts +1043 -81
  227. package/src/session/internal/managed-session-scope.ts +126 -39
  228. package/src/session/internal/managed-session-storage.ts +14 -11
  229. package/src/session/internal/native-publish-outcome.ts +5 -1
  230. package/src/session/messages.ts +47 -6
  231. package/src/session/session-manager.ts +236 -14
  232. package/src/session/startup-auth-config.ts +116 -8
  233. package/src/session/streaming-output.ts +262 -4
  234. package/src/session/terminal-abort.ts +14 -2
  235. package/src/session-import/service.ts +1 -0
  236. package/src/skill-state/workflow-mutation-guard.ts +491 -32
  237. package/src/slash-commands/builtin-registry.ts +15 -3
  238. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  239. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  240. package/src/task/executor.ts +103 -12
  241. package/src/task/index.ts +389 -175
  242. package/src/tools/ast-edit-write-path.ts +64 -0
  243. package/src/tools/ast-edit.ts +18 -1
  244. package/src/tools/atomic-file-write.ts +46 -15
  245. package/src/tools/auto-generated-guard.ts +3 -0
  246. package/src/tools/bash-allowed-prefixes.ts +153 -5
  247. package/src/tools/bash-interactive.ts +14 -2
  248. package/src/tools/bash.ts +14 -0
  249. package/src/tools/debug.ts +7 -6
  250. package/src/tools/descriptors.test.ts +44 -0
  251. package/src/tools/descriptors.ts +34 -2
  252. package/src/tools/fetch.ts +3 -2
  253. package/src/tools/index.ts +2 -0
  254. package/src/tools/output-meta.ts +23 -2
  255. package/src/tools/python.ts +1 -1
  256. package/src/tools/read.ts +17 -35
  257. package/src/tools/report-issue.ts +184 -0
  258. package/src/tools/subagent-render.ts +114 -12
  259. package/src/tools/subagent.ts +108 -1
  260. package/src/tools/todo-write.ts +41 -3
  261. package/src/tools/tool-catalog.generated.ts +31 -3
  262. package/src/tools/write.ts +35 -3
  263. package/src/utils/commit-message-generator.ts +9 -2
  264. package/src/utils/file-mentions.ts +29 -1
  265. package/src/utils/title-generator.ts +7 -2
  266. package/src/web/scrapers/index.ts +3 -1
  267. package/src/web/scrapers/types.ts +8 -0
  268. package/src/web/scrapers/youtube.ts +3 -2
  269. package/src/web/search/providers/insane.ts +91 -28
  270. 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,
@@ -52,6 +54,7 @@ import {
52
54
  } from "@gajae-code/ai/utils/discovery/openai-compatible";
53
55
  // Internal-only: registerFinalizedModelClone is not part of the public @gajae-code/ai surface
54
56
  import { registerFinalizedModelClone } from "@gajae-code/ai/utils/trusted-model-clone";
57
+ import { JSONC, YAML } from "bun";
55
58
 
56
59
  // Sentinels for local-only OAuth tokens — declared inline to avoid loading provider
57
60
  // modules at startup. Must match the provider OAuth modules.
@@ -77,13 +80,15 @@ import {
77
80
  projectActiveProviderDescriptors,
78
81
  } from "../sdk/providers";
79
82
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
83
+ import { writeExplicitOwnerOnlyTextSync } from "../session/session-storage";
80
84
  import type { ActiveSearchModelContext, WebSearchMode } from "../web/search/types";
81
85
  import { type BillingPath, deriveBillingPath } from "./billing-path";
82
- import { ConfigError, ConfigFile } from "./config-file";
86
+ import { ConfigError, ConfigFile, type LoadResult } from "./config-file";
83
87
  import { isAuthenticated, kNoAuth } from "./model-auth";
84
88
  import { type ConfiguredModelBindings, ModelBindingsApplier } from "./model-bindings-applier";
85
89
  import { ModelDiscoveryManager, type ProviderDiscoveryState } from "./model-discovery-manager";
86
90
  import {
91
+ type AcceptedModelPresetRegistry,
87
92
  loadAcceptedModelPresetProfiles,
88
93
  type ModelPresetRegistryDependencies,
89
94
  type ModelPresetRegistryRefreshResult,
@@ -449,6 +454,20 @@ function assertRequestTransformSupportedForKnownProvider(providerName: string, s
449
454
  }
450
455
  }
451
456
 
457
+ function assertStreamIdleTimeoutSupportedForKnownProvider(providerName: string, source: string): void {
458
+ const apis = getKnownProviderApis(providerName);
459
+ if (apis.size === 0) {
460
+ throw new Error(
461
+ `Provider ${providerName}: ${source} requires an OpenAI-compatible "api" when the provider is not built in.`,
462
+ );
463
+ }
464
+ if (![...apis].some(isRequestTransformApi)) {
465
+ throw new Error(
466
+ `Provider ${providerName}: ${source} is only supported with openai-completions or openai-responses APIs.`,
467
+ );
468
+ }
469
+ }
470
+
452
471
  function assertRequestTransformSupportedForModelApi(
453
472
  providerName: string,
454
473
  modelId: string,
@@ -463,8 +482,54 @@ function assertRequestTransformSupportedForModelApi(
463
482
  }
464
483
 
465
484
  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;
485
+ return getBundledModels(providerName as Parameters<typeof getBundledModels>[0]).find(
486
+ model => model.id.toLowerCase() === modelId.toLowerCase(),
487
+ )?.api as Api | undefined;
488
+ }
489
+
490
+ function resolveModelApiOverrideFromDiscovery(
491
+ providerApi: Api,
492
+ discovery: ProviderDiscovery,
493
+ modelId: string,
494
+ entry?: { id?: unknown; owned_by?: unknown },
495
+ allowModelIdDetection = true,
496
+ ): Api | undefined {
497
+ // Explicit per-prefix routing always wins over model-id detection.
498
+ const normalizedModelId = modelId.toLowerCase();
499
+ let matchedPrefixLength = -1;
500
+ let prefixApi: Api | undefined;
501
+ for (const [prefix, routedApi] of Object.entries(discovery.apiByModelPrefix ?? {})) {
502
+ const normalizedPrefix = prefix.toLowerCase();
503
+ if (normalizedModelId.startsWith(normalizedPrefix) && normalizedPrefix.length > matchedPrefixLength) {
504
+ prefixApi = routedApi;
505
+ matchedPrefixLength = normalizedPrefix.length;
506
+ }
507
+ }
508
+ if (prefixApi !== undefined) return prefixApi;
509
+
510
+ // An OpenAI-shaped models list can advertise Anthropic models that must use
511
+ // the Anthropic transport even when the provider's default is OpenAI-family.
512
+ if (discovery.type === "openai-models-list") {
513
+ let candidateEntry = entry;
514
+ if (candidateEntry === undefined && allowModelIdDetection) {
515
+ candidateEntry = { id: modelId };
516
+ }
517
+ const detected = candidateEntry === undefined ? undefined : detectDiscoveredApiFamily(candidateEntry);
518
+ if (detected === "anthropic-messages") return "anthropic-messages";
519
+ if (detected === "openai-completions") {
520
+ return isOpenAIFamilyApi(providerApi) ? providerApi : "openai-completions";
521
+ }
522
+ }
523
+ return undefined;
524
+ }
525
+
526
+ function resolveModelApiFromDiscovery(
527
+ providerApi: Api,
528
+ discovery: ProviderDiscovery,
529
+ modelId: string,
530
+ entry?: { id?: unknown; owned_by?: unknown },
531
+ ): Api {
532
+ return resolveModelApiOverrideFromDiscovery(providerApi, discovery, modelId, entry) ?? providerApi;
468
533
  }
469
534
 
470
535
  function isCanonicalOpenAIAffinityBaseUrl(baseUrl: string | undefined): boolean {
@@ -513,6 +578,7 @@ interface ProviderValidationModel {
513
578
  maxTokens?: number;
514
579
  compat?: Model<Api>["compat"];
515
580
  requestTransform?: ModelRequestTransform;
581
+ streamIdleTimeoutMs?: number;
516
582
  }
517
583
 
518
584
  interface ProviderValidationConfig {
@@ -521,6 +587,7 @@ interface ProviderValidationConfig {
521
587
  apiKey?: string;
522
588
  apiKeyEnv?: string;
523
589
  api?: Api;
590
+ transport?: Model<Api>["transport"];
524
591
  auth?: ProviderAuthMode;
525
592
  oauthConfigured?: boolean;
526
593
  discovery?: ProviderDiscovery;
@@ -528,6 +595,7 @@ interface ProviderValidationConfig {
528
595
  requestTransform?: ModelRequestTransform;
529
596
  disableStrictTools?: boolean;
530
597
  cacheRetention?: CacheRetention;
598
+ streamIdleTimeoutMs?: number;
531
599
  openaiCompat?: { baseUrl: string; apiKey?: string; apiKeyEnv?: string };
532
600
  modelOverrides?: Record<string, unknown>;
533
601
  models: ProviderValidationModel[];
@@ -543,7 +611,20 @@ function validateProviderConfiguration(
543
611
  mode: ProviderValidationMode,
544
612
  ): void {
545
613
  const hasProviderApi = !!config.api;
614
+ const configuredApi = config.api ?? (config.openaiCompat ? "openai-completions" : undefined);
546
615
  const models = config.models;
616
+ if (
617
+ config.transport === "pi-native" &&
618
+ (config.streamIdleTimeoutMs !== undefined ||
619
+ models.some(model => model.streamIdleTimeoutMs !== undefined) ||
620
+ Object.values(config.modelOverrides ?? {}).some(
621
+ override => (override as ModelOverride).streamIdleTimeoutMs !== undefined,
622
+ ))
623
+ ) {
624
+ throw new Error(
625
+ `Provider ${providerName}: "streamIdleTimeoutMs" is not supported with "transport: pi-native"; configure it on the auth-gateway's upstream model.`,
626
+ );
627
+ }
547
628
 
548
629
  if (models.length === 0) {
549
630
  if (mode === "models-config") {
@@ -557,12 +638,13 @@ function validateProviderConfiguration(
557
638
  !config.disableStrictTools &&
558
639
  !config.requestTransform &&
559
640
  !config.cacheRetention &&
641
+ config.streamIdleTimeoutMs === undefined &&
560
642
  !config.openaiCompat &&
561
643
  !hasModelOverrides &&
562
644
  !config.discovery
563
645
  ) {
564
646
  throw new Error(
565
- `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "requestTransform", "cacheRetention", "disableStrictTools", "modelOverrides", "discovery", "openaiCompat", or "models"`,
647
+ `Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "requestTransform", "cacheRetention", "streamIdleTimeoutMs", "disableStrictTools", "modelOverrides", "discovery", "openaiCompat", or "models"`,
566
648
  );
567
649
  }
568
650
  }
@@ -623,10 +705,55 @@ function validateProviderConfiguration(
623
705
  }
624
706
  for (const [modelId, rawOverride] of Object.entries(config.modelOverrides ?? {})) {
625
707
  const override = rawOverride as ModelOverride;
708
+ const configuredModel = models.find(model => model.id.toLowerCase() === modelId.toLowerCase());
709
+ const discoveryProviderApi =
710
+ (config.api as Api | undefined) ??
711
+ dominantOpenAIFamilyModelApi(models) ??
712
+ (config.openaiCompat ? "openai-completions" : undefined);
713
+ const implicitDiscoveryBaseUrl = config.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName);
714
+ const implicitDiscovery: ProviderDiscovery | undefined =
715
+ !config.discovery &&
716
+ (config.openaiCompat !== undefined ||
717
+ (providerName !== "vllm" &&
718
+ providerName !== "sglang" &&
719
+ discoveryProviderApi !== undefined &&
720
+ isOpenAIFamilyApi(discoveryProviderApi) &&
721
+ implicitDiscoveryBaseUrl !== undefined))
722
+ ? { type: "openai-models-list" }
723
+ : undefined;
724
+ const effectiveDiscovery = config.discovery ?? implicitDiscovery;
725
+ const discoveryApi =
726
+ configuredModel || !effectiveDiscovery || !discoveryProviderApi
727
+ ? undefined
728
+ : resolveModelApiOverrideFromDiscovery(discoveryProviderApi, effectiveDiscovery, modelId);
729
+ const discoveryTimeoutApi =
730
+ configuredModel || !effectiveDiscovery || !discoveryProviderApi
731
+ ? undefined
732
+ : resolveModelApiOverrideFromDiscovery(discoveryProviderApi, effectiveDiscovery, modelId, undefined, false);
733
+ const bundledModelApi = getKnownProviderModelApi(providerName, modelId);
734
+ // An OpenAI models-list row may override both its ID-based family and the
735
+ // provider default via `owned_by`. Defer ambiguous timeout validation until
736
+ // discovery supplies that metadata; applying the override validates the
737
+ // resolved model API before it can take effect.
738
+ const deferTimeoutValidation =
739
+ !configuredModel &&
740
+ effectiveDiscovery?.type === "openai-models-list" &&
741
+ discoveryTimeoutApi === undefined &&
742
+ bundledModelApi === undefined;
626
743
  const effectiveApi =
627
- models.find(model => model.id === modelId)?.api ??
628
- config.api ??
629
- getKnownProviderModelApi(providerName, modelId);
744
+ configuredModel?.api ??
745
+ (configuredModel ? config.api : undefined) ??
746
+ discoveryApi ??
747
+ bundledModelApi ??
748
+ config.api;
749
+ const effectiveTimeoutApi =
750
+ configuredModel?.api ??
751
+ (configuredModel ? config.api : undefined) ??
752
+ discoveryTimeoutApi ??
753
+ bundledModelApi ??
754
+ (deferTimeoutValidation
755
+ ? undefined
756
+ : (config.api ?? (config.openaiCompat ? "openai-completions" : undefined)));
630
757
  if (override.compat?.supportsResponsesSessionAffinity !== undefined) {
631
758
  assertResponsesSessionAffinitySupported(
632
759
  providerName,
@@ -635,6 +762,21 @@ function validateProviderConfiguration(
635
762
  `modelOverrides ${modelId} "compat.supportsResponsesSessionAffinity"`,
636
763
  );
637
764
  }
765
+ if (override.streamIdleTimeoutMs !== undefined) {
766
+ if (effectiveTimeoutApi) {
767
+ assertRequestTransformSupportedForModelApi(
768
+ providerName,
769
+ modelId,
770
+ effectiveTimeoutApi,
771
+ 'modelOverrides "streamIdleTimeoutMs"',
772
+ );
773
+ } else if (!deferTimeoutValidation) {
774
+ assertRequestTransformSupportedForKnownProvider(
775
+ providerName,
776
+ `modelOverrides ${modelId} "streamIdleTimeoutMs"`,
777
+ );
778
+ }
779
+ }
638
780
  if (!override.requestTransform) continue;
639
781
  if (effectiveApi) {
640
782
  assertRequestTransformSupportedForModelApi(
@@ -657,6 +799,54 @@ function validateProviderConfiguration(
657
799
  assertRequestTransformSupportedForKnownProvider(providerName, '"requestTransform"');
658
800
  }
659
801
  }
802
+ if (config.streamIdleTimeoutMs !== undefined) {
803
+ const bundledApis = getKnownProviderApis(providerName);
804
+ const hasCompatibleBundledApi = [...bundledApis].some(isRequestTransformApi);
805
+ const hasCompatibleModelApi = models.some(model => {
806
+ const api = model.api ?? config.api;
807
+ return api !== undefined && isRequestTransformApi(api);
808
+ });
809
+ const hasCompatibleDiscoveryApi =
810
+ config.openaiCompat !== undefined ||
811
+ (config.discovery !== undefined &&
812
+ ((configuredApi !== undefined && isRequestTransformApi(configuredApi)) ||
813
+ Object.values(config.discovery.apiByModelPrefix ?? {}).some(isRequestTransformApi)));
814
+ const implicitDiscoveryApi =
815
+ (config.api as Api | undefined) ??
816
+ dominantOpenAIFamilyModelApi(models) ??
817
+ (config.openaiCompat ? "openai-completions" : undefined);
818
+ const implicitDiscoveryBaseUrl = config.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName);
819
+ const hasCompatibleImplicitDiscoveryApi =
820
+ !config.discovery &&
821
+ (config.openaiCompat !== undefined ||
822
+ (providerName !== "vllm" &&
823
+ providerName !== "sglang" &&
824
+ implicitDiscoveryApi !== undefined &&
825
+ isOpenAIFamilyApi(implicitDiscoveryApi) &&
826
+ implicitDiscoveryBaseUrl !== undefined));
827
+ const hasCompatibleDiscoveryRoute = hasCompatibleDiscoveryApi || hasCompatibleImplicitDiscoveryApi;
828
+ if (models.length > 0) {
829
+ if (!hasCompatibleModelApi && !hasCompatibleBundledApi && !hasCompatibleDiscoveryRoute) {
830
+ throw new Error(
831
+ `Provider ${providerName}: "streamIdleTimeoutMs" is only supported with openai-completions or openai-responses APIs.`,
832
+ );
833
+ }
834
+ } else {
835
+ if (!hasCompatibleBundledApi && !hasCompatibleDiscoveryRoute) {
836
+ if (bundledApis.size > 0) {
837
+ assertStreamIdleTimeoutSupportedForKnownProvider(providerName, '"streamIdleTimeoutMs"');
838
+ } else if (configuredApi) {
839
+ if (!isRequestTransformApi(configuredApi)) {
840
+ throw new Error(
841
+ `Provider ${providerName}: "streamIdleTimeoutMs" is only supported with openai-completions or openai-responses APIs.`,
842
+ );
843
+ }
844
+ } else {
845
+ assertStreamIdleTimeoutSupportedForKnownProvider(providerName, '"streamIdleTimeoutMs"');
846
+ }
847
+ }
848
+ }
849
+ }
660
850
 
661
851
  for (const modelDef of models) {
662
852
  if (!hasProviderApi && !modelDef.api) {
@@ -670,6 +860,14 @@ function validateProviderConfiguration(
670
860
  throw new Error(`Provider ${providerName}: model missing "id"`);
671
861
  }
672
862
  const effectiveApi = modelDef.api ?? config.api;
863
+ if (modelDef.streamIdleTimeoutMs !== undefined && effectiveApi) {
864
+ assertRequestTransformSupportedForModelApi(
865
+ providerName,
866
+ modelDef.id,
867
+ effectiveApi,
868
+ 'model "streamIdleTimeoutMs"',
869
+ );
870
+ }
673
871
  const modelCompat = modelDef.compat;
674
872
  if (modelCompat && "supportsResponsesSessionAffinity" in modelCompat) {
675
873
  assertResponsesSessionAffinitySupported(
@@ -706,34 +904,83 @@ function validateProviderConfiguration(
706
904
  }
707
905
  }
708
906
 
907
+ function validateModelsConfig(config: ModelsConfig): void {
908
+ for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
909
+ validateProviderConfiguration(
910
+ providerName,
911
+ {
912
+ baseUrl: providerConfig.baseUrl,
913
+ headers: providerConfig.headers,
914
+ apiKey: providerConfig.apiKey,
915
+ apiKeyEnv: providerConfig.apiKeyEnv,
916
+ api: providerConfig.api as Api | undefined,
917
+ transport: providerConfig.transport,
918
+ auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode,
919
+ discovery: providerConfig.discovery as ProviderDiscovery | undefined,
920
+ compat: providerConfig.compat,
921
+ requestTransform: providerConfig.requestTransform,
922
+ disableStrictTools: providerConfig.disableStrictTools,
923
+ cacheRetention: providerConfig.cacheRetention,
924
+ streamIdleTimeoutMs: providerConfig.streamIdleTimeoutMs,
925
+ openaiCompat: providerConfig.openaiCompat,
926
+ modelOverrides: providerConfig.modelOverrides,
927
+ models: (providerConfig.models ?? []) as ProviderValidationModel[],
928
+ },
929
+ "models-config",
930
+ );
931
+ }
932
+ }
933
+
709
934
  export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsConfigSchema).withValidation(
710
935
  "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
- },
936
+ validateModelsConfig,
735
937
  );
736
938
 
939
+ function parseModelsConfigSnapshot(snapshot: { path: string; text: string | null }): LoadResult<ModelsConfig> {
940
+ if (snapshot.text === null) return { status: "not-found" };
941
+ try {
942
+ const content = snapshot.text.trim();
943
+ let raw: unknown;
944
+ if (snapshot.path.endsWith(".json") || snapshot.path.endsWith(".jsonc")) {
945
+ raw = JSONC.parse(content);
946
+ } else if (snapshot.path.endsWith(".yml") || snapshot.path.endsWith(".yaml")) {
947
+ raw = YAML.parse(content);
948
+ } else {
949
+ throw new Error(`Invalid config file path: ${snapshot.path}`);
950
+ }
951
+ const parsed = ModelsConfigSchema.safeParse(raw);
952
+ if (!parsed.success) {
953
+ return {
954
+ status: "error",
955
+ error: new ConfigError(
956
+ "models",
957
+ parsed.error.issues.slice(0, 50).map(issue => ({
958
+ instancePath: issue.path.length === 0 ? "" : `/${issue.path.map(String).join("/")}`,
959
+ message: issue.message,
960
+ })),
961
+ ),
962
+ };
963
+ }
964
+ try {
965
+ validateModelsConfig(parsed.data);
966
+ } catch (error) {
967
+ return {
968
+ status: "error",
969
+ error:
970
+ error instanceof ConfigError
971
+ ? error
972
+ : new ConfigError("models", undefined, { err: error, stage: "Validate(models)" }),
973
+ };
974
+ }
975
+ return { status: "ok", value: parsed.data };
976
+ } catch (error) {
977
+ return {
978
+ status: "error",
979
+ error: new ConfigError("models", undefined, { err: error, stage: "Unexpected" }),
980
+ };
981
+ }
982
+ }
983
+
737
984
  /** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */
738
985
  interface ProviderOverride {
739
986
  api?: Api;
@@ -745,6 +992,7 @@ interface ProviderOverride {
745
992
  transport?: Model<Api>["transport"];
746
993
  requestTransform?: ModelRequestTransform;
747
994
  cacheRetention?: CacheRetention;
995
+ streamIdleTimeoutMs?: number;
748
996
  isOAuth?: boolean;
749
997
  }
750
998
 
@@ -778,6 +1026,7 @@ function registryModelMetadataWithoutApiSpecificFields(model: Model<Api>): Parti
778
1026
  contextWindow: model.contextWindow,
779
1027
  maxTokens: model.maxTokens,
780
1028
  };
1029
+ if (model.streamIdleTimeoutMs !== undefined) metadata.streamIdleTimeoutMs = model.streamIdleTimeoutMs;
781
1030
  if (model.longContextPricing !== undefined) metadata.longContextPricing = model.longContextPricing;
782
1031
  if (model.premiumMultiplier !== undefined) metadata.premiumMultiplier = model.premiumMultiplier;
783
1032
  if (model.priority !== undefined) metadata.priority = model.priority;
@@ -941,10 +1190,15 @@ export function mergeDiscoveredModel<TApi extends Api>(
941
1190
  existing: Model<Api> | undefined,
942
1191
  providerOverride?: Pick<
943
1192
  ProviderOverride,
944
- "baseUrl" | "headers" | "transport" | "requestTransform" | "cacheRetention"
1193
+ "baseUrl" | "headers" | "transport" | "requestTransform" | "cacheRetention" | "streamIdleTimeoutMs"
945
1194
  >,
946
1195
  ): Model<TApi> {
947
1196
  if (existing) {
1197
+ const streamIdleTimeoutMs =
1198
+ model.streamIdleTimeoutMs ??
1199
+ (isRequestTransformApi(model.api)
1200
+ ? (existing.streamIdleTimeoutMs ?? providerOverride?.streamIdleTimeoutMs)
1201
+ : undefined);
948
1202
  return {
949
1203
  ...model,
950
1204
  baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl,
@@ -954,15 +1208,20 @@ export function mergeDiscoveredModel<TApi extends Api>(
954
1208
  providerOverride?.requestTransform,
955
1209
  ),
956
1210
  cacheRetention: model.cacheRetention ?? existing.cacheRetention ?? providerOverride?.cacheRetention,
1211
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
957
1212
  };
958
1213
  }
959
1214
  if (providerOverride) {
1215
+ const streamIdleTimeoutMs =
1216
+ model.streamIdleTimeoutMs ??
1217
+ (isRequestTransformApi(model.api) ? providerOverride.streamIdleTimeoutMs : undefined);
960
1218
  return {
961
1219
  ...model,
962
1220
  baseUrl: providerOverride.baseUrl ?? model.baseUrl,
963
1221
  headers: mergeCaseInsensitiveHeaders(model.headers, providerOverride.headers),
964
1222
  ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}),
965
1223
  requestTransform: mergeRequestTransform(model.requestTransform, providerOverride.requestTransform),
1224
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
966
1225
  };
967
1226
  }
968
1227
  return model;
@@ -1278,6 +1537,20 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
1278
1537
  if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
1279
1538
  if (override.output !== undefined) result.output = override.output as ("text" | "image")[];
1280
1539
  if (override.cacheRetention !== undefined) result.cacheRetention = override.cacheRetention;
1540
+ if (override.streamIdleTimeoutMs !== undefined) {
1541
+ if (isRequestTransformApi(model.api)) {
1542
+ result.streamIdleTimeoutMs = override.streamIdleTimeoutMs;
1543
+ } else {
1544
+ // Discovery ownership can reveal an incompatible route after config
1545
+ // validation, including while hydrating a cached model at startup.
1546
+ // Reject only this override so the otherwise-valid model stays usable.
1547
+ logger.warn("model stream idle timeout override ignored for incompatible API", {
1548
+ model: model.id,
1549
+ provider: model.provider,
1550
+ api: model.api,
1551
+ });
1552
+ }
1553
+ }
1281
1554
  const contextWindowOverride = toPositiveNumberOrUndefined(override.contextWindow);
1282
1555
  if (contextWindowOverride !== undefined) {
1283
1556
  result.contextWindow = contextWindowOverride;
@@ -1367,6 +1640,7 @@ interface CustomModelDefinitionLike {
1367
1640
  wireModelId?: string;
1368
1641
  requestTransform?: ModelRequestTransform;
1369
1642
  cacheRetention?: CacheRetention;
1643
+ streamIdleTimeoutMs?: number;
1370
1644
  }
1371
1645
 
1372
1646
  interface CustomModelBuildOptions {
@@ -1394,6 +1668,7 @@ type CustomModelOverlay = {
1394
1668
  wireModelId?: string;
1395
1669
  requestTransform?: ModelRequestTransform;
1396
1670
  cacheRetention?: CacheRetention;
1671
+ streamIdleTimeoutMs?: number;
1397
1672
  transport?: Model<Api>["transport"];
1398
1673
  isOAuth?: boolean;
1399
1674
  };
@@ -1508,9 +1783,13 @@ function buildCustomModelOverlay(
1508
1783
  providerCacheRetention: CacheRetention | undefined,
1509
1784
  providerTransport: Model<Api>["transport"] | undefined,
1510
1785
  modelDef: CustomModelDefinitionLike,
1786
+ providerStreamIdleTimeoutMs?: number,
1511
1787
  ): CustomModelOverlay | undefined {
1512
1788
  const api = modelDef.api ?? providerApi;
1513
1789
  if (!api) return undefined;
1790
+ const streamIdleTimeoutMs = isRequestTransformApi(api)
1791
+ ? (modelDef.streamIdleTimeoutMs ?? providerStreamIdleTimeoutMs)
1792
+ : undefined;
1514
1793
  return {
1515
1794
  id: modelDef.id,
1516
1795
  provider: providerName,
@@ -1535,6 +1814,7 @@ function buildCustomModelOverlay(
1535
1814
  contextPromotionTarget: modelDef.contextPromotionTarget,
1536
1815
  premiumMultiplier: modelDef.premiumMultiplier,
1537
1816
  cacheRetention: modelDef.cacheRetention ?? providerCacheRetention,
1817
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
1538
1818
  transport: providerTransport,
1539
1819
  isOAuth: resolveCustomModelIsOAuth(api, providerAuth),
1540
1820
  };
@@ -1671,6 +1951,9 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
1671
1951
  wireModelId: resolvedModel.wireModelId,
1672
1952
  requestTransform: resolvedModel.requestTransform,
1673
1953
  cacheRetention: resolvedModel.cacheRetention ?? reference?.cacheRetention,
1954
+ ...(resolvedModel.streamIdleTimeoutMs !== undefined
1955
+ ? { streamIdleTimeoutMs: resolvedModel.streamIdleTimeoutMs }
1956
+ : {}),
1674
1957
  transport: resolvedModel.transport,
1675
1958
  premiumMultiplier: resolvedModel.premiumMultiplier,
1676
1959
  isOAuth: resolvedModel.isOAuth,
@@ -1742,6 +2025,201 @@ interface ConfiguredDiscoveryResult {
1742
2025
 
1743
2026
  type ProviderRefreshFence = { providerId: string; generation: number } | { generations: ReadonlyMap<string, number> };
1744
2027
 
2028
+ interface StagedConfigApiKey {
2029
+ readonly apiKey: string;
2030
+ readonly envSourced: boolean;
2031
+ // The staged AuthStorage adapter needs only the first two fields; registry-owned entries
2032
+ // also track whether the key came from config, runtime registration, or both.
2033
+ readonly configSourced?: boolean;
2034
+ readonly runtimeSourced?: boolean;
2035
+ }
2036
+
2037
+ interface StagedAuthStorageState {
2038
+ readonly authStorage: AuthStorage;
2039
+ readonly configApiKeys: Map<object, Map<string, StagedConfigApiKey>>;
2040
+ }
2041
+
2042
+ function createStagedAuthStorage(base: AuthStorage): StagedAuthStorageState {
2043
+ const configApiKeys = new Map<object, Map<string, StagedConfigApiKey>>();
2044
+ const fallbackResolvers = new Map<object, (provider: string) => string | undefined>();
2045
+ const lookupOwner = {};
2046
+ const getConfigApiKey = (provider: string, owner: object | undefined) =>
2047
+ (owner && configApiKeys.get(owner)?.get(resolveOAuthStorageProvider(provider))) || undefined;
2048
+ const peekApiKey = async (
2049
+ provider: string,
2050
+ options?: AuthApiKeyOptions & { sessionId?: string },
2051
+ ): Promise<string | undefined> => {
2052
+ if (base.hasRuntimeApiKey(provider)) return base.peekApiKey(provider, options);
2053
+ const configured = getConfigApiKey(provider, options?.owner);
2054
+ if (
2055
+ configured &&
2056
+ base.resolveEffectiveCredentialSelector(provider, options?.sessionId, options?.credentialSelector)
2057
+ ) {
2058
+ throw new Error(
2059
+ `Credential selector cannot be used for ${provider} while a config API key override is active`,
2060
+ );
2061
+ }
2062
+ if (configured && !configured.envSourced) return configured.apiKey;
2063
+ if (configured?.envSourced) {
2064
+ const credentialType = base.getEffectiveCredentialType(provider, options?.sessionId, {
2065
+ owner: lookupOwner,
2066
+ credentialSelector: options?.credentialSelector,
2067
+ });
2068
+ if (credentialType !== "api_key") return configured.apiKey;
2069
+ }
2070
+ const storedKey = await base.peekApiKey(provider, { ...options, owner: lookupOwner });
2071
+ if (storedKey !== undefined) return storedKey;
2072
+ if (configured?.envSourced) return configured.apiKey;
2073
+ return (options?.owner && fallbackResolvers.get(options.owner)?.(provider)) ?? undefined;
2074
+ };
2075
+ const staged = new Proxy(base, {
2076
+ get(target, property) {
2077
+ if (property === "setConfigApiKey") {
2078
+ return (provider: string, apiKey: string, options: { envSourced?: boolean; owner?: object } = {}) => {
2079
+ if (!options.owner) return;
2080
+ const keys = configApiKeys.get(options.owner) ?? new Map<string, StagedConfigApiKey>();
2081
+ keys.set(resolveOAuthStorageProvider(provider), {
2082
+ apiKey,
2083
+ envSourced: options.envSourced === true,
2084
+ });
2085
+ configApiKeys.set(options.owner, keys);
2086
+ };
2087
+ }
2088
+ if (property === "removeConfigApiKey") {
2089
+ return (provider: string, owner?: object) => {
2090
+ if (owner) configApiKeys.get(owner)?.delete(resolveOAuthStorageProvider(provider));
2091
+ };
2092
+ }
2093
+ if (property === "clearConfigApiKeys") {
2094
+ return (owner?: object) => {
2095
+ if (owner) configApiKeys.delete(owner);
2096
+ else configApiKeys.clear();
2097
+ };
2098
+ }
2099
+ if (property === "setFallbackResolver") {
2100
+ return (resolver: (provider: string) => string | undefined, owner?: object) => {
2101
+ if (!owner) return () => {};
2102
+ fallbackResolvers.set(owner, resolver);
2103
+ return () => {
2104
+ if (fallbackResolvers.get(owner) === resolver) fallbackResolvers.delete(owner);
2105
+ };
2106
+ };
2107
+ }
2108
+ if (property === "onGenerationChanged") return () => () => {};
2109
+ if (property === "peekApiKey") return peekApiKey;
2110
+ if (property === "getApiKey")
2111
+ return (provider: string, sessionId?: string, options?: AuthApiKeyOptions) =>
2112
+ peekApiKey(provider, { ...options, sessionId });
2113
+ if (property === "hasConfigApiKey") {
2114
+ return (provider: string, owner?: object) =>
2115
+ getConfigApiKey(provider, owner) !== undefined || base.hasConfigApiKey(provider, lookupOwner);
2116
+ }
2117
+ if (property === "hasAuth") {
2118
+ return (provider: string, sessionId?: string, options?: { owner?: object }) => {
2119
+ const configured = getConfigApiKey(provider, options?.owner);
2120
+ if (configured && base.resolveEffectiveCredentialSelector(provider, sessionId)) return false;
2121
+ return configured !== undefined || base.hasAuth(provider, sessionId, { owner: lookupOwner });
2122
+ };
2123
+ }
2124
+ if (property === "hasUsableAuth") {
2125
+ return (
2126
+ provider: string,
2127
+ options?: { owner?: object; sessionId?: string; credentialSelector?: AuthCredentialSelector },
2128
+ ) => {
2129
+ const configured = getConfigApiKey(provider, options?.owner);
2130
+ if (
2131
+ configured &&
2132
+ base.resolveEffectiveCredentialSelector(provider, options?.sessionId, options?.credentialSelector)
2133
+ ) {
2134
+ return false;
2135
+ }
2136
+ return (
2137
+ configured !== undefined ||
2138
+ base.hasUsableAuth(provider, {
2139
+ owner: lookupOwner,
2140
+ sessionId: options?.sessionId,
2141
+ credentialSelector: options?.credentialSelector,
2142
+ })
2143
+ );
2144
+ };
2145
+ }
2146
+ if (property === "getEffectiveCredentialType") {
2147
+ return (
2148
+ provider: string,
2149
+ sessionId?: string,
2150
+ options?: { owner?: object; credentialSelector?: AuthCredentialSelector },
2151
+ ) => {
2152
+ if (base.hasRuntimeApiKey(provider))
2153
+ return base.getEffectiveCredentialType(provider, sessionId, options);
2154
+ const configured = getConfigApiKey(provider, options?.owner);
2155
+ if (
2156
+ configured &&
2157
+ base.resolveEffectiveCredentialSelector(provider, sessionId, options?.credentialSelector)
2158
+ ) {
2159
+ return undefined;
2160
+ }
2161
+ if (
2162
+ configured &&
2163
+ !(configured.envSourced && base.getSessionCredentialType(provider, sessionId) === "api_key")
2164
+ ) {
2165
+ return "api_key";
2166
+ }
2167
+ return base.getEffectiveCredentialType(provider, sessionId, {
2168
+ owner: lookupOwner,
2169
+ credentialSelector: options?.credentialSelector,
2170
+ });
2171
+ };
2172
+ }
2173
+ if (property === "getProviderEvidenceGeneration") {
2174
+ return (provider: string, resolvedApiKey?: string, owner?: object) => {
2175
+ const configured = getConfigApiKey(provider, owner);
2176
+ const effectiveApiKey = base.hasRuntimeApiKey(provider)
2177
+ ? resolvedApiKey
2178
+ : configured && !configured.envSourced
2179
+ ? configured.apiKey
2180
+ : (resolvedApiKey ?? configured?.apiKey);
2181
+ return base.getProviderEvidenceGeneration(
2182
+ provider,
2183
+ effectiveApiKey,
2184
+ lookupOwner,
2185
+ configured ? { apiKey: configured.apiKey } : undefined,
2186
+ );
2187
+ };
2188
+ }
2189
+ const value = Reflect.get(target, property, target);
2190
+ return typeof value === "function" ? value.bind(target) : value;
2191
+ },
2192
+ }) as AuthStorage;
2193
+ return { authStorage: staged, configApiKeys };
2194
+ }
2195
+
2196
+ export interface ModelsConfigReloadCandidate {
2197
+ readonly valid: boolean;
2198
+ readonly changed: boolean;
2199
+ readonly diagnostics: { readonly valid: boolean; readonly errors: readonly unknown[] };
2200
+ readonly registry: ModelRegistry;
2201
+ isCurrent(): boolean;
2202
+ commit(): void;
2203
+ rollback(): void;
2204
+ finalize(): void;
2205
+ }
2206
+
2207
+ interface ModelRegistryInternalOptions {
2208
+ readonly configFile: ConfigFile<ModelsConfig>;
2209
+ readonly configSource: LoadResult<ModelsConfig>;
2210
+ readonly runtimeSource: ModelRegistry;
2211
+ readonly acceptedPresets: AcceptedModelPresetRegistry;
2212
+ }
2213
+
2214
+ interface RegistryLeaseWaiter {
2215
+ readonly kind: "consumer" | "publication";
2216
+ readonly signal?: AbortSignal;
2217
+ readonly resolve: (release: () => void) => void;
2218
+ readonly reject: (reason: unknown) => void;
2219
+ settled: boolean;
2220
+ abort?: () => void;
2221
+ }
2222
+
1745
2223
  /**
1746
2224
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
1747
2225
  */
@@ -1793,9 +2271,14 @@ export class ModelRegistry {
1793
2271
  #modelProfiles: Map<string, ModelProfileDefinition> = mergeModelProfiles();
1794
2272
  #configError: ConfigError | undefined = undefined;
1795
2273
  #modelsConfigFile: ConfigFile<ModelsConfig>;
2274
+ #modelsConfigSource: LoadResult<ModelsConfig> | undefined;
2275
+ #acceptedConfigValue: ModelsConfig | undefined;
2276
+ #acceptedModelsConfigPresent = false;
2277
+ #acceptedPresets: AcceptedModelPresetRegistry | undefined;
1796
2278
  #settings: Pick<Settings, "get" | "getGlobal">;
1797
2279
  #ownershipSettings: Pick<Settings, "commitAtomicBatchWithCurrent"> | undefined;
1798
- readonly #authStorageConfigOwner: object = {};
2280
+ readonly #authStorageConfigOwner: object;
2281
+ #ownedConfigApiKeys = new Map<string, StagedConfigApiKey>();
1799
2282
  #disposeAuthStorageFallbackResolver: (() => void) | undefined;
1800
2283
  #lastStaticLoadMtime: number | null = null;
1801
2284
  #lastStaticLoadEnvironmentFingerprint: string | undefined;
@@ -1837,6 +2320,11 @@ export class ModelRegistry {
1837
2320
  #configuredApiKeyEnvNames: Set<string> = new Set();
1838
2321
  #optionalAuthPreflightGenerations = new Map<string, number>();
1839
2322
  #optionalAuthPreflightEpoch = 0;
2323
+ #stagedReloadCandidate = false;
2324
+ #leaseWaiters: RegistryLeaseWaiter[] = [];
2325
+ #activeConsumerLeases = 0;
2326
+ #publicationFenceHeld = false;
2327
+ #consumerContext = new AsyncLocalStorage<{ active: boolean }>();
1840
2328
 
1841
2329
  /**
1842
2330
  * @param authStorage - Auth storage for API key resolution
@@ -1846,15 +2334,22 @@ export class ModelRegistry {
1846
2334
  modelsPath?: string,
1847
2335
  registrySettings?: Pick<Settings, "get" | "getGlobal"> & Partial<Pick<Settings, "commitAtomicBatchWithCurrent">>,
1848
2336
  modelPresetRegistryDependencies: ModelPresetRegistryDependencies = {},
2337
+ internalOptions?: ModelRegistryInternalOptions,
1849
2338
  ) {
2339
+ this.#authStorageConfigOwner = internalOptions ? internalOptions.runtimeSource.#authStorageConfigOwner : {};
1850
2340
  this.#settings = registrySettings ?? settings;
1851
- this.#ownershipSettings = registrySettings
1852
- ? typeof registrySettings.commitAtomicBatchWithCurrent === "function"
1853
- ? (registrySettings as Pick<Settings, "commitAtomicBatchWithCurrent">)
1854
- : undefined
1855
- : settings;
2341
+ this.#ownershipSettings = internalOptions
2342
+ ? undefined
2343
+ : registrySettings
2344
+ ? typeof registrySettings.commitAtomicBatchWithCurrent === "function"
2345
+ ? (registrySettings as Pick<Settings, "commitAtomicBatchWithCurrent">)
2346
+ : undefined
2347
+ : settings;
1856
2348
  const configuredAgentDir = path.resolve(modelPresetRegistryDependencies.agentDir ?? getAgentDir());
1857
- this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
2349
+ this.#modelsConfigFile = internalOptions?.configFile ?? ModelsConfigFile.relocate(modelsPath);
2350
+ this.#modelsConfigSource = internalOptions?.configSource;
2351
+ this.#acceptedPresets = internalOptions?.acceptedPresets;
2352
+ this.#stagedReloadCandidate = internalOptions !== undefined;
1858
2353
  this.#modelPresetRegistryAgentDir = modelPresetRegistryDependencies.agentDir
1859
2354
  ? configuredAgentDir
1860
2355
  : modelsPath && path.isAbsolute(modelsPath)
@@ -1863,6 +2358,10 @@ export class ModelRegistry {
1863
2358
  const { agentDir: _agentDir, ...registryDependencies } = modelPresetRegistryDependencies;
1864
2359
  this.#modelPresetRegistryDependencies = registryDependencies;
1865
2360
  this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
2361
+ if (internalOptions) {
2362
+ this.#copyRuntimeStateFrom(internalOptions.runtimeSource);
2363
+ this.#clearOwnedConfigApiKeys();
2364
+ }
1866
2365
  // Set up fallback resolver for custom provider API keys
1867
2366
  this.#disposeAuthStorageFallbackResolver = this.authStorage.setFallbackResolver(provider => {
1868
2367
  const keyConfig = this.#customProviderApiKeys.get(provider);
@@ -1871,32 +2370,620 @@ export class ModelRegistry {
1871
2370
  this.#unsubscribeAuthGeneration = this.authStorage.onGenerationChanged(() => this.#invalidateAvailableModels());
1872
2371
  // Load models synchronously in constructor
1873
2372
  this.#loadModels();
1874
- this.#cancelModelPresetRegistryRefresh = refreshModelPresetRegistryInBackground(
2373
+ if (internalOptions) this.#reapplyRuntimeAuthConfiguration();
2374
+ if (!internalOptions) {
2375
+ this.#cancelModelPresetRegistryRefresh = refreshModelPresetRegistryInBackground(
2376
+ {
2377
+ ...this.#modelPresetRegistryDependencies,
2378
+ agentDir: this.#modelPresetRegistryAgentDir,
2379
+ knownManifestSha256: this.#loadedModelPresetRegistryManifestSha256,
2380
+ },
2381
+ () => {
2382
+ void this.#enqueueCatalogMutation(() => {
2383
+ if (this.#disposed) return;
2384
+ this.#reloadStaticModels();
2385
+ this.#modelBindingsApplier.apply();
2386
+ this.#notifyCatalogChanged();
2387
+ }).catch(() => undefined);
2388
+ },
2389
+ );
2390
+ }
2391
+ }
2392
+
2393
+ #copyRuntimeStateFrom(source: ModelRegistry): void {
2394
+ this.#sessionCanonicalVariants = new Map(source.#sessionCanonicalVariants);
2395
+ this.#suppressedSelectors = new Map(source.#suppressedSelectors);
2396
+ this.#selectorCircuits = new Map(
2397
+ [...source.#selectorCircuits].map(([selector, circuit]) => [selector, structuredClone(circuit)]),
2398
+ );
2399
+ this.#runtimeModelOverlays = [...source.#runtimeModelOverlays];
2400
+ this.#runtimeProviderApiKeys = new Map(source.#runtimeProviderApiKeys);
2401
+ this.#runtimeProviderResolvedApiKeys = new Map(source.#runtimeProviderResolvedApiKeys);
2402
+ this.#runtimeProviderCredentialInstalled = new Set(source.#runtimeProviderCredentialInstalled);
2403
+ this.#runtimeProviderApiKeyEnvNames = new Map(source.#runtimeProviderApiKeyEnvNames);
2404
+ this.#runtimeProviderOverrides = new Map(source.#runtimeProviderOverrides);
2405
+ this.#runtimeProviderAuthHeaders = new Map(source.#runtimeProviderAuthHeaders);
2406
+ this.#generatedAuthHeaderProviders = new Set(source.#generatedAuthHeaderProviders);
2407
+ this.#runtimeProvidersBySource = new Map(
2408
+ [...source.#runtimeProvidersBySource].map(([id, providers]) => [id, new Set(providers)]),
2409
+ );
2410
+ this.#runtimeProviderSourceByName = new Map(source.#runtimeProviderSourceByName);
2411
+ this.#registeredProviderSources = new Set(source.#registeredProviderSources);
2412
+ this.#descriptorDiscoveryEvidence = new Map(source.#descriptorDiscoveryEvidence);
2413
+ this.#descriptorDiscoveryGenerations = new Map(source.#descriptorDiscoveryGenerations);
2414
+ this.#configuredDiscoveryEvidence = new Map(source.#configuredDiscoveryEvidence);
2415
+ this.#providerEvidenceApiKeys = new Map(source.#providerEvidenceApiKeys);
2416
+ this.#credentiallessAuthFallbackProviders = new Map(source.#credentiallessAuthFallbackProviders);
2417
+ this.#optionalAuthPreflightGenerations = new Map(source.#optionalAuthPreflightGenerations);
2418
+ this.#optionalAuthPreflightEpoch = source.#optionalAuthPreflightEpoch;
2419
+ this.#ownedConfigApiKeys = new Map(source.#ownedConfigApiKeys);
2420
+ this.#acceptedPresets = source.#acceptedPresets;
2421
+ this.#loadedModelPresetRegistryManifestSha256 = source.#loadedModelPresetRegistryManifestSha256;
2422
+ }
2423
+
2424
+ #setOwnedConfigApiKey(
2425
+ provider: string,
2426
+ apiKey: string,
2427
+ envSourced = false,
2428
+ source: "config" | "runtime" = "config",
2429
+ ): void {
2430
+ const key = resolveOAuthStorageProvider(provider);
2431
+ const previous = this.#ownedConfigApiKeys.get(key);
2432
+ this.#ownedConfigApiKeys.set(key, {
2433
+ apiKey,
2434
+ envSourced,
2435
+ configSourced: source === "config" || previous?.configSourced === true,
2436
+ runtimeSourced: source === "runtime" || previous?.runtimeSourced === true,
2437
+ });
2438
+ this.authStorage.setConfigApiKey(provider, apiKey, {
2439
+ envSourced,
2440
+ owner: this.#authStorageConfigOwner,
2441
+ });
2442
+ }
2443
+
2444
+ #removeOwnedConfigApiKey(provider: string): void {
2445
+ this.#ownedConfigApiKeys.delete(resolveOAuthStorageProvider(provider));
2446
+ this.authStorage.removeConfigApiKey(provider, this.#authStorageConfigOwner);
2447
+ }
2448
+
2449
+ #clearOwnedConfigApiKeys(): void {
2450
+ this.#ownedConfigApiKeys.clear();
2451
+ this.authStorage.clearConfigApiKeys(this.#authStorageConfigOwner);
2452
+ }
2453
+
2454
+ #reapplyRuntimeAuthConfiguration(): void {
2455
+ for (const [provider, apiKeyConfig] of this.#runtimeProviderApiKeys) {
2456
+ const resolved = this.#runtimeProviderApiKeyEnvNames.has(provider)
2457
+ ? $rotatingCredentialEnv(this.#runtimeProviderApiKeyEnvNames.get(provider)!)
2458
+ : resolveApiKeyConfig(apiKeyConfig);
2459
+ if (!resolved) continue;
2460
+ this.#customProviderApiKeys.set(provider, resolved);
2461
+ this.#runtimeProviderResolvedApiKeys.set(provider, resolved);
2462
+ this.#runtimeProviderCredentialInstalled.add(provider);
2463
+ this.#setOwnedConfigApiKey(provider, resolved, false, "runtime");
2464
+ }
2465
+ }
2466
+
2467
+ #swapReloadableState(other: ModelRegistry): void {
2468
+ [this.#models, other.#models] = [other.#models, this.#models];
2469
+ [this.#canonicalIndex, other.#canonicalIndex] = [other.#canonicalIndex, this.#canonicalIndex];
2470
+ [this.#availableModelsCache, other.#availableModelsCache] = [
2471
+ other.#availableModelsCache,
2472
+ this.#availableModelsCache,
2473
+ ];
2474
+ [this.#availableModelsDisabledProviders, other.#availableModelsDisabledProviders] = [
2475
+ other.#availableModelsDisabledProviders,
2476
+ this.#availableModelsDisabledProviders,
2477
+ ];
2478
+ [this.#availableModelsEnvFingerprint, other.#availableModelsEnvFingerprint] = [
2479
+ other.#availableModelsEnvFingerprint,
2480
+ this.#availableModelsEnvFingerprint,
2481
+ ];
2482
+ [this.#customProviderApiKeys, other.#customProviderApiKeys] = [
2483
+ other.#customProviderApiKeys,
2484
+ this.#customProviderApiKeys,
2485
+ ];
2486
+ [this.#customProviderApiKeyEnvNames, other.#customProviderApiKeyEnvNames] = [
2487
+ other.#customProviderApiKeyEnvNames,
2488
+ this.#customProviderApiKeyEnvNames,
2489
+ ];
2490
+ [this.#customProviderAuthHeaders, other.#customProviderAuthHeaders] = [
2491
+ other.#customProviderAuthHeaders,
2492
+ this.#customProviderAuthHeaders,
2493
+ ];
2494
+ [this.#providerWebSearchModes, other.#providerWebSearchModes] = [
2495
+ other.#providerWebSearchModes,
2496
+ this.#providerWebSearchModes,
2497
+ ];
2498
+ [this.#keylessProviders, other.#keylessProviders] = [other.#keylessProviders, this.#keylessProviders];
2499
+ [this.#optionalAuthProviders, other.#optionalAuthProviders] = [
2500
+ other.#optionalAuthProviders,
2501
+ this.#optionalAuthProviders,
2502
+ ];
2503
+ [this.#credentiallessAuthFallbackProviders, other.#credentiallessAuthFallbackProviders] = [
2504
+ other.#credentiallessAuthFallbackProviders,
2505
+ this.#credentiallessAuthFallbackProviders,
2506
+ ];
2507
+ [this.#providerEvidenceApiKeys, other.#providerEvidenceApiKeys] = [
2508
+ other.#providerEvidenceApiKeys,
2509
+ this.#providerEvidenceApiKeys,
2510
+ ];
2511
+ [this.#providerActivity, other.#providerActivity] = [other.#providerActivity, this.#providerActivity];
2512
+ [this.#configuredProviderIds, other.#configuredProviderIds] = [
2513
+ other.#configuredProviderIds,
2514
+ this.#configuredProviderIds,
2515
+ ];
2516
+ [this.#configuredDiscoveryProviderIds, other.#configuredDiscoveryProviderIds] = [
2517
+ other.#configuredDiscoveryProviderIds,
2518
+ this.#configuredDiscoveryProviderIds,
2519
+ ];
2520
+ [this.#descriptorDiscoveryEvidence, other.#descriptorDiscoveryEvidence] = [
2521
+ other.#descriptorDiscoveryEvidence,
2522
+ this.#descriptorDiscoveryEvidence,
2523
+ ];
2524
+ [this.#descriptorDiscoveryGenerations, other.#descriptorDiscoveryGenerations] = [
2525
+ other.#descriptorDiscoveryGenerations,
2526
+ this.#descriptorDiscoveryGenerations,
2527
+ ];
2528
+ [this.#configuredDiscoveryEvidence, other.#configuredDiscoveryEvidence] = [
2529
+ other.#configuredDiscoveryEvidence,
2530
+ this.#configuredDiscoveryEvidence,
2531
+ ];
2532
+ [this.#discoveryManager, other.#discoveryManager] = [other.#discoveryManager, this.#discoveryManager];
2533
+ [this.#customModelOverlays, other.#customModelOverlays] = [other.#customModelOverlays, this.#customModelOverlays];
2534
+ [this.#providerOverrides, other.#providerOverrides] = [other.#providerOverrides, this.#providerOverrides];
2535
+ [this.#modelOverrides, other.#modelOverrides] = [other.#modelOverrides, this.#modelOverrides];
2536
+ [this.#codexContextWindowOverrides, other.#codexContextWindowOverrides] = [
2537
+ other.#codexContextWindowOverrides,
2538
+ this.#codexContextWindowOverrides,
2539
+ ];
2540
+ [this.#equivalenceConfig, other.#equivalenceConfig] = [other.#equivalenceConfig, this.#equivalenceConfig];
2541
+ [this.#acceptedPresets, other.#acceptedPresets] = [other.#acceptedPresets, this.#acceptedPresets];
2542
+ [this.#modelProfiles, other.#modelProfiles] = [other.#modelProfiles, this.#modelProfiles];
2543
+ [this.#configError, other.#configError] = [other.#configError, this.#configError];
2544
+ [this.#ownedConfigApiKeys, other.#ownedConfigApiKeys] = [other.#ownedConfigApiKeys, this.#ownedConfigApiKeys];
2545
+ [this.#lastStaticLoadMtime, other.#lastStaticLoadMtime] = [other.#lastStaticLoadMtime, this.#lastStaticLoadMtime];
2546
+ [this.#lastStaticLoadEnvironmentFingerprint, other.#lastStaticLoadEnvironmentFingerprint] = [
2547
+ other.#lastStaticLoadEnvironmentFingerprint,
2548
+ this.#lastStaticLoadEnvironmentFingerprint,
2549
+ ];
2550
+ [this.#lastModelPresetRegistryFingerprint, other.#lastModelPresetRegistryFingerprint] = [
2551
+ other.#lastModelPresetRegistryFingerprint,
2552
+ this.#lastModelPresetRegistryFingerprint,
2553
+ ];
2554
+ [this.#loadedModelPresetRegistryManifestSha256, other.#loadedModelPresetRegistryManifestSha256] = [
2555
+ other.#loadedModelPresetRegistryManifestSha256,
2556
+ this.#loadedModelPresetRegistryManifestSha256,
2557
+ ];
2558
+ [this.#staticModelsLoaded, other.#staticModelsLoaded] = [other.#staticModelsLoaded, this.#staticModelsLoaded];
2559
+ [this.#lastDisabledProviderKey, other.#lastDisabledProviderKey] = [
2560
+ other.#lastDisabledProviderKey,
2561
+ this.#lastDisabledProviderKey,
2562
+ ];
2563
+ [this.#runtimeModelOverlays, other.#runtimeModelOverlays] = [
2564
+ other.#runtimeModelOverlays,
2565
+ this.#runtimeModelOverlays,
2566
+ ];
2567
+ [this.#runtimeProviderApiKeys, other.#runtimeProviderApiKeys] = [
2568
+ other.#runtimeProviderApiKeys,
2569
+ this.#runtimeProviderApiKeys,
2570
+ ];
2571
+ [this.#runtimeProviderResolvedApiKeys, other.#runtimeProviderResolvedApiKeys] = [
2572
+ other.#runtimeProviderResolvedApiKeys,
2573
+ this.#runtimeProviderResolvedApiKeys,
2574
+ ];
2575
+ [this.#runtimeProviderCredentialInstalled, other.#runtimeProviderCredentialInstalled] = [
2576
+ other.#runtimeProviderCredentialInstalled,
2577
+ this.#runtimeProviderCredentialInstalled,
2578
+ ];
2579
+ [this.#runtimeProviderApiKeyEnvNames, other.#runtimeProviderApiKeyEnvNames] = [
2580
+ other.#runtimeProviderApiKeyEnvNames,
2581
+ this.#runtimeProviderApiKeyEnvNames,
2582
+ ];
2583
+ [this.#runtimeProviderOverrides, other.#runtimeProviderOverrides] = [
2584
+ other.#runtimeProviderOverrides,
2585
+ this.#runtimeProviderOverrides,
2586
+ ];
2587
+ [this.#runtimeProviderAuthHeaders, other.#runtimeProviderAuthHeaders] = [
2588
+ other.#runtimeProviderAuthHeaders,
2589
+ this.#runtimeProviderAuthHeaders,
2590
+ ];
2591
+ [this.#generatedAuthHeaderProviders, other.#generatedAuthHeaderProviders] = [
2592
+ other.#generatedAuthHeaderProviders,
2593
+ this.#generatedAuthHeaderProviders,
2594
+ ];
2595
+ [this.#generatedAuthHeaders, other.#generatedAuthHeaders] = [
2596
+ other.#generatedAuthHeaders,
2597
+ this.#generatedAuthHeaders,
2598
+ ];
2599
+ [this.#runtimeProvidersBySource, other.#runtimeProvidersBySource] = [
2600
+ other.#runtimeProvidersBySource,
2601
+ this.#runtimeProvidersBySource,
2602
+ ];
2603
+ [this.#runtimeProviderSourceByName, other.#runtimeProviderSourceByName] = [
2604
+ other.#runtimeProviderSourceByName,
2605
+ this.#runtimeProviderSourceByName,
2606
+ ];
2607
+ [this.#registeredProviderSources, other.#registeredProviderSources] = [
2608
+ other.#registeredProviderSources,
2609
+ this.#registeredProviderSources,
2610
+ ];
2611
+ [this.#registryModelKeys, other.#registryModelKeys] = [other.#registryModelKeys, this.#registryModelKeys];
2612
+ [this.#configuredApiKeyEnvNames, other.#configuredApiKeyEnvNames] = [
2613
+ other.#configuredApiKeyEnvNames,
2614
+ this.#configuredApiKeyEnvNames,
2615
+ ];
2616
+ [this.#optionalAuthPreflightGenerations, other.#optionalAuthPreflightGenerations] = [
2617
+ other.#optionalAuthPreflightGenerations,
2618
+ this.#optionalAuthPreflightGenerations,
2619
+ ];
2620
+ [this.#optionalAuthPreflightEpoch, other.#optionalAuthPreflightEpoch] = [
2621
+ other.#optionalAuthPreflightEpoch,
2622
+ this.#optionalAuthPreflightEpoch,
2623
+ ];
2624
+ const previousBindings = this.#modelBindingsApplier.getBindings();
2625
+ this.#modelBindingsApplier.setBindings(other.#modelBindingsApplier.getBindings());
2626
+ other.#modelBindingsApplier.setBindings(previousBindings);
2627
+ this.#modelBindingsApplier.apply();
2628
+ this.#modelsConfigFile.invalidate();
2629
+ this.#availableModelsCache = undefined;
2630
+ }
2631
+
2632
+ #reconcileOwnedConfigApiKeys(
2633
+ configs: ReadonlyMap<string, StagedConfigApiKey>,
2634
+ previousConfigs: ReadonlyMap<string, StagedConfigApiKey>,
2635
+ ): void {
2636
+ for (const [provider, previous] of previousConfigs) {
2637
+ const next = configs.get(provider);
2638
+ if (next?.apiKey === previous.apiKey && next.envSourced === previous.envSourced) continue;
2639
+ this.authStorage.removeConfigApiKey(provider, this.#authStorageConfigOwner);
2640
+ }
2641
+ for (const [provider, config] of configs) {
2642
+ const previous = previousConfigs.get(provider);
2643
+ if (previous?.apiKey === config.apiKey && previous.envSourced === config.envSourced) continue;
2644
+ this.authStorage.setConfigApiKey(provider, config.apiKey, {
2645
+ envSourced: config.envSourced,
2646
+ owner: this.#authStorageConfigOwner,
2647
+ });
2648
+ }
2649
+ }
2650
+
2651
+ getModelsConfigPath(): string | undefined {
2652
+ return this.#modelsConfigFile.path();
2653
+ }
2654
+
2655
+ async stageModelsConfigReload(
2656
+ snapshot: {
2657
+ path: string;
2658
+ text: string | null;
2659
+ identity: string;
2660
+ },
2661
+ registrySettings: Pick<Settings, "get" | "getGlobal"> = this.#settings,
2662
+ ): Promise<ModelsConfigReloadCandidate> {
2663
+ if (this.#disposed) throw new Error("Model registry is disposed");
2664
+ const pathMatches = path.resolve(snapshot.path) === path.resolve(this.#modelsConfigFile.path());
2665
+ const source = pathMatches
2666
+ ? parseModelsConfigSnapshot(snapshot)
2667
+ : {
2668
+ status: "error" as const,
2669
+ error: new ConfigError("models", undefined, {
2670
+ err: new Error("Models config path changed while the watcher was active"),
2671
+ stage: "Snapshot",
2672
+ }),
2673
+ };
2674
+ const accepted = source.status === "ok" || (source.status === "not-found" && !this.#acceptedModelsConfigPresent);
2675
+ const diagnostics = {
2676
+ valid: accepted,
2677
+ errors: accepted
2678
+ ? []
2679
+ : [source.status === "error" ? source.error : new Error("Cannot delete an accepted models config")],
2680
+ };
2681
+ const stagedAuthStorage = createStagedAuthStorage(this.authStorage);
2682
+ stagedAuthStorage.configApiKeys.set(this.#authStorageConfigOwner, new Map(this.#ownedConfigApiKeys));
2683
+ const acceptedPresets =
2684
+ this.#acceptedPresets ??
2685
+ loadAcceptedModelPresetProfiles(this.#modelPresetRegistryAgentDir, this.#modelPresetRegistryDependencies);
2686
+ const registry = new ModelRegistry(
2687
+ stagedAuthStorage.authStorage,
2688
+ this.#modelsConfigFile.path(),
2689
+ registrySettings,
2690
+ { ...this.#modelPresetRegistryDependencies, automaticRefresh: false },
1875
2691
  {
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);
2692
+ configFile: this.#modelsConfigFile,
2693
+ configSource: source,
2694
+ runtimeSource: this,
2695
+ acceptedPresets,
1887
2696
  },
1888
2697
  );
2698
+ if (registry.getError()) {
2699
+ diagnostics.valid = false;
2700
+ diagnostics.errors.push(registry.getError()!);
2701
+ }
2702
+ for (const [provider, apiKey] of registry.#ownedConfigApiKeys) {
2703
+ if (apiKey.runtimeSourced === true && apiKey.configSourced !== true) continue;
2704
+ if (!this.authStorage.hasAnyCredentialPin(provider)) continue;
2705
+ diagnostics.valid = false;
2706
+ diagnostics.errors.push(
2707
+ new Error(
2708
+ `Cannot configure an API key override for ${provider} while a credential pin is active or unavailable`,
2709
+ ),
2710
+ );
2711
+ }
2712
+ let committed = false;
2713
+ let finished = false;
2714
+ const previousConfigPresent = this.#acceptedModelsConfigPresent;
2715
+ const previousConfigValue = this.#acceptedConfigValue;
2716
+ const nextConfigValue = source.status === "ok" ? source.value : undefined;
2717
+ const changed = !isDeepStrictEqual(previousConfigValue, nextConfigValue);
2718
+ const unchangedProviders = new Set(
2719
+ [...this.#providerActivity.keys()].filter(
2720
+ provider =>
2721
+ isDeepStrictEqual(previousConfigValue?.providers?.[provider], nextConfigValue?.providers?.[provider]) &&
2722
+ isDeepStrictEqual(this.#providerOverrides.get(provider), registry.#providerOverrides.get(provider)) &&
2723
+ isDeepStrictEqual(this.#modelOverrides.get(provider), registry.#modelOverrides.get(provider)) &&
2724
+ this.#customProviderApiKeys.get(provider) === registry.#customProviderApiKeys.get(provider) &&
2725
+ isDeepStrictEqual(this.#ownedConfigApiKeys.get(provider), registry.#ownedConfigApiKeys.get(provider)),
2726
+ ),
2727
+ );
2728
+ const unchangedProviderSnapshots = new Map(
2729
+ [...unchangedProviders].map(provider => [
2730
+ provider,
2731
+ {
2732
+ models: this.#models.filter(model => model.provider === provider),
2733
+ discoveryState: this.#discoveryManager.getState(provider),
2734
+ configuredEvidence: this.#configuredDiscoveryEvidence.get(provider),
2735
+ descriptorEvidence: this.#descriptorDiscoveryEvidence.get(provider),
2736
+ descriptorGeneration: this.#descriptorDiscoveryGenerations.get(provider),
2737
+ providerEvidenceApiKey: this.#providerEvidenceApiKeys.get(provider),
2738
+ credentiallessFallback: this.#credentiallessAuthFallbackProviders.get(provider),
2739
+ optionalAuthPreflightGeneration: this.#optionalAuthPreflightGenerations.get(provider),
2740
+ activity: this.#providerActivity.get(provider),
2741
+ },
2742
+ ]),
2743
+ );
2744
+ const copyProviderEntry = <T>(target: Map<string, T>, source: ReadonlyMap<string, T>, provider: string) => {
2745
+ const value = source.get(provider);
2746
+ if (value === undefined) target.delete(provider);
2747
+ else target.set(provider, value);
2748
+ };
2749
+ const stagedModelKeys = new Set(registry.#models.map(model => `${model.provider}/${model.id}`));
2750
+ for (const model of this.#models) {
2751
+ if (!unchangedProviders.has(model.provider) || stagedModelKeys.has(`${model.provider}/${model.id}`)) continue;
2752
+ registry.#models.push(model);
2753
+ }
2754
+ registry.#discoveryManager.retainStatesFrom(this.#discoveryManager, unchangedProviders);
2755
+ const retainedActivity = new Map(registry.#providerActivity);
2756
+ for (const provider of unchangedProviders) {
2757
+ copyProviderEntry(registry.#configuredDiscoveryEvidence, this.#configuredDiscoveryEvidence, provider);
2758
+ copyProviderEntry(registry.#descriptorDiscoveryEvidence, this.#descriptorDiscoveryEvidence, provider);
2759
+ copyProviderEntry(registry.#descriptorDiscoveryGenerations, this.#descriptorDiscoveryGenerations, provider);
2760
+ copyProviderEntry(registry.#providerEvidenceApiKeys, this.#providerEvidenceApiKeys, provider);
2761
+ copyProviderEntry(retainedActivity, this.#providerActivity, provider);
2762
+ }
2763
+ registry.#providerActivity = retainedActivity;
2764
+ registry.#rebuildCanonicalIndex();
2765
+ const rebaseProviderDiscoveryAfterQueuedRefresh = () => {
2766
+ const changedProviders = new Set(
2767
+ [...unchangedProviders].filter(provider => {
2768
+ const snapshot = unchangedProviderSnapshots.get(provider)!;
2769
+ return (
2770
+ !isDeepStrictEqual(
2771
+ snapshot.models,
2772
+ this.#models.filter(model => model.provider === provider),
2773
+ ) ||
2774
+ !isDeepStrictEqual(snapshot.discoveryState, this.#discoveryManager.getState(provider)) ||
2775
+ !isDeepStrictEqual(snapshot.configuredEvidence, this.#configuredDiscoveryEvidence.get(provider)) ||
2776
+ !isDeepStrictEqual(snapshot.descriptorEvidence, this.#descriptorDiscoveryEvidence.get(provider)) ||
2777
+ snapshot.descriptorGeneration !== this.#descriptorDiscoveryGenerations.get(provider) ||
2778
+ snapshot.providerEvidenceApiKey !== this.#providerEvidenceApiKeys.get(provider) ||
2779
+ snapshot.credentiallessFallback !== this.#credentiallessAuthFallbackProviders.get(provider) ||
2780
+ snapshot.optionalAuthPreflightGeneration !== this.#optionalAuthPreflightGenerations.get(provider) ||
2781
+ !isDeepStrictEqual(snapshot.activity, this.#providerActivity.get(provider))
2782
+ );
2783
+ }),
2784
+ );
2785
+ if (changedProviders.size === 0) return;
2786
+
2787
+ registry.#models = registry.#models.filter(model => !changedProviders.has(model.provider));
2788
+ registry.#models.push(...this.#models.filter(model => changedProviders.has(model.provider)));
2789
+ for (const provider of changedProviders) registry.#discoveryManager.invalidate(provider);
2790
+ registry.#discoveryManager.retainStatesFrom(this.#discoveryManager, changedProviders);
2791
+ const providerActivity = new Map(registry.#providerActivity);
2792
+ for (const provider of changedProviders) {
2793
+ copyProviderEntry(registry.#configuredDiscoveryEvidence, this.#configuredDiscoveryEvidence, provider);
2794
+ copyProviderEntry(registry.#descriptorDiscoveryEvidence, this.#descriptorDiscoveryEvidence, provider);
2795
+ copyProviderEntry(registry.#descriptorDiscoveryGenerations, this.#descriptorDiscoveryGenerations, provider);
2796
+ copyProviderEntry(registry.#providerEvidenceApiKeys, this.#providerEvidenceApiKeys, provider);
2797
+ copyProviderEntry(
2798
+ registry.#credentiallessAuthFallbackProviders,
2799
+ this.#credentiallessAuthFallbackProviders,
2800
+ provider,
2801
+ );
2802
+ copyProviderEntry(
2803
+ registry.#optionalAuthPreflightGenerations,
2804
+ this.#optionalAuthPreflightGenerations,
2805
+ provider,
2806
+ );
2807
+ copyProviderEntry(providerActivity, this.#providerActivity, provider);
2808
+ }
2809
+ registry.#providerActivity = providerActivity;
2810
+ registry.#rebuildCanonicalIndex();
2811
+ };
2812
+ const expectedGeneration = this.#catalogRefreshGeneration;
2813
+ const isCurrent = () => !this.#disposed && this.#catalogRefreshGeneration === expectedGeneration;
2814
+ const commit = () => {
2815
+ if (finished) throw new Error("Models config reload candidate is no longer active");
2816
+ if (!diagnostics.valid) throw new Error("Cannot commit an invalid models config reload candidate");
2817
+ if (committed) return;
2818
+ if (!isCurrent()) {
2819
+ throw new Error("Model catalog changed during configuration preflight");
2820
+ }
2821
+ for (const [provider, apiKey] of registry.#ownedConfigApiKeys) {
2822
+ if (apiKey.runtimeSourced === true && apiKey.configSourced !== true) continue;
2823
+ if (this.authStorage.hasAnyCredentialPin(provider)) {
2824
+ throw new Error(
2825
+ `Cannot configure an API key override for ${provider} while a credential pin is active or unavailable`,
2826
+ );
2827
+ }
2828
+ }
2829
+ rebaseProviderDiscoveryAfterQueuedRefresh();
2830
+ this.#swapReloadableState(registry);
2831
+ try {
2832
+ this.#reconcileOwnedConfigApiKeys(this.#ownedConfigApiKeys, registry.#ownedConfigApiKeys);
2833
+ this.#acceptedModelsConfigPresent = source.status === "ok";
2834
+ this.#acceptedConfigValue = nextConfigValue;
2835
+ // The captured source may precede the current disk revision.
2836
+ this.#lastStaticLoadMtime = null;
2837
+ committed = true;
2838
+ } catch (error) {
2839
+ this.#swapReloadableState(registry);
2840
+ this.#reconcileOwnedConfigApiKeys(this.#ownedConfigApiKeys, registry.#ownedConfigApiKeys);
2841
+ throw error;
2842
+ }
2843
+ };
2844
+ const rollback = () => {
2845
+ if (finished) return;
2846
+ if (committed) {
2847
+ this.#swapReloadableState(registry);
2848
+ this.#reconcileOwnedConfigApiKeys(this.#ownedConfigApiKeys, registry.#ownedConfigApiKeys);
2849
+ this.#acceptedModelsConfigPresent = previousConfigPresent;
2850
+ this.#acceptedConfigValue = previousConfigValue;
2851
+ committed = false;
2852
+ }
2853
+ finished = true;
2854
+ void registry.dispose();
2855
+ };
2856
+ const finalize = () => {
2857
+ if (finished) return;
2858
+ if (!committed) throw new Error("Cannot finalize an unpublished models configuration");
2859
+ finished = true;
2860
+ this.#catalogRefreshGeneration++;
2861
+ this.#notifyCatalogChanged();
2862
+ void registry.dispose();
2863
+ };
2864
+ return { valid: diagnostics.valid, changed, diagnostics, registry, isCurrent, commit, rollback, finalize };
2865
+ }
2866
+
2867
+ #acquireLease(kind: RegistryLeaseWaiter["kind"], signal?: AbortSignal): Promise<() => void> {
2868
+ if (this.#disposed) return Promise.reject(new Error("Model registry is disposed"));
2869
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
2870
+ if (
2871
+ !this.#publicationFenceHeld &&
2872
+ this.#leaseWaiters.length === 0 &&
2873
+ (kind === "consumer" || this.#activeConsumerLeases === 0)
2874
+ ) {
2875
+ return Promise.resolve(this.#grantLease(kind));
2876
+ }
2877
+ const { promise, resolve, reject } = Promise.withResolvers<() => void>();
2878
+ const waiter: RegistryLeaseWaiter = { kind, signal, resolve, reject, settled: false };
2879
+ if (signal) {
2880
+ waiter.abort = () => {
2881
+ if (waiter.settled) return;
2882
+ waiter.settled = true;
2883
+ this.#leaseWaiters = this.#leaseWaiters.filter(item => item !== waiter);
2884
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
2885
+ this.#drainLeaseWaiters();
2886
+ };
2887
+ signal.addEventListener("abort", waiter.abort, { once: true });
2888
+ }
2889
+ this.#leaseWaiters.push(waiter);
2890
+ this.#drainLeaseWaiters();
2891
+ return promise;
2892
+ }
2893
+
2894
+ #grantLease(kind: RegistryLeaseWaiter["kind"]): () => void {
2895
+ if (kind === "publication") this.#publicationFenceHeld = true;
2896
+ else this.#activeConsumerLeases += 1;
2897
+ let released = false;
2898
+ return () => {
2899
+ if (released) return;
2900
+ released = true;
2901
+ if (kind === "publication") this.#publicationFenceHeld = false;
2902
+ else this.#activeConsumerLeases -= 1;
2903
+ this.#drainLeaseWaiters();
2904
+ };
2905
+ }
2906
+
2907
+ #drainLeaseWaiters(): void {
2908
+ if (this.#publicationFenceHeld) return;
2909
+ while (this.#leaseWaiters.length > 0) {
2910
+ const waiter = this.#leaseWaiters[0]!;
2911
+ if (waiter.kind === "publication") {
2912
+ if (this.#activeConsumerLeases > 0) return;
2913
+ this.#leaseWaiters.shift();
2914
+ waiter.settled = true;
2915
+ if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort);
2916
+ waiter.resolve(this.#grantLease("publication"));
2917
+ return;
2918
+ }
2919
+ this.#leaseWaiters.shift();
2920
+ waiter.settled = true;
2921
+ if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort);
2922
+ waiter.resolve(this.#grantLease("consumer"));
2923
+ }
2924
+ }
2925
+
2926
+ async withConsumerLease<T>(body: () => T | Promise<T>, signal?: AbortSignal): Promise<T> {
2927
+ signal?.throwIfAborted();
2928
+ if (this.#consumerContext.getStore()?.active) return await body();
2929
+ const release = await this.#acquireLease("consumer", signal);
2930
+ const owner = { active: true };
2931
+ try {
2932
+ return await this.#consumerContext.run(owner, body);
2933
+ } finally {
2934
+ owner.active = false;
2935
+ release();
2936
+ }
2937
+ }
2938
+
2939
+ async acquirePublicationFence(signal?: AbortSignal): Promise<() => void> {
2940
+ if (this.#consumerContext.getStore()?.active) {
2941
+ throw new Error("Cannot publish configuration while holding a model consumer lease");
2942
+ }
2943
+ const release = await this.#acquireLease("publication", signal);
2944
+ const gate = Promise.withResolvers<void>();
2945
+ const entered = Promise.withResolvers<void>();
2946
+ const barrier = this.#enqueueCatalogMutation(async () => {
2947
+ entered.resolve();
2948
+ await gate.promise;
2949
+ });
2950
+ const cancelled = Promise.withResolvers<void>();
2951
+ const abort = () => cancelled.reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
2952
+ signal?.addEventListener("abort", abort, { once: true });
2953
+ try {
2954
+ if (signal?.aborted) abort();
2955
+ await Promise.race([entered.promise, cancelled.promise]);
2956
+ signal?.throwIfAborted();
2957
+ } catch (error) {
2958
+ gate.resolve();
2959
+ void barrier.finally(release);
2960
+ throw error;
2961
+ } finally {
2962
+ signal?.removeEventListener("abort", abort);
2963
+ }
2964
+ let released = false;
2965
+ return () => {
2966
+ if (released) return;
2967
+ released = true;
2968
+ gate.resolve();
2969
+ void barrier.finally(release);
2970
+ };
1889
2971
  }
1890
2972
 
1891
2973
  dispose(): Promise<void> {
1892
2974
  if (this.#disposePromise) return this.#disposePromise;
1893
2975
  this.#disposed = true;
2976
+ for (const waiter of this.#leaseWaiters.splice(0)) {
2977
+ waiter.settled = true;
2978
+ if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort);
2979
+ waiter.reject(new Error("Model registry is disposed"));
2980
+ }
1894
2981
  this.#catalogRefreshGeneration++;
1895
2982
  const awaitRefreshDisposal = this.#cancelModelPresetRegistryRefresh?.() ?? Promise.resolve();
1896
2983
  this.#cancelModelPresetRegistryRefresh = undefined;
1897
2984
  this.#unsubscribeAuthGeneration?.();
1898
2985
  this.#unsubscribeAuthGeneration = undefined;
1899
- this.authStorage.clearConfigApiKeys(this.#authStorageConfigOwner);
2986
+ this.#clearOwnedConfigApiKeys();
1900
2987
  this.#disposeAuthStorageFallbackResolver?.();
1901
2988
  this.#disposeAuthStorageFallbackResolver = undefined;
1902
2989
  this.#catalogChangeListeners.clear();
@@ -2141,6 +3228,11 @@ export class ModelRegistry {
2141
3228
  );
2142
3229
  return JSON.stringify({
2143
3230
  apiKeyEnv: [...this.#configuredApiKeyEnvNames].sort().map(name => [name, Bun.env[name] ?? ""]),
3231
+ credentialPins: this.#stagedReloadCandidate
3232
+ ? []
3233
+ : [...this.#configuredProviderIds]
3234
+ .sort()
3235
+ .map(provider => [provider, this.authStorage.hasAnyCredentialPin(provider)]),
2144
3236
  implicitEndpoints: [
2145
3237
  ["OLLAMA_BASE_URL", Bun.env.OLLAMA_BASE_URL || ""],
2146
3238
  ["LLAMA_CPP_BASE_URL", Bun.env.LLAMA_CPP_BASE_URL || ""],
@@ -2171,7 +3263,7 @@ export class ModelRegistry {
2171
3263
  environmentFingerprint === this.#lastStaticLoadEnvironmentFingerprint &&
2172
3264
  modelPresetRegistryFingerprint === this.#lastModelPresetRegistryFingerprint
2173
3265
  ) {
2174
- // models.json and settings-derived implicit provider state are unchanged.
3266
+ // Config, settings-derived provider state, and effective credential selectors are unchanged.
2175
3267
  return;
2176
3268
  }
2177
3269
  this.#modelsConfigFile.invalidate();
@@ -2189,7 +3281,7 @@ export class ModelRegistry {
2189
3281
  // Drop config-sourced apiKeys from AuthStorage before reload; entries
2190
3282
  // removed from models.yml must actually disappear from the resolver, not
2191
3283
  // linger from the previous parse. The post-load setters below repopulate.
2192
- this.authStorage.clearConfigApiKeys(this.#authStorageConfigOwner);
3284
+ this.#clearOwnedConfigApiKeys();
2193
3285
  // Runtime provider keys are reapplied after #loadModels so they retain
2194
3286
  // registration-time precedence over colliding static provider keys.
2195
3287
  this.#providerOverrides.clear();
@@ -2202,7 +3294,7 @@ export class ModelRegistry {
2202
3294
  const resolved = this.#runtimeProviderApiKeyEnvNames.has(provider)
2203
3295
  ? $rotatingCredentialEnv(this.#runtimeProviderApiKeyEnvNames.get(provider)!)
2204
3296
  : resolveApiKeyConfig(apiKeyConfig);
2205
- if (!resolved) {
3297
+ if (!resolved || this.authStorage.hasAnyCredentialPin(provider)) {
2206
3298
  this.#runtimeProviderCredentialInstalled.delete(provider);
2207
3299
  const authHeader = this.#runtimeProviderAuthHeaders.get(provider);
2208
3300
  if (authHeader === true) {
@@ -2225,7 +3317,7 @@ export class ModelRegistry {
2225
3317
  this.#customProviderApiKeys.set(provider, resolved);
2226
3318
  this.#runtimeProviderResolvedApiKeys.set(provider, resolved);
2227
3319
  this.#runtimeProviderCredentialInstalled.add(provider);
2228
- this.authStorage.setConfigApiKey(provider, resolved, { owner: this.#authStorageConfigOwner });
3320
+ this.#setOwnedConfigApiKey(provider, resolved, false, "runtime");
2229
3321
  const override = this.#runtimeProviderOverrides.get(provider);
2230
3322
  if (override) this.#runtimeProviderOverrides.set(provider, { ...override, apiKey: resolved });
2231
3323
  const authHeader = this.#runtimeProviderAuthHeaders.get(provider);
@@ -2258,11 +3350,11 @@ export class ModelRegistry {
2258
3350
  const resolved = this.#runtimeProviderApiKeyEnvNames.has(provider)
2259
3351
  ? $rotatingCredentialEnv(this.#runtimeProviderApiKeyEnvNames.get(provider)!)
2260
3352
  : resolveApiKeyConfig(apiKeyConfig);
2261
- if (!resolved) continue;
3353
+ if (!resolved || this.authStorage.hasAnyCredentialPin(provider)) continue;
2262
3354
  this.#customProviderApiKeys.set(provider, resolved);
2263
3355
  this.#runtimeProviderResolvedApiKeys.set(provider, resolved);
2264
3356
  this.#runtimeProviderCredentialInstalled.add(provider);
2265
- this.authStorage.setConfigApiKey(provider, resolved, { owner: this.#authStorageConfigOwner });
3357
+ this.#setOwnedConfigApiKey(provider, resolved, false, "runtime");
2266
3358
  }
2267
3359
  this.#lastDisabledProviderKey = disabledProviderKey;
2268
3360
  }
@@ -2298,10 +3390,10 @@ export class ModelRegistry {
2298
3390
  this.#codexContextWindowOverrides = this.#collectCodexContextWindowOverrides();
2299
3391
  this.#equivalenceConfig = equivalence;
2300
3392
  this.#modelBindingsApplier.setBindings(modelBindings);
2301
- const acceptedPresets = loadAcceptedModelPresetProfiles(
2302
- this.#modelPresetRegistryAgentDir,
2303
- this.#modelPresetRegistryDependencies,
2304
- );
3393
+ const acceptedPresets = this.#stagedReloadCandidate
3394
+ ? this.#acceptedPresets!
3395
+ : loadAcceptedModelPresetProfiles(this.#modelPresetRegistryAgentDir, this.#modelPresetRegistryDependencies);
3396
+ this.#acceptedPresets = acceptedPresets;
2305
3397
  const acceptedRegistryError = acceptedPresets.error
2306
3398
  ? new ConfigError("model-preset-registry", undefined, {
2307
3399
  err: new Error(acceptedPresets.error),
@@ -2476,6 +3568,9 @@ export class ModelRegistry {
2476
3568
  const inheritedCompat = explicitTransportMatches
2477
3569
  ? explicitTransport?.compat
2478
3570
  : commonRegistryTransportCompat(transportTemplates);
3571
+ const streamIdleTimeoutMs = isRequestTransformApi(registryModel.api)
3572
+ ? (registryModel.streamIdleTimeoutMs ?? explicitTransport?.streamIdleTimeoutMs)
3573
+ : undefined;
2479
3574
  const hydratedRegistryModel = {
2480
3575
  ...registryModel,
2481
3576
  baseUrl: transportSource.baseUrl!,
@@ -2483,6 +3578,7 @@ export class ModelRegistry {
2483
3578
  transport: transportSource.transport,
2484
3579
  requestTransform: transportSource.requestTransform,
2485
3580
  cacheRetention: transportSource.cacheRetention,
3581
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
2486
3582
  isOAuth: transportSource.isOAuth,
2487
3583
  compat:
2488
3584
  inheritedCompat || registryModel.compat || explicitTransport?.compat
@@ -2498,14 +3594,23 @@ export class ModelRegistry {
2498
3594
  continue;
2499
3595
  }
2500
3596
  const existing = merged[existingIndex]!;
3597
+ const { streamIdleTimeoutMs: existingStreamIdleTimeoutMs, ...existingWithoutStreamIdleTimeout } = existing;
2501
3598
  const effectiveApi = explicitTransport?.api ?? existing.api;
2502
3599
  const registryApiMatchesEffectiveTransport = registryModel.api === effectiveApi;
2503
3600
  const registryMetadata = registryApiMatchesEffectiveTransport
2504
3601
  ? registryModel
2505
3602
  : registryModelMetadataWithoutApiSpecificFields(registryModel);
3603
+ const {
3604
+ streamIdleTimeoutMs: registryMetadataStreamIdleTimeoutMs,
3605
+ ...registryMetadataWithoutStreamIdleTimeout
3606
+ } = registryMetadata;
3607
+ const streamIdleTimeoutMs = isRequestTransformApi(effectiveApi)
3608
+ ? (registryMetadataStreamIdleTimeoutMs ?? existingStreamIdleTimeoutMs)
3609
+ : undefined;
2506
3610
  merged[existingIndex] = {
2507
- ...existing,
2508
- ...registryMetadata,
3611
+ ...existingWithoutStreamIdleTimeout,
3612
+ ...registryMetadataWithoutStreamIdleTimeout,
3613
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
2509
3614
  api: explicitTransport?.api ?? existing.api,
2510
3615
  baseUrl: existing.baseUrl,
2511
3616
  headers: existing.headers,
@@ -2542,9 +3647,14 @@ export class ModelRegistry {
2542
3647
  const existingIndex = indexByKey.get(key);
2543
3648
  if (existingIndex !== undefined) {
2544
3649
  const existingModel = merged[existingIndex];
3650
+ const { streamIdleTimeoutMs: existingStreamIdleTimeoutMs, ...existingModelWithoutStreamIdleTimeout } =
3651
+ existingModel;
2545
3652
  const referenceModel = resolveCustomModelReference(customModel.id);
3653
+ const streamIdleTimeoutMs = isRequestTransformApi(customModel.api)
3654
+ ? (customModel.streamIdleTimeoutMs ?? existingStreamIdleTimeoutMs)
3655
+ : undefined;
2546
3656
  merged[existingIndex] = enrichModelThinking({
2547
- ...existingModel,
3657
+ ...existingModelWithoutStreamIdleTimeout,
2548
3658
  id: customModel.id,
2549
3659
  provider: customModel.provider,
2550
3660
  api: customModel.api,
@@ -2568,6 +3678,7 @@ export class ModelRegistry {
2568
3678
  wireModelId: customModel.wireModelId,
2569
3679
  requestTransform: customModel.requestTransform,
2570
3680
  premiumMultiplier: customModel.premiumMultiplier ?? existingModel.premiumMultiplier,
3681
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
2571
3682
  } as Model<Api>);
2572
3683
  } else {
2573
3684
  merged.push(finalizeCustomModel(customModel, { useDefaults: true }));
@@ -2740,10 +3851,12 @@ export class ModelRegistry {
2740
3851
  });
2741
3852
  }
2742
3853
  #sanitizeDiscoverableModelsForCache(_providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
2743
- return this.#stripModelBaseUrlQueries(models).map(model => ({
2744
- ...model,
2745
- headers: undefined,
2746
- }));
3854
+ return this.#stripModelBaseUrlQueries(models).map(model => {
3855
+ const cachedModel = { ...model, headers: undefined };
3856
+ // Model idle timeouts come from local config and must be re-derived on cache load.
3857
+ delete cachedModel.streamIdleTimeoutMs;
3858
+ return cachedModel;
3859
+ });
2747
3860
  }
2748
3861
  #stripModelBaseUrlQueries(models: readonly Model<Api>[]): Model<Api>[] {
2749
3862
  return models.map(model => createTrustedStrippedModelClone(model));
@@ -2824,7 +3937,10 @@ export class ModelRegistry {
2824
3937
 
2825
3938
  #loadCustomModels(): CustomModelsResult {
2826
3939
  this.#configuredApiKeyEnvNames.clear();
2827
- const { value, error, status } = this.#modelsConfigFile.tryLoad();
3940
+ const loaded = this.#modelsConfigSource ?? this.#modelsConfigFile.tryLoad();
3941
+ const { value, error, status } = loaded;
3942
+ this.#acceptedConfigValue = status === "ok" ? value : undefined;
3943
+ if (status === "ok") this.#acceptedModelsConfigPresent = true;
2828
3944
 
2829
3945
  if (status === "error") {
2830
3946
  return {
@@ -2859,6 +3975,7 @@ export class ModelRegistry {
2859
3975
  const configuredProviders = new Set(Object.keys(value.providers ?? {}));
2860
3976
 
2861
3977
  for (const [providerName, providerConfig] of providerEntries) {
3978
+ const credentialPinActive = !this.#stagedReloadCandidate && this.authStorage.hasAnyCredentialPin(providerName);
2862
3979
  const authMode = (providerConfig.auth ?? "apiKey") as ProviderAuthMode;
2863
3980
  const isOAuth = resolveCustomModelIsOAuth(
2864
3981
  (providerConfig.api as Api | undefined) ?? "openai-completions",
@@ -2873,9 +3990,11 @@ export class ModelRegistry {
2873
3990
  if (providerConfig.openaiCompat?.apiKey)
2874
3991
  this.#configuredApiKeyEnvNames.add(providerConfig.openaiCompat.apiKey);
2875
3992
  if (providerConfig.webSearch) this.#providerWebSearchModes.set(providerName, providerConfig.webSearch);
2876
- const providerApiKeyConfig = providerConfig.apiKey
2877
- ? resolveApiKeyConfig(providerConfig.apiKey)
2878
- : resolveApiKeyEnvConfig(providerConfig.apiKeyEnv);
3993
+ const providerApiKeyConfig = credentialPinActive
3994
+ ? undefined
3995
+ : providerConfig.apiKey
3996
+ ? resolveApiKeyConfig(providerConfig.apiKey)
3997
+ : resolveApiKeyEnvConfig(providerConfig.apiKeyEnv);
2879
3998
  const localOpenAICompat = providerConfig.openaiCompat;
2880
3999
  const rotatingApiKeyEnv = providerConfig.apiKey
2881
4000
  ? undefined
@@ -2883,18 +4002,21 @@ export class ModelRegistry {
2883
4002
  if (rotatingApiKeyEnv) this.#customProviderApiKeyEnvNames.set(providerName, rotatingApiKeyEnv);
2884
4003
  if (providerConfig.authHeader !== undefined)
2885
4004
  this.#customProviderAuthHeaders.set(providerName, providerConfig.authHeader);
2886
- const localOpenAICompatApiKeyConfig = localOpenAICompat
2887
- ? localOpenAICompat.apiKey
2888
- ? resolveApiKeyConfig(localOpenAICompat.apiKey)
2889
- : resolveApiKeyEnvConfig(localOpenAICompat.apiKeyEnv)
2890
- : undefined;
4005
+ const localOpenAICompatApiKeyConfig =
4006
+ localOpenAICompat && !credentialPinActive
4007
+ ? localOpenAICompat.apiKey
4008
+ ? resolveApiKeyConfig(localOpenAICompat.apiKey)
4009
+ : resolveApiKeyEnvConfig(localOpenAICompat.apiKeyEnv)
4010
+ : undefined;
2891
4011
  if (localOpenAICompat) {
2892
4012
  const localOpenAICompatBaseUrl = normalizeLocalOpenAICompatBaseUrl(localOpenAICompat.baseUrl);
2893
- const localCompatResolvedKey = localOpenAICompat.apiKey
2894
- ? resolveApiKeyConfig(localOpenAICompat.apiKey)
2895
- : localOpenAICompat.apiKeyEnv
2896
- ? resolveApiKeyEnvConfig(localOpenAICompat.apiKeyEnv)
2897
- : undefined;
4013
+ const localCompatResolvedKey = credentialPinActive
4014
+ ? undefined
4015
+ : localOpenAICompat.apiKey
4016
+ ? resolveApiKeyConfig(localOpenAICompat.apiKey)
4017
+ : localOpenAICompat.apiKeyEnv
4018
+ ? resolveApiKeyEnvConfig(localOpenAICompat.apiKeyEnv)
4019
+ : undefined;
2898
4020
  overrides.set(providerName, {
2899
4021
  api: "openai-completions",
2900
4022
  baseUrl: localOpenAICompatBaseUrl,
@@ -2920,10 +4042,7 @@ export class ModelRegistry {
2920
4042
  });
2921
4043
  if (localCompatResolvedKey) {
2922
4044
  this.#customProviderApiKeys.set(providerName, localCompatResolvedKey);
2923
- this.authStorage.setConfigApiKey(providerName, localCompatResolvedKey, {
2924
- envSourced: !localOpenAICompat.apiKey,
2925
- owner: this.#authStorageConfigOwner,
2926
- });
4045
+ this.#setOwnedConfigApiKey(providerName, localCompatResolvedKey, !localOpenAICompat.apiKey);
2927
4046
  } else {
2928
4047
  keylessProviders.add(providerName);
2929
4048
  this.#optionalAuthProviders.add(providerName);
@@ -2940,10 +4059,11 @@ export class ModelRegistry {
2940
4059
  providerConfig.disableStrictTools ||
2941
4060
  providerConfig.requestTransform ||
2942
4061
  providerConfig.transport ||
2943
- providerConfig.cacheRetention
4062
+ providerConfig.cacheRetention ||
4063
+ providerConfig.streamIdleTimeoutMs !== undefined
2944
4064
  ) {
2945
4065
  const disableStrictCompat = providerConfig.disableStrictTools ? { disableStrictTools: true } : undefined;
2946
- overrides.set(providerName, {
4066
+ const providerOverride: ProviderOverride = {
2947
4067
  api: providerConfig.api as Api | undefined,
2948
4068
  baseUrl: providerConfig.baseUrl,
2949
4069
  headers: providerConfig.headers,
@@ -2953,8 +4073,14 @@ export class ModelRegistry {
2953
4073
  transport: providerConfig.transport,
2954
4074
  requestTransform: providerConfig.requestTransform,
2955
4075
  cacheRetention: providerConfig.cacheRetention,
4076
+ streamIdleTimeoutMs: providerConfig.streamIdleTimeoutMs,
2956
4077
  isOAuth,
2957
- });
4078
+ };
4079
+ const existingOverride = overrides.get(providerName);
4080
+ overrides.set(
4081
+ providerName,
4082
+ existingOverride ? this.#mergeProviderOverride(existingOverride, providerOverride) : providerOverride,
4083
+ );
2958
4084
  }
2959
4085
 
2960
4086
  if (authMode === "none") {
@@ -3006,22 +4132,20 @@ export class ModelRegistry {
3006
4132
  });
3007
4133
  }
3008
4134
 
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.
4135
+ // Store API keys for fallback resolution and register config overrides.
4136
+ // A pin in any session suppresses shared config keys so a reload cannot
4137
+ // override another session's selected account.
3013
4138
  if (providerConfig.apiKey || providerConfig.apiKeyEnv) {
3014
- const resolved = providerConfig.apiKey
3015
- ? resolveApiKeyConfig(providerConfig.apiKey)
3016
- : providerConfig.apiKeyEnv
3017
- ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
3018
- : undefined;
4139
+ const resolved = credentialPinActive
4140
+ ? undefined
4141
+ : providerConfig.apiKey
4142
+ ? resolveApiKeyConfig(providerConfig.apiKey)
4143
+ : providerConfig.apiKeyEnv
4144
+ ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
4145
+ : undefined;
3019
4146
  if (resolved) this.#customProviderApiKeys.set(providerName, resolved);
3020
4147
  if (resolved) {
3021
- this.authStorage.setConfigApiKey(providerName, resolved, {
3022
- envSourced: !providerConfig.apiKey,
3023
- owner: this.#authStorageConfigOwner,
3024
- });
4148
+ this.#setOwnedConfigApiKey(providerName, resolved, !providerConfig.apiKey);
3025
4149
  }
3026
4150
  }
3027
4151
 
@@ -3205,8 +4329,15 @@ export class ModelRegistry {
3205
4329
  }
3206
4330
 
3207
4331
  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));
4332
+ const configPath = this.#modelsConfigFile.path();
4333
+ const parentPath = path.dirname(configPath);
4334
+ await fs.mkdir(parentPath, { recursive: true, mode: 0o700 });
4335
+ // The owner-only writer rejects a symlink in any path component and rejects
4336
+ // "..". Resolve only the parent so an alias directory and a lexical ".."
4337
+ // still reach the same file. The final name stays lexical, so a symlink
4338
+ // at models.yml is still opened with O_NOFOLLOW.
4339
+ const parent = await fs.realpath(parentPath);
4340
+ writeExplicitOwnerOnlyTextSync(path.join(parent, path.basename(configPath)), Bun.YAML.stringify(config, null, 2));
3210
4341
  this.#modelsConfigFile.invalidate();
3211
4342
  this.#reloadStaticModels();
3212
4343
  }
@@ -4319,36 +5450,7 @@ export class ModelRegistry {
4319
5450
  modelId: string,
4320
5451
  entry?: { id?: unknown; owned_by?: unknown },
4321
5452
  ): 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;
5453
+ return resolveModelApiFromDiscovery(providerConfig.api, providerConfig.discovery, modelId, entry);
4352
5454
  }
4353
5455
 
4354
5456
  async #discoverModelsDevProvider(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
@@ -4823,6 +5925,7 @@ export class ModelRegistry {
4823
5925
  transport: override.transport ?? baseOverride?.transport,
4824
5926
  requestTransform: mergeRequestTransform(baseOverride?.requestTransform, override.requestTransform),
4825
5927
  cacheRetention: override.cacheRetention ?? baseOverride?.cacheRetention,
5928
+ streamIdleTimeoutMs: override.streamIdleTimeoutMs ?? baseOverride?.streamIdleTimeoutMs,
4826
5929
  isOAuth: override.isOAuth ?? baseOverride?.isOAuth,
4827
5930
  };
4828
5931
  }
@@ -4838,6 +5941,7 @@ export class ModelRegistry {
4838
5941
  | "transport"
4839
5942
  | "requestTransform"
4840
5943
  | "cacheRetention"
5944
+ | "streamIdleTimeoutMs"
4841
5945
  >,
4842
5946
  ): T {
4843
5947
  const overrideHeaders = (
@@ -4872,6 +5976,8 @@ export class ModelRegistry {
4872
5976
  delete overrideHeaders[GENERATED_AUTH_HEADER];
4873
5977
  }
4874
5978
  const headers = mergeAuthHeader(overrideHeaders, canInject, canInject ? override.apiKey : undefined);
5979
+ const streamIdleTimeoutMs =
5980
+ entry.streamIdleTimeoutMs ?? (isRequestTransformApi(entry.api) ? override.streamIdleTimeoutMs : undefined);
4875
5981
  const result = {
4876
5982
  ...entry,
4877
5983
  compat: mergeProviderCompat(entry.compat, override.compat),
@@ -4885,6 +5991,7 @@ export class ModelRegistry {
4885
5991
  override.requestTransform,
4886
5992
  ),
4887
5993
  cacheRetention: entry.cacheRetention ?? override.cacheRetention,
5994
+ ...(streamIdleTimeoutMs !== undefined ? { streamIdleTimeoutMs } : {}),
4888
5995
  };
4889
5996
  if (!canInject && explicitAuthKey !== undefined) {
4890
5997
  delete (result.headers as Record<string, string> & { [GENERATED_AUTH_HEADER]?: string })[
@@ -5143,19 +6250,17 @@ export class ModelRegistry {
5143
6250
  for (const [providerName, providerConfig] of Object.entries(config.providers ?? {})) {
5144
6251
  const modelDefs = providerConfig.models ?? [];
5145
6252
  if (modelDefs.length === 0) continue; // Override-only, no custom models
5146
- if (providerConfig.apiKey || providerConfig.apiKeyEnv) {
5147
- const resolved = providerConfig.apiKey
6253
+ const credentialPinActive = !this.#stagedReloadCandidate && this.authStorage.hasAnyCredentialPin(providerName);
6254
+ const apiKey = credentialPinActive
6255
+ ? undefined
6256
+ : providerConfig.apiKey
5148
6257
  ? resolveApiKeyConfig(providerConfig.apiKey)
5149
6258
  : providerConfig.apiKeyEnv
5150
6259
  ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
5151
6260
  : 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
- }
6261
+ if (providerConfig.apiKey || providerConfig.apiKeyEnv) {
6262
+ if (apiKey) this.#customProviderApiKeys.set(providerName, apiKey);
6263
+ if (apiKey) this.#setOwnedConfigApiKey(providerName, apiKey, !providerConfig.apiKey);
5159
6264
  }
5160
6265
  for (const modelDef of modelDefs) {
5161
6266
  const providerCompat = providerConfig.disableStrictTools
@@ -5166,11 +6271,7 @@ export class ModelRegistry {
5166
6271
  providerConfig.baseUrl!,
5167
6272
  providerConfig.api as Api | undefined,
5168
6273
  providerConfig.headers,
5169
- providerConfig.apiKey
5170
- ? resolveApiKeyConfig(providerConfig.apiKey)
5171
- : providerConfig.apiKeyEnv
5172
- ? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv)
5173
- : undefined,
6274
+ apiKey,
5174
6275
  providerConfig.authHeader,
5175
6276
  providerCompat,
5176
6277
  providerConfig.requestTransform,
@@ -5178,6 +6279,7 @@ export class ModelRegistry {
5178
6279
  providerConfig.cacheRetention,
5179
6280
  providerConfig.transport,
5180
6281
  modelDef as CustomModelDefinitionLike,
6282
+ providerConfig.streamIdleTimeoutMs,
5181
6283
  );
5182
6284
  if (!model) continue;
5183
6285
  models.push(model);
@@ -5199,6 +6301,67 @@ export class ModelRegistry {
5199
6301
  return [...this.#configuredProviderIds];
5200
6302
  }
5201
6303
 
6304
+ /** Configured API-key sources in models.yml precedence order, excluding absent environment values. */
6305
+ getProviderConfigApiKeySources(
6306
+ provider: string,
6307
+ ): Array<{ kind: "environment"; name: string; provider: string } | { kind: "models-yml"; provider: string }> {
6308
+ const normalizedProvider = resolveOAuthStorageProvider(provider);
6309
+ const configuredProviders = this.#acceptedConfigValue?.providers;
6310
+ const configuredAliases = Object.entries(configuredProviders ?? {}).filter(
6311
+ ([configuredProvider]) => resolveOAuthStorageProvider(configuredProvider) === normalizedProvider,
6312
+ );
6313
+ const sources: Array<
6314
+ { kind: "environment"; name: string; provider: string } | { kind: "models-yml"; provider: string }
6315
+ > = [];
6316
+ const addSource = (
6317
+ configuredProvider: string,
6318
+ apiKey: string | undefined,
6319
+ apiKeyEnv: string | undefined,
6320
+ ): void => {
6321
+ if (apiKey) {
6322
+ if (Bun.env[apiKey]) sources.push({ kind: "environment", name: apiKey, provider: configuredProvider });
6323
+ else sources.push({ kind: "models-yml", provider: configuredProvider });
6324
+ return;
6325
+ }
6326
+ if (apiKeyEnv && $rotatingCredentialEnv(apiKeyEnv)) {
6327
+ sources.push({ kind: "environment", name: apiKeyEnv, provider: configuredProvider });
6328
+ }
6329
+ };
6330
+ for (const [configuredProvider, configured] of configuredAliases) {
6331
+ addSource(configuredProvider, configured.apiKey, configured.apiKeyEnv);
6332
+ addSource(configuredProvider, configured.openaiCompat?.apiKey, configured.openaiCompat?.apiKeyEnv);
6333
+ }
6334
+ return sources.filter(
6335
+ (source, index) =>
6336
+ source.kind === "models-yml" ||
6337
+ sources.findIndex(
6338
+ candidate =>
6339
+ candidate.kind === "environment" &&
6340
+ candidate.name === source.name &&
6341
+ candidate.provider === source.provider,
6342
+ ) === index,
6343
+ );
6344
+ }
6345
+
6346
+ /** Environment variable name for a provider API key supplied by runtime setup. */
6347
+ getProviderRuntimeApiKeyEnvName(provider: string): string | undefined {
6348
+ const normalizedProvider = resolveOAuthStorageProvider(provider);
6349
+ return (
6350
+ this.#runtimeProviderApiKeyEnvNames.get(provider) ??
6351
+ [...this.#runtimeProviderApiKeyEnvNames].find(
6352
+ ([registeredProvider]) => resolveOAuthStorageProvider(registeredProvider) === normalizedProvider,
6353
+ )?.[1]
6354
+ );
6355
+ }
6356
+
6357
+ /** Whether a runtime extension's provider API key is currently installed in AuthStorage. */
6358
+ hasRuntimeProviderApiKey(provider: string): boolean {
6359
+ const normalizedProvider = resolveOAuthStorageProvider(provider);
6360
+ return [...this.#runtimeProviderCredentialInstalled].some(
6361
+ registeredProvider => resolveOAuthStorageProvider(registeredProvider) === normalizedProvider,
6362
+ );
6363
+ }
6364
+
5202
6365
  /**
5203
6366
  * Whether this registry can serve a provider id from any active source.
5204
6367
  *
@@ -5986,6 +7149,19 @@ export class ModelRegistry {
5986
7149
  return apiKey;
5987
7150
  }
5988
7151
 
7152
+ /** Check credential availability without resolving keys or refreshing OAuth tokens. */
7153
+ hasUsableAuthForProvider(provider: string, sessionId?: string): boolean {
7154
+ return this.authStorage.hasUsableAuth(provider, {
7155
+ owner: this.#authStorageConfigOwner,
7156
+ sessionId,
7157
+ });
7158
+ }
7159
+
7160
+ /** @internal Whether this registry is an unpublished staged reload candidate. */
7161
+ isStagedReloadCandidate(): boolean {
7162
+ return this.#stagedReloadCandidate;
7163
+ }
7164
+
5989
7165
  #refreshRotatingConfigApiKey(provider: string, forceStatic = false): void {
5990
7166
  const runtimeOwned = !forceStatic && this.#runtimeProviderApiKeys.has(provider);
5991
7167
  const envName = runtimeOwned
@@ -5993,6 +7169,7 @@ export class ModelRegistry {
5993
7169
  : this.#customProviderApiKeyEnvNames.get(provider);
5994
7170
  if (!envName) return;
5995
7171
  const resolved = $rotatingCredentialEnv(envName);
7172
+ if (resolved !== undefined && this.authStorage.hasAnyCredentialPin(provider)) return;
5996
7173
  const previous = runtimeOwned
5997
7174
  ? this.#runtimeProviderResolvedApiKeys.get(provider)
5998
7175
  : this.#customProviderApiKeys.get(provider);
@@ -6006,7 +7183,7 @@ export class ModelRegistry {
6006
7183
  return;
6007
7184
  }
6008
7185
  this.#customProviderApiKeys.delete(provider);
6009
- this.authStorage.removeConfigApiKey(provider, this.#authStorageConfigOwner);
7186
+ this.#removeOwnedConfigApiKey(provider);
6010
7187
  } else {
6011
7188
  this.#customProviderApiKeys.set(provider, resolved);
6012
7189
  if (runtimeOwned) {
@@ -6014,12 +7191,9 @@ export class ModelRegistry {
6014
7191
  this.#runtimeProviderCredentialInstalled.add(provider);
6015
7192
  }
6016
7193
  if (runtimeOwned) {
6017
- this.authStorage.setConfigApiKey(provider, resolved, { owner: this.#authStorageConfigOwner });
7194
+ this.#setOwnedConfigApiKey(provider, resolved, false, "runtime");
6018
7195
  } else {
6019
- this.authStorage.setConfigApiKey(provider, resolved, {
6020
- envSourced: true,
6021
- owner: this.#authStorageConfigOwner,
6022
- });
7196
+ this.#setOwnedConfigApiKey(provider, resolved, true);
6023
7197
  }
6024
7198
  }
6025
7199
  const authHeader =
@@ -6166,7 +7340,7 @@ export class ModelRegistry {
6166
7340
  this.#runtimeProviderOverrides.delete(providerName);
6167
7341
  this.#runtimeProviderAuthHeaders.delete(providerName);
6168
7342
  this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName);
6169
- this.authStorage.removeConfigApiKey(providerName, this.#authStorageConfigOwner);
7343
+ this.#removeOwnedConfigApiKey(providerName);
6170
7344
  this.#clearDescriptorDiscoveryEvidence(providerName);
6171
7345
  }
6172
7346
 
@@ -6174,6 +7348,7 @@ export class ModelRegistry {
6174
7348
  * Remove custom API/OAuth registrations for a specific extension source.
6175
7349
  */
6176
7350
  clearSourceRegistrations(sourceId: string): void {
7351
+ this.#catalogRefreshGeneration++;
6177
7352
  unregisterCustomApis(sourceId);
6178
7353
  unregisterOAuthProviders(sourceId);
6179
7354
  const sourceProviders = this.#runtimeProvidersBySource.get(sourceId);
@@ -6217,6 +7392,7 @@ export class ModelRegistry {
6217
7392
  * If provider has oauth: registers OAuth provider for /login support.
6218
7393
  */
6219
7394
  registerProvider(providerName: string, config: ProviderConfigInput, sourceId?: string): void {
7395
+ this.#catalogRefreshGeneration++;
6220
7396
  if (config.streamSimple && !config.api) {
6221
7397
  throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
6222
7398
  }
@@ -6304,7 +7480,7 @@ export class ModelRegistry {
6304
7480
  this.#runtimeProviderResolvedApiKeys.set(providerName, resolved);
6305
7481
  this.#runtimeProviderCredentialInstalled.add(providerName);
6306
7482
  if (config.authHeader !== undefined) this.#runtimeProviderAuthHeaders.set(providerName, config.authHeader);
6307
- this.authStorage.setConfigApiKey(providerName, resolved, { owner: this.#authStorageConfigOwner });
7483
+ this.#setOwnedConfigApiKey(providerName, resolved, false, "runtime");
6308
7484
  }
6309
7485
  if (config.oauth && !config.apiKey && this.#runtimeProviderApiKeys.has(providerName)) {
6310
7486
  const previousApiKey = this.#runtimeProviderResolvedApiKeys.get(providerName);
@@ -6314,7 +7490,7 @@ export class ModelRegistry {
6314
7490
  this.#runtimeProviderApiKeyEnvNames.delete(providerName);
6315
7491
  this.#runtimeProviderAuthHeaders.delete(providerName);
6316
7492
  this.#customProviderApiKeys.delete(providerName);
6317
- this.authStorage.removeConfigApiKey(providerName, this.#authStorageConfigOwner);
7493
+ this.#removeOwnedConfigApiKey(providerName);
6318
7494
  this.#runtimeModelOverlays = this.#runtimeModelOverlays.map(overlay => {
6319
7495
  if (overlay.provider !== providerName) return overlay;
6320
7496
  const headers = { ...(overlay.headers ?? {}) } as Record<string, string> & {