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:
38
packages/pi-llama-cpp/index.ts
Normal file
38
packages/pi-llama-cpp/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
DEFAULT_MAX_TOKENS,
|
||||
PROVIDER_ID,
|
||||
PROVIDER_NAME,
|
||||
} from "./src/constants.js";
|
||||
import { resolveLlamaServerApiKey, resolveLlamaServerUrl } from "./src/resolver.js";
|
||||
import { isLlamaServerReady, listLlamaModels } from "./src/retriever.js";
|
||||
|
||||
export default async function (pi: ExtensionAPI): Promise<void> {
|
||||
const cwd = process.cwd();
|
||||
if (!(await isLlamaServerReady(cwd))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [url, models, apiKey] = await Promise.all([
|
||||
resolveLlamaServerUrl(cwd),
|
||||
listLlamaModels(cwd),
|
||||
resolveLlamaServerApiKey(),
|
||||
]);
|
||||
|
||||
pi.registerProvider(PROVIDER_ID, {
|
||||
name: PROVIDER_NAME,
|
||||
baseUrl: `${url}/v1`,
|
||||
api: "openai-completions",
|
||||
apiKey: apiKey ?? "",
|
||||
models: models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
reasoning: true,
|
||||
input: ["text", "image"] as Array<"text" | "image">,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
})),
|
||||
});
|
||||
}
|
||||
36
packages/pi-llama-cpp/package.json
Normal file
36
packages/pi-llama-cpp/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@fusion/pi-llama-cpp",
|
||||
"version": "0.17.2",
|
||||
"description": "First-party Fusion pi extension for llama.cpp HTTP server integration.",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"fusion",
|
||||
"llama-cpp"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"index.ts"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Runfusion/Fusion",
|
||||
"directory": "packages/pi-llama-cpp"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run --reporter=dot",
|
||||
"typecheck": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
44
packages/pi-llama-cpp/src/__tests__/index.test.ts
Normal file
44
packages/pi-llama-cpp/src/__tests__/index.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import extension from "../../index.js";
|
||||
|
||||
vi.mock("../retriever.js", () => ({
|
||||
isLlamaServerReady: vi.fn(),
|
||||
listLlamaModels: vi.fn(),
|
||||
}));
|
||||
vi.mock("../resolver.js", () => ({
|
||||
resolveLlamaServerUrl: vi.fn(),
|
||||
resolveLlamaServerApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
import { isLlamaServerReady, listLlamaModels } from "../retriever.js";
|
||||
import { resolveLlamaServerApiKey, resolveLlamaServerUrl } from "../resolver.js";
|
||||
|
||||
describe("pi-llama-cpp extension", () => {
|
||||
it("does not register provider when server is offline", async () => {
|
||||
vi.mocked(isLlamaServerReady).mockResolvedValue(false);
|
||||
const registerProvider = vi.fn();
|
||||
|
||||
await extension({ registerProvider } as never);
|
||||
expect(registerProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers llama-server provider when server is reachable", async () => {
|
||||
vi.mocked(isLlamaServerReady).mockResolvedValue(true);
|
||||
vi.mocked(resolveLlamaServerUrl).mockResolvedValue("http://127.0.0.1:8080");
|
||||
vi.mocked(resolveLlamaServerApiKey).mockResolvedValue("abc");
|
||||
vi.mocked(listLlamaModels).mockResolvedValue([{ id: "qwen" }]);
|
||||
const registerProvider = vi.fn();
|
||||
|
||||
await extension({ registerProvider } as never);
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledTimes(1);
|
||||
expect(registerProvider).toHaveBeenCalledWith(
|
||||
"llama-server",
|
||||
expect.objectContaining({
|
||||
api: "openai-completions",
|
||||
baseUrl: "http://127.0.0.1:8080/v1",
|
||||
apiKey: "abc",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
packages/pi-llama-cpp/src/__tests__/resolver.test.ts
Normal file
65
packages/pi-llama-cpp/src/__tests__/resolver.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resetLlamaResolverCache,
|
||||
resolveLlamaServerApiKey,
|
||||
resolveLlamaServerUrl,
|
||||
} from "../resolver.js";
|
||||
|
||||
const readFileMock = vi.fn();
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
readFile: (...args: unknown[]) => readFileMock(...args),
|
||||
}));
|
||||
|
||||
describe("resolveLlamaServerUrl", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetLlamaResolverCache();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.LLAMA_SERVER_URL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("uses project config first", async () => {
|
||||
readFileMock.mockResolvedValueOnce('{"url":"http://localhost:8081/"}');
|
||||
const url = await resolveLlamaServerUrl("/tmp/project");
|
||||
expect(url).toBe("http://localhost:8081");
|
||||
});
|
||||
|
||||
it("falls back to env var", async () => {
|
||||
readFileMock.mockRejectedValueOnce(new Error("missing"));
|
||||
process.env.LLAMA_SERVER_URL = "http://localhost:9999/";
|
||||
const url = await resolveLlamaServerUrl("/tmp/project");
|
||||
expect(url).toBe("http://localhost:9999");
|
||||
});
|
||||
|
||||
it("falls back to default", async () => {
|
||||
readFileMock.mockRejectedValue(new Error("missing"));
|
||||
const url = await resolveLlamaServerUrl("/tmp/project");
|
||||
expect(url).toBe("http://127.0.0.1:8080");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLlamaServerApiKey", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("returns undefined when not configured", async () => {
|
||||
readFileMock.mockRejectedValueOnce(new Error("missing"));
|
||||
await expect(resolveLlamaServerApiKey()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns provider key for llama-server", async () => {
|
||||
readFileMock.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
"llama-server": { type: "api_key", key: "secret-token" },
|
||||
}),
|
||||
);
|
||||
await expect(resolveLlamaServerApiKey()).resolves.toBe("secret-token");
|
||||
});
|
||||
});
|
||||
5
packages/pi-llama-cpp/src/constants.ts
Normal file
5
packages/pi-llama-cpp/src/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const PROVIDER_ID = "llama-server";
|
||||
export const PROVIDER_NAME = "Llama.cpp";
|
||||
export const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
|
||||
export const DEFAULT_MAX_TOKENS = 32000;
|
||||
export const DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
62
packages/pi-llama-cpp/src/resolver.ts
Normal file
62
packages/pi-llama-cpp/src/resolver.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DEFAULT_LLAMA_SERVER_URL, PROVIDER_ID } from "./constants.js";
|
||||
|
||||
type AuthFile = Record<string, { type?: string; key?: string } | undefined>;
|
||||
|
||||
let cachedUrl: string | null = null;
|
||||
|
||||
async function readJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await readFile(path, "utf-8");
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
return url.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export async function resolveLlamaServerUrl(cwd: string): Promise<string> {
|
||||
if (cachedUrl) return cachedUrl;
|
||||
|
||||
const projectCfg = await readJson<{ url?: string }>(
|
||||
join(cwd, ".pi", "llama-server.json"),
|
||||
);
|
||||
if (projectCfg?.url) {
|
||||
cachedUrl = normalizeUrl(projectCfg.url);
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
const envUrl = process.env.LLAMA_SERVER_URL;
|
||||
if (envUrl) {
|
||||
cachedUrl = normalizeUrl(envUrl);
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
const globalCfg = await readJson<{ llamaServerUrl?: string }>(
|
||||
join(process.env.HOME ?? ".", ".pi", "agent", "settings.json"),
|
||||
);
|
||||
if (globalCfg?.llamaServerUrl) {
|
||||
cachedUrl = normalizeUrl(globalCfg.llamaServerUrl);
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
cachedUrl = DEFAULT_LLAMA_SERVER_URL;
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
export async function resolveLlamaServerApiKey(): Promise<string | undefined> {
|
||||
const authCfg = await readJson<AuthFile>(
|
||||
join(process.env.HOME ?? ".", ".pi", "agent", "auth.json"),
|
||||
);
|
||||
const auth = authCfg?.[PROVIDER_ID];
|
||||
const key = typeof auth?.key === "string" ? auth.key.trim() : "";
|
||||
return key.length > 0 ? key : undefined;
|
||||
}
|
||||
|
||||
export function resetLlamaResolverCache(): void {
|
||||
cachedUrl = null;
|
||||
}
|
||||
43
packages/pi-llama-cpp/src/retriever.ts
Normal file
43
packages/pi-llama-cpp/src/retriever.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { resolveLlamaServerApiKey, resolveLlamaServerUrl } from "./resolver.js";
|
||||
|
||||
export type LlamaModel = {
|
||||
id: string;
|
||||
object?: string;
|
||||
owned_by?: string;
|
||||
};
|
||||
|
||||
export type LlamaProviderModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: Array<"text" | "image">;
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
};
|
||||
|
||||
export async function llamaRpc<T>(endpoint: string, cwd = process.cwd()): Promise<T> {
|
||||
const url = `${await resolveLlamaServerUrl(cwd)}${endpoint}`;
|
||||
const apiKey = await resolveLlamaServerApiKey();
|
||||
const response = await fetch(url, {
|
||||
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function isLlamaServerReady(cwd = process.cwd()): Promise<boolean> {
|
||||
try {
|
||||
const status = await llamaRpc<{ status?: string }>("/health", cwd);
|
||||
return status.status === "ok";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLlamaModels(cwd = process.cwd()): Promise<LlamaModel[]> {
|
||||
const response = await llamaRpc<{ data?: LlamaModel[]; models?: unknown }>("/models", cwd);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
18
packages/pi-llama-cpp/tsconfig.json
Normal file
18
packages/pi-llama-cpp/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "index.ts"]
|
||||
}
|
||||
7
packages/pi-llama-cpp/vitest.config.ts
Normal file
7
packages/pi-llama-cpp/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user