@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.
- package/README.md +18 -5
- package/docs/PROVIDER_RESEARCH.md +21 -2
- package/extension/src/benchmark-tui.ts +7 -5
- 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/openrouter-design-arena-source.ts +105 -0
- 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 +52 -47
- package/src/db.ts +13 -30
- package/src/domain/metric.ts +1 -1
- 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
package/src/domain/usage.ts
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const provider =
|
|
72
|
-
const model =
|
|
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
|
-
|
|
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.
|
|
90
|
-
end: index === window.bucketCount - 1 ? window.end : window.start + (index + 1) * window.
|
|
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
|
|
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 (
|
|
110
|
-
if (row.
|
|
111
|
-
const bucket = buckets[
|
|
112
|
-
const series = identity(row);
|
|
113
|
-
bucket.total += row.
|
|
114
|
-
bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.
|
|
115
|
-
breakdown[breakdownKey] += row.
|
|
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.
|
|
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:
|
|
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.
|
|
173
|
-
if (row.
|
|
174
|
-
const bucket = buckets[
|
|
175
|
-
const series = identity(row);
|
|
176
|
-
bucket.total += row.
|
|
177
|
-
bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.
|
|
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.
|
|
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
|
|
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
|
+
}
|