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 getModel,
16 lastSegment,
17 listOwnedModels,
18 listVersions,
19 modelOwnedByAlchemy,
20 modelResourceName,
21 normalizeRegions,
22 parseModelName,
23 parseOwnership,
24 replaceOnIdentity,
25 ResourceNotResolved,
26 sameStringList,
27 sameText,
28 toPhysicalId,
29 userLabels,
30 waitUntilExists,
31 waitUntilGone,
32} from "./internal.ts";
33import { waitForOperation } from "./operations.ts";
34
35export type ModelProps = {
36 /**
37 * Model id (the `{model}` segment of `projects/{project}/models/{model}`).
38 * If omitted, a unique RFC1035 name is generated from the stack, stage,
39 * and logical id. Immutable — changing it replaces the model.
40 */
41 modelId?: string;
42 /**
43 * Human-readable description. Patch supports this field. Alchemy also
44 * stamps an `[alchemy …]` ownership prefix here (labels are not
45 * updatable on existing models) and strips it from attributes.
46 */
47 description?: string;
48 /**
49 * User labels. Alchemy ownership labels are merged in automatically
50 * on create. The patch API does not update labels.
51 */
52 labels?: Record<string, string>;
53 /**
54 * Regions where the model is deployed. Only one region is supported.
55 * Immutable — changing it replaces the model.
56 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
57 */
58 regions?: string[];
59 /**
60 * Send online-prediction access logs to Cloud Logging. Immutable —
61 * changing it replaces the model.
62 * @default false
63 */
64 onlinePredictionLogging?: boolean;
65 /**
66 * Send online-prediction stdout/stderr to Cloud Logging. Immutable —
67 * changing it replaces the model.
68 * @default false
69 */
70 onlinePredictionConsoleLogging?: boolean;
71 /**
72 * Default version id used when a predict request omits the version.
73 * Patchable as `defaultVersion.name`. The first created version becomes
74 * the default automatically.
75 */
76 defaultVersion?: string;
77};
78
79export type Model = Resource<
80 "GCP.ML.Model",
81 ModelProps,
82 {
83 /** Full resource name `projects/{project}/models/{model}`. */
84 name: string;
85 /** Model id (last path segment). */
86 modelId: string;
87 /** Project id. */
88 project: string;
89 /** User description with the Alchemy ownership prefix stripped. */
90 description: string | undefined;
91 /** User labels (Alchemy ownership labels stripped). */
92 labels: Record<string, string>;
93 /** Deployment regions. */
94 regions: string[];
95 /** Whether online-prediction access logs are enabled. */
96 onlinePredictionLogging: boolean;
97 /** Whether online-prediction console logs are enabled. */
98 onlinePredictionConsoleLogging: boolean;
99 /** Default version resource name, if any. */
100 defaultVersion: string | undefined;
101 /** Optimistic-concurrency etag. */
102 etag: string | undefined;
103 },
104 never,
105 Providers
106>;
107
108/**
109 * An AI Platform (legacy ML Engine) model — a named container for
110 * deployed {@link ModelsVersion}s.
111 *
112 * Labels are set at create time but are not patchable, so Alchemy also
113 * stamps ownership into `description` for `list` / nuke. Model id,
114 * regions, and logging flags are identity. Description and the default
115 * version update in place.
116 *
117 * The AI Platform Training and Prediction API (`ml.googleapis.com`)
118 * must be enabled. Create of a model is metadata-only; serving traffic
119 * requires at least one version.
120 *
121 * ### Creating a Model
122 * **Example:** Generated name
123 * ```typescript
124 * const model = yield* GCP.ML.Model("Classifier", {
125 * description: "image classifier",
126 * });
127 * ```
128 *
129 * **Example:** Explicit id, region, and labels
130 * ```typescript
131 * const model = yield* GCP.ML.Model("Classifier", {
132 * modelId: "image-classifier",
133 * regions: ["us-central1"],
134 * labels: { env: "prod" },
135 * onlinePredictionLogging: true,
136 * });
137 * ```
138 *
139 * ### Updating a Model
140 * **Example:** Change the description
141 * ```typescript
142 * const model = yield* GCP.ML.Model("Classifier", {
143 * description: "image classifier v2",
144 * });
145 * ```
146 *
147 * @resource
148 * @category ML
149 */
150export const Model = Resource<Model>("GCP.ML.Model");
151
152export { ResourceNotResolved as ModelNotResolved };
153
154const toAttrs = (model: ml.GoogleCloudMlV1__Model, project: string) => {
155 const name = model.name ?? "";
156 const parsed = parseModelName(name, project);
157 return {
158 name,
159 modelId: parsed.modelId,
160 project: parsed.project || project,
161 description: parseOwnership(model.description).text,
162 labels: userLabels(model.labels),
163 regions: normalizeRegions(model.regions, []),
164 onlinePredictionLogging: model.onlinePredictionLogging === true,
165 onlinePredictionConsoleLogging:
166 model.onlinePredictionConsoleLogging === true,
167 defaultVersion: model.defaultVersion?.name,
168 etag: model.etag,
169 };
170};
171
172const desiredDefaultVersion = (value: string | undefined) =>
173 value === undefined || value.length === 0 ? undefined : lastSegment(value);
174
175const deleteVersions = (parent: string) =>
176 Effect.gen(function* () {
177 const versions = yield* listVersions(parent);
178 const ordered = [
179 ...versions.filter((version) => version.isDefault !== true),
180 ...versions.filter((version) => version.isDefault === true),
181 ];
182 yield* Effect.forEach(
183 ordered,
184 (version) =>
185 version.name
186 ? ml.deleteProjectsModelsVersions({ name: version.name }).pipe(
187 Effect.flatMap((operation) =>
188 waitForOperation(operation, { notFoundOk: true }),
189 ),
190 Effect.catchTag("NotFound", () => Effect.void),
191 )
192 : Effect.void,
193 { concurrency: 1 },
194 );
195 });
196
197export const ModelProvider = () =>
198 Provider.succeed(Model, {
199 stables: ["name", "modelId", "project"],
200
201 diff: Effect.fn(function* ({ news, olds, output }) {
202 if (!isResolved(news)) return undefined;
203 const env = yield* GcpEnvironment.current;
204 const previousRegions = normalizeRegions(
205 olds?.regions ?? output?.regions,
206 [env.region],
207 );
208 const nextRegions = normalizeRegions(
209 news.regions ?? olds?.regions ?? output?.regions,
210 [env.region],
211 );
212 const previousLogging =
213 olds?.onlinePredictionLogging ??
214 output?.onlinePredictionLogging ??
215 false;
216 const nextLogging = news.onlinePredictionLogging ?? previousLogging;
217 const previousConsole =
218 olds?.onlinePredictionConsoleLogging ??
219 output?.onlinePredictionConsoleLogging ??
220 false;
221 const nextConsole =
222 news.onlinePredictionConsoleLogging ?? previousConsole;
223 return replaceOnIdentity({
224 previousId: olds?.modelId ?? output?.modelId,
225 nextId: news.modelId ?? olds?.modelId ?? output?.modelId,
226 extra:
227 !sameStringList(previousRegions, nextRegions) ||
228 previousLogging !== nextLogging ||
229 previousConsole !== nextConsole,
230 });
231 }),
232
233 read: Effect.fn(function* ({ id, olds, output }) {
234 const env = yield* GcpEnvironment.current;
235 const modelId = yield* toPhysicalId(
236 id,
237 olds?.modelId,
238 output?.modelId,
239 "model",
240 );
241 const name = output?.name ?? modelResourceName(env.project, modelId);
242 const existing = yield* getModel(name);
243 if (existing === undefined) return undefined;
244 const attrs = toAttrs(existing, env.project);
245 return (yield* modelOwnedByAlchemy(id, existing))
246 ? attrs
247 : Unowned(attrs);
248 }),
249
250 list: () =>
251 Effect.gen(function* () {
252 const env = yield* GcpEnvironment.current;
253 const models = yield* listOwnedModels(env.project);
254 return models.map((model) => toAttrs(model, env.project));
255 }),
256
257 reconcile: Effect.fn(function* ({ id, news, output }) {
258 const env = yield* GcpEnvironment.current;
259 const modelId = yield* toPhysicalId(
260 id,
261 news.modelId,
262 output?.modelId,
263 "model",
264 );
265 const name = modelResourceName(env.project, modelId);
266 const ownership = yield* createInternalLabels(id);
267 const description = encodeOwnership(ownership, news.description);
268 const desiredLabels = {
269 ...toLabels(news.labels),
270 ...ownership,
271 };
272 const regions = normalizeRegions(news.regions ?? output?.regions, [
273 env.region,
274 ]);
275 const onlinePredictionLogging = news.onlinePredictionLogging === true;
276 const onlinePredictionConsoleLogging =
277 news.onlinePredictionConsoleLogging === true;
278 const defaultVersion = desiredDefaultVersion(news.defaultVersion);
279
280 let current = yield* getModel(output?.name ?? name);
281
282 if (current === undefined) {
283 yield* ml
284 .createProjectsModels({
285 parent: `projects/${env.project}`,
286 body: {
287 name: modelId,
288 description,
289 labels: desiredLabels,
290 regions,
291 onlinePredictionLogging,
292 onlinePredictionConsoleLogging,
293 },
294 })
295 .pipe(Effect.catchTag("Conflict", () => Effect.void));
296 current = yield* waitUntilExists(getModel(name), name);
297 }
298
299 if (current === undefined) {
300 return yield* new ResourceNotResolved({ name });
301 }
302
303 const currentName = current.name ?? name;
304 const descriptionChanged = !sameText(current.description, description);
305 const observedDefault = desiredDefaultVersion(
306 current.defaultVersion?.name,
307 );
308 const defaultChanged =
309 defaultVersion !== undefined && observedDefault !== defaultVersion;
310 const mask = fieldMask([
311 descriptionChanged && "description",
312 defaultChanged && "default_version.name",
313 ]);
314
315 if (mask.length > 0) {
316 const operation = yield* ml.patchProjectsModels({
317 name: currentName,
318 updateMask: mask,
319 body: {
320 name: currentName,
321 description,
322 defaultVersion:
323 defaultVersion !== undefined
324 ? {
325 name: defaultVersion.includes("/")
326 ? defaultVersion
327 : defaultVersion,
328 }
329 : undefined,
330 },
331 });
332 yield* waitForOperation(operation);
333 current = yield* waitUntilExists(getModel(currentName), currentName);
334 }
335
336 return toAttrs(current, env.project);
337 }),
338
339 delete: Effect.fn(function* ({ output }) {
340 const name = output.name;
341 if (!name) return;
342 yield* Effect.gen(function* () {
343 yield* deleteVersions(name);
344 const operation = yield* ml
345 .deleteProjectsModels({ name })
346 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
347 if (operation !== undefined) {
348 yield* waitForOperation(operation, { notFoundOk: true });
349 }
350 }).pipe(
351 Effect.retry({
352 while: (error) =>
353 error._tag === "Conflict" || error._tag === "BadRequest",
354 times: 8,
355 schedule: Schedule.spaced("2 seconds"),
356 }),
357 );
358 yield* waitUntilGone(getModel(name), name);
359 }),
360 });
361
362export const modelNameOf = (project: string, model: string) =>
363 expandModel(project, model);