From bdd5c53bcdf57186125968b8e082e84c6a34742b Mon Sep 17 00:00:00 2001 From: Mark Phelps <209477+markphelps@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:30:40 -0400 Subject: [PATCH] fix(ai): fall back to ambient Cloudflare account id for key-only credentials (#6292) Cloudflare Workers AI / AI Gateway resolved provider config from the credential only, never consulting ambient env for a field the credential omitted. The coding-agent /login flow stores just the API key, so CLOUDFLARE_ACCOUNT_ID lives only in the environment; the key-only credential short-circuited the env lookup, the account id stayed unresolved, and requests hit the literal {CLOUDFLARE_ACCOUNT_ID} base URL -> 404. resolveValue now merges per field: prefer the credential value, fall back to ctx.env(name). closes #6021 Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: Mario Zechner --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/cloudflare-auth.ts | 14 +++++++++----- packages/ai/test/providers.test.ts | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ba5ed670..19f9cebb 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -42,6 +42,7 @@ - Fixed Amazon Bedrock Claude 5 prompt-cache pricing metadata by removing stale fallback overrides. - Fixed DS4 server context overflow detection for `Prompt has ... tokens, but the configured context size is ... tokens` errors ([#6262](https://github.com/earendil-works/pi/issues/6262)). - Fixed OpenAI Codex WebSocket sessions to rotate cached connections before the backend's 60-minute limit, avoiding connection-limit failures on long sessions ([#6268](https://github.com/earendil-works/pi/issues/6268)). +- Fixed Cloudflare Workers AI / AI Gateway auth to fall back to the ambient `CLOUDFLARE_ACCOUNT_ID` (and `CLOUDFLARE_GATEWAY_ID`) when the stored credential carries only the API key, so `/login`-style key-only credentials no longer leave the `{CLOUDFLARE_ACCOUNT_ID}` placeholder unresolved and return 404 ([#6021](https://github.com/earendil-works/pi/issues/6021)). - Fixed OpenAI Completions and Responses providers to send `(no tool output)` instead of `(see attached image)` when a tool result has empty text and no image content, preventing the model from hallucinating image attachments. - Fixed OpenAI Responses and Azure OpenAI Responses requests to avoid sending `max_output_tokens` values below the provider minimum ([#6265](https://github.com/earendil-works/pi/issues/6265)). - Fixed retry classification for Cloudflare 524 timeout responses ([#6239](https://github.com/earendil-works/pi/issues/6239)). diff --git a/packages/ai/src/providers/cloudflare-auth.ts b/packages/ai/src/providers/cloudflare-auth.ts index 511e8d73..7542ef98 100644 --- a/packages/ai/src/providers/cloudflare-auth.ts +++ b/packages/ai/src/providers/cloudflare-auth.ts @@ -12,11 +12,15 @@ async function resolveValue( ctx: AuthContext, credential: ApiKeyCredential | undefined, ): Promise { - if (credential) { - if (name === CLOUDFLARE_API_KEY) return credential.key; - return credential.env?.[name]; - } - return ctx.env(name); + // Per-field merge: prefer the credential value, fall back to ambient env. + // A credential carrying only the API key must still pick up the account / + // gateway id from the environment. + const fromCredential = credential + ? name === CLOUDFLARE_API_KEY + ? credential.key + : credential.env?.[name] + : undefined; + return fromCredential ?? (await ctx.env(name)); } function resolveCloudflareBaseUrl( diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index a22c8abd..3b662996 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -99,6 +99,26 @@ describe("builtin providers", () => { expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" }); }); + // Regression for #6021: a credential carrying only the API key (as stored + // by `/login`) must still resolve CLOUDFLARE_ACCOUNT_ID from ambient env. + it("falls back to ambient CLOUDFLARE_ACCOUNT_ID when the credential carries only the API key", async () => { + const provider = cloudflareWorkersAIProvider(); + const model = builtinModels().getModels("cloudflare-workers-ai")[0]; + const auth = provider.auth.apiKey; + if (!auth) throw new Error("expected api-key auth"); + + const result = await auth.resolve({ + model, + ctx: fakeAuthContext({ CLOUDFLARE_ACCOUNT_ID: "account-id" }), + credential: { type: "api_key", key: "cf-key" }, + }); + expect(result?.auth).toEqual({ + apiKey: "cf-key", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/account-id/ai/v1", + }); + expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" }); + }); + it("requires Cloudflare AI Gateway account and gateway config and returns scoped env headers", async () => { const missingGateway = createModels({ authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key", CLOUDFLARE_ACCOUNT_ID: "account-id" }),