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:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user