@dianshuv/copilot-api 0.9.1 → 0.10.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.
Files changed (3) hide show
  1. package/README.md +125 -0
  2. package/dist/main.mjs +857 -30
  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.1";
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) => {
@@ -7684,6 +8011,472 @@ function translateErrorToAnthropicErrorEvent(error) {
7684
8011
  };
7685
8012
  }
7686
8013
 
8014
+ //#endregion
8015
+ //#region src/routes/messages/tool-call-recovery.ts
8016
+ const ENVELOPE = String.raw`(?:<(?:antml:)?function_calls>|call)`;
8017
+ const INVOKE_BODY = String.raw`<(?:antml:)?invoke\s+name="[^"]+">[\s\S]*?</(?:antml:)?invoke>`;
8018
+ const LEAKED_REGION_RE = new RegExp(String.raw`(?:^|\n)[ \t]*` + ENVELOPE + String.raw`[ \t\n]*(?:` + INVOKE_BODY + String.raw`\s*)+(?:</(?:antml:)?function_calls>)?`, "g");
8019
+ const INCOMPLETE_LEAK_TAIL_RE = new RegExp(String.raw`(?:^|\n)[ \t]*` + ENVELOPE + String.raw`[ \t\n]*<(?:antml:)?invoke\b[\s\S]*$`);
8020
+ const LEAK_OPEN_RE = new RegExp(String.raw`(?:^|\n)[ \t]*` + ENVELOPE + String.raw`[ \t\n]*<(?:antml:)?invoke\s+name="`);
8021
+ const INVOKE_RE = /<(?:antml:)?invoke\s+name="([^"]+)">([\s\S]*?)<\/(?:antml:)?invoke>/g;
8022
+ const PARAMETER_RE = /<(?:antml:)?parameter\s+name="([^"]+)">([\s\S]*?)<\/(?:antml:)?parameter>/g;
8023
+ function coerceParamValue(raw) {
8024
+ const trimmed = raw.trim();
8025
+ if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) try {
8026
+ return JSON.parse(trimmed);
8027
+ } catch {
8028
+ return raw;
8029
+ }
8030
+ return raw;
8031
+ }
8032
+ function parseRegionInvokes(region, knownTools) {
8033
+ const calls = [];
8034
+ for (const invokeMatch of region.matchAll(INVOKE_RE)) {
8035
+ const name = invokeMatch[1];
8036
+ if (knownTools && !knownTools.has(name)) continue;
8037
+ const input = {};
8038
+ for (const paramMatch of invokeMatch[2].matchAll(PARAMETER_RE)) input[paramMatch[1]] = coerceParamValue(paramMatch[2]);
8039
+ calls.push({
8040
+ name,
8041
+ input
8042
+ });
8043
+ }
8044
+ return calls;
8045
+ }
8046
+ /**
8047
+ * Split assistant text into ordered segments — dropping leaked envelope markup
8048
+ * (and undeclared-tool invokes) while preserving the natural-language on either
8049
+ * side and the pre/tool/post ordering. Returns a single text segment when there
8050
+ * is no leak. Shared by both response recovery paths so they cannot diverge.
8051
+ */
8052
+ function recoverSegments(text, knownTools) {
8053
+ const segments = [];
8054
+ let cursor = 0;
8055
+ let sawRegion = false;
8056
+ for (const region of text.matchAll(LEAKED_REGION_RE)) {
8057
+ sawRegion = true;
8058
+ const pre = text.slice(cursor, region.index);
8059
+ if (pre.trim() !== "") segments.push({
8060
+ kind: "text",
8061
+ text: pre
8062
+ });
8063
+ for (const call of parseRegionInvokes(region[0], knownTools)) segments.push({
8064
+ kind: "tool",
8065
+ call
8066
+ });
8067
+ cursor = region.index + region[0].length;
8068
+ }
8069
+ if (!sawRegion) return [{
8070
+ kind: "text",
8071
+ text
8072
+ }];
8073
+ const post = text.slice(cursor);
8074
+ if (post.trim() !== "") segments.push({
8075
+ kind: "text",
8076
+ text: post
8077
+ });
8078
+ return segments;
8079
+ }
8080
+ /** True when `text` contains at least one complete, envelope-wrapped leak. */
8081
+ function containsLeakedToolCall(text) {
8082
+ if (!text.includes("invoke")) return false;
8083
+ for (const _region of text.matchAll(LEAKED_REGION_RE)) return true;
8084
+ return false;
8085
+ }
8086
+ /**
8087
+ * Remove complete leaked tool-call regions from assistant text, preserving the
8088
+ * surrounding natural-language on both sides.
8089
+ */
8090
+ function stripLeakedToolCalls(text) {
8091
+ if (!text.includes("invoke")) return text;
8092
+ const stripped = text.replaceAll(LEAKED_REGION_RE, "");
8093
+ if (stripped === text) return text;
8094
+ return stripped.replace(/[ \t\n]+$/, "");
8095
+ }
8096
+ /**
8097
+ * Declared CLIENT tool names for a payload. Server-side tools are excluded (they
8098
+ * are not client-executable, so a leaked server-tool invoke must not become a
8099
+ * client tool_use). Always returns a set — an empty set means "no declared
8100
+ * tools", which correctly drops every leaked invoke rather than trusting it.
8101
+ */
8102
+ function toolNameSet(tools) {
8103
+ return new Set((tools ?? []).filter((tool) => !isServerToolType(tool.type)).map((tool) => tool.name));
8104
+ }
8105
+ const EMPTIED_ASSISTANT_PLACEHOLDER = "[malformed tool call removed by proxy]";
8106
+ function historyHasLeak(text) {
8107
+ if (!text.includes("invoke")) return false;
8108
+ return containsLeakedToolCall(text) || INCOMPLETE_LEAK_TAIL_RE.test(text);
8109
+ }
8110
+ function scrubHistoryText(text) {
8111
+ const stripped = stripLeakedToolCalls(text);
8112
+ const final = stripped.replace(INCOMPLETE_LEAK_TAIL_RE, "");
8113
+ return final === stripped ? stripped : final.replace(/[ \t\n]+$/, "");
8114
+ }
8115
+ /**
8116
+ * Request-side de-poison. Strips leaked tool-call markup (complete and truncated)
8117
+ * from assistant text in the inbound history so the model never sees a
8118
+ * text-format exemplar to imitate — breaking the self-reinforcing poisoning
8119
+ * loop. When stripping empties an assistant message, the message is kept with a
8120
+ * short placeholder rather than dropped, so role alternation is preserved.
8121
+ */
8122
+ function dePoisonAssistantMessages(payload) {
8123
+ if (!payload.messages.some((m) => m.role === "assistant" && (typeof m.content === "string" ? m.content.includes("invoke") : m.content.some((b) => b.type === "text" && b.text.includes("invoke"))))) return payload;
8124
+ let changed = false;
8125
+ const messages = [];
8126
+ for (const msg of payload.messages) {
8127
+ if (msg.role !== "assistant") {
8128
+ messages.push(msg);
8129
+ continue;
8130
+ }
8131
+ if (typeof msg.content === "string") {
8132
+ if (!historyHasLeak(msg.content)) {
8133
+ messages.push(msg);
8134
+ continue;
8135
+ }
8136
+ changed = true;
8137
+ const cleaned = scrubHistoryText(msg.content);
8138
+ messages.push({
8139
+ ...msg,
8140
+ content: cleaned.trim() === "" ? EMPTIED_ASSISTANT_PLACEHOLDER : cleaned
8141
+ });
8142
+ continue;
8143
+ }
8144
+ if (!msg.content.some((b) => b.type === "text" && historyHasLeak(b.text))) {
8145
+ messages.push(msg);
8146
+ continue;
8147
+ }
8148
+ changed = true;
8149
+ const content = msg.content.flatMap((b) => {
8150
+ if (b.type !== "text" || !historyHasLeak(b.text)) return [b];
8151
+ const cleaned = scrubHistoryText(b.text);
8152
+ return cleaned.trim() === "" ? [] : [{
8153
+ ...b,
8154
+ text: cleaned
8155
+ }];
8156
+ });
8157
+ messages.push(content.length > 0 ? {
8158
+ ...msg,
8159
+ content
8160
+ } : {
8161
+ ...msg,
8162
+ content: EMPTIED_ASSISTANT_PLACEHOLDER
8163
+ });
8164
+ }
8165
+ return changed ? {
8166
+ ...payload,
8167
+ messages
8168
+ } : payload;
8169
+ }
8170
+ let recoveryCounter = 0;
8171
+ function nextToolUseId() {
8172
+ recoveryCounter += 1;
8173
+ return `toolu_recovered_${Date.now().toString(36)}_${recoveryCounter}`;
8174
+ }
8175
+ /**
8176
+ * Non-streaming recovery: rewrites any assistant text block that contains a
8177
+ * leaked tool call into ordered [pre-text, tool_use(s), post-text] blocks,
8178
+ * dropping the leak markup and undeclared-tool invokes. stop_reason is flipped to
8179
+ * tool_use only when a real call was recovered AND upstream reported a plain
8180
+ * end-of-turn (so max_tokens / refusal / pause_turn survive).
8181
+ */
8182
+ function recoverLeakedToolCallsInResponse(response, knownTools) {
8183
+ let changed = false;
8184
+ let recoveredCall = false;
8185
+ const content = [];
8186
+ for (const block of response.content) {
8187
+ if (block.type !== "text" || !containsLeakedToolCall(block.text)) {
8188
+ content.push(block);
8189
+ continue;
8190
+ }
8191
+ changed = true;
8192
+ for (const segment of recoverSegments(block.text, knownTools)) if (segment.kind === "text") content.push({
8193
+ type: "text",
8194
+ text: segment.text
8195
+ });
8196
+ else {
8197
+ recoveredCall = true;
8198
+ content.push({
8199
+ type: "tool_use",
8200
+ id: nextToolUseId(),
8201
+ name: segment.call.name,
8202
+ input: segment.call.input
8203
+ });
8204
+ }
8205
+ }
8206
+ if (!changed) return response;
8207
+ const flip = recoveredCall && (response.stop_reason === "end_turn" || response.stop_reason === null);
8208
+ return {
8209
+ ...response,
8210
+ content,
8211
+ stop_reason: flip ? "tool_use" : response.stop_reason
8212
+ };
8213
+ }
8214
+ function out(event) {
8215
+ return {
8216
+ event,
8217
+ data: JSON.stringify(event)
8218
+ };
8219
+ }
8220
+ const TAIL_GUARD = 48;
8221
+ const MAX_CAPTURE = 65536;
8222
+ /**
8223
+ * Per-response streaming transformer. Feed it each parsed upstream Anthropic
8224
+ * event (plus the original `data` string); forward whatever it returns; call
8225
+ * `flush()` once the upstream stream ends.
8226
+ *
8227
+ * Identity passthrough until an envelope appears in a text block; from there it
8228
+ * suppresses the leaked markup, emits structured tool_use block(s) (declared
8229
+ * tools only) plus any trailing prose, shifts the indices of later blocks, and
8230
+ * flips a plain end-of-turn stop_reason to tool_use.
8231
+ */
8232
+ var LeakedToolCallStreamRecovery = class {
8233
+ extraBlocks = 0;
8234
+ text = null;
8235
+ converted = false;
8236
+ knownTools;
8237
+ constructor(knownTools) {
8238
+ this.knownTools = knownTools;
8239
+ }
8240
+ process(event, rawData) {
8241
+ switch (event.type) {
8242
+ case "content_block_start": return this.onBlockStart(event, rawData);
8243
+ case "content_block_delta": return this.onBlockDelta(event, rawData);
8244
+ case "content_block_stop": return this.onBlockStop(event, rawData);
8245
+ case "message_delta": return this.onMessageDelta(event, rawData);
8246
+ default: return [{
8247
+ event,
8248
+ data: rawData
8249
+ }];
8250
+ }
8251
+ }
8252
+ /** Flush any pending (buffered/capturing) text block at end of stream. */
8253
+ flush() {
8254
+ return this.flushPending();
8255
+ }
8256
+ reindexed(event, rawData) {
8257
+ if (this.extraBlocks === 0) return {
8258
+ event,
8259
+ data: rawData
8260
+ };
8261
+ const shifted = {
8262
+ ...event,
8263
+ index: event.index + this.extraBlocks
8264
+ };
8265
+ return {
8266
+ event: shifted,
8267
+ data: JSON.stringify(shifted)
8268
+ };
8269
+ }
8270
+ onBlockStart(event, rawData) {
8271
+ if (event.content_block.type === "text") {
8272
+ this.text = {
8273
+ upstreamIndex: event.index,
8274
+ opened: false,
8275
+ buffer: "",
8276
+ forwarded: 0,
8277
+ capturing: false,
8278
+ captureStart: 0,
8279
+ abandoned: false
8280
+ };
8281
+ return [];
8282
+ }
8283
+ return [this.reindexed(event, rawData)];
8284
+ }
8285
+ onBlockDelta(event, rawData) {
8286
+ const t = this.text;
8287
+ if (!t || event.index !== t.upstreamIndex || event.delta.type !== "text_delta") return [this.reindexed(event, rawData)];
8288
+ t.buffer += event.delta.text;
8289
+ if (t.capturing) {
8290
+ if (t.buffer.length - t.captureStart > MAX_CAPTURE) return this.abandonCapture(t);
8291
+ return [];
8292
+ }
8293
+ if (!t.abandoned) {
8294
+ const open = LEAK_OPEN_RE.exec(t.buffer);
8295
+ if (open) return this.beginCapture(t, open.index);
8296
+ }
8297
+ return this.flushSafe(t);
8298
+ }
8299
+ onBlockStop(event, rawData) {
8300
+ const t = this.text;
8301
+ if (!t || event.index !== t.upstreamIndex) return [this.reindexed(event, rawData)];
8302
+ return this.flushPending();
8303
+ }
8304
+ onMessageDelta(event, rawData) {
8305
+ const flushed = this.flushPending();
8306
+ const isNaturalEnd = event.delta.stop_reason === "end_turn" || event.delta.stop_reason === null;
8307
+ if (!this.converted || !isNaturalEnd) return [...flushed, {
8308
+ event,
8309
+ data: rawData
8310
+ }];
8311
+ const rewritten = {
8312
+ ...event,
8313
+ delta: {
8314
+ ...event.delta,
8315
+ stop_reason: "tool_use"
8316
+ }
8317
+ };
8318
+ return [...flushed, out(rewritten)];
8319
+ }
8320
+ emitText(t, chunk) {
8321
+ const idx = t.upstreamIndex + this.extraBlocks;
8322
+ const events = [];
8323
+ if (!t.opened) {
8324
+ events.push(out({
8325
+ type: "content_block_start",
8326
+ index: idx,
8327
+ content_block: {
8328
+ type: "text",
8329
+ text: ""
8330
+ }
8331
+ }));
8332
+ t.opened = true;
8333
+ }
8334
+ events.push(out({
8335
+ type: "content_block_delta",
8336
+ index: idx,
8337
+ delta: {
8338
+ type: "text_delta",
8339
+ text: chunk
8340
+ }
8341
+ }));
8342
+ return events;
8343
+ }
8344
+ flushSafe(t) {
8345
+ const safeEnd = t.buffer.length - TAIL_GUARD;
8346
+ if (safeEnd <= t.forwarded) return [];
8347
+ const chunk = t.buffer.slice(t.forwarded, safeEnd);
8348
+ t.forwarded = safeEnd;
8349
+ return this.emitText(t, chunk);
8350
+ }
8351
+ beginCapture(t, start) {
8352
+ t.captureStart = start;
8353
+ t.capturing = true;
8354
+ if (start > t.forwarded) {
8355
+ const preamble = t.buffer.slice(t.forwarded, start);
8356
+ t.forwarded = start;
8357
+ return this.emitText(t, preamble);
8358
+ }
8359
+ return [];
8360
+ }
8361
+ abandonCapture(t) {
8362
+ t.capturing = false;
8363
+ t.abandoned = true;
8364
+ const events = this.emitText(t, t.buffer.slice(t.forwarded));
8365
+ t.forwarded = t.buffer.length;
8366
+ return events;
8367
+ }
8368
+ emitTextBlock(index, text) {
8369
+ return [
8370
+ out({
8371
+ type: "content_block_start",
8372
+ index,
8373
+ content_block: {
8374
+ type: "text",
8375
+ text: ""
8376
+ }
8377
+ }),
8378
+ out({
8379
+ type: "content_block_delta",
8380
+ index,
8381
+ delta: {
8382
+ type: "text_delta",
8383
+ text
8384
+ }
8385
+ }),
8386
+ out({
8387
+ type: "content_block_stop",
8388
+ index
8389
+ })
8390
+ ];
8391
+ }
8392
+ emitToolBlock(index, call) {
8393
+ return [
8394
+ out({
8395
+ type: "content_block_start",
8396
+ index,
8397
+ content_block: {
8398
+ type: "tool_use",
8399
+ id: nextToolUseId(),
8400
+ name: call.name,
8401
+ input: {}
8402
+ }
8403
+ }),
8404
+ out({
8405
+ type: "content_block_delta",
8406
+ index,
8407
+ delta: {
8408
+ type: "input_json_delta",
8409
+ partial_json: JSON.stringify(call.input)
8410
+ }
8411
+ }),
8412
+ out({
8413
+ type: "content_block_stop",
8414
+ index
8415
+ })
8416
+ ];
8417
+ }
8418
+ flushPending() {
8419
+ const t = this.text;
8420
+ if (!t) return [];
8421
+ this.text = null;
8422
+ if (t.capturing) return this.finishCapture(t);
8423
+ const events = [];
8424
+ if (t.buffer.length > t.forwarded) events.push(...this.emitText(t, t.buffer.slice(t.forwarded)));
8425
+ const idx = t.upstreamIndex + this.extraBlocks;
8426
+ if (!t.opened) {
8427
+ events.push(out({
8428
+ type: "content_block_start",
8429
+ index: idx,
8430
+ content_block: {
8431
+ type: "text",
8432
+ text: ""
8433
+ }
8434
+ }));
8435
+ t.opened = true;
8436
+ }
8437
+ events.push(out({
8438
+ type: "content_block_stop",
8439
+ index: idx
8440
+ }));
8441
+ return events;
8442
+ }
8443
+ finishCapture(t) {
8444
+ const segments = recoverSegments(t.buffer.slice(t.captureStart), this.knownTools);
8445
+ const hasTool = segments.some((s) => s.kind === "tool");
8446
+ const base = t.upstreamIndex + this.extraBlocks;
8447
+ const events = [];
8448
+ if (!hasTool) {
8449
+ const text = segments.map((s) => s.kind === "text" ? s.text : "").join("");
8450
+ if (text !== "") events.push(...this.emitText(t, text), out({
8451
+ type: "content_block_stop",
8452
+ index: base
8453
+ }));
8454
+ else if (t.opened) events.push(out({
8455
+ type: "content_block_stop",
8456
+ index: base
8457
+ }));
8458
+ else this.extraBlocks -= 1;
8459
+ return events;
8460
+ }
8461
+ this.converted = true;
8462
+ let cursor = base;
8463
+ if (t.opened) {
8464
+ events.push(out({
8465
+ type: "content_block_stop",
8466
+ index: base
8467
+ }));
8468
+ cursor = base + 1;
8469
+ }
8470
+ for (const [i, segment] of segments.entries()) {
8471
+ const idx = cursor + i;
8472
+ events.push(...segment.kind === "text" ? this.emitTextBlock(idx, segment.text) : this.emitToolBlock(idx, segment.call));
8473
+ }
8474
+ const clientBlocks = (t.opened ? 1 : 0) + segments.length;
8475
+ this.extraBlocks += clientBlocks - 1;
8476
+ return events;
8477
+ }
8478
+ };
8479
+
7687
8480
  //#endregion
