From 2db0ba11575312689558e120f878a9aabae610da Mon Sep 17 00:00:00 2001 From: MattDevy Date: Thu, 14 May 2026 11:42:54 +0100 Subject: [PATCH 1/2] fix(lint): attach cause to re-thrown JSON parse errors Satisfies the preserve-caught-error ESLint rule in analyze-single-shot.ts and instinct-import.ts. --- packages/pi-continuous-learning/src/cli/analyze-single-shot.ts | 1 + packages/pi-continuous-learning/src/instinct-import.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/pi-continuous-learning/src/cli/analyze-single-shot.ts b/packages/pi-continuous-learning/src/cli/analyze-single-shot.ts index 0c085fe..bfd5f1e 100644 --- a/packages/pi-continuous-learning/src/cli/analyze-single-shot.ts +++ b/packages/pi-continuous-learning/src/cli/analyze-single-shot.ts @@ -65,6 +65,7 @@ export function parseChanges(raw: string): InstinctChange[] { } catch (e) { throw new Error( `Analyzer returned invalid JSON: ${String(e)}\nRaw: ${raw.slice(0, 200)}`, + { cause: e }, ); } diff --git a/packages/pi-continuous-learning/src/instinct-import.ts b/packages/pi-continuous-learning/src/instinct-import.ts index f273495..608715a 100644 --- a/packages/pi-continuous-learning/src/instinct-import.ts +++ b/packages/pi-continuous-learning/src/instinct-import.ts @@ -99,6 +99,7 @@ export function loadImportFile(filePath: string): LoadResult { } catch (err) { throw new Error( `Import file contains invalid JSON: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, ); } From 3e6159a7b957b2b24cf3157ebfd7bb9203994007 Mon Sep 17 00:00:00 2001 From: MattDevy Date: Fri, 17 Jul 2026 15:27:32 +0100 Subject: [PATCH 2/2] fix(pi-continuous-learning): support custom analyzer providers --- packages/pi-continuous-learning/README.md | 2 +- .../pi-continuous-learning/docs/internals.md | 5 +- .../src/cli/analyze-model.test.ts | 120 ++++++++++++++---- .../src/cli/analyze-model.ts | 49 ++++--- .../src/cli/analyze-single-shot.ts | 2 + .../pi-continuous-learning/src/cli/analyze.ts | 26 ++-- 6 files changed, 144 insertions(+), 60 deletions(-) diff --git a/packages/pi-continuous-learning/README.md b/packages/pi-continuous-learning/README.md index f21b5af..d06879d 100644 --- a/packages/pi-continuous-learning/README.md +++ b/packages/pi-continuous-learning/README.md @@ -401,7 +401,7 @@ All defaults work out of the box. Override at `~/.pi/continuous-learning/config. | `max_instincts` | 20 | Maximum instincts injected per turn | | `max_injection_chars` | 4000 | Character budget for the injection block (~1,000 tokens) | | `model` | `claude-haiku-4-5` | Model for the background analyzer | -| `provider` | `anthropic` | Pi provider for the background analyzer model | +| `provider` | `anthropic` | Pi provider for the background analyzer model, including custom providers from `~/.pi/agent/models.json` | | `timeout_seconds` | 120 | Per-project LLM session timeout | | `active_hours_start` | 8 | Hour (0–23) at which the active observation window starts | | `active_hours_end` | 23 | Hour (0–23) at which the active observation window ends | diff --git a/packages/pi-continuous-learning/docs/internals.md b/packages/pi-continuous-learning/docs/internals.md index 8625424..42415a0 100644 --- a/packages/pi-continuous-learning/docs/internals.md +++ b/packages/pi-continuous-learning/docs/internals.md @@ -170,10 +170,11 @@ instinct-decay.ts -- apply passive confidence decay (-0.05/week) after cleanup Resolve analyzer provider/model from config: - provider: anthropic (configurable) - model: claude-haiku-4-5 (configurable) - - credentials: existing Pi auth for that provider + - registry: Pi's ModelRegistry, including custom providers from ~/.pi/agent/models.json + - credentials and request headers: existing Pi auth or models.json configuration for that provider | v -runSingleShot(context, model, apiKey) -- sends observations + project context to the configured model +runSingleShot(context, model, apiKey, signal, headers) -- sends observations + project context to the configured model | v Model analyzes patterns and returns structured instinct changes diff --git a/packages/pi-continuous-learning/src/cli/analyze-model.test.ts b/packages/pi-continuous-learning/src/cli/analyze-model.test.ts index 744cc05..93095dc 100644 --- a/packages/pi-continuous-learning/src/cli/analyze-model.test.ts +++ b/packages/pi-continuous-learning/src/cli/analyze-model.test.ts @@ -1,21 +1,47 @@ -import { describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AuthStorage, + ModelRegistry, +} from "@earendil-works/pi-coding-agent"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_CONFIG } from "../config.js"; import type { Config } from "../types.js"; import { resolveAnalyzerModel } from "./analyze-model.js"; +const temporaryDirectories: string[] = []; + function config(overrides: Partial = {}): Config { return { ...DEFAULT_CONFIG, ...overrides }; } +function registry(apiKey: string | undefined) { + const models = ModelRegistry.inMemory(AuthStorage.inMemory()).getAll(); + return { + find: vi.fn((provider: string, modelId: string) => + models.find( + (model) => model.provider === provider && model.id === modelId, + ), + ), + getAll: vi.fn(() => models), + getApiKeyAndHeaders: vi.fn().mockResolvedValue({ ok: true, apiKey }), + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + describe("resolveAnalyzerModel", () => { it("uses the configured provider and model", async () => { - const authStorage = { - getApiKey: vi.fn().mockResolvedValue("codex-token"), - }; + const modelRegistry = registry("codex-token"); const result = await resolveAnalyzerModel( config({ provider: "openai-codex", model: "gpt-5.4-mini" }), - authStorage, + modelRegistry, ); expect(result.providerId).toBe("openai-codex"); @@ -23,60 +49,102 @@ describe("resolveAnalyzerModel", () => { expect(result.model.provider).toBe("openai-codex"); expect(result.model.id).toBe("gpt-5.4-mini"); expect(result.apiKey).toBe("codex-token"); - expect(authStorage.getApiKey).toHaveBeenCalledWith("openai-codex"); + expect(modelRegistry.getApiKeyAndHeaders).toHaveBeenCalledWith(result.model); }); it("keeps Anthropic Haiku as the backwards-compatible default", async () => { - const authStorage = { - getApiKey: vi.fn().mockResolvedValue("anthropic-token"), - }; + const modelRegistry = registry("anthropic-token"); - const result = await resolveAnalyzerModel(config(), authStorage); + const result = await resolveAnalyzerModel(config(), modelRegistry); expect(result.providerId).toBe("anthropic"); expect(result.modelId).toBe("claude-haiku-4-5"); expect(result.model.provider).toBe("anthropic"); - expect(authStorage.getApiKey).toHaveBeenCalledWith("anthropic"); }); - it("throws a provider-specific error when credentials are missing", async () => { - const authStorage = { - getApiKey: vi.fn().mockResolvedValue(undefined), - }; + it("loads custom provider models, credentials, and headers from models.json", async () => { + const directory = mkdtempSync(join(tmpdir(), "pi-cl-model-registry-")); + temporaryDirectories.push(directory); + const modelsPath = join(directory, "models.json"); + writeFileSync( + modelsPath, + JSON.stringify({ + providers: { + "my-custom-provider": { + baseUrl: "https://proxy.example.com/anthropic", + api: "anthropic-messages", + apiKey: "custom-token", + headers: { "X-Proxy-Tenant": "continuous-learning" }, + models: [ + { + id: "custom-model", + name: "Custom Model", + reasoning: true, + input: ["text"], + contextWindow: 100_000, + maxTokens: 8_192, + cost: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + }, + }, + ], + }, + }, + }), + "utf8", + ); + const modelRegistry = ModelRegistry.create( + AuthStorage.inMemory(), + modelsPath, + ); + + const result = await resolveAnalyzerModel( + config({ provider: "my-custom-provider", model: "custom-model" }), + modelRegistry, + ); + expect(result.model.provider).toBe("my-custom-provider"); + expect(result.model.id).toBe("custom-model"); + expect(result.model.baseUrl).toBe("https://proxy.example.com/anthropic"); + expect(result.apiKey).toBe("custom-token"); + expect(result.headers).toEqual({ + "X-Proxy-Tenant": "continuous-learning", + }); + }); + + it("throws a provider-specific error when credentials are missing", async () => { await expect( resolveAnalyzerModel( config({ provider: "openai-codex", model: "gpt-5.4-mini" }), - authStorage, + registry(undefined), ), ).rejects.toThrow("No API key configured for provider: openai-codex"); }); it("throws a provider/model-specific error for unknown model ids", async () => { - const authStorage = { - getApiKey: vi.fn().mockResolvedValue("token"), - }; + const modelRegistry = registry("token"); await expect( resolveAnalyzerModel( config({ provider: "openai-codex", model: "not-a-real-model" }), - authStorage, + modelRegistry, ), ).rejects.toThrow("Unknown analyzer model: openai-codex/not-a-real-model"); - expect(authStorage.getApiKey).not.toHaveBeenCalled(); + expect(modelRegistry.getApiKeyAndHeaders).not.toHaveBeenCalled(); }); it("throws a provider-specific error for unknown provider strings", async () => { - const authStorage = { - getApiKey: vi.fn().mockResolvedValue("token"), - }; + const modelRegistry = registry("token"); await expect( resolveAnalyzerModel( config({ provider: "not-a-real-provider", model: "claude-haiku-4-5" }), - authStorage, + modelRegistry, ), ).rejects.toThrow("Unknown analyzer provider: not-a-real-provider"); - expect(authStorage.getApiKey).not.toHaveBeenCalled(); + expect(modelRegistry.getApiKeyAndHeaders).not.toHaveBeenCalled(); }); }); diff --git a/packages/pi-continuous-learning/src/cli/analyze-model.ts b/packages/pi-continuous-learning/src/cli/analyze-model.ts index 39115dc..3ce5c29 100644 --- a/packages/pi-continuous-learning/src/cli/analyze-model.ts +++ b/packages/pi-continuous-learning/src/cli/analyze-model.ts @@ -1,47 +1,48 @@ -import type { AuthStorage } from "@earendil-works/pi-coding-agent"; -import { - getModel, - getProviders, - type Api, - type KnownProvider, - type Model, -} from "@earendil-works/pi-ai"; +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { Api, Model } from "@earendil-works/pi-ai"; import type { Config } from "../types.js"; -type AnalyzerAuthStorage = Pick; +type AnalyzerModelRegistry = Pick< + ModelRegistry, + "find" | "getAll" | "getApiKeyAndHeaders" +>; export interface AnalyzerModelResolution { readonly apiKey: string; readonly model: Model; readonly modelId: string; readonly providerId: string; -} - -function isKnownProvider(value: string): value is KnownProvider { - return (getProviders() as string[]).includes(value); + readonly headers?: Record; } export async function resolveAnalyzerModel( config: Config, - authStorage: AnalyzerAuthStorage, + modelRegistry: AnalyzerModelRegistry, ): Promise { const providerId = config.provider; const modelId = config.model; - if (!isKnownProvider(providerId)) { + const providerModels = modelRegistry + .getAll() + .filter((candidate) => candidate.provider === providerId); + if (providerModels.length === 0) { throw new Error(`Unknown analyzer provider: ${providerId}`); } - // getModel returns undefined for unknown model IDs but its overload signature - // only accepts known model IDs — cast the result to include undefined so the - // runtime guard below is reachable for arbitrary config values. - const model = getModel(providerId, modelId as never) as Model | undefined; + const model = modelRegistry.find(providerId, modelId); if (!model) { throw new Error(`Unknown analyzer model: ${providerId}/${modelId}`); } - const apiKey = await authStorage.getApiKey(providerId); + const auth = await modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok) { + throw new Error( + `Could not resolve analyzer credentials for provider ${providerId}: ${auth.error}`, + ); + } + + const apiKey = auth.apiKey; if (!apiKey) { throw new Error( `No API key configured for provider: ${providerId}. ` + @@ -49,5 +50,11 @@ export async function resolveAnalyzerModel( ); } - return { apiKey, model, modelId, providerId }; + return { + apiKey, + model, + modelId, + providerId, + ...(auth.headers ? { headers: auth.headers } : {}), + }; } diff --git a/packages/pi-continuous-learning/src/cli/analyze-single-shot.ts b/packages/pi-continuous-learning/src/cli/analyze-single-shot.ts index bfd5f1e..70bdf78 100644 --- a/packages/pi-continuous-learning/src/cli/analyze-single-shot.ts +++ b/packages/pi-continuous-learning/src/cli/analyze-single-shot.ts @@ -261,9 +261,11 @@ export async function runSingleShot( model: Parameters[0], apiKey: string, signal?: AbortSignal, + headers?: Record, ): Promise { const opts: Parameters[2] = { apiKey }; if (signal !== undefined) opts.signal = signal; + if (headers !== undefined) opts.headers = headers; const message = await complete(model, context, opts); const textContent = message.content diff --git a/packages/pi-continuous-learning/src/cli/analyze.ts b/packages/pi-continuous-learning/src/cli/analyze.ts index e2bea9f..36f2bbc 100644 --- a/packages/pi-continuous-learning/src/cli/analyze.ts +++ b/packages/pi-continuous-learning/src/cli/analyze.ts @@ -8,7 +8,10 @@ import { } from "node:fs"; import { createHash } from "node:crypto"; import { join } from "node:path"; -import { AuthStorage } from "@earendil-works/pi-coding-agent"; +import { + AuthStorage, + ModelRegistry, +} from "@earendil-works/pi-coding-agent"; import { loadConfig, DEFAULT_CONFIG } from "../config.js"; import type { InstalledSkill, ProjectEntry } from "../types.js"; @@ -230,7 +233,7 @@ async function analyzeProject( config: ReturnType, baseDir: string, logger: AnalyzeLogger, - authStorage: AuthStorage, + modelRegistry: ModelRegistry, ): Promise { const meta = loadProjectMeta(project.id, baseDir); @@ -401,9 +404,9 @@ async function analyzeProject( }, ); - const { apiKey, model, modelId } = await resolveAnalyzerModel( + const { apiKey, model, modelId, headers } = await resolveAnalyzerModel( config, - authStorage, + modelRegistry, ); const context = { @@ -440,6 +443,7 @@ async function analyzeProject( model, apiKey, abortController.signal, + headers, ); singleShotMessage = result.message; @@ -588,7 +592,7 @@ async function consolidateProject( baseDir: string, logger: AnalyzeLogger, force: boolean, - authStorage: AuthStorage, + modelRegistry: ModelRegistry, ): Promise { const obsPath = getObservationsPath(project.id, baseDir); const sessionCount = countDistinctSessions(obsPath); @@ -666,9 +670,9 @@ async function consolidateProject( projectId: project.id, }); - const { apiKey, model, modelId } = await resolveAnalyzerModel( + const { apiKey, model, modelId, headers } = await resolveAnalyzerModel( config, - authStorage, + modelRegistry, ); const context = { @@ -701,6 +705,7 @@ async function consolidateProject( model, apiKey, abortController.signal, + headers, ); singleShotMessage = result.message; @@ -867,6 +872,7 @@ async function main(): Promise { let errored = 0; const allProjectStats: ProjectRunStats[] = []; const authStorage = AuthStorage.create(); + const modelRegistry = ModelRegistry.create(authStorage); if (isConsolidateOnly) { // --consolidate: manual trigger, consolidation only, skip gates @@ -878,7 +884,7 @@ async function main(): Promise { baseDir, logger, true, - authStorage, + modelRegistry, ); if (result.ran && result.stats) { processed++; @@ -902,7 +908,7 @@ async function main(): Promise { // Normal mode: analyze observations, then opportunistic consolidation for (const project of projects) { try { - const result = await analyzeProject(project, config, baseDir, logger, authStorage); + const result = await analyzeProject(project, config, baseDir, logger, modelRegistry); if (result.ran && result.stats) { processed++; allProjectStats.push(result.stats); @@ -932,7 +938,7 @@ async function main(): Promise { baseDir, logger, false, - authStorage, + modelRegistry, ); if (result.ran && result.stats) { processed++;