fix(ai): finalize cloudflare gateway provider support

This commit is contained in:
Mario Zechner
2026-05-01 00:56:05 +02:00
parent 24fb6b833b
commit a45577bd00
19 changed files with 1086 additions and 768 deletions

View File

@@ -8,6 +8,7 @@
### Added
- Added Cloudflare AI Gateway as a built-in provider with OpenAI, Anthropic, and Workers AI gateway routing plus `CLOUDFLARE_API_KEY`/`CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_GATEWAY_ID` authentication ([#3856](https://github.com/badlogic/pi-mono/pull/3856) by [@mchenco](https://github.com/mchenco)).
- Added Moonshot AI as a built-in OpenAI-compatible provider with model catalog generation and `MOONSHOT_API_KEY` authentication.
- Added Mistral Medium 3.5 model metadata and reasoning-mode handling ([#4009](https://github.com/badlogic/pi-mono/pull/4009) by [@technocidal](https://github.com/technocidal)).
- Added `AssistantMessage.responseModel` on the openai-completions path: surfaces the concrete `chunk.model` when it differs from the requested id (e.g. OpenRouter `auto` -> `anthropic/...`) ([#3968](https://github.com/badlogic/pi-mono/pull/3968) by [@purrgrammer](https://github.com/purrgrammer)).

View File

@@ -4,6 +4,7 @@ import { writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import {
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL,
CLOUDFLARE_WORKERS_AI_BASE_URL,
@@ -419,14 +420,18 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
const upstream = prefixedId.slice(0, slashIdx);
const nativeId = prefixedId.slice(slashIdx + 1);
let api: "openai-completions" | "openai-responses";
let api: "anthropic-messages" | "openai-completions" | "openai-responses";
let baseUrl: string;
let id: string;
if (upstream === "openai") {
api = "openai-responses";
baseUrl = CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL;
id = nativeId;
} else if (upstream === "workers-ai" || upstream === "anthropic") {
} else if (upstream === "anthropic") {
api = "anthropic-messages";
baseUrl = CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL;
id = nativeId;
} else if (upstream === "workers-ai") {
api = "openai-completions";
baseUrl = CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL;
id = prefixedId;

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ import { headersToRecord } from "../utils/headers.js";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { resolveCloudflareBaseUrl } from "./cloudflare.js";
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js";
import { transformMessages } from "./transform-messages.js";
@@ -215,8 +216,8 @@ export interface AnthropicOptions extends StreamOptions {
client?: Anthropic;
}
function mergeHeaders(...headerSources: (Record<string, string> | undefined)[]): Record<string, string> {
const merged: Record<string, string> = {};
function mergeHeaders(...headerSources: (Record<string, string | null> | undefined)[]): Record<string, string | null> {
const merged: Record<string, string | null> = {};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
@@ -772,17 +773,39 @@ function createClient(
// Adaptive thinking models (Opus 4.6, Sonnet 4.6) have interleaved thinking built-in.
// The beta header is deprecated on Opus 4.6 and redundant on Sonnet 4.6, so skip it.
const needsInterleavedBeta = interleavedThinking && !supportsAdaptiveThinking(model.id);
const betaFeatures: string[] = [];
if (useFineGrainedToolStreamingBeta) {
betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA);
}
if (needsInterleavedBeta) {
betaFeatures.push(INTERLEAVED_THINKING_BETA);
}
if (model.provider === "cloudflare-ai-gateway") {
const client = new Anthropic({
apiKey: null,
authToken: null,
baseURL: resolveCloudflareBaseUrl(model),
dangerouslyAllowBrowser: true,
defaultHeaders: mergeHeaders(
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
"cf-aig-authorization": `Bearer ${apiKey}`,
"x-api-key": null,
Authorization: null,
...(betaFeatures.length > 0 ? { "anthropic-beta": betaFeatures.join(",") } : {}),
},
model.headers,
optionsHeaders,
),
});
return { client, isOAuthToken: false };
}
// Copilot: Bearer auth, selective betas.
if (model.provider === "github-copilot") {
const betaFeatures: string[] = [];
if (useFineGrainedToolStreamingBeta) {
betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA);
}
if (needsInterleavedBeta) {
betaFeatures.push(INTERLEAVED_THINKING_BETA);
}
const client = new Anthropic({
apiKey: null,
authToken: apiKey,
@@ -803,14 +826,6 @@ function createClient(
return { client, isOAuthToken: false };
}
const betaFeatures: string[] = [];
if (useFineGrainedToolStreamingBeta) {
betaFeatures.push(FINE_GRAINED_TOOL_STREAMING_BETA);
}
if (needsInterleavedBeta) {
betaFeatures.push(INTERLEAVED_THINKING_BETA);
}
// OAuth: Bearer auth, Claude Code identity headers
if (isOAuthToken(apiKey)) {
const client = new Anthropic({

View File

@@ -12,6 +12,10 @@ export const CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL =
export const CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL =
"https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai";
/** AI Gateway → Anthropic passthrough. */
export const CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL =
"https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic";
export function isCloudflareProvider(provider: string): boolean {
return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway";
}
@@ -20,11 +24,12 @@ export function isCloudflareProvider(provider: string): boolean {
export function resolveCloudflareBaseUrl(model: Model<Api>): string {
const url = model.baseUrl;
if (!url.includes("{")) return url;
return url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => {
const baseUrl = url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required for provider ${model.provider} but is not set.`);
}
return value;
});
return baseUrl;
}

View File

@@ -458,11 +458,20 @@ function createClient(
Object.assign(headers, optionsHeaders);
}
const defaultHeaders =
model.provider === "cloudflare-ai-gateway"
? {
...headers,
Authorization: headers.Authorization ?? null,
"cf-aig-authorization": `Bearer ${apiKey}`,
}
: headers;
return new OpenAI({
apiKey,
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders: headers,
defaultHeaders,
});
}
@@ -1029,6 +1038,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
const isZai = provider === "zai" || baseUrl.includes("api.z.ai");
const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot.");
const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com");
const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
const isNonStandard =
provider === "cerebras" ||
@@ -1041,9 +1051,10 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
isMoonshot ||
provider === "opencode" ||
baseUrl.includes("opencode.ai") ||
isCloudflareWorkersAI;
isCloudflareWorkersAI ||
isCloudflareAiGateway;
const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot;
const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway;
const isGrok = provider === "xai" || baseUrl.includes("api.x.ai");
const isGroq = provider === "groq" || baseUrl.includes("groq.com");
@@ -1070,7 +1081,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
return {
supportsStore: !isNonStandard,
supportsDeveloperRole: !isNonStandard,
supportsReasoningEffort: !isGrok && !isZai && !isMoonshot,
supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isCloudflareAiGateway,
reasoningEffortMap,
supportsUsageInStreaming: true,
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
@@ -1088,10 +1099,10 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
openRouterRouting: {},
vercelGatewayRouting: {},
zaiToolStream: false,
supportsStrictMode: !isMoonshot,
supportsStrictMode: !isMoonshot && !isCloudflareAiGateway,
cacheControlFormat,
sendSessionAffinityHeaders: false,
supportsLongCacheRetention: true,
supportsLongCacheRetention: !(isCloudflareWorkersAI || isCloudflareAiGateway),
};
}

View File

@@ -197,11 +197,20 @@ function createClient(
Object.assign(headers, optionsHeaders);
}
const defaultHeaders =
model.provider === "cloudflare-ai-gateway"
? {
...headers,
Authorization: headers.Authorization ?? null,
"cf-aig-authorization": `Bearer ${apiKey}`,
}
: headers;
return new OpenAI({
apiKey,
baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl,
dangerouslyAllowBrowser: true,
defaultHeaders: headers,
defaultHeaders,
});
}

View File

@@ -0,0 +1,9 @@
export function hasCloudflareWorkersAICredentials(): boolean {
return !!process.env.CLOUDFLARE_API_KEY && !!process.env.CLOUDFLARE_ACCOUNT_ID;
}
export function hasCloudflareAiGatewayCredentials(): boolean {
return (
!!process.env.CLOUDFLARE_API_KEY && !!process.env.CLOUDFLARE_ACCOUNT_ID && !!process.env.CLOUDFLARE_GATEWAY_ID
);
}

View File

@@ -29,6 +29,7 @@ import { getModel } from "../src/models.js";
import { completeSimple, getEnvApiKey } from "../src/stream.js";
import type { Api, AssistantMessage, Message, Model, Tool, ToolResultMessage } from "../src/types.js";
import { hasAzureOpenAICredentials } from "./azure-utils.js";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js";
import { resolveApiKey } from "./oauth.js";
// Simple tool for testing
@@ -48,6 +49,7 @@ interface ProviderModelPair {
model: string;
label: string;
apiOverride?: Api;
upstreamApiKeyEnv?: string;
}
const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [
@@ -83,6 +85,24 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [
{ provider: "cerebras", model: "zai-glm-4.7", label: "cerebras-zai-glm-4.7" },
// Cloudflare Workers AI
{ provider: "cloudflare-workers-ai", model: "@cf/moonshotai/kimi-k2.6", label: "cloudflare-kimi-k2.6" },
// Cloudflare AI Gateway
{
provider: "cloudflare-ai-gateway",
model: "workers-ai/@cf/moonshotai/kimi-k2.6",
label: "cloudflare-gateway-kimi-k2.6",
},
{
provider: "cloudflare-ai-gateway",
model: "claude-sonnet-4-5",
label: "cloudflare-gateway-claude-sonnet-4-5",
upstreamApiKeyEnv: "ANTHROPIC_API_KEY",
},
{
provider: "cloudflare-ai-gateway",
model: "gpt-5.1",
label: "cloudflare-gateway-gpt-5.1",
upstreamApiKeyEnv: "OPENAI_API_KEY",
},
// Groq
{ provider: "groq", model: "openai/gpt-oss-120b", label: "groq-gpt-oss-120b" },
// Hugging Face
@@ -127,18 +147,31 @@ async function getApiKey(provider: string): Promise<string | undefined> {
/**
* Synchronous check for API key availability (env vars only, for skipIf)
*/
function hasApiKey(provider: string): boolean {
if (provider === "azure-openai-responses") {
function hasApiKey(pair: ProviderModelPair): boolean {
if (pair.provider === "azure-openai-responses") {
return hasAzureOpenAICredentials();
}
return !!getEnvApiKey(provider);
if (pair.provider === "cloudflare-workers-ai") {
return hasCloudflareWorkersAICredentials();
}
if (pair.provider === "cloudflare-ai-gateway") {
if (!hasCloudflareAiGatewayCredentials()) return false;
return pair.upstreamApiKeyEnv ? !!process.env[pair.upstreamApiKeyEnv] : true;
}
return !!getEnvApiKey(pair.provider);
}
function getHeaders(pair: ProviderModelPair): Record<string, string> | undefined {
if (!pair.upstreamApiKeyEnv) return undefined;
const upstreamApiKey = process.env[pair.upstreamApiKeyEnv];
return upstreamApiKey ? { Authorization: `Bearer ${upstreamApiKey}` } : undefined;
}
/**
* Check if any provider has API keys available (for skipIf at describe level)
*/
function hasAnyApiKey(): boolean {
return PROVIDER_MODEL_PAIRS.some((pair) => hasApiKey(pair.provider));
return PROVIDER_MODEL_PAIRS.some((pair) => hasApiKey(pair));
}
function dumpFailurePayload(params: { label: string; error: string; payload?: unknown; messages: Message[] }): void {
@@ -176,6 +209,7 @@ async function generateContext(
};
const supportsReasoning = model.reasoning === true;
const headers = getHeaders(pair);
let lastPayload: unknown;
let assistantResponse: AssistantMessage;
try {
@@ -189,6 +223,7 @@ async function generateContext(
{
apiKey,
reasoning: supportsReasoning ? "high" : undefined,
headers,
onPayload: (payload) => {
lastPayload = payload;
},
@@ -250,6 +285,7 @@ async function generateContext(
{
apiKey,
reasoning: supportsReasoning ? "high" : undefined,
headers,
onPayload: (payload) => {
lastPayload = payload;
},
@@ -296,7 +332,7 @@ describe.skipIf(!hasAnyApiKey())("Cross-Provider Handoff", () => {
for (const pair of PROVIDER_MODEL_PAIRS) {
const apiKey = await getApiKey(pair.provider);
if (!apiKey) {
if (!apiKey || !hasApiKey(pair)) {
console.log(`[${pair.label}] Skipping - no auth for ${pair.provider}`);
continue;
}
@@ -344,7 +380,7 @@ describe.skipIf(!hasAnyApiKey())("Cross-Provider Handoff", () => {
for (const targetPair of availablePairs) {
const apiKey = await getApiKey(targetPair.provider);
if (!apiKey) {
if (!apiKey || !hasApiKey(targetPair)) {
console.log(`[Target: ${targetPair.label}] Skipping - no auth`);
continue;
}
@@ -384,6 +420,7 @@ describe.skipIf(!hasAnyApiKey())("Cross-Provider Handoff", () => {
? { ...baseModel, api: targetPair.apiOverride }
: baseModel;
const supportsReasoning = model.reasoning === true;
const headers = getHeaders(targetPair);
console.log(
`[Target: ${targetPair.label}] Testing with ${otherMessages.length} messages from other providers...`,
@@ -401,6 +438,7 @@ describe.skipIf(!hasAnyApiKey())("Cross-Provider Handoff", () => {
{
apiKey,
reasoning: supportsReasoning ? "high" : undefined,
headers,
onPayload: (payload) => {
lastPayload = payload;
},

View File

@@ -7,6 +7,7 @@ type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js";
import { hasBedrockCredentials } from "./bedrock-utils.js";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js";
import { resolveApiKey } from "./oauth.js";
// Resolve OAuth tokens at module level (async, runs before tests)
@@ -306,28 +307,45 @@ describe("AI Providers Empty Message Tests", () => {
});
});
describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)(
"Cloudflare Workers AI Provider Empty Messages",
() => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI Provider Empty Messages", () => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
},
);
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider Empty Messages", () => {
const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider Empty Messages", () => {
const llm = getModel("huggingface", "moonshotai/Kimi-K2.5");

View File

@@ -9,10 +9,15 @@ import { streamSimple } from "../src/stream.js";
const mockState = vi.hoisted(() => ({
lastParams: undefined as unknown,
lastClientOptions: undefined as unknown,
}));
vi.mock("openai", () => {
class FakeOpenAI {
constructor(options: unknown) {
mockState.lastClientOptions = options;
}
chat = {
completions: {
create: (params: unknown) => {
@@ -52,6 +57,7 @@ vi.mock("openai", () => {
describe("openai-completions empty tools handling", () => {
beforeEach(() => {
mockState.lastParams = undefined;
mockState.lastClientOptions = undefined;
});
it("omits tools field when context.tools is an empty array", async () => {
@@ -87,6 +93,79 @@ describe("openai-completions empty tools handling", () => {
expect("tools" in (params as object)).toBe(false);
});
it("uses conservative OpenAI-compatible fields for Cloudflare AI Gateway /compat models", async () => {
process.env.CLOUDFLARE_ACCOUNT_ID = "account-id";
process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id";
const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!;
await streamSimple(
model,
{
systemPrompt: "You are helpful.",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test", reasoning: "high" },
).result();
const params = mockState.lastParams as {
messages: Array<{ role: string }>;
max_tokens?: number;
max_completion_tokens?: number;
reasoning_effort?: string;
store?: boolean;
};
expect(params.messages[0].role).toBe("system");
expect(params.max_tokens).toBeDefined();
expect(params.max_completion_tokens).toBeUndefined();
expect(params.reasoning_effort).toBeUndefined();
expect(params.store).toBeUndefined();
const clientOptions = mockState.lastClientOptions as {
baseURL?: string;
defaultHeaders?: Record<string, unknown>;
};
expect(clientOptions.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/account-id/gateway-id/compat");
expect(clientOptions.defaultHeaders?.Authorization).toBeNull();
expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer test");
});
it("preserves inline upstream Authorization for Cloudflare AI Gateway BYOK requests", async () => {
process.env.CLOUDFLARE_ACCOUNT_ID = "account-id";
process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id";
const model = getModel("cloudflare-ai-gateway", "gpt-5.1")!;
await streamSimple(
model,
{
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "cf-token", headers: { Authorization: "Bearer upstream-token" } },
).result();
const clientOptions = mockState.lastClientOptions as { defaultHeaders?: Record<string, unknown> };
expect(clientOptions.defaultHeaders?.Authorization).toBe("Bearer upstream-token");
expect(clientOptions.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer cf-token");
});
it("sends session affinity headers for Workers AI through Cloudflare AI Gateway", async () => {
process.env.CLOUDFLARE_ACCOUNT_ID = "account-id";
process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id";
const workersModel = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!;
await streamSimple(
workersModel,
{
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test", sessionId: "session-1" },
).result();
const clientOptions = mockState.lastClientOptions as { defaultHeaders?: Record<string, string> };
expect(clientOptions.defaultHeaders?.session_id).toBe("session-1");
expect(clientOptions.defaultHeaders?.["x-client-request-id"]).toBe("session-1");
expect(clientOptions.defaultHeaders?.["x-session-affinity"]).toBe("session-1");
});
it("still emits tools: [] for Anthropic/LiteLLM proxy when conversation has tool history", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
const model = { ...baseModel, api: "openai-completions" } as const;

View File

@@ -13,6 +13,7 @@ type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { StringEnum } from "../src/utils/typebox-helpers.js";
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js";
import { hasBedrockCredentials } from "./bedrock-utils.js";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js";
import { resolveApiKey } from "./oauth.js";
const __filename = fileURLToPath(import.meta.url);
@@ -614,7 +615,7 @@ describe("Generate E2E Tests", () => {
});
});
describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)(
describe.skipIf(!hasCloudflareWorkersAICredentials())(
"Cloudflare Workers AI Provider (Kimi K2.6 via OpenAI Completions)",
() => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
@@ -641,59 +642,98 @@ describe("Generate E2E Tests", () => {
},
);
describe.skipIf(
!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID || !process.env.CLOUDFLARE_GATEWAY_ID,
)("Cloudflare AI Gateway → Workers AI (Kimi K2.6 via /compat)", () => {
const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
describe.skipIf(!hasCloudflareAiGatewayCredentials())(
"Cloudflare AI Gateway → Workers AI (Kimi K2.6 via /compat)",
() => {
const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
});
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm);
});
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm);
});
describe.skipIf(
!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID || !process.env.CLOUDFLARE_GATEWAY_ID,
)("Cloudflare AI Gateway → OpenAI (gpt-5.1 via /openai responses)", () => {
const llm = getModel("cloudflare-ai-gateway", "gpt-5.1");
it("should handle thinking mode", { retry: 3 }, async () => {
await handleThinking(llm, { reasoningEffort: "medium" });
});
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
});
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
await multiTurn(llm, { reasoningEffort: "medium" });
});
},
);
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm);
});
describe.skipIf(!hasCloudflareAiGatewayCredentials() || !process.env.OPENAI_API_KEY)(
"Cloudflare AI Gateway → OpenAI BYOK (gpt-5.1 via /openai responses)",
() => {
const llm = getModel("cloudflare-ai-gateway", "gpt-5.1");
const options = { headers: { Authorization: `Bearer ${process.env.OPENAI_API_KEY}` } };
const thinkingOptions = {
...options,
thinkingEnabled: true,
reasoningEffort: "medium",
} satisfies StreamOptionsWithExtras;
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm);
});
});
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm, options);
});
describe.skipIf(
!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID || !process.env.CLOUDFLARE_GATEWAY_ID,
)("Cloudflare AI Gateway → Anthropic (claude-sonnet-4-5 via /compat)", () => {
const llm = getModel("cloudflare-ai-gateway", "anthropic/claude-sonnet-4-5");
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm, options);
});
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm, options);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm);
});
it("should handle thinking mode", { retry: 3 }, async () => {
await handleThinking(llm, thinkingOptions);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm);
});
});
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
await multiTurn(llm, thinkingOptions);
});
},
);
describe.skipIf(!hasCloudflareAiGatewayCredentials() || !process.env.ANTHROPIC_API_KEY)(
"Cloudflare AI Gateway → Anthropic BYOK (claude-sonnet-4-5 via /anthropic messages)",
() => {
const llm = getModel("cloudflare-ai-gateway", "claude-sonnet-4-5");
const options = { headers: { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` } };
const thinkingOptions = {
...options,
thinkingEnabled: true,
reasoningEffort: "high",
} satisfies StreamOptionsWithExtras;
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm, options);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm, options);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm, options);
});
it("should handle thinking mode", { retry: 3 }, async () => {
await handleThinking(llm, thinkingOptions);
});
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
await multiTurn(llm, thinkingOptions);
});
},
);
describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider (Kimi-K2.5 via OpenAI Completions)", () => {
const llm = getModel("huggingface", "moonshotai/Kimi-K2.5");

View File

@@ -7,6 +7,7 @@ type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js";
import { hasBedrockCredentials } from "./bedrock-utils.js";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js";
import { resolveApiKey } from "./oauth.js";
// Resolve OAuth tokens at module level (async, runs before tests)
@@ -155,16 +156,21 @@ describe("Token Statistics on Abort", () => {
});
});
describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)(
"Cloudflare Workers AI Provider",
() => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI Provider", () => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm);
});
},
);
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm);
});
});
describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider", () => {
const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => {
await testTokensOnAbort(llm);
});
});
describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider", () => {
const llm = getModel("huggingface", "moonshotai/Kimi-K2.5");

