## Summary Wave 17 organizes Fusion into **domain folders** (stacks on #2397). ### Layout - **core/types/** — board, task, agents, settings, merge, workflow, mesh, … - **core/src/** — agents, ai, async-stores, workflows, tasks, config, db, … - **dashboard/app/api/** — client, tasks, agents, git, missions, planning, … - **engine/src/** — agents, auth, execution, merge, missions, overseer, worktree, … Root keepers retained for large entrypoints (`store.ts`, `executor.ts`, `merger.ts`, …). Public barrels (`@fusion/core`, `@fusion/engine`, `app/api.ts` → legacy) stay stable. ## Test plan - [x] `@fusion/core` typecheck - [x] `@fusion/engine` typecheck (pre-existing playwright-core noise only) - [ ] CI merge gate **Stack:** #2394 → #2397 → **this PR**
896 lines
29 KiB
TypeScript
896 lines
29 KiB
TypeScript
/**
|
|
* FNXC:CodeOrganization 2026-07-20-10:00:
|
|
* Auth/CLI and runtime provider status client API peeled from legacy.ts.
|
|
*/
|
|
|
|
import { api } from "../client/client.js";
|
|
import type { FetchOptions } from "../client/client.js";
|
|
import { dedupe } from "../client/dedupe.js";
|
|
// --- Auth API ---
|
|
|
|
/*
|
|
FNXC:ProviderAuth 2026-08-01-06:25:
|
|
The browser bundle cannot import the runtime core helper, so this mirrors its constrained instance-id
|
|
grammar solely to generate opaque client ids. Labels are never part of identity or generation.
|
|
*/
|
|
const isValidProviderInstanceId = (value: unknown): value is string => typeof value === "string"
|
|
&& value.length > 0 && value.length <= 64 && !/\s/.test(value)
|
|
&& !value.includes("[") && !value.includes("]");
|
|
|
|
/** Browser-safe counterpart of the core auth.json key grammar for local UI state only. */
|
|
export function formatProviderInstanceKey(ref: { providerId: string; instanceId: string }): string {
|
|
return ref.instanceId === "default" ? ref.providerId : `${ref.providerId}[${ref.instanceId}]`;
|
|
}
|
|
|
|
/** OAuth provider with current authentication status */
|
|
export interface AuthProvider {
|
|
id: string;
|
|
name: string;
|
|
authenticated: boolean;
|
|
/** True when the server currently has an active OAuth login flow for this provider. */
|
|
loginInProgress?: boolean;
|
|
/** True when an OAuth credential is stored locally but its expires timestamp is in the past — prompt the user to re-login. */
|
|
expired?: boolean;
|
|
/** True when the redirect cannot reach this dashboard host and the user must paste the URL/code back manually. */
|
|
requiresManualCode?: boolean;
|
|
/**
|
|
* Reason the most recent background OAuth login attempt failed, if any.
|
|
* Interactive logins resolve the auth URL immediately and finish in the
|
|
* background; when that background flow rejects (bad/expired code, token
|
|
* exchange rejection, redirect_uri mismatch) this carries the cause so the
|
|
* UI can show why login failed instead of a generic error. Cleared when a
|
|
* fresh login for the provider starts.
|
|
*/
|
|
loginError?: string;
|
|
/**
|
|
* How this provider authenticates / is activated.
|
|
* - "oauth": OAuth flow (user clicks Login → redirect)
|
|
* - "api_key": API key stored locally
|
|
* - "cli": a locally-installed CLI binary is the backing transport
|
|
* (e.g. the synthetic `claude-cli` provider). Cards should render a
|
|
* one-click Enable/Disable + Test button rather than login/key inputs.
|
|
*/
|
|
type?: "oauth" | "api_key" | "cli";
|
|
/** Masked hint of the stored API key (first 3 + bullets + last 4 chars) */
|
|
keyHint?: string;
|
|
/** Credential instance described by the top-level status fields. */
|
|
instanceId?: string;
|
|
instances?: ProviderCredentialInstance[];
|
|
}
|
|
|
|
export interface ProviderCredentialInstance {
|
|
instanceId: string;
|
|
label?: string;
|
|
isDefault: boolean;
|
|
authenticated: boolean;
|
|
expired?: boolean;
|
|
type?: "oauth" | "api_key";
|
|
keyHint?: string;
|
|
}
|
|
|
|
export interface ManualOAuthCodeInfo {
|
|
prompt: string;
|
|
placeholder?: string;
|
|
helpText?: string;
|
|
}
|
|
|
|
export interface OAuthDeviceCodeInfo {
|
|
userCode: string;
|
|
verificationUri: string;
|
|
}
|
|
|
|
/**
|
|
* Snapshot of the Claude-CLI-via-pi health state. Powers the
|
|
* "Anthropic — via Claude CLI" provider card.
|
|
*/
|
|
export interface ClaudeCliStatus {
|
|
binary: {
|
|
available: boolean;
|
|
version?: string;
|
|
binaryPath?: string;
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
};
|
|
enabled: boolean;
|
|
extension: {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
} | null;
|
|
ready: boolean;
|
|
/** Route A ACP transport state (Claude CLI via the claude-code-cli-acp bridge). */
|
|
acp?: {
|
|
/** experimentalFeatures.claudeCliAcp (default ON). */
|
|
enabled: boolean;
|
|
/** The acp-runtime plugin published a bundled bridge path. */
|
|
bridgeAvailable: boolean;
|
|
/** Claude CLI is actually routing through the bridge (enabled + flag + bridge). */
|
|
active: boolean;
|
|
/** The bridged `claude` returned "Not logged in" — needs fallback or re-auth (R17). */
|
|
authFailed: boolean;
|
|
authReason?: string;
|
|
};
|
|
}
|
|
|
|
export interface DroidCliStatus {
|
|
binary: {
|
|
available: boolean;
|
|
version?: string;
|
|
binaryPath?: string;
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
};
|
|
enabled: boolean;
|
|
extension: {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
} | null;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface CursorCliStatus {
|
|
binary: {
|
|
available: boolean;
|
|
version?: string;
|
|
binaryPath?: string;
|
|
configuredBinaryPath?: string;
|
|
usingConfiguredBinaryPath?: boolean;
|
|
diagnostics?: string[];
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
};
|
|
enabled: boolean;
|
|
binaryPath?: string;
|
|
extension: null;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface GrokCliStatus {
|
|
binary: {
|
|
available: boolean;
|
|
/** FNXC:GrokCli 2026-07-09-00:00: FN-7716 — "ready" (binary available), not "key present"; the grok CLI owns auth. */
|
|
authenticated?: boolean;
|
|
/** FNXC:GrokCli 2026-07-09-00:00: FN-7716 — non-blocking informational hint that Fusion detected a Grok API key. Never gates readiness. */
|
|
apiKeyDetected?: boolean;
|
|
version?: string;
|
|
binaryPath?: string;
|
|
configuredBinaryPath?: string;
|
|
usingConfiguredBinaryPath?: boolean;
|
|
diagnostics?: string[];
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
};
|
|
enabled: boolean;
|
|
binaryPath?: string;
|
|
extension: null;
|
|
ready: boolean;
|
|
}
|
|
|
|
/*
|
|
FNXC:OmpAcp 2026-07-13-22:50:
|
|
Status shape for Settings → Oh My Pi (omp) ACP card. ready = enabled + binary available; auth under ~/.omp.
|
|
*/
|
|
export interface OmpCliStatus {
|
|
binary: {
|
|
available: boolean;
|
|
authenticated?: boolean;
|
|
version?: string;
|
|
binaryPath?: string;
|
|
configuredBinaryPath?: string;
|
|
usingConfiguredBinaryPath?: boolean;
|
|
diagnostics?: string[];
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
};
|
|
enabled: boolean;
|
|
binaryPath?: string;
|
|
extension: null;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface LlamaCppStatus {
|
|
enabled: boolean;
|
|
extension: {
|
|
status: "ok" | "not-installed" | "missing-entry" | "error";
|
|
path?: string;
|
|
packageVersion?: string;
|
|
reason?: string;
|
|
} | null;
|
|
ready: boolean;
|
|
server: {
|
|
available: boolean;
|
|
url: string;
|
|
hasApiKey: boolean;
|
|
reason?: string;
|
|
};
|
|
}
|
|
|
|
/** Probe the local Claude CLI binary + setting + extension state. */
|
|
export function fetchClaudeCliStatus(): Promise<ClaudeCliStatus> {
|
|
return api<ClaudeCliStatus>("/providers/claude-cli/status");
|
|
}
|
|
|
|
/**
|
|
* Status snapshot for the Fusion CLI binary (`fn` / `fusion`). Used by
|
|
* Settings → General → CLI Binary and the first-launch banner.
|
|
*/
|
|
export interface FnBinaryStatus {
|
|
binary: {
|
|
installed: boolean;
|
|
binary?: "fn" | "fusion";
|
|
path?: string;
|
|
version?: string;
|
|
invocation: string;
|
|
};
|
|
expectedVersion: string;
|
|
state: "installed" | "missing" | "version-mismatch" | "skipped";
|
|
install: { npm: string; curl: string; package: string };
|
|
}
|
|
|
|
export interface FnBinaryInstallResult {
|
|
success: boolean;
|
|
exitCode: number | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
command: string;
|
|
durationMs: number;
|
|
permissionsHint?: string;
|
|
}
|
|
|
|
export interface FnBinaryInstallResponse extends FnBinaryStatus {
|
|
installResult: FnBinaryInstallResult;
|
|
}
|
|
|
|
/** Read CLI binary install state. */
|
|
export function fetchFnBinaryStatus(): Promise<FnBinaryStatus> {
|
|
return api<FnBinaryStatus>("/system/fn-binary/status");
|
|
}
|
|
|
|
/** Trigger `npm install -g runfusion.ai`. Returns install log + new status. */
|
|
export function installFnBinary(): Promise<FnBinaryInstallResponse> {
|
|
return api<FnBinaryInstallResponse>("/system/fn-binary/install", { method: "POST" });
|
|
}
|
|
|
|
/** Probe the local Droid CLI binary + setting + extension state. */
|
|
export function fetchDroidCliStatus(): Promise<DroidCliStatus> {
|
|
return api<DroidCliStatus>("/providers/droid-cli/status");
|
|
}
|
|
|
|
export function fetchCursorCliStatus(): Promise<CursorCliStatus> {
|
|
return api<CursorCliStatus>("/providers/cursor-cli/status");
|
|
}
|
|
|
|
export function fetchGrokCliStatus(): Promise<GrokCliStatus> {
|
|
return api<GrokCliStatus>("/providers/grok-cli/status");
|
|
}
|
|
|
|
export function fetchOmpCliStatus(): Promise<OmpCliStatus> {
|
|
return api<OmpCliStatus>("/providers/omp-cli/status");
|
|
}
|
|
|
|
/** Probe llama.cpp server + setting + extension state. */
|
|
export function fetchLlamaCppStatus(): Promise<LlamaCppStatus> {
|
|
return api<LlamaCppStatus>("/providers/llama-cpp/status");
|
|
}
|
|
|
|
// --- Runtime Provider Status Types ---
|
|
|
|
export interface RuntimeBinaryStatus {
|
|
available: boolean;
|
|
binaryPath?: string;
|
|
version?: string;
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
}
|
|
|
|
export interface PaperclipConnectionStatus {
|
|
available: boolean;
|
|
apiUrl: string;
|
|
identity?: {
|
|
agentId: string;
|
|
agentName: string;
|
|
role?: string;
|
|
companyId: string;
|
|
companyName?: string;
|
|
};
|
|
reason?: string;
|
|
probeDurationMs: number;
|
|
}
|
|
|
|
export interface HermesProviderStatus {
|
|
binary: RuntimeBinaryStatus;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface OpenClawProviderStatus {
|
|
binary: RuntimeBinaryStatus;
|
|
ready: boolean;
|
|
}
|
|
|
|
export interface PaperclipProviderStatus {
|
|
connection: PaperclipConnectionStatus;
|
|
ready: boolean;
|
|
}
|
|
|
|
/** Probe the local Hermes binary. */
|
|
export async function fetchHermesStatus(opts?: {
|
|
binaryPath?: string;
|
|
}): Promise<HermesProviderStatus> {
|
|
const qs = opts?.binaryPath
|
|
? `?binaryPath=${encodeURIComponent(opts.binaryPath)}`
|
|
: "";
|
|
return api<HermesProviderStatus>(`/providers/hermes/status${qs}`);
|
|
}
|
|
|
|
export interface HermesProfileSummary {
|
|
name: string;
|
|
model?: string;
|
|
gateway?: string;
|
|
alias?: string;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
/** List Hermes profiles from `hermes profile list`. Returns empty array on error. */
|
|
export async function fetchHermesProfiles(opts?: {
|
|
binaryPath?: string;
|
|
}): Promise<HermesProfileSummary[]> {
|
|
const qs = opts?.binaryPath ? `?binaryPath=${encodeURIComponent(opts.binaryPath)}` : "";
|
|
const r = await api<{ profiles: HermesProfileSummary[]; error?: string }>(
|
|
`/providers/hermes/profiles${qs}`,
|
|
);
|
|
return r.profiles ?? [];
|
|
}
|
|
|
|
/** Probe the local OpenClaw binary. */
|
|
export async function fetchOpenClawStatus(opts?: {
|
|
binaryPath?: string;
|
|
}): Promise<OpenClawProviderStatus> {
|
|
const qs = opts?.binaryPath
|
|
? `?binaryPath=${encodeURIComponent(opts.binaryPath)}`
|
|
: "";
|
|
return api<OpenClawProviderStatus>(`/providers/openclaw/status${qs}`);
|
|
}
|
|
|
|
/** Probe the Paperclip API connection. */
|
|
export async function fetchPaperclipStatus(opts: {
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
}): Promise<PaperclipProviderStatus> {
|
|
const params = new URLSearchParams({ apiUrl: opts.apiUrl });
|
|
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
|
return api<PaperclipProviderStatus>(
|
|
`/providers/paperclip/status?${params.toString()}`,
|
|
);
|
|
}
|
|
|
|
export interface PaperclipCompanySummary {
|
|
id: string;
|
|
name: string;
|
|
urlKey?: string;
|
|
}
|
|
|
|
export interface PaperclipAgentSummary {
|
|
id: string;
|
|
name: string;
|
|
role?: string;
|
|
companyId: string;
|
|
status?: string;
|
|
isCurrent?: boolean;
|
|
}
|
|
|
|
export interface PaperclipCliDiscoverySuccess {
|
|
ok: true;
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
configPath: string;
|
|
deploymentMode?: string;
|
|
}
|
|
|
|
export interface PaperclipCliDiscoveryFailure {
|
|
ok: false;
|
|
reason: string;
|
|
configPath?: string;
|
|
}
|
|
|
|
export type PaperclipCliDiscoveryResult =
|
|
| PaperclipCliDiscoverySuccess
|
|
| PaperclipCliDiscoveryFailure;
|
|
|
|
/** List Paperclip companies visible to the bearer. Empty array on failure. */
|
|
export async function fetchPaperclipCompanies(opts: {
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
}): Promise<PaperclipCompanySummary[]> {
|
|
const params = new URLSearchParams({ apiUrl: opts.apiUrl });
|
|
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
|
const r = await api<{ companies: PaperclipCompanySummary[] }>(
|
|
`/providers/paperclip/companies?${params.toString()}`,
|
|
);
|
|
return r.companies ?? [];
|
|
}
|
|
|
|
/** List agents in a Paperclip company. Empty array on failure. */
|
|
export async function fetchPaperclipAgents(opts: {
|
|
apiUrl: string;
|
|
apiKey?: string;
|
|
companyId: string;
|
|
}): Promise<PaperclipAgentSummary[]> {
|
|
const params = new URLSearchParams({
|
|
apiUrl: opts.apiUrl,
|
|
companyId: opts.companyId,
|
|
});
|
|
if (opts.apiKey) params.set("apiKey", opts.apiKey);
|
|
const r = await api<{ agents: PaperclipAgentSummary[] }>(
|
|
`/providers/paperclip/agents?${params.toString()}`,
|
|
);
|
|
return r.agents ?? [];
|
|
}
|
|
|
|
export interface PaperclipMintKeyRequest {
|
|
cliBinaryPath?: string;
|
|
agentRef: string;
|
|
/** Required by paperclipai agent local-cli (`-C/--company-id`). */
|
|
companyId: string;
|
|
keyName?: string;
|
|
configPath?: string;
|
|
dataDir?: string;
|
|
}
|
|
export type PaperclipMintKeyResult =
|
|
| { ok: true; key: { apiKey: string; apiBase?: string; agentId?: string; companyId?: string } }
|
|
| { ok: false; reason: string };
|
|
|
|
/**
|
|
* Mints a Paperclip agent API key via the local `paperclipai` CLI.
|
|
* Always resolves (never rejects); on failure the result has `ok: false`.
|
|
*/
|
|
export async function mintPaperclipApiKey(
|
|
body: PaperclipMintKeyRequest,
|
|
): Promise<PaperclipMintKeyResult> {
|
|
return api<PaperclipMintKeyResult>(`/providers/paperclip/cli-mint-key`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Probe Paperclip via the local `paperclipai` CLI (Local CLI tab). Carries the
|
|
* user's onboarded CLI context (profile / api-base / api-key) instead of having
|
|
* the dashboard server make the HTTP call directly.
|
|
*/
|
|
export async function fetchPaperclipCliStatus(opts: {
|
|
cliBinaryPath?: string;
|
|
cliConfigPath?: string;
|
|
}): Promise<PaperclipProviderStatus> {
|
|
const params = new URLSearchParams();
|
|
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
|
|
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
|
const qs = params.toString();
|
|
return api<PaperclipProviderStatus>(
|
|
`/providers/paperclip/cli-status${qs ? `?${qs}` : ""}`,
|
|
);
|
|
}
|
|
|
|
/** List companies via `paperclipai company list --json`. Empty array on failure. */
|
|
export async function fetchPaperclipCliCompanies(opts: {
|
|
cliBinaryPath?: string;
|
|
cliConfigPath?: string;
|
|
}): Promise<PaperclipCompanySummary[]> {
|
|
const params = new URLSearchParams();
|
|
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
|
|
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
|
const qs = params.toString();
|
|
const r = await api<{ companies: PaperclipCompanySummary[] }>(
|
|
`/providers/paperclip/cli-companies${qs ? `?${qs}` : ""}`,
|
|
);
|
|
return r.companies ?? [];
|
|
}
|
|
|
|
/** List agents in a company via `paperclipai agent list -C <id> --json`. */
|
|
export async function fetchPaperclipCliAgents(opts: {
|
|
cliBinaryPath?: string;
|
|
cliConfigPath?: string;
|
|
companyId: string;
|
|
}): Promise<PaperclipAgentSummary[]> {
|
|
const params = new URLSearchParams({ companyId: opts.companyId });
|
|
if (opts.cliBinaryPath) params.set("cliBinaryPath", opts.cliBinaryPath);
|
|
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
|
const r = await api<{ agents: PaperclipAgentSummary[] }>(
|
|
`/providers/paperclip/cli-agents?${params.toString()}`,
|
|
);
|
|
return r.agents ?? [];
|
|
}
|
|
|
|
/** Read the local paperclipai config to discover apiUrl + deploymentMode. */
|
|
export async function fetchPaperclipCliDiscovery(opts: {
|
|
cliConfigPath?: string;
|
|
} = {}): Promise<PaperclipCliDiscoveryResult> {
|
|
const params = new URLSearchParams();
|
|
if (opts.cliConfigPath) params.set("cliConfigPath", opts.cliConfigPath);
|
|
const qs = params.toString();
|
|
return api<PaperclipCliDiscoveryResult>(
|
|
`/providers/paperclip/cli-discovery${qs ? `?${qs}` : ""}`,
|
|
);
|
|
}
|
|
|
|
/** Enable or disable the Claude CLI provider. Refuses enable if binary is missing. */
|
|
export function setClaudeCliEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/claude-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
/** Enable or disable the Droid CLI provider. Refuses enable if binary is missing. */
|
|
export function setDroidCliEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/droid-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
export function setCursorCliEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/cursor-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
export function setCursorCliBinaryPath(
|
|
binaryPath: string | null,
|
|
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/cursor-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ binaryPath }),
|
|
});
|
|
}
|
|
|
|
export function setGrokCliEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/grok-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
export function setGrokCliBinaryPath(
|
|
binaryPath: string | null,
|
|
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/grok-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ binaryPath }),
|
|
});
|
|
}
|
|
|
|
/*
|
|
FNXC:OmpAcp 2026-07-13-22:50:
|
|
Client helpers for Oh My Pi ACP enable + binary path (mirror Grok/Cursor).
|
|
*/
|
|
export function setOmpCliEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/omp-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
export function setOmpCliBinaryPath(
|
|
binaryPath: string | null,
|
|
): Promise<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; binaryPath?: string; restartRequired: boolean }>("/auth/omp-cli", {
|
|
method: "POST",
|
|
body: JSON.stringify({ binaryPath }),
|
|
});
|
|
}
|
|
|
|
/** Enable or disable the llama.cpp provider. */
|
|
export function setLlamaCppEnabled(
|
|
enabled: boolean,
|
|
): Promise<{ enabled: boolean; restartRequired: boolean }> {
|
|
return api<{ enabled: boolean; restartRequired: boolean }>("/auth/llama-cpp", {
|
|
method: "POST",
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
}
|
|
|
|
export interface CustomProvider {
|
|
id: string;
|
|
name: string;
|
|
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
|
|
baseUrl: string;
|
|
apiKey?: string;
|
|
/**
|
|
* FNXC:ProviderAuth 2026-07-08-00:00:
|
|
* FN-7689: dashboard-local mirror of @fusion/core's CustomProvider.anthropicPromptCaching
|
|
* opt-in. Keep in sync with packages/core/src/types.ts.
|
|
*/
|
|
anthropicPromptCaching?: boolean;
|
|
models?: { id: string; name: string }[];
|
|
}
|
|
|
|
export async function fetchCustomProviders(): Promise<CustomProviderConfig[] & { providers: CustomProviderConfig[] }> {
|
|
const providers = await api<CustomProvider[]>("/custom-providers");
|
|
const legacyProviders = providers.map((provider) => ({
|
|
id: provider.id,
|
|
name: provider.name,
|
|
baseUrl: provider.baseUrl,
|
|
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages"
|
|
: provider.apiType === "google-generative-ai" ? "google-generative-ai"
|
|
: provider.apiType === "openai-responses" ? "openai-responses"
|
|
: "openai-completions",
|
|
apiKey: provider.apiKey,
|
|
anthropicPromptCaching: provider.anthropicPromptCaching,
|
|
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
|
} satisfies CustomProviderConfig));
|
|
return Object.assign(legacyProviders, { providers: legacyProviders });
|
|
}
|
|
|
|
export function addCustomProvider(provider: Omit<CustomProvider, "id">): Promise<CustomProvider> {
|
|
return api<CustomProvider>("/custom-providers", {
|
|
method: "POST",
|
|
body: JSON.stringify(provider),
|
|
});
|
|
}
|
|
|
|
export function updateCustomProvider(
|
|
id: string,
|
|
updates: Partial<Omit<CustomProvider, "id">> | CustomProviderConfig,
|
|
): Promise<CustomProvider> {
|
|
const legacy = updates as Partial<CustomProviderConfig>;
|
|
const normalized: Partial<Omit<CustomProvider, "id">> = {
|
|
...(typeof legacy.name === "string" ? { name: legacy.name } : {}),
|
|
...(typeof legacy.baseUrl === "string" ? { baseUrl: legacy.baseUrl } : {}),
|
|
...(typeof legacy.apiKey === "string" ? { apiKey: legacy.apiKey } : {}),
|
|
...("anthropicPromptCaching" in (updates as Record<string, unknown>)
|
|
? { anthropicPromptCaching: (updates as Partial<Omit<CustomProvider, "id">>).anthropicPromptCaching }
|
|
: {}),
|
|
...(Array.isArray(legacy.models)
|
|
? {
|
|
models: legacy.models.map((model) => ({
|
|
id: model.id,
|
|
name: model.name ?? model.id,
|
|
})),
|
|
}
|
|
: {}),
|
|
...(legacy.api
|
|
? {
|
|
apiType: legacy.api === "anthropic-messages" ? "anthropic-compatible"
|
|
: legacy.api === "google-generative-ai" ? "google-generative-ai"
|
|
: legacy.api === "openai-responses" ? "openai-responses"
|
|
: "openai-compatible",
|
|
}
|
|
: {}),
|
|
...("apiType" in (updates as Record<string, unknown>)
|
|
? { apiType: (updates as Partial<Omit<CustomProvider, "id">>).apiType }
|
|
: {}),
|
|
};
|
|
|
|
return api<CustomProvider>(`/custom-providers/${encodeURIComponent(id)}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(normalized),
|
|
});
|
|
}
|
|
|
|
export function deleteCustomProvider(id: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>(`/custom-providers/${encodeURIComponent(id)}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
|
|
export interface RefreshProviderModelsResponse {
|
|
provider: CustomProvider;
|
|
modelsRefreshed: number;
|
|
}
|
|
|
|
export function refreshProviderModels(id: string): Promise<RefreshProviderModelsResponse> {
|
|
return api<RefreshProviderModelsResponse>(`/custom-providers/${encodeURIComponent(id)}/refresh-models`, {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
// Backward-compatibility exports for existing UI callers; will be removed when
|
|
// custom-provider UI migrates to the new core CustomProvider contract.
|
|
export interface CustomProviderModelInput {
|
|
id: string;
|
|
name?: string;
|
|
reasoning?: boolean;
|
|
contextWindow?: number;
|
|
maxTokens?: number;
|
|
}
|
|
|
|
export interface CustomProviderConfig {
|
|
id: string;
|
|
name?: string;
|
|
baseUrl: string;
|
|
api: "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
|
|
apiKey?: string;
|
|
/** FNXC:ProviderAuth 2026-07-08-00:00: FN-7689 caching opt-in, carried through the legacy shape. */
|
|
anthropicPromptCaching?: boolean;
|
|
models: CustomProviderModelInput[];
|
|
}
|
|
|
|
export function createCustomProvider(config: CustomProviderConfig): Promise<CustomProvider> {
|
|
const apiType = config.api === "anthropic-messages" ? "anthropic-compatible"
|
|
: config.api === "google-generative-ai" ? "google-generative-ai"
|
|
: config.api === "openai-responses" ? "openai-responses"
|
|
: "openai-compatible";
|
|
return addCustomProvider({
|
|
name: config.name?.trim() || config.id,
|
|
apiType,
|
|
baseUrl: config.baseUrl,
|
|
apiKey: config.apiKey,
|
|
models: config.models?.map((model) => ({
|
|
id: model.id,
|
|
name: model.name ?? model.id,
|
|
})),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Probe a custom provider's /models endpoint to discover available models.
|
|
* Supports OpenAI-compatible, Anthropic-compatible, and Google Generative AI providers.
|
|
*/
|
|
export interface ProbeModelResult {
|
|
id: string;
|
|
name: string;
|
|
reasoning?: boolean;
|
|
contextWindow?: number;
|
|
maxTokens?: number;
|
|
}
|
|
|
|
export interface ProbeModelsResponse {
|
|
models: ProbeModelResult[];
|
|
count: number;
|
|
}
|
|
|
|
export interface ProbeModelsParams {
|
|
baseUrl: string;
|
|
apiKey?: string;
|
|
apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai" | "openai-responses";
|
|
}
|
|
|
|
export async function probeProviderModels(params: ProbeModelsParams): Promise<ProbeModelsResponse> {
|
|
return api<ProbeModelsResponse>("/custom-providers/probe-models", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
baseUrl: params.baseUrl,
|
|
apiKey: params.apiKey,
|
|
apiType: params.apiType,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export interface GitCliStatus {
|
|
available: boolean;
|
|
version?: string;
|
|
installUrl?: string;
|
|
}
|
|
|
|
/** Fetch authentication status for all OAuth providers */
|
|
/*
|
|
FNXC:ProviderAuth 2026-08-01-06:11:
|
|
Targeted status requests must dedupe by provider and instance, not a global key, or concurrent
|
|
account rows can receive each other's status payload. The zero-argument request retains its URL.
|
|
*/
|
|
export function fetchAuthStatus(options?: FetchOptions & { provider?: string; instance?: string }): Promise<{
|
|
providers: AuthProvider[];
|
|
ghCli?: { available: boolean; authenticated: boolean };
|
|
gitCli?: GitCliStatus;
|
|
}> {
|
|
const params = new URLSearchParams();
|
|
if (options?.provider) params.set("provider", options.provider);
|
|
if (options?.instance?.trim()) params.set("instance", options.instance.trim());
|
|
const query = params.toString();
|
|
const url = query ? `/auth/status?${query}` : "/auth/status";
|
|
const key = `${options?.provider ?? "*"}::${options?.instance?.trim() || "default"}`;
|
|
return dedupe(`/auth/status:${key}`, () => api<{
|
|
providers: AuthProvider[];
|
|
ghCli?: { available: boolean; authenticated: boolean };
|
|
gitCli?: GitCliStatus;
|
|
}>(url), options);
|
|
}
|
|
|
|
/** Initiate OAuth login for a provider. Returns the auth URL to open in a new tab. */
|
|
export function loginProvider(provider: string, instance?: string, label?: string): Promise<{
|
|
url: string;
|
|
instructions?: string;
|
|
manualCode?: ManualOAuthCodeInfo;
|
|
deviceCode?: OAuthDeviceCodeInfo;
|
|
}> {
|
|
return api<{
|
|
url: string;
|
|
instructions?: string;
|
|
manualCode?: ManualOAuthCodeInfo;
|
|
deviceCode?: OAuthDeviceCodeInfo;
|
|
}>("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider, origin: window.location.origin, ...(instance ? { instance } : {}), ...(label ? { label } : {}) }),
|
|
});
|
|
}
|
|
|
|
/** Submit a pasted OAuth callback URL or authorization code for an active login. */
|
|
export function submitProviderManualCode(provider: string, code: string, instance?: string): Promise<{ success: boolean; submitted: boolean }> {
|
|
return api<{ success: boolean; submitted: boolean }>("/auth/manual-code", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider, code, ...(instance ? { instance } : {}) }),
|
|
});
|
|
}
|
|
|
|
/** Logout from a provider, removing stored credentials. */
|
|
export function logoutProvider(provider: string, instance?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>("/auth/logout", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider, ...(instance ? { instance } : {}) }),
|
|
});
|
|
}
|
|
|
|
/** Cancel an in-progress OAuth login attempt for a provider. */
|
|
export function cancelProviderLogin(provider: string, instance?: string): Promise<{ success: boolean; cancelled: boolean }> {
|
|
return api<{ success: boolean; cancelled: boolean }>("/auth/cancel", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider, ...(instance ? { instance } : {}) }),
|
|
});
|
|
}
|
|
|
|
/** Save an API key for an API-key-backed provider. */
|
|
export function saveApiKey(provider: string, apiKey: string, instance?: string, label?: string): Promise<{
|
|
success: boolean;
|
|
modelsRefreshed?: number;
|
|
refreshReason?: string;
|
|
refreshError?: string;
|
|
}> {
|
|
return api<{
|
|
success: boolean;
|
|
modelsRefreshed?: number;
|
|
refreshReason?: string;
|
|
refreshError?: string;
|
|
}>("/auth/api-key", {
|
|
method: "POST",
|
|
body: JSON.stringify({ provider, apiKey, ...(instance ? { instance } : {}), ...(label ? { label } : {}) }),
|
|
});
|
|
}
|
|
|
|
/** Remove an API key for an API-key-backed provider. */
|
|
export function clearApiKey(provider: string, instance?: string): Promise<{ success: boolean }> {
|
|
return api<{ success: boolean }>("/auth/api-key", {
|
|
method: "DELETE",
|
|
body: JSON.stringify({ provider, ...(instance ? { instance } : {}) }),
|
|
});
|
|
}
|
|
|
|
/** Generates opaque client-side account ids; labels are deliberately not identifiers. */
|
|
export function newProviderInstanceId(knownInstanceIds: Iterable<string> = []): string {
|
|
const known = new Set(knownInstanceIds);
|
|
for (;;) {
|
|
const random = globalThis.crypto?.getRandomValues
|
|
? Array.from(globalThis.crypto.getRandomValues(new Uint32Array(2))).map((part) => part.toString(36)).join("")
|
|
: Math.random().toString(36).slice(2);
|
|
const id = `acct-${random}`.slice(0, 64);
|
|
if (isValidProviderInstanceId(id) && !known.has(id)) return id;
|
|
}
|
|
}
|
|
|
|
export function listProviderInstances(provider: string): Promise<{ instances: ProviderCredentialInstance[] }> {
|
|
return api(`/auth/providers/${encodeURIComponent(provider)}/instances`);
|
|
}
|
|
export function renameProviderInstance(provider: string, instance: string, label: string): Promise<{ success: boolean }> {
|
|
return api(`/auth/providers/${encodeURIComponent(provider)}/instances/${encodeURIComponent(instance)}/rename`, { method: "POST", body: JSON.stringify({ label }) });
|
|
}
|
|
export function setProviderDefaultInstance(provider: string, instance: string): Promise<{ success: boolean }> {
|
|
return api(`/auth/providers/${encodeURIComponent(provider)}/default-instance`, { method: "POST", body: JSON.stringify({ instance }) });
|
|
}
|
|
export function removeProviderInstance(provider: string, instance: string): Promise<{ success: boolean }> {
|
|
return api(`/auth/providers/${encodeURIComponent(provider)}/instances/${encodeURIComponent(instance)}`, { method: "DELETE" });
|
|
}
|
|
|