///
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
import {
AiError,
LanguageModel as AiLanguageModel,
IdGenerator,
Prompt,
Response,
Tool,
} from "effect/unstable/ai";
import * as Sse from "effect/unstable/encoding/Sse";
import type { RuntimeContext } from "../../RuntimeContext.ts";
/**
* The slice of an AI client the LanguageModel adapter needs: the raw Workers
* AI handle plus, when routed through an AI Gateway, the gateway id.
*
* Both `Cloudflare.AI.QueryGateway(gateway)` (gateway-routed) and
* `Cloudflare.Workers.AI()` (plain Workers AI binding) clients satisfy it.
*/
export interface LanguageModelClient {
/** Effect resolving to the raw Workers AI runtime binding. */
readonly raw: Effect.Effect;
/**
* Effect resolving to the AI Gateway id to route `ai.run` calls through.
* Omit for a plain Workers AI binding — requests then go directly to
* Workers AI without a gateway.
*/
readonly id?: Effect.Effect;
}
/**
* Options for constructing a Workers AI-backed LanguageModel.
*/
export interface LanguageModelOptions {
/**
* Already-bound AI client — from `Cloudflare.AI.QueryGateway(gateway)`
* (routed through the gateway) or `Cloudflare.Workers.AI()` (direct).
*/
readonly client: LanguageModelClient;
/** Workers AI model id, e.g. `@cf/meta/llama-3.3-70b-instruct-fp8-fast`. */
readonly model: string;
/** Optional per-call defaults; overridable per request via `providerOptions`. */
readonly parameters?: {
readonly temperature?: number;
readonly maxTokens?: number;
readonly topP?: number;
readonly topK?: number;
readonly seed?: number;
readonly frequencyPenalty?: number;
readonly presencePenalty?: number;
};
}
/**
* Provide a {@link AiLanguageModel.LanguageModel} layer backed by the supplied
* AI Gateway client and Workers AI model.
*/
export const makeLanguageModelLayer = (
options: LanguageModelOptions,
): Layer.Layer =>
Layer.effect(AiLanguageModel.LanguageModel, makeLanguageModel(options));
/**
* Build a {@link AiLanguageModel.Service} that proxies generateText/streamText
* through the supplied AI Gateway client to a Workers AI model.
*/
export const makeLanguageModel = ({
client,
model,
parameters,
}: LanguageModelOptions): Effect.Effect<
AiLanguageModel.Service,
never,
RuntimeContext
> =>
Effect.gen(function* () {
const ai = yield* client.raw;
const gatewayId = client.id === undefined ? undefined : yield* client.id;
const callRaw = (
body: WorkersAiInputs,
method: "generateText" | "streamText",
): Effect.Effect =>
Effect.tryPromise({
try: () =>
ai.run(
model as keyof AiModels,
body as unknown as AiModels[keyof AiModels]["inputs"],
{
...(gatewayId === undefined
? {}
: { gateway: { id: gatewayId } }),
returnRawResponse: true,
},
),
catch: (cause) => toAiError(cause, method),
});
return yield* AiLanguageModel.make({
generateText: (options) =>
Effect.gen(function* () {
const body = toRequestBody({ options, parameters, stream: false });
const resp = yield* callRaw(body, "generateText");
const json = yield* Effect.tryPromise({
try: () => resp.json() as Promise>,
catch: (cause) => toAiError(cause, "generateText"),
});
return yield* parseGenerateText(json);
}),
streamText: (options) =>
Stream.unwrap(
Effect.gen(function* () {
const idGen = yield* IdGenerator.IdGenerator;
const body = toRequestBody({ options, parameters, stream: true });
const resp = yield* callRaw(body, "streamText");
const hasTools =
options.tools.length > 0 && options.toolChoice !== "none";
return parseStreamText(resp, idGen, hasTools);
}),
),
});
});
// ---------------------------------------------------------------------------
// Wire format types (Workers AI request)
//
// Workers AI returns two response shapes depending on the model:
// - Native: { response: "...", tool_calls: [...] , usage }
// - OpenAI: { choices: [{ message: { content, tool_calls, reasoning_content } }], usage }
//
// We accept both defensively — schemas would over-constrain.
// ---------------------------------------------------------------------------
interface WorkersAiMessage {
readonly role: "system" | "user" | "assistant" | "tool";
readonly content?: unknown;
readonly name?: string;
readonly tool_call_id?: string;
readonly tool_calls?: ReadonlyArray<{
readonly id: string;
readonly type: "function";
readonly function: { readonly name: string; readonly arguments: string };
}>;
readonly reasoning?: string;
}
interface WorkersAiToolDef {
readonly type: "function";
readonly function: {
readonly name: string;
readonly description?: string;
readonly parameters: unknown;
};
}
type WorkersAiToolChoice =
| "auto"
| "required"
| "none"
| { readonly type: "function"; readonly function: { readonly name: string } };
interface WorkersAiInputs {
readonly messages: ReadonlyArray;
readonly tools?: ReadonlyArray;
readonly tool_choice?: WorkersAiToolChoice;
readonly stream?: boolean;
readonly stream_options?: { readonly include_usage: boolean };
readonly max_tokens?: number;
readonly temperature?: number;
readonly top_p?: number;
readonly top_k?: number;
readonly random_seed?: number;
readonly frequency_penalty?: number;
readonly presence_penalty?: number;
}
// ---------------------------------------------------------------------------
// Prompt → Workers AI messages (pure, no .push mutation)
// ---------------------------------------------------------------------------
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
let binary = "";
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
const fileToImageUrl = (
data: string | Uint8Array | URL,
mediaType: string,
): string => {
if (data instanceof URL) return data.toString();
if (data instanceof Uint8Array) {
return `data:${mediaType};base64,${uint8ArrayToBase64(data)}`;
}
if (data.startsWith("data:") || data.startsWith("http")) return data;
return `data:${mediaType};base64,${data}`;
};
const convertPromptToMessages = (
prompt: Prompt.Prompt,
): ReadonlyArray =>
prompt.content.flatMap((m): ReadonlyArray => {
switch (m.role) {
case "system":
return [{ role: "system", content: m.content }];
case "user":
return [toUserMessage(m.content)];
case "assistant":
return [toAssistantMessage(m.content)];
case "tool":
return m.content.flatMap(toToolMessage);
}
});
const toUserMessage = (
parts: Prompt.UserMessage["content"],
): WorkersAiMessage => {
const text = parts
.flatMap((p) => (p.type === "text" ? [p.text] : []))
.join("\n");
const images = parts.flatMap((p) =>
p.type === "file"
? [
{
type: "image_url" as const,
image_url: { url: fileToImageUrl(p.data, p.mediaType) },
},
]
: [],
);
if (images.length === 0) return { role: "user", content: text };
return {
role: "user",
content: [
...(text.length > 0 ? [{ type: "text" as const, text }] : []),
...images,
],
};
};
const toAssistantMessage = (
parts: Prompt.AssistantMessage["content"],
): WorkersAiMessage => {
const text = parts
.flatMap((p) => (p.type === "text" ? [p.text] : []))
.join("");
const reasoning = parts
.flatMap((p) => (p.type === "reasoning" ? [p.text] : []))
.join("");
const toolCalls = parts.flatMap((p) =>
p.type === "tool-call"
? [
{
id: p.id,
type: "function" as const,
function: {
name: p.name,
arguments:
typeof p.params === "string"
? p.params
: JSON.stringify(p.params ?? {}),
},
},
]
: [],
);
return {
role: "assistant",
content: text,
...(reasoning ? { reasoning } : {}),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
};
};
const toToolMessage = (
part: Prompt.ToolMessage["content"][number],
): ReadonlyArray =>
part.type === "tool-result"
? [
{
role: "tool",
name: part.name,
tool_call_id: part.id,
content:
typeof part.result === "string"
? part.result
: JSON.stringify(part.result),
},
]
: [];
// ---------------------------------------------------------------------------
// Tools / tool_choice
// ---------------------------------------------------------------------------
const prepareTools = (
tools: ReadonlyArray,
toolChoice: AiLanguageModel.ProviderOptions["toolChoice"],
): {
tools?: ReadonlyArray;
tool_choice?: WorkersAiToolChoice;
} => {
if (tools.length === 0) return {};
const mapped: ReadonlyArray = tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: Tool.getDescription(tool),
parameters: Tool.getJsonSchema(tool),
},
}));
if (toolChoice === "auto" || toolChoice == null) {
return { tools: mapped, tool_choice: "auto" };
}
if (toolChoice === "none") return { tools: mapped, tool_choice: "none" };
if (toolChoice === "required") {
return { tools: mapped, tool_choice: "required" };
}
if (typeof toolChoice === "object" && "tool" in toolChoice) {
return {
tools: mapped.filter((t) => t.function.name === toolChoice.tool),
tool_choice: "required",
};
}
if (typeof toolChoice === "object" && "oneOf" in toolChoice) {
const allowed = new Set(toolChoice.oneOf);
return {
tools: mapped.filter((t) => allowed.has(t.function.name)),
tool_choice: toolChoice.mode === "required" ? "required" : "auto",
};
}
return { tools: mapped, tool_choice: "auto" };
};
// ---------------------------------------------------------------------------
// Request body
// ---------------------------------------------------------------------------
const toRequestBody = ({
options,
parameters,
stream,
}: {
readonly options: AiLanguageModel.ProviderOptions;
readonly parameters: LanguageModelOptions["parameters"];
readonly stream: boolean;
}): WorkersAiInputs => {
const messages = convertPromptToMessages(options.prompt);
const { tools, tool_choice } = prepareTools(
options.tools,
options.toolChoice,
);
return {
messages,
...(tools !== undefined ? { tools } : {}),
...(tool_choice !== undefined ? { tool_choice } : {}),
// `stream_options.include_usage` is the OpenAI-compatible opt-in for
// usage tokens to appear in the final streamed chunk. Without it most
// Workers AI models omit `usage` from the stream entirely, leaving the
// `finish` part with zeroed counts.
...(stream
? { stream: true, stream_options: { include_usage: true } }
: {}),
...(parameters?.maxTokens !== undefined
? { max_tokens: parameters.maxTokens }
: {}),
...(parameters?.temperature !== undefined
? { temperature: parameters.temperature }
: {}),
...(parameters?.topP !== undefined ? { top_p: parameters.topP } : {}),
...(parameters?.topK !== undefined ? { top_k: parameters.topK } : {}),
...(parameters?.seed !== undefined ? { random_seed: parameters.seed } : {}),
...(parameters?.frequencyPenalty !== undefined
? { frequency_penalty: parameters.frequencyPenalty }
: {}),
...(parameters?.presencePenalty !== undefined
? { presence_penalty: parameters.presencePenalty }
: {}),
};
};
// ---------------------------------------------------------------------------
// Finish reason / usage mapping
// ---------------------------------------------------------------------------
const mapFinishReason = (raw: unknown): Response.FinishReason => {
switch (raw) {
case "stop":
return "stop";
case "length":
case "model_length":
return "length";
case "tool_calls":
return "tool-calls";
case "content_filter":
case "content-filter":
return "content-filter";
case "error":
return "error";
case undefined:
case null:
return "unknown";
default:
return "other";
}
};
const mapUsage = (raw: Record | undefined): Response.Usage => {
const usage = (raw?.usage as Record | undefined) ?? {};
const promptTokens = (usage.prompt_tokens as number | undefined) ?? 0;
const completionTokens = (usage.completion_tokens as number | undefined) ?? 0;
const cached = (
usage.prompt_tokens_details as { cached_tokens?: number } | undefined
)?.cached_tokens;
// Construct an actual `Response.Usage` instance — `Schema.Class`
// encodes by going through the class constructor / `isInstance` check, so a
// plain struct that "matches" the encoded shape isn't enough.
return new Response.Usage({
inputTokens: {
uncached:
cached !== undefined
? Math.max(0, promptTokens - cached)
: promptTokens,
total: promptTokens,
cacheRead: cached ?? 0,
cacheWrite: 0,
},
outputTokens: {
total: completionTokens,
text: 0,
reasoning: 0,
},
});
};
// ---------------------------------------------------------------------------
// generateText: JSON → Response.PartEncoded[]
//
// Normalize the dual-shape (native + OpenAI) response into a single
// `DecodedResponse` once, then build the part list with pure spreads.
// ---------------------------------------------------------------------------
interface DecodedToolCall {
readonly rawId: string;
readonly name: string;
readonly arguments: unknown;
}
interface DecodedResponse {
readonly text: string | undefined;
readonly reasoning: string | undefined;
readonly toolCalls: ReadonlyArray;
readonly finishReason: string | undefined;
}
const decodeResponse = (raw: Record): DecodedResponse => {
const choice = (
raw.choices as
| Array<{
message?: {
content?: string | null;
reasoning_content?: string;
reasoning?: string;
tool_calls?: ReadonlyArray>;
};
finish_reason?: string;
}>
| undefined
)?.[0];
const message = choice?.message;
const openAiText = message?.content;
const text =
typeof openAiText === "string" && openAiText.length > 0
? openAiText
: nativeTextOf(raw.response);
const reasoning = message?.reasoning_content ?? message?.reasoning;
const rawToolCalls =
message?.tool_calls ??
(Array.isArray(raw.tool_calls)
? (raw.tool_calls as ReadonlyArray>)
: []);
return {
text,
reasoning: reasoning && reasoning.length > 0 ? reasoning : undefined,
toolCalls: rawToolCalls.flatMap(decodeToolCall),
finishReason:
choice?.finish_reason ?? (raw.finish_reason as string | undefined),
};
};
const nativeTextOf = (raw: unknown): string | undefined => {
if (raw == null) return undefined;
if (typeof raw === "object") return JSON.stringify(raw);
const text = String(raw);
return text.length > 0 ? text : undefined;
};
const decodeToolCall = (
tc: Record,
): ReadonlyArray => {
const fn = tc.function as { name?: string; arguments?: unknown } | undefined;
const rawId = (tc.id as string | undefined) ?? "";
if (fn?.name) {
return [{ rawId, name: fn.name, arguments: fn.arguments ?? "" }];
}
const flatName = tc.name as string | undefined;
if (flatName) {
return [{ rawId, name: flatName, arguments: tc.arguments ?? "" }];
}
return [];
};
const tryParseJsonArgs = (raw: unknown): unknown => {
if (typeof raw !== "string") return raw;
try {
return JSON.parse(raw);
} catch {
// Leave as raw string; the framework's tool-result decoder will fail loudly.
return raw;
}
};
const parseGenerateText = Effect.fn(function* (raw: Record) {
const idGen = yield* IdGenerator.IdGenerator;
const decoded = decodeResponse(raw);
const toolCallParts = yield* Effect.forEach(decoded.toolCalls, (tc) =>
Effect.gen(function* () {
const id = tc.rawId || (yield* idGen.generateId());
return {
type: "tool-call" as const,
id,
name: tc.name,
params: tryParseJsonArgs(tc.arguments),
};
}),
);
const finish = mapFinishReason(
decoded.finishReason ??
(decoded.toolCalls.length > 0 ? "tool_calls" : "stop"),
);
return [
...(decoded.reasoning !== undefined
? [{ type: "reasoning" as const, text: decoded.reasoning }]
: []),
...(decoded.text !== undefined && decoded.text.length > 0
? [{ type: "text" as const, text: decoded.text }]
: []),
...toolCallParts,
{
type: "finish" as const,
reason: finish,
usage: mapUsage(raw),
response: undefined,
},
] satisfies ReadonlyArray;
});
// ---------------------------------------------------------------------------
// streamText: SSE byte stream → Stream
//
// Immutable `StreamState` is threaded through `Stream.mapAccumEffect`. The
// per-chunk output buffer (`parts: Array`) is mutable for
// performance — it's scoped to one chunk, never escapes the handler, and lets
// us avoid the O(n²) array-spread that pure threading would force in the hot
// path. This matches the pattern Effect's own `@effect/ai-*` adapters use.
// ---------------------------------------------------------------------------
interface StreamState {
readonly textId: string | undefined;
readonly reasoningId: string | undefined;
readonly toolCalls: ReadonlyMap<
number,
{ readonly id: string; readonly name: string; readonly arguments: string }
>;
readonly lastToolIndex: number | undefined;
readonly closedToolIndices: ReadonlySet;
readonly usage: Record | undefined;
readonly finishReason: string | undefined;
readonly receivedAnyData: boolean;
readonly receivedDone: boolean;
// Workers AI's *native* streaming for function-calling models (e.g.
// `@cf/meta/llama-3.3-70b`) does not emit structured `tool_calls` deltas
// — it streams the call as a JSON document inside the `response` text
// field (`{"name":"get_weather","parameters":{...}}`). When tools were
// requested we buffer that text here instead of emitting it as
// text-delta, then parse it into tool-params parts on finalize.
readonly nativeToolBuffer: string;
readonly nativeToolId: string | undefined;
}
const initialStreamState = (): StreamState => ({
textId: undefined,
reasoningId: undefined,
toolCalls: new Map(),
lastToolIndex: undefined,
closedToolIndices: new Set(),
usage: undefined,
finishReason: undefined,
receivedAnyData: false,
receivedDone: false,
nativeToolBuffer: "",
nativeToolId: undefined,
});
type StreamParts = Array;
const tryParseJson = (data: string): Record | undefined => {
try {
const v = JSON.parse(data);
return v && typeof v === "object"
? (v as Record)
: undefined;
} catch {
return undefined;
}
};
const isNullFinalizationToolCall = (tc: Record): boolean => {
const fn = tc.function as Record | undefined;
const name = fn?.name ?? tc.name ?? null;
const args = fn?.arguments ?? tc.arguments ?? null;
const id = tc.id ?? null;
return !id && !name && (!args || args === "");
};
const closeReasoning = (
state: StreamState,
parts: StreamParts,
): StreamState => {
if (state.reasoningId === undefined) return state;
parts.push({ type: "reasoning-end", id: state.reasoningId });
return { ...state, reasoningId: undefined };
};
const closeToolCall = (
state: StreamState,
index: number,
parts: StreamParts,
): StreamState => {
if (state.closedToolIndices.has(index)) return state;
const tc = state.toolCalls.get(index);
if (!tc) return state;
parts.push({ type: "tool-params-end", id: tc.id });
const closed = new Set(state.closedToolIndices);
closed.add(index);
return { ...state, closedToolIndices: closed };
};
const emitTextDelta = (
state: StreamState,
delta: string,
parts: StreamParts,
idGen: IdGenerator.Service,
): Effect.Effect =>
Effect.gen(function* () {
let s = closeReasoning(state, parts);
if (s.textId === undefined) {
const id = yield* idGen.generateId();
parts.push({ type: "text-start", id });
s = { ...s, textId: id };
}
parts.push({ type: "text-delta", id: s.textId!, delta });
return s;
});
const emitReasoningDelta = (
state: StreamState,
delta: string,
parts: StreamParts,
idGen: IdGenerator.Service,
): Effect.Effect =>
Effect.gen(function* () {
let s = state;
if (s.reasoningId === undefined) {
const id = yield* idGen.generateId();
parts.push({ type: "reasoning-start", id });
s = { ...s, reasoningId: id };
}
parts.push({ type: "reasoning-delta", id: s.reasoningId!, delta });
return s;
});
const handleToolDeltas = (
state: StreamState,
deltas: ReadonlyArray>,
parts: StreamParts,
idGen: IdGenerator.Service,
): Effect.Effect =>
Effect.gen(function* () {
let s = state;
for (const d of deltas) {
if (isNullFinalizationToolCall(d)) {
if (s.lastToolIndex !== undefined) {
s = closeToolCall(s, s.lastToolIndex, parts);
}
continue;
}
const idx = (d.index as number | undefined) ?? 0;
const fn = d.function as
| { name?: string; arguments?: string }
| undefined;
const name = fn?.name ?? (d.name as string | undefined) ?? "";
const args = fn?.arguments ?? (d.arguments as string | undefined) ?? "";
const rawId = (d.id as string | undefined) ?? "";
const existing = s.toolCalls.get(idx);
if (existing === undefined) {
if (s.lastToolIndex !== undefined && s.lastToolIndex !== idx) {
s = closeToolCall(s, s.lastToolIndex, parts);
}
const id = rawId || (yield* idGen.generateId());
const entry = { id, name, arguments: args };
const next = new Map(s.toolCalls);
next.set(idx, entry);
s = { ...s, toolCalls: next, lastToolIndex: idx };
parts.push({ type: "tool-params-start", id, name });
if (args.length > 0) {
parts.push({ type: "tool-params-delta", id, delta: args });
}
} else {
// OpenAI-compatible providers stream argument fragments, while
// Workers AI can resend the complete accumulated JSON on every
// chunk. Normalize both shapes to incremental deltas.
const delta = args.startsWith(existing.arguments)
? args.slice(existing.arguments.length)
: args;
const accumulated = args.startsWith(existing.arguments)
? args
: existing.arguments + args;
const next = new Map(s.toolCalls);
next.set(idx, { ...existing, arguments: accumulated });
s = { ...s, toolCalls: next, lastToolIndex: idx };
if (delta.length > 0) {
parts.push({
type: "tool-params-delta",
id: existing.id,
delta,
});
}
}
}
return s;
});
const hasNonZeroUsage = (raw: unknown): boolean => {
if (raw == null || typeof raw !== "object") return false;
const u = raw as Record;
const prompt = (u.prompt_tokens as number | undefined) ?? 0;
const completion = (u.completion_tokens as number | undefined) ?? 0;
const total = (u.total_tokens as number | undefined) ?? 0;
return prompt > 0 || completion > 0 || total > 0;
};
const updateChunkMeta = (
state: StreamState,
chunk: Record,
): StreamState => {
let s = state;
// Workers AI's native stream emits the real usage chunk, then a
// "zero-valued terminator" chunk where every count is 0 (it also re-emits
// `usage` with all zeros). Treat the zero chunk as a no-op so we keep the
// meaningful counts.
if (chunk.usage !== undefined && hasNonZeroUsage(chunk.usage)) {
s = { ...s, usage: chunk };
}
const choices = chunk.choices as
| Array<{ finish_reason?: string }>
| undefined;
const finish =
choices?.[0]?.finish_reason ?? (chunk.finish_reason as string | undefined);
if (finish != null) s = { ...s, finishReason: finish };
return s;
};
const handleNativeText = (
state: StreamState,
chunk: Record,
parts: StreamParts,
idGen: IdGenerator.Service,
hasTools: boolean,
): Effect.Effect => {
const native = chunk.response;
if (native == null || native === "") return Effect.succeed(state);
const text =
typeof native === "object" ? JSON.stringify(native) : String(native);
if (text.length === 0) return Effect.succeed(state);
// When tools were requested, the native `response` stream is the
// tool-call JSON, not prose — buffer it and decide on finalize. We
// pre-allocate the tool id here (we're in an Effect, finalize is sync).
if (hasTools) {
return Effect.gen(function* () {
const id = state.nativeToolId ?? (yield* idGen.generateId());
return {
...state,
nativeToolId: id,
nativeToolBuffer: state.nativeToolBuffer + text,
};
});
}
return emitTextDelta(state, text, parts, idGen);
};
const handleNativeToolCalls = (
state: StreamState,
chunk: Record,
parts: StreamParts,
idGen: IdGenerator.Service,
): Effect.Effect => {
const openAiToolCalls = (
chunk.choices as
| Array<{ delta?: { tool_calls?: ReadonlyArray } }>
| undefined
)?.[0]?.delta?.tool_calls;
// Workers AI can mirror the same fragments in both its top-level native
// field and the OpenAI-compatible delta. Prefer the latter so every
// fragment is emitted exactly once.
if (Array.isArray(openAiToolCalls)) return Effect.succeed(state);
if (!Array.isArray(chunk.tool_calls)) return Effect.succeed(state);
return Effect.gen(function* () {
const s = closeReasoning(state, parts);
return yield* handleToolDeltas(
s,
chunk.tool_calls as ReadonlyArray>,
parts,
idGen,
);
});
};
const handleOpenAiDelta = (
state: StreamState,
chunk: Record,
parts: StreamParts,
idGen: IdGenerator.Service,
): Effect.Effect => {
const delta = (
chunk.choices as Array<{ delta?: Record }> | undefined
)?.[0]?.delta;
if (!delta) return Effect.succeed(state);
return Effect.gen(function* () {
let s = state;
const reasoning = (delta.reasoning_content ?? delta.reasoning) as
| string
| undefined;
if (reasoning && reasoning.length > 0) {
s = yield* emitReasoningDelta(s, reasoning, parts, idGen);
}
const text = delta.content as string | undefined;
if (text && text.length > 0) {
s = yield* emitTextDelta(s, text, parts, idGen);
}
const toolDeltas = delta.tool_calls as
| ReadonlyArray>
| undefined;
if (Array.isArray(toolDeltas)) {
s = closeReasoning(s, parts);
s = yield* handleToolDeltas(s, toolDeltas, parts, idGen);
}
return s;
});
};
const handleStreamChunk = (
state: StreamState,
data: string,
idGen: IdGenerator.Service,
hasTools: boolean,
): Effect.Effect<
readonly [StreamState, ReadonlyArray]
> =>
Effect.gen(function* () {
if (data === "") return [state, []] as const;
if (data === "[DONE]") {
return [{ ...state, receivedDone: true }, []] as const;
}
const chunk = tryParseJson(data);
if (chunk === undefined) return [state, []] as const;
const parts: StreamParts = [];
let s: StreamState = { ...state, receivedAnyData: true };
s = updateChunkMeta(s, chunk);
s = yield* handleNativeText(s, chunk, parts, idGen, hasTools);
s = yield* handleNativeToolCalls(s, chunk, parts, idGen);
s = yield* handleOpenAiDelta(s, chunk, parts, idGen);
return [s, parts] as const;
});
/**
* Decode a buffered native-streaming tool call. Workers AI's native shape
* for a function call is a JSON document with `name` plus `parameters` (or
* `arguments`), optionally wrapped in an array. Returns one entry per call,
* or `undefined` if the buffer isn't a tool-call document (so the caller
* can fall back to treating it as plain text).
*/
const decodeNativeToolBuffer = (
buffer: string,
): ReadonlyArray<{ name: string; args: string }> | undefined => {
const trimmed = buffer.trim();
if (trimmed.length === 0 || !(trimmed[0] === "{" || trimmed[0] === "[")) {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return undefined;
}
const items = Array.isArray(parsed) ? parsed : [parsed];
const calls = items.flatMap(
(
item,
): ReadonlyArray<{
name: string;
args: string;
}> => {
if (item == null || typeof item !== "object") return [];
const rec = item as Record;
const fn = rec.function as Record | undefined;
const name = (fn?.name ?? rec.name) as string | undefined;
if (typeof name !== "string" || name.length === 0) return [];
const rawArgs = fn?.arguments ?? rec.arguments ?? rec.parameters ?? {};
const args =
typeof rawArgs === "string" ? rawArgs : JSON.stringify(rawArgs);
return [{ name, args }];
},
);
return calls.length > 0 ? calls : undefined;
};
const flushNativeToolBuffer = (
state: StreamState,
parts: StreamParts,
): StreamState => {
if (state.nativeToolBuffer.length === 0) return state;
const calls = decodeNativeToolBuffer(state.nativeToolBuffer);
// Not a tool-call document — the model returned prose despite tools being
// available; surface it as text so nothing is dropped.
if (calls === undefined) {
const id = state.nativeToolId ?? "text-0";
parts.push({ type: "text-start", id });
parts.push({ type: "text-delta", id, delta: state.nativeToolBuffer });
parts.push({ type: "text-end", id });
return { ...state, nativeToolBuffer: "", nativeToolId: undefined };
}
const baseId = state.nativeToolId ?? "tool-0";
calls.forEach((call, i) => {
const id = i === 0 ? baseId : `${baseId}-${i}`;
parts.push({ type: "tool-params-start", id, name: call.name });
if (call.args.length > 0) {
parts.push({ type: "tool-params-delta", id, delta: call.args });
}
parts.push({ type: "tool-params-end", id });
});
return { ...state, nativeToolBuffer: "", nativeToolId: undefined };
};
const finalizeStream = (
state: StreamState,
): ReadonlyArray => {
const parts: StreamParts = [];
let s = state;
for (const [idx] of s.toolCalls) s = closeToolCall(s, idx, parts);
s = closeReasoning(s, parts);
if (s.textId !== undefined) parts.push({ type: "text-end", id: s.textId });
// Emit any buffered native-streaming tool call (llama-style) before the
// terminal finish part.
s = flushNativeToolBuffer(s, parts);
// Three cases for the final reason:
// 1. The model emitted an explicit `finish_reason` → map it.
// 2. The stream ended cleanly (`[DONE]` seen) but no reason → "stop".
// Workers AI's native shape never includes `finish_reason`,
// so without this rule every native-mode stream would report
// `unknown` despite completing successfully.
// 3. The stream ended abnormally (no `[DONE]`, no reason) → "error".
const reason: Response.FinishReason =
s.finishReason !== undefined
? mapFinishReason(s.finishReason)
: s.receivedDone
? "stop"
: s.receivedAnyData
? "error"
: "unknown";
parts.push({
type: "finish",
reason,
usage: mapUsage(s.usage),
response: undefined,
});
return parts;
};
const parseStreamText = (
resp: Response,
idGen: IdGenerator.Service,
hasTools: boolean,
): Stream.Stream => {
const body = resp.body;
if (body === null) {
return Stream.fromIterable(
finalizeStream(initialStreamState()),
);
}
return Stream.fromReadableStream({
evaluate: () => body,
onError: (cause) => toAiError(cause, "streamText"),
}).pipe(
Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", (retry) => Stream.die(retry)),
Stream.catchTag("SseError", (error) =>
Stream.fail(toAiError(error, "streamText")),
),
Stream.mapAccumEffect(
initialStreamState,
(state, event) => handleStreamChunk(state, event.data, idGen, hasTools),
{ onHalt: (state) => finalizeStream(state) },
),
);
};
// ---------------------------------------------------------------------------
// Error mapping
// ---------------------------------------------------------------------------
const toAiError = (
cause: unknown,
method: "generateText" | "streamText",
): AiError.AiError =>
AiError.AiError.make({
module: "Cloudflare.AI.LanguageModel",
method,
reason: new AiError.UnknownError({
description:
cause instanceof Error ? cause.message : "AI Gateway request failed",
}),
});