@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.
- package/README.md +125 -0
- package/dist/main.mjs +857 -30
- 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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
8213
|
-
|
|
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(
|
|
8217
|
-
return handleTranslatedCompletion(c,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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": {
|