Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 `<pi-provider-id>/<model-id>`:

```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)

Expand Down
135 changes: 113 additions & 22 deletions packages/providers/src/community/pi/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 () => {
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading