@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.
- package/README.md +157 -29
- package/dist/main.mjs +1162 -1272
- 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 {
|
|
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.
|
|
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/
|
|
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
|
-
*
|
|
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
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
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$
|
|
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$
|
|
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/
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
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
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
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/
|
|
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
|
-
*
|
|
4070
|
-
*
|
|
4071
|
-
*
|
|
4072
|
-
*
|
|
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
|
|
4075
|
-
|
|
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
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
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
|
-
*
|
|
4087
|
-
*
|
|
4088
|
-
*
|
|
4089
|
-
*
|
|
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
|
-
*
|
|
4189
|
-
*
|
|
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
|
|
4192
|
-
|
|
4193
|
-
const
|
|
4194
|
-
|
|
4195
|
-
const
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
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
|
-
*
|
|
4269
|
-
*
|
|
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
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
if (
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
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
|
|
4321
|
-
consola.debug("Request payload:", JSON.stringify(
|
|
4322
|
-
const
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
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:
|
|
4336
|
-
temperature:
|
|
4337
|
-
})
|
|
4338
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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/
|
|
4721
|
-
|
|
4722
|
-
|
|
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.
|
|
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,
|
|
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 =
|
|
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 =
|
|
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
|
|
8667
|
-
consola.debug("Anthropic request payload:", JSON.stringify(
|
|
8668
|
-
const
|
|
8669
|
-
|
|
8670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9087
|
-
|
|
9088
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9429
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
9298
|
+
options: visibleModels.map((model) => model.id)
|
|
9439
9299
|
});
|
|
9440
9300
|
const command = generateEnvScript({
|
|
9441
9301
|
ANTHROPIC_BASE_URL: serverUrl,
|
|
9442
|
-
|
|
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
|
});
|