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:
@@ -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.
|
||||||
@@ -3,7 +3,7 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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 {
|
function encodeBase64Url(value: string): string {
|
||||||
return Buffer.from(value, "utf-8").toString("base64url");
|
return Buffer.from(value, "utf-8").toString("base64url");
|
||||||
@@ -218,6 +218,65 @@ describe("createFusionAuthStorage", () => {
|
|||||||
expect(authStorage.hasAuth("anthropic")).toBe(true);
|
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 () => {
|
it("uses Anthropic subscription OAuth for direct model runtime auth when no raw API key exists", async () => {
|
||||||
writeFusionAuth(homeDir, {
|
writeFusionAuth(homeDir, {
|
||||||
"anthropic-subscription": {
|
"anthropic-subscription": {
|
||||||
|
|||||||
@@ -1483,6 +1483,12 @@ describe("isRetryableModelSelectionError", () => {
|
|||||||
expect(isRetryableModelSelectionError("model is overloaded")).toBe(true);
|
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", () => {
|
it("does not match unrelated errors", () => {
|
||||||
expect(isRetryableModelSelectionError("ENOENT: no such file or directory")).toBe(false);
|
expect(isRetryableModelSelectionError("ENOENT: no such file or directory")).toBe(false);
|
||||||
expect(isRetryableModelSelectionError("syntax error near unexpected token")).toBe(false);
|
expect(isRetryableModelSelectionError("syntax error near unexpected token")).toBe(false);
|
||||||
|
|||||||
@@ -144,9 +144,19 @@ class FusionFileAuthStorage implements FusionAuthStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFusionCredentialStore(authStorage: FusionAuthStorage): CredentialStore {
|
export function createFusionCredentialStore(authStorage: FusionAuthStorage): CredentialStore {
|
||||||
return {
|
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[] => {
|
list: async () => authStorage.list().flatMap((providerId): CredentialInfo[] => {
|
||||||
const credential = authStorage.get(providerId);
|
const credential = authStorage.get(providerId);
|
||||||
return credential?.type === "api_key" || credential?.type === "oauth"
|
return credential?.type === "api_key" || credential?.type === "oauth"
|
||||||
|
|||||||
@@ -1177,8 +1177,13 @@ export function isRetryableModelSelectionError(message: string): boolean {
|
|||||||
if (isProviderModelNotFoundError(message)) {
|
if (isProviderModelNotFoundError(message)) {
|
||||||
return true;
|
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();
|
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("too many requests")
|
||||||
|| normalized.includes("429")
|
|| normalized.includes("429")
|
||||||
|| normalized.includes("401")
|
|| normalized.includes("401")
|
||||||
|
|||||||
Reference in New Issue
Block a user