@danypops/jittor 0.5.1 → 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 +62 -7
- package/docs/BENCHMARK_SOURCES.md +32 -0
- package/docs/OUTPUT_CHANNELS.md +31 -0
- package/docs/PROVIDER_RESEARCH.md +57 -0
- package/extension/src/benchmark-tui.ts +105 -0
- package/extension/src/footer.ts +68 -12
- package/extension/src/index.ts +263 -36
- package/extension/src/tui.ts +61 -9
- package/extension/src/usage.ts +165 -47
- package/package.json +5 -1
- package/src/adapters/metric-benchmark-store.ts +99 -0
- package/src/adapters/openrouter-benchmark-index-source.ts +94 -0
- package/src/adapters/openrouter-benchmark-source.ts +109 -0
- package/src/adapters/sqlite-metric-store.ts +43 -2
- package/src/cli.ts +660 -9
- package/src/client.ts +12 -44
- package/src/constants.ts +69 -5
- package/src/daemon.ts +65 -43
- package/src/db.ts +13 -30
- package/src/domain/benchmark.ts +264 -0
- package/src/domain/context-telemetry.ts +31 -0
- package/src/domain/metric.ts +46 -5
- package/src/domain/model-observation.ts +203 -0
- package/src/domain/model-ranking-service.ts +41 -0
- package/src/domain/model-ranking.ts +232 -0
- package/src/domain/task-cost.ts +70 -0
- package/src/domain/task-focus.ts +65 -0
- package/src/domain/usage.ts +134 -33
- package/src/log.ts +28 -0
- package/src/ports/benchmark-controller.ts +11 -0
- package/src/ports/benchmark-source.ts +7 -0
- package/src/ports/benchmark-store.ts +7 -0
- package/src/ports/metric-store.ts +30 -0
- package/src/ports/router-controller.ts +1 -0
- package/src/providers/anthropic-contracts.ts +127 -0
- 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 +116 -0
- package/src/providers/telemetry-sources.ts +35 -0
- package/src/router.ts +12 -0
- package/src/service.ts +132 -17
- package/src/state.ts +31 -57
- package/src/version.ts +2 -14
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { MetricObservation } from "../domain/metric.ts";
|
|
2
|
+
import { MILLISECONDS_PER_MINUTE, MILLISECONDS_PER_SECOND } from "../constants.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Google Vertex AI has no documented per-response rate-limit or remaining-quota header, and no
|
|
6
|
+
* personal polling endpoint Jittor could daemon-poll: quota lives in AWS/GCP-style account-level
|
|
7
|
+
* Service Usage configuration, and errors surface as a `google.rpc.Status` shape
|
|
8
|
+
* (`{error: {code, message, status, details[]}}`, `status` one of the canonical gRPC codes such as
|
|
9
|
+
* `RESOURCE_EXHAUSTED`, `PERMISSION_DENIED`, `UNAVAILABLE`) rather than a header Jittor can read
|
|
10
|
+
* before a request fails (verified against Google Cloud/Gemini API error reports fetched
|
|
11
|
+
* 2026-07-21; no `x-goog-quota-*` or equivalent response header is documented for Vertex
|
|
12
|
+
* generateContent). Jittor therefore does not fabricate a remaining-budget bar for this provider.
|
|
13
|
+
* What it can honestly observe is classified failure pressure: how often and what kind of
|
|
14
|
+
* capacity/auth/request failures Pi is seeing, from the same bounded, content-free
|
|
15
|
+
* `errorMessage` string Pi already exposes for every provider (see classifyCodexFailure for the
|
|
16
|
+
* established pattern this mirrors).
|
|
17
|
+
*/
|
|
18
|
+
export type GoogleVertexFailureKind =
|
|
19
|
+
| "quota"
|
|
20
|
+
| "authentication"
|
|
21
|
+
| "invalid-request"
|
|
22
|
+
| "overload"
|
|
23
|
+
| "transport"
|
|
24
|
+
| "unknown";
|
|
25
|
+
|
|
26
|
+
export interface GoogleVertexFailure {
|
|
27
|
+
kind: GoogleVertexFailureKind;
|
|
28
|
+
transient: boolean;
|
|
29
|
+
status?: string;
|
|
30
|
+
code?: number;
|
|
31
|
+
message?: string;
|
|
32
|
+
retryAfterMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GoogleVertexFailureMetadata {
|
|
36
|
+
status?: number;
|
|
37
|
+
retryAfter?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const GOOGLE_VERTEX_ERROR_MESSAGE_LIMIT = 160;
|
|
41
|
+
const GOOGLE_VERTEX_RETRY_AFTER_MAX_MS = 5 * MILLISECONDS_PER_MINUTE;
|
|
42
|
+
|
|
43
|
+
function matches(value: string, patterns: readonly string[]): boolean {
|
|
44
|
+
return patterns.some((pattern) => value.includes(pattern));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function retryAfterMs(value: string | undefined): number | undefined {
|
|
48
|
+
if (!value) return undefined;
|
|
49
|
+
const seconds = Number(value.trim().replace(/s$/i, ""));
|
|
50
|
+
if (!Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
51
|
+
return Math.min(GOOGLE_VERTEX_RETRY_AFTER_MAX_MS, Math.round(seconds * MILLISECONDS_PER_SECOND));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Extracts a `google.rpc.RetryInfo.retryDelay` (e.g. `"16s"`) if the stringified error embeds one. */
|
|
55
|
+
function embeddedRetryDelay(evidence: string): string | undefined {
|
|
56
|
+
const match = evidence.match(/"retrydelay"\s*:\s*"(\d+(?:\.\d+)?s)"/);
|
|
57
|
+
return match?.[1];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function classifyGoogleVertexFailure(value: unknown, metadata: GoogleVertexFailureMetadata = {}): GoogleVertexFailure {
|
|
61
|
+
const rawMessage = typeof value === "string" ? value : undefined;
|
|
62
|
+
const message = rawMessage?.slice(0, GOOGLE_VERTEX_ERROR_MESSAGE_LIMIT);
|
|
63
|
+
const evidence = (rawMessage ?? "").toLowerCase();
|
|
64
|
+
const retry = retryAfterMs(metadata.retryAfter) ?? retryAfterMs(embeddedRetryDelay(evidence));
|
|
65
|
+
const base = {
|
|
66
|
+
...(message ? { message } : {}),
|
|
67
|
+
...(metadata.status !== undefined ? { code: metadata.status } : {}),
|
|
68
|
+
...(retry !== undefined ? { retryAfterMs: retry } : {}),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (matches(evidence, ["resource_exhausted", "quota"]) || metadata.status === 429) {
|
|
72
|
+
return { kind: "quota", transient: true, status: "RESOURCE_EXHAUSTED", ...base };
|
|
73
|
+
}
|
|
74
|
+
if (matches(evidence, ["unauthenticated", "permission_denied"]) || metadata.status === 401 || metadata.status === 403) {
|
|
75
|
+
return { kind: "authentication", transient: false, status: matches(evidence, ["unauthenticated"]) ? "UNAUTHENTICATED" : "PERMISSION_DENIED", ...base };
|
|
76
|
+
}
|
|
77
|
+
if (matches(evidence, ["invalid_argument", "failed_precondition", "out_of_range"]) || metadata.status === 400 || metadata.status === 422) {
|
|
78
|
+
return { kind: "invalid-request", transient: false, status: "INVALID_ARGUMENT", ...base };
|
|
79
|
+
}
|
|
80
|
+
if (matches(evidence, ["unavailable", "internal", "aborted"]) || (metadata.status !== undefined && metadata.status >= 500 && metadata.status <= 599)) {
|
|
81
|
+
return { kind: "overload", transient: true, status: "UNAVAILABLE", ...base };
|
|
82
|
+
}
|
|
83
|
+
if (matches(evidence, ["deadline_exceeded", "timeout", "timed out", "network", "connection", "fetch failed", "cancelled"])) {
|
|
84
|
+
return { kind: "transport", transient: true, status: "DEADLINE_EXCEEDED", ...base };
|
|
85
|
+
}
|
|
86
|
+
return { kind: "unknown", transient: false, ...base };
|
|
87
|
+
}
|
|
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
|
+
|
|
101
|
+
/** A bounded failure-count observation; never a fabricated remaining-budget fraction. */
|
|
102
|
+
export function googleVertexFailureMetrics(failure: GoogleVertexFailure, observedAt: number, source: GoogleVertexMetricSource = "google-vertex"): MetricObservation[] {
|
|
103
|
+
return [{
|
|
104
|
+
source,
|
|
105
|
+
scope: "failure",
|
|
106
|
+
metric: failure.kind,
|
|
107
|
+
value: 1,
|
|
108
|
+
unit: "count",
|
|
109
|
+
observedAt,
|
|
110
|
+
attributes: {
|
|
111
|
+
transient: failure.transient,
|
|
112
|
+
...(failure.status ? { status: failure.status } : {}),
|
|
113
|
+
...(failure.code !== undefined ? { code: failure.code } : {}),
|
|
114
|
+
},
|
|
115
|
+
}];
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -120,6 +120,18 @@ export class JittorRouter implements RouterController {
|
|
|
120
120
|
return this.status();
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
applyModelRanking(candidates: Route[]): RouterStatus {
|
|
124
|
+
if (!Array.isArray(candidates) || candidates.length === 0) throw new Error("model ranking must contain candidates");
|
|
125
|
+
const ranked = candidates
|
|
126
|
+
.filter((candidate, index) => candidates.findIndex((other) => sameRoute(other, candidate)) === index)
|
|
127
|
+
.map((candidate) => this.availableRoutes.find((route) => sameRoute(route, candidate)))
|
|
128
|
+
.filter((route): route is Route => route !== undefined);
|
|
129
|
+
const current = ranked.find((route) => sameRoute(route, this.currentRoute));
|
|
130
|
+
if (!current) throw new Error("model ranking does not contain the current available route");
|
|
131
|
+
this.availableRoutes = [structuredClone(current), ...ranked.filter((route) => !sameRoute(route, current)).map((route) => structuredClone(route))];
|
|
132
|
+
return this.status();
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
private async runPoll(): Promise<TelemetryPollResult> {
|
|
124
136
|
const statuses = await Promise.all(this.options.sources.map(async (source): Promise<TelemetrySourceStatus> => {
|
|
125
137
|
try {
|