@sriinnu/kosha-discovery 1.14.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 (79) hide show
  1. package/README.md +85 -78
  2. package/dist/cli-cmd-doctor.d.ts.map +1 -1
  3. package/dist/cli-cmd-doctor.js +42 -17
  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 +6 -1
  7. package/dist/cli-cmd-model.js.map +1 -1
  8. package/dist/cli-cmd-registry.d.ts +2 -1
  9. package/dist/cli-cmd-registry.d.ts.map +1 -1
  10. package/dist/cli-cmd-registry.js +30 -1
  11. package/dist/cli-cmd-registry.js.map +1 -1
  12. package/dist/cli-commands.d.ts.map +1 -1
  13. package/dist/cli-commands.js +16 -5
  14. package/dist/cli-commands.js.map +1 -1
  15. package/dist/cli-help.d.ts.map +1 -1
  16. package/dist/cli-help.js +4 -1
  17. package/dist/cli-help.js.map +1 -1
  18. package/dist/cli.js +1 -1
  19. package/dist/cli.js.map +1 -1
  20. package/dist/cost.d.ts +16 -1
  21. package/dist/cost.d.ts.map +1 -1
  22. package/dist/cost.js +73 -14
  23. package/dist/cost.js.map +1 -1
  24. package/dist/discovery/snapshot-catalog.d.ts +18 -0
  25. package/dist/discovery/snapshot-catalog.d.ts.map +1 -1
  26. package/dist/discovery/snapshot-catalog.js +83 -3
  27. package/dist/discovery/snapshot-catalog.js.map +1 -1
  28. package/dist/discovery/vercel.d.ts.map +1 -1
  29. package/dist/discovery/vercel.js +19 -0
  30. package/dist/discovery/vercel.js.map +1 -1
  31. package/dist/discovery-contract.d.ts +8 -3
  32. package/dist/discovery-contract.d.ts.map +1 -1
  33. package/dist/discovery-contract.js.map +1 -1
  34. package/dist/index.d.ts +3 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/lifecycle.d.ts +13 -0
  39. package/dist/lifecycle.d.ts.map +1 -0
  40. package/dist/lifecycle.js +19 -0
  41. package/dist/lifecycle.js.map +1 -0
  42. package/dist/price-trust.d.ts +12 -0
  43. package/dist/price-trust.d.ts.map +1 -1
  44. package/dist/price-trust.js +72 -0
  45. package/dist/price-trust.js.map +1 -1
  46. package/dist/provider-catalog.d.ts +6 -0
  47. package/dist/provider-catalog.d.ts.map +1 -1
  48. package/dist/provider-catalog.js +9 -0
  49. package/dist/provider-catalog.js.map +1 -1
  50. package/dist/proxy.d.ts +9 -0
  51. package/dist/proxy.d.ts.map +1 -1
  52. package/dist/proxy.js +177 -27
  53. package/dist/proxy.js.map +1 -1
  54. package/dist/registry-query.d.ts.map +1 -1
  55. package/dist/registry-query.js +9 -7
  56. package/dist/registry-query.js.map +1 -1
  57. package/dist/registry-routing.d.ts.map +1 -1
  58. package/dist/registry-routing.js +2 -1
  59. package/dist/registry-routing.js.map +1 -1
  60. package/dist/registry-runtime.d.ts +34 -2
  61. package/dist/registry-runtime.d.ts.map +1 -1
  62. package/dist/registry-runtime.js +385 -57
  63. package/dist/registry-runtime.js.map +1 -1
  64. package/dist/registry.d.ts +5 -1
  65. package/dist/registry.d.ts.map +1 -1
  66. package/dist/registry.js +14 -3
  67. package/dist/registry.js.map +1 -1
  68. package/dist/server.d.ts.map +1 -1
  69. package/dist/server.js +16 -3
  70. package/dist/server.js.map +1 -1
  71. package/dist/snapshot-gate.d.ts +50 -0
  72. package/dist/snapshot-gate.d.ts.map +1 -0
  73. package/dist/snapshot-gate.js +128 -0
  74. package/dist/snapshot-gate.js.map +1 -0
  75. package/dist/subscription-plans.d.ts.map +1 -1
  76. package/dist/subscription-plans.js +3 -1
  77. package/dist/subscription-plans.js.map +1 -1
  78. package/package.json +2 -2
  79. package/server.json +2 -2
@@ -12,9 +12,10 @@ 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 { PRICE_RATE_FIELDS, sanitizeModelPricing } from "./price-trust.js";
18
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";
@@ -51,10 +52,15 @@ export async function registryDiscover(state, dependencies, options) {
51
52
  // Only refresh the canonical manifest for full-registry cache hits.
52
53
  // Scoped provider rehydration can carry cache-derived state that should
53
54
  // not be published as a fresh top-level manifest snapshot.
55
+ sanitizeRegistryPricing(state);
54
56
  if (!providers || providers.length === 0) {
55
- // A cache hit makes no network calls, and that includes this file:
56
- // it waits for the next real discovery.
57
- 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;
58
64
  }
59
65
  return Array.from(state.providerMap.values());
60
66
  }
