diff --git a/.changeset/fix-anthropic-subscription-provider-not-configured.md b/.changeset/fix-anthropic-subscription-provider-not-configured.md new file mode 100644 index 0000000000..3a4b2f14c7 --- /dev/null +++ b/.changeset/fix-anthropic-subscription-provider-not-configured.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Anthropic subscription logins failing tasks with "Provider is not configured: anthropic". +category: fix +dev: pi-ai >=0.80 resolves provider auth via `credentials.read(provider.id)` instead of `getApiKey()`, bypassing fusion's `anthropic-subscription` -> `anthropic` alias; alias it at the credential-store `read()` layer (`createFusionCredentialStore`). Also add "not configured" to `isRetryableModelSelectionError` so an unresolved provider triggers the configured fallback model instead of hard-failing. diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index fa889d3d80..b5164037c2 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js"; +import { createFusionAuthStorage, createFusionCredentialStore, getFusionAuthPath } from "../auth-storage.js"; function encodeBase64Url(value: string): string { return Buffer.from(value, "utf-8").toString("base64url"); @@ -218,6 +218,65 @@ describe("createFusionAuthStorage", () => { expect(authStorage.hasAuth("anthropic")).toBe(true); }); + // FNXC:ProviderAuth 2026-07-16-11:00 — Symptom Verification for the pi-ai >=0.80 read()-based + // auth-resolution regression. Original symptom: a subscription-only Anthropic login (stored only + // under `anthropic-subscription`) failed every task with "Provider is not configured: anthropic" + // because pi-ai's resolveProviderAuth calls credentials.read("anthropic") directly instead of + // fusion's getApiKey("anthropic"), so the subscription->anthropic alias was bypassed. Assert the + // credential store's read() path (the exact surface pi-ai uses) resolves the subscription credential. + it("aliases Anthropic subscription OAuth through the pi-ai credential-store read('anthropic') path", async () => { + writeFusionAuth(homeDir, { + "anthropic-subscription": { + type: "oauth", + access: "subscription-access-token", + refresh: "subscription-refresh-token", + expires: Date.now() + 3_600_000, + }, + }); + + const authStorage = createFusionAuthStorage(); + const credentialStore = createFusionCredentialStore(authStorage); + + // pi-ai reads the credential for provider `anthropic` directly; it must see the subscription OAuth. + expect(await credentialStore.read("anthropic")).toMatchObject({ + type: "oauth", + access: "subscription-access-token", + }); + // `anthropic-subscription` still reads its own credential unchanged. + expect(await credentialStore.read("anthropic-subscription")).toMatchObject({ + type: "oauth", + access: "subscription-access-token", + }); + }); + + it("prefers a raw Anthropic credential over the subscription alias in read('anthropic')", async () => { + writeFusionAuth(homeDir, { + anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" }, + "anthropic-subscription": { + type: "oauth", + access: "subscription-access-token", + refresh: "subscription-refresh-token", + expires: Date.now() + 3_600_000, + }, + }); + + const authStorage = createFusionAuthStorage(); + const credentialStore = createFusionCredentialStore(authStorage); + + // Raw api_key wins; the subscription alias only fills the gap when no raw/legacy row exists. + expect(await credentialStore.read("anthropic")).toEqual({ + type: "api_key", + key: "sk-ant-api03-runtime-key", + }); + }); + + it("read('anthropic') is undefined when no Anthropic credential of any kind exists", async () => { + const authStorage = createFusionAuthStorage(); + const credentialStore = createFusionCredentialStore(authStorage); + + expect(await credentialStore.read("anthropic")).toBeUndefined(); + }); + it("uses Anthropic subscription OAuth for direct model runtime auth when no raw API key exists", async () => { writeFusionAuth(homeDir, { "anthropic-subscription": { diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index 31305ea194..6843f2134d 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -1483,6 +1483,12 @@ describe("isRetryableModelSelectionError", () => { expect(isRetryableModelSelectionError("model is overloaded")).toBe(true); }); + it("treats a provider-not-configured failure as model-selection retryable so the fallback model is tried", () => { + // pi-ai surfaces an unresolved provider credential as this exact string (ModelsError code "auth"). + // A configured fallback on a different provider can recover, so it must enter the single-swap path. + expect(isRetryableModelSelectionError("Provider is not configured: anthropic")).toBe(true); + }); + it("does not match unrelated errors", () => { expect(isRetryableModelSelectionError("ENOENT: no such file or directory")).toBe(false); expect(isRetryableModelSelectionError("syntax error near unexpected token")).toBe(false); diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 5230ba3848..bf2451f4ed 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -144,9 +144,19 @@ class FusionFileAuthStorage implements FusionAuthStorage { } } -function createFusionCredentialStore(authStorage: FusionAuthStorage): CredentialStore { +export function createFusionCredentialStore(authStorage: FusionAuthStorage): CredentialStore { return { - read: async (providerId) => authStorage.get(providerId) as Credential | undefined, + /* + FNXC:ProviderAuth 2026-07-16-11:00: + pi-ai >=0.80 resolves provider auth by reading the credential store directly (`resolveProviderAuth` -> `credentials.read(provider.id)`) and performs OAuth refresh + auth derivation itself, instead of calling fusion's `getApiKey(provider)`. Fusion persists an Anthropic subscription login under `anthropic-subscription`, but Anthropic model execution requests provider `anthropic`, so the subscription->anthropic aliasing that lived only in `resolveAnthropicRuntimeApiKey` (the getApiKey path) is now bypassed. Without aliasing at the read() layer a subscription-only login surfaces at prompt time as `Provider is not configured: anthropic` even though the status card shows "connected" (status uses hasVisibleAnthropicCredential, a different path). When no raw/legacy `anthropic` credential exists, alias the separated `anthropic-subscription` OAuth credential into read("anthropic") so pi-ai runs it on the built-in provider (/v1 Claude Code impersonation). A raw `anthropic` api_key or legacy oauth row still wins. See resolveAnthropicRuntimeApiKey for the mirror precedence. + */ + read: async (providerId) => { + const credential = authStorage.get(providerId) as Credential | undefined; + if (!credential && providerId === ANTHROPIC_PROVIDER_ID) { + return authStorage.get(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) as Credential | undefined; + } + return credential; + }, list: async () => authStorage.list().flatMap((providerId): CredentialInfo[] => { const credential = authStorage.get(providerId); return credential?.type === "api_key" || credential?.type === "oauth" diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index bc44a6e135..73b9186fa4 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1177,8 +1177,13 @@ export function isRetryableModelSelectionError(message: string): boolean { if (isProviderModelNotFoundError(message)) { return true; } + /* + * FNXC:ModelFallback 2026-07-16-11:00: + * A provider whose credential fails to resolve at prompt time surfaces from pi-ai as `Provider is not configured: ` (ModelsError code "auth"). This is a provider/credential-availability problem a configured fallback model on a DIFFERENT provider can recover from, so treat it as retryable and enter the single-swap fallback path. Previously this string matched none of the substrings below, so a mis-resolved primary provider hard-failed the task instead of falling back. The `usingFallback` guard upstream keeps it to one swap. + */ const normalized = message.toLowerCase(); - return normalized.includes("rate limit") + return normalized.includes("not configured") + || normalized.includes("rate limit") || normalized.includes("too many requests") || normalized.includes("429") || normalized.includes("401")