diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 36f7491df..1a00dc33b 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -99,12 +99,12 @@ Bindings are [inferred automatically](../reference/resource-inference.md) by sca ```json { "inference": { - "bindings": { "db": true, "kv": true, "storage": false, "ai": true } + "bindings": { "db": true, "kv": true, "storage": false, "ai": "MY_AI" } } } ``` -`db`, `kv`, and `storage` accept a string to customize the binding name (e.g. `"db": "MY_DB"`). `ai` only accepts a boolean. +Every binding accepts a string to customize its name (for example, `"db": "MY_DB"` or `"ai": "MY_AI"`). See [Configuration](../reference/config.md) for details. @@ -364,7 +364,7 @@ These checks detect changes to the deployment target or uploader, but they don't `void/ai` works on your own Cloudflare account, along two paths: -- **Workers AI** (`ai.run`, `ai.stream`, `ai.image`) works out of the box. When your app imports `void/ai`, `vite build` infers that you need AI and adds a Workers AI binding (`env.AI`) to the generated `wrangler.json` automatically -- you do **not** add it to `wrangler.jsonc`. +- **Workers AI** (`ai.run`, `ai.stream`, `ai.image`) works out of the box. When your app imports `void/ai`, `vite build` infers that you need AI and adds a Workers AI binding (`env.AI`) to the generated `wrangler.json` automatically -- you do **not** add it to `wrangler.jsonc`. Set `inference.bindings.ai` to a string to use a custom name. - **Provider models** (`ai.provider("openai").fetch(...)`) route through _your own_ Cloudflare AI Gateway. Set its id in `void.json` and add the provider's API key as a Worker secret. ```jsonc @@ -387,7 +387,7 @@ import { defineHandler } from 'void'; import { ai } from 'void/ai'; export const POST = defineHandler(async (c) => { - // Workers AI -- uses env.AI directly + // Workers AI -- uses the inferred or configured AI binding directly const summary = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Summarize the changelog.', }); @@ -408,7 +408,7 @@ Set `ai.gateway` before calling `ai.provider().fetch()`; otherwise it returns `5 Notes: - **No cross-tenant usage metering.** On the Void platform, AI calls are metered and billed through a shared proxy. Self-hosted, there is no metering -- you get your own [Cloudflare AI Gateway analytics](https://developers.cloudflare.com/ai-gateway/) instead. -- **The runtime reads the `env.AI` binding by name** -- a custom-named root AI binding is not supported. +- **Custom AI binding names are supported.** Configure `"inference": { "bindings": { "ai": "MY_AI" } }`, or declare the custom `ai.binding` in your root Wrangler config; the generated worker records the resolved name for `void/ai` automatically. - **`void dev` has no local Workers AI emulation.** In development, AI still routes through Void, so `void dev` AI requires `void auth login` even when you deploy self-hosted. ### ISR (self-host) diff --git a/docs/reference/config.md b/docs/reference/config.md index 92748af89..2b46223ef 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -423,7 +423,7 @@ Explicitly control inferred Cloudflare bindings. If omitted, bindings are [autom Each binding accepts `true` (use default name), `false` (disable), or a string (custom binding name): ```json -{ "inference": { "bindings": { "db": true, "kv": false, "storage": "MY_BUCKET" } } } +{ "inference": { "bindings": { "db": true, "kv": false, "storage": "MY_BUCKET", "ai": "MY_AI" } } } ``` | Key | Default Binding | Type | Custom Name | @@ -431,9 +431,9 @@ Each binding accepts `true` (use default name), `false` (disable), or a string ( | `db` | `DB` | `D1Database` | `"db": "MY_DB"` | | `kv` | `KV` | `KVNamespace` | `"kv": "MY_KV"` | | `storage` | `STORAGE` | `R2Bucket` | `"storage": "MY_BUCKET"` | -| `ai` | `AI` | `Ai` | Boolean only | +| `ai` | `AI` | `Ai` | `"ai": "MY_AI"` | -Custom names are used in deploy-manifest and Cloudflare-config generation, remote binding proxying, internal migrations, and generated runtime helpers such as `void/db`, `void/kv`, and `void/storage`. +Custom resource binding names flow through deploy manifests, Cloudflare config generation, remote binding proxying, internal migrations, and generated runtime helpers such as `void/db`, `void/kv`, and `void/storage`. Custom AI names apply to generated Cloudflare configuration and `void/ai` runtime resolution for direct Cloudflare builds; managed Workers AI continues to use Void's service proxy. #### `inference.build` diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index e1f899e21..dc57465c1 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -114,8 +114,8 @@ src/ Two features need extra wiring on a self-hosted `vite build && wrangler deploy` (no Void platform proxy/dispatch in front of the worker). Both are inert on managed `void deploy`. -- **`ai.gateway` config + `__VOID_AI_GATEWAY` var** — `void.json`'s `ai.gateway` (validated in `config.ts` as `ai?: { gateway?: string }`; non-empty string, `additionalProperties: false`) names a Cloudflare AI Gateway in the user's own account. `mergeBindings` (`index.ts`): (1) injects `{ ai: { binding: 'AI' } }`, but only when `shouldBindWorkersAi()` is true — the app infers `needsAI`, `command === 'build'`, and neither the resolved root wrangler config nor the result already declares an `ai` binding (so it never clobbers a user's own `ai` binding); (2) emits `vars.__VOID_AI_GATEWAY = config.ai.gateway` whenever `config.ai?.gateway` is set. At runtime, `runtime/ai.ts` `resolveBackend()` selects the direct backend (Priority 3) when `env.AI` is present and no `__VOID_PROXY`/`__VOID_TOKEN` exists. `ai.run/stream/image` hit Workers AI directly; `ai.provider().fetch()` routes through the user's own gateway via `directAi.gateway(__VOID_AI_GATEWAY).getUrl(provider)` with the provider key from the user's own secret (`resolveProviderKey` → `env[PROVIDER_KEY_MAP[provider]]`), returning HTTP 501 if `__VOID_AI_GATEWAY` is unset. No cross-tenant metering on this path. The runtime reads `env.AI` **by name** — a custom-named root AI binding is not honored. -- **Internal-binding leak check permits exactly `__VOID_AI_GATEWAY`** — the integration guard (`test/integration/kitchen-sink-build.test.ts`) that asserts no `__VOID_*` var reaches `dist/ssr/wrangler.json` explicitly excludes `__VOID_AI_GATEWAY`, because it is a legitimate, non-credential runtime var (unlike the credential keys in `INTERNAL_ENV_KEYS`, which are stripped). +- **`ai.gateway`, custom AI bindings, and private runtime vars** — `void.json`'s `ai.gateway` (validated in `config.ts` as `ai?: { gateway?: string }`; non-empty string, `additionalProperties: false`) names a Cloudflare AI Gateway in the user's own account. `inference.bindings.ai` accepts a string custom binding name. `mergeBindings` (`index.ts`) injects the resolved binding only when `shouldBindWorkersAi()` is true — the app infers `needsAI`, `command === 'build'`, and neither the resolved root Wrangler config nor the result already declares an `ai` binding — so a root Wrangler declaration stays authoritative. It emits `vars.__VOID_AI_BINDING` for a non-default binding and `vars.__VOID_AI_GATEWAY` when configured. At runtime, `runtime/ai.ts` `resolveBackend()` uses that resolved binding when no `__VOID_PROXY`/`__VOID_TOKEN` exists. `ai.run/stream/image` hit Workers AI directly; `ai.provider().fetch()` routes through the user's own gateway via `directAi.gateway(__VOID_AI_GATEWAY).getUrl(provider)` with the provider key from the user's own secret (`resolveProviderKey` → `env[PROVIDER_KEY_MAP[provider]]`), returning HTTP 501 if `__VOID_AI_GATEWAY` is unset. No cross-tenant metering on this path. +- **Internal-binding leak check permits the AI routing markers** — the integration guard (`test/integration/kitchen-sink-build.test.ts`) that asserts no credential-bearing `__VOID_*` var reaches `dist/ssr/wrangler.json` permits `__VOID_AI_BINDING` and `__VOID_AI_GATEWAY`, because they are legitimate, non-credential runtime routing values (unlike the credential keys in `INTERNAL_ENV_KEYS`, which are stripped). - **`ISR_CACHE` self-host requirement** — self-host ISR (`@void/isr/runtime`, `runtime/isr.ts`) reads `env.ISR_CACHE` (a `KVNamespace`) + `env.__VOID_ISR_DEPLOYMENT_ID`. Unlike D1/KV/R2, `ISR_CACHE` is **not** auto-injected (a KV binding needs a real namespace id), so the user must create the namespace and declare it as `ISR_CACHE` in their root wrangler config. `routing.revalidate` (void.json) and per-page `export const revalidate` are merged by `collectRevalidate` (`isr-config.ts`) and baked as `__VOID_ISR_REVALIDATE`. On-demand `revalidate()` on self-host purges the worker's own KV (global) plus the local-colo `caches.default` edge variants only. ### Package Exports @@ -241,4 +241,4 @@ Agent instructions use versioned markers (`` / ` = [ 'migrations', ]; -/** Void's own injected Workers AI binding shape (`mergeBindings` in index.ts). */ -const VOID_EMITTED_AI_BINDING = { binding: 'AI' }; +/** Void's default injected Workers AI binding name (`mergeBindings` in index.ts). */ +const DEFAULT_VOID_AI_BINDING = 'AI'; /** * Sub-fields on a `durable_objects` binding that make it target ANOTHER worker's Durable Objects. @@ -1808,12 +1808,14 @@ export function verifyGeneratedDeployConfig( passthroughRawFields?: Map; /** * Whether the pre-build inference found Workers AI use in the source (the SAME `needsAI` - * signal `shouldBindWorkersAi` gates on in index.ts). Authorizes an emitted `{binding:'AI'}` - * ai binding when the root config declares no ai binding: only an AI-using app legitimately - * receives Void's injected Workers AI binding, so a build that injects one into a non-AI app - * (billable inference the source never asked for) fails closed. + * signal `shouldBindWorkersAi` gates on in index.ts). Authorizes Void's emitted AI binding + * (default `AI`, or `aiBindingName`) when the root config declares none: only an AI-using app + * legitimately receives one, so a build that injects it into a non-AI app (billable inference + * the source never asked for) fails closed. */ needsAI?: boolean; + /** Custom `inference.bindings.ai` name authorized for Void's emitted AI binding. */ + aiBindingName?: string; /** * Auth-enabled Cloudflare builds must emit `secrets.required` with * BETTER_AUTH_SECRET so Wrangler inherits the persistent remote value into @@ -2085,9 +2087,9 @@ export function verifyGeneratedDeployConfig( // worker's DOs; Void's own DOs are always LOCAL, so it must match a root binding. // • `ai` — when the root declared its own ai binding it must SURVIVE byte-identical (a build // that REMOVED or rewrote it is rejected); otherwise the emitted binding must be ABSENT or — - // ONLY when the source's pre-build inference says the app uses Workers AI — Void's own - // `{binding:'AI'}`. An injected `{binding:'AI'}` on an app that does not use AI would incur - // account-billed inference from build-injected worker code. + // ONLY when the source's pre-build inference says the app uses Workers AI — Void's + // configured Workers AI binding. An injected AI binding on an app that does not use AI would + // incur account-billed inference from build-injected worker code. // • `migrations` — an emitted Durable Object migration carrying a DESTRUCTIVE key (deleting, // renaming, or transferring classes) must match a root-declared migration byte-for-byte; // Void emits only creation-key migrations, so a destructive one is a build injection that @@ -2131,10 +2133,10 @@ export function verifyGeneratedDeployConfig( }; } } else if (emittedAi !== undefined) { - // No root ai binding: the only ai binding Void's own build emits is `{binding:'AI'}`, and - // ONLY when the pre-build inference says the source uses Workers AI (the SAME signal - // `shouldBindWorkersAi` gates on). Anything else here is a build-injected billable binding. - if (!(expected.needsAI === true && deepEqual(emittedAi, VOID_EMITTED_AI_BINDING))) { + // No root ai binding: Void emits the configured binding name (default `AI`) ONLY when + // pre-build inference says the source uses Workers AI. Anything else is build-injected. + const expectedAi = { binding: expected.aiBindingName ?? DEFAULT_VOID_AI_BINDING }; + if (!(expected.needsAI === true && deepEqual(emittedAi, expectedAi))) { return { ok: false, reason: `generated config declares an ai binding the build injected — the pre-build inference found no Workers AI use in the source and the root config declares no ai binding.`, @@ -2674,10 +2676,12 @@ export async function computeDesiredCloudflareResources( migrations: Array<{ name: string; sql: string }>; /** * Whether the pre-build inference found Workers AI use in the source — the SAME `needsAI` - * signal `shouldBindWorkersAi` (index.ts) gates the emitted `{binding:'AI'}` on. Threaded - * into {@link verifyGeneratedDeployConfig} to authorize that emitted ai binding. + * signal `shouldBindWorkersAi` (index.ts) gates the emitted AI binding on. Threaded into + * {@link verifyGeneratedDeployConfig} to authorize that binding. */ needsAI: boolean; + /** Workers AI binding emitted by the build (default `AI`). */ + aiBindingName: string; /** * Whether Void's build emits the sandbox container application — the SAME signal `mergeBindings` * (index.ts) gates emission on: `isSandboxEnabled(config)` (a `sandbox` field in `void.json`) OR @@ -2838,6 +2842,7 @@ export async function computeDesiredCloudflareResources( migrationsDir: paths.migrationsDir, migrations, needsAI: bindings.needsAI, + aiBindingName: bindingNames?.ai ?? DEFAULT_VOID_AI_BINDING, expectedCrons, // Mirror index.ts's emission signal EXACTLY (line 658): the sandbox container is emitted when // `void.json` enables it OR the source imports `void/sandbox` (the import-walk signal). Using @@ -3931,6 +3936,7 @@ export async function runCloudflareDeploy( migrationsDir, migrations: preBuildMigrations, needsAI, + aiBindingName, needsSandbox, expectedCrons, } = await computeDesiredCloudflareResources(root, voidConfig, configPath); @@ -4863,6 +4869,7 @@ export async function runCloudflareDeploy( passthroughIdentitySnapshot, passthroughRawFields, needsAI, + aiBindingName, authEnabled, expectedContainers, expectedCrons, @@ -5079,6 +5086,7 @@ export async function runCloudflareDeploy( passthroughIdentitySnapshot, passthroughRawFields, needsAI, + aiBindingName, authEnabled, requiredSecretNames: requiredSchemaSecretNames, expectedContainers, diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index 096b6e126..c92d5a08f 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -63,7 +63,7 @@ export type InferenceConfig = { db?: boolean | string; kv?: boolean | string; storage?: boolean | string; - ai?: boolean; + ai?: boolean | string; }; scanDirs?: ReadonlyArray; build?: string; @@ -762,18 +762,10 @@ export function readConfig(root: string): VoidConfig { `config: Unknown binding '${key}' in ${CONFIG_FILE}. Valid: db, kv, storage, ai.`, ); } - if (key === 'ai') { - if (typeof b[key] !== 'boolean') { - throw new Error( - `config: "inference.bindings.${key}" in ${CONFIG_FILE} must be a boolean.`, - ); - } - } else { - if (typeof b[key] !== 'boolean' && typeof b[key] !== 'string') { - throw new Error( - `config: "inference.bindings.${key}" in ${CONFIG_FILE} must be a boolean or a string custom binding name.`, - ); - } + if (typeof b[key] !== 'boolean' && typeof b[key] !== 'string') { + throw new Error( + `config: "inference.bindings.${key}" in ${CONFIG_FILE} must be a boolean or a string custom binding name.`, + ); } } } @@ -988,11 +980,11 @@ export function isNodeTarget(target: VoidConfig['target']): boolean { */ export function resolveBindingNames( bindings: InferenceConfig['bindings'], -): { d1?: string; kv?: string; r2?: string } | undefined { +): { d1?: string; kv?: string; r2?: string; ai?: string } | undefined { if (!bindings) { return undefined; } - const names: { d1?: string; kv?: string; r2?: string } = {}; + const names: { d1?: string; kv?: string; r2?: string; ai?: string } = {}; let hasCustom = false; if (typeof bindings.db === 'string') { names.d1 = bindings.db; @@ -1006,6 +998,10 @@ export function resolveBindingNames( names.r2 = bindings.storage; hasCustom = true; } + if (typeof bindings.ai === 'string') { + names.ai = bindings.ai; + hasCustom = true; + } return hasCustom ? names : undefined; } diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 8f332d5bf..a0c79102f 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -394,6 +394,17 @@ export function shouldBindWorkersAi( return needsAI && command === 'build' && !resultAi && !resolvedAi; } +/** Resolve the runtime Workers AI binding, preserving a root Wrangler declaration. */ +export function resolveWorkersAiBindingName(resolvedAi: unknown, configuredName?: string): string { + if (typeof resolvedAi === 'object' && resolvedAi !== null) { + const binding = (resolvedAi as { binding?: unknown }).binding; + if (typeof binding === 'string') { + return binding; + } + } + return configuredName ?? 'AI'; +} + const CLOUDFLARE_BUILTIN_IMPORT = /^cloudflare:/; const SANDBOX_SDK_IMPORT = '@cloudflare/sandbox'; const SANDBOX_SDK_IMPORT_FILTER = /^@cloudflare\/sandbox$/; @@ -1129,22 +1140,29 @@ export function voidPlugin(options?: { // wins, so `!resolvedAi` is load-bearing (prevents clobbering a user's own // root-wrangler `ai` binding); `!resultAi` is harmless defense-in-depth // (nothing else in mergeBindings ever sets `result.ai`). + const resolvedAi = (resolved as { ai?: unknown }).ai; + const aiBindingName = resolveWorkersAiBindingName(resolvedAi, bindingNames?.ai); if ( shouldBindWorkersAi( viteCommand, bindings.needsAI, - (resolved as { ai?: unknown }).ai, + resolvedAi, (result as { ai?: unknown }).ai, ) ) { - (result as Record).ai = { binding: 'AI' }; + (result as Record).ai = { binding: aiBindingName }; } - // AI Gateway id → runtime var (harmless in serve; only read by the P3 direct - // backend that routes Workers AI inference through the user's own Gateway). - if (config.ai?.gateway) { + // AI binding name / Gateway id → runtime vars (harmless in serve; only read + // by the P3 direct backend). The default binding needs no extra variable. + if (aiBindingName !== 'AI' || config.ai?.gateway) { const vars = (result.vars as Record) ?? {}; - vars.__VOID_AI_GATEWAY = config.ai.gateway; + if (aiBindingName !== 'AI') { + vars.__VOID_AI_BINDING = aiBindingName; + } + if (config.ai?.gateway) { + vars.__VOID_AI_GATEWAY = config.ai.gateway; + } result.vars = vars; } diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index 77b163404..d81295d7b 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -88,7 +88,7 @@ export type InferenceOptions = { db?: boolean | string; kv?: boolean | string; storage?: boolean | string; - ai?: boolean; + ai?: boolean | string; }; paths?: ProjectPaths; }; diff --git a/packages/void/src/runtime/ai.ts b/packages/void/src/runtime/ai.ts index 12629a86a..6646691f1 100644 --- a/packages/void/src/runtime/ai.ts +++ b/packages/void/src/runtime/ai.ts @@ -407,8 +407,12 @@ function resolveBackend(): AiBackend { return makeHttpBackend(token, projectId, runtimeEnv); } - // Priority 3: Direct AI binding (user's own CF account) - const directAi = runtimeEnv.AI as Ai | undefined; + // Priority 3: Direct AI binding (user's own CF account). Void's generated + // wrangler config stamps custom binding names into a private runtime var; + // default and managed deployments continue to use `AI`. + const aiBindingName = + typeof runtimeEnv.__VOID_AI_BINDING === 'string' ? runtimeEnv.__VOID_AI_BINDING : 'AI'; + const directAi = runtimeEnv[aiBindingName] as Ai | undefined; if (directAi) { return makeDirectBackend( directAi, @@ -418,7 +422,7 @@ function resolveBackend(): AiBackend { } throw new Error( - 'ai: Not available. Run `void auth login` and `void project link` first, or bind Workers AI (env.AI) for self-hosted deploys.', + `ai: Not available. Run \`void auth login\` and \`void project link\` first, or bind Workers AI (env.${aiBindingName}) for self-hosted deploys.`, ); } diff --git a/packages/void/test/integration/kitchen-sink-build.test.ts b/packages/void/test/integration/kitchen-sink-build.test.ts index 1221b0751..0b4e649e0 100644 --- a/packages/void/test/integration/kitchen-sink-build.test.ts +++ b/packages/void/test/integration/kitchen-sink-build.test.ts @@ -101,10 +101,11 @@ describe('playground/kitchen-sink build', () => { it('does not leak __VOID_* credentials into dist/ssr/wrangler.json', () => { const wranglerConfig = JSON.parse(readFileSync(join(dist, 'ssr', 'wrangler.json'), 'utf-8')); const vars = (wranglerConfig.vars ?? {}) as Record; - // `__VOID_AI_GATEWAY` is a legitimate, non-credential runtime var emitted from - // `ai.gateway` in void.json (asserted below) — exclude it from the leak filter. + // These are legitimate, non-credential runtime routing vars for a custom Workers AI + // binding / AI Gateway. Exclude them from the credential leak filter. + const allowedRuntimeMarkers = new Set(['__VOID_AI_BINDING', '__VOID_AI_GATEWAY']); const leakedKeys = Object.keys(vars).filter( - (key) => key.startsWith('__VOID_') && key !== '__VOID_AI_GATEWAY', + (key) => key.startsWith('__VOID_') && !allowedRuntimeMarkers.has(key), ); expect(leakedKeys).toEqual([]); }); diff --git a/packages/void/test/unit/ai.test.ts b/packages/void/test/unit/ai.test.ts index 258b8e85b..4b25e145e 100644 --- a/packages/void/test/unit/ai.test.ts +++ b/packages/void/test/unit/ai.test.ts @@ -378,6 +378,19 @@ describe('runtime ai', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('selects a custom binding named by the generated runtime variable', async () => { + vi.resetModules(); + const { envContext } = await import('../../src/runtime/env-raw.ts'); + const { ai } = await import('../../src/runtime/ai.ts'); + const direct = makeDirectAi(); + + await envContext.run({ MY_AI: direct.AI, __VOID_AI_BINDING: 'MY_AI' }, async () => { + expect(await ai.run('@cf/meta/llama', { prompt: 'Hi' })).toEqual({ ok: true }); + }); + + expect(direct.run).toHaveBeenCalledWith('@cf/meta/llama', { prompt: 'Hi' }); + }); + it('provider().fetch hits the user gateway with provider auth and no metering header', async () => { vi.resetModules(); const { envContext } = await import('../../src/runtime/env-raw.ts'); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index aa766cc7f..32161d128 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -1109,6 +1109,21 @@ describe('computeDesiredCloudflareResources — desired set from source', () => }); }); + it('carries a custom Workers AI name into deploy verification expectations', async () => { + const root = makeProject({ + 'package.json': JSON.stringify({ name: 'myapp' }), + 'void.json': JSON.stringify({ inference: { bindings: { ai: 'MY_AI' } } }), + 'routes/index.ts': `export const GET = () => new Response('ok');`, + }); + const resources = await computeDesiredCloudflareResources( + root, + readConfig(root), + join(root, 'wrangler.jsonc'), + ); + expect(resources.needsAI).toBe(true); + expect(resources.aiBindingName).toBe('MY_AI'); + }); + it('desires D1 for a migration-only SQLite app (no env.DB use, no auth)', async () => { const root = makeProject({ 'package.json': JSON.stringify({ name: 'myapp' }), @@ -4343,6 +4358,24 @@ describe('verifyGeneratedDeployConfig', () => { expect(result).toEqual({ ok: true }); }); + it('passes a configured custom emitted ai binding when the source uses AI', () => { + const root = deployConfigProject({ + account_id: 'acc123', + name: 'myapp', + ai: { binding: 'MY_AI' }, + }); + const result = verifyGeneratedDeployConfig(root, { + accountId: 'acc123', + workerName: 'myapp', + desired: { kvNamespaces: [], queues: [] }, + passthroughIdentitySnapshot: snapshotPassthroughIdentities({}), + passthroughRawFields: snapshotPassthroughRawFields({}), + needsAI: true, + aiBindingName: 'MY_AI', + }); + expect(result).toEqual({ ok: true }); + }); + it("fails closed when the build injects Void's own {binding:'AI'} but the source does not use AI", () => { // No root ai + needsAI false: a real build never emits `{binding:'AI'}` here, so an emitted one // is a build-injected billable Workers AI binding. diff --git a/packages/void/test/unit/config.test.ts b/packages/void/test/unit/config.test.ts index 330be1494..a2c2a6287 100644 --- a/packages/void/test/unit/config.test.ts +++ b/packages/void/test/unit/config.test.ts @@ -184,22 +184,25 @@ describe('readConfig', () => { cleanup(); }); - it('accepts string binding names for db/kv/storage', () => { + it('accepts string binding names for db/kv/storage/ai', () => { setup( JSON.stringify({ - inference: { bindings: { db: 'MY_DB', kv: 'MY_KV', storage: 'MY_R2' } }, + inference: { + bindings: { db: 'MY_DB', kv: 'MY_KV', storage: 'MY_R2', ai: 'MY_AI' }, + }, }), ); const config = readConfig(tmpDir); expect(config.inference?.bindings?.db).toBe('MY_DB'); expect(config.inference?.bindings?.kv).toBe('MY_KV'); expect(config.inference?.bindings?.storage).toBe('MY_R2'); + expect(config.inference?.bindings?.ai).toBe('MY_AI'); cleanup(); }); - it('throws on non-boolean ai binding value', () => { - setup(JSON.stringify({ inference: { bindings: { ai: 'yes' } } })); - expect(() => readConfig(tmpDir)).toThrow('must be a boolean'); + it('throws on invalid ai binding value', () => { + setup(JSON.stringify({ inference: { bindings: { ai: 42 } } })); + expect(() => readConfig(tmpDir)).toThrow('must be a boolean or a string'); cleanup(); }); @@ -1618,10 +1621,11 @@ describe('resolveBindingNames', () => { }); it('returns all custom names', () => { - expect(resolveBindingNames({ db: 'D', kv: 'K', storage: 'S' })).toEqual({ + expect(resolveBindingNames({ db: 'D', kv: 'K', storage: 'S', ai: 'A' })).toEqual({ d1: 'D', kv: 'K', r2: 'S', + ai: 'A', }); }); }); diff --git a/packages/void/test/unit/index.test.ts b/packages/void/test/unit/index.test.ts index c3ea22705..8fa2ad71e 100644 --- a/packages/void/test/unit/index.test.ts +++ b/packages/void/test/unit/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { shouldBindWorkersAi } from '../../src/index.ts'; +import { resolveWorkersAiBindingName, shouldBindWorkersAi } from '../../src/index.ts'; // `shouldBindWorkersAi(command, needsAI, resolvedAi, resultAi)` is the pure, // testable surface of the self-host serve-gate (the surrounding `mergeBindings` @@ -24,3 +24,17 @@ describe('shouldBindWorkersAi', () => { expect(shouldBindWorkersAi('build', false, undefined, undefined)).toBe(false); }); }); + +describe('resolveWorkersAiBindingName', () => { + it('uses a custom inference binding name', () => { + expect(resolveWorkersAiBindingName(undefined, 'MY_AI')).toBe('MY_AI'); + }); + + it('preserves a root Wrangler binding over the inference name', () => { + expect(resolveWorkersAiBindingName({ binding: 'ROOT_AI' }, 'MY_AI')).toBe('ROOT_AI'); + }); + + it('defaults to AI', () => { + expect(resolveWorkersAiBindingName(undefined)).toBe('AI'); + }); +});