@@ -97,7 +103,7 @@ export async function registryDiscover(state, dependencies, options) {
97
103
  for (const [providerId, providerInfo] of state.providerMap) {
98
104
  state.providerMap.set(providerId, {
99
105
  ...providerInfo,
100
- models: applyPriceSchedules(applyPromoOverrides(providerInfo.models)),
106
+ models: applyPriceSchedules(applyPromoOverrides(providerInfo.models)).map(sanitizeModelPricing),
101
107
  });
102
108
  }
103
109
  // Attribute pricing provenance at the enrich/merge boundary now that
@@ -110,7 +116,14 @@ export async function registryDiscover(state, dependencies, options) {
110
116
  // Write the consumer-facing manifest last, after recordDiscoveryMutation
111
117
  // has advanced the cursor — that way the snapshot's cursor reflects the
112
118
  // post-discovery revision instead of a stale pre-mutation value.
113
- 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
+ }
114
127
  return Array.from(state.providerMap.values());
115
128
  }
116
129
  /**
@@ -236,13 +249,17 @@ export async function registryEnrichOnly(state) {
236
249
  for (const [providerId, providerInfo] of state.providerMap) {
237
250
  state.providerMap.set(providerId, {
238
251
  ...providerInfo,
239
- models: applyPriceSchedules(applyPromoOverrides(providerInfo.models)),
252
+ models: applyPriceSchedules(applyPromoOverrides(providerInfo.models)).map(sanitizeModelPricing),
240
253
  });
241
254
  }
242
255
  attributePricingSources(state, preEnrichmentPriced);
243
256
  populateRegistryModelAliases(state);
244
257
  await saveRegistryToCache(state);
245
- await exportRegistryManifest(state);
258
+ const merged = await exportRegistryManifest(state);
259
+ if (merged && applyManifestHolds(state, merged)) {
260
+ state.lastSnapshotCache = null;
261
+ await saveRegistryToCache(state);
262
+ }
246
263
  const after = countPricingFields(state);
247
264
  return {
248
265
  modelCount: Array.from(state.providerMap.values()).reduce((sum, p) => sum + p.models.length, 0),
@@ -393,8 +410,10 @@ export async function exportRegistryManifest(state, options) {
393
410
  let manifestPath = null;
394
411
  let lockReleased = true;
395
412
  let lockPath = null;
413
+ let fresh = null;
414
+ let merged = null;
396
415
  try {
397
- const fresh = state.lastSnapshotCache ?? registryDiscoverySnapshot(state);
416
+ fresh = state.lastSnapshotCache ?? registryDiscoverySnapshot(state);
398
417
  state.lastSnapshotCache = fresh;
399
418
  // Honour state.config.cacheDir so isolated tests (and any other caller
400
419
  // that pins a custom cache root) don't clobber the user's real
@@ -427,7 +446,7 @@ export async function exportRegistryManifest(state, options) {
427
446
  // the user's pricing for those models — they retain their last-known
428
447
  // values until a successful fetch supersedes them.
429
448
  const previousManifest = await readPreviousManifest(manifestPath);
430
- const merged = mergeManifests(previousManifest, fresh);
449
+ merged = mergeManifests(previousManifest, fresh);
431
450
  // Pricing-diff alerts. The most dangerous failure mode isn't "kosha
432
451
  // returned null" (we already detect that). It's "kosha returned a
433
452
  // wrong number" — silent over- or undercharging. We compare prev vs
@@ -472,6 +491,7 @@ export async function exportRegistryManifest(state, options) {
472
491
  warnOnNewlyDisputedPrices(previousManifest, merged);
473
492
  if (options?.catalogOnly !== false)
474
493
  await exportCatalogOnlyManifest(manifestPath, new Set(state.providerMap.keys()));
494
+ return merged;
475
495
  }
476
496
  catch (err) {
477
497
  // Manifest export is best-effort — the cache still works either way.
@@ -480,6 +500,19 @@ export async function exportRegistryManifest(state, options) {
480
500
  // gated on a successful export.
481
501
  const reason = err instanceof Error ? err.message : String(err);
482
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;
483
516
  }
484
517
  finally {
485
518
  if (!lockReleased && lockPath) {
@@ -691,6 +724,145 @@ async function exportCatalogOnlyManifest(manifestPath, listedIds) {
691
724
  console.warn(`[kosha] ${CATALOG_ONLY_FILE} not updated: ${err instanceof Error ? err.message : String(err)}`);
692
725
  }
693
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
+ }
694
866
  /** Resolve the on-disk path for the registry manifest. Tests + custom callers
695
867
  * pin via `state.config.cacheDir`; production falls back to ~/.kosha. */
