@gajae-code/coding-agent 0.12.7 → 0.12.8

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 (95) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/commands/notify.d.ts +4 -0
  3. package/dist/types/config/file-lock.d.ts +3 -0
  4. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/resolve-config-value.d.ts +2 -1
  7. package/dist/types/config/settings-schema.d.ts +17 -1
  8. package/dist/types/main.d.ts +1 -0
  9. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  10. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  11. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  12. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  13. package/dist/types/sdk/broker/broker.d.ts +5 -1
  14. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  15. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  16. package/dist/types/sdk/bus/config.d.ts +75 -113
  17. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  18. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  19. package/dist/types/sdk/bus/index.d.ts +1 -1
  20. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  21. package/dist/types/sdk/bus/notification-service.d.ts +31 -4
  22. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  23. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  24. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  25. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  26. package/dist/types/sdk/client/discovery.d.ts +2 -1
  27. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  28. package/dist/types/sdk/index.d.ts +1 -0
  29. package/dist/types/sdk/providers.d.ts +22 -0
  30. package/dist/types/sdk/session-directory.d.ts +1 -1
  31. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  32. package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
  33. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
  34. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  35. package/dist/types/session/session-manager.d.ts +3 -1
  36. package/dist/types/session/session-storage.d.ts +2 -0
  37. package/package.json +10 -8
  38. package/scripts/build-sdk-package-smoke.ts +51 -0
  39. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  40. package/src/cli/notify-cli.ts +204 -94
  41. package/src/commands/notify.ts +19 -8
  42. package/src/config/file-lock.ts +6 -0
  43. package/src/config/model-discovery-manager.ts +50 -8
  44. package/src/config/model-registry.ts +862 -75
  45. package/src/config/resolve-config-value.ts +10 -8
  46. package/src/config/settings-schema.ts +25 -15
  47. package/src/config/settings.ts +15 -15
  48. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  49. package/src/internal-urls/docs-index.generated.ts +6 -6
  50. package/src/main.ts +43 -7
  51. package/src/modes/DESIGN.md +11 -11
  52. package/src/modes/components/hook-selector.ts +155 -44
  53. package/src/modes/components/model-selector.ts +242 -49
  54. package/src/modes/components/notifications-settings-editor.ts +631 -85
  55. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  56. package/src/modes/controllers/selector-controller.ts +464 -50
  57. package/src/sdk/broker/broker.ts +54 -10
  58. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  59. package/src/sdk/broker/lifecycle.ts +47 -50
  60. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  61. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  62. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  63. package/src/sdk/bus/config.ts +534 -286
  64. package/src/sdk/bus/discord-live-provider.ts +57 -2
  65. package/src/sdk/bus/discord-provider.ts +21 -0
  66. package/src/sdk/bus/index.ts +97 -35
  67. package/src/sdk/bus/notification-orchestration.ts +371 -68
  68. package/src/sdk/bus/notification-service.ts +431 -130
  69. package/src/sdk/bus/session-control.ts +119 -44
  70. package/src/sdk/bus/slack-live-provider.ts +78 -2
  71. package/src/sdk/bus/slack-provider.ts +24 -0
  72. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  73. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  74. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  75. package/src/sdk/bus/telegram-daemon.ts +23 -15
  76. package/src/sdk/client/discovery.ts +10 -4
  77. package/src/sdk/host/query/handlers.ts +20 -1
  78. package/src/sdk/index.ts +1 -0
  79. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  80. package/src/sdk/protocol/operation-registry.ts +5 -2
  81. package/src/sdk/providers.ts +51 -0
  82. package/src/sdk/session-directory.ts +1 -0
  83. package/src/sdk/session.ts +10 -7
  84. package/src/session/agent-session.ts +44 -24
  85. package/src/session/internal/managed-session-scope.ts +981 -450
  86. package/src/session/internal/managed-session-storage.ts +382 -45
  87. package/src/session/internal/native-publish-outcome.ts +3 -1
  88. package/src/session/internal/session-open-errors.ts +11 -0
  89. package/src/session/session-manager.ts +27 -3
  90. package/src/session/session-storage.ts +72 -5
  91. package/src/slash-commands/builtin-registry.ts +82 -4
  92. package/src/task/index.ts +9 -3
  93. package/src/tools/ask.ts +1 -1
  94. package/src/tools/bash.ts +1 -1
  95. package/src/tools/telegram-send.ts +4 -2
@@ -37,6 +37,11 @@ import type { OAuthCredentials, OAuthLoginCallbacks } from "@gajae-code/ai/utils
37
37
  import { $pickCredentialEnv, isRecord, logger } from "@gajae-code/utils";
38
38
  import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
39
39
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
40
+ import {
41
+ type ActiveProviderDescriptor,
42
+ ActiveProviderResolutionError,
43
+ projectActiveProviderDescriptors,
44
+ } from "../sdk/providers";
40
45
  import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
41
46
  import type { ActiveSearchModelContext, WebSearchMode } from "../web/search/types";
42
47
  import { type ConfigError, ConfigFile } from "./config-file";
@@ -77,6 +82,20 @@ export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant,
77
82
  export { isAuthenticated, kNoAuth };
78
83
 
79
84
  const MAX_SESSION_CANONICAL_VARIANTS = 64;
85
+ function redactDiscoveryUrl(value: string | URL): string {
86
+ try {
87
+ const url = typeof value === "string" ? new URL(value) : value;
88
+ return `${url.origin}${url.pathname}`;
89
+ } catch {
90
+ return "(invalid URL)";
91
+ }
92
+ }
93
+ function stripUrlQuery(value: string): string {
94
+ const queryStart = value.indexOf("?");
95
+ if (queryStart < 0) return value;
96
+ const fragmentStart = value.indexOf("#", queryStart);
97
+ return value.slice(0, queryStart) + (fragmentStart < 0 ? "" : value.slice(fragmentStart));
98
+ }
80
99
 
81
100
  function envAvailabilityFingerprint(): string {
82
101
  return Object.entries(process.env)
@@ -1014,6 +1033,24 @@ function getConfiguredProviderOrderFromSettings(): string[] {
1014
1033
  return [];
1015
1034
  }
1016
1035
  }
1036
+ interface ProviderActivityEvidence {
1037
+ staticModelIds: ReadonlySet<string>;
1038
+ staticConfigured: boolean;
1039
+ discoveryConfigured: boolean;
1040
+ implicitDiscovery: boolean;
1041
+ descriptorBacked: boolean;
1042
+ descriptorFresh: boolean;
1043
+ descriptorModelIds: ReadonlySet<string>;
1044
+ authGeneration: string;
1045
+ endpoint: string;
1046
+ }
1047
+
1048
+ interface ModelManagerDiscoveryOptions {
1049
+ options: ModelManagerOptions<Api>;
1050
+ authGeneration: string;
1051
+ apiKey: string | undefined;
1052
+ endpoint: string;
1053
+ }
1017
1054
 
