diff --git a/.changeset/fn-7396-anthropic-oauth-cli-api-paths.md b/.changeset/fn-7396-anthropic-oauth-cli-api-paths.md new file mode 100644 index 0000000000..61ae0dfc47 --- /dev/null +++ b/.changeset/fn-7396-anthropic-oauth-cli-api-paths.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Anthropic subscription OAuth, Claude CLI, and direct API-key auth separated. +category: fix +dev: Restores anthropic-subscription status/usage/banner behavior and direct subscription-backed execution while keeping raw anthropic API-key auth and explicit pi-claude-cli execution separate. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index df7a530a2f..06a8ea171a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -623,11 +623,11 @@ Branch names are dynamic from merge/audit payloads; the banner is not hardcoded The global OAuth re-login banner clears a provider row immediately after that provider successfully re-authenticates (from Settings → Authentication or Model Onboarding), instead of waiting for the next `GET /auth/status` poll interval. -For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. When that refresh succeeds, the banner clears for Claude without manual re-login and without waiting for a separate model request. +For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the subscription provider without manual re-login and without waiting for a separate model request. If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. -Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card, so saving or clearing an API key does not affect the OAuth sign-in path. The dashboard only displays masked key hints after a key is saved. +Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, banner clearing, and subscription-backed direct agent execution through the dedicated `anthropic-subscription` path; CLI-backed execution remains the distinct **Claude CLI** provider (`pi-claude-cli`). Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved. ## Smart Pull diff --git a/docs/settings-reference.md b/docs/settings-reference.md index d762a967ce..e33e0bf554 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 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. | +| `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. | | `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. | @@ -722,13 +722,19 @@ Recovery entrypoints in the dashboard: ### OAuth credential refresh -Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporting auth status when the stored OAuth credential includes a refresh token and the access token is expired or within the refresh buffer. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial Claude OAuth login. +Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporting auth status when the stored OAuth credential includes a refresh token and the access token is expired or within the refresh buffer. For Anthropic, this status path is the `anthropic-subscription` surface (including legacy `anthropic` OAuth rows), not Claude CLI state. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial Claude OAuth login. Manual re-login is still required when no refresh token is stored, the refresh request fails, or the expired OAuth credential belongs to a non-Anthropic provider. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding. ### Anthropic API-key authentication -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 for `ANTHROPIC_API_KEY`, 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 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. +- **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. ### Authentication troubleshooting (mobile OAuth fallback) diff --git a/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx b/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx new file mode 100644 index 0000000000..5d4e2451f0 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx @@ -0,0 +1,118 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OAuthReloginBanner } from "../OAuthReloginBanner"; +import { OAUTH_RELOGIN_SUCCESS_EVENT } from "../../auth"; +import * as api from "../../api"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string, values?: Record) => { + let text = fallback ?? _key; + if (values) { + for (const [key, value] of Object.entries(values)) { + text = text.replaceAll(`{{${key}}}`, value); + } + } + return text; + }, + }), +})); + +vi.mock("../../api", () => ({ + fetchAuthStatus: vi.fn(), +})); + +const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus); + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("OAuthReloginBanner", () => { + beforeEach(() => { + window.localStorage.clear(); + mockFetchAuthStatus.mockReset(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("shows expired Anthropic subscription OAuth even when Claude CLI is authenticated", async () => { + mockFetchAuthStatus.mockResolvedValueOnce({ + providers: [ + { id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true }, + { id: "claude-cli", name: "Claude CLI", type: "cli", authenticated: true }, + ], + ghCli: { available: false, authenticated: false }, + }); + + render(); + + expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription"); + expect(screen.getByRole("status")).toHaveTextContent("Re-login required"); + }); + + it("clears the banner when OAuth success is dispatched for the status provider id", async () => { + mockFetchAuthStatus + .mockResolvedValueOnce({ + providers: [ + { id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true }, + ], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [ + { id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: true, expired: false }, + ], + ghCli: { available: false, authenticated: false }, + }); + + render(); + expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription"); + + await act(async () => { + window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "anthropic-subscription" } })); + await flushPromises(); + }); + + await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + }); + + it("keeps dismissed state scoped to currently expired provider ids", async () => { + mockFetchAuthStatus + .mockResolvedValueOnce({ + providers: [ + { id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true }, + ], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [ + { id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true }, + ], + ghCli: { available: false, authenticated: false }, + }); + + const { unmount } = render(); + expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription"); + + fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner")); + await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + + unmount(); + render(); + await act(async () => { + await flushPromises(); + }); + unmount(); + + render(); + expect(await screen.findByRole("status")).toHaveTextContent("OpenAI Codex"); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 4aacae35e7..a9ef995bc3 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -944,6 +944,35 @@ describe("GET /auth/status", () => { expect(anthropic).toMatchObject({ authenticated: true, expired: false }); }); + it("reports legacy Anthropic OAuth expiry under the subscription status id without using CLI state", async () => { + const now = Date.now(); + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => { + if (provider === "anthropic") { + return { + type: "oauth", + access: "expired-legacy-token", + refresh: "refresh", + expires: now - 1_000, + }; + } + return undefined; + }); + (authStorage.getApiKey as ReturnType).mockRejectedValue(new Error("refresh failed")); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + const claudeCli = res.body.providers.find((p: any) => p.id === "claude-cli"); + expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic-subscription"); + expect(anthropic).toMatchObject({ authenticated: false, expired: true }); + expect(claudeCli).toMatchObject({ type: "cli" }); + }); + it("keeps expired oauth status when async refresh fails", async () => { const now = Date.now(); (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 06dd34f92d..f6735d67f9 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -383,7 +383,114 @@ describe("usage", () => { expect(claude).toBeUndefined(); }); - it("reads Claude credentials from Fusion auth-storage anthropic oauth when no CLI files exist", async () => { + it("reads Claude credentials from Fusion auth-storage anthropic-subscription OAuth before CLI files", async () => { + mockReadFile.mockImplementation((filePath: string) => { + if (filePath.includes("claude")) { + return JSON.stringify({ accessToken: "legacy-cli-token", scopes: ["user:profile"], subscriptionType: "free" }); + } + return Promise.reject(new Error("File not found")); + }); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + + const authStorage = { + reload: vi.fn(), + hasAuth: vi.fn((provider: string) => provider === "anthropic-subscription"), + get: vi.fn((provider: string) => { + if (provider !== "anthropic-subscription") return null; + return { + type: "oauth", + access: "subscription-access-token", + refresh: "subscription-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + scopes: ["user:profile"], + subscriptionType: "pro", + }; + }), + getApiKey: vi.fn(), + }; + + const requestUrls: string[] = []; + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((options: any, callback: any) => { + requestUrls.push(`https://${options.hostname}${options.path}`); + const mockRes = { + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from(JSON.stringify({ five_hour: { utilization: 22.0 } }))); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + const providers = await fetchAllProviderUsage(authStorage); + const claude = providers.find((p) => p.name === "Claude")!; + + expect(claude.status).toBe("ok"); + expect(claude.plan).toBe("Pro"); + expect(authStorage.get).toHaveBeenCalledWith("anthropic-subscription"); + expect(authStorage.get).not.toHaveBeenCalledWith("anthropic"); + expect(authStorage.getApiKey).not.toHaveBeenCalledWith("anthropic-subscription"); + expect(requestUrls[0]).toContain("oauth/usage"); + }); + + it("refreshes expired separated Fusion subscription OAuth through auth storage before usage API", async () => { + mockReadFile.mockRejectedValue(new Error("File not found")); + mockExecFileSync.mockImplementation(() => { + throw new Error("Keychain item not found"); + }); + let refreshed = false; + const authStorage = { + reload: vi.fn(), + hasAuth: vi.fn(() => true), + get: vi.fn((provider: string) => { + if (provider !== "anthropic-subscription") return null; + return { + type: "oauth", + access: refreshed ? "refreshed-subscription-token" : "expired-subscription-token", + refresh: "subscription-refresh-token", + expires: refreshed ? Date.now() + 60 * 60 * 1000 : Date.now() - 60_000, + scopes: ["user:profile"], + }; + }), + getApiKey: vi.fn(async (provider: string) => { + if (provider === "anthropic-subscription") { + refreshed = true; + return "refreshed-subscription-token"; + } + return undefined; + }), + }; + + let capturedAuthorization: string | undefined; + const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() }; + mockRequest.mockImplementation((options: any, callback: any) => { + capturedAuthorization = options.headers.authorization; + const mockRes = { + statusCode: 200, + headers: {}, + on: vi.fn((event: string, handler: any) => { + if (event === "data") handler(Buffer.from(JSON.stringify({ five_hour: { utilization: 12.0 } }))); + if (event === "end") handler(); + }), + }; + callback(mockRes); + return mockReq; + }); + + const providers = await fetchAllProviderUsage(authStorage); + const claude = providers.find((p) => p.name === "Claude")!; + + expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic-subscription"); + expect(capturedAuthorization).toBe("Bearer refreshed-subscription-token"); + expect(claude.status).toBe("ok"); + }); + + it("reads Claude credentials from legacy Fusion auth-storage anthropic oauth when no separated credential exists", async () => { mockReadFile.mockImplementation(async () => { return Promise.reject(new Error("File not found")); }); diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index a8e9d736ba..0ac34a939f 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -48,9 +48,25 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { return key.slice(0, 3) + "•••••" + key.slice(-4); } - function isExpiredOauthCredential(providerId: string, storage: AuthStorageLike): boolean { + function getOauthStatusCredential(providerId: string, storage: AuthStorageLike) { const credential = storage.get?.(providerId); - if (!credential || credential.type !== "oauth" || typeof credential.expires !== "number") { + if (credential?.type === "oauth") { + return credential; + } + if (providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { + /* + FNXC:ProviderAuth 2026-07-01-12:46: + The OAuth re-login banner is keyed by the dashboard status id `anthropic-subscription`, but pre-split installs may still hold Claude subscription OAuth in the legacy `anthropic` row. Read that legacy OAuth only for subscription status; raw API-key status remains on the separate `anthropic-api-key` card. + */ + const legacyCredential = storage.get?.(ANTHROPIC_OAUTH_PROVIDER_ID); + return legacyCredential?.type === "oauth" ? legacyCredential : undefined; + } + return undefined; + } + + function isExpiredOauthCredential(providerId: string, storage: AuthStorageLike): boolean { + const credential = getOauthStatusCredential(providerId, storage); + if (!credential || typeof credential.expires !== "number") { return false; } diff --git a/packages/dashboard/src/usage.ts b/packages/dashboard/src/usage.ts index 3d78e838a8..c40c999f22 100644 --- a/packages/dashboard/src/usage.ts +++ b/packages/dashboard/src/usage.ts @@ -89,6 +89,9 @@ export interface AuthCredentialEntry { [key: string]: unknown; } +const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; +const ANTHROPIC_LEGACY_OAUTH_PROVIDER_ID = "anthropic"; + // Cache for usage data with TTL interface CacheEntry { data: ProviderUsage[]; @@ -886,8 +889,21 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise undefined); + fusionCreds = authStorage.get?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) ?? fusionCreds; + } + creds = { accessToken: fusionCreds.access, refreshToken: fusionCreds.refresh || undefined, @@ -896,6 +912,7 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise { expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"])); }); + it("exposes legacy Anthropic OAuth through the subscription provider without raw direct auth", async () => { + writeFusionAuth(homeDir, { + anthropic: { + type: "oauth", + access: "legacy-subscription-access-token", + refresh: "legacy-subscription-refresh-token", + expires: Date.now() + 3_600_000, + }, + }); + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(authStorage.get("anthropic-subscription")).toEqual({ + type: "oauth", + access: "legacy-subscription-access-token", + refresh: "legacy-subscription-refresh-token", + expires: expect.any(Number), + }); + expect(authStorage.hasAuth("anthropic-subscription")).toBe(true); + expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"])); + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("legacy-subscription-access-token"); + }); + + it("refreshes legacy Anthropic OAuth into the subscription provider id", async () => { + writeFusionAuth(homeDir, { + anthropic: { + type: "oauth", + access: "expired-legacy-subscription-access-token", + refresh: "legacy-subscription-refresh-token", + expires: Date.now() - 60_000, + scopes: ["user:profile"], + }, + }); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "refreshed-legacy-as-subscription-access-token", + refresh_token: "rotated-legacy-subscription-refresh-token", + expires_in: 3600, + scope: "user:profile", + }), + } as Response); + globalThis.fetch = fetchMock as typeof fetch; + + const authStorage = createFusionAuthStorage(); + + expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-legacy-as-subscription-access-token"); + expect(authStorage.get("anthropic-subscription")).toEqual({ + type: "oauth", + access: "refreshed-legacy-as-subscription-access-token", + refresh: "rotated-legacy-subscription-refresh-token", + expires: expect.any(Number), + scopes: ["user:profile"], + }); + expect(authStorage.get("anthropic")).toEqual({ + type: "oauth", + access: "expired-legacy-subscription-access-token", + refresh: "legacy-subscription-refresh-token", + expires: expect.any(Number), + scopes: ["user:profile"], + }); + }); + it("keeps raw Anthropic API-key precedence when subscription OAuth also exists", async () => { writeFusionAuth(homeDir, { anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" }, 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 cdbf67475f..67c62ea871 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1703,13 +1703,20 @@ 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); + it("routes subscription-OAuth persisted Anthropic selections to the direct subscription provider even when the CLI picker toggle is unset", async () => { + authStorageGetMock.mockImplementation((provider: string) => provider === "anthropic-subscription" + ? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 } + : undefined); + authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription"); authStorageGetApiKeyMock.mockResolvedValue(undefined); + getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8" }]); 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 === "anthropic-subscription" && 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 }; } @@ -1725,12 +1732,36 @@ describe("createFnAgent", () => { defaultModelId: "claude-opus-4-8", }); + expect(registerProviderMock).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({ + api: "anthropic-messages", + apiKey: "$ANTHROPIC_SUBSCRIPTION_API_KEY", + models: [expect.objectContaining({ id: "claude-opus-4-8" })], + })); + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + model: { provider: "anthropic-subscription", id: "claude-opus-4-8", baseUrl: "https://api.anthropic.com/v1" }, + })); + expect(createAgentSessionMock).not.toHaveBeenCalledWith(expect.objectContaining({ + model: expect.objectContaining({ provider: "pi-claude-cli" }), + })); + }); + + it("keeps explicit Claude CLI selections on the Claude CLI provider", async () => { + authStorageGetApiKeyMock.mockResolvedValue(undefined); + findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); + + const { createFnAgent } = await import("../pi.js"); + await createFnAgent({ + cwd: "/tmp", + systemPrompt: "test", + tools: "readonly", + defaultProvider: "pi-claude-cli", + 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" }), - })); + expect(authStorageGetApiKeyMock).not.toHaveBeenCalledWith("pi-claude-cli"); }); it("keeps raw Anthropic API-key selections on the direct provider", async () => { @@ -1752,8 +1783,11 @@ describe("createFnAgent", () => { })); }); - it("fails clearly for OAuth-only Anthropic selections when the Claude CLI provider is unavailable", async () => { - authStorageGetMock.mockReturnValue(undefined); + it("does not require the Claude CLI provider for subscription-OAuth Anthropic execution", async () => { + authStorageGetMock.mockImplementation((provider: string) => provider === "anthropic-subscription" + ? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 } + : undefined); + authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription"); authStorageGetApiKeyMock.mockResolvedValue(undefined); findMock.mockImplementation((provider: string, modelId: string) => { if (provider === "anthropic" && modelId === "claude-opus-4-8") { @@ -1767,14 +1801,17 @@ describe("createFnAgent", () => { getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8" }]); const { createFnAgent } = await import("../pi.js"); - await expect(createFnAgent({ + await 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(); + }); + + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + model: { provider: "anthropic-subscription", id: "claude-opus-4-8" }, + })); }); it("backfills the resolved model onto sessions that do not mirror it", async () => { diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 2d6d04b6c9..4437232c24 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -405,10 +405,35 @@ export function createFusionAuthStorage(): AuthStorage { const isAnthropicSubscriptionLoggedOut = () => loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); const isAnthropicRawProviderLoggedOut = () => loggedOutProviders.has(ANTHROPIC_PROVIDER_ID); + const selectAnthropicSubscriptionCredential = (): { credential?: StoredCredential; sourceProvider?: string } => { + if (isAnthropicSubscriptionLoggedOut()) { + return {}; + } + + const separatedCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); + if (separatedCredential?.type === "oauth") { + return { credential: separatedCredential, sourceProvider: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID }; + } + + if (!isAnthropicRawProviderLoggedOut()) { + const legacyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth"); + if (legacyCredential) { + return { credential: legacyCredential, sourceProvider: ANTHROPIC_PROVIDER_ID }; + } + } + + return {}; + }; + + const hasVisibleAnthropicSubscriptionCredential = () => Boolean(selectAnthropicSubscriptionCredential().credential); + const selectVisibleStoredCredential = (provider: string) => { if (loggedOutProviders.has(provider)) { return undefined; } + if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { + return selectAnthropicSubscriptionCredential().credential; + } if (provider === ANTHROPIC_PROVIDER_ID && isAnthropicSubscriptionLoggedOut()) { return selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key"); } @@ -430,9 +455,7 @@ export function createFusionAuthStorage(): AuthStorage { || modelsJsonApiKeys.has(ANTHROPIC_PROVIDER_ID)); const hasVisibleLegacyAnthropicOAuth = !isAnthropicRawProviderLoggedOut() && Boolean(selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth")); - const hasVisibleSubscriptionCredential = !isAnthropicSubscriptionLoggedOut() - && (primary.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) - || ANTHROPIC_SUBSCRIPTION_PROVIDER_ID in supplementalCredentials); + const hasVisibleSubscriptionCredential = hasVisibleAnthropicSubscriptionCredential(); const hasVisibleAnthropicFallback = !isAnthropicRawProviderLoggedOut() && hasTargetFallbackAuth(ANTHROPIC_PROVIDER_ID); @@ -584,6 +607,9 @@ export function createFusionAuthStorage(): AuthStorage { if (provider === ANTHROPIC_PROVIDER_ID) { return hasVisibleAnthropicCredential(); } + if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { + return hasVisibleAnthropicSubscriptionCredential(); + } if (loggedOutProviders.has(provider)) { return false; } @@ -596,6 +622,9 @@ export function createFusionAuthStorage(): AuthStorage { if (provider === ANTHROPIC_PROVIDER_ID) { return hasVisibleAnthropicCredential(); } + if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { + return hasVisibleAnthropicSubscriptionCredential(); + } if (loggedOutProviders.has(provider)) { return false; } @@ -611,6 +640,9 @@ export function createFusionAuthStorage(): AuthStorage { ? Object.keys(supplementalCredentials).filter((p) => !loggedOutProviders.has(p)) : Object.keys(supplementalCredentials)), ]); + if (hasVisibleAnthropicSubscriptionCredential()) { + providerIds.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); + } const merged: Record = {}; for (const providerId of providerIds) { const credential = selectVisibleStoredCredential(providerId); @@ -635,10 +667,8 @@ export function createFusionAuthStorage(): AuthStorage { providers.add(p); } } - if ( - !loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) - && (providers.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) || supplementalCredentials[ANTHROPIC_SUBSCRIPTION_PROVIDER_ID]) - ) { + if (hasVisibleAnthropicSubscriptionCredential()) { + providers.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); providers.add(ANTHROPIC_PROVIDER_ID); } return Array.from(providers).filter((p) => { @@ -664,10 +694,19 @@ export function createFusionAuthStorage(): AuthStorage { } if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { - const subscriptionCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); - return subscriptionCredential?.type === "oauth" - ? resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential) - : undefined; + const { credential: subscriptionCredential, sourceProvider } = selectAnthropicSubscriptionCredential(); + if (subscriptionCredential?.type !== "oauth") { + return undefined; + } + if (sourceProvider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { + /* + FNXC:ProviderAuth 2026-07-01-12:34: + Legacy Anthropic OAuth rows are subscription credentials, not raw API keys. Hydrate them into `anthropic-subscription` before refresh so status, usage, and banner clearing share the same provider id without overwriting a raw `anthropic` API-key credential. + */ + primary.set(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential as AuthCredential); + loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); + } + return resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential); } // 1. Primary Fusion auth diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index cde843bb0a..f272b2307e 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -84,7 +84,7 @@ 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"; +const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; export type RtkRewriteMode = "off" | "rewrite"; @@ -1174,27 +1174,51 @@ function readJsonObject(path: string): Record { } } -function resolveClaudeCliModelForAnthropicSelection( +function resolveAnthropicSubscriptionModelForAnthropicSelection( modelRegistry: ModelRegistry, kind: "primary" | "fallback", - modelId: string, + model: ReturnType, ) { - const cliModel = modelRegistry.find(CLAUDE_CLI_PROVIDER_ID, modelId); - if (cliModel) { - return cliModel; + if (!model) return model; + const subscriptionModel = modelRegistry.find(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, model.id); + if (subscriptionModel) { + return subscriptionModel; } - 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 }; + piLog.warn(`${kind} model ${ANTHROPIC_SUBSCRIPTION_PROVIDER_ID}/${model.id} not in registry; using the resolved Anthropic model as a subscription provider template`); + return { ...model, provider: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID }; +} + +function registerAnthropicSubscriptionProvider(modelRegistry: ModelRegistry): void { + const models = modelRegistry.getAll() + .filter((model) => model.provider === ANTHROPIC_PROVIDER_ID) + .map((model) => ({ + id: model.id, + name: model.name ?? model.id, + reasoning: model.reasoning ?? false, + input: Array.isArray(model.input) ? model.input : ["text" as const], + cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.contextWindow ?? 0, + maxTokens: model.maxTokens ?? 0, + ...(model.compat ? { compat: model.compat } : {}), + })); + + if (models.length === 0) { + return; } - 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.", - ); + /* + FNXC:ProviderAuth 2026-07-01-13:06: + Claude subscription OAuth is an execution-capable Anthropic surface, but it must not be saved or resolved as raw `ANTHROPIC_API_KEY` material for the public `anthropic` provider. Register a separate hidden `anthropic-subscription` runtime provider so persisted Anthropic selections can execute with OAuth through their own provider id while explicit `pi-claude-cli` selections remain CLI-only. + */ + modelRegistry.registerProvider(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, { + name: "Anthropic Subscription", + baseUrl: "https://api.anthropic.com/v1", + apiKey: "$ANTHROPIC_SUBSCRIPTION_API_KEY", + api: "anthropic-messages", + models, + }); + modelRegistry.refresh(); } async function routeAnthropicSelectionForAvailableAuth( @@ -1212,11 +1236,16 @@ async function routeAnthropicSelectionForAvailableAuth( return model; } + const hasSubscriptionOAuth = authStorage.hasAuth?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) === true; + if (!hasSubscriptionOAuth) { + 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`. + FNXC:ProviderAuth 2026-07-01-13:08: + OAuth subscription credentials are execution credentials for the dedicated `anthropic-subscription` path, not status-only credentials and not raw `anthropic` `/v1` API keys. Route persisted `anthropic/` selections without a raw key to the subscription provider; explicit `pi-claude-cli` selections remain the only CLI path. */ - return resolveClaudeCliModelForAnthropicSelection(modelRegistry, kind, model.id); + return resolveAnthropicSubscriptionModelForAnthropicSelection(modelRegistry, kind, model); } function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void { @@ -2115,6 +2144,7 @@ export async function createFnAgent(options: AgentOptions): Promise } modelRegistry.refresh(); mergeSupplementalAnthropicModels(modelRegistry, (message) => extensionsLog.warn(message)); + registerAnthropicSubscriptionProvider(modelRegistry); // Build the pi built-in tool set. We deliberately do NOT use the bundled // `createCodingTools` / `createReadOnlyTools` presets — they're missing