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:
@@ -87,6 +87,7 @@
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"@fusion/engine": "workspace:*",
|
||||
"@fusion/pi-claude-cli": "workspace:*",
|
||||
"@fusion/pi-llama-cpp": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@vitest/coverage-v8": "^3.1.0",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,11 @@ import {
|
||||
resolveDroidCliExtensionPaths,
|
||||
setCachedDroidCliResolution,
|
||||
} from "./droid-cli-extension.js";
|
||||
import {
|
||||
getCachedLlamaCppResolution,
|
||||
resolveLlamaCppExtensionPaths,
|
||||
setCachedLlamaCppResolution,
|
||||
} from "./llama-cpp-extension.js";
|
||||
import { resolveSelfExtension } from "./self-extension.js";
|
||||
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.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
|
||||
// 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
|
||||
@@ -492,7 +515,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
);
|
||||
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
[...reconciledExtensionPaths, ...droidCliPaths],
|
||||
[...reconciledExtensionPaths, ...droidCliPaths, ...llamaCppPaths],
|
||||
cwd,
|
||||
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
);
|
||||
@@ -586,6 +609,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
}
|
||||
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) => {
|
||||
if (!next) return;
|
||||
void (async () => {
|
||||
|
||||
@@ -53,6 +53,11 @@ import {
|
||||
resolveDroidCliExtensionPaths,
|
||||
setCachedDroidCliResolution,
|
||||
} from "./droid-cli-extension.js";
|
||||
import {
|
||||
getCachedLlamaCppResolution,
|
||||
resolveLlamaCppExtensionPaths,
|
||||
setCachedLlamaCppResolution,
|
||||
} from "./llama-cpp-extension.js";
|
||||
import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js";
|
||||
import { resolveSelfExtension } from "./self-extension.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
|
||||
// `fn_*` tools register globally even when the user hasn't run
|
||||
// `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,
|
||||
...claudeCliPaths,
|
||||
...droidCliPaths,
|
||||
...llamaCppPaths,
|
||||
],
|
||||
cwd,
|
||||
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 };
|
||||
},
|
||||
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) => {
|
||||
if (!next) return;
|
||||
void (async () => {
|
||||
@@ -1829,6 +1864,17 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}
|
||||
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) => {
|
||||
if (!next) return;
|
||||
void (async () => {
|
||||
|
||||
114
packages/cli/src/commands/llama-cpp-extension.ts
Normal file
114
packages/cli/src/commands/llama-cpp-extension.ts
Normal 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),
|
||||
};
|
||||
@@ -57,6 +57,11 @@ import {
|
||||
resolveDroidCliExtensionPaths,
|
||||
setCachedDroidCliResolution,
|
||||
} from "./droid-cli-extension.js";
|
||||
import {
|
||||
getCachedLlamaCppResolution,
|
||||
resolveLlamaCppExtensionPaths,
|
||||
setCachedLlamaCppResolution,
|
||||
} from "./llama-cpp-extension.js";
|
||||
import { resolveSelfExtension } from "./self-extension.js";
|
||||
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.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
|
||||
// requiring `pi install npm:@runfusion/fusion`.
|
||||
const selfExtension = resolveSelfExtension();
|
||||
@@ -555,6 +578,7 @@ export async function runServe(
|
||||
...packageExtensionPaths,
|
||||
...claudeCliPaths,
|
||||
...droidCliPaths,
|
||||
...llamaCppPaths,
|
||||
],
|
||||
cwd,
|
||||
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
@@ -781,6 +805,17 @@ export async function runServe(
|
||||
}
|
||||
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) => {
|
||||
if (!next) return; // Toggle-off leaves existing skill symlinks alone.
|
||||
void (async () => {
|
||||
|
||||
@@ -10,6 +10,8 @@ const piClaudeCliSrc = join(__dirname, "..", "pi-claude-cli");
|
||||
const piClaudeCliDest = join(__dirname, "dist", "pi-claude-cli");
|
||||
const droidCliSrc = join(__dirname, "..", "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 dependencyGraphPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-dependency-graph");
|
||||
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)) {
|
||||
rmSync(dependencyGraphPluginDest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
28
packages/core/src/__tests__/use-llama-cpp-settings.test.ts
Normal file
28
packages/core/src/__tests__/use-llama-cpp-settings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
modelOnboardingComplete: undefined,
|
||||
useClaudeCli: undefined,
|
||||
useDroidCli: undefined,
|
||||
useLlamaCpp: undefined,
|
||||
// Global baseline lanes for per-role model selection
|
||||
executionGlobalProvider: undefined,
|
||||
executionGlobalModelId: undefined,
|
||||
|
||||
@@ -1403,6 +1403,13 @@ export interface GlobalSettings {
|
||||
* by the dashboard auth toggle. Setting this field explicitly (true/false)
|
||||
* always wins. */
|
||||
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).
|
||||
* This is the global lane that project-level `executionProvider` can override.
|
||||
* Must be set together with `executionGlobalModelId`. Falls back to
|
||||
|
||||
@@ -1317,6 +1317,23 @@ export interface DroidCliStatus {
|
||||
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");
|
||||
@@ -1368,6 +1385,11 @@ export function fetchDroidCliStatus(): Promise<DroidCliStatus> {
|
||||
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 ---
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
19
packages/dashboard/app/components/LlamaCppProviderCard.css
Normal file
19
packages/dashboard/app/components/LlamaCppProviderCard.css
Normal 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);
|
||||
}
|
||||
}
|
||||
98
packages/dashboard/app/components/LlamaCppProviderCard.tsx
Normal file
98
packages/dashboard/app/components/LlamaCppProviderCard.tsx
Normal 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>;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
||||
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
||||
import { LoginInstructions } from "./LoginInstructions";
|
||||
import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
|
||||
import { OnboardingDisclosure } from "./OnboardingDisclosure";
|
||||
@@ -199,6 +200,7 @@ const ONBOARDING_CURATED_PROVIDER_FAMILY_ORDER = [
|
||||
"anthropic",
|
||||
"claude-cli",
|
||||
"droid-cli",
|
||||
"llama-cpp",
|
||||
"openai-codex",
|
||||
"gemini",
|
||||
"minimax",
|
||||
@@ -210,6 +212,7 @@ const ONBOARDING_PROVIDER_FAMILY_ALIASES: Record<string, (typeof ONBOARDING_CURA
|
||||
anthropic: "anthropic",
|
||||
"claude-cli": "claude-cli",
|
||||
"droid-cli": "droid-cli",
|
||||
"llama-cpp": "llama-cpp",
|
||||
"openai-codex": "openai-codex",
|
||||
google: "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") {
|
||||
return (
|
||||
<ClaudeCliProviderCard
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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 {
|
||||
provider: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
@@ -653,6 +657,8 @@ const providerConfig: Record<
|
||||
"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" },
|
||||
"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-codex": { component: OpenAIIcon, color: "var(--provider-openai)", label: "OpenAI Codex" }, // OpenAI alias
|
||||
|
||||
@@ -28,6 +28,7 @@ const PiExtensionsManager = lazy(() => import("./PiExtensionsManager").then((m)
|
||||
import { ClaudeCliProviderCard } from "./ClaudeCliProviderCard";
|
||||
import { CliBinaryPanel } from "./CliBinaryPanel";
|
||||
import { DroidCliProviderCard } from "./DroidCliProviderCard";
|
||||
import { LlamaCppProviderCard } from "./LlamaCppProviderCard";
|
||||
import { HermesRuntimeCard } from "./HermesRuntimeCard";
|
||||
import { OpenClawRuntimeCard } from "./OpenClawRuntimeCard";
|
||||
import { PaperclipRuntimeCard } from "./PaperclipRuntimeCard";
|
||||
@@ -5033,6 +5034,7 @@ export function SettingsModal({
|
||||
// auth state (Authenticated when signed in, Available otherwise).
|
||||
const claudeCliProvider = cliAuthProviders.find((p) => p.id === "claude-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(
|
||||
(entry) => entry.pluginId === "fusion-plugin-droid-runtime",
|
||||
);
|
||||
@@ -5054,14 +5056,25 @@ export function SettingsModal({
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
const llamaCppCard = llamaCppProvider ? (
|
||||
<LlamaCppProviderCard
|
||||
compact
|
||||
authenticated={llamaCppProvider.authenticated}
|
||||
onToggled={() => {
|
||||
void loadAuthStatus();
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
const showAuthenticatedGroup =
|
||||
authenticatedProviders.length > 0
|
||||
|| (claudeCliProvider?.authenticated ?? false)
|
||||
|| ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot);
|
||||
|| ((droidCliProvider?.authenticated ?? false) && !hasDroidPluginSlot)
|
||||
|| (llamaCppProvider?.authenticated ?? false);
|
||||
const showAvailableGroup =
|
||||
unauthenticatedProviders.length > 0
|
||||
|| (claudeCliProvider && !claudeCliProvider.authenticated)
|
||||
|| (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot);
|
||||
|| (droidCliProvider && !droidCliProvider.authenticated && !hasDroidPluginSlot)
|
||||
|| (llamaCppProvider && !llamaCppProvider.authenticated);
|
||||
return (
|
||||
<>
|
||||
<h4 className="settings-section-heading">Authentication</h4>
|
||||
@@ -5085,6 +5098,7 @@ export function SettingsModal({
|
||||
<div className="auth-group-label">Authenticated</div>
|
||||
{claudeCliProvider?.authenticated && claudeCliCard}
|
||||
{droidCliProvider?.authenticated && droidCliCard}
|
||||
{llamaCppProvider?.authenticated && llamaCppCard}
|
||||
{authenticatedProviders.map((provider) => (
|
||||
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
|
||||
<div className="auth-provider-header">
|
||||
@@ -5179,6 +5193,7 @@ export function SettingsModal({
|
||||
<div className="auth-group-label">Available</div>
|
||||
{claudeCliProvider && !claudeCliProvider.authenticated && claudeCliCard}
|
||||
{droidCliProvider && !droidCliProvider.authenticated && droidCliCard}
|
||||
{llamaCppProvider && !llamaCppProvider.authenticated && llamaCppCard}
|
||||
{unauthenticatedProviders.map((provider) => (
|
||||
<div key={provider.id} className="auth-provider-card">
|
||||
<div className="auth-provider-header">
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,13 @@ describe("ProviderIcon", () => {
|
||||
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", () => {
|
||||
render(<ProviderIcon provider="unknown" />);
|
||||
// Cpu icon from lucide-react renders as an svg without our custom data-testid
|
||||
|
||||
@@ -258,6 +258,7 @@ html {
|
||||
--provider-groq: #f55036;
|
||||
--provider-vercel: var(--text);
|
||||
--provider-droid-cli: var(--text);
|
||||
--provider-ollama: #d4a27f;
|
||||
/* Runtime-plugin marks. */
|
||||
--provider-hermes: #d4961c;
|
||||
--provider-openclaw: #ff4f40;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "../ai-session-store.js";
|
||||
import * as usageModule from "../usage.js";
|
||||
import * as claudeCliProbeModule from "../claude-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 terminalServiceModule from "../terminal-service.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);
|
||||
// Filter out synthetic CLI providers — they have dedicated route tests.
|
||||
// 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([
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", loginInProgress: false },
|
||||
{ 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");
|
||||
|
||||
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([
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true, 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 ---
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
78
packages/dashboard/src/llama-cpp-probe.ts
Normal file
78
packages/dashboard/src/llama-cpp-probe.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
||||
import { probeClaudeCli } from "../claude-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 { clearUsageCache } from "../usage.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 = {
|
||||
available: isGhAvailable(),
|
||||
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
|
||||
* Initiates OAuth login for a provider.
|
||||
|
||||
@@ -13,6 +13,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
let defaultModelId: string | undefined;
|
||||
let useClaudeCli = false;
|
||||
let useDroidCli = false;
|
||||
let useLlamaCpp = false;
|
||||
let resolvedPlanningProvider: string | undefined;
|
||||
let resolvedPlanningModelId: string | undefined;
|
||||
if (store) {
|
||||
@@ -25,6 +26,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
defaultModelId = globalSettings.defaultModelId;
|
||||
useClaudeCli = globalSettings.useClaudeCli === true;
|
||||
useDroidCli = globalSettings.useDroidCli === true;
|
||||
useLlamaCpp = globalSettings.useLlamaCpp === true;
|
||||
|
||||
const mergedSettings = await store.getSettingsFast();
|
||||
const resolvedPlanningModel = resolvePlanningSettingsModel(mergedSettings);
|
||||
@@ -82,6 +84,9 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
if (!useDroidCli) {
|
||||
models = models.filter((m) => m.provider !== "droid-cli");
|
||||
}
|
||||
if (!useLlamaCpp) {
|
||||
models = models.filter((m) => m.provider !== "llama-server");
|
||||
}
|
||||
|
||||
res.json({
|
||||
models,
|
||||
|
||||
@@ -1615,6 +1615,14 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
// 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);
|
||||
// Invalidate global settings caches in all project-scoped stores so the
|
||||
// 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);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -287,6 +287,8 @@ export interface ServerOptions {
|
||||
* settings PUT to fail.
|
||||
*/
|
||||
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`
|
||||
* extension wiring. Populated by serve/daemon/dashboard startup checks.
|
||||
@@ -305,6 +307,17 @@ export interface ServerOptions {
|
||||
reason?: string;
|
||||
}
|
||||
| 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 */
|
||||
skillsAdapter?: SkillsAdapter;
|
||||
/** Daemon mode configuration with bearer token authentication.
|
||||
|
||||
38
packages/pi-llama-cpp/index.ts
Normal file
38
packages/pi-llama-cpp/index.ts
Normal 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
36
packages/pi-llama-cpp/package.json
Normal file
36
packages/pi-llama-cpp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
packages/pi-llama-cpp/src/__tests__/index.test.ts
Normal file
44
packages/pi-llama-cpp/src/__tests__/index.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
packages/pi-llama-cpp/src/__tests__/resolver.test.ts
Normal file
65
packages/pi-llama-cpp/src/__tests__/resolver.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
5
packages/pi-llama-cpp/src/constants.ts
Normal file
5
packages/pi-llama-cpp/src/constants.ts
Normal 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;
|
||||
62
packages/pi-llama-cpp/src/resolver.ts
Normal file
62
packages/pi-llama-cpp/src/resolver.ts
Normal 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;
|
||||
}
|
||||
43
packages/pi-llama-cpp/src/retriever.ts
Normal file
43
packages/pi-llama-cpp/src/retriever.ts
Normal 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 : [];
|
||||
}
|
||||
18
packages/pi-llama-cpp/tsconfig.json
Normal file
18
packages/pi-llama-cpp/tsconfig.json
Normal 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"]
|
||||
}
|
||||
7
packages/pi-llama-cpp/vitest.config.ts
Normal file
7
packages/pi-llama-cpp/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user