@victor-software-house/pi-openai-proxy 0.0.3 → 0.1.1

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 CHANGED
@@ -1,12 +1,6 @@
1
1
  # pi-openai-proxy
2
2
 
3
- A local OpenAI-compatible HTTP proxy built on [pi](https://github.com/badlogic/pi-mono)'s SDK. Routes requests through pi's multi-provider model registry and credential management, exposing a single `http://localhost:<port>/v1/...` endpoint that any OpenAI-compatible client can connect to.
4
-
5
- ## Project docs
6
-
7
- - `README.md` -- project overview and API surface
8
- - `ROADMAP.md` -- short phase summary and delivery order
9
- - `PLAN.md` -- detailed implementation contract (internal)
3
+ A local OpenAI-compatible HTTP proxy built on [pi](https://github.com/badlogic/pi-mono)'s SDK. Routes requests through pi's multi-provider model registry and credential management, exposing a single `http://localhost:4141/v1/...` endpoint that any OpenAI-compatible client can connect to.
10
4
 
11
5
  ## Why
12
6
 
@@ -14,70 +8,122 @@ A local OpenAI-compatible HTTP proxy built on [pi](https://github.com/badlogic/p
14
8
  - **No duplicate config** -- reuses pi's `~/.pi/agent/auth.json` and `models.json` for credentials and model definitions
15
9
  - **Self-hosted** -- runs locally, no third-party proxy services
16
10
  - **Streaming** -- full SSE streaming with token usage and cost tracking
17
- - **Agentic mode** (planned) -- expose pi's full agent loop (tools, sessions, compaction) behind a separate experimental endpoint
11
+ - **Strict validation** -- unsupported parameters are rejected clearly, not silently ignored
18
12
 
19
- ## Supported Endpoints
13
+ ## Prerequisites
20
14
 
21
- | Endpoint | Status | Description |
22
- |---|---|---|
23
- | `GET /v1/models` | Implemented | List all available models from pi's ModelRegistry |
24
- | `GET /v1/models/{model}` | Implemented | Model details for a canonical model ID (supports URL-encoded IDs with `/`) |
25
- | `POST /v1/chat/completions` | Implemented | Chat completions (streaming and non-streaming) |
15
+ 1. [pi](https://github.com/badlogic/pi-mono) must be installed
16
+ 2. At least one provider must be configured via `pi /login`
17
+ 3. [Bun](https://bun.sh) (for development) or [Node.js](https://nodejs.org) >= 20 (for production)
26
18
 
27
- ## Supported Chat Completions Features
19
+ ## Installation
28
20
 
29
- | Feature | Status | Notes |
30
- |---|---|---|
31
- | `model` | Implemented | Resolved via `ModelRegistry.find()`, canonical or shorthand |
32
- | `messages` (text) | Implemented | System, developer, user, assistant, tool messages |
33
- | `messages` (base64 images) | Implemented | Base64 data URI image content parts |
34
- | `messages` (remote images) | Rejected | Disabled by default; returns clear error |
35
- | `stream` | Implemented | SSE with `text_delta` / `toolcall_delta` mapping |
36
- | `temperature` | Implemented | Direct passthrough to `StreamOptions` |
37
- | `max_tokens` / `max_completion_tokens` | Implemented | Normalized to `StreamOptions.maxTokens` |
38
- | `stop` sequences | Implemented | Via `onPayload` passthrough |
39
- | `user` | Implemented | Via `onPayload` passthrough |
40
- | `stream_options.include_usage` | Implemented | Final usage chunk in SSE stream |
41
- | `tools` / `tool_choice` | Implemented | JSON Schema -> TypeBox conversion (supported subset) |
42
- | `tool_calls` in messages | Implemented | Assistant tool call + tool result roundtrip |
43
- | `reasoning_effort` | Implemented | Maps to pi's `ThinkingLevel` (`low`, `medium`, `high`) |
44
- | `response_format` | Implemented | `text` and `json_object` via `onPayload` passthrough |
45
- | `top_p` | Implemented | Via `onPayload` passthrough |
46
- | `frequency_penalty` | Implemented | Via `onPayload` passthrough |
47
- | `presence_penalty` | Implemented | Via `onPayload` passthrough |
48
- | `seed` | Implemented | Via `onPayload` passthrough |
49
- | `n > 1` | Not planned | Pi streams one completion at a time |
50
- | `logprobs` | Not planned | Not in pi-ai's abstraction layer |
21
+ ```bash
22
+ # Install globally
23
+ npm install -g @victor-software-house/pi-openai-proxy
51
24
 
52
- ## Architecture
25
+ # Or run directly with npx
26
+ npx @victor-software-house/pi-openai-proxy
27
+ ```
53
28
 
29
+ ## Quickstart
30
+
31
+ ```bash
32
+ # Start the proxy (defaults to http://127.0.0.1:4141)
33
+ pi-openai-proxy
54
34
  ```
55
- HTTP Client pi-openai-proxy
56
- (curl, Aider, Continue, +--------------------------+
57
- LiteLLM, Open WebUI, etc.) | |
58
- | | Hono HTTP Server |
59
- | POST /v1/chat/ | +-- Request parser |
60
- +--completions------>| +-- Message converter |
61
- | | +-- Model resolver |
62
- | GET /v1/models | +-- Tool converter |
63
- +------------------>| +-- SSE encoder |
64
- | | |
65
- | | Pi SDK |
66
- | SSE / JSON | +-- ModelRegistry |
67
- |<------------------+ +-- AuthStorage |
68
- | +-- streamSimple() |
69
- | +-- AgentSession (P4) |
70
- +--------------------------+
35
+
36
+ ### List available models
37
+
38
+ ```bash
39
+ curl http://localhost:4141/v1/models | jq '.data[].id'
71
40
  ```
72
41
 
73
- ### Pi SDK Layers Used
42
+ ### Chat completion (non-streaming)
74
43
 
75
- - **`@mariozechner/pi-ai`** -- `streamSimple()`, `completeSimple()`, `Model`, `Usage`, `AssistantMessageEvent`
76
- - **`@mariozechner/pi-coding-agent`** -- `ModelRegistry`, `AuthStorage`
44
+ ```bash
45
+ curl http://localhost:4141/v1/chat/completions \
46
+ -H "Content-Type: application/json" \
47
+ -d '{
48
+ "model": "anthropic/claude-sonnet-4-20250514",
49
+ "messages": [{"role": "user", "content": "Hello!"}]
50
+ }'
51
+ ```
52
+
53
+ ### Chat completion (streaming)
54
+
55
+ ```bash
56
+ curl http://localhost:4141/v1/chat/completions \
57
+ -H "Content-Type: application/json" \
58
+ -d '{
59
+ "model": "openai/gpt-4o",
60
+ "messages": [{"role": "user", "content": "Tell me a joke"}],
61
+ "stream": true
62
+ }'
63
+ ```
64
+
65
+ ### Use with any OpenAI-compatible client
66
+
67
+ Point any client that supports `OPENAI_API_BASE` (or equivalent) at `http://localhost:4141/v1`:
68
+
69
+ ```bash
70
+ # Example: Aider
71
+ OPENAI_API_BASE=http://localhost:4141/v1 aider --model anthropic/claude-sonnet-4-20250514
72
+
73
+ # Example: Continue (in settings.json)
74
+ # "apiBase": "http://localhost:4141/v1"
75
+
76
+ # Example: Open WebUI
77
+ # Set "OpenAI API Base URL" to http://localhost:4141/v1
78
+ ```
79
+
80
+ ### Shorthand model names
81
+
82
+ If a model ID is unique across providers, you can omit the provider prefix:
83
+
84
+ ```bash
85
+ curl http://localhost:4141/v1/chat/completions \
86
+ -H "Content-Type: application/json" \
87
+ -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hi"}]}'
88
+ ```
89
+
90
+ Ambiguous shorthand requests fail with a clear error listing the matching canonical IDs.
91
+
92
+ ## Supported Endpoints
93
+
94
+ | Endpoint | Description |
95
+ |---|---|
96
+ | `GET /v1/models` | List all available models (only those with configured credentials) |
97
+ | `GET /v1/models/{model}` | Model details by canonical ID (supports URL-encoded IDs with `/`) |
98
+ | `POST /v1/chat/completions` | Chat completions (streaming and non-streaming) |
99
+
100
+ ## Supported Chat Completions Features
101
+
102
+ | Feature | Notes |
103
+ |---|---|
104
+ | `model` | Canonical (`provider/model-id`) or unique shorthand |
105
+ | `messages` (text) | `system`, `developer`, `user`, `assistant`, `tool` roles |
106
+ | `messages` (base64 images) | Base64 data URI image content parts (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) |
107
+ | `stream` | SSE with `text_delta` and `toolcall_delta` mapping |
108
+ | `temperature` | Direct passthrough |
109
+ | `max_tokens` / `max_completion_tokens` | Normalized to `maxTokens` |
110
+ | `stop` | Via passthrough |
111
+ | `user` | Via passthrough |
112
+ | `stream_options.include_usage` | Final usage chunk in SSE stream |
113
+ | `tools` / `tool_choice` | JSON Schema -> TypeBox conversion (supported subset) |
114
+ | `tool_calls` in messages | Assistant tool call + tool result roundtrip |
115
+ | `reasoning_effort` | Maps to pi's `ThinkingLevel` (`low`, `medium`, `high`) |
116
+ | `response_format` | `text` and `json_object` via passthrough |
117
+ | `top_p` | Via passthrough |
118
+ | `frequency_penalty` | Via passthrough |
119
+ | `presence_penalty` | Via passthrough |
120
+ | `seed` | Via passthrough |
121
+
122
+ **Not supported:** `n > 1`, `logprobs`, `logit_bias`, remote image URLs (disabled by default).
77
123
 
78
124
  ## Model Naming
79
125
 
80
- Models are addressed as `provider/model-id`, matching pi's registry:
126
+ Models use the `provider/model-id` canonical format, matching pi's registry:
81
127
 
82
128
  ```
83
129
  anthropic/claude-sonnet-4-20250514
@@ -87,15 +133,12 @@ xai/grok-3
87
133
  openrouter/anthropic/claude-sonnet-4-20250514
88
134
  ```
89
135
 
90
- Shorthand (bare model ID) is resolved by scanning all providers for a unique match. Ambiguous shorthand requests fail with a clear error listing the matching canonical IDs.
91
-
92
136
  ## Configuration
93
137
 
94
- Uses pi's existing configuration:
138
+ The proxy reuses pi's existing configuration:
95
139
 
96
140
  - **API keys**: `~/.pi/agent/auth.json` (managed by `pi /login`)
97
141
  - **Custom models**: `~/.pi/agent/models.json`
98
- - **Per-request override**: `X-Pi-Upstream-Api-Key` header overrides the registry-resolved API key for a single request, keeping `Authorization` available for proxy authentication
99
142
 
100
143
  ### Environment Variables
101
144
 
@@ -104,11 +147,95 @@ Uses pi's existing configuration:
104
147
  | `PI_PROXY_HOST` | `127.0.0.1` | Bind address |
105
148
  | `PI_PROXY_PORT` | `4141` | Listen port |
106
149
  | `PI_PROXY_AUTH_TOKEN` | (disabled) | Bearer token for proxy authentication |
107
- | `PI_PROXY_AGENTIC` | `false` | Enable experimental agentic mode |
108
150
  | `PI_PROXY_REMOTE_IMAGES` | `false` | Enable remote image URL fetching |
109
151
  | `PI_PROXY_MAX_BODY_SIZE` | `52428800` (50 MB) | Maximum request body size in bytes |
110
152
  | `PI_PROXY_UPSTREAM_TIMEOUT_MS` | `120000` (120s) | Upstream request timeout in milliseconds |
111
153
 
154
+ ### Per-request API key override
155
+
156
+ The `X-Pi-Upstream-Api-Key` header overrides the registry-resolved API key for a single request. This keeps `Authorization` available for proxy authentication:
157
+
158
+ ```bash
159
+ curl http://localhost:4141/v1/chat/completions \
160
+ -H "Content-Type: application/json" \
161
+ -H "X-Pi-Upstream-Api-Key: sk-your-key-here" \
162
+ -d '{"model": "openai/gpt-4o", "messages": [{"role": "user", "content": "Hi"}]}'
163
+ ```
164
+
165
+ ### Proxy authentication
166
+
167
+ Set `PI_PROXY_AUTH_TOKEN` to require a bearer token for all requests:
168
+
169
+ ```bash
170
+ PI_PROXY_AUTH_TOKEN=my-secret-token pi-openai-proxy
171
+
172
+ # Clients must include the token
173
+ curl http://localhost:4141/v1/models \
174
+ -H "Authorization: Bearer my-secret-token"
175
+ ```
176
+
177
+ ## Pi Integration
178
+
179
+ Install as a pi package to get the `/proxy` command and `--proxy` flag inside pi sessions:
180
+
181
+ ```bash
182
+ pi install npm:@victor-software-house/pi-openai-proxy
183
+ ```
184
+
185
+ ### Start the proxy from inside pi
186
+
187
+ ```
188
+ /proxy start Start the proxy server
189
+ /proxy stop Stop the proxy server
190
+ /proxy status Show proxy status (default)
191
+ /proxy Show proxy status
192
+ ```
193
+
194
+ ### Auto-start with pi
195
+
196
+ ```bash
197
+ pi --proxy
198
+ ```
199
+
200
+ The proxy starts automatically on session start and stops when the session ends. A status indicator in the footer shows the proxy URL and model count.
201
+
202
+ The proxy can also run standalone (see [Installation](#installation) above). The extension detects externally running instances and shows their status without trying to manage them.
203
+
204
+ ## Architecture
205
+
206
+ ```
207
+ HTTP Client pi-openai-proxy
208
+ (curl, Aider, Continue, +--------------------------+
209
+ LiteLLM, Open WebUI, etc.) | |
210
+ | | Hono HTTP Server |
211
+ | POST /v1/chat/ | +-- Request parser |
212
+ +--completions------>| +-- Message converter |
213
+ | | +-- Model resolver |
214
+ | GET /v1/models | +-- Tool converter |
215
+ +------------------>| +-- SSE encoder |
216
+ | | |
217
+ | | Pi SDK |
218
+ | SSE / JSON | +-- ModelRegistry |
219
+ |<------------------+ +-- AuthStorage |
220
+ | +-- streamSimple() |
221
+ | +-- completeSimple() |
222
+ +--------------------------+
223
+ ```
224
+
225
+ ### Pi SDK layers used
226
+
227
+ - **`@mariozechner/pi-ai`** -- `streamSimple()`, `completeSimple()`, `Model`, `Usage`, `AssistantMessageEvent`
228
+ - **`@mariozechner/pi-coding-agent`** -- `ModelRegistry`, `AuthStorage`
229
+
230
+ ## Security defaults
231
+
232
+ - Binds to `127.0.0.1` (localhost only) by default
233
+ - Remote image URLs disabled by default
234
+ - Request body size limited to 50 MB
235
+ - Upstream timeout of 120 seconds
236
+ - Secrets are never included in error responses
237
+ - Client disconnects abort upstream work immediately
238
+
112
239
  ## Dev Workflow
113
240
 
114
241
  ```bash
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Pi extension: /proxy command and --proxy flag.
3
+ *
4
+ * Manages the pi-openai-proxy server from inside a pi session.
5
+ *
6
+ * - /proxy Show status
7
+ * - /proxy start Start the proxy server
8
+ * - /proxy stop Stop the proxy server (session-managed only)
9
+ * - /proxy status Show status
10
+ * - --proxy Auto-start on session start
11
+ */
12
+
13
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
14
+ import { spawn, type ChildProcess } from "node:child_process";
15
+ import { resolve, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ export default function proxyExtension(pi: ExtensionAPI) {
19
+ let proxyProcess: ChildProcess | undefined;
20
+
21
+ const host = process.env["PI_PROXY_HOST"] ?? "127.0.0.1";
22
+ const port = process.env["PI_PROXY_PORT"] ?? "4141";
23
+ const proxyUrl = `http://${host}:${port}`;
24
+
25
+ const extensionDir = dirname(fileURLToPath(import.meta.url));
26
+ const packageRoot = resolve(extensionDir, "..");
27
+ const proxyEntry = resolve(packageRoot, "dist", "index.mjs");
28
+
29
+ // --- Flag: --proxy ---
30
+
31
+ pi.registerFlag("proxy", {
32
+ description: "Start the OpenAI proxy on session start",
33
+ type: "boolean",
34
+ default: false,
35
+ });
36
+
37
+ // --- Lifecycle ---
38
+
39
+ pi.on("session_start", async (_event, ctx) => {
40
+ if (pi.getFlag("--proxy")) {
41
+ await startProxy(ctx);
42
+ } else {
43
+ await refreshStatus(ctx);
44
+ }
45
+ });
46
+
47
+ pi.on("session_shutdown", async () => {
48
+ killProxy();
49
+ });
50
+
51
+ // --- Command: /proxy ---
52
+
53
+ pi.registerCommand("proxy", {
54
+ description: "Manage the OpenAI-compatible proxy (start/stop/status)",
55
+ getArgumentCompletions: (prefix) => {
56
+ const subs = [
57
+ { value: "start", label: "Start the proxy server" },
58
+ { value: "stop", label: "Stop the proxy server" },
59
+ { value: "status", label: "Show proxy status" },
60
+ ];
61
+ if (prefix.length === 0) return subs;
62
+ return subs.filter((s) => s.value.startsWith(prefix));
63
+ },
64
+ handler: async (args, ctx) => {
65
+ const sub = args.trim().split(/\s+/)[0] ?? "";
66
+
67
+ switch (sub) {
68
+ case "":
69
+ case "status":
70
+ await showStatus(ctx);
71
+ break;
72
+ case "start":
73
+ await startProxy(ctx);
74
+ break;
75
+ case "stop":
76
+ await stopProxy(ctx);
77
+ break;
78
+ default:
79
+ ctx.ui.notify("/proxy [start|stop|status]", "info");
80
+ }
81
+ },
82
+ });
83
+
84
+ // --- Proxy management ---
85
+
86
+ async function probe(): Promise<{ reachable: boolean; models: number }> {
87
+ try {
88
+ const res = await fetch(`${proxyUrl}/v1/models`, {
89
+ signal: AbortSignal.timeout(2000),
90
+ });
91
+ if (res.ok) {
92
+ const body = (await res.json()) as { data?: unknown[] };
93
+ return { reachable: true, models: body.data?.length ?? 0 };
94
+ }
95
+ } catch {
96
+ // not reachable
97
+ }
98
+ return { reachable: false, models: 0 };
99
+ }
100
+
101
+ async function refreshStatus(ctx: ExtensionContext): Promise<void> {
102
+ const status = await probe();
103
+ if (status.reachable) {
104
+ ctx.ui.setStatus("proxy", `proxy: ${proxyUrl} (${String(status.models)} models)`);
105
+ } else if (proxyProcess !== undefined) {
106
+ ctx.ui.setStatus("proxy", "proxy: starting...");
107
+ } else {
108
+ ctx.ui.setStatus("proxy", undefined);
109
+ }
110
+ }
111
+
112
+ async function startProxy(ctx: ExtensionContext): Promise<void> {
113
+ const status = await probe();
114
+ if (status.reachable) {
115
+ ctx.ui.notify(
116
+ `Proxy already running at ${proxyUrl} (${String(status.models)} models)`,
117
+ "info",
118
+ );
119
+ await refreshStatus(ctx);
120
+ return;
121
+ }
122
+
123
+ if (proxyProcess !== undefined) {
124
+ ctx.ui.notify("Proxy is already starting...", "info");
125
+ return;
126
+ }
127
+
128
+ ctx.ui.setStatus("proxy", "proxy: starting...");
129
+
130
+ try {
131
+ proxyProcess = spawn("bun", ["run", proxyEntry], {
132
+ stdio: ["ignore", "pipe", "pipe"],
133
+ detached: false,
134
+ env: { ...process.env },
135
+ });
136
+
137
+ proxyProcess.on("exit", (code) => {
138
+ proxyProcess = undefined;
139
+ if (code !== null && code !== 0) {
140
+ ctx.ui.notify(`Proxy exited with code ${String(code)}`, "warning");
141
+ }
142
+ ctx.ui.setStatus("proxy", undefined);
143
+ });
144
+
145
+ proxyProcess.on("error", (err) => {
146
+ proxyProcess = undefined;
147
+ ctx.ui.notify(`Failed to start proxy: ${err.message}`, "error");
148
+ ctx.ui.setStatus("proxy", undefined);
149
+ });
150
+
151
+ // Wait for the server to become reachable
152
+ const ready = await waitForReady(3000);
153
+ if (ready.reachable) {
154
+ ctx.ui.notify(
155
+ `Proxy started at ${proxyUrl} (${String(ready.models)} models)`,
156
+ "info",
157
+ );
158
+ } else {
159
+ ctx.ui.notify(`Proxy spawned but not yet reachable at ${proxyUrl}`, "warning");
160
+ }
161
+ await refreshStatus(ctx);
162
+ } catch (err: unknown) {
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ ctx.ui.notify(`Failed to start proxy: ${message}`, "error");
165
+ ctx.ui.setStatus("proxy", undefined);
166
+ }
167
+ }
168
+
169
+ async function stopProxy(ctx: ExtensionContext): Promise<void> {
170
+ if (proxyProcess !== undefined) {
171
+ killProxy();
172
+ ctx.ui.notify("Proxy stopped", "info");
173
+ ctx.ui.setStatus("proxy", undefined);
174
+ return;
175
+ }
176
+
177
+ const status = await probe();
178
+ if (status.reachable) {
179
+ ctx.ui.notify(
180
+ `Proxy at ${proxyUrl} is running externally (not managed by this session)`,
181
+ "info",
182
+ );
183
+ } else {
184
+ ctx.ui.notify("Proxy is not running", "info");
185
+ }
186
+ }
187
+
188
+ async function showStatus(ctx: ExtensionContext): Promise<void> {
189
+ const status = await probe();
190
+ const managed = proxyProcess !== undefined ? " (managed)" : " (external)";
191
+
192
+ if (status.reachable) {
193
+ ctx.ui.notify(
194
+ `${proxyUrl}${managed} -- ${String(status.models)} models available`,
195
+ "info",
196
+ );
197
+ } else {
198
+ ctx.ui.notify("Proxy not running. Use /proxy start or pi --proxy", "info");
199
+ }
200
+ await refreshStatus(ctx);
201
+ }
202
+
203
+ function killProxy(): void {
204
+ if (proxyProcess !== undefined) {
205
+ proxyProcess.kill("SIGTERM");
206
+ proxyProcess = undefined;
207
+ }
208
+ }
209
+
210
+ async function waitForReady(
211
+ timeoutMs: number,
212
+ ): Promise<{ reachable: boolean; models: number }> {
213
+ const start = Date.now();
214
+ const interval = 300;
215
+ while (Date.now() - start < timeoutMs) {
216
+ const status = await probe();
217
+ if (status.reachable) return status;
218
+ await new Promise((r) => setTimeout(r, interval));
219
+ }
220
+ return { reachable: false, models: 0 };
221
+ }
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-openai-proxy",
3
- "version": "0.0.3",
3
+ "version": "0.1.1",
4
4
  "description": "Local OpenAI-compatible HTTP proxy built on pi's SDK",
5
5
  "license": "MIT",
6
6
  "author": "Victor Software House",
@@ -20,6 +20,11 @@
20
20
  "llm",
21
21
  "gateway"
22
22
  ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./extensions"
26
+ ]
27
+ },
23
28
  "engines": {
24
29
  "node": ">=20"
25
30
  },
@@ -29,7 +34,8 @@
29
34
  "pi-openai-proxy": "dist/index.mjs"
30
35
  },
31
36
  "files": [
32
- "dist"
37
+ "dist",
38
+ "extensions"
33
39
  ],
34
40
  "scripts": {
35
41
  "dev": "bun src/index.ts",