1018
1055
  /**
1019
1056
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
@@ -1028,6 +1065,21 @@ export class ModelRegistry {
1028
1065
  #customProviderApiKeys: Map<string, string> = new Map();
1029
1066
  #providerWebSearchModes: Map<string, WebSearchMode> = new Map();
1030
1067
  #keylessProviders: Set<string> = new Set();
1068
+ #optionalAuthProviders: Set<string> = new Set();
1069
+ #credentiallessAuthFallbackProviders: Map<string, string> = new Map();
1070
+ #providerEvidenceApiKeys: Map<string, string | undefined> = new Map();
1071
+ #providerActivity: ReadonlyMap<string, ProviderActivityEvidence> = new Map();
1072
+ #configuredProviderIds: ReadonlySet<string> = new Set();
1073
+ #configuredDiscoveryProviderIds: ReadonlySet<string> = new Set();
1074
+ #descriptorDiscoveryEvidence = new Map<
1075
+ string,
1076
+ { fresh: boolean; modelIds: ReadonlySet<string>; authGeneration: string; endpoint: string }
1077
+ >();
1078
+ #descriptorDiscoveryGenerations = new Map<string, number>();
1079
+ #configuredDiscoveryEvidence = new Map<
1080
+ string,
1081
+ { authGeneration: string; endpoint: string; modelIds: ReadonlySet<string> }
1082
+ >();
1031
1083
  #discoveryManager = new ModelDiscoveryManager<DiscoveryProviderConfig>();
1032
1084
  #customModelOverlays: CustomModelOverlay[] = [];
1033
1085
  #providerOverrides: Map<string, ProviderOverride> = new Map();
@@ -1038,6 +1090,9 @@ export class ModelRegistry {
1038
1090
  #configError: ConfigError | undefined = undefined;
1039
1091
  #modelsConfigFile: ConfigFile<ModelsConfig>;
1040
1092
  #lastStaticLoadMtime: number | null = null;
1093
+ #lastStaticLoadEnvironmentFingerprint: string | undefined;
1094
+ #staticModelsLoaded = false;
1095
+ #lastDisabledProviderKey: string | undefined;
1041
1096
  #registeredProviderSources: Set<string> = new Set();
1042
1097
  #cacheDbPath?: string;
1043
1098
  #suppressedSelectors: Map<string, number> = new Map();
@@ -1051,6 +1106,9 @@ export class ModelRegistry {
1051
1106
  #runtimeProviderSourceByName: Map<string, string> = new Map();
1052
1107
  #rebuildPending: boolean = false;
1053
1108
  #rebuildSuspended: number = 0;
1109
+ #configuredApiKeyEnvNames: Set<string> = new Set();
1110
+ #optionalAuthPreflightGenerations = new Map<string, number>();
1111
+ #optionalAuthPreflightEpoch = 0;
1054
1112
 
1055
1113
  /**
1056
1114
  * @param authStorage - Auth storage for API key resolution
@@ -1120,17 +1178,48 @@ export class ModelRegistry {
1120
1178
  }
1121
1179
  }
1122
1180
 
1181
+ #getStaticLoadEnvironmentFingerprint(): string {
1182
+ const providerBaseUrlEnvKeys = new Set(
1183
+ [
1184
+ ...getBundledProviders(),
1185
+ ...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId),
1186
+ ...this.#configuredProviderIds,
1187
+ ].flatMap(getProviderBaseUrlEnvKeys),
1188
+ );
1189
+ return JSON.stringify({
1190
+ apiKeyEnv: [...this.#configuredApiKeyEnvNames].sort().map(name => [name, Bun.env[name] ?? ""]),
1191
+ implicitEndpoints: [
1192
+ ["OLLAMA_BASE_URL", Bun.env.OLLAMA_BASE_URL || ""],
1193
+ ["LLAMA_CPP_BASE_URL", Bun.env.LLAMA_CPP_BASE_URL || ""],
1194
+ ["LM_STUDIO_BASE_URL", Bun.env.LM_STUDIO_BASE_URL || ""],
1195
+ ],
1196
+ providerBaseUrls: [...providerBaseUrlEnvKeys].sort().map(name => [name, Bun.env[name] ?? ""]),
1197
+ });
1198
+ }
1199
+
1123
1200
  #reloadStaticModels(): void {
1124
1201
  const currentMtime = this.#modelsConfigFile.getMtimeMs();
1125
- if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
1126
- // models.json unchanged since last load; reload + canonical rebuild would be redundant.
1202
+ const disabledProviderKey = [...getDisabledProviderIdsFromSettings()].sort().join("\u0000");
1203
+ const environmentFingerprint = this.#getStaticLoadEnvironmentFingerprint();
1204
+ if (
1205
+ this.#staticModelsLoaded &&
1206
+ currentMtime === this.#lastStaticLoadMtime &&
1207
+ disabledProviderKey === this.#lastDisabledProviderKey &&
1208
+ environmentFingerprint === this.#lastStaticLoadEnvironmentFingerprint
1209
+ ) {
1210
+ // models.json and settings-derived implicit provider state are unchanged.
1127
1211
  return;
1128
1212
  }
1129
1213
  this.#modelsConfigFile.invalidate();
1130
1214
  this.#customProviderApiKeys.clear();
1131
1215
  this.#providerWebSearchModes.clear();
1132
1216
  this.#keylessProviders.clear();
1217
+ this.#optionalAuthProviders.clear();
1218
+ this.#credentiallessAuthFallbackProviders.clear();
1219
+ this.#optionalAuthPreflightEpoch += 1;
1133
1220
  this.#discoveryManager.reset();
1221
+ for (const descriptor of PROVIDER_DESCRIPTORS) this.#clearDescriptorDiscoveryEvidence(descriptor.providerId);
1222
+ this.#configuredDiscoveryEvidence.clear();
1134
1223
  // Drop config-sourced apiKeys from AuthStorage before reload; entries
1135
1224
  // removed from models.yml must actually disappear from the resolver, not
1136
1225
  // linger from the previous parse. The post-load setters below repopulate.
@@ -1146,6 +1235,7 @@ export class ModelRegistry {
1146
1235
  this.#modelBindingsApplier.setBindings(undefined);
1147
1236
  this.#configError = undefined;
1148
1237
  this.#loadModels();
1238
+ this.#lastDisabledProviderKey = disabledProviderKey;
1149
1239
  }
1150
1240
 
1151
1241
  /**
@@ -1172,6 +1262,8 @@ export class ModelRegistry {
1172
1262
  this.#configError = configError;
1173
1263
  this.#keylessProviders = keylessProviders;
1174
1264
  this.#discoveryManager.setProviders(discoverableProviders);
1265
+ this.#configuredProviderIds = new Set(configuredProviders);
1266
+ this.#configuredDiscoveryProviderIds = new Set(discoverableProviders.map(provider => provider.provider));
1175
1267
  this.#customModelOverlays = customModels;
1176
1268
  this.#providerOverrides = overrides;
1177
1269
  this.#modelOverrides = modelOverrides;
@@ -1192,10 +1284,57 @@ export class ModelRegistry {
1192
1284
  const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
1193
1285
  const withModelOverrides = this.#applyModelOverrides(combined, this.#modelOverrides);
1194
1286
  this.#models = applyFinalCodexGpt56ContextCap(this.#applyRuntimeProviderOverrides(withModelOverrides));
1287
+ this.#rebuildProviderActivity();
1195
1288
  this.#rebuildCanonicalIndex();
1196
1289
  this.#lastStaticLoadMtime = this.#modelsConfigFile.getMtimeMs();
1290
+ this.#lastStaticLoadEnvironmentFingerprint = this.#getStaticLoadEnvironmentFingerprint();
1291
+ this.#staticModelsLoaded = true;
1197
1292
  }
1198
1293
 
1294
+ #rebuildProviderActivity(): void {
1295
+ const staticModelIds = new Map<string, Set<string>>();
1296
+ const addStaticModel = (provider: string, id: string) => {
1297
+ const modelIds = staticModelIds.get(provider) ?? new Set<string>();
1298
+ modelIds.add(id);
1299
+ staticModelIds.set(provider, modelIds);
1300
+ };
1301
+ for (const provider of getBundledProviders()) {
1302
+ for (const model of getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[])
1303
+ addStaticModel(provider, model.id);
1304
+ }
1305
+ for (const overlay of [...this.#customModelOverlays, ...this.#runtimeModelOverlays])
1306
+ addStaticModel(overlay.provider, overlay.id);
1307
+
1308
+ const runtimeProviderIds = new Set(this.#runtimeProviderSourceByName.keys());
1309
+ const providerIds = new Set<string>([
1310
+ ...this.#configuredProviderIds,
1311
+ ...this.#keylessProviders,
1312
+ ...this.#discoveryManager.providerIds(),
1313
+ ...this.#descriptorDiscoveryEvidence.keys(),
1314
+ ...runtimeProviderIds,
1315
+ ...staticModelIds.keys(),
1316
+ ]);
1317
+ const activity = new Map<string, ProviderActivityEvidence>();
1318
+ for (const provider of providerIds) {
1319
+ const discoveryConfigured = this.#configuredDiscoveryProviderIds.has(provider);
1320
+ const isDiscoveryProvider = this.#discoveryManager.providerIds().has(provider);
1321
+ const descriptorEvidence = this.#descriptorDiscoveryEvidence.get(provider);
1322
+ activity.set(provider, {
1323
+ staticModelIds: new Set(staticModelIds.get(provider) ?? []),
1324
+ staticConfigured: staticModelIds.has(provider),
1325
+ discoveryConfigured,
1326
+ implicitDiscovery: isDiscoveryProvider && !discoveryConfigured,
1327
+ descriptorBacked:
1328
+ descriptorEvidence !== undefined ||
1329
+ (!discoveryConfigured && PROVIDER_DESCRIPTORS.some(descriptor => descriptor.providerId === provider)),
1330
+ descriptorFresh: descriptorEvidence?.fresh ?? false,
1331
+ descriptorModelIds: new Set(descriptorEvidence?.modelIds ?? []),
1332
+ authGeneration: descriptorEvidence?.authGeneration ?? "",
1333
+ endpoint: descriptorEvidence?.endpoint ?? "",
1334
+ });
1335
+ }
1336
+ this.#providerActivity = activity;
1337
+ }
1199
1338
  /** Load built-in models, applying provider-level overrides only.
1200
1339
  * Per-model overrides are applied later by #applyModelOverrides. */
1201
1340
  #loadBuiltInModels(overrides: Map<string, ProviderOverride>): Model<Api>[] {
@@ -1338,6 +1477,12 @@ export class ModelRegistry {
1338
1477
  }
1339
1478
 
1340
1479
  #normalizeDiscoverableModels(providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
1480
+ const liveBaseUrl =
1481
+ providerConfig.discovery.type === "openai-models-list" || providerConfig.discovery.type === "lm-studio"
1482
+ ? this.#normalizeOpenAIModelsListBaseUrl(
1483
+ this.#getProviderBaseUrlForDiscovery(providerConfig.provider) ?? providerConfig.baseUrl,
1484
+ )
1485
+ : undefined;
1341
1486
  return models.map(model => {
1342
1487
  const normalized =
1343
1488
  providerConfig.provider === "ollama" &&
@@ -1345,8 +1490,10 @@ export class ModelRegistry {
1345
1490
  model.api === "openai-completions"
1346
1491
  ? ({ ...model, api: "openai-responses" } as Model<Api>)
1347
1492
  : model;
1493
+ const baseUrl = this.#restoreLiveDiscoveryBaseUrl(normalized.baseUrl, liveBaseUrl);
1348
1494
  return {
1349
1495
  ...normalized,
1496
+ ...(baseUrl !== normalized.baseUrl ? { baseUrl } : {}),
1350
1497
  requestTransform: providerConfig.requestTransform
1351
1498
  ? mergeRequestTransform(undefined, providerConfig.requestTransform)
1352
1499
  : undefined,
@@ -1354,6 +1501,21 @@ export class ModelRegistry {
1354
1501
  };
1355
1502
  });
1356
1503
  }
1504
+ #sanitizeDiscoverableModelsForCache(providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
1505
+ return providerConfig.discovery.type === "openai-models-list" || providerConfig.discovery.type === "lm-studio"
1506
+ ? this.#stripModelBaseUrlQueries(models)
1507
+ : models;
1508
+ }
1509
+ #stripModelBaseUrlQueries(models: readonly Model<Api>[]): Model<Api>[] {
1510
+ return models.map(model => (model.baseUrl ? { ...model, baseUrl: stripUrlQuery(model.baseUrl) } : model));
1511
+ }
1512
+ #restoreLiveDiscoveryBaseUrl(modelBaseUrl: string | undefined, liveBaseUrl: string | undefined): string | undefined {
1513
+ if (!modelBaseUrl || !liveBaseUrl?.includes("?")) return modelBaseUrl;
1514
+ return this.#normalizeDiscoveryEvidenceEndpoint(stripUrlQuery(modelBaseUrl)) ===
1515
+ this.#normalizeDiscoveryEvidenceEndpoint(stripUrlQuery(liveBaseUrl))
1516
+ ? liveBaseUrl
1517
+ : modelBaseUrl;
1518
+ }
1357
1519
 
