fix: route Anthropic subscription instance login through upstream anthropic provider

The Authentication cards pass an explicit credential-instance id, which sent
dashboard subscription logins through loginInstance. That seam mapped the card
to the anthropic-subscription storage row id and passed it verbatim to
ModelRuntime.login, which pi rejects with 'Unknown provider:
anthropic-subscription' (GitHub #3462) — every subscription login failed while
the credential path itself was healthy. loginInstance now reuses the
Anthropic-aware login seam (upstream login as 'anthropic', credential relocated
to the anthropic-subscription row) with a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-15 14:48:07 -07:00
parent 38d128ca29
commit 7fa5029ef8
3 changed files with 73 additions and 14 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Anthropic Subscription login failing with "Unknown provider: anthropic-subscription".
category: fix
dev: Instance-scoped OAuth login (`loginInstance`) now reuses the Anthropic-aware login seam, logging in upstream as `anthropic` and persisting to the `anthropic-subscription` storage row, instead of passing the storage-only id to `ModelRuntime.login` (GitHub #3462).

View File

@@ -62,4 +62,37 @@ describe("DashboardAuthStorage instance facade", () => {
expect(credentials.has("default")).toBe(false);
expect(defaultId).toBe("acct-first");
});
/*
FNXC:ProviderAuth 2026-08-15-21:46:
Regression for GitHub #3462: instance-scoped Anthropic subscription login must reach the
upstream runtime as `anthropic` and persist the OAuth result under the `anthropic-subscription`
storage row. Passing the storage row id to the runtime login fails with
`Unknown provider: anthropic-subscription` because pi never registers that id as a provider.
*/
it("routes Anthropic subscription instance login through the upstream anthropic provider", async () => {
const rows = new Map<string, { type: string; key?: string; expires?: number }>();
const storage = {
...storageFixture(),
login: vi.fn(async (providerId: string) => {
rows.set(providerId, { type: "oauth", expires: Date.now() + 60_000 });
}),
get: vi.fn((providerId: string) => rows.get(providerId)),
set: vi.fn(async (providerId: string, credential: { type: string }) => {
rows.set(providerId, credential as never);
}),
remove: vi.fn(async (providerId: string) => { rows.delete(providerId); }),
getDefaultInstance: vi.fn(() => undefined),
getInstance: vi.fn(() => undefined),
setInstance: vi.fn(),
};
const facade = wrapAuthStorageWithApiKeyProviders(storage as unknown as FusionAuthStorage, {} as ModelRegistry);
await facade.loginInstance?.({ providerId: "anthropic", instanceId: "default" }, {} as never);
expect(storage.login).toHaveBeenCalledTimes(1);
expect(storage.login.mock.calls[0]?.[0]).toBe("anthropic");
expect(storage.setInstance).toHaveBeenCalledWith(
{ providerId: "anthropic-subscription", instanceId: "default" },
expect.objectContaining({ type: "oauth" }),
);
});
});

View File

@@ -142,18 +142,16 @@ export function wrapAuthStorageWithApiKeyProviders(
return legacySubscription;
};
return {
reload: () => mergedAuthStorage.reload(),
getOAuthProviders: () =>
mergedAuthStorage
.getOAuthProviders()
.map((provider) => provider.id === ANTHROPIC_STORAGE_PROVIDER_ID
? ({ id: ANTHROPIC_STORAGE_PROVIDER_ID, name: "Anthropic Subscription" })
: ({ id: provider.id, name: provider.name })),
hasAuth: (provider) => provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID
? Boolean(getAnthropicSubscriptionCredential())
: mergedAuthStorage.hasAuth(provider),
login: async (providerId, callbacks) => {
/*
FNXC:ProviderAuth 2026-08-15-21:46:
This is the ONLY seam allowed to start an OAuth login: it maps the split Anthropic ids
(`anthropic` / `anthropic-subscription`) onto the upstream `anthropic` runtime login before
relocating the credential to the subscription storage row. Instance-scoped login must reuse it —
calling `mergedAuthStorage.login("anthropic-subscription", ...)` directly reaches
`ModelRuntime.login` with a storage-only id pi does not register and fails with
`Unknown provider: anthropic-subscription` (GitHub #3462).
*/
const login = async (providerId: string, callbacks: LoginCallbacks): Promise<void> => {
if (providerId !== ANTHROPIC_STORAGE_PROVIDER_ID && providerId !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
await mergedAuthStorage.login(
providerId,
@@ -181,7 +179,20 @@ export function wrapAuthStorageWithApiKeyProviders(
await authStorage.remove(ANTHROPIC_STORAGE_PROVIDER_ID);
}
}
},
};
return {
reload: () => mergedAuthStorage.reload(),
getOAuthProviders: () =>
mergedAuthStorage
.getOAuthProviders()
.map((provider) => provider.id === ANTHROPIC_STORAGE_PROVIDER_ID
? ({ id: ANTHROPIC_STORAGE_PROVIDER_ID, name: "Anthropic Subscription" })
: ({ id: provider.id, name: provider.name })),
hasAuth: (provider) => provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID
? Boolean(getAnthropicSubscriptionCredential())
: mergedAuthStorage.hasAuth(provider),
login,
logout: async (provider) => {
if (provider !== ANTHROPIC_STORAGE_PROVIDER_ID && provider !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
await mergedAuthStorage.logout(provider);
@@ -294,8 +305,16 @@ export function wrapAuthStorageWithApiKeyProviders(
The runtime OAuth adapter only accepts a bare provider and therefore writes its resolved
default slot. Capture and restore that slot around the login before persisting the result to
the requested instance, so adding or reauthorizing an account never repoints its credential.
FNXC:ProviderAuth 2026-08-15-21:46:
Instance login must go through the Anthropic-aware `login` seam above, never raw
`mergedAuthStorage.login(providerId, ...)`: for the subscription card `providerId` here is the
storage row id `anthropic-subscription`, which pi's ModelRuntime does not register as a
provider, so the raw call failed every dashboard subscription login with
`Unknown provider: anthropic-subscription` (GitHub #3462) once the Authentication cards began
passing an explicit credential-instance id.
*/
await mergedAuthStorage.login(providerId, callbacks);
await login(providerId, callbacks);
const credential = mergedAuthStorage.get(providerId);
if (!credential) return;
await mergedAuthStorage.setInstance(target, { ...credential, ...(label ? { label } : {}) });