FN-7396: separate Anthropic OAuth routing paths
Separate Anthropic subscription OAuth execution from raw API-key and Claude CLI routing. - Route subscription-backed Anthropic selections through a dedicated anthropic-subscription provider instead of raw anthropic API-key storage or implicit CLI execution. - Preserve Anthropic OAuth refresh, status, usage, and re-login banner behavior across separated and legacy credential rows. - Add dashboard and engine coverage for OAuth subscription auth, raw API-key isolation, and explicit Claude CLI routing. - Document the separated Anthropic subscription, API-key, and CLI surfaces and add a release changeset. Files changed: .../fn-7396-anthropic-oauth-cli-api-paths.md | 7 ++ docs/dashboard-guide.md | 4 +- docs/settings-reference.md | 12 ++- .../__tests__/OAuthReloginBanner.test.tsx | 118 +++++++++++++++++++++ .../dashboard/src/__tests__/routes-auth.test.ts | 29 +++++ packages/dashboard/src/__tests__/usage.test.ts | 109 ++++++++++++++++++- .../dashboard/src/routes/register-auth-routes.ts | 20 +++- packages/dashboard/src/usage.ts | 21 +++- packages/engine/src/__tests__/auth-storage.test.ts | 65 ++++++++++++ .../src/__tests__/pi-create-fn-agent.test.ts | 55 ++++++++-- packages/engine/src/auth-storage.ts | 61 +++++++++-- packages/engine/src/pi.ts | 70 ++++++++---- 12 files changed, 521 insertions(+), 50 deletions(-) Fusion-Task-Id: FN-7396 Fusion-Task-Lineage: f5f42a40-b855-46ad-b2ae-3509e903f286 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7396-anthropic-oauth-cli-api-paths.md
Normal file
7
.changeset/fn-7396-anthropic-oauth-cli-api-paths.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Keep Anthropic subscription OAuth, Claude CLI, and direct API-key auth separated.
|
||||
category: fix
|
||||
dev: Restores anthropic-subscription status/usage/banner behavior and direct subscription-backed execution while keeping raw anthropic API-key auth and explicit pi-claude-cli execution separate.
|
||||
@@ -623,11 +623,11 @@ Branch names are dynamic from merge/audit payloads; the banner is not hardcoded
|
||||
|
||||
The global OAuth re-login banner clears a provider row immediately after that provider successfully re-authenticates (from Settings → Authentication or Model Onboarding), instead of waiting for the next `GET /auth/status` poll interval.
|
||||
|
||||
For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. When that refresh succeeds, the banner clears for Claude without manual re-login and without waiting for a separate model request.
|
||||
For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the subscription provider without manual re-login and without waiting for a separate model request.
|
||||
|
||||
If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding.
|
||||
|
||||
Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card, so saving or clearing an API key does not affect the OAuth sign-in path. The dashboard only displays masked key hints after a key is saved.
|
||||
Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, banner clearing, and subscription-backed direct agent execution through the dedicated `anthropic-subscription` path; CLI-backed execution remains the distinct **Claude CLI** provider (`pi-claude-cli`). Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved.
|
||||
|
||||
## Smart Pull
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`.
|
||||
| `language` | `"en" \| "zh-CN" \| "zh-TW" \| "fr" \| "es" \| "ko"` | `undefined` | UI language for the dashboard and TUI. When unset, the dashboard detects from localStorage → browser language and the CLI from `--lang` flag → environment locale, falling back to `en`. Validated at the store write boundary (`validateLocale`); invalid values are dropped. Reset to auto-detect via the dashboard's "Auto" language option or `fn settings set language auto` (clears the persisted key). |
|
||||
| `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. |
|
||||
| `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. |
|
||||
| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three distinct surfaces: direct `anthropic` uses raw API-key material only (`ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential); subscription OAuth remains the `anthropic-subscription` auth/usage credential; Claude CLI execution uses the `pi-claude-cli` model provider. OAuth-only Anthropic selections are never sent to `api.anthropic.com/v1`; Fusion routes them to the CLI provider when available or fails with a configuration error. |
|
||||
| `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three distinct surfaces: direct `anthropic` uses raw API-key material only (`ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential); subscription OAuth uses the dedicated `anthropic-subscription` auth/status/usage/banner and direct execution path; Claude CLI execution uses the explicit `pi-claude-cli` model provider. OAuth-backed execution never stores or resolves subscription tokens as raw `ANTHROPIC_API_KEY` material. |
|
||||
| `defaultModelId` | `string` | `undefined` | Default AI model ID. |
|
||||
| `modelPricingOverrides` | `Record<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. |
|
||||
@@ -722,13 +722,19 @@ Recovery entrypoints in the dashboard:
|
||||
|
||||
### OAuth credential refresh
|
||||
|
||||
Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporting auth status when the stored OAuth credential includes a refresh token and the access token is expired or within the refresh buffer. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial Claude OAuth login.
|
||||
Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporting auth status when the stored OAuth credential includes a refresh token and the access token is expired or within the refresh buffer. For Anthropic, this status path is the `anthropic-subscription` surface (including legacy `anthropic` OAuth rows), not Claude CLI state. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial Claude OAuth login.
|
||||
|
||||
Manual re-login is still required when no refresh token is stored, the refresh request fails, or the expired OAuth credential belongs to a non-Anthropic provider. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding.
|
||||
|
||||
### Anthropic API-key authentication
|
||||
|
||||
Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card for `ANTHROPIC_API_KEY`, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. `/api/auth/status` returns only masked key hints for the API-key card.
|
||||
Anthropic has three independent authentication/routing paths:
|
||||
|
||||
- **Anthropic Subscription** (`anthropic-subscription`) is Claude subscription OAuth. It powers login/logout, `/api/auth/status`, usage/subscription checks through `https://api.anthropic.com/api/oauth/usage`, and the OAuth re-login banner. Legacy `anthropic` OAuth rows are treated as this subscription surface.
|
||||
- **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid.
|
||||
- **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth only. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential and is the only path used for `https://api.anthropic.com/v1` requests.
|
||||
|
||||
Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. `/api/auth/status` returns only masked key hints for the API-key card.
|
||||
|
||||
### Authentication troubleshooting (mobile OAuth fallback)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OAuthReloginBanner } from "../OAuthReloginBanner";
|
||||
import { OAUTH_RELOGIN_SUCCESS_EVENT } from "../../auth";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string, values?: Record<string, string>) => {
|
||||
let text = fallback ?? _key;
|
||||
if (values) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
text = text.replaceAll(`{{${key}}}`, value);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("OAuthReloginBanner", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockFetchAuthStatus.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("shows expired Anthropic subscription OAuth even when Claude CLI is authenticated", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true },
|
||||
{ id: "claude-cli", name: "Claude CLI", type: "cli", authenticated: true },
|
||||
],
|
||||
ghCli: { available: false, authenticated: false },
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Re-login required");
|
||||
});
|
||||
|
||||
it("clears the banner when OAuth success is dispatched for the status provider id", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true },
|
||||
],
|
||||
ghCli: { available: false, authenticated: false },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: true, expired: false },
|
||||
],
|
||||
ghCli: { available: false, authenticated: false },
|
||||
});
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription");
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "anthropic-subscription" } }));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps dismissed state scoped to currently expired provider ids", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true },
|
||||
],
|
||||
ghCli: { available: false, authenticated: false },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [],
|
||||
ghCli: { available: false, authenticated: false },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true },
|
||||
],
|
||||
ghCli: { available: false, authenticated: false },
|
||||
});
|
||||
|
||||
const { unmount } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
|
||||
unmount();
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
unmount();
|
||||
|
||||
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("OpenAI Codex");
|
||||
});
|
||||
});
|
||||
@@ -944,6 +944,35 @@ describe("GET /auth/status", () => {
|
||||
expect(anthropic).toMatchObject({ authenticated: true, expired: false });
|
||||
});
|
||||
|
||||
it("reports legacy Anthropic OAuth expiry under the subscription status id without using CLI state", async () => {
|
||||
const now = Date.now();
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
]);
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic-subscription");
|
||||
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => {
|
||||
if (provider === "anthropic") {
|
||||
return {
|
||||
type: "oauth",
|
||||
access: "expired-legacy-token",
|
||||
refresh: "refresh",
|
||||
expires: now - 1_000,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
(authStorage.getApiKey as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("refresh failed"));
|
||||
|
||||
const res = await GET(app, "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription");
|
||||
const claudeCli = res.body.providers.find((p: any) => p.id === "claude-cli");
|
||||
expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
|
||||
expect(anthropic).toMatchObject({ authenticated: false, expired: true });
|
||||
expect(claudeCli).toMatchObject({ type: "cli" });
|
||||
});
|
||||
|
||||
it("keeps expired oauth status when async refresh fails", async () => {
|
||||
const now = Date.now();
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
|
||||
@@ -383,7 +383,114 @@ describe("usage", () => {
|
||||
expect(claude).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads Claude credentials from Fusion auth-storage anthropic oauth when no CLI files exist", async () => {
|
||||
it("reads Claude credentials from Fusion auth-storage anthropic-subscription OAuth before CLI files", async () => {
|
||||
mockReadFile.mockImplementation((filePath: string) => {
|
||||
if (filePath.includes("claude")) {
|
||||
return JSON.stringify({ accessToken: "legacy-cli-token", scopes: ["user:profile"], subscriptionType: "free" });
|
||||
}
|
||||
return Promise.reject(new Error("File not found"));
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
|
||||
const authStorage = {
|
||||
reload: vi.fn(),
|
||||
hasAuth: vi.fn((provider: string) => provider === "anthropic-subscription"),
|
||||
get: vi.fn((provider: string) => {
|
||||
if (provider !== "anthropic-subscription") return null;
|
||||
return {
|
||||
type: "oauth",
|
||||
access: "subscription-access-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
scopes: ["user:profile"],
|
||||
subscriptionType: "pro",
|
||||
};
|
||||
}),
|
||||
getApiKey: vi.fn(),
|
||||
};
|
||||
|
||||
const requestUrls: string[] = [];
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
requestUrls.push(`https://${options.hostname}${options.path}`);
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({ five_hour: { utilization: 22.0 } })));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage(authStorage);
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(claude.status).toBe("ok");
|
||||
expect(claude.plan).toBe("Pro");
|
||||
expect(authStorage.get).toHaveBeenCalledWith("anthropic-subscription");
|
||||
expect(authStorage.get).not.toHaveBeenCalledWith("anthropic");
|
||||
expect(authStorage.getApiKey).not.toHaveBeenCalledWith("anthropic-subscription");
|
||||
expect(requestUrls[0]).toContain("oauth/usage");
|
||||
});
|
||||
|
||||
it("refreshes expired separated Fusion subscription OAuth through auth storage before usage API", async () => {
|
||||
mockReadFile.mockRejectedValue(new Error("File not found"));
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
let refreshed = false;
|
||||
const authStorage = {
|
||||
reload: vi.fn(),
|
||||
hasAuth: vi.fn(() => true),
|
||||
get: vi.fn((provider: string) => {
|
||||
if (provider !== "anthropic-subscription") return null;
|
||||
return {
|
||||
type: "oauth",
|
||||
access: refreshed ? "refreshed-subscription-token" : "expired-subscription-token",
|
||||
refresh: "subscription-refresh-token",
|
||||
expires: refreshed ? Date.now() + 60 * 60 * 1000 : Date.now() - 60_000,
|
||||
scopes: ["user:profile"],
|
||||
};
|
||||
}),
|
||||
getApiKey: vi.fn(async (provider: string) => {
|
||||
if (provider === "anthropic-subscription") {
|
||||
refreshed = true;
|
||||
return "refreshed-subscription-token";
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
let capturedAuthorization: string | undefined;
|
||||
const mockReq = { on: vi.fn(), write: vi.fn(), end: vi.fn() };
|
||||
mockRequest.mockImplementation((options: any, callback: any) => {
|
||||
capturedAuthorization = options.headers.authorization;
|
||||
const mockRes = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: vi.fn((event: string, handler: any) => {
|
||||
if (event === "data") handler(Buffer.from(JSON.stringify({ five_hour: { utilization: 12.0 } })));
|
||||
if (event === "end") handler();
|
||||
}),
|
||||
};
|
||||
callback(mockRes);
|
||||
return mockReq;
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage(authStorage);
|
||||
const claude = providers.find((p) => p.name === "Claude")!;
|
||||
|
||||
expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic-subscription");
|
||||
expect(capturedAuthorization).toBe("Bearer refreshed-subscription-token");
|
||||
expect(claude.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("reads Claude credentials from legacy Fusion auth-storage anthropic oauth when no separated credential exists", async () => {
|
||||
mockReadFile.mockImplementation(async () => {
|
||||
return Promise.reject(new Error("File not found"));
|
||||
});
|
||||
|
||||
@@ -48,9 +48,25 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
return key.slice(0, 3) + "•••••" + key.slice(-4);
|
||||
}
|
||||
|
||||
function isExpiredOauthCredential(providerId: string, storage: AuthStorageLike): boolean {
|
||||
function getOauthStatusCredential(providerId: string, storage: AuthStorageLike) {
|
||||
const credential = storage.get?.(providerId);
|
||||
if (!credential || credential.type !== "oauth" || typeof credential.expires !== "number") {
|
||||
if (credential?.type === "oauth") {
|
||||
return credential;
|
||||
}
|
||||
if (providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-01-12:46:
|
||||
The OAuth re-login banner is keyed by the dashboard status id `anthropic-subscription`, but pre-split installs may still hold Claude subscription OAuth in the legacy `anthropic` row. Read that legacy OAuth only for subscription status; raw API-key status remains on the separate `anthropic-api-key` card.
|
||||
*/
|
||||
const legacyCredential = storage.get?.(ANTHROPIC_OAUTH_PROVIDER_ID);
|
||||
return legacyCredential?.type === "oauth" ? legacyCredential : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isExpiredOauthCredential(providerId: string, storage: AuthStorageLike): boolean {
|
||||
const credential = getOauthStatusCredential(providerId, storage);
|
||||
if (!credential || typeof credential.expires !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,9 @@ export interface AuthCredentialEntry {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
|
||||
const ANTHROPIC_LEGACY_OAUTH_PROVIDER_ID = "anthropic";
|
||||
|
||||
// Cache for usage data with TTL
|
||||
interface CacheEntry {
|
||||
data: ProviderUsage[];
|
||||
@@ -886,8 +889,21 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<Provider
|
||||
// Reload may fail if no storage - ignore
|
||||
}
|
||||
try {
|
||||
const fusionCreds = authStorage?.get?.("anthropic");
|
||||
if (fusionCreds?.type === "oauth" && fusionCreds.access) {
|
||||
for (const providerId of [ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, ANTHROPIC_LEGACY_OAUTH_PROVIDER_ID]) {
|
||||
let fusionCreds = authStorage?.get?.(providerId);
|
||||
if (fusionCreds?.type !== "oauth" || !fusionCreds.access) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isTokenExpired(typeof fusionCreds.expires === "number" ? fusionCreds.expires : undefined) && authStorage?.getApiKey) {
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-01-12:55:
|
||||
Claude usage/subscription checks must use Anthropic subscription OAuth before the Claude CLI fallback. Refresh through `anthropic-subscription` even when the credential was found in the legacy `anthropic` OAuth row so rotated tokens land on the subscription surface and never become raw `/v1` API-key material.
|
||||
*/
|
||||
await Promise.resolve(authStorage.getApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)).catch(() => undefined);
|
||||
fusionCreds = authStorage.get?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) ?? fusionCreds;
|
||||
}
|
||||
|
||||
creds = {
|
||||
accessToken: fusionCreds.access,
|
||||
refreshToken: fusionCreds.refresh || undefined,
|
||||
@@ -896,6 +912,7 @@ async function fetchClaudeUsage(authStorage?: AuthStorageLike): Promise<Provider
|
||||
...(fusionCreds.subscriptionType ? { subscriptionType: fusionCreds.subscriptionType } : {}),
|
||||
...(fusionCreds.rateLimitTier ? { rateLimitTier: fusionCreds.rateLimitTier } : {}),
|
||||
};
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// get() may not be implemented or throw - ignore
|
||||
|
||||
@@ -234,6 +234,71 @@ describe("createFusionAuthStorage", () => {
|
||||
expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
|
||||
});
|
||||
|
||||
it("exposes legacy Anthropic OAuth through the subscription provider without raw direct auth", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: {
|
||||
type: "oauth",
|
||||
access: "legacy-subscription-access-token",
|
||||
refresh: "legacy-subscription-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
expect(authStorage.get("anthropic-subscription")).toEqual({
|
||||
type: "oauth",
|
||||
access: "legacy-subscription-access-token",
|
||||
refresh: "legacy-subscription-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
});
|
||||
expect(authStorage.hasAuth("anthropic-subscription")).toBe(true);
|
||||
expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
|
||||
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("legacy-subscription-access-token");
|
||||
});
|
||||
|
||||
it("refreshes legacy Anthropic OAuth into the subscription provider id", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: {
|
||||
type: "oauth",
|
||||
access: "expired-legacy-subscription-access-token",
|
||||
refresh: "legacy-subscription-refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
scopes: ["user:profile"],
|
||||
},
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: "refreshed-legacy-as-subscription-access-token",
|
||||
refresh_token: "rotated-legacy-subscription-refresh-token",
|
||||
expires_in: 3600,
|
||||
scope: "user:profile",
|
||||
}),
|
||||
} as Response);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-legacy-as-subscription-access-token");
|
||||
expect(authStorage.get("anthropic-subscription")).toEqual({
|
||||
type: "oauth",
|
||||
access: "refreshed-legacy-as-subscription-access-token",
|
||||
refresh: "rotated-legacy-subscription-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
expect(authStorage.get("anthropic")).toEqual({
|
||||
type: "oauth",
|
||||
access: "expired-legacy-subscription-access-token",
|
||||
refresh: "legacy-subscription-refresh-token",
|
||||
expires: expect.any(Number),
|
||||
scopes: ["user:profile"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps raw Anthropic API-key precedence when subscription OAuth also exists", async () => {
|
||||
writeFusionAuth(homeDir, {
|
||||
anthropic: { type: "api_key", key: "sk-ant-api03-runtime-key" },
|
||||
|
||||
@@ -1703,13 +1703,20 @@ describe("createFnAgent", () => {
|
||||
expect(anthropicRegistrations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("routes OAuth-only persisted Anthropic selections to the Claude CLI provider even when the picker toggle is unset", async () => {
|
||||
authStorageGetMock.mockReturnValue(undefined);
|
||||
it("routes subscription-OAuth persisted Anthropic selections to the direct subscription provider even when the CLI picker toggle is unset", async () => {
|
||||
authStorageGetMock.mockImplementation((provider: string) => provider === "anthropic-subscription"
|
||||
? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 }
|
||||
: undefined);
|
||||
authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription");
|
||||
authStorageGetApiKeyMock.mockResolvedValue(undefined);
|
||||
getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8" }]);
|
||||
findMock.mockImplementation((provider: string, modelId: string) => {
|
||||
if (provider === "anthropic" && modelId === "claude-opus-4-8") {
|
||||
return { provider, id: modelId, baseUrl: "https://api.anthropic.com/v1" };
|
||||
}
|
||||
if (provider === "anthropic-subscription" && modelId === "claude-opus-4-8") {
|
||||
return { provider, id: modelId, baseUrl: "https://api.anthropic.com/v1" };
|
||||
}
|
||||
if (provider === "pi-claude-cli" && modelId === "claude-opus-4-8") {
|
||||
return { provider, id: modelId };
|
||||
}
|
||||
@@ -1725,12 +1732,36 @@ describe("createFnAgent", () => {
|
||||
defaultModelId: "claude-opus-4-8",
|
||||
});
|
||||
|
||||
expect(registerProviderMock).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({
|
||||
api: "anthropic-messages",
|
||||
apiKey: "$ANTHROPIC_SUBSCRIPTION_API_KEY",
|
||||
models: [expect.objectContaining({ id: "claude-opus-4-8" })],
|
||||
}));
|
||||
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: { provider: "anthropic-subscription", id: "claude-opus-4-8", baseUrl: "https://api.anthropic.com/v1" },
|
||||
}));
|
||||
expect(createAgentSessionMock).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: expect.objectContaining({ provider: "pi-claude-cli" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps explicit Claude CLI selections on the Claude CLI provider", async () => {
|
||||
authStorageGetApiKeyMock.mockResolvedValue(undefined);
|
||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
await createFnAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "readonly",
|
||||
defaultProvider: "pi-claude-cli",
|
||||
defaultModelId: "claude-opus-4-8",
|
||||
});
|
||||
|
||||
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: { provider: "pi-claude-cli", id: "claude-opus-4-8" },
|
||||
}));
|
||||
expect(createAgentSessionMock).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: expect.objectContaining({ provider: "anthropic" }),
|
||||
}));
|
||||
expect(authStorageGetApiKeyMock).not.toHaveBeenCalledWith("pi-claude-cli");
|
||||
});
|
||||
|
||||
it("keeps raw Anthropic API-key selections on the direct provider", async () => {
|
||||
@@ -1752,8 +1783,11 @@ describe("createFnAgent", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("fails clearly for OAuth-only Anthropic selections when the Claude CLI provider is unavailable", async () => {
|
||||
authStorageGetMock.mockReturnValue(undefined);
|
||||
it("does not require the Claude CLI provider for subscription-OAuth Anthropic execution", async () => {
|
||||
authStorageGetMock.mockImplementation((provider: string) => provider === "anthropic-subscription"
|
||||
? { type: "oauth", access: "subscription-access-token", refresh: "refresh", expires: Date.now() + 3_600_000 }
|
||||
: undefined);
|
||||
authStorageHasAuthMock.mockImplementation((provider: string) => provider === "anthropic-subscription");
|
||||
authStorageGetApiKeyMock.mockResolvedValue(undefined);
|
||||
findMock.mockImplementation((provider: string, modelId: string) => {
|
||||
if (provider === "anthropic" && modelId === "claude-opus-4-8") {
|
||||
@@ -1767,14 +1801,17 @@ describe("createFnAgent", () => {
|
||||
getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8" }]);
|
||||
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
await expect(createFnAgent({
|
||||
await createFnAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "readonly",
|
||||
defaultProvider: "anthropic",
|
||||
defaultModelId: "claude-opus-4-8",
|
||||
})).rejects.toThrow("requires the Claude CLI provider");
|
||||
expect(createAgentSessionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
model: { provider: "anthropic-subscription", id: "claude-opus-4-8" },
|
||||
}));
|
||||
});
|
||||
|
||||
it("backfills the resolved model onto sessions that do not mirror it", async () => {
|
||||
|
||||
@@ -405,10 +405,35 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
const isAnthropicSubscriptionLoggedOut = () => loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
const isAnthropicRawProviderLoggedOut = () => loggedOutProviders.has(ANTHROPIC_PROVIDER_ID);
|
||||
|
||||
const selectAnthropicSubscriptionCredential = (): { credential?: StoredCredential; sourceProvider?: string } => {
|
||||
if (isAnthropicSubscriptionLoggedOut()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const separatedCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
if (separatedCredential?.type === "oauth") {
|
||||
return { credential: separatedCredential, sourceProvider: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID };
|
||||
}
|
||||
|
||||
if (!isAnthropicRawProviderLoggedOut()) {
|
||||
const legacyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth");
|
||||
if (legacyCredential) {
|
||||
return { credential: legacyCredential, sourceProvider: ANTHROPIC_PROVIDER_ID };
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const hasVisibleAnthropicSubscriptionCredential = () => Boolean(selectAnthropicSubscriptionCredential().credential);
|
||||
|
||||
const selectVisibleStoredCredential = (provider: string) => {
|
||||
if (loggedOutProviders.has(provider)) {
|
||||
return undefined;
|
||||
}
|
||||
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
|
||||
return selectAnthropicSubscriptionCredential().credential;
|
||||
}
|
||||
if (provider === ANTHROPIC_PROVIDER_ID && isAnthropicSubscriptionLoggedOut()) {
|
||||
return selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
|
||||
}
|
||||
@@ -430,9 +455,7 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
|| modelsJsonApiKeys.has(ANTHROPIC_PROVIDER_ID));
|
||||
const hasVisibleLegacyAnthropicOAuth = !isAnthropicRawProviderLoggedOut()
|
||||
&& Boolean(selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth"));
|
||||
const hasVisibleSubscriptionCredential = !isAnthropicSubscriptionLoggedOut()
|
||||
&& (primary.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)
|
||||
|| ANTHROPIC_SUBSCRIPTION_PROVIDER_ID in supplementalCredentials);
|
||||
const hasVisibleSubscriptionCredential = hasVisibleAnthropicSubscriptionCredential();
|
||||
const hasVisibleAnthropicFallback = !isAnthropicRawProviderLoggedOut()
|
||||
&& hasTargetFallbackAuth(ANTHROPIC_PROVIDER_ID);
|
||||
|
||||
@@ -584,6 +607,9 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
if (provider === ANTHROPIC_PROVIDER_ID) {
|
||||
return hasVisibleAnthropicCredential();
|
||||
}
|
||||
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
|
||||
return hasVisibleAnthropicSubscriptionCredential();
|
||||
}
|
||||
if (loggedOutProviders.has(provider)) {
|
||||
return false;
|
||||
}
|
||||
@@ -596,6 +622,9 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
if (provider === ANTHROPIC_PROVIDER_ID) {
|
||||
return hasVisibleAnthropicCredential();
|
||||
}
|
||||
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
|
||||
return hasVisibleAnthropicSubscriptionCredential();
|
||||
}
|
||||
if (loggedOutProviders.has(provider)) {
|
||||
return false;
|
||||
}
|
||||
@@ -611,6 +640,9 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
? Object.keys(supplementalCredentials).filter((p) => !loggedOutProviders.has(p))
|
||||
: Object.keys(supplementalCredentials)),
|
||||
]);
|
||||
if (hasVisibleAnthropicSubscriptionCredential()) {
|
||||
providerIds.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
}
|
||||
const merged: Record<string, StoredCredential> = {};
|
||||
for (const providerId of providerIds) {
|
||||
const credential = selectVisibleStoredCredential(providerId);
|
||||
@@ -635,10 +667,8 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
providers.add(p);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID)
|
||||
&& (providers.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) || supplementalCredentials[ANTHROPIC_SUBSCRIPTION_PROVIDER_ID])
|
||||
) {
|
||||
if (hasVisibleAnthropicSubscriptionCredential()) {
|
||||
providers.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
providers.add(ANTHROPIC_PROVIDER_ID);
|
||||
}
|
||||
return Array.from(providers).filter((p) => {
|
||||
@@ -664,10 +694,19 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
}
|
||||
|
||||
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
|
||||
const subscriptionCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
return subscriptionCredential?.type === "oauth"
|
||||
? resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential)
|
||||
: undefined;
|
||||
const { credential: subscriptionCredential, sourceProvider } = selectAnthropicSubscriptionCredential();
|
||||
if (subscriptionCredential?.type !== "oauth") {
|
||||
return undefined;
|
||||
}
|
||||
if (sourceProvider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-01-12:34:
|
||||
Legacy Anthropic OAuth rows are subscription credentials, not raw API keys. Hydrate them into `anthropic-subscription` before refresh so status, usage, and banner clearing share the same provider id without overwriting a raw `anthropic` API-key credential.
|
||||
*/
|
||||
primary.set(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential as AuthCredential);
|
||||
loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
}
|
||||
return resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);
|
||||
}
|
||||
|
||||
// 1. Primary Fusion auth
|
||||
|
||||
@@ -84,7 +84,7 @@ const RTK_EXPECTED_PASSTHROUGH_EXIT_CODES = new Set([1, 2]);
|
||||
const RTK_EXPECTED_FAIL_OPEN_ERROR_CODES = new Set(["ABORT_ERR", "ENOENT", "ETIMEDOUT"]);
|
||||
const RTK_REWRITE_MAX_BUFFER_BYTES = 64 * 1024;
|
||||
const ANTHROPIC_PROVIDER_ID = "anthropic";
|
||||
const CLAUDE_CLI_PROVIDER_ID = "pi-claude-cli";
|
||||
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
|
||||
|
||||
export type RtkRewriteMode = "off" | "rewrite";
|
||||
|
||||
@@ -1174,27 +1174,51 @@ function readJsonObject(path: string): Record<string, any> {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClaudeCliModelForAnthropicSelection(
|
||||
function resolveAnthropicSubscriptionModelForAnthropicSelection(
|
||||
modelRegistry: ModelRegistry,
|
||||
kind: "primary" | "fallback",
|
||||
modelId: string,
|
||||
model: ReturnType<typeof resolveConfiguredModel>,
|
||||
) {
|
||||
const cliModel = modelRegistry.find(CLAUDE_CLI_PROVIDER_ID, modelId);
|
||||
if (cliModel) {
|
||||
return cliModel;
|
||||
if (!model) return model;
|
||||
const subscriptionModel = modelRegistry.find(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, model.id);
|
||||
if (subscriptionModel) {
|
||||
return subscriptionModel;
|
||||
}
|
||||
|
||||
const providerModels = modelRegistry.getAll().filter((model) => model.provider === CLAUDE_CLI_PROVIDER_ID);
|
||||
if (providerModels.length > 0) {
|
||||
const baseModel = providerModels[0]!;
|
||||
piLog.warn(`${kind} model ${CLAUDE_CLI_PROVIDER_ID}/${modelId} not in registry; using Claude CLI provider base model as template`);
|
||||
return { ...baseModel, id: modelId, name: modelId };
|
||||
piLog.warn(`${kind} model ${ANTHROPIC_SUBSCRIPTION_PROVIDER_ID}/${model.id} not in registry; using the resolved Anthropic model as a subscription provider template`);
|
||||
return { ...model, provider: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID };
|
||||
}
|
||||
|
||||
function registerAnthropicSubscriptionProvider(modelRegistry: ModelRegistry): void {
|
||||
const models = modelRegistry.getAll()
|
||||
.filter((model) => model.provider === ANTHROPIC_PROVIDER_ID)
|
||||
.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
reasoning: model.reasoning ?? false,
|
||||
input: Array.isArray(model.input) ? model.input : ["text" as const],
|
||||
cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: model.contextWindow ?? 0,
|
||||
maxTokens: model.maxTokens ?? 0,
|
||||
...(model.compat ? { compat: model.compat } : {}),
|
||||
}));
|
||||
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Anthropic subscription/OAuth model ${ANTHROPIC_PROVIDER_ID}/${modelId} requires the Claude CLI provider, but ${CLAUDE_CLI_PROVIDER_ID} is not available. `
|
||||
+ "Enable Settings → Model Providers → Claude CLI and ensure the Claude Code CLI is installed, or configure a raw ANTHROPIC_API_KEY for the direct Anthropic API provider.",
|
||||
);
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-01-13:06:
|
||||
Claude subscription OAuth is an execution-capable Anthropic surface, but it must not be saved or resolved as raw `ANTHROPIC_API_KEY` material for the public `anthropic` provider. Register a separate hidden `anthropic-subscription` runtime provider so persisted Anthropic selections can execute with OAuth through their own provider id while explicit `pi-claude-cli` selections remain CLI-only.
|
||||
*/
|
||||
modelRegistry.registerProvider(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, {
|
||||
name: "Anthropic Subscription",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
apiKey: "$ANTHROPIC_SUBSCRIPTION_API_KEY",
|
||||
api: "anthropic-messages",
|
||||
models,
|
||||
});
|
||||
modelRegistry.refresh();
|
||||
}
|
||||
|
||||
async function routeAnthropicSelectionForAvailableAuth(
|
||||
@@ -1212,11 +1236,16 @@ async function routeAnthropicSelectionForAvailableAuth(
|
||||
return model;
|
||||
}
|
||||
|
||||
const hasSubscriptionOAuth = authStorage.hasAuth?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) === true;
|
||||
if (!hasSubscriptionOAuth) {
|
||||
return model;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-01-12:00:
|
||||
Persisted `anthropic/<model>` selections from subscription users must be re-routed at runtime, not merely hidden from the picker. With no raw Anthropic API key, `pi-claude-cli` is the compliant OAuth surface; direct `/v1` remains raw-key-only. Preserve the pre-0.52 working behavior by using the vendored CLI provider whenever it is registered, even if `useClaudeCli` was not explicitly toggled on for picker visibility; if the CLI provider is unavailable, fail before any OAuth token can reach `api.anthropic.com/v1`.
|
||||
FNXC:ProviderAuth 2026-07-01-13:08:
|
||||
OAuth subscription credentials are execution credentials for the dedicated `anthropic-subscription` path, not status-only credentials and not raw `anthropic` `/v1` API keys. Route persisted `anthropic/<model>` selections without a raw key to the subscription provider; explicit `pi-claude-cli` selections remain the only CLI path.
|
||||
*/
|
||||
return resolveClaudeCliModelForAnthropicSelection(modelRegistry, kind, model.id);
|
||||
return resolveAnthropicSubscriptionModelForAnthropicSelection(modelRegistry, kind, model);
|
||||
}
|
||||
|
||||
function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void {
|
||||
@@ -2115,6 +2144,7 @@ export async function createFnAgent(options: AgentOptions): Promise<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
|
||||
|
||||
Reference in New Issue
Block a user