Files
fusion/packages/engine/src/auth-storage.ts
gsxdsm 8b039a543e fix(desktop): advance Pi runtime pin to 0.82.1 for packaging PR lane (#2465)
## Summary
- Advance the matched Pi runtime pin (`pi-ai`, `pi-coding-agent`,
`pi-agent-core`, `pi-tui`) from **0.82.0 → 0.82.1** so
electron-builder's production-dependency walk accepts `pi-agent-core`'s
`pi-ai@^0.82.1` requirement.
- Fixes the Desktop packaging PR-lane failure:
`Production dependency @earendil-works/pi-ai not found for package
@earendil-works/pi-agent-core` (required `^0.82.1`).
- Keep the workspace override guard; update pin-policy fixtures and CLI
package-config expectations.
- Tighten the advisory packaging step-order test so it asserts against
the real `electron-builder --dir` step (not a missing release-only step
name that previously passed via `indexOf === -1`).
- Run `pnpm dedupe` so the packaging lane's lockfile dedupe
early-warning is clean.

## Context
#2439 pinned the full Pi closure at 0.82.0 and made recent main-based
packaging runs green. This advances to the current upstream patch so
deploy + electron-builder stay aligned with `pi-agent-core@0.82.1`'s
declared dependency range.

## Test plan
- [x] `node scripts/check-pi-versions-pinned.mjs`
- [x] `node --test scripts/__tests__/check-pi-versions-pinned.test.mjs`
- [x] `pnpm --filter @runfusion/fusion exec vitest run
src/__tests__/package-config.test.ts`
- [x] `pnpm --filter @fusion/desktop exec vitest run
src/__tests__/release-workflow.test.ts`
- [x] `pnpm dedupe --check`
- [ ] GitHub: Desktop packaging (should run full packaging walk —
lockfile/package.json touched)
- [ ] GitHub: PR Checks (Lint, Typecheck, Build, Gate)
2026-07-26 23:47:49 -07:00

1115 lines
50 KiB
TypeScript

import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import lockfile from "proper-lockfile";
import {
choosePreferredStoredCredential,
getClaudeCodeCredentialPaths,
getCodexCliAuthPath,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
type StoredAuthCredential,
} from "@fusion/core";
import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { AuthInteraction, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
type StoredCredential = StoredAuthCredential;
export interface FusionAuthStorage {
reload(): void;
get(provider: string): StoredCredential | undefined;
getAll(): Record<string, StoredCredential>;
list(): string[];
has(provider: string): boolean;
hasAuth(provider: string): boolean;
set(provider: string, credential: StoredCredential): Promise<void>;
remove(provider: string): Promise<void>;
logout(provider: string): Promise<void>;
getApiKey(provider: string): Promise<string | undefined>;
getOAuthProviders(): Array<{ id: string; name: string }>;
login(provider: string, callbacks: unknown): Promise<void>;
modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined>;
setModelRuntime(modelRuntime: ModelRuntime): void;
}
/*
FNXC:ProviderAuth 2026-07-17-08:30:
FN-8205 replaces the immediate-fail `lockSync` write path: proper-lockfile rejects
synchronous retries with ESYNC, so a held shared auth.json lock used to surface ELOCKED
straight through set/remove/logout. Writes now share this asynchronous retry policy with
modify() and queue per resolved auth path before taking the cross-process file lock. This
prevents same-engine sessions from self-contending while preserving fresh read-modify-merge
under the lock for independent Fusion processes. The vendored pi-coding-agent synchronous
lock defect from Runfusion/Fusion#2167 remains a separately tracked upstream follow-up.
*/
const AUTH_LOCK_OPTIONS = {
realpath: false,
stale: 30_000,
retries: {
retries: 10,
factor: 2,
minTimeout: 20,
maxTimeout: 10_000,
randomize: true,
},
} as const;
const authWriteQueues = new Map<string, Promise<void>>();
function enqueueAuthWrite<T>(authPath: string, write: () => Promise<T>): Promise<T> {
const queueKey = resolve(authPath);
const previous = authWriteQueues.get(queueKey) ?? Promise.resolve();
const operation = previous.catch(() => undefined).then(write);
const tail = operation.then(() => undefined, () => undefined);
authWriteQueues.set(queueKey, tail);
void tail.finally(() => {
if (authWriteQueues.get(queueKey) === tail) authWriteQueues.delete(queueKey);
});
return operation;
}
async function withOAuthRefreshLock<T>(
authPath: string,
providerId: string,
refresh: () => Promise<T>,
): Promise<T> {
const safeProviderId = providerId.replace(/[^a-zA-Z0-9._-]/g, "_");
// Use a distinct lock target, not authPath with a custom lockfilePath. proper-lockfile
// tracks held locks by target path internally, so two lock domains sharing authPath
// would corrupt each other's renewal/release bookkeeping.
const release = await lockfile.lock(`${authPath}.${safeProviderId}.refresh`, AUTH_LOCK_OPTIONS);
try {
return await refresh();
} finally {
await release();
}
}
class FusionFileAuthStorage implements FusionAuthStorage {
private data: Record<string, StoredCredential> = {};
private modelRuntime: ModelRuntime | undefined;
constructor(private readonly authPath: string) {
this.reload();
}
private ensureFile(): void {
const parent = dirname(this.authPath);
if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 });
if (!existsSync(this.authPath)) {
writeFileSync(this.authPath, "{}", { encoding: "utf-8", mode: 0o600 });
chmodSync(this.authPath, 0o600);
}
}
private readCurrent(): Record<string, StoredCredential> {
try {
return JSON.parse(readFileSync(this.authPath, "utf-8")) as Record<string, StoredCredential>;
} catch {
return {};
}
}
private async withLock<T>(fn: (current: Record<string, StoredCredential>) => Promise<T>): Promise<T> {
return enqueueAuthWrite(this.authPath, async () => {
this.ensureFile();
const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS);
try {
// Always merge the on-disk state observed after acquiring the lock, never this.data.
const current = this.readCurrent();
const result = await fn(current);
writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 });
chmodSync(this.authPath, 0o600);
this.data = current;
return result;
} finally {
await release();
}
});
}
reload(): void {
this.ensureFile();
this.data = this.readCurrent();
}
get(provider: string): StoredCredential | undefined { return this.data[provider]; }
getAll(): Record<string, StoredCredential> { return { ...this.data }; }
list(): string[] { return Object.keys(this.data); }
has(provider: string): boolean { return Boolean(this.data[provider]); }
hasAuth(provider: string): boolean { return this.has(provider); }
async set(provider: string, credential: StoredCredential): Promise<void> {
await this.withLock(async (current) => { current[provider] = credential; });
}
async remove(provider: string): Promise<void> {
await this.withLock(async (current) => { delete current[provider]; });
}
async logout(provider: string): Promise<void> { await this.remove(provider); }
async getApiKey(provider: string): Promise<string | undefined> {
return resolveStoredCredentialApiKey(provider, this.get(provider));
}
getOAuthProviders(): Array<{ id: string; name: string }> {
return [
{ id: "anthropic", name: "Anthropic" },
{ id: "openai-codex", name: "OpenAI Codex" },
{ id: "github-copilot", name: "GitHub Copilot" },
];
}
setModelRuntime(modelRuntime: ModelRuntime): void {
this.modelRuntime = modelRuntime;
}
async login(provider: string, callbacks: unknown): Promise<void> {
if (!this.modelRuntime) throw new Error("OAuth login requires a ModelRuntime-backed Fusion auth storage");
const legacy = callbacks as {
onAuth?: (info: { url: string; instructions?: string }) => void;
onDeviceCode?: (info: { userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }) => void;
onPrompt?: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
};
const interaction: AuthInteraction = {
signal: legacy.signal,
prompt: async (prompt) => legacy.onPrompt?.({
message: prompt.message,
placeholder: "placeholder" in prompt ? prompt.placeholder : undefined,
}) ?? "",
notify: (event) => {
if (event.type === "auth_url") legacy.onAuth?.({ url: event.url, instructions: event.instructions });
else if (event.type === "device_code") legacy.onDeviceCode?.(event);
else if (event.type === "progress") legacy.onProgress?.(event.message);
},
};
await this.modelRuntime.login(provider, "oauth", interaction);
this.reload();
}
async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
return this.withLock(async (current) => {
const next = await fn(current[provider]);
if (next !== undefined) current[provider] = next;
return current[provider];
});
}
}
export function createFusionCredentialStore(authStorage: FusionAuthStorage): CredentialStore {
return {
/*
FNXC:ProviderAuth 2026-07-17-06:30:
pi >=0.80.8 moved session request auth from `ModelRegistry.getApiKeyAndHeaders` (which called fusion's `getApiKey(provider)`) to `ModelRuntime.getAuth` -> pi-ai `resolveProviderAuth`, which reads the credential store directly (`credentials.read(provider.id)`) and, for an OAuth credential, refreshes it ITSELF via `credentials.modify(provider.id, ...)`. That refresh path is broken for Anthropic: fusion persists the subscription login under `anthropic-subscription` (there is NO raw `anthropic` row), so `modify("anthropic")` reads `current === undefined`, the refresh callback bails, and `resolveStoredOAuth` returns undefined -> the task fails with `Provider is not configured: anthropic` (then falls back). The status card still shows "connected" because status uses a different path (hasVisibleAnthropicCredential). Fix: resolve Anthropic auth through fusion's `getApiKey("anthropic")`, the battle-tested path that already handles token refresh + the raw-key/legacy-oauth/subscription/fallback precedence (see resolveAnthropicRuntimeApiKey), and hand pi-ai a ready-to-use api_key credential. pi-ai's anthropic-messages layer routes by token prefix — `sk-ant-oat*` -> OAuth Bearer + Claude Code identity headers, otherwise x-api-key — so a subscription OAuth token still runs as OAuth, and returning it as `api_key` deliberately bypasses pi-ai's own (broken-for-us) OAuth refresh-via-modify. Other OAuth providers (openai-codex, github-copilot) are stored under their own provider id, so read/modify share an id and pi-ai's refresh works — only Anthropic needs this indirection.
*/
read: async (providerId) => {
if (providerId === ANTHROPIC_PROVIDER_ID) {
const token = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID);
return token ? ({ type: "api_key", key: token } as Credential) : undefined;
}
return authStorage.get(providerId) as Credential | undefined;
},
list: async () => authStorage.list().flatMap((providerId): CredentialInfo[] => {
const credential = authStorage.get(providerId);
return credential?.type === "api_key" || credential?.type === "oauth"
? [{ providerId, type: credential.type }]
: [];
}),
modify: async (providerId, fn) => authStorage.modify(providerId, async (current) => fn(current as Credential | undefined) as Promise<StoredCredential | undefined>) as Promise<Credential | undefined>,
delete: async (providerId) => { await authStorage.remove(providerId); },
};
}
/*
FNXC:ProviderAuth 2026-07-07-00:00:
FN-7646: the cross-process ~/.fusion/agent/auth.json coordination invariant (concurrent
writers merge per-provider instead of clobbering each other's credentials) depends on the
vendored @earendil-works/pi-coding-agent AuthStorage backend using proper-lockfile locking
plus a per-provider read-modify-merge (FileAuthStorageBackend.persistProviderChange /
refreshOAuthTokenWithLock re-read the file under a lock and spread
{...currentData, [provider]: credential} rather than flushing a whole-file in-memory
snapshot). packages/engine/package.json already pins this at
"@earendil-works/pi-coding-agent": "0.82.1" (exact matched runtime pair; locked per-provider
merge requires >=0.80.3) — do not downgrade below 0.80.x, and re-verify this comment against
dist/core/auth-storage.js if the pin is ever lowered. See
FNXC:ProviderAuth 2026-07-24-12:00:
FN-8564 retains Fusion's credential-store adapter on Pi 0.82.x. The release adds Kimi Code and
OpenRouter OAuth, so new upstream login providers must still use the same queued, cross-process
credential persistence contract rather than bypassing Fusion auth storage.
packages/engine/src/__tests__/auth-storage-concurrency.test.ts for the regression coverage.
*/
/*
FNXC:ClaudeOAuth 2026-07-05-00:00:
FN-7574: a 60s reactive refresh buffer meant a healthy Anthropic subscription token was
only ever refreshed a few seconds before (or after) it actually expired — too late to
reliably beat a slow/failed network round trip, so subscriptions routinely lapsed and
forced a manual re-login even though the refresh token was still valid. Widen the
proactive-refresh window to 5 minutes so both the reactive getApiKey() path AND the new
background OAuthRefreshScheduler (see notification/oauth-refresh-scheduler.ts) renew the
access token well ahead of expiry, without refreshing needlessly often (the scheduler
runs on a multi-minute interval, and the in-flight dedupe + failure cooldown below still
apply so a single stuck token doesn't get hammered).
*/
const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000;
const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000;
export function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir();
}
export function getFusionAuthPath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "auth.json");
}
export function getFusionOAuthAlertStatePath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "oauth-alert-state.json");
}
export function getFusionModelsPath(home = getHomeDir()): string {
return join(home, ".fusion", "agent", "models.json");
}
function getLegacyAuthPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "auth.json"),
join(home, ".pi", "auth.json"),
];
}
function getSupplementalAuthPaths(home = getHomeDir()): string[] {
return [
...getLegacyAuthPaths(home),
getCodexCliAuthPath(home),
...getClaudeCodeCredentialPaths(home),
];
}
function getLegacyModelsPaths(home = getHomeDir()): string[] {
return [
join(home, ".pi", "agent", "models.json"),
join(home, ".pi", "models.json"),
];
}
export function getModelRegistryModelsPath(home = getHomeDir()): string {
const fusionModelsPath = getFusionModelsPath(home);
if (existsSync(fusionModelsPath)) {
return fusionModelsPath;
}
return getLegacyModelsPaths(home).find((modelsPath) => existsSync(modelsPath)) ?? fusionModelsPath;
}
function readSupplementalCredentials(authPaths = getSupplementalAuthPaths()): Record<string, StoredCredential> {
const credentials: Record<string, StoredCredential> = {};
for (const authPath of authPaths) {
const parsed = readStoredCredentialsFromAuthFile(authPath);
for (const [provider, credential] of Object.entries(parsed)) {
credentials[provider] = choosePreferredStoredCredential(credentials[provider], credential) ?? credential;
}
}
return credentials;
}
function resolveStoredApiKey(key: string | undefined): string | undefined {
if (!key) return undefined;
return process.env[key] ?? key;
}
function getOAuthResolutionProviderId(providerId: string): string {
return providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID ? ANTHROPIC_PROVIDER_ID : providerId;
}
function resolveOAuthApiKey(providerId: string, credential: StoredCredential): string | undefined {
if (
credential.type !== "oauth" ||
typeof credential.access !== "string" ||
typeof credential.refresh !== "string" ||
typeof credential.expires !== "number" ||
Date.now() >= credential.expires
) {
return undefined;
}
// pi 0.80.8 moved provider OAuth conversion behind ModelRuntime. The legacy
// dashboard adapter only needs the stored bearer access token; ModelRuntime handles
// provider-specific `toAuth` conversion for actual model requests.
return credential.access;
}
function shouldRefreshOAuthCredential(credential: StoredCredential): boolean {
return credential.type === "oauth"
&& typeof credential.refresh === "string"
&& credential.refresh.length > 0
&& typeof credential.expires === "number"
&& Number.isFinite(credential.expires)
&& Date.now() >= credential.expires - OAUTH_REFRESH_BUFFER_MS;
}
function isSameOAuthCredentialIdentity(
left: StoredCredential | undefined,
right: StoredCredential,
): boolean {
return left?.type === "oauth"
&& right.type === "oauth"
&& left.access === right.access
&& left.refresh === right.refresh
&& left.expires === right.expires;
}
async function refreshAnthropicOAuthCredential(credential: StoredCredential): Promise<StoredCredential | undefined> {
if (credential.type !== "oauth" || !credential.refresh) {
return undefined;
}
try {
/*
FNXC:ClaudeOAuth 2026-07-14-14:25:
Refresh through pi-ai's registered Anthropic provider—the same implementation that performs login and owns the endpoint, client id, expiry buffer, and response contract. Fusion's duplicated HTTP implementation drifted, so expired subscription OAuth degraded into a misleading missing-API-key failure after restart or PostgreSQL migration. Preserve Fusion's recorded scopes because the provider refresh result intentionally contains only runtime token fields.
*/
const response = await fetch("https://platform.claude.com/v1/oauth/token", {
method: "POST",
headers: { "content-type": "application/json" },
// Do not include scope: RFC 6749 refreshes preserve the granted scope only when omitted.
body: JSON.stringify({
grant_type: "refresh_token",
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
refresh_token: credential.refresh,
}),
});
if (!response.ok) return undefined;
const payload = JSON.parse(await response.text()) as { access_token?: string; refresh_token?: string; expires_in?: number };
if (!payload.access_token || !payload.refresh_token || typeof payload.expires_in !== "number") return undefined;
return {
...credential,
access: payload.access_token,
refresh: payload.refresh_token,
expires: Date.now() + payload.expires_in * 1000 - OAUTH_REFRESH_BUFFER_MS,
};
} catch {
return undefined;
}
}
async function refreshOAuthCredential(providerId: string, credential: StoredCredential): Promise<StoredCredential | undefined> {
if (!shouldRefreshOAuthCredential(credential)) {
return credential;
}
if (getOAuthResolutionProviderId(providerId) !== ANTHROPIC_PROVIDER_ID) {
return undefined;
}
return refreshAnthropicOAuthCredential(credential);
}
function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined {
if (credential?.type === "api_key") {
return resolveStoredApiKey(credential.key);
}
if (credential?.type === "oauth") {
return resolveOAuthApiKey(providerId, credential);
}
return undefined;
}
/**
* Reads API keys from the resolved models.json file.
*
* Some providers (e.g., kimi-coding, lmstudio, ollama) store their API keys
* in `models.json` under `providers.<providerId>.apiKey` rather than in
* `auth.json`. This function extracts those keys so the auth storage proxy
* can return them as a fallback when neither Fusion auth nor legacy auth.json
* contains a key for the provider.
*/
/*
FNXC:ProviderAuth 2026-07-24-18:20:
Global settings live in `settings.json` under the resolved GLOBAL dir — not in Postgres
(Postgres holds project settings, which are merged over the global ones by
`settings-ops.ts`, and the immutable configuration-revision journal). The global dir is
`~/.fusion` for current installs but falls back to the pre-rename `~/.pi/fusion` and
`~/.pi/kb` dirs for operators who never migrated (see core `resolveGlobalDirForHome`).
Hardcoding `~/.fusion` would silently ignore the operator's preference on those installs and
fall back to raw-key precedence — the same silent-fallback failure this preference exists to
remove. Mirror the legacy-aware lookup `getModelRegistryModelsPath` already does for
models.json rather than importing core's resolver, which throws under VITEST when called
without an explicit dir.
*/
export function getFusionGlobalSettingsPath(home = getHomeDir()): string {
const candidates = [
join(home, ".fusion", "settings.json"),
join(home, ".pi", "fusion", "settings.json"),
join(home, ".pi", "kb", "settings.json"),
];
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
}
/*
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<string, string> {
const apiKeys = new Map<string, string>();
const modelsPath = getModelRegistryModelsPath(home);
if (!existsSync(modelsPath)) {
return apiKeys;
}
try {
const parsed = JSON.parse(readFileSync(modelsPath, "utf-8")) as {
providers?: Record<string, { apiKey?: string }>;
};
const providers = parsed?.providers;
if (providers) {
for (const [providerId, config] of Object.entries(providers)) {
if (config.apiKey) {
apiKeys.set(providerId, config.apiKey);
}
}
}
} catch {
// Ignore invalid models.json files.
}
return apiKeys;
}
/*
* FNXC:ModelRegistry 2026-07-03-07:00:
* Shared model-registry factory so non-CLI hosts (the desktop embedded runtime) can wire the
* dashboard's models API without depending on @earendil-works/pi-coding-agent directly. Mirrors the
* CLI's the pre-0.80.8 registry factory. Without a ModelRegistry the
* /api/models endpoint returns an empty list, so the onboarding model picker shows "no models" even
* when a provider (e.g. Anthropic) is connected.
*/
export type FusionModelRegistry = ModelRegistry & { readonly modelRuntime: ModelRuntime };
/**
* FNXC:ModelRegistry 2026-07-16-17:20:
* pi 0.80.8 made model initialization asynchronous and moved auth ownership to
* ModelRuntime. Keep Fusion's file-backed, locked credential adapter as the runtime
* CredentialStore so FN-7646's per-provider read-modify-merge guarantee survives.
*/
export async function createFusionModelRegistry(authStorage: FusionAuthStorage, home?: string): Promise<FusionModelRegistry> {
const modelRuntime = await ModelRuntime.create({
credentials: createFusionCredentialStore(authStorage),
modelsPath: getModelRegistryModelsPath(home),
});
authStorage.setModelRuntime(modelRuntime);
return Object.assign(new ModelRegistry(modelRuntime), { modelRuntime });
}
export function createFusionAuthStorage(): FusionAuthStorage {
const authPath = getFusionAuthPath();
const primary = new FusionFileAuthStorage(authPath);
let supplementalCredentials = readSupplementalCredentials();
// models.json provider API keys — final fallback after primary auth and supplemental auth.json files
let modelsJsonApiKeys = readModelsJsonApiKeys();
/*
FNXC:ClaudeOAuth 2026-06-13-22:46:
Dashboard auth-status polling can run while model execution also resolves credentials, so expired Claude credentials need one refresh attempt per provider at a time.
Cache an in-flight refresh and briefly cool down failed attempts so repeated polls do not stampede the Anthropic token endpoint.
*/
const oauthRefreshInFlight = new Map<string, Promise<StoredCredential | undefined>>();
const oauthRefreshCooldownUntil = new Map<string, number>();
let supplementalHydration = Promise.resolve();
// Providers the user has explicitly logged out from. These should not be
// "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json).
// Cleared when the user re-authenticates via set().
const loggedOutProviders = new Set<string>();
/*
FNXC:ProviderAuth 2026-07-05-00:00:
Re-authenticating a provider must clear its in-memory logged-out suppression so the settings card flips back to connected.
Anthropic subscription OAuth is aliased across the legacy `anthropic` row — where interactive login persists the credential — and the separated `anthropic-subscription` id, where the card's logged-out flag and status read are keyed. Because a re-login only writes `anthropic`, clearing just the written id left `anthropic-subscription` suppressed: a user who logged out of the subscription earlier in the same dashboard session saw every successful re-login reported as "Login did not complete. Please try again." until the process restarted (the credential was valid on disk the whole time). Clear BOTH aliases when either is re-authenticated. Only OAuth credentials alias this way; a raw `anthropic` API key stays scoped to its own card, so api_key writes never clear the subscription alias.
*/
const clearReauthenticatedLogoutState = (
provider: string,
credentialType?: StoredCredential["type"],
) => {
loggedOutProviders.delete(provider);
oauthRefreshCooldownUntil.delete(provider);
const isAnthropicAlias =
provider === ANTHROPIC_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID;
const aliasesSubscriptionOAuth = credentialType === undefined || credentialType === "oauth";
if (isAnthropicAlias && aliasesSubscriptionOAuth) {
loggedOutProviders.delete(ANTHROPIC_PROVIDER_ID);
loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
};
/*
FNXC:ProviderAuth 2026-07-17-08:45:
Supplemental hydration persists through the same per-path queue as direct writes. Construction
and lock-free reload intentionally start it without blocking reads; callers that require a
hydrated credential use getApiKey(), which awaits its own persistence. Other reads observe
hydrated state after this queue drains rather than racing an un-awaited file write.
*/
const syncSupplementalOauthCredentials = async (): Promise<void> => {
for (const [provider, credential] of Object.entries(supplementalCredentials)) {
if (loggedOutProviders.has(provider)) {
continue;
}
const current = primary.get(provider) as StoredCredential | undefined;
if (!shouldHydrateStoredCredential(current, credential)) {
continue;
}
if (credential.type === "oauth") {
if (typeof credential.expires !== "number" || Date.now() >= credential.expires) {
continue;
}
await primary.set(provider, credential as StoredCredential);
continue;
}
if (credential.type === "api_key") {
await primary.set(provider, credential as StoredCredential);
}
}
};
const refreshProviderOAuthCredential = async (
storageProvider: string,
credential: StoredCredential,
): Promise<StoredCredential | undefined> => {
if (!shouldRefreshOAuthCredential(credential)) {
return credential;
}
const now = Date.now();
const cooldownUntil = oauthRefreshCooldownUntil.get(storageProvider);
if (cooldownUntil && cooldownUntil > now) {
return undefined;
}
const existing = oauthRefreshInFlight.get(storageProvider);
if (existing) {
return existing;
}
/*
FNXC:ClaudeOAuth 2026-07-18-16:50:
Anthropic refresh tokens rotate on use. The in-memory single-flight map above is
scoped to one createFusionAuthStorage() instance, while every agent session creates
its own instance and separate Fusion processes share the same auth.json. Refreshing
outside the file lock therefore let a burst of sessions submit the same refresh
token concurrently: one request rotated it and the losers fell back to another
provider with stale auth.
Hold a provider-specific cross-process refresh lock for the complete
read/refresh/write transaction. It deliberately differs from the auth-file write
lock so a manual login can persist while the network request is in flight. A waiter
re-reads the provider row after acquiring the refresh lock; if another session
already rotated it, the fresh credential is returned without a second request.
Re-read again before persistence so a concurrent manual login always wins.
*/
// Anthropic's legacy `anthropic` row and separated `anthropic-subscription`
// row can refer to the same rotating refresh token, so both aliases must use
// one canonical refresh-lock domain.
const refreshLockProvider = getOAuthResolutionProviderId(storageProvider);
const refreshPromise = withOAuthRefreshLock(authPath, refreshLockProvider, async () => {
primary.reload();
const selectPersistedRefreshCredential = (): StoredCredential | undefined => {
const storedCredential = primary.get(storageProvider) as StoredCredential | undefined;
if (refreshLockProvider !== ANTHROPIC_PROVIDER_ID) {
return storedCredential;
}
const legacyCredential = primary.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
const subscriptionCredential = primary.get(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) as StoredCredential | undefined;
return choosePreferredStoredCredential(
legacyCredential?.type === "oauth" ? legacyCredential : undefined,
subscriptionCredential?.type === "oauth" ? subscriptionCredential : undefined,
);
};
const initialPersistedCredential = selectPersistedRefreshCredential();
const refreshCandidate = choosePreferredStoredCredential(initialPersistedCredential, credential) ?? credential;
if (!shouldRefreshOAuthCredential(refreshCandidate)) {
return refreshCandidate;
}
const refreshed = await refreshOAuthCredential(storageProvider, refreshCandidate);
if (!refreshed) {
return initialPersistedCredential;
}
primary.reload();
const latestPersistedCredential = selectPersistedRefreshCredential();
const changedWhileRefreshing = initialPersistedCredential
? !isSameOAuthCredentialIdentity(latestPersistedCredential, initialPersistedCredential)
: latestPersistedCredential !== undefined;
if (changedWhileRefreshing) {
return latestPersistedCredential;
}
await primary.set(storageProvider, refreshed);
return refreshed;
})
.then((refreshed) => {
if (refreshed && !shouldRefreshOAuthCredential(refreshed)) {
oauthRefreshCooldownUntil.delete(storageProvider);
} else {
oauthRefreshCooldownUntil.set(storageProvider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS);
}
return refreshed;
})
.catch(() => {
oauthRefreshCooldownUntil.set(storageProvider, Date.now() + OAUTH_REFRESH_FAILURE_COOLDOWN_MS);
return undefined;
})
.finally(() => {
oauthRefreshInFlight.delete(storageProvider);
});
oauthRefreshInFlight.set(storageProvider, refreshPromise);
return refreshPromise;
};
const selectStoredCredential = (provider: string) => choosePreferredStoredCredential(
primary.get(provider) as StoredCredential | undefined,
supplementalCredentials[provider],
);
const selectStoredCredentialByType = (
provider: string,
type: StoredCredential["type"],
) => choosePreferredStoredCredential(
((primary.get(provider) as StoredCredential | undefined)?.type === type
? primary.get(provider) as StoredCredential
: undefined),
supplementalCredentials[provider]?.type === type ? supplementalCredentials[provider] : undefined,
);
const isAnthropicSubscriptionLoggedOut = () => loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
const isAnthropicRawProviderLoggedOut = () => loggedOutProviders.has(ANTHROPIC_PROVIDER_ID);
const selectAnthropicSubscriptionCredential = (): { credential?: StoredCredential; sourceProvider?: string } => {
if (isAnthropicSubscriptionLoggedOut()) {
return {};
}
const separatedCredential = selectStoredCredential(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
if (separatedCredential?.type === "oauth") {
return { credential: separatedCredential, sourceProvider: ANTHROPIC_SUBSCRIPTION_PROVIDER_ID };
}
if (!isAnthropicRawProviderLoggedOut()) {
const legacyCredential = selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth");
if (legacyCredential) {
return { credential: legacyCredential, sourceProvider: ANTHROPIC_PROVIDER_ID };
}
}
return {};
};
const hasVisibleAnthropicSubscriptionCredential = () => Boolean(selectAnthropicSubscriptionCredential().credential);
const selectVisibleStoredCredential = (provider: string) => {
if (loggedOutProviders.has(provider)) {
return undefined;
}
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
return selectAnthropicSubscriptionCredential().credential;
}
if (provider === ANTHROPIC_PROVIDER_ID && isAnthropicSubscriptionLoggedOut()) {
return selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key");
}
return selectStoredCredential(provider);
};
const resolveTargetFallbackApiKey = (provider: string): string | undefined => {
const fallbackResolver = (primary as unknown as {
fallbackResolver?: (provider: string) => string | undefined;
}).fallbackResolver;
return fallbackResolver?.(provider);
};
const hasTargetFallbackAuth = (provider: string): boolean => Boolean(resolveTargetFallbackApiKey(provider));
const hasVisibleAnthropicCredential = () => {
const hasVisibleRawAnthropicApiKey = !isAnthropicRawProviderLoggedOut()
&& (Boolean(selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "api_key"))
|| modelsJsonApiKeys.has(ANTHROPIC_PROVIDER_ID));
const hasVisibleLegacyAnthropicOAuth = !isAnthropicRawProviderLoggedOut()
&& Boolean(selectStoredCredentialByType(ANTHROPIC_PROVIDER_ID, "oauth"));
const hasVisibleSubscriptionCredential = hasVisibleAnthropicSubscriptionCredential();
const hasVisibleAnthropicFallback = !isAnthropicRawProviderLoggedOut()
&& hasTargetFallbackAuth(ANTHROPIC_PROVIDER_ID);
if (!isAnthropicSubscriptionLoggedOut()) {
/*
FNXC:ProviderAuth 2026-06-30-12:23:
Logging out of the raw Anthropic API-key provider must suppress only the raw/legacy `anthropic` storage slot.
Model-runtime reads for `anthropic` still need to see an independently logged-in `anthropic-subscription` credential from the separated subscription card.
FNXC:ProviderAuth 2026-06-30-12:47:
Anthropic's subscription alias is an extra runtime credential source, not a replacement for AuthStorage's existing fallback-resolver contract.
Keep custom fallback auth visible after explicit raw/subscription credentials are checked so ModelRegistry provider request configs still work for `anthropic` like every other provider.
*/
return hasVisibleRawAnthropicApiKey
|| hasVisibleLegacyAnthropicOAuth
|| hasVisibleSubscriptionCredential
|| hasVisibleAnthropicFallback;
}
/*
FNXC:ProviderAuth 2026-06-30-12:05:
Logging out of the Anthropic subscription must suppress legacy `anthropic` OAuth aliases from status/list reads as well as model-runtime resolution.
Keep raw API-key credentials and models.json fallback visible so the separate API-key card is not hidden by subscription logout.
*/
return hasVisibleRawAnthropicApiKey || hasVisibleAnthropicFallback;
};
const resolveRefreshableCredentialApiKey = async (
storageProvider: string,
credential: StoredCredential | undefined,
): Promise<string | undefined> => {
if (!credential) {
return undefined;
}
const refreshWasNeeded = shouldRefreshOAuthCredential(credential);
const refreshedCredential = await refreshProviderOAuthCredential(storageProvider, credential);
if (refreshedCredential?.type === "oauth" && refreshedCredential.access) {
if (refreshWasNeeded) {
/*
FNXC:ClaudeOAuth 2026-06-13-22:46:
A manual re-login or replacement credential must win over an older in-flight refresh response.
Re-check the credential identity before persisting so a delayed refresh cannot restore stale OAuth material after the user already fixed auth.
FNXC:ProviderAuth 2026-07-07-00:00:
FN-7646: ~/.fusion/agent/auth.json is shared across independent Fusion processes on one
machine (e.g. a CLI-served web app and the desktop app). The FileAuthStorageBackend already
coordinates concurrent writers with a lock plus a per-provider read-modify-merge, so it never
clobbers OTHER providers — but this process's own in-flight refresh must not overwrite a
NEWER credential another process wrote for the SAME provider while this refresh was pending.
Re-read from disk (primary.reload()) before comparing identity so `latestCredential` reflects
what is actually on disk right now, not this process's possibly-stale in-memory snapshot from
before the refresh started. Without this reload, a concurrent process's newer login/refresh for
this exact provider could be silently overwritten by our older refreshed token.
*/
primary.reload();
const latestCredential = selectStoredCredential(storageProvider);
if (!isSameOAuthCredentialIdentity(latestCredential, credential)) {
return resolveStoredCredentialApiKey(storageProvider, latestCredential);
}
}
await primary.set(storageProvider, refreshedCredential as StoredCredential);
loggedOutProviders.delete(storageProvider);
return resolveStoredCredentialApiKey(storageProvider, refreshedCredential);
}
return resolveStoredCredentialApiKey(storageProvider, credential);
};
const resolveAnthropicRuntimeApiKey = async (): Promise<string | undefined> => {
const rawProviderLoggedOut = isAnthropicRawProviderLoggedOut();
/*
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).
*/
/*
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");
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);
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:
The separated subscription login stores OAuth material under `anthropic-subscription` so the API-key card stays raw-key-only, but Anthropic model execution still requests provider `anthropic`. Resolve and refresh the subscription credential (persisting rotated tokens back to `anthropic-subscription`) so a subscription user's `anthropic/<model>` selection runs on their OAuth token.
*/
const subscriptionKey = await resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);
if (subscriptionKey) return subscriptionKey;
}
}
// 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:
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; subscription OAuth stays governed by the separate `anthropic-subscription` logout state above.
*/
const modelsJsonApiKey = modelsJsonApiKeys.get(ANTHROPIC_PROVIDER_ID);
if (modelsJsonApiKey) return modelsJsonApiKey;
return resolveTargetFallbackApiKey(ANTHROPIC_PROVIDER_ID);
}
return undefined;
};
supplementalHydration = syncSupplementalOauthCredentials();
return new Proxy(primary, {
// Forward property writes to the target so that methods like
// `setFallbackResolver` (called by ModelRegistry) correctly update the
// underlying AuthStorage. Without this trap, writes land on the Proxy
// object itself and the target's fallbackResolver stays undefined.
set(target: FusionAuthStorage, prop: string | symbol, value: unknown) {
(target as unknown as Record<string | symbol, unknown>)[prop] = value;
return true;
},
get(target, prop, receiver) {
if (prop === "logout") {
return async (provider: string): Promise<void> => {
await target.logout(provider);
loggedOutProviders.add(provider);
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
await target.logout(ANTHROPIC_PROVIDER_ID);
}
}
};
}
if (prop === "remove") {
return async (provider: string): Promise<void> => {
await target.remove(provider);
loggedOutProviders.add(provider);
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
await target.remove(ANTHROPIC_PROVIDER_ID);
}
}
};
}
if (prop === "setFallbackResolver") {
return (resolver: (provider: string) => string | undefined) => {
(target as unknown as { fallbackResolver?: (provider: string) => string | undefined }).fallbackResolver = resolver;
};
}
if (prop === "login") {
// Preserve the original invocation semantics (bind `this` to the proxy so
// any internal credential writes still flow through the set/logout traps),
// and only ADD the alias-aware logged-out clearing on top.
const originalLogin = Reflect.get(target, prop, receiver) as (
provider: string,
callbacks: unknown,
) => Promise<void>;
return async (provider: string, callbacks: unknown) => {
const result = await originalLogin.call(receiver, provider, callbacks);
/*
FNXC:ProviderAuth 2026-07-05-00:00:
A completed interactive login means the user re-authenticated this provider, so lift its logged-out suppression (and the Anthropic subscription alias) even though the credential is persisted under `anthropic`. Without this, subscription re-login after an in-session logout stays invisible to the status card. See clearReauthenticatedLogoutState.
*/
clearReauthenticatedLogoutState(provider);
return result;
};
}
if (prop === "set") {
return async (provider: string, credential: StoredCredential): Promise<void> => {
await target.set(provider, credential);
clearReauthenticatedLogoutState(provider, (credential as StoredCredential | undefined)?.type);
};
}
if (prop === "reload") {
return () => {
target.reload();
supplementalCredentials = readSupplementalCredentials();
supplementalHydration = syncSupplementalOauthCredentials();
modelsJsonApiKeys = readModelsJsonApiKeys();
};
}
if (prop === "get") {
return (provider: string) => selectVisibleStoredCredential(provider);
}
if (prop === "has") {
return (provider: string) => {
if (provider === ANTHROPIC_PROVIDER_ID) {
return hasVisibleAnthropicCredential();
}
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
return hasVisibleAnthropicSubscriptionCredential();
}
if (loggedOutProviders.has(provider)) {
return false;
}
return target.has(provider) || provider in supplementalCredentials || modelsJsonApiKeys.has(provider) || hasTargetFallbackAuth(provider);
};
}
if (prop === "hasAuth") {
return (provider: string) => {
if (provider === ANTHROPIC_PROVIDER_ID) {
return hasVisibleAnthropicCredential();
}
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
return hasVisibleAnthropicSubscriptionCredential();
}
if (loggedOutProviders.has(provider)) {
return false;
}
return target.hasAuth(provider) || Boolean(supplementalCredentials[provider]) || modelsJsonApiKeys.has(provider) || hasTargetFallbackAuth(provider);
};
}
if (prop === "getAll") {
return () => {
const providerIds = new Set([
...Object.keys(target.getAll() as Record<string, StoredCredential>),
...(loggedOutProviders.size > 0
? Object.keys(supplementalCredentials).filter((p) => !loggedOutProviders.has(p))
: Object.keys(supplementalCredentials)),
]);
if (hasVisibleAnthropicSubscriptionCredential()) {
providerIds.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
const credential = selectVisibleStoredCredential(providerId);
if (credential) {
merged[providerId] = credential;
}
}
return merged;
};
}
if (prop === "list") {
return () => {
const providers = new Set([...target.list()]);
for (const p of modelsJsonApiKeys.keys()) {
if (!loggedOutProviders.has(p)) {
providers.add(p);
}
}
for (const p of Object.keys(supplementalCredentials)) {
if (!loggedOutProviders.has(p)) {
providers.add(p);
}
}
if (hasVisibleAnthropicSubscriptionCredential()) {
providers.add(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
providers.add(ANTHROPIC_PROVIDER_ID);
}
return Array.from(providers).filter((p) => {
if (p === ANTHROPIC_PROVIDER_ID) {
return hasVisibleAnthropicCredential();
}
if (loggedOutProviders.has(p)) {
return false;
}
return true;
});
};
}
if (prop === "getApiKey") {
return async (provider: string) => {
await supplementalHydration;
if (provider === ANTHROPIC_PROVIDER_ID) {
return resolveAnthropicRuntimeApiKey();
}
if (loggedOutProviders.has(provider)) {
return undefined;
}
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const { credential: subscriptionCredential, sourceProvider } = selectAnthropicSubscriptionCredential();
if (subscriptionCredential?.type !== "oauth") {
return undefined;
}
if (sourceProvider !== ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
/*
FNXC:ProviderAuth 2026-07-01-12:34:
Legacy Anthropic OAuth rows are subscription credentials, not raw API keys. Hydrate them into `anthropic-subscription` before refresh so status, usage, and banner clearing share the same provider id without overwriting a raw `anthropic` API-key credential.
*/
await primary.set(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential as StoredCredential);
loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
}
return resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);
}
// 1. Primary Fusion auth
const primaryKey = await target.getApiKey(provider);
if (primaryKey) return primaryKey;
// 2. Supplemental auth.json credentials (.pi + .codex)
const refreshCandidate = selectStoredCredential(provider);
const refreshedKey = await resolveRefreshableCredentialApiKey(provider, refreshCandidate);
if (refreshedKey) return refreshedKey;
const supplementalKey = resolveStoredCredentialApiKey(provider, supplementalCredentials[provider]);
if (supplementalKey) return supplementalKey;
// 3. models.json provider API keys (e.g., kimi-coding, lmstudio)
const modelsJsonApiKey = modelsJsonApiKeys.get(provider);
if (modelsJsonApiKey) return modelsJsonApiKey;
// 4. ModelRegistry fallback resolver (env-backed provider configs)
return resolveTargetFallbackApiKey(provider);
};
}
return Reflect.get(target, prop, receiver);
},
}) as FusionAuthStorage;
}