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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 14:00:53 -07:00
parent f998fe3ffc
commit 9c2a264322
6 changed files with 333 additions and 8 deletions

View File

@@ -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.

View File

@@ -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<string, ModelPricing>` | `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.

View File

@@ -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> = {}): 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(
<ModelSelectorTab
task={task}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
/>,
);
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(<ModelSelectorTab task={makeTask()} addToast={vi.fn()} />);
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();
});
});
});

View File

@@ -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<ReturnType<typeof fetchModels>>) => void) | undefined;
mockFetchModels.mockImplementationOnce(() => new Promise((resolve) => {

View File

@@ -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<typeof vi.fn>).mockResolvedValue(
useClaudeCli === undefined ? {} : { useClaudeCli },
);
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).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<void>) {
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);

View File

@@ -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<Set<string>> {
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<string>): 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<Set<string>> {
const home = process.env.HOME || process.env.USERPROFILE || homedir();
const providers = new Set<string>();
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<Set<string>> {
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");