7688
8481
  //#region src/routes/messages/direct-anthropic-handler.ts
7689
8482
  /**
@@ -7742,7 +8535,7 @@ async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiat
7742
8535
  });
7743
8536
  });
7744
8537
  }
7745
- return handleDirectAnthropicNonStreamingResponse(c, response, ctx, truncateResult, effectivePayload);
8538
+ return handleDirectAnthropicNonStreamingResponse(c, recoverLeakedToolCallsInResponse(response, toolNameSet(effectivePayload.tools)), ctx, truncateResult, effectivePayload);
7746
8539
  } catch (error) {
7747
8540
  if (error instanceof HTTPError && error.status === 413) logPayloadSizeInfoAnthropic(effectivePayload, selectedModel);
7748
8541
  recordErrorResponse(ctx, anthropicPayload.model, error, "messages", anthropicPayload.stream ?? false);
@@ -7860,6 +8653,19 @@ async function handleDirectAnthropicStreamingResponse(opts) {
7860
8653
  const acc = createAnthropicStreamAccumulator();
7861
8654
  const checkRepetition = createStreamRepetitionChecker(`anthropic:${anthropicPayload.model}`);
7862
8655
  const serverToolFilter = createServerToolBlockFilter();
8656
+ const recovery = new LeakedToolCallStreamRecovery(toolNameSet(anthropicPayload.tools));
8657
+ const forward = async (recovered) => {
8658
+ const outEvent = recovered.event;
8659
+ processAnthropicEvent(outEvent, acc);
8660
+ if (outEvent.type === "content_block_start") logServerToolBlock(outEvent.content_block);
8661
+ const forwardData = serverToolFilter.rewriteEvent(outEvent, recovered.data);
8662
+ if (forwardData === null) return;
8663
+ const echoedData = echoForwardData(forwardData, outEvent.type, ctx);
8664
+ await stream.writeSSE({
8665
+ event: outEvent.type,
8666
+ data: echoedData
8667
+ });
8668
+ };
7863
8669
  try {
7864
8670
  for await (const rawEvent of response) {
7865
8671
  consola.debug("Direct Anthropic raw stream event:", JSON.stringify(rawEvent));
@@ -7872,17 +8678,10 @@ async function handleDirectAnthropicStreamingResponse(opts) {
7872
8678
  consola.error("Failed to parse Anthropic stream event:", parseError, rawEvent.data);
7873
8679
  continue;
7874
8680
  }
7875
- processAnthropicEvent(event, acc);
7876
- if (event.type === "content_block_start") logServerToolBlock(event.content_block);
7877
8681
  if (event.type === "content_block_delta" && event.delta.type === "text_delta") checkRepetition(event.delta.text);
7878
- const forwardData = serverToolFilter.rewriteEvent(event, rawEvent.data);
7879
- if (forwardData === null) continue;
7880
- const echoedData = echoForwardData(forwardData, event.type, ctx);
7881
- await stream.writeSSE({
7882
- event: rawEvent.event || event.type,
7883
- data: echoedData
7884
- });
8682
+ for (const recovered of recovery.process(event, rawEvent.data)) await forward(recovered);
7885
8683
  }
8684
+ for (const recovered of recovery.flush()) await forward(recovered);
7886
8685
  recordAnthropicStreamingResponse(acc, anthropicPayload.model, ctx);
7887
8686
  completeTracking(ctx.trackingId, acc.inputTokens, acc.outputTokens, ctx.queueWaitMs, void 0, {
7888
8687
  model: acc.model || anthropicPayload.model,
@@ -8209,12 +9008,13 @@ async function handleCompletion(c) {
8209
9008
  system: extractSystemPrompt(p.system)
8210
9009
  })
8211
9010
  });
8212
- logToolInfo(anthropicPayload);
8213
- const subagentMarker = parseSubagentMarkerFromFirstUser(anthropicPayload);
9011
+ const sanitizedPayload = dePoisonAssistantMessages(anthropicPayload);
9012
+ logToolInfo(sanitizedPayload);
9013
+ const subagentMarker = parseSubagentMarkerFromFirstUser(sanitizedPayload);
8214
9014
  const initiatorOverride = subagentMarker ? "agent" : void 0;
8215
9015
  if (subagentMarker) consola.debug("Detected Subagent marker:", JSON.stringify(subagentMarker));
8216
- if (supportsDirectAnthropicApi(anthropicPayload.model)) return handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiatorOverride);
8217
- return handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOverride);
9016
+ if (supportsDirectAnthropicApi(sanitizedPayload.model)) return handleDirectAnthropicCompletion(c, sanitizedPayload, ctx, initiatorOverride);
9017
+ return handleTranslatedCompletion(c, sanitizedPayload, ctx, initiatorOverride);
8218
9018
  }
8219
9019
  /**
8220
9020
  * Log tool-related information for debugging
@@ -8610,7 +9410,7 @@ const handleResponses = async (c) => {
8610
9410
  normalizePayload: (p) => {
8611
9411
  const np = state.normalizeResponsesCallIds ? normalizeCallIds(p) : p;
8612
9412
  useFunctionApplyPatch(np);
8613
- removeWebSearchTool(np);
9413
+ filterUnsupportedBuiltins(np);
8614
9414
  return np;
8615
9415
  },
8616
9416
  buildHistoryRequest: (p) => {
@@ -8767,11 +9567,10 @@ const useFunctionApplyPatch = (payload) => {
8767
9567
  }
8768
9568
  }
8769
9569
  };
8770
- const removeWebSearchTool = (payload) => {
9570
+ const UNSUPPORTED_BUILTIN_TOOL_TYPES = new Set(["web_search", "image_generation"]);
9571
+ const filterUnsupportedBuiltins = (payload) => {
8771
9572
  if (!Array.isArray(payload.tools) || payload.tools.length === 0) return;
8772
- payload.tools = payload.tools.filter((t) => {
8773
- return t.type !== "web_search";
8774
- });
9573
+ payload.tools = payload.tools.filter((t) => typeof t.type !== "string" || !UNSUPPORTED_BUILTIN_TOOL_TYPES.has(t.type));
8775
9574
  };
8776
9575
  /** Record a ResponsesResult to history */
