agents/models/pi-ai is a Pi AI ↗︎ provider for Workers AI and AI Gateway. It has the same createAI factory and options as the AI SDK provider, and returns Pi AI Model objects. Use it with Pi AI's stream and complete, with Pi Durable ↗︎, with the Pi harness, or with any framework built on a Pi AI Models registry.
The provider needs @earendil-works/pi-ai@~1.4.0, an optional peer dependency of agents:
npm i agents @earendil-works/pi-aiyarn add agents @earendil-works/pi-aipnpm add agents @earendil-works/pi-aibun add agents @earendil-works/pi-aiAdd the AI binding to your Wrangler configuration:
{
"ai": {
"binding": "AI",
},
}[ai]
binding = "AI"To use a gateway in another account, or to call from outside a Worker, pass accountId and apiToken instead of binding. Everything else is the same, and the gateway still defaults to default:
const ai = createAI({
accountId: "<ACCOUNT_ID>",
apiToken: env.CLOUDFLARE_API_TOKEN,
gateway: "my-gateway",
});Create the API token with Workers AI Read and AI Gateway Run. The token is sent to the AI Gateway REST API and to gateway.ai.cloudflare.com only.
The provider carries Pi AI's entry points, so nothing is registered globally:
import { createAI } from "agents/models/pi-ai";
export default {
async fetch(request, env) {
const ai = createAI({ binding: env.AI });
const reply = await ai.complete(ai("@cf/zai-org/glm-5.3-flash"), {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
});
// `content` holds text, thinking, and tool call parts.
const text = reply.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
return Response.json({ text });
},
};import { createAI } from "agents/models/pi-ai";
export default {
async fetch(request: Request, env: Env) {
const ai = createAI({ binding: env.AI });
const reply = await ai.complete(ai("@cf/zai-org/glm-5.3-flash"), {
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
});
// `content` holds text, thinking, and tool call parts.
const text = reply.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
return Response.json({ text });
},
};ai.stream() returns Pi AI's AssistantMessageEvent stream. ai.completeSimple() and ai.streamSimple() take Pi AI's simple options, such as a reasoning level and maxTokens:
const model = ai("anthropic/claude-opus-5.5");
const context = {
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
};
for await (const event of ai.stream(model, context)) {
// text_delta, thinking_delta, toolcall_*, done, error
}
await ai.completeSimple(model, context, { reasoning: "low" });ai.streamFn is streamSimple, bound, for frameworks that take a stream function, such as pi-agent-core's Agent({ streamFn }).
There are four ways to name a model:
import { createAI } from "agents/models/pi-ai";
import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all";
const ai = createAI({ binding: env.AI, id: "my-gateway" });
// 1. A Workers AI model, by its @cf/ id.
ai("@cf/zai-org/glm-5.3-flash");
// 2. An AI Gateway dynamic route, by dynamic/<route>.
ai("dynamic/support");
// 3. A model from Pi AI's Cloudflare AI Gateway catalog, by <provider>/<id>.
ai("anthropic/claude-opus-5.5");
ai("openai/gpt-5.4");
// 4. Any Pi AI model object, from any Pi AI registry.
ai(getBuiltinModel("anthropic", "claude-opus-5-5"));Any other string throws a TypeError. The provider does not guess what a prefix means.
A dynamic route runs on env.AI.run() under the gateway you created it on, so set that gateway's id. The route picks the model and answers in the chat completions format. It takes the same metadata defaults and overrides as a Workers AI id.
A catalog id uses the AI Gateway catalog's spelling, anthropic/claude-opus-5.5. Pi AI's own list spells Claude ids the way Anthropic does (claude-opus-5-5), so the provider lists them under the catalog spelling. A model object from a vendor registry keeps the vendor's spelling.
The third and fourth forms keep Pi AI's model metadata, including api, compat, thinkingLevelMap, cost, context window, and maximum tokens. That metadata decides how each vendor is asked to think. For example, Claude Opus gets thinking: { type: "adaptive" } and GPT-5 gets reasoning.effort. ai(model) does not change the object you pass.
Third-party models go through AI Gateway, which holds the credential through Unified Billing or a key stored on the gateway. Your Worker holds no provider key. Unified Billing requires authentication on the gateway.
Pi Durable's Harness takes a Pi AI Models registry. Register the provider on one, then pass models from ai() to the Pi harness:
import { createModels } from "@earendil-works/pi-ai/models";
import { createAI } from "agents/models/pi-ai";
const ai = createAI({ binding: env.AI });
const models = createModels();
models.setProvider(ai.provider);
// In PiHarness options:
// defaults: { model: ai("@cf/zai-org/glm-5.3-flash") }
// For one session:
await session.setModel(ai("anthropic/claude-opus-5.5"));A Pi session stores only its model's provider and id, then looks the model up in the Models registry. Use the @cf/, dynamic/, and <provider>/<id> forms there. They belong to the cloudflare provider (CLOUDFLARE_PROVIDER_ID) that you registered. A model object keeps its vendor's provider id, which this registry does not know. Options passed to ai(model, options) do not reach a session. Set them on createAI instead.
A model stream that is cut off, or that sends nothing for streamIdleTimeoutMs, ends in an error that Pi AI's isRetryableAssistantError() treats as transient. The Pi harness retries the turn under its retry settings instead of failing it.
The provider's model list is every Workers AI model Pi AI knows, plus every <provider>/<id> in Pi AI's AI Gateway catalog, so models.getModel("cloudflare", "anthropic/claude-opus-5.5") resolves.
Gateway options go on createAI, on a model, or on a call. They can be flat or nested under gateway:
const ai = createAI({ binding: env.AI, id: "prod", cacheTtl: 60 });
const model = ai("anthropic/claude-opus-5.5", {
cacheTtl: 3600,
cacheKey: "greeting-v1",
metadata: { team: "growth" },
eventId: "turn-17",
requestTimeoutMs: 30_000,
retries: { maxAttempts: 3, backoff: "exponential", retryDelayMs: 500 },
byokAlias: "production",
});The options are the same as the AI SDK provider's options: id, skipCache, cacheTtl, cacheKey, collectLog, metadata, eventId, requestTimeoutMs, retries, byokAlias, zdr, fallback, headers, sessionAffinity, reasoningEffort, and chatTemplateKwargs. vendorKey is AI SDK only.
byokAlias picks a stored key by alias when the gateway holds several keys for one provider. The provider sends it as cf-aig-byok-alias for catalog models and model objects. zdr turns Zero Data Retention on or off for a Unified Billing request. It applies only to model objects outside Pi AI's gateway catalog, because catalog models do not read it.
Pi AI's own call options map onto them. headers merge last, metadata joins the gateway metadata, sessionId sets session affinity, and reasoning maps through the model's own thinkingLevelMap. A call option wins over a model option, which wins over createAI.
@cf/ models also take Pi AI metadata overrides: name, contextWindow, maxTokens, reasoning, input, cost, and streamIdleTimeoutMs. A Workers AI id that Pi AI does not list resolves with a 128,000 token context window, 8,192 output tokens, and zero cost, so pass overrides when you know the real values.
A Pi AI model is plain data. Per-model options are stored on it under symbol keys, which JSON.stringify and structuredClone drop. A model you store and read back still routes to its vendor, but loses its options. Pass them again with ai(model, options) after you load it.
ai(getBuiltinModel("anthropic", "claude-opus-5-5"), {
fallback: ["@cf/zai-org/glm-5.3-flash"],
});Fallback models can be Workers AI ids, dynamic routes, <provider>/<id> strings, or model objects. The provider tries them in order. It moves on when a stream ends with an error before producing any output. Once a model has produced output, its answer is final.
The message that answers has a cloudflare-fallback diagnostic listing the attempts that failed, and message.model names the model that answered.
A fallback model uses the chain's gateway options. A fallback model named by a @cf/ id has no options of its own, so it also takes the chain's headers, session affinity, and Workers AI options. It does not take the chain's model metadata, such as cost or contextWindow.
Every assistant message has a cloudflare diagnostic with the gateway details for its response:
const message = await ai.complete(model, context);
const cloudflare = message.diagnostics?.find(
(d) => d.type === "cloudflare",
)?.details;
// { model, gateway, provider?, specifier?, logId?, eventId?, cacheStatus?,
// step?, traceId?, runId? }A failed request ends the stream with an error event. Its message has a cloudflare-error diagnostic with status, code, and logId. onResponse receives the status and headers of every request.
The gateway's own failures, such as a missing gateway or an unpaid account, become a CloudflareAIError. Errors from the vendor come back as the vendor sent them.
Through the binding, a stream from a catalog model or a dynamic route resumes after a dropped connection. You do not need to configure anything.
AI Gateway names each streamed run in the cf-aig-run-id header and keeps its events. If the connection drops, the provider reconnects through the binding and replays the run from the last complete event. The stream continues with nothing lost or repeated.
The provider gives up after five drops in one stream, or once the gateway no longer holds the run. The gateway keeps a run for a few minutes after it ends. Aborting the call never resumes it. Workers AI @cf/ streams and model objects outside the catalog have no run id, so they do not resume. The run id is on the cloudflare diagnostic as runId.
The provider routes every request through the AI binding, or over HTTP with an account id and API token:
| Model | api |
Through the binding | With an account id and token |
|---|---|---|---|
@cf/ models and dynamic routes |
cloudflare-ai |
env.AI.run(), with Cloudflare's compatibility layer |
The AI Gateway REST API, with the same compatibility layer |
| Anthropic models from Pi AI's gateway catalog | anthropic-messages |
env.AI.run() to AI Gateway, in Anthropic Messages format |
The REST API's /ai/v1/messages, in the same format |
| OpenAI models from Pi AI's gateway catalog | openai-responses |
env.AI.run() to AI Gateway, in OpenAI Responses format |
The REST API's /ai/v1/responses, in the same format |
| Any other model object, such as Anthropic, OpenAI, or Groq | its own api |
The binding's fetch to AI Gateway's provider-native endpoint |
gateway.ai.cloudflare.com, the provider-native endpoint |
A model from Pi AI's Cloudflare AI Gateway catalog runs on env.AI.run() under its catalog id, in the model's native format. AI Gateway converts nothing, so Pi AI's own stream processors read the answer. The gateway picks the provider and bills through Unified Billing or a key stored on the gateway.
Any other model object goes to AI Gateway's provider-native endpoint through the binding's fetch. The provider takes the gateway provider from the model's baseUrl host. Pi AI's own converter builds the vendor's request body, and Pi AI parses the vendor's response stream.
Either way, no vendor credential leaves your Worker. The provider never rewrites the model's api, provider, or id, so thinking signatures and encrypted reasoning items work on the next turn.
The provider routes four api values: cloudflare-ai, anthropic-messages, openai-responses, and openai-completions. Models from several Pi AI registries use other values and cannot be routed yet, including mistral-conversations, google-generative-ai, google-vertex, amazon-bedrock, azure-openai-responses, and openai-codex. Such a model's stream ends with an error event that names its api. So does a model whose baseUrl host AI Gateway has no provider for.
On the openai-completions wire, the provider sends reasoning only as OpenAI's reasoning_effort. A model whose compat.thinkingFormat is anything else, such as deepseek, qwen, or openrouter, has its reasoning level dropped. A cloudflare-compat diagnostic on the message records the drop. Use Pi AI's own provider for that vendor if you need its thinking format.
reasoningEffort and chatTemplateKwargs are Workers AI options. On a third-party model they are dropped and recorded in a cloudflare-compat diagnostic. Use the Pi AI reasoning level instead.
Image generation uses Workers AI models:
const images = ai.images("@cf/black-forest-labs/flux-1-schnell", { steps: 8 });
const result = await ai.generateImages(images, {
input: [{ type: "text", text: "A red bicycle" }],
});flux-1-schnell takes a prompt and steps only. For it, width, height, seed, guidance, and negativePrompt are dropped and listed in a cloudflare-compat diagnostic. Other image models in the catalog take all of them.