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