1import * as ml from "@distilled.cloud/gcp/ml_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels, toLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 encodeOwnership,
13 expandModel,
14 fieldMask,
15 getVersion,
16 lastSegment,
17 listOwnedVersions,
18 parseOwnership,
19 parseVersionName,
20 replaceOnIdentity,
21 ResourceNotResolved,
22 sameText,
23 toPhysicalId,
24 userLabels,
25 versionOwnedByAlchemy,
26 versionResourceName,
27 waitUntilExists,
28 waitUntilGone,
29 waitUntilVersionReady,
30} from "./internal.ts";
31import { waitForOperation } from "./operations.ts";
32
33export type AutoScaling = ml.GoogleCloudMlV1__AutoScaling;
34export type ManualScaling = ml.GoogleCloudMlV1__ManualScaling;
35export type ContainerSpec = ml.GoogleCloudMlV1__ContainerSpec;
36export type RequestLoggingConfig = ml.GoogleCloudMlV1__RequestLoggingConfig;
37export type AcceleratorConfig = ml.GoogleCloudMlV1__AcceleratorConfig;
38export type ExplanationConfig = ml.GoogleCloudMlV1__ExplanationConfig;
39export type RouteMap = ml.GoogleCloudMlV1__RouteMap;
40export type VersionFramework =
41 | ml.GoogleCloudMlV1__VersionFrameworkEnum
42 | (string & {});
43
44export type ModelsVersionProps = {
45 /**
46 * Parent model. Full name `projects/{project}/models/{model}` or the
47 * model id. Immutable — changing it replaces the version.
48 */
49 model: string;
50 /**
51 * Version id (the `{version}` segment of
52 * `projects/{project}/models/{model}/versions/{version}`). If omitted,
53 * a unique RFC1035 name is generated. Immutable — changing it
54 * replaces the version.
55 */
56 versionId?: string;
57 /**
58 * Cloud Storage URI of a directory containing trained model artifacts
59 * (`gs://bucket/path`). Required unless `container` is set. Immutable.
60 */
61 deploymentUri?: string;
62 /**
63 * AI Platform runtime version (for example `"2.11"`). Immutable.
64 */
65 runtimeVersion?: string;
66 /**
67 * Python version used in prediction (`"3.7"`, `"3.5"`, `"2.7"`).
68 * Immutable.
69 */
70 pythonVersion?: string;
71 /**
72 * Framework: `TENSORFLOW`, `SCIKIT_LEARN`, or `XGBOOST`. Immutable.
73 */
74 framework?: VersionFramework;
75 /**
76 * Machine type for online prediction (for example `mls1-c1-m2` or
77 * `n1-standard-2`). Immutable.
78 */
79 machineType?: string;
80 /**
81 * Human-readable description. Patchable. Alchemy stamps an
82 * `[alchemy …]` ownership prefix here and strips it from attributes.
83 */
84 description?: string;
85 /**
86 * User labels. Merged with Alchemy ownership labels on create. The
87 * patch API does not update labels.
88 */
89 labels?: Record<string, string>;
90 /**
91 * Automatic node scaling. `minNodes` is patchable.
92 */
93 autoScaling?: AutoScaling;
94 /**
95 * Fixed node count. `nodes` is patchable on Compute Engine machine
96 * types.
97 */
98 manualScaling?: ManualScaling;
99 /**
100 * Custom serving container. Immutable. When set, `deploymentUri` is
101 * optional and `runtimeVersion` must be omitted.
102 */
103 container?: ContainerSpec;
104 /**
105 * Custom prediction-routine packages (`gs://…`). Immutable.
106 */
107 packageUris?: string[];
108 /**
109 * Fully qualified Predictor class (`module.Class`). Immutable.
110 */
111 predictionClass?: string;
112 /**
113 * Service account for resource access control. Immutable.
114 */
115 serviceAccount?: string;
116 /**
117 * GPU accelerator for N1 machine types. Immutable.
118 */
119 acceleratorConfig?: AcceleratorConfig;
120 /**
121 * Explainability configuration. Immutable.
122 */
123 explanationConfig?: ExplanationConfig;
124 /**
125 * Custom container HTTP routes. Immutable.
126 */
127 routes?: RouteMap;
128 /**
129 * Request-response pair logging to BigQuery. Patchable.
130 */
131 requestLoggingConfig?: RequestLoggingConfig;
132 /**
133 * When true, this version handles predict requests that omit a
134 * version. The first version of a model is default automatically;
135 * later versions call `versions.setDefault`.
136 */
137 isDefault?: boolean;
138};
139
140export type ModelsVersion = Resource<
141 "GCP.ML.ModelsVersion",
142 ModelsVersionProps,
143 {
144 /** Full resource name `projects/{project}/models/{model}/versions/{version}`. */
145 name: string;
146 /** Version id (last path segment). */
147 versionId: string;
148 /** Parent model resource name. */
149 model: string;
150 /** Parent model id. */
151 modelId: string;
152 /** Project id. */
153 project: string;
154 /** User description with the Alchemy ownership prefix stripped. */
155 description: string | undefined;
156 /** User labels (Alchemy ownership labels stripped). */
157 labels: Record<string, string>;
158 /** Model-artifact Cloud Storage URI. */
159 deploymentUri: string | undefined;
160 /** Runtime version. */
161 runtimeVersion: string | undefined;
162 /** Python version. */
163 pythonVersion: string | undefined;
164 /** Framework. */
165 framework: string | undefined;
166 /** Serving machine type. */
167 machineType: string | undefined;
168 /** Automatic scaling, if set. */
169 autoScaling: AutoScaling | undefined;
170 /** Manual scaling, if set. */
171 manualScaling: ManualScaling | undefined;
172 /** Whether this is the model's default version. */
173 isDefault: boolean;
174 /** Version state (`READY`, `CREATING`, `FAILED`, …). */
175 state: string | undefined;
176 /** Failure details, if the version failed. */
177 errorMessage: string | undefined;
178 /** RFC3339 creation timestamp. */
179 createTime: string | undefined;
180 },
181 never,
182 Providers
183>;
184
185/**
186 * A deployed version of an AI Platform (legacy ML Engine)
187 * {@link Model}. Each version serves online and batch predictions from
188 * a Cloud Storage SavedModel (or a custom container).
189 *
190 * Version id, parent model, `deploymentUri`, runtime, framework, and
191 * machine type are identity. Description, `autoScaling.minNodes`,
192 * `manualScaling.nodes`, and `requestLoggingConfig` update in place.
193 * Create and delete are long-running operations.
194 *
195 * ### Creating a ModelsVersion
196 * **Example:** TensorFlow SavedModel
197 * ```typescript
198 * const model = yield* GCP.ML.Model("Classifier", {});
199 * const version = yield* GCP.ML.ModelsVersion("V1", {
200 * model: model.name,
201 * deploymentUri: "gs://my-bucket/saved-model",
202 * runtimeVersion: "2.11",
203 * pythonVersion: "3.7",
204 * framework: "TENSORFLOW",
205 * });
206 * ```
207 *
208 * **Example:** Explicit version id and labels
209 * ```typescript
210 * const version = yield* GCP.ML.ModelsVersion("V1", {
211 * model: model.name,
212 * versionId: "v1",
213 * deploymentUri: "gs://my-bucket/saved-model",
214 * labels: { env: "prod" },
215 * autoScaling: { minNodes: 0 },
216 * });
217 * ```
218 *
219 * ### Updating a ModelsVersion
220 * **Example:** Raise min nodes
221 * ```typescript
222 * const version = yield* GCP.ML.ModelsVersion("V1", {
223 * model: model.name,
224 * autoScaling: { minNodes: 1 },
225 * });
226 * ```
227 *
228 * @resource
229 * @category ML
230 */
231export const ModelsVersion = Resource<ModelsVersion>("GCP.ML.ModelsVersion");
232
233export { ResourceNotResolved as ModelsVersionNotResolved };
234
235const toAttrs = (version: ml.GoogleCloudMlV1__Version, project: string) => {
236 const name = version.name ?? "";
237 const parsed = parseVersionName(name, project);
238 return {
239 name,
240 versionId: parsed.versionId,
241 model: parsed.model,
242 modelId: parsed.modelId,
243 project: parsed.project || project,
244 description: parseOwnership(version.description).text,
245 labels: userLabels(version.labels),
246 deploymentUri: version.deploymentUri,
247 runtimeVersion: version.runtimeVersion,
248 pythonVersion: version.pythonVersion,
249 framework: version.framework,
250 machineType: version.machineType,
251 autoScaling: version.autoScaling,
252 manualScaling: version.manualScaling,
253 isDefault: version.isDefault === true,
254 state: version.state,
255 errorMessage: version.errorMessage,
256 createTime: version.createTime,
257 };
258};
259
260const sameMinNodes = (
261 left: AutoScaling | undefined,
262 right: AutoScaling | undefined,
263) => (left?.minNodes ?? undefined) === (right?.minNodes ?? undefined);
264
265const sameManualNodes = (
266 left: ManualScaling | undefined,
267 right: ManualScaling | undefined,
268) => (left?.nodes ?? undefined) === (right?.nodes ?? undefined);
269
270const sameLogging = (
271 left: RequestLoggingConfig | undefined,
272 right: RequestLoggingConfig | undefined,
273) =>
274 (left?.bigqueryTableName ?? "") === (right?.bigqueryTableName ?? "") &&
275 (left?.samplingPercentage ?? undefined) ===
276 (right?.samplingPercentage ?? undefined);
277
278export const ModelsVersionProvider = () =>
279 Provider.succeed(ModelsVersion, {
280 stables: ["name", "versionId", "model", "modelId", "project", "createTime"],
281
282 diff: Effect.fn(function* ({ news, olds, output }) {
283 if (!isResolved(news)) return undefined;
284 const env = yield* GcpEnvironment.current;
285 const previousParent = expandModel(
286 env.project,
287 olds?.model ?? output?.model ?? "",
288 );
289 const nextParent = expandModel(env.project, news.model);
290 return replaceOnIdentity({
291 previousId: olds?.versionId ?? output?.versionId,
292 nextId: news.versionId ?? olds?.versionId ?? output?.versionId,
293 previousParent,
294 nextParent,
295 extra:
296 (news.deploymentUri !== undefined &&
297 (olds?.deploymentUri ?? output?.deploymentUri) !== undefined &&
298 news.deploymentUri !==
299 (olds?.deploymentUri ?? output?.deploymentUri)) ||
300 (news.runtimeVersion !== undefined &&
301 (olds?.runtimeVersion ?? output?.runtimeVersion) !== undefined &&
302 news.runtimeVersion !==
303 (olds?.runtimeVersion ?? output?.runtimeVersion)) ||
304 (news.pythonVersion !== undefined &&
305 (olds?.pythonVersion ?? output?.pythonVersion) !== undefined &&
306 news.pythonVersion !==
307 (olds?.pythonVersion ?? output?.pythonVersion)) ||
308 (news.framework !== undefined &&
309 (olds?.framework ?? output?.framework) !== undefined &&
310 news.framework !== (olds?.framework ?? output?.framework)) ||
311 (news.machineType !== undefined &&
312 (olds?.machineType ?? output?.machineType) !== undefined &&
313 news.machineType !== (olds?.machineType ?? output?.machineType)),
314 });
315 }),
316
317 read: Effect.fn(function* ({ id, olds, output }) {
318 const env = yield* GcpEnvironment.current;
319 const versionId = yield* toPhysicalId(
320 id,
321 olds?.versionId,
322 output?.versionId,
323 "version",
324 );
325 const model = expandModel(
326 env.project,
327 olds?.model ?? output?.model ?? "",
328 );
329 const name =
330 output?.name ??
331 (model.length > 0 ? versionResourceName(model, versionId) : "");
332 const existing = yield* getVersion(name);
333 if (existing === undefined) return undefined;
334 const attrs = toAttrs(existing, env.project);
335 return (yield* versionOwnedByAlchemy(id, existing))
336 ? attrs
337 : Unowned(attrs);
338 }),
339
340 list: () =>
341 Effect.gen(function* () {
342 const env = yield* GcpEnvironment.current;
343 const versions = yield* listOwnedVersions(env.project);
344 return versions.map((version) => toAttrs(version, env.project));
345 }),
346
347 reconcile: Effect.fn(function* ({ id, news, output }) {
348 const env = yield* GcpEnvironment.current;
349 const versionId = yield* toPhysicalId(
350 id,
351 news.versionId,
352 output?.versionId,
353 "version",
354 );
355 const model = expandModel(env.project, news.model);
356 const name = versionResourceName(model, versionId);
357 const ownership = yield* createInternalLabels(id);
358 const description = encodeOwnership(ownership, news.description);
359 const desiredLabels = {
360 ...toLabels(news.labels),
361 ...ownership,
362 };
363
364 let current = yield* getVersion(output?.name ?? name);
365
366 if (current === undefined) {
367 const created = yield* ml
368 .createProjectsModelsVersions({
369 parent: model,
370 body: {
371 name: versionId,
372 description,
373 labels: desiredLabels,
374 deploymentUri: news.deploymentUri,
375 runtimeVersion: news.runtimeVersion,
376 pythonVersion: news.pythonVersion,
377 framework: news.framework,
378 machineType: news.machineType,
379 autoScaling: news.autoScaling,
380 manualScaling: news.manualScaling,
381 container: news.container,
382 packageUris: news.packageUris,
383 predictionClass: news.predictionClass,
384 serviceAccount: news.serviceAccount,
385 acceleratorConfig: news.acceleratorConfig,
386 explanationConfig: news.explanationConfig,
387 routes: news.routes,
388 requestLoggingConfig: news.requestLoggingConfig,
389 },
390 })
391 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
392 if (created !== undefined) {
393 yield* waitForOperation(created);
394 }
395 current = yield* waitUntilVersionReady(name);
396 }
397
398 if (current === undefined) {
399 return yield* new ResourceNotResolved({ name });
400 }
401
402 const currentName = current.name ?? name;
403 const descriptionChanged = !sameText(current.description, description);
404 const minNodesChanged = !sameMinNodes(
405 current.autoScaling,
406 news.autoScaling,
407 );
408 const manualNodesChanged = !sameManualNodes(
409 current.manualScaling,
410 news.manualScaling,
411 );
412 const loggingChanged =
413 news.requestLoggingConfig !== undefined &&
414 !sameLogging(current.requestLoggingConfig, news.requestLoggingConfig);
415 const mask = fieldMask([
416 descriptionChanged && "description",
417 minNodesChanged &&
418 news.autoScaling !== undefined &&
419 "autoScaling.minNodes",
420 manualNodesChanged &&
421 news.manualScaling !== undefined &&
422 "manualScaling.nodes",
423 loggingChanged && "requestLoggingConfig",
424 ]);
425
426 if (mask.length > 0) {
427 const operation = yield* ml.patchProjectsModelsVersions({
428 name: currentName,
429 updateMask: mask,
430 body: {
431 name: currentName,
432 description,
433 autoScaling: news.autoScaling,
434 manualScaling: news.manualScaling,
435 requestLoggingConfig: news.requestLoggingConfig,
436 },
437 });
438 yield* waitForOperation(operation);
439 current = yield* waitUntilExists(getVersion(currentName), currentName);
440 }
441
442 if (news.isDefault === true && current.isDefault !== true) {
443 current = yield* ml.setDefaultProjectsModelsVersions({
444 name: currentName,
445 body: {},
446 });
447 }
448
449 return toAttrs(current, env.project);
450 }),
451
452 delete: Effect.fn(function* ({ output }) {
453 const name = output.name;
454 if (!name) return;
455 const operation = yield* ml.deleteProjectsModelsVersions({ name }).pipe(
456 Effect.retry({
457 while: (error) => error._tag === "Conflict",
458 times: 8,
459 schedule: Schedule.spaced("2 seconds"),
460 }),
461 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
462 );
463 if (operation !== undefined) {
464 yield* waitForOperation(operation, { notFoundOk: true });
465 }
466 yield* waitUntilGone(getVersion(name), name);
467 }),
468 });