diff --git a/.changeset/fn-7391-anthropic-subscription-cli-routing.md b/.changeset/fn-7391-anthropic-subscription-cli-routing.md new file mode 100644 index 0000000000..97e9b961bc --- /dev/null +++ b/.changeset/fn-7391-anthropic-subscription-cli-routing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Anthropic subscription chat failing with 429/502 by routing it through the Claude CLI. +category: fix +dev: Anthropic routing now keeps three surfaces distinct: raw API keys authenticate direct api.anthropic.com/v1, subscription/OAuth remains `anthropic-subscription`, and CLI execution uses `pi-claude-cli`; OAuth-only selections never authenticate direct `/v1` and are routed to the CLI provider when available. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index f0e2df603c..d762a967ce 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. | +| `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 remains the `anthropic-subscription` auth/usage credential; Claude CLI execution uses the `pi-claude-cli` model provider. OAuth-only Anthropic selections are never sent to `api.anthropic.com/v1`; Fusion routes them to the CLI provider when available or fails with a configuration error. | | `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. | diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 43c6372282..198bfb4b82 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -498,6 +498,80 @@ describe("GET /models", () => { const providers = res.body.models.map((m: { provider: string }) => m.provider); expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai", "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); + if (value.endsWith("auth.json")) { + return JSON.stringify({ + anthropic: { type: "oauth", access: "legacy-oauth", refresh: "refresh", expires: Date.now() + 60_000 }, + "anthropic-subscription": { type: "oauth", access: "subscription-oauth", refresh: "refresh", expires: Date.now() + 60_000 }, + openai: { type: "api_key", key: "openai-key" }, + }); + } + if (value.endsWith("models.json")) { + return JSON.stringify({ providers: { openai: { apiKey: "openai-key" } } }); + } + return "{}"; + }, async () => { + const registry = 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: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 }, + ]), + }); + const res = await GET(buildAppWithSetting(true, registry), "/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).toContain("openai"); + expect(providers).not.toContain("anthropic"); + expect(providers).not.toContain("anthropic-subscription"); + }); + }); + + it("hides all Anthropic model rows for OAuth-only auth when Claude CLI picker visibility is disabled", async () => { + await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => { + const value = String(path); + if (value.endsWith("auth.json")) { + return JSON.stringify({ + "anthropic-subscription": { type: "oauth", access: "subscription-oauth", refresh: "refresh", expires: Date.now() + 60_000 }, + }); + } + if (value.endsWith("models.json")) { + return JSON.stringify({ providers: {} }); + } + return "{}"; + }, async () => { + const res = await GET(buildAppWithSetting(false, registryWithCli()), "/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("shows direct Anthropic rows when a raw API key exists", async () => { + await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => { + const value = String(path); + if (value.endsWith("auth.json")) { + return JSON.stringify({ anthropic: { type: "api_key", key: "sk-ant-api03-direct" } }); + } + if (value.endsWith("models.json")) { + return JSON.stringify({ providers: {} }); + } + return "{}"; + }, async () => { + const res = await GET(buildAppWithSetting(false, registryWithCli()), "/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"); + }); + }); }); }); diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index 8fd6c19275..1ba3c547f6 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -6,6 +6,9 @@ import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; import type { ApiRouteRegistrar } from "./types.js"; +const ANTHROPIC_PROVIDER_ID = "anthropic"; +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, @@ -27,14 +30,34 @@ async function getConfiguredProviderNames(): Promise> { try { await access(authPath); const parsed = JSON.parse(await readFile(authPath, "utf-8")) as Record; - for (const key of Object.keys(parsed)) { - providers.add(key); + for (const [key, credential] of Object.entries(parsed)) { + if (key === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { + continue; + } + if (key !== ANTHROPIC_PROVIDER_ID) { + providers.add(key); + continue; + } + if (credential && typeof credential === "object" && (credential as { type?: unknown }).type === "api_key") { + providers.add(key); + } } } catch { // Ignore missing or invalid auth files } } + /* + FNXC:ProviderAuth 2026-07-01-12:06: + The model picker must not advertise direct `anthropic` rows for OAuth-only Claude subscription setups. Only raw API-key material (auth.json `type: api_key`, models.json apiKey, or ANTHROPIC_API_KEY) configures the direct api.anthropic.com/v1 provider. + + 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. + */ + if (process.env.ANTHROPIC_API_KEY) { + providers.add(ANTHROPIC_PROVIDER_ID); + } + // Check models.json for providers with inline API keys const modelsPaths = [ join(home, ".fusion", "agent", "models.json"), diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index 774954d8c8..0e5deca80e 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -165,7 +165,7 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); expect(authStorage.get("anthropic")).toEqual({ type: "oauth", access: "claude-access-token", @@ -216,7 +216,7 @@ describe("createFusionAuthStorage", () => { expect(authStorage.hasAuth("anthropic")).toBe(true); }); - it("uses Anthropic subscription OAuth for model runtime auth when no raw API key exists", async () => { + it("does not use Anthropic subscription OAuth for direct model runtime auth when no raw API key exists", async () => { writeFusionAuth(homeDir, { "anthropic-subscription": { type: "oauth", @@ -228,7 +228,8 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token"); expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"])); }); @@ -297,7 +298,8 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-subscription-access-token"); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-subscription-access-token"); expect(fetchMock).toHaveBeenCalledWith( "https://platform.claude.com/v1/oauth/token", expect.objectContaining({ @@ -366,7 +368,8 @@ describe("createFusionAuthStorage", () => { expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"])); - expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token"); }); it("uses a newly set subscription credential for runtime auth after raw Anthropic logout", async () => { @@ -386,7 +389,8 @@ describe("createFusionAuthStorage", () => { }); expect(authStorage.get("anthropic")).toBeUndefined(); - expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token"); }); it("keeps raw Anthropic API keys visible when subscription logout suppresses OAuth aliases", async () => { @@ -419,7 +423,7 @@ describe("createFusionAuthStorage", () => { }); const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token"); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); authStorage.logout("anthropic-subscription"); @@ -521,11 +525,12 @@ describe("createFusionAuthStorage", () => { }); authStorage.reload(); - expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token"); }); }); - it("refreshes and persists expired Claude OAuth credentials from Claude credential files", async () => { + it("does not refresh Claude OAuth credentials as direct Anthropic API keys", async () => { const claudeDir = join(homeDir, ".claude"); mkdirSync(claudeDir, { recursive: true }); @@ -541,43 +546,24 @@ describe("createFusionAuthStorage", () => { }), ); - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: "refreshed-claude-access-token", - refresh_token: "rotated-claude-refresh-token", - expires_in: 3600, - scope: "user:profile org:create_api_key", - }), - } as Response); + const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response); globalThis.fetch = fetchMock as typeof fetch; const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-claude-access-token"); - expect(fetchMock).toHaveBeenCalledWith( - "https://platform.claude.com/v1/oauth/token", - expect.objectContaining({ - method: "POST", - body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""), - }), - ); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); expect(authStorage.get("anthropic")).toEqual({ type: "oauth", - access: "refreshed-claude-access-token", - refresh: "rotated-claude-refresh-token", - expires: expect.any(Number), - scopes: ["user:profile", "org:create_api_key"], - }); - - const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")); - expect(persisted.anthropic).toEqual({ - type: "oauth", - access: "refreshed-claude-access-token", - refresh: "rotated-claude-refresh-token", + access: "expired-claude-access-token", + refresh: "claude-refresh-token", expires: expect.any(Number), scopes: ["user:profile", "org:create_api_key"], }); + const persisted = existsSync(getFusionAuthPath(homeDir)) + ? JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")) + : {}; + expect(persisted.anthropic?.access).not.toBe("refreshed-claude-access-token"); }); it("does not persist an invalid Claude OAuth refresh response", async () => { @@ -629,8 +615,8 @@ describe("createFusionAuthStorage", () => { expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); - const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")); + expect(fetchMock).not.toHaveBeenCalled(); + const persisted = existsSync(getFusionAuthPath(homeDir)) ? JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")) : {}; expect(persisted.anthropic).toBeUndefined(); }); @@ -665,14 +651,14 @@ describe("createFusionAuthStorage", () => { authStorage.getApiKey("anthropic"), authStorage.getApiKey("anthropic"), ])).resolves.toEqual([ - "refreshed-claude-access-token", - "refreshed-claude-access-token", - "refreshed-claude-access-token", + undefined, + undefined, + undefined, ]); - expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).not.toHaveBeenCalled(); }); - it("does not let a stale Claude OAuth refresh overwrite a newer login", async () => { + it("does not refresh stale Claude OAuth material for direct Anthropic API auth", async () => { const claudeDir = join(homeDir, ".claude"); mkdirSync(claudeDir, { recursive: true }); @@ -687,39 +673,13 @@ describe("createFusionAuthStorage", () => { }), ); - let resolveJson: ((value: unknown) => void) | undefined; - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: () => new Promise((resolve) => { - resolveJson = resolve; - }), - } as Response); + const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response); globalThis.fetch = fetchMock as typeof fetch; const authStorage = createFusionAuthStorage(); - const pendingRefresh = authStorage.getApiKey("anthropic"); - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); - authStorage.set("anthropic", { - type: "oauth", - access: "fresh-login-access-token", - refresh: "fresh-login-refresh-token", - expires: Date.now() + 3_600_000, - }); - - resolveJson?.({ - access_token: "stale-refresh-access-token", - refresh_token: "stale-refresh-refresh-token", - expires_in: 3600, - }); - - await expect(pendingRefresh).resolves.toBe("fresh-login-access-token"); - expect(authStorage.get("anthropic")).toEqual({ - type: "oauth", - access: "fresh-login-access-token", - refresh: "fresh-login-refresh-token", - expires: expect.any(Number), - }); + await expect(authStorage.getApiKey("anthropic")).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); }); it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => { @@ -974,7 +934,7 @@ describe("createFusionAuthStorage", () => { // Before logout, supplemental credentials are visible expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true); - expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); // Log out authStorage.logout("anthropic"); diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 42ce606ed3..cdbf67475f 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -23,6 +23,12 @@ const sessionManagerGetSessionIdMock = vi.fn(() => undefined); const settingsManagerCreateMock = vi.fn(() => ({ kind: "settings-manager-create" })); const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" })); const setFallbackResolverMock = vi.fn(); +const authStorageGetApiKeyMock = vi.fn(async () => undefined); +const authStorageGetMock = vi.fn(() => undefined); +const authStorageHasMock = vi.fn(() => false); +const authStorageHasAuthMock = vi.fn(() => false); +const authStorageGetAllMock = vi.fn(() => ({})); +const authStorageListMock = vi.fn(() => []); const reloadMock = vi.fn(async () => {}); const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => ""); const spawnSyncMock = vi.fn(() => ({ status: 1, stdout: "" })); @@ -94,6 +100,16 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ AuthStorage: { create: () => ({ setFallbackResolver: setFallbackResolverMock, + getApiKey: authStorageGetApiKeyMock, + get: authStorageGetMock, + set: vi.fn(), + has: authStorageHasMock, + hasAuth: authStorageHasAuthMock, + getAll: authStorageGetAllMock, + list: authStorageListMock, + logout: vi.fn(), + remove: vi.fn(), + reload: vi.fn(), }), }, createAgentSession: createAgentSessionMock, @@ -1111,6 +1127,16 @@ describe("createFnAgent", () => { getAllMock.mockReturnValue([]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); // #1675: re-establish default auth + session-id mock returns after clearAllMocks. + authStorageGetApiKeyMock.mockImplementation(async (provider: string) => ( + provider === "anthropic" ? "sk-ant-api03-test-key" : undefined + )); + authStorageGetMock.mockImplementation((provider: string) => ( + provider === "anthropic" ? { type: "api_key", key: "sk-ant-api03-test-key" } : undefined + )); + authStorageHasMock.mockReturnValue(false); + authStorageHasAuthMock.mockReturnValue(false); + authStorageGetAllMock.mockReturnValue({}); + authStorageListMock.mockReturnValue([]); getApiKeyAndHeadersMock.mockResolvedValue({ ok: true, apiKey: undefined, headers: undefined }); sessionManagerGetSessionIdMock.mockReturnValue(undefined); createBashToolMock.mockClear(); @@ -1677,6 +1703,80 @@ describe("createFnAgent", () => { expect(anthropicRegistrations).toHaveLength(0); }); + it("routes OAuth-only persisted Anthropic selections to the Claude CLI provider even when the picker toggle is unset", async () => { + authStorageGetMock.mockReturnValue(undefined); + authStorageGetApiKeyMock.mockResolvedValue(undefined); + findMock.mockImplementation((provider: string, modelId: string) => { + if (provider === "anthropic" && modelId === "claude-opus-4-8") { + return { provider, id: modelId, baseUrl: "https://api.anthropic.com/v1" }; + } + if (provider === "pi-claude-cli" && modelId === "claude-opus-4-8") { + return { provider, id: modelId }; + } + return { provider, id: modelId }; + }); + + const { createFnAgent } = await import("../pi.js"); + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + defaultProvider: "anthropic", + defaultModelId: "claude-opus-4-8", + }); + + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + model: { provider: "pi-claude-cli", id: "claude-opus-4-8" }, + })); + expect(createAgentSessionMock).not.toHaveBeenCalledWith(expect.objectContaining({ + model: expect.objectContaining({ provider: "anthropic" }), + })); + }); + + it("keeps raw Anthropic API-key selections on the direct provider", async () => { + authStorageGetApiKeyMock.mockImplementation(async (provider: string) => ( + provider === "anthropic" ? "sk-ant-api03-direct" : undefined + )); + + const { createFnAgent } = await import("../pi.js"); + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + defaultProvider: "anthropic", + defaultModelId: "claude-opus-4-8", + }); + + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + model: { provider: "anthropic", id: "claude-opus-4-8" }, + })); + }); + + it("fails clearly for OAuth-only Anthropic selections when the Claude CLI provider is unavailable", async () => { + authStorageGetMock.mockReturnValue(undefined); + authStorageGetApiKeyMock.mockResolvedValue(undefined); + findMock.mockImplementation((provider: string, modelId: string) => { + if (provider === "anthropic" && modelId === "claude-opus-4-8") { + return { provider, id: modelId }; + } + if (provider === "pi-claude-cli") { + return undefined; + } + return { provider, id: modelId }; + }); + getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8" }]); + + const { createFnAgent } = await import("../pi.js"); + await expect(createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + defaultProvider: "anthropic", + defaultModelId: "claude-opus-4-8", + })).rejects.toThrow("requires the Claude CLI provider"); + expect(createAgentSessionMock).not.toHaveBeenCalled(); + }); + it("backfills the resolved model onto sessions that do not mirror it", async () => { const session = { prompt: vi.fn(), diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index b256029ec8..2d6d04b6c9 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -496,35 +496,14 @@ export function createFusionAuthStorage(): AuthStorage { if (anthropicApiKeyCredential) { return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential); } - } - const subscriptionLoggedOut = loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); - const legacyAnthropicOAuthCredential = rawProviderLoggedOut - ? undefined - : selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth"); - if (!subscriptionLoggedOut && legacyAnthropicOAuthCredential) { - const legacyKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_PROVIDER_ID, legacyAnthropicOAuthCredential); - if (legacyKey) return legacyKey; - } - - if (!subscriptionLoggedOut) { - const subscriptionCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); - if (subscriptionCredential?.type === "oauth") { - /* - FNXC:ProviderAuth 2026-06-30-11:26: - Anthropic model execution still requests provider `anthropic`, but the separated subscription login now stores OAuth material under `anthropic-subscription` so the API-key card can remain raw-key-only. - Resolve and refresh the subscription credential with the upstream Anthropic OAuth provider id while persisting rotated tokens back to `anthropic-subscription`. - */ - const subscriptionKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential); - if (subscriptionKey) return subscriptionKey; - } - } - - if (!rawProviderLoggedOut) { /* + FNXC:ProviderAuth 2026-07-01-11:55: + Subscription/OAuth Anthropic tokens must never authenticate the direct `api.anthropic.com/v1` provider because Anthropic blocks Claude Pro/Max OAuth material on that public API surface. Keep provider `anthropic` raw-key-only here; the `anthropic-subscription` OAuth getter below remains available for Claude CLI and usage endpoints that intentionally consume subscription OAuth. + FNXC:ProviderAuth 2026-06-30-13:28: Logging out of the raw Anthropic provider must suppress raw-key sources consistently across status and runtime resolution. - Treat models.json Anthropic keys and ModelRegistry fallback resolver keys as raw-key fallback material, while subscription OAuth remains governed by the separate `anthropic-subscription` logout state above. + Treat models.json Anthropic keys and ModelRegistry fallback resolver keys as raw-key fallback material, while subscription OAuth remains governed by the separate `anthropic-subscription` logout state. */ const modelsJsonApiKey = modelsJsonApiKeys.get(ANTHROPIC_PROVIDER_ID); if (modelsJsonApiKey) return modelsJsonApiKey; @@ -573,6 +552,12 @@ export function createFusionAuthStorage(): AuthStorage { }; } + if (prop === "setFallbackResolver") { + return (resolver: (provider: string) => string | undefined) => { + (target as unknown as { fallbackResolver?: (provider: string) => string | undefined }).fallbackResolver = resolver; + }; + } + if (prop === "set") { return (provider: string, credential: AuthCredential) => { target.set(provider, credential); @@ -602,7 +587,7 @@ export function createFusionAuthStorage(): AuthStorage { if (loggedOutProviders.has(provider)) { return false; } - return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider); + return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider) || hasTargetFallbackAuth(provider); }; } @@ -614,7 +599,7 @@ export function createFusionAuthStorage(): AuthStorage { if (loggedOutProviders.has(provider)) { return false; } - return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider); + return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider) || hasTargetFallbackAuth(provider); }; } @@ -698,7 +683,11 @@ export function createFusionAuthStorage(): AuthStorage { if (supplementalKey) return supplementalKey; // 3. models.json provider API keys (e.g., kimi-coding, lmstudio) - return modelsJsonApiKeys.get(provider); + const modelsJsonApiKey = modelsJsonApiKeys.get(provider); + if (modelsJsonApiKey) return modelsJsonApiKey; + + // 4. ModelRegistry fallback resolver (env-backed provider configs) + return resolveTargetFallbackApiKey(provider); }; } diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 1b4b5d272d..cde843bb0a 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -31,6 +31,7 @@ import { ModelRegistry, SessionManager, SettingsManager, + type AuthStorage, type AgentSession, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; @@ -82,6 +83,8 @@ const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]); const RTK_EXPECTED_PASSTHROUGH_EXIT_CODES = new Set([1, 2]); const RTK_EXPECTED_FAIL_OPEN_ERROR_CODES = new Set(["ABORT_ERR", "ENOENT", "ETIMEDOUT"]); const RTK_REWRITE_MAX_BUFFER_BYTES = 64 * 1024; +const ANTHROPIC_PROVIDER_ID = "anthropic"; +const CLAUDE_CLI_PROVIDER_ID = "pi-claude-cli"; export type RtkRewriteMode = "off" | "rewrite"; @@ -1171,6 +1174,51 @@ function readJsonObject(path: string): Record { } } +function resolveClaudeCliModelForAnthropicSelection( + modelRegistry: ModelRegistry, + kind: "primary" | "fallback", + modelId: string, +) { + const cliModel = modelRegistry.find(CLAUDE_CLI_PROVIDER_ID, modelId); + if (cliModel) { + return cliModel; + } + + const providerModels = modelRegistry.getAll().filter((model) => model.provider === CLAUDE_CLI_PROVIDER_ID); + if (providerModels.length > 0) { + const baseModel = providerModels[0]!; + piLog.warn(`${kind} model ${CLAUDE_CLI_PROVIDER_ID}/${modelId} not in registry; using Claude CLI provider base model as template`); + return { ...baseModel, id: modelId, name: modelId }; + } + + throw new Error( + `Anthropic subscription/OAuth model ${ANTHROPIC_PROVIDER_ID}/${modelId} requires the Claude CLI provider, but ${CLAUDE_CLI_PROVIDER_ID} is not available. ` + + "Enable Settings → Model Providers → Claude CLI and ensure the Claude Code CLI is installed, or configure a raw ANTHROPIC_API_KEY for the direct Anthropic API provider.", + ); +} + +async function routeAnthropicSelectionForAvailableAuth( + authStorage: AuthStorage, + modelRegistry: ModelRegistry, + kind: "primary" | "fallback", + model: ReturnType, +) { + if (!model || model.provider !== ANTHROPIC_PROVIDER_ID) { + return model; + } + + const rawApiKey = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID); + if (rawApiKey) { + return model; + } + + /* + FNXC:ProviderAuth 2026-07-01-12:00: + Persisted `anthropic/` selections from subscription users must be re-routed at runtime, not merely hidden from the picker. With no raw Anthropic API key, `pi-claude-cli` is the compliant OAuth surface; direct `/v1` remains raw-key-only. Preserve the pre-0.52 working behavior by using the vendored CLI provider whenever it is registered, even if `useClaudeCli` was not explicitly toggled on for picker visibility; if the CLI provider is unavailable, fail before any OAuth token can reach `api.anthropic.com/v1`. + */ + return resolveClaudeCliModelForAnthropicSelection(modelRegistry, kind, model.id); +} + function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void { const entries = sessionManager.fileEntries; if (!Array.isArray(entries) || entries.length === 0) { @@ -2164,6 +2212,19 @@ export async function createFnAgent(options: AgentOptions): Promise ); } + selectedModel = await routeAnthropicSelectionForAvailableAuth( + authStorage, + modelRegistry, + "primary", + selectedModel, + ); + fallbackModel = await routeAnthropicSelectionForAvailableAuth( + authStorage, + modelRegistry, + "fallback", + fallbackModel, + ); + // Resolve skill selection: explicit skillSelection wins over convenience `skills` let effectiveSkillSelection: SkillSelectionContext | undefined = options.skillSelection; if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {