fest-gateway 0.1.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/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/cli/claude.js +326 -0
- package/dist/cli/claude.js.map +1 -0
- package/dist/cli/config.js +59 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/login.js +130 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/logout.js +20 -0
- package/dist/cli/logout.js.map +1 -0
- package/dist/cli/whoami.js +34 -0
- package/dist/cli/whoami.js.map +1 -0
- package/dist/server/adapters/anthropic.js +74 -0
- package/dist/server/adapters/anthropic.js.map +1 -0
- package/dist/server/adapters/fireworks.js +54 -0
- package/dist/server/adapters/fireworks.js.map +1 -0
- package/dist/server/adapters/index.js +16 -0
- package/dist/server/adapters/index.js.map +1 -0
- package/dist/server/adapters/registry.js +38 -0
- package/dist/server/adapters/registry.js.map +1 -0
- package/dist/server/adapters/rewrite.js +152 -0
- package/dist/server/adapters/rewrite.js.map +1 -0
- package/dist/server/adapters/url.js +24 -0
- package/dist/server/adapters/url.js.map +1 -0
- package/dist/server/api/auth.js +213 -0
- package/dist/server/api/auth.js.map +1 -0
- package/dist/server/api/live.js +108 -0
- package/dist/server/api/live.js.map +1 -0
- package/dist/server/api/models.js +129 -0
- package/dist/server/api/models.js.map +1 -0
- package/dist/server/api/oauth.js +300 -0
- package/dist/server/api/oauth.js.map +1 -0
- package/dist/server/api/routes.js +164 -0
- package/dist/server/api/routes.js.map +1 -0
- package/dist/server/api/routing-view.js +47 -0
- package/dist/server/api/routing-view.js.map +1 -0
- package/dist/server/auth/accounts.js +111 -0
- package/dist/server/auth/accounts.js.map +1 -0
- package/dist/server/auth/gateway-401.js +85 -0
- package/dist/server/auth/gateway-401.js.map +1 -0
- package/dist/server/auth/guard.js +74 -0
- package/dist/server/auth/guard.js.map +1 -0
- package/dist/server/auth/oauth.js +43 -0
- package/dist/server/auth/oauth.js.map +1 -0
- package/dist/server/auth/password.js +104 -0
- package/dist/server/auth/password.js.map +1 -0
- package/dist/server/auth/posture.js +145 -0
- package/dist/server/auth/posture.js.map +1 -0
- package/dist/server/auth/session.js +161 -0
- package/dist/server/auth/session.js.map +1 -0
- package/dist/server/bin/fest.js +589 -0
- package/dist/server/bin/fest.js.map +1 -0
- package/dist/server/config.js +109 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/credentials/provider.js +70 -0
- package/dist/server/credentials/provider.js.map +1 -0
- package/dist/server/credentials/resolve.js +89 -0
- package/dist/server/credentials/resolve.js.map +1 -0
- package/dist/server/http/body.js +66 -0
- package/dist/server/http/body.js.map +1 -0
- package/dist/server/http/errors.js +144 -0
- package/dist/server/http/errors.js.map +1 -0
- package/dist/server/http/headers.js +189 -0
- package/dist/server/http/headers.js.map +1 -0
- package/dist/server/http/pipe.js +97 -0
- package/dist/server/http/pipe.js.map +1 -0
- package/dist/server/http/server.js +226 -0
- package/dist/server/http/server.js.map +1 -0
- package/dist/server/http/sse.js +167 -0
- package/dist/server/http/sse.js.map +1 -0
- package/dist/server/http/static.js +138 -0
- package/dist/server/http/static.js.map +1 -0
- package/dist/server/ingest/live-bus.js +67 -0
- package/dist/server/ingest/live-bus.js.map +1 -0
- package/dist/server/ingest/sink.js +141 -0
- package/dist/server/ingest/sink.js.map +1 -0
- package/dist/server/log.js +31 -0
- package/dist/server/log.js.map +1 -0
- package/dist/server/pipeline/count-tokens.js +48 -0
- package/dist/server/pipeline/count-tokens.js.map +1 -0
- package/dist/server/pipeline/dispatch.js +138 -0
- package/dist/server/pipeline/dispatch.js.map +1 -0
- package/dist/server/pipeline/passthrough.js +392 -0
- package/dist/server/pipeline/passthrough.js.map +1 -0
- package/dist/server/pipeline/preflight.js +66 -0
- package/dist/server/pipeline/preflight.js.map +1 -0
- package/dist/server/pipeline/record.js +49 -0
- package/dist/server/pipeline/record.js.map +1 -0
- package/dist/server/pipeline/substitute.js +287 -0
- package/dist/server/pipeline/substitute.js.map +1 -0
- package/dist/server/routes/resolve.js +176 -0
- package/dist/server/routes/resolve.js.map +1 -0
- package/dist/server/routes/table.js +247 -0
- package/dist/server/routes/table.js.map +1 -0
- package/dist/server/routes/watch.js +101 -0
- package/dist/server/routes/watch.js.map +1 -0
- package/dist/server/secret/fingerprint.js +107 -0
- package/dist/server/secret/fingerprint.js.map +1 -0
- package/dist/server/secret/non-persistable.js +69 -0
- package/dist/server/secret/non-persistable.js.map +1 -0
- package/dist/server/store/audit.js +53 -0
- package/dist/server/store/audit.js.map +1 -0
- package/dist/server/store/bootstrap.js +53 -0
- package/dist/server/store/bootstrap.js.map +1 -0
- package/dist/server/store/db.js +157 -0
- package/dist/server/store/db.js.map +1 -0
- package/dist/server/store/ids.js +14 -0
- package/dist/server/store/ids.js.map +1 -0
- package/dist/server/store/migrations/001-init.sql +213 -0
- package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
- package/dist/server/store/migrations/003-routing.sql +42 -0
- package/dist/server/store/migrations/004-admin-auth.sql +72 -0
- package/dist/server/store/migrations/005-notional-cost.sql +28 -0
- package/dist/server/store/queries.js +904 -0
- package/dist/server/store/queries.js.map +1 -0
- package/dist/server/store/retention.js +79 -0
- package/dist/server/store/retention.js.map +1 -0
- package/dist/server/store/seed.js +203 -0
- package/dist/server/store/seed.js.map +1 -0
- package/dist/server/store/tokens.js +133 -0
- package/dist/server/store/tokens.js.map +1 -0
- package/dist/server/store/write.js +330 -0
- package/dist/server/store/write.js.map +1 -0
- package/dist/server/usage/accumulator.js +209 -0
- package/dist/server/usage/accumulator.js.map +1 -0
- package/dist/server/usage/cost.js +57 -0
- package/dist/server/usage/cost.js.map +1 -0
- package/dist/server/usage/prices/catalog.js +136 -0
- package/dist/server/usage/prices/catalog.js.map +1 -0
- package/dist/server/usage/prices/refresh.js +125 -0
- package/dist/server/usage/prices/refresh.js.map +1 -0
- package/dist/server/usage/prices/snapshot.json.gz +0 -0
- package/dist/server/usage/prices/table.js +102 -0
- package/dist/server/usage/prices/table.js.map +1 -0
- package/dist/server/usage/pricing.js +103 -0
- package/dist/server/usage/pricing.js.map +1 -0
- package/dist/shared/api.js +19 -0
- package/dist/shared/api.js.map +1 -0
- package/dist/shared/base-models.js +28 -0
- package/dist/shared/base-models.js.map +1 -0
- package/dist/shared/demotion-vars.js +17 -0
- package/dist/shared/demotion-vars.js.map +1 -0
- package/dist/shared/series.js +50 -0
- package/dist/shared/series.js.map +1 -0
- package/dist/shared/types.js +40 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
- package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/web/dist/index.html +44 -0
- package/dist/web/dist/nearform-icon.svg +4 -0
- package/package.json +82 -0
- package/routes.example.json +22 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* litellm's price file, normalised into Fest's rate shape.
|
|
3
|
+
*
|
|
4
|
+
* This module is the ONLY place that knows litellm's JSON schema. Both the
|
|
5
|
+
* offline sync tool (`tools/prices-sync.ts`) and the runtime refresh
|
|
6
|
+
* (`refresh.ts`) go through `normaliseCatalog`, so the vendored snapshot and a
|
|
7
|
+
* freshly fetched file can never be two different formats.
|
|
8
|
+
*
|
|
9
|
+
* Why normalise at all rather than ship litellm's file verbatim:
|
|
10
|
+
*
|
|
11
|
+
* - litellm prices PER TOKEN in floats like `5e-7`. Fest prices per million,
|
|
12
|
+
* because that is how vendors publish and how a human checks the number.
|
|
13
|
+
* Converting once at build time keeps float noise out of the hot path.
|
|
14
|
+
* - The raw file is 2.9MB and mostly fields Fest will never read (image,
|
|
15
|
+
* audio, video, batch, OCR, embedding rates). Filtering to chat models and
|
|
16
|
+
* the five token buckets Fest actually meters takes it to ~700KB, and ~48KB
|
|
17
|
+
* gzipped — small enough to vendor.
|
|
18
|
+
* - Tier suffixes are encoded in KEY NAMES (`input_cost_per_token_above_200k_tokens`),
|
|
19
|
+
* with thresholds that vary by model: 128k, 200k, 256k, 272k, 512k all
|
|
20
|
+
* appear. Discovering them by parsing the suffix is the only thing that
|
|
21
|
+
* doesn't rot; a hardcoded 200k silently understates every other model.
|
|
22
|
+
*/
|
|
23
|
+
export const LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
|
24
|
+
const PER_MILLION = 1_000_000;
|
|
25
|
+
function numeric(v) {
|
|
26
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build one tier from a suffixed family of litellm keys.
|
|
30
|
+
*
|
|
31
|
+
* Returns null unless BOTH input and output are published — a tier with only
|
|
32
|
+
* half its rates would price every call short, which is exactly the silent
|
|
33
|
+
* undercount this codebase treats as the worst failure mode.
|
|
34
|
+
*
|
|
35
|
+
* The cache fallbacks are deliberate and worth stating plainly: when a provider
|
|
36
|
+
* publishes no cache rate, cached tokens are billed as ordinary input, so the
|
|
37
|
+
* input rate is the correct fallback. Zero would claim cache reads are free,
|
|
38
|
+
* and Anthropic's ×0.1 / ×1.25 / ×2.0 multipliers are an *Anthropic* policy —
|
|
39
|
+
* applying them to Fireworks would be inventing a discount nobody offers.
|
|
40
|
+
*/
|
|
41
|
+
function tierFrom(entry, suffix) {
|
|
42
|
+
const input = numeric(entry[`input_cost_per_token${suffix}`]);
|
|
43
|
+
const output = numeric(entry[`output_cost_per_token${suffix}`]);
|
|
44
|
+
if (input === null || output === null)
|
|
45
|
+
return null;
|
|
46
|
+
const cacheRead = numeric(entry[`cache_read_input_token_cost${suffix}`]);
|
|
47
|
+
const write5m = numeric(entry[`cache_creation_input_token_cost${suffix}`]);
|
|
48
|
+
// Anthropic spells the 1h variant with `_above_1hr` BEFORE the tier suffix.
|
|
49
|
+
const write1h = numeric(entry[`cache_creation_input_token_cost_above_1hr${suffix}`]) ??
|
|
50
|
+
numeric(entry[`cache_creation_input_token_cost_above_1hr`]);
|
|
51
|
+
return {
|
|
52
|
+
cachePublished: cacheRead !== null || write5m !== null,
|
|
53
|
+
rates: {
|
|
54
|
+
inputPerMillion: input * PER_MILLION,
|
|
55
|
+
outputPerMillion: output * PER_MILLION,
|
|
56
|
+
cacheReadPerMillion: (cacheRead ?? input) * PER_MILLION,
|
|
57
|
+
cacheWrite5mPerMillion: (write5m ?? input) * PER_MILLION,
|
|
58
|
+
cacheWrite1hPerMillion: (write1h ?? write5m ?? input) * PER_MILLION,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** `input_cost_per_token_above_200k_tokens` → 200_000. Ignores flex/priority tiers. */
|
|
63
|
+
const TIER_SUFFIX = /^input_cost_per_token_above_(\d+)k_tokens$/;
|
|
64
|
+
function tierSuffixes(entry) {
|
|
65
|
+
const found = [];
|
|
66
|
+
for (const key of Object.keys(entry)) {
|
|
67
|
+
const m = TIER_SUFFIX.exec(key);
|
|
68
|
+
if (m?.[1] === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
found.push({ above: Number(m[1]) * 1000, suffix: `_above_${m[1]}k_tokens` });
|
|
71
|
+
}
|
|
72
|
+
return found.sort((a, b) => a.above - b.above);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Web search, normalised to USD per thousand queries to match how Fest counts
|
|
76
|
+
* `usage.webSearches`. litellm publishes it per query, keyed by context size;
|
|
77
|
+
* Fest cannot know which size was requested, so it takes the medium rate as the
|
|
78
|
+
* representative one and falls back to whichever is present.
|
|
79
|
+
*/
|
|
80
|
+
function webSearchPerThousand(entry) {
|
|
81
|
+
const raw = entry["search_context_cost_per_query"];
|
|
82
|
+
if (raw === null || typeof raw !== "object")
|
|
83
|
+
return null;
|
|
84
|
+
const sizes = raw;
|
|
85
|
+
const perQuery = numeric(sizes["search_context_size_medium"]) ??
|
|
86
|
+
numeric(sizes["search_context_size_low"]) ??
|
|
87
|
+
numeric(sizes["search_context_size_high"]);
|
|
88
|
+
return perQuery === null ? null : perQuery * 1000;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Filter litellm's raw file down to priceable chat models.
|
|
92
|
+
*
|
|
93
|
+
* Non-chat modes (embedding, image, audio, rerank) are dropped: Fest meters
|
|
94
|
+
* `/v1/messages` and nothing else, so an image model's per-pixel rate is weight
|
|
95
|
+
* with no reader. A chat model with no usable base tier is dropped too —
|
|
96
|
+
* carrying an entry we cannot price would turn an honest "unknown model" into a
|
|
97
|
+
* confident zero.
|
|
98
|
+
*/
|
|
99
|
+
export function normaliseCatalog(raw, opts) {
|
|
100
|
+
if (raw === null || typeof raw !== "object") {
|
|
101
|
+
throw new Error("price catalog is not an object");
|
|
102
|
+
}
|
|
103
|
+
const models = {};
|
|
104
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
105
|
+
// litellm carries a `sample_spec` pseudo-entry documenting its own schema.
|
|
106
|
+
if (key === "sample_spec" || value === null || typeof value !== "object")
|
|
107
|
+
continue;
|
|
108
|
+
const entry = value;
|
|
109
|
+
if (entry["mode"] !== "chat")
|
|
110
|
+
continue;
|
|
111
|
+
const base = tierFrom(entry, "");
|
|
112
|
+
if (base === null)
|
|
113
|
+
continue;
|
|
114
|
+
const provider = typeof entry["litellm_provider"] === "string" ? entry["litellm_provider"] : "";
|
|
115
|
+
const tiers = [];
|
|
116
|
+
for (const { above, suffix } of tierSuffixes(entry)) {
|
|
117
|
+
const t = tierFrom(entry, suffix);
|
|
118
|
+
if (t !== null)
|
|
119
|
+
tiers.push({ aboveTokens: above, rates: t.rates });
|
|
120
|
+
}
|
|
121
|
+
models[key] = {
|
|
122
|
+
label: key,
|
|
123
|
+
provider,
|
|
124
|
+
base: base.rates,
|
|
125
|
+
tiers,
|
|
126
|
+
priority: tierFrom(entry, "_priority")?.rates ?? null,
|
|
127
|
+
webSearchPerThousand: webSearchPerThousand(entry),
|
|
128
|
+
cacheRatesPublished: base.cachePublished,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (Object.keys(models).length === 0) {
|
|
132
|
+
throw new Error("price catalog contained no priceable chat models");
|
|
133
|
+
}
|
|
134
|
+
return { source: opts.source, fetchedAt: opts.fetchedAt, models };
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=catalog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog.js","sourceRoot":"","sources":["../../../../server/usage/prices/catalog.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping prices current without ever making the gateway depend on the network.
|
|
3
|
+
*
|
|
4
|
+
* The contract, in order of importance:
|
|
5
|
+
*
|
|
6
|
+
* 1. Boot always succeeds and always has prices. The vendored snapshot is
|
|
7
|
+
* enough; a clone with no network, a CI run and an airgapped container all
|
|
8
|
+
* price correctly.
|
|
9
|
+
* 2. A refresh that fails changes NOTHING. No half-written cache, no cleared
|
|
10
|
+
* table, no thrown error — a warn line and the previous rates.
|
|
11
|
+
* 3. No request ever waits on a fetch. The refresh runs on an unref'd timer
|
|
12
|
+
* off the hot path, and swaps a whole catalog in one assignment.
|
|
13
|
+
*
|
|
14
|
+
* Prices drift slowly (a vendor repricing is a quarterly event, not a daily
|
|
15
|
+
* one), so a 24h cadence is generous. The point of refreshing at all is that a
|
|
16
|
+
* *new model* appears the week it ships, and an unpriced model is a row the
|
|
17
|
+
* dashboard has to report as "n/a".
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { dirname } from "node:path";
|
|
21
|
+
import { log } from "../../log.js";
|
|
22
|
+
import { LITELLM_URL, normaliseCatalog } from "./catalog.js";
|
|
23
|
+
import { loadSnapshot, setPriceCatalog } from "./table.js";
|
|
24
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
26
|
+
export function pricingOptionsFromEnv(dbPath) {
|
|
27
|
+
const raw = (process.env.FEST_PRICING_REFRESH ?? "").trim().toLowerCase();
|
|
28
|
+
return {
|
|
29
|
+
// Next to the database, so the one writable volume an operator already
|
|
30
|
+
// mounts carries the cache too.
|
|
31
|
+
cachePath: `${dirname(dbPath)}/prices.json`,
|
|
32
|
+
refreshEnabled: !["0", "false", "no", "off"].includes(raw),
|
|
33
|
+
url: (process.env.FEST_PRICING_URL ?? "").trim() || LITELLM_URL,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Load the best catalog available, synchronously, at boot.
|
|
38
|
+
*
|
|
39
|
+
* The cache wins only if it is genuinely NEWER than the snapshot. That
|
|
40
|
+
* comparison is on `fetchedAt` rather than file mtime because the failure it
|
|
41
|
+
* prevents is real: upgrading Fest ships a newer snapshot, and a stale
|
|
42
|
+
* `data/prices.json` left in the volume would otherwise shadow it forever.
|
|
43
|
+
*/
|
|
44
|
+
export function loadPrices(opts) {
|
|
45
|
+
const snapshot = loadSnapshot();
|
|
46
|
+
let cached = null;
|
|
47
|
+
try {
|
|
48
|
+
cached = JSON.parse(readFileSync(opts.cachePath, "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// No cache, or one this build cannot read. The snapshot is always enough.
|
|
52
|
+
}
|
|
53
|
+
const chosen = cached !== null && typeof cached.fetchedAt === "number" && cached.fetchedAt > snapshot.fetchedAt
|
|
54
|
+
? cached
|
|
55
|
+
: snapshot;
|
|
56
|
+
setPriceCatalog(chosen);
|
|
57
|
+
log.info("prices loaded", {
|
|
58
|
+
models: Object.keys(chosen.models).length,
|
|
59
|
+
fetchedAt: new Date(chosen.fetchedAt).toISOString(),
|
|
60
|
+
from: chosen === snapshot ? "snapshot" : "cache",
|
|
61
|
+
});
|
|
62
|
+
return chosen;
|
|
63
|
+
}
|
|
64
|
+
async function fetchCatalog(url) {
|
|
65
|
+
const res = await fetch(url, {
|
|
66
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
67
|
+
headers: { accept: "application/json" },
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
throw new Error(`HTTP ${res.status}`);
|
|
71
|
+
return normaliseCatalog(await res.json(), { source: url, fetchedAt: Date.now() });
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* One refresh attempt. Never throws, never partially applies.
|
|
75
|
+
*
|
|
76
|
+
* The catalog is validated by `normaliseCatalog` (which throws on an empty or
|
|
77
|
+
* malformed file) BEFORE anything is written or swapped, so a truncated
|
|
78
|
+
* response cannot leave Fest pricing from a table with three models in it.
|
|
79
|
+
*/
|
|
80
|
+
export async function refreshPricesOnce(opts) {
|
|
81
|
+
try {
|
|
82
|
+
const catalog = await fetchCatalog(opts.url);
|
|
83
|
+
// Atomic: a crash mid-write leaves the old cache, never a torn one.
|
|
84
|
+
mkdirSync(dirname(opts.cachePath), { recursive: true });
|
|
85
|
+
const tmp = `${opts.cachePath}.${process.pid}.tmp`;
|
|
86
|
+
writeFileSync(tmp, JSON.stringify(catalog));
|
|
87
|
+
renameSync(tmp, opts.cachePath);
|
|
88
|
+
setPriceCatalog(catalog);
|
|
89
|
+
log.info("prices refreshed", { models: Object.keys(catalog.models).length, url: opts.url });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
log.warn("price refresh failed, keeping current rates", {
|
|
94
|
+
url: opts.url,
|
|
95
|
+
error: err instanceof Error ? err.message : String(err),
|
|
96
|
+
});
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Start the background refresh loop. Returns a handle whose `stop` is safe to
|
|
102
|
+
* call more than once, so shutdown ordering never has to be careful.
|
|
103
|
+
*
|
|
104
|
+
* Both timers are `unref`'d: prices must never be the reason the process won't
|
|
105
|
+
* exit, and a short-lived CLI command that happens to load the table should not
|
|
106
|
+
* hang for 24 hours waiting for a tick.
|
|
107
|
+
*/
|
|
108
|
+
export function startPriceRefresh(opts) {
|
|
109
|
+
if (!opts.refreshEnabled) {
|
|
110
|
+
log.info("price refresh disabled", { reason: "FEST_PRICING_REFRESH" });
|
|
111
|
+
return { stop: () => { } };
|
|
112
|
+
}
|
|
113
|
+
const timers = [];
|
|
114
|
+
// Deferred rather than awaited: boot must not wait on GitHub.
|
|
115
|
+
timers.push(setTimeout(() => void refreshPricesOnce(opts), 0).unref());
|
|
116
|
+
timers.push(setInterval(() => void refreshPricesOnce(opts), DAY_MS).unref());
|
|
117
|
+
return {
|
|
118
|
+
stop: () => {
|
|
119
|
+
for (const t of timers)
|
|
120
|
+
clearTimeout(t);
|
|
121
|
+
timers.length = 0;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=refresh.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refresh.js","sourceRoot":"","sources":["../../../../server/usage/prices/refresh.ts"],"names":[],"mappings":"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"}
|
|
Binary file
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-memory price table: load once at boot, look up per request.
|
|
3
|
+
*
|
|
4
|
+
* Two hard rules, both inherited from the hand-maintained table this replaced:
|
|
5
|
+
*
|
|
6
|
+
* 1. **An unknown model yields `null`, never an estimate.** A guessed rate
|
|
7
|
+
* that looks plausible is worse than an honest "n/a", because nobody
|
|
8
|
+
* investigates a number that looks fine.
|
|
9
|
+
* 2. **A gateway must not depend on an external price service to serve a
|
|
10
|
+
* request.** The vendored snapshot is loaded synchronously at boot and is
|
|
11
|
+
* always sufficient. `refresh.ts` only ever swaps in something newer.
|
|
12
|
+
*
|
|
13
|
+
* Note the deliberate absence of prefix matching. The old table had 13 entries
|
|
14
|
+
* and matched `claude-opus-4-1-20260101` by longest prefix out of necessity.
|
|
15
|
+
* litellm carries every dated snapshot id explicitly alongside its alias, so
|
|
16
|
+
* prefix matching would now be guessing where an exact answer exists — and it
|
|
17
|
+
* guesses badly: `claude-opus-4-5-20251101` would match a bare `claude-opus`
|
|
18
|
+
* entry at a completely different rate.
|
|
19
|
+
*/
|
|
20
|
+
import { gunzipSync } from "node:zlib";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
const SNAPSHOT_URL = new URL("./snapshot.json.gz", import.meta.url);
|
|
24
|
+
/** The vendored snapshot. Always present, always loadable, never the network. */
|
|
25
|
+
export function loadSnapshot() {
|
|
26
|
+
const gz = readFileSync(fileURLToPath(SNAPSHOT_URL));
|
|
27
|
+
return JSON.parse(gunzipSync(gz).toString("utf8"));
|
|
28
|
+
}
|
|
29
|
+
let current = null;
|
|
30
|
+
export function priceCatalog() {
|
|
31
|
+
current ??= loadSnapshot();
|
|
32
|
+
return current;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Swap in a newer catalog. Called only by `refresh.ts` after a successful
|
|
36
|
+
* fetch; a failed refresh leaves the previous table exactly as it was.
|
|
37
|
+
*/
|
|
38
|
+
export function setPriceCatalog(catalog) {
|
|
39
|
+
current = catalog;
|
|
40
|
+
}
|
|
41
|
+
/** Test seam: forget the loaded table so the next lookup re-reads the snapshot. */
|
|
42
|
+
export function resetPriceCatalog() {
|
|
43
|
+
current = null;
|
|
44
|
+
}
|
|
45
|
+
/** Strip Claude Code's client-side context-window tag before matching. */
|
|
46
|
+
function normalise(model) {
|
|
47
|
+
return model.trim().toLowerCase().replace(/\[1m\]$/, "");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Candidate catalog keys for a model id, most specific first.
|
|
51
|
+
*
|
|
52
|
+
* The provider-qualified forms matter for the substitute path: a route sends
|
|
53
|
+
* `accounts/fireworks/models/deepseek-v4-pro`, which litellm files under
|
|
54
|
+
* `fireworks_ai/accounts/fireworks/models/deepseek-v4-pro`. It also files many
|
|
55
|
+
* Fireworks models under the bare leaf (`fireworks_ai/deepseek-v4-pro-0813`),
|
|
56
|
+
* hence the basename form.
|
|
57
|
+
*/
|
|
58
|
+
function candidates(id, provider) {
|
|
59
|
+
const out = [id];
|
|
60
|
+
if (provider !== undefined && provider !== "") {
|
|
61
|
+
out.push(`${provider}/${id}`);
|
|
62
|
+
const leaf = id.slice(id.lastIndexOf("/") + 1);
|
|
63
|
+
if (leaf !== id)
|
|
64
|
+
out.push(`${provider}/${leaf}`);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
export function lookupRate(model, provider) {
|
|
69
|
+
if (model === null || model === undefined || model.trim() === "")
|
|
70
|
+
return null;
|
|
71
|
+
const { models } = priceCatalog();
|
|
72
|
+
// Raw first, then normalised: an id that matches verbatim should never be
|
|
73
|
+
// reinterpreted by the `[1m]` strip.
|
|
74
|
+
for (const id of [model.trim(), normalise(model)]) {
|
|
75
|
+
for (const key of candidates(id, provider)) {
|
|
76
|
+
const hit = models[key];
|
|
77
|
+
if (hit !== undefined)
|
|
78
|
+
return hit;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Pick the tier a call is billed at.
|
|
85
|
+
*
|
|
86
|
+
* Long-context tiers are thresholds on the WHOLE request: once context crosses
|
|
87
|
+
* the line the entire call prices at the higher rate, which is how Anthropic
|
|
88
|
+
* and Google both bill it. Priority overrides only the base tier — no vendor
|
|
89
|
+
* currently publishes a priority rate for a long-context tier, so combining
|
|
90
|
+
* them would be inventing a price.
|
|
91
|
+
*/
|
|
92
|
+
export function tierFor(rate, contextTokens, serviceTier) {
|
|
93
|
+
for (let i = rate.tiers.length - 1; i >= 0; i -= 1) {
|
|
94
|
+
const tier = rate.tiers[i];
|
|
95
|
+
if (tier !== undefined && contextTokens > tier.aboveTokens)
|
|
96
|
+
return tier.rates;
|
|
97
|
+
}
|
|
98
|
+
if (serviceTier === "priority" && rate.priority !== null)
|
|
99
|
+
return rate.priority;
|
|
100
|
+
return rate.base;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=table.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"table.js","sourceRoot":"","sources":["../../../../server/usage/prices/table.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-model rate lookup and call pricing.
|
|
3
|
+
*
|
|
4
|
+
* Two rules carried over from the cost engine in fw-ai/fireconnect
|
|
5
|
+
* (packages/setup-cli/lib/harnesses/claude/usage/pricing.mjs), both of which are
|
|
6
|
+
* easy to get wrong and expensive to get wrong:
|
|
7
|
+
*
|
|
8
|
+
* 1. The token buckets are DISJOINT and priced at different rates. Cache reads
|
|
9
|
+
* are ~10% of the input rate; a 5-minute cache write is ~125% and a 1-hour
|
|
10
|
+
* write ~200%. Folding cache reads into input inflates a bill by an order of
|
|
11
|
+
* magnitude on a cache-heavy agent workload, which Claude Code is.
|
|
12
|
+
* 2. An unknown model yields `null`, never an estimate. A guessed rate that
|
|
13
|
+
* looks plausible is worse than an honest "n/a", because nobody
|
|
14
|
+
* investigates a number that looks fine.
|
|
15
|
+
*
|
|
16
|
+
* Rates come from the vendored litellm catalog in `prices/` — see that
|
|
17
|
+
* directory for why they are a snapshot with a background refresh rather than a
|
|
18
|
+
* live vendor call. This file replaced a hand-maintained 13-entry Claude table
|
|
19
|
+
* that had drifted 3x on Opus 5; the drift is the argument for not hand-
|
|
20
|
+
* maintaining rates.
|
|
21
|
+
*
|
|
22
|
+
* ── Two dollar figures, and why they are not interchangeable ─────────────────
|
|
23
|
+
*
|
|
24
|
+
* `cost` is ORG SPEND: what the organisation will be invoiced. It is null for a
|
|
25
|
+
* subscription request, because the developer's own plan absorbed it and there
|
|
26
|
+
* is no invoice.
|
|
27
|
+
*
|
|
28
|
+
* `notionalCost` is VALUE: what the same call would have cost at published API
|
|
29
|
+
* rates, computed on every path including subscription. It exists because the
|
|
30
|
+
* largest slice of Fest's traffic is otherwise invisible in money terms — a
|
|
31
|
+
* team lead cannot see what their Max seats are actually delivering.
|
|
32
|
+
*
|
|
33
|
+
* They are separate fields, separate columns and separate UI stats precisely so
|
|
34
|
+
* that "add them up" is never the path of least resistance. A notional figure
|
|
35
|
+
* summed into spend is a fabricated invoice.
|
|
36
|
+
*/
|
|
37
|
+
import { contextTokens } from "../../shared/types.js";
|
|
38
|
+
import { lookupRate, tierFor } from "./prices/table.js";
|
|
39
|
+
export { lookupRate } from "./prices/table.js";
|
|
40
|
+
/**
|
|
41
|
+
* Price one call at list rates.
|
|
42
|
+
*
|
|
43
|
+
* The tier is chosen from the call's own context size and service tier: a
|
|
44
|
+
* long-context request bills entirely at the higher tier, which is how both
|
|
45
|
+
* Anthropic and Google publish it, and is where Claude Code's 1M-context
|
|
46
|
+
* sessions were previously understated by half.
|
|
47
|
+
*/
|
|
48
|
+
function listPrice(rate, usage) {
|
|
49
|
+
const t = tierFor(rate, contextTokens(usage), usage.serviceTier);
|
|
50
|
+
const perMillion = usage.inputTokens * t.inputPerMillion +
|
|
51
|
+
usage.cacheReadTokens * t.cacheReadPerMillion +
|
|
52
|
+
usage.cacheWrite5mTokens * t.cacheWrite5mPerMillion +
|
|
53
|
+
usage.cacheWrite1hTokens * t.cacheWrite1hPerMillion +
|
|
54
|
+
usage.outputTokens * t.outputPerMillion;
|
|
55
|
+
// A model with no published search rate contributes nothing for searches
|
|
56
|
+
// rather than borrowing another vendor's price.
|
|
57
|
+
const search = rate.webSearchPerThousand === null
|
|
58
|
+
? 0
|
|
59
|
+
: (usage.webSearches * rate.webSearchPerThousand) / 1_000;
|
|
60
|
+
return perMillion / 1_000_000 + search;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Price a call.
|
|
64
|
+
*
|
|
65
|
+
* `postureIsSubscription` still yields `basis: "subscription"` with a null
|
|
66
|
+
* `cost` — that invariant is unchanged and load-bearing. What is new is that
|
|
67
|
+
* `notionalCost` is populated anyway, so the value of subscription work is
|
|
68
|
+
* recorded without ever entering a spend total.
|
|
69
|
+
*
|
|
70
|
+
* `provider` is litellm's provider key, needed on the substitute path where the
|
|
71
|
+
* served model is a provider-native id. See `ADAPTER_PRICE_PROVIDERS`.
|
|
72
|
+
*/
|
|
73
|
+
export function priceUsage(model, usage, postureIsSubscription, provider) {
|
|
74
|
+
const rate = lookupRate(model, provider);
|
|
75
|
+
if (rate === null) {
|
|
76
|
+
// Unpriced, not free — and unpriced on both figures. We do not know what
|
|
77
|
+
// this model charges, so we do not know what it would have been worth.
|
|
78
|
+
return {
|
|
79
|
+
cost: null,
|
|
80
|
+
basis: postureIsSubscription ? "subscription" : "none",
|
|
81
|
+
notionalCost: null,
|
|
82
|
+
rateLabel: null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const value = listPrice(rate, usage);
|
|
86
|
+
if (postureIsSubscription) {
|
|
87
|
+
return { cost: null, basis: "subscription", notionalCost: value, rateLabel: rate.label };
|
|
88
|
+
}
|
|
89
|
+
return { cost: value, basis: "list", notionalCost: value, rateLabel: rate.label };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Fraction of context that came from cache. The single most actionable number
|
|
93
|
+
* on a gateway dashboard: a collapsed cache hit rate is where both spend and
|
|
94
|
+
* latency go, and it usually means something upstream is mangling
|
|
95
|
+
* `cache_control`.
|
|
96
|
+
*/
|
|
97
|
+
export function cacheHitRatio(usage) {
|
|
98
|
+
const context = contextTokens(usage);
|
|
99
|
+
if (context <= 0)
|
|
100
|
+
return null;
|
|
101
|
+
return usage.cacheReadTokens / context;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=pricing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pricing.js","sourceRoot":"","sources":["../../../server/usage/pricing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAGtD,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAGxD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAe/C;;;;;;;GAOG;AACH,SAAS,SAAS,CAAC,IAAe,EAAE,KAAmB;IACrD,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;IAEjE,MAAM,UAAU,GACd,KAAK,CAAC,WAAW,GAAG,CAAC,CAAC,eAAe;QACrC,KAAK,CAAC,eAAe,GAAG,CAAC,CAAC,mBAAmB;QAC7C,KAAK,CAAC,kBAAkB,GAAG,CAAC,CAAC,sBAAsB;QACnD,KAAK,CAAC,kBAAkB,GAAG,CAAC,CAAC,sBAAsB;QACnD,KAAK,CAAC,YAAY,GAAG,CAAC,CAAC,gBAAgB,CAAC;IAE1C,yEAAyE;IACzE,gDAAgD;IAChD,MAAM,MAAM,GAAG,IAAI,CAAC,oBAAoB,KAAK,IAAI;QAC/C,CAAC,CAAC,CAAC;QACH,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC,oBAAoB,CAAC,GAAG,KAAK,CAAC;IAE5D,OAAO,UAAU,GAAG,SAAS,GAAG,MAAM,CAAC;AACzC,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,UAAU,CACxB,KAAoB,EACpB,KAAmB,EACnB,qBAA8B,EAC9B,QAA6B;IAE7B,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IACzC,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QAClB,yEAAyE;QACzE,uEAAuE;QACvE,OAAO;YACL,IAAI,EAAE,IAAI;YACV,KAAK,EAAE,qBAAqB,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,MAAM;YACtD,YAAY,EAAE,IAAI;YAClB,SAAS,EAAE,IAAI;SAChB,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAErC,IAAI,qBAAqB,EAAE,CAAC;QAC1B,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;IAC3F,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;AACpF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,KAAmB;IAC/C,MAAM,OAAO,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IACrC,IAAI,OAAO,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9B,OAAO,KAAK,CAAC,eAAe,GAAG,OAAO,CAAC;AACzC,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard wire contract.
|
|
3
|
+
*
|
|
4
|
+
* Declared here rather than imported from `server/store/queries.ts` because the
|
|
5
|
+
* browser bundle must not reach into server modules — that file's import graph
|
|
6
|
+
* reaches `node:sqlite`. Declaring the shapes independently would normally
|
|
7
|
+
* invite drift, so `server/api/routes.ts` asserts every response with
|
|
8
|
+
* `satisfies`: if a query's return type stops matching what the dashboard is
|
|
9
|
+
* promised, `tsc --noEmit` fails at the route rather than the browser failing at
|
|
10
|
+
* runtime. Fields added server-side are invisible to the UI, which is harmless;
|
|
11
|
+
* fields removed or renamed break the build, which is the direction that
|
|
12
|
+
* matters.
|
|
13
|
+
*
|
|
14
|
+
* Accounting rules the UI must honour are documented on the fields themselves,
|
|
15
|
+
* because a field named `pricedCostUsd` is otherwise indistinguishable from a
|
|
16
|
+
* total.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.js","sourceRoot":"","sources":["../../shared/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic's own current base models, shared between the gateway menu, the
|
|
3
|
+
* `fest claude` preflight and the Routing screen's classification. Keeping one
|
|
4
|
+
* list prevents the CLI from warning about a model the server no longer
|
|
5
|
+
* considers a base, and prevents the server from offering a model the CLI does
|
|
6
|
+
* not know to check.
|
|
7
|
+
*
|
|
8
|
+
* A model missing from here is not a cosmetic gap. Three things go quietly
|
|
9
|
+
* wrong: the gateway menu never offers it, `fest claude` never warns that this
|
|
10
|
+
* gateway cannot serve it, and the dashboard files a rule that redirects it
|
|
11
|
+
* under "models this gateway adds" instead of "substitutions" — which is the
|
|
12
|
+
* heading an operator reads as harmless.
|
|
13
|
+
*
|
|
14
|
+
* Checked against the lineup table at
|
|
15
|
+
* platform.claude.com/docs/en/docs/about-claude/models/overview on 2026-09-22.
|
|
16
|
+
* Ids are the Claude API ids; from the 4.6 generation on, a dateless id is
|
|
17
|
+
* itself a pinned snapshot, which is why only Haiku carries a date. Legacy
|
|
18
|
+
* models that are still servable (Fable 5, Opus 4.8 and earlier, Sonnet 4.6
|
|
19
|
+
* and earlier) are deliberately absent: this is the list Fest offers and
|
|
20
|
+
* checks, not an archive.
|
|
21
|
+
*/
|
|
22
|
+
export const BASE_MODELS = [
|
|
23
|
+
{ id: "claude-fable-5-1", name: "Fable 5.1" },
|
|
24
|
+
{ id: "claude-opus-5", name: "Opus 5" },
|
|
25
|
+
{ id: "claude-sonnet-5", name: "Sonnet 5" },
|
|
26
|
+
{ id: "claude-haiku-4-5-20251001", name: "Haiku 4.5" },
|
|
27
|
+
];
|
|
28
|
+
//# sourceMappingURL=base-models.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base-models.js","sourceRoot":"","sources":["../../shared/base-models.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAOH,MAAM,CAAC,MAAM,WAAW,GAA6B;IACnD,EAAE,EAAE,EAAE,kBAAkB,EAAE,IAAI,EAAE,WAAW,EAAE;IAC7C,EAAE,EAAE,EAAE,eAAe,EAAE,IAAI,EAAE,QAAQ,EAAE;IACvC,EAAE,EAAE,EAAE,iBAAiB,EAAE,IAAI,EAAE,UAAU,EAAE;IAC3C,EAAE,EAAE,EAAE,2BAA2B,EAAE,IAAI,EAAE,WAAW,EAAE;CAC9C,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env vars that demote Claude Code off subscription auth onto key auth.
|
|
3
|
+
*
|
|
4
|
+
* Setting any of these makes `isAnthropicAuthEnabled()` return false, silently
|
|
5
|
+
* moving a developer off their own Max/Team subscription onto whatever key the
|
|
6
|
+
* gateway holds. `tools/canary.ts` clears these before spawning a *test*
|
|
7
|
+
* client; `cli/claude.ts` clears them before spawning a *developer's* client,
|
|
8
|
+
* for the identical reason. One list, so they cannot drift apart.
|
|
9
|
+
*/
|
|
10
|
+
export const DEMOTION_VARS = [
|
|
11
|
+
"ANTHROPIC_API_KEY",
|
|
12
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
13
|
+
"ANTHROPIC_BASE_URL",
|
|
14
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
15
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
16
|
+
];
|
|
17
|
+
//# sourceMappingURL=demotion-vars.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"demotion-vars.js","sourceRoot":"","sources":["../../shared/demotion-vars.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B,mBAAmB;IACnB,sBAAsB;IACtB,oBAAoB;IACpB,yBAAyB;IACzB,wBAAwB;CAChB,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-filling a gapped hourly series.
|
|
3
|
+
*
|
|
4
|
+
* The API returns gaps AS GAPS: an hour with no traffic is absent, not zero,
|
|
5
|
+
* because the server cannot tell "nobody worked" from "outside retention",
|
|
6
|
+
* whereas the caller knows exactly which range it asked for. Filling is
|
|
7
|
+
* therefore the caller's job, and this is that job done once.
|
|
8
|
+
*
|
|
9
|
+
* It lives in `shared/` rather than in the chart component for one reason: the
|
|
10
|
+
* bug it guards against is invisible in a component test and obvious in a unit
|
|
11
|
+
* test. The hour KEY must be the epoch-ms hour start, never a formatted clock
|
|
12
|
+
* label — a label repeats every 24 hours, so on any range wider than a day two
|
|
13
|
+
* different hours collide on one categorical band and a stacked layout rejects
|
|
14
|
+
* the data outright. Format at draw time; key on time.
|
|
15
|
+
*/
|
|
16
|
+
export const HOUR_MS = 3_600_000;
|
|
17
|
+
/**
|
|
18
|
+
* Hours drawn at most. Beyond this the bars are sub-pixel on any real screen,
|
|
19
|
+
* so the extra columns cost layout time and communicate nothing.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_HOURS = 168;
|
|
22
|
+
/** Epoch-ms hour starts covering `[fromMs, toMs]`, oldest first. */
|
|
23
|
+
export function hourStarts(fromMs, toMs, maxHours = MAX_HOURS) {
|
|
24
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs < fromMs)
|
|
25
|
+
return [];
|
|
26
|
+
const lastHour = Math.floor(toMs / HOUR_MS) * HOUR_MS;
|
|
27
|
+
const firstHour = Math.floor(fromMs / HOUR_MS) * HOUR_MS;
|
|
28
|
+
const available = Math.floor((lastHour - firstHour) / HOUR_MS) + 1;
|
|
29
|
+
const count = Math.min(available, maxHours);
|
|
30
|
+
// When the range is longer than the cap, keep the RECENT end. A truncated
|
|
31
|
+
// chart that drops the last hour would be actively misleading on a dashboard
|
|
32
|
+
// whose whole job is "what is happening now".
|
|
33
|
+
const start = lastHour - (count - 1) * HOUR_MS;
|
|
34
|
+
const hours = [];
|
|
35
|
+
for (let t = start; t <= lastHour; t += HOUR_MS)
|
|
36
|
+
hours.push(t);
|
|
37
|
+
return hours;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Join a gapped series onto a dense hour axis.
|
|
41
|
+
*
|
|
42
|
+
* `fill` supplies the value for an hour with no row, so callers never have to
|
|
43
|
+
* decide whether a missing hour is zero — here it genuinely is zero traffic,
|
|
44
|
+
* which is different from an unpriced or unavailable figure elsewhere.
|
|
45
|
+
*/
|
|
46
|
+
export function fillSeries(rows, fromMs, toMs, project, maxHours = MAX_HOURS) {
|
|
47
|
+
const byHour = new Map(rows.map((r) => [r.hourStart, r]));
|
|
48
|
+
return hourStarts(fromMs, toMs, maxHours).map((t) => project(t, byHour.get(t)));
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=series.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"series.js","sourceRoot":"","sources":["../../shared/series.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,CAAC,MAAM,OAAO,GAAG,SAAS,CAAC;AAEjC;;;GAGG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,GAAG,CAAC;AAE7B,oEAAoE;AACpE,MAAM,UAAU,UAAU,CAAC,MAAc,EAAE,IAAY,EAAE,QAAQ,GAAG,SAAS;IAC3E,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,MAAM;QAAE,OAAO,EAAE,CAAC;IAEnF,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,OAAO,CAAC,GAAG,OAAO,CAAC;IACtD,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,OAAO,CAAC,GAAG,OAAO,CAAC;IACzD,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,QAAQ,GAAG,SAAS,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IACnE,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IAE5C,0EAA0E;IAC1E,6EAA6E;IAC7E,8CAA8C;IAC9C,MAAM,KAAK,GAAG,QAAQ,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC;IAE/C,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,IAAI,CAAC,GAAG,KAAK,EAAE,CAAC,IAAI,QAAQ,EAAE,CAAC,IAAI,OAAO;QAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC/D,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CACxB,IAAqB,EACrB,MAAc,EACd,IAAY,EACZ,OAA2D,EAC3D,QAAQ,GAAG,SAAS;IAEpB,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1D,OAAO,UAAU,CAAC,MAAM,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAClF,CAAC"}
|