FN-7391: route Anthropic subscription chats through Claude CLI

Keep Anthropic OAuth subscription credentials away from the direct /v1 provider while preserving raw API-key routing.

- Treat direct anthropic auth as raw API-key-only in auth storage and model discovery.
- Re-route persisted anthropic model selections to the Claude CLI provider when no raw Anthropic API key is configured.
- Add regression coverage for OAuth-only routing, raw API-key preservation, and provider list separation.
- Document the distinct raw API key, OAuth subscription, and Claude CLI routes.

Files changed:
 .../fn-7391-anthropic-subscription-cli-routing.md  |   7 ++
 docs/settings-reference.md                         |   2 +-
 .../dashboard/src/__tests__/routes-auth.test.ts    |  74 ++++++++++++++
 .../dashboard/src/routes/register-model-routes.ts  |  27 +++++-
 packages/engine/src/__tests__/auth-storage.test.ts | 108 +++++++--------------
 .../src/__tests__/pi-create-fn-agent.test.ts       | 100 +++++++++++++++++++
 packages/engine/src/auth-storage.ts                |  45 ++++-----
 packages/engine/src/pi.ts                          |  61 ++++++++++++
 8 files changed, 319 insertions(+), 105 deletions(-)

Fusion-Task-Id: FN-7391

Fusion-Task-Lineage: d1fecc2d-7f31-408c-bf65-0e65b7fd88b1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 12:12:18 -07:00
parent 18b07b5b7d
commit 775ff5fb9c
8 changed files with 319 additions and 105 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Anthropic subscription chat failing with 429/502 by routing it through the Claude CLI.
category: fix
dev: Anthropic routing now keeps three surfaces distinct: raw API keys authenticate direct api.anthropic.com/v1, subscription/OAuth remains `anthropic-subscription`, and CLI execution uses `pi-claude-cli`; OAuth-only selections never authenticate direct `/v1` and are routed to the CLI provider when available.

View File

@@ -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). | | `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. | | `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. | | `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. |
| `defaultProvider` | `string` | `undefined` | Default AI provider. | | `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three distinct surfaces: direct `anthropic` uses raw API-key material only (`ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential); subscription OAuth remains the `anthropic-subscription` auth/usage credential; Claude CLI execution uses the `pi-claude-cli` model provider. OAuth-only Anthropic selections are never sent to `api.anthropic.com/v1`; Fusion routes them to the CLI provider when available or fails with a configuration error. |
| `defaultModelId` | `string` | `undefined` | Default AI model ID. | | `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. | | `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. | | `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing summary. |

View File

@@ -498,6 +498,80 @@ describe("GET /models", () => {
const providers = res.body.models.map((m: { provider: string }) => m.provider); const providers = res.body.models.map((m: { provider: string }) => m.provider);
expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai", "pi-claude-cli"])); expect(providers).toEqual(expect.arrayContaining(["anthropic", "openai", "pi-claude-cli"]));
}); });
it("hides direct Anthropic rows for OAuth-only subscription auth while showing distinct Claude CLI rows", async () => {
await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => {
const value = String(path);
if (value.endsWith("auth.json")) {
return JSON.stringify({
anthropic: { type: "oauth", access: "legacy-oauth", refresh: "refresh", expires: Date.now() + 60_000 },
"anthropic-subscription": { type: "oauth", access: "subscription-oauth", refresh: "refresh", expires: Date.now() + 60_000 },
openai: { type: "api_key", key: "openai-key" },
});
}
if (value.endsWith("models.json")) {
return JSON.stringify({ providers: { openai: { apiKey: "openai-key" } } });
}
return "{}";
}, async () => {
const registry = createMockModelRegistry({
getAvailable: vi.fn().mockReturnValue([
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", provider: "anthropic", reasoning: true, contextWindow: 200000 },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 OAuth", provider: "anthropic-subscription", reasoning: true, contextWindow: 200000 },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (CLI)", provider: "pi-claude-cli", reasoning: true, contextWindow: 200000 },
{ id: "gpt-4o", name: "GPT-4o", provider: "openai", reasoning: false, contextWindow: 128000 },
]),
});
const res = await GET(buildAppWithSetting(true, registry), "/api/models");
expect(res.status).toBe(200);
const providers = res.body.models.map((m: { provider: string }) => m.provider);
expect(providers).toContain("pi-claude-cli");
expect(providers).toContain("openai");
expect(providers).not.toContain("anthropic");
expect(providers).not.toContain("anthropic-subscription");
});
});
it("hides all Anthropic model rows for OAuth-only auth when Claude CLI picker visibility is disabled", async () => {
await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => {
const value = String(path);
if (value.endsWith("auth.json")) {
return JSON.stringify({
"anthropic-subscription": { type: "oauth", access: "subscription-oauth", refresh: "refresh", expires: Date.now() + 60_000 },
});
}
if (value.endsWith("models.json")) {
return JSON.stringify({ providers: {} });
}
return "{}";
}, async () => {
const res = await GET(buildAppWithSetting(false, registryWithCli()), "/api/models");
expect(res.status).toBe(200);
const providers = res.body.models.map((m: { provider: string }) => m.provider);
expect(providers).not.toContain("anthropic");
expect(providers).not.toContain("anthropic-subscription");
expect(providers).not.toContain("pi-claude-cli");
});
});
it("shows direct Anthropic rows when a raw API key exists", async () => {
await vi.mocked(fsPromises.readFile).withImplementation(async (path: unknown) => {
const value = String(path);
if (value.endsWith("auth.json")) {
return JSON.stringify({ anthropic: { type: "api_key", key: "sk-ant-api03-direct" } });
}
if (value.endsWith("models.json")) {
return JSON.stringify({ providers: {} });
}
return "{}";
}, async () => {
const res = await GET(buildAppWithSetting(false, registryWithCli()), "/api/models");
expect(res.status).toBe(200);
const providers = res.body.models.map((m: { provider: string }) => m.provider);
expect(providers).toContain("anthropic");
expect(providers).not.toContain("pi-claude-cli");
});
});
}); });
}); });

