@sriinnu/kosha-discovery 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +88 -78
  2. package/dist/cli-cmd-doctor.d.ts.map +1 -1
  3. package/dist/cli-cmd-doctor.js +69 -18
  4. package/dist/cli-cmd-doctor.js.map +1 -1
  5. package/dist/cli-cmd-model.d.ts.map +1 -1
  6. package/dist/cli-cmd-model.js +18 -7
  7. package/dist/cli-cmd-model.js.map +1 -1
  8. package/dist/cli-cmd-query.d.ts.map +1 -1
  9. package/dist/cli-cmd-query.js +17 -0
  10. package/dist/cli-cmd-query.js.map +1 -1
  11. package/dist/cli-cmd-registry.d.ts +2 -1
  12. package/dist/cli-cmd-registry.d.ts.map +1 -1
  13. package/dist/cli-cmd-registry.js +30 -1
  14. package/dist/cli-cmd-registry.js.map +1 -1
  15. package/dist/cli-commands.d.ts +4 -0
  16. package/dist/cli-commands.d.ts.map +1 -1
  17. package/dist/cli-commands.js +43 -5
  18. package/dist/cli-commands.js.map +1 -1
  19. package/dist/cli-help.d.ts.map +1 -1
  20. package/dist/cli-help.js +4 -1
  21. package/dist/cli-help.js.map +1 -1
  22. package/dist/cli.js +1 -1
  23. package/dist/cli.js.map +1 -1
  24. package/dist/cost.d.ts +16 -1
  25. package/dist/cost.d.ts.map +1 -1
  26. package/dist/cost.js +73 -14
  27. package/dist/cost.js.map +1 -1
  28. package/dist/credentials/resolver.d.ts +0 -1
  29. package/dist/credentials/resolver.d.ts.map +1 -1
  30. package/dist/credentials/resolver.js +10 -46
  31. package/dist/credentials/resolver.js.map +1 -1
  32. package/dist/discovery/catalog-only.d.ts.map +1 -1
  33. package/dist/discovery/catalog-only.js +8 -1
  34. package/dist/discovery/catalog-only.js.map +1 -1
  35. package/dist/discovery/free-tier.d.ts +2 -0
  36. package/dist/discovery/free-tier.d.ts.map +1 -1
  37. package/dist/discovery/free-tier.js +4 -0
  38. package/dist/discovery/free-tier.js.map +1 -1
  39. package/dist/discovery/generic-openai.d.ts +27 -0
  40. package/dist/discovery/generic-openai.d.ts.map +1 -1
  41. package/dist/discovery/generic-openai.js +109 -5
  42. package/dist/discovery/generic-openai.js.map +1 -1
  43. package/dist/discovery/modelsdev-seed.d.ts.map +1 -1
  44. package/dist/discovery/modelsdev-seed.js +21 -6
  45. package/dist/discovery/modelsdev-seed.js.map +1 -1
  46. package/dist/discovery/openai-compatible.d.ts +2 -0
  47. package/dist/discovery/openai-compatible.d.ts.map +1 -1
  48. package/dist/discovery/openai-compatible.js +19 -11
  49. package/dist/discovery/openai-compatible.js.map +1 -1
  50. package/dist/discovery/snapshot-catalog.d.ts +18 -0
  51. package/dist/discovery/snapshot-catalog.d.ts.map +1 -1
  52. package/dist/discovery/snapshot-catalog.js +83 -3
  53. package/dist/discovery/snapshot-catalog.js.map +1 -1
  54. package/dist/discovery/vercel.d.ts.map +1 -1
  55. package/dist/discovery/vercel.js +19 -0
  56. package/dist/discovery/vercel.js.map +1 -1
  57. package/dist/discovery-contract.d.ts +8 -3
  58. package/dist/discovery-contract.d.ts.map +1 -1
  59. package/dist/discovery-contract.js.map +1 -1
  60. package/dist/enrichment/catalog.js +2 -2
  61. package/dist/enrichment/catalog.js.map +1 -1
  62. package/dist/enrichment/litellm.d.ts.map +1 -1
  63. package/dist/enrichment/litellm.js +18 -16
  64. package/dist/enrichment/litellm.js.map +1 -1
  65. package/dist/index.d.ts +3 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +2 -1
  68. package/dist/index.js.map +1 -1
  69. package/dist/lifecycle.d.ts +13 -0
  70. package/dist/lifecycle.d.ts.map +1 -0
  71. package/dist/lifecycle.js +19 -0
  72. package/dist/lifecycle.js.map +1 -0
  73. package/dist/mcp-server.d.ts +1 -1
  74. package/dist/mcp-server.d.ts.map +1 -1
  75. package/dist/mcp-server.js +8 -1
  76. package/dist/mcp-server.js.map +1 -1
  77. package/dist/price-trust.d.ts +12 -0
  78. package/dist/price-trust.d.ts.map +1 -1
  79. package/dist/price-trust.js +72 -0
  80. package/dist/price-trust.js.map +1 -1
  81. package/dist/provider-catalog.d.ts +48 -0
  82. package/dist/provider-catalog.d.ts.map +1 -1
  83. package/dist/provider-catalog.js +114 -6
  84. package/dist/provider-catalog.js.map +1 -1
  85. package/dist/proxy.d.ts +9 -0
  86. package/dist/proxy.d.ts.map +1 -1
  87. package/dist/proxy.js +196 -31
  88. package/dist/proxy.js.map +1 -1
  89. package/dist/registry-discovery.js +7 -1
  90. package/dist/registry-discovery.js.map +1 -1
  91. package/dist/registry-query.d.ts.map +1 -1
  92. package/dist/registry-query.js +20 -8
  93. package/dist/registry-query.js.map +1 -1
  94. package/dist/registry-routing.d.ts.map +1 -1
  95. package/dist/registry-routing.js +2 -1
  96. package/dist/registry-routing.js.map +1 -1
  97. package/dist/registry-runtime.d.ts +34 -2
  98. package/dist/registry-runtime.d.ts.map +1 -1
  99. package/dist/registry-runtime.js +499 -67
  100. package/dist/registry-runtime.js.map +1 -1
  101. package/dist/registry.d.ts +5 -1
  102. package/dist/registry.d.ts.map +1 -1
  103. package/dist/registry.js +22 -4
  104. package/dist/registry.js.map +1 -1
  105. package/dist/server.d.ts.map +1 -1
  106. package/dist/server.js +21 -6
  107. package/dist/server.js.map +1 -1
  108. package/dist/snapshot-gate.d.ts +50 -0
  109. package/dist/snapshot-gate.d.ts.map +1 -0
  110. package/dist/snapshot-gate.js +128 -0
  111. package/dist/snapshot-gate.js.map +1 -0
  112. package/dist/subscription-plans.d.ts +63 -0
  113. package/dist/subscription-plans.d.ts.map +1 -0
  114. package/dist/subscription-plans.js +261 -0
  115. package/dist/subscription-plans.js.map +1 -0
  116. package/dist/types.d.ts +22 -0
  117. package/dist/types.d.ts.map +1 -1
  118. package/package.json +8 -8
  119. package/server.json +2 -2
