@billjr99/pi-openai-compat 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -0
- package/index.ts +377 -0
- package/package.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# @billjr99/pi-openai-compat — pi-coding-agent extension
|
|
2
|
+
|
|
3
|
+
Registers OpenAI-compatible LLM endpoints as first-class providers inside
|
|
4
|
+
[pi](https://pi.dev), so their models appear directly in pi's native `/model`
|
|
5
|
+
list and `Ctrl+L` picker alongside built-in Anthropic, OpenAI, and Google
|
|
6
|
+
models. No custom model selection UI — pi handles it natively.
|
|
7
|
+
|
|
8
|
+
Multiple providers can be registered at the same time. All of their models
|
|
9
|
+
appear together in `/model` under their own provider label.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Install from npm
|
|
17
|
+
pi install npm:@billjr99/pi-openai-compat
|
|
18
|
+
|
|
19
|
+
# Or from GitHub
|
|
20
|
+
pi install git:github.com/BillJr99/pi-openai-compat
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then inside pi:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
/compat-login
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Pick your provider, enter credentials, and pi fetches the model list
|
|
30
|
+
automatically. Your models are immediately available in `/model`.
|
|
31
|
+
|
|
32
|
+
If pi is already running when you install, type `/reload` first.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Supported providers
|
|
37
|
+
|
|
38
|
+
| Provider | Default base URL | Auth |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| **OpenRouter** | `https://openrouter.ai/api/v1` | `sk-or-...` from openrouter.ai/keys |
|
|
41
|
+
| **NVIDIA NIM** | `https://integrate.api.nvidia.com/v1` | `nvapi-...` from build.nvidia.com |
|
|
42
|
+
| **Nous Research Portal** | `https://inference-api.nousresearch.com/v1` | Nous Portal API key |
|
|
43
|
+
| **Ollama (local)** | `http://localhost:11434/v1` | Keyless |
|
|
44
|
+
| **Custom** | Any URL you supply | Optional bearer token |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Commands
|
|
49
|
+
|
|
50
|
+
Only two commands are needed.
|
|
51
|
+
|
|
52
|
+
### `/compat-login`
|
|
53
|
+
|
|
54
|
+
Walks you through a short wizard:
|
|
55
|
+
|
|
56
|
+
1. Select a provider from the list above (or choose Custom).
|
|
57
|
+
2. For Ollama and Custom, confirm or change the base URL.
|
|
58
|
+
3. Enter your API key (skipped for keyless providers like Ollama).
|
|
59
|
+
4. The extension connects, fetches the model list from `/v1/models`, and
|
|
60
|
+
registers the provider with pi.
|
|
61
|
+
|
|
62
|
+
After login, the provider's models appear in pi's `/model` command and
|
|
63
|
+
`Ctrl+L` picker immediately. You can run `/compat-login` again to add a
|
|
64
|
+
second provider — all providers are active simultaneously.
|
|
65
|
+
|
|
66
|
+
### `/compat-logout`
|
|
67
|
+
|
|
68
|
+
Unregisters a provider from pi. If you have multiple providers registered,
|
|
69
|
+
you are asked which one to remove. If you have only one, you are asked to
|
|
70
|
+
confirm.
|
|
71
|
+
|
|
72
|
+
When the last compat provider is removed, the extension restores whichever
|
|
73
|
+
built-in model you were using before you added any compat providers.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Typical workflow
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
/compat-login
|
|
81
|
+
→ pick Ollama
|
|
82
|
+
→ press Enter to accept http://localhost:11434/v1
|
|
83
|
+
→ 3 models added to /model
|
|
84
|
+
|
|
85
|
+
/model
|
|
86
|
+
→ scroll to ollama/gemma4:latest
|
|
87
|
+
→ select it
|
|
88
|
+
|
|
89
|
+
(start chatting)
|
|
90
|
+
|
|
91
|
+
/compat-logout
|
|
92
|
+
→ confirm remove Ollama
|
|
93
|
+
→ previous model restored automatically
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
To add OpenRouter alongside Ollama:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
/compat-login
|
|
100
|
+
→ pick OpenRouter
|
|
101
|
+
→ enter API key
|
|
102
|
+
→ 300+ models added to /model alongside your Ollama models
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## How it works
|
|
108
|
+
|
|
109
|
+
The extension uses pi's `registerProvider` API to register each configured
|
|
110
|
+
endpoint as a named provider. This is the same mechanism pi uses internally
|
|
111
|
+
for Anthropic, OpenAI, and Google. Registered providers appear in `/model`
|
|
112
|
+
and `Ctrl+L` with their own label, and pi handles all model selection,
|
|
113
|
+
routing, and request formatting natively.
|
|
114
|
+
|
|
115
|
+
On unregistration, pi's built-in `unregisterProvider` restores the original
|
|
116
|
+
model list automatically.
|
|
117
|
+
|
|
118
|
+
Model lists are cached in `config.json` at startup so no network call is
|
|
119
|
+
needed to re-register providers when pi restarts.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Config file
|
|
124
|
+
|
|
125
|
+
Credentials and cached model lists are stored at:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
~/.config/pi-openai-compat/config.json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
API keys are stored in plaintext. Protect the file with `chmod 600` if
|
|
132
|
+
needed, or delete it to clear all saved credentials.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Publishing to npmjs.com
|
|
137
|
+
|
|
138
|
+
### First-time setup
|
|
139
|
+
|
|
140
|
+
1. Register at https://www.npmjs.com/signup — use `billjr99` as your username
|
|
141
|
+
to match the `@billjr99/` package scope.
|
|
142
|
+
2. Verify your email address (required before publishing).
|
|
143
|
+
3. Enable two-factor authentication under your npm account settings.
|
|
144
|
+
4. Log in from the command line:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
npm login
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Publishing
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
cd /path/to/pi-openai-compat
|
|
154
|
+
npm publish --access public
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`--access public` is required for scoped packages on the first publish.
|
|
158
|
+
Subsequent publishes do not need it.
|
|
159
|
+
|
|
160
|
+
The package is immediately available at
|
|
161
|
+
https://www.npmjs.com/package/@billjr99/pi-openai-compat.
|
|
162
|
+
|
|
163
|
+
### Releasing a new version
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm version patch # 1.0.0 → 1.0.1 (bug fixes)
|
|
167
|
+
npm version minor # 1.0.0 → 1.1.0 (new features)
|
|
168
|
+
npm version major # 1.0.0 → 2.0.0 (breaking changes)
|
|
169
|
+
npm publish --access public
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`npm version` also creates a git tag, so GitHub gets release tags automatically.
|
|
173
|
+
|
|
174
|
+
### Installing from npm
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
pi install npm:@billjr99/pi-openai-compat # latest
|
|
178
|
+
pi install npm:@billjr99/pi-openai-compat@1.0.0 # pinned version
|
|
179
|
+
pi update npm:@billjr99/pi-openai-compat # update to latest
|
|
180
|
+
pi remove npm:@billjr99/pi-openai-compat # uninstall
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Troubleshooting
|
|
186
|
+
|
|
187
|
+
**Connection fails during `/compat-login`**
|
|
188
|
+
Verify the base URL does not have a trailing slash and ends with `/v1`.
|
|
189
|
+
Confirm the API key is correct and has model-access permissions.
|
|
190
|
+
For Ollama: ensure `ollama serve` is running.
|
|
191
|
+
|
|
192
|
+
**No models appear after login**
|
|
193
|
+
For Ollama: pull at least one model first (`ollama pull llama3`).
|
|
194
|
+
For OpenRouter: some keys are restricted to free-tier models only.
|
|
195
|
+
For NIM: confirm your account has inference access enabled.
|
|
196
|
+
|
|
197
|
+
**Models appear in `/model` but requests fail**
|
|
198
|
+
Check `/compat-login` ran successfully (no error message).
|
|
199
|
+
Verify Ollama is still running if using a local endpoint.
|
package/index.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @billjr99/pi-openai-compat — pi-coding-agent extension
|
|
3
|
+
*
|
|
4
|
+
* Registers OpenAI-compatible LLM endpoints as first-class pi providers so
|
|
5
|
+
* their models appear in pi's native /model list and Ctrl+L picker.
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Every provider saved in config.json is registered automatically at
|
|
9
|
+
* startup. The factory is async so pi waits for registration to complete
|
|
10
|
+
* before showing the model list — no session_start delay.
|
|
11
|
+
* - /compat-login adds a provider (fetches fresh model list, registers).
|
|
12
|
+
* - /compat-logout removes a provider (unregisters, restores previous model).
|
|
13
|
+
* - No activeProviders list — presence in config.providers means registered.
|
|
14
|
+
*
|
|
15
|
+
* Config: ~/.config/pi-openai-compat/config.json
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import * as os from "node:os";
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// Types
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface CachedModel {
|
|
28
|
+
id: string;
|
|
29
|
+
contextWindow?: number;
|
|
30
|
+
maxTokens?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ProviderConfig {
|
|
34
|
+
displayName: string;
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
apiKey: string | null;
|
|
37
|
+
cachedModels: CachedModel[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ExtensionConfig {
|
|
41
|
+
/** Last non-compat model; restored when the last compat provider is removed. */
|
|
42
|
+
previousModel: { provider: string; id: string } | null;
|
|
43
|
+
/** Every key here is a registered provider. Presence = registered. */
|
|
44
|
+
providers: Record<string, ProviderConfig>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface OpenAIModelsResponse {
|
|
48
|
+
data: Array<{ id: string; context_window?: number; max_tokens?: number }>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
52
|
+
// Provider templates
|
|
53
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const TEMPLATES: Record<string, {
|
|
56
|
+
displayName: string;
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
keyless: boolean;
|
|
59
|
+
keyHint?: string;
|
|
60
|
+
}> = {
|
|
61
|
+
openrouter: {
|
|
62
|
+
displayName: "OpenRouter",
|
|
63
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
64
|
+
keyless: false,
|
|
65
|
+
keyHint: "sk-or-... from openrouter.ai/keys",
|
|
66
|
+
},
|
|
67
|
+
nvidia_nim: {
|
|
68
|
+
displayName: "NVIDIA NIM",
|
|
69
|
+
baseUrl: "https://integrate.api.nvidia.com/v1",
|
|
70
|
+
keyless: false,
|
|
71
|
+
keyHint: "nvapi-... from build.nvidia.com",
|
|
72
|
+
},
|
|
73
|
+
nous: {
|
|
74
|
+
displayName: "Nous Research Portal",
|
|
75
|
+
baseUrl: "https://inference-api.nousresearch.com/v1",
|
|
76
|
+
keyless: false,
|
|
77
|
+
keyHint: "Your Nous Portal API key",
|
|
78
|
+
},
|
|
79
|
+
ollama: {
|
|
80
|
+
displayName: "Ollama (local, keyless)",
|
|
81
|
+
baseUrl: "http://localhost:11434/v1",
|
|
82
|
+
keyless: true,
|
|
83
|
+
},
|
|
84
|
+
custom: {
|
|
85
|
+
displayName: "Custom Endpoint",
|
|
86
|
+
baseUrl: "",
|
|
87
|
+
keyless: false,
|
|
88
|
+
keyHint: "Bearer token or API key (leave blank if keyless)",
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// Config
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "pi-openai-compat");
|
|
97
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
98
|
+
|
|
99
|
+
function loadConfig(): ExtensionConfig {
|
|
100
|
+
const empty: ExtensionConfig = { previousModel: null, providers: {} };
|
|
101
|
+
try {
|
|
102
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
103
|
+
if (!fs.existsSync(CONFIG_PATH)) return empty;
|
|
104
|
+
|
|
105
|
+
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Record<string, unknown>;
|
|
106
|
+
const config: ExtensionConfig = {
|
|
107
|
+
previousModel: (raw.previousModel as any) ?? null,
|
|
108
|
+
providers: (raw.providers as Record<string, ProviderConfig>) ?? {},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Migrate: cachedModels missing on older records.
|
|
112
|
+
for (const key of Object.keys(config.providers)) {
|
|
113
|
+
if (!Array.isArray(config.providers[key].cachedModels)) {
|
|
114
|
+
config.providers[key].cachedModels = [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return config;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error("[openai-compat:loadConfig]", e);
|
|
121
|
+
return empty;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function saveConfig(config: ExtensionConfig): void {
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
128
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error("[openai-compat:saveConfig]", e);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
// Networking
|
|
136
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async function fetchModels(baseUrl: string, apiKey: string | null): Promise<CachedModel[]> {
|
|
139
|
+
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
|
140
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
141
|
+
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
142
|
+
|
|
143
|
+
const resp = await fetch(url, { headers });
|
|
144
|
+
if (!resp.ok) {
|
|
145
|
+
const body = await resp.text().catch(() => "");
|
|
146
|
+
throw new Error(`HTTP ${resp.status} from ${url}: ${body}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const json = (await resp.json()) as OpenAIModelsResponse;
|
|
150
|
+
if (!Array.isArray(json?.data)) throw new Error(`Unexpected response from ${url}`);
|
|
151
|
+
|
|
152
|
+
return json.data
|
|
153
|
+
.map((m) => ({ id: m.id, contextWindow: m.context_window, maxTokens: m.max_tokens }))
|
|
154
|
+
.filter((m) => Boolean(m.id))
|
|
155
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
// Provider registration helpers
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function buildProviderModels(models: CachedModel[]) {
|
|
163
|
+
return models.map((m) => ({
|
|
164
|
+
id: m.id,
|
|
165
|
+
name: m.id,
|
|
166
|
+
reasoning: false,
|
|
167
|
+
input: ["text"] as string[],
|
|
168
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
169
|
+
contextWindow: m.contextWindow ?? 128_000,
|
|
170
|
+
maxTokens: m.maxTokens ?? 4_096,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function compatKey(key: string): string {
|
|
175
|
+
return `compat-${key}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function registerProvider(pi: ExtensionAPI, key: string, p: ProviderConfig): void {
|
|
179
|
+
pi.registerProvider(compatKey(key), {
|
|
180
|
+
name: p.displayName,
|
|
181
|
+
baseUrl: p.baseUrl,
|
|
182
|
+
apiKey: p.apiKey ?? "ollama",
|
|
183
|
+
api: "openai-completions" as const,
|
|
184
|
+
models: buildProviderModels(p.cachedModels),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
// Extension — async factory so registration completes before pi shows /model
|
|
190
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
export default async function (pi: ExtensionAPI) {
|
|
193
|
+
let config = loadConfig();
|
|
194
|
+
|
|
195
|
+
// Register all saved providers immediately using cached model lists.
|
|
196
|
+
// The factory is async, so pi waits for this to finish before startup
|
|
197
|
+
// continues — providers are visible in /model from the very first render.
|
|
198
|
+
for (const [key, p] of Object.entries(config.providers)) {
|
|
199
|
+
if (p.cachedModels.length > 0) {
|
|
200
|
+
registerProvider(pi, key, p);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── session_start ──────────────────────────────────────────────────────────
|
|
205
|
+
// Reload config in case it changed (e.g. edited by hand) and re-register
|
|
206
|
+
// any providers whose cache was empty at factory time.
|
|
207
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
208
|
+
config = loadConfig();
|
|
209
|
+
|
|
210
|
+
const registered: string[] = [];
|
|
211
|
+
const failed: string[] = [];
|
|
212
|
+
|
|
213
|
+
for (const [key, p] of Object.entries(config.providers)) {
|
|
214
|
+
if (p.cachedModels.length > 0) {
|
|
215
|
+
// Use cached list — fast, no network call.
|
|
216
|
+
registerProvider(pi, key, p);
|
|
217
|
+
registered.push(p.displayName);
|
|
218
|
+
} else {
|
|
219
|
+
// Cache is empty (e.g. migrated from older config). Try a live fetch.
|
|
220
|
+
try {
|
|
221
|
+
const models = await fetchModels(p.baseUrl, p.apiKey);
|
|
222
|
+
if (models.length > 0) {
|
|
223
|
+
p.cachedModels = models;
|
|
224
|
+
saveConfig(config);
|
|
225
|
+
registerProvider(pi, key, p);
|
|
226
|
+
registered.push(`${p.displayName} (refreshed)`);
|
|
227
|
+
} else {
|
|
228
|
+
failed.push(p.displayName);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
failed.push(p.displayName);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (registered.length > 0) {
|
|
237
|
+
ctx.ui.notify(`OpenAI-compat: ${registered.join(", ")} available in /model`, "info");
|
|
238
|
+
}
|
|
239
|
+
if (failed.length > 0) {
|
|
240
|
+
ctx.ui.notify(
|
|
241
|
+
`OpenAI-compat: could not reach ${failed.join(", ")} — run /compat-login to refresh.`,
|
|
242
|
+
"warning"
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── model_select ───────────────────────────────────────────────────────────
|
|
248
|
+
// Track the last non-compat model for logout restoration.
|
|
249
|
+
pi.on("model_select", async (event, _ctx) => {
|
|
250
|
+
const compatProviderKeys = Object.keys(config.providers).map(compatKey);
|
|
251
|
+
if (!compatProviderKeys.includes(event.model.provider)) {
|
|
252
|
+
config.previousModel = { provider: event.model.provider, id: event.model.id };
|
|
253
|
+
saveConfig(config);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ── /compat-login ──────────────────────────────────────────────────────────
|
|
258
|
+
pi.registerCommand("compat-login", {
|
|
259
|
+
description: "Fetch models from an OpenAI-compatible endpoint and add it to /model",
|
|
260
|
+
handler: async (args, ctx) => {
|
|
261
|
+
// Step 1 — pick provider template
|
|
262
|
+
const keys = Object.keys(TEMPLATES);
|
|
263
|
+
const labels = keys.map((k) => TEMPLATES[k].displayName);
|
|
264
|
+
const selectedLabel = await ctx.ui.select("Select Provider", labels);
|
|
265
|
+
if (!selectedLabel) { ctx.ui.notify("Login cancelled.", "info"); return; }
|
|
266
|
+
const key = keys[labels.indexOf(selectedLabel)];
|
|
267
|
+
const tpl = TEMPLATES[key];
|
|
268
|
+
|
|
269
|
+
// Step 2 — base URL (prompted for ollama / custom)
|
|
270
|
+
let baseUrl = tpl.baseUrl;
|
|
271
|
+
if (key === "ollama" || key === "custom") {
|
|
272
|
+
const defaultUrl = tpl.baseUrl;
|
|
273
|
+
const entered = await ctx.ui.input(
|
|
274
|
+
"Base URL",
|
|
275
|
+
key === "ollama"
|
|
276
|
+
? `Ollama base URL — press Enter for default (${defaultUrl}):`
|
|
277
|
+
: "Base URL of your endpoint (e.g. https://api.example.com/v1):",
|
|
278
|
+
defaultUrl
|
|
279
|
+
);
|
|
280
|
+
if (entered === null) { ctx.ui.notify("Login cancelled.", "info"); return; }
|
|
281
|
+
baseUrl = (entered.trim() || defaultUrl).replace(/\/+$/, "");
|
|
282
|
+
if (!baseUrl) { ctx.ui.notify("Base URL cannot be empty.", "error"); return; }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Step 3 — API key
|
|
286
|
+
let apiKey: string | null = null;
|
|
287
|
+
if (!tpl.keyless) {
|
|
288
|
+
const entered = await ctx.ui.input(
|
|
289
|
+
"API Key",
|
|
290
|
+
`${tpl.keyHint ?? "Your API key"} — leave blank if keyless:`,
|
|
291
|
+
""
|
|
292
|
+
);
|
|
293
|
+
if (entered === null) { ctx.ui.notify("Login cancelled.", "info"); return; }
|
|
294
|
+
apiKey = entered.trim() || null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Step 4 — fetch fresh model list
|
|
298
|
+
ctx.ui.notify(`Connecting to ${baseUrl} …`, "info");
|
|
299
|
+
let models: CachedModel[];
|
|
300
|
+
try {
|
|
301
|
+
models = await fetchModels(baseUrl, apiKey);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
ctx.ui.notify(`Connection failed — not saved.\n${err}`, "error");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!models.length) {
|
|
307
|
+
ctx.ui.notify("Connected but no models returned. Check URL and key.", "error");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Step 5 — save to config and register with pi
|
|
312
|
+
config.providers[key] = {
|
|
313
|
+
displayName: tpl.displayName,
|
|
314
|
+
baseUrl,
|
|
315
|
+
apiKey,
|
|
316
|
+
cachedModels: models,
|
|
317
|
+
};
|
|
318
|
+
saveConfig(config);
|
|
319
|
+
registerProvider(pi, key, config.providers[key]);
|
|
320
|
+
|
|
321
|
+
ctx.ui.notify(
|
|
322
|
+
`${tpl.displayName} registered — ${models.length} model(s) added to /model.`,
|
|
323
|
+
"success"
|
|
324
|
+
);
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// ── /compat-logout ─────────────────────────────────────────────────────────
|
|
329
|
+
pi.registerCommand("compat-logout", {
|
|
330
|
+
description: "Remove an OpenAI-compatible provider from pi's model list",
|
|
331
|
+
handler: async (_args, ctx) => {
|
|
332
|
+
const providerKeys = Object.keys(config.providers);
|
|
333
|
+
if (!providerKeys.length) {
|
|
334
|
+
ctx.ui.notify("No compat providers are registered.", "info");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// If only one provider, confirm directly; otherwise ask which to remove.
|
|
339
|
+
let key: string;
|
|
340
|
+
if (providerKeys.length === 1) {
|
|
341
|
+
key = providerKeys[0];
|
|
342
|
+
const name = config.providers[key].displayName;
|
|
343
|
+
const ok = await ctx.ui.confirm("Remove Provider", `Unregister "${name}"?`);
|
|
344
|
+
if (!ok) { ctx.ui.notify("Cancelled.", "info"); return; }
|
|
345
|
+
} else {
|
|
346
|
+
const labels = providerKeys.map((k) => config.providers[k].displayName);
|
|
347
|
+
const chosen = await ctx.ui.select("Remove which provider?", labels);
|
|
348
|
+
if (!chosen) { ctx.ui.notify("Cancelled.", "info"); return; }
|
|
349
|
+
key = providerKeys[labels.indexOf(chosen)];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const name = config.providers[key].displayName;
|
|
353
|
+
pi.unregisterProvider(compatKey(key));
|
|
354
|
+
delete config.providers[key];
|
|
355
|
+
saveConfig(config);
|
|
356
|
+
|
|
357
|
+
// If no compat providers remain, restore the previous model.
|
|
358
|
+
const isLast = Object.keys(config.providers).length === 0;
|
|
359
|
+
if (isLast && config.previousModel) {
|
|
360
|
+
const prev = config.previousModel;
|
|
361
|
+
try {
|
|
362
|
+
await (pi as any).setModel({ provider: prev.provider, id: prev.id });
|
|
363
|
+
ctx.ui.notify(`"${name}" removed. Restored ${prev.provider}/${prev.id}.`, "info");
|
|
364
|
+
} catch {
|
|
365
|
+
ctx.ui.notify(`"${name}" removed. Use /model to pick a model.`, "info");
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
ctx.ui.notify(
|
|
369
|
+
isLast
|
|
370
|
+
? `"${name}" removed. Use /model to pick a model.`
|
|
371
|
+
: `"${name}" removed.`,
|
|
372
|
+
"info"
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@billjr99/pi-openai-compat",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "pi-coding-agent extension: OpenAI-compatible endpoint support (OpenRouter, NVIDIA NIM, Nous Portal, Ollama, custom)",
|
|
5
|
+
"author": "Bill Mongan <https://github.com/BillJr99>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/BillJr99/pi-openai-compat#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/BillJr99/pi-openai-compat.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/BillJr99/pi-openai-compat/issues"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.ts",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": ["./index.ts"]
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {}
|
|
23
|
+
}
|