@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.
- package/README.md +88 -78
- package/dist/cli-cmd-doctor.d.ts.map +1 -1
- package/dist/cli-cmd-doctor.js +69 -18
- 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 +18 -7
- package/dist/cli-cmd-model.js.map +1 -1
- package/dist/cli-cmd-query.d.ts.map +1 -1
- package/dist/cli-cmd-query.js +17 -0
- package/dist/cli-cmd-query.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 +4 -0
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +43 -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/credentials/resolver.d.ts +0 -1
- package/dist/credentials/resolver.d.ts.map +1 -1
- package/dist/credentials/resolver.js +10 -46
- package/dist/credentials/resolver.js.map +1 -1
- package/dist/discovery/catalog-only.d.ts.map +1 -1
- package/dist/discovery/catalog-only.js +8 -1
- package/dist/discovery/catalog-only.js.map +1 -1
- package/dist/discovery/free-tier.d.ts +2 -0
- package/dist/discovery/free-tier.d.ts.map +1 -1
- package/dist/discovery/free-tier.js +4 -0
- package/dist/discovery/free-tier.js.map +1 -1
- package/dist/discovery/generic-openai.d.ts +27 -0
- package/dist/discovery/generic-openai.d.ts.map +1 -1
- package/dist/discovery/generic-openai.js +109 -5
- package/dist/discovery/generic-openai.js.map +1 -1
- package/dist/discovery/modelsdev-seed.d.ts.map +1 -1
- package/dist/discovery/modelsdev-seed.js +21 -6
- package/dist/discovery/modelsdev-seed.js.map +1 -1
- package/dist/discovery/openai-compatible.d.ts +2 -0
- package/dist/discovery/openai-compatible.d.ts.map +1 -1
- package/dist/discovery/openai-compatible.js +19 -11
- package/dist/discovery/openai-compatible.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/enrichment/catalog.js +2 -2
- package/dist/enrichment/catalog.js.map +1 -1
- package/dist/enrichment/litellm.d.ts.map +1 -1
- package/dist/enrichment/litellm.js +18 -16
- package/dist/enrichment/litellm.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/mcp-server.d.ts +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +8 -1
- package/dist/mcp-server.js.map +1 -1
- 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 +48 -0
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +114 -6
- 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 +196 -31
- package/dist/proxy.js.map +1 -1
- package/dist/registry-discovery.js +7 -1
- package/dist/registry-discovery.js.map +1 -1
- package/dist/registry-query.d.ts.map +1 -1
- package/dist/registry-query.js +20 -8
- 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 +499 -67
- 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 +22 -4
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +21 -6
- 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 +63 -0
- package/dist/subscription-plans.d.ts.map +1 -0
- package/dist/subscription-plans.js +261 -0
- package/dist/subscription-plans.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -8
- package/server.json +2 -2
package/dist/registry-runtime.js
CHANGED
|
@@ -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 {
|
|
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
|
|
55
|
-
//
|
|
56
|
-
|
|
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
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
|
|
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
|
-
|
|
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
|
|
765
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
801
|
-
// routing however long
|
|
802
|
-
|
|
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
|
|
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
|
-
|
|
847
|
-
|
|
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:
|
|
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
|
|
875
|
-
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) {
|
|
876
1145
|
const a = previous.pricing;
|
|
877
1146
|
const b = fresh.pricing;
|
|
878
1147
|
if (!a || !b)
|
|
879
1148
|
return false;
|
|
880
|
-
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
|
+
});
|
|
881
1158
|
}
|
|
882
|
-
/**
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
|
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
|
-
|
|
1247
|
+
steps.push({ side, field, prev, next });
|
|
913
1248
|
}
|
|
914
1249
|
}
|
|
915
|
-
return
|
|
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
|
-
|
|
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
|