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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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