@@ -12,13 +12,15 @@ import { dirname, join } from "path";
12
12
  import { catalogCacheKey, loadActiveCatalog, resolveCatalogSource } from "./catalog-source.js";
13
13
  import { buildCatalogOnlyRows } from "./discovery/catalog-only.js";
14
14
  import { priceAgreement } from "./discovery/price-evidence.js";
15
- import { applyPriceSchedules } from "./discovery/price-schedules.js";
15
+ import { applyPriceSchedules, SCHEDULED_PRICES } from "./discovery/price-schedules.js";
16
16
  import { applyPromoOverrides } from "./discovery/promo-overrides.js";
17
17
  import { DISCOVERY_SCHEMA_VERSION } from "./discovery-contract.js";
18
- import { getProviderConfig, getProviderDescriptor, isLocalProvider, normalizeProviderId } from "./provider-catalog.js";
18
+ import { PRICE_RATE_FIELDS, sanitizeModelPricing } from "./price-trust.js";
19
+ import { getProviderConfig, getProviderDescriptor, isLocalProvider, normalizeProviderId, resolveEnvCredential, } from "./provider-catalog.js";
19
20
  import { registryDiscoverySnapshot } from "./registry-discovery.js";
20
21
  import { StaleCachePolicy } from "./resilience.js";
21
22
  import { assertCleanPayload } from "./security.js";
23
+ import { getSubscriptionPlan } from "./subscription-plans.js";
22
24
  const DEFAULT_CACHE_TTL_MS = 86_400_000;
23
25
  const DEFAULT_TIMEOUT_MS = 10_000;
24
26
  const CACHE_KEY_PREFIX = "provider_";
@@ -50,10 +52,15 @@ export async function registryDiscover(state, dependencies, options) {
50
52
  // Only refresh the canonical manifest for full-registry cache hits.
51
53
  // Scoped provider rehydration can carry cache-derived state that should
52
54
  // not be published as a fresh top-level manifest snapshot.
55
+ sanitizeRegistryPricing(state);
53
56
  if (!providers || providers.length === 0) {
54
- // A cache hit makes no network calls, and that includes this file:
55
- // it waits for the next real discovery.
56
- await exportRegistryManifest(state, { catalogOnly: false });
57
+ // A cache hit is not a discovery pass, so it doesn't rewrite the
58
+ // manifest (that would advance missingRunCount and reset price
59
+ // holds on every `kosha list`). It does read the manifest, so a
60
+ // price held there is the price kosha routes and budgets with.
61
+ const manifest = await readPreviousManifest(resolveManifestPath(state));
62
+ if (manifest && applyManifestHolds(state, manifest))
63
+ state.lastSnapshotCache = null;
57
64
  }
58
65
  return Array.from(state.providerMap.values());
59
66
  }
@@ -63,7 +70,8 @@ export async function registryDiscover(state, dependencies, options) {
63
70
  // Providers whose credentialed call failed but which were still listed from
64
71
  // the public catalog: the error is reported, the provider is not dropped.
65
72
  const degraded = [];
66
- const results = await Promise.allSettled(discoverers.map((discoverer) => discoverProvider(state, dependencies, discoverer, timeout, degraded)));
73
+ const sharedKeys = { accepted: new Set(), refused: new Map() };
74
+ const results = await Promise.allSettled(discoverers.map((discoverer) => discoverProvider(state, dependencies, discoverer, timeout, degraded, sharedKeys)));
67
75
  state.lastDiscoveryErrors = [...degraded];
68
76
  for (let index = 0; index < results.length; index += 1) {
69
77
  const result = results[index];
@@ -81,6 +89,7 @@ export async function registryDiscover(state, dependencies, options) {
81
89
  timestamp: Date.now(),
82
90
  });
83
91
  }
92
+ reportRefusedSharedKeys(state, sharedKeys);
84
93
  // Snapshot which models the discoverers already priced, BEFORE the
85
94
  // litellm enrichment pass runs. Pricing present here is attributed to
86
95
  // the discoverer's own origin (provider-live API vs static seed);
