FN-7622: unify desktop and CLI provider seeding to fix truncated provider list

The Electron desktop app's in-process dashboard server skipped the CLI's provider seeding sequence, so /api/providers and /api/models returned a truncated catalog (missing built-in API-key providers and user customProviders[]) compared to the identical config on the web build.

- Move provider-auth.ts and custom-provider-registry.ts from @fusion/cli into @fusion/engine as the single shared implementation
- Add engine/src/provider-registration.ts exposing seedDashboardProviders(), mirroring the CLI's exact startup order (built-in Zai provider registration -> wrapAuthStorageWithApiKeyProviders -> model merge/refresh -> registerCustomProviders -> settings:updated resubscription)
- Update desktop/src/local-runtime.ts and local-server.ts to call the shared seedDashboardProviders() helper instead of constructing a raw authStorage/modelRegistry
- Convert packages/cli/src/commands/provider-auth.ts and custom-provider-registry.ts into re-export shims preserving unchanged observable behavior
- Add engine/src/__tests__/provider-registration.test.ts and expand desktop local-runtime/local-server tests to cover the shared seeding path
- Add changeset for @runfusion/fusion (patch)

Files changed:
 .changeset/fn-7622-desktop-provider-parity.md      |   7 +
 .../cli/src/commands/custom-provider-registry.ts   | 122 +----
 packages/cli/src/commands/provider-auth.ts         | 517 +--------------------
 .../desktop/src/__tests__/local-runtime.test.ts    |  93 ++++
 .../desktop/src/__tests__/local-server.test.ts     |  62 ++-
 packages/desktop/src/local-runtime.ts              |  33 +-
 packages/desktop/src/local-server.ts               |  21 +-
 .../src/__tests__/provider-registration.test.ts    | 192 ++++++++
 packages/engine/src/custom-provider-registry.ts    | 117 +++++
 packages/engine/src/index.ts                       |  18 +
 packages/engine/src/provider-auth.ts               | 513 ++++++++++++++++++++
 packages/engine/src/provider-registration.ts       | 105 +++++
 12 files changed, 1172 insertions(+), 628 deletions(-)

Fusion-Task-Id: FN-7622
Fusion-Task-Lineage: fb6fbbf3-745e-4623-b7af-11471e13f138
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 08:08:44 -07:00
parent a4f5fbc0e9
commit fe5a595984
12 changed files with 1172 additions and 628 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix desktop app showing a truncated provider/model list vs. the web build.
category: fix
dev: The Electron desktop app's in-process dashboard server (local-runtime.ts, local-server.ts) now routes through a shared `@fusion/engine` `seedDashboardProviders()` helper that mirrors the CLI serve/dashboard/daemon startup sequence (built-in Zai/API-key provider seeding, `wrapAuthStorageWithApiKeyProviders`, `registerCustomProviders`). `provider-auth.ts` and `custom-provider-registry.ts` moved from `@fusion/cli` into `@fusion/engine`; the CLI files are now re-export shims with unchanged observable behavior.

View File