View File

@@ -6,6 +6,9 @@ import type { CustomProvider } from "@fusion/core";
import { ApiError } from "../api-error.js"; import { ApiError } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js"; import type { ApiRouteRegistrar } from "./types.js";
const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
/** /**
* Read provider names from Fusion's own auth stores (primary + legacy .pi). * Read provider names from Fusion's own auth stores (primary + legacy .pi).
* These represent providers the user has explicitly configured in Fusion, * These represent providers the user has explicitly configured in Fusion,
@@ -27,14 +30,34 @@ async function getConfiguredProviderNames(): Promise<Set<string>> {
try { try {
await access(authPath); await access(authPath);
const parsed = JSON.parse(await readFile(authPath, "utf-8")) as Record<string, unknown>; const parsed = JSON.parse(await readFile(authPath, "utf-8")) as Record<string, unknown>;
for (const key of Object.keys(parsed)) { for (const [key, credential] of Object.entries(parsed)) {
providers.add(key); if (key === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
continue;
}
if (key !== ANTHROPIC_PROVIDER_ID) {
providers.add(key);
continue;
}
if (credential && typeof credential === "object" && (credential as { type?: unknown }).type === "api_key") {
providers.add(key);
}
} }
} catch { } catch {
// Ignore missing or invalid auth files // Ignore missing or invalid auth files
} }
} }
/*
FNXC:ProviderAuth 2026-07-01-12:06:
The model picker must not advertise direct `anthropic` rows for OAuth-only Claude subscription setups. Only raw API-key material (auth.json `type: api_key`, models.json apiKey, or ANTHROPIC_API_KEY) configures the direct api.anthropic.com/v1 provider.
FNXC:ProviderAuth 2026-07-01-12:18:
Keep Anthropic's three surfaces distinct in discovery: raw API-key auth configures direct `anthropic`, subscription OAuth stays an auth/usage credential (`anthropic-subscription`) and is not a model provider row, and Claude CLI models appear only as `pi-claude-cli` when the CLI picker toggle is enabled.
*/
if (process.env.ANTHROPIC_API_KEY) {
providers.add(ANTHROPIC_PROVIDER_ID);
}
// Check models.json for providers with inline API keys // Check models.json for providers with inline API keys
const modelsPaths = [ const modelsPaths = [
join(home, ".fusion", "agent", "models.json"), join(home, ".fusion", "agent", "models.json"),

View File

@@ -165,7 +165,7 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(authStorage.get("anthropic")).toEqual({ expect(authStorage.get("anthropic")).toEqual({
type: "oauth", type: "oauth",
access: "claude-access-token", access: "claude-access-token",
@@ -216,7 +216,7 @@ describe("createFusionAuthStorage", () => {
expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true);
}); });
it("uses Anthropic subscription OAuth for model runtime auth when no raw API key exists", async () => { it("does not use Anthropic subscription OAuth for direct model runtime auth when no raw API key exists", async () => {
writeFusionAuth(homeDir, { writeFusionAuth(homeDir, {
"anthropic-subscription": { "anthropic-subscription": {
type: "oauth", type: "oauth",
@@ -228,7 +228,8 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true);
expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"])); expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
}); });
@@ -297,7 +298,8 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-subscription-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-subscription-access-token");
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
"https://platform.claude.com/v1/oauth/token", "https://platform.claude.com/v1/oauth/token",
expect.objectContaining({ expect.objectContaining({
@@ -366,7 +368,8 @@ describe("createFusionAuthStorage", () => {
expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.has("anthropic")).toBe(true);
expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true);
expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"])); expect(authStorage.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
}); });
it("uses a newly set subscription credential for runtime auth after raw Anthropic logout", async () => { it("uses a newly set subscription credential for runtime auth after raw Anthropic logout", async () => {
@@ -386,7 +389,8 @@ describe("createFusionAuthStorage", () => {
}); });
expect(authStorage.get("anthropic")).toBeUndefined(); expect(authStorage.get("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
}); });
it("keeps raw Anthropic API keys visible when subscription logout suppresses OAuth aliases", async () => { it("keeps raw Anthropic API keys visible when subscription logout suppresses OAuth aliases", async () => {
@@ -419,7 +423,7 @@ describe("createFusionAuthStorage", () => {
}); });
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
authStorage.logout("anthropic-subscription"); authStorage.logout("anthropic-subscription");
@@ -521,11 +525,12 @@ describe("createFusionAuthStorage", () => {
}); });
authStorage.reload(); authStorage.reload();
expect(await authStorage.getApiKey("anthropic")).toBe("subscription-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("subscription-access-token");
}); });
}); });
it("refreshes and persists expired Claude OAuth credentials from Claude credential files", async () => { it("does not refresh Claude OAuth credentials as direct Anthropic API keys", async () => {
const claudeDir = join(homeDir, ".claude"); const claudeDir = join(homeDir, ".claude");
mkdirSync(claudeDir, { recursive: true }); mkdirSync(claudeDir, { recursive: true });
@@ -541,43 +546,24 @@ describe("createFusionAuthStorage", () => {
}), }),
); );
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response);
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; globalThis.fetch = fetchMock as typeof fetch;
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-claude-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).not.toHaveBeenCalled();
"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({ expect(authStorage.get("anthropic")).toEqual({
type: "oauth", type: "oauth",
access: "refreshed-claude-access-token", access: "expired-claude-access-token",
refresh: "rotated-claude-refresh-token", refresh: "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), expires: expect.any(Number),
scopes: ["user:profile", "org:create_api_key"], 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 () => { it("does not persist an invalid Claude OAuth refresh response", async () => {
@@ -629,8 +615,8 @@ describe("createFusionAuthStorage", () => {
expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).not.toHaveBeenCalled();
const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")); const persisted = existsSync(getFusionAuthPath(homeDir)) ? JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")) : {};
expect(persisted.anthropic).toBeUndefined(); expect(persisted.anthropic).toBeUndefined();
}); });
@@ -665,14 +651,14 @@ describe("createFusionAuthStorage", () => {
authStorage.getApiKey("anthropic"), authStorage.getApiKey("anthropic"),
authStorage.getApiKey("anthropic"), authStorage.getApiKey("anthropic"),
])).resolves.toEqual([ ])).resolves.toEqual([
"refreshed-claude-access-token", undefined,
"refreshed-claude-access-token", undefined,
"refreshed-claude-access-token", undefined,
]); ]);
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).not.toHaveBeenCalled();
}); });
it("does not let a stale Claude OAuth refresh overwrite a newer login", async () => { it("does not refresh stale Claude OAuth material for direct Anthropic API auth", async () => {
const claudeDir = join(homeDir, ".claude"); const claudeDir = join(homeDir, ".claude");
mkdirSync(claudeDir, { recursive: true }); mkdirSync(claudeDir, { recursive: true });
@@ -687,39 +673,13 @@ describe("createFusionAuthStorage", () => {
}), }),
); );
let resolveJson: ((value: unknown) => void) | undefined; const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => new Promise((resolve) => {
resolveJson = resolve;
}),
} as Response);
globalThis.fetch = fetchMock as typeof fetch; globalThis.fetch = fetchMock as typeof fetch;
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
const pendingRefresh = authStorage.getApiKey("anthropic");
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
authStorage.set("anthropic", { await expect(authStorage.getApiKey("anthropic")).resolves.toBeUndefined();
type: "oauth", expect(fetchMock).not.toHaveBeenCalled();
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 () => { it("hydrates newer Codex CLI OAuth credentials into Fusion auth on reload", async () => {
@@ -974,7 +934,7 @@ describe("createFusionAuthStorage", () => {
// Before logout, supplemental credentials are visible // Before logout, supplemental credentials are visible
expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.has("anthropic")).toBe(true);
expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true);
expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
// Log out // Log out
authStorage.logout("anthropic"); authStorage.logout("anthropic");

View File

@@ -23,6 +23,12 @@ const sessionManagerGetSessionIdMock = vi.fn(() => undefined);
const settingsManagerCreateMock = vi.fn(() => ({ kind: "settings-manager-create" })); const settingsManagerCreateMock = vi.fn(() => ({ kind: "settings-manager-create" }));
const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" })); const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" }));
const setFallbackResolverMock = vi.fn(); const setFallbackResolverMock = vi.fn();
const authStorageGetApiKeyMock = vi.fn(async () => undefined);
const authStorageGetMock = vi.fn(() => undefined);
const authStorageHasMock = vi.fn(() => false);
const authStorageHasAuthMock = vi.fn(() => false);
const authStorageGetAllMock = vi.fn(() => ({}));
const authStorageListMock = vi.fn(() => []);
const reloadMock = vi.fn(async () => {}); const reloadMock = vi.fn(async () => {});
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => ""); const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
const spawnSyncMock = vi.fn(() => ({ status: 1, stdout: "" })); const spawnSyncMock = vi.fn(() => ({ status: 1, stdout: "" }));
@@ -94,6 +100,16 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
AuthStorage: { AuthStorage: {
create: () => ({ create: () => ({
setFallbackResolver: setFallbackResolverMock, setFallbackResolver: setFallbackResolverMock,
getApiKey: authStorageGetApiKeyMock,
get: authStorageGetMock,
set: vi.fn(),
has: authStorageHasMock,
hasAuth: authStorageHasAuthMock,
getAll: authStorageGetAllMock,
list: authStorageListMock,
logout: vi.fn(),
remove: vi.fn(),
reload: vi.fn(),
}), }),
}, },
createAgentSession: createAgentSessionMock, createAgentSession: createAgentSessionMock,
@@ -1111,6 +1127,16 @@ describe("createFnAgent", () => {
getAllMock.mockReturnValue([]); getAllMock.mockReturnValue([]);
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
// #1675: re-establish default auth + session-id mock returns after clearAllMocks. // #1675: re-establish default auth + session-id mock returns after clearAllMocks.
authStorageGetApiKeyMock.mockImplementation(async (provider: string) => (
provider === "anthropic" ? "sk-ant-api03-test-key" : undefined
));
authStorageGetMock.mockImplementation((provider: string) => (
provider === "anthropic" ? { type: "api_key", key: "sk-ant-api03-test-key" } : undefined
));
authStorageHasMock.mockReturnValue(false);
authStorageHasAuthMock.mockReturnValue(false);
authStorageGetAllMock.mockReturnValue({});
authStorageListMock.mockReturnValue([]);
getApiKeyAndHeadersMock.mockResolvedValue({ ok: true, apiKey: undefined, headers: undefined }); getApiKeyAndHeadersMock.mockResolvedValue({ ok: true, apiKey: undefined, headers: undefined });
sessionManagerGetSessionIdMock.mockReturnValue(undefined); sessionManagerGetSessionIdMock.mockReturnValue(undefined);
createBashToolMock.mockClear(); createBashToolMock.mockClear();
@@ -1677,6 +1703,80 @@ describe("createFnAgent", () => {
expect(anthropicRegistrations).toHaveLength(0); expect(anthropicRegistrations).toHaveLength(0);
}); });
it("routes OAuth-only persisted Anthropic selections to the Claude CLI provider even when the picker toggle is unset", async () => {
authStorageGetMock.mockReturnValue(undefined);
authStorageGetApiKeyMock.mockResolvedValue(undefined);
findMock.mockImplementation((provider: string, modelId: string) => {
if (provider === "anthropic" && modelId === "claude-opus-4-8") {
return { provider, id: modelId, baseUrl: "https://api.anthropic.com/v1" };
}
if (provider === "pi-claude-cli" && modelId === "claude-opus-4-8") {
return { provider, id: modelId };
}
return { provider, id: modelId };
});
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "anthropic",
defaultModelId: "claude-opus-4-8",
});
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
model: { provider: "pi-claude-cli", id: "claude-opus-4-8" },
}));
expect(createAgentSessionMock).not.toHaveBeenCalledWith(expect.objectContaining({
model: expect.objectContaining({ provider: "anthropic" }),
}));
});
it("keeps raw Anthropic API-key selections on the direct provider", async () => {
authStorageGetApiKeyMock.mockImplementation(async (provider: string) => (
provider === "anthropic" ? "sk-ant-api03-direct" : undefined
));
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "anthropic",
defaultModelId: "claude-opus-4-8",
});
expect(createAgentSessionMock).toHaveBeenCalledWith(expect.objectContaining({
model: { provider: "anthropic", id: "claude-opus-4-8" },
}));
});
it("fails clearly for OAuth-only Anthropic selections when the Claude CLI provider is unavailable", async () => {
authStorageGetMock.mockReturnValue(undefined);
authStorageGetApiKeyMock.mockResolvedValue(undefined);
findMock.mockImplementation((provider: string, modelId: string) => {
if (provider === "anthropic" && modelId === "claude-opus-4-8") {
return { provider, id: modelId };
}
if (provider === "pi-claude-cli") {
return undefined;
}
return { provider, id: modelId };
});
getAllMock.mockReturnValue([{ provider: "anthropic", id: "claude-opus-4-8" }]);
const { createFnAgent } = await import("../pi.js");
await expect(createFnAgent({
cwd: "/tmp",
systemPrompt: "test",
tools: "readonly",
defaultProvider: "anthropic",
defaultModelId: "claude-opus-4-8",
})).rejects.toThrow("requires the Claude CLI provider");
expect(createAgentSessionMock).not.toHaveBeenCalled();
});
it("backfills the resolved model onto sessions that do not mirror it", async () => { it("backfills the resolved model onto sessions that do not mirror it", async () => {
const session = { const session = {
prompt: vi.fn(), prompt: vi.fn(),

View File

@@ -496,35 +496,14 @@ export function createFusionAuthStorage(): AuthStorage {
if (anthropicApiKeyCredential) { if (anthropicApiKeyCredential) {
return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential); return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential);
} }
}
const subscriptionLoggedOut = loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
const legacyAnthropicOAuthCredential = rawProviderLoggedOut
? undefined
: selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth");
if (!subscriptionLoggedOut && legacyAnthropicOAuthCredential) {
const legacyKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_PROVIDER_ID, legacyAnthropicOAuthCredential);
if (legacyKey) return legacyKey;
}
if (!subscriptionLoggedOut) {
const subscriptionCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
if (subscriptionCredential?.type === "oauth") {
/*
FNXC:ProviderAuth 2026-06-30-11:26:
Anthropic model execution still requests provider `anthropic`, but the separated subscription login now stores OAuth material under `anthropic-subscription` so the API-key card can remain raw-key-only.
Resolve and refresh the subscription credential with the upstream Anthropic OAuth provider id while persisting rotated tokens back to `anthropic-subscription`.
*/
const subscriptionKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);
if (subscriptionKey) return subscriptionKey;
}
}
if (!rawProviderLoggedOut) {
/* /*
FNXC:ProviderAuth 2026-07-01-11:55:
Subscription/OAuth Anthropic tokens must never authenticate the direct `api.anthropic.com/v1` provider because Anthropic blocks Claude Pro/Max OAuth material on that public API surface. Keep provider `anthropic` raw-key-only here; the `anthropic-subscription` OAuth getter below remains available for Claude CLI and usage endpoints that intentionally consume subscription OAuth.
FNXC:ProviderAuth 2026-06-30-13:28: 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. Logging out of the raw Anthropic provider must suppress raw-key sources consistently across status and runtime resolution.
Treat models.json Anthropic keys and ModelRegistry fallback resolver keys as raw-key fallback material, while subscription OAuth remains governed by the separate `anthropic-subscription` logout state above. Treat models.json Anthropic keys and ModelRegistry fallback resolver keys as raw-key fallback material, while subscription OAuth remains governed by the separate `anthropic-subscription` logout state.
*/ */
const modelsJsonApiKey = modelsJsonApiKeys.get(ANTHROPIC_PROVIDER_ID); const modelsJsonApiKey = modelsJsonApiKeys.get(ANTHROPIC_PROVIDER_ID);
if (modelsJsonApiKey) return modelsJsonApiKey; if (modelsJsonApiKey) return modelsJsonApiKey;
@@ -573,6 +552,12 @@ export function createFusionAuthStorage(): AuthStorage {
}; };
} }
if (prop === "setFallbackResolver") {
return (resolver: (provider: string) => string | undefined) => {
(target as unknown as { fallbackResolver?: (provider: string) => string | undefined }).fallbackResolver = resolver;
};
}
if (prop === "set") { if (prop === "set") {
return (provider: string, credential: AuthCredential) => { return (provider: string, credential: AuthCredential) => {
target.set(provider, credential); target.set(provider, credential);
@@ -602,7 +587,7 @@ export function createFusionAuthStorage(): AuthStorage {
if (loggedOutProviders.has(provider)) { if (loggedOutProviders.has(provider)) {
return false; return false;
} }
return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider); return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider) || hasTargetFallbackAuth(provider);
}; };
} }
@@ -614,7 +599,7 @@ export function createFusionAuthStorage(): AuthStorage {
if (loggedOutProviders.has(provider)) { if (loggedOutProviders.has(provider)) {
return false; return false;
} }
return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider); return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider) || hasTargetFallbackAuth(provider);
}; };
} }
@@ -698,7 +683,11 @@ export function createFusionAuthStorage(): AuthStorage {
if (supplementalKey) return supplementalKey; if (supplementalKey) return supplementalKey;
// 3. models.json provider API keys (e.g., kimi-coding, lmstudio) // 3. models.json provider API keys (e.g., kimi-coding, lmstudio)
return modelsJsonApiKeys.get(provider); const modelsJsonApiKey = modelsJsonApiKeys.get(provider);
if (modelsJsonApiKey) return modelsJsonApiKey;
// 4. ModelRegistry fallback resolver (env-backed provider configs)
return resolveTargetFallbackApiKey(provider);
}; };
} }

