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:
5
.changeset/add-llama-cpp-provider.md
Normal file
5
.changeset/add-llama-cpp-provider.md
Normal 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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
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,
|
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 () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 { 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
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 { 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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
121
pnpm-lock.yaml
generated
121
pnpm-lock.yaml
generated
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user