@@ -1,110 +1,12 @@
import { customProviderRegistryKey, type CustomProvider } from "@fusion/core";
interface ModelRegistryLike {
registerProvider: (name: string, config: {
baseUrl: string;
api: string;
apiKey?: string;
models: Array<{
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
compat?: {
supportsDeveloperRole?: boolean;
};
}>;
}) => void;
refresh: () => void;
}
export function resolveApiType(apiType: string): string {
if (apiType === "anthropic-compatible") {
return "anthropic";
}
if (apiType === "openai-responses") {
return "openai-responses";
}
return "openai-completions";
}
function toProviderConfig(provider: CustomProvider) {
const api = resolveApiType(provider.apiType);
const supportsDeveloperRole = provider.supportsDeveloperRole === true;
return {
baseUrl: provider.baseUrl,
api,
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name,
reasoning: false,
input: ["text" as const],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
...(api === "openai-completions" ? { compat: { supportsDeveloperRole } } : {}),
})),
};
}
function providersDiffer(previous: CustomProvider, current: CustomProvider): boolean {
return JSON.stringify(toProviderConfig(previous)) !== JSON.stringify(toProviderConfig(current));
}
export function registerCustomProviders(
modelRegistry: ModelRegistryLike,
customProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
const providers = customProviders ?? [];
for (const provider of providers) {
const registryKey = customProviderRegistryKey(provider, providers);
try {
modelRegistry.registerProvider(registryKey, toProviderConfig(provider));
logFn(`Registered custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
}
}
modelRegistry.refresh();
}
export function reregisterCustomProviders(
modelRegistry: ModelRegistryLike,
previousProviders: CustomProvider[] | undefined,
currentProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
const previousById = new Map((previousProviders ?? []).map((provider) => [provider.id, provider]));
const providers = currentProviders ?? [];
for (const provider of providers) {
const previous = previousById.get(provider.id);
if (previous && !providersDiffer(previous, provider)) {
continue;
}
const registryKey = customProviderRegistryKey(provider, providers);
try {
modelRegistry.registerProvider(registryKey, toProviderConfig(provider));
logFn(`${previous ? "Updated" : "Registered"} custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
}
}
modelRegistry.refresh();
}
/*
FNXC:ProviderAuth 2026-07-07-00:00:
FN-7622: this module's implementation moved to packages/engine/src/custom-provider-registry.ts so
the desktop in-process dashboard server and the CLI serve/dashboard/daemon paths share one custom-
provider registration sequence. This file is now a thin re-export shim so existing CLI imports
(`./custom-provider-registry.js`) and its test suite keep working unchanged.
*/
export {
resolveApiType,
registerCustomProviders,
reregisterCustomProviders,
} from "@fusion/engine";

View File

@@ -1,504 +1,13 @@
import type {
AuthStorage,
ModelRegistry,
AuthCredential,
} from "@earendil-works/pi-coding-agent";
import {
choosePreferredStoredCredential,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
type StoredAuthCredential,
} from "@fusion/core";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
export type LoginCallbacks = Parameters<AuthStorage["login"]>[1] & {
onManualCodeInput?: () => Promise<string>;
};
export interface DashboardAuthStorage {
reload(): void;
getOAuthProviders(): Array<{ id: string; name: string }>;
hasAuth(provider: string): boolean;
login(providerId: string, callbacks: LoginCallbacks): Promise<void>;
logout(provider: string): void;
getApiKeyProviders(): Array<{ id: string; name: string }>;
setApiKey(providerId: string, apiKey: string): void;
clearApiKey(providerId: string): void;
hasApiKey(providerId: string): boolean;
getApiKey(providerId: string): Promise<string | undefined>;
get(providerId: string): { type?: string; key?: string } | undefined;
}
interface ReadFallbackAuthStorage {
reload(): void;
hasAuth(provider: string): boolean;
getApiKey(providerId: string): Promise<string | undefined>;
get(providerId: string): StoredCredential | undefined;
getAll(): Record<string, StoredCredential>;
list(): string[];
}
type StoredCredential = StoredAuthCredential;
const ANTHROPIC_API_KEY_PROVIDER_ID = "anthropic-api-key";
const ANTHROPIC_STORAGE_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID = "anthropic-subscription";
const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
{ id: ANTHROPIC_API_KEY_PROVIDER_ID, name: "Anthropic API Key" },
{ id: "brave", name: "Brave Search" },
{ id: "kimi-coding", name: "Kimi" },
{ id: "minimax", name: "Minimax" },
{ id: "openrouter", name: "OpenRouter" },
{ id: "opencode-go", name: "Opencode (Go)" },
{ id: "tavily", name: "Tavily" },
{ id: "zai", name: "Zai" },
];
const CLI_PROVIDER_IDS = new Set(["pi-claude-cli", "droid-cli"]);
function toApiKeyStorageProviderId(providerId: string): string {
return providerId === ANTHROPIC_API_KEY_PROVIDER_ID ? ANTHROPIC_STORAGE_PROVIDER_ID : providerId;
}
function getProviderDisplayName(providerId: string): string {
const knownProviderNames = new Map(
BUILT_IN_API_KEY_PROVIDERS.map((provider) => [provider.id, provider.name]),
);
const knownName = knownProviderNames.get(providerId);
if (knownName) return knownName;
return providerId
.split(/[-_]+/)
.filter(Boolean)
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(" ");
}
export function wrapAuthStorageWithApiKeyProviders(
authStorage: AuthStorage,
modelRegistry: ModelRegistry,
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
): DashboardAuthStorage {
const mergedAuthStorage = mergeAuthStorageReads(authStorage, readFallbackAuthStorages);
const getAnthropicSubscriptionCredential = () => {
const syntheticCredential = mergedAuthStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
if (syntheticCredential) return syntheticCredential;
const legacyCredential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
return legacyCredential?.type === "oauth" ? legacyCredential : undefined;
};
const migrateStoredAnthropicSubscriptionCredential = () => {
const existingSubscription = authStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (existingSubscription?.type === "oauth") {
return existingSubscription;
}
const legacySubscription = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (legacySubscription?.type !== "oauth") {
return undefined;
}
/*
FNXC:ProviderAuth 2026-06-29-23:58:
Saving or clearing the separated `anthropic-api-key` provider overwrites the raw `anthropic` storage slot used by model execution.
Read the primary auth storage directly and migrate legacy subscription OAuth from `anthropic` to `anthropic-subscription` before that write, because merged Anthropic reads intentionally expose `anthropic` as API-key-only.
*/
mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, legacySubscription as AuthCredential);
return legacySubscription;
};
return {
reload: () => mergedAuthStorage.reload(),
getOAuthProviders: () =>
mergedAuthStorage
.getOAuthProviders()
.map((provider) => provider.id === ANTHROPIC_STORAGE_PROVIDER_ID
? ({ id: ANTHROPIC_STORAGE_PROVIDER_ID, name: "Anthropic Subscription" })
: ({ id: provider.id, name: provider.name })),
hasAuth: (provider) => provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID
? Boolean(getAnthropicSubscriptionCredential())
: mergedAuthStorage.hasAuth(provider),
login: async (providerId, callbacks) => {
if (providerId !== ANTHROPIC_STORAGE_PROVIDER_ID && providerId !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
await mergedAuthStorage.login(
providerId as Parameters<AuthStorage["login"]>[0],
callbacks as Parameters<AuthStorage["login"]>[1],
);
return;
}
const existingApiKey = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
await mergedAuthStorage.login(
ANTHROPIC_STORAGE_PROVIDER_ID as Parameters<AuthStorage["login"]>[0],
callbacks as Parameters<AuthStorage["login"]>[1],
);
const oauthCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (oauthCredential?.type === "oauth") {
/*
FNXC:ProviderAuth 2026-06-29-23:15:
Anthropic subscription OAuth and raw Anthropic API-key auth must be separate UI providers: OAuth stays `anthropic`, while the UI/API key card uses `anthropic-api-key` and maps back to the `anthropic` model credential.
Store subscription OAuth under an internal key after upstream login because the OAuth library writes through the same `anthropic` id used by model API-key execution.
*/
mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, oauthCredential as AuthCredential);
if (existingApiKey?.type === "api_key") {
mergedAuthStorage.set(ANTHROPIC_STORAGE_PROVIDER_ID, existingApiKey as AuthCredential);
} else {
authStorage.remove(ANTHROPIC_STORAGE_PROVIDER_ID);
}
}
},
logout: (provider) => {
if (provider !== ANTHROPIC_STORAGE_PROVIDER_ID && provider !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
mergedAuthStorage.logout(provider);
return;
}
mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
/*
FNXC:ProviderAuth 2026-06-29-23:59:
Logging out Anthropic subscription auth must also remove pre-split OAuth credentials still stored under `anthropic`.
Check primary storage directly because merged Anthropic reads expose `anthropic` as the model API-key credential only, so an OAuth credential would otherwise survive reload and reappear as `anthropic-subscription`.
*/
const legacyAnthropicCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID);
}
},
getApiKeyProviders: () => {
const oauthProviderIds = new Set(
mergedAuthStorage
.getOAuthProviders()
.map((provider) => provider.id),
);
const providers = new Map<string, string>();
for (const provider of BUILT_IN_API_KEY_PROVIDERS) {
/*
FNXC:ProviderAuth 2026-06-29-23:32:
Anthropic subscription OAuth and Anthropic API-key auth are separate UI providers: the API-key card is `anthropic-api-key`, but reads and writes the `anthropic` model credential through toApiKeyStorageProviderId().
Keep OAuth-id exclusion only for registry-derived providers so OpenAI stays split as `openai-codex` OAuth plus `openai` API key, while unrelated OAuth providers are not reclassified.
*/
providers.set(provider.id, provider.name);
}
for (const model of modelRegistry.getAll()) {
const providerId = model.provider;
if (
!providerId ||
oauthProviderIds.has(providerId) ||
providers.has(providerId) ||
CLI_PROVIDER_IDS.has(providerId)
) {
continue;
}
providers.set(providerId, getProviderDisplayName(providerId));
}
return Array.from(providers, ([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name),
);
},
setApiKey: (providerId, apiKey) => {
const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
migrateStoredAnthropicSubscriptionCredential();
}
mergedAuthStorage.set(storageProviderId, { type: "api_key", key: apiKey });
},
clearApiKey: (providerId) => {
const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
migrateStoredAnthropicSubscriptionCredential();
}
mergedAuthStorage.remove(storageProviderId);
},
hasApiKey: (providerId) => {
const credential = mergedAuthStorage.get(toApiKeyStorageProviderId(providerId));
return credential?.type === "api_key" && !!credential.key;
},
getApiKey: async (providerId) => {
const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined;
}
return mergedAuthStorage.getApiKey(storageProviderId);
},
get: (providerId) => {
if (providerId === ANTHROPIC_API_KEY_PROVIDER_ID) {
const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
return credential?.type === "api_key" ? credential : undefined;
}
if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) {
return getAnthropicSubscriptionCredential();
}
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
return getAnthropicSubscriptionCredential();
}
return mergedAuthStorage.get(providerId);
},
};
}
export function mergeAuthStorageReads(
authStorage: AuthStorage,
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
): AuthStorage {
const readAuthStorages = [authStorage, ...readFallbackAuthStorages];
// 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>();
const selectCredential = (
providerId: string,
storages: Array<Pick<ReadFallbackAuthStorage, "get">>,
): StoredCredential | undefined => {
let best: StoredCredential | undefined;
for (const storage of storages) {
const credential = storage.get(providerId);
if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) {
if (credential?.type === "api_key") {
best = choosePreferredStoredCredential(best, credential);
}
continue;
}
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
if (credential?.type === "oauth") {
best = choosePreferredStoredCredential(best, credential);
}
const legacyAnthropic = storage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
if (legacyAnthropic?.type === "oauth") {
best = choosePreferredStoredCredential(best, legacyAnthropic);
}
continue;
}
best = choosePreferredStoredCredential(best, credential);
}
return best;
};
const getCredential = (providerId: string) => {
if (loggedOutProviders.has(providerId)) {
return undefined;
}
return selectCredential(providerId, readAuthStorages);
};
const syncFallbackOauthCredentials = () => {
const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list()));
for (const providerId of providerIds) {
const storageProviderId = providerId === ANTHROPIC_STORAGE_PROVIDER_ID
? ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID
: providerId;
if (loggedOutProviders.has(providerId) || loggedOutProviders.has(storageProviderId)) {
continue;
}
const current = authStorage.get(storageProviderId) as StoredCredential | undefined;
const candidate = selectCredential(storageProviderId, readFallbackAuthStorages);
if (!shouldHydrateStoredCredential(current, candidate)) {
continue;
}
if (candidate && (candidate.type === "oauth" || candidate.type === "api_key")) {
/*
FNXC:ProviderAuth 2026-06-29-23:48:
Legacy Anthropic OAuth files may still store subscription credentials under `anthropic`; hydrate those as `anthropic-subscription` so Anthropic model/API-key reads only trust `api_key` credentials under `anthropic`.
*/
authStorage.set(storageProviderId, candidate as AuthCredential);
}
}
};
syncFallbackOauthCredentials();
return new Proxy(authStorage, {
get(target, prop, receiver) {
if (prop === "logout") {
return (provider: string) => {
target.logout(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "remove") {
return (provider: string) => {
target.remove(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "set") {
return (provider: string, credential: AuthCredential) => {
target.set(provider, credential);
loggedOutProviders.delete(provider);
};
}
if (prop === "reload") {
return () => {
for (const storage of readAuthStorages) {
storage.reload();
}
syncFallbackOauthCredentials();
};
}
if (prop === "get") {
return getCredential;
}
if (prop === "has") {
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return false;
}
if (provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
return Boolean(getCredential(provider));
}
return readAuthStorages.some((storage) => Boolean(storage.get(provider)));
};
}
if (prop === "hasAuth") {
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return false;
}
if (provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
return Boolean(getCredential(provider));
}
return readAuthStorages.some((storage) => storage.hasAuth(provider));
};
}
if (prop === "getAll") {
return () => {
const providerIds = new Set(readAuthStorages.flatMap((storage) => storage.list()));
if (providerIds.has(ANTHROPIC_STORAGE_PROVIDER_ID)) {
providerIds.add(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
}
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
if (loggedOutProviders.has(providerId)) {
continue;
}
const credential = getCredential(providerId);
if (credential) {
merged[providerId] = credential;
}
}
return merged;
};
}
if (prop === "list") {
return () => {
const providers = new Set(readAuthStorages.flatMap((storage) => storage.list()));
if (providers.has(ANTHROPIC_STORAGE_PROVIDER_ID) && !loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID)) {
providers.add(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
}
return Array.from(providers).filter((p) => !loggedOutProviders.has(p) && getCredential(p));
};
}
if (prop === "getApiKey") {
return async (providerId: string) => {
if (loggedOutProviders.has(providerId)) {
return undefined;
}
const credential = getCredential(providerId);
if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) {
return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined;
}
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID && credential) {
/*
FNXC:ProviderAuth 2026-07-05-09:10:
Reading `anthropic-subscription` through this merge proxy must delegate to the underlying real engine `authStorage.getApiKey(...)` (the `target` primary storage) so the refresh-token HTTP round trip in packages/engine/src/auth-storage.ts actually runs. The prior local static `Date.now() >= credential.expires` check (`resolveStoredCredentialApiKey`/`resolveOAuthApiKey`) never called the real engine and silently no-oped the refresh in production, e.g. the dashboard status route's best-effort refresh-on-expiry read (register-auth-routes.ts). `target.getApiKey` internally handles both the separated `anthropic-subscription` row and the legacy `anthropic` OAuth row, so this single delegated call covers both storage permutations without duplicating that logic here. Only fall back to the read-only fallback storages' local (non-refreshing) resolution when the primary engine yields no key; a logged-out subscription is already excluded above and must never reach this delegated call.
*/
const engineApiKey = await target.getApiKey(providerId);
if (engineApiKey) return engineApiKey;
for (const fallbackStorage of readFallbackAuthStorages) {
const fallbackApiKey = await fallbackStorage.getApiKey(providerId);
if (fallbackApiKey) return fallbackApiKey;
}
return undefined;
}
for (const storage of readAuthStorages) {
const apiKey = await storage.getApiKey(providerId);
if (apiKey) return apiKey;
}
return undefined;
};
}
return Reflect.get(target, prop, receiver);
},
}) as AuthStorage;
}
function resolveStoredApiKey(key: string | undefined): string | undefined {
if (!key) return undefined;
return process.env[key] ?? key;
}
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;
}
const oauthProviderId = providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID
? ANTHROPIC_STORAGE_PROVIDER_ID
: providerId;
return getOAuthProvider(oauthProviderId)?.getApiKey(credential as OAuthCredentials);
}
function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined {
if (credential?.type === "api_key") {
return resolveStoredApiKey(credential.key);
}
if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) {
return undefined;
}
if (credential?.type === "oauth") {
return resolveOAuthApiKey(providerId, credential);
}
return undefined;
}
export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallbackAuthStorage {
let credentials: Record<string, StoredCredential> = {};
const reload = () => {
const nextCredentials: Record<string, StoredCredential> = {};
for (const authPath of authPaths) {
const parsed = readStoredCredentialsFromAuthFile(authPath);
for (const [provider, credential] of Object.entries(parsed)) {
nextCredentials[provider] = choosePreferredStoredCredential(nextCredentials[provider], credential) ?? credential;
}
}
credentials = nextCredentials;
};
reload();
return {
reload,
hasAuth: (provider) => Boolean(credentials[provider]),
get: (provider) => credentials[provider],
getAll: () => ({ ...credentials }),
list: () => Object.keys(credentials),
getApiKey: async (provider) => {
return resolveStoredCredentialApiKey(provider, credentials[provider]);
},
};
}
/*
FNXC:ProviderAuth 2026-07-07-00:00:
FN-7622: this module's implementation moved to packages/engine/src/provider-auth.ts so the desktop
in-process dashboard server and the CLI serve/dashboard/daemon paths share one provider-auth-wrapping
sequence instead of the desktop silently skipping it. This file is now a thin re-export shim so
existing CLI imports (`./provider-auth.js`) and its test suite keep working unchanged.
*/
export type { LoginCallbacks, DashboardAuthStorage } from "@fusion/engine";
export {
wrapAuthStorageWithApiKeyProviders,
mergeAuthStorageReads,
createReadOnlyAuthFileStorage,
} from "@fusion/engine";