View File

@@ -31,6 +31,7 @@ import {
ModelRegistry, ModelRegistry,
SessionManager, SessionManager,
SettingsManager, SettingsManager,
type AuthStorage,
type AgentSession, type AgentSession,
type ToolDefinition, type ToolDefinition,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
@@ -82,6 +83,8 @@ const RTK_ACCEPTED_REWRITE_EXIT_CODES = new Set([0, 3]);
const RTK_EXPECTED_PASSTHROUGH_EXIT_CODES = new Set([1, 2]); const RTK_EXPECTED_PASSTHROUGH_EXIT_CODES = new Set([1, 2]);
const RTK_EXPECTED_FAIL_OPEN_ERROR_CODES = new Set(["ABORT_ERR", "ENOENT", "ETIMEDOUT"]); const RTK_EXPECTED_FAIL_OPEN_ERROR_CODES = new Set(["ABORT_ERR", "ENOENT", "ETIMEDOUT"]);
const RTK_REWRITE_MAX_BUFFER_BYTES = 64 * 1024; const RTK_REWRITE_MAX_BUFFER_BYTES = 64 * 1024;
const ANTHROPIC_PROVIDER_ID = "anthropic";
const CLAUDE_CLI_PROVIDER_ID = "pi-claude-cli";
export type RtkRewriteMode = "off" | "rewrite"; export type RtkRewriteMode = "off" | "rewrite";
@@ -1171,6 +1174,51 @@ function readJsonObject(path: string): Record<string, any> {
} }
} }
function resolveClaudeCliModelForAnthropicSelection(
modelRegistry: ModelRegistry,
kind: "primary" | "fallback",
modelId: string,
) {
const cliModel = modelRegistry.find(CLAUDE_CLI_PROVIDER_ID, modelId);
if (cliModel) {
return cliModel;
}
const providerModels = modelRegistry.getAll().filter((model) => model.provider === CLAUDE_CLI_PROVIDER_ID);
if (providerModels.length > 0) {
const baseModel = providerModels[0]!;
piLog.warn(`${kind} model ${CLAUDE_CLI_PROVIDER_ID}/${modelId} not in registry; using Claude CLI provider base model as template`);
return { ...baseModel, id: modelId, name: modelId };
}
throw new Error(
`Anthropic subscription/OAuth model ${ANTHROPIC_PROVIDER_ID}/${modelId} requires the Claude CLI provider, but ${CLAUDE_CLI_PROVIDER_ID} is not available. `
+ "Enable Settings → Model Providers → Claude CLI and ensure the Claude Code CLI is installed, or configure a raw ANTHROPIC_API_KEY for the direct Anthropic API provider.",
);
}
async function routeAnthropicSelectionForAvailableAuth(
authStorage: AuthStorage,
modelRegistry: ModelRegistry,
kind: "primary" | "fallback",
model: ReturnType<typeof resolveConfiguredModel>,
) {
if (!model || model.provider !== ANTHROPIC_PROVIDER_ID) {
return model;
}
const rawApiKey = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID);
if (rawApiKey) {
return model;
}
/*
FNXC:ProviderAuth 2026-07-01-12:00:
Persisted `anthropic/<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`.
*/
return resolveClaudeCliModelForAnthropicSelection(modelRegistry, kind, model.id);
}
function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void { function normalizeSessionHistoryEntries(sessionManager: SessionManagerLike): void {
const entries = sessionManager.fileEntries; const entries = sessionManager.fileEntries;
if (!Array.isArray(entries) || entries.length === 0) { if (!Array.isArray(entries) || entries.length === 0) {
@@ -2164,6 +2212,19 @@ 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` // Resolve skill selection: explicit skillSelection wins over convenience `skills`
let effectiveSkillSelection: SkillSelectionContext | undefined = options.skillSelection; let effectiveSkillSelection: SkillSelectionContext | undefined = options.skillSelection;
if (!effectiveSkillSelection && options.skills && options.skills.length > 0) { if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {