diff --git a/packages/alchemy/src/Cloudflare/AI/GatewayProvider.ts b/packages/alchemy/src/Cloudflare/AI/GatewayProvider.ts index 2e1dc2d0e6..cc49bc6503 100644 --- a/packages/alchemy/src/Cloudflare/AI/GatewayProvider.ts +++ b/packages/alchemy/src/Cloudflare/AI/GatewayProvider.ts @@ -145,10 +145,11 @@ export type GatewayProvider = Resource< * }); * * // The secret name must be `{gatewayId}_{providerSlug}_{alias}`. + * // Prefer `Cloudflare.AI.ProviderKey` to wire this secret automatically. * const secret = yield* Cloudflare.SecretsStore.Secret("OpenAiKey", { * store, * name: "my-gateway_openai_default", - * value: Redacted.make(process.env.OPENAI_API_KEY!), + * value: yield* Config.redacted("OPENAI_API_KEY"), * scopes: ["ai_gateway"], * }); * diff --git a/packages/alchemy/src/Cloudflare/AI/ProviderKey.ts b/packages/alchemy/src/Cloudflare/AI/ProviderKey.ts new file mode 100644 index 0000000000..1feab455f9 --- /dev/null +++ b/packages/alchemy/src/Cloudflare/AI/ProviderKey.ts @@ -0,0 +1,161 @@ +import * as Effect from "effect/Effect"; +import type * as Redacted from "effect/Redacted"; +import type { InputProps } from "../../Input.ts"; +import * as Namespace from "../../Namespace.ts"; +import * as Output from "../../Output.ts"; +import { Secret } from "../SecretsStore/Secret.ts"; +import { GatewayProvider } from "./GatewayProvider.ts"; + +export interface ProviderKeyProps { + /** + * The AI Gateway the provider key belongs to. The gateway must have its + * `storeId` set to a Secrets Store id — Cloudflare resolves the key inside + * that store. Changing the gateway triggers a replacement. + */ + gatewayId: string; + /** + * The upstream provider the key authenticates against (e.g. `openai`, + * `anthropic`, `workers-ai`). Changing the provider triggers a + * replacement. + */ + providerSlug: string; + /** + * The Secrets Store attached to the AI Gateway via `storeId`. + */ + store: { + storeId: string; + accountId: string; + }; + /** + * The provider API key. Stored in Cloudflare Secrets Store and never bound + * into the Worker runtime. + */ + value: Redacted.Redacted; + /** + * Alias distinguishing multiple keys for the same provider. Changing the + * alias renames the backing secret, replacing it and the provider config. + * @default "default" + */ + alias?: string; + /** + * Optional free-form description on the Secrets Store secret. + */ + comment?: string; + /** + * Whether this key is the gateway's default credential for the provider + * (used when a request does not name a specific key). + * @default false + */ + defaultConfig?: boolean; + /** + * Maximum number of requests allowed per `rateLimitPeriod` through this + * key. Omit for no limit. Changing the limit triggers a replacement + * (Cloudflare exposes no update API for provider configs). + */ + rateLimit?: number; + /** + * The rate limit window in seconds. + * @default 60 + */ + rateLimitPeriod?: number; +} + +export type ProviderKey = { + /** + * The Secrets Store secret holding the provider API key, named + * `{gatewayId}_{providerSlug}_{alias}` and scoped to `ai_gateway`. + */ + readonly secret: Secret; + /** + * The gateway's BYOK provider config referencing {@link secret}. + */ + readonly gatewayProvider: GatewayProvider; +}; + +/** + * Declares a Cloudflare AI Gateway BYOK provider key. + * + * Cloudflare requires BYOK secrets to live in the gateway's attached Secrets + * Store, be scoped to `ai_gateway`, and use the exact + * `{gatewayId}_{providerSlug}_{alias}` name. This helper keeps that naming + * contract with the {@link GatewayProvider} declaration so app stacks do not + * have to wire the secret and provider config manually. + * + * The children are namespaced under the given id: a {@link Secret} (child + * `Secret`) holding the key, and a {@link GatewayProvider} (child `Provider`) + * referencing it. It returns `{ secret, gatewayProvider }` so either + * underlying resource stays addressable. + * + * Rotating `value` updates the secret in place. Changing `alias` (or + * `providerSlug`) renames the secret — a replacement — and cascades: the + * provider config is replaced and re-pointed at the new secret. + * + * @resource + * @product AI Gateway + * @category AI + * @section Bringing your own key + * @example Bring your own OpenAI key + * ```typescript + * const store = yield* Cloudflare.SecretsStore.Store("Store"); + * + * const gateway = yield* Cloudflare.AI.Gateway("Gateway", { + * id: "my-gateway", + * storeId: store.storeId, + * }); + * + * const { secret, gatewayProvider } = yield* Cloudflare.AI.ProviderKey("OpenAiKey", { + * store, + * gatewayId: gateway.gatewayId, + * providerSlug: "openai", + * value: yield* Config.redacted("OPENAI_API_KEY"), + * }); + * ``` + * + * @example Multiple keys for one provider + * Distinguish keys for the same provider with an `alias` — each alias gets + * its own secret and provider config. + * ```typescript + * const production = yield* Cloudflare.AI.ProviderKey("OpenAiKey", { + * store, + * gatewayId: gateway.gatewayId, + * providerSlug: "openai", + * value: yield* Config.redacted("OPENAI_API_KEY"), + * }); + * + * const evals = yield* Cloudflare.AI.ProviderKey("OpenAiEvalsKey", { + * store, + * gatewayId: gateway.gatewayId, + * providerSlug: "openai", + * alias: "evals", + * value: yield* Config.redacted("OPENAI_EVALS_API_KEY"), + * }); + * ``` + * + * @see https://developers.cloudflare.com/ai-gateway/configuration/bring-your-own-keys/ + */ +export const ProviderKey = (id: string, props: InputProps) => + Effect.gen(function* () { + const alias = props.alias ?? "default"; + const secret = yield* Secret("Secret", { + store: props.store, + name: Output.interpolate`${props.gatewayId}_${props.providerSlug}_${alias}`, + value: props.value, + scopes: ["ai_gateway"], + comment: props.comment, + }); + + const gatewayProvider = yield* GatewayProvider("Provider", { + gatewayId: props.gatewayId, + providerSlug: props.providerSlug, + alias, + secretId: secret.secretId, + defaultConfig: props.defaultConfig, + rateLimit: props.rateLimit, + rateLimitPeriod: props.rateLimitPeriod, + }); + + return { + secret, + gatewayProvider, + } satisfies ProviderKey; + }).pipe(Namespace.push(id)); diff --git a/packages/alchemy/src/Cloudflare/AI/index.ts b/packages/alchemy/src/Cloudflare/AI/index.ts index 8cbde3abbc..8e459c994e 100644 --- a/packages/alchemy/src/Cloudflare/AI/index.ts +++ b/packages/alchemy/src/Cloudflare/AI/index.ts @@ -6,6 +6,7 @@ export * from "./Gateway.ts"; export * from "./GatewayDynamicRouting.ts"; export * from "./GatewayProvider.ts"; export * from "./LanguageModel.ts"; +export * from "./ProviderKey.ts"; export * from "./QueryGateway.ts"; export * from "./QueryGatewayBinding.ts"; export * from "./QuerySearch.ts"; diff --git a/packages/alchemy/test/Cloudflare/AI/ProviderKey.test.ts b/packages/alchemy/test/Cloudflare/AI/ProviderKey.test.ts new file mode 100644 index 0000000000..67655138d4 --- /dev/null +++ b/packages/alchemy/test/Cloudflare/AI/ProviderKey.test.ts @@ -0,0 +1,226 @@ +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +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", +); + +const GATEWAY_ID = "alchemy-test-aigw-providerkey"; +const PROVIDER_SLUG = "openai"; + +test.provider( + "provisions a Secret + GatewayProvider with the correct naming contract", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore.Store("PkStore"); + const gateway = yield* Cloudflare.AI.Gateway("PkGateway", { + id: GATEWAY_ID, + storeId: store.storeId, + }); + // ProviderKey composes Secret + GatewayProvider, deriving the secret + // name from `{gatewayId}_{providerSlug}_{alias}`. + return yield* Cloudflare.AI.ProviderKey("PkKey", { + store, + gatewayId: gateway.gatewayId, + providerSlug: PROVIDER_SLUG, + value: Redacted.make("alchemy-test-not-a-real-key"), + }); + }), + ); + + // The secret must be named exactly `{gatewayId}_{providerSlug}_{alias}`. + const expectedName = `${GATEWAY_ID}_${PROVIDER_SLUG}_default`; + expect(deployed.secret.secretName).toEqual(expectedName); + // The secret must be scoped to `ai_gateway` for BYOK resolution. + expect(deployed.secret.scopes).toContain("ai_gateway"); + + // The gateway provider must reference the backing secret's id. + expect(deployed.gatewayProvider.secretId).toEqual( + deployed.secret.secretId, + ); + // The alias carried through is `"default"` (the omitted-alias default). + expect(deployed.gatewayProvider.alias).toEqual("default"); + expect(deployed.gatewayProvider.providerSlug).toEqual(PROVIDER_SLUG); + expect(deployed.gatewayProvider.gatewayId).toEqual(GATEWAY_ID); + + // Redeploying identical props is a no-op — same resource IDs. + const redeployed = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore.Store("PkStore"); + const gateway = yield* Cloudflare.AI.Gateway("PkGateway", { + id: GATEWAY_ID, + storeId: store.storeId, + }); + return yield* Cloudflare.AI.ProviderKey("PkKey", { + store, + gatewayId: gateway.gatewayId, + providerSlug: PROVIDER_SLUG, + value: Redacted.make("alchemy-test-not-a-real-key"), + }); + }), + ); + expect(redeployed.secret.secretId).toEqual(deployed.secret.secretId); + expect(redeployed.gatewayProvider.providerConfigId).toEqual( + deployed.gatewayProvider.providerConfigId, + ); + + yield* stack.destroy(); + }).pipe(logLevel), +); + +test.provider("explicit alias threads through the naming contract", (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore.Store("PkAliasStore"); + const gateway = yield* Cloudflare.AI.Gateway("PkAliasGateway", { + id: GATEWAY_ID + "-alias", + storeId: store.storeId, + }); + return yield* Cloudflare.AI.ProviderKey("PkAliasKey", { + store, + gatewayId: gateway.gatewayId, + providerSlug: "anthropic", + alias: "evals", + value: Redacted.make("alchemy-test-not-a-real-key"), + }); + }), + ); + + expect(deployed.secret.secretName).toEqual( + `${GATEWAY_ID}-alias_anthropic_evals`, + ); + expect(deployed.gatewayProvider.alias).toEqual("evals"); + + yield* stack.destroy(); + }).pipe(logLevel), +); + +test.provider( + "value rotation updates in place; alias change cascades a replacement", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const program = (opts: { value: string; alias?: string }) => + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore.Store("PkRotateStore"); + const gateway = yield* Cloudflare.AI.Gateway("PkRotateGateway", { + id: GATEWAY_ID + "-rotate", + storeId: store.storeId, + }); + return yield* Cloudflare.AI.ProviderKey("PkRotateKey", { + store, + gatewayId: gateway.gatewayId, + providerSlug: PROVIDER_SLUG, + alias: opts.alias, + value: Redacted.make(opts.value), + }); + }); + + const initial = yield* stack.deploy( + program({ value: "alchemy-test-key-v1" }), + ); + + // Rotating the key value is an in-place update: the secret keeps its + // id, so the provider config it feeds is untouched. + const rotated = yield* stack.deploy( + program({ value: "alchemy-test-key-v2" }), + ); + expect(rotated.secret.secretId).toEqual(initial.secret.secretId); + expect(rotated.gatewayProvider.providerConfigId).toEqual( + initial.gatewayProvider.providerConfigId, + ); + + // Changing the alias renames the secret — a replacement (new id) — + // and must cascade: the provider config is replaced (delete-first) + // and re-pointed at the new secret. + const renamed = yield* stack.deploy( + program({ value: "alchemy-test-key-v2", alias: "rotated" }), + ); + expect(renamed.secret.secretName).toEqual( + `${GATEWAY_ID}-rotate_${PROVIDER_SLUG}_rotated`, + ); + expect(renamed.secret.secretId).not.toEqual(initial.secret.secretId); + expect(renamed.gatewayProvider.providerConfigId).not.toEqual( + initial.gatewayProvider.providerConfigId, + ); + expect(renamed.gatewayProvider.alias).toEqual("rotated"); + expect(renamed.gatewayProvider.secretId).toEqual(renamed.secret.secretId); + + // The replaced secret is reclaimed — no orphan left in the store. + const oldSecret = yield* secretsStore + .getStoreSecret({ + accountId: initial.secret.accountId, + storeId: initial.secret.storeId, + secretId: initial.secret.secretId, + }) + .pipe( + Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)), + ); + expect(oldSecret).toBeUndefined(); + + yield* stack.destroy(); + }).pipe(logLevel), +); + +test.provider( + "destroying the provider key removes the secret from the surviving store", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const deployed = yield* stack.deploy( + Effect.gen(function* () { + const store = yield* Cloudflare.SecretsStore.Store("PkDeleteStore"); + const gateway = yield* Cloudflare.AI.Gateway("PkDeleteGateway", { + id: GATEWAY_ID + "-delete", + storeId: store.storeId, + }); + return yield* Cloudflare.AI.ProviderKey("PkDeleteKey", { + store, + gatewayId: gateway.gatewayId, + providerSlug: PROVIDER_SLUG, + value: Redacted.make("alchemy-test-not-a-real-key"), + }); + }), + ); + + expect(deployed.secret.secretName).toEqual( + `${GATEWAY_ID}-delete_${PROVIDER_SLUG}_default`, + ); + + yield* stack.destroy(); + + // The account-level Secrets Store is adopted, not created, so it + // survives `destroy` — the helper's Secret resource is the only thing + // that reclaims the BYOK secret. Verify the secret is actually gone + // (an orphaned secret is the one real leak this composition could + // introduce). A surviving store means a missing secret surfaces as + // `SecretNotFound`, not `StoreNotFound`. + const secretAfter = yield* secretsStore + .getStoreSecret({ + accountId: deployed.secret.accountId, + storeId: deployed.secret.storeId, + secretId: deployed.secret.secretId, + }) + .pipe( + Effect.catchTag("SecretNotFound", () => Effect.succeed(undefined)), + ); + expect(secretAfter).toBeUndefined(); + }).pipe(logLevel), +);