@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.
- package/README.md +15 -4
- package/docs/PROVIDER_RESEARCH.md +21 -2
- package/extension/src/benchmark-tui.ts +6 -4
- package/extension/src/footer.ts +38 -22
- package/extension/src/index.ts +73 -9
- package/extension/src/tui.ts +29 -2
- package/extension/src/usage.ts +24 -15
- package/package.json +5 -1
- package/src/adapters/openrouter-benchmark-index-source.ts +2 -1
- package/src/adapters/sqlite-metric-store.ts +43 -2
- package/src/cli.ts +139 -10
- package/src/client.ts +12 -44
- package/src/constants.ts +31 -4
- package/src/daemon.ts +47 -46
- package/src/db.ts +13 -30
- package/src/domain/model-observation.ts +41 -19
- package/src/domain/model-ranking-service.ts +3 -2
- package/src/domain/model-ranking.ts +31 -12
- package/src/domain/task-cost.ts +70 -0
- package/src/domain/task-focus.ts +65 -0
- package/src/domain/usage.ts +80 -56
- package/src/log.ts +28 -0
- package/src/ports/metric-store.ts +30 -0
- package/src/providers/anthropic-contracts.ts +22 -11
- package/src/providers/google-adc-auth.ts +63 -0
- package/src/providers/google-vertex-budget-contracts.ts +181 -0
- package/src/providers/google-vertex-budget.ts +127 -0
- package/src/providers/google-vertex-contracts.ts +14 -2
- package/src/providers/telemetry-sources.ts +35 -0
- package/src/service.ts +80 -22
- package/src/state.ts +31 -57
- package/src/version.ts +2 -14
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|