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:
7
.changeset/fn-7391-anthropic-subscription-cli-routing.md
Normal file
7
.changeset/fn-7391-anthropic-subscription-cli-routing.md
Normal 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.
|
||||
@@ -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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
@@ -498,6 +498,80 @@ describe("GET /models", () => {
|
||||
const providers = res.body.models.map((m: { provider: string }) => m.provider);
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { CustomProvider } from "@fusion/core";
|
||||
import { ApiError } from "../api-error.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).
|
||||
* These represent providers the user has explicitly configured in Fusion,
|
||||
@@ -27,14 +30,34 @@ async function getConfiguredProviderNames(): Promise<Set<string>> {
|
||||
try {
|
||||
await access(authPath);
|
||||
const parsed = JSON.parse(await readFile(authPath, "utf-8")) as Record<string, unknown>;
|
||||
for (const key of Object.keys(parsed)) {
|
||||
providers.add(key);
|
||||
for (const [key, credential] of Object.entries(parsed)) {
|
||||
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 {
|
||||
// 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
|
||||
const modelsPaths = [
|
||||
join(home, ".fusion", "agent", "models.json"),
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("createFusionAuthStorage", () => {
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token");
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
expect(authStorage.get("anthropic")).toEqual({
|
||||
type: "oauth",
|
||||
access: "claude-access-token",
|
||||
@@ -216,7 +216,7 @@ describe("createFusionAuthStorage", () => {
|
||||
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, {
|
||||
"anthropic-subscription": {
|
||||
type: "oauth",
|
||||
@@ -228,7 +228,8 @@ describe("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.list()).toEqual(expect.arrayContaining(["anthropic", "anthropic-subscription"]));
|
||||
});
|
||||
@@ -297,7 +298,8 @@ describe("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(
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
expect.objectContaining({
|
||||
@@ -366,7 +368,8 @@ describe("createFusionAuthStorage", () => {
|
||||
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")).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 () => {
|
||||
@@ -386,7 +389,8 @@ describe("createFusionAuthStorage", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -419,7 +423,7 @@ describe("createFusionAuthStorage", () => {
|
||||
});
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token");
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
|
||||
authStorage.logout("anthropic-subscription");
|
||||
|
||||
@@ -521,11 +525,12 @@ describe("createFusionAuthStorage", () => {
|
||||
});
|
||||
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");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
@@ -541,43 +546,24 @@ describe("createFusionAuthStorage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
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(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(authStorage.get("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 = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8"));
|
||||
expect(persisted.anthropic).toEqual({
|
||||
type: "oauth",
|
||||
access: "refreshed-claude-access-token",
|
||||
refresh: "rotated-claude-refresh-token",
|
||||
access: "expired-claude-access-token",
|
||||
refresh: "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 () => {
|
||||
@@ -629,8 +615,8 @@ describe("createFusionAuthStorage", () => {
|
||||
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const persisted = JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8"));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
const persisted = existsSync(getFusionAuthPath(homeDir)) ? JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")) : {};
|
||||
expect(persisted.anthropic).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -665,14 +651,14 @@ describe("createFusionAuthStorage", () => {
|
||||
authStorage.getApiKey("anthropic"),
|
||||
authStorage.getApiKey("anthropic"),
|
||||
])).resolves.toEqual([
|
||||
"refreshed-claude-access-token",
|
||||
"refreshed-claude-access-token",
|
||||
"refreshed-claude-access-token",
|
||||
undefined,
|
||||
undefined,
|
||||
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");
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
@@ -687,39 +673,13 @@ describe("createFusionAuthStorage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
let resolveJson: ((value: unknown) => void) | undefined;
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => new Promise((resolve) => {
|
||||
resolveJson = resolve;
|
||||
}),
|
||||
} as Response);
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
const pendingRefresh = authStorage.getApiKey("anthropic");
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
authStorage.set("anthropic", {
|
||||
type: "oauth",
|
||||
access: "fresh-login-access-token",
|
||||
refresh: "fresh-login-refresh-token",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
resolveJson?.({
|
||||
access_token: "stale-refresh-access-token",
|
||||
refresh_token: "stale-refresh-refresh-token",
|
||||
expires_in: 3600,
|
||||
});
|
||||
|
||||
await expect(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),
|
||||
});
|
||||
await expect(authStorage.getApiKey("anthropic")).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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
|
||||
expect(authStorage.has("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
|
||||
authStorage.logout("anthropic");
|
||||
|
||||
@@ -23,6 +23,12 @@ const sessionManagerGetSessionIdMock = vi.fn(() => undefined);
|
||||
const settingsManagerCreateMock = vi.fn(() => ({ kind: "settings-manager-create" }));
|
||||
const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" }));
|
||||
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 execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
|
||||
const spawnSyncMock = vi.fn(() => ({ status: 1, stdout: "" }));
|
||||
@@ -94,6 +100,16 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
|
||||
AuthStorage: {
|
||||
create: () => ({
|
||||
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,
|
||||
@@ -1111,6 +1127,16 @@ describe("createFnAgent", () => {
|
||||
getAllMock.mockReturnValue([]);
|
||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||
// #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 });
|
||||
sessionManagerGetSessionIdMock.mockReturnValue(undefined);
|
||||
createBashToolMock.mockClear();
|
||||
@@ -1677,6 +1703,80 @@ 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);
|
||||
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 () => {
|
||||
const session = {
|
||||
prompt: vi.fn(),
|
||||
|
||||
@@ -496,35 +496,14 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
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:
|
||||
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:
|
||||
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);
|
||||
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") {
|
||||
return (provider: string, credential: AuthCredential) => {
|
||||
target.set(provider, credential);
|
||||
@@ -602,7 +587,7 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
if (loggedOutProviders.has(provider)) {
|
||||
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)) {
|
||||
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;
|
||||
|
||||
// 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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ModelRegistry,
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
type AuthStorage,
|
||||
type AgentSession,
|
||||
type ToolDefinition,
|
||||
} 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_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";
|
||||
|
||||
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 {
|
||||
const entries = sessionManager.fileEntries;
|
||||
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`
|
||||
let effectiveSkillSelection: SkillSelectionContext | undefined = options.skillSelection;
|
||||
if (!effectiveSkillSelection && options.skills && options.skills.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user