fix: resolve Anthropic subscription auth under pi-ai >=0.80 read() contract

pi-ai >=0.80 resolves provider auth via credentials.read(provider.id) and
performs OAuth refresh/derivation itself, bypassing fusion's getApiKey()
where the anthropic-subscription -> anthropic alias lived. A subscription-only
login surfaced as "Provider is not configured: anthropic" at prompt time even
though the status card showed connected.

- Alias the subscription OAuth credential into read("anthropic") at the
  credential-store layer (createFusionCredentialStore); raw/legacy rows still win.
- Match "not configured" in isRetryableModelSelectionError so an unresolved
  provider triggers the configured fallback model instead of hard-failing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-16 23:04:42 -07:00
parent 00cca460fc
commit 4b150e2280
5 changed files with 91 additions and 4 deletions

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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);

View File

@@ -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"

View File

@@ -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: <provider>` (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")