@@ -94,7 +103,7 @@ export async function registryDiscover(state, dependencies, options) {
94
103
  for (const [providerId, providerInfo] of state.providerMap) {
95
104
  state.providerMap.set(providerId, {
96
105
  ...providerInfo,
97
- models: applyPriceSchedules(applyPromoOverrides(providerInfo.models)),
106
+ models: applyPriceSchedules(applyPromoOverrides(providerInfo.models)).map(sanitizeModelPricing),
98
107
  });
99
108
  }
100
109
  // Attribute pricing provenance at the enrich/merge boundary now that
@@ -107,7 +116,14 @@ export async function registryDiscover(state, dependencies, options) {
107
116
  // Write the consumer-facing manifest last, after recordDiscoveryMutation
108
117
  // has advanced the cursor — that way the snapshot's cursor reflects the
109
118
  // post-discovery revision instead of a stale pre-mutation value.
110
- await exportRegistryManifest(state);
119
+ const merged = await exportRegistryManifest(state);
120
+ // Quarantine decides in the merge, so carry its verdict back into memory
121
+ // and the cache: kosha's own routing, `cheapest`, MCP and the budget must
122
+ // use the held price, not the suspect one the merge refused.
123
+ if (merged && applyManifestHolds(state, merged)) {
124
+ state.lastSnapshotCache = null;
125
+ await dependencies.saveToCache();
126
+ }
111
127
  return Array.from(state.providerMap.values());
112
128
  }
113
129
  /**
@@ -177,9 +193,10 @@ export function fallbackRegistryCredential(providerId, explicitKey) {
177
193
  if (oidcToken)
178
194
  return { accessToken: oidcToken, source: "env" };
179
195
  }
180
- const envVar = getProviderDescriptor(normalizedProviderId)?.primaryCredentialEnvVar;
181
- const value = envVar ? process.env[envVar] : undefined;
182
- return value ? { apiKey: value, source: "env" } : { source: "none" };
196
+ const found = resolveEnvCredential(getProviderDescriptor(normalizedProviderId));
197
+ return found
198
+ ? { apiKey: found.apiKey, source: "env", envVar: found.envVar, shared: found.shared }
199
+ : { source: "none" };
183
200
  }
184
201
  /**
185
202
  * Enrich the in-memory model set with pricing metadata when available.
@@ -232,13 +249,17 @@ export async function registryEnrichOnly(state) {
232
249
  for (const [providerId, providerInfo] of state.providerMap) {
233
250
  state.providerMap.set(providerId, {
234
251
  ...providerInfo,
235
- models: applyPriceSchedules(applyPromoOverrides(providerInfo.models)),
252
+ models: applyPriceSchedules(applyPromoOverrides(providerInfo.models)).map(sanitizeModelPricing),
236
253
  });
237
254
  }
238
255
  attributePricingSources(state, preEnrichmentPriced);
239
256
  populateRegistryModelAliases(state);
240
257
  await saveRegistryToCache(state);
241
- await exportRegistryManifest(state);
258
+ const merged = await exportRegistryManifest(state);
259
+ if (merged && applyManifestHolds(state, merged)) {
260
+ state.lastSnapshotCache = null;
261
+ await saveRegistryToCache(state);
262
+ }
242
263
  const after = countPricingFields(state);
243
264
  return {
244
265
  modelCount: Array.from(state.providerMap.values()).reduce((sum, p) => sum + p.models.length, 0),
@@ -389,8 +410,10 @@ export async function exportRegistryManifest(state, options) {
389
410
  let manifestPath = null;
390
411
  let lockReleased = true;
391
412
  let lockPath = null;
413
+ let fresh = null;
414
+ let merged = null;
392
415
  try {
393
- const fresh = state.lastSnapshotCache ?? registryDiscoverySnapshot(state);
416
+ fresh = state.lastSnapshotCache ?? registryDiscoverySnapshot(state);
394
417
  state.lastSnapshotCache = fresh;
395
418
  // Honour state.config.cacheDir so isolated tests (and any other caller
396
419
  // that pins a custom cache root) don't clobber the user's real
@@ -423,7 +446,7 @@ export async function exportRegistryManifest(state, options) {
423
446
  // the user's pricing for those models — they retain their last-known
424
447
  // values until a successful fetch supersedes them.
425
448
  const previousManifest = await readPreviousManifest(manifestPath);
426
- const merged = mergeManifests(previousManifest, fresh);
449
+ merged = mergeManifests(previousManifest, fresh);
427
450
  // Pricing-diff alerts. The most dangerous failure mode isn't "kosha
428
451
  // returned null" (we already detect that). It's "kosha returned a
429
452
  // wrong number" — silent over- or undercharging. We compare prev vs
@@ -467,7 +490,8 @@ export async function exportRegistryManifest(state, options) {
467
490
  }
468
491
  warnOnNewlyDisputedPrices(previousManifest, merged);
469
492
  if (options?.catalogOnly !== false)
470
- await exportCatalogOnlyManifest(manifestPath);
493
+ await exportCatalogOnlyManifest(manifestPath, new Set(state.providerMap.keys()));
494
+ return merged;
471
495
  }
472
496
  catch (err) {
473
497
  // Manifest export is best-effort — the cache still works either way.
@@ -476,6 +500,19 @@ export async function exportRegistryManifest(state, options) {
476
500
  // gated on a successful export.
477
501
  const reason = err instanceof Error ? err.message : String(err);
478
502
  console.warn(`[kosha] manifest export failed (${manifestPath ?? "unknown path"}): ${reason}`);
503
+ // The write failed, but the price verdicts must not: compute the merge in
504
+ // memory so the caller still applies its holds. Without this a lock
505
+ // timeout or a full disk let a suspect price straight into routing.
506
+ if (!merged && fresh) {
507
+ try {
508
+ const previous = manifestPath ? await readPreviousManifest(manifestPath) : null;
509
+ merged = mergeManifests(previous, fresh);
510
+ }
511
+ catch {
512
+ // Nothing left to judge against; the caller keeps what it has.
513
+ }
514
+ }
515
+ return merged;
479
516
  }
480
517
  finally {
481
518
  if (!lockReleased && lockPath) {
@@ -648,14 +685,17 @@ const MAX_CATALOG_ONLY_BYTES = 32 * 1024 * 1024;
648
685
  *
649
686
  * Failing leaves the previous file in place: the registry above is already saved.
650
687
  */
651
- async function exportCatalogOnlyManifest(manifestPath) {
688
+ async function exportCatalogOnlyManifest(manifestPath, listedIds) {
652
689
  if (resolveCatalogSource().mode !== "community")
653
690
  return;
654
691
  const path = join(dirname(manifestPath), CATALOG_ONLY_FILE);
655
692
  let tmpPath = null;
656
693
  try {
657
694
  const { getAllDiscoverers } = await import("./discovery/index.js");
658
- const models = await buildCatalogOnlyRows(getAllDiscoverers().map((discoverer) => discoverer.providerId));
695
+ const claimed = getAllDiscoverers()
696
+ .map((discoverer) => discoverer.providerId)
697
+ .filter((id) => !getSubscriptionPlan(id) || listedIds.has(id));
698
+ const models = await buildCatalogOnlyRows(claimed);
659
699
  if (models.length === 0)
660
700
  return;
661
701
  const fresh = {
@@ -684,6 +724,145 @@ async function exportCatalogOnlyManifest(manifestPath) {
684
724
  console.warn(`[kosha] ${CATALOG_ONLY_FILE} not updated: ${err instanceof Error ? err.message : String(err)}`);
685
725
  }
686
726
  }
727
+ /** Tags the manifest merge puts on a price that isn't settled. */
728
+ const PRICE_HOLD_TAGS = ["pricing_quarantined", "pricing_sources_disagree", "pricing_single_source"];
729
+ /** Run {@link sanitizeModelPricing} over every model in memory. */
730
+ function sanitizeRegistryPricing(state) {
731
+ for (const [providerId, providerInfo] of state.providerMap) {
732
+ const models = providerInfo.models.map(sanitizeModelPricing);
733
+ if (models.some((m, i) => m !== providerInfo.models[i]))
734
+ state.providerMap.set(providerId, { ...providerInfo, models });
735
+ }
736
+ }
737
+ /**
738
+ * Copy the merge's price verdicts onto the in-memory registry. A quarantined
739
+ * row carries the held (previous) price, which replaces the suspect one in
740
+ * memory; dispute and single-source flags are copied onto rawCapabilities so
741
+ * every reader sees them. A secondary rate (cache, image, audio…) the merge
742
+ * kept because a catalog reported it as 0 is copied back too; before, only
743
+ * `registry.json` had it and routing and the budget used 0. Rows the manifest
744
+ * kept for a model this run didn't see are ignored. Returns true when anything
745
+ * in memory changed.
746
+ */
747
+ export function applyManifestHolds(state, manifest) {
748
+ const rows = new Map();
749
+ for (const row of manifest.models ?? [])
750
+ rows.set(`${row.providerId}\u0000${row.modelId}`, row);
751
+ if (rows.size === 0)
752
+ return false;
753
+ let changed = false;
754
+ for (const [providerId, providerInfo] of state.providerMap) {
755
+ let touched = false;
756
+ const models = providerInfo.models.map((original) => {
757
+ const row = rows.get(`${providerId}\u0000${original.id}`);
758
+ if (!row)
759
+ return original;
760
+ const model = keepReportedSecondaryRates(row, original);
761
+ const next = { ...model };
762
+ const before = model.rawCapabilities ?? [];
763
+ let tags = before;
764
+ for (const tag of PRICE_HOLD_TAGS) {
765
+ if ((row.rawCapabilities ?? []).includes(tag) && !tags.includes(tag))
766
+ tags = [...tags, tag];
767
+ }
768
+ let differs = model !== original || tags !== before;
769
+ if (tags.length > 0)
770
+ next.rawCapabilities = tags;
771
+ const quarantined = (row.rawCapabilities ?? []).includes("pricing_quarantined");
772
+ const disputed = (row.rawCapabilities ?? []).includes(PRICING_SOURCES_DISAGREE);
773
+ if (quarantined || disputed) {
774
+ // While a price is held or disputed, kosha routes and budgets on the
775
+ // highest rate anyone reported: the held one, the fresh one and each
776
+ // source's. Overpaying in the estimate is safe; undercounting isn't.
777
+ const base = quarantined ? (row.pricing ?? null) : (model.pricing ?? null);
778
+ const evidence = model.priceEvidence ?? row.priceEvidence ?? [];
779
+ const held = base
780
+ ? (sanitizeModelPricing({ ...model, pricing: highestRates(base, model.pricing ?? null, evidence) }).pricing ??
781
+ null)
782
+ : null;
783
+ if (JSON.stringify(held) !== JSON.stringify(model.pricing ?? null)) {
784
+ differs = true;
785
+ if (held)
786
+ next.pricing = held;
787
+ else
788
+ delete next.pricing;
789
+ }
790
+ if (quarantined && JSON.stringify(row.originPricing ?? null) !== JSON.stringify(model.originPricing ?? null)) {
791
+ differs = true;
792
+ if (row.originPricing)
793
+ next.originPricing = row.originPricing;
794
+ else
795
+ delete next.originPricing;
796
+ }
797
+ }
798
+ if (!differs)
799
+ return original;
800
+ touched = true;
801
+ return next;
802
+ });
803
+ if (touched) {
804
+ state.providerMap.set(providerId, { ...providerInfo, models });
805
+ changed = true;
806
+ }
807
+ }
808
+ return changed;
809
+ }
810
+ /** Each rate at the highest of `base`, `other` and the sources' headline
811
+ * rates. Fields `base` doesn't price stay unpriced. */
812
+ export function highestRates(base, other, evidence) {
813
+ const out = { ...base };
814
+ for (const field of QUARANTINE_FIELDS) {
815
+ const current = out[field];
816
+ if (typeof current !== "number")
817
+ continue;
818
+ let highest = current;
819
+ const fresh = other?.[field];
820
+ if (typeof fresh === "number" && Number.isFinite(fresh))
821
+ highest = Math.max(highest, fresh);
822
+ if (field === "inputPerMillion" || field === "outputPerMillion") {
823
+ for (const entry of evidence) {
824
+ if (Number.isFinite(entry[field]))
825
+ highest = Math.max(highest, entry[field]);
826
+ }
827
+ }
828
+ out[field] = highest;
829
+ }
830
+ return out;
831
+ }
832
+ /**
833
+ * Accept the price a quarantine is holding for one model. The hold remembers
834
+ * exactly which rates it's waiting on, so the acceptance covers those rates
835
+ * only: the next discovery pass takes them, and a different publish after
836
+ * that is judged afresh. Writes the manifest under the export lock.
837
+ */
838
+ export async function acceptHeldPrice(state, providerId, modelId) {
839
+ const manifestPath = resolveManifestPath(state);
840
+ const lockPath = `${manifestPath}.lock`;
841
+ await acquireExportLock(lockPath);
842
+ try {
843
+ const manifest = await readManifestFile(manifestPath);
844
+ const row = manifest?.models.find((m) => m.providerId === providerId && m.modelId === modelId);
845
+ if (!manifest || !row)
846
+ return { status: "not-found" };
847
+ const hold = row.pricingHold;
848
+ if (!hold || !(row.rawCapabilities ?? []).includes("pricing_quarantined"))
849
+ return { status: "not-held" };
850
+ row.pricingHold = { ...hold, accepted: true };
851
+ const tmpPath = `${manifestPath}.${randomBytes(4).toString("hex")}.tmp`;
852
+ try {
853
+ await writeFile(tmpPath, JSON.stringify(manifest, null, 2), { encoding: "utf-8", mode: 0o600 });
854
+ await rename(tmpPath, manifestPath);
855
+ }
856
+ catch (err) {
857
+ await unlink(tmpPath).catch(() => { });
858
+ throw err;
859
+ }
860
+ return { status: "accepted", held: row.pricing ?? null, waitingSince: hold.since };
861
+ }
862
+ finally {
863
+ await unlink(lockPath).catch(() => { });
864
+ }
865
+ }
687
866
  /** Resolve the on-disk path for the registry manifest. Tests + custom callers
688
867
  * pin via `state.config.cacheDir`; production falls back to ~/.kosha. */
689
868
  function resolveManifestPath(state) {
@@ -708,14 +887,28 @@ function resolveManifestPath(state) {
708
887
  * fresh snapshot as-is (first run / corrupted file).
709
888
  */
710
889
  async function readPreviousManifest(manifestPath) {
890
+ const current = await readManifestFile(manifestPath);
891
+ if (current)
892
+ return current;
893
+ // The manifest holds every price baseline. Losing it to a corrupt write, a
894
+ // schema bump or a failed scan would accept every fresh price unchecked, so
895
+ // fall back to the newest readable backup in the rollback ring.
896
+ for (const backup of await listBackups(manifestPath)) {
897
+ const parsed = await readManifestFile(backup);
898
+ if (parsed)
899
+ return parsed;
900
+ }
901
+ return null;
902
+ }
903
+ async function readManifestFile(path) {
711
904
  try {
712
905
  const { readFile } = await import("fs/promises");
713
- const raw = await readFile(manifestPath, "utf-8");
906
+ const raw = await readFile(path, "utf-8");
714
907
  const parsed = JSON.parse(raw);
715
908
  if (!parsed?.models)
716
909
  return null;
717
910
  // Schema-version guard. A different version on disk means we can't
718
- // trust the field shapes — caller falls through to fresh-only.
911
+ // trust the field shapes.
719
912
  if (parsed.schemaVersion !== DISCOVERY_SCHEMA_VERSION)
720
913
  return null;
721
914
  assertCleanPayload(parsed, "registry manifest");
@@ -725,6 +918,22 @@ async function readPreviousManifest(manifestPath) {
725
918
  return null; // missing or unreadable
726
919
  }
727
920
  }
921
+ /** The manifest's `.bak.<YYYY-MM-DD>` copies, newest first. */
922
+ async function listBackups(manifestPath) {
923
+ const dir = dirname(manifestPath);
924
+ const base = manifestPath.slice(dir.length + 1);
925
+ const bakRe = new RegExp(`^${escapeRegExp(base)}\\.bak\\.\\d{4}-\\d{2}-\\d{2}$`);
926
+ try {
927
+ return (await readdir(dir))
928
+ .filter((name) => bakRe.test(name))
929
+ .sort()
930
+ .reverse()
931
+ .map((name) => join(dir, name));
932
+ }
933
+ catch {
934
+ return [];
935
+ }
936
+ }
728
937
  /** Beyond this fractional rate change in either direction, we treat a fresh
729
938
  * price as suspect and keep the previous value (quarantine). The 25% delta
730
939
  * used by anomaly logging is a UI-alert threshold; quarantine fires only on
@@ -745,14 +954,20 @@ const PRICING_DISPUTED_HOLD_MS = 7 * DAY_MS;
745
954
  const PRICING_SINGLE_SOURCE = "pricing_single_source";
746
955
  /** Sources list different prices for this model. */
747
956
  const PRICING_SOURCES_DISAGREE = "pricing_sources_disagree";
748
- /** After this many consecutive discovery passes in which a previously-known
749
- * model is missing from the fresh snapshot, the model is dropped from the
750
- * merged manifest. Prevents the manifest from becoming a graveyard of
751
- * deprecated SKUs while still riding through transient outages. */
752
- const MISSING_RUNS_TTL = 14;
957
+ /** A previously-known model missing from fresh snapshots for longer than this
958
+ * is dropped from the merged manifest. Prevents the manifest from becoming a
959
+ * graveyard of deprecated SKUs while still riding through outages. */
960
+ const MISSING_MODEL_TTL_MS = 30 * DAY_MS;
753
961
  export function mergeManifests(previous, fresh) {
754
- if (!previous)
755
- return fresh;
962
+ if (!previous) {
963
+ // Nothing to judge against yet, but every price seen now is the anchor
964
+ // the next run is checked against.
965
+ const seen = fresh.discoveredAt ?? Date.now();
966
+ return {
967
+ ...fresh,
968
+ models: (fresh.models ?? []).map((m) => withAnchor({ ...m, lastSeenAt: seen }, null, seen)),
969
+ };
970
+ }
756
971
  // Index previous entries by key so we can both detect collisions AND
757
972
  // look back at the old data for pricing-degraded merge below.
758
973
  const previousByKey = new Map();
@@ -761,8 +976,8 @@ export function mergeManifests(previous, fresh) {
761
976
  previousByKey.set(m.key, m);
762
977
  }
763
978
  // Per-model merge with three rules:
764
- // (1) Old entries fresh dropped entirely → keep until missingRunCount
765
- // exceeds MISSING_RUNS_TTL (model lifecycle TTL).
979
+ // (1) Old entries fresh dropped entirely → keep until unseen for
980
+ // MISSING_MODEL_TTL_MS (model lifecycle TTL).
766
981
  // (2) Fresh entries that came back with NO pricing while the old entry
767
982
  // HAD pricing → keep the old pricing block (degraded-fresh defence).
768
983
  // (3) Fresh entries whose rates moved beyond PRICING_QUARANTINE_THRESHOLD
@@ -771,35 +986,44 @@ export function mergeManifests(previous, fresh) {
771
986
  // can't silently overcharge or underprice downstream consumers.
772
987
  // The same rates are accepted after a day if two sources agree on them,
773
988
  // after three days if only one lists them, and not while sources disagree.
989
+ const now = fresh.discoveredAt ?? Date.now();
774
990
  const freshModelKeys = new Set();
775
991
  const mergedModels = [];
776
992
  for (const f of fresh.models ?? []) {
777
993
  if (f.key)
778
994
  freshModelKeys.add(f.key);
779
995
  const old = f.key ? previousByKey.get(f.key) : undefined;
780
- let kept = { ...f, missingRunCount: 0 };
996
+ let kept = { ...f, missingRunCount: 0, lastSeenAt: now };
997
+ // A secondary rate (cache, audio, image…) that falls from a price to 0
998
+ // reads as "not reported" in the catalogs, but billing it at 0 would
999
+ // undercount spend. Keep the last real rate instead.
1000
+ if (old)
1001
+ kept = keepReportedSecondaryRates(old, kept);
1002
+ const anchor = old ? liveAnchor(old, now) : null;
781
1003
  if (old && !hasUsablePricing(f) && hasUsablePricing(old)) {
782
1004
  // Keep all of fresh's metadata but restore pricing from old.
783
1005
  kept = { ...kept, pricing: old.pricing, originPricing: old.originPricing };
784
1006
  }
785
- else if (old && !f.pricing?.schedule && quarantinePricingMove(old, f)) {
1007
+ else if (old && !isCuratedSchedule(f) && priceMoveSuspect(old, kept, anchor)) {
786
1008
  // A scheduled price is curated and cited, not a catalog's publish, so
787
1009
  // it skips quarantine. Otherwise the first move to it would be refused
788
- // on every run.
789
- //
790
- const now = fresh.discoveredAt ?? Date.now();
791
- const rates = pricingFingerprint(f);
1010
+ // on every run. Only kosha's own schedule table counts: a `schedule`
1011
+ // field carried in by a snapshot or a local file proves nothing.
1012
+ const rates = pricingFingerprint(kept);
792
1013
  const hold = old.pricingHold;
793
- const since = hold?.rates === rates ? hold.since : now;
1014
+ const same = hold !== undefined && (hold.rates === rates || hold.rates === legacyPricingFingerprint(kept));
1015
+ const since = same ? hold.since : now;
1016
+ const accepted = same && hold.accepted === true;
794
1017
  const agreement = priceAgreement(f.priceEvidence);
795
1018
  const window = agreement === "agree"
796
1019
  ? PRICING_AGREED_HOLD_MS
797
1020
  : agreement === "disagree"
798
1021
  ? PRICING_DISPUTED_HOLD_MS
799
1022
  : PRICING_SINGLE_SOURCE_HOLD_MS;
800
- // A billed rate of $0 is never accepted this way: it poisons cheapest
801
- // routing however long it's published.
802
- if (billedRateDroppedToZero(old, f) || now - since < window) {
1023
+ // A billed rate that collapses to $0, or to under 1% of what it was, is
1024
+ // never accepted by waiting: it poisons cheapest routing however long
1025
+ // it's published. Only `kosha registry accept` releases it.
1026
+ if (!accepted && (billedRateCollapsed(old, kept) || now - since < window)) {
803
1027
  // Quarantine the suspect rates: keep the old pricing block and
804
1028
  // tag the operational `rawCapabilities` array (the trusted
805
1029
  // `capabilities` taxonomy stays clean, since this isn't a routing
@@ -820,7 +1044,7 @@ export function mergeManifests(previous, fresh) {
820
1044
  kept = { ...kept, rawCapabilities: tagCapability(kept.rawCapabilities, flag) };
821
1045
  }
822
1046
  }
823
- else if (old && !f.pricing?.schedule) {
1047
+ else if (old && !isCuratedSchedule(f)) {
824
1048
  // Below the quarantine line a price still takes effect, but a move its
825
1049
  // sources dispute is the believable lie, so it carries a flag. Flags
826
1050
  // from an earlier move last until the rates change or the evidence does.
@@ -837,16 +1061,20 @@ export function mergeManifests(previous, fresh) {
837
1061
  if (tags !== kept.rawCapabilities)
838
1062
  kept = { ...kept, rawCapabilities: tags };
839
1063
  }
840
- mergedModels.push(kept);
1064
+ mergedModels.push(withAnchor(kept, anchor, now));
841
1065
  }
842
1066
  for (const old of previous.models) {
843
1067
  if (!old.key || freshModelKeys.has(old.key))
844
1068
  continue;
845
1069
  const prevMissing = old.missingRunCount ?? 0;
846
- const nextMissing = prevMissing + 1;
847
- if (nextMissing > MISSING_RUNS_TTL)
1070
+ // The row is the baseline a returning model's price is checked against,
1071
+ // so it ages by time, not by how often discovery ran: an hourly loop
1072
+ // would otherwise drop it in under a day. Rows written before lastSeenAt
1073
+ // existed date from the previous run.
1074
+ const lastSeenAt = old.lastSeenAt ?? previous.discoveredAt ?? now;
1075
+ if (now - lastSeenAt > MISSING_MODEL_TTL_MS)
848
1076
  continue; // drop after TTL
849
- mergedModels.push({ ...old, missingRunCount: nextMissing });
1077
+ mergedModels.push({ ...old, missingRunCount: prevMissing + 1, lastSeenAt });
850
1078
  }
851
1079
  const freshProviderIds = new Set((fresh.providers ?? []).map((p) => p.providerId));
852
1080
  const mergedProviders = [...(fresh.providers ?? [])];
@@ -861,7 +1089,20 @@ export function mergeManifests(previous, fresh) {
861
1089
  providers: mergedProviders,
862
1090
  };
863
1091
  }
1092
+ /** Every rate that bills or ranks. The long-context threshold is a token
1093
+ * count, not a price, so it isn't compared. */
1094
+ const QUARANTINE_FIELDS = PRICE_RATE_FIELDS.filter((field) => field !== "longContextThresholdTokens");
1095
+ /** The billed rates that decide a model's headline price. */
1096
+ const HEADLINE_FIELDS = new Set(["inputPerMillion", "outputPerMillion"]);
1097
+ /** A billed rate below this share of the one it replaces counts as a collapse,
1098
+ * like $0: never accepted by waiting. */
1099
+ const PRICING_COLLAPSE_RATIO = 0.01;
864
1100
  function pricingFingerprint(model) {
1101
+ return JSON.stringify([model.pricing, model.originPricing].map((side) => QUARANTINE_FIELDS.map((field) => side?.[field] ?? null)));
1102
+ }
1103
+ /** The five-field fingerprint holds recorded before every rate was compared.
1104
+ * Matching it keeps an upgrade from restarting every hold's clock. */
1105
+ function legacyPricingFingerprint(model) {
865
1106
  const fields = [
866
1107
  "inputPerMillion",
867
1108
  "outputPerMillion",
@@ -871,48 +1112,142 @@ function pricingFingerprint(model) {
871
1112
  ];
872
1113
  return JSON.stringify([model.pricing, model.originPricing].map((side) => fields.map((field) => side?.[field] ?? null)));
873
1114
  }
874
- /** True if a priced model's billed input or output rate fell to zero. */
875
- function billedRateDroppedToZero(previous, fresh) {
1115
+ /** True when the fresh row's pricing is kosha's own cited schedule for that model. */
1116
+ function isCuratedSchedule(row) {
1117
+ if (!row.pricing?.schedule)
1118
+ return false;
1119
+ const match = SCHEDULED_PRICES.find((s) => s.providerId === row.providerId && s.modelId === row.modelId);
1120
+ return (match !== undefined &&
1121
+ row.pricing.inputPerMillion === match.peak.inputPerMillion &&
1122
+ row.pricing.outputPerMillion === match.peak.outputPerMillion);
1123
+ }
1124
+ /** Restore a secondary billed rate that went from a price to 0. */
1125
+ function keepReportedSecondaryRates(previous, fresh) {
1126
+ const a = previous.pricing;
1127
+ const b = fresh.pricing;
1128
+ if (!a || !b)
1129
+ return fresh;
1130
+ let pricing = null;
1131
+ for (const field of QUARANTINE_FIELDS) {
1132
+ if (HEADLINE_FIELDS.has(field))
1133
+ continue;
1134
+ const prev = a[field];
1135
+ if (typeof prev === "number" && prev > 0 && b[field] === 0) {
1136
+ pricing ??= { ...b };
1137
+ pricing[field] = prev;
1138
+ }
1139
+ }
1140
+ return pricing ? { ...fresh, pricing } : fresh;
1141
+ }
1142
+ /** True if a billed rate fell to zero (input or output) or to under 1% of the
1143
+ * rate it replaces (any field). */
1144
+ function billedRateCollapsed(previous, fresh) {
876
1145
  const a = previous.pricing;
877
1146
  const b = fresh.pricing;
878
1147
  if (!a || !b)
879
1148
  return false;
880
- return ["inputPerMillion", "outputPerMillion"].some((field) => (a[field] ?? 0) > 0 && b[field] === 0);
1149
+ return QUARANTINE_FIELDS.some((field) => {
1150
+ const prev = a[field];
1151
+ const next = b[field];
1152
+ if (typeof prev !== "number" || typeof next !== "number" || prev <= 0)
1153
+ return false;
1154
+ if (next === 0)
1155
+ return HEADLINE_FIELDS.has(field);
1156
+ return next < prev * PRICING_COLLAPSE_RATIO;
1157
+ });
881
1158
  }
882
- /** True if any defined per-million rate moved by more than the quarantine
883
- * threshold (either direction), compared to the previous snapshot. */
884
- function quarantinePricingMove(previous, fresh) {
885
- const fields = [
886
- "inputPerMillion",
887
- "outputPerMillion",
888
- "cacheReadPerMillion",
889
- "cacheWritePerMillion",
890
- "cacheWrite1hPerMillion",
891
- ];
1159
+ /** How long a high price stays the anchor before the current one replaces it. */
1160
+ const PRICE_ANCHOR_WINDOW_MS = 30 * DAY_MS;
1161
+ /** A fresh headline rate below this share of the anchor is suspect: one real
1162
+ * 50% cut passes, a second inside the window waits. */
1163
+ const PRICE_ANCHOR_FLOOR = 0.5;
1164
+ /** A fresh headline rate above this multiple of the anchor is suspect. */
1165
+ const PRICE_ANCHOR_CEILING = 4;
1166
+ function liveAnchor(row, now) {
1167
+ const anchor = row.priceAnchor;
1168
+ if (!anchor || typeof anchor.at !== "number" || now - anchor.at > PRICE_ANCHOR_WINDOW_MS)
1169
+ return null;
1170
+ return anchor;
1171
+ }
1172
+ /** Raise the anchor to the accepted price where it's higher; reset it once
1173
+ * the window has passed. Rows without headline rates carry no anchor. */
1174
+ function withAnchor(row, anchor, now) {
1175
+ const pricing = row.pricing;
1176
+ const next = { at: anchor?.at ?? now };
1177
+ let raised = anchor === null;
1178
+ for (const field of ["inputPerMillion", "outputPerMillion"]) {
1179
+ const accepted = pricing?.[field];
1180
+ const held = anchor?.[field];
1181
+ if (typeof accepted === "number" && accepted > 0 && (typeof held !== "number" || accepted > held)) {
1182
+ next[field] = accepted;
1183
+ raised = true;
1184
+ }
1185
+ else if (typeof held === "number") {
1186
+ next[field] = held;
1187
+ }
1188
+ }
1189
+ if (next.inputPerMillion === undefined && next.outputPerMillion === undefined)
1190
+ return row;
1191
+ if (raised)
1192
+ next.at = now;
1193
+ return { ...row, priceAnchor: next };
1194
+ }
1195
+ /** True if a headline rate sits outside the anchor's band. */
1196
+ function driftsFromAnchor(anchor, fresh) {
1197
+ const b = fresh.pricing;
1198
+ return ["inputPerMillion", "outputPerMillion"].some((field) => {
1199
+ const a = anchor[field];
1200
+ const next = b?.[field];
1201
+ if (typeof a !== "number" || a <= 0 || typeof next !== "number" || next <= 0)
1202
+ return false;
1203
+ return next < a * PRICE_ANCHOR_FLOOR || next > a * PRICE_ANCHOR_CEILING;
1204
+ });
1205
+ }
1206
+ /**
1207
+ * Whether a fresh price needs holding: a collapse, a drift outside the
1208
+ * anchor's band, or a step past the threshold. A step back up toward the
1209
+ * anchor is a correction, not a suspect publish, so it's taken at once:
1210
+ * otherwise a wrong low price, once in, would be protected by its own hold.
1211
+ */
1212
+ function priceMoveSuspect(previous, fresh, anchor) {
1213
+ if (pricingFingerprint(previous) === pricingFingerprint(fresh))
1214
+ return false;
1215
+ if (billedRateCollapsed(previous, fresh))
1216
+ return true;
1217
+ if (anchor && driftsFromAnchor(anchor, fresh))
1218
+ return true;
1219
+ const steps = thresholdSteps(previous, fresh);
1220
+ if (steps.length === 0)
1221
+ return false;
1222
+ if (!anchor)
1223
+ return true;
1224
+ return !steps.every((step) => step.side === "pricing" &&
1225
+ HEADLINE_FIELDS.has(step.field) &&
1226
+ step.next > step.prev &&
1227
+ step.next <= (anchor[step.field] ?? 0));
1228
+ }
1229
+ /** The rates that moved by the quarantine threshold or more (either direction). */
1230
+ function thresholdSteps(previous, fresh) {
1231
+ const steps = [];
892
1232
  for (const side of ["pricing", "originPricing"]) {
893
1233
  const a = previous[side] ?? null;
894
1234
  const b = fresh[side] ?? null;
895
1235
  if (!a || !b)
896
1236
  continue;
897
- for (const field of fields) {
1237
+ for (const field of QUARANTINE_FIELDS) {
898
1238
  const prev = a[field];
899
1239
  const next = b[field];
900
1240
  if (typeof prev !== "number" || typeof next !== "number")
901
1241
  continue;
902
- // A priced model whose billed input or output rate drops to zero is
903
- // the cheapest-routing poison case, not a placeholder. originPricing
904
- // zeros stay placeholders ("no direct price known").
905
- if (side === "pricing" && prev > 0 && next === 0 && (field === "inputPerMillion" || field === "outputPerMillion"))
906
- return true;
907
1242
  if (prev === 0 || next === 0)
908
1243
  continue; // zero on either side is a "not reported" placeholder, not a price move
909
1244
  if (prev === next)
910
1245
  continue;
911
1246
  if (Math.abs((next - prev) / prev) >= PRICING_QUARANTINE_THRESHOLD)
912
- return true;
1247
+ steps.push({ side, field, prev, next });
913
1248
  }
914
1249
  }
915
- return false;
1250
+ return steps;
916
1251
  }
917
1252
  /** Add a capability tag exactly once, preserving order. */
918
1253
  function tagCapability(existing, tag) {
@@ -1063,7 +1398,7 @@ async function appendAnomalies(manifestDir, detected) {
1063
1398
  }
1064
1399
  return fresh.length;
1065
1400
  }
1066
- async function discoverProvider(state, dependencies, discoverer, timeout, degraded) {
1401
+ async function discoverProvider(state, dependencies, discoverer, timeout, degraded, sharedKeys) {
1067
1402
  const breaker = state.healthTracker.breaker(discoverer.providerId);
1068
1403
  const startedAt = Date.now();
1069
1404
  if (!breaker.canExecute()) {
@@ -1074,6 +1409,11 @@ async function discoverProvider(state, dependencies, discoverer, timeout, degrad
1074
1409
  const credential = dependencies.resolveCredential
1075
1410
  ? await dependencies.resolveCredential(discoverer.providerId, explicitKey)
1076
1411
  : dependencies.fallbackCredential(discoverer.providerId, explicitKey);
1412
+ // A plan is listed only for someone holding a key to it; without one its
1413
+ // models stay price-only rows in catalog-only.json.
1414
+ const isPlan = getProviderDescriptor(discoverer.providerId)?.subscriptionPlan === true;
1415
+ if (isPlan && (credential.source === "none" || state.config.keyless))
1416
+ return null;
1077
1417
  try {
1078
1418
  // Cloud discoverers fall back to ambient credentials (the AWS chain, gcloud)
1079
1419
  // whatever credential they're given, so keyless mode has its own path there.
@@ -1084,6 +1424,8 @@ async function discoverProvider(state, dependencies, discoverer, timeout, degrad
1084
1424
  latencyMs: Date.now() - startedAt,
1085
1425
  errorType: null,
1086
1426
  });
1427
+ if (credential.shared && credential.envVar)
1428
+ sharedKeys.accepted.add(credential.envVar);
1087
1429
  return {
1088
1430
  id: discoverer.providerId,
1089
1431
  name: discoverer.providerName,
@@ -1096,6 +1438,20 @@ async function discoverProvider(state, dependencies, discoverer, timeout, degrad
1096
1438
  }
1097
1439
  catch (error) {
1098
1440
  const errorMessage = error instanceof Error ? error.message : String(error);
1441
+ // A key borrowed from a shared env var (QWEN_API_KEY read by Alibaba's
1442
+ // regions and plans) is expected to be refused by the siblings it isn't
1443
+ // for. That is no fault of this provider, so no breaker failure and no
1444
+ // warning here; only a key every reader refuses gets reported, once.
1445
+ if (credential.shared && credential.envVar && isTentativeKeyMiss(error)) {
1446
+ if (!isPlanKeyUnverified(error)) {
1447
+ const refusers = sharedKeys.refused.get(credential.envVar) ?? [];
1448
+ refusers.push(discoverer.providerId);
1449
+ sharedKeys.refused.set(credential.envVar, refusers);
1450
+ }
1451
+ if (isPlan)
1452
+ return null;
1453
+ return listedWithoutKey(state, discoverer, timeout);
1454
+ }
1099
1455
  breaker.onFailure(errorMessage);
1100
1456
  dependencies.recordObservation(discoverer.providerId, {
1101
1457
  latencyMs: Date.now() - startedAt,
@@ -1103,7 +1459,19 @@ async function discoverProvider(state, dependencies, discoverer, timeout, degrad
1103
1459
  });
1104
1460
  const stale = await StaleCachePolicy.getWithStale(state.cache, catalogCacheKey(`${CACHE_KEY_PREFIX}${discoverer.providerId}`));
1105
1461
  if (stale) {
1106
- return stale.data;
1462
+ // Serving yesterday's listing is right; serving it silently is not.
1463
+ // A key rejected today otherwise looks healthy for as long as the
1464
+ // old cache lasts. A keyless failure (a local runtime that isn't
1465
+ // up) stays quiet, as it always has.
1466
+ if (credential.source === "none")
1467
+ return stale.data;
1468
+ degraded.push({
1469
+ providerId: discoverer.providerId,
1470
+ providerName: discoverer.providerName,
1471
+ error: errorMessage,
1472
+ timestamp: Date.now(),
1473
+ });
1474
+ return { ...stale.data, degradedReason: errorMessage, degradedSource: "cache" };
1107
1475
  }
1108
1476
  // A rejected or unreachable credentialed call says nothing about what
1109
1477
  // the provider serves or charges — that is public. Without this, one
@@ -1129,6 +1497,8 @@ async function discoverProvider(state, dependencies, discoverer, timeout, degrad
1129
1497
  credentialSource: "none",
1130
1498
  models,
1131
1499
  lastRefreshed: Date.now(),
1500
+ degradedReason: errorMessage,
1501
+ degradedSource: "catalog",
1132
1502
  };
1133
1503
  }
1134
1504
  }
@@ -1139,4 +1509,66 @@ async function discoverProvider(state, dependencies, discoverer, timeout, degrad
1139
1509
  throw error;
1140
1510
  }
1141
1511
  }
1512
+ /** Matched by name: the discovery modules load lazily, so no class import here. */
1513
+ function isPlanKeyUnverified(error) {
1514
+ return error instanceof Error && error.name === "PlanKeyUnverifiedError";
1515
+ }
1516
+ /** A 401/403, or a plan with no list to test the key on. A timeout is neither. */
1517
+ function isTentativeKeyMiss(error) {
1518
+ if (isPlanKeyUnverified(error))
1519
+ return true;
1520
+ return error instanceof Error && /API error:\s*40[13]\b/.test(error.message);
1521
+ }
1522
+ /**
1523
+ * The provider's public listing, used when a shared key turned out not to be
1524
+ * its key. Quiet: the provider is fine, the key belongs to a sibling.
1525
+ */
1526
+ async function listedWithoutKey(state, discoverer, timeout) {
1527
+ try {
1528
+ const models = await discoverer.discover({ source: "none" }, { timeout });
1529
+ if (models.length > 0) {
1530
+ return {
1531
+ id: discoverer.providerId,
1532
+ name: discoverer.providerName,
1533
+ baseUrl: discoverer.baseUrl,
1534
+ authenticated: false,
1535
+ credentialSource: "none",
1536
+ models,
1537
+ lastRefreshed: Date.now(),
1538
+ };
1539
+ }
1540
+ }
1541
+ catch {
1542
+ /* fall back to the last good listing */
1543
+ }
1544
+ const stale = await StaleCachePolicy.getWithStale(state.cache, catalogCacheKey(`${CACHE_KEY_PREFIX}${discoverer.providerId}`));
1545
+ return stale ? { ...stale.data, authenticated: false, credentialSource: "none" } : null;
1546
+ }
1547
+ /**
1548
+ * One warning per shared env var no provider accepted. It goes on each
1549
+ * listed provider that refused it, so `update` and `doctor` show it, and
1550
+ * names every host that said no.
1551
+ */
1552
+ function reportRefusedSharedKeys(state, ledger) {
1553
+ for (const [envVar, refusers] of ledger.refused) {
1554
+ if (ledger.accepted.has(envVar))
1555
+ continue;
1556
+ const families = new Set(refusers.map((id) => getSubscriptionPlan(id)?.family ?? id));
1557
+ const hint = families.has("alibaba")
1558
+ ? " If it is an Alibaba Coding Plan key, set ALIBABA_CODING_PLAN_API_KEY instead: that host can't test a key."
1559
+ : "";
1560
+ const message = `${envVar} was refused by every provider that reads it: ${refusers.join(", ")} (API error: 401).${hint}`;
1561
+ for (const id of refusers) {
1562
+ const provider = state.providerMap.get(id);
1563
+ if (provider)
1564
+ state.providerMap.set(id, { ...provider, degradedReason: message, degradedSource: "catalog" });
1565
+ }
1566
+ state.lastDiscoveryErrors.push({
1567
+ providerId: refusers[0],
1568
+ providerName: envVar,
1569
+ error: message,
1570
+ timestamp: Date.now(),
1571
+ });
1572
+ }
1573
+ }
1142
1574
  //# sourceMappingURL=registry-runtime.js.map