diff --git a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md index ff4f8e6533..7a65b97adf 100644 --- a/packages/docs-web/src/content/docs/getting-started/ai-assistants.md +++ b/packages/docs-web/src/content/docs/getting-started/ai-assistants.md @@ -229,7 +229,7 @@ DEFAULT_AI_ASSISTANT=codex ## Pi (Community Provider) -**One adapter, ~20 LLM backends.** Pi (`@mariozechner/pi-coding-agent`) is a community-maintained coding-agent harness that Archon integrates as the first community provider. It unlocks Anthropic, OpenAI, Google (Gemini + Vertex), Groq, Mistral, Cerebras, xAI, OpenRouter, Hugging Face, and more under a single `provider: pi` entry. +**One adapter, ~20 LLM backends.** Pi (`@mariozechner/pi-coding-agent`) is a community-maintained coding-agent harness that Archon integrates as the first community provider. It unlocks Anthropic, OpenAI, Google (Gemini + Vertex), Groq, Mistral, Cerebras, xAI, OpenRouter, Hugging Face, and local inference (LM Studio, ollama, llamacpp, custom OpenAI-compatible endpoints registered in `~/.pi/agent/models.json`) under a single `provider: pi` entry. Pi is registered as `builtIn: false` — it validates the community-provider seam rather than being a core-team-maintained option. If it proves stable and valuable it may be promoted to `builtIn: true` later. @@ -262,7 +262,20 @@ Pi supports both OAuth subscriptions and API keys. Archon's adapter reads your e | `openrouter` | `OPENROUTER_API_KEY` | | `huggingface` | `HUGGINGFACE_API_KEY` | -Additional Pi backends exist (Azure, Bedrock, Vertex, etc.) — file an issue if you need them wired. +Additional cloud backends exist (Azure, Bedrock, Vertex, etc.) — file an issue if you need an env-var shortcut wired for them. + +**Local / custom providers (no credentials needed):** + +Providers that aren't in the env-var table above (LM Studio, ollama, llamacpp, custom OpenAI-compatible endpoints) work without any Archon-side configuration. Register them in `~/.pi/agent/models.json` per Pi's own docs and reference them as `/`: + +```yaml +# .archon/config.yaml +assistants: + pi: + model: lm-studio/qwen2.5-coder-14b # whatever ID you registered with Pi +``` + +Archon logs an info-level `pi.auth_missing` event when no credentials are found and continues — Pi's SDK then connects directly to the local endpoint defined in `models.json`. If the provider does require auth (a less-common cloud backend not in the env-var table) the SDK call fails downstream; the `pi.auth_missing` breadcrumb in the log lets you trace it back to a missing env-var mapping. ### Extensions (on by default) diff --git a/packages/providers/src/community/pi/provider.test.ts b/packages/providers/src/community/pi/provider.test.ts index 40ffcec80f..4de4314147 100644 --- a/packages/providers/src/community/pi/provider.test.ts +++ b/packages/providers/src/community/pi/provider.test.ts @@ -81,7 +81,14 @@ const mockAuthCreate = mock(() => ({ setRuntimeApiKey: mockSetRuntimeApiKey, getApiKey: mockGetApiKey, })); -const mockModelRegistryInMemory = mock(() => ({})); + +const mockModelRegistryFind = mock((provider: string, modelId: string) => { + if (provider === 'nonexistent') return undefined; + return { id: modelId, provider, name: `${provider}/${modelId}` }; +}); +const mockModelRegistryCreate = mock(() => ({ + find: mockModelRegistryFind, +})); // SessionManager mocks. Each returns a tagged session-manager stub so tests // can assert whether resume resolved to an existing session or fell through @@ -115,7 +122,7 @@ const mockCreateLsTool = mock((_cwd: string) => ({ __piTool: 'ls' })); mock.module('@mariozechner/pi-coding-agent', () => ({ createAgentSession: mockCreateAgentSession, AuthStorage: { create: mockAuthCreate }, - ModelRegistry: { inMemory: mockModelRegistryInMemory }, + ModelRegistry: { create: mockModelRegistryCreate }, SessionManager: { create: mockSessionCreate, open: mockSessionOpen, @@ -132,16 +139,6 @@ mock.module('@mariozechner/pi-coding-agent', () => ({ createLsTool: mockCreateLsTool, })); -// getModel is imported from pi-ai. Return a fake model for known refs and -// undefined for unknown refs so the provider's not-found branch is testable. -const mockGetModel = mock((provider: string, modelId: string) => { - if (provider === 'nonexistent') return undefined; - return { id: modelId, provider, name: `${provider}/${modelId}` }; -}); -mock.module('@mariozechner/pi-ai', () => ({ - getModel: mockGetModel, -})); - // Import AFTER mocks are set — module resolution freezes the mocks. import { PiProvider } from './provider'; import { PI_CAPABILITIES } from './capabilities'; @@ -169,6 +166,12 @@ function resetScript(events: FakeEvent[]): void { describe('PiProvider', () => { beforeEach(() => { + mockLogger.fatal.mockClear(); + mockLogger.error.mockClear(); + mockLogger.warn.mockClear(); + mockLogger.info.mockClear(); + mockLogger.debug.mockClear(); + mockLogger.trace.mockClear(); mockPrompt.mockClear(); mockAbort.mockClear(); mockDispose.mockClear(); @@ -177,8 +180,9 @@ describe('PiProvider', () => { mockSetFlagValue.mockClear(); mockResourceLoaderReload.mockClear(); mockCreateAgentSession.mockClear(); - mockGetModel.mockClear(); mockAuthCreate.mockClear(); + mockModelRegistryCreate.mockClear(); + mockModelRegistryFind.mockClear(); mockSetRuntimeApiKey.mockClear(); mockGetApiKey.mockClear(); MockDefaultResourceLoader.mockClear(); @@ -236,15 +240,102 @@ describe('PiProvider', () => { expect(error?.message).toContain('Invalid Pi model ref'); }); - test('throws when Pi provider id is unknown AND no creds available', async () => { - // No env var, no auth.json entry → fail-fast with hint about env-var table + test('logs credential hint when Pi provider id is unknown AND no creds available', async () => { + // No env var, no auth.json entry → log hint, but continue, to support custom providers that don't use credentials or that use non-Pi means of providing credentials. + resetScript(scriptedAgentEnd()); const { error } = await consume( new PiProvider().sendQuery('hi', '/tmp', undefined, { model: 'unknownprovider/some-model', }) ); - expect(error?.message).toContain("no credentials for provider 'unknownprovider'"); - expect(error?.message).toContain("not in the Archon adapter's env-var table"); + + expect(error).toBeUndefined(); + expect(mockLogger.info).toHaveBeenCalledWith( + { + piProvider: 'unknownprovider', + envHint: expect.stringContaining("not in the Archon adapter's env-var table"), + loginHint: expect.stringContaining('/login'), + }, + 'pi.auth_missing' + ); + expect(mockCreateAgentSession).toHaveBeenCalledTimes(1); + }); + + test('ModelRegistry.create receives the AuthStorage instance', async () => { + // Headline-fix wiring: ModelRegistry.create must receive the same + // AuthStorage instance returned by AuthStorage.create(), so registry + // lookups can resolve user-configured custom models from + // ~/.pi/agent/models.json (LM Studio, ollama, llamacpp, etc.). Without + // this wiring the registry only sees the static built-in catalog. + process.env.GEMINI_API_KEY = 'sk-test'; + resetScript(scriptedAgentEnd()); + + await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { + model: 'google/gemini-2.5-pro', + }) + ); + + expect(mockAuthCreate).toHaveBeenCalledTimes(1); + expect(mockModelRegistryCreate).toHaveBeenCalledTimes(1); + const authInstance = mockAuthCreate.mock.results[0]?.value; + expect(mockModelRegistryCreate).toHaveBeenCalledWith(authInstance); + }); + + test('AuthStorage.create() throwing surfaces a contextualized error', async () => { + // Both AuthStorage.create() and ModelRegistry.create() read from disk + // and can throw on malformed JSON or filesystem errors. Wrap with + // try/catch and surface a Pi-framed error so operators see the cause + // rather than a raw SDK stack trace. + mockAuthCreate.mockImplementationOnce(() => { + throw new Error('Unexpected token } in JSON at position 42'); + }); + + const { error } = await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { + model: 'google/gemini-2.5-pro', + }) + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain('Pi auth storage init failed'); + expect(error?.message).toContain('Unexpected token'); + expect(error?.message).toContain('~/.pi/agent/auth.json'); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ piProvider: 'google' }), + 'pi.auth_storage_init_failed' + ); + }); + + test('Pi model not found includes models.json load error when registry reports one', async () => { + // ModelRegistry swallows models.json parse/validation errors into an + // internal loadError. When find() returns undefined we surface that + // error in both the structured log and the throw message so users + // debugging a custom-provider config see the actual reason. + process.env.GEMINI_API_KEY = 'sk-test'; + mockModelRegistryFind.mockImplementationOnce(() => undefined); + mockModelRegistryCreate.mockImplementationOnce(() => ({ + find: mockModelRegistryFind, + getError: () => 'Provider lm-studio: "baseUrl" is required when defining custom models.', + })); + + const { error } = await consume( + new PiProvider().sendQuery('hi', '/tmp', undefined, { + model: 'lm-studio/some-model', + }) + ); + + expect(error?.message).toContain('Pi model not found'); + expect(error?.message).toContain('models.json failed to load'); + expect(error?.message).toContain('"baseUrl" is required'); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + piProvider: 'lm-studio', + modelId: 'some-model', + loadError: expect.stringContaining('"baseUrl" is required'), + }), + 'pi.model_not_found' + ); }); test('throws when env var missing AND auth.json has no entry', async () => { @@ -295,13 +386,13 @@ describe('PiProvider', () => { expect(mockGetApiKey).toHaveBeenCalledWith('anthropic'); }); - test('throws when getModel returns undefined', async () => { + test('throws when ModelRegistry.find returns undefined', async () => { process.env.GEMINI_API_KEY = 'sk-test'; - // 'nonexistent' is handled in mockGetModel to return undefined, but - // the adapter rejects unknown providers before getModel. To exercise + // 'nonexistent' is handled in mockModelRegistryFind to return undefined, but + // the adapter rejects unknown providers. To exercise // the not-found branch, use a known provider but unknown modelId by - // temporarily swapping mockGetModel to always return undefined. - mockGetModel.mockImplementationOnce(() => undefined); + // temporarily swapping mockModelRegistryFind to always return undefined. + mockModelRegistryFind.mockImplementationOnce(() => undefined); const { error } = await consume( new PiProvider().sendQuery('hi', '/tmp', undefined, { model: 'google/unknown-model-id', diff --git a/packages/providers/src/community/pi/provider.ts b/packages/providers/src/community/pi/provider.ts index 610bcd56ab..5a14ed6166 100644 --- a/packages/providers/src/community/pi/provider.ts +++ b/packages/providers/src/community/pi/provider.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createLogger } from '@archon/paths'; -import type { Api, Model } from '@mariozechner/pi-ai'; import type { IAgentProvider, @@ -95,24 +94,6 @@ function getLog(): ReturnType { return cachedLog; } -/** - * Typed wrapper around Pi's `getModel` for a runtime-string provider/model - * pair. Pi's getModel signature constrains `TModelId` to - * `keyof MODELS[TProvider]`, which isn't knowable from a runtime string — - * the local `GetModelFn` alias is the narrowest shape that still lets us - * bypass that constraint. Isolating the escape hatch behind one searchable - * name keeps it auditable. Takes `getModel` as a parameter because the Pi - * SDK is loaded dynamically (see the header comment on this file for why). - */ -type GetModelFn = (provider: string, modelId: string) => Model | undefined; -function lookupPiModel( - getModel: GetModelFn, - provider: string, - modelId: string -): Model | undefined { - return getModel(provider, modelId); -} - /** * Append a "respond with JSON matching this schema" instruction to the user * prompt so Pi-backed models produce parseable structured output. Pi's SDK @@ -140,15 +121,7 @@ ${JSON.stringify(schema, null, 2)}`; /** * Pi community provider — wraps `@mariozechner/pi-coding-agent`'s full * coding-agent harness. Each `sendQuery()` call creates a fresh session - * (no reuse) with in-memory auth/session/settings, so the server never - * touches `~/.pi/` and concurrent calls don't collide. - * - * Capabilities (see `capabilities.ts` for the canonical list): Pi declares - * `sessionResume`, `skills`, `toolRestrictions`, `structuredOutput`, - * `envInjection`, `effortControl`, and `thinkingControl`. Features Pi does - * not currently support through Archon (`mcp`, `hooks`, `agents`, - * `costControl`, `fallbackModel`, `sandbox`) stay off; the dag-executor - * surfaces a warning for any unsupported nodeConfig field. + * (no reuse) so concurrent calls don't collide. */ export class PiProvider implements IAgentProvider { async *sendQuery( @@ -174,7 +147,6 @@ export class PiProvider implements IAgentProvider { // destructured PascalCase bindings trip eslint's naming-convention rule. const [ piCodingAgent, - piAi, { bridgeSession }, { resolvePiSkills, resolvePiThinkingLevel, resolvePiTools }, { createNoopResourceLoader }, @@ -182,7 +154,6 @@ export class PiProvider implements IAgentProvider { { createArchonUIBridge, createArchonUIContext }, ] = await Promise.all([ import('@mariozechner/pi-coding-agent'), - import('@mariozechner/pi-ai'), import('./event-bridge'), import('./options-translator'), import('./resource-loader'), @@ -227,39 +198,74 @@ export class PiProvider implements IAgentProvider { ); } - // 2. Look up the Model via Pi's static catalog. `lookupPiModel` returns - // undefined when not found; we guard explicitly below. - // Cast to the runtime-string-friendly shape — see `lookupPiModel`'s docblock. - const model = lookupPiModel(piAi.getModel as GetModelFn, parsed.provider, parsed.modelId); + // 2. Build AuthStorage + ModelRegistry. Both `create()` calls read from + // disk: AuthStorage reads ~/.pi/agent/auth.json (or + // $PI_CODING_AGENT_DIR/auth.json), and ModelRegistry reads + // ~/.pi/agent/models.json — the user's per-host config including + // custom models for local providers (LM Studio, ollama, llamacpp, + // custom OpenAI-compatible endpoints). Reads are synchronous and + // happen on every sendQuery; we don't cache because the user can + // edit either file between calls and expects pickup without restart + // (Pi's `/login` flow rewrites auth.json under a file lock). + // ModelRegistry captures any models.json load/parse error in its + // internal loadError rather than throwing — surfaced below if the + // requested model is then not found. + let authStorage: ReturnType; + let modelRegistry: ReturnType; + try { + authStorage = piCodingAgent.AuthStorage.create(); + modelRegistry = piCodingAgent.ModelRegistry.create(authStorage); + } catch (err) { + const e = err as Error; + getLog().error({ err: e, piProvider: parsed.provider }, 'pi.auth_storage_init_failed'); + throw new Error( + `Pi auth storage init failed: ${e.message}. Check that ~/.pi/agent/auth.json ` + + '(or $PI_CODING_AGENT_DIR/auth.json) is valid JSON and readable.' + ); + } + + // 3. Look up the model. find() returns undefined when not found; if + // models.json itself failed to load (e.g. a custom provider entry + // missing baseUrl/apiKey), surface the load error so users debugging + // custom-provider configs see the actual reason. + const model = modelRegistry.find(parsed.provider, parsed.modelId); if (!model) { + const loadError = modelRegistry.getError?.(); + const loadErrorHint = loadError + ? ` ~/.pi/agent/models.json failed to load: ${loadError}` + : ''; + getLog().error( + { + piProvider: parsed.provider, + modelId: parsed.modelId, + loadError: loadError ?? null, + }, + 'pi.model_not_found' + ); throw new Error( - `Pi model not found: provider='${parsed.provider}' model='${parsed.modelId}'. ` + + `Pi model not found: provider='${parsed.provider}' model='${parsed.modelId}'.${loadErrorHint} ` + 'See https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/models.generated.ts for the Pi model catalog.' ); } - // 3. Build AuthStorage. `AuthStorage.create()` reads ~/.pi/agent/auth.json - // (or $PI_CODING_AGENT_DIR/auth.json), so any credential the user has - // populated via `pi` → `/login` (OAuth subscriptions: Claude Pro/Max, - // ChatGPT Plus, GitHub Copilot, Gemini CLI, Antigravity) or by editing - // the file directly (api_key entries) is picked up transparently. - // - // Per-request env vars override the file via setRuntimeApiKey — this - // mirrors Claude's process-env + request-env merge pattern and - // ensures codebase-scoped env vars (from .archon/config.yaml `env:`) - // win over the user's global Pi login. + // 4. Resolve credentials. authStorage already loaded ~/.pi/agent/auth.json + // so any creds populated via `pi` → `/login` (OAuth subscriptions: + // Claude Pro/Max, ChatGPT Plus, GitHub Copilot, Gemini CLI, + // Antigravity) or by hand-edited api_key entries are picked up + // transparently. Per-request env vars override via setRuntimeApiKey — + // mirrors Claude's process-env + request-env merge so codebase-scoped + // env vars (.archon/config.yaml `env:`) win over the user's global + // Pi login. // // Pi's internal resolution order: // 1. runtime override (our setRuntimeApiKey below) // 2. auth.json api_key entry // 3. auth.json oauth entry (auto-refreshes expired tokens) - // 4. env var fallback (Pi's getEnvApiKey, e.g. ANTHROPIC_API_KEY) + // 4. env var fallback (Pi's getEnvApiKey, e.g. ANTHROPIC_API_KEY) // // OAuth refresh note: Pi refreshes expired access tokens against the // provider's OAuth server and rewrites ~/.pi/agent/auth.json under a // file lock (same mechanism pi CLI uses — safe for concurrent access). - const authStorage = piCodingAgent.AuthStorage.create(); - const envVarName = PI_PROVIDER_ENV_VARS[parsed.provider]; const envOverride = envVarName ? (requestOptions?.env?.[envVarName] ?? process.env[envVarName]) @@ -268,16 +274,28 @@ export class PiProvider implements IAgentProvider { authStorage.setRuntimeApiKey(parsed.provider, envOverride); } - // Fail-fast: resolve creds synchronously before spinning up a session. - // Matches Claude's auth-error fast-fail pattern (no retry on auth failures). const resolvedKey = await authStorage.getApiKey(parsed.provider); if (!resolvedKey) { - const envHint = envVarName - ? `Set ${envVarName} in the environment or codebase env vars (.archon/config.yaml env: section).` - : `Provider '${parsed.provider}' is not in the Archon adapter's env-var table — file an issue if you want a shortcut env var for it.`; - const loginHint = `Or run \`pi\` and type \`/login\` locally to authenticate '${parsed.provider}' via OAuth; credentials land in ~/.pi/agent/auth.json and are picked up automatically.`; - throw new Error( - `Pi auth: no credentials for provider '${parsed.provider}'. ${envHint} ${loginHint}` + if (envVarName) { + const envHint = `Set ${envVarName} in the environment or codebase env vars (.archon/config.yaml env: section).`; + const loginHint = `Or run \`pi\` and type \`/login\` locally to authenticate '${parsed.provider}' via OAuth; credentials land in ~/.pi/agent/auth.json and are picked up automatically.`; + throw new Error( + `Pi auth: no credentials for provider '${parsed.provider}'. ${envHint} ${loginHint}` + ); + } + + // Unmapped providers (LM Studio, ollama, llamacpp, custom + // OpenAI-compatible endpoints) often don't need credentials at all — + // log + continue rather than failing fast so local models work without + // ceremony. If the SDK call later fails for a provider that *does* + // need creds, the auth_missing breadcrumb is searchable in the log. + getLog().info( + { + piProvider: parsed.provider, + envHint: `Provider '${parsed.provider}' is not in the Archon adapter's env-var table — file an issue if you want a shortcut env var for it.`, + loginHint: `Or run \`pi\` and type \`/login\` locally to authenticate '${parsed.provider}' via OAuth; credentials land in ~/.pi/agent/auth.json and are picked up automatically.`, + }, + 'pi.auth_missing' ); } @@ -343,13 +361,11 @@ export class PiProvider implements IAgentProvider { }; } - // ModelRegistry + settings stay in-memory — only sessions persist, to - // match Claude/Codex. Resource loader still suppresses filesystem - // discovery by default, except for explicitly-passed skill paths and — - // when piConfig.enableExtensions is true — Pi's community extension - // ecosystem (tools + lifecycle hooks from ~/.pi/agent/extensions/ and - // packages installed via `pi install npm:`). - const modelRegistry = piCodingAgent.ModelRegistry.inMemory(authStorage); + // Settings stay in-memory — only sessions persist, to match Claude/Codex. + // Resource loader still suppresses filesystem except for explicitly-passed + // skill paths and — when piConfig.enableExtensions is true — Pi's community + // extension ecosystem (tools + lifecycle hooks from ~/.pi/agent/extensions/ + // and packages installed via `pi install npm:`). const settingsManager = piCodingAgent.SettingsManager.inMemory(); // Default ON: extensions (community packages like @plannotator/pi-extension // or your own local ones) are a core reason users run Pi. Opt out with