696
868
  function resolveManifestPath(state) {
@@ -715,14 +887,28 @@ function resolveManifestPath(state) {
715
887
  * fresh snapshot as-is (first run / corrupted file).
716
888
  */
717
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) {
718
904
  try {
719
905
  const { readFile } = await import("fs/promises");
720
- const raw = await readFile(manifestPath, "utf-8");
906
+ const raw = await readFile(path, "utf-8");
721
907
  const parsed = JSON.parse(raw);
722
908
  if (!parsed?.models)
723
909
  return null;
724
910
  // Schema-version guard. A different version on disk means we can't
725
- // trust the field shapes — caller falls through to fresh-only.
911
+ // trust the field shapes.
726
912
  if (parsed.schemaVersion !== DISCOVERY_SCHEMA_VERSION)
727
913
  return null;
728
914
  assertCleanPayload(parsed, "registry manifest");
@@ -732,6 +918,22 @@ async function readPreviousManifest(manifestPath) {
732
918
  return null; // missing or unreadable
733
919
  }
734
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
+ }
735
937
  /** Beyond this fractional rate change in either direction, we treat a fresh
736
938
  * price as suspect and keep the previous value (quarantine). The 25% delta
737
939
  * used by anomaly logging is a UI-alert threshold; quarantine fires only on
@@ -752,14 +954,20 @@ const PRICING_DISPUTED_HOLD_MS = 7 * DAY_MS;
752
954
  const PRICING_SINGLE_SOURCE = "pricing_single_source";
753
955
  /** Sources list different prices for this model. */
754
956
  const PRICING_SOURCES_DISAGREE = "pricing_sources_disagree";
755
- /** After this many consecutive discovery passes in which a previously-known
756
- * model is missing from the fresh snapshot, the model is dropped from the
757
- * merged manifest. Prevents the manifest from becoming a graveyard of
758
- * deprecated SKUs while still riding through transient outages. */
759
- 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;
760
961
  export function mergeManifests(previous, fresh) {
761
- if (!previous)
762
- 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
+ }
763
971
  // Index previous entries by key so we can both detect collisions AND
764
972
  // look back at the old data for pricing-degraded merge below.
765
973
  const previousByKey = new Map();
@@ -768,8 +976,8 @@ export function mergeManifests(previous, fresh) {
768
976
  previousByKey.set(m.key, m);
769
977
  }
770
978
  // Per-model merge with three rules:
771
- // (1) Old entries fresh dropped entirely → keep until missingRunCount
772
- // 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).
773
981
  // (2) Fresh entries that came back with NO pricing while the old entry
774
982
  // HAD pricing → keep the old pricing block (degraded-fresh defence).
775
983
  // (3) Fresh entries whose rates moved beyond PRICING_QUARANTINE_THRESHOLD
@@ -778,35 +986,44 @@ export function mergeManifests(previous, fresh) {
778
986
  // can't silently overcharge or underprice downstream consumers.
779
987
  // The same rates are accepted after a day if two sources agree on them,
780
988
  // after three days if only one lists them, and not while sources disagree.
989
+ const now = fresh.discoveredAt ?? Date.now();
781
990
  const freshModelKeys = new Set();
782
991
  const mergedModels = [];
783
992
  for (const f of fresh.models ?? []) {
784
993
  if (f.key)
785
994
  freshModelKeys.add(f.key);
786
995
  const old = f.key ? previousByKey.get(f.key) : undefined;
787
- 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;
788
1003
  if (old && !hasUsablePricing(f) && hasUsablePricing(old)) {
789
1004
  // Keep all of fresh's metadata but restore pricing from old.
790
1005
  kept = { ...kept, pricing: old.pricing, originPricing: old.originPricing };
791
1006
  }
792
- else if (old && !f.pricing?.schedule && quarantinePricingMove(old, f)) {
1007
+ else if (old && !isCuratedSchedule(f) && priceMoveSuspect(old, kept, anchor)) {
793
1008
  // A scheduled price is curated and cited, not a catalog's publish, so
794
1009
  // it skips quarantine. Otherwise the first move to it would be refused
795
- // on every run.
796
- //
797
- const now = fresh.discoveredAt ?? Date.now();
798
- 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);
799
1013
  const hold = old.pricingHold;
800
- 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;
801
1017
  const agreement = priceAgreement(f.priceEvidence);
802
1018
  const window = agreement === "agree"
803
1019
  ? PRICING_AGREED_HOLD_MS
804
1020
  : agreement === "disagree"
805
1021
  ? PRICING_DISPUTED_HOLD_MS
806
1022
  : PRICING_SINGLE_SOURCE_HOLD_MS;
807
- // A billed rate of $0 is never accepted this way: it poisons cheapest
808
- // routing however long it's published.
809
- 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)) {
810
1027
  // Quarantine the suspect rates: keep the old pricing block and
811
1028
  // tag the operational `rawCapabilities` array (the trusted
812
1029
  // `capabilities` taxonomy stays clean, since this isn't a routing
@@ -827,7 +1044,7 @@ export function mergeManifests(previous, fresh) {
827
1044
  kept = { ...kept, rawCapabilities: tagCapability(kept.rawCapabilities, flag) };
828
1045
  }
829
1046
  }
830
- else if (old && !f.pricing?.schedule) {
1047
+ else if (old && !isCuratedSchedule(f)) {
831
1048
  // Below the quarantine line a price still takes effect, but a move its
832
1049
  // sources dispute is the believable lie, so it carries a flag. Flags
833
1050
  // from an earlier move last until the rates change or the evidence does.
@@ -844,16 +1061,20 @@ export function mergeManifests(previous, fresh) {
844
1061
  if (tags !== kept.rawCapabilities)
845
1062
  kept = { ...kept, rawCapabilities: tags };
846
1063
  }
847
- mergedModels.push(kept);
1064
+ mergedModels.push(withAnchor(kept, anchor, now));
848
1065
  }
849
1066
  for (const old of previous.models) {
850
1067
  if (!old.key || freshModelKeys.has(old.key))
851
1068
  continue;
852
1069
  const prevMissing = old.missingRunCount ?? 0;
853
- const nextMissing = prevMissing + 1;
854
- 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)
855
1076
  continue; // drop after TTL
856
- mergedModels.push({ ...old, missingRunCount: nextMissing });
1077
+ mergedModels.push({ ...old, missingRunCount: prevMissing + 1, lastSeenAt });
857
1078
  }
858
1079
  const freshProviderIds = new Set((fresh.providers ?? []).map((p) => p.providerId));
859
1080
  const mergedProviders = [...(fresh.providers ?? [])];
@@ -868,7 +1089,20 @@ export function mergeManifests(previous, fresh) {
868
1089
  providers: mergedProviders,
869
1090
  };
870
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;
871
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) {
872
1106
  const fields = [
873
1107
  "inputPerMillion",
874
1108
  "outputPerMillion",
@@ -878,48 +1112,142 @@ function pricingFingerprint(model) {
878
1112
  ];
879
1113
  return JSON.stringify([model.pricing, model.originPricing].map((side) => fields.map((field) => side?.[field] ?? null)));
880
1114
  }
881
- /** True if a priced model's billed input or output rate fell to zero. */
882
- 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) {
883
1145
  const a = previous.pricing;
884
1146
  const b = fresh.pricing;
885
1147
  if (!a || !b)
886
1148
  return false;
887
- 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
+ });
888
1158
  }
