@openhoo/hoopilot 1.3.0 → 2.1.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 CHANGED
@@ -7,9 +7,29 @@ Hoopilot is a local OpenAI- and Anthropic-compatible proxy for GitHub Copilot ac
7
7
 
8
8
  This project uses GitHub Copilot service endpoints and is not an official GitHub product. Upstream behavior can change without notice. Use Hoopilot only with accounts and usage patterns you are allowed to use.
9
9
 
10
+ ## Contents
11
+
12
+ - [Highlights](#highlights)
13
+ - [Requirements](#requirements)
14
+ - [Quick start](#quick-start)
15
+ - [Install](#install)
16
+ - [Update](#update)
17
+ - [Running the proxy](#running-the-proxy)
18
+ - [Client setup](#client-setup)
19
+ - [Authentication](#authentication)
20
+ - [Logging](#logging)
21
+ - [Metrics and usage](#metrics-and-usage)
22
+ - [Dashboard](#dashboard)
23
+ - [Troubleshooting](#troubleshooting)
24
+ - [Configuration](#configuration)
25
+ - [CLI reference](#cli-reference)
26
+ - [Endpoints](#endpoints)
27
+ - [Development](#development)
28
+ - [Release](#release)
29
+
10
30
  ## Highlights
11
31
 
12
- - Browser-based GitHub Copilot OAuth login with a local credential store.
32
+ - GitHub Copilot OAuth login via GitHub's device flow — prints a one-time code and opens your browser when possible — with a local credential store.
13
33
  - OpenAI-compatible Chat Completions, Responses, legacy Completions, and model-list routes.
14
34
  - Anthropic Messages compatibility for Claude Code and other Anthropic-style clients.
15
35
  - Bundled `codexx` launcher that runs Codex against a local Hoopilot server with the right Responses API provider settings.
@@ -17,6 +37,12 @@ This project uses GitHub Copilot service endpoints and is not an official GitHub
17
37
  - Self-contained live dashboard at `/dashboard` showing usage and status metrics in real time.
18
38
  - npm package, standalone binaries, Docker image, and self-update support for release binaries.
19
39
 
40
+ ## Requirements
41
+
42
+ - A GitHub account with an active GitHub Copilot subscription.
43
+ - [Bun](https://bun.sh) 1.3 or newer to run from npm or source — the CLI runs on the Bun runtime, so `bun` must be on your `PATH` (this applies to `npx @openhoo/hoopilot` too).
44
+ - Nothing extra for the standalone binary or Docker image: both bundle the runtime, so neither needs Bun or Node.js installed.
45
+
20
46
  ## Quick start
21
47
 
22
48
  Sign in once, then start the proxy on localhost:
@@ -55,6 +81,8 @@ npx --package @openhoo/hoopilot codexx
55
81
 
56
82
  ## Install
57
83
 
84
+ Choose the method that fits your environment: **npm** if you already have Bun, a **standalone binary** for a dependency-free install, or **Docker** to run Hoopilot as a long-lived service.
85
+
58
86
  ### npm
59
87
 
60
88
  Run without installing:
@@ -63,13 +91,16 @@ Run without installing:
63
91
  npx @openhoo/hoopilot
64
92
  ```
65
93
 
66
- Or install the package globally:
94
+ Or install the package globally with either npm or Bun:
67
95
 
68
96
  ```sh
69
97
  npm install -g @openhoo/hoopilot
98
+ # or
70
99
  bun add -g @openhoo/hoopilot
71
100
  ```
72
101
 
102
+ The package is published **ESM-only**: the CLIs (`hoopilot`, `codexx`) and the library entry (`import { startHoopilotServer } from "@openhoo/hoopilot"`) both require an ESM loader. There is no CommonJS (`require()`) entry — it was dropped because Hoopilot is a Bun/ESM-native tool, not because any dependency forced it.
103
+
73
104
  ### Standalone binary
74
105
 
75
106
  When npm is unavailable but GitHub releases are reachable, install a prebuilt self-contained binary. Node.js and Bun are not required to run the binary.
@@ -100,32 +131,45 @@ The standalone installer also installs a `codexx` wrapper next to `hoopilot`. Re
100
131
 
101
132
  ### Docker
102
133
 
103
- Run Hoopilot as a long-lived service from the published multi-arch image on the GitHub Container Registry (`linux/amd64` and `linux/arm64`):
134
+ Run Hoopilot as a long-lived service from the published multi-arch image on the GitHub Container Registry (`linux/amd64` and `linux/arm64`). The commands below are the same on Windows (PowerShell or `cmd`), macOS, and Linux — only the shell prompt differs.
135
+
136
+ #### Keyless local quick start
137
+
138
+ Published on loopback (`127.0.0.1`) only, the proxy is unreachable from other hosts, so no client API key is needed. Three commands — nothing to export, no keys to generate or keep in sync:
104
139
 
105
140
  ```sh
106
- # 1. Sign in once; the OAuth credential is written to the persisted /data volume.
141
+ # 1. Sign in once; the OAuth credential persists in the named volume.
107
142
  docker run --rm -it -v hoopilot-data:/data ghcr.io/openhoo/hoopilot login
108
143
 
109
- # 2. Run the proxy on localhost with a strong, unique API key.
110
- export HOOPILOT_API_KEY=$(openssl rand -hex 24)
111
- docker run -d --name hoopilot --restart unless-stopped \
112
- -p 127.0.0.1:4141:4141 \
113
- -e HOOPILOT_API_KEY \
114
- -v hoopilot-data:/data ghcr.io/openhoo/hoopilot
144
+ # 2. Start the proxy: loopback-only, no key.
145
+ docker run -d --name hoopilot --restart unless-stopped -p 127.0.0.1:4141:4141 -e HOOPILOT_ALLOW_UNAUTHENTICATED=1 -v hoopilot-data:/data ghcr.io/openhoo/hoopilot
146
+
147
+ # 3. Run Codex through it from any directory — no key, no setup.
148
+ npx --package @openhoo/hoopilot codexx --yolo
115
149
  ```
116
150
 
117
- Tags follow the release version, for example `ghcr.io/openhoo/hoopilot:0.10`, `:0.10.0`, and `:latest`. The image listens on `0.0.0.0:4141` (required so Docker port publishing can reach it), runs as a non-root user, and stores its OAuth credential at `/data/auth.json` by default. Override that path with `HOOPILOT_AUTH_FILE`.
151
+ Every line is a single command that pastes as-is into PowerShell, `cmd`, bash, or zsh. Step 3 needs the `codex` CLI on your `PATH`; `codexx` defaults to `gpt-5.5` and sends a throwaway key that the keyless proxy accepts (Bun users can swap `npx --package @openhoo/hoopilot` for `bunx`).
118
152
 
119
- Because it binds a non-loopback interface, the image fails closed: it refuses to start unless you set `HOOPILOT_API_KEY` to a strong, unique secret (well-known demo keys are rejected). Clients then send that key as `Authorization: Bearer <key>` or `x-api-key: <key>`. To intentionally run without authentication for example behind your own authenticating proxy — set `HOOPILOT_ALLOW_UNAUTHENTICATED=1`.
153
+ Or, from a clone of this repo, use the bundled `docker-compose.yml` — it is keyless on loopback by default:
120
154
 
121
- A `docker-compose.yml` is provided. Set `HOOPILOT_API_KEY` first; compose passes it through to the container:
155
+ ```sh
156
+ docker compose run --rm hoopilot login # one-time
157
+ docker compose up -d # keyless on loopback
158
+ ```
159
+
160
+ Tags follow the release version, for example `ghcr.io/openhoo/hoopilot:1.3`, `:1.3.0`, and `:latest`. The image listens on `0.0.0.0:4141` (required so Docker port publishing can reach it), runs as a non-root user, and stores its OAuth credential at `/data/auth.json` by default. Override that path with `HOOPILOT_AUTH_FILE`.
161
+
162
+ #### Exposing the proxy beyond loopback
163
+
164
+ The image binds `0.0.0.0` and cannot tell whether the published port is loopback-only, so it fails closed: drop the `-e HOOPILOT_ALLOW_UNAUTHENTICATED=1` opt-in (or map the port to a non-loopback interface) and it refuses to start without a strong, unique `HOOPILOT_API_KEY` (well-known demo keys are rejected). Clients then send that key as `Authorization: Bearer <key>` or `x-api-key: <key>`:
122
165
 
123
166
  ```sh
124
- docker compose run --rm hoopilot login
125
167
  export HOOPILOT_API_KEY=$(openssl rand -hex 24)
126
- docker compose up -d
168
+ docker run -d --name hoopilot --restart unless-stopped -p 4141:4141 -e HOOPILOT_API_KEY -v hoopilot-data:/data ghcr.io/openhoo/hoopilot
127
169
  ```
128
170
 
171
+ With compose, a set `HOOPILOT_API_KEY` takes precedence over the keyless default: `export HOOPILOT_API_KEY=$(openssl rand -hex 24)` then `docker compose up -d`. To run unauthenticated on a non-loopback bind anyway — for example behind your own authenticating proxy — keep `HOOPILOT_ALLOW_UNAUTHENTICATED=1`. Point `codexx` at a keyed server by exporting the same `HOOPILOT_API_KEY` (or `CODEXX_API_KEY`) in its environment.
172
+
129
173
  ## Update
130
174
 
131
175
  Standalone binaries update themselves in place from the latest GitHub release:
@@ -172,9 +216,7 @@ export OPENAI_BASE_URL=http://127.0.0.1:4141/v1
172
216
  export OPENAI_API_KEY=hoopilot
173
217
  ```
174
218
 
175
- The client key value is arbitrary when the server runs without `HOOPILOT_API_KEY`; if you set one, use that value here instead.
176
-
177
- Use any model returned by:
219
+ The client key is arbitrary unless you set `HOOPILOT_API_KEY` (see [Running the proxy](#running-the-proxy)). Available models depend on your Copilot plan; list the ones your account can use with:
178
220
 
179
221
  ```sh
180
222
  hoopilot models
@@ -204,7 +246,7 @@ Without a global install:
204
246
  npx --package @openhoo/hoopilot codexx
205
247
  ```
206
248
 
207
- If the server requires an API key, set `HOOPILOT_API_KEY` (or `CODEXX_API_KEY`) in the `codexx` environment to match.
249
+ With the [keyless Docker quick start](#keyless-local-quick-start), no key is involved: `codexx --yolo` works from any directory because `codexx` sends a throwaway key that the loopback-only proxy accepts. If the server *does* require an API key, set `HOOPILOT_API_KEY` (or `CODEXX_API_KEY`) in the `codexx` environment to match.
208
250
 
209
251
  `codexx` does not start Hoopilot and does not alter your shell environment. It starts `codex` with a temporary `hoopilot` model provider pointed at `http://127.0.0.1:4141/v1`, uses the Responses API wire format, disables Responses WebSockets for that provider, maps `HOOPILOT_API_KEY` (or a random throwaway key when none is set) to `OPENAI_API_KEY` for the child process, passes `--disable network_proxy`, and removes standard proxy variables from the spawned Codex process.
210
252
 
@@ -225,7 +267,7 @@ Direct bearer tokens, GitHub CLI token fallback, classic GitHub PATs, and fine-g
225
267
 
226
268
  Re-run `hoopilot login` after upgrading Hoopilot if Copilot reports a supported model as unavailable. Older stored tokens can have a reduced model set.
227
269
 
228
- To print the verified OAuth token for another local tool, use `--print-key`. Login status goes to stderr, so stdout contains only the token.
270
+ To print the verified OAuth token for another local tool, use `--print-key` (the alias `--print-token` also works). Login status goes to stderr, so stdout contains only the token.
229
271
 
230
272
  ```sh
231
273
  hoopilot login --print-key | sed 's/^/COPILOT_OAUTH_TOKEN=/' >> .env
@@ -270,7 +312,7 @@ Incoming `x-request-id` headers are preserved on responses. If a request has no
270
312
 
271
313
  Hoopilot tracks token usage, request counts, and latency in memory while the server runs. It can also report your GitHub Copilot account quota and premium-request usage, plus your GitHub REST API rate-limit budget.
272
314
 
273
- - `GET /metrics` returns Prometheus text (`text/plain; version=0.0.4`). It exposes request counters, upstream call counters, token counters by model and type, a request-duration histogram, an in-flight gauge, Copilot quota gauges, and GitHub REST API rate-limit gauges (`hoopilot_github_ratelimit_limit`, `_remaining`, `_used`, `_reset_timestamp_seconds`, `_retry_after_seconds`, labelled by `resource`) — the quota and rate-limit series appear after `/v1/usage` has been fetched at least once. Counters reset to zero on restart, which Prometheus handles natively.
315
+ - `GET /metrics` returns Prometheus text (`text/plain; version=0.0.4; charset=utf-8`). It exposes request counters, upstream call counters, token counters by model and type, a request-duration histogram, an in-flight gauge, process start-time and uptime gauges (`hoopilot_process_start_time_seconds`, `hoopilot_uptime_seconds`), Copilot quota gauges, and GitHub REST API rate-limit gauges (`hoopilot_github_ratelimit_limit`, `_remaining`, `_used`, `_reset_timestamp_seconds`, `_retry_after_seconds`, labelled by `resource`) — the quota and rate-limit series appear after `/v1/usage` has been fetched at least once. Counters reset to zero on restart, which Prometheus handles natively.
274
316
  - `GET /v1/usage` returns JSON combining the proxy metrics snapshot with live Copilot quota fetched from GitHub and cached for 60 seconds. If quota cannot be read, `copilot` is `null` and `copilot_error` explains why. The snapshot's `proxy.githubRateLimit` field reports the most recent GitHub REST rate-limit budget per resource (`limit`, `remaining`, `used`, `resetAt`, `retryAfterSeconds`, `observedAt`).
275
317
  - `hoopilot usage` prints your Copilot plan and quota — and, when GitHub returns them, your GitHub API rate-limit budget — from the command line.
276
318
 
@@ -312,6 +354,8 @@ If `/v1/models` returns `401 copilot_auth_error`, rerun `hoopilot login` and con
312
354
 
313
355
  ## Configuration
314
356
 
357
+ Settings written as `ENV / --flag` accept either the environment variable or the command-line flag.
358
+
315
359
  Server and local-client settings:
316
360
 
317
361
  | Setting | Description |
@@ -322,14 +366,14 @@ Server and local-client settings:
322
366
  | `--api-key-file` | Read the local API key from a file instead of argv. |
323
367
  | `HOOPILOT_ALLOWED_ORIGINS` | Comma-separated browser origins allowed to make cross-origin requests. Loopback origins are always allowed; every other origin is blocked. |
324
368
  | `HOOPILOT_ALLOW_UNAUTHENTICATED` / `--allow-unauthenticated` | Allow non-loopback binds without a local API key. |
325
- | `HOOPILOT_STREAM_MODE` / `--stream-mode` | `auto`, `live`, or `buffer`. `auto` buffers streams for Windows standalone binaries. |
369
+ | `HOOPILOT_STREAM_MODE` / `--stream-mode` | `auto`, `live`, or `buffer`. `auto` buffers streams for Windows standalone binaries. `HOOPILOT_STREAMING_PROXY_MODE` is accepted as an alias. |
326
370
 
327
371
  Copilot and GitHub settings:
328
372
 
329
373
  | Setting | Description |
330
374
  | --- | --- |
331
375
  | `HOOPILOT_AUTH_FILE` / `--auth-file` | OAuth credential store path. |
332
- | `HOOPILOT_GITHUB_CLIENT_ID` | GitHub OAuth app client ID override. |
376
+ | `HOOPILOT_GITHUB_CLIENT_ID` | GitHub OAuth app client ID override. `COPILOT_GITHUB_CLIENT_ID` is accepted as an alias. |
333
377
  | `HOOPILOT_GITHUB_DOMAIN` | GitHub domain override. Default: `github.com`. |
334
378
  | `COPILOT_API_BASE_URL` / `--copilot-api-base-url` | Upstream Copilot API base URL. Default: `https://api.githubcopilot.com`. |
335
379
  | `HOOPILOT_GITHUB_API_BASE_URL` | GitHub REST API base URL used for quota lookup. Default: `https://api.github.com`. |
@@ -39,8 +39,12 @@ function parseUrl(rawUrl) {
39
39
  }
40
40
  return url;
41
41
  }
42
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
43
+ function isLoopbackHostname(host) {
44
+ return LOOPBACK_HOSTNAMES.has(host);
45
+ }
42
46
  function isLoopbackHttpUrl(url) {
43
- return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]");
47
+ return url.protocol === "http:" && isLoopbackHostname(url.hostname);
44
48
  }
45
49
  async function truncatedResponseText(response, max = 500) {
46
50
  const text = await response.text();
@@ -49,6 +53,63 @@ async function truncatedResponseText(response, max = 500) {
49
53
  function asRecord(value) {
50
54
  return value && typeof value === "object" && !Array.isArray(value) ? value : {};
51
55
  }
56
+ function errorMessage(error) {
57
+ return error instanceof Error ? error.message : String(error);
58
+ }
59
+ function firstNumber(...values) {
60
+ for (const value of values) {
61
+ if (typeof value === "number" && Number.isFinite(value)) {
62
+ return value;
63
+ }
64
+ }
65
+ return void 0;
66
+ }
67
+ function randomId() {
68
+ return crypto.randomUUID().replaceAll("-", "");
69
+ }
70
+ function removeUndefined(value) {
71
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
72
+ }
73
+ function safeJsonParse(text) {
74
+ try {
75
+ return JSON.parse(text);
76
+ } catch {
77
+ return void 0;
78
+ }
79
+ }
80
+ function parseJsonObject(text) {
81
+ try {
82
+ return asRecord(JSON.parse(text));
83
+ } catch {
84
+ return void 0;
85
+ }
86
+ }
87
+ function modelIdsFromResponse(body) {
88
+ const record = asRecord(body);
89
+ const data = Array.isArray(record.data) ? record.data : Array.isArray(body) ? body : [];
90
+ const seen = /* @__PURE__ */ new Set();
91
+ const ids = [];
92
+ for (const model of data) {
93
+ const id = asRecord(model).id;
94
+ if (typeof id !== "string" || id.length === 0 || seen.has(id)) {
95
+ continue;
96
+ }
97
+ seen.add(id);
98
+ ids.push(id);
99
+ }
100
+ return ids;
101
+ }
102
+ var STREAMING_PROXY_MODES = [
103
+ "auto",
104
+ "buffer",
105
+ "live"
106
+ ];
107
+ function parseStreamingProxyMode(value) {
108
+ if (STREAMING_PROXY_MODES.includes(value)) {
109
+ return value;
110
+ }
111
+ throw new Error(`Invalid stream mode: ${value}. Expected ${STREAMING_PROXY_MODES.join(", ")}.`);
112
+ }
52
113
 
53
114
  // src/codexx.ts
54
115
  var DEFAULT_BASE_URL = "http://127.0.0.1:4141/v1";
@@ -138,13 +199,19 @@ async function main(argv = Bun.argv.slice(2), env = process.env) {
138
199
  process.exitCode = exitCode;
139
200
  }
140
201
  async function verifyCodexxModel(invocation, fetcher = fetch) {
141
- const modelsUrl = `${invocation.baseUrl.replace(/\/+$/, "")}/models`;
202
+ const modelsUrl = `${trimTrailingSlash(invocation.baseUrl)}/models`;
203
+ const apiKey = invocation.env.OPENAI_API_KEY;
204
+ if (apiKey === void 0) {
205
+ throw new Error(
206
+ "verifyCodexxModel requires invocation.env.OPENAI_API_KEY; build the invocation with buildCodexxInvocation."
207
+ );
208
+ }
142
209
  let response;
143
210
  try {
144
211
  response = await fetcher(modelsUrl, {
145
212
  headers: {
146
213
  accept: "application/json",
147
- authorization: `Bearer ${invocation.env.OPENAI_API_KEY ?? generateEphemeralApiKey()}`
214
+ authorization: `Bearer ${apiKey}`
148
215
  },
149
216
  method: "GET"
150
217
  });
@@ -155,10 +222,10 @@ async function verifyCodexxModel(invocation, fetcher = fetch) {
155
222
  }
156
223
  if (!response.ok) {
157
224
  throw new Error(
158
- `Could not verify model ${JSON.stringify(invocation.model)} because ${modelsUrl} returned ${response.status}: ${await shortResponseText(response)}`
225
+ `Could not verify model ${JSON.stringify(invocation.model)} because ${modelsUrl} returned ${response.status}: ${await truncatedResponseText(response)}`
159
226
  );
160
227
  }
161
- const models = modelIds(await response.json().catch(() => void 0));
228
+ const models = modelIdsFromResponse(await response.json().catch(() => void 0));
162
229
  if (models.length > 0 && !models.includes(invocation.model)) {
163
230
  throw new Error(
164
231
  `The logged-in Copilot account does not advertise model ${JSON.stringify(invocation.model)} at ${modelsUrl}. Available models: ${models.join(", ")}. After upgrading Hoopilot, rerun "hoopilot login" to refresh the Copilot OAuth token, or set CODEXX_MODEL to one of the advertised model IDs.`
@@ -191,20 +258,6 @@ codexx does not start Hoopilot and does not change your shell environment. It se
191
258
  function signalNumber(signal) {
192
259
  return osConstants.signals[signal] ?? 1;
193
260
  }
194
- function modelIds(value) {
195
- const record = value && typeof value === "object" && !Array.isArray(value) ? value : {};
196
- const data = "data" in record && Array.isArray(record.data) ? record.data : [];
197
- return data.map(
198
- (entry) => entry && typeof entry === "object" && "id" in entry && typeof entry.id === "string" ? entry.id : void 0
199
- ).filter((id) => typeof id === "string" && id.length > 0);
200
- }
201
- async function shortResponseText(response) {
202
- const text = await response.text();
203
- return text.slice(0, 500);
204
- }
205
- function errorMessage(error) {
206
- return error instanceof Error ? error.message : String(error);
207
- }
208
261
  if (import.meta.main) {
209
262
  main().catch((error) => {
210
263
  console.error(errorMessage(error));
@@ -216,10 +269,19 @@ export {
216
269
  trimTrailingSlash,
217
270
  envValue,
218
271
  isTrustedTokenBaseUrl,
272
+ isLoopbackHostname,
219
273
  truncatedResponseText,
220
274
  asRecord,
275
+ errorMessage,
276
+ firstNumber,
277
+ randomId,
278
+ removeUndefined,
279
+ safeJsonParse,
280
+ parseJsonObject,
281
+ modelIdsFromResponse,
282
+ parseStreamingProxyMode,
221
283
  buildCodexxInvocation,
222
284
  main,
223
285
  verifyCodexxModel
224
286
  };
225
- //# sourceMappingURL=chunk-JU6F5L34.js.map
287
+ //# sourceMappingURL=chunk-6ALEIJJM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/codexx.ts","../src/util.ts"],"sourcesContent":["#!/usr/bin/env bun\n\nimport { spawn } from \"node:child_process\";\nimport { constants as osConstants } from \"node:os\";\nimport type { FetchLike } from \"./types\";\nimport {\n envValue,\n errorMessage,\n modelIdsFromResponse,\n trimTrailingSlash,\n truncatedResponseText,\n} from \"./util\";\n\nconst DEFAULT_BASE_URL = \"http://127.0.0.1:4141/v1\";\nconst DEFAULT_CODEX_BIN = \"codex\";\nconst DEFAULT_MODEL = \"gpt-5.5\";\nconst DEFAULT_REASONING_EFFORT = \"xhigh\";\nconst PROXY_ENV_KEYS = [\n \"ALL_PROXY\",\n \"HTTPS_PROXY\",\n \"HTTP_PROXY\",\n \"NO_PROXY\",\n \"all_proxy\",\n \"https_proxy\",\n \"http_proxy\",\n \"no_proxy\",\n];\n\nexport interface CodexxInvocation {\n args: string[];\n baseUrl: string;\n command: string;\n env: NodeJS.ProcessEnv;\n model: string;\n}\n\nexport function buildCodexxInvocation(\n argv: string[],\n env: NodeJS.ProcessEnv = process.env,\n): CodexxInvocation {\n const baseUrl = envValue(env.CODEXX_BASE_URL) ?? DEFAULT_BASE_URL;\n // Never fall back to a public, predictable key: a shared constant like the old\n // \"local-key\" default is also a credential a malicious local/browser client\n // could guess. When no key is configured the local server is expected to run\n // unauthenticated, which accepts any value, so a random throwaway key is safe.\n const apiKey =\n envValue(env.CODEXX_API_KEY) ?? envValue(env.HOOPILOT_API_KEY) ?? generateEphemeralApiKey();\n const command = envValue(env.CODEXX_CODEX_BIN) ?? DEFAULT_CODEX_BIN;\n const model = envValue(env.CODEXX_MODEL) ?? DEFAULT_MODEL;\n const reasoningEffort = envValue(env.CODEXX_MODEL_REASONING_EFFORT) ?? DEFAULT_REASONING_EFFORT;\n const providerConfig = [\n '{ name = \"Hoopilot\"',\n `base_url = ${JSON.stringify(baseUrl)}`,\n 'env_key = \"OPENAI_API_KEY\"',\n 'wire_api = \"responses\"',\n \"supports_websockets = false }\",\n ].join(\", \");\n\n return {\n args: [\n \"--disable\",\n \"network_proxy\",\n \"-c\",\n 'model_provider=\"hoopilot\"',\n \"-c\",\n `model_providers.hoopilot=${providerConfig}`,\n \"-m\",\n model,\n \"-c\",\n `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,\n ...argv,\n ],\n baseUrl,\n command,\n env: withoutProxyEnv({\n ...env,\n OPENAI_API_KEY: apiKey,\n }),\n model,\n };\n}\n\n// A random, non-guessable placeholder key for when neither CODEXX_API_KEY nor\n// HOOPILOT_API_KEY is set. An unauthenticated local Hoopilot accepts any value;\n// a keyed server rejects it with a 401, which the model preflight surfaces.\nfunction generateEphemeralApiKey(): string {\n return `codexx-${crypto.randomUUID()}`;\n}\n\nfunction withoutProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n const next = { ...env };\n for (const key of PROXY_ENV_KEYS) {\n delete next[key];\n }\n return next;\n}\n\nexport async function main(argv = Bun.argv.slice(2), env = process.env): Promise<void> {\n if (argv.length === 1 && (argv[0] === \"--help\" || argv[0] === \"-h\")) {\n console.log(helpText());\n return;\n }\n\n const invocation = buildCodexxInvocation(argv, env);\n if (env.CODEXX_SKIP_MODEL_PREFLIGHT !== \"1\") {\n await verifyCodexxModel(invocation);\n }\n const child = spawn(invocation.command, invocation.args, {\n env: invocation.env,\n shell: process.platform === \"win32\",\n stdio: \"inherit\",\n });\n\n const exitCode = await new Promise<number>((resolve, reject) => {\n child.once(\"error\", reject);\n child.once(\"exit\", (code, signal) => {\n if (typeof code === \"number\") {\n resolve(code);\n return;\n }\n resolve(signal ? 128 + signalNumber(signal) : 1);\n });\n });\n\n process.exitCode = exitCode;\n}\n\nexport async function verifyCodexxModel(\n invocation: Pick<CodexxInvocation, \"baseUrl\" | \"env\" | \"model\">,\n fetcher: FetchLike = fetch,\n): Promise<void> {\n const modelsUrl = `${trimTrailingSlash(invocation.baseUrl)}/models`;\n const apiKey = invocation.env.OPENAI_API_KEY;\n if (apiKey === undefined) {\n throw new Error(\n \"verifyCodexxModel requires invocation.env.OPENAI_API_KEY; build the invocation with buildCodexxInvocation.\",\n );\n }\n let response: Response;\n try {\n response = await fetcher(modelsUrl, {\n headers: {\n accept: \"application/json\",\n authorization: `Bearer ${apiKey}`,\n },\n method: \"GET\",\n });\n } catch (error) {\n throw new Error(\n `Could not reach Hoopilot at ${modelsUrl}. Start Hoopilot first, or set CODEXX_SKIP_MODEL_PREFLIGHT=1 to skip this check. ${errorMessage(error)}`,\n );\n }\n\n if (!response.ok) {\n throw new Error(\n `Could not verify model ${JSON.stringify(invocation.model)} because ${modelsUrl} returned ${response.status}: ${await truncatedResponseText(response)}`,\n );\n }\n\n const models = modelIdsFromResponse(await response.json().catch(() => undefined));\n if (models.length > 0 && !models.includes(invocation.model)) {\n throw new Error(\n `The logged-in Copilot account does not advertise model ${JSON.stringify(invocation.model)} at ${modelsUrl}. Available models: ${models.join(\", \")}. After upgrading Hoopilot, rerun \"hoopilot login\" to refresh the Copilot OAuth token, or set CODEXX_MODEL to one of the advertised model IDs.`,\n );\n }\n}\n\nfunction helpText(): string {\n return `codexx\n\nRun Codex against an already-running local Hoopilot server.\n\nUsage:\n codexx [codex options] [prompt]\n\nEnvironment:\n CODEXX_BASE_URL OpenAI-compatible base URL. Default: ${DEFAULT_BASE_URL}\n CODEXX_API_KEY API key sent to the local Hoopilot server.\n HOOPILOT_API_KEY Used as the API key when CODEXX_API_KEY is unset. When\n neither is set, a random throwaway key is generated for\n an unauthenticated local server.\n CODEXX_CODEX_BIN Codex executable to run. Default: ${DEFAULT_CODEX_BIN}\n CODEXX_MODEL Codex model to use. Default: ${DEFAULT_MODEL}\n CODEXX_MODEL_REASONING_EFFORT\n Codex reasoning effort. Default: ${DEFAULT_REASONING_EFFORT}\n CODEXX_SKIP_MODEL_PREFLIGHT\n Set to 1 to skip checking /v1/models before starting Codex.\n\ncodexx does not start Hoopilot and does not change your shell environment. It selects a temporary Hoopilot model provider with Responses WebSockets disabled, uses ${DEFAULT_MODEL} with ${DEFAULT_REASONING_EFFORT} reasoning by default, disables Codex's network_proxy feature, and removes proxy variables only from the spawned Codex process.`;\n}\n\nfunction signalNumber(signal: NodeJS.Signals): number {\n return osConstants.signals[signal] ?? 1;\n}\n\nif (import.meta.main) {\n main().catch((error: unknown) => {\n console.error(errorMessage(error));\n process.exit(1);\n });\n}\n","import type { JsonObject, StreamingProxyMode } from \"./types\";\n\n/** Remove any trailing slashes from a URL or path string. */\nexport function trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\n/** Treat blank environment variables as unset while preserving nonblank values. */\nexport function envValue(value: string | undefined): string | undefined {\n const trimmed = value?.trim();\n return trimmed ? trimmed : undefined;\n}\n\n/** True for HTTPS URLs, or HTTP only on loopback hosts used by local tests/dev. */\nexport function isHttpsOrLoopbackUrl(rawUrl: string): boolean {\n const url = parseUrl(rawUrl);\n if (!url) {\n return false;\n }\n return url.protocol === \"https:\" || isLoopbackHttpUrl(url);\n}\n\n/** Validate a base URL before sending a bearer/OAuth token to it. */\nexport function isTrustedTokenBaseUrl(\n rawUrl: string,\n allowedHttpsHosts: readonly string[],\n allowUnsafeHttps = false,\n): boolean {\n const url = parseUrl(rawUrl);\n if (!url) {\n return false;\n }\n if (url.username || url.password || url.search || url.hash) {\n return false;\n }\n if (url.pathname !== \"\" && url.pathname !== \"/\") {\n return false;\n }\n if (isLoopbackHttpUrl(url)) {\n return true;\n }\n if (url.protocol !== \"https:\") {\n return false;\n }\n const host = url.hostname.toLowerCase();\n return allowedHttpsHosts.includes(host) || allowUnsafeHttps;\n}\n\nfunction parseUrl(rawUrl: string): URL | undefined {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n return undefined;\n }\n return url;\n}\n\nconst LOOPBACK_HOSTNAMES = new Set([\"localhost\", \"127.0.0.1\", \"::1\", \"[::1]\"]);\n\n/** True for hostnames that always resolve to the local machine. */\nexport function isLoopbackHostname(host: string): boolean {\n return LOOPBACK_HOSTNAMES.has(host);\n}\n\nfunction isLoopbackHttpUrl(url: URL): boolean {\n return url.protocol === \"http:\" && isLoopbackHostname(url.hostname);\n}\n\n/** Read a response body as text, truncated to keep error messages bounded. */\nexport async function truncatedResponseText(response: Response, max = 500): Promise<string> {\n const text = await response.text();\n return text.slice(0, max);\n}\n\n/** Narrow an unknown value to a plain object, returning {} for arrays/primitives/null. */\nexport function asRecord(value: unknown): JsonObject {\n return value && typeof value === \"object\" && !Array.isArray(value) ? (value as JsonObject) : {};\n}\n\n/** Extract a human-readable message from an unknown thrown value. */\nexport function errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Return the first finite number among the candidates, else undefined. */\nexport function firstNumber(...values: unknown[]): number | undefined {\n for (const value of values) {\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return value;\n }\n }\n return undefined;\n}\n\n/** Generate a dash-free random identifier for synthesized response/message ids. */\nexport function randomId(): string {\n return crypto.randomUUID().replaceAll(\"-\", \"\");\n}\n\n/** Drop keys whose value is undefined so they are omitted from JSON output. */\nexport function removeUndefined<T extends object>(value: T): T {\n return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T;\n}\n\n/** Parse JSON, returning undefined instead of throwing on malformed input. */\nexport function safeJsonParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n}\n\n/** Parse JSON into a plain object, returning undefined on malformed or non-object input. */\nexport function parseJsonObject(text: string): JsonObject | undefined {\n try {\n return asRecord(JSON.parse(text));\n } catch {\n return undefined;\n }\n}\n\n/**\n * Extract de-duplicated model IDs from an OpenAI-style `/models` response (an\n * object carrying a `data` array, or a bare array of model objects).\n */\nexport function modelIdsFromResponse(body: unknown): string[] {\n const record = asRecord(body);\n const data = Array.isArray(record.data) ? record.data : Array.isArray(body) ? body : [];\n const seen = new Set<string>();\n const ids: string[] = [];\n for (const model of data) {\n const id = asRecord(model).id;\n if (typeof id !== \"string\" || id.length === 0 || seen.has(id)) {\n continue;\n }\n seen.add(id);\n ids.push(id);\n }\n return ids;\n}\n\n/** Canonical set of accepted streaming-proxy modes, kept in sync with {@link StreamingProxyMode}. */\nexport const STREAMING_PROXY_MODES = [\n \"auto\",\n \"buffer\",\n \"live\",\n] as const satisfies readonly StreamingProxyMode[];\n\n/** Validate a stream-mode string against the allowed {@link StreamingProxyMode} values. */\nexport function parseStreamingProxyMode(value: string): StreamingProxyMode {\n if ((STREAMING_PROXY_MODES as readonly string[]).includes(value)) {\n return value as StreamingProxyMode;\n }\n throw new Error(`Invalid stream mode: ${value}. Expected ${STREAMING_PROXY_MODES.join(\", \")}.`);\n}\n"],"mappings":"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","names":[]}