feat(FN-3195): document llama.cpp provider setup and onboarding

Docs(FN-3195): adds llama.cpp provider setup and onboarding documentation to the main and dashboard READMEs.

Fusion-Task-Id: FN-3195
This commit is contained in:
Fusion
2026-05-04 10:12:43 -07:00
committed by gsxdsm
parent e9f2a4cfb5
commit b7ac46ecef
37 changed files with 1440 additions and 38 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add first-class llama.cpp provider support with bundled extension wiring, dashboard status/auth routes, model filtering, and onboarding/settings UI for enabling llama-server models without manual `pi install` steps.

View File

@@ -87,6 +87,7 @@
"@fusion/dashboard": "workspace:*", "@fusion/dashboard": "workspace:*",
"@fusion/engine": "workspace:*", "@fusion/engine": "workspace:*",
"@fusion/pi-claude-cli": "workspace:*", "@fusion/pi-claude-cli": "workspace:*",
"@fusion/pi-llama-cpp": "workspace:*",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.1.0", "@vitest/coverage-v8": "^3.1.0",

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
resolveLlamaCppExtension,
resolveLlamaCppExtensionPaths,
} from "../llama-cpp-extension.js";
describe("resolveLlamaCppExtension", () => {
it("finds the bundled @fusion/pi-llama-cpp package", () => {
const result = resolveLlamaCppExtension();
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.path).toMatch(/pi-llama-cpp[\\/]index\.ts$/);
expect(result.packageVersion).toMatch(/^\d+\.\d+\.\d+$/);
}
});
});
describe("resolveLlamaCppExtensionPaths", () => {
it("returns empty when useLlamaCpp is off", () => {
const result = resolveLlamaCppExtensionPaths({});
expect(result.paths).toEqual([]);
expect(result.warning).toBeUndefined();
expect(result.resolution).toBeNull();
});
it("returns extension path when useLlamaCpp is on", () => {
const result = resolveLlamaCppExtensionPaths({ useLlamaCpp: true });
expect(result.paths).toHaveLength(1);
expect(result.paths[0]).toMatch(/pi-llama-cpp[\\/]index\.ts$/);
expect(result.resolution?.status).toBe("ok");
});
});
describe("cached resolution roundtrip", () => {
it("set/get preserves snapshot", async () => {
const { setCachedLlamaCppResolution, getCachedLlamaCppResolution } =
await import("../llama-cpp-extension.js");
setCachedLlamaCppResolution({ status: "not-installed" });
expect(getCachedLlamaCppResolution()).toEqual({ status: "not-installed" });
setCachedLlamaCppResolution(null);
expect(getCachedLlamaCppResolution()).toBeNull();
});
});

View File

