Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ Templates:

### Added

- Read the API key from pi's `auth.json` (`explabs` entry), supporting literals, `$ENV_VAR` references and `!command` secret lookups, with `EXPLABS_API_KEY` as a fallback

### Changed

- `/login explabs` now points at `auth.json` and `EXPLABS_API_KEY`

### Fixed

### Removed
Expand Down
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- 🔍 **Dynamic model discovery** — models are fetched live from the Experiential Labs API
- 🔄 **Auto-refresh** — model list updates when you open `/model` in pi
- 🛠️ **Zero configuration** — just set your API key and go
- 🔑 **Flexible credentials** — read your key from pi's `auth.json`, or set an environment variable
- 🔌 **Multiple API formats** — supports Chat Completions, Responses, and Anthropic Messages APIs

## Set up
Expand All @@ -27,12 +28,40 @@ pi install npm:@joeljuca/pi-explabs

You'll need an API key to authenticate. Get one at [experientiallabs.ai](https://experientiallabs.ai).

Set an environment variable `EXPLABS_API_KEY` with your API key, somehow. I use and recommend [direnv](https://direnv.net), but any method will do.
### Option 1: pi's credential store (recommended)

Add an `explabs` entry to your `auth.json` (usually `~/.pi/agent/auth.json`). If you keep secrets in a password manager, point `key` at a command prefixed with `!` and the key is resolved on demand instead of being written to disk in plain text:

```json
{
"explabs": {
"type": "api_key",
"key": "<Your API key here>"
}
}
```

```json
{
"explabs": {
"type": "api_key",
"key": "!secret-tool lookup service explabs"
}
}
```

Values may also reference an environment variable, like `$EXPLABS_API_KEY`.

### Option 2: environment variable (fallback)

When `auth.json` has no usable `explabs` entry, the extension falls back to the `EXPLABS_API_KEY` environment variable. I use and recommend [direnv](https://direnv.net), but any method will do.

```sh
EXPLABS_API_KEY='<Your API key here>'
```

`auth.json` wins when both are set.

You're ready. Run pi normally, the extension should be automatically:

```
Expand All @@ -46,13 +75,22 @@ Usage is mostly automatic. The extension will make your pi integrate seamlessly
Here's what the extenstion will do:

1. It registers `explabs` as a model provider in pi
1. It resolves your API key from `auth.json`, falling back to `EXPLABS_API_KEY`
1. Fetches a fresh list of available models from Experiential Labs when you open pi
1. Refresh the list of models when you `/model` or `/reload`
1. **API fetch**: The callback fetches models from the Experiential Labs `/v1/models` endpoint

> 💡 Troubleshoot
>
> If something is not working properly, you're not seeing Experiential Labs' models when you `/model`, etc., the first place to check is your API key:
> If something is not working properly, you're not seeing Experiential Labs' models when you `/model`, etc., the first place to check is your API key. Run pi with `DEBUG=pi-explabs` to see which source was used (`auth.json`, `env`, or `none`) — the key itself is never logged.
>
> If you're using `auth.json`, check the entry:
>
> ```sh
> cat ~/.pi/agent/auth.json
> ```
>
> If you're relying on the environment variable:
>
> ```sh
> echo $EXPLABS_API_KEY
Expand Down
232 changes: 232 additions & 0 deletions src/api-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* Tests for API key resolution from auth.json and the environment
*
* @vitest-environment node
*/

import { mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
PROVIDER_ID,
readAuthJsonApiKey,
resolveApiKey,
resolveCredentialValue,
} from "./api-key.js";

let dir: string;
let authPath: string;

function writeAuth(contents: string) {
writeFileSync(authPath, contents, { encoding: "utf-8", mode: 0o600 });
}

function missingPath() {
return join(dir, "does-not-exist.json");
}

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "pi-explabs-auth-"));
authPath = join(dir, "auth.json");
delete process.env.EXPLABS_API_KEY;
delete process.env.MY_CUSTOM_KEY;
delete process.env.EXPLABS_TEST_SECRET;
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
delete process.env.EXPLABS_API_KEY;
delete process.env.MY_CUSTOM_KEY;
delete process.env.EXPLABS_TEST_SECRET;
});

describe("readAuthJsonApiKey", () => {
it("returns the key for the explabs entry", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "xpl_abc" } }));

expect(readAuthJsonApiKey(authPath)).toBe("xpl_abc");
});

it("returns a !command key unresolved", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "!echo xpl_abc" } }));

expect(readAuthJsonApiKey(authPath)).toBe("!echo xpl_abc");
});

it("returns undefined for a missing file", () => {
expect(readAuthJsonApiKey(missingPath())).toBeUndefined();
});

it("returns undefined for invalid JSON", () => {
writeAuth("{ not json");

expect(readAuthJsonApiKey(authPath)).toBeUndefined();
});

it("returns undefined when the explabs entry is missing", () => {
writeAuth(JSON.stringify({ openrouter: { type: "api_key", key: "sk-or" } }));

expect(readAuthJsonApiKey(authPath)).toBeUndefined();
});

it("returns undefined for a non api_key credential", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "oauth", access: "token" } }));

expect(readAuthJsonApiKey(authPath)).toBeUndefined();
});