8777
9576
  function recordResponseResult(result, fallbackModel, historyId, startTime) {
@@ -8830,8 +9629,9 @@ usageRoute.get("/", async (c) => {
8830
9629
  const server = new Hono();
8831
9630
  server.use(tuiLogger());
8832
9631
  server.use(cors());
9632
+ server.use(authGate());
8833
9633
  server.get("/", (c) => c.text("Server running"));
8834
- server.get("/health", (c) => {
9634
+ const healthHandler = (c) => {
8835
9635
  const healthy = Boolean(state.copilotToken && state.githubToken);
8836
9636
  return c.json({
8837
9637
  status: healthy ? "healthy" : "unhealthy",
@@ -8841,7 +9641,9 @@ server.get("/health", (c) => {
8841
9641
  models: Boolean(state.models)
8842
9642
  }
8843
9643
  }, healthy ? 200 : 503);
8844
- });
9644
+ };
9645
+ server.get("/health", healthHandler);
9646
+ server.get("/health/", healthHandler);
8845
9647
  server.route("/chat/completions", completionRoutes);
8846
9648
  server.route("/models", modelRoutes);
8847
9649
  server.route("/usage", usageRoute);
@@ -8883,6 +9685,7 @@ function formatModelInfo(model) {
8883
9685
  }
8884
9686
  async function runServer(options) {
8885
9687
  consola.info(`copilot-api v${version}`);
9688
+ configureProxyApiKey(options.apiKey);
8886
9689
  if (options.proxyEnv) initProxyFromEnv();
8887
9690
  if (options.verbose) {
8888
9691
  consola.level = 5;
@@ -8953,7 +9756,7 @@ async function runServer(options) {
8953
9756
  const visibleModels = allModels.filter((m) => !isHiddenModel(m.id, state.showAllModels));
8954
9757
  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
9758
  else consola.info(`Available models:\n${visibleModels.map((m) => formatModelInfo(m)).join("\n")}`);
8956
- const serverUrl = `http://${options.host ?? "localhost"}:${options.port}`;
9759
+ const serverUrl = `http://${resolveClientHost(options.host, process.env.HOST)}:${options.port}`;
8957
9760
  if (options.claudeCode) {
8958
9761
  if (visibleModels.length === 0) {
8959
9762
  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 +9772,7 @@ async function runServer(options) {
8969
9772
  });
8970
9773
  const command = generateEnvScript({
8971
9774
  ANTHROPIC_BASE_URL: serverUrl,
8972
- ANTHROPIC_AUTH_TOKEN: "dummy",
9775
+ [CLAUDE_CODE_AUTH_TOKEN_ENV]: CLAUDE_CODE_AUTH_TOKEN_PLACEHOLDER,
8973
9776
  ANTHROPIC_MODEL: selectedModel,
8974
9777
  ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel,
8975
9778
  ANTHROPIC_SMALL_FAST_MODEL: selectedSmallModel,
@@ -8984,13 +9787,27 @@ async function runServer(options) {
8984
9787
  consola.warn("Failed to copy to clipboard. Here is the Claude Code command:");
8985
9788
  consola.log(command);
8986
9789
  }
9790
+ for (const line of buildClaudeCodeAuthHint(options.apiKeySource)) consola.warn(line);
8987
9791
  }
8988
9792
  consola.box(`🌐 Usage Viewer: https://ericc-ch.github.io/copilot-api?endpoint=${serverUrl}/usage${options.history ? `\n📜 History UI: ${serverUrl}/history` : ""}`);
9793
+ for (const line of buildStartupAuthLines({
9794
+ source: options.apiKeySource,
9795
+ bindAddress: resolveBindAddress(options.host, process.env.HOST)
9796
+ })) process.stdout.write(`${line}\n`);
8989
9797
  setupShutdownHandlers();
8990
9798
  setServerInstance(serve({
8991
9799
  fetch: server.fetch,
8992
9800
  port: options.port,
8993
- hostname: options.host
9801
+ hostname: options.host,
9802
+ bun: { websocket: {
9803
+ open(ws) {
9804
+ if (ws.data?.kind === "history") addClient(ws);
9805
+ },
9806
+ close(ws) {
9807
+ if (ws.data?.kind === "history") removeClient(ws);
9808
+ },
9809
+ message() {}
9810
+ } }
8994
9811
  }));
8995
9812
  }
8996
9813
  function parseTimezoneOffset(value) {
@@ -9072,7 +9889,7 @@ const start = defineCommand({
9072
9889
  "github-token": {
9073
9890
  alias: "g",
9074
9891
  type: "string",
9075
- description: "Provide GitHub token directly (must be generated using the `auth` subcommand)"
9892
+ 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
9893
  },
9077
9894
  "claude-code": {
9078
9895
  alias: "c",
@@ -9138,9 +9955,17 @@ const start = defineCommand({
9138
9955
  "posthog-key": {
9139
9956
  type: "string",
9140
9957
  description: "PostHog API key for token usage analytics (opt-in, no key = disabled)"
9958
+ },
9959
+ "api-key": {
9960
+ type: "string",
9961
+ 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
9962
  }
9142
9963
  },
9143
9964
  run({ args }) {
9965
+ const resolvedApiKey = resolveProxyApiKey({
9966
+ flag: args["api-key"],
9967
+ env: process.env.COPILOT_API_KEY
9968
+ });
9144
9969
  return runServer({
9145
9970
  port: Number.parseInt(args.port, 10),
9146
9971
  host: args.host,
@@ -9152,7 +9977,7 @@ const start = defineCommand({
9152
9977
  requestInterval: Number.parseInt(args["request-interval"], 10),
9153
9978
  recoveryTimeout: Number.parseInt(args["recovery-timeout"], 10),
9154
9979
  consecutiveSuccesses: Number.parseInt(args["consecutive-successes"], 10),
9155
- githubToken: args["github-token"],
9980
+ githubToken: args["github-token"] || process.env.GH_TOKEN,
9156
9981
  claudeCode: args["claude-code"],
9157
9982
  showToken: args["show-token"],
9158
9983
  showAllModels: args["show-all-models"],
@@ -9165,7 +9990,9 @@ const start = defineCommand({
9165
9990
  stripServerTools: args["strip-server-tools"],
9166
9991
  contextEditing: parseContextEditing(args["context-editing"]),
9167
9992
  timezoneOffset: parseTimezoneOffset(args["timezone-offset"]),
9168
- posthogKey: args["posthog-key"]
9993
+ posthogKey: args["posthog-key"],
9994
+ apiKey: resolvedApiKey.key,
9995
+ apiKeySource: resolvedApiKey.source
9169
9996
  });
9170
9997
  }
9171
9998
  });
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.1",
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": {