Skip to content
Open
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
14 changes: 14 additions & 0 deletions .changeset/ai-search-bindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"wrangler": minor
"miniflare": minor
"@cloudflare/workers-utils": minor
---

feat: Add `ai_search_namespaces` and `ai_search` binding types

Two new binding types for the standalone AI Search binding, replacing the `env.AI.autorag()` path:

- `ai_search_namespaces`: Namespace binding with dynamic instance access (`get()`, `list()`, `create()`, `delete()`)
- `ai_search`: Single instance binding bound to one specific instance within a namespace

Both are remote-only in local dev. Namespace auto-creation during deploy follows the R2 bucket provisioning pattern. The `namespace` field on `ai_search` defaults to `"default"` if omitted.
129 changes: 129 additions & 0 deletions packages/miniflare/src/plugins/ai-search/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { z } from "zod";
import {
getUserBindingServiceName,
Plugin,
ProxyNodeBinding,
remoteProxyClientWorker,
RemoteProxyConnectionString,
} from "../shared";

const AISearchEntrySchema = z.object({
namespace: z.string().optional(),
instance_name: z.string().optional(),
remoteProxyConnectionString: z
.custom<RemoteProxyConnectionString>()
.optional(),
});

export const AISearchOptionsSchema = z.object({
aiSearchNamespaces: z.record(AISearchEntrySchema).optional(),
aiSearchInstances: z.record(AISearchEntrySchema).optional(),
});

export const AI_SEARCH_PLUGIN_NAME = "ai-search";

// Distinct scopes for service name generation to avoid collisions
// between namespace and instance bindings with the same binding name.
const AI_SEARCH_NS_SCOPE = "ai-search-ns";
const AI_SEARCH_INST_SCOPE = "ai-search-inst";

// TODO: Once workerd adds a `cloudflare-internal:ai-search-api` module,
// update getBindings() to return `wrapped` bindings (like the AI plugin)
// instead of plain service bindings. This will give workers a properly
// typed AISearchNamespace / AISearchInstance object instead of a raw Fetcher.
// The `namespace` and `instance_name` fields from the schema should then be
// passed as inner bindings for correct instance routing.