1358
1520
  #addImplicitDiscoverableProviders(configuredProviders: Set<string>): void {
1359
1521
  const disabledProviders = getDisabledProviderIdsFromSettings();
@@ -1365,6 +1527,8 @@ export class ModelRegistry {
1365
1527
  discovery: { type: "ollama" },
1366
1528
  optional: true,
1367
1529
  });
1530
+ // Implicit Ollama auth is optional and may be added after startup.
1531
+ this.#optionalAuthProviders.add("ollama");
1368
1532
  this.#keylessProviders.add("ollama");
1369
1533
  }
1370
1534
  if (!configuredProviders.has("llama.cpp") && !disabledProviders.has("llama.cpp")) {
@@ -1375,10 +1539,9 @@ export class ModelRegistry {
1375
1539
  discovery: { type: "llama.cpp" },
1376
1540
  optional: true,
1377
1541
  });
1378
- // Only mark as keyless if no API key is configured
1379
- if (!this.authStorage.hasAuth("llama.cpp")) {
1380
- this.#keylessProviders.add("llama.cpp");
1381
- }
1542
+ // Implicit llama.cpp auth is optional and may be added after startup.
1543
+ this.#optionalAuthProviders.add("llama.cpp");
1544
+ this.#keylessProviders.add("llama.cpp");
1382
1545
  }
1383
1546
  if (!configuredProviders.has("lm-studio") && !disabledProviders.has("lm-studio")) {
1384
1547
  this.#discoveryManager.addProvider({
@@ -1388,11 +1551,14 @@ export class ModelRegistry {
1388
1551
  discovery: { type: "lm-studio" },
1389
1552
  optional: true,
1390
1553
  });
1554
+ // Implicit LM Studio auth is optional and may be added after startup.
1555
+ this.#optionalAuthProviders.add("lm-studio");
1391
1556
  this.#keylessProviders.add("lm-studio");
1392
1557
  }
1393
1558
  }
1394
1559
 
1395
1560
  #loadCustomModels(): CustomModelsResult {
1561
+ this.#configuredApiKeyEnvNames.clear();
1396
1562
  const { value, error, status } = this.#modelsConfigFile.tryLoad();
1397
1563
 
1398
1564
  if (status === "error") {
@@ -1428,6 +1594,12 @@ export class ModelRegistry {
1428
1594
  const configuredProviders = new Set(Object.keys(value.providers ?? {}));
1429
1595
 
1430
1596
  for (const [providerName, providerConfig] of providerEntries) {
1597
+ if (providerConfig.apiKeyEnv) this.#configuredApiKeyEnvNames.add(providerConfig.apiKeyEnv);
1598
+ if (providerConfig.apiKey) this.#configuredApiKeyEnvNames.add(providerConfig.apiKey);
1599
+ if (providerConfig.openaiCompat?.apiKeyEnv)
1600
+ this.#configuredApiKeyEnvNames.add(providerConfig.openaiCompat.apiKeyEnv);
1601
+ if (providerConfig.openaiCompat?.apiKey)
1602
+ this.#configuredApiKeyEnvNames.add(providerConfig.openaiCompat.apiKey);
1431
1603
  if (providerConfig.webSearch) this.#providerWebSearchModes.set(providerName, providerConfig.webSearch);
1432
1604
  const providerApiKeyConfig = providerConfig.apiKey ?? resolveApiKeyEnvConfig(providerConfig.apiKeyEnv);
1433
1605
  const localOpenAICompat = providerConfig.openaiCompat;
@@ -1467,6 +1639,7 @@ export class ModelRegistry {
1467
1639
  this.authStorage.setConfigApiKey(providerName, localCompatResolvedKey);
1468
1640
  } else {
1469
1641
  keylessProviders.add(providerName);
1642
+ this.#optionalAuthProviders.add(providerName);
1470
1643
  }
1471
1644
  }
1472
1645
  // Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/transport are present
@@ -1701,15 +1874,145 @@ export class ModelRegistry {
1701
1874
  ).filter(provider => !disabledProviders.has(provider.provider));
1702
1875
  const configuredDiscoveriesPromise =
1703
1876
  selectedDiscoverableProviders.length === 0
1704
- ? Promise.resolve<Model<Api>[]>([])
1877
+ ? Promise.resolve(
1878
+ [] as Array<{
1879
+ provider: string;
1880
+ current: boolean;
1881
+ models: Model<Api>[];
1882
+ authGeneration: string;
1883
+ configurationGeneration: number;
1884
+ endpoint: string;
1885
+ fetched: boolean;
1886
+ }>,
1887
+ )
1705
1888
  : Promise.all(
1706
1889
  selectedDiscoverableProviders.map(provider => this.#discoverProviderModels(provider, strategy)),
1707
- ).then(results => results.flat());
1708
- const [configuredDiscovered, builtInDiscovered] = await Promise.all([
1890
+ );
1891
+ const [configuredDiscoveryResults, builtInDiscovered] = await Promise.all([
1709
1892
  configuredDiscoveriesPromise,
1710
1893
  this.#discoverBuiltInProviderModels(strategy, providerFilter),
1711
1894
  ]);
1712
- const discovered = [...configuredDiscovered, ...builtInDiscovered];
1895
+ const currentConfiguredDiscoveryResults = configuredDiscoveryResults.map(result => {
1896
+ const providerConfig = selectedDiscoverableProviders.find(provider => provider.provider === result.provider);
1897
+ const current =
1898
+ result.current &&
1899
+ providerConfig !== undefined &&
1900
+ (() => {
1901
+ try {
1902
+ return (
1903
+ result.authGeneration ===
1904
+ this.#getProviderEvidenceGeneration(
1905
+ result.provider,
1906
+ this.#providerEvidenceApiKeys.get(result.provider),
1907
+ ) &&
1908
+ result.endpoint ===
1909
+ this.#normalizeDiscoveryEvidenceEndpoint(
1910
+ this.#effectiveDiscoveryProviderConfig(providerConfig).baseUrl ?? "",
1911
+ )
1912
+ );
1913
+ } catch {
1914
+ return false;
1915
+ }
1916
+ })();
1917
+ const invalidatesPublishedState =
1918
+ result.current &&
1919
+ providerConfig !== undefined &&
1920
+ (() => {
1921
+ try {
1922
+ return (
1923
+ result.authGeneration !==
1924
+ this.#getProviderEvidenceGeneration(
1925
+ result.provider,
1926
+ this.#providerEvidenceApiKeys.get(result.provider),
1927
+ ) ||
1928
+ result.configurationGeneration !==
1929
+ this.authStorage.getProviderConfigurationGeneration(result.provider) ||
1930
+ result.endpoint !==
1931
+ this.#normalizeDiscoveryEvidenceEndpoint(
1932
+ this.#effectiveDiscoveryProviderConfig(providerConfig).baseUrl ?? "",
1933
+ )
1934
+ );
1935
+ } catch {
1936
+ return true;
1937
+ }
1938
+ })();
1939
+ return current
1940
+ ? { ...result, invalidatesPublishedState }
1941
+ : { ...result, current: false, models: [], fetched: false, invalidatesPublishedState };
1942
+ });
1943
+ const currentBuiltInDiscovered = builtInDiscovered.filter(model => {
1944
+ const evidence = this.#descriptorDiscoveryEvidence.get(model.provider);
1945
+ const currentEndpoint = this.#normalizeDiscoveryEvidenceEndpoint(
1946
+ this.#getProviderBaseUrlForDiscovery(model.provider) ?? model.baseUrl ?? "",
1947
+ );
1948
+ const canUseCredentialDerivedXiaomiEndpoint =
1949
+ model.provider === "xiaomi" &&
1950
+ this.#providerEvidenceApiKeys.get("xiaomi")?.startsWith("tp-") === true &&
1951
+ this.#runtimeProviderOverrides.get("xiaomi")?.baseUrl === undefined &&
1952
+ this.#providerOverrides.get("xiaomi")?.baseUrl === undefined &&
1953
+ resolveProviderBaseUrlFromEnv("xiaomi") === undefined;
1954
+ try {
1955
+ return (
1956
+ evidence !== undefined &&
1957
+ evidence.authGeneration ===
1958
+ this.#getProviderEvidenceGeneration(
1959
+ model.provider,
1960
+ this.#providerEvidenceApiKeys.get(model.provider),
1961
+ ) &&
1962
+ (evidence.endpoint === currentEndpoint ||
1963
+ (canUseCredentialDerivedXiaomiEndpoint &&
1964
+ evidence.endpoint === this.#normalizeDiscoveryEvidenceEndpoint(model.baseUrl ?? ""))) &&
1965
+ evidence.modelIds.has(model.id)
1966
+ );
1967
+ } catch {
1968
+ return false;
1969
+ }
1970
+ });
1971
+ const configuredDiscoveryEvidence = new Map(
1972
+ currentConfiguredDiscoveryResults
1973
+ .filter(result => result.current)
1974
+ .map(result => [
1975
+ result.provider,
1976
+ {
1977
+ authGeneration: result.authGeneration,
1978
+ endpoint: result.endpoint,
1979
+ modelIds: new Set(result.models.map(model => model.id)),
1980
+ },
1981
+ ]),
1982
+ );
1983
+ const configuredDiscoveries = new Map(currentConfiguredDiscoveryResults.map(result => [result.provider, result]));
1984
+ const configuredDiscovered = currentConfiguredDiscoveryResults.flatMap(result => result.models);
1985
+ const discovered = [...configuredDiscovered, ...currentBuiltInDiscovered];
1986
+ for (const provider of selectedDiscoverableProviders) {
1987
+ const evidence = configuredDiscoveryEvidence.get(provider.provider);
1988
+ const discovery = configuredDiscoveries.get(provider.provider);
1989
+ const state = this.#discoveryManager.getState(provider.provider);
1990
+ if (!discovery?.current) {
1991
+ if (discovery?.invalidatesPublishedState) this.#discoveryManager.invalidate(provider.provider);
1992
+ continue;
1993
+ }
1994
+ const currentAuthGeneration = discovery.authGeneration;
1995
+ const currentEndpoint = this.#normalizeDiscoveryEvidenceEndpoint(
1996
+ this.#effectiveDiscoveryProviderConfig(provider).baseUrl ?? "",
1997
+ );
1998
+ if (
1999
+ evidence !== undefined &&
2000
+ state?.status === "ok" &&
2001
+ discovery.fetched &&
2002
+ currentAuthGeneration === evidence.authGeneration &&
2003
+ currentEndpoint === evidence.endpoint
2004
+ ) {
2005
+ this.#configuredDiscoveryEvidence.set(provider.provider, evidence);
2006
+ } else if (
2007
+ (state?.status !== "cached" && !(state?.status === "ok" && !discovery.fetched)) ||
2008
+ state.error !== undefined ||
2009
+ this.#configuredDiscoveryEvidence.get(provider.provider)?.authGeneration !== currentAuthGeneration ||
2010
+ this.#configuredDiscoveryEvidence.get(provider.provider)?.endpoint !== currentEndpoint
2011
+ ) {
2012
+ this.#configuredDiscoveryEvidence.delete(provider.provider);
2013
+ }
2014
+ }
2015
+ this.#rebuildProviderActivity();
1713
2016
  if (discovered.length === 0) {
1714
2017
  return;
1715
2018
  }
