1import * as aiplatform from "@distilled.cloud/gcp/aiplatform_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { listLocations } from "./names.ts";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 toLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20import { waitForOperation } from "./operations.ts";
21import {
22 fingerprint,
23 hasAlchemyLabelMap,
24 lastSegment,
25 normalizeLocation,
26 parseResourceName,
27 specifiedEquals,
28 toPhysicalRfc1035,
29 userLabels,
30} from "./helpers.ts";
31import type { EncryptionSpec } from "./shared.ts";
32
33const MAX_NAME_LENGTH = 63;
34
35export type ClientConnectionConfig = {
36 /** Online prediction request timeout (e.g. `"5s"`). */
37 inferenceTimeout?: string;
38};
39
40export type PredictRequestResponseLoggingConfig = {
41 /** Enable request/response logging. */
42 enabled?: boolean;
43 /** Sampling rate in `(0, 1]`. */
44 samplingRate?: number;
45 /** BigQuery destination for logs. */
46 bigqueryDestination?: { outputUri?: string };
47};
48
49export type PrivateServiceConnectConfig = {
50 /** Expose the endpoint via Private Service Connect. */
51 enablePrivateServiceConnect?: boolean;
52 /** Projects allowed to create forwarding rules. */
53 projectAllowlist?: string[];
54};
55
56export type GenAiAdvancedFeaturesConfig = {
57 /** Enable native RAG on GenAI endpoints. */
58 ragConfig?: { enableRag?: boolean };
59};
60
61export type GdcConfig = {
62 /** Google Distributed Cloud zone for this endpoint. Immutable. */
63 zone?: string;
64};
65
66export type EndpointProps = {
67 /**
68 * Endpoint id (the `{endpoint}` segment of
69 * `projects/{project}/locations/{location}/endpoints/{endpoint}`).
70 * If omitted, a unique name is generated. Immutable — changing it
71 * replaces the endpoint.
72 */
73 endpointId?: string;
74 /**
75 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
76 * replaces the endpoint.
77 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
78 */
79 location?: string;
80 /**
81 * Display name. Defaults to the endpoint id when omitted.
82 */
83 displayName?: string;
84 /**
85 * Human-readable description.
86 */
87 description?: string;
88 /**
89 * User labels. Alchemy ownership labels are merged in automatically.
90 */
91 labels?: Record<string, string>;
92 /**
93 * VPC network to peer
94 * (`projects/{project}/global/networks/{network}`). Immutable.
95 */
96 network?: string;
97 /**
98 * Customer-managed encryption. Immutable — changing it replaces the
99 * endpoint.
100 */
101 encryptionSpec?: EncryptionSpec;
102 /**
103 * Serve this endpoint through a dedicated DNS.
104 * @default false
105 */
106 dedicatedEndpointEnabled?: boolean;
107 /**
108 * Traffic split from deployed-model id to percentage. Values must sum
109 * to 100, or the map must be empty.
110 */
111 trafficSplit?: Record<string, number>;
112 /**
113 * Online-prediction client settings (inference timeout).
114 */
115 clientConnectionConfig?: ClientConnectionConfig;
116 /**
117 * Request/response logging for online prediction.
118 */
119 predictRequestResponseLoggingConfig?: PredictRequestResponseLoggingConfig;
120 /**
121 * Private Service Connect. Mutually exclusive with `network`.
122 */
123 privateServiceConnectConfig?: PrivateServiceConnectConfig;
124 /**
125 * Deprecated PSC flag. Prefer `privateServiceConnectConfig`. Immutable.
126 */
127 enablePrivateServiceConnect?: boolean;
128 /**
129 * GenAI advanced features (native RAG).
130 */
131 genAiAdvancedFeaturesConfig?: GenAiAdvancedFeaturesConfig;
132 /**
133 * Google Distributed Cloud zone. Immutable.
134 */
135 gdcConfig?: GdcConfig;
136};
137
138export type Endpoint = Resource<
139 "GCP.AIPlatform.Endpoint",
140 EndpointProps,
141 {
142 /** Full resource name `projects/{project}/locations/{location}/endpoints/{endpoint}`. */
143 name: string;
144 /** Endpoint id (last path segment). */
145 endpointId: string;
146 /** Project id. */
147 project: string;
148 /** Location id. */
149 location: string;
150 /** Display name. */
151 displayName: string | undefined;
152 /** Description. */
153 description: string | undefined;
154 /** User labels (Alchemy ownership labels stripped). */
155 labels: Record<string, string>;
156 /** Peered VPC network, if any. */
157 network: string | undefined;
158 /** Customer-managed encryption key, if any. */
159 encryptionSpec: EncryptionSpec | undefined;
160 /** Whether a dedicated DNS is enabled. */
161 dedicatedEndpointEnabled: boolean;
162 /** Dedicated endpoint DNS, if provisioned. */
163 dedicatedEndpointDns: string | undefined;
164 /** Traffic split map. */
165 trafficSplit: Record<string, number>;
166 /** Client connection config. */
167 clientConnectionConfig: ClientConnectionConfig | undefined;
168 /** Request/response logging config. */
169 predictRequestResponseLoggingConfig:
170 | PredictRequestResponseLoggingConfig
171 | undefined;
172 /** Private Service Connect config. */
173 privateServiceConnectConfig: PrivateServiceConnectConfig | undefined;
174 /** Deprecated PSC flag. */
175 enablePrivateServiceConnect: boolean;
176 /** GenAI advanced features. */
177 genAiAdvancedFeaturesConfig: GenAiAdvancedFeaturesConfig | undefined;
178 /** GDC config. */
179 gdcConfig: GdcConfig | undefined;
180 /** Deployed model ids. */
181 deployedModelIds: string[];
182 /** Associated model-deployment monitoring job, if any. */
183 modelDeploymentMonitoringJob: string | undefined;
184 /** RFC3339 creation timestamp. */
185 createTime: string | undefined;
186 /** RFC3339 last-update timestamp. */
187 updateTime: string | undefined;
188 /** Server etag. */
189 etag: string | undefined;
190 },
191 never,
192 Providers
193>;
194
195/**
196 * A Vertex AI Endpoint for online prediction.
197 *
198 * Name, location, network, encryption, and GDC zone are identity —
199 * changing them replaces the endpoint. Display name, description, labels,
200 * traffic split, dedicated-endpoint flag, client connection, logging, and
201 * GenAI features update in place.
202 *
203 * ### Creating an Endpoint
204 * **Example:** Generated name
205 * ```typescript
206 * const endpoint = yield* GCP.AIPlatform.Endpoint("Predictor", {});
207 * ```
208 *
209 * **Example:** Named endpoint with labels
210 * ```typescript
211 * const endpoint = yield* GCP.AIPlatform.Endpoint("Predictor", {
212 * endpointId: "orders-predictor",
213 * displayName: "orders",
214 * labels: { env: "prod" },
215 * });
216 * ```
217 *
218 * ### Updating an Endpoint
219 * **Example:** Change display name and labels
220 * ```typescript
221 * // Same logical id, changed props: the engine updates it in place.
222 * const endpoint = yield* GCP.AIPlatform.Endpoint("Predictor", {
223 * displayName: "orders-v2",
224 * labels: { env: "prod", role: "predict" },
225 * });
226 * ```
227 *
228 * @resource
229 * @category AIPlatform
230 */
231export const Endpoint = Resource<Endpoint>("GCP.AIPlatform.Endpoint");
232
233export class EndpointNotResolved extends Data.TaggedError(
234 "GCP.AIPlatform.EndpointNotResolved",
235)<{
236 name: string;
237}> {}
238
239export class EndpointStillExists extends Data.TaggedError(
240 "GCP.AIPlatform.EndpointStillExists",
241)<{
242 name: string;
243}> {}
244
245const resourceName = (project: string, location: string, endpointId: string) =>
246 `projects/${project}/locations/${location}/endpoints/${endpointId}`;
247
248const trafficSplitOf = (
249 split: Record<string, number | undefined> | null | undefined,
250): Record<string, number> =>
251 Object.fromEntries(
252 Object.entries(split ?? {}).flatMap(([key, value]) =>
253 typeof value === "number" ? [[key, value] as const] : [],
254 ),
255 );
256
257const toEncryption = (
258 spec:
259 | aiplatform.GoogleCloudAiplatformV1EncryptionSpec
260 | EncryptionSpec
261 | undefined,
262): EncryptionSpec | undefined => {
263 const kmsKeyName = spec?.kmsKeyName;
264 if (kmsKeyName === undefined || kmsKeyName.length === 0) return undefined;
265 return { kmsKeyName };
266};
267
268const toClientConnection = (
269 config:
270 | aiplatform.GoogleCloudAiplatformV1ClientConnectionConfig
271 | ClientConnectionConfig
272 | undefined,
273): ClientConnectionConfig | undefined => {
274 if (config?.inferenceTimeout === undefined) return undefined;
275 return { inferenceTimeout: config.inferenceTimeout };
276};
277
278const toLogging = (
279 config:
280 | aiplatform.GoogleCloudAiplatformV1PredictRequestResponseLoggingConfig
281 | PredictRequestResponseLoggingConfig
282 | undefined,
283): PredictRequestResponseLoggingConfig | undefined => {
284 if (config === undefined) return undefined;
285 return {
286 enabled: config.enabled,
287 samplingRate: config.samplingRate,
288 bigqueryDestination: config.bigqueryDestination
289 ? { outputUri: config.bigqueryDestination.outputUri }
290 : undefined,
291 };
292};
293
294const toPsc = (
295 config:
296 | aiplatform.GoogleCloudAiplatformV1PrivateServiceConnectConfig
297 | PrivateServiceConnectConfig
298 | undefined,
299): PrivateServiceConnectConfig | undefined => {
300 if (config === undefined) return undefined;
301 return {
302 enablePrivateServiceConnect: config.enablePrivateServiceConnect === true,
303 projectAllowlist: config.projectAllowlist,
304 };
305};
306
307const toGenAi = (
308 config:
309 | aiplatform.GoogleCloudAiplatformV1GenAiAdvancedFeaturesConfig
310 | GenAiAdvancedFeaturesConfig
311 | undefined,
312): GenAiAdvancedFeaturesConfig | undefined => {
313 if (config === undefined) return undefined;
314 return {
315 ragConfig:
316 config.ragConfig === undefined
317 ? undefined
318 : { enableRag: config.ragConfig.enableRag === true },
319 };
320};
321
322const toGdc = (
323 config: aiplatform.GoogleCloudAiplatformV1GdcConfig | GdcConfig | undefined,
324): GdcConfig | undefined => {
325 const zone = config?.zone;
326 if (zone === undefined || zone.length === 0) return undefined;
327 return { zone };
328};
329
330const toAttrs = (
331 endpoint: aiplatform.GoogleCloudAiplatformV1Endpoint,
332 project: string,
333) => {
334 const name = endpoint.name ?? "";
335 const parsed = parseResourceName(name, "endpoints");
336 return {
337 name,
338 endpointId: parsed.id,
339 project: parsed.project || project,
340 location: parsed.location,
341 displayName: endpoint.displayName,
342 description: endpoint.description,
343 labels: userLabels(endpoint.labels),
344 network: endpoint.network,
345 encryptionSpec: toEncryption(endpoint.encryptionSpec),
346 dedicatedEndpointEnabled: endpoint.dedicatedEndpointEnabled === true,
347 dedicatedEndpointDns: endpoint.dedicatedEndpointDns,
348 trafficSplit: trafficSplitOf(endpoint.trafficSplit),
349 clientConnectionConfig: toClientConnection(endpoint.clientConnectionConfig),
350 predictRequestResponseLoggingConfig: toLogging(
351 endpoint.predictRequestResponseLoggingConfig,
352 ),
353 privateServiceConnectConfig: toPsc(endpoint.privateServiceConnectConfig),
354 enablePrivateServiceConnect: endpoint.enablePrivateServiceConnect === true,
355 genAiAdvancedFeaturesConfig: toGenAi(endpoint.genAiAdvancedFeaturesConfig),
356 gdcConfig: toGdc(endpoint.gdcConfig),
357 deployedModelIds: (endpoint.deployedModels ?? []).flatMap((model) =>
358 model.id ? [model.id] : [],
359 ),
360 modelDeploymentMonitoringJob: endpoint.modelDeploymentMonitoringJob,
361 createTime: endpoint.createTime,
362 updateTime: endpoint.updateTime,
363 etag: endpoint.etag,
364 };
365};
366
367const getByName = (name: string) =>
368 aiplatform
369 .getProjectsLocationsEndpoints({ name })
370 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
371
372const waitUntilExists = (name: string) =>
373 getByName(name).pipe(
374 Effect.flatMap((endpoint) =>
375 endpoint
376 ? Effect.succeed(endpoint)
377 : Effect.fail(new EndpointNotResolved({ name })),
378 ),
379 Effect.retry({
380 while: (error) => error._tag === "GCP.AIPlatform.EndpointNotResolved",
381 times: 8,
382 schedule: Schedule.spaced("1 second"),
383 }),
384 );
385
386const waitUntilGone = (name: string) =>
387 getByName(name).pipe(
388 Effect.flatMap((endpoint) =>
389 endpoint === undefined
390 ? Effect.void
391 : Effect.fail(new EndpointStillExists({ name })),
392 ),
393 Effect.retry({
394 while: (error) => error._tag === "GCP.AIPlatform.EndpointStillExists",
395 times: 10,
396 schedule: Schedule.spaced("2 seconds"),
397 }),
398 );
399
400const toCreateBody = (
401 news: EndpointProps,
402 endpointId: string,
403 desiredLabels: Record<string, string>,
404): aiplatform.GoogleCloudAiplatformV1Endpoint => ({
405 displayName: news.displayName ?? endpointId,
406 description: news.description,
407 labels: desiredLabels,
408 network: news.network,
409 encryptionSpec: news.encryptionSpec,
410 dedicatedEndpointEnabled:
411 news.dedicatedEndpointEnabled === true ? true : undefined,
412 trafficSplit: news.trafficSplit,
413 clientConnectionConfig: news.clientConnectionConfig,
414 predictRequestResponseLoggingConfig: news.predictRequestResponseLoggingConfig,
415 privateServiceConnectConfig: news.privateServiceConnectConfig,
416 enablePrivateServiceConnect:
417 news.enablePrivateServiceConnect === true ? true : undefined,
418 genAiAdvancedFeaturesConfig: news.genAiAdvancedFeaturesConfig,
419 gdcConfig: news.gdcConfig,
420});
421
422export const EndpointProvider = () =>
423 Provider.succeed(Endpoint, {
424 stables: ["name", "endpointId", "project", "location", "createTime"],
425
426 diff: Effect.fn(function* ({ news, olds, output }) {
427 const env = yield* GcpEnvironment.current;
428 if (!isResolved(news)) return undefined;
429 const previousId = olds?.endpointId ?? output?.endpointId;
430 const nextId = news.endpointId
431 ? lastSegment(news.endpointId)
432 : previousId;
433 const previousLocation = normalizeLocation(
434 olds?.location ?? output?.location,
435 env.region,
436 );
437 const nextLocation = normalizeLocation(
438 news.location ?? output?.location,
439 env.region,
440 );
441 const previousNetwork = olds?.network ?? output?.network ?? "";
442 const nextNetwork = news.network ?? previousNetwork;
443 const previousKey =
444 olds?.encryptionSpec?.kmsKeyName ??
445 output?.encryptionSpec?.kmsKeyName ??
446 "";
447 const nextKey = news.encryptionSpec?.kmsKeyName ?? previousKey;
448 const previousGdc =
449 olds?.gdcConfig?.zone ?? output?.gdcConfig?.zone ?? "";
450 const nextGdc = news.gdcConfig?.zone ?? previousGdc;
451 const previousPsc =
452 olds?.enablePrivateServiceConnect ??
453 output?.enablePrivateServiceConnect ??
454 false;
455 const nextPsc = news.enablePrivateServiceConnect ?? previousPsc;
456 const replace =
457 (previousId !== undefined &&
458 nextId !== undefined &&
459 nextId !== previousId) ||
460 previousLocation !== nextLocation ||
461 previousNetwork !== nextNetwork ||
462 previousKey !== nextKey ||
463 previousGdc !== nextGdc ||
464 previousPsc !== nextPsc;
465 if (!replace) return undefined;
466 return {
467 action: "replace" as const,
468 deleteFirst:
469 previousLocation === nextLocation &&
470 previousId !== undefined &&
471 nextId === previousId,
472 };
473 }),
474
475 read: Effect.fn(function* ({ id, olds, output }) {
476 const env = yield* GcpEnvironment.current;
477 const endpointId = yield* toPhysicalRfc1035(
478 id,
479 olds?.endpointId,
480 output?.endpointId,
481 MAX_NAME_LENGTH,
482 );
483 const location = normalizeLocation(
484 olds?.location ?? output?.location,
485 env.region,
486 );
487 const name =
488 output?.name ?? resourceName(env.project, location, endpointId);
489 const existing = yield* getByName(name);
490 if (existing === undefined) return undefined;
491 const attrs = toAttrs(existing, env.project);
492 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
493 ? attrs
494 : Unowned(attrs);
495 }),
496
497 list: () =>
498 Effect.gen(function* () {
499 const env = yield* GcpEnvironment.current;
500 return yield* Stream.fromIterable(listLocations(env.region))
501 .pipe(
502 Stream.flatMap((location) =>
503 aiplatform.listProjectsLocationsEndpoints.pages({
504 parent: `projects/${env.project}/locations/${location}`,
505 pageSize: 100,
506 }),
507 ),
508 )
509 .pipe(
510 Stream.flatMap((page) => Stream.fromIterable(page.endpoints ?? [])),
511 Stream.filter((endpoint) => hasAlchemyLabelMap(endpoint.labels)),
512 Stream.map((endpoint) => toAttrs(endpoint, env.project)),
513 Stream.runCollect,
514 Effect.map((chunk) => Array.from(chunk)),
515 Effect.catchTag("NotFound", () => Effect.succeed([])),
516 );
517 }),
518
519 reconcile: Effect.fn(function* ({ id, news, output }) {
520 const env = yield* GcpEnvironment.current;
521 const endpointId = yield* toPhysicalRfc1035(
522 id,
523 news.endpointId,
524 output?.endpointId,
525 MAX_NAME_LENGTH,
526 );
527 const location = normalizeLocation(
528 news.location ?? output?.location,
529 env.region,
530 );
531 const name = resourceName(env.project, location, endpointId);
532 const desiredLabels = {
533 ...toLabels(news.labels),
534 ...(yield* createInternalLabels(id)),
535 };
536 const displayName = news.displayName ?? endpointId;
537
538 let current = yield* getByName(output?.name ?? name);
539
540 if (current === undefined) {
541 const created = yield* aiplatform
542 .createProjectsLocationsEndpoints({
543 parent: `projects/${env.project}/locations/${location}`,
544 endpointId,
545 body: toCreateBody(news, endpointId, desiredLabels),
546 })
547 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
548 if (created !== undefined) {
549 yield* waitForOperation(created);
550 }
551 current = yield* waitUntilExists(name);
552 }
553
554 if (current === undefined) {
555 return yield* new EndpointNotResolved({ name });
556 }
557
558 const observedLabels = tagRecord(current.labels);
559 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
560 const labelsChanged = upsert.length > 0 || removed.length > 0;
561 const displayNameChanged = (current.displayName ?? "") !== displayName;
562 const descriptionChanged =
563 (current.description ?? "") !== (news.description ?? "");
564 const dedicatedChanged =
565 (current.dedicatedEndpointEnabled === true) !==
566 (news.dedicatedEndpointEnabled === true);
567 const trafficChanged =
568 news.trafficSplit !== undefined &&
569 fingerprint(trafficSplitOf(current.trafficSplit)) !==
570 fingerprint(news.trafficSplit);
571 const clientChanged =
572 news.clientConnectionConfig !== undefined &&
573 !specifiedEquals(
574 news.clientConnectionConfig,
575 toClientConnection(current.clientConnectionConfig),
576 );
577 const loggingChanged =
578 news.predictRequestResponseLoggingConfig !== undefined &&
579 !specifiedEquals(
580 news.predictRequestResponseLoggingConfig,
581 toLogging(current.predictRequestResponseLoggingConfig),
582 );
583 const pscChanged =
584 news.privateServiceConnectConfig !== undefined &&
585 !specifiedEquals(
586 news.privateServiceConnectConfig,
587 toPsc(current.privateServiceConnectConfig),
588 );
589 const genAiChanged =
590 news.genAiAdvancedFeaturesConfig !== undefined &&
591 !specifiedEquals(
592 news.genAiAdvancedFeaturesConfig,
593 toGenAi(current.genAiAdvancedFeaturesConfig),
594 );
595
596 if (
597 labelsChanged ||
598 displayNameChanged ||
599 descriptionChanged ||
600 dedicatedChanged ||
601 trafficChanged ||
602 clientChanged ||
603 loggingChanged ||
604 pscChanged ||
605 genAiChanged
606 ) {
607 const updateMask = [
608 labelsChanged ? "labels" : undefined,
609 displayNameChanged ? "display_name" : undefined,
610 descriptionChanged ? "description" : undefined,
611 dedicatedChanged ? "dedicated_endpoint_enabled" : undefined,
612 trafficChanged ? "traffic_split" : undefined,
613 clientChanged ? "client_connection_config" : undefined,
614 loggingChanged
615 ? "predict_request_response_logging_config"
616 : undefined,
617 pscChanged ? "private_service_connect_config" : undefined,
618 genAiChanged ? "gen_ai_advanced_features_config" : undefined,
619 ].filter((field): field is string => field !== undefined);
620
621 current = yield* aiplatform
622 .patchProjectsLocationsEndpoints({
623 name,
624 updateMask: updateMask.join(","),
625 body: {
626 name,
627 labels: desiredLabels,
628 displayName,
629 description: news.description,
630 dedicatedEndpointEnabled: news.dedicatedEndpointEnabled === true,
631 trafficSplit: news.trafficSplit,
632 clientConnectionConfig: news.clientConnectionConfig,
633 predictRequestResponseLoggingConfig:
634 news.predictRequestResponseLoggingConfig,
635 privateServiceConnectConfig: news.privateServiceConnectConfig,
636 genAiAdvancedFeaturesConfig: news.genAiAdvancedFeaturesConfig,
637 },
638 })
639 .pipe(
640 Effect.retry({
641 while: (error) => error._tag === "Conflict",
642 times: 8,
643 schedule: Schedule.spaced("5 seconds"),
644 }),
645 );
646 }
647
648 return toAttrs(current, env.project);
649 }),
650
651 delete: Effect.fn(function* ({ output }) {
652 const operation = yield* aiplatform
653 .deleteProjectsLocationsEndpoints({ name: output.name })
654 .pipe(
655 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
656 Effect.retry({
657 while: (error) => error._tag === "Conflict",
658 times: 8,
659 schedule: Schedule.spaced("5 seconds"),
660 }),
661 );
662 if (operation !== undefined) {
663 yield* waitForOperation(operation, { notFoundOk: true });
664 }
665 yield* waitUntilGone(output.name);
666 }),
667 });