@danypops/jittor 0.6.0 → 0.7.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,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
+ }
@@ -0,0 +1,127 @@
1
+ import type { BudgetWindow } from "../policy.ts";
2
+ import type { MetricObservation } from "../domain/metric.ts";
3
+ import {
4
+ googleVertexBudgetMetrics,
5
+ googleVertexBudgetWindow,
6
+ parseGoogleVertexBudgetNotification,
7
+ type GoogleVertexBudgetNotification,
8
+ } from "./google-vertex-budget-contracts.ts";
9
+ import type { GoogleVertexMetricSource } from "./google-vertex-contracts.ts";
10
+ import type { GoogleAdcTokenProvider } from "./google-adc-auth.ts";
11
+ import { GOOGLE_VERTEX_BUDGET_MAX_MESSAGES_PER_PULL } from "../constants.ts";
12
+
13
+ export {
14
+ googleVertexBudgetMetrics,
15
+ googleVertexBudgetWindow,
16
+ parseGoogleVertexBudgetNotification,
17
+ type GoogleVertexBudgetAmountType,
18
+ type GoogleVertexBudgetNotification,
19
+ } from "./google-vertex-budget-contracts.ts";
20
+
21
+ const PUBSUB_BASE_URL = "https://pubsub.googleapis.com/v1";
22
+ export const GOOGLE_PUBSUB_READONLY_SCOPE = "https://www.googleapis.com/auth/pubsub";
23
+
24
+ export type GoogleVertexBudgetTransport = (request: Request) => Promise<Response>;
25
+
26
+ export interface GoogleVertexBudgetSnapshot {
27
+ notification: GoogleVertexBudgetNotification;
28
+ metrics: MetricObservation[];
29
+ window: BudgetWindow | null;
30
+ }
31
+
32
+ interface RawPubSubMessage {
33
+ ackId?: unknown;
34
+ message?: { data?: unknown; publishTime?: unknown; attributes?: Record<string, unknown> };
35
+ }
36
+
37
+ const SUBSCRIPTION_NAME_PATTERN = /^projects\/[^/]+\/subscriptions\/[^/]+$/;
38
+
39
+ /**
40
+ * Pulls (never pushes -- Jittor is a local loopback-only daemon with no public inbound endpoint)
41
+ * the individual GCP project's budget-notification Pub/Sub subscription, and turns Cloud
42
+ * Billing's own documented notification payload into Jittor's normalized metrics/BudgetWindow
43
+ * shape. One-time setup outside Jittor (create the topic, connect it to the budget, create a pull
44
+ * subscription) is required first -- see docs/PROVIDER_RESEARCH.md.
45
+ */
46
+ export class GoogleVertexBudgetTelemetryAdapter {
47
+ constructor(
48
+ private readonly subscription: string,
49
+ private readonly tokenProvider: GoogleAdcTokenProvider,
50
+ private readonly transport: GoogleVertexBudgetTransport = fetch,
51
+ private readonly source: GoogleVertexMetricSource = "google-vertex",
52
+ ) {
53
+ if (!SUBSCRIPTION_NAME_PATTERN.test(subscription)) {
54
+ throw new Error("Google Vertex budget subscription must be of the form projects/{project}/subscriptions/{subscription}");
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Pulls the pending notifications, acknowledges every message it received (Pub/Sub pull
60
+ * subscriptions redeliver un-acked messages forever, and Cloud Billing publishes multiple
61
+ * times per day regardless of whether Jittor is running -- an un-drained subscription would
62
+ * grow without bound), and returns the freshest successfully-parsed notification by the
63
+ * message's own `publishTime`. Throws (fail closed, matching every other Jittor provider's
64
+ * schema-drift contract) if any pulled message fails to parse, after acknowledging it so a
65
+ * single malformed message cannot wedge every future poll.
66
+ */
67
+ async pull(observedAt = Date.now()): Promise<GoogleVertexBudgetSnapshot | null> {
68
+ const token = await this.tokenProvider();
69
+ const pullResponse = await this.request(":pull", token, { maxMessages: GOOGLE_VERTEX_BUDGET_MAX_MESSAGES_PER_PULL });
70
+ const body = await pullResponse.json() as { receivedMessages?: RawPubSubMessage[] };
71
+ const received = Array.isArray(body.receivedMessages) ? body.receivedMessages : [];
72
+ if (received.length === 0) return null;
73
+
74
+ const ackIds = received.map((entry) => entry.ackId).filter((id): id is string => typeof id === "string" && id.length > 0);
75
+ let parseFailure: unknown;
76
+ const parsed: GoogleVertexBudgetNotification[] = [];
77
+ for (const entry of received) {
78
+ try {
79
+ parsed.push(this.parseMessage(entry));
80
+ } catch (error) {
81
+ parseFailure = error;
82
+ }
83
+ }
84
+ if (ackIds.length > 0) await this.acknowledge(token, ackIds);
85
+ if (parseFailure) throw parseFailure;
86
+ if (parsed.length === 0) return null;
87
+
88
+ const freshest = parsed.reduce((latest, candidate) => candidate.publishedAt > latest.publishedAt ? candidate : latest);
89
+ return {
90
+ notification: freshest,
91
+ metrics: googleVertexBudgetMetrics(freshest, observedAt, this.source),
92
+ window: googleVertexBudgetWindow(freshest, observedAt, this.source),
93
+ };
94
+ }
95
+
96
+ private parseMessage(entry: RawPubSubMessage): GoogleVertexBudgetNotification {
97
+ const data = entry.message?.data;
98
+ if (typeof data !== "string" || data.length === 0) throw new Error("Google Vertex budget notification schema changed: message.data");
99
+ const publishTime = entry.message?.publishTime;
100
+ if (typeof publishTime !== "string") throw new Error("Google Vertex budget notification schema changed: message.publishTime");
101
+ const publishedAt = Date.parse(publishTime);
102
+ if (Number.isNaN(publishedAt)) throw new Error("Google Vertex budget notification schema changed: message.publishTime is not RFC 3339");
103
+ let decoded: unknown;
104
+ try {
105
+ decoded = JSON.parse(Buffer.from(data, "base64").toString("utf8"));
106
+ } catch {
107
+ throw new Error("Google Vertex budget notification schema changed: message.data is not valid base64 JSON");
108
+ }
109
+ return parseGoogleVertexBudgetNotification(decoded, entry.message?.attributes ?? {}, publishedAt);
110
+ }
111
+
112
+ private async acknowledge(token: string, ackIds: string[]): Promise<void> {
113
+ // Best-effort: a failed ack only causes redelivery after the ack deadline, which the next
114
+ // poll will drain again; it must never fail the poll that already extracted real metrics.
115
+ await this.request(":acknowledge", token, { ackIds }).catch(() => undefined);
116
+ }
117
+
118
+ private async request(action: ":pull" | ":acknowledge", token: string, body: Record<string, unknown>): Promise<Response> {
119
+ const response = await this.transport(new Request(`${PUBSUB_BASE_URL}/${this.subscription}${action}`, {
120
+ method: "POST",
121
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
122
+ body: JSON.stringify(body),
123
+ }));
124
+ if (!response.ok) throw new Error(`Google Cloud Pub/Sub ${action.slice(1)} failed with HTTP ${response.status}`);
125
+ return response;
126
+ }
127
+ }
@@ -86,10 +86,22 @@ export function classifyGoogleVertexFailure(value: unknown, metadata: GoogleVert
86
86
  return { kind: "unknown", transient: false, ...base };
87
87
  }
88
88
 
89
+ /**
90
+ * Also reused for the third-party `anthropic-vertex` provider (Anthropic Claude models served
91
+ * through Google Vertex, e.g. via `@twogiants/pi-anthropic-vertex`): real-world reports show its
92
+ * 429s still carry GCP's own quota-exceeded message shape
93
+ * (`aiplatform.googleapis.com/online_prediction_requests_per_base_model`) even through Anthropic's
94
+ * own official Vertex SDK client, since Google's quota enforcement happens at the infra layer
95
+ * regardless of client wire format. `source` keeps its metrics distinguishable from Pi's native
96
+ * `google-vertex` provider (a different, unrelated Vertex route) and from direct Anthropic (a
97
+ * different account/quota pool at Anthropic's origin).
98
+ */
99
+ export type GoogleVertexMetricSource = "google-vertex" | "anthropic-vertex";
100
+
89
101
  /** A bounded failure-count observation; never a fabricated remaining-budget fraction. */
90
- export function googleVertexFailureMetrics(failure: GoogleVertexFailure, observedAt: number): MetricObservation[] {
102
+ export function googleVertexFailureMetrics(failure: GoogleVertexFailure, observedAt: number, source: GoogleVertexMetricSource = "google-vertex"): MetricObservation[] {
91
103
  return [{
92
- source: "google-vertex",
104
+ source,
93
105
  scope: "failure",
94
106
  metric: failure.kind,
95
107
  value: 1,
@@ -2,6 +2,9 @@ import type { BudgetWindow } from "../policy.ts";
2
2
  import type { TelemetryBatch, TelemetrySource } from "../ports/telemetry-source.ts";
3
3
  import { CodexSubscriptionTelemetryAdapter, loadCodexFileCredentials, type CodexRateLimitSnapshot, type CodexWindow, type CodexTransport } from "./codex.ts";
4
4
  import { OpenRouterTelemetryAdapter, type OpenRouterTransport } from "./openrouter.ts";
5
+ import { GoogleVertexBudgetTelemetryAdapter, type GoogleVertexBudgetTransport } from "./google-vertex-budget.ts";
6
+ import type { GoogleVertexMetricSource } from "./google-vertex-contracts.ts";
7
+ import type { GoogleAdcTokenProvider } from "./google-adc-auth.ts";
5
8
 
6
9
  function budgetWindow(
7
10
  limit: CodexRateLimitSnapshot,
@@ -67,3 +70,35 @@ export class OpenRouterTelemetrySource implements TelemetrySource {
67
70
  return { observedAt, metrics: snapshot.metrics, windows: [] };
68
71
  }
69
72
  }
73
+
74
+ /**
75
+ * Optional (never `required`): the one-time GCP setup (Pub/Sub topic + pull subscription
76
+ * connected to the individual project's budget) lives entirely outside Jittor, so a subscription
77
+ * that doesn't exist yet, or a project not yet migrated onto the individual-project model, must
78
+ * not block every other route the way a missing required source would.
79
+ */
80
+ export class GoogleVertexBudgetTelemetrySource implements TelemetrySource {
81
+ readonly id: string;
82
+ readonly provider = "google-vertex";
83
+ readonly required = false;
84
+
85
+ private readonly adapter: GoogleVertexBudgetTelemetryAdapter;
86
+
87
+ constructor(
88
+ subscription: string,
89
+ tokenProvider: GoogleAdcTokenProvider,
90
+ private readonly clock: () => number = Date.now,
91
+ transport: GoogleVertexBudgetTransport = fetch,
92
+ source: GoogleVertexMetricSource = "google-vertex",
93
+ ) {
94
+ this.id = `google-vertex-budget:${source}`;
95
+ this.adapter = new GoogleVertexBudgetTelemetryAdapter(subscription, tokenProvider, transport, source);
96
+ }
97
+
98
+ async poll(): Promise<TelemetryBatch> {
99
+ const observedAt = this.clock();
100
+ const snapshot = await this.adapter.pull(observedAt);
101
+ if (!snapshot) return { observedAt, metrics: [], windows: [] };
102
+ return { observedAt, metrics: snapshot.metrics, windows: snapshot.window ? [snapshot.window] : [] };
103
+ }
104
+ }