@@ -54,6 +54,11 @@ import {
resolveDroidCliExtensionPaths, resolveDroidCliExtensionPaths,
setCachedDroidCliResolution, setCachedDroidCliResolution,
} from "./droid-cli-extension.js"; } from "./droid-cli-extension.js";
import {
getCachedLlamaCppResolution,
resolveLlamaCppExtensionPaths,
setCachedLlamaCppResolution,
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js"; import { resolveSelfExtension } from "./self-extension.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
@@ -473,6 +478,24 @@ export async function runDaemon(opts: DaemonOptions = {}) {
} }
})(); })();
const llamaCppPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveLlamaCppExtensionPaths(globalSettings);
setCachedLlamaCppResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] llama-cpp: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useLlamaCpp setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedLlamaCppResolution(null);
return [];
}
})();
// Always prefer Fusion's vendored `@fusion/pi-claude-cli` over any // Always prefer Fusion's vendored `@fusion/pi-claude-cli` over any
// external `pi-claude-cli` install. Drops shadowing externals (e.g. a // external `pi-claude-cli` install. Drops shadowing externals (e.g. a
// global `npm install -g pi-claude-cli`) so the upstream's once-and-lock // global `npm install -g pi-claude-cli`) so the upstream's once-and-lock
@@ -492,7 +515,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
); );
const extensionsResult = await discoverAndLoadExtensions( const extensionsResult = await discoverAndLoadExtensions(
[...reconciledExtensionPaths, ...droidCliPaths], [...reconciledExtensionPaths, ...droidCliPaths, ...llamaCppPaths],
cwd, cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"), join(cwd, ".fusion", "disabled-auto-extension-discovery"),
); );
@@ -586,6 +609,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
} }
return { status: r.status, reason: r.reason }; return { status: r.status, reason: r.reason };
}, },
getLlamaCppExtensionStatus: () => {
const r = getCachedLlamaCppResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => { onUseClaudeCliToggled: (_prev, next) => {
if (!next) return; if (!next) return;
void (async () => { void (async () => {

View File

@@ -53,6 +53,11 @@ import {
resolveDroidCliExtensionPaths, resolveDroidCliExtensionPaths,
setCachedDroidCliResolution, setCachedDroidCliResolution,
} from "./droid-cli-extension.js"; } from "./droid-cli-extension.js";
import {
getCachedLlamaCppResolution,
resolveLlamaCppExtensionPaths,
setCachedLlamaCppResolution,
} from "./llama-cpp-extension.js";
import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js"; import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js";
import { resolveSelfExtension } from "./self-extension.js"; import { resolveSelfExtension } from "./self-extension.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
@@ -1272,6 +1277,24 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
} }
})(); })();
const llamaCppPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveLlamaCppExtensionPaths(globalSettings);
setCachedLlamaCppResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] llama-cpp: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useLlamaCpp setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedLlamaCppResolution(null);
return [];
}
})();
// Always inject the cli's own extension (`@runfusion/fusion`) so its // Always inject the cli's own extension (`@runfusion/fusion`) so its
// `fn_*` tools register globally even when the user hasn't run // `fn_*` tools register globally even when the user hasn't run
// `pi install npm:@runfusion/fusion`. Without this, agent chat with // `pi install npm:@runfusion/fusion`. Without this, agent chat with
@@ -1294,6 +1317,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
...packageExtensionPaths, ...packageExtensionPaths,
...claudeCliPaths, ...claudeCliPaths,
...droidCliPaths, ...droidCliPaths,
...llamaCppPaths,
], ],
cwd, cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"), join(cwd, ".fusion", "disabled-auto-extension-discovery"),
@@ -1580,6 +1604,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
} }
return { status: r.status, reason: r.reason }; return { status: r.status, reason: r.reason };
}, },
getLlamaCppExtensionStatus: () => {
const r = getCachedLlamaCppResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => { onUseClaudeCliToggled: (_prev, next) => {
if (!next) return; if (!next) return;
void (async () => { void (async () => {
@@ -1829,6 +1864,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
} }
return { status: r.status, reason: r.reason }; return { status: r.status, reason: r.reason };
}, },
getLlamaCppExtensionStatus: () => {
const r = getCachedLlamaCppResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => { onUseClaudeCliToggled: (_prev, next) => {
if (!next) return; if (!next) return;
void (async () => { void (async () => {

View File

@@ -0,0 +1,114 @@
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const require_ = createRequire(import.meta.url);
export type LlamaCppExtensionResolution =
| { status: "ok"; path: string; packageVersion: string }
| { status: "not-installed" }
| { status: "missing-entry"; reason: string }
| { status: "error"; reason: string };
export function resolveLlamaCppExtensionFromModuleUrl(
moduleUrl: string,
): LlamaCppExtensionResolution {
let pkgJsonPath: string | undefined;
const here = dirname(fileURLToPath(moduleUrl));
for (const rel of ["pi-llama-cpp", "../pi-llama-cpp", "../../pi-llama-cpp"]) {
const candidate = resolve(here, rel, "package.json");
if (existsSync(candidate)) {
pkgJsonPath = candidate;
break;
}
}
if (!pkgJsonPath) {
try {
pkgJsonPath = require_.resolve("@fusion/pi-llama-cpp/package.json");
} catch {
return { status: "not-installed" };
}
}
let pkgJson: { pi?: { extensions?: unknown }; version?: string };
try {
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as typeof pkgJson;
} catch (err) {
return {
status: "error",
reason: `Failed to read @fusion/pi-llama-cpp package.json: ${err instanceof Error ? err.message : String(err)}`,
};
}
const extensions = pkgJson.pi?.extensions;
if (!Array.isArray(extensions) || extensions.length === 0) {
return {
status: "missing-entry",
reason: "@fusion/pi-llama-cpp package.json has no pi.extensions array",
};
}
const rawEntry = extensions[0];
if (typeof rawEntry !== "string" || rawEntry.length === 0) {
return {
status: "missing-entry",
reason: "@fusion/pi-llama-cpp pi.extensions[0] is not a valid path string",
};
}
const entryPath = resolve(dirname(pkgJsonPath), rawEntry);
if (!existsSync(entryPath)) {
return {
status: "missing-entry",
reason: `@fusion/pi-llama-cpp extension file not found at ${entryPath}`,
};
}
return { status: "ok", path: entryPath, packageVersion: pkgJson.version ?? "unknown" };
}
export function resolveLlamaCppExtension(): LlamaCppExtensionResolution {
return resolveLlamaCppExtensionFromModuleUrl(import.meta.url);
}
export function resolveLlamaCppExtensionPaths(globalSettings: {
useLlamaCpp?: unknown;
}): { paths: string[]; warning?: string; resolution: LlamaCppExtensionResolution | null } {
const enabled = globalSettings?.useLlamaCpp === true;
if (!enabled) return { paths: [], resolution: null };
const resolution = resolveLlamaCppExtension();
switch (resolution.status) {
case "ok":
return { paths: [resolution.path], resolution };
case "not-installed":
return {
paths: [],
resolution,
warning:
"useLlamaCpp is on but @fusion/pi-llama-cpp is not installed in node_modules. Run `pnpm install`.",
};
case "missing-entry":
case "error":
return { paths: [], resolution, warning: resolution.reason };
}
}
let cachedResolution: LlamaCppExtensionResolution | null = null;
export function setCachedLlamaCppResolution(
resolution: LlamaCppExtensionResolution | null,
): void {
cachedResolution = resolution;
}
export function getCachedLlamaCppResolution(): LlamaCppExtensionResolution | null {
return cachedResolution;
}
export const _testInternals = {
moduleUrl: (): string => fileURLToPath(import.meta.url),
};

View File

@@ -57,6 +57,11 @@ import {
resolveDroidCliExtensionPaths, resolveDroidCliExtensionPaths,
setCachedDroidCliResolution, setCachedDroidCliResolution,
} from "./droid-cli-extension.js"; } from "./droid-cli-extension.js";
import {
getCachedLlamaCppResolution,
resolveLlamaCppExtensionPaths,
setCachedLlamaCppResolution,
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js"; import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js"; import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
@@ -539,6 +544,24 @@ export async function runServe(
} }
})(); })();
const llamaCppPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveLlamaCppExtensionPaths(globalSettings);
setCachedLlamaCppResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] llama-cpp: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useLlamaCpp setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedLlamaCppResolution(null);
return [];
}
})();
// Inject the cli's own extension so fn_* tools register globally without // Inject the cli's own extension so fn_* tools register globally without
// requiring `pi install npm:@runfusion/fusion`. // requiring `pi install npm:@runfusion/fusion`.
const selfExtension = resolveSelfExtension(); const selfExtension = resolveSelfExtension();
@@ -555,6 +578,7 @@ export async function runServe(
...packageExtensionPaths, ...packageExtensionPaths,
...claudeCliPaths, ...claudeCliPaths,
...droidCliPaths, ...droidCliPaths,
...llamaCppPaths,
], ],
cwd, cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"), join(cwd, ".fusion", "disabled-auto-extension-discovery"),
@@ -781,6 +805,17 @@ export async function runServe(
} }
return { status: r.status, reason: r.reason }; return { status: r.status, reason: r.reason };
}, },
getLlamaCppExtensionStatus: () => {
const r = getCachedLlamaCppResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => { onUseClaudeCliToggled: (_prev, next) => {
if (!next) return; // Toggle-off leaves existing skill symlinks alone. if (!next) return; // Toggle-off leaves existing skill symlinks alone.
void (async () => { void (async () => {

View File

@@ -10,6 +10,8 @@ const piClaudeCliSrc = join(__dirname, "..", "pi-claude-cli");
const piClaudeCliDest = join(__dirname, "dist", "pi-claude-cli"); const piClaudeCliDest = join(__dirname, "dist", "pi-claude-cli");
const droidCliSrc = join(__dirname, "..", "droid-cli"); const droidCliSrc = join(__dirname, "..", "droid-cli");
const droidCliDest = join(__dirname, "dist", "droid-cli"); const droidCliDest = join(__dirname, "dist", "droid-cli");
const llamaCppSrc = join(__dirname, "..", "pi-llama-cpp");
const llamaCppDest = join(__dirname, "dist", "pi-llama-cpp");
const dependencyGraphPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-dependency-graph"); const dependencyGraphPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-dependency-graph");
const dependencyGraphPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-dependency-graph"); const dependencyGraphPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-dependency-graph");
const dashboardClientStub = `<!doctype html> const dashboardClientStub = `<!doctype html>
@@ -96,6 +98,21 @@ export default defineConfig({
); );
} }
if (existsSync(llamaCppDest)) {
rmSync(llamaCppDest, { recursive: true, force: true });
}
if (existsSync(llamaCppSrc)) {
mkdirSync(llamaCppDest, { recursive: true });
cpSync(join(llamaCppSrc, "index.ts"), join(llamaCppDest, "index.ts"));
cpSync(join(llamaCppSrc, "src"), join(llamaCppDest, "src"), { recursive: true });
cpSync(join(llamaCppSrc, "package.json"), join(llamaCppDest, "package.json"));
console.log("Copied pi-llama-cpp extension to dist/pi-llama-cpp/");
} else {
console.warn(
`WARNING: pi-llama-cpp source not found at ${llamaCppSrc}; useLlamaCpp will not work in the published package.`,
);
}
if (existsSync(dependencyGraphPluginDest)) { if (existsSync(dependencyGraphPluginDest)) {
rmSync(dependencyGraphPluginDest, { recursive: true, force: true }); rmSync(dependencyGraphPluginDest, { recursive: true, force: true });
} }

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import type { GlobalSettings } from "../types.js";
import {
DEFAULT_GLOBAL_SETTINGS,
GLOBAL_SETTINGS_KEYS,
isGlobalSettingsKey,
} from "../settings-schema.js";
describe("useLlamaCpp global setting", () => {
it("is included in GLOBAL_SETTINGS_KEYS", () => {
expect(GLOBAL_SETTINGS_KEYS).toContain("useLlamaCpp");
});
it("defaults to undefined", () => {
expect(DEFAULT_GLOBAL_SETTINGS.useLlamaCpp).toBeUndefined();
});
it("is recognized by isGlobalSettingsKey", () => {
expect(isGlobalSettingsKey("useLlamaCpp")).toBe(true);
});
it("accepts boolean values in GlobalSettings", () => {
const enabled: GlobalSettings = { useLlamaCpp: true };
const disabled: GlobalSettings = { useLlamaCpp: false };
expect(enabled.useLlamaCpp).toBe(true);
expect(disabled.useLlamaCpp).toBe(false);
});
});

View File

@@ -53,6 +53,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
modelOnboardingComplete: undefined, modelOnboardingComplete: undefined,
useClaudeCli: undefined, useClaudeCli: undefined,
useDroidCli: undefined, useDroidCli: undefined,
useLlamaCpp: undefined,
// Global baseline lanes for per-role model selection // Global baseline lanes for per-role model selection
executionGlobalProvider: undefined, executionGlobalProvider: undefined,
executionGlobalModelId: undefined, executionGlobalModelId: undefined,

View File

@@ -1403,6 +1403,13 @@ export interface GlobalSettings {
* by the dashboard auth toggle. Setting this field explicitly (true/false) * by the dashboard auth toggle. Setting this field explicitly (true/false)
* always wins. */ * always wins. */
useDroidCli?: boolean; useDroidCli?: boolean;
/** When true, enable llama.cpp model-provider support (provider ID: `llama-server`)
* via Fusion's bundled `@fusion/pi-llama-cpp` extension.
*
* When left undefined, llama.cpp routing stays disabled unless explicitly enabled
* by the dashboard auth toggle. Setting this field explicitly (true/false)
* always wins. */
useLlamaCpp?: boolean;
/** Global baseline AI model provider for task execution (executor agent). /** Global baseline AI model provider for task execution (executor agent).
* This is the global lane that project-level `executionProvider` can override. * This is the global lane that project-level `executionProvider` can override.
* Must be set together with `executionGlobalModelId`. Falls back to * Must be set together with `executionGlobalModelId`. Falls back to

View File

@@ -1317,6 +1317,23 @@ export interface DroidCliStatus {
ready: boolean; 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. */ /** Probe the local Claude CLI binary + setting + extension state. */
export function fetchClaudeCliStatus(): Promise<ClaudeCliStatus> { export function fetchClaudeCliStatus(): Promise<ClaudeCliStatus> {
return api<ClaudeCliStatus>("/providers/claude-cli/status"); return api<ClaudeCliStatus>("/providers/claude-cli/status");
@@ -1368,6 +1385,11 @@ export function fetchDroidCliStatus(): Promise<DroidCliStatus> {
return api<DroidCliStatus>("/providers/droid-cli/status"); return api<DroidCliStatus>("/providers/droid-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 --- // --- Runtime Provider Status Types ---
export interface RuntimeBinaryStatus { export interface RuntimeBinaryStatus {
@@ -1627,6 +1649,16 @@ export function setDroidCliEnabled(
}); });
} }
/** 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 { export interface CustomProvider {
id: string; id: string;
name: string; name: string;

View File

@@ -0,0 +1,19 @@
.llama-cpp-provider-card {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.llama-cpp-status {
color: var(--text-muted);
}
.llama-cpp-status--ok {
color: var(--color-success);
}
@media (max-width: 768px) {
.llama-cpp-provider-card {
gap: var(--space-xs);
}
}

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { fetchLlamaCppStatus, setLlamaCppEnabled, type LlamaCppStatus } from "../api";
import { ProviderIcon } from "./ProviderIcon";
import "./LlamaCppProviderCard.css";
interface LlamaCppProviderCardProps {
authenticated: boolean;
onToggled?: (nextEnabled: boolean) => void;
compact?: boolean;
}
export function LlamaCppProviderCard({ authenticated, onToggled, compact = false }: LlamaCppProviderCardProps) {
const [status, setStatus] = useState<LlamaCppStatus | null>(null);
const [busy, setBusy] = useState<"enabling" | "disabling" | "testing" | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const refresh = useCallback(async () => {
const next = await fetchLlamaCppStatus();
if (mountedRef.current) setStatus(next);
return next;
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const handleToggle = useCallback(async (next: boolean) => {
setBusy(next ? "enabling" : "disabling");
try {
const result = await setLlamaCppEnabled(next);
onToggled?.(result.enabled);
await refresh();
} finally {
if (mountedRef.current) setBusy(null);
}
}, [onToggled, refresh]);
const handleTest = useCallback(async () => {
setBusy("testing");
try {
await refresh();
} finally {
if (mountedRef.current) setBusy(null);
}
}, [refresh]);
const enabled = status?.enabled ?? authenticated;
const serverAvailable = status?.server.available ?? false;
const content = (
<>
<div className="auth-provider-info">
<ProviderIcon provider="llama-cpp" size={compact ? "sm" : "md"} />
<strong>llama.cpp via HTTP server</strong>
</div>
<small className={`llama-cpp-status${status?.ready ? " llama-cpp-status--ok" : ""}`}>
{!status
? "Probing llama.cpp server…"
: status.server.available
? `Server reachable at ${status.server.url}`
: `Server unavailable: ${status.server.reason ?? "not reachable"}`}
</small>
<div className="auth-provider-cli-actions">
<button type="button" className="btn btn-sm" onClick={() => void handleTest()} disabled={busy !== null}>
{busy === "testing" ? <><Loader2 size={12} className="animate-spin" />Testing</> : "Test"}
</button>
{enabled ? (
<button type="button" className="btn btn-sm" onClick={() => void handleToggle(false)} disabled={busy !== null}>
{busy === "disabling" ? "Disabling…" : "Disable"}
</button>
) : (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => void handleToggle(true)}
disabled={busy !== null || !serverAvailable}
>
{busy === "enabling" ? "Enabling…" : "Enable"}
</button>
)}
</div>
</>
);
if (compact) {
return <div className="auth-provider-card auth-provider-card--cli llama-cpp-provider-card" data-testid="llama-cpp-provider-card">{content}</div>;
}
return <div className="onboarding-provider-card llama-cpp-provider-card" data-testid="llama-cpp-provider-card">{content}</div>;
}

View File

@@ -24,6 +24,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard"; import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { DroidCliProviderCard } from "./DroidCliProviderCard"; import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
import { LoginInstructions } from "./LoginInstructions"; import { LoginInstructions } from "./LoginInstructions";
import { OAuthManualCodeForm } from "./OAuthManualCodeForm"; import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
import { OnboardingDisclosure } from "./OnboardingDisclosure"; import { OnboardingDisclosure } from "./OnboardingDisclosure";
@@ -199,6 +200,7 @@ const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [
"anthropic", "anthropic",
"claude-cli", "claude-cli",
"droid-cli", "droid-cli",
"llama-cpp",
"openai-codex", "openai-codex",
"gemini", "gemini",
"minimax", "minimax",
@@ -210,6 +212,7 @@ const ONBOARDING_PROVIDER_FAMILY_ALIASES: Record<string, (typeof ONBOARDING_CURA
anthropic: "anthropic", anthropic: "anthropic",
"claude-cli": "claude-cli", "claude-cli": "claude-cli",
"droid-cli": "droid-cli", "droid-cli": "droid-cli",
"llama-cpp": "llama-cpp",
"openai-codex": "openai-codex", "openai-codex": "openai-codex",
google: "gemini", google: "gemini",
gemini: "gemini", gemini: "gemini",
@@ -1769,6 +1772,18 @@ export function ModelOnboardingModal({
); );
} }
if (provider.id === "llama-cpp" && provider.type === "cli") {
return (
<LlamaCppProviderCard
key={provider.id}
authenticated={provider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
);
}
if (provider.id === "claude-cli" && provider.type === "cli") { if (provider.id === "claude-cli" && provider.type === "cli") {
return ( return (
<ClaudeCliProviderCard <ClaudeCliProviderCard

View File

@@ -1,5 +1,9 @@
import { Cpu } from "lucide-react"; import { Cpu } from "lucide-react";
function LlamaCppIcon({ size, color, label = "llama.cpp" }: { size: number; color: string; label?: string }) {
return <Cpu size={size} color={color} aria-label={label} data-testid="llama-cpp-icon" />;
}
export interface ProviderIconProps { export interface ProviderIconProps {
provider: string; provider: string;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
@@ -653,6 +657,8 @@ const providerConfig: Record<
"claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" }, "claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" },
"pi-claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" }, "pi-claude-cli": { component: ClaudeCliIcon, color: "var(--provider-anthropic)", label: "Anthropic — via Claude CLI" },
"droid-cli": { component: DroidCliIcon, color: "var(--provider-openai)", label: "Factory AI — via Droid CLI" }, "droid-cli": { component: DroidCliIcon, color: "var(--provider-openai)", label: "Factory AI — via Droid CLI" },
"llama-cpp": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },
"llama-server": { component: LlamaCppIcon, color: "var(--provider-ollama)", label: "llama.cpp" },
openai: { component: OpenAIIcon, color: "var(--provider-openai)" }, openai: { component: OpenAIIcon, color: "var(--provider-openai)" },
"openai-codex": { component: OpenAIIcon, color: "var(--provider-openai)", label: "OpenAI Codex" }, // OpenAI alias "openai-codex": { component: OpenAIIcon, color: "var(--provider-openai)", label: "OpenAI Codex" }, // OpenAI alias

View File

@@ -28,6 +28,7 @@ const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m)
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard"; import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
import { CliBinaryPanel } from "./CliBinaryPanel"; import { CliBinaryPanel } from "./CliBinaryPanel";
import { DroidCliProviderCard } from "./DroidCliProviderCard"; import { DroidCliProviderCard } from "./DroidCliProviderCard";
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
import { HermesRuntimeCard } from "./HermesRuntimeCard"; import { HermesRuntimeCard } from "./HermesRuntimeCard";
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard"; import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
import { PaperclipRuntimeCard } from "./PaperclipRuntimeCard"; import { PaperclipRuntimeCard } from "./PaperclipRuntimeCard";
@@ -5033,6 +5034,7 @@ export function SettingsModal({
// auth state (Authenticated when signed in, Available otherwise). // auth state (Authenticated when signed in, Available otherwise).
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli"); const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-cli");
const droidCliProvider = cliAuthProviders.find((p) => p.id === "droid-cli"); const droidCliProvider = cliAuthProviders.find((p) => p.id === "droid-cli");
const llamaCppProvider = cliAuthProviders.find((p) => p.id === "llama-cpp");
const hasDroidPluginSlot = getSlotsForId("settings-provider-card").some( const hasDroidPluginSlot = getSlotsForId("settings-provider-card").some(
(entry) => entry.pluginId === "fusion-plugin-droid-runtime", (entry) => entry.pluginId === "fusion-plugin-droid-runtime",
); );
@@ -5054,14 +5056,25 @@ export function SettingsModal({
}} }}
/> />
) : null; ) : null;
const llamaCppCard = llamaCppProvider ? (
<LlamaCppProviderCard
compact
authenticated={llamaCppProvider.authenticated}
onToggled={() => {
void loadAuthStatus();
}}
/>
) : null;
const showAuthenticatedGroup = const showAuthenticatedGroup =
authenticatedProviders.length > 0 authenticatedProviders.length > 0
|| (claudeCliProvider?.authenticated ?? false) || (claudeCliProvider?.authenticated ?? false)
|| ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot); || ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot)
|| (llamaCppProvider?.authenticated ?? false);
const showAvailableGroup = const showAvailableGroup =
unauthenticatedProviders.length > 0 unauthenticatedProviders.length > 0
|| (claudeCliProvider && !claudeCliProvider.authenticated) || (claudeCliProvider && !claudeCliProvider.authenticated)
|| (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot); || (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot)
|| (llamaCppProvider && !llamaCppProvider.authenticated);
return ( return (
<> <>
<h4 className="settings-section-heading">Authentication</h4> <h4 className="settings-section-heading">Authentication</h4>
@@ -5085,6 +5098,7 @@ export function SettingsModal({
<div className="auth-group-label">Authenticated</div> <div className="auth-group-label">Authenticated</div>
{claudeCliProvider?.authenticated && claudeCliCard} {claudeCliProvider?.authenticated && claudeCliCard}
{droidCliProvider?.authenticated && droidCliCard} {droidCliProvider?.authenticated && droidCliCard}
{llamaCppProvider?.authenticated && llamaCppCard}
{authenticatedProviders.map((provider) => ( {authenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated"> <div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
<div className="auth-provider-header"> <div className="auth-provider-header">
@@ -5179,6 +5193,7 @@ export function SettingsModal({
<div className="auth-group-label">Available</div> <div className="auth-group-label">Available</div>
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard} {claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
{droidCliProvider && !droidCliProvider.authenticated && droidCliCard} {droidCliProvider && !droidCliProvider.authenticated && droidCliCard}
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
{unauthenticatedProviders.map((provider) => ( {unauthenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card"> <div key={provider.id} className="auth-provider-card">
<div className="auth-provider-header"> <div className="auth-provider-header">

View File

@@ -0,0 +1,30 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LlamaCppProviderCard } from "../LlamaCppProviderCard";
const fetchLlamaCppStatus = vi.fn();
const setLlamaCppEnabled = vi.fn();
vi.mock("../../api", () => ({
fetchLlamaCppStatus: (...args: unknown[]) => fetchLlamaCppStatus(...args),
setLlamaCppEnabled: (...args: unknown[]) => setLlamaCppEnabled(...args),
}));
describe("LlamaCppProviderCard", () => {
beforeEach(() => {
fetchLlamaCppStatus.mockResolvedValue({
enabled: false,
ready: false,
extension: { status: "ok" },
server: { available: true, url: "http://127.0.0.1:8080", hasApiKey: false },
});
setLlamaCppEnabled.mockResolvedValue({ enabled: true, restartRequired: false });
});
it("renders and enables provider", async () => {
render(<LlamaCppProviderCard authenticated={false} />);
await waitFor(() => expect(fetchLlamaCppStatus).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
await waitFor(() => expect(setLlamaCppEnabled).toHaveBeenCalledWith(true));
});
});

View File

@@ -95,6 +95,13 @@ describe("ProviderIcon", () => {
expect(screen.getByLabelText("Ollama")).toBeInTheDocument(); expect(screen.getByLabelText("Ollama")).toBeInTheDocument();
}); });
it("renders llama.cpp icon aliases", () => {
const { rerender } = render(<ProviderIcon provider="llama-cpp" />);
expect(screen.getByTestId("llama-cpp-icon")).toBeInTheDocument();
rerender(<ProviderIcon provider="llama-server" />);
expect(screen.getByTestId("llama-cpp-icon")).toBeInTheDocument();
});
it("renders Cpu icon as fallback for unknown providers", () => { it("renders Cpu icon as fallback for unknown providers", () => {
render(<ProviderIcon provider="unknown" />); render(<ProviderIcon provider="unknown" />);
// Cpu icon from lucide-react renders as an svg without our custom data-testid // Cpu icon from lucide-react renders as an svg without our custom data-testid

View File

@@ -258,6 +258,7 @@ html {
--provider-groq: #f55036; --provider-groq: #f55036;
--provider-vercel: var(--text); --provider-vercel: var(--text);
--provider-droid-cli: var(--text); --provider-droid-cli: var(--text);
--provider-ollama: #d4a27f;
/* Runtime-plugin marks. */ /* Runtime-plugin marks. */
--provider-hermes: #d4961c; --provider-hermes: #d4961c;
--provider-openclaw: #ff4f40; --provider-openclaw: #ff4f40;

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import type { Router } from "express";
import { registerModelRoutes } from "../routes/register-model-routes.js";
function setup(useLlamaCpp?: boolean) {
const getHandlers = new Map<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
getHandlers.set(path, handler);
}),
} as unknown as Router;
const store = {
getGlobalSettingsStore: () => ({
getSettings: vi.fn().mockResolvedValue({ useLlamaCpp }),
}),
getSettingsFast: vi.fn().mockResolvedValue({}),
};
const runtimeLogger = {
child: vi.fn(() => ({ warn: vi.fn() })),
};
const modelRegistry = {
refresh: vi.fn(),
getAvailable: vi.fn(() => [
{ provider: "llama-server", id: "llama3", name: "Llama 3", reasoning: true, contextWindow: 128000 },
{ provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 },
]),
};
registerModelRoutes({
router,
store: store as never,
runtimeLogger: runtimeLogger as never,
options: { modelRegistry } as never,
} as never);
return { handler: getHandlers.get("/models")! };
}
describe("registerModelRoutes llama-server filter", () => {
it("filters llama-server models when useLlamaCpp is false", async () => {
const { handler } = setup(false);
const json = vi.fn();
await handler({}, { json });
const response = json.mock.calls[0][0] as { models: Array<{ provider: string }> };
expect(response.models.some((model) => model.provider === "llama-server")).toBe(false);
});
it("includes llama-server models when useLlamaCpp is true", async () => {
const { handler } = setup(true);
const json = vi.fn();
await handler({}, { json });
const response = json.mock.calls[0][0] as { models: Array<{ provider: string }> };
expect(response.models.some((model) => model.provider === "llama-server")).toBe(true);
});
it("filters llama-server models when useLlamaCpp is unset", async () => {
const { handler } = setup(undefined);
const json = vi.fn();
await handler({}, { json });
const response = json.mock.calls[0][0] as { models: Array<{ provider: string }> };
expect(response.models.some((model) => model.provider === "llama-server")).toBe(false);
});
});

View File

@@ -31,6 +31,7 @@ import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "../ai-session-store.js";
import * as usageModule from "../usage.js"; import * as usageModule from "../usage.js";
import * as claudeCliProbeModule from "../claude-cli-probe.js"; import * as claudeCliProbeModule from "../claude-cli-probe.js";
import * as droidCliProbeModule from "../droid-cli-probe.js"; import * as droidCliProbeModule from "../droid-cli-probe.js";
import * as llamaCppProbeModule from "../llama-cpp-probe.js";
import * as projectStoreResolver from "../project-store-resolver.js"; import * as projectStoreResolver from "../project-store-resolver.js";
import * as terminalServiceModule from "../terminal-service.js"; import * as terminalServiceModule from "../terminal-service.js";
import { get as performGet, request as performRequest } from "../test-request.js"; import { get as performGet, request as performRequest } from "../test-request.js";
@@ -562,7 +563,7 @@ describe("GET /auth/status", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
// Filter out synthetic CLI providers — they have dedicated route tests. // Filter out synthetic CLI providers — they have dedicated route tests.
// Structural assertions here are about OAuth + API-key paths only. // Structural assertions here are about OAuth + API-key paths only.
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli"); const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "llama-cpp");
expect(providers).toEqual([ expect(providers).toEqual([
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", loginInProgress: false }, { id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }, { id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
@@ -587,7 +588,7 @@ describe("GET /auth/status", () => {
const res = await GET(buildApp(), "/api/auth/status"); const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200); expect(res.status).toBe(200);
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli"); const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "llama-cpp");
expect(providers).toEqual([ expect(providers).toEqual([
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", loginInProgress: false }, { id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: false }, { id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: false },
@@ -3143,3 +3144,182 @@ describe("Pause/Unpause endpoints", () => {
}); });
// --- GitHub Import route tests --- // --- GitHub Import route tests ---
describe("llama.cpp auth routes", () => {
let store: TaskStore;
let authStorage: AuthStorageLike;
function buildApp(options?: Parameters<typeof createApiRoutes>[1]) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage, ...options }));
return app;
}
beforeEach(() => {
store = createMockStore({
updateGlobalSettings: vi.fn().mockResolvedValue({ useLlamaCpp: true }),
getGlobalSettingsStore: vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useLlamaCpp: false }),
}),
});
authStorage = createMockAuthStorage();
vi.spyOn(llamaCppProbeModule, "probeLlamaCpp").mockResolvedValue({
reachable: true,
url: "http://127.0.0.1:8080",
hasApiKey: false,
});
});
it("enables llama.cpp when probe passes", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/llama-cpp", JSON.stringify({ enabled: true }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: true, restartRequired: false });
expect(store.updateGlobalSettings).toHaveBeenCalledWith({ useLlamaCpp: true });
});
it("returns 400 when enabling with unreachable server", async () => {
vi.spyOn(llamaCppProbeModule, "probeLlamaCpp").mockResolvedValue({
reachable: false,
url: "http://127.0.0.1:8080",
hasApiKey: false,
reason: "llama.cpp server did not return a healthy response",
});
const res = await REQUEST(buildApp(), "POST", "/api/auth/llama-cpp", JSON.stringify({ enabled: true }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Cannot enable llama.cpp routing");
});
it("disabling works without probing the server", async () => {
const probeSpy = vi.spyOn(llamaCppProbeModule, "probeLlamaCpp");
const res = await REQUEST(buildApp(), "POST", "/api/auth/llama-cpp", JSON.stringify({ enabled: false }), {
"content-type": "application/json",
});
expect(res.status).toBe(200);
expect(probeSpy).not.toHaveBeenCalled();
});
it("returns 400 for non-boolean enabled", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/llama-cpp", JSON.stringify({ enabled: "yes" }), {
"content-type": "application/json",
});
expect(res.status).toBe(400);
});
it("returns llama.cpp provider status including API key flag", async () => {
vi.spyOn(llamaCppProbeModule, "probeLlamaCpp").mockResolvedValue({
reachable: true,
url: "http://127.0.0.1:8080",
hasApiKey: true,
});
const res = await GET(buildApp(), "/api/providers/llama-cpp/status");
expect(res.status).toBe(200);
expect(res.body.server.url).toBe("http://127.0.0.1:8080");
expect(res.body.server.hasApiKey).toBe(true);
expect(res.body.ready).toBe(false);
});
it("marks llama.cpp status not ready when extension resolution fails", async () => {
const res = await GET(
buildApp({
getLlamaCppExtensionStatus: () => ({ status: "error", reason: "extension failed" }),
} as Parameters<typeof createApiRoutes>[1]),
"/api/providers/llama-cpp/status",
);
expect(res.status).toBe(200);
expect(res.body.ready).toBe(false);
expect(res.body.extension.status).toBe("error");
});
it("GET /auth/status includes llama-cpp provider with cli type", async () => {
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useLlamaCpp: true }),
});
const res = await GET(buildApp(), "/api/auth/status");
expect(res.status).toBe(200);
expect(res.body.providers).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "llama-cpp",
name: "llama.cpp — via HTTP server",
type: "cli",
authenticated: true,
}),
]),
);
});
it("GET /auth/status marks llama-cpp unauthenticated when extension status is not ok", async () => {
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useLlamaCpp: true }),
});
const res = await GET(
buildApp({ getLlamaCppExtensionStatus: () => ({ status: "error", reason: "bad ext" }) } as Parameters<
typeof createApiRoutes
>[1]),
"/api/auth/status",
);
expect(res.status).toBe(200);
expect(res.body.providers).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "llama-cpp",
authenticated: false,
type: "cli",
}),
]),
);
});
it("fires onUseLlamaCppToggled hook on transition", async () => {
const onUseLlamaCppToggled = vi.fn();
const res = await REQUEST(
buildApp({ onUseLlamaCppToggled } as Parameters<typeof createApiRoutes>[1]),
"POST",
"/api/auth/llama-cpp",
JSON.stringify({ enabled: true }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(onUseLlamaCppToggled).toHaveBeenCalledWith(false, true);
});
it("PUT /settings/global with useLlamaCpp fires onUseLlamaCppToggled", async () => {
const onUseLlamaCppToggled = vi.fn();
store.updateGlobalSettings = vi.fn().mockResolvedValue({ useLlamaCpp: true });
store.getGlobalSettingsStore = vi.fn().mockReturnValue({
...createMockGlobalSettingsStore(),
getSettings: vi.fn().mockResolvedValue({ useLlamaCpp: false, useClaudeCli: false, useDroidCli: false }),
});
const res = await REQUEST(
buildApp({ onUseLlamaCppToggled } as Parameters<typeof createApiRoutes>[1]),
"PUT",
"/api/settings/global",
JSON.stringify({ useLlamaCpp: true }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(onUseLlamaCppToggled).toHaveBeenCalledWith(false, true);
});
});

View File

@@ -0,0 +1,78 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
type LlamaProjectConfig = { url?: string };
type LlamaGlobalConfig = { llamaServerUrl?: string };
type LlamaAuthConfig = Record<string, { key?: string } | undefined>;
export interface LlamaCppProbeStatus {
reachable: boolean;
url: string;
hasApiKey: boolean;
reason?: string;
}
async function readJson<T>(path: string): Promise<T | null> {
try {
const raw = await readFile(path, "utf-8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, "");
}
async function resolveLlamaServerUrl(cwd: string): Promise<string> {
const projectCfg = await readJson<LlamaProjectConfig>(join(cwd, ".pi", "llama-server.json"));
if (projectCfg?.url) return normalizeUrl(projectCfg.url);
const envUrl = process.env.LLAMA_SERVER_URL;
if (envUrl) return normalizeUrl(envUrl);
const globalCfg = await readJson<LlamaGlobalConfig>(
join(process.env.HOME ?? ".", ".pi", "agent", "settings.json"),
);
if (globalCfg?.llamaServerUrl) return normalizeUrl(globalCfg.llamaServerUrl);
return DEFAULT_LLAMA_SERVER_URL;
}
async function resolveLlamaServerApiKey(): Promise<string | undefined> {
const authCfg = await readJson<LlamaAuthConfig>(join(process.env.HOME ?? ".", ".pi", "agent", "auth.json"));
const key = authCfg?.["llama-server"]?.key?.trim();
return key ? key : undefined;
}
async function isLlamaServerReady(url: string, apiKey?: string): Promise<boolean> {
try {
const response = await fetch(`${url}/health`, {
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
});
if (!response.ok) {
return false;
}
const payload = (await response.json()) as { status?: string };
return payload.status === "ok";
} catch {
return false;
}
}
export async function probeLlamaCpp(options: { cwd?: string } = {}): Promise<LlamaCppProbeStatus> {
const cwd = options.cwd ?? process.cwd();
const url = await resolveLlamaServerUrl(cwd);
const apiKey = await resolveLlamaServerApiKey();
const reachable = await isLlamaServerReady(url, apiKey);
return {
reachable,
url,
hasApiKey: Boolean(apiKey),
reason: reachable ? undefined : "llama.cpp server did not return a healthy response",
};
}

View File

@@ -1,6 +1,7 @@
import { isGhAvailable, isGhAuthenticated } from "@fusion/core"; import { isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { probeClaudeCli } from "../claude-cli-probe.js"; import { probeClaudeCli } from "../claude-cli-probe.js";
import { probeDroidCli } from "../droid-cli-probe.js"; import { probeDroidCli } from "../droid-cli-probe.js";
import { probeLlamaCpp } from "../llama-cpp-probe.js";
import { ApiError, badRequest, conflict } from "../api-error.js"; import { ApiError, badRequest, conflict } from "../api-error.js";
import { clearUsageCache } from "../usage.js"; import { clearUsageCache } from "../usage.js";
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"; import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
@@ -283,6 +284,26 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
}); });
} }
// Inject synthetic llama.cpp provider.
if (store) {
let llamaEnabled = false;
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
llamaEnabled = globalSettings.useLlamaCpp === true;
} catch {
// Best-effort
}
const llamaExtension = options?.getLlamaCppExtensionStatus?.() ?? null;
const extensionOk = llamaExtension === null || llamaExtension.status === "ok";
const probe = await probeLlamaCpp();
providers.push({
id: "llama-cpp",
name: "llama.cpp — via HTTP server",
authenticated: llamaEnabled && probe.reachable && extensionOk,
type: "cli" as const,
});
}
const ghCli = { const ghCli = {
available: isGhAvailable(), available: isGhAvailable(),
authenticated: isGhAuthenticated(), authenticated: isGhAuthenticated(),
@@ -517,6 +538,93 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
} }
}); });
router.post("/auth/llama-cpp", async (req, res) => {
try {
if (!store) {
throw new ApiError(500, "Settings store unavailable");
}
const enabled = req.body?.enabled;
if (typeof enabled !== "boolean") {
throw badRequest("enabled must be a boolean");
}
if (enabled) {
const probe = await probeLlamaCpp();
if (!probe.reachable) {
throw new ApiError(400, `Cannot enable llama.cpp routing: ${probe.reason ?? "server unreachable"}`);
}
}
let prev = false;
try {
const priorGlobal = await store.getGlobalSettingsStore().getSettings();
prev = priorGlobal.useLlamaCpp === true;
} catch {
// best effort
}
const settings = await store.updateGlobalSettings({ useLlamaCpp: enabled });
invalidateAllGlobalSettingsCaches();
const engineManager = options?.engineManager;
if (engineManager) {
for (const engine of engineManager.getAllEngines().values()) {
engine.getTaskStore().getGlobalSettingsStore().invalidateCache();
}
}
const next = settings.useLlamaCpp === true;
if (options?.onUseLlamaCppToggled && prev !== next) {
try {
options.onUseLlamaCppToggled(prev, next);
} catch (hookErr) {
console.warn(
`[auth/llama-cpp] onUseLlamaCppToggled callback threw: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
);
}
}
res.json({ enabled: next, restartRequired: false });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/providers/llama-cpp/status", async (_req, res) => {
try {
const probe = await probeLlamaCpp();
let enabled = false;
if (store) {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
enabled = globalSettings.useLlamaCpp === true;
} catch {
// best-effort
}
}
const extension = options?.getLlamaCppExtensionStatus?.() ?? null;
const ready = enabled && probe.reachable && (extension === null || extension.status === "ok");
res.json({
enabled,
extension,
ready,
server: {
available: probe.reachable,
url: probe.url,
hasApiKey: probe.hasApiKey,
reason: probe.reason,
},
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/** /**
* POST /api/auth/login * POST /api/auth/login
* Initiates OAuth login for a provider. * Initiates OAuth login for a provider.

View File

@@ -13,6 +13,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
let defaultModelId: string | undefined; let defaultModelId: string | undefined;
let useClaudeCli = false; let useClaudeCli = false;
let useDroidCli = false; let useDroidCli = false;
let useLlamaCpp = false;
let resolvedPlanningProvider: string | undefined; let resolvedPlanningProvider: string | undefined;
let resolvedPlanningModelId: string | undefined; let resolvedPlanningModelId: string | undefined;
if (store) { if (store) {
@@ -25,6 +26,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
defaultModelId = globalSettings.defaultModelId; defaultModelId = globalSettings.defaultModelId;
useClaudeCli = globalSettings.useClaudeCli === true; useClaudeCli = globalSettings.useClaudeCli === true;
useDroidCli = globalSettings.useDroidCli === true; useDroidCli = globalSettings.useDroidCli === true;
useLlamaCpp = globalSettings.useLlamaCpp === true;
const mergedSettings = await store.getSettingsFast(); const mergedSettings = await store.getSettingsFast();
const resolvedPlanningModel = resolvePlanningSettingsModel(mergedSettings); const resolvedPlanningModel = resolvePlanningSettingsModel(mergedSettings);
@@ -82,6 +84,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
if (!useDroidCli) { if (!useDroidCli) {
models = models.filter((m) => m.provider !== "droid-cli"); models = models.filter((m) => m.provider !== "droid-cli");
} }
if (!useLlamaCpp) {
models = models.filter((m) => m.provider !== "llama-server");
}
res.json({ res.json({
models, models,

View File

@@ -1615,6 +1615,14 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
// Best-effort: on read failure assume false so a flip-on still fires. // Best-effort: on read failure assume false so a flip-on still fires.
} }
let prevUseLlamaCpp = false;
try {
const priorGlobal = await store.getGlobalSettingsStore().getSettings();
prevUseLlamaCpp = priorGlobal.useLlamaCpp === true;
} catch {
// Best-effort: on read failure assume false so a flip-on still fires.
}
const settings = await store.updateGlobalSettings(req.body); const settings = await store.updateGlobalSettings(req.body);
// Invalidate global settings caches in all project-scoped stores so the // Invalidate global settings caches in all project-scoped stores so the
// next GET /settings?projectId=xxx reads fresh values from disk rather // next GET /settings?projectId=xxx reads fresh values from disk rather
@@ -1653,6 +1661,17 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
} }
} }
const nextUseLlamaCpp = settings.useLlamaCpp === true;
if (options?.onUseLlamaCppToggled && prevUseLlamaCpp !== nextUseLlamaCpp) {
try {
options.onUseLlamaCppToggled(prevUseLlamaCpp, nextUseLlamaCpp);
} catch (hookErr) {
runtimeLogger.warn(
`onUseLlamaCppToggled callback threw: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
);
}
}
res.json(settings); res.json(settings);
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof ApiError) { if (err instanceof ApiError) {

View File

@@ -287,6 +287,8 @@ export interface ServerOptions {
* settings PUT to fail. * settings PUT to fail.
*/ */
onUseDroidCliToggled?: (prev: boolean, next: boolean) => void; onUseDroidCliToggled?: (prev: boolean, next: boolean) => void;
/** Called when the user toggles the `useLlamaCpp` global setting. */
onUseLlamaCppToggled?: (prev: boolean, next: boolean) => void;
/** /**
* Returns the host's last-observed resolution of the bundled `droid-cli` * Returns the host's last-observed resolution of the bundled `droid-cli`
* extension wiring. Populated by serve/daemon/dashboard startup checks. * extension wiring. Populated by serve/daemon/dashboard startup checks.
@@ -305,6 +307,17 @@ export interface ServerOptions {
reason?: string; reason?: string;
} }
| null; | null;
/** Returns the host's last-observed resolution of the bundled
* `@fusion/pi-llama-cpp` extension wiring. Populated by startup checks.
*/
getLlamaCppExtensionStatus?: () =>
| {
status: "ok" | "not-installed" | "missing-entry" | "error";
path?: string;
packageVersion?: string;
reason?: string;
}
| null;
/** Optional SkillsAdapter for skills discovery, execution toggling, and catalog fetching */ /** Optional SkillsAdapter for skills discovery, execution toggling, and catalog fetching */
skillsAdapter?: SkillsAdapter; skillsAdapter?: SkillsAdapter;
/** Daemon mode configuration with bearer token authentication. /** Daemon mode configuration with bearer token authentication.

View File

@@ -0,0 +1,38 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import {
DEFAULT_CONTEXT_WINDOW,
DEFAULT_MAX_TOKENS,
PROVIDER_ID,
PROVIDER_NAME,
} from "./src/constants.js";
import { resolveLlamaServerApiKey, resolveLlamaServerUrl } from "./src/resolver.js";
import { isLlamaServerReady, listLlamaModels } from "./src/retriever.js";
export default async function (pi: ExtensionAPI): Promise<void> {
const cwd = process.cwd();
if (!(await isLlamaServerReady(cwd))) {
return;
}
const [url, models, apiKey] = await Promise.all([
resolveLlamaServerUrl(cwd),
listLlamaModels(cwd),
resolveLlamaServerApiKey(),
]);
pi.registerProvider(PROVIDER_ID, {
name: PROVIDER_NAME,
baseUrl: `${url}/v1`,
api: "openai-completions",
apiKey: apiKey ?? "",
models: models.map((model) => ({
id: model.id,
name: model.id,
reasoning: true,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
})),
});
}

View File

@@ -0,0 +1,36 @@
{
"name": "@fusion/pi-llama-cpp",
"version": "0.17.2",
"description": "First-party Fusion pi extension for llama.cpp HTTP server integration.",
"license": "MIT",
"private": true,
"type": "module",
"main": "index.ts",
"keywords": [
"pi-package",
"fusion",
"llama-cpp"
],
"pi": {
"extensions": [
"index.ts"
]
},
"repository": {
"type": "git",
"url": "https://github.com/Runfusion/Fusion",
"directory": "packages/pi-llama-cpp"
},
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"scripts": {
"test": "vitest run --reporter=dot",
"typecheck": "tsc --noEmit"
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import extension from "../../index.js";
vi.mock("../retriever.js", () => ({
isLlamaServerReady: vi.fn(),
listLlamaModels: vi.fn(),
}));
vi.mock("../resolver.js", () => ({
resolveLlamaServerUrl: vi.fn(),
resolveLlamaServerApiKey: vi.fn(),
}));
import { isLlamaServerReady, listLlamaModels } from "../retriever.js";
import { resolveLlamaServerApiKey, resolveLlamaServerUrl } from "../resolver.js";
describe("pi-llama-cpp extension", () => {
it("does not register provider when server is offline", async () => {
vi.mocked(isLlamaServerReady).mockResolvedValue(false);
const registerProvider = vi.fn();
await extension({ registerProvider } as never);
expect(registerProvider).not.toHaveBeenCalled();
});
it("registers llama-server provider when server is reachable", async () => {
vi.mocked(isLlamaServerReady).mockResolvedValue(true);
vi.mocked(resolveLlamaServerUrl).mockResolvedValue("http://127.0.0.1:8080");
vi.mocked(resolveLlamaServerApiKey).mockResolvedValue("abc");
vi.mocked(listLlamaModels).mockResolvedValue([{ id: "qwen" }]);
const registerProvider = vi.fn();
await extension({ registerProvider } as never);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith(
"llama-server",
expect.objectContaining({
api: "openai-completions",
baseUrl: "http://127.0.0.1:8080/v1",
apiKey: "abc",
}),
);
});
});

View File

@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resetLlamaResolverCache,
resolveLlamaServerApiKey,
resolveLlamaServerUrl,
} from "../resolver.js";
const readFileMock = vi.fn();
vi.mock("node:fs/promises", () => ({
readFile: (...args: unknown[]) => readFileMock(...args),
}));
describe("resolveLlamaServerUrl", () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetAllMocks();
resetLlamaResolverCache();
process.env = { ...originalEnv };
delete process.env.LLAMA_SERVER_URL;
});
afterEach(() => {
process.env = originalEnv;
});
it("uses project config first", async () => {
readFileMock.mockResolvedValueOnce('{"url":"http://localhost:8081/"}');
const url = await resolveLlamaServerUrl("/tmp/project");
expect(url).toBe("http://localhost:8081");
});
it("falls back to env var", async () => {
readFileMock.mockRejectedValueOnce(new Error("missing"));
process.env.LLAMA_SERVER_URL = "http://localhost:9999/";
const url = await resolveLlamaServerUrl("/tmp/project");
expect(url).toBe("http://localhost:9999");
});
it("falls back to default", async () => {
readFileMock.mockRejectedValue(new Error("missing"));
const url = await resolveLlamaServerUrl("/tmp/project");
expect(url).toBe("http://127.0.0.1:8080");
});
});
describe("resolveLlamaServerApiKey", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("returns undefined when not configured", async () => {
readFileMock.mockRejectedValueOnce(new Error("missing"));
await expect(resolveLlamaServerApiKey()).resolves.toBeUndefined();
});
it("returns provider key for llama-server", async () => {
readFileMock.mockResolvedValueOnce(
JSON.stringify({
"llama-server": { type: "api_key", key: "secret-token" },
}),
);
await expect(resolveLlamaServerApiKey()).resolves.toBe("secret-token");
});
});

View File

@@ -0,0 +1,5 @@
export const PROVIDER_ID = "llama-server";
export const PROVIDER_NAME = "Llama.cpp";
export const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
export const DEFAULT_MAX_TOKENS = 32000;
export const DEFAULT_CONTEXT_WINDOW = 128000;

View File

@@ -0,0 +1,62 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { DEFAULT_LLAMA_SERVER_URL, PROVIDER_ID } from "./constants.js";
type AuthFile = Record<string, { type?: string; key?: string } | undefined>;
let cachedUrl: string | null = null;
async function readJson<T>(path: string): Promise<T | null> {
try {
const raw = await readFile(path, "utf-8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, "");
}
export async function resolveLlamaServerUrl(cwd: string): Promise<string> {
if (cachedUrl) return cachedUrl;
const projectCfg = await readJson<{ url?: string }>(
join(cwd, ".pi", "llama-server.json"),
);
if (projectCfg?.url) {
cachedUrl = normalizeUrl(projectCfg.url);
return cachedUrl;
}
const envUrl = process.env.LLAMA_SERVER_URL;
if (envUrl) {
cachedUrl = normalizeUrl(envUrl);
return cachedUrl;
}
const globalCfg = await readJson<{ llamaServerUrl?: string }>(
join(process.env.HOME ?? ".", ".pi", "agent", "settings.json"),
);
if (globalCfg?.llamaServerUrl) {
cachedUrl = normalizeUrl(globalCfg.llamaServerUrl);
return cachedUrl;
}
cachedUrl = DEFAULT_LLAMA_SERVER_URL;
return cachedUrl;
}
export async function resolveLlamaServerApiKey(): Promise<string | undefined> {
const authCfg = await readJson<AuthFile>(
join(process.env.HOME ?? ".", ".pi", "agent", "auth.json"),
);
const auth = authCfg?.[PROVIDER_ID];
const key = typeof auth?.key === "string" ? auth.key.trim() : "";
return key.length > 0 ? key : undefined;
}
export function resetLlamaResolverCache(): void {
cachedUrl = null;
}

View File

@@ -0,0 +1,43 @@
import { resolveLlamaServerApiKey, resolveLlamaServerUrl } from "./resolver.js";
export type LlamaModel = {
id: string;
object?: string;
owned_by?: string;
};
export type LlamaProviderModel = {
id: string;
name: string;
reasoning: boolean;
input: Array<"text" | "image">;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
};
export async function llamaRpc<T>(endpoint: string, cwd = process.cwd()): Promise<T> {
const url = `${await resolveLlamaServerUrl(cwd)}${endpoint}`;
const apiKey = await resolveLlamaServerApiKey();
const response = await fetch(url, {
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
});
if (!response.ok) {
throw new Error(`${response.status}: ${await response.text()}`);
}
return (await response.json()) as T;
}
export async function isLlamaServerReady(cwd = process.cwd()): Promise<boolean> {
try {
const status = await llamaRpc<{ status?: string }>("/health", cwd);
return status.status === "ok";
} catch {
return false;
}
}
export async function listLlamaModels(cwd = process.cwd()): Promise<LlamaModel[]> {
const response = await llamaRpc<{ data?: LlamaModel[]; models?: unknown }>("/models", cwd);
return Array.isArray(response.data) ? response.data : [];
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*.ts", "index.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
},
});

121
pnpm-lock.yaml generated
View File

@@ -75,6 +75,9 @@ importers:
'@fusion/pi-claude-cli': '@fusion/pi-claude-cli':
specifier: workspace:* specifier: workspace:*
version: link:../pi-claude-cli version: link:../pi-claude-cli
'@fusion/pi-llama-cpp':
specifier: workspace:*
version: link:../pi-llama-cpp
'@types/node': '@types/node':
specifier: ^22.0.0 specifier: ^22.0.0
version: 22.19.15 version: 22.19.15
@@ -204,7 +207,7 @@ importers:
version: link:../engine version: link:../engine
'@mariozechner/pi-coding-agent': '@mariozechner/pi-coding-agent':
specifier: ^0.72.1 specifier: ^0.72.1
version: 0.72.1(ws@8.20.0)(zod@3.25.76) version: 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@types/multer': '@types/multer':
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0 version: 2.1.0
@@ -485,6 +488,22 @@ importers:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3) version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
packages/pi-llama-cpp:
dependencies:
'@mariozechner/pi-coding-agent':
specifier: '*'
version: 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.15
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.8.3)
packages/plugin-sdk: packages/plugin-sdk:
dependencies: dependencies:
'@fusion/core': '@fusion/core':
@@ -8108,6 +8127,19 @@ snapshots:
'@exodus/bytes@1.15.0': {} '@exodus/bytes@1.15.0': {}
'@google/genai@1.46.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))':
dependencies:
google-auth-library: 10.6.2
p-retry: 4.6.2
protobufjs: 7.5.4
ws: 8.20.0
optionalDependencies:
'@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76)
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@google/genai@1.46.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))': '@google/genai@1.46.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))':
dependencies: dependencies:
google-auth-library: 10.6.2 google-auth-library: 10.6.2
@@ -8526,9 +8558,9 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-agent-core@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@mariozechner/pi-agent-core@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies: dependencies:
'@mariozechner/pi-ai': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
typebox: 1.1.32 typebox: 1.1.32
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
@@ -8539,9 +8571,9 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-agent-core@0.72.1(ws@8.20.0)(zod@3.25.76)': '@mariozechner/pi-agent-core@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/pi-ai': 0.72.1(ws@8.20.0)(zod@3.25.76) '@mariozechner/pi-ai': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
typebox: 1.1.32 typebox: 1.1.32
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
@@ -8598,6 +8630,28 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-ai@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1036.0
'@google/genai': 1.46.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))
'@mistralai/mistralai': 2.2.1
chalk: 5.6.2
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
partial-json: 0.1.7
proxy-agent: 6.5.0
typebox: 1.1.32
undici: 7.24.6
zod-to-json-schema: 3.25.1(zod@3.25.76)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-ai@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@mariozechner/pi-ai@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6) '@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
@@ -8620,28 +8674,6 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-ai@0.72.1(ws@8.20.0)(zod@3.25.76)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@3.25.76)
'@aws-sdk/client-bedrock-runtime': 3.1036.0
'@google/genai': 1.46.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
'@mistralai/mistralai': 2.2.1
chalk: 5.6.2
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
partial-json: 0.1.7
proxy-agent: 6.5.0
typebox: 1.1.32
undici: 7.24.6
zod-to-json-schema: 3.25.1(zod@3.25.76)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
- bufferutil
- supports-color
- utf-8-validate
- ws
- zod
'@mariozechner/pi-coding-agent@0.62.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@mariozechner/pi-coding-agent@0.62.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/jiti': 2.6.5 '@mariozechner/jiti': 2.6.5
@@ -8708,11 +8740,11 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-coding-agent@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': '@mariozechner/pi-coding-agent@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)':
dependencies: dependencies:
'@mariozechner/jiti': 2.6.5 '@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-agent-core': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@mariozechner/pi-ai': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-ai': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)
'@mariozechner/pi-tui': 0.72.1 '@mariozechner/pi-tui': 0.72.1
'@silvia-odwyer/photon-node': 0.3.4 '@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2 chalk: 5.6.2
@@ -8742,11 +8774,11 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-coding-agent@0.72.1(ws@8.20.0)(zod@3.25.76)': '@mariozechner/pi-coding-agent@0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@mariozechner/jiti': 2.6.5 '@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.72.1(ws@8.20.0)(zod@3.25.76) '@mariozechner/pi-agent-core': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.72.1(ws@8.20.0)(zod@3.25.76) '@mariozechner/pi-ai': 0.72.1(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.72.1 '@mariozechner/pi-tui': 0.72.1
'@silvia-odwyer/photon-node': 0.3.4 '@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2 chalk: 5.6.2
@@ -8824,6 +8856,29 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
'@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.12(hono@4.12.9)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.1(express@5.2.1)
hono: 4.12.9
jose: 6.2.2
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
transitivePeerDependencies:
- supports-color
optional: true
'@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)':
dependencies: dependencies:
'@hono/node-server': 1.19.12(hono@4.12.9) '@hono/node-server': 1.19.12(hono@4.12.9)