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:
7
.changeset/fn-7622-desktop-provider-parity.md
Normal file
7
.changeset/fn-7622-desktop-provider-parity.md
Normal 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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
192
packages/engine/src/__tests__/provider-registration.test.ts
Normal file
192
packages/engine/src/__tests__/provider-registration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
117
packages/engine/src/custom-provider-registry.ts
Normal file
117
packages/engine/src/custom-provider-registry.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
513
packages/engine/src/provider-auth.ts
Normal file
513
packages/engine/src/provider-auth.ts
Normal 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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
105
packages/engine/src/provider-registration.ts
Normal file
105
packages/engine/src/provider-registration.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user