@gajae-code/coding-agent 0.12.7 → 0.12.10

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