@@ -1734,40 +2037,198 @@ export class ModelRegistry {
1734
2037
  async #discoverProviderModels(
1735
2038
  providerConfig: DiscoveryProviderConfig,
1736
2039
  strategy: ModelRefreshStrategy,
1737
- ): Promise<Model<Api>[]> {
1738
- const mergeInput = await this.#discoveryManager.discover(providerConfig, strategy, {
2040
+ ): Promise<{
2041
+ provider: string;
2042
+ current: boolean;
2043
+ models: Model<Api>[];
2044
+ authGeneration: string;
2045
+ configurationGeneration: number;
2046
+ endpoint: string;
2047
+ fetched: boolean;
2048
+ }> {
2049
+ const provider = providerConfig.provider;
2050
+ const preflightEpoch = this.#optionalAuthPreflightEpoch;
2051
+ const preflightGeneration = (this.#optionalAuthPreflightGenerations.get(provider) ?? 0) + 1;
2052
+ this.#optionalAuthPreflightGenerations.set(provider, preflightGeneration);
2053
+ const isCurrentPreflight = () =>
2054
+ this.#optionalAuthPreflightEpoch === preflightEpoch &&
2055
+ this.#optionalAuthPreflightGenerations.get(provider) === preflightGeneration;
2056
+ let preflightApiKey: string | undefined;
2057
+ let preflightFailed = false;
2058
+ let preflightStale = false;
2059
+ let preflightCompleted = false;
2060
+ const optionalAuth = this.#optionalAuthProviders.has(provider);
2061
+ const shouldPreflightAuth = optionalAuth
2062
+ ? this.authStorage.has(provider) || this.authStorage.hasAuth(provider)
2063
+ : !this.#isCredentiallessProvider(provider);
2064
+ let preflightAuthConfigurationGeneration = this.authStorage.getProviderConfigurationGeneration(provider);
2065
+ let preflightOAuthRefreshGeneration = this.authStorage.getProviderOAuthRefreshGeneration(provider);
2066
+ if (shouldPreflightAuth) {
2067
+ if (optionalAuth && isCurrentPreflight()) this.#credentiallessAuthFallbackProviders.delete(provider);
2068
+ let apiKey: string | undefined;
2069
+ try {
2070
+ apiKey = await this.#peekApiKeyForProvider(provider, {
2071
+ ignoreCredentiallessFallback: optionalAuth,
2072
+ refreshOAuth: true,
2073
+ baseUrl: providerConfig.baseUrl,
2074
+ });
2075
+ const currentAuthConfigurationGeneration = this.authStorage.getProviderConfigurationGeneration(provider);
2076
+ if (preflightAuthConfigurationGeneration !== currentAuthConfigurationGeneration) {
2077
+ const currentOAuthRefreshGeneration = this.authStorage.getProviderOAuthRefreshGeneration(provider);
2078
+ if (
2079
+ currentOAuthRefreshGeneration === preflightOAuthRefreshGeneration ||
2080
+ currentAuthConfigurationGeneration - preflightAuthConfigurationGeneration !==
2081
+ currentOAuthRefreshGeneration - preflightOAuthRefreshGeneration
2082
+ ) {
2083
+ preflightStale = true;
2084
+ } else {
2085
+ preflightAuthConfigurationGeneration = currentAuthConfigurationGeneration;
2086
+ preflightOAuthRefreshGeneration = currentOAuthRefreshGeneration;
2087
+ }
2088
+ }
2089
+ } catch (error) {
2090
+ preflightFailed = true;
2091
+ logger.warn("model discovery credential preflight failed", {
2092
+ provider,
2093
+ error: error instanceof Error ? error.message : String(error),
2094
+ });
2095
+ }
2096
+ preflightApiKey = apiKey;
2097
+ preflightCompleted = true;
2098
+ if (!preflightFailed && optionalAuth && isCurrentPreflight()) {
2099
+ this.#providerEvidenceApiKeys.set(provider, apiKey);
2100
+ const authGeneration = this.authStorage.getProviderEvidenceGeneration(provider, apiKey);
2101
+ if (apiKey === undefined) this.#credentiallessAuthFallbackProviders.set(provider, authGeneration);
2102
+ else this.#credentiallessAuthFallbackProviders.delete(provider);
2103
+ }
2104
+ }
2105
+ const effectiveProviderConfig = this.#effectiveDiscoveryProviderConfig(providerConfig);
2106
+ const endpoint = this.#normalizeDiscoveryEvidenceEndpoint(effectiveProviderConfig.baseUrl ?? "");
2107
+ if (!isCurrentPreflight() || preflightStale) {
2108
+ return {
2109
+ provider: effectiveProviderConfig.provider,
2110
+ current: false,
2111
+ models: [],
2112
+ authGeneration: this.#getProviderEvidenceGeneration(provider, preflightApiKey),
2113
+ configurationGeneration: preflightAuthConfigurationGeneration,
2114
+ endpoint,
2115
+ fetched: false,
2116
+ };
2117
+ }
2118
+ if (optionalAuth && preflightFailed) {
2119
+ return {
2120
+ provider: effectiveProviderConfig.provider,
2121
+ current: false,
2122
+ models: [],
2123
+ authGeneration: this.#getProviderEvidenceGeneration(provider, preflightApiKey),
2124
+ configurationGeneration: preflightAuthConfigurationGeneration,
2125
+ endpoint,
2126
+ fetched: false,
2127
+ };
2128
+ }
2129
+ const authGenerationBeforeDiscovery = this.#getProviderEvidenceGeneration(provider, preflightApiKey);
2130
+ const isCurrentEndpoint = () =>
2131
+ endpoint ===
2132
+ this.#normalizeDiscoveryEvidenceEndpoint(this.#effectiveDiscoveryProviderConfig(providerConfig).baseUrl ?? "");
2133
+ const evidence = this.#configuredDiscoveryEvidence.get(provider);
2134
+ const refreshStrategy =
2135
+ strategy === "online-if-uncached" &&
2136
+ evidence !== undefined &&
2137
+ (evidence.authGeneration !== authGenerationBeforeDiscovery || evidence.endpoint !== endpoint)
2138
+ ? "online"
2139
+ : strategy;
2140
+ const mergeInput = await this.#discoveryManager.discover(effectiveProviderConfig, refreshStrategy, {
1739
2141
  cacheDbPath: this.#cacheDbPath,
1740
- requiresAuth: provider => !this.#keylessProviders.has(provider.provider),
1741
- peekApiKey: provider => this.#peekApiKeyForProvider(provider.provider),
2142
+ requiresAuth: provider => !this.#isCredentiallessProvider(provider.provider),
2143
+ peekApiKey: async provider =>
2144
+ preflightCompleted
2145
+ ? preflightApiKey
2146
+ : this.#peekApiKeyForProvider(provider.provider, {
2147
+ refreshOAuth: true,
2148
+ baseUrl: provider.baseUrl,
2149
+ }),
1742
2150
  isAuthenticated,
