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.
Files changed (161) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/dist/cli/claude.js +326 -0
  4. package/dist/cli/claude.js.map +1 -0
  5. package/dist/cli/config.js +59 -0
  6. package/dist/cli/config.js.map +1 -0
  7. package/dist/cli/login.js +130 -0
  8. package/dist/cli/login.js.map +1 -0
  9. package/dist/cli/logout.js +20 -0
  10. package/dist/cli/logout.js.map +1 -0
  11. package/dist/cli/whoami.js +34 -0
  12. package/dist/cli/whoami.js.map +1 -0
  13. package/dist/server/adapters/anthropic.js +74 -0
  14. package/dist/server/adapters/anthropic.js.map +1 -0
  15. package/dist/server/adapters/fireworks.js +54 -0
  16. package/dist/server/adapters/fireworks.js.map +1 -0
  17. package/dist/server/adapters/index.js +16 -0
  18. package/dist/server/adapters/index.js.map +1 -0
  19. package/dist/server/adapters/registry.js +38 -0
  20. package/dist/server/adapters/registry.js.map +1 -0
  21. package/dist/server/adapters/rewrite.js +152 -0
  22. package/dist/server/adapters/rewrite.js.map +1 -0
  23. package/dist/server/adapters/url.js +24 -0
  24. package/dist/server/adapters/url.js.map +1 -0
  25. package/dist/server/api/auth.js +213 -0
  26. package/dist/server/api/auth.js.map +1 -0
  27. package/dist/server/api/live.js +108 -0
  28. package/dist/server/api/live.js.map +1 -0
  29. package/dist/server/api/models.js +129 -0
  30. package/dist/server/api/models.js.map +1 -0
  31. package/dist/server/api/oauth.js +300 -0
  32. package/dist/server/api/oauth.js.map +1 -0
  33. package/dist/server/api/routes.js +164 -0
  34. package/dist/server/api/routes.js.map +1 -0
  35. package/dist/server/api/routing-view.js +47 -0
  36. package/dist/server/api/routing-view.js.map +1 -0
  37. package/dist/server/auth/accounts.js +111 -0
  38. package/dist/server/auth/accounts.js.map +1 -0
  39. package/dist/server/auth/gateway-401.js +85 -0
  40. package/dist/server/auth/gateway-401.js.map +1 -0
  41. package/dist/server/auth/guard.js +74 -0
  42. package/dist/server/auth/guard.js.map +1 -0
  43. package/dist/server/auth/oauth.js +43 -0
  44. package/dist/server/auth/oauth.js.map +1 -0
  45. package/dist/server/auth/password.js +104 -0
  46. package/dist/server/auth/password.js.map +1 -0
  47. package/dist/server/auth/posture.js +145 -0
  48. package/dist/server/auth/posture.js.map +1 -0
  49. package/dist/server/auth/session.js +161 -0
  50. package/dist/server/auth/session.js.map +1 -0
  51. package/dist/server/bin/fest.js +589 -0
  52. package/dist/server/bin/fest.js.map +1 -0
  53. package/dist/server/config.js +109 -0
  54. package/dist/server/config.js.map +1 -0
  55. package/dist/server/credentials/provider.js +70 -0
  56. package/dist/server/credentials/provider.js.map +1 -0
  57. package/dist/server/credentials/resolve.js +89 -0
  58. package/dist/server/credentials/resolve.js.map +1 -0
  59. package/dist/server/http/body.js +66 -0
  60. package/dist/server/http/body.js.map +1 -0
  61. package/dist/server/http/errors.js +144 -0
  62. package/dist/server/http/errors.js.map +1 -0
  63. package/dist/server/http/headers.js +189 -0
  64. package/dist/server/http/headers.js.map +1 -0
  65. package/dist/server/http/pipe.js +97 -0
  66. package/dist/server/http/pipe.js.map +1 -0
  67. package/dist/server/http/server.js +226 -0
  68. package/dist/server/http/server.js.map +1 -0
  69. package/dist/server/http/sse.js +167 -0
  70. package/dist/server/http/sse.js.map +1 -0
  71. package/dist/server/http/static.js +138 -0
  72. package/dist/server/http/static.js.map +1 -0
  73. package/dist/server/ingest/live-bus.js +67 -0
  74. package/dist/server/ingest/live-bus.js.map +1 -0
  75. package/dist/server/ingest/sink.js +141 -0
  76. package/dist/server/ingest/sink.js.map +1 -0
  77. package/dist/server/log.js +31 -0
  78. package/dist/server/log.js.map +1 -0
  79. package/dist/server/pipeline/count-tokens.js +48 -0
  80. package/dist/server/pipeline/count-tokens.js.map +1 -0
  81. package/dist/server/pipeline/dispatch.js +138 -0
  82. package/dist/server/pipeline/dispatch.js.map +1 -0
  83. package/dist/server/pipeline/passthrough.js +392 -0
  84. package/dist/server/pipeline/passthrough.js.map +1 -0
  85. package/dist/server/pipeline/preflight.js +66 -0
  86. package/dist/server/pipeline/preflight.js.map +1 -0
  87. package/dist/server/pipeline/record.js +49 -0
  88. package/dist/server/pipeline/record.js.map +1 -0
  89. package/dist/server/pipeline/substitute.js +287 -0
  90. package/dist/server/pipeline/substitute.js.map +1 -0
  91. package/dist/server/routes/resolve.js +176 -0
  92. package/dist/server/routes/resolve.js.map +1 -0
  93. package/dist/server/routes/table.js +247 -0
  94. package/dist/server/routes/table.js.map +1 -0
  95. package/dist/server/routes/watch.js +101 -0
  96. package/dist/server/routes/watch.js.map +1 -0
  97. package/dist/server/secret/fingerprint.js +107 -0
  98. package/dist/server/secret/fingerprint.js.map +1 -0
  99. package/dist/server/secret/non-persistable.js +69 -0
  100. package/dist/server/secret/non-persistable.js.map +1 -0
  101. package/dist/server/store/audit.js +53 -0
  102. package/dist/server/store/audit.js.map +1 -0
  103. package/dist/server/store/bootstrap.js +53 -0
  104. package/dist/server/store/bootstrap.js.map +1 -0
  105. package/dist/server/store/db.js +157 -0
  106. package/dist/server/store/db.js.map +1 -0
  107. package/dist/server/store/ids.js +14 -0
  108. package/dist/server/store/ids.js.map +1 -0
  109. package/dist/server/store/migrations/001-init.sql +213 -0
  110. package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
  111. package/dist/server/store/migrations/003-routing.sql +42 -0
  112. package/dist/server/store/migrations/004-admin-auth.sql +72 -0
  113. package/dist/server/store/migrations/005-notional-cost.sql +28 -0
  114. package/dist/server/store/queries.js +904 -0
  115. package/dist/server/store/queries.js.map +1 -0
  116. package/dist/server/store/retention.js +79 -0
  117. package/dist/server/store/retention.js.map +1 -0
  118. package/dist/server/store/seed.js +203 -0
  119. package/dist/server/store/seed.js.map +1 -0
  120. package/dist/server/store/tokens.js +133 -0
  121. package/dist/server/store/tokens.js.map +1 -0
  122. package/dist/server/store/write.js +330 -0
  123. package/dist/server/store/write.js.map +1 -0
  124. package/dist/server/usage/accumulator.js +209 -0
  125. package/dist/server/usage/accumulator.js.map +1 -0
  126. package/dist/server/usage/cost.js +57 -0
  127. package/dist/server/usage/cost.js.map +1 -0
  128. package/dist/server/usage/prices/catalog.js +136 -0
  129. package/dist/server/usage/prices/catalog.js.map +1 -0
  130. package/dist/server/usage/prices/refresh.js +125 -0
  131. package/dist/server/usage/prices/refresh.js.map +1 -0
  132. package/dist/server/usage/prices/snapshot.json.gz +0 -0
  133. package/dist/server/usage/prices/table.js +102 -0
  134. package/dist/server/usage/prices/table.js.map +1 -0
  135. package/dist/server/usage/pricing.js +103 -0
  136. package/dist/server/usage/pricing.js.map +1 -0
  137. package/dist/shared/api.js +19 -0
  138. package/dist/shared/api.js.map +1 -0
  139. package/dist/shared/base-models.js +28 -0
  140. package/dist/shared/base-models.js.map +1 -0
  141. package/dist/shared/demotion-vars.js +17 -0
  142. package/dist/shared/demotion-vars.js.map +1 -0
  143. package/dist/shared/series.js +50 -0
  144. package/dist/shared/series.js.map +1 -0
  145. package/dist/shared/types.js +40 -0
  146. package/dist/shared/types.js.map +1 -0
  147. package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
  148. package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
  149. package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
  150. package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  151. package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  152. package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  153. package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  154. package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  155. package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  156. package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  157. package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  158. package/dist/web/dist/index.html +44 -0
  159. package/dist/web/dist/nearform-icon.svg +4 -0
  160. package/package.json +82 -0
  161. 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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"}
@@ -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"}