@dianshuv/copilot-api 0.9.1 → 0.10.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.
Files changed (3) hide show
  1. package/README.md +125 -0
  2. package/dist/main.mjs +370 -16
  3. package/package.json +4 -3
package/README.md CHANGED
@@ -80,6 +80,7 @@ make down
80
80
  | `--context-editing` | Context editing mode: off, clear-thinking, clear-tooluse, clear-both | off |
81
81
  | `--timezone-offset` | Timezone offset in hours from UTC for log timestamps (e.g., +8, -5, 0) | +8 |
82
82
  | `--posthog-key` | PostHog API key for token usage analytics (opt-in) | none |
83
+ | `--api-key` | Proxy API key for inbound authentication (see [Authentication](#authentication)). Empty = disabled | none |
83
84
 
84
85
  ### Hidden Models
85
86
 
@@ -157,6 +158,130 @@ When enabled (default), auto-truncate automatically compacts conversation histor
157
158
  - **Smart compression**: With `--compress-tool-results`, old tool results are compressed before removing messages, preserving more conversation context.
158
159
  - **Orphan filtering**: After truncation, orphaned tool results (without matching tool calls) are automatically removed.
159
160
 
161
+ ## Authentication
162
+
163
+ By default the proxy is **unauthenticated**: every endpoint is open and any
164
+ request passes through unchanged. You can optionally protect the proxy's
165
+ *inbound* surface with a **Proxy API key** (this is your own secret for this
166
+ proxy — it is unrelated to the GitHub OAuth / Copilot token the proxy uses
167
+ upstream). When a key is configured, all endpoints **except `/` and `/health`**
168
+ require it (fail-closed: any route not on that exemption list — including future
169
+ ones — is protected). Genuine CORS preflight `OPTIONS` requests are also let
170
+ through (they carry no payload), so browser clients aren't tripped by an opaque
171
+ preflight failure.
172
+
173
+ ### Enabling it
174
+
175
+ Provide the key via either source:
176
+
177
+ - `--api-key <key>` flag
178
+ - `COPILOT_API_KEY` environment variable
179
+
180
+ Rules:
181
+
182
+ - **Precedence**: when both are set to a non-empty value, the **flag wins** and
183
+ the env value is ignored.
184
+ - **Trim + empty disables**: each source is trimmed first; a value that is empty
185
+ or whitespace-only (`--api-key ""`, `COPILOT_API_KEY=`) counts as *not
186
+ provided*. If neither source yields a non-empty value, auth stays **disabled**
187
+ (the default).
188
+
189
+ The startup banner prints whether auth is on/off and, when on, the source
190
+ (`flag` / `env`) — but **never** the key value itself.
191
+
192
+ > [!CAUTION]
193
+ > **Ambient `COPILOT_API_KEY` footgun.** Because the env var is read on every
194
+ > start, a `COPILOT_API_KEY` exported anywhere the process can see it (your
195
+ > shell profile, a `.env` sourced into the environment, a systemd unit, a
196
+ > container env) **silently enables authentication** for that instance — even
197
+ > when you did not pass `--api-key`. If a previously-open instance suddenly
198
+ > returns `401`, check for an inherited `COPILOT_API_KEY`. The startup banner's
199
+ > `source: env` line makes this visible.
200
+
201
+ ### Network binding
202
+
203
+ The proxy **binds all interfaces (`0.0.0.0`) by default**, so a key-less
204
+ instance is reachable from anywhere on your network. For a local-only,
205
+ unauthenticated instance, bind loopback explicitly:
206
+
207
+ ```sh
208
+ copilot-api start --host 127.0.0.1
209
+ ```
210
+
211
+ The startup banner reports the **real** bind address (`0.0.0.0` for an
212
+ all-interfaces bind, or the narrowed host you passed), so a wide-open bind is
213
+ never hidden.
214
+
215
+ ### Choosing a key
216
+
217
+ Use a **high-entropy** secret (e.g. `openssl rand -hex 32`), not a guessable
218
+ word. The comparison is constant-time, but a weak key can still be guessed
219
+ offline or brute-forced over the network.
220
+
221
+ ### Migrating clients
222
+
223
+ Once a key is configured, clients that previously sent a placeholder must send
224
+ the real key. Two header shapes are accepted and **either** is sufficient:
225
+
226
+ - **OpenAI-compatible clients** → `Authorization: Bearer <key>` (the scheme is
227
+ matched case-insensitively; a bare value with no `Bearer ` prefix is also
228
+ tolerated).
229
+ - **Anthropic-native clients** → `x-api-key: <key>`.
230
+
231
+ If both headers are present, a match on *either* one is accepted.
232
+
233
+ Per client:
234
+
235
+ - **opencode** (`opencode.json`): set `provider.<name>.options.apiKey` from
236
+ `"dummy"` to your real key (the `@ai-sdk/openai-compatible` provider sends it
237
+ as `Authorization: Bearer`).
238
+ - **Claude Code** (`.claude/settings.json`, see [below](#using-with-claude-code)):
239
+ change `ANTHROPIC_AUTH_TOKEN` from `"dummy"` to your real key. Claude Code
240
+ talks to the proxy's `/v1/messages` (Anthropic) surface; the auth gate accepts
241
+ the token from either header. `--claude-code` keeps emitting the `"dummy"`
242
+ placeholder on purpose (so the secret never lands in your clipboard / shell
243
+ history) and prints a reminder to replace it when auth is on.
244
+ - **Raw OpenAI SDK** → `Authorization: Bearer <key>`; **raw Anthropic SDK** →
245
+ `x-api-key: <key>`.
246
+
247
+ > [!TIP]
248
+ > **Keep the key out of shell history.** Don't paste the literal key onto a
249
+ > command line (e.g. `curl -H "Authorization: Bearer sk-real-key" …`), or it
250
+ > lands in `~/.zsh_history`. Read it from an env var or a file instead:
251
+ >
252
+ > ```sh
253
+ > # Put the key in an env var read from a non-echoed prompt or a 0600 file,
254
+ > # then reference the var — the literal secret never appears in argv/history.
255
+ > read -rs COPILOT_API_KEY # typed key is not echoed or saved
256
+ > export COPILOT_API_KEY
257
+ > curl -H "Authorization: Bearer $COPILOT_API_KEY" http://127.0.0.1:4141/v1/models
258
+ > ```
259
+
260
+ A failed auth returns `401` with a client-appropriate body: requests to the
261
+ Anthropic surface (`/v1/messages`, `/v1/messages/count_tokens`) get
262
+ `{"type":"error","error":{"type":"authentication_error", …}}`; every other
263
+ endpoint gets the OpenAI-style `{"error":{"code":"invalid_api_key", …}}`.
264
+ Missing and wrong keys return the identical body (no oracle), always with a
265
+ `WWW-Authenticate: Bearer` header.
266
+
267
+ ### Browser tools when auth is on
268
+
269
+ The request logger does **not** record the `Authorization` / `x-api-key`
270
+ headers, so your key never lands in logs. But the browser-based tools can't send
271
+ a custom header, so when auth is on they stop working:
272
+
273
+ - **History UI** (`/history`) and its live-update WebSocket (`/history/ws`) — the
274
+ page load and socket upgrade are gated like any other protected route.
275
+ - **External Usage Viewer** (the `ericc-ch.github.io` link in the banner) — it
276
+ fetches `/usage`, which is now protected, so it can't load your data.
277
+
278
+ Workarounds:
279
+
280
+ - Run a **separate local, unauthenticated instance** (`--host 127.0.0.1` with no
281
+ `--api-key`) for the browser UI, or
282
+ - Hit the history JSON API directly with a key, e.g.
283
+ `curl -H "Authorization: Bearer $COPILOT_API_KEY" http://127.0.0.1:4141/history/api/entries`.
284
+
160
285
  ## Using with Claude Code
161
286
 
162
287
  Create `.claude/settings.json` in your project:
package/dist/main.mjs CHANGED
@@ -6,7 +6,7 @@ import os from "node:os";
6
6
  import path, { dirname, join } from "node:path";
7
7
  import { getProxyForUrl } from "proxy-from-env";
8
8
  import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
9
- import { createHash, randomUUID } from "node:crypto";
9
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
10
10
  import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
11
11
  import clipboard from "clipboardy";
12
12
  import { serve } from "srvx";
@@ -1348,7 +1348,7 @@ const patchClaude = defineCommand({
1348
1348
 
1349
1349
  //#endregion
1350
1350
  //#region package.json
1351
- var version = "0.9.1";
1351
+ var version = "0.10.0";
1352
1352
 
1353
1353
  //#endregion
1354
1354
  //#region src/lib/adaptive-rate-limiter.ts
@@ -1657,6 +1657,315 @@ async function executeWithAdaptiveRateLimit(fn) {
1657
1657
  return rateLimiterInstance.execute(fn);
1658
1658
  }
1659
1659
 
1660
+ //#endregion
1661
+ //#region src/lib/auth-gate.ts
1662
+ /**
1663
+ * Auth gate — the inbound authentication decision point for the proxy.
1664
+ *
1665
+ * Protects this proxy's *inbound* surface with a configured **Proxy API key**
1666
+ * (NOT the outbound GitHub OAuth token or Copilot token). The decision logic
1667
+ * is expressed as pure functions so it can be unit-tested without booting the
1668
+ * server or reaching upstream.
1669
+ */
1670
+ /**
1671
+ * Extract candidate presented credential values from request headers.
1672
+ *
1673
+ * Two header shapes are read, and **both** contribute candidates when present
1674
+ * (compare-all-present) so neither is silently ignored in favor of the other —
1675
+ * a later any-match over the candidates decides acceptance:
1676
+ * - `Authorization`: the scheme prefix is stripped case-insensitively
1677
+ * (`Bearer ` / `bearer ` …) because the scheme is case-insensitive per
1678
+ * RFC 7235, while the secret itself is case-sensitive. A bare value with no
1679
+ * scheme prefix is tolerated and returned verbatim.
1680
+ * - `x-api-key` (Issue 02): the Anthropic-native header. Taken verbatim — no
1681
+ * scheme stripping (a value that happens to start with `Bearer ` is kept
1682
+ * as-is).
1683
+ *
1684
+ * Order is `[Authorization, x-api-key]` for any present header; absent headers
1685
+ * contribute nothing.
1686
+ */
1687
+ function extractCredentials(headers) {
1688
+ const candidates = [];
1689
+ const authorization = headers.get("authorization");
1690
+ if (authorization !== null) candidates.push(authorization.replace(/^Bearer\s+/i, ""));
1691
+ const apiKey = headers.get("x-api-key");
1692
+ if (apiKey !== null) candidates.push(apiKey);
1693
+ return candidates;
1694
+ }
1695
+ /**
1696
+ * Hard-coded exemption set: the liveness (`/`) and readiness (`/health`)
1697
+ * endpoints are reachable without a key so container orchestration probes are
1698
+ * never blocked. Everything else is protected (fail-closed) — unknown / future
1699
+ * routes default to protected.
1700
+ *
1701
+ * Matching is by **exact path**, with a trailing slash tolerated (so `/health/`
1702
+ * is exempt too) and `/` itself handled explicitly. Prefix matching is
1703
+ * deliberately avoided: `/healthz` or `/health/extra` must NOT be exempt. The
1704
+ * server registers a matching `/health/` route, so an exempt `/health/` request
1705
+ * resolves to the readiness handler rather than 404ing.
1706
+ */
1707
+ function isExemptPath(path) {
1708
+ if (path === "/") return true;
1709
+ return (path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path) === "/health";
1710
+ }
1711
+ /**
1712
+ * Compute the fixed-length sha256 digest (32 bytes) of the configured key.
1713
+ * The configured key is trimmed before hashing (config-side trim).
1714
+ */
1715
+ function digestConfiguredKey(configuredKey) {
1716
+ return createHash("sha256").update(configuredKey.trim()).digest();
1717
+ }
1718
+ /**
1719
+ * Constant-time membership test: does any presented candidate match the
1720
+ * configured key?
1721
+ *
1722
+ * Each candidate is sha256'd to a fixed 32-byte digest and compared against the
1723
+ * configured digest. Hashing to a fixed length sidesteps the `RangeError` that
1724
+ * `crypto.timingSafeEqual` throws on length-mismatched buffers, so a
1725
+ * wrong-length presented value yields `false` rather than throwing.
1726
+ */
1727
+ function matchesConfiguredKey(configuredDigest, candidates) {
1728
+ return candidates.some((candidate) => {
1729
+ return timingSafeEqual(createHash("sha256").update(candidate).digest(), configuredDigest);
1730
+ });
1731
+ }
1732
+ /**
1733
+ * Resolve the inbound Proxy API key from its two operator-facing sources,
1734
+ * applying the precedence + normalization contract (Issue 03):
1735
+ *
1736
+ * - `--api-key` flag (`flag`) and `COPILOT_API_KEY` env (`env`) are each
1737
+ * **trimmed first**; a trimmed-empty source (`""`, whitespace, or
1738
+ * `undefined`) counts as **not provided**.
1739
+ * - When both provide a non-empty value, the **flag wins** (env ignored).
1740
+ * - When only one provides a non-empty value, that one is used.
1741
+ * - When neither does, `key` is `undefined` and `source` is `"none"` → auth
1742
+ * stays disabled (same as the no-`--api-key` default).
1743
+ *
1744
+ * Pure: it reads nothing from `process.env` itself (the caller passes the env
1745
+ * value in), so it is fully unit-testable and the precedence logic is decoupled
1746
+ * from how the values are sourced.
1747
+ */
1748
+ function resolveProxyApiKey(sources) {
1749
+ const flag = sources.flag?.trim() ?? "";
1750
+ if (flag !== "") return {
1751
+ key: flag,
1752
+ source: "flag"
1753
+ };
1754
+ const env = sources.env?.trim() ?? "";
1755
+ if (env !== "") return {
1756
+ key: env,
1757
+ source: "env"
1758
+ };
1759
+ return {
1760
+ key: void 0,
1761
+ source: "none"
1762
+ };
1763
+ }
1764
+ /**
1765
+ * Resolve the address the server will *actually* bind to for the startup banner
1766
+ * (Issue 04).
1767
+ *
1768
+ * Mirrors srvx's own host resolution EXACTLY so the banner reports the TRUE bind
1769
+ * rather than a guess that could diverge from what srvx passes to the runtime.
1770
+ * srvx computes `hostname = opts.hostname ?? process.env.HOST` (a raw nullish
1771
+ * coalesce — no trimming, no empty-string special-casing), and start.ts passes
1772
+ * `hostname: options.host`. So:
1773
+ * - an explicit `--host` (even `""` / whitespace) is what srvx uses verbatim —
1774
+ * it does NOT fall back to HOST once `opts.hostname` is a non-null string;
1775
+ * - only an absent (`undefined`) `--host` lets srvx fall back to `HOST`;
1776
+ * - when the coalesced value is `undefined` or empty, the runtime binds all
1777
+ * interfaces, which we report as the explicit `0.0.0.0` so a wide-open bind
1778
+ * is unmistakable (srvx renders the same bind as "localhost (all
1779
+ * interfaces)").
1780
+ *
1781
+ * Critically, the resolved non-empty value is returned VERBATIM (not trimmed):
1782
+ * srvx hands the runtime exactly that string, so the banner must report exactly
1783
+ * that string — trimming here would make the banner claim a different address
1784
+ * than the one actually bound. `env` is passed in (not read) to keep the
1785
+ * function pure and unit-testable.
1786
+ */
1787
+ function resolveBindAddress(host, env) {
1788
+ const resolved = host ?? env;
1789
+ if (resolved === void 0 || resolved === "") return "0.0.0.0";
1790
+ return resolved;
1791
+ }
1792
+ /**
1793
+ * Resolve the CLIENT-FACING host for generated configs and viewer links
1794
+ * (Issue 04), derived from the SAME srvx host resolution as the banner so the
1795
+ * two never disagree about what was bound — and formatted as a valid URL
1796
+ * authority so the links actually parse.
1797
+ *
1798
+ * Two differences from {@link resolveBindAddress}:
1799
+ * - All-interfaces rendering: a wildcard bind (`0.0.0.0` / `::` / `[::]` /
1800
+ * empty) is not a connectable target, so it maps to `localhost` for URLs a
1801
+ * client will actually dial (matching srvx's "localhost (all interfaces)"
1802
+ * presentation). A narrowed bind (e.g. `127.0.0.1`, `192.168.1.10`, an IPv6
1803
+ * address) is kept so generated links point at the real interface — fixing
1804
+ * the prior bug where setting `HOST` (with `--host` omitted) yielded
1805
+ * `http://localhost:<port>` links the narrowed bind wasn't listening on.
1806
+ * - IPv6 bracketing: a literal IPv6 host (contains `:`) is wrapped in `[...]`,
1807
+ * exactly as srvx's own `fmtURL` does, so `http://[2001:db8::1]:<port>` is a
1808
+ * valid authority rather than the unparseable `http://2001:db8::1:<port>`.
1809
+ *
1810
+ * Returns a host token ready to drop into `http://<token>:<port>`.
1811
+ */
1812
+ function resolveClientHost(host, env) {
1813
+ const bind = resolveBindAddress(host, env);
1814
+ if (bind === "0.0.0.0" || bind === "::" || bind === "[::]") return "localhost";
1815
+ if (bind.includes(":") && !bind.startsWith("[")) return `[${bind}]`;
1816
+ return bind;
1817
+ }
1818
+ /**
1819
+ * Build the inbound-auth startup banner lines (Issue 04).
1820
+ *
1821
+ * Returns the human-readable lines the proxy prints at boot so operators can see,
1822
+ * at a glance, the security posture of *this* instance:
1823
+ * - auth ON → `认证开启`, plus the key's origin (`flag` / `env`), plus the real
1824
+ * bind address.
1825
+ * - auth OFF → `认证关闭`, plus the real bind address (so a careless all-
1826
+ * interfaces bind without auth is visible).
1827
+ *
1828
+ * The configured key value is **never** an input here, so it can never leak into
1829
+ * the banner — the function only knows the *source* tag, not the secret. Pure
1830
+ * (string in → strings out) so the banner copy is pinned by unit tests.
1831
+ */
1832
+ function buildStartupAuthLines(params) {
1833
+ const { source, bindAddress } = params;
1834
+ return [source === "none" ? `Inbound auth: 认证关闭 (no proxy API key configured)` : `Inbound auth: 认证开启 (source: ${source})`, `Binding to: ${bindAddress}`];
1835
+ }
1836
+ /**
1837
+ * The env var Claude Code reads for its inbound credential, and the placeholder
1838
+ * value the `--claude-code` setup always embeds for it. Exported so the
1839
+ * generated env script (src/start.ts) and the auth hint below reference the SAME
1840
+ * literals — changing the placeholder or the var name in one place can't silently
1841
+ * desync the other (the hint would otherwise keep naming a string the generated
1842
+ * command no longer contains).
1843
+ */
1844
+ const CLAUDE_CODE_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN";
1845
+ const CLAUDE_CODE_AUTH_TOKEN_PLACEHOLDER = "dummy";
1846
+ /**
1847
+ * Build the auth-aware hint lines for the `--claude-code` setup (Issue 05).
1848
+ *
1849
+ * The generated env script ALWAYS sets `ANTHROPIC_AUTH_TOKEN="dummy"` — a real
1850
+ * key is deliberately never embedded, so the secret can't land in the clipboard
1851
+ * or shell history. When inbound auth is ON, that placeholder won't authenticate
1852
+ * against this proxy, so the operator must replace it. This builder returns the
1853
+ * visible hint that tells them which variable to change:
1854
+ * - auth OFF (`source === "none"`) → no hint (today's behavior, unchanged).
1855
+ * - auth ON (`flag` / `env`) → a one-line hint naming
1856
+ * `ANTHROPIC_AUTH_TOKEN` as the field to set to the proxy API key value.
1857
+ *
1858
+ * Like {@link buildStartupAuthLines}, the key value is **never** an input here —
1859
+ * the builder only knows the `source` tag — so it is structurally impossible for
1860
+ * the secret to leak into the hint. Pure (tag in → strings out) so the copy is
1861
+ * pinned by unit tests.
1862
+ */
1863
+ function buildClaudeCodeAuthHint(source) {
1864
+ if (source === "none") return [];
1865
+ return [`Inbound auth is ON: replace ${CLAUDE_CODE_AUTH_TOKEN_ENV}="${CLAUDE_CODE_AUTH_TOKEN_PLACEHOLDER}" with your proxy API key value before using Claude Code.`];
1866
+ }
1867
+ /**
1868
+ * Configure the proxy API key on global state from a raw configured value.
1869
+ *
1870
+ * The value is trimmed; a trimmed-empty value (or `undefined`) is treated as
1871
+ * "not provided" → auth stays disabled. Otherwise the precomputed digest is
1872
+ * stored on state (presence === enabled). Returns whether auth is enabled.
1873
+ *
1874
+ * The `--api-key` flag and `COPILOT_API_KEY` env source are reconciled upstream
1875
+ * by `resolveProxyApiKey` (flag-over-env precedence, Issue 03); this function
1876
+ * receives only the already-resolved value.
1877
+ */
1878
+ function configureProxyApiKey(rawKey) {
1879
+ const trimmed = rawKey?.trim() ?? "";
1880
+ if (trimmed === "") {
1881
+ state.proxyApiKeyDigest = void 0;
1882
+ return false;
1883
+ }
1884
+ state.proxyApiKeyDigest = digestConfiguredKey(trimmed);
1885
+ return true;
1886
+ }
1887
+ /**
1888
+ * Path → auth-family selector (Issue 02).
1889
+ *
1890
+ * The Anthropic-native surface is `/v1/messages` and its `count_tokens`
1891
+ * subpath; both map to the Anthropic family so a native Anthropic client gets
1892
+ * the `authentication_error` body. Everything else — including every other
1893
+ * `/v1/…` endpoint and any unknown / future route — defaults to the OpenAI
1894
+ * family.
1895
+ *
1896
+ * Matching is by **exact path** (a trailing slash tolerated), deliberately not
1897
+ * a prefix test: the shared `/v1/` prefix must not sweep OpenAI-style endpoints
1898
+ * into the Anthropic family, and `/v1/messages-extra` or a deeper unexpected
1899
+ * subpath must not be misclassified either. This mirrors `isExemptPath`'s
1900
+ * exact-with-trailing-slash convention.
1901
+ */
1902
+ function selectFamily(path) {
1903
+ const normalized = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
1904
+ if (normalized === "/v1/messages" || normalized === "/v1/messages/count_tokens") return "anthropic";
1905
+ return "openai";
1906
+ }
1907
+ /**
1908
+ * OpenAI-family 401 response body. The literal field values are pinned by the
1909
+ * ADR so OpenAI-compatible SDKs recognize the failure as an auth error. The
1910
+ * same body is returned whether credentials were missing or wrong (no oracle).
1911
+ */
1912
+ function unauthorizedOpenAIBody() {
1913
+ return { error: {
1914
+ message: "Invalid API key provided.",
1915
+ type: "invalid_request_error",
1916
+ code: "invalid_api_key",
1917
+ param: null
1918
+ } };
1919
+ }
1920
+ /**
1921
+ * Anthropic-family 401 response body (Issue 02). Shape is pinned so Anthropic
1922
+ * SDKs (and Claude Code via `/v1/messages`) recognize the failure as an auth
1923
+ * error: a top-level `{type:"error", error:{type:"authentication_error",
1924
+ * message}}`. As with the OpenAI body, missing and wrong credentials return the
1925
+ * identical body (no oracle).
1926
+ */
1927
+ function unauthorizedAnthropicBody() {
1928
+ return {
1929
+ type: "error",
1930
+ error: {
1931
+ type: "authentication_error",
1932
+ message: "Invalid API key provided."
1933
+ }
1934
+ };
1935
+ }
1936
+ /**
1937
+ * Global fail-closed authentication middleware.
1938
+ *
1939
+ * Registered after the request logger and CORS but before route dispatch.
1940
+ * Behavior:
1941
+ * - Disabled (no configured digest) → pass through unchanged (default).
1942
+ * - Exempt path (`/`, `/health`) → pass through.
1943
+ * - CORS preflight `OPTIONS` on a protected path → pass through so browser
1944
+ * preflight isn't mistaken for a 401 (blocking it surfaces as an opaque CORS
1945
+ * error, very hard to diagnose). Scoped to *actual* preflights — an
1946
+ * `OPTIONS` carrying `Access-Control-Request-Method` — rather than any
1947
+ * `OPTIONS`, so the bypass surface can't silently widen. Preflights carry no
1948
+ * protected payload, so this doesn't weaken fail-closed.
1949
+ * - Otherwise require a valid Proxy API key; on failure return 401 with a
1950
+ * `WWW-Authenticate: Bearer` header and a **family-appropriate** body —
1951
+ * Anthropic-family (`/v1/messages*`) gets the `authentication_error` shape,
1952
+ * everything else the OpenAI `invalid_api_key` shape (Issue 02). The family
1953
+ * only selects the body shape; it does not change what is protected. Missing
1954
+ * and wrong credentials return the field-identical body for that family.
1955
+ */
1956
+ function authGate() {
1957
+ return async (c, next) => {
1958
+ const configuredDigest = state.proxyApiKeyDigest;
1959
+ if (!configuredDigest) return next();
1960
+ if (isExemptPath(c.req.path)) return next();
1961
+ if (c.req.method === "OPTIONS" && c.req.raw.headers.get("access-control-request-method") !== null) return next();
1962
+ if (matchesConfiguredKey(configuredDigest, extractCredentials(c.req.raw.headers))) return next();
1963
+ c.header("WWW-Authenticate", "Bearer");
1964
+ if (selectFamily(c.req.path) === "anthropic") return c.json(unauthorizedAnthropicBody(), 401);
1965
+ return c.json(unauthorizedOpenAIBody(), 401);
1966
+ };
1967
+ }
1968
+
1660
1969
  //#endregion
1661
1970
  //#region src/lib/context/request.ts
1662
1971
  let idCounter = 0;
@@ -1933,6 +2242,18 @@ function isHiddenModel(id, showAll) {
1933
2242
  * Enables real-time updates when new requests are recorded.
1934
2243
  */
1935
2244
  const clients = /* @__PURE__ */ new Set();
2245
+ function addClient(ws) {
2246
+ clients.add(ws);
2247
+ const msg = {
2248
+ type: "connected",
2249
+ data: { clientCount: clients.size },
2250
+ timestamp: Date.now()
2251
+ };
2252
+ ws.send(JSON.stringify(msg));
2253
+ }
2254
+ function removeClient(ws) {
2255
+ clients.delete(ws);
2256
+ }
1936
2257
  function getClientCount() {
1937
2258
  return clients.size;
1938
2259
  }
@@ -4743,6 +5064,12 @@ eventLoggingRoutes.post("/batch", (c) => {
4743
5064
  return c.text("OK", 200);
4744
5065
  });
4745
5066
 
5067
+ //#endregion
5068
+ //#region src/lib/srvx-bun.ts
5069
+ function getBunServerFromRequest(req) {
5070
+ return req.runtime?.bun?.server;
5071
+ }
5072
+
4746
5073
  //#endregion
4747
5074
  //#region src/routes/history/api.ts
4748
5075
  function handleGetEntries(c) {
@@ -6240,7 +6567,7 @@ historyRoutes.get("/api/sessions/:id", handleGetSession);
6240
6567
  historyRoutes.delete("/api/sessions/:id", handleDeleteSession);
6241
6568
  historyRoutes.get("/ws", (c) => {
6242
6569
  if (c.req.header("Upgrade") !== "websocket") return c.text("Expected WebSocket upgrade", 426);
6243
- if (c.env?.server?.upgrade(c.req.raw)) return new Response(null, { status: 101 });
6570
+ if (getBunServerFromRequest(c.req.raw)?.upgrade(c.req.raw, { data: { kind: "history" } })) return new Response(null, { status: 101 });
6244
6571
  return c.text("WebSocket upgrade failed", 500);
6245
6572
  });
6246
6573
  historyRoutes.get("/", (c) => {
@@ -8610,7 +8937,7 @@ const handleResponses = async (c) => {
8610
8937
  normalizePayload: (p) => {
8611
8938
  const np = state.normalizeResponsesCallIds ? normalizeCallIds(p) : p;
8612
8939
  useFunctionApplyPatch(np);
8613
- removeWebSearchTool(np);
8940
+ filterUnsupportedBuiltins(np);
8614
8941
  return np;
8615
8942
  },
8616
8943
  buildHistoryRequest: (p) => {
@@ -8767,11 +9094,10 @@ const useFunctionApplyPatch = (payload) => {
8767
9094
  }
8768
9095
  }
8769
9096
  };
8770
- const removeWebSearchTool = (payload) => {
9097
+ const UNSUPPORTED_BUILTIN_TOOL_TYPES = new Set(["web_search", "image_generation"]);
9098
+ const filterUnsupportedBuiltins = (payload) => {
8771
9099
  if (!Array.isArray(payload.tools) || payload.tools.length === 0) return;
8772
- payload.tools = payload.tools.filter((t) => {
8773
- return t.type !== "web_search";
8774
- });
9100
+ payload.tools = payload.tools.filter((t) => typeof t.type !== "string" || !UNSUPPORTED_BUILTIN_TOOL_TYPES.has(t.type));
8775
9101
  };
8776
9102
  /** Record a ResponsesResult to history */
8777
9103
  function recordResponseResult(result, fallbackModel, historyId, startTime) {
@@ -8830,8 +9156,9 @@ usageRoute.get("/", async (c) => {
8830
9156
  const server = new Hono();
8831
9157
  server.use(tuiLogger());
8832
9158
  server.use(cors());
9159
+ server.use(authGate());
8833
9160
  server.get("/", (c) => c.text("Server running"));
8834
- server.get("/health", (c) => {
9161
+ const healthHandler = (c) => {
8835
9162
  const healthy = Boolean(state.copilotToken && state.githubToken);
8836
9163
  return c.json({
8837
9164
  status: healthy ? "healthy" : "unhealthy",
@@ -8841,7 +9168,9 @@ server.get("/health", (c) => {
8841
9168
  models: Boolean(state.models)
8842
9169
  }
8843
9170
  }, healthy ? 200 : 503);
8844
- });
9171
+ };
9172
+ server.get("/health", healthHandler);
9173
+ server.get("/health/", healthHandler);
8845
9174
  server.route("/chat/completions", completionRoutes);
8846
9175
  server.route("/models", modelRoutes);
8847
9176
  server.route("/usage", usageRoute);
@@ -8883,6 +9212,7 @@ function formatModelInfo(model) {
8883
9212
  }
8884
9213
  async function runServer(options) {
8885
9214
  consola.info(`copilot-api v${version}`);
9215
+ configureProxyApiKey(options.apiKey);
8886
9216
  if (options.proxyEnv) initProxyFromEnv();
8887
9217
  if (options.verbose) {
8888
9218
  consola.level = 5;
@@ -8953,7 +9283,7 @@ async function runServer(options) {
8953
9283
  const visibleModels = allModels.filter((m) => !isHiddenModel(m.id, state.showAllModels));
8954
9284
  if (visibleModels.length === 0) consola.warn("All upstream models are filtered by the hardcoded blacklist. /v1/models will return an empty list, but explicit POSTs with a hidden id still pass through to upstream. Restart with --show-all-models to see the full catalogue.");
8955
9285
  else consola.info(`Available models:\n${visibleModels.map((m) => formatModelInfo(m)).join("\n")}`);
8956
- const serverUrl = `http://${options.host ?? "localhost"}:${options.port}`;
9286
+ const serverUrl = `http://${resolveClientHost(options.host, process.env.HOST)}:${options.port}`;
8957
9287
  if (options.claudeCode) {
8958
9288
  if (visibleModels.length === 0) {
8959
9289
  consola.error("--claude-code interactive setup needs at least one visible model. Restart with --show-all-models or update src/lib/hidden-models.ts.");
@@ -8969,7 +9299,7 @@ async function runServer(options) {
8969
9299
  });
8970
9300
  const command = generateEnvScript({
8971
9301
  ANTHROPIC_BASE_URL: serverUrl,
8972
- ANTHROPIC_AUTH_TOKEN: "dummy",
9302
+ [CLAUDE_CODE_AUTH_TOKEN_ENV]: CLAUDE_CODE_AUTH_TOKEN_PLACEHOLDER,
8973
9303
  ANTHROPIC_MODEL: selectedModel,
8974
9304
  ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel,
8975
9305
  ANTHROPIC_SMALL_FAST_MODEL: selectedSmallModel,
@@ -8984,13 +9314,27 @@ async function runServer(options) {
8984
9314
  consola.warn("Failed to copy to clipboard. Here is the Claude Code command:");
8985
9315
  consola.log(command);
8986
9316
  }
9317
+ for (const line of buildClaudeCodeAuthHint(options.apiKeySource)) consola.warn(line);
8987
9318
  }
8988
9319
  consola.box(`🌐 Usage Viewer: https://ericc-ch.github.io/copilot-api?endpoint=${serverUrl}/usage${options.history ? `\n📜 History UI: ${serverUrl}/history` : ""}`);
9320
+ for (const line of buildStartupAuthLines({
9321
+ source: options.apiKeySource,
9322
+ bindAddress: resolveBindAddress(options.host, process.env.HOST)
9323
+ })) process.stdout.write(`${line}\n`);
8989
9324
  setupShutdownHandlers();
8990
9325
  setServerInstance(serve({
8991
9326
  fetch: server.fetch,
8992
9327
  port: options.port,
8993
- hostname: options.host
9328
+ hostname: options.host,
9329
+ bun: { websocket: {
9330
+ open(ws) {
9331
+ if (ws.data?.kind === "history") addClient(ws);
9332
+ },
9333
+ close(ws) {
9334
+ if (ws.data?.kind === "history") removeClient(ws);
9335
+ },
9336
+ message() {}
9337
+ } }
8994
9338
  }));
8995
9339
  }
8996
9340
  function parseTimezoneOffset(value) {
@@ -9072,7 +9416,7 @@ const start = defineCommand({
9072
9416
  "github-token": {
9073
9417
  alias: "g",
9074
9418
  type: "string",
9075
- description: "Provide GitHub token directly (must be generated using the `auth` subcommand)"
9419
+ description: "Provide GitHub token directly (must be generated using the `auth` subcommand). Falls back to the GH_TOKEN env var if the flag is omitted — prefer the env for automation since argv is visible via /proc/<pid>/cmdline."
9076
9420
  },
9077
9421
  "claude-code": {
9078
9422
  alias: "c",
@@ -9138,9 +9482,17 @@ const start = defineCommand({
9138
9482
  "posthog-key": {
9139
9483
  type: "string",
9140
9484
  description: "PostHog API key for token usage analytics (opt-in, no key = disabled)"
9485
+ },
9486
+ "api-key": {
9487
+ type: "string",
9488
+ description: "Proxy API key for inbound authentication. When set (non-empty after trimming), all endpoints except / and /health require this key via 'Authorization: Bearer <key>'. Omitted or empty = auth disabled (default, all requests pass through)."
9141
9489
  }
9142
9490
  },
9143
9491
  run({ args }) {
9492
+ const resolvedApiKey = resolveProxyApiKey({
9493
+ flag: args["api-key"],
9494
+ env: process.env.COPILOT_API_KEY
9495
+ });
9144
9496
  return runServer({
9145
9497
  port: Number.parseInt(args.port, 10),
9146
9498
  host: args.host,
@@ -9152,7 +9504,7 @@ const start = defineCommand({
9152
9504
  requestInterval: Number.parseInt(args["request-interval"], 10),
9153
9505
  recoveryTimeout: Number.parseInt(args["recovery-timeout"], 10),
9154
9506
  consecutiveSuccesses: Number.parseInt(args["consecutive-successes"], 10),
9155
- githubToken: args["github-token"],
9507
+ githubToken: args["github-token"] || process.env.GH_TOKEN,
9156
9508
  claudeCode: args["claude-code"],
9157
9509
  showToken: args["show-token"],
9158
9510
  showAllModels: args["show-all-models"],
@@ -9165,7 +9517,9 @@ const start = defineCommand({
9165
9517
  stripServerTools: args["strip-server-tools"],
9166
9518
  contextEditing: parseContextEditing(args["context-editing"]),
9167
9519
  timezoneOffset: parseTimezoneOffset(args["timezone-offset"]),
9168
- posthogKey: args["posthog-key"]
9520
+ posthogKey: args["posthog-key"],
9521
+ apiKey: resolvedApiKey.key,
9522
+ apiKeySource: resolvedApiKey.source
9169
9523
  });
9170
9524
  }
9171
9525
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dianshuv/copilot-api",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!",
5
5
  "author": "dianshuv",
6
6
  "type": "module",
@@ -22,8 +22,9 @@
22
22
  "release": "npm publish --access public --//registry.npmjs.org/:_authToken=$NPM_TOKEN",
23
23
  "start": "NODE_ENV=production bun run ./src/main.ts start",
24
24
  "test": "bun test tests/*.test.ts",
25
- "test:all": "bun test tests/*.test.ts && bun test tests/integration/",
26
- "test:integration": "bun test tests/integration/",
25
+ "test:all": "bun test tests/*.test.ts && bun test tests/integration/ --max-concurrency=1 --parallel=2 && bun test tests/e2e/ --max-concurrency=1 --parallel=2",
26
+ "test:e2e": "bun test tests/e2e/ --max-concurrency=1 --parallel=2",
27
+ "test:integration": "bun test tests/integration/ --max-concurrency=1 --parallel=2",
27
28
  "typecheck": "tsc"
28
29
  },
29
30
  "simple-git-hooks": {