FN-9101: normalize Anthropic auth provider selections

Route Anthropic subscription and API-key auth selections through the built-in execution provider.

- Add a shared provider-ID normalization helper and export it from core.
- Normalize persisted model selections during session creation and registry lookup.
- Hide credential-only Anthropic provider rows from the dashboard model catalog.
- Add regression coverage and a patch changeset for subscription-backed execution.

Files changed:
 ...9101-anthropic-subscription-model-resolution.md |  7 ++++
 .../__tests__/anthropic-execution-provider.test.ts | 18 ++++++++
 packages/core/src/ai/anthropic-models.ts           | 13 ++++++
 packages/core/src/index.gate.ts                    |  2 +
 packages/core/src/index.ts                         |  2 +
 .../dashboard/src/__tests__/routes-auth.test.ts    |  2 +
 .../dashboard/src/routes/register-model-routes.ts  | 14 ++++---
 ...-session-helpers-anthropic-subscription.test.ts | 49 ++++++++++++++++++++++
 .../src/__tests__/pi-create-fn-agent.test.ts       | 25 +++++++++++
 .../engine/src/agents/agent-session-helpers.ts     | 26 ++++++++----
 packages/engine/src/pi.ts                          | 14 +++++--
 11 files changed, 154 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-9101

Fusion-Task-Lineage: 72e70e67-b291-44fc-ba3f-12b54d06eba9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 14:19:23 -07:00
parent 7ed1c39a67
commit 6401fdea89
11 changed files with 154 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Claude subscription model resolution failing with unknown provider anthropic-subscription.
category: fix
dev: Normalize auth-surface ids anthropic-subscription/anthropic-api-key to execution provider anthropic at model resolution seams; keep subscription OAuth credentials and auth cards on anthropic-subscription.

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import {
ANTHROPIC_API_KEY_PROVIDER_ID,
ANTHROPIC_PROVIDER_ID,
ANTHROPIC_SUBSCRIPTION_PROVIDER_ID,
toExecutionModelProviderId,
} from "../index.js";
describe("toExecutionModelProviderId", () => {
it("maps Anthropic auth-surface ids to the direct execution provider", () => {
expect(toExecutionModelProviderId(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)).toBe(ANTHROPIC_PROVIDER_ID);
expect(toExecutionModelProviderId(ANTHROPIC_API_KEY_PROVIDER_ID)).toBe(ANTHROPIC_PROVIDER_ID);
});
it.each([ANTHROPIC_PROVIDER_ID, "pi-claude-cli", "custom-provider"])("preserves %s", (providerId) => {
expect(toExecutionModelProviderId(providerId)).toBe(providerId);
});
});

View File