1743
- fetchModels: provider => this.#discoverModelsByProviderType(provider),
2151
+ fetchModels: async (provider, apiKey) =>
2152
+ this.#sanitizeDiscoverableModelsForCache(
2153
+ provider,
2154
+ await this.#discoverModelsByProviderType(provider, apiKey),
2155
+ ),
2156
+ getEvidenceGeneration: provider => this.#getProviderEvidenceGeneration(provider.provider, preflightApiKey),
2157
+ canPublishCache: () => isCurrentEndpoint(),
1744
2158
  });
1745
- if (!mergeInput.current) return [];
2159
+ const authGeneration =
2160
+ mergeInput.authGeneration ??
2161
+ this.#getProviderEvidenceGeneration(effectiveProviderConfig.provider, preflightApiKey);
2162
+ const current =
2163
+ mergeInput.current &&
2164
+ authGeneration === this.#getProviderEvidenceGeneration(effectiveProviderConfig.provider, preflightApiKey) &&
2165
+ isCurrentEndpoint();
2166
+ if (!current) {
2167
+ return {
2168
+ provider: effectiveProviderConfig.provider,
2169
+ current: false,
2170
+ models: [],
2171
+ authGeneration,
2172
+ configurationGeneration: preflightAuthConfigurationGeneration,
2173
+ endpoint,
2174
+ fetched: false,
2175
+ };
2176
+ }
1746
2177
  if (mergeInput.warning) {
1747
2178
  logger.warn("model discovery failed for provider", {
1748
- provider: providerConfig.provider,
1749
- url: providerConfig.baseUrl,
2179
+ provider: effectiveProviderConfig.provider,
2180
+ url: redactDiscoveryUrl(effectiveProviderConfig.baseUrl ?? ""),
1750
2181
  error: mergeInput.warning,
1751
2182
  });
1752
2183
  }
1753
- return this.#applyProviderModelOverrides(
1754
- providerConfig.provider,
1755
- this.#normalizeDiscoverableModels(
1756
- providerConfig,
1757
- this.#applyProviderCompat(providerConfig.compat, [...mergeInput.models]),
2184
+ this.#providerEvidenceApiKeys.set(effectiveProviderConfig.provider, preflightApiKey);
2185
+ return {
2186
+ provider: effectiveProviderConfig.provider,
2187
+ current: true,
2188
+ authGeneration,
2189
+ configurationGeneration: preflightAuthConfigurationGeneration,
2190
+ endpoint,
2191
+ fetched: mergeInput.fetched ?? false,
2192
+ models: this.#applyProviderModelOverrides(
2193
+ effectiveProviderConfig.provider,
2194
+ this.#normalizeDiscoverableModels(
2195
+ effectiveProviderConfig,
2196
+ this.#applyProviderCompat(effectiveProviderConfig.compat, [...mergeInput.models]),
2197
+ ),
1758
2198
  ),
1759
- );
2199
+ };
2200
+ }
2201
+ #effectiveDiscoveryProviderConfig(providerConfig: DiscoveryProviderConfig): DiscoveryProviderConfig {
2202
+ const override = this.#runtimeProviderOverrides.get(providerConfig.provider);
2203
+ const baseUrl = this.#getProviderBaseUrlForDiscovery(providerConfig.provider) ?? providerConfig.baseUrl;
2204
+ const effectiveBaseUrl =
2205
+ providerConfig.discovery.type === "ollama"
2206
+ ? this.#normalizeOllamaBaseUrl(baseUrl)
2207
+ : providerConfig.discovery.type === "llama.cpp"
2208
+ ? this.#normalizeLlamaCppBaseUrl(baseUrl)
2209
+ : this.#normalizeOpenAIModelsListBaseUrl(baseUrl);
2210
+ return {
2211
+ ...providerConfig,
2212
+ baseUrl: effectiveBaseUrl,
2213
+ headers: override?.headers ? { ...providerConfig.headers, ...override.headers } : providerConfig.headers,
2214
+ compat: override?.compat ? mergeCompat(providerConfig.compat, override.compat) : providerConfig.compat,
2215
+ requestTransform: mergeRequestTransform(providerConfig.requestTransform, override?.requestTransform),
2216
+ cacheRetention: override?.cacheRetention ?? providerConfig.cacheRetention,
2217
+ };
1760
2218
  }
1761
2219
 
1762
- #discoverModelsByProviderType(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
2220
+ #discoverModelsByProviderType(
2221
+ providerConfig: DiscoveryProviderConfig,
2222
+ apiKey: string | undefined,
2223
+ ): Promise<Model<Api>[]> {
1763
2224
  switch (providerConfig.discovery.type) {
1764
2225
  case "ollama":
1765
- return this.#discoverOllamaModels(providerConfig);
2226
+ return this.#discoverOllamaModels(providerConfig, apiKey);
1766
2227
  case "llama.cpp":
1767
- return this.#discoverLlamaCppModels(providerConfig);
2228
+ return this.#discoverLlamaCppModels(providerConfig, apiKey);
1768
2229
  case "lm-studio":
1769
2230
  case "openai-models-list":
1770
- return this.#discoverOpenAIModelsList(providerConfig);
2231
+ return this.#discoverOpenAIModelsList(providerConfig, apiKey);
1771
2232
  }
1772
2233
  }
1773
2234
 
