@dianshuv/copilot-api 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +157 -29
  2. package/dist/main.mjs +1162 -1272
  3. package/package.json +4 -3
package/dist/main.mjs CHANGED
@@ -6,18 +6,17 @@ 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";
13
- import invariant from "tiny-invariant";
14
13
  import { PostHog } from "posthog-node";
15
14
  import { execSync } from "node:child_process";
16
15
  import process$1 from "node:process";
17
16
  import pc from "picocolors";
18
17
  import { Hono } from "hono";
19
18
  import { cors } from "hono/cors";
20
- import { stream, streamSSE } from "hono/streaming";
19
+ import { streamSSE } from "hono/streaming";
21
20
  import { events } from "fetch-event-stream";
22
21
 
23
22
  //#region src/lib/paths.ts
@@ -125,6 +124,7 @@ const state = {
125
124
  accountType: "individual",
126
125
  manualApprove: false,
127
126
  showToken: false,
127
+ showAllModels: false,
128
128
  verbose: false,
129
129
  autoTruncate: true,
130
130
  compressToolResults: false,
@@ -1348,7 +1348,7 @@ const patchClaude = defineCommand({
1348
1348
 
1349
1349
  //#endregion
1350
1350
  //#region package.json
1351
- var version = "0.9.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;
@@ -1876,6 +2185,56 @@ function createRequestContextManager(staleMaxAgeSec) {
1876
2185
  };
1877
2186
  }
1878
2187
 
2188
+ //#endregion
2189
+ //#region src/lib/hidden-models.ts
2190
+ /**
2191
+ * Hardcoded list of GitHub Copilot model ids that are hidden from listing
2192
+ * endpoints (the /v1/models response, the startup ASCII banner, and the
2193
+ * --claude-code interactive prompts), unless `--show-all-models` is passed.
2194
+ *
2195
+ * Note: this is a DISPLAY filter only. Explicit POSTs to handler endpoints
2196
+ * with a hidden id are NOT rejected — they pass through to upstream verbatim.
2197
+ *
2198
+ * Bumping the list requires a code change + release. No env var, no config
2199
+ * file, no CLI append interface.
2200
+ */
2201
+ const HIDDEN_MODEL_IDS = new Set([
2202
+ "gpt-3.5-turbo",
2203
+ "gpt-3.5-turbo-0613",
2204
+ "gpt-4",
2205
+ "gpt-4-0613",
2206
+ "gpt-4-0125-preview",
2207
+ "gpt-4o",
2208
+ "gpt-4o-mini",
2209
+ "gpt-4-o-preview",
2210
+ "gpt-4o-2024-05-13",
2211
+ "gpt-4o-2024-08-06",
2212
+ "gpt-4o-2024-11-20",
2213
+ "gpt-4o-mini-2024-07-18",
2214
+ "gpt-4.1",
2215
+ "gpt-4.1-2025-04-14",
2216
+ "gpt-41-copilot",
2217
+ "gpt-5-mini",
2218
+ "gpt-5.3-codex",
2219
+ "gpt-5.4",
2220
+ "text-embedding-ada-002",
2221
+ "text-embedding-3-small",
2222
+ "text-embedding-3-small-inference",
2223
+ "gemini-2.5-pro",
2224
+ "gemini-3-flash-preview",
2225
+ "claude-opus-4.5",
2226
+ "claude-opus-4.6",
2227
+ "claude-opus-4.7-high",
2228
+ "claude-opus-4.7-xhigh",
2229
+ "claude-sonnet-4.5",
2230
+ "mai-code-1-flash-internal",
2231
+ "trajectory-compaction"
2232
+ ]);
2233
+ function isHiddenModel(id, showAll) {
2234
+ if (showAll) return false;
2235
+ return HIDDEN_MODEL_IDS.has(id);
2236
+ }
2237
+
1879
2238
  //#endregion
1880
2239
  //#region src/lib/history-ws.ts
1881
2240
  /**
@@ -1883,6 +2242,18 @@ function createRequestContextManager(staleMaxAgeSec) {
1883
2242
  * Enables real-time updates when new requests are recorded.
1884
2243
  */
1885
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
+ }
1886
2257
  function getClientCount() {
1887
2258
  return clients.size;
1888
2259
  }
@@ -3042,158 +3413,6 @@ const awaitApproval = async () => {
3042
3413
  if (!await consola.prompt(`Accept incoming request?`, { type: "confirm" })) throw new HTTPError("Request rejected", 403, JSON.stringify({ message: "Request rejected" }));
3043
3414
  };
3044
3415
 
3045
- //#endregion
3046
- //#region src/lib/echo-model.ts
3047
- /**
3048
- * Capture the requested model id from a raw request `model` value, classifying
3049
- * it into the three-state contract. `undefined`/missing → `absent`; `""` →
3050
- * `empty`; any other string → `present`.
3051
- */
3052
- function captureRequestedModel(rawModel) {
3053
- if (rawModel === void 0 || rawModel === null) return { kind: "absent" };
3054
- if (typeof rawModel !== "string") return { kind: "absent" };
3055
- if (rawModel === "") return { kind: "empty" };
3056
- return {
3057
- kind: "present",
3058
- value: rawModel
3059
- };
3060
- }
3061
- /**
3062
- * Resolve a {@link RequestedModel} into the action to take on a model field:
3063
- * - `{ write: true, value }` → set the field to `value`.
3064
- * - `{ write: false, omit: true }` → remove the field (absent case).
3065
- * - `null` → leave the field untouched (context-missing case).
3066
- */
3067
- function resolveFieldAction(requested) {
3068
- if (requested.kind === "present") return { value: requested.value };
3069
- if (requested.kind === "empty") return { value: "" };
3070
- if (requested.kind === "absent") return { omit: true };
3071
- return null;
3072
- }
3073
- /**
3074
- * Apply the requested-model action to a single `model`-like key on a shallow
3075
- * clone of `obj`. Returns a new object; never mutates `obj`. If `obj` does not
3076
- * own `key`, it is returned (cloned) unchanged regardless of the action — we
3077
- * only ever rewrite a field the upstream payload actually carries (supports
3078
- * AC-MALFORMED-SSE: no model field → original passthrough).
3079
- */
3080
- function rewriteKey(obj, key, requested) {
3081
- if (!Object.hasOwn(obj, key)) return obj;
3082
- const action = resolveFieldAction(requested);
3083
- if (action === null) return obj;
3084
- if ("omit" in action) {
3085
- const { [key]: _omitted, ...rest } = obj;
3086
- return rest;
3087
- }
3088
- return {
3089
- ...obj,
3090
- [key]: action.value
3091
- };
3092
- }
3093
- /**
3094
- * Core rewrite over a plain record. Rewrites the documented model fields:
3095
- * - top-level `model`, top-level `modelVersion` (Gemini),
3096
- * - nested `message.model` (Anthropic message_start),
3097
- * - nested `response.model` (OpenAI Responses event).
3098
- * Returns a shallow clone; never mutates the input.
3099
- */
3100
- function echoRecord(body, requested) {
3101
- let out = rewriteKey(body, "model", requested);
3102
- out = rewriteKey(out, "modelVersion", requested);
3103
- if (isRecord$1(out.message) && Object.hasOwn(out.message, "model")) {
3104
- const newMessage = rewriteKey(out.message, "model", requested);
3105
- if (newMessage !== out.message) out = {
3106
- ...out,
3107
- message: newMessage
3108
- };
3109
- }
3110
- if (isRecord$1(out.response) && Object.hasOwn(out.response, "model")) {
3111
- const newResponse = rewriteKey(out.response, "model", requested);
3112
- if (newResponse !== out.response) out = {
3113
- ...out,
3114
- response: newResponse
3115
- };
3116
- }
3117
- return out;
3118
- }
3119
- /**
3120
- * Rewrite the documented client-facing model field(s) of a JSON response body
3121
- * to the requested model id. Handles every supported non-stream/body shape:
3122
- * - top-level `model` (OpenAI chat/completions & Responses bodies, Anthropic
3123
- * messages body, embeddings),
3124
- * - nested `message.model` (Anthropic `message_start` event object),
3125
- * - nested `response.model` (OpenAI Responses streaming event object),
3126
- * - top-level `modelVersion` (Gemini body & chunk).
3127
- *
3128
- * Returns a shallow-cloned object; the input is never mutated. Fields that are
3129
- * not present are left as-is (a payload with no model field round-trips
3130
- * unchanged), supporting AC-MALFORMED-SSE.
3131
- *
3132
- * The generic is constrained to `object` (not an index-signature shape) so it
3133
- * accepts the project's domain interfaces (`AnthropicResponse`,
3134
- * `ChatCompletionChunk`, …) directly without forcing callers to widen them.
3135
- */
3136
- function echoModelInResponseBody(body, requested) {
3137
- return echoRecord(body, requested);
3138
- }
3139
- /**
3140
- * Ensure a response body's top-level `model` field equals the requested model
3141
- * id — **setting it even when the body omits it**. This differs from
3142
- * {@link echoModelInResponseBody}, which only rewrites a `model` field the
3143
- * payload already carries (the passthrough rule that protects malformed-SSE /
3144
- * model-less events). Some upstreams omit `model` from an otherwise-valid
3145
- * response body (notably the Copilot embeddings endpoint, whose 200 body carries
3146
- * only `data` + `usage`); for those, the documented client-facing contract is
3147
- * still "`response.model` == R", so the field must be added, not skipped.
3148
- *
3149
- * Three-state per AC-MISSING-MODEL, keyed on the REQUESTER's model (not the
3150
- * upstream's):
3151
- * - `present` → set `model` to R (added if absent, overwritten if present).
3152
- * - `empty` → set `model` to "" (the client sent an empty string).
3153
- * - `absent` → omit `model` (client sent no model → never invent one); if the
3154
- * body happened to carry an upstream `model`, drop it.
3155
- * - `context-missing` → leave the body untouched (defensive passthrough).
3156
- *
3157
- * Only the documented client's own string R is ever written — never the upstream
3158
- * id — so this introduces no side channel (AC-NO-SIDECHANNEL). Returns a shallow
3159
- * clone; the input is never mutated (protects the AC-OBS data source).
3160
- */
3161
- function echoTopLevelModel(body, requested) {
3162
- if (requested.kind === "context-missing") return body;
3163
- const rec = body;
3164
- if (requested.kind === "absent") {
3165
- if (!Object.hasOwn(rec, "model")) return body;
3166
- const { model: _dropped, ...rest } = rec;
3167
- return rest;
3168
- }
3169
- const value = requested.kind === "present" ? requested.value : "";
3170
- return {
3171
- ...rec,
3172
- model: value
3173
- };
3174
- }
3175
- function isRecord$1(value) {
3176
- return typeof value === "object" && value !== null && !Array.isArray(value);
3177
- }
3178
- /**
3179
- * Rewrite the model field of an already-parsed SSE event payload to the
3180
- * requested model id, dispatched by protocol shape:
3181
- * - Anthropic `message_start` → `message.model`,
3182
- * - OpenAI chunk → top-level `model`,
3183
- * - OpenAI Responses event → nested `response.model`,
3184
- * - Gemini chunk → `modelVersion`.
3185
- *
3186
- * The field dispatch is identical to {@link echoModelInResponseBody} (both
3187
- * operate on the same documented model fields), so this delegates to the same
3188
- * core rather than duplicating the shape logic — the two exports exist to name
3189
- * the two responsibilities (body vs parsed-event) at call sites, per the PRD's
3190
- * single-policy-point design. Events with no model field round-trip unchanged
3191
- * (AC-MALFORMED-SSE); the input is never mutated.
3192
- */
3193
- function echoModelInParsedEvent(event, requested) {
3194
- return echoRecord(event, requested);
3195
- }
3196
-
3197
3416
  //#endregion
3198
3417
  //#region src/lib/message-sanitizer.ts
3199
3418
  const startPattern = /^\s*<system-reminder>[\s\S]*?<\/system-reminder>\n*/;
@@ -3215,144 +3434,23 @@ function removeSystemReminderTags(text) {
3215
3434
  }
3216
3435
 
3217
3436
  //#endregion
3218
- //#region src/lib/repetition-detector.ts
3437
+ //#region src/lib/tokenizer.ts
3438
+ const ENCODING_MAP = {
3439
+ o200k_base: () => import("gpt-tokenizer/encoding/o200k_base"),
3440
+ cl100k_base: () => import("gpt-tokenizer/encoding/cl100k_base"),
3441
+ p50k_base: () => import("gpt-tokenizer/encoding/p50k_base"),
3442
+ p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
3443
+ r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
3444
+ };
3445
+ const encodingCache = /* @__PURE__ */ new Map();
3219
3446
  /**
3220
- * Stream repetition detector.
3221
- *
3222
- * Uses the KMP failure function (prefix function) to detect repeated patterns
3223
- * in streaming text output. When a model gets stuck in a repetitive loop,
3224
- * it wastes tokens producing the same content over and over. This detector
3225
- * identifies such loops early so the caller can take action (log warning,
3226
- * abort stream, etc.).
3227
- *
3228
- * The algorithm works by maintaining a sliding buffer of recent text and
3229
- * computing the longest proper prefix that is also a suffix — if this
3230
- * length exceeds `(text.length - period) >= minRepetitions * period`,
3231
- * it means a pattern of length `period` has repeated enough times.
3447
+ * Calculate tokens for tool calls
3232
3448
  */
3233
- const DEFAULT_CONFIG = {
3234
- minPatternLength: 10,
3235
- minRepetitions: 3,
3236
- maxBufferSize: 5e3
3237
- };
3238
- var RepetitionDetector = class {
3239
- buffer = "";
3240
- config;
3241
- detected = false;
3242
- constructor(config) {
3243
- this.config = {
3244
- ...DEFAULT_CONFIG,
3245
- ...config
3246
- };
3247
- }
3248
- /**
3249
- * Feed a text chunk into the detector.
3250
- * Returns `true` if repetition has been detected (now or previously).
3251
- * Once detected, subsequent calls return `true` without further analysis.
3252
- */
3253
- feed(text) {
3254
- if (this.detected) return true;
3255
- if (!text) return false;
3256
- this.buffer += text;
3257
- if (this.buffer.length > this.config.maxBufferSize) this.buffer = this.buffer.slice(-this.config.maxBufferSize);
3258
- const minRequired = this.config.minPatternLength * this.config.minRepetitions;
3259
- if (this.buffer.length < minRequired) return false;
3260
- this.detected = detectRepetition(this.buffer, this.config.minPatternLength, this.config.minRepetitions);
3261
- return this.detected;
3262
- }
3263
- /** Reset detector state for a new stream */
3264
- reset() {
3265
- this.buffer = "";
3266
- this.detected = false;
3267
- }
3268
- /** Whether repetition has been detected */
3269
- get isDetected() {
3270
- return this.detected;
3271
- }
3272
- };
3273
- /**
3274
- * Detect if the tail of `text` contains a repeating pattern.
3275
- *
3276
- * Uses the KMP prefix function: for a string S, the prefix function π[i]
3277
- * gives the length of the longest proper prefix of S[0..i] that is also
3278
- * a suffix. If π[n-1] ≥ (n - period) where period = n - π[n-1], then
3279
- * the string is composed of a repeating unit of length `period`.
3280
- *
3281
- * We check the suffix of the buffer (last `checkLength` chars) to detect
3282
- * if a pattern of at least `minPatternLength` chars repeats at least
3283
- * `minRepetitions` times.
3284
- */
3285
- function detectRepetition(text, minPatternLength, minRepetitions) {
3286
- const minWindow = minPatternLength * minRepetitions;
3287
- const maxWindow = Math.min(text.length, 2e3);
3288
- const windowSizes = [
3289
- minWindow,
3290
- Math.floor(maxWindow * .5),
3291
- maxWindow
3292
- ].filter((w) => w >= minWindow && w <= text.length);
3293
- for (const windowSize of windowSizes) {
3294
- const window = text.slice(-windowSize);
3295
- const period = findRepeatingPeriod(window);
3296
- if (period >= minPatternLength) {
3297
- if (Math.floor(window.length / period) >= minRepetitions) return true;
3298
- }
3299
- }
3300
- return false;
3301
- }
3302
- /**
3303
- * Find the shortest repeating period in a string using KMP prefix function.
3304
- * Returns the period length, or the string length if no repetition found.
3305
- */
3306
- function findRepeatingPeriod(s) {
3307
- const n = s.length;
3308
- if (n === 0) return 0;
3309
- const pi = new Int32Array(n);
3310
- for (let i = 1; i < n; i++) {
3311
- let j = pi[i - 1] ?? 0;
3312
- while (j > 0 && s[i] !== s[j]) j = pi[j - 1] ?? 0;
3313
- if (s[i] === s[j]) j++;
3314
- pi[i] = j;
3315
- }
3316
- const period = n - pi[n - 1];
3317
- if (period < n && n % period === 0) return period;
3318
- if (period < n && pi[n - 1] >= period) return period;
3319
- return n;
3320
- }
3321
- /**
3322
- * Create a repetition detector callback for use in stream processing.
3323
- * Returns a function that accepts text deltas and logs a warning on first detection.
3324
- */
3325
- function createStreamRepetitionChecker(label, config) {
3326
- const detector = new RepetitionDetector(config);
3327
- let warned = false;
3328
- return (textDelta) => {
3329
- const isRepetitive = detector.feed(textDelta);
3330
- if (isRepetitive && !warned) {
3331
- warned = true;
3332
- consola.warn(`[RepetitionDetector] ${label}: Repetitive output detected in stream`);
3333
- }
3334
- return isRepetitive;
3335
- };
3336
- }
3337
-
3338
- //#endregion
3339
- //#region src/lib/tokenizer.ts
3340
- const ENCODING_MAP = {
3341
- o200k_base: () => import("gpt-tokenizer/encoding/o200k_base"),
3342
- cl100k_base: () => import("gpt-tokenizer/encoding/cl100k_base"),
3343
- p50k_base: () => import("gpt-tokenizer/encoding/p50k_base"),
3344
- p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
3345
- r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
3346
- };
3347
- const encodingCache = /* @__PURE__ */ new Map();
3348
- /**
3349
- * Calculate tokens for tool calls
3350
- */
3351
- const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
3352
- let tokens = 0;
3353
- for (const toolCall of toolCalls) {
3354
- tokens += constants.funcInit;
3355
- tokens += encoder.encode(JSON.stringify(toolCall)).length;
3449
+ const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
3450
+ let tokens = 0;
3451
+ for (const toolCall of toolCalls) {
3452
+ tokens += constants.funcInit;
3453
+ tokens += encoder.encode(JSON.stringify(toolCall)).length;
3356
3454
  }
3357
3455
  tokens += constants.funcEnd;
3358
3456
  return tokens;
@@ -3547,135 +3645,6 @@ const getTokenCount = async (payload, model) => {
3547
3645
  };
3548
3646
  };
3549
3647
 
3550
- //#endregion
3551
- //#region src/lib/anthropic/beta.ts
3552
- /**
3553
- * Vendor-neutral utilities for manipulating the `anthropic-beta` request header.
3554
- *
3555
- * Lives in `lib/anthropic/` (not in either transport module) so both the
3556
- * Anthropic-native and OpenAI-translated transport layers can share these
3557
- * helpers without introducing cross-transport imports.
3558
- */
3559
- /** Anthropic beta feature that unlocks the 1M context window. */
3560
- const CONTEXT_1M_BETA_FEATURE = "context-1m-2025-08-07";
3561
- /**
3562
- * Merge two comma-separated anthropic-beta header values. Trims whitespace,
3563
- * drops empty tokens, and dedupes by exact string match. Returns a canonical
3564
- * comma-joined string with no spaces.
3565
- *
3566
- * Either input may be undefined / empty.
3567
- */
3568
- function mergeBetaFeatures(existing, incoming) {
3569
- const seen = /* @__PURE__ */ new Set();
3570
- const out = [];
3571
- for (const raw of [existing, incoming]) {
3572
- if (!raw) continue;
3573
- for (const part of raw.split(",")) {
3574
- const f = part.trim();
3575
- if (f.length === 0 || seen.has(f)) continue;
3576
- seen.add(f);
3577
- out.push(f);
3578
- }
3579
- }
3580
- return out.join(",");
3581
- }
3582
- /**
3583
- * Append the context-1m feature to an anthropic-beta header value, deduping
3584
- * any prior occurrence. Returns the merged comma-separated string.
3585
- */
3586
- function appendContext1mBeta(existing) {
3587
- return mergeBetaFeatures(existing, CONTEXT_1M_BETA_FEATURE);
3588
- }
3589
- /**
3590
- * True iff a model id appears to be the suffixed 1M-context variant of an
3591
- * Anthropic Claude model (e.g. claude-opus-4-8-1m, claude-opus-4.6-1m).
3592
- *
3593
- * Used as a state.models-independent signal for whether to inject the
3594
- * context-1m-2025-08-07 beta header, so the 1M intent survives a stale or
3595
- * empty model cache (where `resolveAnthropicModelForDirectPath` would return
3596
- * undefined). Forwarding the beta is harmless to upstreams that ignore it.
3597
- */
3598
- function isOneMillionSuffixedClaudeId(modelId) {
3599
- return modelId.startsWith("claude-") && modelId.endsWith("-1m");
3600
- }
3601
-
3602
- //#endregion
3603
- //#region src/lib/headers.ts
3604
- /**
3605
- * Vendor-neutral header-bag helpers.
3606
- *
3607
- * HTTP header names are case-insensitive, but a plain-object header bag is
3608
- * case-sensitive on its keys. Code that wants to look up "anthropic-beta"
3609
- * without knowing whether some other producer wrote "Anthropic-Beta" needs
3610
- * `findHeaderKey`. Code that wants to set a header without creating a
3611
- * second case variant of the same name needs `setHeader`.
3612
- */
3613
- /** Case-insensitive lookup of a header key in a plain-object header bag. */
3614
- function findHeaderKey(headers, name) {
3615
- const lower = name.toLowerCase();
3616
- return Object.keys(headers).find((k) => k.toLowerCase() === lower);
3617
- }
3618
- /** Case-insensitive read of a header value. */
3619
- function getHeader(headers, name) {
3620
- const key = findHeaderKey(headers, name);
3621
- return key === void 0 ? void 0 : headers[key];
3622
- }
3623
- /**
3624
- * Set a header value at the existing case variant if one is present, else at
3625
- * the supplied canonical name. Prevents a second key (different case) from
3626
- * being added for the same logical header.
3627
- */
3628
- function setHeader(headers, name, value) {
3629
- const key = findHeaderKey(headers, name) ?? name;
3630
- headers[key] = value;
3631
- }
3632
-
3633
- //#endregion
3634
- //#region src/services/copilot/create-chat-completions.ts
3635
- const GPT_MODEL_PATTERN = /^gpt-/i;
3636
- const createChatCompletions = async (payload, options) => {
3637
- if (!state.copilotToken) throw new Error("Copilot token not found");
3638
- const vendor = options?.resolvedModel?.vendor;
3639
- const isOpenAIVendor = vendor === "OpenAI" || vendor === "Azure OpenAI";
3640
- const isLikelyGPT = !options?.resolvedModel && GPT_MODEL_PATTERN.test(payload.model);
3641
- let wire = payload;
3642
- if (isOpenAIVendor || isLikelyGPT) {
3643
- const { max_tokens, max_completion_tokens, ...rest } = payload;
3644
- const effective = max_completion_tokens ?? max_tokens;
3645
- wire = {
3646
- ...rest,
3647
- ...effective !== null && effective !== void 0 && { max_completion_tokens: effective }
3648
- };
3649
- }
3650
- const enableVision = wire.messages.some((x) => typeof x.content !== "string" && x.content?.some((x) => x.type === "image_url"));
3651
- const isAgentCall = wire.messages.some((msg) => ["assistant", "tool"].includes(msg.role));
3652
- const modelSupportsVision = options?.resolvedModel?.capabilities?.supports?.vision !== false;
3653
- const headers = {
3654
- ...copilotHeaders(state, {
3655
- vision: enableVision && modelSupportsVision,
3656
- modelRequestHeaders: options?.resolvedModel?.request_headers,
3657
- intent: isAgentCall ? "conversation-agent" : "conversation-panel"
3658
- }),
3659
- "X-Initiator": options?.initiator ?? (isAgentCall ? "agent" : "user")
3660
- };
3661
- if (options?.anthropicBeta) {
3662
- const existingKey = findHeaderKey(headers, "anthropic-beta") ?? "anthropic-beta";
3663
- headers[existingKey] = mergeBetaFeatures(headers[existingKey], options.anthropicBeta);
3664
- consola.debug(`[ChatCompletions] anthropic-beta after merge: ${headers[existingKey]}`);
3665
- }
3666
- const response = await copilotFetch("/chat/completions", {
3667
- method: "POST",
3668
- headers,
3669
- body: JSON.stringify(wire)
3670
- });
3671
- if (!response.ok) {
3672
- consola.error("Failed to create chat completions", response);
3673
- throw await HTTPError.fromResponse("Failed to create chat completions", response, options?.errorModelIdOverride ?? payload.model);
3674
- }
3675
- if (payload.stream) return events(response);
3676
- return await response.json();
3677
- };
3678
-
3679
3648
  //#endregion
3680
3649
  //#region src/lib/auto-truncate-openai.ts
3681
3650
  /**
@@ -3865,7 +3834,7 @@ function createTruncationSystemContext$1(removedCount, compressedCount, summary)
3865
3834
  return context;
3866
3835
  }
3867
3836
  /** Create a truncation marker message (fallback when no system message) */
3868
- function createTruncationMarker$2(removedCount, compressedCount, summary) {
3837
+ function createTruncationMarker$1(removedCount, compressedCount, summary) {
3869
3838
  const parts = [];
3870
3839
  if (removedCount > 0) parts.push(`${removedCount} earlier messages removed`);
3871
3840
  if (compressedCount > 0) parts.push(`${compressedCount} tool results compressed`);
@@ -3992,7 +3961,7 @@ async function autoTruncateOpenAI(payload, model, config = {}) {
3992
3961
  content: typeof lastSystem.content === "string" ? lastSystem.content + truncationContext : lastSystem.content
3993
3962
  };
3994
3963
  newSystemMessages = [...systemMessages.slice(0, lastSystemIdx), updatedSystem];
3995
- } else newMessages = [createTruncationMarker$2(removedCount, compressedCount, summary), ...preserved];
3964
+ } else newMessages = [createTruncationMarker$1(removedCount, compressedCount, summary), ...preserved];
3996
3965
  const newPayload = {
3997
3966
  ...payload,
3998
3967
  messages: [...newSystemMessages, ...newMessages]
@@ -4018,22 +3987,481 @@ async function autoTruncateOpenAI(payload, model, config = {}) {
4018
3987
  }
4019
3988
 
4020
3989
  //#endregion
4021
- //#region src/lib/error-metrics.ts
4022
- function safeString(value, max = 200) {
4023
- try {
4024
- if (typeof value !== "string") return void 0;
4025
- return value.length > max ? value.slice(0, max) : value;
4026
- } catch {
4027
- return;
4028
- }
3990
+ //#region src/lib/openai-payload-prep.ts
3991
+ /**
3992
+ * OpenAI-shape payload preparation.
3993
+ *
3994
+ * Pre-flight steps for any request whose final payload is an OpenAI
3995
+ * ChatCompletionsPayload — auto-truncate decisions, 413 diagnostic logging,
3996
+ * and the non-streaming type guard. Used by both `routes/chat-completions`
3997
+ * (native OpenAI) and `routes/messages/translated-handler` (Anthropic that
3998
+ * gets translated into OpenAI shape before hitting upstream).
3999
+ */
4000
+ /** Type guard for non-streaming responses */
4001
+ function isNonStreaming(response) {
4002
+ return Object.hasOwn(response, "choices");
4029
4003
  }
4030
- function getStringProp(obj, key, max = 200) {
4031
- try {
4032
- if (typeof obj !== "object" || obj === null) return void 0;
4033
- return safeString(obj[key], max);
4034
- } catch {
4035
- return;
4036
- }
4004
+ /** Build final payload with auto-truncate if needed */
4005
+ async function buildFinalPayload(payload, model, autoTruncateConfig = {}) {
4006
+ if (!state.autoTruncate || !model) {
4007
+ if (state.autoTruncate && !model) consola.warn(`Auto-truncate: Model '${payload.model}' not found in cached models, skipping`);
4008
+ return {
4009
+ finalPayload: payload,
4010
+ truncateResult: null
4011
+ };
4012
+ }
4013
+ try {
4014
+ const check = await checkNeedsCompactionOpenAI(payload, model, autoTruncateConfig);
4015
+ consola.debug(`Auto-truncate check: ${check.currentTokens} tokens (limit ${check.tokenLimit}), ${Math.round(check.currentBytes / 1024)}KB (limit ${check.byteLimit === Infinity ? "unlimited" : `${Math.round(check.byteLimit / 1024)}KB`}), needed: ${check.needed}${check.reason ? ` (${check.reason})` : ""}`);
4016
+ if (!check.needed) return {
4017
+ finalPayload: payload,
4018
+ truncateResult: null
4019
+ };
4020
+ let reasonText;
4021
+ if (check.reason === "both") reasonText = "tokens and size";
4022
+ else if (check.reason === "bytes") reasonText = "size";
4023
+ else reasonText = "tokens";
4024
+ consola.info(`Auto-truncate triggered: exceeds ${reasonText} limit`);
4025
+ const truncateResult = await autoTruncateOpenAI(payload, model, autoTruncateConfig);
4026
+ return {
4027
+ finalPayload: truncateResult.payload,
4028
+ truncateResult
4029
+ };
4030
+ } catch (error) {
4031
+ consola.warn("Auto-truncate failed, proceeding with original payload:", error instanceof Error ? error.message : error);
4032
+ return {
4033
+ finalPayload: payload,
4034
+ truncateResult: null
4035
+ };
4036
+ }
4037
+ }
4038
+ /**
4039
+ * Log helpful debugging information when a 413 error occurs.
4040
+ * Also adjusts the dynamic byte limit for future requests.
4041
+ */
4042
+ async function logPayloadSizeInfo(payload, model) {
4043
+ const messageCount = payload.messages.length;
4044
+ const bodySize = JSON.stringify(payload).length;
4045
+ const bodySizeKB = Math.round(bodySize / 1024);
4046
+ onRequestTooLarge(bodySize);
4047
+ let imageCount = 0;
4048
+ let largeMessages = 0;
4049
+ let totalImageSize = 0;
4050
+ for (const msg of payload.messages) {
4051
+ if (Array.isArray(msg.content)) {
4052
+ for (const part of msg.content) if (part.type === "image_url") {
4053
+ imageCount++;
4054
+ if (part.image_url.url.startsWith("data:")) totalImageSize += part.image_url.url.length;
4055
+ }
4056
+ }
4057
+ if ((typeof msg.content === "string" ? msg.content.length : JSON.stringify(msg.content).length) > 5e4) largeMessages++;
4058
+ }
4059
+ consola.info("");
4060
+ consola.info("╭─────────────────────────────────────────────────────────╮");
4061
+ consola.info("│ 413 Request Entity Too Large │");
4062
+ consola.info("╰─────────────────────────────────────────────────────────╯");
4063
+ consola.info("");
4064
+ consola.info(` Request body size: ${bodySizeKB} KB (${bodySize.toLocaleString()} bytes)`);
4065
+ consola.info(` Message count: ${messageCount}`);
4066
+ if (model) try {
4067
+ const tokenCount = await getTokenCount(payload, model);
4068
+ const limit = model.capabilities?.limits?.max_prompt_tokens ?? 128e3;
4069
+ consola.info(` Estimated tokens: ${tokenCount.input.toLocaleString()} / ${limit.toLocaleString()}`);
4070
+ } catch {}
4071
+ if (imageCount > 0) {
4072
+ const imageSizeKB = Math.round(totalImageSize / 1024);
4073
+ consola.info(` Images: ${imageCount} (${imageSizeKB} KB base64 data)`);
4074
+ }
4075
+ if (largeMessages > 0) consola.info(` Large messages (>50KB): ${largeMessages}`);
4076
+ consola.info("");
4077
+ consola.info(" Suggestions:");
4078
+ if (!state.autoTruncate) consola.info(" • Enable --auto-truncate to automatically truncate history");
4079
+ if (imageCount > 0) consola.info(" • Remove or resize large images in the conversation");
4080
+ consola.info(" • Start a new conversation with /clear or /reset");
4081
+ consola.info(" • Reduce conversation history by deleting old messages");
4082
+ consola.info("");
4083
+ }
4084
+
4085
+ //#endregion
4086
+ //#region src/lib/repetition-detector.ts
4087
+ /**
4088
+ * Stream repetition detector.
4089
+ *
4090
+ * Uses the KMP failure function (prefix function) to detect repeated patterns
4091
+ * in streaming text output. When a model gets stuck in a repetitive loop,
4092
+ * it wastes tokens producing the same content over and over. This detector
4093
+ * identifies such loops early so the caller can take action (log warning,
4094
+ * abort stream, etc.).
4095
+ *
4096
+ * The algorithm works by maintaining a sliding buffer of recent text and
4097
+ * computing the longest proper prefix that is also a suffix — if this
4098
+ * length exceeds `(text.length - period) >= minRepetitions * period`,
4099
+ * it means a pattern of length `period` has repeated enough times.
4100
+ */
4101
+ const DEFAULT_CONFIG = {
4102
+ minPatternLength: 10,
4103
+ minRepetitions: 3,
4104
+ maxBufferSize: 5e3
4105
+ };
4106
+ var RepetitionDetector = class {
4107
+ buffer = "";
4108
+ config;
4109
+ detected = false;
4110
+ constructor(config) {
4111
+ this.config = {
4112
+ ...DEFAULT_CONFIG,
4113
+ ...config
4114
+ };
4115
+ }
4116
+ /**
4117
+ * Feed a text chunk into the detector.
4118
+ * Returns `true` if repetition has been detected (now or previously).
4119
+ * Once detected, subsequent calls return `true` without further analysis.
4120
+ */
4121
+ feed(text) {
4122
+ if (this.detected) return true;
4123
+ if (!text) return false;
4124
+ this.buffer += text;
4125
+ if (this.buffer.length > this.config.maxBufferSize) this.buffer = this.buffer.slice(-this.config.maxBufferSize);
4126
+ const minRequired = this.config.minPatternLength * this.config.minRepetitions;
4127
+ if (this.buffer.length < minRequired) return false;
4128
+ this.detected = detectRepetition(this.buffer, this.config.minPatternLength, this.config.minRepetitions);
4129
+ return this.detected;
4130
+ }
4131
+ /** Reset detector state for a new stream */
4132
+ reset() {
4133
+ this.buffer = "";
4134
+ this.detected = false;
4135
+ }
4136
+ /** Whether repetition has been detected */
4137
+ get isDetected() {
4138
+ return this.detected;
4139
+ }
4140
+ };
4141
+ /**
4142
+ * Detect if the tail of `text` contains a repeating pattern.
4143
+ *
4144
+ * Uses the KMP prefix function: for a string S, the prefix function π[i]
4145
+ * gives the length of the longest proper prefix of S[0..i] that is also
4146
+ * a suffix. If π[n-1] ≥ (n - period) where period = n - π[n-1], then
4147
+ * the string is composed of a repeating unit of length `period`.
4148
+ *
4149
+ * We check the suffix of the buffer (last `checkLength` chars) to detect
4150
+ * if a pattern of at least `minPatternLength` chars repeats at least
4151
+ * `minRepetitions` times.
4152
+ */
4153
+ function detectRepetition(text, minPatternLength, minRepetitions) {
4154
+ const minWindow = minPatternLength * minRepetitions;
4155
+ const maxWindow = Math.min(text.length, 2e3);
4156
+ const windowSizes = [
4157
+ minWindow,
4158
+ Math.floor(maxWindow * .5),
4159
+ maxWindow
4160
+ ].filter((w) => w >= minWindow && w <= text.length);
4161
+ for (const windowSize of windowSizes) {
4162
+ const window = text.slice(-windowSize);
4163
+ const period = findRepeatingPeriod(window);
4164
+ if (period >= minPatternLength) {
4165
+ if (Math.floor(window.length / period) >= minRepetitions) return true;
4166
+ }
4167
+ }
4168
+ return false;
4169
+ }
4170
+ /**
4171
+ * Find the shortest repeating period in a string using KMP prefix function.
4172
+ * Returns the period length, or the string length if no repetition found.
4173
+ */
4174
+ function findRepeatingPeriod(s) {
4175
+ const n = s.length;
4176
+ if (n === 0) return 0;
4177
+ const pi = new Int32Array(n);
4178
+ for (let i = 1; i < n; i++) {
4179
+ let j = pi[i - 1] ?? 0;
4180
+ while (j > 0 && s[i] !== s[j]) j = pi[j - 1] ?? 0;
4181
+ if (s[i] === s[j]) j++;
4182
+ pi[i] = j;
4183
+ }
4184
+ const period = n - pi[n - 1];
4185
+ if (period < n && n % period === 0) return period;
4186
+ if (period < n && pi[n - 1] >= period) return period;
4187
+ return n;
4188
+ }
4189
+ /**
4190
+ * Create a repetition detector callback for use in stream processing.
4191
+ * Returns a function that accepts text deltas and logs a warning on first detection.
4192
+ */
4193
+ function createStreamRepetitionChecker(label, config) {
4194
+ const detector = new RepetitionDetector(config);
4195
+ let warned = false;
4196
+ return (textDelta) => {
4197
+ const isRepetitive = detector.feed(textDelta);
4198
+ if (isRepetitive && !warned) {
4199
+ warned = true;
4200
+ consola.warn(`[RepetitionDetector] ${label}: Repetitive output detected in stream`);
4201
+ }
4202
+ return isRepetitive;
4203
+ };
4204
+ }
4205
+
4206
+ //#endregion
4207
+ //#region src/lib/anthropic/beta.ts
4208
+ /**
4209
+ * Vendor-neutral utilities for manipulating the `anthropic-beta` request header.
4210
+ *
4211
+ * Lives in `lib/anthropic/` (not in either transport module) so both the
4212
+ * Anthropic-native and OpenAI-translated transport layers can share these
4213
+ * helpers without introducing cross-transport imports.
4214
+ */
4215
+ /** Anthropic beta feature that unlocks the 1M context window. */
4216
+ const CONTEXT_1M_BETA_FEATURE = "context-1m-2025-08-07";
4217
+ /**
4218
+ * Merge two comma-separated anthropic-beta header values. Trims whitespace,
4219
+ * drops empty tokens, and dedupes by exact string match. Returns a canonical
4220
+ * comma-joined string with no spaces.
4221
+ *
4222
+ * Either input may be undefined / empty.
4223
+ */
4224
+ function mergeBetaFeatures(existing, incoming) {
4225
+ const seen = /* @__PURE__ */ new Set();
4226
+ const out = [];
4227
+ for (const raw of [existing, incoming]) {
4228
+ if (!raw) continue;
4229
+ for (const part of raw.split(",")) {
4230
+ const f = part.trim();
4231
+ if (f.length === 0 || seen.has(f)) continue;
4232
+ seen.add(f);
4233
+ out.push(f);
4234
+ }
4235
+ }
4236
+ return out.join(",");
4237
+ }
4238
+ /**
4239
+ * Append the context-1m feature to an anthropic-beta header value, deduping
4240
+ * any prior occurrence. Returns the merged comma-separated string.
4241
+ */
4242
+ function appendContext1mBeta(existing) {
4243
+ return mergeBetaFeatures(existing, CONTEXT_1M_BETA_FEATURE);
4244
+ }
4245
+ /**
4246
+ * True iff a model id appears to be the suffixed 1M-context variant of an
4247
+ * Anthropic Claude model (e.g. claude-opus-4-8-1m, claude-opus-4.6-1m).
4248
+ *
4249
+ * Used as a state.models-independent signal for whether to inject the
4250
+ * context-1m-2025-08-07 beta header, so the 1M intent survives a stale or
4251
+ * empty model cache (where `resolveAnthropicModelForDirectPath` would return
4252
+ * undefined). Forwarding the beta is harmless to upstreams that ignore it.
4253
+ */
4254
+ function isOneMillionSuffixedClaudeId(modelId) {
4255
+ return modelId.startsWith("claude-") && modelId.endsWith("-1m");
4256
+ }
4257
+
4258
+ //#endregion
4259
+ //#region src/lib/headers.ts
4260
+ /**
4261
+ * Vendor-neutral header-bag helpers.
4262
+ *
4263
+ * HTTP header names are case-insensitive, but a plain-object header bag is
4264
+ * case-sensitive on its keys. Code that wants to look up "anthropic-beta"
4265
+ * without knowing whether some other producer wrote "Anthropic-Beta" needs
4266
+ * `findHeaderKey`. Code that wants to set a header without creating a
4267
+ * second case variant of the same name needs `setHeader`.
4268
+ */
4269
+ /** Case-insensitive lookup of a header key in a plain-object header bag. */
4270
+ function findHeaderKey(headers, name) {
4271
+ const lower = name.toLowerCase();
4272
+ return Object.keys(headers).find((k) => k.toLowerCase() === lower);
4273
+ }
4274
+ /** Case-insensitive read of a header value. */
4275
+ function getHeader(headers, name) {
4276
+ const key = findHeaderKey(headers, name);
4277
+ return key === void 0 ? void 0 : headers[key];
4278
+ }
4279
+ /**
4280
+ * Set a header value at the existing case variant if one is present, else at
4281
+ * the supplied canonical name. Prevents a second key (different case) from
4282
+ * being added for the same logical header.
4283
+ */
4284
+ function setHeader(headers, name, value) {
4285
+ const key = findHeaderKey(headers, name) ?? name;
4286
+ headers[key] = value;
4287
+ }
4288
+
4289
+ //#endregion
4290
+ //#region src/services/copilot/create-chat-completions.ts
4291
+ const GPT_MODEL_PATTERN = /^gpt-/i;
4292
+ const createChatCompletions = async (payload, options) => {
4293
+ if (!state.copilotToken) throw new Error("Copilot token not found");
4294
+ const vendor = options?.resolvedModel?.vendor;
4295
+ const isOpenAIVendor = vendor === "OpenAI" || vendor === "Azure OpenAI";
4296
+ const isLikelyGPT = !options?.resolvedModel && GPT_MODEL_PATTERN.test(payload.model);
4297
+ let wire = payload;
4298
+ if (isOpenAIVendor || isLikelyGPT) {
4299
+ const { max_tokens, max_completion_tokens, ...rest } = payload;
4300
+ const effective = max_completion_tokens ?? max_tokens;
4301
+ wire = {
4302
+ ...rest,
4303
+ ...effective !== null && effective !== void 0 && { max_completion_tokens: effective }
4304
+ };
4305
+ }
4306
+ const enableVision = wire.messages.some((x) => typeof x.content !== "string" && x.content?.some((x) => x.type === "image_url"));
4307
+ const isAgentCall = wire.messages.some((msg) => ["assistant", "tool"].includes(msg.role));
4308
+ const modelSupportsVision = options?.resolvedModel?.capabilities?.supports?.vision !== false;
4309
+ const headers = {
4310
+ ...copilotHeaders(state, {
4311
+ vision: enableVision && modelSupportsVision,
4312
+ modelRequestHeaders: options?.resolvedModel?.request_headers,
4313
+ intent: isAgentCall ? "conversation-agent" : "conversation-panel"
4314
+ }),
4315
+ "X-Initiator": options?.initiator ?? (isAgentCall ? "agent" : "user")
4316
+ };
4317
+ if (options?.anthropicBeta) {
4318
+ const existingKey = findHeaderKey(headers, "anthropic-beta") ?? "anthropic-beta";
4319
+ headers[existingKey] = mergeBetaFeatures(headers[existingKey], options.anthropicBeta);
4320
+ consola.debug(`[ChatCompletions] anthropic-beta after merge: ${headers[existingKey]}`);
4321
+ }
4322
+ const response = await copilotFetch("/chat/completions", {
4323
+ method: "POST",
4324
+ headers,
4325
+ body: JSON.stringify(wire)
4326
+ });
4327
+ if (!response.ok) {
4328
+ consola.error("Failed to create chat completions", response);
4329
+ throw await HTTPError.fromResponse("Failed to create chat completions", response, options?.errorModelIdOverride ?? payload.model);
4330
+ }
4331
+ if (payload.stream) return events(response);
4332
+ return await response.json();
4333
+ };
4334
+
4335
+ //#endregion
4336
+ //#region src/lib/echo-model.ts
4337
+ /**
4338
+ * Capture the requested model id from a raw request `model` value, classifying
4339
+ * it into the three-state contract. `undefined`/missing → `absent`; `""` →
4340
+ * `empty`; any other string → `present`.
4341
+ */
4342
+ function captureRequestedModel(rawModel) {
4343
+ if (rawModel === void 0 || rawModel === null) return { kind: "absent" };
4344
+ if (typeof rawModel !== "string") return { kind: "absent" };
4345
+ if (rawModel === "") return { kind: "empty" };
4346
+ return {
4347
+ kind: "present",
4348
+ value: rawModel
4349
+ };
4350
+ }
4351
+ /**
4352
+ * Resolve a {@link RequestedModel} into the action to take on a model field:
4353
+ * - `{ write: true, value }` → set the field to `value`.
4354
+ * - `{ write: false, omit: true }` → remove the field (absent case).
4355
+ * - `null` → leave the field untouched (context-missing case).
4356
+ */
4357
+ function resolveFieldAction(requested) {
4358
+ if (requested.kind === "present") return { value: requested.value };
4359
+ if (requested.kind === "empty") return { value: "" };
4360
+ if (requested.kind === "absent") return { omit: true };
4361
+ return null;
4362
+ }
4363
+ /**
4364
+ * Apply the requested-model action to a single `model`-like key on a shallow
4365
+ * clone of `obj`. Returns a new object; never mutates `obj`. If `obj` does not
4366
+ * own `key`, it is returned (cloned) unchanged regardless of the action — we
4367
+ * only ever rewrite a field the upstream payload actually carries (supports
4368
+ * AC-MALFORMED-SSE: no model field → original passthrough).
4369
+ */
4370
+ function rewriteKey(obj, key, requested) {
4371
+ if (!Object.hasOwn(obj, key)) return obj;
4372
+ const action = resolveFieldAction(requested);
4373
+ if (action === null) return obj;
4374
+ if ("omit" in action) {
4375
+ const { [key]: _omitted, ...rest } = obj;
4376
+ return rest;
4377
+ }
4378
+ return {
4379
+ ...obj,
4380
+ [key]: action.value
4381
+ };
4382
+ }
4383
+ /**
4384
+ * Core rewrite over a plain record. Rewrites the documented model fields:
4385
+ * - top-level `model`,
4386
+ * - nested `message.model` (Anthropic message_start),
4387
+ * - nested `response.model` (OpenAI Responses event).
4388
+ * Returns a shallow clone; never mutates the input.
4389
+ */
4390
+ function echoRecord(body, requested) {
4391
+ let out = rewriteKey(body, "model", requested);
4392
+ if (isRecord(out.message) && Object.hasOwn(out.message, "model")) {
4393
+ const newMessage = rewriteKey(out.message, "model", requested);
4394
+ if (newMessage !== out.message) out = {
4395
+ ...out,
4396
+ message: newMessage
4397
+ };
4398
+ }
4399
+ if (isRecord(out.response) && Object.hasOwn(out.response, "model")) {
4400
+ const newResponse = rewriteKey(out.response, "model", requested);
4401
+ if (newResponse !== out.response) out = {
4402
+ ...out,
4403
+ response: newResponse
4404
+ };
4405
+ }
4406
+ return out;
4407
+ }
4408
+ /**
4409
+ * Rewrite the documented client-facing model field(s) of a JSON response body
4410
+ * to the requested model id. Handles every supported non-stream/body shape:
4411
+ * - top-level `model` (OpenAI chat/completions & Responses bodies, Anthropic
4412
+ * messages body),
4413
+ * - nested `message.model` (Anthropic `message_start` event object),
4414
+ * - nested `response.model` (OpenAI Responses streaming event object).
4415
+ *
4416
+ * Returns a shallow-cloned object; the input is never mutated. Fields that are
4417
+ * not present are left as-is (a payload with no model field round-trips
4418
+ * unchanged), supporting AC-MALFORMED-SSE.
4419
+ *
4420
+ * The generic is constrained to `object` (not an index-signature shape) so it
4421
+ * accepts the project's domain interfaces (`AnthropicResponse`,
4422
+ * `ChatCompletionChunk`, …) directly without forcing callers to widen them.
4423
+ */
4424
+ function echoModelInResponseBody(body, requested) {
4425
+ return echoRecord(body, requested);
4426
+ }
4427
+ function isRecord(value) {
4428
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4429
+ }
4430
+ /**
4431
+ * Rewrite the model field of an already-parsed SSE event payload to the
4432
+ * requested model id, dispatched by protocol shape:
4433
+ * - Anthropic `message_start` → `message.model`,
4434
+ * - OpenAI chunk → top-level `model`,
4435
+ * - OpenAI Responses event → nested `response.model`.
4436
+ *
4437
+ * The field dispatch is identical to {@link echoModelInResponseBody} (both
4438
+ * operate on the same documented model fields), so this delegates to the same
4439
+ * core rather than duplicating the shape logic — the two exports exist to name
4440
+ * the two responsibilities (body vs parsed-event) at call sites, per the PRD's
4441
+ * single-policy-point design. Events with no model field round-trip unchanged
4442
+ * (AC-MALFORMED-SSE); the input is never mutated.
4443
+ */
4444
+ function echoModelInParsedEvent(event, requested) {
4445
+ return echoRecord(event, requested);
4446
+ }
4447
+
4448
+ //#endregion
4449
+ //#region src/lib/error-metrics.ts
4450
+ function safeString(value, max = 200) {
4451
+ try {
4452
+ if (typeof value !== "string") return void 0;
4453
+ return value.length > max ? value.slice(0, max) : value;
4454
+ } catch {
4455
+ return;
4456
+ }
4457
+ }
4458
+ function getStringProp(obj, key, max = 200) {
4459
+ try {
4460
+ if (typeof obj !== "object" || obj === null) return void 0;
4461
+ return safeString(obj[key], max);
4462
+ } catch {
4463
+ return;
4464
+ }
4037
4465
  }
4038
4466
  function extractErrorMetrics(error) {
4039
4467
  if (error instanceof HTTPError) {
@@ -4060,37 +4488,96 @@ function extractErrorMetrics(error) {
4060
4488
  }
4061
4489
 
4062
4490
  //#endregion
4063
- //#region src/routes/shared.ts
4064
- /**
4065
- * Shared utilities for request handlers.
4066
- * Contains common functions used by both OpenAI and Anthropic message handlers.
4067
- */
4491
+ //#region src/routes/observability-recording.ts
4068
4492
  /**
4069
- * Resolve the requested model id (R) carried on the response context into a
4070
- * {@link RequestedModel}. A context that never captured R (`undefined`) is
4071
- * treated as `context-missing` so the echo passes the upstream value through
4072
- * unchanged it must never invent an id or throw.
4493
+ * Observability recording for error paths.
4494
+ *
4495
+ * Writes failed requests to history and emits matching PostHog analytics
4496
+ * events for both non-streaming errors (before the stream starts) and
4497
+ * mid-stream errors (after the accumulator has partial data). Success-path
4498
+ * analytics live in `tracker-mutations.ts` alongside `completeTracking`.
4073
4499
  */
4074
- function requestedModelOf(ctx) {
4075
- return ctx.requestedModel ?? { kind: "context-missing" };
4500
+ function formatError(error) {
4501
+ if (error instanceof Error) return error.message || error.name;
4502
+ if (typeof error === "string") return error;
4503
+ try {
4504
+ const s = JSON.stringify(error);
4505
+ if (s && s !== "{}") return s;
4506
+ } catch {}
4507
+ try {
4508
+ return String(error);
4509
+ } catch {
4510
+ return "Unknown error";
4511
+ }
4076
4512
  }
4077
- /**
4078
- * Echo the requested model id into a JSON response body at the client write-out
4079
- * boundary. Thin context-aware wrapper over {@link echoModelInResponseBody};
4080
- * MUST be called AFTER history/posthog/TUI have read the upstream value.
4081
- */
4082
- function echoResponseBody(body, ctx) {
4083
- return echoModelInResponseBody(body, requestedModelOf(ctx));
4513
+ /** Record error response to history */
4514
+ function recordErrorResponse(ctx, model, error, endpoint, stream) {
4515
+ recordResponse(ctx.historyId, {
4516
+ success: false,
4517
+ model,
4518
+ usage: {
4519
+ input_tokens: 0,
4520
+ output_tokens: 0
4521
+ },
4522
+ error: formatError(error),
4523
+ content: null
4524
+ }, Date.now() - ctx.startTime);
4525
+ const metrics = extractErrorMetrics(error);
4526
+ const { attempts } = getRetryAttempts(error);
4527
+ captureRequest({
4528
+ model,
4529
+ inputTokens: 0,
4530
+ outputTokens: 0,
4531
+ durationMs: Date.now() - ctx.startTime,
4532
+ success: false,
4533
+ stream: stream ?? false,
4534
+ toolCount: 0,
4535
+ ...metrics,
4536
+ errorPhase: stream ? "pre_stream" : "non_stream",
4537
+ endpoint,
4538
+ attempt: attempts
4539
+ });
4540
+ }
4541
+ /** Record streaming error to history (works with any accumulator type) */
4542
+ function recordStreamError(opts) {
4543
+ const { acc, fallbackModel, ctx, error, endpoint } = opts;
4544
+ const model = acc.model || fallbackModel;
4545
+ recordResponse(ctx.historyId, {
4546
+ success: false,
4547
+ model,
4548
+ usage: {
4549
+ input_tokens: 0,
4550
+ output_tokens: 0
4551
+ },
4552
+ error: formatError(error),
4553
+ content: null
4554
+ }, Date.now() - ctx.startTime);
4555
+ const metrics = extractErrorMetrics(error);
4556
+ captureRequest({
4557
+ model,
4558
+ inputTokens: acc.inputTokens ?? 0,
4559
+ outputTokens: acc.outputTokens ?? 0,
4560
+ durationMs: Date.now() - ctx.startTime,
4561
+ success: false,
4562
+ stream: true,
4563
+ toolCount: 0,
4564
+ ...metrics,
4565
+ errorPhase: "mid_stream",
4566
+ endpoint,
4567
+ attempt: 1
4568
+ });
4084
4569
  }
4570
+
4571
+ //#endregion
4572
+ //#region src/routes/tracker-mutations.ts
4085
4573
  /**
4086
- * Echo the requested model id into an already-parsed SSE event payload at the
4087
- * client write-out boundary. Thin context-aware wrapper over
4088
- * {@link echoModelInParsedEvent}; MUST be called AFTER the stream accumulator
4089
- * (the observability data source) has read the upstream value.
4574
+ * TUI tracker mutations and analytics completion.
4575
+ *
4576
+ * All in-place updates to the TUI request tracker (model, status, resolved
4577
+ * model) and the success/failure terminal transitions. `completeTracking`
4578
+ * additionally emits a PostHog analytics event for successful requests; error
4579
+ * paths emit their PostHog events through `observability-recording.ts`.
4090
4580
  */
4091
- function echoParsedEvent(event, ctx) {
4092
- return echoModelInParsedEvent(event, requestedModelOf(ctx));
4093
- }
4094
4581
  /** Helper to update tracker model */
4095
4582
  function updateTrackerModel(trackingId, model, resolvedModel) {
4096
4583
  if (!trackingId) return;
@@ -4110,36 +4597,6 @@ function updateTrackerStatus(trackingId, status) {
4110
4597
  if (!trackingId) return;
4111
4598
  requestTracker.updateRequest(trackingId, { status });
4112
4599
  }
4113
- /** Record error response to history */
4114
- function recordErrorResponse(ctx, model, error, endpoint, stream) {
4115
- recordResponse(ctx.historyId, {
4116
- success: false,
4117
- model,
4118
- usage: {
4119
- input_tokens: 0,
4120
- output_tokens: 0
4121
- },
4122
- error: error instanceof Error ? error.message : "Unknown error",
4123
- content: null
4124
- }, Date.now() - ctx.startTime);
4125
- if (endpoint !== void 0) {
4126
- const metrics = extractErrorMetrics(error);
4127
- const { attempts } = getRetryAttempts(error);
4128
- captureRequest({
4129
- model,
4130
- inputTokens: 0,
4131
- outputTokens: 0,
4132
- durationMs: Date.now() - ctx.startTime,
4133
- success: false,
4134
- stream: stream ?? false,
4135
- toolCount: 0,
4136
- ...metrics,
4137
- errorPhase: stream ? "pre_stream" : "non_stream",
4138
- endpoint,
4139
- attempt: attempts
4140
- });
4141
- }
4142
- }
4143
4600
  /** Complete TUI tracking and send PostHog analytics */
4144
4601
  function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, reasoningTokens, analytics) {
4145
4602
  if (!trackingId) return;
@@ -4166,149 +4623,90 @@ function completeTracking(trackingId, inputTokens, outputTokens, queueWaitMs, re
4166
4623
  stopReason: analytics.stopReason
4167
4624
  });
4168
4625
  }
4169
- function formatError(error) {
4170
- if (error instanceof Error) return error.message || error.name;
4171
- if (typeof error === "string") return error;
4172
- try {
4173
- const s = JSON.stringify(error);
4174
- if (s && s !== "{}") return s;
4175
- } catch {}
4176
- try {
4177
- return String(error);
4178
- } catch {
4179
- return "Unknown error";
4180
- }
4181
- }
4182
4626
  /** Fail TUI tracking */
4183
4627
  function failTracking(trackingId, error) {
4184
4628
  if (!trackingId) return;
4185
4629
  requestTracker.failRequest(trackingId, formatError(error));
4186
4630
  }
4631
+
4632
+ //#endregion
4633
+ //#region src/routes/entry-context.ts
4187
4634
  /**
4188
- * Create a marker to prepend to responses indicating auto-truncation occurred.
4189
- * Works with both OpenAI and Anthropic truncate results.
4635
+ * Construct the route-entry context for any handler.
4636
+ *
4637
+ * Returns `{ ctx, payload }` where `payload` is the normalized version of
4638
+ * `rawPayload` (possibly unchanged) and `ctx` is fully populated with the
4639
+ * captured R, history id, tracking id, and start time.
4190
4640
  */
4191
- function createTruncationMarker$1(result) {
4192
- if (!result.wasCompacted) return "";
4193
- const { originalTokens, compactedTokens, removedMessageCount } = result;
4194
- if (originalTokens === void 0 || compactedTokens === void 0 || removedMessageCount === void 0) return `\n\n---\n[Auto-truncated: conversation history was reduced to fit context limits]`;
4195
- const reduction = originalTokens - compactedTokens;
4196
- return `\n\n---\n[Auto-truncated: ${removedMessageCount} messages removed, ${originalTokens} → ${compactedTokens} tokens (${Math.round(reduction / originalTokens * 100)}% reduction)]`;
4197
- }
4198
- /** Record streaming error to history (works with any accumulator type) */
4199
- function recordStreamError(opts) {
4200
- const { acc, fallbackModel, ctx, error, endpoint } = opts;
4201
- const model = acc.model || fallbackModel;
4202
- recordResponse(ctx.historyId, {
4203
- success: false,
4204
- model,
4205
- usage: {
4206
- input_tokens: 0,
4207
- output_tokens: 0
4208
- },
4209
- error: formatError(error),
4210
- content: null
4211
- }, Date.now() - ctx.startTime);
4212
- if (endpoint !== void 0) {
4213
- const metrics = extractErrorMetrics(error);
4214
- captureRequest({
4215
- model,
4216
- inputTokens: acc.inputTokens ?? 0,
4217
- outputTokens: acc.outputTokens ?? 0,
4218
- durationMs: Date.now() - ctx.startTime,
4219
- success: false,
4220
- stream: true,
4221
- toolCount: 0,
4222
- ...metrics,
4223
- errorPhase: "mid_stream",
4224
- endpoint,
4225
- attempt: 1
4226
- });
4227
- }
4228
- }
4229
- /** Type guard for non-streaming responses */
4230
- function isNonStreaming(response) {
4231
- return Object.hasOwn(response, "choices");
4232
- }
4233
- /** Build final payload with auto-truncate if needed */
4234
- async function buildFinalPayload(payload, model, autoTruncateConfig = {}) {
4235
- if (!state.autoTruncate || !model) {
4236
- if (state.autoTruncate && !model) consola.warn(`Auto-truncate: Model '${payload.model}' not found in cached models, skipping`);
4237
- return {
4238
- finalPayload: payload,
4239
- truncateResult: null
4240
- };
4241
- }
4242
- try {
4243
- const check = await checkNeedsCompactionOpenAI(payload, model, autoTruncateConfig);
4244
- consola.debug(`Auto-truncate check: ${check.currentTokens} tokens (limit ${check.tokenLimit}), ${Math.round(check.currentBytes / 1024)}KB (limit ${check.byteLimit === Infinity ? "unlimited" : `${Math.round(check.byteLimit / 1024)}KB`}), needed: ${check.needed}${check.reason ? ` (${check.reason})` : ""}`);
4245
- if (!check.needed) return {
4246
- finalPayload: payload,
4247
- truncateResult: null
4248
- };
4249
- let reasonText;
4250
- if (check.reason === "both") reasonText = "tokens and size";
4251
- else if (check.reason === "bytes") reasonText = "size";
4252
- else reasonText = "tokens";
4253
- consola.info(`Auto-truncate triggered: exceeds ${reasonText} limit`);
4254
- const truncateResult = await autoTruncateOpenAI(payload, model, autoTruncateConfig);
4255
- return {
4256
- finalPayload: truncateResult.payload,
4257
- truncateResult
4258
- };
4259
- } catch (error) {
4260
- consola.warn("Auto-truncate failed, proceeding with original payload:", error instanceof Error ? error.message : error);
4261
- return {
4262
- finalPayload: payload,
4263
- truncateResult: null
4264
- };
4265
- }
4641
+ function createEntryContext(args) {
4642
+ const requestedModel = captureRequestedModel(args.rawPayload.model);
4643
+ const payload = args.normalizePayload ? args.normalizePayload(args.rawPayload) : args.rawPayload;
4644
+ const trackingId = args.c.get("trackingId");
4645
+ const startTime = (trackingId ? requestTracker.getRequest(trackingId) : void 0)?.startTime ?? Date.now();
4646
+ updateTrackerModel(trackingId, payload.model);
4647
+ return {
4648
+ payload,
4649
+ ctx: {
4650
+ historyId: recordRequest(args.endpoint, args.buildHistoryRequest(payload)),
4651
+ trackingId,
4652
+ startTime,
4653
+ requestedModel
4654
+ }
4655
+ };
4266
4656
  }
4657
+
4658
+ //#endregion
4659
+ //#region src/routes/response-context.ts
4267
4660
  /**
4268
- * Log helpful debugging information when a 413 error occurs.
4269
- * Also adjusts the dynamic byte limit for future requests.
4661
+ * Resolve the requested model id (R) carried on the response context into a
4662
+ * {@link RequestedModel}. A context that never captured R (`undefined`) is
4663
+ * treated as `context-missing` so the echo passes the upstream value through
4664
+ * unchanged — it must never invent an id or throw.
4665
+ *
4666
+ * `createEntryContext` always populates `requestedModel`, so callers operating
4667
+ * on a ctx that came from the entry adapter never hit the `context-missing`
4668
+ * branch. The fallback exists for the (theoretically impossible) case where
4669
+ * a ctx was constructed by hand without `requestedModel` — e.g., a test
4670
+ * fixture or a future code path that bypasses the adapter.
4270
4671
  */
4271
- async function logPayloadSizeInfo(payload, model) {
4272
- const messageCount = payload.messages.length;
4273
- const bodySize = JSON.stringify(payload).length;
4274
- const bodySizeKB = Math.round(bodySize / 1024);
4275
- onRequestTooLarge(bodySize);
4276
- let imageCount = 0;
4277
- let largeMessages = 0;
4278
- let totalImageSize = 0;
4279
- for (const msg of payload.messages) {
4280
- if (Array.isArray(msg.content)) {
4281
- for (const part of msg.content) if (part.type === "image_url") {
4282
- imageCount++;
4283
- if (part.image_url.url.startsWith("data:")) totalImageSize += part.image_url.url.length;
4284
- }
4285
- }
4286
- if ((typeof msg.content === "string" ? msg.content.length : JSON.stringify(msg.content).length) > 5e4) largeMessages++;
4287
- }
4288
- consola.info("");
4289
- consola.info("╭─────────────────────────────────────────────────────────╮");
4290
- consola.info("│ 413 Request Entity Too Large │");
4291
- consola.info("╰─────────────────────────────────────────────────────────╯");
4292
- consola.info("");
4293
- consola.info(` Request body size: ${bodySizeKB} KB (${bodySize.toLocaleString()} bytes)`);
4294
- consola.info(` Message count: ${messageCount}`);
4295
- if (model) try {
4296
- const tokenCount = await getTokenCount(payload, model);
4297
- const limit = model.capabilities?.limits?.max_prompt_tokens ?? 128e3;
4298
- consola.info(` Estimated tokens: ${tokenCount.input.toLocaleString()} / ${limit.toLocaleString()}`);
4299
- } catch {}
4300
- if (imageCount > 0) {
4301
- const imageSizeKB = Math.round(totalImageSize / 1024);
4302
- consola.info(` Images: ${imageCount} (${imageSizeKB} KB base64 data)`);
4303
- }
4304
- if (largeMessages > 0) consola.info(` Large messages (>50KB): ${largeMessages}`);
4305
- consola.info("");
4306
- consola.info(" Suggestions:");
4307
- if (!state.autoTruncate) consola.info(" • Enable --auto-truncate to automatically truncate history");
4308
- if (imageCount > 0) consola.info(" • Remove or resize large images in the conversation");
4309
- consola.info(" • Start a new conversation with /clear or /reset");
4310
- consola.info(" • Reduce conversation history by deleting old messages");
4311
- consola.info("");
4672
+ function requestedModelOf(ctx) {
4673
+ return ctx.requestedModel ?? { kind: "context-missing" };
4674
+ }
4675
+ /**
4676
+ * Echo the requested model id into a JSON response body at the client write-out
4677
+ * boundary. Thin context-aware wrapper over {@link echoModelInResponseBody};
4678
+ * MUST be called AFTER history/posthog/TUI have read the upstream value.
4679
+ */
4680
+ function echoResponseBody(body, ctx) {
4681
+ return echoModelInResponseBody(body, requestedModelOf(ctx));
4682
+ }
4683
+ /**
4684
+ * Echo the requested model id into an already-parsed SSE event payload at the
4685
+ * client write-out boundary. Thin context-aware wrapper over
4686
+ * {@link echoModelInParsedEvent}; MUST be called AFTER the stream accumulator
4687
+ * (the observability data source) has read the upstream value.
4688
+ */
4689
+ function echoParsedEvent(event, ctx) {
4690
+ return echoModelInParsedEvent(event, requestedModelOf(ctx));
4691
+ }
4692
+
4693
+ //#endregion
4694
+ //#region src/routes/truncation-marker.ts
4695
+ /**
4696
+ * Create a marker to prepend to responses indicating auto-truncation occurred.
4697
+ * Works with both OpenAI and Anthropic truncate results.
4698
+ *
4699
+ * Distinct from the file-private `createTruncationMarker` helpers in
4700
+ * `lib/auto-truncate-{openai,anthropic}.ts` those build a synthetic upstream
4701
+ * `Message`/`AnthropicMessage` used as a no-system-message fallback inside the
4702
+ * truncate algorithm; this one returns the client-facing display suffix.
4703
+ */
4704
+ function formatClientTruncationMarker(result) {
4705
+ if (!result.wasCompacted) return "";
4706
+ const { originalTokens, compactedTokens, removedMessageCount } = result;
4707
+ if (originalTokens === void 0 || compactedTokens === void 0 || removedMessageCount === void 0) return `\n\n---\n[Auto-truncated: conversation history was reduced to fit context limits]`;
4708
+ const reduction = originalTokens - compactedTokens;
4709
+ return `\n\n---\n[Auto-truncated: ${removedMessageCount} messages removed, ${originalTokens} ${compactedTokens} tokens (${Math.round(reduction / originalTokens * 100)}% reduction)]`;
4312
4710
  }
4313
4711
 
4314
4712
  //#endregion
@@ -4317,28 +4715,24 @@ function getReasoningTokensFromOpenAIUsage(usage) {
4317
4715
  return usage?.completion_tokens_details?.reasoning_tokens;
4318
4716
  }
4319
4717
  async function handleCompletion$1(c) {
4320
- const originalPayload = await c.req.json();
4321
- consola.debug("Request payload:", JSON.stringify(originalPayload).slice(-400));
4322
- const requestedModel = captureRequestedModel(originalPayload.model);
4323
- const trackingId = c.get("trackingId");
4324
- const startTime = (trackingId ? requestTracker.getRequest(trackingId) : void 0)?.startTime ?? Date.now();
4325
- updateTrackerModel(trackingId, originalPayload.model);
4326
- const ctx = {
4327
- historyId: recordRequest("openai", {
4328
- model: originalPayload.model,
4329
- messages: convertOpenAIMessages(originalPayload.messages),
4330
- stream: originalPayload.stream ?? false,
4331
- tools: originalPayload.tools?.map((t) => ({
4718
+ const rawPayload = await c.req.json();
4719
+ consola.debug("Request payload:", JSON.stringify(rawPayload).slice(-400));
4720
+ const { ctx, payload: originalPayload } = createEntryContext({
4721
+ c,
4722
+ rawPayload,
4723
+ endpoint: "openai",
4724
+ buildHistoryRequest: (p) => ({
4725
+ model: p.model,
4726
+ messages: convertOpenAIMessages(p.messages),
4727
+ stream: p.stream ?? false,
4728
+ tools: p.tools?.map((t) => ({
4332
4729
  name: t.function.name,
4333
4730
  description: t.function.description
4334
4731
  })),
4335
- max_tokens: originalPayload.max_tokens ?? void 0,
4336
- temperature: originalPayload.temperature ?? void 0
4337
- }),
4338
- trackingId,
4339
- startTime,
4340
- requestedModel
4341
- };
4732
+ max_tokens: p.max_tokens ?? void 0,
4733
+ temperature: p.temperature ?? void 0
4734
+ })
4735
+ });
4342
4736
  const selectedModel = findModelById(originalPayload.model);
4343
4737
  await logTokenCount(originalPayload, selectedModel);
4344
4738
  const { finalPayload, truncateResult } = await buildFinalPayload(originalPayload, selectedModel);
@@ -4353,21 +4747,20 @@ async function handleCompletion$1(c) {
4353
4747
  c,
4354
4748
  payload,
4355
4749
  selectedModel,
4356
- ctx,
4357
- trackingId
4750
+ ctx
4358
4751
  });
4359
4752
  }
4360
4753
  /**
4361
4754
  * Execute the API call with enhanced error handling for 413 errors.
4362
4755
  */
4363
4756
  async function executeRequest(opts) {
4364
- const { c, payload, selectedModel, ctx, trackingId } = opts;
4757
+ const { c, payload, selectedModel, ctx } = opts;
4365
4758
  try {
4366
4759
  const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createChatCompletions(payload, { resolvedModel: selectedModel }));
4367
4760
  ctx.queueWaitMs = queueWaitMs;
4368
4761
  if (isNonStreaming(response)) return handleNonStreamingResponse$1(c, response, ctx, payload);
4369
4762
  consola.debug("Streaming response");
4370
- updateTrackerStatus(trackingId, "streaming");
4763
+ updateTrackerStatus(ctx.trackingId, "streaming");
4371
4764
  return streamSSE(c, async (stream) => {
4372
4765
  await handleStreamingResponse$1({
4373
4766
  stream,
@@ -4397,7 +4790,7 @@ function handleNonStreamingResponse$1(c, originalResponse, ctx, payload) {
4397
4790
  consola.debug("Non-streaming response:", JSON.stringify(originalResponse));
4398
4791
  let response = originalResponse;
4399
4792
  if (state.verbose && ctx.truncateResult?.wasCompacted && response.choices[0]?.message.content) {
4400
- const marker = createTruncationMarker$1(ctx.truncateResult);
4793
+ const marker = formatClientTruncationMarker(ctx.truncateResult);
4401
4794
  response = {
4402
4795
  ...response,
4403
4796
  choices: response.choices.map((choice, i) => i === 0 ? {
@@ -4483,7 +4876,7 @@ async function handleStreamingResponse$1(opts) {
4483
4876
  const checkRepetition = createStreamRepetitionChecker(`openai:${payload.model}`);
4484
4877
  try {
4485
4878
  if (state.verbose && ctx.truncateResult?.wasCompacted) {
4486
- const marker = createTruncationMarker$1(ctx.truncateResult);
4879
+ const marker = formatClientTruncationMarker(ctx.truncateResult);
4487
4880
  const markerChunk = {
4488
4881
  id: `compact-marker-${Date.now()}`,
4489
4882
  object: "chat.completion.chunk",
@@ -4664,51 +5057,6 @@ completionRoutes.post("/", async (c) => {
4664
5057
  }
4665
5058
  });
4666
5059
 
4667
- //#endregion
4668
- //#region src/services/copilot/create-embeddings.ts
4669
- const createEmbeddings = async (payload) => {
4670
- if (!state.copilotToken) throw new Error("Copilot token not found");
4671
- const response = await copilotFetch("/embeddings", {
4672
- method: "POST",
4673
- headers: copilotHeaders(state),
4674
- body: JSON.stringify(payload)
4675
- });
4676
- if (!response.ok) throw await HTTPError.fromResponse("Failed to create embeddings", response);
4677
- return await response.json();
4678
- };
4679
-
4680
- //#endregion
4681
- //#region src/routes/embeddings/route.ts
4682
- const embeddingRoutes = new Hono();
4683
- function isRecord(value) {
4684
- return typeof value === "object" && value !== null && !Array.isArray(value);
4685
- }
4686
- embeddingRoutes.post("/", async (c) => {
4687
- const startTime = Date.now();
4688
- try {
4689
- const rawBody = await c.req.json();
4690
- const payload = rawBody;
4691
- const requestedModel = captureRequestedModel(isRecord(rawBody) ? rawBody.model : void 0);
4692
- const response = await createEmbeddings(payload);
4693
- if (!isRecord(response)) return c.json(response);
4694
- const upstreamModel = typeof response.model === "string" ? response.model : "";
4695
- const usage = response.usage;
4696
- captureRequest({
4697
- model: upstreamModel,
4698
- inputTokens: isRecord(usage) && typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
4699
- outputTokens: 0,
4700
- durationMs: Date.now() - startTime,
4701
- success: true,
4702
- stream: false,
4703
- toolCount: 0,
4704
- endpoint: "embeddings"
4705
- });
4706
- return c.json(echoTopLevelModel(response, requestedModel));
4707
- } catch (error) {
4708
- return forwardError(c, error);
4709
- }
4710
- });
4711
-
4712
5060
  //#endregion
4713
5061
  //#region src/routes/event-logging/route.ts
4714
5062
  const eventLoggingRoutes = new Hono();
@@ -4717,509 +5065,11 @@ eventLoggingRoutes.post("/batch", (c) => {
4717
5065
  });
4718
5066
 
4719
5067
  //#endregion
4720
- //#region src/routes/gemini/error.ts
4721
- const STATUS_MAP = {
4722
- 400: "INVALID_ARGUMENT",
4723
- 401: "PERMISSION_DENIED",
4724
- 403: "PERMISSION_DENIED",
4725
- 404: "NOT_FOUND",
4726
- 413: "INVALID_ARGUMENT",
4727
- 429: "RESOURCE_EXHAUSTED",
4728
- 500: "INTERNAL"
4729
- };
4730
- function geminiError(c, code, status, message) {
4731
- return c.json({ error: {
4732
- code,
4733
- message,
4734
- status
4735
- } }, code);
4736
- }
4737
- function forwardGeminiError(c, error) {
4738
- if (error instanceof HTTPError) {
4739
- const status = STATUS_MAP[error.status] ?? "INTERNAL";
4740
- const code = error.status;
4741
- let message = error.responseText;
4742
- try {
4743
- const parsed = JSON.parse(error.responseText);
4744
- if (parsed.error?.message) message = parsed.error.message;
4745
- } catch {}
4746
- consola.error(`HTTP ${code}:`, message.slice(0, 200));
4747
- return geminiError(c, code, status, message);
4748
- }
4749
- consola.error("Unexpected error:", error);
4750
- return geminiError(c, 500, "INTERNAL", error instanceof Error ? error.message : "Unknown error");
4751
- }
4752
-
4753
- //#endregion
4754
- //#region src/routes/gemini/gemini-to-openai.ts
4755
- function translateGeminiToOpenAI(request, model) {
4756
- const messages = [];
4757
- if (request.systemInstruction) {
4758
- const systemText = extractTextFromParts(request.systemInstruction.parts);
4759
- if (systemText) messages.push({
4760
- role: "system",
4761
- content: systemText
4762
- });
4763
- }
4764
- let globalCallIndex = 0;
4765
- const callIdQueue = /* @__PURE__ */ new Map();
4766
- if (!Array.isArray(request.contents)) return { payload: {
4767
- messages: [],
4768
- model
4769
- } };
4770
- for (const content of request.contents) {
4771
- const translated = translateContent(content, callIdQueue, () => `call_gemini_${globalCallIndex++}`);
4772
- messages.push(...translated);
4773
- }
4774
- const payload = {
4775
- messages,
4776
- model
4777
- };
4778
- const config = request.generationConfig;
4779
- if (config) {
4780
- if (config.temperature !== void 0) payload.temperature = config.temperature;
4781
- if (config.topP !== void 0) payload.top_p = config.topP;
4782
- if (config.maxOutputTokens !== void 0) payload.max_tokens = config.maxOutputTokens;
4783
- if (config.stopSequences !== void 0) payload.stop = config.stopSequences;
4784
- if (config.responseMimeType === "application/json") payload.response_format = { type: "json_object" };
4785
- }
4786
- if (request.tools) {
4787
- const tools = translateTools(request.tools);
4788
- if (tools.length > 0) payload.tools = tools;
4789
- }
4790
- if (request.toolConfig?.functionCallingConfig?.mode) payload.tool_choice = {
4791
- AUTO: "auto",
4792
- ANY: "required",
4793
- NONE: "none"
4794
- }[request.toolConfig.functionCallingConfig.mode];
4795
- return { payload };
4796
- }
4797
- function mapFunctionCallsToToolCalls(functionCalls, callIdQueue, generateId) {
4798
- return functionCalls.map((fc) => {
4799
- const id = generateId();
4800
- pushToQueue(callIdQueue, fc.functionCall.name, id);
4801
- return {
4802
- id,
4803
- type: "function",
4804
- function: {
4805
- name: fc.functionCall.name,
4806
- arguments: JSON.stringify(fc.functionCall.args)
4807
- }
4808
- };
4809
- });
4810
- }
4811
- function translateContent(content, callIdQueue, generateId) {
4812
- const role = content.role === "model" ? "assistant" : "user";
4813
- const messages = [];
4814
- const textParts = [];
4815
- const imageParts = [];
4816
- const functionCalls = [];
4817
- const functionResponses = [];
4818
- for (const part of content.parts) if (isTextPart(part)) {
4819
- if (!part.thought) textParts.push(part);
4820
- } else if (isInlineDataPart(part)) imageParts.push(part);
4821
- else if (isFunctionCallPart(part)) functionCalls.push(part);
4822
- else if (isFunctionResponsePart(part)) functionResponses.push(part);
4823
- else if (isFileDataPart(part)) throw new HTTPError("fileData parts are not supported", 400, "fileData parts are not supported");
4824
- if (imageParts.length > 0) {
4825
- const contentParts = [];
4826
- for (const part of content.parts) if (isTextPart(part) && !part.thought) contentParts.push({
4827
- type: "text",
4828
- text: part.text
4829
- });
4830
- else if (isInlineDataPart(part)) contentParts.push({
4831
- type: "image_url",
4832
- image_url: { url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` }
4833
- });
4834
- const msg = {
4835
- role,
4836
- content: contentParts
4837
- };
4838
- if (functionCalls.length > 0 && role === "assistant") msg.tool_calls = mapFunctionCallsToToolCalls(functionCalls, callIdQueue, generateId);
4839
- messages.push(msg);
4840
- } else if (functionCalls.length > 0 && role === "assistant") {
4841
- const textContent = textParts.length > 0 ? textParts.map((p) => p.text).join("") : null;
4842
- messages.push({
4843
- role: "assistant",
4844
- content: textContent,
4845
- tool_calls: mapFunctionCallsToToolCalls(functionCalls, callIdQueue, generateId)
4846
- });
4847
- } else if (textParts.length > 0) messages.push({
4848
- role,
4849
- content: textParts.map((p) => p.text).join("")
4850
- });
4851
- let orphanIndex = 0;
4852
- for (const fr of functionResponses) {
4853
- const queue = callIdQueue.get(fr.functionResponse.name);
4854
- const id = queue && queue.length > 0 ? queue.shift() : `call_gemini_orphan_${orphanIndex++}`;
4855
- messages.push({
4856
- role: "tool",
4857
- content: JSON.stringify(fr.functionResponse.response),
4858
- tool_call_id: id
4859
- });
4860
- }
4861
- return messages;
4862
- }
4863
- function translateTools(geminiTools) {
4864
- const tools = [];
4865
- for (const tool of geminiTools) if (tool.functionDeclarations) for (const decl of tool.functionDeclarations) tools.push({
4866
- type: "function",
4867
- function: {
4868
- name: decl.name,
4869
- description: decl.description,
4870
- parameters: decl.parameters ?? {
4871
- type: "object",
4872
- properties: {}
4873
- }
4874
- }
4875
- });
4876
- return tools;
4877
- }
4878
- function pushToQueue(queue, name, id) {
4879
- const existing = queue.get(name);
4880
- if (existing) existing.push(id);
4881
- else queue.set(name, [id]);
4882
- }
4883
- function extractTextFromParts(parts) {
4884
- return parts.filter((p) => "text" in p && (!("thought" in p) || !p.thought)).map((p) => p.text).join("\n");
4885
- }
4886
- function isTextPart(part) {
4887
- return "text" in part;
4888
- }
4889
- function isInlineDataPart(part) {
4890
- return "inlineData" in part;
4891
- }
4892
- function isFunctionCallPart(part) {
4893
- return "functionCall" in part;
4894
- }
4895
- function isFunctionResponsePart(part) {
4896
- return "functionResponse" in part;
4897
- }
4898
- function isFileDataPart(part) {
4899
- return "fileData" in part;
4900
- }
4901
-
4902
- //#endregion
4903
- //#region src/routes/gemini/count-tokens-handler.ts
4904
- async function handleGeminiCountTokens(c, model) {
4905
- try {
4906
- const { payload } = translateGeminiToOpenAI(await c.req.json(), model);
4907
- const selectedModel = findModelById(model);
4908
- if (!selectedModel) {
4909
- consola.warn("Model not found for count_tokens, returning estimate");
4910
- return c.json({ totalTokens: 1 });
4911
- }
4912
- const tokenCount = await getTokenCount(payload, selectedModel);
4913
- const totalTokens = tokenCount.input + tokenCount.output;
4914
- consola.debug(`Gemini countTokens: ${totalTokens} tokens`);
4915
- return c.json({ totalTokens });
4916
- } catch (error) {
4917
- return forwardGeminiError(c, error);
4918
- }
4919
- }
4920
-
4921
- //#endregion
4922
- //#region src/routes/gemini/openai-to-gemini.ts
4923
- function translateOpenAIResponseToGemini(response, model) {
4924
- const choice = response.choices.at(0);
4925
- if (!choice) return {
4926
- candidates: [],
4927
- usageMetadata: buildUsageMetadata(response.usage),
4928
- modelVersion: model
4929
- };
4930
- const parts = [];
4931
- if (choice.message.content) parts.push({ text: choice.message.content });
4932
- if (choice.message.tool_calls) for (const tc of choice.message.tool_calls) {
4933
- const args = parseArgs(tc.function.arguments);
4934
- parts.push({ functionCall: {
4935
- name: tc.function.name,
4936
- args
4937
- } });
4938
- }
4939
- if (parts.length === 0) parts.push({ text: "" });
4940
- return {
4941
- candidates: [{
4942
- content: {
4943
- role: "model",
4944
- parts
4945
- },
4946
- finishReason: mapFinishReason(choice.finish_reason),
4947
- index: 0
4948
- }],
4949
- usageMetadata: buildUsageMetadata(response.usage),
4950
- modelVersion: model
4951
- };
4952
- }
4953
- function createGeminiStreamState() {
4954
- return {
4955
- toolCalls: /* @__PURE__ */ new Map(),
4956
- usage: {
4957
- promptTokens: 0,
4958
- completionTokens: 0,
4959
- totalTokens: 0
4960
- },
4961
- model: "",
4962
- finishReason: ""
4963
- };
4964
- }
4965
- function translateOpenAIChunkToGemini(chunk, state) {
4966
- const results = [];
4967
- if (!state.model && chunk.model) state.model = chunk.model;
4968
- if (chunk.usage) {
4969
- state.usage.promptTokens = chunk.usage.prompt_tokens;
4970
- state.usage.completionTokens = chunk.usage.completion_tokens;
4971
- state.usage.totalTokens = chunk.usage.total_tokens;
4972
- }
4973
- const choice = chunk.choices.at(0);
4974
- if (!choice) return results;
4975
- const delta = choice.delta;
4976
- if (delta.tool_calls) for (const tc of delta.tool_calls) {
4977
- const existing = state.toolCalls.get(tc.index);
4978
- if (existing) {
4979
- if (tc.function?.arguments) existing.args += tc.function.arguments;
4980
- } else {
4981
- const flushed = flushToolCalls(state, tc.index);
4982
- if (flushed) results.push(flushed);
4983
- state.toolCalls.set(tc.index, {
4984
- name: tc.function?.name ?? "",
4985
- args: tc.function?.arguments ?? ""
4986
- });
4987
- }
4988
- }
4989
- if (delta.content) results.push(buildGeminiChunk([{ text: delta.content }], choice.finish_reason, state));
4990
- if (choice.finish_reason) {
4991
- state.finishReason = choice.finish_reason;
4992
- const flushed = flushToolCalls(state);
4993
- if (flushed) results.push(flushed);
4994
- if (!delta.content) results.push(buildGeminiChunk([], choice.finish_reason, state));
4995
- }
4996
- return results;
4997
- }
4998
- function flushToolCalls(state, belowIndex) {
4999
- if (state.toolCalls.size === 0) return null;
5000
- const parts = [];
5001
- for (const [idx, tc] of state.toolCalls) {
5002
- if (belowIndex !== void 0 && idx >= belowIndex) continue;
5003
- const args = parseArgs(tc.args);
5004
- parts.push({ functionCall: {
5005
- name: tc.name,
5006
- args
5007
- } });
5008
- state.toolCalls.delete(idx);
5009
- }
5010
- if (parts.length === 0) return null;
5011
- return buildGeminiChunk(parts, null, state);
5012
- }
5013
- function buildGeminiChunk(parts, finishReason, state) {
5014
- const candidate = {
5015
- content: {
5016
- role: "model",
5017
- parts: parts.length > 0 ? parts : [{ text: "" }]
5018
- },
5019
- index: 0
5020
- };
5021
- if (finishReason) candidate.finishReason = mapFinishReason(finishReason);
5022
- return {
5023
- candidates: [candidate],
5024
- usageMetadata: {
5025
- promptTokenCount: state.usage.promptTokens,
5026
- candidatesTokenCount: state.usage.completionTokens,
5027
- totalTokenCount: state.usage.totalTokens
5028
- },
5029
- modelVersion: state.model
5030
- };
5031
- }
5032
- function parseArgs(raw) {
5033
- try {
5034
- return JSON.parse(raw);
5035
- } catch {
5036
- return { raw };
5037
- }
5038
- }
5039
- function mapFinishReason(reason) {
5040
- switch (reason) {
5041
- case "stop":
5042
- case "tool_calls": return "STOP";
5043
- case "length": return "MAX_TOKENS";
5044
- case "content_filter": return "SAFETY";
5045
- default: return "OTHER";
5046
- }
5047
- }
5048
- function buildUsageMetadata(usage) {
5049
- return {
5050
- promptTokenCount: usage?.prompt_tokens ?? 0,
5051
- candidatesTokenCount: usage?.completion_tokens ?? 0,
5052
- totalTokenCount: usage?.total_tokens ?? 0
5053
- };
5054
- }
5055
-
5056
- //#endregion
5057
- //#region src/routes/gemini/handler.ts
5058
- async function handleGeminiGenerate(c, model, isStream, requestedModel) {
5059
- try {
5060
- const geminiRequest = await c.req.json();
5061
- consola.debug("Gemini request for model:", model, "stream:", isStream);
5062
- const trackingId = c.get("trackingId");
5063
- const startTime = Date.now();
5064
- updateTrackerModel(trackingId, model);
5065
- const { payload } = translateGeminiToOpenAI(geminiRequest, model);
5066
- payload.stream = isStream;
5067
- const selectedModel = findModelById(model);
5068
- if (isNullish(payload.max_tokens) && selectedModel) payload.max_tokens = selectedModel.capabilities?.limits?.max_output_tokens;
5069
- const ctx = {
5070
- historyId: recordRequest("gemini", {
5071
- model,
5072
- messages: payload.messages.map((m) => ({
5073
- role: m.role,
5074
- content: typeof m.content === "string" ? m.content : JSON.stringify(m.content),
5075
- tool_calls: m.tool_calls,
5076
- tool_call_id: m.tool_call_id
5077
- })),
5078
- stream: isStream,
5079
- max_tokens: payload.max_tokens ?? void 0,
5080
- temperature: payload.temperature ?? void 0
5081
- }),
5082
- trackingId,
5083
- startTime,
5084
- requestedModel
5085
- };
5086
- const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createChatCompletions(payload, { resolvedModel: selectedModel }));
5087
- ctx.queueWaitMs = queueWaitMs;
5088
- if (isNonStreaming(response)) return handleNonStreamResponse(c, response, model, ctx, payload);
5089
- consola.debug("Streaming Gemini response");
5090
- updateTrackerStatus(trackingId, "streaming");
5091
- return stream(c, async (s) => {
5092
- c.header("Content-Type", "text/event-stream");
5093
- c.header("Cache-Control", "no-cache");
5094
- c.header("Connection", "keep-alive");
5095
- const streamState = createGeminiStreamState();
5096
- try {
5097
- for await (const rawEvent of response) {
5098
- if (rawEvent.data === "[DONE]") break;
5099
- let chunk;
5100
- try {
5101
- chunk = JSON.parse(rawEvent.data);
5102
- } catch (parseError) {
5103
- consola.debug("Failed to parse stream chunk:", parseError);
5104
- continue;
5105
- }
5106
- const geminiChunks = translateOpenAIChunkToGemini(chunk, streamState);
5107
- for (const gc of geminiChunks) await s.write(`data: ${JSON.stringify(echoParsedEvent(gc, ctx))}\n\n`);
5108
- }
5109
- recordResponse(ctx.historyId, {
5110
- success: true,
5111
- model: streamState.model || model,
5112
- usage: {
5113
- input_tokens: streamState.usage.promptTokens,
5114
- output_tokens: streamState.usage.completionTokens
5115
- },
5116
- content: null
5117
- }, Date.now() - ctx.startTime);
5118
- completeTracking(ctx.trackingId, streamState.usage.promptTokens, streamState.usage.completionTokens, ctx.queueWaitMs, void 0, {
5119
- model: streamState.model || model,
5120
- stream: true,
5121
- durationMs: Date.now() - ctx.startTime,
5122
- stopReason: streamState.finishReason || void 0,
5123
- toolCount: payload.tools?.length ?? 0
5124
- });
5125
- } catch (error) {
5126
- recordStreamError({
5127
- acc: { model: streamState.model || model },
5128
- fallbackModel: model,
5129
- ctx,
5130
- error,
5131
- endpoint: "chat_completions"
5132
- });
5133
- failTracking(ctx.trackingId, error);
5134
- try {
5135
- await s.write(`data: ${JSON.stringify({ candidates: [{
5136
- content: {
5137
- role: "model",
5138
- parts: [{ text: `\n\n[copilot-api: upstream stream terminated. Please retry.]` }]
5139
- },
5140
- finishReason: "OTHER",
5141
- index: 0
5142
- }] })}\n\n`);
5143
- } catch {}
5144
- }
5145
- });
5146
- } catch (error) {
5147
- const trackingId = c.get("trackingId");
5148
- if (trackingId) failTracking(trackingId, error);
5149
- return forwardGeminiError(c, error);
5150
- }
5151
- }
5152
- function handleNonStreamResponse(c, response, model, ctx, payload) {
5153
- const geminiResponse = translateOpenAIResponseToGemini(response, model);
5154
- const usage = response.usage;
5155
- recordResponse(ctx.historyId, {
5156
- success: true,
5157
- model: response.model || model,
5158
- usage: {
5159
- input_tokens: usage?.prompt_tokens ?? 0,
5160
- output_tokens: usage?.completion_tokens ?? 0
5161
- },
5162
- stop_reason: response.choices[0]?.finish_reason,
5163
- content: response.choices[0] ? {
5164
- role: "assistant",
5165
- content: response.choices[0].message.content ?? ""
5166
- } : null
5167
- }, Date.now() - ctx.startTime);
5168
- completeTracking(ctx.trackingId, usage?.prompt_tokens ?? 0, usage?.completion_tokens ?? 0, ctx.queueWaitMs, void 0, {
5169
- model: response.model || model,
5170
- stream: false,
5171
- durationMs: Date.now() - ctx.startTime,
5172
- stopReason: response.choices[0]?.finish_reason,
5173
- toolCount: payload.tools?.length ?? 0
5174
- });
5175
- return c.json(echoResponseBody(geminiResponse, ctx));
5176
- }
5177
-
5178
- //#endregion
5179
- //#region src/routes/gemini/model-alias.ts
5180
- /**
5181
- * Maps Gemini model names to equivalent models available on GitHub Copilot.
5182
- *
5183
- * Two types of aliases:
5184
- *
5185
- * - **Forced**: Always applied regardless of Copilot model availability.
5186
- * Use when the old model name should never reach the backend.
5187
- *
5188
- * - **Conditional**: Only applied when the requested model is absent from
5189
- * the Copilot model list, so if Copilot adds native support the request
5190
- * goes through unchanged.
5191
- */
5192
- const GEMINI_FORCED_ALIASES = { "gemini-3.1-pro-preview-customtools": "gemini-3.1-pro-preview" };
5193
- const GEMINI_CONDITIONAL_ALIASES = {
5194
- "gemini-2.5-flash-lite": "gemini-3.5-flash",
5195
- "gemini-2.5-flash": "gemini-3.5-flash"
5196
- };
5197
- function resolveGeminiModelAlias(model) {
5198
- if (model in GEMINI_FORCED_ALIASES) return GEMINI_FORCED_ALIASES[model];
5199
- if (!(model in GEMINI_CONDITIONAL_ALIASES)) return model;
5200
- if (findModelById(model)) return model;
5201
- return GEMINI_CONDITIONAL_ALIASES[model];
5068
+ //#region src/lib/srvx-bun.ts
5069
+ function getBunServerFromRequest(req) {
5070
+ return req.runtime?.bun?.server;
5202
5071
  }
5203
5072
 
5204
- //#endregion
5205
- //#region src/routes/gemini/route.ts
5206
- const geminiRoutes = new Hono();
5207
- geminiRoutes.post("/:modelAction", async (c) => {
5208
- const modelAction = c.req.param("modelAction");
5209
- const colonIndex = modelAction.lastIndexOf(":");
5210
- if (colonIndex === -1) return geminiError(c, 400, "INVALID_ARGUMENT", "Missing action in URL");
5211
- const rawModel = modelAction.slice(0, Math.max(0, colonIndex));
5212
- const requestedModel = captureRequestedModel(rawModel);
5213
- const model = resolveGeminiModelAlias(rawModel);
5214
- const action = modelAction.slice(Math.max(0, colonIndex + 1));
5215
- switch (action) {
5216
- case "generateContent": return handleGeminiGenerate(c, model, false, requestedModel);
5217
- case "streamGenerateContent": return handleGeminiGenerate(c, model, true, requestedModel);
5218
- case "countTokens": return handleGeminiCountTokens(c, model);
5219
- default: return geminiError(c, 400, "INVALID_ARGUMENT", `Unknown action: ${action}`);
5220
- }
5221
- });
5222
-
5223
5073
  //#endregion
5224
5074
  //#region src/routes/history/api.ts
5225
5075
  function handleGetEntries(c) {
@@ -6717,7 +6567,7 @@ historyRoutes.get("/api/sessions/:id", handleGetSession);
6717
6567
  historyRoutes.delete("/api/sessions/:id", handleDeleteSession);
6718
6568
  historyRoutes.get("/ws", (c) => {
6719
6569
  if (c.req.header("Upgrade") !== "websocket") return c.text("Expected WebSocket upgrade", 426);
6720
- if (c.env?.server?.upgrade(c.req.raw)) return new Response(null, { status: 101 });
6570
+ if (getBunServerFromRequest(c.req.raw)?.upgrade(c.req.raw, { data: { kind: "history" } })) return new Response(null, { status: 101 });
6721
6571
  return c.text("WebSocket upgrade failed", 500);
6722
6572
  });
6723
6573
  historyRoutes.get("/", (c) => {
@@ -8291,7 +8141,7 @@ function handleDirectAnthropicNonStreamingResponse(c, response, ctx, truncateRes
8291
8141
  stopReason: response.stop_reason ?? void 0
8292
8142
  });
8293
8143
  let finalResponse = response;
8294
- if (state.verbose && truncateResult?.wasCompacted) finalResponse = prependMarkerToResponse(response, createTruncationMarker$1(truncateResult));
8144
+ if (state.verbose && truncateResult?.wasCompacted) finalResponse = prependMarkerToResponse(response, formatClientTruncationMarker(truncateResult));
8295
8145
  logServerToolBlocks(finalResponse.content);
8296
8146
  finalResponse = filterServerToolBlocksFromResponse(finalResponse);
8297
8147
  return c.json(echoResponseBody(finalResponse, ctx));
@@ -8496,7 +8346,7 @@ function handleNonStreamingResponse(opts) {
8496
8346
  let anthropicResponse = translateToAnthropic(response, toolNameMapping);
8497
8347
  consola.debug("Translated Anthropic response:", JSON.stringify(anthropicResponse));
8498
8348
  if (state.verbose && ctx.truncateResult?.wasCompacted) {
8499
- const marker = createTruncationMarker$1(ctx.truncateResult);
8349
+ const marker = formatClientTruncationMarker(ctx.truncateResult);
8500
8350
  anthropicResponse = prependMarkerToResponse(anthropicResponse, marker);
8501
8351
  }
8502
8352
  recordResponse(ctx.historyId, {
@@ -8551,7 +8401,7 @@ async function handleStreamingResponse(opts) {
8551
8401
  const checkRepetition = createStreamRepetitionChecker(`translated:${anthropicPayload.model}`);
8552
8402
  try {
8553
8403
  if (ctx.truncateResult?.wasCompacted) {
8554
- const marker = createTruncationMarker$1(ctx.truncateResult);
8404
+ const marker = formatClientTruncationMarker(ctx.truncateResult);
8555
8405
  await sendTruncationMarkerEvent(stream, streamState, marker);
8556
8406
  acc.content += marker;
8557
8407
  }
@@ -8663,37 +8513,34 @@ function resolveModelFromBetaHeader(model, betaHeader) {
8663
8513
  return resolved;
8664
8514
  }
8665
8515
  async function handleCompletion(c) {
8666
- const anthropicPayload = await c.req.json();
8667
- consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload));
8668
- const requestedModel = captureRequestedModel(anthropicPayload.model);
8669
- const betaHeader = c.req.header("anthropic-beta");
8670
- anthropicPayload.model = resolveModelFromBetaHeader(anthropicPayload.model, betaHeader);
8516
+ const rawPayload = await c.req.json();
8517
+ consola.debug("Anthropic request payload:", JSON.stringify(rawPayload));
8518
+ const { ctx, payload: anthropicPayload } = createEntryContext({
8519
+ c,
8520
+ rawPayload,
8521
+ endpoint: "anthropic",
8522
+ normalizePayload: (p) => ({
8523
+ ...p,
8524
+ model: resolveModelFromBetaHeader(p.model, c.req.header("anthropic-beta"))
8525
+ }),
8526
+ buildHistoryRequest: (p) => ({
8527
+ model: p.model,
8528
+ messages: convertAnthropicMessages(p.messages),
8529
+ stream: p.stream ?? false,
8530
+ tools: p.tools?.map((t) => ({
8531
+ name: t.name,
8532
+ description: t.description
8533
+ })),
8534
+ max_tokens: p.max_tokens,
8535
+ temperature: p.temperature,
8536
+ system: extractSystemPrompt(p.system)
8537
+ })
8538
+ });
8671
8539
  logToolInfo(anthropicPayload);
8672
8540
  const subagentMarker = parseSubagentMarkerFromFirstUser(anthropicPayload);
8673
8541
  const initiatorOverride = subagentMarker ? "agent" : void 0;
8674
8542
  if (subagentMarker) consola.debug("Detected Subagent marker:", JSON.stringify(subagentMarker));
8675
- const useDirectAnthropicApi = supportsDirectAnthropicApi(anthropicPayload.model);
8676
- const trackingId = c.get("trackingId");
8677
- const startTime = (trackingId ? requestTracker.getRequest(trackingId) : void 0)?.startTime ?? Date.now();
8678
- updateTrackerModel(trackingId, anthropicPayload.model);
8679
- const ctx = {
8680
- historyId: recordRequest("anthropic", {
8681
- model: anthropicPayload.model,
8682
- messages: convertAnthropicMessages(anthropicPayload.messages),
8683
- stream: anthropicPayload.stream ?? false,
8684
- tools: anthropicPayload.tools?.map((t) => ({
8685
- name: t.name,
8686
- description: t.description
8687
- })),
8688
- max_tokens: anthropicPayload.max_tokens,
8689
- temperature: anthropicPayload.temperature,
8690
- system: extractSystemPrompt(anthropicPayload.system)
8691
- }),
8692
- trackingId,
8693
- startTime,
8694
- requestedModel
8695
- };
8696
- if (useDirectAnthropicApi) return handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiatorOverride);
8543
+ if (supportsDirectAnthropicApi(anthropicPayload.model)) return handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiatorOverride);
8697
8544
  return handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOverride);
8698
8545
  }
8699
8546
  /**
@@ -8793,7 +8640,7 @@ const modelRoutes = new Hono();
8793
8640
  modelRoutes.get("/", async (c) => {
8794
8641
  try {
8795
8642
  if (!state.models) await cacheModels();
8796
- const models = state.models?.data.map((model) => ({
8643
+ const models = state.models?.data.filter((model) => !isHiddenModel(model.id, state.showAllModels)).map((model) => ({
8797
8644
  id: model.id,
8798
8645
  object: "model",
8799
8646
  type: "model",
@@ -9083,33 +8930,34 @@ const TERMINAL_EVENTS = new Set([
9083
8930
  "error"
9084
8931
  ]);
9085
8932
  const handleResponses = async (c) => {
9086
- let payload = await c.req.json();
9087
- const requestedModel = captureRequestedModel(payload.model);
9088
- if (state.normalizeResponsesCallIds) payload = normalizeCallIds(payload);
8933
+ const { ctx, payload } = createEntryContext({
8934
+ c,
8935
+ rawPayload: await c.req.json(),
8936
+ endpoint: "openai",
8937
+ normalizePayload: (p) => {
8938
+ const np = state.normalizeResponsesCallIds ? normalizeCallIds(p) : p;
8939
+ useFunctionApplyPatch(np);
8940
+ filterUnsupportedBuiltins(np);
8941
+ return np;
8942
+ },
8943
+ buildHistoryRequest: (p) => {
8944
+ const historyTools = convertResponsesToolsToDefinitions(p.tools);
8945
+ return {
8946
+ model: p.model,
8947
+ messages: convertResponsesInputToMessages(p.input),
8948
+ stream: p.stream ?? false,
8949
+ tools: historyTools.length > 0 ? historyTools : void 0,
8950
+ max_tokens: p.max_output_tokens ?? void 0,
8951
+ temperature: p.temperature ?? void 0,
8952
+ system: p.instructions ?? void 0
8953
+ };
8954
+ }
8955
+ });
9089
8956
  consola.debug("Responses request payload:", JSON.stringify(payload));
9090
- const trackingId = c.get("trackingId");
9091
- const startTime = (trackingId ? requestTracker.getRequest(trackingId) : void 0)?.startTime ?? Date.now();
9092
- updateTrackerModel(trackingId, payload.model);
9093
- useFunctionApplyPatch(payload);
9094
- removeWebSearchTool(payload);
9095
8957
  const model = payload.model;
9096
8958
  const stream = payload.stream ?? false;
9097
8959
  const tools = convertResponsesToolsToDefinitions(payload.tools);
9098
- const historyId = recordRequest("openai", {
9099
- model,
9100
- messages: convertResponsesInputToMessages(payload.input),
9101
- stream,
9102
- tools: tools.length > 0 ? tools : void 0,
9103
- max_tokens: payload.max_output_tokens ?? void 0,
9104
- temperature: payload.temperature ?? void 0,
9105
- system: payload.instructions ?? void 0
9106
- });
9107
- const ctx = {
9108
- historyId,
9109
- trackingId,
9110
- startTime,
9111
- requestedModel
9112
- };
8960
+ const { historyId, trackingId, startTime } = ctx;
9113
8961
  const selectedModel = findModelById(payload.model);
9114
8962
  if (!(selectedModel?.supported_endpoints?.includes(RESPONSES_ENDPOINT) ?? false)) {
9115
8963
  recordErrorResponse(ctx, model, /* @__PURE__ */ new Error("This model does not support the responses endpoint."), "responses", stream);
@@ -9149,7 +8997,7 @@ const handleResponses = async (c) => {
9149
8997
  const parsed = JSON.parse(rawData);
9150
8998
  if (typeof parsed.sequence_number === "number") lastSequenceNumber = parsed.sequence_number;
9151
8999
  } catch {}
9152
- const processedData = fixStreamIds(rawData, eventType, idTracker, requestedModel);
9000
+ const processedData = fixStreamIds(rawData, eventType, idTracker, requestedModelOf(ctx));
9153
9001
  await stream.writeSSE({
9154
9002
  id: chunk.id,
9155
9003
  event: eventType,
@@ -9246,11 +9094,10 @@ const useFunctionApplyPatch = (payload) => {
9246
9094
  }
9247
9095
  }
9248
9096
  };
9249
- const removeWebSearchTool = (payload) => {
9097
+ const UNSUPPORTED_BUILTIN_TOOL_TYPES = new Set(["web_search", "image_generation"]);
9098
+ const filterUnsupportedBuiltins = (payload) => {
9250
9099
  if (!Array.isArray(payload.tools) || payload.tools.length === 0) return;
9251
- payload.tools = payload.tools.filter((t) => {
9252
- return t.type !== "web_search";
9253
- });
9100
+ payload.tools = payload.tools.filter((t) => typeof t.type !== "string" || !UNSUPPORTED_BUILTIN_TOOL_TYPES.has(t.type));
9254
9101
  };
9255
9102
  /** Record a ResponsesResult to history */
9256
9103
  function recordResponseResult(result, fallbackModel, historyId, startTime) {
@@ -9309,8 +9156,9 @@ usageRoute.get("/", async (c) => {
9309
9156
  const server = new Hono();
9310
9157
  server.use(tuiLogger());
9311
9158
  server.use(cors());
9159
+ server.use(authGate());
9312
9160
  server.get("/", (c) => c.text("Server running"));
9313
- server.get("/health", (c) => {
9161
+ const healthHandler = (c) => {
9314
9162
  const healthy = Boolean(state.copilotToken && state.githubToken);
9315
9163
  return c.json({
9316
9164
  status: healthy ? "healthy" : "unhealthy",
@@ -9320,20 +9168,19 @@ server.get("/health", (c) => {
9320
9168
  models: Boolean(state.models)
9321
9169
  }
9322
9170
  }, healthy ? 200 : 503);
9323
- });
9171
+ };
9172
+ server.get("/health", healthHandler);
9173
+ server.get("/health/", healthHandler);
9324
9174
  server.route("/chat/completions", completionRoutes);
9325
9175
  server.route("/models", modelRoutes);
9326
- server.route("/embeddings", embeddingRoutes);
9327
9176
  server.route("/usage", usageRoute);
9328
9177
  server.route("/token", tokenRoute);
9329
9178
  server.route("/v1/chat/completions", completionRoutes);
9330
9179
  server.route("/v1/models", modelRoutes);
9331
- server.route("/v1/embeddings", embeddingRoutes);
9332
9180
  server.route("/v1/messages", messageRoutes);
9333
9181
  server.route("/api/event_logging", eventLoggingRoutes);
9334
9182
  server.route("/v1/responses", responsesRoutes);
9335
9183
  server.route("/responses", responsesRoutes);
9336
- server.route("/v1beta/models", geminiRoutes);
9337
9184
  server.route("/history", historyRoutes);
9338
9185
 
9339
9186
  //#endregion
@@ -9365,6 +9212,7 @@ function formatModelInfo(model) {
9365
9212
  }
9366
9213
  async function runServer(options) {
9367
9214
  consola.info(`copilot-api v${version}`);
9215
+ configureProxyApiKey(options.apiKey);
9368
9216
  if (options.proxyEnv) initProxyFromEnv();
9369
9217
  if (options.verbose) {
9370
9218
  consola.level = 5;
@@ -9381,6 +9229,8 @@ async function runServer(options) {
9381
9229
  if (options.accountType !== "individual") consola.info(`Using ${options.accountType} plan GitHub account`);
9382
9230
  state.manualApprove = options.manual;
9383
9231
  state.showToken = options.showToken;
9232
+ state.showAllModels = options.showAllModels;
9233
+ if (options.showAllModels) consola.warn("--show-all-models: hidden model blacklist is BYPASSED for this run");
9384
9234
  state.autoTruncate = options.autoTruncate;
9385
9235
  state.compressToolResults = options.compressToolResults;
9386
9236
  state.redirectAnthropic = options.redirectAnthropic;
@@ -9425,21 +9275,31 @@ async function runServer(options) {
9425
9275
  consola.error(error instanceof Error ? error.message : String(error));
9426
9276
  process.exit(1);
9427
9277
  }
9428
- consola.info(`Available models:\n${state.models?.data.map((m) => formatModelInfo(m)).join("\n")}`);
9429
- const serverUrl = `http://${options.host ?? "localhost"}:${options.port}`;
9278
+ const allModels = state.models?.data ?? [];
9279
+ if (allModels.length === 0) {
9280
+ consola.error(`Upstream returned zero models for account type "${state.accountType}". Verify the account type matches your Copilot plan and that upstream is reachable.`);
9281
+ process.exit(1);
9282
+ }
9283
+ const visibleModels = allModels.filter((m) => !isHiddenModel(m.id, state.showAllModels));
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.");
9285
+ else consola.info(`Available models:\n${visibleModels.map((m) => formatModelInfo(m)).join("\n")}`);
9286
+ const serverUrl = `http://${resolveClientHost(options.host, process.env.HOST)}:${options.port}`;
9430
9287
  if (options.claudeCode) {
9431
- invariant(state.models, "Models should be loaded by now");
9288
+ if (visibleModels.length === 0) {
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.");
9290
+ process.exit(1);
9291
+ }
9432
9292
  const selectedModel = await consola.prompt("Select a model to use with Claude Code", {
9433
9293
  type: "select",
9434
- options: state.models.data.map((model) => model.id)
9294
+ options: visibleModels.map((model) => model.id)
9435
9295
  });
9436
9296
  const selectedSmallModel = await consola.prompt("Select a small model to use with Claude Code", {
9437
9297
  type: "select",
9438
- options: state.models.data.map((model) => model.id)
9298
+ options: visibleModels.map((model) => model.id)
9439
9299
  });
9440
9300
  const command = generateEnvScript({
9441
9301
  ANTHROPIC_BASE_URL: serverUrl,
9442
- ANTHROPIC_AUTH_TOKEN: "dummy",
9302
+ [CLAUDE_CODE_AUTH_TOKEN_ENV]: CLAUDE_CODE_AUTH_TOKEN_PLACEHOLDER,
9443
9303
  ANTHROPIC_MODEL: selectedModel,
9444
9304
  ANTHROPIC_DEFAULT_SONNET_MODEL: selectedModel,
9445
9305
  ANTHROPIC_SMALL_FAST_MODEL: selectedSmallModel,
@@ -9454,13 +9314,27 @@ async function runServer(options) {
9454
9314
  consola.warn("Failed to copy to clipboard. Here is the Claude Code command:");
9455
9315
  consola.log(command);
9456
9316
  }
9317
+ for (const line of buildClaudeCodeAuthHint(options.apiKeySource)) consola.warn(line);
9457
9318
  }
9458
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`);
9459
9324
  setupShutdownHandlers();
9460
9325
  setServerInstance(serve({
9461
9326
  fetch: server.fetch,
9462
9327
  port: options.port,
9463
- 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
+ } }
9464
9338
  }));
9465
9339
  }
9466
9340
  function parseTimezoneOffset(value) {
@@ -9542,7 +9416,7 @@ const start = defineCommand({
9542
9416
  "github-token": {
9543
9417
  alias: "g",
9544
9418
  type: "string",
9545
- 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."
9546
9420
  },
9547
9421
  "claude-code": {
9548
9422
  alias: "c",
@@ -9555,6 +9429,11 @@ const start = defineCommand({
9555
9429
  default: false,
9556
9430
  description: "Show GitHub and Copilot tokens on fetch and refresh"
9557
9431
  },
9432
+ "show-all-models": {
9433
+ type: "boolean",
9434
+ default: false,
9435
+ description: "Show ALL upstream models, including the hardcoded blacklist (default: false, blacklist filtered from listings)"
9436
+ },
9558
9437
  "proxy-env": {
9559
9438
  type: "boolean",
9560
9439
  default: false,
@@ -9603,9 +9482,17 @@ const start = defineCommand({
9603
9482
  "posthog-key": {
9604
9483
  type: "string",
9605
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)."
9606
9489
  }
9607
9490
  },
9608
9491
  run({ args }) {
9492
+ const resolvedApiKey = resolveProxyApiKey({
9493
+ flag: args["api-key"],
9494
+ env: process.env.COPILOT_API_KEY
9495
+ });
9609
9496
  return runServer({
9610
9497
  port: Number.parseInt(args.port, 10),
9611
9498
  host: args.host,
@@ -9617,9 +9504,10 @@ const start = defineCommand({
9617
9504
  requestInterval: Number.parseInt(args["request-interval"], 10),
9618
9505
  recoveryTimeout: Number.parseInt(args["recovery-timeout"], 10),
9619
9506
  consecutiveSuccesses: Number.parseInt(args["consecutive-successes"], 10),
9620
- githubToken: args["github-token"],
9507
+ githubToken: args["github-token"] || process.env.GH_TOKEN,
9621
9508
  claudeCode: args["claude-code"],
9622
9509
  showToken: args["show-token"],
9510
+ showAllModels: args["show-all-models"],
9623
9511
  proxyEnv: args["proxy-env"],
9624
9512
  history: !args["no-history"],
9625
9513
  historyLimit: Number.parseInt(args["history-limit"], 10),
@@ -9629,7 +9517,9 @@ const start = defineCommand({
9629
9517
  stripServerTools: args["strip-server-tools"],
9630
9518
  contextEditing: parseContextEditing(args["context-editing"]),
9631
9519
  timezoneOffset: parseTimezoneOffset(args["timezone-offset"]),
9632
- posthogKey: args["posthog-key"]
9520
+ posthogKey: args["posthog-key"],
9521
+ apiKey: resolvedApiKey.key,
9522
+ apiKeySource: resolvedApiKey.source
9633
9523
  });
9634
9524
  }
9635
9525
  });