diff --git a/.changeset/fix-anthropic-subscription-provider-not-configured.md b/.changeset/fix-anthropic-subscription-provider-not-configured.md index 3a4b2f14c7..27b6e5b3fd 100644 --- a/.changeset/fix-anthropic-subscription-provider-not-configured.md +++ b/.changeset/fix-anthropic-subscription-provider-not-configured.md @@ -4,4 +4,4 @@ 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. +dev: pi >=0.80.8 moved session auth from `ModelRegistry.getApiKeyAndHeaders` (fusion's `getApiKey`) to `ModelRuntime.getAuth` -> pi-ai `resolveProviderAuth`, which reads `credentials.read("anthropic")` and refreshes OAuth via `credentials.modify("anthropic")`. Fusion stores the subscription login under `anthropic-subscription` (no raw `anthropic` row), so the refresh saw `current === undefined` and auth resolved to undefined. Fix: `createFusionCredentialStore.read("anthropic")` now resolves through `getApiKey("anthropic")` (handles refresh + raw/legacy/subscription/fallback precedence) and returns a ready `api_key` credential; pi-ai routes it as OAuth by the `sk-ant-oat` token prefix. 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 b5164037c2..44237644ee 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -218,17 +218,19 @@ 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 () => { + // FNXC:ProviderAuth 2026-07-17-06:30 — Symptom Verification for the pi >=0.80.8 ModelRuntime.getAuth + // regression. Original symptom: a subscription-only Anthropic login (stored only under + // `anthropic-subscription`) failed every task with "Provider is not configured: anthropic" and then + // fell back, because pi-ai's resolveProviderAuth reads credentials.read("anthropic") directly and + // refreshes an OAuth credential via credentials.modify("anthropic") — but fusion has no raw + // `anthropic` row, so the refresh callback saw `current === undefined` and bailed. Fix: read("anthropic") + // resolves through fusion's getApiKey (refresh + subscription/raw precedence) and returns a ready + // api_key credential carrying the OAuth token (which pi-ai routes as OAuth by its sk-ant-oat prefix). + it("resolves Anthropic subscription auth into a ready api_key credential via read('anthropic')", async () => { writeFusionAuth(homeDir, { "anthropic-subscription": { type: "oauth", - access: "subscription-access-token", + access: "sk-ant-oat01-subscription-access-token", refresh: "subscription-refresh-token", expires: Date.now() + 3_600_000, }, @@ -237,15 +239,11 @@ describe("createFusionAuthStorage", () => { 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", + // pi-ai's resolveProviderAuth reads provider `anthropic` directly; it must get a usable credential + // whose token still carries the sk-ant-oat prefix so the anthropic-messages layer routes it as OAuth. + expect(await credentialStore.read("anthropic")).toEqual({ + type: "api_key", + key: "sk-ant-oat01-subscription-access-token", }); }); @@ -254,7 +252,7 @@ describe("createFusionAuthStorage", () => { anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" }, "anthropic-subscription": { type: "oauth", - access: "subscription-access-token", + access: "sk-ant-oat01-subscription-access-token", refresh: "subscription-refresh-token", expires: Date.now() + 3_600_000, }, @@ -263,7 +261,7 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); const credentialStore = createFusionCredentialStore(authStorage); - // Raw api_key wins; the subscription alias only fills the gap when no raw/legacy row exists. + // Raw api_key wins (getApiKey precedence); the subscription token only fills the gap otherwise. expect(await credentialStore.read("anthropic")).toEqual({ type: "api_key", key: "sk-ant-api03-runtime-key", diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index bf2451f4ed..0ed83e7c61 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -147,15 +147,15 @@ class FusionFileAuthStorage implements FusionAuthStorage { export function createFusionCredentialStore(authStorage: FusionAuthStorage): CredentialStore { return { /* - 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. + FNXC:ProviderAuth 2026-07-17-06:30: + pi >=0.80.8 moved session request auth from `ModelRegistry.getApiKeyAndHeaders` (which called fusion's `getApiKey(provider)`) to `ModelRuntime.getAuth` -> pi-ai `resolveProviderAuth`, which reads the credential store directly (`credentials.read(provider.id)`) and, for an OAuth credential, refreshes it ITSELF via `credentials.modify(provider.id, ...)`. That refresh path is broken for Anthropic: fusion persists the subscription login under `anthropic-subscription` (there is NO raw `anthropic` row), so `modify("anthropic")` reads `current === undefined`, the refresh callback bails, and `resolveStoredOAuth` returns undefined -> the task fails with `Provider is not configured: anthropic` (then falls back). The status card still shows "connected" because status uses a different path (hasVisibleAnthropicCredential). Fix: resolve Anthropic auth through fusion's `getApiKey("anthropic")`, the battle-tested path that already handles token refresh + the raw-key/legacy-oauth/subscription/fallback precedence (see resolveAnthropicRuntimeApiKey), and hand pi-ai a ready-to-use api_key credential. pi-ai's anthropic-messages layer routes by token prefix — `sk-ant-oat*` -> OAuth Bearer + Claude Code identity headers, otherwise x-api-key — so a subscription OAuth token still runs as OAuth, and returning it as `api_key` deliberately bypasses pi-ai's own (broken-for-us) OAuth refresh-via-modify. Other OAuth providers (openai-codex, github-copilot) are stored under their own provider id, so read/modify share an id and pi-ai's refresh works — only Anthropic needs this indirection. */ 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; + if (providerId === ANTHROPIC_PROVIDER_ID) { + const token = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID); + return token ? ({ type: "api_key", key: token } as Credential) : undefined; } - return credential; + return authStorage.get(providerId) as Credential | undefined; }, list: async () => authStorage.list().flatMap((providerId): CredentialInfo[] => { const credential = authStorage.get(providerId);