View File

@@ -41,6 +41,57 @@ class FakeServer {
}
}
/*
* FN-7622 symptom-verification mocks for createDashboardServerDefault (the real default
* createDashboardServer implementation, exercised only when a test does NOT override
* `createDashboardServer` in LocalRuntimeManagerOptions). Mirrors local-server.test.ts's pattern.
*/
const engineMocks = vi.hoisted(() => {
const centralCore = {
init: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
listProjects: vi.fn(async () => [] as Array<{ id: string; name: string; path: string; status: string }>),
};
const engineManager = {
startAll: vi.fn(async () => undefined),
startReconciliation: vi.fn(),
stopAll: vi.fn(async () => undefined),
ensureEngine: vi.fn(async () => ({ id: "engine-1" })),
onProjectAccessed: vi.fn(),
};
const CentralCore = vi.fn(function () {
return centralCore;
});
const ProjectEngineManager = vi.fn(function () {
return engineManager;
});
const seedDashboardProvidersDispose = vi.fn();
const seedDashboardProviders = vi.fn(async ({ authStorage }: { authStorage: unknown }) => ({
authStorage: { ...(authStorage as object), __wrapped: true },
dispose: seedDashboardProvidersDispose,
}));
const createServer = vi.fn(() => ({ listen: vi.fn() }));
return {
centralCore,
engineManager,
CentralCore,
ProjectEngineManager,
seedDashboardProviders,
seedDashboardProvidersDispose,
createServer,
};
});
vi.mock("@fusion/core", () => ({ CentralCore: engineMocks.CentralCore }));
vi.mock("@fusion/dashboard", () => ({ createServer: engineMocks.createServer }));
vi.mock("@fusion/engine", () => ({
ProjectEngineManager: engineMocks.ProjectEngineManager,
createFusionAuthStorage: () => ({ reload: () => undefined, getOAuthProviders: () => [], hasAuth: () => false }),
createFusionModelRegistry: () => ({ listModels: () => [], refresh: () => undefined }),
seedDashboardProviders: engineMocks.seedDashboardProviders,
}));
describe("LocalRuntimeManager", () => {
const store = {
init: vi.fn(async () => undefined),
@@ -349,4 +400,46 @@ describe("LocalRuntimeManager", () => {
expect(first).toEqual(second);
expect(store.init).toHaveBeenCalledTimes(1);
});
/*
* FN-7622 symptom verification: before this fix, createDashboardServerDefault (the embedded
* in-process server path) constructed a RAW authStorage/modelRegistry and passed the raw
* authStorage straight to createServer, never running the built-in/API-key/custom-provider
* registration sequence the CLI serve/dashboard/daemon commands run — so desktop's
* /api/providers and /api/models exposed a truncated catalog vs. the identical web-build config.
* This test exercises the REAL default createDashboardServer (no createDashboardServer override)
* and asserts it now routes through seedDashboardProviders and hands createServer the WRAPPED
* auth storage, matching the CLI-equivalent catalog seedDashboardProviders produces (see
* packages/engine/src/__tests__/provider-registration.test.ts for the underlying catalog
* assertions across customProviders undefined/[]/one/multiple).
*/
it("createDashboardServerDefault seeds providers and passes the WRAPPED auth storage to createServer (FN-7622)", async () => {
const { LocalRuntimeManager } = await import("../local-runtime.ts");
const server = new FakeServer(4545);
engineMocks.createServer.mockReturnValueOnce({
listen: vi.fn(() => {
setTimeout(() => server.emit("listening"), 0);
return server as unknown as Server;
}),
});
const manager = new LocalRuntimeManager({
rootDir: "/repo",
createStore: async () => store,
// No createDashboardServer override: exercises the real createDashboardServerDefault.
});
await manager.startLocal();
expect(engineMocks.seedDashboardProviders).toHaveBeenCalledWith(
expect.objectContaining({ authStorage: expect.anything(), modelRegistry: expect.anything() }),
);
expect(engineMocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ authStorage: expect.objectContaining({ __wrapped: true }) }),
);
await manager.stopLocal();
expect(engineMocks.seedDashboardProvidersDispose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -82,7 +82,28 @@ const mocks = vi.hoisted(() => {
return engineManager;
});
return { TaskStore, CentralCore, ProjectEngineManager, createServer, store, listen, centralCore, engineManager, engine };
// FN-7622: mirrors @fusion/engine's real seedDashboardProviders() shape — wraps the raw
// authStorage into a distinguishable WRAPPED object so tests can assert local-server.ts passes
// the wrapped storage (not the raw one) into createServer, and returns a disposer.
const seedDashboardProvidersDispose = vi.fn();
const seedDashboardProviders = vi.fn(async ({ authStorage }: { authStorage: unknown }) => ({
authStorage: { ...(authStorage as object), __wrapped: true },
dispose: seedDashboardProvidersDispose,
}));
return {
TaskStore,
CentralCore,
ProjectEngineManager,
createServer,
store,
listen,
centralCore,
engineManager,
engine,
seedDashboardProviders,
seedDashboardProvidersDispose,
};
});
vi.mock("@fusion/core", () => ({ TaskStore: mocks.TaskStore, CentralCore: mocks.CentralCore }));
@@ -90,7 +111,11 @@ vi.mock("@fusion/dashboard", () => ({ createServer: mocks.createServer }));
vi.mock("@fusion/engine", () => ({
ProjectEngineManager: mocks.ProjectEngineManager,
createFusionAuthStorage: () => ({ reload: () => undefined, getOAuthProviders: () => [], hasAuth: () => false }),
createFusionModelRegistry: () => ({ listModels: () => [] }),
createFusionModelRegistry: () => ({ listModels: () => [], refresh: () => undefined }),
// FN-7622: seedDashboardProviders is asserted directly in provider-registration.test.ts; this
// desktop-side mock just proves local-server.ts calls it and wires its returned WRAPPED auth
// storage (not the raw one) into createServer.
seedDashboardProviders: mocks.seedDashboardProviders,
}));
describe("DesktopLocalServerManager", () => {
@@ -187,4 +212,37 @@ describe("DesktopLocalServerManager", () => {
expect(first).toBe(second);
expect(mocks.listen).toHaveBeenCalledTimes(1);
});
/*
* FN-7622 symptom verification: before this fix, DesktopLocalServerManager.start() passed the
* RAW authStorage straight to createServer and never called any provider-seeding sequence, so
* the desktop's Authentication page / model routes only ever saw OAuth + CLI providers (never
* built-in API-key providers or user customProviders[]) — the truncated-catalog symptom vs. the
* CLI/web build. Assert the fix: seedDashboardProviders is invoked with the store (so it can
* read globalSettings.customProviders) and createServer receives its returned WRAPPED auth
* storage, not the raw one, and the seeding disposer is invoked on stop().
*/
it("seeds providers via seedDashboardProviders and passes the WRAPPED auth storage to createServer (FN-7622)", async () => {
const { DesktopLocalServerManager } = await import("../local-server.ts");
const manager = new DesktopLocalServerManager("/repo");
await manager.start();
expect(mocks.seedDashboardProviders).toHaveBeenCalledWith(
expect.objectContaining({
store: expect.objectContaining({ init: mocks.store.init, watch: mocks.store.watch }),
authStorage: expect.anything(),
modelRegistry: expect.anything(),
}),
);
expect(mocks.createServer).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
authStorage: expect.objectContaining({ __wrapped: true }),
}),
);
await manager.stop();
expect(mocks.seedDashboardProvidersDispose).toHaveBeenCalledTimes(1);
});
});

