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). |
|
| `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. |
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user