import type * as runtime from "@cloudflare/workers-types";
import * as aisearch from "@distilled.cloud/cloudflare/aisearch";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import type * as HttpClient from "effect/unstable/http/HttpClient";
import type { RuntimeContext } from "../../RuntimeContext.ts";
import type { Credentials } from "../Credentials.ts";
import { SearchError, type QuerySearchClient } from "./QuerySearch.ts";
import type { QuerySearchNamespaceClient } from "./QuerySearchNamespace.ts";
// Shared HTTP scaffolding for the AI Search `*Local` binding layers. NOT
// re-exported from `index.ts` — only the per-level `*Local` layers are public.
//
// The `ai_search` / `ai_search_namespace` Worker bindings proxy the same AI
// Search REST API that distilled wraps, so the whole data plane
// (`search` / `chatCompletions` / `info` / `stats` / `list`) is reachable over
// HTTP with the current credentials — no Worker host, no native binding.
//
// The one wrinkle: distilled decodes responses to **camelCase**, while the
// binding client contract types come from `@cloudflare/workers-types` and are
// the **snake_case** wire shape. These adapters translate both directions
// (mirroring the D1 `*Local` shim), passing user-controlled maps
// (`item.metadata`, retrieval `filters`) through untouched.
/**
* Injectable auth shared by a future Http (scoped-token) impl and the Local
* (current-credentials) impl. `authorize` discharges the
* `Credentials | HttpClient` requirement of a distilled op; `accountId` is the
* Cloudflare account the ops run against.
*/
export interface SearchAuth {
authorize: (
eff: Effect.Effect,
) => Effect.Effect;
accountId: string;
}
const u = (v: T | null | undefined): T | undefined =>
v == null ? undefined : v;
const run = (
auth: SearchAuth,
eff: Effect.Effect,
): Effect.Effect =>
auth.authorize(eff).pipe(
Effect.mapError((cause) => {
const message =
cause instanceof Error
? cause.message
: typeof (cause as { message?: unknown } | undefined)?.message ===
"string"
? (cause as { message: string }).message
: "AI Search HTTP error";
return new SearchError({ message, cause });
}),
);
// ── response mappers (distilled camelCase -> runtime snake_case) ─────────────
type ChunkIn = aisearch.SearchNamespaceInstanceResponse["chunks"][number];
const mapChunk = (
c: ChunkIn,
): runtime.AiSearchSearchResponse["chunks"][number] => ({
id: c.id,
type: c.type,
score: c.score,
text: c.text,
item: {
key: c.item?.key ?? "",
timestamp: u(c.item?.timestamp),
metadata: u(c.item?.metadata) as Record | undefined,
},
scoring_details: c.scoringDetails
? {
keyword_score: u(c.scoringDetails.keywordScore),
vector_score: u(c.scoringDetails.vectorScore),
keyword_rank: u(c.scoringDetails.keywordRank),
vector_rank: u(c.scoringDetails.vectorRank),
reranking_score: u(c.scoringDetails.rerankingScore),
fusion_method: u(c.scoringDetails.fusionMethod) as
| "rrf"
| "max"
| undefined,
}
: undefined,
});
const mapSearch = (
r: aisearch.SearchNamespaceInstanceResponse,
): runtime.AiSearchSearchResponse => ({
search_query: r.searchQuery ?? "",
chunks: r.chunks.map(mapChunk),
});
const mapChat = (
r: aisearch.ChatCompletionsNamespaceInstanceResponse,
): runtime.AiSearchChatCompletionsResponse => ({
id: u(r.id),
object: u(r.object),
model: u(r.model),
choices: r.choices.map((choice) => ({
index: u(choice.index),
message: {
role: choice.message.role as
| "system"
| "developer"
| "user"
| "assistant"
| "tool",
content:
typeof choice.message.content === "string"
? choice.message.content
: choice.message.content == null
? null
: JSON.stringify(choice.message.content),
},
})),
chunks: r.chunks.map(mapChunk),
});
const mapStats = (
r: aisearch.StatsNamespaceInstanceResponse,
): runtime.AiSearchStatsResponse => ({
queued: u(r.queued),
running: u(r.running),
completed: u(r.completed),
error: u(r.error),
skipped: u(r.skipped),
outdated: u(r.outdated),
last_activity: u(r.lastActivity),
engine: r.engine
? {
vectorize: r.engine.vectorize
? {
vectorsCount: r.engine.vectorize.vectorsCount,
dimensions: r.engine.vectorize.dimensions,
}
: undefined,
r2: r.engine.r2
? {
payloadSizeBytes: r.engine.r2.payloadSizeBytes,
metadataSizeBytes: r.engine.r2.metadataSizeBytes,
objectCount: r.engine.r2.objectCount,
}
: undefined,
}
: undefined,
});
const mapInfo = (
r: aisearch.ReadNamespaceInstanceResponse,
): runtime.AiSearchInstanceInfo => ({
id: r.id,
type: u(r.type) as runtime.AiSearchInstanceInfo["type"],
source: u(r.source),
source_params: u(r.sourceParams),
paused: u(r.paused),
status: u(r.status),
namespace: u(r.namespace),
created_at: u(r.createdAt),
modified_at: u(r.modifiedAt),
token_id: u(r.tokenId),
ai_gateway_id: u(r.aiGatewayId),
rewrite_query: u(r.rewriteQuery),
reranking: u(r.reranking),
embedding_model: u(r.embeddingModel),
ai_search_model: u(r.aiSearchModel),
rewrite_model: u(r.rewriteModel),
reranking_model: u(r.rerankingModel),
hybrid_search_enabled: u(r.hybridSearchEnabled),
index_method: r.indexMethod
? { vector: r.indexMethod.vector, keyword: r.indexMethod.keyword }
: undefined,
fusion_method: u(r.fusionMethod) as "max" | "rrf" | undefined,
indexing_options: r.indexingOptions
? {
keyword_tokenizer: u(r.indexingOptions.keywordTokenizer) as
| "porter"
| "trigram"
| undefined,
}
: undefined,
retrieval_options: r.retrievalOptions
? {
keyword_match_mode: u(r.retrievalOptions.keywordMatchMode) as
| "and"
| "or"
| undefined,
boost_by: u(r.retrievalOptions.boostBy)?.map((b) => ({
field: b.field,
direction: u(b.direction) as
| "asc"
| "desc"
| "exists"
| "not_exists"
| undefined,
})),
}
: undefined,
chunk_size: u(r.chunkSize),
chunk_overlap: u(r.chunkOverlap),
score_threshold: u(r.scoreThreshold),
max_num_results: u(r.maxNumResults),
cache: u(r.cache),
cache_threshold: u(
r.cacheThreshold,
) as runtime.AiSearchInstanceInfo["cache_threshold"],
custom_metadata: u(r.customMetadata)?.map((m) => ({
field_name: m.fieldName,
data_type: m.dataType as "text" | "number" | "boolean" | "datetime",
})),
sync_interval: u(
r.syncInterval,
) as runtime.AiSearchInstanceInfo["sync_interval"],
metadata: u(r.metadata) as Record | undefined,
});
const mapList = (
result: aisearch.ReadNamespaceInstanceResponse[],
): runtime.AiSearchListResponse => ({
result: result.map(mapInfo),
});
const mapMulti = (
r: aisearch.SearchNamespaceResponse,
): runtime.AiSearchMultiSearchResponse => ({
search_query: r.searchQuery ?? "",
chunks: r.chunks.map((c) => ({ ...mapChunk(c), instance_id: c.instanceId })),
errors: u(r.errors)?.map((e) => ({
instance_id: e.instanceId,
message: e.message,
})),
});
// ── request mappers (runtime snake_case -> distilled camelCase) ──────────────
const mapMessages = (messages: runtime.AiSearchMessage[]) =>
messages.map((m) => ({
role: m.role as "system" | "developer" | "user" | "assistant" | "tool",
content:
typeof m.content === "string" || m.content == null
? m.content
: (m.content as unknown[]).map((part) => {
const p = part as Record;
if ("image_url" in p || "imageUrl" in p) {
const img = (p.image_url ?? p.imageUrl) as { url: string };
return { type: "image_url" as const, imageUrl: { url: img.url } };
}
return { type: "text" as const, text: p.text as string };
}),
}));
const mapOptions = (o: runtime.AiSearchOptions | undefined) => {
if (!o) return undefined;
return {
cache: o.cache
? { enabled: o.cache.enabled, cacheThreshold: o.cache.cache_threshold }
: undefined,
queryRewrite: o.query_rewrite
? {
enabled: o.query_rewrite.enabled,
model: o.query_rewrite.model,
rewritePrompt: o.query_rewrite.rewrite_prompt,
}
: undefined,
reranking: o.reranking
? {
enabled: o.reranking.enabled,
model: o.reranking.model,
matchThreshold: o.reranking.match_threshold,
}
: undefined,
retrieval: o.retrieval
? {
retrievalType: o.retrieval.retrieval_type,
fusionMethod: o.retrieval.fusion_method,
keywordMatchMode: o.retrieval.keyword_match_mode,
matchThreshold: o.retrieval.match_threshold,
maxNumResults: o.retrieval.max_num_results,
contextExpansion: o.retrieval.context_expansion,
returnOnFailure: o.retrieval.return_on_failure,
// `filters` is a Vectorize metadata filter over user-defined fields —
// pass through untouched.
filters: o.retrieval.filters as Record | undefined,
boostBy: o.retrieval.boost_by,
}
: undefined,
};
};
// ── client builders ──────────────────────────────────────────────────────────
/** Effect resolving `{ name (namespace), id (instanceId) }` at apply time. */
export type InstanceRef = Effect.Effect<{ name: string; id: string }>;
/** Effect resolving the namespace `name` at apply time. */
export type NamespaceRef = Effect.Effect;
const dieRaw = (kind: string): Effect.Effect =>
Effect.die(
new Error(
`The AI Search ${kind} *Local binding runs over the HTTP API; the raw native runtime binding is only available inside a deployed Worker.`,
),
);
/**
* Build a single-instance {@link QuerySearchClient} over the HTTP API. `ref`
* resolves the `{ namespace, instanceId }` at apply time.
*/
export const makeLocalSearchClient = (
auth: SearchAuth,
ref: InstanceRef,
): QuerySearchClient => {
const withRef = (
fn: (r: {
name: string;
id: string;
}) => Effect.Effect,
) => Effect.flatMap(ref, (r) => run(auth, fn(r)));
return {
raw: dieRaw("instance"),
search: (params) =>
withRef((r) =>
aisearch.searchNamespaceInstance({
accountId: auth.accountId,
name: r.name,
id: r.id,
...("query" in params && params.query !== undefined
? { query: params.query }
: { messages: mapMessages(params.messages ?? []) }),
aiSearchOptions: mapOptions(params.ai_search_options),
} as aisearch.SearchNamespaceInstanceRequest),
).pipe(Effect.map(mapSearch)),
chatCompletions: (params) =>
withRef((r) =>
aisearch.chatCompletionsNamespaceInstance({
accountId: auth.accountId,
name: r.name,
id: r.id,
messages: mapMessages(params.messages),
aiSearchOptions: mapOptions(params.ai_search_options),
} as aisearch.ChatCompletionsNamespaceInstanceRequest),
).pipe(Effect.map(mapChat)),
info: () =>
withRef((r) =>
aisearch.readNamespaceInstance({
accountId: auth.accountId,
name: r.name,
id: r.id,
}),
).pipe(Effect.map(mapInfo)),
stats: () =>
withRef((r) =>
aisearch.statsNamespaceInstance({
accountId: auth.accountId,
name: r.name,
id: r.id,
}),
).pipe(Effect.map(mapStats)),
} satisfies QuerySearchClient;
};
/**
* Build a namespace {@link QuerySearchNamespaceClient} over the HTTP API.
* `.get(instanceName)` scopes a single-instance client to `(namespace,
* instanceName)`.
*/
export const makeLocalSearchNamespaceClient = (
auth: SearchAuth,
ref: NamespaceRef,
): QuerySearchNamespaceClient => ({
raw: dieRaw("namespace"),
get: (instanceName) =>
makeLocalSearchClient(
auth,
Effect.map(ref, (name) => ({ name, id: instanceName })),
),
list: (params) =>
Effect.flatMap(ref, (name) =>
run(
auth,
Stream.runCollect(
aisearch.listNamespaceInstances.pages({
accountId: auth.accountId,
name,
perPage: params?.per_page,
orderBy: params?.order_by,
orderByDirection: params?.order_by_direction,
search: params?.search,
}),
),
),
).pipe(
Effect.map((chunk) =>
mapList(
Array.from(chunk).flatMap(
(page) =>
(page.result ??
[]) as unknown as aisearch.ReadNamespaceInstanceResponse[],
),
),
),
),
search: (params) =>
Effect.flatMap(ref, (name) =>
run(
auth,
aisearch.searchNamespace({
accountId: auth.accountId,
name,
aiSearchOptions: {
instanceIds: params.ai_search_options.instance_ids,
...mapOptions(params.ai_search_options),
},
...("query" in params && params.query !== undefined
? { query: params.query }
: { messages: mapMessages(params.messages ?? []) }),
} as aisearch.SearchNamespaceRequest),
),
).pipe(Effect.map(mapMulti)),
});