{t("settings.auth.groupAvailable", "Available")}
diff --git a/packages/engine/src/__tests__/auth-storage-anthropic-preference.test.ts b/packages/engine/src/__tests__/auth-storage-anthropic-preference.test.ts
new file mode 100644
index 0000000000..18954b3ed8
--- /dev/null
+++ b/packages/engine/src/__tests__/auth-storage-anthropic-preference.test.ts
@@ -0,0 +1,147 @@
+/*
+FNXC:ProviderAuth 2026-07-24-17:05:
+Regression tests for the operator-selectable Anthropic credential precedence.
+
+Reported symptom class: an operator holding BOTH a raw Anthropic API key and a Claude
+subscription OAuth login always ran on the raw key, because runtime resolution put it first
+unconditionally. When that saved key was stale or revoked, every lane that calls the Anthropic
+endpoint directly failed with `401 invalid x-api-key` while the subscription card still showed
+"Active" — and nothing in the product explained which credential was in use.
+
+Invariant: `anthropicAuthPreference` decides which credential wins WHEN BOTH EXIST, and
+never removes a source — with only one credential configured, resolution reaches it under
+either setting. Default stays "api-key" so upgrades do not silently move traffic.
+*/
+
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdirSync, writeFileSync } from "node:fs";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js";
+
+const RAW_API_KEY = "sk-ant-api03-raw-key-from-settings-card";
+const SUBSCRIPTION_ACCESS_TOKEN = "sk-ant-oat01-subscription-access-token";
+
+function writeAuth(homeDir: string, credentials: Record
): void {
+ mkdirSync(join(homeDir, ".fusion", "agent"), { recursive: true });
+ writeFileSync(getFusionAuthPath(homeDir), JSON.stringify(credentials));
+}
+
+function writeGlobalSettings(homeDir: string, settings: Record): void {
+ mkdirSync(join(homeDir, ".fusion"), { recursive: true });
+ writeFileSync(join(homeDir, ".fusion", "settings.json"), JSON.stringify(settings));
+}
+
+/** A subscription OAuth credential that is still valid, so no refresh is attempted. */
+function liveSubscriptionCredential() {
+ return {
+ type: "oauth",
+ access: SUBSCRIPTION_ACCESS_TOKEN,
+ refresh: "sk-ant-ort01-refresh-token",
+ expires: Date.now() + 60 * 60_000,
+ };
+}
+
+describe("Anthropic credential precedence (anthropicAuthPreference)", () => {
+ const originalHome = process.env.HOME;
+ const originalApiKeyEnv = process.env.ANTHROPIC_API_KEY;
+ let homeDir: string;
+
+ beforeEach(async () => {
+ homeDir = await mkdtemp(join(tmpdir(), "fusion-anthropic-pref-"));
+ process.env.HOME = homeDir;
+ // The env key is a separate fallback source; keep it out of these assertions.
+ delete process.env.ANTHROPIC_API_KEY;
+ });
+
+ afterEach(async () => {
+ if (homeDir) {
+ await rm(homeDir, { recursive: true, force: true });
+ }
+ if (originalHome === undefined) {
+ delete process.env.HOME;
+ } else {
+ process.env.HOME = originalHome;
+ }
+ if (originalApiKeyEnv === undefined) {
+ delete process.env.ANTHROPIC_API_KEY;
+ } else {
+ process.env.ANTHROPIC_API_KEY = originalApiKeyEnv;
+ }
+ });
+
+ it("defaults to the raw API key when both credentials exist and no preference is stored", async () => {
+ writeAuth(homeDir, {
+ anthropic: { type: "api_key", key: RAW_API_KEY },
+ "anthropic-subscription": liveSubscriptionCredential(),
+ });
+
+ const storage = createFusionAuthStorage();
+
+ // Historical precedence (FN-7391/FN-7396) — upgrading must not move traffic.
+ await expect(storage.getApiKey("anthropic")).resolves.toBe(RAW_API_KEY);
+ });
+
+ it("keeps the raw API key first when the preference is explicitly api-key", async () => {
+ writeAuth(homeDir, {
+ anthropic: { type: "api_key", key: RAW_API_KEY },
+ "anthropic-subscription": liveSubscriptionCredential(),
+ });
+ writeGlobalSettings(homeDir, { anthropicAuthPreference: "api-key" });
+
+ const storage = createFusionAuthStorage();
+
+ await expect(storage.getApiKey("anthropic")).resolves.toBe(RAW_API_KEY);
+ });
+
+ it("resolves the subscription token over a stale saved key when the operator prefers subscription", async () => {
+ writeAuth(homeDir, {
+ anthropic: { type: "api_key", key: RAW_API_KEY },
+ "anthropic-subscription": liveSubscriptionCredential(),
+ });
+ writeGlobalSettings(homeDir, { anthropicAuthPreference: "subscription" });
+
+ const storage = createFusionAuthStorage();
+
+ /*
+ The exact reported failure: with the raw key winning, this returned a key that pi-ai
+ sends as `x-api-key` (it lacks the `sk-ant-oat` marker) and Anthropic rejects with
+ `401 invalid x-api-key`. The OAuth token routes as a Bearer credential instead.
+ */
+ await expect(storage.getApiKey("anthropic")).resolves.toBe(SUBSCRIPTION_ACCESS_TOKEN);
+ });
+
+ it("still falls back to the raw key under the subscription preference when no OAuth credential exists", async () => {
+ writeAuth(homeDir, { anthropic: { type: "api_key", key: RAW_API_KEY } });
+ writeGlobalSettings(homeDir, { anthropicAuthPreference: "subscription" });
+
+ const storage = createFusionAuthStorage();
+
+ // The preference disambiguates; it must never remove the only credential present.
+ await expect(storage.getApiKey("anthropic")).resolves.toBe(RAW_API_KEY);
+ });
+
+ it("resolves the subscription token under the api-key preference when no raw key exists", async () => {
+ writeAuth(homeDir, { "anthropic-subscription": liveSubscriptionCredential() });
+ writeGlobalSettings(homeDir, { anthropicAuthPreference: "api-key" });
+
+ const storage = createFusionAuthStorage();
+
+ await expect(storage.getApiKey("anthropic")).resolves.toBe(SUBSCRIPTION_ACCESS_TOKEN);
+ });
+
+ it("falls back to the historical precedence when the settings file is unreadable", async () => {
+ writeAuth(homeDir, {
+ anthropic: { type: "api_key", key: RAW_API_KEY },
+ "anthropic-subscription": liveSubscriptionCredential(),
+ });
+ mkdirSync(join(homeDir, ".fusion"), { recursive: true });
+ writeFileSync(join(homeDir, ".fusion", "settings.json"), "{ this is not json");
+
+ const storage = createFusionAuthStorage();
+
+ // A corrupt settings file must not strand credential resolution.
+ await expect(storage.getApiKey("anthropic")).resolves.toBe(RAW_API_KEY);
+ });
+});
diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts
index 15b60b185a..bd48bdf00f 100644
--- a/packages/engine/src/auth-storage.ts
+++ b/packages/engine/src/auth-storage.ts
@@ -421,6 +421,37 @@ function resolveStoredCredentialApiKey(providerId: string, credential: StoredCre
* can return them as a fallback when neither Fusion auth nor legacy auth.json
* contains a key for the provider.
*/
+/** Global settings file that carries the operator's Anthropic credential preference. */
+export function getFusionGlobalSettingsPath(home = getHomeDir()): string {
+ return join(home, ".fusion", "settings.json");
+}
+
+/*
+FNXC:ProviderAuth 2026-07-24-17:05:
+Read the operator's `anthropicAuthPreference` straight off the global settings file rather
+than threading a Settings object down here. Credential resolution runs deep inside
+createFnAgent (via createFusionAuthStorage, which takes no arguments) and is shared by every
+host — CLI, dashboard, desktop, daemon — so a settings parameter would have to be plumbed
+through all of them. The preference is global by definition (credentials live in the global
+auth.json), and this file is already the sibling of the auth/models files this module reads
+synchronously. Re-read per resolution so toggling the setting takes effect on the next lane
+without a restart; a missing/corrupt file falls back to the historical "api-key" precedence.
+*/
+function readAnthropicAuthPreference(home = getHomeDir()): "api-key" | "subscription" {
+ const settingsPath = getFusionGlobalSettingsPath(home);
+ if (!existsSync(settingsPath)) {
+ return "api-key";
+ }
+ try {
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf-8")) as {
+ anthropicAuthPreference?: unknown;
+ };
+ return parsed?.anthropicAuthPreference === "subscription" ? "subscription" : "api-key";
+ } catch {
+ return "api-key";
+ }
+}
+
function readModelsJsonApiKeys(home = getHomeDir()): Map {
const apiKeys = new Map();
const modelsPath = getModelRegistryModelsPath(home);
@@ -784,11 +815,27 @@ export function createFusionAuthStorage(): FusionAuthStorage {
FNXC:ProviderAuth 2026-07-01-14:55:
Anthropic runtime auth (`getApiKey("anthropic")`) resolves in precedence order: (1) raw API key, (2) legacy `anthropic` OAuth, (3) separated `anthropic-subscription` OAuth, (4) models.json / ModelRegistry fallback raw key. Raw key wins so an explicit `ANTHROPIC_API_KEY` keeps using x-api-key; subscription/OAuth tokens must resolve here so the built-in provider runs them on `/v1` with Claude Code impersonation. Do NOT gate OAuth behind the CLI or reroute it to an `/v1` `anthropic-subscription` provider — that reintroduced the #1857 regression (FN-7391/FN-7396).
*/
- if (!rawProviderLoggedOut) {
+ /*
+ FNXC:ProviderAuth 2026-07-24-17:05:
+ `anthropicAuthPreference` selects which credential wins when BOTH are configured.
+ "api-key" (default) keeps the order documented above. "subscription" moves the raw-key
+ step BELOW the OAuth steps so a stale or revoked saved key can no longer shadow a working
+ Claude subscription login — the failure mode that surfaced as `401 invalid x-api-key` on
+ lanes that call the Anthropic endpoint directly. Neither setting REMOVES a source: with
+ only one credential present, resolution falls through to it either way.
+ */
+ const preferSubscription = readAnthropicAuthPreference() === "subscription";
+ const resolveRawApiKey = (): string | undefined => {
+ if (rawProviderLoggedOut) return undefined;
const anthropicApiKeyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
- if (anthropicApiKeyCredential) {
- return resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential);
- }
+ return anthropicApiKeyCredential
+ ? resolveStoredCredentialApiKey(ANTHROPIC_PROVIDER_ID, anthropicApiKeyCredential)
+ : undefined;
+ };
+
+ if (!preferSubscription) {
+ const rawKey = resolveRawApiKey();
+ if (rawKey) return rawKey;
}
const subscriptionLoggedOut = loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
@@ -812,6 +859,12 @@ export function createFusionAuthStorage(): FusionAuthStorage {
}
}
+ // Subscription-preferred: the raw key is the fallback once no OAuth credential resolved.
+ if (preferSubscription) {
+ const rawKey = resolveRawApiKey();
+ if (rawKey) return rawKey;
+ }
+
if (!rawProviderLoggedOut) {
/*
FNXC:ProviderAuth 2026-06-30-13:28: