diff --git a/.changeset/fn-7622-desktop-provider-parity.md b/.changeset/fn-7622-desktop-provider-parity.md new file mode 100644 index 0000000000..f576d6096e --- /dev/null +++ b/.changeset/fn-7622-desktop-provider-parity.md @@ -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. diff --git a/packages/cli/src/commands/custom-provider-registry.ts b/packages/cli/src/commands/custom-provider-registry.ts index 4d67b9a5ef..ac51687dd7 100644 --- a/packages/cli/src/commands/custom-provider-registry.ts +++ b/packages/cli/src/commands/custom-provider-registry.ts @@ -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"; diff --git a/packages/cli/src/commands/provider-auth.ts b/packages/cli/src/commands/provider-auth.ts index 2f7d445905..e175f1ec25 100644 --- a/packages/cli/src/commands/provider-auth.ts +++ b/packages/cli/src/commands/provider-auth.ts @@ -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[1] & { - onManualCodeInput?: () => Promise; -}; - -export interface DashboardAuthStorage { - reload(): void; - getOAuthProviders(): Array<{ id: string; name: string }>; - hasAuth(provider: string): boolean; - login(providerId: string, callbacks: LoginCallbacks): Promise; - 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; - get(providerId: string): { type?: string; key?: string } | undefined; -} - -interface ReadFallbackAuthStorage { - reload(): void; - hasAuth(provider: string): boolean; - getApiKey(providerId: string): Promise; - get(providerId: string): StoredCredential | undefined; - getAll(): Record; - 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[0], - callbacks as Parameters[1], - ); - return; - } - - const existingApiKey = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID); - await mergedAuthStorage.login( - ANTHROPIC_STORAGE_PROVIDER_ID as Parameters[0], - callbacks as Parameters[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(); - - 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(); - - const selectCredential = ( - providerId: string, - storages: Array>, - ): 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 = {}; - 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 = {}; - - const reload = () => { - const nextCredentials: Record = {}; - 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"; diff --git a/packages/desktop/src/__tests__/local-runtime.test.ts b/packages/desktop/src/__tests__/local-runtime.test.ts index af16fe2a19..af1541a28d 100644 --- a/packages/desktop/src/__tests__/local-runtime.test.ts +++ b/packages/desktop/src/__tests__/local-runtime.test.ts @@ -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); + }); }); diff --git a/packages/desktop/src/__tests__/local-server.test.ts b/packages/desktop/src/__tests__/local-server.test.ts index 3f73846b02..6e3434ee63 100644 --- a/packages/desktop/src/__tests__/local-server.test.ts +++ b/packages/desktop/src/__tests__/local-server.test.ts @@ -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); + }); }); diff --git a/packages/desktop/src/local-runtime.ts b/packages/desktop/src/local-runtime.ts index 8fa5eb7f06..a12165d8df 100644 --- a/packages/desktop/src/local-runtime.ts +++ b/packages/desktop/src/local-runtime.ts @@ -89,7 +89,7 @@ async function createStoreDefault(rootDir: string): Promise { 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), }); diff --git a/packages/desktop/src/local-server.ts b/packages/desktop/src/local-server.ts index 0f0edecf82..e4c978705a 100644 --- a/packages/desktop/src/local-server.ts +++ b/packages/desktop/src/local-server.ts @@ -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), }); diff --git a/packages/engine/src/__tests__/provider-registration.test.ts b/packages/engine/src/__tests__/provider-registration.test.ts new file mode 100644 index 0000000000..53b178f40c --- /dev/null +++ b/packages/engine/src/__tests__/provider-registration.test.ts @@ -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 = {}; + 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 }>(); + 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 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 => ({ + 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(); + }); +}); diff --git a/packages/engine/src/custom-provider-registry.ts b/packages/engine/src/custom-provider-registry.ts new file mode 100644 index 0000000000..cb9a84682a --- /dev/null +++ b/packages/engine/src/custom-provider-registry.ts @@ -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(); +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index cdd22c1c47..e0204278b4 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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, diff --git a/packages/engine/src/provider-auth.ts b/packages/engine/src/provider-auth.ts new file mode 100644 index 0000000000..e94e964810 --- /dev/null +++ b/packages/engine/src/provider-auth.ts @@ -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[1] & { + onManualCodeInput?: () => Promise; +}; + +export interface DashboardAuthStorage { + reload(): void; + getOAuthProviders(): Array<{ id: string; name: string }>; + hasAuth(provider: string): boolean; + login(providerId: string, callbacks: LoginCallbacks): Promise; + 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; + get(providerId: string): { type?: string; key?: string } | undefined; +} + +interface ReadFallbackAuthStorage { + reload(): void; + hasAuth(provider: string): boolean; + getApiKey(providerId: string): Promise; + get(providerId: string): StoredCredential | undefined; + getAll(): Record; + 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[0], + callbacks as Parameters[1], + ); + return; + } + + const existingApiKey = mergedAuthStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID); + await mergedAuthStorage.login( + ANTHROPIC_STORAGE_PROVIDER_ID as Parameters[0], + callbacks as Parameters[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(); + + 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(); + + const selectCredential = ( + providerId: string, + storages: Array>, + ): 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 = {}; + 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 = {}; + + const reload = () => { + const nextCredentials: Record = {}; + 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]); + }, + }; +} diff --git a/packages/engine/src/provider-registration.ts b/packages/engine/src/provider-registration.ts new file mode 100644 index 0000000000..b24890157e --- /dev/null +++ b/packages/engine/src/provider-registration.ts @@ -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 { + 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); + }, + }; +}