Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/integrations/cloudflare.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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.',
});
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,17 +423,17 @@ 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 |
| --------- | --------------- | ------------- | ------------------------ |
| `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`

Expand Down
6 changes: 3 additions & 3 deletions packages/void/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -241,4 +241,4 @@ Agent instructions use versioned markers (`<!--injected-by-void-v0.0.1-->` / `<!

- **`__VOID_ISR_DEPLOYMENT_ID` must be a compile-time literal.** `router/compile.ts` stringifies `crypto.randomUUID()` once at build time (`export const __VOID_ISR_DEPLOYMENT_ID = "..."`, exactly like `__VOID_BUILD_KEY`) so every isolate of a deployment shares one id. A bare runtime `crypto.randomUUID()` per-isolate in the emitted source would mint a fresh id per isolate, so the edge-cache key (`handleIsr`'s `deploymentId` option) and the KV key (`env.__VOID_ISR_DEPLOYMENT_ID`, also used by the `revalidate()` purge path) would disagree — breaking cache reads and on-demand purge.
- **Self-host ISR requires a user-declared `ISR_CACHE` KV.** There is no `id: 'local'` injection for it: a placeholder id is fine for miniflare-backed dev but breaks a real `wrangler deploy`, which rejects an unknown namespace id. The user creates the namespace and binds it as `ISR_CACHE`; `readIsrCache`/`writeIsrCache` fail open to a live render when it is absent.
- **Workers AI binding injection is `command === 'build'`-gated.** `shouldBindWorkersAi()` only injects `{ ai: { binding: 'AI' } }` on build, because dev/miniflare has no local Workers AI emulation — `void dev` AI still routes through the Void proxy (P2 `__VOID_TOKEN` path) and needs `void auth login`.
- **Workers AI binding injection is `command === 'build'`-gated.** `shouldBindWorkersAi()` only injects the resolved AI binding (default `AI`, or `inference.bindings.ai`) on build, because dev/miniflare has no local Workers AI emulation — `void dev` AI still routes through the Void proxy (P2 `__VOID_TOKEN` path) and needs `void auth login`. A root Wrangler `ai.binding` takes precedence and is never overwritten.
4 changes: 2 additions & 2 deletions packages/void/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,8 @@
"description": "Enable R2 bucket binding. `true` uses default name \"STORAGE\", a string sets a custom binding name."
},
"ai": {
"type": "boolean",
"description": "Enable Workers AI binding"
"oneOf": [{ "type": "boolean" }, { "type": "string" }],
"description": "Enable Workers AI binding. `true` uses default name \"AI\", a string sets a custom binding name."
}
},
"additionalProperties": false
Expand Down
38 changes: 23 additions & 15 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,8 +955,8 @@ const DEPLOY_RAW_SNAPSHOT_FIELDS: ReadonlyArray<string> = [
'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.
Expand Down Expand Up @@ -1808,12 +1808,14 @@ export function verifyGeneratedDeployConfig(
passthroughRawFields?: Map<string, unknown>;
/**
* 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -3931,6 +3936,7 @@ export async function runCloudflareDeploy(
migrationsDir,
migrations: preBuildMigrations,
needsAI,
aiBindingName,
needsSandbox,
expectedCrons,
} = await computeDesiredCloudflareResources(root, voidConfig, configPath);
Expand Down Expand Up @@ -4863,6 +4869,7 @@ export async function runCloudflareDeploy(
passthroughIdentitySnapshot,
passthroughRawFields,
needsAI,
aiBindingName,
authEnabled,
expectedContainers,
expectedCrons,
Expand Down Expand Up @@ -5079,6 +5086,7 @@ export async function runCloudflareDeploy(
passthroughIdentitySnapshot,
passthroughRawFields,
needsAI,
aiBindingName,
authEnabled,
requiredSecretNames: requiredSchemaSecretNames,
expectedContainers,
Expand Down
26 changes: 11 additions & 15 deletions packages/void/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export type InferenceConfig = {
db?: boolean | string;
kv?: boolean | string;
storage?: boolean | string;
ai?: boolean;
ai?: boolean | string;
};
scanDirs?: ReadonlyArray<string>;
build?: string;
Expand Down Expand Up @@ -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.`,
);
}
}
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
Loading