export const AI_SEARCH_PLUGIN: Plugin<typeof AISearchOptionsSchema> = {
options: AISearchOptionsSchema,
async getBindings(options) {
const bindings: {
name: string;
service: { name: string };
}[] = [];

for (const [bindingName, entry] of Object.entries(
options.aiSearchNamespaces ?? {}
)) {
bindings.push({
name: bindingName,
service: {
name: getUserBindingServiceName(
AI_SEARCH_NS_SCOPE,
bindingName,
entry.remoteProxyConnectionString
),
},
});
}

for (const [bindingName, entry] of Object.entries(
options.aiSearchInstances ?? {}
)) {
bindings.push({
name: bindingName,
service: {
name: getUserBindingServiceName(
AI_SEARCH_INST_SCOPE,
bindingName,
entry.remoteProxyConnectionString
),
},
});
}

return bindings;
},
getNodeBindings(options: z.infer<typeof AISearchOptionsSchema>) {
const nodeBindings: Record<string, ProxyNodeBinding> = {};

for (const bindingName of Object.keys(options.aiSearchNamespaces ?? {})) {
nodeBindings[bindingName] = new ProxyNodeBinding();
}
for (const bindingName of Object.keys(options.aiSearchInstances ?? {})) {
nodeBindings[bindingName] = new ProxyNodeBinding();
}

return nodeBindings;
},
async getServices({ options }) {
const services: {
name: string;
worker: ReturnType<typeof remoteProxyClientWorker>;
}[] = [];

for (const [bindingName, entry] of Object.entries(
options.aiSearchNamespaces ?? {}
)) {
services.push({
name: getUserBindingServiceName(
AI_SEARCH_NS_SCOPE,
bindingName,
entry.remoteProxyConnectionString
),
worker: remoteProxyClientWorker(
entry.remoteProxyConnectionString,
bindingName
),
});
}

for (const [bindingName, entry] of Object.entries(
options.aiSearchInstances ?? {}
)) {
services.push({
name: getUserBindingServiceName(
AI_SEARCH_INST_SCOPE,
bindingName,
entry.remoteProxyConnectionString
),
worker: remoteProxyClientWorker(
entry.remoteProxyConnectionString,
bindingName
),
});
}

return services;
},
};
4 changes: 4 additions & 0 deletions packages/miniflare/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import { ValueOf } from "../workers";
import { AI_PLUGIN, AI_PLUGIN_NAME } from "./ai";
import { AI_SEARCH_PLUGIN, AI_SEARCH_PLUGIN_NAME } from "./ai-search";
import {
ANALYTICS_ENGINE_PLUGIN,
ANALYTICS_ENGINE_PLUGIN_NAME,
Expand Down Expand Up @@ -60,6 +61,7 @@ export const PLUGINS = {
[EMAIL_PLUGIN_NAME]: EMAIL_PLUGIN,
[ANALYTICS_ENGINE_PLUGIN_NAME]: ANALYTICS_ENGINE_PLUGIN,
[AI_PLUGIN_NAME]: AI_PLUGIN,
[AI_SEARCH_PLUGIN_NAME]: AI_SEARCH_PLUGIN,
[BROWSER_RENDERING_PLUGIN_NAME]: BROWSER_RENDERING_PLUGIN,
[DISPATCH_NAMESPACE_PLUGIN_NAME]: DISPATCH_NAMESPACE_PLUGIN,
[IMAGES_PLUGIN_NAME]: IMAGES_PLUGIN,
Expand Down Expand Up @@ -124,6 +126,7 @@ export type WorkerOptions = z.input<typeof CORE_PLUGIN.options> &
z.input<typeof SECRET_STORE_PLUGIN.options> &
z.input<typeof ANALYTICS_ENGINE_PLUGIN.options> &
z.input<typeof AI_PLUGIN.options> &
z.input<typeof AI_SEARCH_PLUGIN.options> &
z.input<typeof BROWSER_RENDERING_PLUGIN.options> &
z.input<typeof DISPATCH_NAMESPACE_PLUGIN.options> &
z.input<typeof IMAGES_PLUGIN.options> &
Expand Down Expand Up @@ -203,6 +206,7 @@ export * from "./secret-store";
export * from "./email";
export * from "./analytics-engine";
export * from "./ai";
export * from "./ai-search";
export * from "./browser-rendering";
export * from "./dispatch-namespace";
export * from "./images";
Expand Down
2 changes: 2 additions & 0 deletions packages/workers-utils/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ export const defaultWranglerConfig: Config = {
r2_buckets: [],
d1_databases: [],
vectorize: [],
ai_search_namespaces: [],
ai_search: [],
hyperdrive: [],
workflows: [],
secrets_store_secrets: [],
Expand Down
40 changes: 40 additions & 0 deletions packages/workers-utils/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,46 @@ export interface EnvironmentNonInheritable {
remote?: boolean;
}[];

/**
* Specifies AI Search namespace bindings that are bound to this Worker environment.
* Each binding is scoped to a namespace and allows dynamic instance CRUD within it.
*
* NOTE: This field is not automatically inherited from the top level environment,
* and so must be specified in every named environment.
*
* @default []
* @nonInheritable
*/
ai_search_namespaces: {
/** The binding name used to refer to the AI Search namespace in the Worker. */
binding: string;
/** The user-chosen namespace name. Must exist in Cloudflare at deploy time. */
namespace?: string;
/** Whether the AI Search namespace binding should be remote in local development */
remote?: boolean;
}[];

/**
* Specifies AI Search instance bindings that are bound to this Worker environment.
* Each binding is bound directly to a single pre-existing instance within a namespace.
*
* NOTE: This field is not automatically inherited from the top level environment,
* and so must be specified in every named environment.
*
* @default []
* @nonInheritable
*/
ai_search: {
/** The binding name used to refer to the AI Search instance in the Worker. */
binding: string;
/** The user-chosen instance name. Must exist in Cloudflare at deploy time. */
instance_name: string;
/** The namespace the instance belongs to. Defaults to "default" if omitted. */
namespace?: string;
/** Whether the AI Search instance binding should be remote in local development */
remote?: boolean;
}[];

/**
* Specifies Hyperdrive configs that are bound to this Worker environment.
*
Expand Down
108 changes: 108 additions & 0 deletions packages/workers-utils/src/config/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export type ConfigBindingFieldName =
| "queues"
| "d1_databases"
| "vectorize"
| "ai_search_namespaces"
| "ai_search"
| "hyperdrive"
| "r2_buckets"
| "logfwdr"
Expand Down Expand Up @@ -116,6 +118,8 @@ export const friendlyBindingNames: Record<ConfigBindingFieldName, string> = {
queues: "Queue",
d1_databases: "D1 Database",
vectorize: "Vectorize Index",
ai_search_namespaces: "AI Search Namespace",
ai_search: "AI Search Instance",
hyperdrive: "Hyperdrive Config",
r2_buckets: "R2 Bucket",
logfwdr: "logfwdr",
Expand Down Expand Up @@ -168,6 +172,8 @@ const bindingTypeFriendlyNames: Record<Binding["type"], string> = {
r2_bucket: "R2 Bucket",
d1: "D1 Database",
vectorize: "Vectorize Index",
ai_search_namespace: "AI Search Namespace",
ai_search: "AI Search Instance",
hyperdrive: "Hyperdrive Config",
service: "Worker",
fetcher: "Service Binding",
Expand Down Expand Up @@ -1695,6 +1701,26 @@ function normalizeAndValidateEnvironment(
validateBindingArray(envName, validateVectorizeBinding),
[]
),
ai_search_namespaces: notInheritable(
diagnostics,
topLevelEnv,
rawConfig,
rawEnv,
envName,
"ai_search_namespaces",
validateBindingArray(envName, validateAISearchNamespaceBinding),
[]
),
ai_search: notInheritable(
diagnostics,
topLevelEnv,
rawConfig,
rawEnv,
envName,
"ai_search",
validateBindingArray(envName, validateAISearchBinding),
[]
),
hyperdrive: notInheritable(
diagnostics,
topLevelEnv,
Expand Down Expand Up @@ -2920,6 +2946,8 @@ const validateUnsafeBinding: ValidatorFn = (diagnostics, field, value) => {
"text_blob",
"browser",
"ai",
"ai_search_namespace",
"ai_search",
"kv_namespace",
"durable_object_namespace",
"d1_database",
Expand Down Expand Up @@ -3920,6 +3948,86 @@ const validateVectorizeBinding: ValidatorFn = (diagnostics, field, value) => {
return isValid;
};

const validateAISearchNamespaceBinding: ValidatorFn = (
diagnostics,
field,
value
) => {
if (typeof value !== "object" || value === null) {
diagnostics.errors.push(
`"ai_search_namespaces" bindings should be objects, but got ${JSON.stringify(value)}`
);
return false;
}
let isValid = true;
if (!isRequiredProperty(value, "binding", "string")) {
diagnostics.errors.push(
`"${field}" bindings should have a string "binding" field but got ${JSON.stringify(value)}.`
);
isValid = false;
}
if (!isRequiredProperty(value, "namespace", "string")) {
diagnostics.errors.push(
`"${field}" bindings must have a "namespace" field but got ${JSON.stringify(value)}.`
);
isValid = false;
}

if (!isRemoteValid(value, field, diagnostics)) {
isValid = false;
}

validateAdditionalProperties(diagnostics, field, Object.keys(value), [
"binding",
"namespace",
"remote",
]);

return isValid;
};

const validateAISearchBinding: ValidatorFn = (diagnostics, field, value) => {
if (typeof value !== "object" || value === null) {
diagnostics.errors.push(
`"ai_search" bindings should be objects, but got ${JSON.stringify(value)}`
);
return false;
}
let isValid = true;
if (!isRequiredProperty(value, "binding", "string")) {
diagnostics.errors.push(
`"${field}" bindings should have a string "binding" field but got ${JSON.stringify(value)}.`
);
isValid = false;
}
if (!isRequiredProperty(value, "instance_name", "string")) {
diagnostics.errors.push(
`"${field}" bindings must have an "instance_name" field but got ${JSON.stringify(value)}.`
);
isValid = false;
}
if (!isOptionalProperty(value, "namespace", "string")) {
diagnostics.errors.push(
`Expected "${field}.namespace" to be of type string but got ${JSON.stringify(
(value as Record<string, unknown>).namespace
)}.`
);
isValid = false;
}
if (!isRemoteValid(value, field, diagnostics)) {
isValid = false;
}

validateAdditionalProperties(diagnostics, field, Object.keys(value), [
"binding",
"instance_name",
"namespace",
"remote",
]);

return isValid;
};

const validateHyperdriveBinding: ValidatorFn = (diagnostics, field, value) => {
if (typeof value !== "object" || value === null) {
diagnostics.errors.push(
Expand Down
Loading
Loading