agents
Version:
A home for your AI agents
1,861 lines • 71 kB
JavaScript
import { C as isCatalogModelId, T as isRunPathLanguageModelId, _ as isGatewayErrorEnvelope, a as normalizeChatCompletion, c as gatewaySlugForBaseUrl, d as resolveGatewayProvider, f as createTransport, g as headersToObject, h as errorFromResponse, i as normalizeChatCompletionsStream, l as isGatewayShapedUrl, m as errorFromGatewayEnvelope, n as imageMediaTypeOf, o as prepareChatCompletionsRequest, r as sseDataStream, s as GATEWAY_PROVIDER_NAMES, t as buildImageInput, v as unwrapEnvelope, x as isAbortError, y as CloudflareAIError } from "../../images-CfuRCjwq.js";
import { i as mergeModelOptions, o as resolveOptions, r as gatewayLayerOf } from "../../settings-Dk2il2Hf.js";
import { ToolCallArgumentsStream, calculateCost, clampThinkingLevel, createAssistantMessageEventStream, createProvider, getCurrentTools, normalizeContext } from "@earendil-works/pi-ai";
import { CLOUDFLARE_AI_GATEWAY_MODELS } from "@earendil-works/pi-ai/providers/cloudflare-ai-gateway.models";
import { CLOUDFLARE_WORKERS_AI_MODELS } from "@earendil-works/pi-ai/providers/cloudflare-workers-ai.models";
import { convertMessages } from "@earendil-works/pi-ai/api/openai-completions";
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
import { adjustMaxTokensForThinking } from "@earendil-works/pi-ai/api/simple-options";
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "@earendil-works/pi-ai/api/openai-responses-shared";
import { clampOpenAIPromptCacheKey } from "@earendil-works/pi-ai/api/openai-prompt-cache";
//#region src/models/pi-ai/catalog.ts
/**
* The `api` marker Workers AI models from this provider carry. It is not a
* wire format: Workers AI runs on `env.AI.run`, and the compat layer maps that
* onto strict OpenAI chat completions.
*/
const CLOUDFLARE_AI_API = "cloudflare-ai";
/**
* The pi-ai provider id. A `Models` registry resolves specifiers as
* `cloudflare/<id>`, e.g. `cloudflare/anthropic/claude-opus-4.8`.
*/
const CLOUDFLARE_PROVIDER_ID = "cloudflare";
const MODEL_OPTIONS = Symbol.for("agents.models.pi-ai.options");
const MODEL_ROUTING = Symbol.for("agents.models.pi-ai.routing");
function tag(model, key, value) {
Object.defineProperty(model, key, {
configurable: true,
enumerable: true,
value,
writable: false
});
}
/** Reads the options a model was created with, if it came from this provider. */
function optionsOf(model) {
const options = model[MODEL_OPTIONS];
return options !== null && typeof options === "object" ? options : void 0;
}
/**
* The gateway registry entry a model's own id names, for a model this provider
* built from a `<slug>/<id>` specifier. It is looked up rather than inferred,
* so a model that lost its symbol tags — `JSON.stringify` and
* `structuredClone` drop them, and an Agent's state goes through both — still
* reaches the vendor under the vendor's own spelling.
*/
function registryEntryFor(model) {
if (model.provider !== "cloudflare" || model.id.startsWith("@cf/")) return;
gatewayRegistry ??= buildGatewayRegistry();
return gatewayRegistry.get(model.id);
}
/**
* The id the request body must carry. It differs from `model.id` only for a
* `<slug>/<id>` specifier, whose model is listed under the specifier so a
* `Models` registry can find it while the vendor still sees its own spelling.
*/
function wireModelId(model) {
const tagged = model[MODEL_ROUTING]?.modelId;
if (tagged !== void 0) return tagged;
return registryEntryFor(model)?.id ?? model.id;
}
/** The `<slug>/<id>` specifier a model was resolved from, for diagnostics. */
function specifierOf(model) {
const tagged = model[MODEL_ROUTING]?.specifier;
if (tagged !== void 0) return tagged;
return registryEntryFor(model) === void 0 ? void 0 : model.id;
}
/**
* Copies a caller's model and tags the copy with the resolved options. The
* caller's object is never mutated, and every field it declares — `api`,
* `provider`, `id`, `baseUrl`, `compat`, `thinkingLevelMap`, cost and limits —
* is kept verbatim, because that metadata is the model's own author's, not
* ours.
*/
function withOptions(model, options) {
const copy = { ...model };
if (options !== void 0) tag(copy, MODEL_OPTIONS, options);
return copy;
}
const DEFAULT_CONTEXT_WINDOW = 128e3;
const DEFAULT_MAX_TOKENS = 8192;
const ZERO_COST = {
cacheRead: 0,
cacheWrite: 0,
input: 0,
output: 0
};
let workersAI;
function workersAIEntry(modelId) {
workersAI ??= new Map(Object.values(CLOUDFLARE_WORKERS_AI_MODELS).map((entry) => [entry.id, entry]));
return workersAI.get(modelId);
}
/** Every Workers AI id pi-ai knows, for the provider's static model list. */
function workersAIModelIds() {
workersAI ??= new Map(Object.values(CLOUDFLARE_WORKERS_AI_MODELS).map((entry) => [entry.id, entry]));
return [...workersAI.keys()].sort();
}
/**
* Builds the pi-ai model for a Workers AI id. Registry metadata is used when
* pi-ai knows the id; explicit options override it; everything else falls back
* to defaults that keep the model usable, because the Workers AI catalog moves
* faster than any generated registry.
*/
function buildWorkersAIModel(modelId, options) {
const known = workersAIEntry(modelId);
const model = {
api: CLOUDFLARE_AI_API,
baseUrl: "",
contextWindow: options?.contextWindow ?? known?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
cost: options?.cost ?? known?.cost ?? ZERO_COST,
id: modelId,
input: options?.input ?? known?.input ?? ["text"],
maxTokens: options?.maxTokens ?? known?.maxTokens ?? DEFAULT_MAX_TOKENS,
name: options?.name ?? known?.name ?? modelId,
provider: CLOUDFLARE_PROVIDER_ID,
reasoning: options?.reasoning ?? known?.reasoning ?? true,
...known?.thinkingLevelMap !== void 0 ? { thinkingLevelMap: known.thinkingLevelMap } : {}
};
if (options !== void 0) tag(model, MODEL_OPTIONS, options);
return model;
}
/** The gateway provider slug a registry entry's base URL names. */
function slugOf(entry) {
return entry.baseUrl.split("/").filter((part) => part !== "").at(-1);
}
/**
* The id AI Gateway's model catalog — `env.AI.run` — knows a registry entry
* by. pi-ai spells Claude ids as Anthropic does (`claude-opus-4-8`), because pi
* sends them through the gateway's Anthropic passthrough, while the catalog
* puts a dot between version numbers (`anthropic/claude-opus-4.8`) and answers
* `Model not found` to the dashed spelling. Checked against the live catalog
* for every entry in pi-ai 1.2: that dot is the only difference, and only
* Anthropic ids have it.
*/
function gatewayCatalogId(slug, id) {
return `${slug}/${slug === "anthropic" ? id.replace(/(\d)-(\d)(?!\d)/g, "$1.$2") : id}`;
}
let gatewayRegistry;
/**
* pi-ai's generated Cloudflare AI Gateway registry, keyed by the id AI
* Gateway's catalog runs each entry under (see {@link gatewayCatalogId}).
* `/compat` entries are skipped: they alias `@cf/` ids the Workers AI registry
* already declares, and those belong on the run path.
*/
function buildGatewayRegistry() {
const map = /* @__PURE__ */ new Map();
for (const entry of Object.values(CLOUDFLARE_AI_GATEWAY_MODELS)) {
const slug = slugOf(entry);
if (slug === void 0 || slug === "compat") continue;
map.set(gatewayCatalogId(slug, entry.id), entry);
}
return map;
}
/** Every `<slug>/<id>` specifier pi-ai's gateway registry declares. */
function gatewayModelIds() {
gatewayRegistry ??= buildGatewayRegistry();
return [...gatewayRegistry.keys()].sort();
}
/** An id with case and separators (`.`, `-`, `_`) ignored. */
function looseId(id) {
return id.toLowerCase().replace(/[._-]/g, "-");
}
/**
* Catalog specifiers that differ from `specifier` only in case or separators.
* AI Gateway's catalog spells `anthropic/claude-opus-4.8` where Anthropic
* spells `claude-opus-4-8`, and the difference is easy to miss.
*/
function nearSpecifiers(specifier, specifiers) {
const wanted = looseId(specifier);
return [...specifiers].filter((id) => looseId(id) === wanted).sort();
}
/**
* Resolves a `<slug>/<id>` specifier through pi-ai's generated Cloudflare AI
* Gateway registry. The entry's metadata — api, base URL, compat,
* `thinkingLevelMap`, cost, limits — is kept exactly as pi generated it; only
* `id` and `provider` are restated so a `Models` registry can find the model
* under `cloudflare/<slug>/<id>`, and the vendor's own spelling rides along for
* the request body.
*
* There is no prefix guessing: an id pi-ai does not list is a `TypeError`,
* pointing at the model-object form, which needs no catalog of ours at all.
*/
function buildGatewayModel(specifier, options) {
gatewayRegistry ??= buildGatewayRegistry();
const separator = specifier.indexOf("/");
const slug = separator === -1 ? "" : specifier.slice(0, separator);
const vendorId = specifier.slice(separator + 1);
if (slug === "workers-ai") throw new TypeError(`"${specifier}" is a Workers AI model: pass its id directly, ai("${vendorId}").`);
const entry = gatewayRegistry.get(specifier);
if (entry === void 0) {
const near = nearSpecifiers(specifier, gatewayRegistry.keys());
const suggestion = near.length === 0 ? "" : ` Did you mean ${near.map((id) => `ai("${id}")`).join(" or ")}? The catalog spells ids as AI Gateway does.`;
throw new TypeError(`"${specifier}" is not in pi-ai's Cloudflare AI Gateway catalog.${suggestion} Pass the model object instead, e.g. ai(getBuiltinModel("${slug || "anthropic"}", "${vendorId}")) from "@earendil-works/pi-ai/providers/all", or any model from a provider registry you imported.`);
}
const model = {
...entry,
id: specifier,
provider: CLOUDFLARE_PROVIDER_ID
};
tag(model, MODEL_ROUTING, {
modelId: entry.id,
specifier
});
if (options !== void 0) tag(model, MODEL_OPTIONS, options);
return model;
}
//#endregion
//#region src/models/pi-ai/routing.ts
/**
* The path each pi api speaks, relative to the model's own `baseUrl` — exactly
* what pi's SDK clients append to it.
*/
const API_PATHS = {
"anthropic-messages": "v1/messages",
"openai-completions": "chat/completions",
"openai-responses": "responses"
};
/**
* The provider-native `endpoint` for a gateway-shaped base URL, whose path
* already ends at the provider slug, so what follows is the vendor's own path.
*/
const GATEWAY_ENDPOINTS = {
"anthropic-messages": "v1/messages",
"openai-completions": "v1/chat/completions",
"openai-responses": "v1/responses"
};
/**
* The gateway provider slug that serves a model, from its own base URL.
*
* @throws TypeError when the model carries no base URL, or one AI Gateway has
* no provider for.
*/
function gatewaySlugForModel(model) {
if (model.baseUrl === "") throw new TypeError(`The model "${model.id}" has no baseUrl, so AI Gateway cannot tell which provider serves it.`);
return gatewaySlugForBaseUrl(model.baseUrl);
}
/** `https://api.groq.com/openai/v1` → `https://api.groq.com/openai/v1/`. */
function asBase(baseUrl) {
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
}
/**
* The provider slug and endpoint one model's request travels on.
*
* @throws TypeError when the model has no base URL, when AI Gateway has no
* provider for it, or when this module knows no path for the model's api.
*/
function routeForModel(model) {
const provider = gatewaySlugForModel(model);
if (isGatewayShapedUrl(model.baseUrl)) {
const endpoint = GATEWAY_ENDPOINTS[model.api];
if (endpoint === void 0) throw noEndpoint(model.api);
return {
endpoint,
provider
};
}
const path = API_PATHS[model.api];
if (path === void 0) throw noEndpoint(model.api);
const resolved = resolveGatewayProvider(new URL(path, asBase(model.baseUrl)).toString());
if (resolved === void 0) return {
endpoint: GATEWAY_ENDPOINTS[model.api] ?? path,
provider
};
return {
endpoint: resolved.endpoint,
provider: resolved.provider
};
}
function noEndpoint(api) {
return /* @__PURE__ */ new TypeError(`No AI Gateway endpoint is known for the "${api}" API.`);
}
//#endregion
//#region src/models/pi-ai/wires/shared.ts
/** Diagnostic type under which gateway correlation ids are recorded. */
const CLOUDFLARE_DIAGNOSTIC = "cloudflare";
/** The diagnostic type under which compat-layer warnings are recorded. */
const COMPAT_DIAGNOSTIC = "cloudflare-compat";
/** Records compat warnings on a message as a `cloudflare-compat` diagnostic. */
/**
* pi-ai types diagnostic details as JSON. These details are plain data this
* module built, so a JSON round trip only drops their undefined fields.
*/
function jsonDetails(value) {
return JSON.parse(JSON.stringify(value));
}
function recordWarnings(output, warnings) {
if (warnings.length === 0) return;
output.diagnostics = [...output.diagnostics ?? [], {
details: jsonDetails({ warnings }),
timestamp: Date.now(),
type: COMPAT_DIAGNOSTIC
}];
}
/**
* The Workers-AI-only knobs a vendor model cannot honour. They are not sent —
* the vendor's own reasoning follows the model's metadata and the pi
* `reasoning` level — and the drop is recorded rather than silent.
*/
function ignoredWorkersAIKnobs(request) {
const warnings = [];
if (request.resolved.reasoningEffort !== void 0) warnings.push({
feature: "reasoning-effort",
message: "reasoningEffort is a Workers AI setting and does not apply to a model routed through AI Gateway; use the pi `reasoning` level, which the model's own metadata maps."
});
if (request.resolved.chatTemplateKwargs !== void 0) warnings.push({
feature: "chat-template-kwargs",
message: "chatTemplateKwargs is a Workers AI setting and was not sent to a model routed through AI Gateway."
});
return warnings;
}
function emptyUsage() {
return {
cacheRead: 0,
cacheWrite: 0,
cost: {
cacheRead: 0,
cacheWrite: 0,
input: 0,
output: 0,
total: 0
},
input: 0,
output: 0,
totalTokens: 0
};
}
/** The assistant message a stream mutates as events arrive. */
function startMessage(model) {
return {
api: model.api,
content: [],
model: model.id,
provider: model.provider,
role: "assistant",
stopReason: "stop",
timestamp: Date.now(),
usage: emptyUsage()
};
}
/**
* Maps OpenAI-shaped usage onto pi-ai's. `input` excludes cached tokens, as
* pi-ai's own OpenAI adapter does, and cost comes from the model metadata.
*/
function usageFromOpenAI(model, raw) {
if (raw === void 0) return emptyUsage();
const prompt = raw.prompt_tokens ?? 0;
const completion = raw.completion_tokens ?? 0;
const cacheRead = raw.prompt_tokens_details?.cached_tokens ?? raw.prompt_cache_hit_tokens ?? 0;
const cacheWrite = raw.prompt_tokens_details?.cache_write_tokens ?? 0;
const reasoning = raw.completion_tokens_details?.reasoning_tokens;
const totalTokens = raw.total_tokens ?? prompt + completion;
const output = raw.total_tokens === void 0 ? completion : Math.max(completion, totalTokens - prompt);
const usage = {
cacheRead,
cacheWrite,
cost: {
cacheRead: 0,
cacheWrite: 0,
input: 0,
output: 0,
total: 0
},
input: Math.max(0, prompt - cacheRead - cacheWrite),
output,
...reasoning !== void 0 ? { reasoning } : {},
totalTokens
};
usage.cost = calculateCost(model, usage);
return usage;
}
function correlationOf(response, transport) {
const get = (name) => response.headers.get(name) ?? void 0;
return {
cacheStatus: get("cf-aig-cache-status"),
eventId: get("cf-aig-event-id"),
logId: get("cf-aig-log-id") ?? transport.logIdFallback(response),
runId: get("cf-aig-run-id"),
step: get("cf-aig-step"),
traceId: get("cf-aig-trace-id")
};
}
function definedEntries$1(record) {
const result = {};
for (const [key, value] of Object.entries(record)) if (value !== void 0) result[key] = value;
return result;
}
/**
* Records where a response came from on the assistant message, as a pi-ai
* diagnostic: the gateway log id and friends, the answering model and the
* gateway id. Callers read it back from `message.diagnostics`.
*/
function attachCorrelation(message, details) {
const diagnostic = {
details: jsonDetails(definedEntries$1({ ...details })),
timestamp: Date.now(),
type: CLOUDFLARE_DIAGNOSTIC
};
message.diagnostics = [...message.diagnostics ?? [], diagnostic];
}
/**
* Wraps a body so a gap longer than `idleMs` between chunks rejects the read
* instead of pending forever. Only an outstanding read is timed, so a slow
* consumer never trips it; `idleMs <= 0` disables the guard.
*/
function withIdleDeadline(body, idleMs) {
if (idleMs <= 0) return body;
const reader = body.getReader();
return new ReadableStream({
cancel(reason) {
return reader.cancel(reason);
},
async pull(controller) {
let timer;
try {
const result = await Promise.race([reader.read(), new Promise((_, reject) => {
timer = setTimeout(() => {
reject(/* @__PURE__ */ new Error(`Model stream timed out: no data received for ${Math.round(idleMs / 1e3)}s.`));
}, idleMs);
})]);
if (result.done) controller.close();
else controller.enqueue(result.value);
} catch (error) {
reader.cancel().catch(() => {});
controller.error(error);
} finally {
clearTimeout(timer);
}
}
});
}
/**
* Iterates the JSON payload of each SSE event. `[DONE]` sentinels and
* unparseable payloads are skipped; the reader is cancelled if iteration
* stops early so the upstream request is not left generating.
*/
async function* sseJson(body, onDone) {
const reader = body.pipeThrough(sseDataStream()).getReader();
let finished = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
finished = true;
return;
}
if (value === "") continue;
if (value === "[DONE]") {
onDone?.();
continue;
}
try {
yield JSON.parse(value);
} catch {}
}
} finally {
if (!finished) await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
/** `response.headers` as pi-ai's `onResponse` callback wants them. */
function responseInfo(response) {
return {
headers: headersToObject(response.headers),
status: response.status
};
}
/**
* The Anthropic API version every Messages request must name. pi-ai leaves it
* to the Anthropic SDK, which we replace, so the wire states it here.
*/
const ANTHROPIC_VERSION = "2023-06-01";
/**
* The vendor headers an Anthropic Messages request needs. pi-ai normally gets
* them from the Anthropic SDK's defaults plus its own `createClient`; this
* wire owns the transport instead, so it sets the ones the vendor requires and
* sends the beta features pi-ai chose as `anthropic-beta`, as the SDK would.
* No credential header is sent: the gateway holds the key (unified billing or
* BYOK).
*/
function anthropicVendorHeaders(betas) {
return {
accept: "application/json",
"anthropic-version": ANTHROPIC_VERSION,
...betas.length > 0 ? { "anthropic-beta": betas.join(",") } : {},
"content-type": "application/json"
};
}
/**
* The vendor headers `/run` also carries. The rest (`anthropic-version`,
* `accept`, `content-type`) belong to the gateway there, but `anthropic-beta`
* holds the beta features the body was built for: without it Anthropic refuses
* a body that uses one, or quietly drops the feature. The binding sends
* `extraHeaders` as request headers, and AI Gateway passes this one on.
*/
const RUN_VENDOR_HEADERS = ["anthropic-beta"];
/**
* The headers a `/run` request sends: the vendor headers in
* {@link RUN_VENDOR_HEADERS}, then the caller's own, which win. Names compare
* case-insensitively, so a caller's `Anthropic-Beta` replaces the wire's
* rather than being sent alongside it.
*/
function runHeaders(vendorHeaders, callerHeaders) {
const callerNames = new Set(Object.keys(callerHeaders).map((name) => name.toLowerCase()));
const headers = {};
for (const name of RUN_VENDOR_HEADERS) {
const value = vendorHeaders[name];
if (value !== void 0 && !callerNames.has(name)) headers[name] = value;
}
return {
...headers,
...callerHeaders
};
}
/** The vendor headers an OpenAI-shaped request needs. */
function openAIVendorHeaders() {
return {
accept: "application/json",
"content-type": "application/json"
};
}
/**
* The AI Gateway catalog id a model runs under on `/run`, when it has one: the
* `<author>/<model>` specifier it was resolved from, spelled as the gateway's
* catalog spells it (`anthropic/claude-opus-4.8`, not pi's `claude-opus-4-8`).
*/
function catalogIdOf(model) {
const specifier = specifierOf(model);
return specifier !== void 0 && isCatalogModelId(specifier) ? specifier : void 0;
}
/**
* Sends a vendor-format request body through AI Gateway, by the binding.
*
* A model from pi's Cloudflare AI Gateway catalog runs on `env.AI.run` under
* its catalog id, in its native `format`: `/run` converts nothing, so the
* answer — streams included — is the shape this wire parses. Any other model
* goes to the gateway's provider-native path with the request unchanged; the
* provider slug comes from the model's base URL and the endpoint from its
* `api`.
*
* The gateway's *own* failures (no such gateway, unified billing unpaid, an
* unknown catalog model) become a `CloudflareAIError`, so fallback and retry
* behave as they do on the Workers AI run path; a vendor's own error response
* on the provider-native path is left for the wire's parser to read.
*/
async function sendGateway(request, transport, body, vendorHeaders, format, onResponse) {
const { model, options, resolved } = request;
const catalogId = catalogIdOf(model);
if (catalogId !== void 0) {
const { model: _vendorId, ...input } = body;
const response = await transport.run({
format,
gateway: resolved.gateway,
headers: runHeaders(vendorHeaders, request.headers),
input,
model: catalogId,
signal: options.signal
});
onResponse?.(response);
if (!response.ok) throw await errorFromResponse(response, {
model: catalogId,
requestBodyValues: input,
url: transport.url
});
return response;
}
const route = routeForModel(model);
const response = await transport.native({
body: JSON.stringify(body),
endpoint: route.endpoint,
gateway: resolved.gateway,
headers: {
...vendorHeaders,
...request.headers
},
method: "POST",
provider: route.provider,
signal: options.signal
});
onResponse?.(response);
return await liftGatewayError(response, {
model: wireModelId(model),
requestBodyValues: body,
url: transport.url
});
}
/**
* Turns the gateway's own error envelope into a `CloudflareAIError`. Only a
* JSON body can be one, so a streaming response is passed straight through and
* never buffered.
*/
async function liftGatewayError(response, context) {
if (!(response.headers.get("content-type") ?? "").includes("application/json")) return response;
const text = await response.text().catch(() => "");
let parsed;
try {
parsed = text === "" ? void 0 : JSON.parse(text);
} catch {
parsed = void 0;
}
if (isGatewayErrorEnvelope(parsed)) throw errorFromGatewayEnvelope(parsed, {
...context,
responseBody: text,
responseHeaders: headersToObject(response.headers),
status: response.status
});
return new Response(text, response);
}
/** The correlation details every wire records, with the routing it used. */
function correlationDetails(request, response, transport) {
const { model, resolved } = request;
const specifier = specifierOf(model);
return {
...correlationOf(response, transport),
gateway: resolved.gateway.id,
model: model.id,
...specifier !== void 0 ? { specifier } : {},
...model.api === "cloudflare-ai" ? {} : { provider: gatewaySlugForModel(model) }
};
}
//#endregion
//#region src/models/pi-ai/errors.ts
/** Diagnostic type under which a failed dispatch is recorded. */
const CLOUDFLARE_ERROR_DIAGNOSTIC = "cloudflare-error";
function definedEntries(record) {
const result = {};
for (const [key, value] of Object.entries(record)) if (value !== void 0) result[key] = value;
return result;
}
/**
* Terminates a stream with a pi-ai `error` event built from a thrown value.
* `aborted` when the caller's signal fired or the error is an abort; `error`
* otherwise, with the failed dispatch recorded as a diagnostic.
*/
function failStream(stream, message, error, signal) {
const aborted = signal?.aborted === true || isAbortError(error);
const reason = aborted ? "aborted" : "error";
message.stopReason = reason;
message.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
if (!aborted) {
const details = {};
if (error instanceof CloudflareAIError) Object.assign(details, definedEntries({
code: error.code,
logId: error.logId,
model: error.model,
status: error.status
}));
message.diagnostics = [...message.diagnostics ?? [], {
details: jsonDetails(details),
error: {
message: message.errorMessage,
name: error instanceof Error ? error.name : "Error",
...error instanceof CloudflareAIError ? { code: error.code } : {}
},
timestamp: Date.now(),
type: CLOUDFLARE_ERROR_DIAGNOSTIC
}];
}
stream.push({
error: message,
reason,
type: "error"
});
stream.end(message);
}
/** Throws the typed error for a non-2xx response. */
async function assertOk(response, context) {
if (response.ok) return;
throw await errorFromResponse(response, context);
}
//#endregion
//#region src/models/pi-ai/wires/chat-completions.ts
/**
* Chat-completions wire: Workers AI on the run path, and any pi-ai model whose
* `api` is `openai-completions`, through AI Gateway.
*
* The request is shaped with pi-ai's own message converter so replay rules
* (tool-call id normalization, reasoning replay, role bridging) match pi's
* other OpenAI-compatible providers. Workers AI then runs through the shared
* compat layer, which absorbs its native events, heartbeats, per-delta usage
* and per-family request quirks so the parser below only ever sees strict
* OpenAI chunks. A vendor model brings its own `compat` profile and needs none
* of that: its bytes are already what its author says they are.
*/
/**
* pi-ai's message converter wants a fully resolved compat profile. This is
* the strict OpenAI profile: replayed reasoning goes to `reasoning_content`,
* `max_tokens` is the cap, and nothing vendor-specific is applied here — the
* shared compat layer handles that per model afterwards.
*/
const STRICT_OPENAI_COMPAT = {
chatTemplateArgs: {},
chatTemplateKwargs: {},
maxTokensField: "max_tokens",
openRouterRouting: {},
requiresAssistantAfterToolResult: false,
requiresReasoningContentOnAssistantMessages: false,
requiresThinkingAsText: false,
requiresToolResultName: false,
sendSessionAffinityHeaders: false,
sessionAffinityFormat: "openai",
supportsDeveloperRole: false,
supportsFinishReason: true,
supportsLongCacheRetention: false,
supportsOpenAIGrammarTools: false,
supportsReasoningEffort: true,
supportsStore: false,
supportsStrictMode: false,
supportsUsageInStreaming: true,
thinkingFormat: "openai",
vercelGatewayRouting: {},
zaiToolStream: false
};
/**
* The fields an OpenAI-compatible endpoint puts reasoning in, in pi-ai's own
* order (`dist/api/openai-completions.js`). The first non-empty one wins and
* the rest are skipped: some endpoints send the same text under two spellings,
* and reading both would double the thinking block.
*/
const REASONING_FIELDS = [
"reasoning_content",
"reasoning",
"reasoning_text"
];
function mapFinishReason(reason) {
switch (reason) {
case "stop":
case "eos": return "stop";
case "length":
case "model_length": return "length";
case "tool_calls":
case "function_call": return "toolUse";
default: return "error";
}
}
function convertTools(tools) {
return tools.map((tool) => ({
function: {
description: tool.description,
name: tool.name,
parameters: tool.parameters
},
type: "function"
}));
}
/** Whether a tool-call chunk is the null finalizer some vLLM parsers emit. */
function isFinalizer(call) {
const fn = call.function;
return call.index === void 0 && (call.id === null || call.id === void 0) && (fn?.name === null || fn?.name === void 0) && (fn?.arguments === null || fn?.arguments === void 0 || fn?.arguments === "");
}
/**
* Incremental state for one streamed completion: the blocks being built on
* the assistant message and the events that announce them.
*/
var CompletionAssembler = class {
constructor(model, output, stream) {
this.model = model;
this.output = output;
this.stream = stream;
this.openToolCalls = /* @__PURE__ */ new Map();
this.toolCallArguments = /* @__PURE__ */ new Map();
this.closedToolCalls = /* @__PURE__ */ new Set();
this.blocks = output.content;
}
indexOf(block) {
return this.blocks.indexOf(block);
}
endText() {
if (this.textBlock === void 0) return;
this.stream.push({
content: this.textBlock.text,
contentIndex: this.indexOf(this.textBlock),
partial: this.output,
type: "text_end"
});
this.textBlock = void 0;
}
endThinking() {
if (this.thinkingBlock === void 0) return;
this.stream.push({
content: this.thinkingBlock.thinking,
contentIndex: this.indexOf(this.thinkingBlock),
partial: this.output,
type: "thinking_end"
});
this.thinkingBlock = void 0;
}
pushText(delta) {
if (delta.length === 0) return;
this.endThinking();
if (this.textBlock === void 0) {
this.textBlock = {
text: "",
type: "text"
};
this.blocks.push(this.textBlock);
this.stream.push({
contentIndex: this.indexOf(this.textBlock),
partial: this.output,
type: "text_start"
});
}
this.textBlock.text += delta;
this.stream.push({
contentIndex: this.indexOf(this.textBlock),
delta,
partial: this.output,
type: "text_delta"
});
}
pushThinking(delta, signature) {
if (delta.length === 0) return;
this.endText();
if (this.thinkingBlock === void 0) {
this.thinkingBlock = {
thinking: "",
thinkingSignature: signature,
type: "thinking"
};
this.blocks.push(this.thinkingBlock);
this.stream.push({
contentIndex: this.indexOf(this.thinkingBlock),
partial: this.output,
type: "thinking_start"
});
}
this.thinkingBlock.thinking += delta;
this.stream.push({
contentIndex: this.indexOf(this.thinkingBlock),
delta,
partial: this.output,
type: "thinking_delta"
});
}
closeToolCall(index) {
const block = this.openToolCalls.get(index);
if (block === void 0 || this.closedToolCalls.has(index)) return;
this.closedToolCalls.add(index);
const args = this.toolCallArguments.get(index);
this.toolCallArguments.delete(index);
Object.assign(block, (args ?? new ToolCallArgumentsStream()).finish());
this.stream.push({
contentIndex: this.indexOf(block),
partial: this.output,
toolCall: block,
type: "toolcall_end"
});
}
pushToolCalls(calls) {
this.endThinking();
this.endText();
for (const call of calls) {
if (isFinalizer(call)) {
if (this.lastToolIndex !== void 0) this.closeToolCall(this.lastToolIndex);
continue;
}
const name = call.function?.name ?? void 0;
const args = call.function?.arguments ?? "";
const index = typeof call.index === "number" ? call.index : this.lastToolIndex ?? 0;
if (this.closedToolCalls.has(index)) continue;
let block = this.openToolCalls.get(index);
if (block === void 0) {
if (this.lastToolIndex !== void 0 && this.lastToolIndex !== index) this.closeToolCall(this.lastToolIndex);
block = {
arguments: {},
id: call.id ?? crypto.randomUUID(),
name: name ?? "",
type: "toolCall"
};
this.openToolCalls.set(index, block);
this.toolCallArguments.set(index, new ToolCallArgumentsStream());
this.blocks.push(block);
this.stream.push({
contentIndex: this.indexOf(block),
partial: this.output,
type: "toolcall_start"
});
} else {
if (block.name === "" && name) block.name = name;
if (call.id) block.id = call.id;
}
this.lastToolIndex = index;
if (args.length > 0) {
let stream = this.toolCallArguments.get(index);
if (stream === void 0) {
stream = new ToolCallArgumentsStream();
this.toolCallArguments.set(index, stream);
}
block.arguments = stream.append(args);
this.stream.push({
contentIndex: this.indexOf(block),
delta: args,
partial: this.output,
type: "toolcall_delta"
});
}
}
}
applyFinishReason(reason) {
if (reason === null || reason === void 0) return false;
this.output.stopReason = mapFinishReason(reason);
if (this.output.stopReason === "error") this.output.errorMessage = `The model stopped with finish_reason "${reason}".`;
return true;
}
/** Applies one strict chunk (or a whole normalized body); true if it carried a finish reason. */
apply(chunk) {
if (chunk.error !== void 0) {
this.output.stopReason = "error";
this.output.errorMessage = chunk.error.message ?? "The model reported an error.";
return true;
}
const choice = chunk.choices?.[0];
if (chunk.id !== void 0) this.output.responseId ??= chunk.id;
if (typeof chunk.model === "string" && chunk.model.length > 0 && chunk.model !== this.model.id) this.output.responseModel ??= chunk.model;
if (chunk.usage !== void 0 && chunk.usage !== null) this.output.usage = usageFromOpenAI(this.model, chunk.usage);
const finished = this.applyFinishReason(choice?.finish_reason);
const delta = choice?.delta ?? choice?.message;
if (delta !== void 0) {
for (const field of REASONING_FIELDS) {
const value = delta[field];
if (typeof value === "string" && value.length > 0) {
this.pushThinking(value, field);
break;
}
}
if (typeof delta.content === "string") this.pushText(delta.content);
if (Array.isArray(delta.tool_calls)) {
const complete = choice?.delta === void 0;
this.pushToolCalls(complete ? delta.tool_calls.map((call, position) => ({
...call,
index: typeof call.index === "number" ? call.index : position
})) : delta.tool_calls);
}
}
return finished;
}
/** Closes open blocks and settles the stop reason. */
finish(sawFinishReason, sawDone) {
for (const index of this.openToolCalls.keys()) this.closeToolCall(index);
this.endThinking();
this.endText();
const hasToolCalls = this.blocks.some((b) => b.type === "toolCall");
if (this.output.stopReason === "error") return;
if (!sawFinishReason) if (sawDone) this.output.stopReason = hasToolCalls ? "toolUse" : "stop";
else {
this.output.stopReason = "error";
this.output.errorMessage = this.blocks.length > 0 ? "The stream ended without the model finishing its answer." : "The stream ended without any output from the model.";
}
else if (this.output.stopReason === "stop" && hasToolCalls) this.output.stopReason = "toolUse";
}
};
/**
* Puts the reasoning knobs on a chat-completions body, and reports the ones a
* model cannot take.
*
* Workers AI is the one model family with knobs of ours: `reasoning_effort` on
* its own three-level scale and `chat_template_kwargs`, both of which the
* compat layer re-checks against the quirk table afterwards. For every other
* model the decision is the model author's, read off the merged compat profile
* exactly as pi-ai's own `openai-completions` implementation reads it: the
* effort is emitted only when the model reasons, declares
* `supportsReasoningEffort` and asks for OpenAI's `reasoning_effort` shape,
* and it is remapped through the model's own `thinkingLevelMap`.
*/
function applyReasoning(strict, context) {
const { chatTemplateKwargs, compat, effort, model, workersAI } = context;
if (workersAI) {
if (effort !== void 0) strict.reasoning_effort = effort;
if (chatTemplateKwargs !== void 0) strict.chat_template_kwargs = chatTemplateKwargs;
return [];
}
const ignored = [];
const thinkingFormat = compat.thinkingFormat;
if (typeof effort === "string") {
if (model.reasoning === true && compat.supportsReasoningEffort !== false && thinkingFormat === "openai") strict.reasoning_effort = model.thinkingLevelMap?.[effort] ?? effort;
else if (thinkingFormat !== "openai") ignored.push({
feature: "reasoning-effort",
message: `This model declares the "${thinkingFormat}" thinking format, which this wire does not build; its reasoning level was dropped. Reach it through pi-ai's own provider if you need that shape.`
});
}
if (context.workersAIEffort === null) ignored.push({
feature: "reasoning-off",
message: "`reasoningEffort: null` is a Workers AI setting; a model routed through AI Gateway decides its own reasoning, so it was dropped."
});
else if (context.workersAIEffort !== void 0) ignored.push({
feature: "reasoning-effort",
message: "`reasoningEffort` is a Workers AI setting and does not apply to a model routed through AI Gateway; use the pi `reasoning` level, which the model's own metadata maps."
});
if (chatTemplateKwargs !== void 0) ignored.push({
feature: "chat-template-kwargs",
message: "`chatTemplateKwargs` is a Workers AI setting and does not apply to a model routed through AI Gateway; it was dropped."
});
return ignored;
}
/** Streams a chat-completions request and maps it onto pi-ai events. */
function streamCompletions(request, transport) {
const { model, context, options, resolved } = request;
const stream = createAssistantMessageEventStream();
const output = startMessage(model);
const assembler = new CompletionAssembler(model, output, stream);
const workersAI = model.api === CLOUDFLARE_AI_API;
(async () => {
let response;
try {
const compat = workersAI ? STRICT_OPENAI_COMPAT : {
...STRICT_OPENAI_COMPAT,
...model.compat ?? {}
};
const strict = {
messages: convertMessages(model, context, compat),
stream: true,
stream_options: { include_usage: true }
};
if (!workersAI) strict.model = wireModelId(model);
const tools = getCurrentTools(context.messages);
if (tools.length > 0) strict.tools = convertTools(tools);
if (request.toolChoice !== void 0) strict.tool_choice = request.toolChoice;
if (options.maxTokens !== void 0) strict.max_tokens = options.maxTokens;
if (options.temperature !== void 0) strict.temperature = options.temperature;
const ignored = applyReasoning(strict, {
chatTemplateKwargs: resolved.chatTemplateKwargs,
compat,
effort: request.reasoningEffort,
model,
workersAI,
workersAIEffort: resolved.reasoningEffort
});
const sampling = options.samplingParams;
if (sampling !== void 0) Object.assign(strict, sampling);
const prepared = workersAI ? prepareChatCompletionsRequest(strict, model.id) : {
body: strict,
reasoningOff: false,
warnings: []
};
recordWarnings(output, [...ignored, ...prepared.warnings]);
const overridden = await options.onPayload?.(prepared.body, model);
const payload = overridden === void 0 ? prepared.body : overridden;
response = workersAI ? await transport.run({
format: "chat-completions",
gateway: resolved.gateway,
headers: request.headers,
input: payload,
model: model.id,
signal: options.signal
}) : await sendGateway(request, transport, payload, openAIVendorHeaders(), "chat-completions");
await options.onResponse?.(responseInfo(response), model);
attachCorrelation(output, correlationDetails(request, response, transport));
await assertOk(response, {
model: model.id,
requestBodyValues: payload,
url: transport.url
});
if (response.body === null) throw new Error("The model returned an empty response body.");
stream.push({
partial: output,
type: "start"
});
if ((response.headers.get("content-type") ?? "").includes("text/event-stream")) {
let sawFinishReason = false;
let sawDone = false;
const decoded = withIdleDeadline(response.body, request.streamIdleTimeoutMs).pipeThrough(sseDataStream());
const reader = (workersAI ? decoded.pipeThrough(normalizeChatCompletionsStream(model.id, { reasoningOff: prepared.reasoningOff })) : decoded).getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value === "[DONE]") {
sawDone = true;
continue;
}
let parsed;
try {
parsed = JSON.parse(value);
} catch {
continue;
}
if (parsed === null || typeof parsed !== "object") continue;
sawFinishReason = assembler.apply(parsed) || sawFinishReason;
}
} finally {
reader.releaseLock();
}
assembler.finish(sawFinishReason, sawDone);
} else {
const body = await response.json();
const json = workersAI ? normalizeChatCompletion(body, model.id) : body;
assembler.finish(assembler.apply(json), true);
}
if (options.signal?.aborted) throw new Error("Request was aborted.");
if (output.stopReason === "error") throw new Error(output.errorMessage ?? "The model reported an error stop reason.");
stream.push({
message: output,
reason: output.stopReason,
type: "done"
});
stream.end(output);
} catch (error) {
if (response?.body !== null && response?.body?.locked === false) response.body.cancel().catch(() => {});
failStream(stream, output, error, options.signal);
}
})();
return stream;
}
//#endregion
//#region src/models/pi-ai/models/image.ts
/**
* The `api` marker on image models from this provider.
*/
const CLOUDFLARE_AI_IMAGES_API = "cloudflare-ai-images";
const IMAGE_OPTIONS = Symbol.for("agents.models.pi-ai.image-options");
/** Builds the pi image model for a catalog id. */
function buildImagesModel(modelId, options) {
const model = {
api: CLOUDFLARE_AI_IMAGES_API,
baseUrl: "",
cost: {
cacheRead: 0,
cacheWrite: 0,
input: 0,
output: 0
},
id: modelId,
input: ["text", "image"],
name: options?.name ?? modelId,
output: ["image"],
provider: CLOUDFLARE_PROVIDER_ID,
type: "image"
};
if (options !== void 0) Object.defineProperty(model, IMAGE_OPTIONS, {
configurable: true,
enumerable: true,
value: options,
writable: false
});
return model;
}
function imageOptionsOf(model) {
const options = model[IMAGE_OPTIONS];
return options !== null && typeof options === "object" ? options : void 0;
}
function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function bytesToBase64(bytes) {
let binary = "";
const chunk = 8192;
for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
return btoa(binary);
}
/** Reads an image out of the run path's response, whichever shape it used. */
async function readImage(response) {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const body = unwrapEnvelope(await response.json());
const record = body !== null && typeof body === "object" ? body : {};
if (typeof record.image === "string") return {
data: record.image,
mimeType: imageMediaTypeOf(record.image)
};
throw new Error("The image model returned JSON without an image field.");
}
const data = bytesToBase64(new Uint8Array(await response.arrayBuffer()));
return {
data,
mimeType: contentType.split(";")[0] || imageMediaTypeOf(data)
};
}
/**
* Generates images with a Cloudflare catalog model. Text input parts join into
* the prompt; the first image part, when present, is passed as the source
* image for image-to-image models. Failures are reported through
* `stopReason`/`errorMessage`, as pi-ai's images contract expects.
*/
async function generateImages(config, model, context, options) {
const modelOptions = imageOptionsOf(model);
const resolved = resolveOptions(config.providerGateway, modelOptions, void 0);
const prompt = context.input.filter((part) => part.type === "text").map((part) => part.text).join("\n");
const source = context.input.find((part) => part.type === "image");
const setting = (key) => options?.[key] !== void 0 ? options[key] : modelOptions?.[key];
const built = buildImageInput(model.id, prompt, {
...setting("width") === void 0 ? {} : { width: setting("width") },
...setting("height") === void 0 ? {} : { height: setting("height") },
...setting("steps") === void 0 ? {} : { steps: setting("steps") },
...setting("guidance") === void 0 ? {} : { guidance: setting("guidance") },
...setting("negativePrompt") === void 0 ? {} : { negativePrompt: setting("negativePrompt") },
...setting("seed") === void 0 ? {} : { seed: setting("seed") },
...source === void 0 ? {} : { sourceImage: true }
});
const input = built.input;
const warnings = built.warnings;
if (!built.minimal && source !== void 0 && source.type === "image") input.image = Array.from(base64ToBytes(source.data));
const result = {
api: model.api,
model: model.id,
output: [],
provider: model.provider,
stopReason: "stop",
timestamp: Date.now(),
usage: emptyUsage()
};
try {
const response = await config.transport.run({
gateway: resolved.gateway,
headers: resolved.headers,
input,
model: model.id,
signal: options?.signal
});
await assertOk(response, {
model: model.id,
requestBodyValues: input,
url: config.transport.url
});
const image = await readImage(response);
result.output.push({
data: image.data,
mimeType: image.mimeType,
type: "image"
});
const requestId = response.headers.get("cf-aig-log-id");
if (requestId !== null) result.responseId = requestId;
} catch (error) {
result.stopReason = options?.signal?.aborted ? "aborted" : "error";
result.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
}
if (warnings.length > 0) result.diagnostics = [{
details: { warnings },
timestamp: Date.now(),
type: COMPAT_DIAGNOSTIC
}];
return result;
}
//#endregion
//#region src/models/pi-ai/settings.ts
/** The pi thinking levels, for reading a caller's explicit effort. */
const THINKING_LEVELS = [
"minimal",
"low",
"medium",
"high",
"xhigh"
];
function isThinkingLevel(value) {
return typeof value === "string" && THINKING_LEVELS.includes(value);
}
/**
* Resolves a pi thinking level onto the effort a request carries.
*
* Workers AI is the one catalog with an effort scale of ours: its quirk table
* declares three levels, so the run path collapses onto them. Every other
* model keeps its own level name — the wire then applies the model's own
* `thinkingLevelMap`, the way pi-ai's implementations do — because a table of
* ours has no business deciding a vendor's efforts.
*/
function effortForLevel$1(model, level) {
if (level === void 0 || !model.reasoning) return void 0;
const clamped = clampThinkingLevel(model, level);
if (clamped === "off") return void 0;
if (model.api !== "cloudflare-ai") return clamped;
switch (clamped) {
case "minimal":
case "low": return "low";
case "medium": return "medium";
case "high":
case "xhigh":
case "max": return "high";
default: return clamped;
}
}
/** Scalars only: the gateway rejects nested metadata. */
function scalarMetadata(metadata) {
if (metadata === void 0) return void 0;
const result = {};
for (const [key, value] of Object.entries(metadata)) if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") result[key] = value;
return Object.keys(result).length > 0 ? result : void 0;
}
/** pi headers allow `null` to suppress a default; those are dropped here. */
function stringHeaders(headers) {
if (headers === void 0) return void 0;
const result = {};
for (const [key, value] of Object.entries(headers)) if (typeof value === "string") result[key] = value;
return Object.keys(result).length > 0 ? result : void 0;
}
/** The per-call layer of options, read off pi-ai's call options. */
function callLayer(options) {
const layer = {};
const headers = stringHeaders(options.headers);
if (headers !== void 0) layer.headers = headers;
const metadata = scalarMetadata(options.metadata);
if (metadata !== void 0) layer.metadata = metadata;
if (options.sessionId !== void 0) layer.sessionAffinity = options.sessionId;
return layer;
}
function toolChoiceOf(options) {
const raw = options.toolChoice;
if (raw === "auto" || raw === "none" || raw === "required") return raw;
if (raw === "any") return "required";
if (raw !== null && typeof raw === "object") {
const record = raw;
const name = typeof record.function?.name === "string" ? record.function.name : typeof record.name === "string" ? record.name : void 0;
if (name !== void 0) return {
function: { name },
type: "function"
};
}
}
function buildRequest(model, context, options, simple, config) {
const modelOptions = optionsOf(model);
const resolved = resolveOptions(config.providerGateway, modelOptions, callLayer(options));
const headers = { ...resolved.headers };
if (resolved.sessionAffinity !== void 0 && model.api === "cloudflare-ai") headers["x-session-affinity"] = resolved.sessionAffinity;
const explicitEffort = options.reasoningEffort;
const reasoningEffort = resolved.reasoningEffort !== void 0 && model.api === "cloudflare-ai" ? resolved.reasoningEffort : isThinkingLevel(explicitEffort) ? effortForLevel$1(model, explicitEffort) : explicitEffort === "off" ? void 0 : typeof explicitEffort === "string" ? explicitEffort : simple ? effortForLevel$1(model, options.reasoning) : void 0;
return {
context,
fallback: modelOptions?.fallback ?? [],
headers,
model,
options,
reasoningEffort,
resolved,
simple,
streamIdleTimeoutMs: modelOptions?.streamIdleTimeoutMs ?? 3e5,
toolChoice: toolChoiceOf(options)
};
}
//#endregion
//#region src/models/pi-ai/wires/anthropic.ts
/**
* Anthropic Messages wire: pi-ai's own Anthropic implementation, over AI
* Gateway — `/run` for a catalog model, the provider-native path otherwise.
*
* Everything about the wire — request shaping, thinking, tool use, stream
* parsing, thinking-signature replay — is pi-ai's. It accepts a `client` whose
* `messages.create(...).asResponse()` returns the raw HTTP response; the one
* built here routes that call through the gateway instead of the Anthropic
* SDK, and sets the vendor headers the SDK would otherwise have added.
*
* The model object is never rewritten: `api`, `provider` and `id` reach pi's
* implementation exactly as they will be recorded on the assistant message, so
* a later turn replaying a thinking block is recognised as the same model.
*/
/**
* Maps a pi thinking level onto an Anthropic effort the way pi-ai's own
* `streamSimple` does: the model's `thinkingLevelMap` first, then the level's
* own name. The map is the model author's; nothing here decides it.
*/
function effortForLevel(model, level) {
const mapped = model.thinkingLevelMap?.[level];
if (typeof mapped === "string") return mapped;
switch (level) {
case "minimal":
case "low": return "low";
case "medium": return "medium";
default: return "high";
}
}
/**
* pi-ai's `streamSimple` thinking rules, applied here because that entry point
* insists on a credential and this transport has none. Which branch a model
* takes is the model's own `compat.forceAdaptiveThinking`, not a rule of ours.
*/
function thinkingOptions(model, options) {
const level = options.reasoning;
if (level === void 0 || !model.reasoning) return { thinkingEnabled: false };
if (model.compat?.forceAdaptiveThinking === true) return {
effort: effortForLevel(model, level),
thinkingEnabled: true
};
const { maxTokens, thinkingBudget } = adjustMaxTokensForThinking(options.maxTokens, model.maxTokens, level, options.thinkingBudgets);
return {
maxTokens,
thinkingBudgetTokens: thinkingBudget,
thinkingEnabled: true
};
}
/** Streams an Anthropic request through pi-ai's implementation. */
function streamAnthropic(request, transport) {
const { model, context, options } = request;
const outer = createAssistantMessageEventStream();
const placeholder = startMessage(model);
let correlation;
let transportError;
const create = (params) => ({ async asResponse() {
const { betas, ...vendorParams } = params;
const body = {
...vendorParams,
model: wireModelId(model)
};
try {
const response = await sendGateway(request, transport, body, anthropicVendorHeaders(Array.isArray(betas) ? betas : []), "messages", (raw) => {
correlation = correlationDetails(request, raw, transport);
});
await assertOk(response, {
model: model.id,
requestBodyValues: body,
url: transport.url
});
if (response.body === null) return response;
return new Response(withIdleDeadline(response.body, request.streamIdleTimeoutMs), response);
} catch (error) {
transportError = error;
throw error;
}
} });
const client = {
beta: { messages: { create } },
messages: { create }
};
const anthropicOptions = {
...options,
...request.simple ? thinkingOptions(model, options) : {},
client,
...request.toolChoice !== void 0 ? { toolChoice: typeof request.toolChoice === "string" ? request.toolChoice === "required" ? "any" : request.toolChoice : {
name: request.toolChoice.function.name,
type: "tool"
} } : {}
};
(async () => {
try {
const inner = anthropicMessagesApi().stream(model, context, anthropicOptions);
for await (const event of inner) {
if (event.type === "error" && transportError !== void 0) {
failFrom(event.error);
return;
}
if (event.type === "done" || event.type === "error") {
const message = event.type === "done" ? event.message : event.error;
if (correlation !== void 0) attachCorrelation(message, correlation);
recordWarnings(message, ignoredWorkersAIKnobs(request));
}
outer.push(event);
}
if (transportError !== void 0) {
failFrom(placeholder);
return;
}
outer.end(await inner.result());
} catch (error) {
failStream(outer, placeholder, transportError ?? error, options.signal);
}
})();
/** Ends the stream with the transport's own error, keeping any content. */
function failFrom(message) {
if (correlation !== void 0) attachCorrelation(message, correlation);
failStream(outer, message, transportError, options.signal);
}
return outer;
}
//#endregion
//#region src/models/pi-ai/wires/responses.ts
/**
* OpenAI Responses wire: pi-ai's own Responses converters and stream
* processor, over AI Gateway: `/run` for a catalog model, the provider-native
* path otherwise.
*
* Only the transport differs from talking to OpenAI directly: the body goes to
* the gateway, which forwards it to `api.openai.com/v1/responses` with the key
* it holds, and the raw SSE `Response` comes back. The model object is passed
* through untouched, so the encrypted reasoning items pi replays on a later
* turn are recognised as the same model's.
*/
/**
* Providers whose tool-call ids carry OpenAI `call|item` pairing across
* turns. This provider sanitizes ids instead, like pi-ai's own gateway
* provider, so the set is empty.
*/
const TOOL_CALL_ID_PROVIDERS = /* @__PURE__ */ new Set();
const TERMINAL_EVENTS = /* @__PURE__ */ new Set([
"response.completed",
"response.incomplete",
"response.failed"
]);
/** A level through the model's own map, or the level's own name. */
function mappedEffort(model, effort) {
return model.thinkingLevelMap?.[effort] ?? effort;
}
/** Streams a Responses request and maps it onto pi-ai events. */
function streamResponses(request, transport) {
const { model, context, options, resolved } = request;
const stream = createAssistantMessageEventStream();
const output = startMessage(model);
const observed = {
endedCleanly: false,
sawTerminalEvent: false
};
(async () => {
let response;
try {
const responsesModel = model;
const body = {
input: convertResponsesMessages(responsesModel, context, TOOL_CALL_ID_PROVIDERS),
model: wireModelId(model),
store: false,
stream: true
};
const tools = getCurrentTools(context.messages);
if (tools.length > 0) body.tools = convertResponsesTools(tools);
if (request.toolChoice !== void 0) body.tool_choice = typeof request.toolChoice === "string" ? request.toolChoice : {
name: request.toolChoice.function.name,
type: "function"
};
if (options.maxTokens !== void 0) body.max_output_tokens = Math.max(options.maxTokens, 16);
if (options.temperature !== void 0) body.temperature = options.temperature;
const cacheKey = clampOpenAIPromptCacheKey(resolved.sessionAffinity ?? options.sessionId);
if (cacheKey !== void 0) body.prompt_cache_key = cacheKey;
if (model.reasoning === true) {
body.include = ["reasoning.encrypted_content"];
const effort = request.reasoningEffort;
if (typeof effort === "string") body.reasoning = {
effort: mappedEffort(model, effort),
summary: "auto"
};
else if (model.thinkingLevelMap?.off !== null) body.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" };
}
const sampling = options.samplingParams;
if (sampling !== void 0) Object.assign(body, sampling);
const overridden = await options.onPayload?.(body, model);
const payload = overridden === void 0 ? body : overridden;
response = await sendGateway(request, transport, payload, openAIVendorHeaders(), "responses");
await options.onResponse?.(responseInfo(response), model);
attachCorrelation(output, correlationDetails(request, response, transport));
recordWarnings(output, ignoredWorkersAIKnobs(request));
await assertOk(response, {
model: model.id,
requestBodyValues: payload,
url: transport.url
});
if (response.body === null) throw new Error("The model returned an empty response body.");
stream.push({
partial: output,
type: "start"
});
await processResponsesStream(observe(sseJson(withIdleDeadline(response.body, request.streamIdleTimeoutMs))), output, stream, responsesModel);
if (options.signal?.aborted) throw new Error("Request was aborted.");
if (output.stopReason === "aborted" || output.stopReason === "error") throw new Error(output.errorMessage ?? "The model reported an error stop reason.");
if (observed.endedCleanly && !observed.sawTerminalEvent) throw new Error("The stream ended without a terminal response event.");
stream.push({
message: output,
reason: output.stopReason,
type: "done"
});
stream.end(output);
} catch (error) {
if (response?.body !== null && response?.body?.locked === false) response.body.cancel().catch(() => {});
failStream(stream, output, error, options.signal);
}
})();
return stream;
async function* observe(events) {
for await (const event of events) {
const type = event?.type;
if (typeof type === "string" && TERMINAL_EVENTS.has(type)) observed.sawTerminalEvent = true;
yield event;
}
observed.endedCleanly = true;
}
}
//#endregion
//#region src/models/pi-ai/models/fallback.ts
/**
* Client-side model fallback for pi-ai streams.
*
* A leg is abandoned when its stream terminates with an `error` event before
* it produced any content; the next model is tried with the same context and
* options. Once a leg has emitted content it is committed to, and a later
* error propagates. `start` and the block starts that may follow it carry no
* content, so they are held back until a leg proves itself.
*/
/** Diagnostic type under which abandoned legs are recorded. */
const FALLBACK_DIAGNOSTIC = "cloudflare-fallback";
/**
* Events that announce output without carrying any. A leg that emits only
* these and then fails has produced nothing, so they are held back with
* `start` until the leg proves itself.
*/
const PREAMBLE_EVENTS = /* @__PURE__ */ new Set([
"start",
"text_start",
"thinking_start",
"toolcall_start"
]);
function isAbandonable(event) {
return event.type === "error" && event.reason === "error";
}
/**
* Runs legs in order, committing to the first one that produces output.
*
* The returned stream always ends. A leg that throws, a stream that rejects,
* a leg that starts answering and then ends without a `done` or `error`
* event, and a last leg that ends without producing anything all end it
* with an `error` event, so a caller awaiting the result is never left
* waiting.
*/
function streamWithFallback(legs, signal) {
const first = legs[0];
if (first === void 0) throw new Error("streamWithFallback needs at least one leg.");
const outer = createAssistantMessageEventStream();
const attempts = [];
let ended = false;
const end = (message) => {
ended = true;
outer.end(message);
};
const recordAttempts = (message) => {
message.diagnostics = [...message.diagnostics ?? [], {
details: jsonDetails({ attempts }),
timestamp: Date.now(),
type: FALLBACK_DIAGNOSTIC
}];
};
(async () => {
let current = first;
let lastError;
try {
for (const [index, leg] of legs.entries()) {
current = leg;
const isLast = index === legs.length - 1;
const inner = leg.start();
let pending = [];
let committed = false;
let finished = false;
for await (const event of inner) {
if (!committed && PREAMBLE_EVENTS.has(event.type)) {
pending.push(event);
continue;
}
if (!committed) {
if (event.type === "error" && isAbandonable(event) && !isLast) {
attempts.push({
errorMessage: event.error.errorMessage,
model: leg.model.id
});
lastError = event.error;
break;
}
committed = true;
for (const held of pending) outer.push(held);
pending = [];
}
if ((event.type === "done" || event.type === "error") && attempts.length > 0) recordAttempts(event.type === "done" ? event.message : event.error);
outer.push(event);
if (event.type === "done" || event.type === "error") finished = true;
}
if (finished) {
end(await inner.result());
return;
}
if (committed) throw new Error(`${leg.model.id} ended its stream without a done or error event.`);
}
if (lastError !== void 0) {
recordAttempts(lastError);
outer.push({
error: lastError,
reason: "error",
type: "error"
});
end(lastError);
return;
}
throw new Error(`${current.model.id} ended its stream without output.`);
} catch (error) {
if (ended) return;
const message = startMessage(current.model);
if (attempts.length > 0) recordAttempts(message);
ended = true;
failStream(outer, message, error, signal);
}
})();
return outer;
}
//#endregion
//#region src/models/pi-ai/models/language.ts
/**
* The pi-ai language dispatcher: picks the wire from the model's own `api`
* marker and runs the request through the transport, with client-side fallback
* across models.
*
* The `api` is the model author's declaration, not a guess of ours: Workers AI
* models carry {@link CLOUDFLARE_AI_API} and run on `env.AI.run`; every other
* model carries whatever protocol its registry says it speaks, and goes to AI
* Gateway in that protocol: `/run` for a catalog model, the provider-native
* path otherwise.
*/
/** A stream that fails immediately, for a model this provider cannot route. */
function failedStream(request, error) {
const stream = createAssistantMessageEventStream();
failStream(stream, startMessage(request.model), error, request.options.signal);
return stream;
}
function dispatchOne(request, transport) {
switch (request.model.api) {
case CLOUDFLARE_AI_API:
case "openai-completions": return streamCompletions(request, transport);
case "anthropic-messages": return streamAnthropic(request, transport);
case "openai-responses": return streamResponses(request, transport);
default: return failedStream(request, /* @__PURE__ */ new TypeError(`agents/models/pi-ai cannot route the "${request.model.api}" API. AI Gateway serves anthropic-messages, openai-responses and openai-completions models; Workers AI models use "${CLOUDFLARE_AI_API}".`));
}
}
/**
* The options a fallback leg inherits from the chain: the transport bag, and
* nothing that describes a model.
*
* `name`, `cost`, `contextWindow`, `maxTokens`, `reasoning` and `input` were
* written about the model they were written on — a leg has its own, from pi's
* registry — and inheriting them bills the leg's answer at the primary's
* prices. The leg's own `fallback` is dropped for the same reason `ai()`
* expands a chain once: a leg is already part of one.
*/
function legOptions(options) {
if (options === void 0) return void 0;
const { fallback: _fallback, name: _name, cost: _cost, contextWindow: _contextWindow, maxTokens: _maxTokens, reasoning: _reasoning, input: _input, ...rest } = options;
return rest;
}
/** A leg may be given as a Workers AI id or as a model of any kind. */
function legModel(leg, options) {
if (typeof leg === "string") return isRunPathLanguageModelId(leg) ? buildWorkersAIModel(leg, options) : buildGatewayModel(leg, options);
const own = optionsOf(leg);
const inherited = gatewayLayerOf(options);
return withOptions(leg, mergeModelOptions(own, Object.keys(inherited).length > 0 ? inherited : void 0));
}
/**
* Builds pi-ai's `stream` and `streamSimple` for one provider instance. Any
* model reaches them — this provider's own, or one the caller built from their
* registry and handed to `ai(model)` — and dispatches on its `api`.
*/
function createStreams(config) {
const run = (model, context, options, simple) => {
const callOptions = options ?? {};
const primary = buildRequest(model, context, callOptions, simple, config);
if (primary.fallback.length === 0) return dispatchOne(primary, config.transport);
const shared = legOptions(optionsOf(model));
return streamWithFallback([{
model,
start: () => dispatchOne(primary, config.transport)
}, ...primary.fallback.map((leg) => {
const legTarget = legModel(leg, shared);
return {
model: legTarget,
start: () => dispatchOne(buildRequest(legTarget, context, callOptions, simple, config), config.transport)
};
})], callOptions.signal);
};
return {
stream: (model, context, options) => run(model, context, options, false),
streamSimple: (model, context, options) => run(model, context, options, true)
};
}
//#endregion
//#region src/models/pi-ai/index.ts
/**
* `agents/models/pi-ai` — Cloudflare's AI as a pi-ai provider, with the same
* shape as `agents/models/ai-sdk`.
*
* One rule decides everything here: **Workers AI is the catalog Cloudflare
* keeps; every other vendor is pi-ai's.** So there are two call forms, and no
* third:
*
* ```ts
* import { createAI } from "agents/models/pi-ai";
* import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
*
* const ai = createAI({ binding: env.AI, id: "prod" });
*
* ai("@cf/zai-org/glm-5.3-flash"); // Workers AI: ours
* ai(getBuiltinModel("anthropic", "claude-opus-4-8")); // theirs, our transport
* ai("anthropic/claude-opus-4-8"); // same, via pi's catalog
* ```
*
* Workers AI runs on `env.AI.run` with Cloudflare's own compat layer. Every
* other model goes to AI Gateway in its own protocol — `/run` for a model
* from the gateway's catalog, the provider-native path otherwise — and the
* gateway calls the vendor with the key it holds, so no
* provider key, and no vendor wire format, lives in your Worker. The model's
* metadata (`api`, `compat`, `thinkingLevelMap`, cost, limits) is whatever its
* own registry says, and pi-ai's converters and stream processors do the rest.
*
* @module
*/
/** The prefixes a bare vendor id is most often written with, for the error. */
const VENDOR_HINTS = {
anthropic: "anthropic",
google: "google",
openai: "openai"
};
/**
* A string that is neither a Workers AI id nor a `<slug>/<id>` this provider
* can resolve. There is no guessing here by design: a model we cannot name
* exactly is the caller's to construct.
*/
function unroutableString(specifier) {
const hint = VENDOR_HINTS[specifier.split("/")[0] ?? ""];
return /* @__PURE__ */ new TypeError(`ai("${specifier}") is not a model this provider knows. Workers AI ids start with "@cf/", AI Gateway dynamic routes with "dynamic/". A third-party model comes from its own pi-ai registry: ai(getBuiltinModel(${hint === void 0 ? "\"<provider>\"" : `"${hint}"`}, "<model id>")) from "@earendil-works/pi-ai/providers/all", or any Model object you built yourself.`);
}
function buildModel(target, options) {
for (const leg of options?.fallback ?? []) {
if (typeof leg !== "string" || isRunPathLanguageModelId(leg)) continue;
if (!leg.includes("/")) throw unroutableString(leg);
buildGatewayModel(leg, void 0);
}
if (typeof target !== "string") return withOptions(target, options);
if (isRunPathLanguageModelId(target)) return buildWorkersAIModel(target, options);
if (!target.includes("/")) throw unroutableString(target);
return buildGatewayModel(target, options);
}
/**
* Creates a pi-ai provider over Cloudflare's AI.
*
* Workers AI and catalog models go through `env.AI.run`, other model objects
* through the binding's `fetch` to AI Gateway's provider-native path. With
* `{ accountId, apiToken }` instead of `binding`, the same requests go to the
* REST API and to `gateway.ai.cloudflare.com`. Gateway options may be given flat
* (`{ binding, id: "prod", cacheTtl: 60 }`) or nested under `gateway`;
* per-model options and per-call pi-ai options (`headers`, `metadata`,
* `sessionId`) override them in that order.
*
* ```ts
* const ai = createAI({ binding: env.AI, id: "prod" });
* ai("@cf/zai-org/glm-5.3-flash");
* ai(anthropicModel, { fallback: ["@cf/zai-org/glm-5.3-flash"] });
* new Agent({ streamFn: ai.streamFn, initialState: { model: ai(openaiModel) } });
* ```
*/
function createAI(settings) {
const transport = createTransport(settings);
const streams = createStreams({
providerGateway: settings,
transport
});
const model = ((target, options) => buildModel(target, options));
const provider = createProvider({
api: streams,
auth: { apiKey: {
name: "Cloudflare (the AI binding)",
resolve: async () => ({ auth: {} })
} },
id: CLOUDFLARE_PROVIDER_ID,
models: [...workersAIModelIds().map((id) => buildWorkersAIModel(id, void 0)), ...gatewayModelIds().map((id) => buildGatewayModel(id, void 0))],
name: "Cloudflare"
});
const stream = (target, context, options) => streams.stream(target, normalizeContext(context), options);
const streamSimple = (target, context, options) => streams.streamSimple(target, normalizeContext(context), options);
return Object.assign(model, {
complete: (target, context, options) => stream(target, context, options).result(),
completeSimple: (target, context, options) => streamSimple(target, context, options).result(),
generateImages: (target, context, options) => generateImages({
providerGateway: settings,
transport
}, target, context, options),
images: (modelId, options) => buildImagesModel(modelId, options),
model,
provider,
stream,
streamFn: streamSimple,
streamSimple
});
}
//#endregion
export { CLOUDFLARE_AI_API, CLOUDFLARE_AI_IMAGES_API, CLOUDFLARE_DIAGNOSTIC, CLOUDFLARE_ERROR_DIAGNOSTIC, CLOUDFLARE_PROVIDER_ID, CloudflareAIError, FALLBACK_DIAGNOSTIC, GATEWAY_PROVIDER_NAMES, createAI };
//# sourceMappingURL=index.js.map