889
- /** True if any defined per-million rate moved by more than the quarantine
890
- * threshold (either direction), compared to the previous snapshot. */
891
- function quarantinePricingMove(previous, fresh) {
892
- const fields = [
893
- "inputPerMillion",
894
- "outputPerMillion",
895
- "cacheReadPerMillion",
896
- "cacheWritePerMillion",
897
- "cacheWrite1hPerMillion",
898
- ];
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 = [];
899
1232
  for (const side of ["pricing", "originPricing"]) {
900
1233
  const a = previous[side] ?? null;
901
1234
  const b = fresh[side] ?? null;
902
1235
  if (!a || !b)
903
1236
  continue;
904
- for (const field of fields) {
1237
+ for (const field of QUARANTINE_FIELDS) {
905
1238
  const prev = a[field];
906
1239
  const next = b[field];
907
1240
  if (typeof prev !== "number" || typeof next !== "number")
908
1241
  continue;
909
- // A priced model whose billed input or output rate drops to zero is
910
- // the cheapest-routing poison case, not a placeholder. originPricing
911
- // zeros stay placeholders ("no direct price known").
912
- if (side === "pricing" && prev > 0 && next === 0 && (field === "inputPerMillion" || field === "outputPerMillion"))
913
- return true;
914
1242
  if (prev === 0 || next === 0)
915
1243
  continue; // zero on either side is a "not reported" placeholder, not a price move
916
1244
  if (prev === next)
917
1245
  continue;
918
1246
  if (Math.abs((next - prev) / prev) >= PRICING_QUARANTINE_THRESHOLD)
919
- return true;
1247
+ steps.push({ side, field, prev, next });
920
1248
  }
921
1249
  }
922
- return false;
1250
+ return steps;
923
1251
  }
924
1252
  /** Add a capability tag exactly once, preserving order. */
925
1253
  function tagCapability(existing, tag) {