1import * as ces from "@distilled.cloud/gcp/ces_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
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 } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectPages,
13 encodeOwnership,
14 expandApp,
15 forEachApp,
16 hasOwnershipMarker,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 replaceOnIdentity,
22 retryTransient,
23 sameJson,
24 sameText,
25 toPhysicalId,
26 updateMaskOf,
27 waitUntilGone,
28} from "./internal.ts";
29
30const DEFAULT_CONTENT_FILTER: ces.GuardrailContentFilter = {
31 matchType: "SIMPLE_STRING_MATCH",
32 bannedContents: ["alchemy-ban"],
33};
34
35export type AppsGuardrailProps = {
36 /**
37 * Parent CES app. Full name
38 * `projects/{project}/locations/{location}/apps/{app}` or the app id
39 * (combined with `location`). Immutable — changing it replaces the
40 * guardrail.
41 */
42 app: string;
43 /**
44 * Region used when `app` is a bare id.
45 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
46 */
47 location?: string;
48 /**
49 * Guardrail id. If omitted, a unique name is generated. Immutable —
50 * changing it replaces the guardrail.
51 */
52 guardrailId?: string;
53 /**
54 * Human-readable name. Required by the API; Alchemy falls back to the
55 * generated guardrail id.
56 */
57 displayName?: string;
58 /**
59 * Human-readable description. Guardrails have no labels field, so
60 * Alchemy stamps ownership into this field.
61 */
62 description?: string;
63 /**
64 * Whether the guardrail is enabled.
65 * @default true
66 */
67 enabled?: boolean;
68 /**
69 * Phrase / regexp content filter. Defaults to a simple-string match
70 * on `alchemy-ban` when no other check is set.
71 */
72 contentFilter?: ces.GuardrailContentFilter;
73 /**
74 * LLM policy check.
75 */
76 llmPolicy?: ces.GuardrailLlmPolicy;
77 /**
78 * Prompt-security check.
79 */
80 llmPromptSecurity?: ces.GuardrailLlmPromptSecurity;
81 /**
82 * Model safety overrides.
83 */
84 modelSafety?: ces.GuardrailModelSafety;
85 /**
86 * Code-callback check.
87 */
88 codeCallback?: ces.GuardrailCodeCallback;
89 /**
90 * Action taken when the guardrail triggers.
91 */
92 action?: ces.TriggerAction;
93};
94
95export type AppsGuardrail = Resource<
96 "GCP.CES.AppsGuardrail",
97 AppsGuardrailProps,
98 {
99 /** Full resource name `.../apps/{app}/guardrails/{guardrail}`. */
100 name: string;
101 /** Guardrail id (last path segment). */
102 guardrailId: string;
103 /** Parent app resource name. */
104 app: string;
105 /** Location id. */
106 location: string;
107 /** Project id. */
108 project: string;
109 /** Display name. */
110 displayName: string | undefined;
111 /** User description with the Alchemy ownership prefix stripped. */
112 description: string | undefined;
113 /** Whether the guardrail is enabled. */
114 enabled: boolean;
115 /** Content filter. */
116 contentFilter: ces.GuardrailContentFilter | undefined;
117 /** LLM policy. */
118 llmPolicy: ces.GuardrailLlmPolicy | undefined;
119 /** Prompt-security check. */
120 llmPromptSecurity: ces.GuardrailLlmPromptSecurity | undefined;
121 /** Model safety overrides. */
122 modelSafety: ces.GuardrailModelSafety | undefined;
123 /** Code-callback check. */
124 codeCallback: ces.GuardrailCodeCallback | undefined;
125 /** Trigger action. */
126 action: ces.TriggerAction | undefined;
127 /** RFC3339 creation timestamp. */
128 createTime: string | undefined;
129 /** RFC3339 last-update timestamp. */
130 updateTime: string | undefined;
131 /** Server-assigned etag. */
132 etag: string | undefined;
133 },
134 never,
135 Providers
136>;
137
138/**
139 * A Customer Engagement Suite guardrail — content filter, LLM policy,
140 * model safety, or callback checks attached to an app or agent.
141 *
142 * Guardrails have no labels field — Alchemy stamps ownership into
143 * `description` so `list` / nuke can find them. Parent app, location,
144 * and guardrail id are immutable.
145 *
146 * ### Creating a Guardrail
147 * **Example:** Banned-phrase filter
148 * ```typescript
149 * const guardrail = yield* GCP.CES.AppsGuardrail("Safety", {
150 * app: app.name,
151 * displayName: "safety",
152 * contentFilter: {
153 * matchType: "SIMPLE_STRING_MATCH",
154 * bannedContents: ["forbidden"],
155 * },
156 * });
157 * ```
158 *
159 * @resource
160 * @category CES
161 */
162export const AppsGuardrail = Resource<AppsGuardrail>("GCP.CES.AppsGuardrail");
163
164export class AppsGuardrailNotResolved extends Data.TaggedError(
165 "GCP.CES.AppsGuardrailNotResolved",
166)<{
167 name: string;
168}> {}
169
170const resourceName = (app: string, guardrailId: string) =>
171 `${app}/guardrails/${guardrailId}`;
172
173const defaultFilter = (news: AppsGuardrailProps) => {
174 if (
175 news.contentFilter ||
176 news.llmPolicy ||
177 news.llmPromptSecurity ||
178 news.modelSafety ||
179 news.codeCallback
180 ) {
181 return news.contentFilter;
182 }
183 return DEFAULT_CONTENT_FILTER;
184};
185
186const toAttrs = (
187 guardrail: ces.Guardrail,
188 project: string,
189 region: string,
190 appHint?: string,
191) => {
192 const name = guardrail.name ?? "";
193 const parsed = parseResourceName(name, "guardrails", region);
194 return {
195 name,
196 guardrailId: parsed.id,
197 app: name.includes("/guardrails/")
198 ? parsed.app
199 : (appHint ?? parsed.parent),
200 location: parsed.location,
201 project: parsed.project || project,
202 displayName: guardrail.displayName,
203 description: parseOwnership(guardrail.description).text,
204 enabled: guardrail.enabled !== false,
205 contentFilter: guardrail.contentFilter,
206 llmPolicy: guardrail.llmPolicy,
207 llmPromptSecurity: guardrail.llmPromptSecurity,
208 modelSafety: guardrail.modelSafety,
209 codeCallback: guardrail.codeCallback,
210 action: guardrail.action,
211 createTime: guardrail.createTime,
212 updateTime: guardrail.updateTime,
213 etag: guardrail.etag,
214 };
215};
216
217const getByName = (name: string) =>
218 name.length === 0
219 ? Effect.succeed(undefined)
220 : ces
221 .getProjectsLocationsAppsGuardrails({ name })
222 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
223
224const listAt = (parent: string, project: string, region: string) =>
225 collectPages(
226 ces.listProjectsLocationsAppsGuardrails.pages({ parent, pageSize: 100 }),
227 (page) => page.guardrails,
228 ).pipe(
229 Effect.map((guardrails) =>
230 guardrails
231 .filter((guardrail) => hasOwnershipMarker(guardrail.description))
232 .map((guardrail) => toAttrs(guardrail, project, region, parent)),
233 ),
234 );
235
236export const AppsGuardrailProvider = () =>
237 Provider.succeed(AppsGuardrail, {
238 stables: [
239 "name",
240 "guardrailId",
241 "app",
242 "location",
243 "project",
244 "createTime",
245 ],
246
247 diff: Effect.fn(function* ({ news, olds, output }) {
248 if (!isResolved(news)) return undefined;
249 return replaceOnIdentity({
250 previousId: olds?.guardrailId ?? output?.guardrailId,
251 nextId: news.guardrailId,
252 previousParent: olds?.app ?? output?.app,
253 nextParent: news.app,
254 });
255 }),
256
257 read: Effect.fn(function* ({ id, olds, output }) {
258 const env = yield* GcpEnvironment.current;
259 const location = normalizeLocation(
260 olds?.location ?? output?.location,
261 env.region,
262 );
263 const app = olds?.app
264 ? expandApp(olds.app, env.project, location)
265 : output?.app;
266 const guardrailId = yield* toPhysicalId(
267 id,
268 olds?.guardrailId,
269 output?.guardrailId,
270 );
271 const name =
272 output?.name ??
273 (app !== undefined ? resourceName(app, guardrailId) : "");
274 const existing = yield* getByName(name);
275 if (existing === undefined) return undefined;
276 const attrs = toAttrs(existing, env.project, env.region, app);
277 return (yield* ownedByAlchemy(id, existing.description))
278 ? attrs
279 : Unowned(attrs);
280 }),
281
282 list: () =>
283 Effect.gen(function* () {
284 const env = yield* GcpEnvironment.current;
285 return yield* forEachApp(
286 env.project,
287 (parent) => listAt(parent, env.project, env.region),
288 env.region,
289 );
290 }),
291
292 reconcile: Effect.fn(function* ({ id, news, output }) {
293 const env = yield* GcpEnvironment.current;
294 const location = normalizeLocation(
295 news.location ?? output?.location,
296 env.region,
297 );
298 const app = expandApp(news.app, env.project, location);
299 const guardrailId = yield* toPhysicalId(
300 id,
301 news.guardrailId,
302 output?.guardrailId,
303 );
304 const name = output?.name ?? resourceName(app, guardrailId);
305 const ownership = yield* createInternalLabels(id);
306 const description = encodeOwnership(ownership, news.description);
307 const displayName = news.displayName ?? guardrailId;
308 const enabled = news.enabled !== false;
309 const contentFilter = defaultFilter(news);
310
311 let current = yield* getByName(name);
312
313 if (current === undefined) {
314 const created = yield* retryTransient(
315 ces.createProjectsLocationsAppsGuardrails({
316 parent: app,
317 guardrailId,
318 body: {
319 displayName,
320 description,
321 enabled,
322 contentFilter,
323 llmPolicy: news.llmPolicy,
324 llmPromptSecurity: news.llmPromptSecurity,
325 modelSafety: news.modelSafety,
326 codeCallback: news.codeCallback,
327 action: news.action,
328 },
329 }),
330 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
331 current = created ?? undefined;
332 }
333
334 if (current === undefined) {
335 return yield* new AppsGuardrailNotResolved({ name });
336 }
337
338 const currentName = current.name ?? name;
339 const displayChanged = !sameText(current.displayName, displayName);
340 const descriptionChanged = !sameText(current.description, description);
341 const enabledChanged = (current.enabled !== false) !== enabled;
342 const filterChanged = !sameJson(current.contentFilter, contentFilter);
343 const policyChanged = !sameJson(current.llmPolicy, news.llmPolicy);
344 const promptChanged = !sameJson(
345 current.llmPromptSecurity,
346 news.llmPromptSecurity,
347 );
348 const safetyChanged = !sameJson(current.modelSafety, news.modelSafety);
349 const callbackChanged = !sameJson(
350 current.codeCallback,
351 news.codeCallback,
352 );
353 const actionChanged = !sameJson(current.action, news.action);
354
355 if (
356 displayChanged ||
357 descriptionChanged ||
358 enabledChanged ||
359 filterChanged ||
360 policyChanged ||
361 promptChanged ||
362 safetyChanged ||
363 callbackChanged ||
364 actionChanged
365 ) {
366 current = yield* retryTransient(
367 ces.patchProjectsLocationsAppsGuardrails({
368 name: currentName,
369 updateMask: updateMaskOf(
370 displayChanged ? "display_name" : undefined,
371 descriptionChanged ? "description" : undefined,
372 enabledChanged ? "enabled" : undefined,
373 filterChanged ? "content_filter" : undefined,
374 policyChanged ? "llm_policy" : undefined,
375 promptChanged ? "llm_prompt_security" : undefined,
376 safetyChanged ? "model_safety" : undefined,
377 callbackChanged ? "code_callback" : undefined,
378 actionChanged ? "action" : undefined,
379 ),
380 body: {
381 displayName,
382 description,
383 enabled,
384 contentFilter,
385 llmPolicy: news.llmPolicy,
386 llmPromptSecurity: news.llmPromptSecurity,
387 modelSafety: news.modelSafety,
388 codeCallback: news.codeCallback,
389 action: news.action,
390 },
391 }),
392 );
393 }
394
395 return toAttrs(current, env.project, env.region, app);
396 }),
397
398 delete: Effect.fn(function* ({ output }) {
399 if (!output.name) return;
400 // `force: true` makes guardrail deletes fail with 500 INTERNAL.
401 yield* retryTransient(
402 ces.deleteProjectsLocationsAppsGuardrails({ name: output.name }),
403 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
404 yield* waitUntilGone(getByName(output.name));
405 }),
406 });