@danypops/jittor 0.6.0 → 0.8.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.
@@ -1,5 +1,4 @@
1
1
  import { MAX_USAGE_BUCKETS, MILLISECONDS_PER_DAY, MILLISECONDS_PER_HOUR } from "../constants.ts";
2
- import type { StoredMetricObservation } from "./metric.ts";
3
2
 
4
3
  export const USAGE_PERIODS = [
5
4
  { id: "hourly", label: "Hourly", windowMs: MILLISECONDS_PER_HOUR, bucketCount: 12 },
@@ -14,6 +13,52 @@ export function usagePeriod(period: UsagePeriod): typeof USAGE_PERIODS[number] {
14
13
  return USAGE_PERIODS.find((candidate) => candidate.id === period)!;
15
14
  }
16
15
 
16
+ export function usagePeriodStart(period: UsagePeriod, now: number): number {
17
+ return Math.max(0, now - usagePeriod(period).windowMs);
18
+ }
19
+
20
+ /**
21
+ * The bucket boundaries a chart period actually renders. Shared, byte-identical, by both sides of
22
+ * the wire: the daemon computes the SAME window from the same (period, now[, bucketCount]) before
23
+ * running its SQL-side GROUP BY, so a pre-aggregated bucket sum always lands in exactly the bucket
24
+ * the chart expects -- there is no second, independent bucketing pass left to disagree with it.
25
+ */
26
+ export interface UsageBucketWindow {
27
+ start: number;
28
+ end: number;
29
+ bucketCount: number;
30
+ bucketSizeMs: number;
31
+ }
32
+
33
+ export function resolveUsageWindow(period: UsagePeriod, now: number, bucketCount?: number): UsageBucketWindow {
34
+ const end = now;
35
+ const start = usagePeriodStart(period, end);
36
+ const requested = bucketCount ?? usagePeriod(period).bucketCount;
37
+ const count = Math.max(1, Math.min(MAX_USAGE_BUCKETS, Math.floor(requested)));
38
+ const bucketSizeMs = Math.max(1, (end - start) / count);
39
+ return { start, end, bucketCount: count, bucketSizeMs };
40
+ }
41
+
42
+ /** Mirrors the SQL-side `MIN(CAST((observed_at - start) / bucketSizeMs AS INTEGER), bucketCount - 1)` grouping exactly. */
43
+ export function usageBucketIndex(observedAt: number, window: UsageBucketWindow): number {
44
+ return Math.min(window.bucketCount - 1, Math.max(0, Math.floor((observedAt - window.start) / window.bucketSizeMs)));
45
+ }
46
+
47
+ /**
48
+ * One already-summed (scope, metric, bucket) cell -- what the daemon's SQL-side GROUP BY returns,
49
+ * replacing a bounded-but-still-truncatable fetch of raw per-observation rows. Result size scales
50
+ * with (distinct scopes x distinct metrics x bucket count), never with raw event count, so a
51
+ * heavy scope's full history is represented exactly regardless of how many observations it made
52
+ * (a real incident: a scope with 49,270 rows in a week had its "weekly" chart built from the 250
53
+ * most recent rows alone -- 3.3 minutes of real activity mislabeled as a full week).
54
+ */
55
+ export interface UsageAggregateRow {
56
+ scope: string;
57
+ metric: string;
58
+ bucketIndex: number;
59
+ sum: number;
60
+ }
61
+
17
62
  export interface UsageSeries {
18
63
  key: string;
19
64
  provider: string;
@@ -48,8 +93,6 @@ export interface UsageGraph {
48
93
 
49
94
  export interface UsageGraphOptions {
50
95
  period: UsagePeriod;
51
- now: number;
52
- bucketCount?: number;
53
96
  truncated?: boolean;
54
97
  }
55
98
 
@@ -60,69 +103,52 @@ const BREAKDOWN_KEYS = {
60
103
  "cache-write-tokens": "cacheWrite",
61
104
  } as const satisfies Record<string, keyof UsageBreakdown>;
62
105
 
63
- export function usagePeriodStart(period: UsagePeriod, now: number): number {
64
- return Math.max(0, now - usagePeriod(period).windowMs);
65
- }
66
-
67
- export function identity(row: StoredMetricObservation): { key: string; provider: string; model: string } {
68
- const separator = row.scope.indexOf(":");
69
- const fallbackProvider = separator >= 0 ? row.scope.slice(0, separator) : row.scope;
70
- const fallbackModel = separator >= 0 ? row.scope.slice(separator + 1) : "unknown";
71
- const provider = typeof row.attributes["provider"] === "string" ? row.attributes["provider"] : fallbackProvider;
72
- const model = typeof row.attributes["model"] === "string" ? row.attributes["model"] : fallbackModel;
106
+ /**
107
+ * Derives provider/model from `scope` alone (`"${provider}:${model}"`, see assistantUsageMetrics),
108
+ * rather than needing a row's `attributes.provider`/`attributes.model` -- source "pi" always
109
+ * constructs `scope` from those exact same two values, so the two are equivalent for this source,
110
+ * and an aggregated bucket sum has no per-row attributes left to read anyway.
111
+ */
112
+ export function identity(scope: string): { key: string; provider: string; model: string } {
113
+ const separator = scope.indexOf(":");
114
+ const provider = separator >= 0 ? scope.slice(0, separator) : scope;
115
+ const model = separator >= 0 ? scope.slice(separator + 1) : "unknown";
73
116
  return { key: `${provider}/${model}`, provider, model };
74
117
  }
75
118
 
76
- interface BucketWindow { start: number; end: number; bucketCount: number; bucketSize: number }
77
-
78
- function bucketWindow(options: UsageGraphOptions): BucketWindow {
79
- const end = options.now;
80
- const start = usagePeriodStart(options.period, end);
81
- const requestedBuckets = options.bucketCount ?? usagePeriod(options.period).bucketCount;
82
- const bucketCount = Math.max(1, Math.min(MAX_USAGE_BUCKETS, Math.floor(requestedBuckets)));
83
- const bucketSize = Math.max(1, (end - start) / bucketCount);
84
- return { start, end, bucketCount, bucketSize };
85
- }
86
-
87
- function emptyBuckets(window: BucketWindow): { start: number; end: number; total: number; series: Record<string, number> }[] {
119
+ function emptyBuckets(window: UsageBucketWindow): { start: number; end: number; total: number; series: Record<string, number> }[] {
88
120
  return Array.from({ length: window.bucketCount }, (_, index) => ({
89
- start: window.start + index * window.bucketSize,
90
- end: index === window.bucketCount - 1 ? window.end : window.start + (index + 1) * window.bucketSize,
121
+ start: window.start + index * window.bucketSizeMs,
122
+ end: index === window.bucketCount - 1 ? window.end : window.start + (index + 1) * window.bucketSizeMs,
91
123
  total: 0,
92
124
  series: {},
93
125
  }));
94
126
  }
95
127
 
96
- function bucketIndexFor(observedAt: number, window: BucketWindow): number {
97
- return Math.min(window.bucketCount - 1, Math.floor((observedAt - window.start) / window.bucketSize));
98
- }
99
-
100
- export function buildUsageGraph(rows: StoredMetricObservation[], options: UsageGraphOptions): UsageGraph {
101
- const window = bucketWindow(options);
102
- const { start, end } = window;
128
+ export function buildUsageGraph(rows: UsageAggregateRow[], window: UsageBucketWindow, options: UsageGraphOptions): UsageGraph {
103
129
  const buckets: UsageBucket[] = emptyBuckets(window);
104
130
  const breakdown: UsageBreakdown = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
105
131
  const identities = new Map<string, UsageSeries>();
106
132
 
107
133
  for (const row of rows) {
108
134
  const breakdownKey = BREAKDOWN_KEYS[row.metric as keyof typeof BREAKDOWN_KEYS];
109
- if (row.source !== "pi" || row.unit !== "tokens" || !breakdownKey || typeof row.value !== "number" || row.value < 0) continue;
110
- if (row.observedAt < start || row.observedAt > end) continue;
111
- const bucket = buckets[bucketIndexFor(row.observedAt, window)]!;
112
- const series = identity(row);
113
- bucket.total += row.value;
114
- bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.value;
115
- breakdown[breakdownKey] += row.value;
135
+ if (!breakdownKey || !Number.isFinite(row.sum) || row.sum < 0) continue;
136
+ if (!Number.isInteger(row.bucketIndex) || row.bucketIndex < 0 || row.bucketIndex >= buckets.length) continue;
137
+ const bucket = buckets[row.bucketIndex]!;
138
+ const series = identity(row.scope);
139
+ bucket.total += row.sum;
140
+ bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.sum;
141
+ breakdown[breakdownKey] += row.sum;
116
142
  const current = identities.get(series.key) ?? { ...series, total: 0 };
117
- current.total += row.value;
143
+ current.total += row.sum;
118
144
  identities.set(series.key, current);
119
145
  }
120
146
 
121
147
  const series = [...identities.values()].sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
122
148
  return {
123
149
  period: options.period,
124
- start,
125
- end,
150
+ start: window.start,
151
+ end: window.end,
126
152
  buckets,
127
153
  series,
128
154
  totalTokens: buckets.reduce((sum, bucket) => sum + bucket.total, 0),
@@ -162,29 +188,27 @@ export interface CostGraph {
162
188
  * surface from day one (alongside raw token counts), since token counts alone do not reflect that
163
189
  * output tokens and premium models cost disproportionately more per token.
164
190
  */
165
- export function buildCostGraph(rows: StoredMetricObservation[], options: UsageGraphOptions): CostGraph {
166
- const window = bucketWindow(options);
167
- const { start, end } = window;
191
+ export function buildCostGraph(rows: UsageAggregateRow[], window: UsageBucketWindow, options: UsageGraphOptions): CostGraph {
168
192
  const buckets: CostBucket[] = emptyBuckets(window);
169
193
  const identities = new Map<string, CostSeries>();
170
194
 
171
195
  for (const row of rows) {
172
- if (row.source !== "pi" || row.unit !== "usd" || row.metric !== "cost" || typeof row.value !== "number" || row.value < 0) continue;
173
- if (row.observedAt < start || row.observedAt > end) continue;
174
- const bucket = buckets[bucketIndexFor(row.observedAt, window)]!;
175
- const series = identity(row);
176
- bucket.total += row.value;
177
- bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.value;
196
+ if (row.metric !== "cost" || !Number.isFinite(row.sum) || row.sum < 0) continue;
197
+ if (!Number.isInteger(row.bucketIndex) || row.bucketIndex < 0 || row.bucketIndex >= buckets.length) continue;
198
+ const bucket = buckets[row.bucketIndex]!;
199
+ const series = identity(row.scope);
200
+ bucket.total += row.sum;
201
+ bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.sum;
178
202
  const current = identities.get(series.key) ?? { ...series, total: 0 };
179
- current.total += row.value;
203
+ current.total += row.sum;
180
204
  identities.set(series.key, current);
181
205
  }
182
206
 
183
207
  const series = [...identities.values()].sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
184
208
  return {
185
209
  period: options.period,
186
- start,
187
- end,
210
+ start: window.start,
211
+ end: window.end,
188
212
  buckets,
189
213
  series,
190
214
  totalUsd: buckets.reduce((sum, bucket) => sum + bucket.total, 0),
package/src/log.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Structured daemon logging, now backed by `@danypops/daemon-kit/logging` (pino) instead of a
3
+ * hand-rolled `console.error(JSON.stringify(...))` -- daemon-kit's own module doc explains why:
4
+ * level ordering/filtering/child-scoping is exactly the kind of thing worth one shared,
5
+ * dependency-backed implementation instead of four independent hand-rolled ones. One deliberate,
6
+ * disclosed shape change from jittor's old bespoke format: the event name is now pino's `msg`
7
+ * field rather than a separate `event` field, matching daemon-kit's shared convention across all
8
+ * four daemons. `component`/`level`/`timestamp` and credential-safety (callers still must pass
9
+ * only bounded, non-sensitive fields) are unchanged.
10
+ */
11
+ import { createLogger, type LogLevel as DaemonKitLogLevel, type Logger } from "@danypops/daemon-kit/logging";
12
+
13
+ export type LogLevel = Extract<DaemonKitLogLevel, "info" | "warn" | "error">;
14
+
15
+ /**
16
+ * Also passed directly as `StartDaemonOptions.logger` so daemon-kit's own maintenance-task
17
+ * failure logging shares this same sink/shape. `destination` is pinned to `console.error` rather
18
+ * than daemon-kit's own default (a raw fd 2 write via `pino.destination(2)`, which bypasses
19
+ * `console.error` entirely) so existing tooling/tests that intercept `console.error` keep working.
20
+ */
21
+ export const logger: Logger = createLogger("jittor-daemon", {
22
+ destination: { write: (chunk: string) => { console.error(chunk.replace(/\n$/, "")); return true; } },
23
+ });
24
+
25
+ /** Credential-safe structured daemon event. Callers must pass bounded, non-sensitive fields. */
26
+ export function logEvent(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
27
+ logger[level](event, fields);
28
+ }
@@ -1,8 +1,38 @@
1
+ import type { UsageAggregateRow } from "../domain/usage.ts";
1
2
  import type { MetricObservation, MetricQuery, StoredMetricObservation } from "../domain/metric.ts";
2
3
 
4
+ export interface DistinctScopesFilter {
5
+ source: string;
6
+ since: number;
7
+ until: number;
8
+ limit: number;
9
+ }
10
+
11
+ export interface UsageAggregateFilter {
12
+ source: string;
13
+ /** Only these scopes are summed -- callers pass the bounded result of distinctScopes, so a real explosion of distinct scopes truncates honestly instead of this method silently discovering and aggregating an unbounded set on its own. */
14
+ scopes: string[];
15
+ since: number;
16
+ until: number;
17
+ bucketSizeMs: number;
18
+ bucketCount: number;
19
+ }
20
+
3
21
  export interface MetricStore {
4
22
  record(observation: MetricObservation): StoredMetricObservation;
5
23
  query(filter?: MetricQuery): StoredMetricObservation[];
24
+ /** Bounded distinct scope values for a source within a time window, so callers can fetch a fair share per scope instead of one flat query a single heavy scope could monopolize. */
25
+ distinctScopes(filter: DistinctScopesFilter): string[];
26
+ /**
27
+ * SQL-side (scope, metric, bucket) sums for a bounded scope list -- result size scales with
28
+ * (scopes x distinct metrics x buckets), never with raw event count, so a heavy scope's full
29
+ * history is represented exactly regardless of how many observations fed it. Replaces fetching
30
+ * up to a fixed number of raw rows per scope, which could silently truncate a single heavy
31
+ * scope's own older history within the requested window (a real incident: a scope with 49,270
32
+ * rows in a week had its "weekly" chart built from the 250 most recent rows alone -- 3.3
33
+ * minutes of real activity mislabeled as a full week).
34
+ */
35
+ aggregateUsage(filter: UsageAggregateFilter): UsageAggregateRow[];
6
36
  pruneBefore(cutoff: number): number;
7
37
  checkpoint(): void;
8
38
  close(): void;
@@ -27,6 +27,7 @@ export interface AnthropicRateLimitSnapshot {
27
27
  }
28
28
 
29
29
  function metric(
30
+ source: AnthropicMetricSource,
30
31
  scope: string,
31
32
  name: string,
32
33
  value: number,
@@ -34,7 +35,7 @@ function metric(
34
35
  observedAt: number,
35
36
  attributes: Record<string, unknown> = {},
36
37
  ): MetricObservation {
37
- return { source: "anthropic", scope, metric: name, value, unit, observedAt, attributes };
38
+ return { source, scope, metric: name, value, unit, observedAt, attributes };
38
39
  }
39
40
 
40
41
  function headerInteger(headers: Headers, name: string): number | null {
@@ -61,20 +62,30 @@ function parseWindow(headers: Headers, prefix: string): AnthropicRateLimitWindow
61
62
  return { limit, remaining, resetsAt };
62
63
  }
63
64
 
64
- function windowMetrics(scope: string, window: AnthropicRateLimitWindow | null, observedAt: number): MetricObservation[] {
65
+ function windowMetrics(source: AnthropicMetricSource, scope: string, window: AnthropicRateLimitWindow | null, observedAt: number): MetricObservation[] {
65
66
  if (!window) return [];
66
67
  const metrics: MetricObservation[] = [];
67
68
  const attributes = { limit: window.limit, remaining: window.remaining, resetsAt: window.resetsAt };
68
69
  if (window.limit !== null && window.limit > 0 && window.remaining !== null) {
69
70
  const remainingFraction = window.remaining / window.limit;
70
71
  if (remainingFraction < 0 || remainingFraction > 1) throw new Error(`Anthropic ${scope} remaining exceeds its configured limit`);
71
- metrics.push(metric(scope, "remaining-fraction", remainingFraction, "ratio", observedAt, attributes));
72
- metrics.push(metric(scope, "used-fraction", 1 - remainingFraction, "ratio", observedAt, attributes));
72
+ metrics.push(metric(source, scope, "remaining-fraction", remainingFraction, "ratio", observedAt, attributes));
73
+ metrics.push(metric(source, scope, "used-fraction", 1 - remainingFraction, "ratio", observedAt, attributes));
73
74
  }
74
75
  return metrics;
75
76
  }
76
77
 
77
- export function parseAnthropicRateLimitHeaders(headers: Headers, observedAt = Date.now()): AnthropicRateLimitSnapshot {
78
+ /**
79
+ * Direct Anthropic API calls and Anthropic-on-Vertex passthroughs (e.g. the third-party
80
+ * `@twogiants/pi-anthropic-vertex` extension, which reuses Pi's built-in Anthropic Messages stream
81
+ * with Anthropic's own `@anthropic-ai/vertex-sdk` client) may both emit this same header shape, but
82
+ * they are different accounts against different quota pools -- Anthropic's own org-scoped buckets
83
+ * vs whatever a Vertex project's passthrough exposes, if anything. Tagging the metric source keeps
84
+ * them from ever being blended into one budget reading.
85
+ */
86
+ export type AnthropicMetricSource = "anthropic" | "anthropic-vertex";
87
+
88
+ export function parseAnthropicRateLimitHeaders(headers: Headers, observedAt = Date.now(), source: AnthropicMetricSource = "anthropic"): AnthropicRateLimitSnapshot {
78
89
  const requests = parseWindow(headers, "anthropic-ratelimit-requests");
79
90
  const tokens = parseWindow(headers, "anthropic-ratelimit-tokens");
80
91
  const inputTokens = parseWindow(headers, "anthropic-ratelimit-input-tokens");
@@ -96,12 +107,12 @@ export function parseAnthropicRateLimitHeaders(headers: Headers, observedAt = Da
96
107
  retryAfterMs: retryAfterSeconds === null ? null : Math.round(retryAfterSeconds * 1_000),
97
108
  observedAt,
98
109
  metrics: [
99
- ...windowMetrics("requests", requests, observedAt),
100
- ...windowMetrics("tokens", tokens, observedAt),
101
- ...windowMetrics("input-tokens", inputTokens, observedAt),
102
- ...windowMetrics("output-tokens", outputTokens, observedAt),
103
- ...windowMetrics("priority-input-tokens", priorityInputTokens, observedAt),
104
- ...windowMetrics("priority-output-tokens", priorityOutputTokens, observedAt),
110
+ ...windowMetrics(source, "requests", requests, observedAt),
111
+ ...windowMetrics(source, "tokens", tokens, observedAt),
112
+ ...windowMetrics(source, "input-tokens", inputTokens, observedAt),
113
+ ...windowMetrics(source, "output-tokens", outputTokens, observedAt),
114
+ ...windowMetrics(source, "priority-input-tokens", priorityInputTokens, observedAt),
115
+ ...windowMetrics(source, "priority-output-tokens", priorityOutputTokens, observedAt),
105
116
  ],
106
117
  };
107
118
  }
@@ -0,0 +1,63 @@
1
+ import { GoogleAuth } from "google-auth-library";
2
+ import { GOOGLE_ADC_TOKEN_REFRESH_SKEW_MS } from "../constants.ts";
3
+
4
+ /**
5
+ * The only file in Jittor that imports `google-auth-library` (Jittor's first runtime dependency;
6
+ * see docs/PROVIDER_RESEARCH.md for why ADC token acquisition -- not the Vertex/Budget API
7
+ * surface itself -- is the one piece worth a mature dependency instead of hand-rolling: it is
8
+ * security-sensitive OAuth/JWT/metadata-server protocol code, the exact class of thing the
9
+ * project's own off-the-shelf-modules guidance calls out as worth not reimplementing).
10
+ *
11
+ * This matches the individual-GCP-project migration's "passwordless/keyless" model: Application
12
+ * Default Credentials (a user's own `gcloud auth application-default login` session, a Compute/
13
+ * Cloud Run metadata identity, or Workload Identity Federation), never a static service-account
14
+ * key file. `google-auth-library` auto-detects which of those applies to the environment it runs
15
+ * in; Jittor does not choose or configure a credential type itself.
16
+ */
17
+ export type GoogleAdcTokenProvider = () => Promise<string>;
18
+
19
+ /** The minimal shape this module actually calls on an ADC client; matches google-auth-library's real `AuthClient`/`Credentials` fields (verified against its published `.d.ts`), kept narrow so tests can inject a fake without constructing a real `GoogleAuth`. */
20
+ export interface GoogleAdcClient {
21
+ getAccessToken(): Promise<{ token?: string | null }>;
22
+ credentials?: { expiry_date?: number | null };
23
+ }
24
+
25
+ export type GoogleAdcClientFactory = (scopes: readonly string[]) => Promise<GoogleAdcClient>;
26
+
27
+ async function defaultAdcClientFactory(scopes: readonly string[]): Promise<GoogleAdcClient> {
28
+ return new GoogleAuth({ scopes: [...scopes] }).getClient();
29
+ }
30
+
31
+ /**
32
+ * Wraps ADC token acquisition with the caching every other Jittor provider adapter skips
33
+ * only because their upstreams don't require an OAuth exchange per call: a fresh Google access
34
+ * token is valid for roughly an hour, and re-deriving ADC (which may itself make a metadata-server
35
+ * or token-exchange network call) on every poll would be wasteful and, for some ADC sources,
36
+ * rate-limited. `GOOGLE_ADC_TOKEN_REFRESH_SKEW_MS` triggers a refresh slightly before Google
37
+ * itself would consider the cached token invalid.
38
+ */
39
+ export function createGoogleAdcTokenProvider(
40
+ scopes: readonly string[],
41
+ clock: () => number = Date.now,
42
+ clientFactory: GoogleAdcClientFactory = defaultAdcClientFactory,
43
+ ): GoogleAdcTokenProvider {
44
+ let cachedClient: GoogleAdcClient | undefined;
45
+ let cachedToken: string | undefined;
46
+ let cachedExpiryEpochMs = 0;
47
+
48
+ return async function getAccessToken(): Promise<string> {
49
+ const now = clock();
50
+ if (cachedToken && now < cachedExpiryEpochMs - GOOGLE_ADC_TOKEN_REFRESH_SKEW_MS) return cachedToken;
51
+ cachedClient ??= await clientFactory(scopes);
52
+ const { token } = await cachedClient.getAccessToken();
53
+ if (!token) throw new Error("Google Application Default Credentials did not return an access token");
54
+ // `credentials.expiry_date` is the verified google-auth-library field (epoch ms). When a
55
+ // credential type doesn't report one, this deliberately does not fabricate an assumed TTL --
56
+ // it treats the token as already due for refresh, so the next call re-fetches rather than
57
+ // caching for a guessed duration.
58
+ const expiryDate = cachedClient.credentials?.expiry_date;
59
+ cachedToken = token;
60
+ cachedExpiryEpochMs = typeof expiryDate === "number" ? expiryDate : now;
61
+ return token;
62
+ };
63
+ }
@@ -0,0 +1,181 @@
1
+ import type { BudgetWindow } from "../policy.ts";
2
+ import type { MetricObservation } from "../domain/metric.ts";
3
+ import type { GoogleVertexMetricSource } from "./google-vertex-contracts.ts";
4
+ import {
5
+ GOOGLE_VERTEX_BUDGET_CONFIDENCE,
6
+ GOOGLE_VERTEX_BUDGET_DISPLAY_NAME_MAX_CHARACTERS,
7
+ MILLISECONDS_PER_SECOND,
8
+ } from "../constants.ts";
9
+
10
+ /**
11
+ * Cloud Billing's programmatic budget notification schema (Pub/Sub attributes + base64 JSON data
12
+ * body), verified against
13
+ * https://docs.cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications#notification-format
14
+ * and the worked example in
15
+ * https://docs.cloud.google.com/billing/docs/how-to/listen-to-notifications (fetched 2026-07-23).
16
+ * This is the individual-GCP-project era's real hot(ish)-path budget signal Google documents:
17
+ * "Budget notifications are sent to the Pub/Sub topic multiple times per day with the current
18
+ * status of your budget", unlike the per-response rate-limit header Vertex generateContent itself
19
+ * does not expose (see google-vertex-contracts.ts). Two honesty caveats the docs are explicit
20
+ * about and this module must not paper over: (1) "Budgets use estimated Cloud Billing data which
21
+ * is subject to change until your invoice is finalized" and (2) "Pub/Sub only provides
22
+ * at-least-once delivery. You might receive a message multiple times, and messages might arrive
23
+ * out of order."
24
+ */
25
+ export type GoogleVertexBudgetAmountType = "SPECIFIED_AMOUNT" | "LAST_MONTH_COST" | "LAST_PERIODS_COST";
26
+
27
+ export interface GoogleVertexBudgetNotification {
28
+ billingAccountId: string;
29
+ budgetId: string;
30
+ schemaVersion: string;
31
+ budgetDisplayName: string;
32
+ costAmount: number;
33
+ costIntervalStart: number;
34
+ budgetAmount: number;
35
+ budgetAmountType: GoogleVertexBudgetAmountType;
36
+ currencyCode: string;
37
+ alertThresholdExceeded?: number;
38
+ forecastThresholdExceeded?: number;
39
+ publishedAt: number;
40
+ }
41
+
42
+ const BUDGET_AMOUNT_TYPES: readonly GoogleVertexBudgetAmountType[] = ["SPECIFIED_AMOUNT", "LAST_MONTH_COST", "LAST_PERIODS_COST"];
43
+
44
+ function requiredString(value: unknown, name: string, maxLength = 512): string {
45
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
46
+ throw new Error(`Google Vertex budget notification schema changed: ${name}`);
47
+ }
48
+ return value;
49
+ }
50
+
51
+ function requiredFiniteNumber(value: unknown, name: string): number {
52
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`Google Vertex budget notification schema changed: ${name}`);
53
+ return value;
54
+ }
55
+
56
+ function requiredTimestamp(value: unknown, name: string): number {
57
+ const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN;
58
+ if (Number.isNaN(parsed)) throw new Error(`Google Vertex budget notification schema changed: ${name} is not RFC 3339`);
59
+ return parsed;
60
+ }
61
+
62
+ function optionalFraction(value: unknown, name: string): number | undefined {
63
+ if (value === undefined) return undefined;
64
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`Google Vertex budget notification schema changed: ${name}`);
65
+ return value;
66
+ }
67
+
68
+ /**
69
+ * Parses one already-base64-decoded, JSON-parsed notification body plus its Pub/Sub message
70
+ * attributes (`billingAccountId`, `budgetId`, `schemaVersion`) and the message's own
71
+ * `publishTime`. Fails closed (throws) on any missing/mistyped field, matching the
72
+ * `classifyGoogleVertexFailure`/Anthropic header-parsing convention: an unrecognized shape must
73
+ * never be silently coerced into a plausible-looking budget number.
74
+ */
75
+ export function parseGoogleVertexBudgetNotification(
76
+ data: unknown,
77
+ attributes: { billingAccountId?: unknown; budgetId?: unknown; schemaVersion?: unknown },
78
+ publishedAt: number,
79
+ ): GoogleVertexBudgetNotification {
80
+ if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error("Google Vertex budget notification schema changed: data");
81
+ const input = data as Record<string, unknown>;
82
+ if (!Number.isFinite(publishedAt) || publishedAt < 0) throw new Error("Google Vertex budget notification schema changed: publishTime");
83
+
84
+ const budgetAmountType = requiredString(input["budgetAmountType"], "budgetAmountType");
85
+ if (!BUDGET_AMOUNT_TYPES.includes(budgetAmountType as GoogleVertexBudgetAmountType)) {
86
+ throw new Error("Google Vertex budget notification schema changed: budgetAmountType");
87
+ }
88
+
89
+ return {
90
+ billingAccountId: requiredString(attributes.billingAccountId, "billingAccountId"),
91
+ budgetId: requiredString(attributes.budgetId, "budgetId"),
92
+ schemaVersion: requiredString(attributes.schemaVersion, "schemaVersion"),
93
+ budgetDisplayName: requiredString(input["budgetDisplayName"], "budgetDisplayName", GOOGLE_VERTEX_BUDGET_DISPLAY_NAME_MAX_CHARACTERS),
94
+ costAmount: requiredFiniteNumber(input["costAmount"], "costAmount"),
95
+ costIntervalStart: requiredTimestamp(input["costIntervalStart"], "costIntervalStart"),
96
+ budgetAmount: requiredFiniteNumber(input["budgetAmount"], "budgetAmount"),
97
+ budgetAmountType: budgetAmountType as GoogleVertexBudgetAmountType,
98
+ currencyCode: requiredString(input["currencyCode"], "currencyCode", 8),
99
+ alertThresholdExceeded: optionalFraction(input["alertThresholdExceeded"], "alertThresholdExceeded"),
100
+ forecastThresholdExceeded: optionalFraction(input["forecastThresholdExceeded"], "forecastThresholdExceeded"),
101
+ publishedAt,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Real dollar figures from Google, not a fabricated fraction: `spend`/`cap` are the two numbers
107
+ * the notification actually carries, and `spend-fraction` is their honest quotient (which the
108
+ * BudgetWindow below separately clamps to 1 for policy purposes -- this raw metric intentionally
109
+ * is not clamped, so a genuine over-cap soft-quota period stays visible in the metrics history).
110
+ */
111
+ export function googleVertexBudgetMetrics(
112
+ notification: GoogleVertexBudgetNotification,
113
+ observedAt: number,
114
+ source: GoogleVertexMetricSource = "google-vertex",
115
+ ): MetricObservation[] {
116
+ const attributes: Record<string, unknown> = {
117
+ billingAccountId: notification.billingAccountId,
118
+ budgetId: notification.budgetId,
119
+ budgetDisplayName: notification.budgetDisplayName,
120
+ budgetAmountType: notification.budgetAmountType,
121
+ currencyCode: notification.currencyCode,
122
+ ...(notification.alertThresholdExceeded !== undefined ? { alertThresholdExceeded: notification.alertThresholdExceeded } : {}),
123
+ ...(notification.forecastThresholdExceeded !== undefined ? { forecastThresholdExceeded: notification.forecastThresholdExceeded } : {}),
124
+ };
125
+ const metrics: MetricObservation[] = [
126
+ { source, scope: "budget", metric: "spend", value: notification.costAmount, unit: "usd", observedAt, attributes },
127
+ { source, scope: "budget", metric: "cap", value: notification.budgetAmount, unit: "usd", observedAt, attributes },
128
+ ];
129
+ if (notification.budgetAmount > 0) {
130
+ metrics.push({ source, scope: "budget", metric: "spend-fraction", value: notification.costAmount / notification.budgetAmount, unit: "ratio", observedAt, attributes });
131
+ }
132
+ return metrics;
133
+ }
134
+
135
+ /**
136
+ * Cloud Billing's notification payload does not carry the budget's configured calendar period
137
+ * (month/quarter/year/custom); the Budget resource itself defaults to a monthly period when
138
+ * unset (see the Budget REST resource docs), and this is what Cloud Billing budgets default to
139
+ * and what the P&GE individual-project migration documents ("The $500 quota limit is a monthly
140
+ * limit"). Calendar periods reset "at 12 AM US and Canadian Pacific Time (UTC-8)" per Google's
141
+ * own documented wording -- a fixed offset, not DST-aware America/Los_Angeles -- so this mirrors
142
+ * that literal documented rule rather than a locale-aware guess.
143
+ */
144
+ const PACIFIC_FIXED_OFFSET_MS = 8 * 60 * 60 * MILLISECONDS_PER_SECOND;
145
+
146
+ function nextPacificCalendarMonthStart(epochMs: number): number {
147
+ const pacific = new Date(epochMs - PACIFIC_FIXED_OFFSET_MS);
148
+ const nextMonthStartPacific = Date.UTC(pacific.getUTCFullYear(), pacific.getUTCMonth() + 1, 1, 0, 0, 0, 0);
149
+ return nextMonthStartPacific + PACIFIC_FIXED_OFFSET_MS;
150
+ }
151
+
152
+ /**
153
+ * Builds the BudgetWindow the routing policy consumes. Returns null (no window, not a fabricated
154
+ * one) when `budgetAmount` isn't a usable positive cap. `usedFraction` is clamped to 1 for the
155
+ * policy-facing window even when real spend has exceeded a soft-quota cap (the P&GE rollout keeps
156
+ * serving requests past 100% during its soft-quota phase) -- clamping a known-to-be->=100% real
157
+ * number to the window's documented [0,1] invariant is not fabrication; the unclamped truth is
158
+ * still recorded by `googleVertexBudgetMetrics`'s `spend-fraction`.
159
+ */
160
+ export function googleVertexBudgetWindow(
161
+ notification: GoogleVertexBudgetNotification,
162
+ observedAt: number,
163
+ source: GoogleVertexMetricSource = "google-vertex",
164
+ ): BudgetWindow | null {
165
+ if (notification.budgetAmount <= 0) return null;
166
+ const resetsAt = nextPacificCalendarMonthStart(notification.costIntervalStart);
167
+ const windowSeconds = (resetsAt - notification.costIntervalStart) / MILLISECONDS_PER_SECOND;
168
+ if (windowSeconds <= 0) return null;
169
+ const usedFraction = Math.min(1, Math.max(0, notification.costAmount / notification.budgetAmount));
170
+ return {
171
+ id: `google-vertex-budget:${notification.budgetId}@${observedAt}`,
172
+ source,
173
+ scope: `budget:${notification.budgetId}`,
174
+ usedFraction,
175
+ windowSeconds,
176
+ resetsAt,
177
+ observedAt,
178
+ freshness: "fresh",
179
+ confidence: GOOGLE_VERTEX_BUDGET_CONFIDENCE,
180
+ };
181
+ }