From 9c2a264322923bc276e65f714aa4a5fc213398bb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 14:00:53 -0700 Subject: [PATCH] FN-7398: restore Anthropic model selection Restore Anthropic model selector discovery across API-key, OAuth subscription, and Claude CLI flows. - Read injected auth storage when building /api/models so Settings connection state feeds model availability. - Keep Anthropic subscription OAuth separate from direct Anthropic API-key rows while allowing enabled Claude CLI models to appear. - Add regression coverage for selector rendering, stale empty catalog refresh, and authenticated model-route filtering. - Document the restored Anthropic provider surfaces and add a patch changeset. Files changed: .changeset/fn-7398-anthropic-model-selector.md | 7 ++ docs/settings-reference.md | 4 +- .../components/__tests__/ModelSelectorTab.test.tsx | 105 +++++++++++++++++ .../app/hooks/__tests__/useModelsCache.test.ts | 40 +++++++ .../dashboard/src/__tests__/routes-auth.test.ts | 125 ++++++++++++++++++++- .../dashboard/src/routes/register-model-routes.ts | 60 +++++++++- 6 files changed, 333 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7398 Fusion-Task-Lineage: 5d1b1b8e-9b8b-40fe-b76f-7d6142e0e21f Co-authored-by: Fusion (runfusion.ai) --- .../fn-7398-anthropic-model-selector.md | 7 + docs/settings-reference.md | 4 +- .../__tests__/ModelSelectorTab.test.tsx | 105 +++++++++++++++ .../hooks/__tests__/useModelsCache.test.ts | 40 ++++++ .../src/__tests__/routes-auth.test.ts | 125 +++++++++++++++++- .../src/routes/register-model-routes.ts | 60 ++++++++- 6 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 .changeset/fn-7398-anthropic-model-selector.md create mode 100644 packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx diff --git a/.changeset/fn-7398-anthropic-model-selector.md b/.changeset/fn-7398-anthropic-model-selector.md new file mode 100644 index 0000000000..6cac1b6861 --- /dev/null +++ b/.changeset/fn-7398-anthropic-model-selector.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show Claude CLI models when Anthropic subscription OAuth and Claude CLI are connected. +category: fix +dev: Keeps subscription OAuth on anthropic-subscription while direct anthropic remains raw API-key-only. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index e33e0bf554..90a300af8a 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -53,7 +53,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). | | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | | `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. | -| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three distinct surfaces: direct `anthropic` uses raw API-key material only (`ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential); subscription OAuth uses the dedicated `anthropic-subscription` auth/status/usage/banner and direct execution path; Claude CLI execution uses the explicit `pi-claude-cli` model provider. OAuth-backed execution never stores or resolves subscription tokens as raw `ANTHROPIC_API_KEY` material. | +| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three distinct surfaces: direct `anthropic` uses raw API-key material only (`ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential); subscription OAuth uses the dedicated `anthropic-subscription` auth/status/usage/banner surface and does not create direct `anthropic/*` selector rows; Claude CLI execution uses the explicit `pi-claude-cli` model provider. Connected subscription OAuth plus an enabled Claude CLI exposes selectable `pi-claude-cli/*` rows, while OAuth-backed execution never stores or resolves subscription tokens as raw `ANTHROPIC_API_KEY` material. | | `defaultModelId` | `string` | `undefined` | Default AI model ID. | | `modelPricingOverrides` | `Record` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. | | `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing summary. | @@ -731,7 +731,7 @@ Manual re-login is still required when no refresh token is stored, the refresh r Anthropic has three independent authentication/routing paths: - **Anthropic Subscription** (`anthropic-subscription`) is Claude subscription OAuth. It powers login/logout, `/api/auth/status`, usage/subscription checks through `https://api.anthropic.com/api/oauth/usage`, and the OAuth re-login banner. Legacy `anthropic` OAuth rows are treated as this subscription surface. -- **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. +- **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. When subscription OAuth is connected and Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) instead of treating OAuth as direct `anthropic/*` API-key auth. - **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth only. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential and is the only path used for `https://api.anthropic.com/v1` requests. Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. `/api/auth/status` returns only masked key hints for the API-key card. diff --git a/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx b/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx new file mode 100644 index 0000000000..eb34d0c1b5 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TaskDetail } from "@fusion/core"; +import { SWR_CACHE_KEYS } from "../../utils/swrCache"; +import { ModelSelectorTab } from "../ModelSelectorTab"; + +vi.mock("../../api", () => ({ + fetchModels: vi.fn(), + updateTask: vi.fn(), + updateGlobalSettings: vi.fn(), +})); + +const { fetchModels, updateTask } = await import("../../api"); +const mockFetchModels = vi.mocked(fetchModels); +const mockUpdateTask = vi.mocked(updateTask); + +function makeTask(overrides: Partial = {}): TaskDetail { + return { + id: "FN-7398", + title: "Anthropic model selector", + description: "Verify Claude CLI model selection", + column: "todo", + steps: [], + dependencies: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as unknown as TaskDetail; +} + +describe("ModelSelectorTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.removeItem(SWR_CACHE_KEYS.MODELS); + mockFetchModels.mockResolvedValue({ + models: [ + { provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", reasoning: true, contextWindow: 1_000_000 }, + ], + favoriteProviders: [], + favoriteModels: [], + }); + }); + + it("renders and selects Anthropic Claude CLI rows from the shared model catalog", async () => { + const user = userEvent.setup(); + const addToast = vi.fn(); + const onTaskUpdated = vi.fn(); + const task = makeTask(); + mockUpdateTask.mockResolvedValueOnce({ + ...task, + modelProvider: "pi-claude-cli", + modelId: "claude-sonnet-5", + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.queryByText(/No models available/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Executor Model")).toBeInTheDocument(); + }); + + await user.click(screen.getByLabelText("Executor Model")); + + const listbox = await screen.findByRole("listbox"); + expect(within(listbox).getByText("pi-claude-cli")).toBeInTheDocument(); + await user.click(within(listbox).getByText("Claude Sonnet 5 (CLI)")); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", { + modelProvider: "pi-claude-cli", + modelId: "claude-sonnet-5", + }); + expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({ + modelProvider: "pi-claude-cli", + modelId: "claude-sonnet-5", + })); + }); + }); + + it("updates from a cached empty catalog to populated Claude CLI rows without remounting", async () => { + localStorage.setItem( + SWR_CACHE_KEYS.MODELS, + JSON.stringify({ + savedAt: Date.now(), + data: { models: [], favoriteProviders: [], favoriteModels: [] }, + }), + ); + + render(); + + expect(screen.getByText(/No models available/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText(/No models available/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText("Executor Model")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts b/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts index 88c2914466..dff711c885 100644 --- a/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useModelsCache.test.ts @@ -62,6 +62,46 @@ describe("useModelsCache", () => { expect(cached.data.models[0]?.id).toBe("gpt-4o"); }); + it("replaces a stale empty cached catalog for all mounted consumers", async () => { + localStorage.setItem( + SWR_CACHE_KEYS.MODELS, + JSON.stringify({ + savedAt: Date.now(), + data: { + models: [], + favoriteProviders: [], + favoriteModels: [], + }, + }), + ); + mockFetchModels.mockResolvedValueOnce({ + models: [{ provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)" }], + favoriteProviders: [], + favoriteModels: [], + }); + + const hookA = renderHook(() => useModelsCache()); + const hookB = renderHook(() => useModelsCache()); + + expect(hookA.result.current.loading).toBe(false); + expect(hookA.result.current.models).toEqual([]); + + await waitFor(() => { + expect(mockFetchModels).toHaveBeenCalledTimes(1); + expect(hookA.result.current.models).toEqual([ + expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }), + ]); + expect(hookB.result.current.models).toEqual([ + expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }), + ]); + }); + + const cached = JSON.parse(localStorage.getItem(SWR_CACHE_KEYS.MODELS) ?? "null") as { data: { models: Array<{ provider: string; id: string }> } }; + expect(cached.data.models).toEqual([ + expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }), + ]); + }); + it("deduplicates concurrent mounts", async () => { let resolveFetch: ((value: Awaited>) => void) | undefined; mockFetchModels.mockImplementationOnce(() => new Promise((resolve) => { diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index a9ef995bc3..43a7e86c85 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -350,10 +350,10 @@ describe("GET /models", () => { store = createMockStore(); }); - function buildApp(modelRegistry?: ModelRegistryLike) { + function buildApp(modelRegistry?: ModelRegistryLike, authStorage?: AuthStorageLike) { const app = express(); app.use(express.json()); - app.use("/api", createApiRoutes(store, { modelRegistry })); + app.use("/api", createApiRoutes(store, { modelRegistry, authStorage })); return app; } @@ -458,13 +458,34 @@ describe("GET /models", () => { // failure mode is silent and project-wide — keep these tests close to the // route so any future flip flips CI red immediately. describe("useClaudeCli filter", () => { - function buildAppWithSetting(useClaudeCli: boolean | undefined, modelRegistry: ModelRegistryLike) { + function buildAppWithSetting(useClaudeCli: boolean | undefined, modelRegistry: ModelRegistryLike, authStorage?: AuthStorageLike) { const globalStore = createMockGlobalSettingsStore(); (globalStore.getSettings as ReturnType).mockResolvedValue( useClaudeCli === undefined ? {} : { useClaudeCli }, ); (store.getGlobalSettingsStore as ReturnType).mockReturnValue(globalStore); - return buildApp(modelRegistry); + return buildApp(modelRegistry, authStorage); + } + + function registryWithAnthropicSurfaces(): ModelRegistryLike { + return createMockModelRegistry({ + 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 (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 }, + ]), + }); + } + + async function withNoFilesystemProviders(run: () => Promise) { + await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => { + const value = String(path); + if (value.endsWith("auth.json")) return "{}"; + if (value.endsWith("models.json")) return JSON.stringify({ providers: {} }); + return "{}"; + }, run); } function registryWithCli(): ModelRegistryLike { @@ -510,6 +531,102 @@ describe("GET /models", () => { })); }); + it("uses authStorage subscription OAuth plus Claude CLI to expose selectable CLI models only", async () => { + await withNoFilesystemProviders(async () => { + const authStorage = createMockAuthStorage({ + getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]), + hasAuth: vi.fn((provider: string) => provider === "anthropic-subscription"), + get: vi.fn((provider: string) => provider === "anthropic-subscription" ? { + type: "oauth", + access: "subscription-oauth", + refresh: "refresh", + expires: Date.now() + 60_000, + } : undefined), + }); + + const res = await GET(buildAppWithSetting(true, registryWithAnthropicSurfaces(), authStorage), "/api/models"); + + expect(res.status).toBe(200); + expect(res.body.models).not.toEqual([]); + const providers = res.body.models.map((m: { provider: string }) => m.provider); + expect(providers).toContain("pi-claude-cli"); + expect(providers).not.toContain("anthropic"); + expect(providers).not.toContain("anthropic-subscription"); + 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); + }); + }); + + it("keeps legacy Anthropic OAuth from exposing direct rows while Claude CLI remains selectable", async () => { + await withNoFilesystemProviders(async () => { + const authStorage = createMockAuthStorage({ + getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]), + hasAuth: vi.fn((provider: string) => provider === "anthropic"), + get: vi.fn((provider: string) => provider === "anthropic" ? { + type: "oauth", + access: "legacy-oauth", + refresh: "refresh", + expires: Date.now() + 60_000, + } : undefined), + }); + + const res = await GET(buildAppWithSetting(true, registryWithAnthropicSurfaces(), authStorage), "/api/models"); + + expect(res.status).toBe(200); + const providers = res.body.models.map((m: { provider: string }) => m.provider); + expect(providers).toContain("pi-claude-cli"); + expect(providers).not.toContain("anthropic"); + expect(providers).not.toContain("anthropic-subscription"); + expect(res.body.models).toEqual(expect.arrayContaining([ + expect.objectContaining({ provider: "pi-claude-cli", id: "claude-sonnet-5" }), + ])); + }); + }); + + it("uses authStorage raw Anthropic API key to expose direct rows without requiring OAuth", async () => { + await withNoFilesystemProviders(async () => { + const authStorage = createMockAuthStorage({ + getApiKeyProviders: vi.fn().mockReturnValue([{ id: "anthropic-api-key", name: "Anthropic API Key" }]), + hasApiKey: vi.fn((provider: string) => provider === "anthropic-api-key"), + get: vi.fn((provider: string) => provider === "anthropic-api-key" ? { + type: "api_key", + key: "sk-ant-api03-direct", + } : undefined), + }); + + const res = await GET(buildAppWithSetting(false, registryWithAnthropicSurfaces(), authStorage), "/api/models"); + + expect(res.status).toBe(200); + const providers = res.body.models.map((m: { provider: string }) => m.provider); + expect(providers).toContain("anthropic"); + expect(providers).not.toContain("pi-claude-cli"); + expect(providers).not.toContain("anthropic-subscription"); + }); + }); + + it("does not expose Anthropic rows for authStorage OAuth-only auth when Claude CLI is disabled", async () => { + await withNoFilesystemProviders(async () => { + const authStorage = createMockAuthStorage({ + getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]), + hasAuth: vi.fn((provider: string) => provider === "anthropic-subscription"), + get: vi.fn((provider: string) => provider === "anthropic-subscription" ? { + type: "oauth", + access: "subscription-oauth", + refresh: "refresh", + expires: Date.now() + 60_000, + } : undefined), + }); + + const res = await GET(buildAppWithSetting(false, registryWithAnthropicSurfaces(), authStorage), "/api/models"); + + expect(res.status).toBe(200); + const providers = res.body.models.map((m: { provider: string }) => m.provider); + expect(providers).not.toContain("anthropic"); + expect(providers).not.toContain("anthropic-subscription"); + expect(providers).not.toContain("pi-claude-cli"); + }); + }); + it("hides direct Anthropic rows for OAuth-only subscription auth while showing distinct Claude CLI rows", async () => { await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => { const value = String(path); diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index b4e368b45b..21a6d7a26a 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -4,9 +4,11 @@ import { join } from "node:path"; import { customProviderRegistryKey, mergeSupplementalAnthropicModels, resolvePlanningSettingsModel } from "@fusion/core"; import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; +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"; /** @@ -15,10 +17,61 @@ const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; * as opposed to supplemental credentials inherited from Codex CLI, * Claude Code, or environment variables. */ -async function getConfiguredProviderNames(): Promise> { +function isRawAnthropicApiKeyCredential(credential: unknown): boolean { + return Boolean( + credential + && typeof credential === "object" + && (credential as { type?: unknown; key?: unknown }).type === "api_key" + && typeof (credential as { key?: unknown }).key === "string" + && (credential as { key: string }).key.length > 0, + ); +} + +function toModelProviderId(providerId: string): string { + return providerId === ANTHROPIC_API_KEY_PROVIDER_ID ? ANTHROPIC_PROVIDER_ID : providerId; +} + +function addAuthStorageConfiguredProviders(authStorage: AuthStorageLike | undefined, providers: Set): void { + if (!authStorage) { + return; + } + + try { + authStorage.reload?.(); + } catch { + // Ignore unreadable auth storage and fall back to persisted files below. + } + + for (const provider of authStorage.getOAuthProviders?.() ?? []) { + const providerId = provider.id; + if (providerId === ANTHROPIC_PROVIDER_ID || providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { + continue; + } + if (authStorage.hasAuth?.(providerId)) { + providers.add(providerId); + } + } + + for (const provider of authStorage.getApiKeyProviders?.() ?? []) { + const storedCredential = authStorage.get?.(provider.id); + if (authStorage.hasApiKey?.(provider.id) || isRawAnthropicApiKeyCredential(storedCredential)) { + providers.add(toModelProviderId(provider.id)); + } + } + + const anthropicCredential = authStorage.get?.(ANTHROPIC_PROVIDER_ID); + const anthropicApiKeyCredential = authStorage.get?.(ANTHROPIC_API_KEY_PROVIDER_ID); + if (isRawAnthropicApiKeyCredential(anthropicCredential) || isRawAnthropicApiKeyCredential(anthropicApiKeyCredential)) { + providers.add(ANTHROPIC_PROVIDER_ID); + } +} + +async function getConfiguredProviderNames(authStorage?: AuthStorageLike): Promise> { const home = process.env.HOME || process.env.USERPROFILE || homedir(); const providers = new Set(); + addAuthStorageConfiguredProviders(authStorage, providers); + // Fusion primary + legacy .pi auth files const authPaths = [ join(home, ".fusion", "agent", "auth.json"), @@ -53,6 +106,9 @@ async function getConfiguredProviderNames(): Promise> { FNXC:ProviderAuth 2026-07-01-12:18: Keep Anthropic's three surfaces distinct in discovery: raw API-key auth configures direct `anthropic`, subscription OAuth stays an auth/usage credential (`anthropic-subscription`) and is not a model provider row, and Claude CLI models appear only as `pi-claude-cli` when the CLI picker toggle is enabled. + + FNXC:ModelCatalog 2026-07-01-13:41: + `/api/models` must follow the same connected-state source as Settings/auth status when ServerOptions.authStorage is injected. Use auth storage first for OAuth/API-key surfaces, keep OAuth-only Anthropic out of direct `anthropic/*`, and then fall back to legacy files/env so v0.50-style local API-key discovery still works. */ if (process.env.ANTHROPIC_API_KEY) { providers.add(ANTHROPIC_PROVIDER_ID); @@ -200,7 +256,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { // have set up in Fusion. We restrict to providers with credentials // in Fusion's own auth stores (primary + legacy .pi + models.json), // plus any providers enabled via settings toggles (Claude CLI, etc.). - const configuredProviders = await getConfiguredProviderNames(); + const configuredProviders = await getConfiguredProviderNames(options?.authStorage); if (useClaudeCli) configuredProviders.add("pi-claude-cli"); if (useDroidCli) configuredProviders.add("droid-cli"); if (useLlamaCpp) configuredProviders.add("llama-server");