View File

@@ -8,6 +8,7 @@ type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js";
import { hasBedrockCredentials } from "./bedrock-utils.js";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js";
import { resolveApiKey } from "./oauth.js";
// Resolve OAuth tokens at module level (async, runs before tests)
@@ -166,20 +167,21 @@ describe("Tool Call Without Result Tests", () => {
});
});
describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)(
"Cloudflare Workers AI Provider",
() => {
const model = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI Provider", () => {
const model = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
it(
"should filter out tool calls without corresponding tool results",
{ retry: 3, timeout: 30000 },
async () => {
await testToolCallWithoutResult(model);
},
);
},
);
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
await testToolCallWithoutResult(model);
});
});
describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider", () => {
const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => {
await testToolCallWithoutResult(model);
});
});
describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider", () => {
const model = getModel("huggingface", "moonshotai/Kimi-K2.5");

View File

@@ -21,6 +21,7 @@ type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js";
import { hasBedrockCredentials } from "./bedrock-utils.js";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js";
import { resolveApiKey } from "./oauth.js";
// Resolve OAuth tokens at module level (async, runs before tests)
@@ -308,29 +309,51 @@ describe("totalTokens field", () => {
// Cloudflare Workers AI
// =========================================================================
describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)(
"Cloudflare Workers AI",
() => {
it(
"@cf/moonshotai/kimi-k2.6 - should return totalTokens equal to sum of components",
{ retry: 3, timeout: 60000 },
async () => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI", () => {
it(
"@cf/moonshotai/kimi-k2.6 - should return totalTokens equal to sum of components",
{ retry: 3, timeout: 60000 },
async () => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
console.log(`\nCloudflare Workers AI / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, {
apiKey: process.env.CLOUDFLARE_API_KEY,
});
console.log(`\nCloudflare Workers AI / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, {
apiKey: process.env.CLOUDFLARE_API_KEY,
});
logUsage("First request", first);
logUsage("Second request", second);
logUsage("First request", first);
logUsage("Second request", second);
assertTotalTokensEqualsComponents(first);
assertTotalTokensEqualsComponents(second);
},
);
},
);
assertTotalTokensEqualsComponents(first);
assertTotalTokensEqualsComponents(second);
},
);
});
// =========================================================================
// Cloudflare AI Gateway
// =========================================================================
describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway", () => {
it(
"workers-ai/@cf/moonshotai/kimi-k2.6 - should return totalTokens equal to sum of components",
{ retry: 3, timeout: 60000 },
async () => {
const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
console.log(`\nCloudflare AI Gateway / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, {
apiKey: process.env.CLOUDFLARE_API_KEY,
});
logUsage("First request", first);
logUsage("Second request", second);
assertTotalTokensEqualsComponents(first);
assertTotalTokensEqualsComponents(second);
},
);
});
// =========================================================================
// Hugging Face

View File

@@ -8,6 +8,7 @@ type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js";
import { hasBedrockCredentials } from "./bedrock-utils.js";
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js";
import { resolveApiKey } from "./oauth.js";
// Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist
@@ -497,28 +498,37 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
});
});
describe.skipIf(!process.env.CLOUDFLARE_API_KEY || !process.env.CLOUDFLARE_ACCOUNT_ID)(
"Cloudflare Workers AI Provider Unicode Handling",
() => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI Provider Unicode Handling", () => {
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
await testEmojiInToolResults(llm);
});
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
await testEmojiInToolResults(llm);
});
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
await testRealWorldLinkedInData(llm);
});
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
await testRealWorldLinkedInData(llm);
});
it(
"should handle unpaired high surrogate (0xD83D) in tool results",
{ retry: 3, timeout: 30000 },
async () => {
await testUnpairedHighSurrogate(llm);
},
);
},
);
it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => {
await testUnpairedHighSurrogate(llm);
});
});
describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider Unicode Handling", () => {
const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => {
await testEmojiInToolResults(llm);
});
it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => {
await testRealWorldLinkedInData(llm);
});
it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => {
await testUnpairedHighSurrogate(llm);
});
});
describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider Unicode Handling", () => {
const llm = getModel("huggingface", "moonshotai/Kimi-K2.5");

View File

@@ -15,6 +15,7 @@
### Added
- Added Cloudflare AI Gateway as a built-in provider with `CLOUDFLARE_API_KEY`/`CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_GATEWAY_ID` setup, default model resolution, `/login` display support, and provider documentation ([#3856](https://github.com/badlogic/pi-mono/pull/3856) by [@mchenco](https://github.com/mchenco)).
- Added Moonshot AI as a built-in provider with `MOONSHOT_API_KEY` setup, default model resolution, and `/login` display support.
- Added Mistral Medium 3.5 built-in model support via `@mariozechner/pi-ai` ([#4009](https://github.com/badlogic/pi-mono/pull/4009) by [@technocidal](https://github.com/technocidal)).
- Added routed OpenAI-compatible response model metadata in assistant messages, so providers such as OpenRouter can expose the concrete model used ([#3968](https://github.com/badlogic/pi-mono/pull/3968) by [@purrgrammer](https://github.com/purrgrammer)).

View File

@@ -178,10 +178,21 @@ export AWS_BEDROCK_FORCE_HTTP1=1
export CLOUDFLARE_API_KEY=... # or use /login
export CLOUDFLARE_ACCOUNT_ID=...
export CLOUDFLARE_GATEWAY_ID=... # create at dash.cloudflare.com → AI → AI Gateway
pi --provider cloudflare-ai-gateway --model "anthropic/claude-sonnet-4-5"
pi --provider cloudflare-ai-gateway --model "claude-sonnet-4-5"
```
Routes to OpenAI, Anthropic, and Workers AI through Cloudflare's [Unified API](https://developers.cloudflare.com/ai-gateway/usage/unified-api/) (`/compat`). Workers AI and Anthropic models use the prefixed model id (`workers-ai/@cf/...`, `anthropic/...`). OpenAI reasoning models (gpt-5.x, o-series) need `/v1/responses` which `/compat` doesn't yet expose, so they route through the gateway's `/openai` provider-specific subpath with the native id (`gpt-5.1`). Requires BYOK or unified billing on the gateway for non-Workers-AI upstreams.
Routes to OpenAI, Anthropic, and Workers AI through Cloudflare AI Gateway. Workers AI uses the Unified API (`/compat`) and prefixed model IDs (`workers-ai/@cf/...`). OpenAI uses the OpenAI passthrough route (`/openai`) with native OpenAI model IDs such as `gpt-5.1`. Anthropic uses the Anthropic passthrough route (`/anthropic`) with native Anthropic model IDs such as `claude-sonnet-4-5`.
AI Gateway authentication uses `CLOUDFLARE_API_KEY` as `cf-aig-authorization`. Upstream authentication can be one of:
| Mode | Request auth | Upstream auth |
|------|--------------|---------------|
| Workers AI | Cloudflare token only | Cloudflare-native |
| Unified billing | Cloudflare token only | Cloudflare handles upstream auth and deducts credits |
| Stored BYOK | Cloudflare token only | Cloudflare injects provider keys stored in the AI Gateway dashboard |
| Inline BYOK | Cloudflare token plus upstream `Authorization` header | The request supplies the upstream provider key |
For normal pi usage, prefer unified billing or stored BYOK. Inline BYOK requires configuring an additional upstream `Authorization` header for the Cloudflare AI Gateway provider, for example via a `models.json` provider/model override.
### Cloudflare Workers AI

View File

@@ -3,6 +3,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
"amazon-bedrock": "Amazon Bedrock",
"azure-openai-responses": "Azure OpenAI Responses",
cerebras: "Cerebras",
"cloudflare-ai-gateway": "Cloudflare AI Gateway",
"cloudflare-workers-ai": "Cloudflare Workers AI",
deepseek: "DeepSeek",
fireworks: "Fireworks",