@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.
- package/README.md +85 -78
- package/dist/cli-cmd-doctor.d.ts.map +1 -1
- package/dist/cli-cmd-doctor.js +42 -17
- package/dist/cli-cmd-doctor.js.map +1 -1
- package/dist/cli-cmd-model.d.ts.map +1 -1
- package/dist/cli-cmd-model.js +6 -1
- package/dist/cli-cmd-model.js.map +1 -1
- package/dist/cli-cmd-registry.d.ts +2 -1
- package/dist/cli-cmd-registry.d.ts.map +1 -1
- package/dist/cli-cmd-registry.js +30 -1
- package/dist/cli-cmd-registry.js.map +1 -1
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +16 -5
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +4 -1
- package/dist/cli-help.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/cost.d.ts +16 -1
- package/dist/cost.d.ts.map +1 -1
- package/dist/cost.js +73 -14
- package/dist/cost.js.map +1 -1
- package/dist/discovery/snapshot-catalog.d.ts +18 -0
- package/dist/discovery/snapshot-catalog.d.ts.map +1 -1
- package/dist/discovery/snapshot-catalog.js +83 -3
- package/dist/discovery/snapshot-catalog.js.map +1 -1
- package/dist/discovery/vercel.d.ts.map +1 -1
- package/dist/discovery/vercel.js +19 -0
- package/dist/discovery/vercel.js.map +1 -1
- package/dist/discovery-contract.d.ts +8 -3
- package/dist/discovery-contract.d.ts.map +1 -1
- package/dist/discovery-contract.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lifecycle.d.ts +13 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +19 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/price-trust.d.ts +12 -0
- package/dist/price-trust.d.ts.map +1 -1
- package/dist/price-trust.js +72 -0
- package/dist/price-trust.js.map +1 -1
- package/dist/provider-catalog.d.ts +6 -0
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +9 -0
- package/dist/provider-catalog.js.map +1 -1
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +177 -27
- package/dist/proxy.js.map +1 -1
- package/dist/registry-query.d.ts.map +1 -1
- package/dist/registry-query.js +9 -7
- package/dist/registry-query.js.map +1 -1
- package/dist/registry-routing.d.ts.map +1 -1
- package/dist/registry-routing.js +2 -1
- package/dist/registry-routing.js.map +1 -1
- package/dist/registry-runtime.d.ts +34 -2
- package/dist/registry-runtime.d.ts.map +1 -1
- package/dist/registry-runtime.js +385 -57
- package/dist/registry-runtime.js.map +1 -1
- package/dist/registry.d.ts +5 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +14 -3
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +16 -3
- package/dist/server.js.map +1 -1
- package/dist/snapshot-gate.d.ts +50 -0
- package/dist/snapshot-gate.d.ts.map +1 -0
- package/dist/snapshot-gate.js +128 -0
- package/dist/snapshot-gate.js.map +1 -0
- package/dist/subscription-plans.d.ts.map +1 -1
- package/dist/subscription-plans.js +3 -1
- package/dist/subscription-plans.js.map +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/registry-runtime.js
CHANGED
|
@@ -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
|
|
56
|
-
//
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
|
|
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
|
-
|
|
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
|
|
772
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
808
|
-
// routing however long
|
|
809
|
-
|
|
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
|
|
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
|
-
|
|
854
|
-
|
|
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:
|
|
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
|
|
882
|
-
function
|
|
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
|
|
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
|
-
/**
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
|
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
|
-
|
|
1247
|
+
steps.push({ side, field, prev, next });
|
|
920
1248
|
}
|
|
921
1249
|
}
|
|
922
|
-
return
|
|
1250
|
+
return steps;
|
|
923
1251
|
}
|
|
924
1252
|
/** Add a capability tag exactly once, preserving order. */
|
|
925
1253
|
function tagCapability(existing, tag) {
|