View File

@@ -89,7 +89,7 @@ async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
async function createDashboardServerDefault(store: TaskStoreLike, rootDir: string): Promise<{ server: Server; cleanup: RuntimeCleanup }> {
const { CentralCore } = await import("@fusion/core");
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry } = await import("@fusion/engine");
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine");
/*
* FNXC:DesktopRuntime 2026-06-20-23:39:
@@ -97,7 +97,9 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
*/
const centralCore = new CentralCore();
const engineManager = new ProjectEngineManager(centralCore);
const providerSeeding: { dispose?: () => void } = {};
const cleanup = async () => {
providerSeeding.dispose?.();
await engineManager.stopAll();
await centralCore.close?.();
};
@@ -122,24 +124,37 @@ async function createDashboardServerDefault(store: TaskStoreLike, rootDir: strin
strace(`createDashboardServer: primaryProject=${rootProject?.id ?? "none"}`);
const primaryEngine = rootProject ? await engineManager.ensureEngine(rootProject.id) : undefined;
/*
* FNXC:DesktopRuntime 2026-07-03-06:20:
* Wire an auth storage into the embedded server. Without it, GET /api/auth/status throws 500
* "Authentication is not configured", the dashboard's first-run onboarding hook (useAuthOnboarding
* -> fetchAuthStatus) hits its silent catch and NEVER opens the AI/GitHub onboarding wizard, and
* providers can't be authenticated at all. The CLI wires the same storage (createFusionAuthStorage);
* the desktop must too so operators can set up AI accounts. (API-key provider wrapping remains
* CLI-only for now; OAuth + CLI providers are available here.)
* FNXC:DesktopRuntime 2026-07-07-00:00:
* FN-7622: wire an auth storage into the embedded server AND run it through the same
* registration sequence the CLI serve/dashboard/daemon commands use — built-in Zai/API-key
* provider seeding (registerBuiltInZaiProvider), wrapAuthStorageWithApiKeyProviders, and
* registerCustomProviders(globalSettings.customProviders) — via the shared
* @fusion/engine seedDashboardProviders() helper. Previously this path passed the RAW
* authStorage/modelRegistry straight to createServer and skipped that whole sequence, so
* desktop's Authentication page and model picker showed a truncated provider catalog
* (stock API-key providers and user customProviders[] missing) versus the identical config
* rendered by the web build. Passing the WRAPPED authStorage returned by
* seedDashboardProviders (not the raw one) closes that gap; the disposer unsubscribes the
* settings:updated -> reregisterCustomProviders listener on shutdown.
*/
const authStorage = createFusionAuthStorage();
// FNXC:DesktopRuntime 2026-07-03-07:00: a ModelRegistry is required for the /api/models endpoint;
// without it the onboarding model picker shows "no models" even with a provider connected.
const modelRegistry = createFusionModelRegistry(authStorage);
strace("createDashboardServer: seedDashboardProviders");
const { authStorage: wrappedAuthStorage, dispose } = await seedDashboardProviders({
store: store as never,
authStorage,
modelRegistry,
log: (scope, message) => strace(`[${scope}] ${message}`),
});
providerSeeding.dispose = dispose;
strace("createDashboardServer: createServer");
const app = createServer(store as never, {
...(primaryEngine ? { engine: primaryEngine } : {}),
engineManager,
centralCore,
authStorage,
authStorage: wrappedAuthStorage,
modelRegistry,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
});

View File

@@ -55,7 +55,7 @@ export class DesktopLocalServerManager {
const { TaskStore } = await import("@fusion/core");
const { CentralCore } = await import("@fusion/core");
const { createServer } = await import("@fusion/dashboard");
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry } = await import("@fusion/engine");
const { ProjectEngineManager, createFusionAuthStorage, createFusionModelRegistry, seedDashboardProviders } = await import("@fusion/engine");
store = new TaskStore(this.rootDir) as TaskStoreLike;
await store.init();
await store.watch();
@@ -65,7 +65,9 @@ export class DesktopLocalServerManager {
*/
const centralCore = new CentralCore();
const engineManager = new ProjectEngineManager(centralCore);
const providerSeeding: { dispose?: () => void } = {};
cleanup = async () => {
providerSeeding.dispose?.();
await engineManager.stopAll();
await centralCore.close?.();
};
@@ -75,14 +77,27 @@ export class DesktopLocalServerManager {
engineManager.startReconciliation();
const rootProject = await resolveDesktopRuntimePrimaryProject(centralCore);
const primaryEngine = rootProject ? await engineManager.ensureEngine(rootProject.id) : undefined;
// FNXC:DesktopRuntime 2026-07-03-06:20: wire auth storage so /api/auth/status works and first-run onboarding can open (see local-runtime.ts).
/*
* FNXC:DesktopRuntime 2026-07-07-00:00:
* FN-7622: this legacy path had the same truncated-provider-list gap as local-runtime.ts — wire
* auth storage AND run it through the shared seedDashboardProviders() sequence (built-in Zai/
* API-key seeding, wrapAuthStorageWithApiKeyProviders, registerCustomProviders) so this path
* surfaces the same provider catalog as the CLI and the embedded runtime path. Pass the WRAPPED
* authStorage to createServer, not the raw one.
*/
const authStorage = createFusionAuthStorage();
const modelRegistry = createFusionModelRegistry(authStorage);
const { authStorage: wrappedAuthStorage, dispose } = await seedDashboardProviders({
store: store as never,
authStorage,
modelRegistry,
});
providerSeeding.dispose = dispose;
const app = createServer(store as never, {
...(primaryEngine ? { engine: primaryEngine } : {}),
engineManager,
centralCore,
authStorage,
authStorage: wrappedAuthStorage,
modelRegistry,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
});

View File

@@ -0,0 +1,192 @@
import { describe, expect, it, vi } from "vitest";
import type { CustomProvider } from "@fusion/core";
import { seedDashboardProviders } from "../provider-registration.js";
/*
FNXC:ProviderRegistration 2026-07-07-00:00:
FN-7622 regression coverage: asserts seedDashboardProviders() produces the SAME provider catalog the
CLI serve/dashboard/daemon commands produce (built-in API-key providers + any registered custom
provider) across the enumerated data states — customProviders undefined/[]/one/multiple — and that a
settings:updated change re-registers custom providers via the disposer-managed listener.
*/
function makeAuthStorage() {
const credentials: Record<string, { type: string; key?: string }> = {};
return {
reload: vi.fn(),
getOAuthProviders: vi.fn(() => []),
hasAuth: vi.fn((provider: string) => Boolean(credentials[provider])),
login: vi.fn(),
logout: vi.fn((provider: string) => {
delete credentials[provider];
}),
set: vi.fn((provider: string, credential: { type: string; key?: string }) => {
credentials[provider] = credential;
}),
remove: vi.fn((provider: string) => {
delete credentials[provider];
}),
get: vi.fn((provider: string) => credentials[provider]),
getAll: vi.fn(() => ({ ...credentials })),
list: vi.fn(() => Object.keys(credentials)),
getApiKey: vi.fn(async (provider: string) => credentials[provider]?.key),
} as any;
}
function makeModelRegistry() {
const registeredProviders = new Map<string, { models: Array<{ provider: string; id: string }> }>();
return {
registerProvider: vi.fn((name: string, config: { models?: Array<{ id: string }> }) => {
registeredProviders.set(name, {
models: (config.models ?? []).map((model) => ({ provider: name, id: model.id })),
});
}),
refresh: vi.fn(),
getAll: vi.fn(() =>
Array.from(registeredProviders.entries()).flatMap(([, provider]) => provider.models),
),
registeredProviders,
} as any;
}
interface FakeGlobalSettings {
customProviders?: CustomProvider[];
}
function makeStore(initialCustomProviders?: CustomProvider[]) {
let settings: FakeGlobalSettings = { customProviders: initialCustomProviders };
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
return {
getGlobalSettingsStore: () => ({
getSettings: async () => settings,
}),
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
const current = listeners.get(event) ?? [];
current.push(listener);
listeners.set(event, current);
return undefined as never;
}),
off: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
const current = listeners.get(event) ?? [];
listeners.set(event, current.filter((item) => item !== listener));
return undefined as never;
}),
// Test-only helper: emits settings:updated the way TaskStore does.
__emitSettingsUpdated(next: FakeGlobalSettings) {
const previous = settings;
settings = next;
for (const listener of listeners.get("settings:updated") ?? []) {
listener({ settings: next, previous });
}
},
};
}
const customProvider = (overrides: Partial<CustomProvider> = {}): CustomProvider => ({
id: "550e8400-e29b-41d4-a716-446655440000",
name: "Acme AI",
apiType: "openai-compatible",
baseUrl: "https://acme.test/v1",
apiKey: "ACME_KEY",
models: [{ id: "acme-1", name: "Acme Model 1" }],
...overrides,
});
describe("seedDashboardProviders", () => {
it("registers built-in API-key providers even with no custom providers (undefined)", async () => {
const store = makeStore(undefined);
const authStorage = makeAuthStorage();
const modelRegistry = makeModelRegistry();
const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry });
const providerIds = wrapped.getApiKeyProviders().map((p) => p.id);
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "kimi-coding"]));
});
it("registers built-in API-key providers with an empty customProviders array", async () => {
const store = makeStore([]);
const authStorage = makeAuthStorage();
const modelRegistry = makeModelRegistry();
const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry });
const providerIds = wrapped.getApiKeyProviders().map((p) => p.id);
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "kimi-coding"]));
});
it("registers one custom provider alongside built-ins", async () => {
const store = makeStore([customProvider()]);
const authStorage = makeAuthStorage();
const modelRegistry = makeModelRegistry();
const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry });
expect(modelRegistry.registerProvider).toHaveBeenCalledWith(
"acme-ai",
expect.objectContaining({ baseUrl: "https://acme.test/v1" }),
);
const providerIds = wrapped.getApiKeyProviders().map((p) => p.id);
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "kimi-coding", "acme-ai"]));
});
it("registers multiple custom providers alongside built-ins", async () => {
const store = makeStore([
customProvider({ id: "id-1", name: "Acme One", baseUrl: "https://one.test" }),
customProvider({ id: "id-2", name: "Acme Two", baseUrl: "https://two.test" }),
]);
const authStorage = makeAuthStorage();
const modelRegistry = makeModelRegistry();
const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry });
const providerIds = wrapped.getApiKeyProviders().map((p) => p.id);
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter", "acme-one", "acme-two"]));
});
it("does not abort startup when reading custom providers from global settings fails", async () => {
const authStorage = makeAuthStorage();
const modelRegistry = makeModelRegistry();
const log = vi.fn();
const store = {
getGlobalSettingsStore: () => ({
getSettings: async () => {
throw new Error("disk read failed");
},
}),
on: vi.fn(),
off: vi.fn(),
};
const { authStorage: wrapped } = await seedDashboardProviders({ store, authStorage, modelRegistry, log });
// Built-ins still registered despite the custom-provider load failure.
const providerIds = wrapped.getApiKeyProviders().map((p) => p.id);
expect(providerIds).toEqual(expect.arrayContaining(["zai", "openrouter"]));
expect(log).toHaveBeenCalledWith("custom-providers", expect.stringContaining("disk read failed"));
});
it("re-registers custom providers on settings:updated and dispose() unsubscribes", async () => {
const store = makeStore([]);
const authStorage = makeAuthStorage();
const modelRegistry = makeModelRegistry();
const { dispose } = await seedDashboardProviders({ store, authStorage, modelRegistry });
expect(store.on).toHaveBeenCalledWith("settings:updated", expect.any(Function));
modelRegistry.registerProvider.mockClear();
store.__emitSettingsUpdated({ customProviders: [customProvider()] });
expect(modelRegistry.registerProvider).toHaveBeenCalledWith(
"acme-ai",
expect.objectContaining({ baseUrl: "https://acme.test/v1" }),
);
dispose();
expect(store.off).toHaveBeenCalledWith("settings:updated", expect.any(Function));
modelRegistry.registerProvider.mockClear();
store.__emitSettingsUpdated({ customProviders: [customProvider({ id: "id-2", name: "Second" })] });
expect(modelRegistry.registerProvider).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,117 @@
/*
FNXC:ProviderAuth 2026-07-07-00:00:
FN-7622: relocated from packages/cli/src/commands/custom-provider-registry.ts into @fusion/engine
so the desktop in-process dashboard server and the CLI serve/dashboard/daemon paths share ONE
custom-provider registration implementation. packages/cli/src/commands/custom-provider-registry.ts
is now a thin re-export shim of this module; its observable behavior is unchanged.
*/
import { customProviderRegistryKey, type CustomProvider } from "@fusion/core";
interface ModelRegistryLike {
registerProvider: (name: string, config: {
baseUrl: string;
api: string;
apiKey?: string;
models: Array<{
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
compat?: {
supportsDeveloperRole?: boolean;
};
}>;
}) => void;
refresh: () => void;
}
export function resolveApiType(apiType: string): string {
if (apiType === "anthropic-compatible") {
return "anthropic";
}
if (apiType === "openai-responses") {
return "openai-responses";
}
return "openai-completions";
}
function toProviderConfig(provider: CustomProvider) {
const api = resolveApiType(provider.apiType);
const supportsDeveloperRole = provider.supportsDeveloperRole === true;
return {
baseUrl: provider.baseUrl,
api,
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name,
reasoning: false,
input: ["text" as const],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
...(api === "openai-completions" ? { compat: { supportsDeveloperRole } } : {}),
})),
};
}
function providersDiffer(previous: CustomProvider, current: CustomProvider): boolean {
return JSON.stringify(toProviderConfig(previous)) !== JSON.stringify(toProviderConfig(current));
}
export function registerCustomProviders(
modelRegistry: ModelRegistryLike,
customProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
const providers = customProviders ?? [];
for (const provider of providers) {
const registryKey = customProviderRegistryKey(provider, providers);
try {
modelRegistry.registerProvider(registryKey, toProviderConfig(provider));
logFn(`Registered custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
}
}
modelRegistry.refresh();
}
export function reregisterCustomProviders(
modelRegistry: ModelRegistryLike,
previousProviders: CustomProvider[] | undefined,
currentProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
const previousById = new Map((previousProviders ?? []).map((provider) => [provider.id, provider]));
const providers = currentProviders ?? [];
for (const provider of providers) {
const previous = previousById.get(provider.id);
if (previous && !providersDiffer(previous, provider)) {
continue;
}
const registryKey = customProviderRegistryKey(provider, providers);
try {
modelRegistry.registerProvider(registryKey, toProviderConfig(provider));
logFn(`${previous ? "Updated" : "Registered"} custom provider "${provider.name}" (key=${registryKey}, id=${provider.id})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider "${provider.name}" (key=${registryKey}, id=${provider.id}): ${message}`);
}
}
modelRegistry.refresh();
}

View File

@@ -1,6 +1,24 @@
export { AgentLogger, type AgentLoggerOptions, summarizeToolArgs } from "./agent-logger.js";
export { reloadExemptTools, addToExemptTools, getExemptToolNames } from "./agent-action-gate.js";
export { createFusionAuthStorage, createFusionModelRegistry } from "./auth-storage.js";
export {
wrapAuthStorageWithApiKeyProviders,
mergeAuthStorageReads,
createReadOnlyAuthFileStorage,
type LoginCallbacks,
type DashboardAuthStorage,
} from "./provider-auth.js";
export {
resolveApiType,
registerCustomProviders,
reregisterCustomProviders,
} from "./custom-provider-registry.js";
export {
seedDashboardProviders,
type SeedDashboardProvidersStore,
type SeedDashboardProvidersOptions,
type SeedDashboardProvidersResult,
} from "./provider-registration.js";
export {
createTaskCreateTool,
createTaskListTool,

View File

@@ -0,0 +1,513 @@
/*
FNXC:ProviderAuth 2026-07-07-00:00:
FN-7622: relocated from packages/cli/src/commands/provider-auth.ts into @fusion/engine so the desktop
in-process dashboard server (packages/desktop/src/local-runtime.ts, local-server.ts) and the CLI
serve/dashboard/daemon paths share ONE provider-auth-wrapping implementation instead of the desktop
skipping it entirely (the root cause of the desktop-vs-web truncated provider list). The CLI's
packages/cli/src/commands/provider-auth.ts is now a thin re-export shim of this module; its
observable behavior is unchanged.
*/
import type {
AuthStorage,
ModelRegistry,
AuthCredential,
} from "@earendil-works/pi-coding-agent";
import {
choosePreferredStoredCredential,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
type StoredAuthCredential,
} from "@fusion/core";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
export type LoginCallbacks = Parameters<AuthStorage["login"]>[1] & {
onManualCodeInput?: () => Promise<string>;
};
export interface DashboardAuthStorage {
reload(): void;
getOAuthProviders(): Array<{ id: string; name: string }>;
hasAuth(provider: string): boolean;
login(providerId: string, callbacks: LoginCallbacks): Promise<void>;
logout(provider: string): void;
getApiKeyProviders(): Array<{ id: string; name: string }>;
setApiKey(providerId: string, apiKey: string): void;
clearApiKey(providerId: string): void;
hasApiKey(providerId: string): boolean;
getApiKey(providerId: string): Promise<string | undefined>;
get(providerId: string): { type?: string; key?: string } | undefined;
}
interface ReadFallbackAuthStorage {
reload(): void;
hasAuth(provider: string): boolean;
getApiKey(providerId: string): Promise<string | undefined>;
get(providerId: string): StoredCredential | undefined;
getAll(): Record<string, StoredCredential>;
list(): string[];
}
type StoredCredential = StoredAuthCredential;
const ANTHROPIC_API_KEY_PROVIDER_ID = "anthropic-api-key";
const ANTHROPIC_STORAGE_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID = "anthropic-subscription";
const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
{ id: ANTHROPIC_API_KEY_PROVIDER_ID, name: "Anthropic API Key" },
{ id: "brave", name: "Brave Search" },
{ id: "kimi-coding", name: "Kimi" },
{ id: "minimax", name: "Minimax" },
{ id: "openrouter", name: "OpenRouter" },
{ id: "opencode-go", name: "Opencode (Go)" },
{ id: "tavily", name: "Tavily" },
{ id: "zai", name: "Zai" },
];
const CLI_PROVIDER_IDS = new Set(["pi-claude-cli", "droid-cli"]);
function toApiKeyStorageProviderId(providerId: string): string {
return providerId === ANTHROPIC_API_KEY_PROVIDER_ID ? ANTHROPIC_STORAGE_PROVIDER_ID : providerId;
}
function getProviderDisplayName(providerId: string): string {
const knownProviderNames = new Map(
BUILT_IN_API_KEY_PROVIDERS.map((provider) => [provider.id, provider.name]),
);
const knownName = knownProviderNames.get(providerId);
if (knownName) return knownName;
return providerId
.split(/[-_]+/)
.filter(Boolean)
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(" ");
}
export function wrapAuthStorageWithApiKeyProviders(
authStorage: AuthStorage,
modelRegistry: ModelRegistry,
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
): DashboardAuthStorage {
const mergedAuthStorage = mergeAuthStorageReads(authStorage, readFallbackAuthStorages);
const getAnthropicSubscriptionCredential = () => {
const syntheticCredential = mergedAuthStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
if (syntheticCredential) return syntheticCredential;
const legacyCredential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
return legacyCredential?.type === "oauth" ? legacyCredential : undefined;
};
const migrateStoredAnthropicSubscriptionCredential = () => {
const existingSubscription = authStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (existingSubscription?.type === "oauth") {
return existingSubscription;
}
const legacySubscription = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (legacySubscription?.type !== "oauth") {
return undefined;
}
/*
FNXC:ProviderAuth 2026-06-29-23:58:
Saving or clearing the separated `anthropic-api-key` provider overwrites the raw `anthropic` storage slot used by model execution.
Read the primary auth storage directly and migrate legacy subscription OAuth from `anthropic` to `anthropic-subscription` before that write, because merged Anthropic reads intentionally expose `anthropic` as API-key-only.
*/
mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, legacySubscription as AuthCredential);
return legacySubscription;
};
return {
reload: () => mergedAuthStorage.reload(),
getOAuthProviders: () =>
mergedAuthStorage
.getOAuthProviders()
.map((provider) => provider.id === ANTHROPIC_STORAGE_PROVIDER_ID
? ({ id: ANTHROPIC_STORAGE_PROVIDER_ID, name: "Anthropic Subscription" })
: ({ id: provider.id, name: provider.name })),
hasAuth: (provider) => provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID
? Boolean(getAnthropicSubscriptionCredential())
: mergedAuthStorage.hasAuth(provider),
login: async (providerId, callbacks) => {
if (providerId !== ANTHROPIC_STORAGE_PROVIDER_ID && providerId !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
await mergedAuthStorage.login(
providerId as Parameters<AuthStorage["login"]>[0],
callbacks as Parameters<AuthStorage["login"]>[1],
);
return;
}
const existingApiKey = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
await mergedAuthStorage.login(
ANTHROPIC_STORAGE_PROVIDER_ID as Parameters<AuthStorage["login"]>[0],
callbacks as Parameters<AuthStorage["login"]>[1],
);
const oauthCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (oauthCredential?.type === "oauth") {
/*
FNXC:ProviderAuth 2026-06-29-23:15:
Anthropic subscription OAuth and raw Anthropic API-key auth must be separate UI providers: OAuth stays `anthropic`, while the UI/API key card uses `anthropic-api-key` and maps back to the `anthropic` model credential.
Store subscription OAuth under an internal key after upstream login because the OAuth library writes through the same `anthropic` id used by model API-key execution.
*/
mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, oauthCredential as AuthCredential);
if (existingApiKey?.type === "api_key") {
mergedAuthStorage.set(ANTHROPIC_STORAGE_PROVIDER_ID, existingApiKey as AuthCredential);
} else {
authStorage.remove(ANTHROPIC_STORAGE_PROVIDER_ID);
}
}
},
logout: (provider) => {
if (provider !== ANTHROPIC_STORAGE_PROVIDER_ID && provider !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
mergedAuthStorage.logout(provider);
return;
}
mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
/*
FNXC:ProviderAuth 2026-06-29-23:59:
Logging out Anthropic subscription auth must also remove pre-split OAuth credentials still stored under `anthropic`.
Check primary storage directly because merged Anthropic reads expose `anthropic` as the model API-key credential only, so an OAuth credential would otherwise survive reload and reappear as `anthropic-subscription`.
*/
const legacyAnthropicCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") {
mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID);
}
},
getApiKeyProviders: () => {
const oauthProviderIds = new Set(
mergedAuthStorage
.getOAuthProviders()
.map((provider) => provider.id),
);
const providers = new Map<string, string>();
for (const provider of BUILT_IN_API_KEY_PROVIDERS) {
/*
FNXC:ProviderAuth 2026-06-29-23:32:
Anthropic subscription OAuth and Anthropic API-key auth are separate UI providers: the API-key card is `anthropic-api-key`, but reads and writes the `anthropic` model credential through toApiKeyStorageProviderId().
Keep OAuth-id exclusion only for registry-derived providers so OpenAI stays split as `openai-codex` OAuth plus `openai` API key, while unrelated OAuth providers are not reclassified.
*/
providers.set(provider.id, provider.name);
}
for (const model of modelRegistry.getAll()) {
const providerId = model.provider;
if (
!providerId ||
oauthProviderIds.has(providerId) ||
providers.has(providerId) ||
CLI_PROVIDER_IDS.has(providerId)
) {
continue;
}
providers.set(providerId, getProviderDisplayName(providerId));
}
return Array.from(providers, ([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name),
);
},
setApiKey: (providerId, apiKey) => {
const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
migrateStoredAnthropicSubscriptionCredential();
}
mergedAuthStorage.set(storageProviderId, { type: "api_key", key: apiKey });
},
clearApiKey: (providerId) => {
const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
migrateStoredAnthropicSubscriptionCredential();
}
mergedAuthStorage.remove(storageProviderId);
},
hasApiKey: (providerId) => {
const credential = mergedAuthStorage.get(toApiKeyStorageProviderId(providerId));
return credential?.type === "api_key" && !!credential.key;
},
getApiKey: async (providerId) => {
const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined;
}
return mergedAuthStorage.getApiKey(storageProviderId);
},
get: (providerId) => {
if (providerId === ANTHROPIC_API_KEY_PROVIDER_ID) {
const credential = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
return credential?.type === "api_key" ? credential : undefined;
}
if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) {
return getAnthropicSubscriptionCredential();
}
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
return getAnthropicSubscriptionCredential();
}
return mergedAuthStorage.get(providerId);
},
};
}
export function mergeAuthStorageReads(
authStorage: AuthStorage,
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
): AuthStorage {
const readAuthStorages = [authStorage, ...readFallbackAuthStorages];
// 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>();
const selectCredential = (
providerId: string,
storages: Array<Pick<ReadFallbackAuthStorage, "get">>,
): StoredCredential | undefined => {
let best: StoredCredential | undefined;
for (const storage of storages) {
const credential = storage.get(providerId);
if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) {
if (credential?.type === "api_key") {
best = choosePreferredStoredCredential(best, credential);
}
continue;
}
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
if (credential?.type === "oauth") {
best = choosePreferredStoredCredential(best, credential);
}
const legacyAnthropic = storage.get(ANTHROPIC_STORAGE_PROVIDER_ID);
if (legacyAnthropic?.type === "oauth") {
best = choosePreferredStoredCredential(best, legacyAnthropic);
}
continue;
}
best = choosePreferredStoredCredential(best, credential);
}
return best;
};
const getCredential = (providerId: string) => {
if (loggedOutProviders.has(providerId)) {
return undefined;
}
return selectCredential(providerId, readAuthStorages);
};
const syncFallbackOauthCredentials = () => {
const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list()));
for (const providerId of providerIds) {
const storageProviderId = providerId === ANTHROPIC_STORAGE_PROVIDER_ID
? ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID
: providerId;
if (loggedOutProviders.has(providerId) || loggedOutProviders.has(storageProviderId)) {
continue;
}
const current = authStorage.get(storageProviderId) as StoredCredential | undefined;
const candidate = selectCredential(storageProviderId, readFallbackAuthStorages);
if (!shouldHydrateStoredCredential(current, candidate)) {
continue;
}
if (candidate && (candidate.type === "oauth" || candidate.type === "api_key")) {
/*
FNXC:ProviderAuth 2026-06-29-23:48:
Legacy Anthropic OAuth files may still store subscription credentials under `anthropic`; hydrate those as `anthropic-subscription` so Anthropic model/API-key reads only trust `api_key` credentials under `anthropic`.
*/
authStorage.set(storageProviderId, candidate as AuthCredential);
}
}
};
syncFallbackOauthCredentials();
return new Proxy(authStorage, {
get(target, prop, receiver) {
if (prop === "logout") {
return (provider: string) => {
target.logout(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "remove") {
return (provider: string) => {
target.remove(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "set") {
return (provider: string, credential: AuthCredential) => {
target.set(provider, credential);
loggedOutProviders.delete(provider);
};
}
if (prop === "reload") {
return () => {
for (const storage of readAuthStorages) {
storage.reload();
}
syncFallbackOauthCredentials();
};
}
if (prop === "get") {
return getCredential;
}
if (prop === "has") {
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return false;
}
if (provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
return Boolean(getCredential(provider));
}
return readAuthStorages.some((storage) => Boolean(storage.get(provider)));
};
}
if (prop === "hasAuth") {
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return false;
}
if (provider === ANTHROPIC_STORAGE_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
return Boolean(getCredential(provider));
}
return readAuthStorages.some((storage) => storage.hasAuth(provider));
};
}
if (prop === "getAll") {
return () => {
const providerIds = new Set(readAuthStorages.flatMap((storage) => storage.list()));
if (providerIds.has(ANTHROPIC_STORAGE_PROVIDER_ID)) {
providerIds.add(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
}
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
if (loggedOutProviders.has(providerId)) {
continue;
}
const credential = getCredential(providerId);
if (credential) {
merged[providerId] = credential;
}
}
return merged;
};
}
if (prop === "list") {
return () => {
const providers = new Set(readAuthStorages.flatMap((storage) => storage.list()));
if (providers.has(ANTHROPIC_STORAGE_PROVIDER_ID) && !loggedOutProviders.has(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID)) {
providers.add(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
}
return Array.from(providers).filter((p) => !loggedOutProviders.has(p) && getCredential(p));
};
}
if (prop === "getApiKey") {
return async (providerId: string) => {
if (loggedOutProviders.has(providerId)) {
return undefined;
}
const credential = getCredential(providerId);
if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) {
return credential?.type === "api_key" ? resolveStoredApiKey(credential.key) : undefined;
}
if (providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID && credential) {
/*
FNXC:ProviderAuth 2026-07-05-09:10:
Reading `anthropic-subscription` through this merge proxy must delegate to the underlying real engine `authStorage.getApiKey(...)` (the `target` primary storage) so the refresh-token HTTP round trip in packages/engine/src/auth-storage.ts actually runs. The prior local static `Date.now() >= credential.expires` check (`resolveStoredCredentialApiKey`/`resolveOAuthApiKey`) never called the real engine and silently no-oped the refresh in production, e.g. the dashboard status route's best-effort refresh-on-expiry read (register-auth-routes.ts). `target.getApiKey` internally handles both the separated `anthropic-subscription` row and the legacy `anthropic` OAuth row, so this single delegated call covers both storage permutations without duplicating that logic here. Only fall back to the read-only fallback storages' local (non-refreshing) resolution when the primary engine yields no key; a logged-out subscription is already excluded above and must never reach this delegated call.
*/
const engineApiKey = await target.getApiKey(providerId);
if (engineApiKey) return engineApiKey;
for (const fallbackStorage of readFallbackAuthStorages) {
const fallbackApiKey = await fallbackStorage.getApiKey(providerId);
if (fallbackApiKey) return fallbackApiKey;
}
return undefined;
}
for (const storage of readAuthStorages) {
const apiKey = await storage.getApiKey(providerId);
if (apiKey) return apiKey;
}
return undefined;
};
}
return Reflect.get(target, prop, receiver);
},
}) as AuthStorage;
}
function resolveStoredApiKey(key: string | undefined): string | undefined {
if (!key) return undefined;
return process.env[key] ?? key;
}
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;
}
const oauthProviderId = providerId === ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID
? ANTHROPIC_STORAGE_PROVIDER_ID
: providerId;
return getOAuthProvider(oauthProviderId)?.getApiKey(credential as OAuthCredentials);
}
function resolveStoredCredentialApiKey(providerId: string, credential: StoredCredential | undefined): string | undefined {
if (credential?.type === "api_key") {
return resolveStoredApiKey(credential.key);
}
if (providerId === ANTHROPIC_STORAGE_PROVIDER_ID) {
return undefined;
}
if (credential?.type === "oauth") {
return resolveOAuthApiKey(providerId, credential);
}
return undefined;
}
export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallbackAuthStorage {
let credentials: Record<string, StoredCredential> = {};
const reload = () => {
const nextCredentials: Record<string, StoredCredential> = {};
for (const authPath of authPaths) {
const parsed = readStoredCredentialsFromAuthFile(authPath);
for (const [provider, credential] of Object.entries(parsed)) {
nextCredentials[provider] = choosePreferredStoredCredential(nextCredentials[provider], credential) ?? credential;
}
}
credentials = nextCredentials;
};
reload();
return {
reload,
hasAuth: (provider) => Boolean(credentials[provider]),
get: (provider) => credentials[provider],
getAll: () => ({ ...credentials }),
list: () => Object.keys(credentials),
getApiKey: async (provider) => {
return resolveStoredCredentialApiKey(provider, credentials[provider]);
},
};
}

View File

@@ -0,0 +1,105 @@
/*
FNXC:ProviderRegistration 2026-07-07-00:00:
FN-7622: the Electron desktop app's in-process dashboard server (packages/desktop/src/local-runtime.ts,
local-server.ts) constructed a raw authStorage/modelRegistry and skipped the built-in provider seeding,
the API-key-provider auth wrapping, and custom-provider registration that the CLI `serve`/`dashboard`/
`daemon` commands perform — so desktop's /api/providers and /api/models returned a truncated catalog
(missing built-in API-key providers like zai/openrouter/kimi-coding and any user customProviders[])
compared to the identical config rendered by the web build. This module is the SINGLE shared helper
both the CLI paths and the desktop paths call so that sequence can never drift apart again: it
mirrors the CLI's exact order — registerBuiltInZaiProvider -> wrapAuthStorageWithApiKeyProviders ->
mergeBuiltInZaiProviderModels -> modelRegistry.refresh() -> registerCustomProviders(customProviders)
-> subscribe settings:updated -> reregisterCustomProviders — and returns the wrapped
DashboardAuthStorage callers MUST pass to `createServer(...)` (not the raw authStorage) plus a
disposer to unsubscribe the settings listener on shutdown.
*/
import {
mergeBuiltInZaiProviderModels,
registerBuiltInZaiProvider,
type CustomProvider,
type TaskStore,
} from "@fusion/core";
import type { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
import {
wrapAuthStorageWithApiKeyProviders,
type DashboardAuthStorage,
} from "./provider-auth.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
export interface SeedDashboardProvidersStore {
getGlobalSettingsStore(): {
getSettings(): Promise<{ customProviders?: CustomProvider[] }>;
};
on: TaskStore["on"];
off: TaskStore["off"];
}
export interface SeedDashboardProvidersOptions {
/** The task store used to read `globalSettings.customProviders` and subscribe to `settings:updated`. */
store: SeedDashboardProvidersStore;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
/** Optional structured logger; defaults to a no-op so callers can opt into console/trace logging. */
log?: (scope: string, message: string) => void;
}
export interface SeedDashboardProvidersResult {
/** The wrapped auth storage — pass this (NOT the raw authStorage) to `createServer(...)`. */
authStorage: DashboardAuthStorage;
/** Unsubscribes the `settings:updated` -> reregisterCustomProviders listener. Call on shutdown. */
dispose: () => void;
}
/**
* Performs the full provider-registration sequence the CLI `serve`/`dashboard`/`daemon` commands run
* at startup, so any host (CLI or desktop in-process server) that calls this gets an identical
* provider/model catalog. See the FNXC:ProviderRegistration header above for the FN-7622 motivation.
*/
export async function seedDashboardProviders(
options: SeedDashboardProvidersOptions,
): Promise<SeedDashboardProvidersResult> {
const { store, authStorage, modelRegistry } = options;
const log = options.log ?? (() => {});
registerBuiltInZaiProvider(modelRegistry, (message) => log("extensions", message));
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
mergeBuiltInZaiProviderModels(modelRegistry, (message) => log("extensions", message));
modelRegistry.refresh();
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
registerCustomProviders(
modelRegistry,
globalSettings.customProviders,
(message) => log("custom-providers", message),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log("custom-providers", `Failed to load custom providers from global settings: ${message}`);
}
const onSettingsUpdated = (data: { settings: { customProviders?: CustomProvider[] }; previous: { customProviders?: CustomProvider[] } }) => {
const currentProviders = data.settings.customProviders;
const previousProviders = data.previous.customProviders;
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
return;
}
reregisterCustomProviders(
modelRegistry,
previousProviders,
currentProviders,
(message) => log("custom-providers", message),
);
};
store.on("settings:updated", onSettingsUpdated);
return {
authStorage: dashboardAuthStorage,
dispose: () => {
store.off("settings:updated", onSettingsUpdated);
},
};
}