@@ -1777,22 +2238,21 @@ export class ModelRegistry {
1777
2238
  ): Promise<Model<Api>[]> {
1778
2239
  // Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
1779
2240
  const configuredDiscoveryProviders = new Set(this.#discoveryManager.providers.map(p => p.provider));
1780
- const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => {
1781
- if (configuredDiscoveryProviders.has(opts.providerId)) {
1782
- return false;
1783
- }
1784
- return providerFilter ? providerFilter.has(opts.providerId) : true;
1785
- });
2241
+ const managerOptions = (await this.#collectBuiltInModelManagerOptions(configuredDiscoveryProviders)).filter(
2242
+ entry => (providerFilter ? providerFilter.has(entry.options.providerId) : true),
2243
+ );
1786
2244
  if (managerOptions.length === 0) {
1787
2245
  return [];
1788
2246
  }
1789
2247
  const discoveries = await Promise.all(
1790
- managerOptions.map(options => this.#discoverWithModelManager(options, strategy)),
2248
+ managerOptions.map(entry => this.#discoverWithModelManager(entry, strategy)),
1791
2249
  );
1792
2250
  return discoveries.flat();
1793
2251
  }
1794
2252
 
1795
- async #collectBuiltInModelManagerOptions(): Promise<ModelManagerOptions<Api>[]> {
2253
+ async #collectBuiltInModelManagerOptions(
2254
+ excludedProviderIds: ReadonlySet<string> = new Set(),
2255
+ ): Promise<ModelManagerDiscoveryOptions[]> {
1796
2256
  const specialProviderDescriptors: Array<{
1797
2257
  providerId: string;
1798
2258
  resolveKey: (value: string | undefined) => string | undefined;
@@ -1830,55 +2290,180 @@ export class ModelRegistry {
1830
2290
  ];
1831
2291
  const disabledProviders = getDisabledProviderIdsFromSettings();
1832
2292
  const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(
1833
- descriptor => !disabledProviders.has(descriptor.providerId),
2293
+ descriptor => !disabledProviders.has(descriptor.providerId) && !excludedProviderIds.has(descriptor.providerId),
1834
2294
  );
1835
2295
  const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(
1836
- descriptor => !disabledProviders.has(descriptor.providerId),
2296
+ descriptor => !disabledProviders.has(descriptor.providerId) && !excludedProviderIds.has(descriptor.providerId),
1837
2297
  );
2298
+ for (const descriptor of standardProviderDescriptors) {
2299
+ if (!descriptor.allowUnauthenticated) continue;
2300
+ this.#keylessProviders.add(descriptor.providerId);
2301
+ this.#optionalAuthProviders.add(descriptor.providerId);
2302
+ }
1838
2303
  // Use peekApiKey to avoid OAuth token refresh during discovery.
1839
2304
  // The token is only needed if the dynamic fetch fires (cache miss),
1840
2305
  // and failures there are handled gracefully.
1841
- const peekKey = (descriptor: { providerId: string }) => this.#peekApiKeyForProvider(descriptor.providerId);
1842
- const [standardProviderKeys, specialKeys] = await Promise.all([
2306
+ const peekKey = async (descriptor: { providerId: string }) => {
2307
+ const configurationGeneration = this.authStorage.getProviderConfigurationGeneration(descriptor.providerId);
2308
+ const apiKey = await this.#peekApiKeyForProvider(descriptor.providerId);
2309
+ if (configurationGeneration !== this.authStorage.getProviderConfigurationGeneration(descriptor.providerId)) {
2310
+ return { apiKey: undefined, authGeneration: undefined };
2311
+ }
2312
+ return {
2313
+ apiKey,
2314
+ authGeneration: this.#getProviderEvidenceGeneration(descriptor.providerId, apiKey),
2315
+ };
2316
+ };
2317
+ const [standardProviderCredentials, specialProviderCredentials] = await Promise.all([
1843
2318
  Promise.all(standardProviderDescriptors.map(peekKey)),
1844
2319
  Promise.all(enabledSpecialProviderDescriptors.map(peekKey)),
1845
2320
  ]);
1846
- const options: ModelManagerOptions<Api>[] = [];
2321
+ const options: ModelManagerDiscoveryOptions[] = [];
1847
2322
  for (let i = 0; i < standardProviderDescriptors.length; i++) {
1848
2323
  const descriptor = standardProviderDescriptors[i];
1849
- const apiKey = standardProviderKeys[i];
1850
- if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated) {
1851
- options.push(
1852
- descriptor.createModelManagerOptions({
2324
+ const { apiKey, authGeneration } = standardProviderCredentials[i];
2325
+ if (
2326
+ authGeneration !== undefined &&
2327
+ authGeneration === this.#getProviderEvidenceGeneration(descriptor.providerId, apiKey) &&
2328
+ (isAuthenticated(apiKey) || descriptor.allowUnauthenticated)
2329
+ ) {
2330
+ const baseUrl = this.#getProviderBaseUrlForDiscovery(descriptor.providerId);
2331
+ options.push({
2332
+ options: descriptor.createModelManagerOptions({
1853
2333
  apiKey: isAuthenticated(apiKey) ? apiKey : undefined,
1854
- baseUrl: this.#getProviderBaseUrlForDiscovery(descriptor.providerId),
2334
+ baseUrl,
1855
2335
  }),
1856
- );
2336
+ authGeneration,
2337
+ apiKey,
2338
+ endpoint: this.#normalizeDiscoveryEvidenceEndpoint(baseUrl ?? ""),
2339
+ });
1857
2340
  }
1858
2341
  }
1859
2342
 
1860
2343
  for (let i = 0; i < enabledSpecialProviderDescriptors.length; i++) {
1861
2344
  const descriptor = enabledSpecialProviderDescriptors[i];
1862
- const key = descriptor.resolveKey(specialKeys[i]);
1863
- if (!isAuthenticated(key)) {
1864
- continue;
2345
+ const { apiKey: apiKeyValue, authGeneration } = specialProviderCredentials[i];
2346
+ const key = descriptor.resolveKey(apiKeyValue);
2347
+ if (
2348
+ authGeneration !== undefined &&
2349
+ authGeneration === this.#getProviderEvidenceGeneration(descriptor.providerId, apiKeyValue) &&
2350
+ isAuthenticated(key)
2351
+ ) {
2352
+ const managerOptions = descriptor.createOptions(key);
2353
+ options.push({
2354
+ options: managerOptions,
2355
+ authGeneration,
2356
+ apiKey: apiKeyValue,
2357
+ endpoint: this.#normalizeDiscoveryEvidenceEndpoint(
2358
+ this.#getProviderBaseUrlForDiscovery(descriptor.providerId) ?? "",
2359
+ ),
2360
+ });
1865
2361
  }
1866
- options.push(descriptor.createOptions(key));
1867
2362
  }
1868
2363
  return options;
1869
2364
  }
1870
2365
 
1871
2366
  async #discoverWithModelManager(
1872
- options: ModelManagerOptions<Api>,
2367
+ { options, authGeneration, apiKey, endpoint }: ModelManagerDiscoveryOptions,
1873
2368
  strategy: ModelRefreshStrategy,
1874
2369
  ): Promise<Model<Api>[]> {
2370
+ const generation = (this.#descriptorDiscoveryGenerations.get(options.providerId) ?? 0) + 1;
2371
+ this.#descriptorDiscoveryGenerations.set(options.providerId, generation);
2372
+ const canUseCredentialDerivedXiaomiEndpoint =
2373
+ options.providerId === "xiaomi" &&
2374
+ apiKey?.startsWith("tp-") === true &&
2375
+ this.#runtimeProviderOverrides.get("xiaomi")?.baseUrl === undefined &&
2376
+ this.#providerOverrides.get("xiaomi")?.baseUrl === undefined &&
2377
+ resolveProviderBaseUrlFromEnv("xiaomi") === undefined;
2378
+ let credentialDerivedEndpoint: string | undefined;
2379
+ const isCurrentDiscovery = () =>
2380
+ (this.#descriptorDiscoveryGenerations.get(options.providerId) ?? 0) === generation &&
2381
+ this.#getProviderEvidenceGeneration(options.providerId, apiKey) === authGeneration &&
2382
+ (endpoint ===
2383
+ this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(options.providerId) ?? "") ||
2384
+ (canUseCredentialDerivedXiaomiEndpoint && credentialDerivedEndpoint !== undefined));
1875
2385
  try {
1876
- const manager = createModelManager({ ...options, cacheDbPath: this.#cacheDbPath });
1877
- const result = await manager.refresh(strategy);
1878
- return result.models.map(model =>
1879
- model.provider === options.providerId ? model : { ...model, provider: options.providerId },
1880
- );
2386
+ const manager = createModelManager({
2387
+ ...options,
2388
+ cacheDbPath: this.#cacheDbPath,
2389
+ canPublishCache: isCurrentDiscovery,
2390
+ ...(options.fetchDynamicModels
2391
+ ? {
2392
+ fetchDynamicModels: async () => {
2393
+ const models = await options.fetchDynamicModels?.();
2394
+ if (models === null) return null;
2395
+ const sanitizedModels = this.#stripModelBaseUrlQueries(models ?? []);
2396
+ if (canUseCredentialDerivedXiaomiEndpoint) {
2397
+ credentialDerivedEndpoint = sanitizedModels[0]?.baseUrl;
2398
+ }
2399
+ return sanitizedModels;
2400
+ },
2401
+ }
2402
+ : {}),
2403
+ ...(options.modelsDev
2404
+ ? {
2405
+ modelsDev: {
2406
+ ...options.modelsDev,
2407
+ map: (payload, providerId) =>
2408
+ this.#stripModelBaseUrlQueries(options.modelsDev?.map(payload, providerId) ?? []),
2409
+ },
2410
+ }
2411
+ : {}),
2412
+ });
2413
+ const evidence = this.#descriptorDiscoveryEvidence.get(options.providerId);
2414
+ const refreshStrategy =
2415
+ strategy === "online-if-uncached" &&
2416
+ (evidence?.authGeneration !== authGeneration || evidence.endpoint !== endpoint)
2417
+ ? "online"
2418
+ : strategy;
2419
+ const result = await manager.refresh(refreshStrategy);
2420
+ const liveBaseUrl = this.#getProviderBaseUrlForDiscovery(options.providerId);
2421
+ const models = result.models.map(model => {
2422
+ const baseUrl = this.#restoreLiveDiscoveryBaseUrl(model.baseUrl, liveBaseUrl);
2423
+ return {
2424
+ ...(model.provider === options.providerId ? model : { ...model, provider: options.providerId }),
2425
+ ...(baseUrl !== model.baseUrl ? { baseUrl } : {}),
2426
+ };
2427
+ });
2428
+ if (
2429
+ isCurrentDiscovery() &&
2430
+ (result.fetched ||
2431
+ result.stale ||
2432
+ this.#descriptorDiscoveryEvidence.get(options.providerId)?.authGeneration !== authGeneration ||
2433
+ this.#descriptorDiscoveryEvidence.get(options.providerId)?.endpoint !== endpoint)
2434
+ ) {
2435
+ this.#descriptorDiscoveryEvidence.set(options.providerId, {
2436
+ fresh: result.fetched,
2437
+ modelIds: new Set(models.map(model => model.id)),
2438
+ authGeneration,
2439
+ endpoint: this.#normalizeDiscoveryEvidenceEndpoint(models[0]?.baseUrl ?? endpoint),
2440
+ });
2441
+ }
2442
+ if (!isCurrentDiscovery()) {
2443
+ return [];
2444
+ }
2445
+ this.#providerEvidenceApiKeys.set(options.providerId, apiKey);
2446
+ if (options.providerId === "opencodex" && !isAuthenticated(apiKey)) {
2447
+ this.#credentiallessAuthFallbackProviders.set(options.providerId, authGeneration);
2448
+ const evidence = this.#descriptorDiscoveryEvidence.get(options.providerId);
2449
+ if (evidence?.authGeneration === authGeneration) {
2450
+ this.#descriptorDiscoveryEvidence.set(options.providerId, {
2451
+ ...evidence,
2452
+ authGeneration: this.#getProviderEvidenceGeneration(options.providerId),
2453
+ });
2454
+ }
2455
+ }
2456
+ return models;
1881
2457
  } catch (error) {
2458
+ if (isCurrentDiscovery()) {
2459
+ this.#providerEvidenceApiKeys.set(options.providerId, apiKey);
2460
+ this.#descriptorDiscoveryEvidence.set(options.providerId, {
2461
+ fresh: false,
2462
+ modelIds: new Set(),
2463
+ authGeneration,
2464
+ endpoint,
2465
+ });
2466
+ }
1882
2467
  logger.warn("model discovery failed for provider", {
1883
2468
  provider: options.providerId,
1884
2469
  error: error instanceof Error ? error.message : String(error),
@@ -1938,10 +2523,21 @@ export class ModelRegistry {
1938
2523
  }
1939
2524
  }
1940
2525
 
1941
- async #discoverOllamaModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
2526
+ async #discoverOllamaModels(
2527
+ providerConfig: DiscoveryProviderConfig,
2528
+ discoveryApiKey?: string,
2529
+ ): Promise<Model<Api>[]> {
1942
2530
  const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl);
1943
2531
  const tagsUrl = `${endpoint}/api/tags`;
1944
- const headers = { ...(providerConfig.headers ?? {}) };
2532
+ const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
2533
+ const apiKey =
2534
+ discoveryApiKey ??
2535
+ (this.#isCredentiallessProvider(providerConfig.provider)
2536
+ ? kNoAuth
2537
+ : await this.authStorage.getApiKey(providerConfig.provider));
2538
+ if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
2539
+ headers.Authorization = `Bearer ${apiKey}`;
2540
+ }
1945
2541
  const response = await fetch(tagsUrl, {
1946
2542
  headers,
1947
2543
  signal: AbortSignal.timeout(250),
@@ -2006,12 +2602,19 @@ export class ModelRegistry {
2006
2602
  }
2007
2603
  }
2008
2604
 
2009
- async #discoverLlamaCppModels(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
2605
+ async #discoverLlamaCppModels(
2606
+ providerConfig: DiscoveryProviderConfig,
2607
+ discoveryApiKey?: string,
2608
+ ): Promise<Model<Api>[]> {
2010
2609
  const baseUrl = this.#normalizeLlamaCppBaseUrl(providerConfig.baseUrl);
2011
2610
  const modelsUrl = `${baseUrl}/models`;
2012
2611
 
2013
2612
  const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
2014
- const apiKey = await this.authStorage.getApiKey(providerConfig.provider);
2613
+ const apiKey =
2614
+ discoveryApiKey ??
2615
+ (this.#isCredentiallessProvider(providerConfig.provider)
2616
+ ? kNoAuth
2617
+ : await this.authStorage.getApiKey(providerConfig.provider));
2015
2618
  if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
2016
2619
  headers.Authorization = `Bearer ${apiKey}`;
2017
2620
  }
@@ -2056,15 +2659,24 @@ export class ModelRegistry {
2056
2659
  return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
2057
2660
  }
2058
2661
 
2059
- async #discoverOpenAIModelsList(providerConfig: DiscoveryProviderConfig): Promise<Model<Api>[]> {
2662
+ async #discoverOpenAIModelsList(
2663
+ providerConfig: DiscoveryProviderConfig,
2664
+ discoveryApiKey?: string,
2665
+ ): Promise<Model<Api>[]> {
2060
2666
  const baseUrl = this.#normalizeOpenAIModelsListBaseUrl(providerConfig.baseUrl);
2061
- const modelsUrl = `${baseUrl}/models`;
2667
+ const modelsUrl = new URL(baseUrl);
2668
+ const requestBaseUrl = baseUrl;
2669
+ modelsUrl.pathname = `${modelsUrl.pathname.replace(/\/+$/g, "")}/models`;
2062
2670
 
2063
2671
  const headers: Record<string, string> = { ...(providerConfig.headers ?? {}) };
2064
2672
  // Resolve with the same baseUrl context completion requests use so an
2065
2673
  // endpoint-scoped (or config-pinned) credential wins here exactly as it
2066
2674
  // does for chat completions.
2067
- const apiKey = await this.authStorage.getApiKey(providerConfig.provider, undefined, { baseUrl });
2675
+ const apiKey =
2676
+ discoveryApiKey ??
2677
+ (this.#isCredentiallessProvider(providerConfig.provider)
2678
+ ? kNoAuth
2679
+ : await this.authStorage.getApiKey(providerConfig.provider, undefined, { baseUrl }));
2068
2680
  if (apiKey && apiKey !== DEFAULT_LOCAL_TOKEN && apiKey !== kNoAuth) {
2069
2681
  headers.Authorization = `Bearer ${apiKey}`;
2070
2682
  }
@@ -2078,10 +2690,10 @@ export class ModelRegistry {
2078
2690
  // Redacted by construction: name the provider, endpoint, and the
2079
2691
  // config surface to fix — never the resolved key.
2080
2692
  throw new Error(
2081
- `HTTP ${response.status} from ${modelsUrl}: provider "${providerConfig.provider}" credential was rejected for OpenAI models-list discovery; check providers.${providerConfig.provider}.apiKey/apiKeyEnv.`,
2693
+ `HTTP ${response.status} from ${redactDiscoveryUrl(modelsUrl)}: provider "${providerConfig.provider}" credential was rejected for OpenAI models-list discovery; check providers.${providerConfig.provider}.apiKey/apiKeyEnv.`,
2082
2694
  );
2083
2695
  }
2084
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
2696
+ throw new Error(`HTTP ${response.status} from ${redactDiscoveryUrl(modelsUrl)}`);
2085
2697
  }
2086
2698
  const payload = (await response.json()) as { data?: Array<{ id: string }> };
2087
2699
  const models = payload.data ?? [];
@@ -2095,7 +2707,7 @@ export class ModelRegistry {
2095
2707
  name: id,
2096
2708
  api: providerConfig.api,
2097
2709
  provider: providerConfig.provider,
2098
- baseUrl,
2710
+ baseUrl: requestBaseUrl,
2099
2711
  reasoning: false,
2100
2712
  input: ["text"],
2101
2713
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -2124,7 +2736,6 @@ export class ModelRegistry {
2124
2736
  return raw;
2125
2737
  }
2126
2738
  }
2127
-
2128
2739
  #toLlamaCppNativeBaseUrl(baseUrl: string): string {
2129
2740
  try {
2130
2741
  const parsed = new URL(baseUrl);
@@ -2136,7 +2747,43 @@ export class ModelRegistry {
2136
2747
  return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl;
2137
2748
  }
2138
2749
  }
2139
-
2750
+ #normalizeDiscoveryEvidenceEndpoint(endpoint: string): string {
2751
+ try {
2752
+ const parsed = new URL(endpoint);
2753
+ const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2754
+ return `${parsed.protocol}//${parsed.host}${trimmedPath}${parsed.search}`;
2755
+ } catch {
2756
+ return endpoint.replace(/\/+$/g, "");
2757
+ }
2758
+ }
2759
+ #isCredentiallessProvider(provider: string): boolean {
2760
+ let fallbackMatchesCurrentEvidence = false;
2761
+ const fallbackEvidenceGeneration = this.#credentiallessAuthFallbackProviders.get(provider);
2762
+ if (fallbackEvidenceGeneration !== undefined) {
2763
+ try {
2764
+ fallbackMatchesCurrentEvidence =
2765
+ fallbackEvidenceGeneration ===
2766
+ this.authStorage.getProviderEvidenceGeneration(provider, this.#providerEvidenceApiKeys.get(provider));
2767
+ } catch {
2768
+ // AuthStorage may be unavailable while a registry is being torn down.
2769
+ }
2770
+ }
2771
+ return (
2772
+ this.#keylessProviders.has(provider) &&
2773
+ (!this.#optionalAuthProviders.has(provider) ||
2774
+ (!this.authStorage.hasAuth(provider) && !this.authStorage.has(provider)) ||
2775
+ fallbackMatchesCurrentEvidence)
2776
+ );
2777
+ }
2778
+ #getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string {
2779
+ if (this.#isCredentiallessProvider(provider)) {
2780
+ return `credentialless:${provider}`;
2781
+ }
2782
+ return this.authStorage.getProviderEvidenceGeneration(
2783
+ provider,
2784
+ resolvedApiKey ?? this.#providerEvidenceApiKeys.get(provider),
2785
+ );
2786
+ }
2140
2787
  #normalizeOpenAIModelsListBaseUrl(baseUrl?: string): string {
2141
2788
  const defaultBaseUrl = "http://127.0.0.1:1234/v1";
2142
2789
  const raw = baseUrl || defaultBaseUrl;
@@ -2144,7 +2791,7 @@ export class ModelRegistry {
2144
2791
  const parsed = new URL(raw);
2145
2792
  const trimmedPath = parsed.pathname.replace(/\/+$/g, "");
2146
2793
  parsed.pathname = trimmedPath.endsWith("/v1") ? trimmedPath || "/v1" : `${trimmedPath}/v1`;
2147
- return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
2794
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
2148
2795
  } catch {
2149
2796
  return raw;
2150
2797
  }
@@ -2189,6 +2836,7 @@ export class ModelRegistry {
2189
2836
 
2190
2837
  #getProviderBaseUrlForDiscovery(provider: string): string | undefined {
2191
2838
  return (
2839
+ this.#runtimeProviderOverrides.get(provider)?.baseUrl ??
2192
2840
  this.#providerOverrides.get(provider)?.baseUrl ??
2193
2841
  resolveProviderBaseUrlFromEnv(provider) ??
2194
2842
  this.getProviderBaseUrl(provider)
@@ -2505,6 +3153,103 @@ export class ModelRegistry {
2505
3153
  this.#availableModelsEnvFingerprint = envFingerprint;
2506
3154
  return this.#availableModelsCache;
2507
3155
  }
3156
+ #hasFreshOrStaticModelEvidence(model: Model<Api>): boolean {
3157
+ const evidence = this.#providerActivity.get(model.provider);
3158
+ if (
3159
+ !evidence ||
3160
+ (!evidence.staticConfigured &&
3161
+ !evidence.discoveryConfigured &&
3162
+ !evidence.implicitDiscovery &&
3163
+ !evidence.descriptorBacked)
3164
+ ) {
3165
+ return false;
3166
+ }
3167
+ if (evidence.staticConfigured && (!evidence.discoveryConfigured || evidence.staticModelIds.has(model.id))) {
3168
+ return true;
3169
+ }
3170
+ if (
3171
+ evidence.descriptorFresh &&
3172
+ evidence.authGeneration === this.#getProviderEvidenceGeneration(model.provider) &&
3173
+ evidence.endpoint ===
3174
+ this.#normalizeDiscoveryEvidenceEndpoint(
3175
+ this.#getProviderBaseUrlForDiscovery(model.provider) ?? model.baseUrl ?? "",
3176
+ ) &&
3177
+ evidence.descriptorModelIds.has(model.id)
3178
+ )
3179
+ return true;
3180
+ const discoveryState = this.#discoveryManager.getState(model.provider);
3181
+ const configuredEvidence = this.#configuredDiscoveryEvidence.get(model.provider);
3182
+ return (
3183
+ (discoveryState?.status === "ok" || discoveryState?.status === "cached") &&
3184
+ configuredEvidence?.authGeneration === this.#getProviderEvidenceGeneration(model.provider) &&
3185
+ configuredEvidence.endpoint ===
3186
+ this.#normalizeDiscoveryEvidenceEndpoint(this.#getProviderBaseUrlForDiscovery(model.provider) ?? "") &&
3187
+ configuredEvidence.modelIds.has(model.id)
3188
+ );
3189
+ }
3190
+
3191
+ #activeConnectionKind(model: Model<Api>): ActiveProviderDescriptor["connectionKind"] | undefined {
3192
+ const evidence = this.#providerActivity.get(model.provider);
3193
+ if (!this.#isCredentiallessProvider(model.provider) && this.authStorage.hasUsableAuth(model.provider)) {
3194
+ if (!evidence) return undefined;
3195
+ const discoveryOnly =
3196
+ !evidence.staticConfigured &&
3197
+ (evidence.discoveryConfigured || evidence.implicitDiscovery || evidence.descriptorBacked);
3198
+ return !discoveryOnly || this.#hasFreshOrStaticModelEvidence(model) ? "credential" : undefined;
3199
+ }
3200
+ if (this.#isCredentiallessProvider(model.provider)) {
3201
+ const discoveryOnly =
3202
+ evidence !== undefined &&
3203
+ !evidence.staticConfigured &&
3204
+ (evidence.discoveryConfigured || evidence.implicitDiscovery || evidence.descriptorBacked);
3205
+ if (discoveryOnly) {
3206
+ if (evidence.descriptorBacked)
3207
+ return this.#hasFreshOrStaticModelEvidence(model) ? "credentialless" : undefined;
3208
+ const configuredBaseUrl = this.#getProviderBaseUrlForDiscovery(model.provider);
3209
+ const discoveryType = this.#discoveryManager.providers.find(
3210
+ provider => provider.provider === model.provider,
3211
+ )?.discovery.type;
3212
+ const endpointFor = (baseUrl: string | undefined): string =>
3213
+ this.#normalizeDiscoveryEvidenceEndpoint(
3214
+ discoveryType === "ollama"
3215
+ ? `${this.#normalizeOllamaBaseUrl(baseUrl)}/v1`
3216
+ : discoveryType === "openai-models-list" || discoveryType === "lm-studio"
3217
+ ? this.#normalizeOpenAIModelsListBaseUrl(baseUrl)
3218
+ : (baseUrl ?? ""),
3219
+ );
3220
+ if (
3221
+ endpointFor(model.baseUrl) !== endpointFor(configuredBaseUrl) ||
3222
+ this.#discoveryManager.getState(model.provider)?.status === "empty"
3223
+ )
3224
+ return undefined;
3225
+ const configuredEvidence = this.#configuredDiscoveryEvidence.get(model.provider);
3226
+ if (
3227
+ this.#discoveryManager.getState(model.provider)?.error !== undefined ||
3228
+ (this.#optionalAuthProviders.has(model.provider) &&
3229
+ (configuredEvidence === undefined ||
3230
+ configuredEvidence.authGeneration !== this.#getProviderEvidenceGeneration(model.provider)))
3231
+ )
3232
+ return undefined;
3233
+ }
3234
+ return "credentialless";
3235
+ }
3236
+ return undefined;
3237
+ }
3238
+
3239
+ getActiveProviders(): ActiveProviderDescriptor[] {
3240
+ try {
3241
+ const descriptors: ActiveProviderDescriptor[] = [];
3242
+ const disabledProviders = getDisabledProviderIdsFromSettings();
3243
+ const available = this.#models.filter(model => this.#isModelAvailable(model, disabledProviders));
3244
+ for (const model of available) {
3245
+ const connectionKind = this.#activeConnectionKind(model);
3246
+ if (connectionKind) descriptors.push({ provider: model.provider, connectionKind });
3247
+ }
3248
+ return projectActiveProviderDescriptors(descriptors);
3249
+ } catch {
3250
+ throw new ActiveProviderResolutionError();
3251
+ }
3252
+ }
2508
3253
 
