@rawdash/connector-vertex-ai 0.22.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/dist/index.js ADDED
@@ -0,0 +1,1080 @@
1
+ // ../gcp-shared/dist/index.js
2
+ import { z } from "zod";
3
+ import { z as z2 } from "zod";
4
+ import { z as z3 } from "zod";
5
+ var serviceAccountKeySchema = z.object({
6
+ client_email: z.string().min(1),
7
+ private_key: z.string().min(1),
8
+ token_uri: z.string().url().optional(),
9
+ project_id: z.string().optional()
10
+ });
11
+ var tokenResponseSchema = z.object({
12
+ access_token: z.string().min(1),
13
+ expires_in: z.number().int().positive().optional()
14
+ });
15
+ function base64urlFromBytes(bytes) {
16
+ let binary = "";
17
+ for (let i = 0; i < bytes.length; i++) {
18
+ binary += String.fromCharCode(bytes[i]);
19
+ }
20
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
21
+ }
22
+ function base64urlFromString(str) {
23
+ return base64urlFromBytes(new TextEncoder().encode(str));
24
+ }
25
+ async function signRS256JWT(payload, privateKeyPem) {
26
+ const header = { alg: "RS256", typ: "JWT" };
27
+ const headerB64 = base64urlFromString(JSON.stringify(header));
28
+ const payloadB64 = base64urlFromString(JSON.stringify(payload));
29
+ const signingInput = `${headerB64}.${payloadB64}`;
30
+ const pemContent = privateKeyPem.replace(/-----BEGIN PRIVATE KEY-----/g, "").replace(/-----END PRIVATE KEY-----/g, "").replace(/\s/g, "");
31
+ const der = Uint8Array.from(atob(pemContent), (c) => c.charCodeAt(0));
32
+ const key = await globalThis.crypto.subtle.importKey(
33
+ "pkcs8",
34
+ der,
35
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
36
+ false,
37
+ ["sign"]
38
+ );
39
+ const signature = await globalThis.crypto.subtle.sign(
40
+ "RSASSA-PKCS1-v1_5",
41
+ key,
42
+ new TextEncoder().encode(signingInput)
43
+ );
44
+ return `${signingInput}.${base64urlFromBytes(new Uint8Array(signature))}`;
45
+ }
46
+ function parseServiceAccountJson(value) {
47
+ const trimmed = value.trim();
48
+ if (trimmed.startsWith("{")) {
49
+ return serviceAccountKeySchema.parse(JSON.parse(trimmed));
50
+ }
51
+ const binary = atob(trimmed);
52
+ const bytes = new Uint8Array(binary.length);
53
+ for (let i = 0; i < binary.length; i++) {
54
+ bytes[i] = binary.charCodeAt(i);
55
+ }
56
+ const decoded = new TextDecoder().decode(bytes);
57
+ return serviceAccountKeySchema.parse(JSON.parse(decoded));
58
+ }
59
+ async function buildServiceAccountJwt(serviceAccountJson, scope) {
60
+ const sa = parseServiceAccountJson(serviceAccountJson);
61
+ const now = Math.floor(Date.now() / 1e3);
62
+ const jwt = await signRS256JWT(
63
+ {
64
+ iss: sa.client_email,
65
+ scope,
66
+ aud: sa.token_uri ?? "https://oauth2.googleapis.com/token",
67
+ exp: now + 3600,
68
+ iat: now
69
+ },
70
+ sa.private_key
71
+ );
72
+ const body = new URLSearchParams({
73
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
74
+ assertion: jwt
75
+ }).toString();
76
+ return {
77
+ url: sa.token_uri ?? "https://oauth2.googleapis.com/token",
78
+ body
79
+ };
80
+ }
81
+ function buildRefreshTokenGrant(credentials) {
82
+ const body = new URLSearchParams({
83
+ grant_type: "refresh_token",
84
+ refresh_token: credentials.refreshToken,
85
+ client_id: credentials.clientId,
86
+ client_secret: credentials.clientSecret
87
+ }).toString();
88
+ return { url: "https://oauth2.googleapis.com/token", body };
89
+ }
90
+ var gcpAuthConfigShape = {
91
+ serviceAccountJson: z2.object({ $secret: z2.string().trim().min(1) }).meta({
92
+ label: "Service Account JSON",
93
+ description: "Contents of the JSON key file for a Google service account with the role required by this connector. Create one at Google Cloud -> IAM & Admin -> Service Accounts and store the JSON as a secret.",
94
+ secret: true
95
+ })
96
+ };
97
+ var HttpClientError = class extends Error {
98
+ response;
99
+ constructor(message, response) {
100
+ super(message);
101
+ this.name = new.target.name;
102
+ this.response = response;
103
+ }
104
+ };
105
+ var AuthError = class extends HttpClientError {
106
+ kind = "auth";
107
+ };
108
+ var HTTP_CLIENT_VERSION = "0.0.0";
109
+ var DEFAULT_USER_AGENT = `rawdash-connector/${HTTP_CLIENT_VERSION} (+https://rawdash.dev)`;
110
+ function parseEpoch(value, unit) {
111
+ if (value === null || value === void 0) {
112
+ return null;
113
+ }
114
+ if (unit === "iso") {
115
+ if (typeof value !== "string") {
116
+ return null;
117
+ }
118
+ const ms = new Date(value).getTime();
119
+ return Number.isFinite(ms) ? ms : null;
120
+ }
121
+ if (typeof value === "string" && value.trim() === "") {
122
+ return null;
123
+ }
124
+ const n = typeof value === "number" ? value : Number(value);
125
+ if (!Number.isFinite(n)) {
126
+ return null;
127
+ }
128
+ const result = unit === "s" ? n * 1e3 : n;
129
+ return Number.isFinite(result) ? result : null;
130
+ }
131
+ var BQ_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
132
+ var BQ_DATASET_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
133
+ var BQ_API_BASE = "https://bigquery.googleapis.com/bigquery/v2";
134
+ var BQ_READONLY_SCOPE = "https://www.googleapis.com/auth/bigquery.readonly";
135
+ var BQ_PAGE_SIZE = 1e4;
136
+ var BQ_QUERY_TIMEOUT_MS = 3e4;
137
+ var bqQueryResponseSchema = z3.object({
138
+ jobComplete: z3.boolean().optional(),
139
+ schema: z3.object({
140
+ fields: z3.array(z3.object({ name: z3.string(), type: z3.string() }))
141
+ }).optional(),
142
+ rows: z3.array(
143
+ z3.object({
144
+ f: z3.array(z3.object({ v: z3.string().nullable().optional() }))
145
+ })
146
+ ).optional(),
147
+ pageToken: z3.string().optional(),
148
+ jobReference: z3.object({
149
+ projectId: z3.string(),
150
+ jobId: z3.string(),
151
+ location: z3.string().optional()
152
+ }).optional()
153
+ });
154
+ function buildBigQueryPageRequest(opts) {
155
+ const pageSize = opts.pageSize ?? BQ_PAGE_SIZE;
156
+ const timeoutMs = opts.timeoutMs ?? BQ_QUERY_TIMEOUT_MS;
157
+ if (opts.pageToken === void 0) {
158
+ const url2 = `${BQ_API_BASE}/projects/${encodeURIComponent(
159
+ opts.projectId
160
+ )}/queries`;
161
+ const body = {
162
+ query: opts.sql,
163
+ useLegacySql: false,
164
+ maxResults: pageSize,
165
+ timeoutMs
166
+ };
167
+ if (opts.location !== void 0) {
168
+ body["location"] = opts.location;
169
+ }
170
+ return { method: "POST", url: url2, body: JSON.stringify(body) };
171
+ }
172
+ if (opts.jobReference === void 0) {
173
+ throw new Error(
174
+ "cannot fetch the next page of BigQuery results without a jobReference"
175
+ );
176
+ }
177
+ const params = new URLSearchParams({
178
+ pageToken: opts.pageToken,
179
+ maxResults: String(pageSize),
180
+ timeoutMs: String(timeoutMs)
181
+ });
182
+ const location = opts.jobReference.location ?? opts.location;
183
+ if (location !== void 0) {
184
+ params.set("location", location);
185
+ }
186
+ const url = `${BQ_API_BASE}/projects/${encodeURIComponent(
187
+ opts.jobReference.projectId
188
+ )}/queries/${encodeURIComponent(opts.jobReference.jobId)}?${params.toString()}`;
189
+ return { method: "GET", url };
190
+ }
191
+ async function collectBigQueryPages(opts) {
192
+ const rows = [];
193
+ let pageToken;
194
+ let jobReference;
195
+ let page = 0;
196
+ const phaseStart = Date.now();
197
+ do {
198
+ if (opts.signal?.aborted) {
199
+ return { rows, aborted: true };
200
+ }
201
+ const request = buildBigQueryPageRequest({
202
+ projectId: opts.projectId,
203
+ sql: opts.sql,
204
+ pageToken,
205
+ jobReference,
206
+ location: opts.location,
207
+ pageSize: opts.pageSize
208
+ });
209
+ let response;
210
+ try {
211
+ response = await opts.fetchPage(request, opts.signal);
212
+ } catch (err) {
213
+ opts.logger?.warn("fetch page failed", {
214
+ resource: opts.resource,
215
+ page: page + 1,
216
+ error: err instanceof Error ? err.message : String(err)
217
+ });
218
+ throw err;
219
+ }
220
+ if (response.jobComplete === false) {
221
+ throw new Error(opts.jobIncompleteMessage);
222
+ }
223
+ if (response.jobReference !== void 0) {
224
+ jobReference = response.jobReference;
225
+ }
226
+ const pageRows = opts.mapRows(response);
227
+ rows.push(...pageRows);
228
+ pageToken = typeof response.pageToken === "string" && response.pageToken.length > 0 ? response.pageToken : void 0;
229
+ page += 1;
230
+ opts.logger?.info("fetched page", {
231
+ resource: opts.resource,
232
+ page,
233
+ items: pageRows.length,
234
+ next: pageToken ?? null
235
+ });
236
+ } while (pageToken !== void 0);
237
+ opts.logger?.info("resource done", {
238
+ resource: opts.resource,
239
+ pages: page,
240
+ items: rows.length,
241
+ duration_ms: Date.now() - phaseStart
242
+ });
243
+ return { rows, aborted: false };
244
+ }
245
+ function indexBqFields(response) {
246
+ const fieldIndex = {};
247
+ (response.schema?.fields ?? []).forEach((field, idx) => {
248
+ fieldIndex[field.name] = idx;
249
+ });
250
+ return fieldIndex;
251
+ }
252
+ function readBqCell(cells, fieldIndex, name) {
253
+ const idx = fieldIndex[name];
254
+ if (idx === void 0) {
255
+ return null;
256
+ }
257
+ const raw = cells[idx]?.v;
258
+ if (raw === void 0 || raw === null) {
259
+ return null;
260
+ }
261
+ return raw;
262
+ }
263
+ function parseBqDateOrEpoch(value) {
264
+ const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
265
+ if (dateMatch) {
266
+ return Date.UTC(
267
+ Number(dateMatch[1]),
268
+ Number(dateMatch[2]) - 1,
269
+ Number(dateMatch[3])
270
+ );
271
+ }
272
+ return parseEpoch(value, "iso");
273
+ }
274
+ var GcpAccessTokenProvider = class {
275
+ constructor(opts) {
276
+ this.opts = opts;
277
+ }
278
+ opts;
279
+ cached = null;
280
+ async resolveGrant() {
281
+ const serviceAccountJson = this.opts.getServiceAccountJson();
282
+ if (serviceAccountJson) {
283
+ return buildServiceAccountJwt(serviceAccountJson, this.opts.scope);
284
+ }
285
+ const refreshTokenCredentials = this.opts.getRefreshTokenCredentials?.();
286
+ if (refreshTokenCredentials) {
287
+ return buildRefreshTokenGrant(refreshTokenCredentials);
288
+ }
289
+ throw new AuthError(
290
+ `${this.opts.connectorId}: missing serviceAccountJson or refresh-token credentials`
291
+ );
292
+ }
293
+ async getToken(signal) {
294
+ if (this.cached && Date.now() < this.cached.expiresAt) {
295
+ return this.cached.token;
296
+ }
297
+ const { url, body } = await this.resolveGrant();
298
+ const res = await this.opts.post(url, {
299
+ resource: "oauth_token",
300
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
301
+ body,
302
+ signal
303
+ });
304
+ const expiresIn = res.body.expires_in ?? 3600;
305
+ this.cached = {
306
+ token: res.body.access_token,
307
+ expiresAt: Date.now() + (expiresIn - 60) * 1e3
308
+ };
309
+ return this.cached.token;
310
+ }
311
+ };
312
+ var MS_PER_DAY = 864e5;
313
+ function pad2(n) {
314
+ return String(n).padStart(2, "0");
315
+ }
316
+ function toDateStr(ms) {
317
+ const d = new Date(ms);
318
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
319
+ }
320
+ function startOfUtcDay(ms) {
321
+ return Math.floor(ms / MS_PER_DAY) * MS_PER_DAY;
322
+ }
323
+
324
+ // ../../connector-shared/dist/index.js
325
+ var HTTP_CLIENT_VERSION2 = "0.0.0";
326
+ var DEFAULT_USER_AGENT2 = `rawdash-connector/${HTTP_CLIENT_VERSION2} (+https://rawdash.dev)`;
327
+ function connectorUserAgent(connectorId) {
328
+ return `rawdash-connector-${connectorId}/${HTTP_CLIENT_VERSION2} (+https://rawdash.dev)`;
329
+ }
330
+ function parseEpoch2(value, unit) {
331
+ if (value === null || value === void 0) {
332
+ return null;
333
+ }
334
+ if (unit === "iso") {
335
+ if (typeof value !== "string") {
336
+ return null;
337
+ }
338
+ const ms = new Date(value).getTime();
339
+ return Number.isFinite(ms) ? ms : null;
340
+ }
341
+ if (typeof value === "string" && value.trim() === "") {
342
+ return null;
343
+ }
344
+ const n = typeof value === "number" ? value : Number(value);
345
+ if (!Number.isFinite(n)) {
346
+ return null;
347
+ }
348
+ const result = unit === "s" ? n * 1e3 : n;
349
+ return Number.isFinite(result) ? result : null;
350
+ }
351
+
352
+ // src/vertex-ai.ts
353
+ import {
354
+ BaseConnector,
355
+ defineConfigFields,
356
+ defineConnectorDoc,
357
+ defineResources,
358
+ makeChunkedCursorGuard,
359
+ schemasFromResources
360
+ } from "@rawdash/core";
361
+ import { z as z4 } from "zod";
362
+ var configFields = defineConfigFields(
363
+ z4.object({
364
+ projectId: z4.string().regex(BQ_IDENT_RE, "projectId must be a valid GCP project id").meta({
365
+ label: "GCP project ID",
366
+ description: "Google Cloud project ID that hosts the Vertex AI workload. Cloud Monitoring metrics are read from this project.",
367
+ placeholder: "my-project-123"
368
+ }),
369
+ ...gcpAuthConfigShape,
370
+ bqProject: z4.string().regex(BQ_IDENT_RE, "bqProject must be a valid GCP project id").optional().meta({
371
+ label: "BigQuery project ID (optional)",
372
+ description: "Project that hosts the Cloud Billing -> BigQuery export. Required to sync the spend resource; omit to disable spend syncing.",
373
+ placeholder: "my-billing-project"
374
+ }),
375
+ bqDataset: z4.string().regex(
376
+ BQ_DATASET_RE,
377
+ "bqDataset must be a valid BigQuery dataset id (letters, digits, underscores; must start with a letter or underscore)"
378
+ ).optional().meta({
379
+ label: "BigQuery dataset (optional)",
380
+ description: "BigQuery dataset containing the Cloud Billing export tables (gcp_billing_export_v1_*). Required to sync the spend resource.",
381
+ placeholder: "billing_export"
382
+ }),
383
+ bqLocation: z4.string().min(1).optional().meta({
384
+ label: "BigQuery location (optional)",
385
+ description: "Region or multi-region of the billing dataset (e.g. US, EU, us-central1). Defaults to US when bqDataset is set.",
386
+ placeholder: "US"
387
+ }),
388
+ spendServiceFilter: z4.string().min(1).optional().meta({
389
+ label: "Spend service filter (optional)",
390
+ description: 'BigQuery LIKE pattern matched against service.description to scope spend rows to Vertex AI. Defaults to "Vertex AI%" which covers both "Vertex AI" and "Vertex AI Generative AI" services.',
391
+ placeholder: "Vertex AI%"
392
+ }),
393
+ lookbackDays: z4.number().int().positive().max(365).optional().meta({
394
+ label: "Backfill window (days)",
395
+ description: "How many days of history to pull on a full sync. Defaults to 30.",
396
+ placeholder: "30"
397
+ })
398
+ })
399
+ );
400
+ var doc = defineConnectorDoc({
401
+ displayName: "Google Cloud Vertex AI",
402
+ category: "engineering",
403
+ brandColor: "#4285F4",
404
+ tagline: "Sync daily Vertex AI model invocations, token counts, errors, and spend (Gemini and partner models) into a single dashboard view of GCP AI usage.",
405
+ vendor: {
406
+ name: "Google Cloud",
407
+ domain: "cloud.google.com",
408
+ apiDocs: "https://cloud.google.com/vertex-ai/docs/general/monitoring",
409
+ website: "https://cloud.google.com/vertex-ai"
410
+ },
411
+ auth: {
412
+ summary: "Authenticate against the Cloud Monitoring v3 API (and optionally BigQuery for spend) with a Google service account JSON key. The service account needs the Monitoring Viewer role on the project running Vertex AI. To sync spend, it additionally needs BigQuery Data Viewer on the billing dataset and BigQuery Job User on the billing project.",
413
+ setup: [
414
+ "Identify the GCP project running Vertex AI (it owns the publisher/online_serving metrics).",
415
+ "Create a service account at Google Cloud -> IAM & Admin -> Service Accounts in that project (or grant an existing one access).",
416
+ "Grant the service account the Monitoring Viewer role (roles/monitoring.viewer) on the project so it can read Vertex AI metrics.",
417
+ "To sync spend, enable the Cloud Billing -> BigQuery export (Billing -> Billing export -> BigQuery export). Then grant the service account roles/bigquery.dataViewer on the export dataset and roles/bigquery.jobUser on the bqProject.",
418
+ "Generate a JSON key for the service account and store its contents as a secret (e.g. GCP_SA_JSON).",
419
+ 'Reference the key from config as serviceAccountJson: secret("GCP_SA_JSON") and set projectId to the Vertex AI project. Set bqProject / bqDataset to enable the spend resource.'
420
+ ]
421
+ },
422
+ rateLimit: "Cloud Monitoring projects.timeSeries.list and BigQuery jobs.query are rate-limited per project; 429 / RESOURCE_EXHAUSTED responses are retried with backoff. Each sync issues at most three requests (invocations metric, tokens metric, optional BigQuery query).",
423
+ limitations: [
424
+ "Only the publisher (Gemini and partner online-serving) metric family is synced. Custom model deployments under aiplatform.googleapis.com/prediction/* are out of scope; query Cloud Monitoring directly via the gcp-monitoring connector if you need them.",
425
+ "Spend rows come from the Cloud Billing -> BigQuery export; the export must be configured manually in the GCP console and only days after the configuration date are present.",
426
+ "BigQuery cost rows are back-revised by GCP for several days; an incremental sync refetches a short trailing window to pick up corrections.",
427
+ "Each BigQuery query is billed against the bqProject; keep lookbackDays reasonable.",
428
+ "Daily aggregation only - sub-day granularity is intentionally not exposed for spend or invocation rollups."
429
+ ]
430
+ });
431
+ var vertexAiCredentials = {
432
+ serviceAccountJson: {
433
+ description: "Google service account JSON key (raw JSON or base64)",
434
+ auth: "required"
435
+ }
436
+ };
437
+ var int64String = z4.string().regex(/^-?\d+$/);
438
+ var isoTimestamp = z4.iso.datetime();
439
+ var pointValue = z4.object({
440
+ doubleValue: z4.number().optional(),
441
+ int64Value: int64String.optional(),
442
+ boolValue: z4.boolean().optional(),
443
+ stringValue: z4.string().optional(),
444
+ distributionValue: z4.unknown().optional()
445
+ }).refine(
446
+ (v) => v.doubleValue !== void 0 || v.int64Value !== void 0 || v.boolValue !== void 0 || v.stringValue !== void 0 || v.distributionValue !== void 0,
447
+ {
448
+ message: "point value must carry at least one supported value field"
449
+ }
450
+ );
451
+ var pointSchema = z4.object({
452
+ interval: z4.object({
453
+ startTime: isoTimestamp.optional(),
454
+ endTime: isoTimestamp
455
+ }),
456
+ value: pointValue
457
+ });
458
+ var timeSeriesSchema = z4.object({
459
+ metric: z4.object({
460
+ type: z4.string(),
461
+ labels: z4.record(z4.string(), z4.string()).optional()
462
+ }),
463
+ resource: z4.object({
464
+ type: z4.string().optional(),
465
+ labels: z4.record(z4.string(), z4.string()).optional()
466
+ }).optional(),
467
+ valueType: z4.string().optional(),
468
+ metricKind: z4.string().optional(),
469
+ points: z4.array(pointSchema).optional()
470
+ });
471
+ var listTimeSeriesResponseSchema = z4.object({
472
+ timeSeries: z4.array(timeSeriesSchema).optional(),
473
+ nextPageToken: z4.string().optional()
474
+ });
475
+ var INVOCATIONS_METRIC_NAME = "vertex_ai_invocations";
476
+ var ERRORS_METRIC_NAME = "vertex_ai_errors";
477
+ var TOKENS_METRIC_NAME = "vertex_ai_tokens";
478
+ var SPEND_METRIC_NAME = "vertex_ai_spend";
479
+ var INVOCATION_COUNT_TYPE = "aiplatform.googleapis.com/publisher/online_serving/model_invocation_count";
480
+ var TOKEN_COUNT_TYPE = "aiplatform.googleapis.com/publisher/online_serving/token_count";
481
+ var vertexAiResources = defineResources({
482
+ [INVOCATIONS_METRIC_NAME]: {
483
+ shape: "metric",
484
+ description: "Daily count of successful Vertex AI model invocations (HTTP 2xx) per (date, modelId). Sourced from the Cloud Monitoring metric `aiplatform.googleapis.com/publisher/online_serving/model_invocation_count`, aggregated to one sample per day with SUM.",
485
+ endpoint: "GET /v3/projects/{projectId}/timeSeries",
486
+ granularity: "daily",
487
+ notes: "On every sync the trailing `lookbackDays` window is rewritten idempotently. Non-2xx response codes flow to `vertex_ai_errors` instead.",
488
+ dimensions: [
489
+ {
490
+ name: "modelId",
491
+ description: "Vertex AI publisher model identifier, e.g. gemini-1.5-pro or text-bison."
492
+ },
493
+ {
494
+ name: "responseCode",
495
+ description: "Upstream HTTP response code reported by Vertex AI (always 2xx in this resource)."
496
+ }
497
+ ],
498
+ responses: {
499
+ oauth_token: tokenResponseSchema,
500
+ invocations: listTimeSeriesResponseSchema
501
+ }
502
+ },
503
+ [ERRORS_METRIC_NAME]: {
504
+ shape: "metric",
505
+ description: "Daily count of failed Vertex AI model invocations (non-2xx) per (date, modelId, errorType). Sourced from the same Cloud Monitoring API call as `vertex_ai_invocations`; rows with response_code outside 200-299 are routed here.",
506
+ endpoint: "GET /v3/projects/{projectId}/timeSeries (shared with vertex_ai_invocations)",
507
+ granularity: "daily",
508
+ notes: "errorType carries the upstream HTTP status (e.g. 400, 429, 500). Use it to slice quota errors (429) from request errors (4xx) and platform errors (5xx). The response schema is registered under `vertex_ai_invocations`.",
509
+ dimensions: [
510
+ {
511
+ name: "modelId",
512
+ description: "Vertex AI publisher model identifier."
513
+ },
514
+ {
515
+ name: "errorType",
516
+ description: "HTTP status code returned by Vertex AI (400, 401, 403, 429, 5xx, ...)."
517
+ }
518
+ ]
519
+ },
520
+ [TOKENS_METRIC_NAME]: {
521
+ shape: "metric",
522
+ description: "Daily Vertex AI token usage per (date, modelId, tokenType). Sourced from the Cloud Monitoring metric `aiplatform.googleapis.com/publisher/online_serving/token_count`. tokenType is either `input` (prompt) or `output` (completion).",
523
+ endpoint: "GET /v3/projects/{projectId}/timeSeries",
524
+ granularity: "daily",
525
+ notes: "Sum across both tokenType values to get total tokens; slice by tokenType to separate input from output cost drivers.",
526
+ dimensions: [
527
+ {
528
+ name: "modelId",
529
+ description: "Vertex AI publisher model identifier."
530
+ },
531
+ {
532
+ name: "tokenType",
533
+ description: "Either `input` (prompt tokens) or `output` (response tokens)."
534
+ }
535
+ ],
536
+ responses: {
537
+ tokens: listTimeSeriesResponseSchema
538
+ }
539
+ },
540
+ [SPEND_METRIC_NAME]: {
541
+ shape: "metric",
542
+ description: "Daily Vertex AI spend per (date, sku) sourced from the Cloud Billing -> BigQuery export. Skipped unless bqProject and bqDataset are configured.",
543
+ endpoint: "POST /bigquery/v2/projects/{bqProject}/queries",
544
+ unit: "USD",
545
+ granularity: "daily",
546
+ notes: 'The trailing 5 days are always refetched on incremental syncs to pick up GCP back-revisions. SKU describes the specific Vertex AI model and token type (e.g. "Gemini 1.5 Pro Online Inference - Input").',
547
+ dimensions: [
548
+ {
549
+ name: "sku",
550
+ description: 'GCP SKU description, e.g. "Gemini 1.5 Pro Online Inference - Input Tokens".'
551
+ },
552
+ {
553
+ name: "service",
554
+ description: 'GCP service description, typically "Vertex AI" or "Vertex AI Generative AI".'
555
+ },
556
+ {
557
+ name: "currency",
558
+ description: "Billing currency reported by GCP."
559
+ }
560
+ ],
561
+ responses: {
562
+ spend: bqQueryResponseSchema
563
+ }
564
+ }
565
+ });
566
+ var PHASE_ORDER = ["invocations", "tokens", "spend"];
567
+ var PHASE_TO_RESOURCES = {
568
+ invocations: [INVOCATIONS_METRIC_NAME, ERRORS_METRIC_NAME],
569
+ tokens: [TOKENS_METRIC_NAME],
570
+ spend: [SPEND_METRIC_NAME]
571
+ };
572
+ var isVertexAiCursor = makeChunkedCursorGuard(PHASE_ORDER);
573
+ var MONITORING_API_BASE = "https://monitoring.googleapis.com/v3";
574
+ var MONITORING_SCOPE = "https://www.googleapis.com/auth/monitoring.read";
575
+ var MONITORING_PAGE_SIZE = 1e3;
576
+ var DAILY_ALIGNMENT_SECONDS = 86400;
577
+ var DAILY_ALIGNMENT = `${DAILY_ALIGNMENT_SECONDS}s`;
578
+ var DEFAULT_LOOKBACK_DAYS = 30;
579
+ var INCREMENTAL_LOOKBACK_DAYS = 5;
580
+ var DEFAULT_SPEND_SERVICE_FILTER = "Vertex AI%";
581
+ var id = "vertex-ai";
582
+ var cost = {
583
+ recommendedInterval: "1 day",
584
+ minInterval: "1 hour",
585
+ perSync: "2 Cloud Monitoring requests, plus 1 BigQuery query when bqProject/bqDataset are set",
586
+ warning: "Each BigQuery spend query is billed against the bqProject. Prefer once-a-day syncs unless you need fresher invocation counts."
587
+ };
588
+ var VertexAiConnector = class _VertexAiConnector extends BaseConnector {
589
+ static id = id;
590
+ static resources = vertexAiResources;
591
+ static schemas = schemasFromResources(vertexAiResources);
592
+ static cost = cost;
593
+ static create(input, ctx) {
594
+ const parsed = configFields.parse(input);
595
+ return new _VertexAiConnector(
596
+ {
597
+ projectId: parsed.projectId,
598
+ bqProject: parsed.bqProject,
599
+ bqDataset: parsed.bqDataset,
600
+ bqLocation: parsed.bqLocation,
601
+ spendServiceFilter: parsed.spendServiceFilter,
602
+ lookbackDays: parsed.lookbackDays
603
+ },
604
+ { serviceAccountJson: parsed.serviceAccountJson },
605
+ ctx
606
+ );
607
+ }
608
+ id = id;
609
+ credentials = vertexAiCredentials;
610
+ monitoringTokenProvider;
611
+ bigQueryTokenProvider;
612
+ getMonitoringToken(signal) {
613
+ this.monitoringTokenProvider ??= new GcpAccessTokenProvider({
614
+ connectorId: this.id,
615
+ scope: MONITORING_SCOPE,
616
+ getServiceAccountJson: () => this.creds.serviceAccountJson,
617
+ post: (url, opts) => this.post(url, opts)
618
+ });
619
+ return this.monitoringTokenProvider.getToken(signal);
620
+ }
621
+ getBigQueryToken(signal) {
622
+ this.bigQueryTokenProvider ??= new GcpAccessTokenProvider({
623
+ connectorId: this.id,
624
+ scope: BQ_READONLY_SCOPE,
625
+ getServiceAccountJson: () => this.creds.serviceAccountJson,
626
+ post: (url, opts) => this.post(url, opts)
627
+ });
628
+ return this.bigQueryTokenProvider.getToken(signal);
629
+ }
630
+ async listTimeSeries(metricType, groupByFields, startMs, endMs, pageToken, resource, signal) {
631
+ const params = new URLSearchParams();
632
+ params.set("filter", `metric.type = "${metricType}"`);
633
+ params.set("interval.startTime", new Date(startMs).toISOString());
634
+ params.set("interval.endTime", new Date(endMs).toISOString());
635
+ params.set("aggregation.alignmentPeriod", DAILY_ALIGNMENT);
636
+ params.set("aggregation.perSeriesAligner", "ALIGN_SUM");
637
+ params.set("aggregation.crossSeriesReducer", "REDUCE_SUM");
638
+ for (const field of groupByFields) {
639
+ params.append("aggregation.groupByFields", field);
640
+ }
641
+ params.set("pageSize", String(MONITORING_PAGE_SIZE));
642
+ if (pageToken !== void 0) {
643
+ params.set("pageToken", pageToken);
644
+ }
645
+ const url = `${MONITORING_API_BASE}/projects/${encodeURIComponent(
646
+ this.settings.projectId
647
+ )}/timeSeries?${params.toString()}`;
648
+ const accessToken = await this.getMonitoringToken(signal);
649
+ const res = await this.get(
650
+ url,
651
+ {
652
+ resource,
653
+ headers: {
654
+ Authorization: `Bearer ${accessToken}`,
655
+ "User-Agent": connectorUserAgent(this.id)
656
+ },
657
+ signal
658
+ }
659
+ );
660
+ return res.body;
661
+ }
662
+ async fetchBigQueryPage(request, signal) {
663
+ const accessToken = await this.getBigQueryToken(signal);
664
+ const headers = {
665
+ Authorization: `Bearer ${accessToken}`,
666
+ "Content-Type": "application/json",
667
+ "User-Agent": connectorUserAgent(this.id)
668
+ };
669
+ if (request.method === "POST") {
670
+ const res2 = await this.post(request.url, {
671
+ resource: "spend",
672
+ headers,
673
+ body: request.body,
674
+ signal
675
+ });
676
+ return res2.body;
677
+ }
678
+ const res = await this.get(request.url, {
679
+ resource: "spend",
680
+ headers,
681
+ signal
682
+ });
683
+ return res.body;
684
+ }
685
+ async syncInvocations(storage, window, requestedResources, signal) {
686
+ const wantInvocations = !requestedResources || requestedResources.size === 0 || requestedResources.has(INVOCATIONS_METRIC_NAME);
687
+ const wantErrors = !requestedResources || requestedResources.size === 0 || requestedResources.has(ERRORS_METRIC_NAME);
688
+ if (!wantInvocations && !wantErrors) {
689
+ return;
690
+ }
691
+ const invocations = [];
692
+ const errors = [];
693
+ let pageToken;
694
+ let page = 0;
695
+ const phaseStart = Date.now();
696
+ do {
697
+ if (signal?.aborted) {
698
+ return;
699
+ }
700
+ let response;
701
+ try {
702
+ response = await this.listTimeSeries(
703
+ INVOCATION_COUNT_TYPE,
704
+ ["metric.labels.model_user_id", "metric.labels.response_code"],
705
+ window.startMs,
706
+ window.endMs,
707
+ pageToken,
708
+ "invocations",
709
+ signal
710
+ );
711
+ } catch (err) {
712
+ this.logger.warn("fetch page failed", {
713
+ resource: "invocations",
714
+ page: page + 1,
715
+ error: err instanceof Error ? err.message : String(err)
716
+ });
717
+ throw err;
718
+ }
719
+ const series = response.timeSeries ?? [];
720
+ let pageItems = 0;
721
+ for (const ts of series) {
722
+ const modelId = ts.metric.labels?.["model_user_id"] ?? null;
723
+ const responseCode = ts.metric.labels?.["response_code"] ?? "";
724
+ const isError = !isSuccessCode(responseCode);
725
+ for (const point of ts.points ?? []) {
726
+ const sample = pointToCountSample(modelId, responseCode, point);
727
+ if (sample === null) {
728
+ continue;
729
+ }
730
+ if (isError) {
731
+ errors.push({
732
+ name: ERRORS_METRIC_NAME,
733
+ ts: sample.ts,
734
+ value: sample.value,
735
+ attributes: {
736
+ modelId: sample.attributes["modelId"] ?? null,
737
+ errorType: responseCode
738
+ }
739
+ });
740
+ } else {
741
+ invocations.push({
742
+ name: INVOCATIONS_METRIC_NAME,
743
+ ts: sample.ts,
744
+ value: sample.value,
745
+ attributes: sample.attributes
746
+ });
747
+ }
748
+ pageItems += 1;
749
+ }
750
+ }
751
+ pageToken = nextPageTokenOrUndefined(response.nextPageToken);
752
+ page += 1;
753
+ this.logger.info("fetched page", {
754
+ resource: "invocations",
755
+ page,
756
+ items: pageItems,
757
+ next: pageToken ?? null
758
+ });
759
+ } while (pageToken !== void 0);
760
+ if (wantInvocations) {
761
+ await storage.metrics(invocations, { names: [INVOCATIONS_METRIC_NAME] });
762
+ }
763
+ if (wantErrors) {
764
+ await storage.metrics(errors, { names: [ERRORS_METRIC_NAME] });
765
+ }
766
+ this.logger.info("resource done", {
767
+ resource: "invocations",
768
+ pages: page,
769
+ items: invocations.length + errors.length,
770
+ duration_ms: Date.now() - phaseStart
771
+ });
772
+ }
773
+ async syncTokens(storage, window, signal) {
774
+ const samples = [];
775
+ let pageToken;
776
+ let page = 0;
777
+ const phaseStart = Date.now();
778
+ do {
779
+ if (signal?.aborted) {
780
+ return;
781
+ }
782
+ let response;
783
+ try {
784
+ response = await this.listTimeSeries(
785
+ TOKEN_COUNT_TYPE,
786
+ ["metric.labels.model_user_id", "metric.labels.type"],
787
+ window.startMs,
788
+ window.endMs,
789
+ pageToken,
790
+ "tokens",
791
+ signal
792
+ );
793
+ } catch (err) {
794
+ this.logger.warn("fetch page failed", {
795
+ resource: "tokens",
796
+ page: page + 1,
797
+ error: err instanceof Error ? err.message : String(err)
798
+ });
799
+ throw err;
800
+ }
801
+ const series = response.timeSeries ?? [];
802
+ let pageItems = 0;
803
+ for (const ts of series) {
804
+ const modelId = ts.metric.labels?.["model_user_id"] ?? null;
805
+ const tokenType = ts.metric.labels?.["type"] ?? ts.metric.labels?.["token_type"] ?? "";
806
+ for (const point of ts.points ?? []) {
807
+ const sample = pointToTokenSample(modelId, tokenType, point);
808
+ if (sample === null) {
809
+ continue;
810
+ }
811
+ samples.push(sample);
812
+ pageItems += 1;
813
+ }
814
+ }
815
+ pageToken = nextPageTokenOrUndefined(response.nextPageToken);
816
+ page += 1;
817
+ this.logger.info("fetched page", {
818
+ resource: "tokens",
819
+ page,
820
+ items: pageItems,
821
+ next: pageToken ?? null
822
+ });
823
+ } while (pageToken !== void 0);
824
+ await storage.metrics(samples, { names: [TOKENS_METRIC_NAME] });
825
+ this.logger.info("resource done", {
826
+ resource: "tokens",
827
+ pages: page,
828
+ items: samples.length,
829
+ duration_ms: Date.now() - phaseStart
830
+ });
831
+ }
832
+ async syncSpend(storage, window, signal) {
833
+ if (this.settings.bqProject === void 0 || this.settings.bqDataset === void 0) {
834
+ this.logger.info("resource done", {
835
+ resource: "spend",
836
+ pages: 0,
837
+ items: 0,
838
+ duration_ms: 0,
839
+ skipped: "bqProject or bqDataset not configured"
840
+ });
841
+ return;
842
+ }
843
+ const sql = buildVertexSpendSql({
844
+ bqProject: this.settings.bqProject,
845
+ bqDataset: this.settings.bqDataset,
846
+ startDate: window.startDate,
847
+ endDate: window.endDate,
848
+ serviceFilter: this.settings.spendServiceFilter ?? DEFAULT_SPEND_SERVICE_FILTER
849
+ });
850
+ const { rows: samples, aborted } = await collectBigQueryPages(
851
+ {
852
+ projectId: this.settings.bqProject,
853
+ sql,
854
+ resource: "spend",
855
+ location: this.settings.bqLocation,
856
+ signal,
857
+ logger: this.logger,
858
+ mapRows: (response) => buildSpendSamplesFromBqResponse(response),
859
+ jobIncompleteMessage: `${this.id}: BigQuery spend query did not complete within the synchronous timeout (jobComplete=false). Lower lookbackDays so the query finishes faster.`,
860
+ fetchPage: (request, sig) => this.fetchBigQueryPage(request, sig)
861
+ }
862
+ );
863
+ if (aborted) {
864
+ return;
865
+ }
866
+ await storage.metrics(samples, { names: [SPEND_METRIC_NAME] });
867
+ }
868
+ async sync(options, storage, signal) {
869
+ const lookbackDays = this.settings.lookbackDays ?? DEFAULT_LOOKBACK_DAYS;
870
+ const monitoringWindow = getMonitoringWindow(options, lookbackDays);
871
+ const spendWindow = getSpendWindow(options, lookbackDays);
872
+ const cursor = isVertexAiCursor(options.cursor) ? options.cursor : void 0;
873
+ const resumeIdx = cursor ? PHASE_ORDER.indexOf(cursor.phase) : 0;
874
+ const startIdx = resumeIdx >= 0 ? resumeIdx : 0;
875
+ for (let i = startIdx; i < PHASE_ORDER.length; i++) {
876
+ const phase = PHASE_ORDER[i];
877
+ if (signal?.aborted) {
878
+ return { done: false, cursor: { phase, page: null } };
879
+ }
880
+ if (options.resources && options.resources.size > 0 && !PHASE_TO_RESOURCES[phase].some((r) => options.resources.has(r))) {
881
+ continue;
882
+ }
883
+ try {
884
+ if (phase === "invocations") {
885
+ await this.syncInvocations(
886
+ storage,
887
+ monitoringWindow,
888
+ options.resources,
889
+ signal
890
+ );
891
+ } else if (phase === "tokens") {
892
+ await this.syncTokens(storage, monitoringWindow, signal);
893
+ } else {
894
+ await this.syncSpend(storage, spendWindow, signal);
895
+ }
896
+ } catch (err) {
897
+ if (signal?.aborted) {
898
+ return { done: false, cursor: { phase, page: null } };
899
+ }
900
+ throw err;
901
+ }
902
+ }
903
+ return { done: true };
904
+ }
905
+ };
906
+ function isSuccessCode(code) {
907
+ if (code.length === 0) {
908
+ return true;
909
+ }
910
+ const n = Number.parseInt(code, 10);
911
+ if (!Number.isFinite(n)) {
912
+ return false;
913
+ }
914
+ return n >= 200 && n < 300;
915
+ }
916
+ function nextPageTokenOrUndefined(token) {
917
+ if (typeof token === "string" && token.length > 0) {
918
+ return token;
919
+ }
920
+ return void 0;
921
+ }
922
+ function pointToCountSample(modelId, responseCode, point) {
923
+ const ts = parseEpoch2(point.interval.endTime, "iso");
924
+ if (ts === null) {
925
+ return null;
926
+ }
927
+ const value = extractScalarValue(point.value);
928
+ if (value === null) {
929
+ return null;
930
+ }
931
+ return {
932
+ ts,
933
+ value,
934
+ attributes: {
935
+ modelId: modelId ?? null,
936
+ responseCode
937
+ }
938
+ };
939
+ }
940
+ function pointToTokenSample(modelId, tokenType, point) {
941
+ const ts = parseEpoch2(point.interval.endTime, "iso");
942
+ if (ts === null) {
943
+ return null;
944
+ }
945
+ const value = extractScalarValue(point.value);
946
+ if (value === null) {
947
+ return null;
948
+ }
949
+ return {
950
+ name: TOKENS_METRIC_NAME,
951
+ ts,
952
+ value,
953
+ attributes: {
954
+ modelId: modelId ?? null,
955
+ tokenType
956
+ }
957
+ };
958
+ }
959
+ function extractScalarValue(v) {
960
+ if (v.doubleValue !== void 0 && Number.isFinite(v.doubleValue)) {
961
+ return v.doubleValue;
962
+ }
963
+ if (v.int64Value !== void 0) {
964
+ const n = Number(v.int64Value);
965
+ return Number.isSafeInteger(n) ? n : null;
966
+ }
967
+ if (v.boolValue !== void 0) {
968
+ return v.boolValue ? 1 : 0;
969
+ }
970
+ return null;
971
+ }
972
+ function getMonitoringWindow(options, lookbackDays, now = Date.now()) {
973
+ const endMs = startOfUtcDay(now) + MS_PER_DAY;
974
+ let days = lookbackDays;
975
+ if (options.mode === "latest") {
976
+ days = INCREMENTAL_LOOKBACK_DAYS;
977
+ } else if (options.since !== void 0) {
978
+ const sinceMs = parseEpoch2(options.since, "iso");
979
+ if (sinceMs !== null) {
980
+ const elapsed = Math.ceil((now - sinceMs) / MS_PER_DAY);
981
+ days = Math.min(
982
+ Math.max(elapsed + INCREMENTAL_LOOKBACK_DAYS, 1),
983
+ lookbackDays
984
+ );
985
+ }
986
+ }
987
+ return { startMs: endMs - days * MS_PER_DAY, endMs };
988
+ }
989
+ function getSpendWindow(options, lookbackDays, now = Date.now()) {
990
+ const endMs = startOfUtcDay(now) + MS_PER_DAY;
991
+ let days = lookbackDays;
992
+ if (options.mode === "latest") {
993
+ days = INCREMENTAL_LOOKBACK_DAYS;
994
+ } else if (options.since !== void 0) {
995
+ const sinceMs = parseEpoch2(options.since, "iso");
996
+ if (sinceMs !== null) {
997
+ const elapsed = Math.ceil((now - sinceMs) / MS_PER_DAY);
998
+ days = Math.min(
999
+ Math.max(elapsed + INCREMENTAL_LOOKBACK_DAYS, 1),
1000
+ lookbackDays
1001
+ );
1002
+ }
1003
+ }
1004
+ return {
1005
+ startDate: toDateStr(endMs - days * MS_PER_DAY),
1006
+ endDate: toDateStr(endMs)
1007
+ };
1008
+ }
1009
+ function buildVertexSpendSql(args) {
1010
+ const escapedFilter = args.serviceFilter.replace(/'/g, "\\'");
1011
+ const table = `\`${args.bqProject}.${args.bqDataset}.gcp_billing_export_v1_*\``;
1012
+ return [
1013
+ `SELECT DATE(usage_start_time) AS date,`,
1014
+ ` service.description AS service,`,
1015
+ ` sku.description AS sku,`,
1016
+ ` SUM(cost) AS cost,`,
1017
+ ` ANY_VALUE(currency) AS currency`,
1018
+ `FROM ${table}`,
1019
+ `WHERE DATE(usage_start_time) >= DATE('${args.startDate}')`,
1020
+ ` AND DATE(usage_start_time) < DATE('${args.endDate}')`,
1021
+ ` AND service.description LIKE '${escapedFilter}'`,
1022
+ `GROUP BY date, service, sku`,
1023
+ `ORDER BY date`
1024
+ ].join("\n");
1025
+ }
1026
+ function buildSpendSamplesFromBqResponse(response) {
1027
+ const fieldIndex = indexBqFields(response);
1028
+ const samples = [];
1029
+ for (const row of response.rows ?? []) {
1030
+ const dateValue = readBqCell(row.f, fieldIndex, "date");
1031
+ if (dateValue === null) {
1032
+ continue;
1033
+ }
1034
+ const ts = parseBqDateOrEpoch(dateValue);
1035
+ if (ts === null) {
1036
+ continue;
1037
+ }
1038
+ const costValue = readBqCell(row.f, fieldIndex, "cost");
1039
+ if (costValue === null) {
1040
+ continue;
1041
+ }
1042
+ const value = Number.parseFloat(costValue);
1043
+ if (!Number.isFinite(value)) {
1044
+ continue;
1045
+ }
1046
+ const attributes = {
1047
+ sku: readBqCell(row.f, fieldIndex, "sku") ?? null,
1048
+ service: readBqCell(row.f, fieldIndex, "service") ?? null
1049
+ };
1050
+ const currency = readBqCell(row.f, fieldIndex, "currency");
1051
+ if (currency !== null) {
1052
+ attributes["currency"] = currency;
1053
+ }
1054
+ samples.push({ name: SPEND_METRIC_NAME, ts, value, attributes });
1055
+ }
1056
+ return samples;
1057
+ }
1058
+
1059
+ // src/index.ts
1060
+ var index_default = VertexAiConnector;
1061
+ export {
1062
+ ERRORS_METRIC_NAME,
1063
+ INVOCATIONS_METRIC_NAME,
1064
+ SPEND_METRIC_NAME,
1065
+ TOKENS_METRIC_NAME,
1066
+ VertexAiConnector,
1067
+ buildSpendSamplesFromBqResponse,
1068
+ buildVertexSpendSql,
1069
+ configFields,
1070
+ cost,
1071
+ index_default as default,
1072
+ doc,
1073
+ getMonitoringWindow,
1074
+ getSpendWindow,
1075
+ id,
1076
+ pointToCountSample,
1077
+ pointToTokenSample,
1078
+ vertexAiResources as resources
1079
+ };
1080
+ //# sourceMappingURL=index.js.map