diff --git a/.changeset/fix-anthropic-oauth-direct-access.md b/.changeset/fix-anthropic-oauth-direct-access.md new file mode 100644 index 000000000..e3d4e320b --- /dev/null +++ b/.changeset/fix-anthropic-oauth-direct-access.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Anthropic Claude subscription chats failing (404/502/429) by restoring direct OAuth execution. +category: fix +dev: Reverts the FN-7391/FN-7396 runtime rerouting that sent subscription OAuth to a `/v1`-based `anthropic-subscription` provider (reintroducing issue #1857). `getApiKey("anthropic")` again resolves subscription/legacy OAuth (raw API key still wins), so `anthropic/*` selections run on pi-ai's built-in provider with Claude Code OAuth headers; the model picker advertises `anthropic` for OAuth users; explicit `pi-claude-cli` and raw `ANTHROPIC_API_KEY` remain separate surfaces. diff --git a/.changeset/restore-claude-sonnet-5-catalog.md b/.changeset/restore-claude-sonnet-5-catalog.md new file mode 100644 index 000000000..79925b27d --- /dev/null +++ b/.changeset/restore-claude-sonnet-5-catalog.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore Claude Sonnet 5 in the model picker (it had disappeared from every surface). +category: fix +dev: Re-adds `claude-sonnet-5` to SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION and its static pricing (removed by FN-7374). Live-verified: Sonnet 5 returns 200 on api.anthropic.com/v1 with a raw ANTHROPIC_API_KEY and runs via the Claude CLI; it 403s (scope) on subscription-OAuth /v1, where the runtime actionable-failure/fallback path applies. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 06a8ea171..634e829f4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -627,7 +627,7 @@ For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attemp 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 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. +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, and banner clearing; it also drives direct agent execution on the `anthropic` provider — a subscription/OAuth token runs `anthropic/*` selections against `https://api.anthropic.com/v1` with Claude Code identity headers, no API key required. CLI-backed execution remains the distinct, explicit **Claude CLI** provider (`pi-claude-cli`); subscription OAuth does not require it. A configured API key takes precedence over OAuth on the direct provider. 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 90a300af8..dd1654619 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -53,7 +53,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). | | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | | `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. | -| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three distinct surfaces: direct `anthropic` uses raw API-key material only (`ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential); subscription OAuth uses the dedicated `anthropic-subscription` auth/status/usage/banner 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. | +| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three independent surfaces, all executing on the direct `anthropic` provider except the CLI: (1) **direct OAuth** — a Claude subscription/OAuth login drives `anthropic/*` selections; Fusion sends the OAuth token to `https://api.anthropic.com/v1` with Claude Code identity headers (the same path the Claude Code CLI uses), so a subscription needs no API key. Credentials live under the `anthropic-subscription` auth/status/usage/banner id but are resolved for the direct provider at runtime; they are never stored or resolved as raw `ANTHROPIC_API_KEY` material. (2) **raw API key** — `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential uses `x-api-key` on the same direct provider and takes precedence over OAuth. (3) **Claude CLI** — the explicit `pi-claude-cli` model provider runs sessions through the local `claude` CLI. There is no runtime rerouting between these surfaces. | | `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. | @@ -730,9 +730,9 @@ Manual re-login is still required when no refresh token is stored, the refresh r Anthropic has three independent authentication/routing paths: -- **Anthropic Subscription** (`anthropic-subscription`) is Claude subscription OAuth. It powers login/logout, `/api/auth/status`, usage/subscription checks through `https://api.anthropic.com/api/oauth/usage`, and the OAuth re-login banner. Legacy `anthropic` OAuth rows are treated as this subscription surface. -- **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. 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 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. It is **also an execution surface**: subscription OAuth resolves for the direct `anthropic` provider at runtime, so `anthropic/*` selections run on `https://api.anthropic.com/v1` with Claude Code identity headers (Bearer OAuth, no API key required), and `anthropic/*` rows appear in the model picker when subscription OAuth is connected. +- **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. It is a separate, explicit choice — subscription OAuth does not require or reroute to it. When Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) in addition to the direct `anthropic/*` rows. +- **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential, uses `x-api-key`, and takes precedence over subscription OAuth when both are configured for the direct `https://api.anthropic.com/v1` provider. Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. `/api/auth/status` returns only masked key hints for the API-key card. diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts index 4381bd796..7ae646ce8 100644 --- a/packages/core/src/__tests__/model-pricing.test.ts +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -36,7 +36,7 @@ describe("model-pricing", () => { expect(result.usd).toBeCloseTo(10.0, 2); }); - it("reports direct Anthropic Claude Sonnet 5 pricing as unavailable without a static catalog row", () => { + it("prices direct Anthropic Claude Sonnet 5 from the restored static catalog row", () => { const usage = { inputTokens: 1_000_000, outputTokens: 200_000, @@ -45,12 +45,12 @@ describe("model-pricing", () => { }; const anthropic = costFor(usage, { provider: "anthropic", model: "claude-sonnet-5" }); - expect(anthropic.unavailable).toBe(true); - expect(anthropic.usd).toBeNull(); + expect(anthropic.unavailable).toBe(false); + expect(anthropic.usd).toBeGreaterThan(0); const bare = costFor(usage, { model: "claude-sonnet-5" }); - expect(bare.unavailable).toBe(true); - expect(bare.usd).toBeNull(); + expect(bare.unavailable).toBe(false); + expect(bare.usd).toBeGreaterThan(0); }); it("prices OpenAI Codex GPT-5 models instead of reporting unavailable", () => { @@ -204,11 +204,11 @@ describe("model-pricing", () => { ).toBe(MODEL_PRICING["openai-codex:gpt-5-codex"]); }); - it("does not resolve static pricing for direct Anthropic Claude Sonnet 5", () => { + it("resolves restored static pricing for direct Anthropic Claude Sonnet 5", () => { expect( lookupPricing({ provider: " Anthropic ", model: " Claude-Sonnet-5 " }), - ).toBeUndefined(); - expect(lookupPricing({ model: "claude-sonnet-5" })).toBeUndefined(); + ).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]); + expect(lookupPricing({ model: "claude-sonnet-5" })).toBe(MODEL_PRICING["anthropic:claude-sonnet-5"]); }); it("falls back to a bare model id when provider is unset", () => { diff --git a/packages/core/src/anthropic-models.ts b/packages/core/src/anthropic-models.ts index fa4e1b07d..22c4ab0d1 100644 --- a/packages/core/src/anthropic-models.ts +++ b/packages/core/src/anthropic-models.ts @@ -28,15 +28,33 @@ export interface AnthropicProviderRegistration { } /* - * FNXC:ModelCatalog 2026-07-01-18:05: - * Anthropic's official model overview lists `claude-sonnet-5` as a Claude API ID, but FN-7374 observed sparse provider `404 not_found_error` responses for direct Anthropic accounts after Fusion force-added that ID from static supplemental metadata. Fusion cannot encode per-account/model-surface availability from static docs, so direct Anthropic pickers must rely on the live/upstream registry for Sonnet 5 and only dedupe rows the registry already provides. Existing saved selections keep runtime fallback/actionable failure handling instead of being newly advertised here. + * FNXC:ModelCatalog 2026-07-01-22:40: + * Re-advertise `claude-sonnet-5`: the pinned pi-ai builtin registry ships opus-4-8/sonnet-4-6/fable-5 but NOT sonnet-5, and FN-7374 removed the static row expecting the live registry to carry it — so Sonnet 5 was left visible on no surface at all. FN-7374's "404 for direct accounts" premise is disproven by a live probe: `claude-sonnet-5` returns 200 on `api.anthropic.com/v1` with a raw `ANTHROPIC_API_KEY`, and runs via the Claude CLI/`pi-claude-cli` (claude.ai backend). It DOES 403 (scope) on subscription-OAuth `/v1`, so OAuth-only users fall back to the runtime actionable-failure path; keep it advertised so API-key and CLI users can select it. */ export const SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION: AnthropicProviderRegistration = { name: "Anthropic", baseUrl: "https://api.anthropic.com/v1", apiKey: "$ANTHROPIC_API_KEY", api: "anthropic-messages", - models: [], + models: [ + { + id: CLAUDE_SONNET_5_MODEL_ID, + name: "Claude Sonnet 5", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1_000_000, + maxTokens: 128_000, + compat: { + supportsDeveloperRole: false, + }, + }, + ], }; type AnthropicModelLike = Partial> & { diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts index 664d15eb7..024bc3c9f 100644 --- a/packages/core/src/model-pricing.ts +++ b/packages/core/src/model-pricing.ts @@ -105,9 +105,16 @@ export const MODEL_PRICING: Readonly> = { // ── Anthropic Claude ──────────────────────────────────────────────── // input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL) /* - * FNXC:ModelCatalog 2026-07-01-18:10: - * Do not maintain static pricing for `anthropic:claude-sonnet-5` while Fusion cannot prove that a direct Anthropic account can call the model. Saved selections that hit Anthropic's sparse `not_found_error` should be treated as unavailable/fallback candidates rather than receiving a confident cost from a model row Fusion no longer force-advertises. + * FNXC:ModelCatalog 2026-07-01-22:40: + * `anthropic:claude-sonnet-5` is advertised again (works on raw API key + Claude CLI; live-verified), so restore its static pricing. Matches the cost in SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION. */ + "anthropic:claude-sonnet-5": { + inputPer1M: 2, + outputPer1M: 10, + cacheReadPer1M: 0.2, + cacheWritePer1M: 2.5, + source: "platform.claude.com/docs/en/pricing", + }, "anthropic:claude-opus-4-8": { inputPer1M: 5, outputPer1M: 25, diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 43a7e86c8..b4950cd24 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -398,7 +398,7 @@ describe("GET /models", () => { expect(res.body.models).toEqual([]); }); - it("does not force-add Claude Sonnet 5 for configured direct Anthropic users", async () => { + it("advertises Claude Sonnet 5 for configured direct Anthropic users", async () => { const modelRegistry = createMutableModelRegistry([ { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 }, { id: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 }, @@ -407,13 +407,12 @@ describe("GET /models", () => { const res = await GET(buildApp(modelRegistry), "/api/models"); expect(res.status).toBe(200); + // Sonnet 5 is re-advertised via SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION (works on API key + CLI). expect(res.body.models).toEqual(expect.arrayContaining([ expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-4-5" }), - ])); - expect(res.body.models).not.toEqual(expect.arrayContaining([ expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-5" }), ])); - expect(modelRegistry.registerProvider).not.toHaveBeenCalledWith("anthropic", expect.objectContaining({ + expect(modelRegistry.registerProvider).toHaveBeenCalledWith("anthropic", expect.objectContaining({ models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]), })); }); @@ -434,7 +433,10 @@ describe("GET /models", () => { expect(res.status).toBe(200); expect(res.body.models).toEqual([]); - expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(false); + // Sonnet 5 is merged into the registry from supplemental metadata, but stays hidden + // from the response because no Anthropic auth is configured (provider-visibility filter). + expect(modelRegistry.models.some((model) => model.id === "claude-sonnet-5")).toBe(true); + expect(res.body.models.some((model: { id: string }) => model.id === "claude-sonnet-5")).toBe(false); } finally { readFileSpy.mockRestore(); } @@ -531,7 +533,7 @@ describe("GET /models", () => { })); }); - it("uses authStorage subscription OAuth plus Claude CLI to expose selectable CLI models only", async () => { + it("uses authStorage subscription OAuth plus Claude CLI to expose both direct Anthropic and CLI rows", async () => { await withNoFilesystemProviders(async () => { const authStorage = createMockAuthStorage({ getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]), @@ -549,15 +551,16 @@ describe("GET /models", () => { expect(res.status).toBe(200); expect(res.body.models).not.toEqual([]); const providers = res.body.models.map((m: { provider: string }) => m.provider); + // Subscription OAuth exposes the direct `anthropic` provider AND the CLI is selectable. + expect(providers).toContain("anthropic"); 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 () => { + it("exposes direct Anthropic rows for legacy OAuth while Claude CLI remains selectable", async () => { await withNoFilesystemProviders(async () => { const authStorage = createMockAuthStorage({ getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]), @@ -574,8 +577,11 @@ describe("GET /models", () => { expect(res.status).toBe(200); const providers = res.body.models.map((m: { provider: string }) => m.provider); + // Restored v0.51.0: legacy OAuth makes the direct `anthropic` provider selectable + // (pi-ai runs it on /v1 via Claude Code impersonation). `anthropic-subscription` is + // an auth/status id only, never a picker row. + expect(providers).toContain("anthropic"); 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" }), @@ -604,7 +610,7 @@ describe("GET /models", () => { }); }); - it("does not expose Anthropic rows for authStorage OAuth-only auth when Claude CLI is disabled", async () => { + it("exposes the direct Anthropic provider for subscription OAuth even when Claude CLI is disabled", async () => { await withNoFilesystemProviders(async () => { const authStorage = createMockAuthStorage({ getOAuthProviders: vi.fn().mockReturnValue([{ id: "anthropic", name: "Anthropic" }]), @@ -621,13 +627,15 @@ describe("GET /models", () => { expect(res.status).toBe(200); const providers = res.body.models.map((m: { provider: string }) => m.provider); - expect(providers).not.toContain("anthropic"); + // Subscription OAuth drives the direct `anthropic` provider; the CLI stays hidden + // (disabled) and `anthropic-subscription` is never advertised as its own picker row. + expect(providers).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("exposes direct Anthropic rows for OAuth-only subscription auth alongside distinct Claude CLI rows", async () => { await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => { const value = String(path); if (value.endsWith("auth.json")) { @@ -653,14 +661,16 @@ describe("GET /models", () => { 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); + // auth.json legacy + subscription OAuth both make the direct `anthropic` provider + // selectable; `anthropic-subscription` remains an auth id, not a picker row. + expect(providers).toContain("anthropic"); 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 () => { + it("exposes direct Anthropic rows for OAuth-only auth even when the Claude CLI picker is disabled", async () => { await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => { const value = String(path); if (value.endsWith("auth.json")) { @@ -676,7 +686,9 @@ describe("GET /models", () => { 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"); + // Subscription OAuth (file-scan path) advertises the direct `anthropic` provider; the + // CLI stays hidden while disabled, and `anthropic-subscription` is not a picker row. + expect(providers).toContain("anthropic"); expect(providers).not.toContain("anthropic-subscription"); 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 21a6d7a26..90eb87de4 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -59,9 +59,11 @@ function addAuthStorageConfiguredProviders(authStorage: AuthStorageLike | undefi } } - const anthropicCredential = authStorage.get?.(ANTHROPIC_PROVIDER_ID); - const anthropicApiKeyCredential = authStorage.get?.(ANTHROPIC_API_KEY_PROVIDER_ID); - if (isRawAnthropicApiKeyCredential(anthropicCredential) || isRawAnthropicApiKeyCredential(anthropicApiKeyCredential)) { + /* + FNXC:ProviderAuth 2026-07-01-15:10: + Advertise the direct `anthropic` provider whenever auth storage reports usable anthropic auth — raw API key, subscription OAuth, legacy OAuth, or fallback. Restored v0.51.0 behavior (issue #1857): a subscription/OAuth token executes on the built-in `anthropic` provider via pi-ai's Claude Code impersonation, so OAuth-only users must be able to pick Claude models. `hasAuth("anthropic")` already unifies these sources. + */ + if (authStorage.hasAuth?.(ANTHROPIC_PROVIDER_ID) || authStorage.hasAuth?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)) { providers.add(ANTHROPIC_PROVIDER_ID); } } @@ -85,13 +87,19 @@ async function getConfiguredProviderNames(authStorage?: AuthStorageLike): Promis const parsed = JSON.parse(await readFile(authPath, "utf-8")) as Record; for (const [key, credential] of Object.entries(parsed)) { if (key === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { + // A separated subscription OAuth row makes the direct `anthropic` provider usable. + providers.add(ANTHROPIC_PROVIDER_ID); continue; } if (key !== ANTHROPIC_PROVIDER_ID) { providers.add(key); continue; } - if (credential && typeof credential === "object" && (credential as { type?: unknown }).type === "api_key") { + // Raw API key OR OAuth (legacy subscription) both configure the direct `anthropic` provider. + const credType = credential && typeof credential === "object" + ? (credential as { type?: unknown }).type + : undefined; + if (credType === "api_key" || credType === "oauth") { providers.add(key); } } @@ -101,14 +109,11 @@ async function getConfiguredProviderNames(authStorage?: AuthStorageLike): Promis } /* - 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. + FNXC:ProviderAuth 2026-07-01-15:10: + Anthropic's three surfaces in discovery (restored v0.51.0 behavior, issue #1857): the direct `anthropic` provider is advertised for raw API-key auth (auth.json `type: api_key`, models.json apiKey, `ANTHROPIC_API_KEY`) AND for subscription/legacy OAuth (which executes on the built-in `anthropic` provider via pi-ai's Claude Code impersonation to /v1). `anthropic-subscription` is an auth/usage credential id, never its own picker row. Claude CLI models appear as `pi-claude-cli` only 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. + `/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, then fall back to legacy files/env so v0.50-style local API-key discovery still works. */ if (process.env.ANTHROPIC_API_KEY) { providers.add(ANTHROPIC_PROVIDER_ID); diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index 1d69ba2fb..01875d737 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -165,7 +165,9 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + // Restored v0.51.0 behavior: a Claude subscription OAuth token resolves for the + // direct `anthropic` provider (pi-ai POSTs it to /v1 with Claude Code impersonation). + expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); expect(authStorage.get("anthropic")).toEqual({ type: "oauth", access: "claude-access-token", @@ -216,7 +218,7 @@ describe("createFusionAuthStorage", () => { expect(authStorage.hasAuth("anthropic")).toBe(true); }); - it("does not use Anthropic subscription OAuth for direct model runtime auth when no raw API key exists", async () => { + it("uses Anthropic subscription OAuth for direct model runtime auth when no raw API key exists", async () => { writeFusionAuth(homeDir, { "anthropic-subscription": { type: "oauth", @@ -228,7 +230,8 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + // The direct `anthropic` provider resolves the separated subscription OAuth token. + expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); 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"])); @@ -246,7 +249,8 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + // Legacy `anthropic` OAuth rows still drive the direct provider at runtime. + expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token"); expect(authStorage.get("anthropic-subscription")).toEqual({ type: "oauth", access: "legacy-subscription-access-token", @@ -258,7 +262,7 @@ describe("createFusionAuthStorage", () => { expect(await authStorage.getApiKey("anthropic-subscription")).toBe("legacy-subscription-access-token"); }); - it("refreshes legacy Anthropic OAuth into the subscription provider id", async () => { + it("refreshes legacy Anthropic OAuth in place for direct runtime auth", async () => { writeFusionAuth(homeDir, { anthropic: { type: "oauth", @@ -271,8 +275,8 @@ describe("createFusionAuthStorage", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ - access_token: "refreshed-legacy-as-subscription-access-token", - refresh_token: "rotated-legacy-subscription-refresh-token", + access_token: "refreshed-legacy-access-token", + refresh_token: "rotated-legacy-refresh-token", expires_in: 3600, scope: "user:profile", }), @@ -281,19 +285,13 @@ describe("createFusionAuthStorage", () => { 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"], - }); + // A legacy `anthropic` OAuth row refreshes and persists back under `anthropic` + // (the v0.51.0 direct-provider path); it is not re-homed into a subscription slot. + expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-legacy-access-token"); expect(authStorage.get("anthropic")).toEqual({ type: "oauth", - access: "expired-legacy-subscription-access-token", - refresh: "legacy-subscription-refresh-token", + access: "refreshed-legacy-access-token", + refresh: "rotated-legacy-refresh-token", expires: expect.any(Number), scopes: ["user:profile"], }); @@ -363,7 +361,9 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + // Refreshed subscription OAuth drives the direct provider and persists under the + // subscription id only — the raw `anthropic` slot stays empty. + expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-subscription-access-token"); expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-subscription-access-token"); expect(fetchMock).toHaveBeenCalledWith( "https://platform.claude.com/v1/oauth/token", @@ -429,11 +429,13 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); authStorage.logout("anthropic"); + // Raw-key logout removes only the raw slot; subscription OAuth still powers + // the direct `anthropic` runtime provider. expect(authStorage.get("anthropic")).toBeUndefined(); 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")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token"); }); @@ -454,7 +456,7 @@ describe("createFusionAuthStorage", () => { }); expect(authStorage.get("anthropic")).toBeUndefined(); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token"); }); @@ -488,10 +490,12 @@ describe("createFusionAuthStorage", () => { }); const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + // Before logout the legacy OAuth row drives direct runtime auth… + expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token"); authStorage.logout("anthropic-subscription"); + // …and subscription logout suppresses the legacy OAuth alias everywhere. expect(authStorage.get("anthropic")).toBeUndefined(); expect(authStorage.has("anthropic")).toBe(false); expect(authStorage.hasAuth("anthropic")).toBe(false); @@ -590,12 +594,12 @@ describe("createFusionAuthStorage", () => { }); authStorage.reload(); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token"); }); }); - it("does not refresh Claude OAuth credentials as direct Anthropic API keys", async () => { + it("refreshes and persists expired Claude OAuth credentials from Claude credential files", async () => { const claudeDir = join(homeDir, ".claude"); mkdirSync(claudeDir, { recursive: true }); @@ -611,24 +615,43 @@ describe("createFusionAuthStorage", () => { }), ); - const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response); + 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); globalThis.fetch = fetchMock as typeof fetch; const authStorage = createFusionAuthStorage(); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); + 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(authStorage.get("anthropic")).toEqual({ type: "oauth", - access: "expired-claude-access-token", - refresh: "claude-refresh-token", + 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", 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 () => { @@ -680,7 +703,8 @@ describe("createFusionAuthStorage", () => { expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); + // Failed refresh is attempted once, then cooled down (second call does not re-hit the endpoint). + expect(fetchMock).toHaveBeenCalledTimes(1); const persisted = existsSync(getFusionAuthPath(homeDir)) ? JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")) : {}; expect(persisted.anthropic).toBeUndefined(); }); @@ -716,14 +740,14 @@ describe("createFusionAuthStorage", () => { authStorage.getApiKey("anthropic"), authStorage.getApiKey("anthropic"), ])).resolves.toEqual([ - undefined, - undefined, - undefined, + "refreshed-claude-access-token", + "refreshed-claude-access-token", + "refreshed-claude-access-token", ]); - expect(fetchMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("does not refresh stale Claude OAuth material for direct Anthropic API auth", async () => { + it("does not let a stale Claude OAuth refresh overwrite a newer login", async () => { const claudeDir = join(homeDir, ".claude"); mkdirSync(claudeDir, { recursive: true }); @@ -738,13 +762,39 @@ describe("createFusionAuthStorage", () => { }), ); - const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response); + let resolveJson: ((value: unknown) => void) | undefined; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => new Promise((resolve) => { + resolveJson = resolve; + }), + } 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(authStorage.getApiKey("anthropic")).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); + 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), + }); }); it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => { @@ -996,10 +1046,10 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); - // Before logout, supplemental credentials are visible + // Before logout, supplemental credentials are visible and drive runtime auth expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true); - expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); + expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); // 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 67c62ea87..774cde836 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -1651,28 +1651,27 @@ describe("createFnAgent", () => { }); }); - it("does not synthesize direct Anthropic Claude Sonnet 5 when the registry lacks it", async () => { - getAllMock.mockReturnValueOnce([]); - findMock.mockImplementation((provider: string, modelId: string) => { - if (provider === "anthropic" && modelId === "claude-sonnet-5") { - return undefined; - } - return { provider, id: modelId }; - }); + it("synthesizes direct Anthropic Claude Sonnet 5 from supplemental metadata when the live registry lacks it", async () => { + // Live registry has no anthropic models; mergeSupplementalAnthropicModels re-adds Sonnet 5. + getAllMock.mockReturnValue([]); + findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); const { createFnAgent } = await import("../pi.js"); - await expect(createFnAgent({ + await createFnAgent({ cwd: "/tmp", systemPrompt: "test", tools: "readonly", defaultProvider: "anthropic", defaultModelId: "claude-sonnet-5", - })).rejects.toThrow("Configured model anthropic/claude-sonnet-5 (primary selection) was not found in the pi model registry"); + }); - expect(registerProviderMock).not.toHaveBeenCalledWith("anthropic", expect.objectContaining({ + // SUPPLEMENTAL_ANTHROPIC_PROVIDER_REGISTRATION advertises claude-sonnet-5 on the direct provider again. + expect(registerProviderMock).toHaveBeenCalledWith("anthropic", expect.objectContaining({ models: expect.arrayContaining([expect.objectContaining({ id: "claude-sonnet-5" })]), })); - expect(createAgentSessionMock).not.toHaveBeenCalled(); + expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ + model: { provider: "anthropic", id: "claude-sonnet-5" }, + })); }); it("does not duplicate Claude Sonnet 5 when the Anthropic registry already has it", async () => { @@ -1703,25 +1702,19 @@ describe("createFnAgent", () => { expect(anthropicRegistrations).toHaveLength(0); }); - it("routes subscription-OAuth persisted Anthropic selections to the direct subscription provider even when the CLI picker toggle is unset", async () => { + // Restored v0.51.0 behavior: a subscription-OAuth `anthropic/` selection stays on + // the built-in `anthropic` provider (pi-ai POSTs the OAuth token to /v1 with Claude Code + // impersonation). No `/v1`-based `anthropic-subscription` provider is registered, and there + // is no runtime reroute to `pi-claude-cli`. + it("keeps subscription-OAuth Anthropic selections on the direct anthropic provider", 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); + // Model selection no longer reads getApiKey (the reroute was removed); in production + // getApiKey("anthropic") returns the OAuth token, resolved later at session execution. 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 }; - } - return { provider, id: modelId }; - }); + findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); const { createFnAgent } = await import("../pi.js"); await createFnAgent({ @@ -1732,14 +1725,10 @@ 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" }, + model: { provider: "anthropic", id: "claude-opus-4-8" }, })); + expect(registerProviderMock).not.toHaveBeenCalledWith("anthropic-subscription", expect.anything()); expect(createAgentSessionMock).not.toHaveBeenCalledWith(expect.objectContaining({ model: expect.objectContaining({ provider: "pi-claude-cli" }), })); @@ -1783,16 +1772,16 @@ describe("createFnAgent", () => { })); }); + // Subscription OAuth must NOT depend on the Claude CLI provider being present — direct + // OAuth to /v1 is its own surface. With pi-claude-cli unavailable it still runs on `anthropic`. 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); + // Model selection no longer reads getApiKey (the reroute was removed); production + // resolves the OAuth token later, at session execution. 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; } @@ -1810,7 +1799,7 @@ describe("createFnAgent", () => { }); expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({ - model: { provider: "anthropic-subscription", id: "claude-opus-4-8" }, + model: { provider: "anthropic", id: "claude-opus-4-8" }, })); }); diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 4437232c2..ee67c9e42 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -514,19 +514,43 @@ export function createFusionAuthStorage(): AuthStorage { const resolveAnthropicRuntimeApiKey = async (): Promise => { const rawProviderLoggedOut = isAnthropicRawProviderLoggedOut(); + + /* + FNXC:ProviderAuth 2026-07-01-14:55: + Anthropic runtime auth (`getApiKey("anthropic")`) resolves in precedence order: (1) raw API key, (2) legacy `anthropic` OAuth, (3) separated `anthropic-subscription` OAuth, (4) models.json / ModelRegistry fallback raw key. Raw key wins so an explicit `ANTHROPIC_API_KEY` keeps using x-api-key; subscription/OAuth tokens must resolve here so the built-in provider runs them on `/v1` with Claude Code impersonation. Do NOT gate OAuth behind the CLI or reroute it to an `/v1` `anthropic-subscription` provider — that reintroduced the #1857 regression (FN-7391/FN-7396). + */ if (!rawProviderLoggedOut) { const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key"); if (anthropicApiKeyCredential) { return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential); } + } - /* - 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. + 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: + The separated subscription login stores OAuth material under `anthropic-subscription` so the API-key card stays raw-key-only, but Anthropic model execution still requests provider `anthropic`. Resolve and refresh the subscription credential (persisting rotated tokens back to `anthropic-subscription`) so a subscription user's `anthropic/` selection runs on their OAuth token. + */ + const subscriptionKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential); + if (subscriptionKey) return subscriptionKey; + } + } + + if (!rawProviderLoggedOut) { + /* 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. + 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; subscription OAuth stays governed by the separate `anthropic-subscription` logout state above. */ const modelsJsonApiKey = modelsJsonApiKeys.get(ANTHROPIC_PROVIDER_ID); if (modelsJsonApiKey) return modelsJsonApiKey; diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index f272b2307..662253cb4 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -31,7 +31,6 @@ import { ModelRegistry, SessionManager, SettingsManager, - type AuthStorage, type AgentSession, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; @@ -83,8 +82,6 @@ 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 ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; export type RtkRewriteMode = "off" | "rewrite"; @@ -1174,79 +1171,10 @@ function readJsonObject(path: string): Record { } } -function resolveAnthropicSubscriptionModelForAnthropicSelection( - modelRegistry: ModelRegistry, - kind: "primary" | "fallback", - model: ReturnType, -) { - if (!model) return model; - const subscriptionModel = modelRegistry.find(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, model.id); - if (subscriptionModel) { - return subscriptionModel; - } - - 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; - } - - /* - 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( - 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; - } - - const hasSubscriptionOAuth = authStorage.hasAuth?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) === true; - if (!hasSubscriptionOAuth) { - return model; - } - - /* - 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 resolveAnthropicSubscriptionModelForAnthropicSelection(modelRegistry, kind, model); -} +/* +FNXC:ProviderAuth 2026-07-01-14:55: +Anthropic has three independent execution surfaces with NO runtime rerouting: direct OAuth and raw API key both run on pi-ai's built-in `anthropic` provider (OAuth → `/v1` with Claude Code impersonation; raw key → x-api-key), and explicit `pi-claude-cli/` runs the vendored CLI. Do NOT register or route through an `/v1`-based `anthropic-subscription` provider — that reroute reintroduced the #1857 regression (FN-7391/FN-7396). +*/ function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void { const entries = sessionManager.fileEntries; @@ -2144,7 +2072,6 @@ 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 @@ -2242,19 +2169,6 @@ 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) { diff --git a/packages/pi-claude-cli/package.json b/packages/pi-claude-cli/package.json index cc09247ff..a312f972d 100644 --- a/packages/pi-claude-cli/package.json +++ b/packages/pi-claude-cli/package.json @@ -23,8 +23,8 @@ "@agentclientprotocol/sdk": "0.24.0" }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*" + "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-coding-agent": "^0.80.3" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95c8033f6..ed7d89a23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -610,11 +610,11 @@ importers: specifier: 0.24.0 version: 0.24.0(zod@4.3.6) '@earendil-works/pi-ai': - specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + specifier: ^0.80.3 + version: 0.80.3(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': - specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + specifier: ^0.80.3 + version: 0.80.3(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) devDependencies: '@types/node': specifier: ^25.5.2