fix(anthropic): restore direct OAuth execution for Claude subscriptions
Claude subscription (Max/Pro) chats regressed to 404/502/429 because FN-7396 rerouted subscription OAuth to a /v1-based `anthropic-subscription` runtime provider — reintroducing issue #1857 that FN-7391 had fixed. Both routed the OAuth token to api.anthropic.com/v1, the surface that broke. Proven in code that v0.51.0 (working) sent subscription OAuth directly to /v1 via pi-ai's built-in `anthropic` provider (Claude Code impersonation: Bearer + anthropic-beta oauth headers), NOT through the CLI. Restore that: - auth-storage: getApiKey("anthropic") resolves subscription/legacy OAuth again (raw API key still wins), so the built-in provider gets the token. - pi.ts: remove the runtime reroute and the /v1 `anthropic-subscription` execution provider so anthropic/* selections stay on the built-in provider. - register-model-routes: advertise `anthropic` for OAuth users so direct OAuth is selectable in the picker. Three independent surfaces, no rerouting: direct OAuth, raw ANTHROPIC_API_KEY (precedence), and explicit pi-claude-cli. Fixes #1857 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/fix-anthropic-oauth-direct-access.md
Normal file
7
.changeset/fix-anthropic-oauth-direct-access.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, ModelPricing>` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. |
|
||||
| `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing summary. |
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -531,7 +531,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 +549,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 +575,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 +608,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 +625,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 +659,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 +684,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");
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
await expect(authStorage.getApiKey("anthropic")).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
authStorage.set("anthropic", {
|
||||
type: "oauth",
|
||||
access: "fresh-login-access-token",
|
||||
refresh: "fresh-login-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
resolveJson?.({
|
||||
access_token: "stale-refresh-access-token",
|
||||
refresh_token: "stale-refresh-refresh-token",
|
||||
expires_in: 3600,
|
||||
});
|
||||
|
||||
await expect(pendingRefresh).resolves.toBe("fresh-login-access-token");
|
||||
expect(authStorage.get("anthropic")).toEqual({
|
||||
type: "oauth",
|
||||
access: "fresh-login-access-token",
|
||||
refresh: "fresh-login-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -1703,25 +1703,18 @@ 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/<model>` 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);
|
||||
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,6 +1772,8 @@ 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 }
|
||||
@@ -1790,9 +1781,6 @@ describe("createFnAgent", () => {
|
||||
authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription");
|
||||
authStorageGetApiKeyMock.mockResolvedValue(undefined);
|
||||
findMock.mockImplementation((provider: string, modelId: string) => {
|
||||
if (provider === "anthropic" && modelId === "claude-opus-4-8") {
|
||||
return { provider, id: modelId };
|
||||
}
|
||||
if (provider === "pi-claude-cli") {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1810,7 +1798,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" },
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -514,19 +514,43 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
|
||||
const resolveAnthropicRuntimeApiKey = async (): Promise<string | undefined> => {
|
||||
const rawProviderLoggedOut = isAnthropicRawProviderLoggedOut();
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-01-14:55:
|
||||
Restore the v0.51.0 direct-OAuth execution path (regressed by FN-7291 → FN-7391 → FN-7396, all built on issue #1857's incorrect "Anthropic blocks subscription OAuth on /v1, must use the CLI" conclusion). PROVEN in code: at v0.51.0 `getApiKey("anthropic")` returned the subscription OAuth access token, and pi-ai's built-in `anthropic` provider POSTs it to `api.anthropic.com/v1/messages` with full Claude Code impersonation (`Authorization: Bearer` + `anthropic-beta: claude-code-20250219,oauth-2025-04-20`), which Anthropic accepts. So `anthropic` runtime auth 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 still wins so an explicit `ANTHROPIC_API_KEY` keeps using x-api-key. Direct OAuth, raw API key, and explicit `pi-claude-cli` remain three independent surfaces.
|
||||
*/
|
||||
if (!rawProviderLoggedOut) {
|
||||
const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
|
||||
if (anthropicApiKeyCredential) {
|
||||
return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential);
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionLoggedOut = loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
const legacyAnthropicOAuthCredential = rawProviderLoggedOut
|
||||
? undefined
|
||||
: selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth");
|
||||
if (!subscriptionLoggedOut && legacyAnthropicOAuthCredential) {
|
||||
const legacyKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_PROVIDER_ID, legacyAnthropicOAuthCredential);
|
||||
if (legacyKey) return legacyKey;
|
||||
}
|
||||
|
||||
if (!subscriptionLoggedOut) {
|
||||
const subscriptionCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
if (subscriptionCredential?.type === "oauth") {
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-06-30-11:26:
|
||||
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/<model>` selection runs on their OAuth token.
|
||||
*/
|
||||
const subscriptionKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);
|
||||
if (subscriptionKey) return subscriptionKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawProviderLoggedOut) {
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-01-11:55:
|
||||
Subscription/OAuth Anthropic tokens must never authenticate the direct `api.anthropic.com/v1` provider because Anthropic blocks Claude Pro/Max OAuth material on that public API surface. Keep provider `anthropic` raw-key-only here; the `anthropic-subscription` OAuth getter below remains available for Claude CLI and usage endpoints that intentionally consume subscription OAuth.
|
||||
|
||||
FNXC:ProviderAuth 2026-06-30-13:28:
|
||||
Logging out of the raw Anthropic provider must suppress raw-key sources consistently across status and runtime resolution.
|
||||
Treat models.json Anthropic keys and ModelRegistry fallback resolver keys as raw-key fallback material, while subscription OAuth remains governed by the separate `anthropic-subscription` logout state.
|
||||
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;
|
||||
|
||||
@@ -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<string, any> {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnthropicSubscriptionModelForAnthropicSelection(
|
||||
modelRegistry: ModelRegistry,
|
||||
kind: "primary" | "fallback",
|
||||
model: ReturnType<typeof resolveConfiguredModel>,
|
||||
) {
|
||||
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<typeof resolveConfiguredModel>,
|
||||
) {
|
||||
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/<model>` 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 surfaces and NO runtime rerouting between them: (1) direct OAuth — a subscription/OAuth `anthropic/<model>` selection executes on pi-ai's built-in `anthropic` provider, which detects the `sk-ant-oat` token and POSTs to `api.anthropic.com/v1/messages` with full Claude Code impersonation (the v0.51.0 working path; `authStorage.getApiKey("anthropic")` supplies the OAuth token); (2) raw API key — a configured `ANTHROPIC_API_KEY` takes precedence in `getApiKey("anthropic")` and uses x-api-key on the same built-in provider; (3) Claude CLI — an explicit `pi-claude-cli/<model>` selection runs through the vendored CLI extension. FN-7291/FN-7391/FN-7396 added an `/v1`-based `anthropic-subscription` reroute on the incorrect premise that Anthropic blocks subscription OAuth on `/v1`; that reroute is intentionally absent so direct OAuth is not re-broken (issue #1857).
|
||||
*/
|
||||
|
||||
function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void {
|
||||
const entries = sessionManager.fileEntries;
|
||||
@@ -2144,7 +2072,6 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
}
|
||||
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<AgentResult>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user