2509
3254
  /**
2510
3255
  * Check whether auth is configured for a model's provider.
@@ -2571,10 +3316,14 @@ export class ModelRegistry {
2571
3316
  }
2572
3317
 
2573
3318
  async #getApiKeyOrNoAuth(provider: string, lookup: () => Promise<string | undefined>): Promise<string | undefined> {
2574
- if (this.#keylessProviders.has(provider) && !this.authStorage.hasAuth(provider)) {
2575
- return kNoAuth;
3319
+ if (!this.#isCredentiallessProvider(provider)) return lookup();
3320
+ if (!this.#optionalAuthProviders.has(provider)) return kNoAuth;
3321
+ const apiKey = await lookup();
3322
+ if (apiKey !== undefined) {
3323
+ this.#credentiallessAuthFallbackProviders.delete(provider);
3324
+ return apiKey;
2576
3325
  }
2577
- return lookup();
3326
+ return kNoAuth;
2578
3327
  }
2579
3328
 
2580
3329
  /**
@@ -2613,8 +3362,28 @@ export class ModelRegistry {
2613
3362
  );
2614
3363
  }
2615
3364
 
2616
- async #peekApiKeyForProvider(provider: string): Promise<string | undefined> {
2617
- return this.#getApiKeyOrNoAuth(provider, () => this.authStorage.peekApiKey(provider));
3365
+ async #peekApiKeyForProvider(
3366
+ provider: string,
3367
+ options: {
3368
+ ignoreCredentiallessFallback?: boolean;
3369
+ refreshOAuth?: boolean;
3370
+ baseUrl?: string;
3371
+ } = {},
3372
+ ): Promise<string | undefined> {
3373
+ if (!options.ignoreCredentiallessFallback && this.#isCredentiallessProvider(provider)) {
3374
+ return kNoAuth;
3375
+ }
3376
+ try {
3377
+ this.authStorage.getProviderEvidenceGeneration(provider);
3378
+ } catch {
3379
+ return undefined;
3380
+ }
3381
+ if (options.refreshOAuth && this.authStorage.hasOAuth(provider)) {
3382
+ return this.authStorage.getApiKey(provider, undefined, { baseUrl: options.baseUrl });
3383
+ }
3384
+ return options.ignoreCredentiallessFallback
3385
+ ? this.authStorage.peekApiKey(provider)
3386
+ : this.#getApiKeyOrNoAuth(provider, () => this.authStorage.peekApiKey(provider));
2618
3387
  }
2619
3388
 
2620
3389
  /**
@@ -2628,11 +3397,22 @@ export class ModelRegistry {
2628
3397
  return this.authStorage.getSessionCredentialType(provider, sessionId);
2629
3398
  }
2630
3399
 
3400
+ #clearDescriptorDiscoveryEvidence(providerName: string): void {
3401
+ this.#descriptorDiscoveryGenerations.set(
3402
+ providerName,
3403
+ (this.#descriptorDiscoveryGenerations.get(providerName) ?? 0) + 1,
3404
+ );
3405
+ this.#descriptorDiscoveryEvidence.delete(providerName);
3406
+ this.#configuredDiscoveryEvidence.delete(providerName);
3407
+ this.#discoveryManager.invalidate(providerName);
3408
+ }
3409
+
2631
3410
  #clearRuntimeProviderState(providerName: string): void {
2632
3411
  this.#runtimeProviderApiKeys.delete(providerName);
2633
3412
  this.#runtimeProviderOverrides.delete(providerName);
2634
3413
  this.#runtimeModelOverlays = this.#runtimeModelOverlays.filter(overlay => overlay.provider !== providerName);
2635
3414
  this.authStorage.removeConfigApiKey(providerName);
3415
+ this.#clearDescriptorDiscoveryEvidence(providerName);
2636
3416
  }
2637
3417
 
2638
3418
  /**
@@ -2654,6 +3434,7 @@ export class ModelRegistry {
2654
3434
  this.#clearRuntimeProviderState(providerName);
2655
3435
  }
2656
3436
  this.#lastStaticLoadMtime = null;
3437
+ this.#staticModelsLoaded = false;
2657
3438
  this.#reloadStaticModels();
2658
3439
  this.#rebuildCanonicalIndex();
2659
3440
  }
@@ -2698,6 +3479,8 @@ export class ModelRegistry {
2698
3479
  },
2699
3480
  "runtime-register",
2700
3481
  );
3482
+ this.#clearDescriptorDiscoveryEvidence(providerName);
3483
+ this.#rebuildProviderActivity();
2701
3484
 
2702
3485
  if (config.streamSimple && config.api) {
2703
3486
  const streamSimple = config.streamSimple;
@@ -2734,6 +3517,7 @@ export class ModelRegistry {
2734
3517
  }
2735
3518
  if (sourceHandoff) {
2736
3519
  this.#lastStaticLoadMtime = null;
3520
+ this.#staticModelsLoaded = false;
2737
3521
  this.#reloadStaticModels();
2738
3522
  }
2739
3523
 
@@ -2791,12 +3575,14 @@ export class ModelRegistry {
2791
3575
  config.oauth.modifyModels(withRuntimeTransportOverride, credential),
2792
3576
  );
2793
3577
  this.#rebuildCanonicalIndex();
3578
+ this.#rebuildProviderActivity();
2794
3579
  return;
2795
3580
  }
2796
3581
  }
2797
3582
 
2798
3583
  this.#models = applyFinalCodexGpt56ContextCap(withRuntimeTransportOverride);
2799
3584
  this.#rebuildCanonicalIndex();
3585
+ this.#rebuildProviderActivity();
2800
3586
  return;
2801
3587
  }
2802
3588
 
@@ -2826,6 +3612,7 @@ export class ModelRegistry {
2826
3612
  return this.#applyProviderTransportOverride(m, transportOverride);
2827
3613
  });
2828
3614
  this.#rebuildCanonicalIndex();
3615
+ this.#rebuildProviderActivity();
2829
3616
  }
2830
3617
  }
2831
3618