it("returns undefined for an empty key", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: " " } }));

expect(readAuthJsonApiKey(authPath)).toBeUndefined();
});

it("tolerates a BOM", () => {
writeAuth(`${JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "xpl_abc" } })}`);

expect(readAuthJsonApiKey(authPath)).toBe("xpl_abc");
});
});

describe("resolveCredentialValue", () => {
it("returns literals as-is", () => {
expect(resolveCredentialValue("xpl_abc")).toBe("xpl_abc");
});

it("trims whitespace", () => {
expect(resolveCredentialValue(" xpl_abc\n")).toBe("xpl_abc");
});

it("returns undefined for an empty value", () => {
expect(resolveCredentialValue(" ")).toBeUndefined();
});

it("runs a !command", () => {
expect(resolveCredentialValue("!printf 'xpl_from_command\\n'")).toBe("xpl_from_command");
});

it("returns undefined when a !command fails", () => {
expect(resolveCredentialValue("!exit 1")).toBeUndefined();
});

it("returns undefined when a !command prints nothing", () => {
expect(resolveCredentialValue("!true")).toBeUndefined();
});

it("interpolates $VAR", () => {
process.env.EXPLABS_TEST_SECRET = "xpl_env";

expect(resolveCredentialValue("$EXPLABS_TEST_SECRET")).toBe("xpl_env");
});

it("interpolates ${VAR}", () => {
process.env.EXPLABS_TEST_SECRET = "xpl_env";

expect(resolveCredentialValue("${EXPLABS_TEST_SECRET}")).toBe("xpl_env");
});

it("returns undefined when the referenced variable is unset", () => {
expect(resolveCredentialValue("$EXPLABS_TEST_SECRET")).toBeUndefined();
});

it("unescapes $$ and $!", () => {
expect(resolveCredentialValue("$$literal")).toBe("$literal");
expect(resolveCredentialValue("$!literal")).toBe("!literal");
});
});

describe("resolveApiKey", () => {
it("prefers the auth.json credential", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "xpl_from_auth" } }));
process.env.EXPLABS_API_KEY = "xpl_from_env";

expect(resolveApiKey({ authPath })).toEqual({ value: "xpl_from_auth", source: "auth.json" });
});

it("resolves an auth.json !command", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "!printf 'xpl_cmd\\n'" } }));

expect(resolveApiKey({ authPath })).toEqual({ value: "xpl_cmd", source: "auth.json" });
});

it("returns the unresolved value when resolve is false", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "!printf 'xpl_cmd\\n'" } }));

expect(resolveApiKey({ authPath, resolve: false })).toEqual({
value: "!printf 'xpl_cmd\\n'",
source: "auth.json",
});
});

it("falls back to EXPLABS_API_KEY when auth.json has no entry", () => {
writeAuth(JSON.stringify({ openrouter: { type: "api_key", key: "sk-or" } }));
process.env.EXPLABS_API_KEY = "xpl_from_env";

expect(resolveApiKey({ authPath })).toEqual({ value: "xpl_from_env", source: "env" });
});

it("falls back to EXPLABS_API_KEY when auth.json is missing", () => {
process.env.EXPLABS_API_KEY = "xpl_from_env";

expect(resolveApiKey({ authPath: missingPath() })).toEqual({
value: "xpl_from_env",
source: "env",
});
});

it("falls back to EXPLABS_API_KEY when auth.json cannot be read", () => {
writeAuth("{ not json");
process.env.EXPLABS_API_KEY = "xpl_from_env";

expect(resolveApiKey({ authPath })).toEqual({ value: "xpl_from_env", source: "env" });
});

it("reports no source when nothing is configured", () => {
expect(resolveApiKey({ authPath })).toEqual({ value: "", source: "none" });
});

it("returns none when an auth.json command cannot be resolved", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "!exit 1" } }));

expect(resolveApiKey({ authPath })).toEqual({ value: "", source: "none" });
});

it("prefers an explicit apiKey over everything else", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "xpl_from_auth" } }));
process.env.EXPLABS_API_KEY = "xpl_from_env";

expect(resolveApiKey({ apiKey: "xpl_from_config", authPath })).toEqual({
value: "xpl_from_config",
source: "config",
});
});

it("keeps auth.json when apiKeyEnv is set explicitly", () => {
writeAuth(JSON.stringify({ [PROVIDER_ID]: { type: "api_key", key: "xpl_from_auth" } }));
process.env.MY_CUSTOM_KEY = "xpl_custom";

expect(resolveApiKey({ apiKeyEnv: "MY_CUSTOM_KEY", authPath })).toEqual({
value: "xpl_from_auth",
source: "auth.json",
});
});

it("uses the apiKeyEnv variable when auth.json has no entry", () => {
process.env.MY_CUSTOM_KEY = "xpl_custom";

expect(resolveApiKey({ apiKeyEnv: "MY_CUSTOM_KEY", authPath })).toEqual({
value: "xpl_custom",
source: "env",
});
});

it("reports no source when apiKeyEnv is set but unset", () => {
process.env.MY_CUSTOM_KEY = "";

expect(resolveApiKey({ apiKeyEnv: "MY_CUSTOM_KEY", authPath })).toEqual({
value: "",
source: "none",
});
});
});
Loading