Files
pi-map/tests/llm-integration.test.ts
T
alex 69d3acda5d feat: M7 proper LLM integration with dual providers, caching, and parallel batching
- Add LLM client abstraction (src/llm-client.ts) with factory pattern
- Add OpenAI-compatible external client (src/external-llm-client.ts)
- Add Kimi.com client using Anthropic-based API (src/kimi-llm-client.ts)
- Add Pi native LLM stub (src/pi-llm-client.ts) for future ExtensionAPI wiring
- Add SHA-256 disk cache at ~/.cache/pi-project-map/ (src/llm-cache.ts)
- Add parallel batching with p-limit, retry + exponential backoff (src/llm-batch.ts)
- Rewrite llm-extract.ts to use real LLM calls with structured prompts
  - File-level: PURPOSE, DEPS, CONCEPTS
  - Package-level: ROLE, ARCH
  - Context truncation, 50KB skip, cache before LLM call
- Wire CLI with --llm-provider, --llm-model, --llm-base-url flags
- Update config.ts with llmProvider, llmBaseUrl fields
- Update init.ts and patch.ts to accept optional LLMClient
- Add sample project fixture for manual testing
- Add tests: llm-cache (3), llm-batch (5), llm-integration (8 with real Kimi API),
  pi-extension (14 mocked)
- All 56 tests pass
2026-06-09 22:49:34 +02:00

179 lines
5.8 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { writeFileSync, mkdtempSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { createLLMClient } from "../src/llm-client.js";
import { extractFileLLM, extractPackageLLM } from "../src/llm-extract.js";
import { processFiles } from "../src/llm-batch.js";
// Load .env file manually (no dotenv dependency needed)
function loadEnv(): Record<string, string> {
const env: Record<string, string> = {};
try {
const content = readFileSync(".env", "utf8");
for (const line of content.split("\n")) {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (match) env[match[1]] = match[2];
}
} catch {
// No .env file
}
return env;
}
const env = loadEnv();
const kimiKey = env.KIMI_API_KEY || process.env.KIMI_API_KEY;
const kimiModel =
env.KIMI_MODEL ||
env.LLM_MODEL ||
process.env.KIMI_MODEL ||
process.env.LLM_MODEL ||
"kimi-k2-6";
const hasKimiKey = !!kimiKey;
// Set env vars so the clients can pick them up
if (env.KIMI_API_KEY) process.env.KIMI_API_KEY = env.KIMI_API_KEY;
if (env.LLM_MODEL) process.env.LLM_MODEL = env.LLM_MODEL;
describe.skipIf(!hasKimiKey)("LLM integration with Kimi", () => {
it("creates kimi client and calls complete", async () => {
const client = createLLMClient("kimi", { model: kimiModel });
const response = await client.complete(
"PURPOSE: test\nAnalyze this: export const x = 1;",
);
expect(typeof response).toBe("string");
expect(response.length).toBeGreaterThan(0);
console.log(" complete() response:", response.slice(0, 120));
}, 30000);
it("extracts file purpose with real LLM", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const file = join(dir, "config.ts");
writeFileSync(
file,
`export const API_URL = "https://api.example.com";\nexport const TIMEOUT = 5000;`,
);
const client = createLLMClient("kimi", { model: kimiModel });
const result = await extractFileLLM(file, client);
expect(result.purpose).toBeTruthy();
expect(result.purpose.length).toBeGreaterThan(5);
expect(Array.isArray(result.deps)).toBe(true);
expect(Array.isArray(result.concepts)).toBe(true);
console.log(" File purpose:", result.purpose);
console.log(" Concepts:", result.concepts.join(", ") || "none");
}, 30000);
it("extracts package role with real LLM", async () => {
const client = createLLMClient("kimi", { model: kimiModel });
const result = await extractPackageLLM(
"src/utils",
[
{ name: "http.ts", purpose: "HTTP client wrapper" },
{ name: "cache.ts", purpose: "In-memory cache" },
{ name: "retry.ts", purpose: "Retry logic with backoff" },
],
client,
);
expect(result.role).toBeTruthy();
expect(result.role.length).toBeGreaterThan(5);
expect(result.arch).toBeTruthy();
console.log(" Package role:", result.role);
console.log(" Package arch:", result.arch);
}, 30000);
it("caches LLM results on disk", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const file = join(dir, "test.ts");
writeFileSync(file, `export const version = "1.0.0";`);
const client = createLLMClient("kimi", { model: kimiModel });
const result1 = await extractFileLLM(file, client);
expect(result1.purpose).toBeTruthy();
// Second call should hit cache — much faster
const start = Date.now();
const result2 = await extractFileLLM(file, client);
const elapsed = Date.now() - start;
expect(result2.purpose).toBe(result1.purpose);
expect(elapsed).toBeLessThan(500); // Cache hit should be fast
console.log(" Cache hit time:", elapsed, "ms");
}, 30000);
it("processes multiple files in parallel", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const files: string[] = [];
for (let i = 0; i < 3; i++) {
const f = join(dir, `file${i}.ts`);
writeFileSync(
f,
`export const val${i} = ${i};\n// Some logic here\nexport function helper${i}() { return val${i}; }`,
);
files.push(f);
}
const client = createLLMClient("kimi", { model: kimiModel });
const start = Date.now();
const results = await processFiles(
files,
async (f) => extractFileLLM(f, client),
{ concurrency: 3, maxRetries: 1, retryDelaysMs: [2000] },
);
const elapsed = Date.now() - start;
expect(results.length).toBe(3);
for (const r of results) {
expect(r.purpose).toBeTruthy();
expect(r.purpose.length).toBeGreaterThan(5);
}
console.log(" Parallel processing:", elapsed, "ms for 3 files");
}, 60000);
it("skips large files without calling LLM", async () => {
const dir = mkdtempSync(join(tmpdir(), "pi-map-llm-"));
const file = join(dir, "big.ts");
writeFileSync(file, "x".repeat(60 * 1024));
let calls = 0;
const trackingClient = createLLMClient("kimi", { model: kimiModel });
const originalComplete = trackingClient.complete.bind(trackingClient);
trackingClient.complete = async (...args) => {
calls++;
return originalComplete(...args);
};
const result = await extractFileLLM(file, trackingClient);
expect(result.purpose).toBe("Large/generated file");
expect(calls).toBe(0); // Should never call LLM for large files
});
});
describe("LLM integration without env vars", () => {
it("throws clear error when API key is missing", () => {
const saved = process.env.KIMI_API_KEY;
delete process.env.KIMI_API_KEY;
try {
expect(() => createLLMClient("kimi", { apiKey: undefined })).toThrow(
"No Kimi API key",
);
} finally {
if (saved) process.env.KIMI_API_KEY = saved;
}
});
it("throws clear error for OpenAI without key", () => {
const saved = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
expect(() => createLLMClient("openai", { apiKey: undefined })).toThrow(
"No OpenAI API key",
);
} finally {
if (saved) process.env.OPENAI_API_KEY = saved;
}
});
});