diff --git a/.changeset/fn-9101-anthropic-subscription-model-resolution.md b/.changeset/fn-9101-anthropic-subscription-model-resolution.md new file mode 100644 index 0000000000..565ee6434a --- /dev/null +++ b/.changeset/fn-9101-anthropic-subscription-model-resolution.md @@ -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. diff --git a/packages/core/src/__tests__/anthropic-execution-provider.test.ts b/packages/core/src/__tests__/anthropic-execution-provider.test.ts new file mode 100644 index 0000000000..87e79fa8e5 --- /dev/null +++ b/packages/core/src/__tests__/anthropic-execution-provider.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/ai/anthropic-models.ts b/packages/core/src/ai/anthropic-models.ts index 22c4ab0d13..aa63b96923 100644 --- a/packages/core/src/ai/anthropic-models.ts +++ b/packages/core/src/ai/anthropic-models.ts @@ -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; diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index a30d90f15c..518c8864e7 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -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 { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3c810ba8b..7f3dcadf04 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 067beede5b..e23d44bf7e 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -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); }); diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index d20178152f..c11b46ecd6 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -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): 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. diff --git a/packages/engine/src/__tests__/agent-session-helpers-anthropic-subscription.test.ts b/packages/engine/src/__tests__/agent-session-helpers-anthropic-subscription.test.ts new file mode 100644 index 0000000000..492895bea0 --- /dev/null +++ b/packages/engine/src/__tests__/agent-session-helpers-anthropic-subscription.test.ts @@ -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("../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", + })); + }); +}); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 5609522c5c..45cd360749 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -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 })); diff --git a/packages/engine/src/agents/agent-session-helpers.ts b/packages/engine/src/agents/agent-session-helpers.ts index 69a5621ab2..097fe63114 100644 --- a/packages/engine/src/agents/agent-session-helpers.ts +++ b/packages/engine/src/agents/agent-session-helpers.ts @@ -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 { 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 | 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 ? { diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 7d6ba36f9e..e31b426425 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -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.", );