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:
7
.changeset/fn-7398-anthropic-model-selector.md
Normal file
7
.changeset/fn-7398-anthropic-model-selector.md
Normal 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.
|
||||||
@@ -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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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 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.
|
- **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 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.
|
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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,6 +62,46 @@ describe("useModelsCache", () => {
|
|||||||
expect(cached.data.models[0]?.id).toBe("gpt-4o");
|
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 () => {
|
it("deduplicates concurrent mounts", async () => {
|
||||||
let resolveFetch: ((value: Awaited<ReturnType<typeof fetchModels>>) => void) | undefined;
|
let resolveFetch: ((value: Awaited<ReturnType<typeof fetchModels>>) => void) | undefined;
|
||||||
mockFetchModels.mockImplementationOnce(() => new Promise((resolve) => {
|
mockFetchModels.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
|||||||
@@ -350,10 +350,10 @@ describe("GET /models", () => {
|
|||||||
store = createMockStore();
|
store = createMockStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildApp(modelRegistry?: ModelRegistryLike) {
|
function buildApp(modelRegistry?: ModelRegistryLike, authStorage?: AuthStorageLike) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use("/api", createApiRoutes(store, { modelRegistry }));
|
app.use("/api", createApiRoutes(store, { modelRegistry, authStorage }));
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,13 +458,34 @@ describe("GET /models", () => {
|
|||||||
// failure mode is silent and project-wide — keep these tests close to the
|
// failure mode is silent and project-wide — keep these tests close to the
|
||||||
// route so any future flip flips CI red immediately.
|
// route so any future flip flips CI red immediately.
|
||||||
describe("useClaudeCli filter", () => {
|
describe("useClaudeCli filter", () => {
|
||||||
function buildAppWithSetting(useClaudeCli: boolean | undefined, modelRegistry: ModelRegistryLike) {
|
function buildAppWithSetting(useClaudeCli: boolean | undefined, modelRegistry: ModelRegistryLike, authStorage?: AuthStorageLike) {
|
||||||
const globalStore = createMockGlobalSettingsStore();
|
const globalStore = createMockGlobalSettingsStore();
|
||||||
(globalStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(
|
(globalStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||||
useClaudeCli === undefined ? {} : { useClaudeCli },
|
useClaudeCli === undefined ? {} : { useClaudeCli },
|
||||||
);
|
);
|
||||||
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue(globalStore);
|
(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 {
|
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 () => {
|
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) => {
|
await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => {
|
||||||
const value = String(path);
|
const value = String(path);
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { join } from "node:path";
|
|||||||
import { customProviderRegistryKey, mergeSupplementalAnthropicModels, resolvePlanningSettingsModel } from "@fusion/core";
|
import { customProviderRegistryKey, mergeSupplementalAnthropicModels, resolvePlanningSettingsModel } from "@fusion/core";
|
||||||
import type { CustomProvider } from "@fusion/core";
|
import type { CustomProvider } from "@fusion/core";
|
||||||
import { ApiError } from "../api-error.js";
|
import { ApiError } from "../api-error.js";
|
||||||
|
import type { AuthStorageLike } from "../routes.js";
|
||||||
import type { ApiRouteRegistrar } from "./types.js";
|
import type { ApiRouteRegistrar } from "./types.js";
|
||||||
|
|
||||||
const ANTHROPIC_PROVIDER_ID = "anthropic";
|
const ANTHROPIC_PROVIDER_ID = "anthropic";
|
||||||
|
const ANTHROPIC_API_KEY_PROVIDER_ID = "anthropic-api-key";
|
||||||
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
|
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,
|
* as opposed to supplemental credentials inherited from Codex CLI,
|
||||||
* Claude Code, or environment variables.
|
* 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 home = process.env.HOME || process.env.USERPROFILE || homedir();
|
||||||
const providers = new Set<string>();
|
const providers = new Set<string>();
|
||||||
|
|
||||||
|
addAuthStorageConfiguredProviders(authStorage, providers);
|
||||||
|
|
||||||
// Fusion primary + legacy .pi auth files
|
// Fusion primary + legacy .pi auth files
|
||||||
const authPaths = [
|
const authPaths = [
|
||||||
join(home, ".fusion", "agent", "auth.json"),
|
join(home, ".fusion", "agent", "auth.json"),
|
||||||
@@ -53,6 +106,9 @@ async function getConfiguredProviderNames(): Promise<Set<string>> {
|
|||||||
|
|
||||||
FNXC:ProviderAuth 2026-07-01-12:18:
|
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.
|
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) {
|
if (process.env.ANTHROPIC_API_KEY) {
|
||||||
providers.add(ANTHROPIC_PROVIDER_ID);
|
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
|
// have set up in Fusion. We restrict to providers with credentials
|
||||||
// in Fusion's own auth stores (primary + legacy .pi + models.json),
|
// in Fusion's own auth stores (primary + legacy .pi + models.json),
|
||||||
// plus any providers enabled via settings toggles (Claude CLI, etc.).
|
// 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 (useClaudeCli) configuredProviders.add("pi-claude-cli");
|
||||||
if (useDroidCli) configuredProviders.add("droid-cli");
|
if (useDroidCli) configuredProviders.add("droid-cli");
|
||||||
if (useLlamaCpp) configuredProviders.add("llama-server");
|
if (useLlamaCpp) configuredProviders.add("llama-server");
|
||||||
|
|||||||
Reference in New Issue
Block a user