@@ -1,8 +1,21 @@
import { ANTHROPIC_SUBSCRIPTION_PROVIDER_ID } from "../provider-instance.js";
type AnthropicModelInput = "text" | "image";
export const ANTHROPIC_PROVIDER_ID = "anthropic";
export const ANTHROPIC_API_KEY_PROVIDER_ID = "anthropic-api-key";
export const CLAUDE_SONNET_5_MODEL_ID = "claude-sonnet-5";
/*
FNXC:ProviderAuth 2026-08-15-20:57:
Anthropic's subscription and API-key card ids identify credential/auth surfaces, not pi-ai providers. Normalize stale persisted selections before model lookup or runtime session creation so subscription OAuth continues to execute through pi-ai's built-in `anthropic` provider without registering a fake provider.
*/
export function toExecutionModelProviderId(providerId: string): string {
return providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID || providerId === ANTHROPIC_API_KEY_PROVIDER_ID
? ANTHROPIC_PROVIDER_ID
: providerId;
}
interface AnthropicModelRegistration {
id: string;
name: string;

View File

@@ -62,9 +62,11 @@ export type {
export { customProviderRegistryKey } from "./ai/custom-provider-key.js";
export {
ANTHROPIC_PROVIDER_ID,
ANTHROPIC_API_KEY_PROVIDER_ID,
CLAUDE_SONNET_5_MODEL_ID,
SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION,
mergeSupplementalAnthropicModels,
toExecutionModelProviderId,
} from "./ai/anthropic-models.js";
export type { AnthropicProviderRegistration } from "./ai/anthropic-models.js";
export {

View File

@@ -73,9 +73,11 @@ export type {
export { customProviderRegistryKey } from "./ai/custom-provider-key.js";
export {
ANTHROPIC_PROVIDER_ID,
ANTHROPIC_API_KEY_PROVIDER_ID,
CLAUDE_SONNET_5_MODEL_ID,
SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION,
mergeSupplementalAnthropicModels,
toExecutionModelProviderId,
} from "./ai/anthropic-models.js";
export type { AnthropicProviderRegistration } from "./ai/anthropic-models.js";
export {

View File

@@ -577,6 +577,7 @@ describe("GET /models", () => {
getAvailable: vi.fn().mockReturnValue([
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 OAuth", provider: "anthropic-subscription", reasoning: true, contextWindow: 200000 },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 API Key", provider: "anthropic-api-key", reasoning: true, contextWindow: 200000 },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (CLI)", provider: "pi-claude-cli", reasoning: true, contextWindow: 200000 },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", provider: "pi-claude-cli", reasoning: true, contextWindow: 1_000_000 },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5 Duplicate (CLI)", provider: "pi-claude-cli", reasoning: true, contextWindow: 1_000_000 },
@@ -658,6 +659,7 @@ describe("GET /models", () => {
expect(providers).toContain("anthropic");
expect(providers).toContain("pi-claude-cli");
expect(providers).not.toContain("anthropic-subscription");
expect(providers).not.toContain("anthropic-api-key");
const cliSonnetFiveRows = res.body.models.filter((m: { provider: string; id: string }) => m.provider === "pi-claude-cli" && m.id === "claude-sonnet-5");
expect(cliSonnetFiveRows).toHaveLength(1);
});

View File

@@ -1,7 +1,7 @@
import { access, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { customProviderRegistryKey, mergeSupplementalAnthropicModels, mergeSupplementalOpenAiCodexModels, resolvePlanningSettingsModel } from "@fusion/core";
import { customProviderRegistryKey, mergeSupplementalAnthropicModels, mergeSupplementalOpenAiCodexModels, resolvePlanningSettingsModel, toExecutionModelProviderId, ANTHROPIC_API_KEY_PROVIDER_ID, ANTHROPIC_PROVIDER_ID, ANTHROPIC_SUBSCRIPTION_PROVIDER_ID } from "@fusion/core";
import type { CustomProvider } from "@fusion/core";
import { ApiError } from "../api-error.js";
import { getCursorPickerModels, CURSOR_PICKER_PROVIDER_ID } from "../cursor-model-cache.js";
@@ -13,10 +13,6 @@ import { refreshModelRegistryForRequest } from "../model-registry-refresh-cache.
import type { AuthStorageLike } from "../routes.js";
import type { ApiRouteRegistrar } from "./types.js";
const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_API_KEY_PROVIDER_ID = "anthropic-api-key";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
/**
* Read provider names from Fusion's own auth stores (primary + legacy .pi).
* These represent providers the user has explicitly configured in Fusion,
@@ -34,7 +30,7 @@ function isRawAnthropicApiKeyCredential(credential: unknown): boolean {
}
function toModelProviderId(providerId: string): string {
return providerId === ANTHROPIC_API_KEY_PROVIDER_ID ? ANTHROPIC_PROVIDER_ID : providerId;
return toExecutionModelProviderId(providerId);
}
function addAuthStorageConfiguredProviders(authStorage: AuthStorageLike | undefined, providers: Set<string>): void {
@@ -360,6 +356,12 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
contextWindow: m.contextWindow,
}));
/*
FNXC:ProviderAuth 2026-08-15-20:57:
A registry/plugin may emit a credential-card row despite Fusion never registering it as an execution provider. Drop Anthropic auth ids rather than normalizing catalog rows: only the built-in `anthropic` row is selectable and can safely reach pi-ai.
*/
models = models.filter((model) => model.provider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID && model.provider !== ANTHROPIC_API_KEY_PROVIDER_ID);
/*
* FNXC:ModelCatalog 2026-07-01-12:02:
* Model visibility is provider-surface-specific: Claude CLI can advertise its own `pi-claude-cli/claude-sonnet-5` row while direct Anthropic must only show Sonnet 5 when the upstream registry returns it. Dedupe after refresh/supplemental merges so overlapping live and supplemental catalogs expose one selectable row without reintroducing static direct-Anthropic advertisement.

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { resolveRuntimeMock } = vi.hoisted(() => ({ resolveRuntimeMock: vi.fn() }));
vi.mock("../execution/runtime-resolution.js", async () => {
const actual = await vi.importActual<typeof import("../execution/runtime-resolution.js")>("../execution/runtime-resolution.js");
return { ...actual, resolveRuntime: resolveRuntimeMock };
});
import { createResolvedAgentSession } from "../agents/agent-session-helpers.js";
describe("createResolvedAgentSession Anthropic auth-id normalization", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes direct Anthropic ids to credential resolution and runtime creation", async () => {
const createSession = vi.fn().mockResolvedValue({ session: { prompt: vi.fn() } });
resolveRuntimeMock.mockResolvedValue({
runtime: { id: "pi", createSession, promptWithFallback: vi.fn(), describeModel: vi.fn() },
runtimeId: "pi",
wasConfigured: false,
});
const getDefaultInstance = vi.fn().mockReturnValue({ providerId: "anthropic", instanceId: "subscription" });
const authStorage = {
getInstance: vi.fn(),
getDefaultInstance,
listInstances: vi.fn().mockReturnValue([]),
} as any;
await createResolvedAgentSession({
sessionPurpose: "executor",
cwd: "/tmp/project",
systemPrompt: "system",
defaultProvider: "anthropic-subscription",
defaultModelId: "claude-opus-4-8",
fallbackProvider: "anthropic-api-key",
fallbackModelId: "claude-sonnet-4-5",
credentialInstanceId: "missing",
authStorage,
});
expect(getDefaultInstance).toHaveBeenCalledWith("anthropic");
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
defaultProvider: "anthropic",
fallbackProvider: "anthropic",
}));
});
});

View File

@@ -2390,6 +2390,31 @@ describe("createFnAgent", () => {
}));
});
it("self-heals stale subscription auth-id selections onto the direct Anthropic provider", async () => {
authStorageGetMock.mockImplementation((provider: string) => provider === "anthropic-subscription"
? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 }
: undefined);
authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription");
getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8" }]);
findMock.mockImplementation((provider: string, modelId: string) => provider === "anthropic"
? { provider, id: modelId }
: undefined);
const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "anthropic-subscription",
defaultModelId: "claude-opus-4-8",
})).resolves.toBeDefined();
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
model: { provider: "anthropic", id: "claude-opus-4-8" },
}));
expect(registerProviderMock).not.toHaveBeenCalledWith("anthropic-subscription", expect.anything());
});
it("keeps explicit Claude CLI selections on the Claude CLI provider", async () => {
authStorageGetApiKeyMock.mockResolvedValue(undefined);
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));

View File

@@ -27,6 +27,7 @@ import {
resolveTaskPlanningModel,
resolveTaskValidatorModel,
TEST_MODE_RESOLVED,
toExecutionModelProviderId,
type ResolvedModelSelection,
type Settings,
type ThinkingLevel,
@@ -783,11 +784,20 @@ export async function createResolvedAgentSession(
options: ResolvedSessionOptions,
): Promise<ResolvedSessionResult> {
const { sessionPurpose, pluginRunner, runtimeHint, runAuditor, settings, authStorage: injectedAuthStorage, credentialInstanceId: requestedCredentialInstanceId, ...runtimeOptionsRaw } = options;
/*
FNXC:ProviderAuth 2026-08-15-20:57:
This shared session seam receives persisted task, lane, and agent selections from every runtime. Convert Anthropic auth-card ids before credential-instance lookup and runtime creation, so pi-ai receives only `anthropic` while Fusion auth storage still resolves its subscription material through that execution id.
*/
const executionRuntimeOptions = {
...runtimeOptionsRaw,
...(runtimeOptionsRaw.defaultProvider ? { defaultProvider: toExecutionModelProviderId(runtimeOptionsRaw.defaultProvider) } : {}),
...(runtimeOptionsRaw.fallbackProvider ? { fallbackProvider: toExecutionModelProviderId(runtimeOptionsRaw.fallbackProvider) } : {}),
};
let credentialResolution: ReturnType<typeof resolveCredentialInstanceRef> | undefined;
if (requestedCredentialInstanceId) {
try {
const storage = injectedAuthStorage ?? createFusionAuthStorage();
credentialResolution = resolveCredentialInstanceRef(storage, runtimeOptionsRaw.defaultProvider ?? "", requestedCredentialInstanceId);
credentialResolution = resolveCredentialInstanceRef(storage, executionRuntimeOptions.defaultProvider ?? "", requestedCredentialInstanceId);
} catch (error) {
/*
FNXC:ProviderAuth 2026-08-03-17:35:
@@ -800,7 +810,7 @@ export async function createResolvedAgentSession(
*/
if ((error as Error).name === "CredentialInstanceResolutionError") {
sessionLog.warn(
`[${sessionPurpose}] credential instance "${requestedCredentialInstanceId}" for provider "${runtimeOptionsRaw.defaultProvider ?? ""}" unresolved; continuing with legacy provider auth (custom provider apiKey / unscoped default)`,
`[${sessionPurpose}] credential instance "${requestedCredentialInstanceId}" for provider "${executionRuntimeOptions.defaultProvider ?? ""}" unresolved; continuing with legacy provider auth (custom provider apiKey / unscoped default)`,
);
} else {
sessionLog.warn(`[${sessionPurpose}] credential instance resolution unavailable; using provider default`);
@@ -823,9 +833,9 @@ export async function createResolvedAgentSession(
);
}
const skillNamesFromSelection = extractSkillNamesFromSelection(runtimeOptionsRaw.skillSelection);
const mergedSkillNames = runtimeOptionsRaw.skills && runtimeOptionsRaw.skills.length > 0
? runtimeOptionsRaw.skills
const skillNamesFromSelection = extractSkillNamesFromSelection(executionRuntimeOptions.skillSelection);
const mergedSkillNames = executionRuntimeOptions.skills && executionRuntimeOptions.skills.length > 0
? executionRuntimeOptions.skills
: skillNamesFromSelection;
/*
@@ -840,9 +850,9 @@ export async function createResolvedAgentSession(
the finite default, custom cap, or explicit no-limit sentinel interpretation.
*/
const runtimeOptions: AgentRuntimeOptions = {
...runtimeOptionsRaw,
toolOutputMaxChars: runtimeOptionsRaw.toolOutputMaxChars !== undefined
? runtimeOptionsRaw.toolOutputMaxChars
...executionRuntimeOptions,
toolOutputMaxChars: executionRuntimeOptions.toolOutputMaxChars !== undefined
? executionRuntimeOptions.toolOutputMaxChars
: resolveAgentToolOutputMaxChars(settings ?? {}),
...(mergedSkillNames.length > 0 ? { skills: mergedSkillNames } : {}),
...(credentialResolution ? {

View File

@@ -49,6 +49,7 @@ import {
mergeBuiltInZaiProviderModels,
mergeSupplementalAnthropicModels,
mergeSupplementalOpenAiCodexModels,
toExecutionModelProviderId,
registerBuiltInGrokProvider,
registerBuiltInZaiProvider,
registerFusionSessionIdentity,
@@ -1167,7 +1168,12 @@ function resolveConfiguredModel(
return undefined;
}
const model = modelRegistry.find(provider, modelId);
/*
FNXC:ProviderAuth 2026-08-15-20:57:
Persisted model settings from the split Anthropic authentication cards may name an auth id. pi-ai only knows the direct execution provider, so normalize before registry lookup and template fallback; never register the auth id as a provider.
*/
const executionProvider = toExecutionModelProviderId(provider);
const model = modelRegistry.find(executionProvider, modelId);
if (model) {
return model;
}
@@ -1176,15 +1182,15 @@ function resolveConfiguredModel(
// This mirrors the pi CLI's buildFallbackModel behaviour, which accepts any
// model ID for a configured provider (e.g. any OpenRouter model string) even
// when it isn't in the built-in or custom model list.
const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider);
const providerModels = modelRegistry.getAll().filter((m) => m.provider === executionProvider);
if (providerModels.length > 0) {
const baseModel = providerModels[0]!;
piLog.warn(`${kind} model ${provider}/${modelId} not in registry; using provider base model as template`);
piLog.warn(`${kind} model ${executionProvider}/${modelId} not in registry; using provider base model as template`);
return { ...baseModel, id: modelId, name: modelId };
}
throw new Error(
`Configured model ${provider}/${modelId} (${kind} selection) was not found in the pi model registry. `
`Configured model ${executionProvider}/${modelId} (${kind} selection) was not found in the pi model registry. `
+ "If this model comes from a custom provider, verify Settings → Custom Providers (stored in ~/.fusion/settings.json) includes this provider/model, "
+ "or choose an available model from /api/models.",
);