From 8b62a2883b905f3186a4f742cb4c388c89a67bd7 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 11 Jun 2026 11:25:12 +0200 Subject: [PATCH] feat(cloudflare/ai-gateway): add AiGatewayProviderKey (BYOK provider keys) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declarative BYOK provider keys for AI Gateway. The resource owns both halves of Cloudflare's BYOK contract: the Secrets Store secret named per the {gatewayId}_{provider}_{alias} lookup convention (scoped to ai_gateway) and the gateway provider config that activates it. - Rotation: a value change patches the named secret in place — runtime lookup is by name, so traffic picks up the new key immediately. - defaultConfig / rate limits: immutable in place on the API, converged by recreating the config row (the secret is untouched). - provider / alias / gateway / store changes replace the resource (the secret name is the identity). - read brands name-matched configs Unowned (no ownership signal). Depends on @distilled.cloud/cloudflare gaining updateProviderConfig / deleteProviderConfig and optional secret/secretId on create (alchemy-run/distilled#336). Co-Authored-By: Claude Fable 5 --- .../AiGateway/AiGatewayProviderKey.ts | 511 ++++++++++++++++++ .../alchemy/src/Cloudflare/AiGateway/index.ts | 1 + packages/alchemy/src/Cloudflare/Providers.ts | 2 + .../AiGateway/AiGatewayProviderKey.test.ts | 193 +++++++ 4 files changed, 707 insertions(+) create mode 100644 packages/alchemy/src/Cloudflare/AiGateway/AiGatewayProviderKey.ts create mode 100644 packages/alchemy/test/Cloudflare/AiGateway/AiGatewayProviderKey.test.ts diff --git a/packages/alchemy/src/Cloudflare/AiGateway/AiGatewayProviderKey.ts b/packages/alchemy/src/Cloudflare/AiGateway/AiGatewayProviderKey.ts new file mode 100644 index 000000000..24afd9158 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/AiGateway/AiGatewayProviderKey.ts @@ -0,0 +1,511 @@ +import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway"; +import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { Unowned } from "../../AdoptPolicy.ts"; +import { deepEqual, isResolved } from "../../Diff.ts"; +import * as Provider from "../../Provider.ts"; +import { Resource } from "../../Resource.ts"; +import type { Providers } from "../Providers.ts"; + +export type AiGatewayProviderKeyProps = { + /** + * The AI Gateway this provider key belongs to. The gateway must be + * configured with the same Secrets Store via its `storeId` prop. + */ + gateway: { + gatewayId: string; + accountId: string; + storeId: string; + }; + /** + * The Secrets Store that holds the key material. AI Gateway resolves + * BYOK keys from the account's Secrets Store by name, so this is the + * account's (single) store — pass the `SecretsStore` resource. + */ + store: { + storeId: string; + accountId: string; + }; + /** + * The AI provider slug, e.g. `"openai"`, `"anthropic"`, + * `"google-ai-studio"`. Must match the provider segment used in + * gateway request URLs. + */ + provider: string; + /** + * Alias distinguishing multiple keys for the same provider. Requests + * select a non-default key with the `cf-aig-byok-alias` header. + * + * @default "default" + */ + alias?: string; + /** + * The provider API key. Treated as redacted and never logged. + */ + value: Redacted.Redacted; + /** + * Whether this key is the gateway's default config for the provider. + * + * @default true when `alias` is "default" + */ + defaultConfig?: boolean; + /** + * Maximum requests through this provider key per `rateLimitPeriod`. + * Set to `null` (or omit) to disable rate limiting. + * + * @default null + */ + rateLimit?: number | null; + /** + * Rate limiting window in seconds. Only meaningful with `rateLimit`. + * + * @default 60 when `rateLimit` is set + */ + rateLimitPeriod?: number | null; +}; + +export type AiGatewayProviderKey = Resource< + "Cloudflare.AiGatewayProviderKey", + AiGatewayProviderKeyProps, + { + configId: string; + accountId: string; + gatewayId: string; + provider: string; + alias: string; + defaultConfig: boolean; + secretId: string; + secretName: string; + storeId: string; + rateLimit: number | null; + rateLimitPeriod: number | null; + modifiedAt: string; + }, + never, + Providers +>; + +export const isAiGatewayProviderKey = ( + value: unknown, +): value is AiGatewayProviderKey => + typeof value === "object" && + value !== null && + "Type" in value && + (value as AiGatewayProviderKey).Type === "Cloudflare.AiGatewayProviderKey"; + +/** + * A BYOK (bring-your-own-key) provider key on a Cloudflare AI Gateway. + * + * AI Gateway resolves BYOK keys from the account's Secrets Store by the + * naming convention `{gatewayId}_{provider}_{alias}`. This resource owns + * both halves of that contract: the Secrets Store secret (named per the + * convention, scoped to `ai_gateway`) and the gateway's provider config + * that activates it. Requests through the gateway then authenticate to + * the provider without carrying API keys in headers. + * + * The key value is treated as redacted and only ever sent to Cloudflare. + * Changing `value` rotates the stored secret in place — gateway traffic + * picks up the new key immediately. Changing `provider` or `alias` + * changes the key's identity and replaces the resource. + * + * @section Creating a Provider Key + * @example BYOK key for a provider + * ```typescript + * const store = yield* Cloudflare.SecretsStore("Store"); + * const gateway = yield* Cloudflare.AiGateway("Gateway", { + * storeId: store.storeId, + * }); + * + * const openaiKey = yield* Cloudflare.AiGatewayProviderKey("OpenAIKey", { + * gateway, + * store, + * provider: "openai", + * value: yield* Config.redacted("OPENAI_API_KEY"), + * }); + * ``` + * + * @section Multiple Keys per Provider + * @example Aliased keys + * Requests use the `default` alias unless they send a + * `cf-aig-byok-alias` header naming another alias. + * ```typescript + * const production = yield* Cloudflare.AiGatewayProviderKey("OpenAIProd", { + * gateway, + * store, + * provider: "openai", + * value: yield* Config.redacted("OPENAI_API_KEY"), + * }); + * + * const evals = yield* Cloudflare.AiGatewayProviderKey("OpenAIEvals", { + * gateway, + * store, + * provider: "openai", + * alias: "evals", + * value: yield* Config.redacted("OPENAI_EVALS_API_KEY"), + * }); + * ``` + * + * @section Rate Limiting + * @example Limit requests through a key + * ```typescript + * const limited = yield* Cloudflare.AiGatewayProviderKey("AnthropicKey", { + * gateway, + * store, + * provider: "anthropic", + * value: yield* Config.redacted("ANTHROPIC_API_KEY"), + * rateLimit: 100, + * rateLimitPeriod: 60, + * }); + * ``` + */ +export const AiGatewayProviderKey = Resource( + "Cloudflare.AiGatewayProviderKey", +); + +export const AiGatewayProviderKeyProvider = () => + Provider.succeed(AiGatewayProviderKey, { + stables: [ + "configId", + "secretId", + "secretName", + "storeId", + "gatewayId", + "accountId", + ], + diff: Effect.fn(function* ({ olds = {} as any, news, output }) { + if (!isResolved(news)) return undefined; + // The secret name {gatewayId}_{provider}_{alias} is the key's + // identity — AI Gateway looks the key up by name at request time. + // Any change to a name component (or to the account/store that + // hosts the secret) is a replacement. + const oldGatewayId = output?.gatewayId ?? olds.gateway?.gatewayId; + const oldAccountId = output?.accountId ?? olds.gateway?.accountId; + const oldStoreId = output?.storeId ?? olds.store?.storeId; + const oldProvider = output?.provider ?? olds.provider; + const oldAlias = output?.alias ?? resolveAlias(olds.alias); + if ( + oldGatewayId !== news.gateway.gatewayId || + oldAccountId !== news.gateway.accountId || + oldStoreId !== news.store.storeId || + oldProvider !== news.provider || + oldAlias !== resolveAlias(news.alias) + ) { + return { action: "replace" } as const; + } + const oldValue = olds.value ? Redacted.value(olds.value) : undefined; + if (oldValue !== Redacted.value(news.value)) { + return { action: "update" } as const; + } + const oldMutable = output ? mutable(output) : desired(olds); + if (!deepEqual(oldMutable, desired(news))) { + return { action: "update" } as const; + } + }), + reconcile: Effect.fn(function* ({ news, output }) { + const accountId = news.gateway.accountId; + const gatewayId = news.gateway.gatewayId; + const storeId = news.store.storeId; + if (news.gateway.storeId !== storeId) { + return yield* Effect.die( + new Error( + `AiGatewayProviderKey requires gateway.storeId (${news.gateway.storeId}) to match store.storeId (${storeId}). Create the gateway with { storeId: store.storeId }.`, + ), + ); + } + const alias = resolveAlias(news.alias); + const name = secretName(gatewayId, news.provider, alias); + const next = desired(news); + + // Observe — re-fetch the cached secret; fall back to a name scan + // over the store so we recover from out-of-band deletes or partial + // state-persistence failures. + let secret: { id: string; name: string; storeId: string } | undefined; + if (output?.secretId) { + secret = yield* secretsStore + .getStoreSecret({ + accountId, + storeId: output.storeId, + secretId: output.secretId, + }) + .pipe( + Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)), + Effect.catchTag("StoreNotFound", () => Effect.succeed(undefined)), + ); + } + if (!secret) { + secret = yield* findSecretByName(accountId, storeId, name); + } + + // Ensure — create the named secret if missing. The `ai_gateway` + // scope is what makes it visible to AI Gateway's BYOK lookup. + // Cloudflare reports a concurrent create as + // `SecretNameAlreadyExists`; tolerate by re-listing and adopting + // the secret with the same name. + if (!secret) { + const created = yield* secretsStore + .createStoreSecret({ + accountId, + storeId, + body: [ + { + name, + scopes: SCOPES, + value: Redacted.value(news.value), + }, + ], + }) + .pipe( + Effect.catchTag("SecretNameAlreadyExists", () => + Effect.succeed(undefined), + ), + ); + if (created) { + const result = created.result[0]!; + secret = { id: result.id, name: result.name, storeId }; + } else { + secret = yield* findSecretByName(accountId, storeId, name); + if (!secret) { + return yield* Effect.die( + new Error( + `Secret '${name}' reported as already existing in store ${storeId} but could not be found on lookup.`, + ), + ); + } + } + } + + // Sync — always push the desired value and scopes onto the secret. + // The value can't be read back from the API, so the PATCH is the + // convergence point for rotation, drift, and adoption alike. + yield* secretsStore.patchStoreSecret({ + accountId, + storeId, + secretId: secret.id, + scopes: SCOPES, + value: Redacted.value(news.value), + }); + + // Observe — the gateway's provider config for this provider+alias. + // There is no GET-by-id, so scan the (small) config list. + let config = yield* findConfig( + accountId, + gatewayId, + news.provider, + alias, + ); + + // Sync — the provider-config update API only rotates the secret + // value; `default_config` and rate limits are immutable in place. + // Structural drift is converged by recreating the config row (the + // underlying secret is untouched, so there is no key downtime + // window for the *other* configs). + if ( + config && + (config.secretId !== secret.id || !deepEqual(mutable(config), next)) + ) { + yield* aiGateway + .deleteProviderConfig({ + accountId, + gatewayId, + id: config.id, + }) + .pipe(Effect.catchTag("ProviderConfigNotFound", () => Effect.void)); + config = undefined; + } + + // Ensure — create the config row referencing the named secret. + if (!config) { + config = yield* aiGateway + .createProviderConfig({ + accountId, + gatewayId, + providerSlug: news.provider, + alias, + defaultConfig: next.defaultConfig, + secretId: secret.id, + rateLimit: next.rateLimit ?? undefined, + rateLimitPeriod: + next.rateLimit != null + ? (next.rateLimitPeriod ?? undefined) + : undefined, + }) + .pipe( + // The storeId guard above catches permanent misconfiguration. + // This typed error can still happen briefly after a secret + // create/patch while AI Gateway catches up to Secrets Store. + Effect.retry({ + while: (e) => e._tag === "ProviderConfigSecretNotFound", + schedule: Schedule.spaced("5 seconds"), + times: 10, + }), + ); + } + + return { + configId: config.id, + accountId, + gatewayId, + provider: config.providerSlug, + alias: config.alias, + defaultConfig: Boolean(config.defaultConfig), + secretId: secret.id, + secretName: name, + storeId, + rateLimit: config.rateLimit ?? null, + rateLimitPeriod: config.rateLimitPeriod ?? null, + modifiedAt: config.modifiedAt, + }; + }), + delete: Effect.fn(function* ({ output }) { + // Remove the gateway config first — it references the secret. + yield* aiGateway + .deleteProviderConfig({ + accountId: output.accountId, + gatewayId: output.gatewayId, + id: output.configId, + }) + .pipe( + // Covers both a missing config row and a gateway that was + // already torn down — the route 404s with code 7002 either way. + Effect.catchTag("ProviderConfigNotFound", () => Effect.void), + ); + yield* secretsStore + .deleteStoreSecret({ + accountId: output.accountId, + storeId: output.storeId, + secretId: output.secretId, + }) + .pipe( + Effect.catchTag("SecretNotFound", () => Effect.void), + Effect.catchTag("StoreNotFound", () => Effect.void), + Effect.catchTag("NotFound", () => Effect.void), + ); + }), + read: Effect.fn(function* ({ olds, output }) { + if (output?.configId) { + const config = yield* aiGateway.listProviderConfigs + .items({ + accountId: output.accountId, + gatewayId: output.gatewayId, + }) + .pipe( + Stream.filter((c) => c.id === output.configId), + Stream.runHead, + Effect.map(Option.getOrUndefined), + ); + if (!config) return undefined; + return { + configId: config.id, + accountId: output.accountId, + gatewayId: output.gatewayId, + provider: config.providerSlug, + alias: config.alias, + defaultConfig: Boolean(config.defaultConfig), + secretId: config.secretId, + secretName: output.secretName, + storeId: output.storeId, + rateLimit: config.rateLimit ?? null, + rateLimitPeriod: config.rateLimitPeriod ?? null, + modifiedAt: config.modifiedAt, + }; + } + if (!olds?.gateway || !olds?.store) return undefined; + const alias = resolveAlias(olds.alias); + const config = yield* findConfig( + olds.gateway.accountId, + olds.gateway.gatewayId, + olds.provider, + alias, + ); + if (!config) return undefined; + // Provider configs carry no ownership signal, so a provider+alias + // match is not proof we own it. Brand it `Unowned` so the engine + // surfaces `OwnedBySomeoneElse` unless the caller opted in via + // `--adopt`. + return Unowned({ + configId: config.id, + accountId: olds.gateway.accountId, + gatewayId: olds.gateway.gatewayId, + provider: config.providerSlug, + alias: config.alias, + defaultConfig: Boolean(config.defaultConfig), + secretId: config.secretId, + secretName: secretName(olds.gateway.gatewayId, olds.provider, alias), + storeId: olds.store.storeId, + rateLimit: config.rateLimit ?? null, + rateLimitPeriod: config.rateLimitPeriod ?? null, + modifiedAt: config.modifiedAt, + }); + }), + }); + +// AI Gateway's BYOK lookup only sees secrets carrying this scope. +const SCOPES = ["ai_gateway"]; + +const resolveAlias = (alias: string | undefined): string => alias ?? "default"; + +/** + * The Secrets Store naming convention AI Gateway uses to resolve BYOK + * keys at request time. The `secret_id` is not used for runtime lookup, + * so the name is the binding contract. + */ +const secretName = (gatewayId: string, provider: string, alias: string) => + `${gatewayId}_${provider}_${alias}`; + +// Resolve the desired mutable config shape from props (or prior props), +// applying defaults. `rateLimitPeriod` mirrors the API's server-side +// default of 60 when rate limiting is enabled so reconcile converges to +// noop when the user didn't set it explicitly. +const desired = (props: { + alias?: string; + defaultConfig?: boolean; + rateLimit?: number | null; + rateLimitPeriod?: number | null; +}) => + mutable({ + defaultConfig: + props.defaultConfig ?? resolveAlias(props.alias) === "default", + rateLimit: props.rateLimit, + rateLimitPeriod: props.rateLimitPeriod, + }); + +// Normalize the mutable config fields (from props, attributes, or an +// observed API response) for diffing. A rate-limit period is only +// meaningful while rate limiting is enabled, so it collapses to null +// without a limit — the API reports its default of 60 either way. +const mutable = (v: { + defaultConfig: unknown; + rateLimit?: number | null; + rateLimitPeriod?: number | null; +}) => { + const rateLimit = v.rateLimit ?? null; + return { + defaultConfig: Boolean(v.defaultConfig), + rateLimit, + rateLimitPeriod: rateLimit != null ? (v.rateLimitPeriod ?? 60) : null, + }; +}; + +const findSecretByName = (accountId: string, storeId: string, name: string) => + secretsStore.listStoreSecrets.items({ accountId, storeId }).pipe( + Stream.filter((s) => s.name === name), + Stream.runHead, + Effect.catchTag("StoreNotFound", () => Effect.succeedNone), + Effect.map(Option.getOrUndefined), + ); + +const findConfig = ( + accountId: string, + gatewayId: string, + provider: string, + alias: string, +) => + aiGateway.listProviderConfigs.items({ accountId, gatewayId }).pipe( + Stream.filter((c) => c.providerSlug === provider && c.alias === alias), + Stream.runHead, + Effect.map(Option.getOrUndefined), + ); diff --git a/packages/alchemy/src/Cloudflare/AiGateway/index.ts b/packages/alchemy/src/Cloudflare/AiGateway/index.ts index db5a7ccc1..1cd3c991f 100644 --- a/packages/alchemy/src/Cloudflare/AiGateway/index.ts +++ b/packages/alchemy/src/Cloudflare/AiGateway/index.ts @@ -1,6 +1,7 @@ export * from "./AiGateway.ts"; export * from "./AiGatewayBinding.ts"; export * from "./LanguageModel.ts"; +export * from "./AiGatewayProviderKey.ts"; export * from "./AiGatewaySpendingLimit.ts"; export * from "./Dataset.ts"; export * from "./DynamicRouting.ts"; diff --git a/packages/alchemy/src/Cloudflare/Providers.ts b/packages/alchemy/src/Cloudflare/Providers.ts index 0f5ec68bc..6b1cebdd3 100644 --- a/packages/alchemy/src/Cloudflare/Providers.ts +++ b/packages/alchemy/src/Cloudflare/Providers.ts @@ -180,6 +180,7 @@ export const providers = () => AiGateway.AiGatewayDynamicRouting, AiGateway.AiGatewayEvaluation, AiGateway.AiGatewayProviderConfig, + AiGateway.AiGatewayProviderKey, AiGateway.AiGatewaySpendingLimit, AiSearch.AiSearchInstance, AiSearch.AiSearchNamespace, @@ -435,6 +436,7 @@ export const providers = () => AccessPol.AccessPolicyProvider(), AiGateway.AiGatewayBindingPolicyLive, AiGateway.AiGatewayProvider(), + AiGateway.AiGatewayProviderKeyProvider(), AiGateway.AiGatewaySpendingLimitProvider(), AnalyticsEngine.AnalyticsEngineDatasetBindingPolicyLive, ApiToken.AccountApiTokenProvider(), diff --git a/packages/alchemy/test/Cloudflare/AiGateway/AiGatewayProviderKey.test.ts b/packages/alchemy/test/Cloudflare/AiGateway/AiGatewayProviderKey.test.ts new file mode 100644 index 000000000..ca37a31bf --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AiGateway/AiGatewayProviderKey.test.ts @@ -0,0 +1,193 @@ +import * as Cloudflare from "@/Cloudflare"; +import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; +import * as Test from "@/Test/Vitest"; +import * as aiGateway from "@distilled.cloud/cloudflare/ai-gateway"; +import * as secretsStore from "@distilled.cloud/cloudflare/secrets-store"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { MinimumLogLevel } from "effect/References"; + +const { test } = Test.make({ + providers: Cloudflare.providers(), +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +test.provider("create, rotate, and delete a BYOK provider key", (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const gatewayId = "alchemy-test-aigw-byok"; + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore("Store"); + const gateway = yield* Cloudflare.AiGateway("Gateway", { + id: gatewayId, + storeId: store.storeId, + }); + return yield* Cloudflare.AiGatewayProviderKey("OpenAIKey", { + gateway, + store, + provider: "openai", + value: Redacted.make("alchemy-test-not-a-real-key-1"), + }); + }), + ); + + expect(deployed.gatewayId).toEqual(gatewayId); + expect(deployed.provider).toEqual("openai"); + expect(deployed.alias).toEqual("default"); + expect(deployed.defaultConfig).toEqual(true); + expect(deployed.rateLimit).toEqual(null); + // The Secrets Store secret follows AI Gateway's BYOK lookup + // convention: {gatewayId}_{provider}_{alias}. + expect(deployed.secretName).toEqual(`${gatewayId}_openai_default`); + + // Verify the provider config exists on the gateway and references + // the secret we created. + const configs = yield* aiGateway.listProviderConfigs({ + accountId, + gatewayId, + }); + const config = configs.result.find((c) => c.id === deployed.configId); + expect(config).toBeDefined(); + expect(config!.providerSlug).toEqual("openai"); + expect(config!.alias).toEqual("default"); + expect(config!.secretId).toEqual(deployed.secretId); + + // Verify the secret exists under the convention name. + const secret = yield* secretsStore.getStoreSecret({ + accountId, + storeId: deployed.storeId, + secretId: deployed.secretId, + }); + expect(secret.name).toEqual(`${gatewayId}_openai_default`); + + // Rotate the key value and enable a rate limit. Rotation patches + // the secret in place; the rate-limit change recreates the config + // row — both keep the secret's identity. + const rotated = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore("Store"); + const gateway = yield* Cloudflare.AiGateway("Gateway", { + id: gatewayId, + storeId: store.storeId, + }); + return yield* Cloudflare.AiGatewayProviderKey("OpenAIKey", { + gateway, + store, + provider: "openai", + value: Redacted.make("alchemy-test-not-a-real-key-2"), + rateLimit: 50, + }); + }), + ); + + expect(rotated.secretId).toEqual(deployed.secretId); + expect(rotated.secretName).toEqual(deployed.secretName); + expect(rotated.rateLimit).toEqual(50); + + const rotatedConfigs = yield* aiGateway.listProviderConfigs({ + accountId, + gatewayId, + }); + const rotatedConfig = rotatedConfigs.result.find( + (c) => c.id === rotated.configId, + ); + expect(rotatedConfig).toBeDefined(); + expect(rotatedConfig!.rateLimit).toEqual(50); + expect(rotatedConfig!.secretId).toEqual(deployed.secretId); + + yield* stack.destroy(); + + // The provider config is gone with the gateway; the secret must be + // gone from the (account-level, surviving) Secrets Store. + const secretAfter = yield* secretsStore + .getStoreSecret({ + accountId, + storeId: deployed.storeId, + secretId: deployed.secretId, + }) + .pipe( + Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)), + Effect.catchTag("StoreNotFound", () => Effect.succeed(undefined)), + ); + expect(secretAfter).toBeUndefined(); + }).pipe(logLevel), +); + +test.provider("changing the alias replaces the provider key", (stack) => + Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + + yield* stack.destroy(); + + const gatewayId = "alchemy-test-aigw-byok-alias"; + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore("Store"); + const gateway = yield* Cloudflare.AiGateway("Gateway", { + id: gatewayId, + storeId: store.storeId, + }); + return yield* Cloudflare.AiGatewayProviderKey("AnthropicKey", { + gateway, + store, + provider: "anthropic", + value: Redacted.make("alchemy-test-not-a-real-key-1"), + }); + }), + ); + expect(initial.secretName).toEqual(`${gatewayId}_anthropic_default`); + + // A new alias is a new identity: new secret name, new config row. + const replaced = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore("Store"); + const gateway = yield* Cloudflare.AiGateway("Gateway", { + id: gatewayId, + storeId: store.storeId, + }); + return yield* Cloudflare.AiGatewayProviderKey("AnthropicKey", { + gateway, + store, + provider: "anthropic", + alias: "evals", + value: Redacted.make("alchemy-test-not-a-real-key-1"), + }); + }), + ); + + expect(replaced.alias).toEqual("evals"); + expect(replaced.secretName).toEqual(`${gatewayId}_anthropic_evals`); + expect(replaced.secretId).not.toEqual(initial.secretId); + // A non-default alias is not the provider's default config. + expect(replaced.defaultConfig).toEqual(false); + + // The old identity is cleaned up: neither the old config row nor + // the old secret survive the replacement. + const configs = yield* aiGateway.listProviderConfigs({ + accountId, + gatewayId, + }); + expect(configs.result.map((c) => c.id)).not.toContain(initial.configId); + const oldSecret = yield* secretsStore + .getStoreSecret({ + accountId, + storeId: initial.storeId, + secretId: initial.secretId, + }) + .pipe(Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined))); + expect(oldSecret).toBeUndefined(); + + yield* stack.destroy(); + }).pipe(logLevel), +);