ghc-proxy 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/main.mjs +98 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -119,7 +119,7 @@ bunx ghc-proxy@latest selfcheck # Probe the packaged bundle (loads every to
119
119
  | `--dump-failed-payloads` | `-D` | `false` | Dump failed `/responses` payloads on upstream 400 errors for debugging. Can also be enabled with `DUMP_FAILED_PAYLOADS=1`. |
120
120
  | `--proxy-env` | -- | `false` | Use `HTTP_PROXY`/`HTTPS_PROXY` from env (Node.js only; Bun reads proxy env natively) |
121
121
  | `--idle-timeout` | -- | `120` | Bun server idle timeout in seconds (`0` disables; Bun max is `255`; streaming routes disable idle timeout automatically) |
122
- | `--upstream-timeout` | -- | `1800` | Upstream request timeout in seconds (0 to disable) |
122
+ | `--upstream-timeout` | -- | `1800` | Upstream request timeout in seconds (`0` disables). Enforced as a total-duration `AbortSignal`. Note both runtimes also apply their own ~300s **idle** timeout to `fetch` (Bun's built-in limit; Node's undici `headersTimeout`/`bodyTimeout`), which fires when no byte arrives for that long — a steadily streaming response is not capped by it, but a stalled one is rejected at ~300s and returned as a `504`. |
123
123
  | `--upstream-queue-concurrency` | -- | `10` | Maximum concurrent Copilot upstream requests |
124
124
  | `--upstream-queue-retries` | -- | `5` | Maximum retries for transient upstream responses. Completion requests (`/v1/messages`, `/chat/completions`, `/responses`) retry only `429`/`529`; effect-free requests also retry `408`, `500`, `502`, `503`, `504` |
125
125
  | `--upstream-queue-base-delay` | -- | `2` | Base delay in seconds for upstream retry backoff when `Retry-After` is absent |
package/dist/main.mjs CHANGED
@@ -7419,7 +7419,7 @@ const checkUsage = defineCommand({
7419
7419
  });
7420
7420
  //#endregion
7421
7421
  //#region src/util/version.ts
7422
- const VERSION = "0.9.0";
7422
+ const VERSION = "0.9.1";
7423
7423
  //#endregion
7424
7424
  //#region src/debug.ts
7425
7425
  function getRuntimeInfo() {
@@ -47868,6 +47868,61 @@ function logRequest(method, url, status, elapsed, modelInfo, requestId) {
47868
47868
  console.log(`${line}${formatModelMapping(modelInfo)}${rid}`);
47869
47869
  }
47870
47870
  //#endregion
47871
+ //#region src/lib/timeout-error.ts
47872
+ /**
47873
+ * Whether an error represents a request that timed out or was aborted.
47874
+ *
47875
+ * The shape differs by runtime, so the check is structural rather than a
47876
+ * single `name` comparison:
47877
+ * - Bun rejects with a flat `DOMException` named `TimeoutError` (its ~300s
47878
+ * `fetch` ceiling, `AbortSignal.timeout`) or `AbortError`.
47879
+ * - Node rejects with `TypeError('fetch failed' | 'terminated')` and puts the
47880
+ * real undici error on `.cause` (`HeadersTimeoutError`, `BodyTimeoutError`,
47881
+ * `ConnectTimeoutError`), so the top-level error carries no signal at all —
47882
+ * `TypeError('fetch failed')` is also what `ECONNREFUSED` and DNS failures
47883
+ * look like. The discriminator is the cause's `name`/`code`.
47884
+ *
47885
+ * Both runtimes enforce a ~300s upstream ceiling by default (Node's is
47886
+ * undici's `headersTimeout`/`bodyTimeout` default of `300e3`), which fires
47887
+ * long before the configured `--upstream-timeout` of 1800s.
47888
+ *
47889
+ * Kept in one place because the rule is checked on both sides of the stream
47890
+ * boundary: `src/server.ts` maps it to a 504 before the first byte, and the
47891
+ * Anthropic stream transducer maps it to an SSE error frame after. Two
47892
+ * implementations of "what counts as a timeout" is how one of them ends up
47893
+ * recognizing only half the errors.
47894
+ */
47895
+ const TIMEOUT_ERROR_NAMES = new Set([
47896
+ "AbortError",
47897
+ "TimeoutError",
47898
+ "ConnectTimeoutError",
47899
+ "HeadersTimeoutError",
47900
+ "BodyTimeoutError"
47901
+ ]);
47902
+ const TIMEOUT_ERROR_CODES = new Set([
47903
+ "UND_ERR_CONNECT_TIMEOUT",
47904
+ "UND_ERR_HEADERS_TIMEOUT",
47905
+ "UND_ERR_BODY_TIMEOUT",
47906
+ "ETIMEDOUT"
47907
+ ]);
47908
+ const MAX_CAUSE_DEPTH = 5;
47909
+ function matchesTimeoutShape(value, depth) {
47910
+ if (typeof value !== "object" || value === null) return false;
47911
+ const candidate = value;
47912
+ if (typeof candidate.name === "string" && TIMEOUT_ERROR_NAMES.has(candidate.name)) return true;
47913
+ if (typeof candidate.code === "string" && TIMEOUT_ERROR_CODES.has(candidate.code)) return true;
47914
+ if (depth >= MAX_CAUSE_DEPTH) return false;
47915
+ if (matchesTimeoutShape(candidate.cause, depth + 1)) return true;
47916
+ return Array.isArray(candidate.errors) && candidate.errors.some((inner) => matchesTimeoutShape(inner, depth + 1));
47917
+ }
47918
+ function isTimeoutLikeError(error) {
47919
+ try {
47920
+ return matchesTimeoutShape(error, 0);
47921
+ } catch {
47922
+ return false;
47923
+ }
47924
+ }
47925
+ //#endregion
47871
47926
  //#region src/lib/sse-adapter.ts
47872
47927
  /**
47873
47928
  * Serializes Anthropic stream events into SSE output items
@@ -49418,6 +49473,14 @@ function createUpstreamSignal(clientSignal, timeoutMs = DEFAULT_TIMEOUT_MS) {
49418
49473
  }
49419
49474
  /**
49420
49475
  * Convenience wrapper that reads the upstream timeout from runtime config.
49476
+ *
49477
+ * This signal is a *total-duration* limit. Both runtimes separately apply an
49478
+ * ~300s **idle** timeout to `fetch` — Bun's is built in, Node's is undici's
49479
+ * `headersTimeout` / `bodyTimeout` default of `300e3` — which resets on every
49480
+ * byte received. A response that keeps streaming therefore runs past 300s and
49481
+ * is bounded only by this signal; a stalled one is rejected at ~300s by the
49482
+ * runtime instead. `isTimeoutLikeError` recognizes both runtimes' shapes so
49483
+ * every path maps to a 504.
49421
49484
  */
49422
49485
  function createUpstreamSignalFromConfig(clientSignal) {
49423
49486
  return createUpstreamSignal(clientSignal, authStore.upstreamTimeoutSeconds !== void 0 ? authStore.upstreamTimeoutSeconds * 1e3 : void 0);
@@ -50465,14 +50528,9 @@ var AnthropicStreamTranslator = class {
50465
50528
  this.state.messageStartSent = true;
50466
50529
  }
50467
50530
  getErrorMessage(error) {
50468
- if (this.isTimeoutError(error)) return "Upstream streaming request timed out. Please retry.";
50531
+ if (isTimeoutLikeError(error)) return "Upstream streaming request timed out. Please retry.";
50469
50532
  return "An unexpected error occurred during streaming.";
50470
50533
  }
50471
- isTimeoutError(error) {
50472
- if (error instanceof DOMException) return error.name === "TimeoutError";
50473
- if (error instanceof Error) return error.name === "TimeoutError";
50474
- return false;
50475
- }
50476
50534
  toConversationDeltas(chunk) {
50477
50535
  if (chunk.choices.length === 0) return [];
50478
50536
  const choice = chunk.choices.toSorted((left, right) => left.index - right.index)[0];
@@ -53344,6 +53402,33 @@ function createUsageRoute() {
53344
53402
  //#endregion
53345
53403
  //#region src/server.ts
53346
53404
  const isBun = typeof globalThis.Bun !== "undefined";
53405
+ /**
53406
+ * Maps a thrown error to a client response.
53407
+ *
53408
+ * `set.status` is written on every branch because `onError` returns a fresh
53409
+ * `Response` instead of falling through Elysia's normal path — `set.status`
53410
+ * would otherwise still hold whatever it was before the throw, and the access
53411
+ * log in `onAfterResponse` reads it. Without the write-back, a 504 is logged
53412
+ * as a 500.
53413
+ *
53414
+ * Exported so tests exercise this mapping rather than a copy of it.
53415
+ */
53416
+ function handleRouteError({ code, error, set }) {
53417
+ if (code === "HTTP") return;
53418
+ if (isTimeoutLikeError(error)) {
53419
+ set.status = 504;
53420
+ return Response.json({ error: {
53421
+ message: "Upstream request timed out before a response was received.",
53422
+ type: "timeout_error"
53423
+ } }, { status: 504 });
53424
+ }
53425
+ const message = error instanceof Error ? error.message : String(error);
53426
+ set.status = 500;
53427
+ return Response.json({ error: {
53428
+ message,
53429
+ type: "error"
53430
+ } }, { status: 500 });
53431
+ }
53347
53432
  function createServer(options) {
53348
53433
  return new Elysia({
53349
53434
  adapter: isBun ? void 0 : node(),
@@ -53363,18 +53448,11 @@ function createServer(options) {
53363
53448
  const elapsed = formatElapsed(requestStart);
53364
53449
  const status = typeof set.status === "number" ? set.status : 200;
53365
53450
  logRequest(request.method, request.url, status, elapsed, getRequestModelMapping(request), requestId);
53366
- }).onError(({ code, error }) => {
53367
- if (code === "HTTP") return;
53368
- if (error instanceof Error && error.name === "AbortError") return Response.json({ error: {
53369
- message: "Upstream request was aborted",
53370
- type: "timeout_error"
53371
- } }, { status: 504 });
53372
- const message = error instanceof Error ? error.message : String(error);
53373
- return Response.json({ error: {
53374
- message,
53375
- type: "error"
53376
- } }, { status: 500 });
53377
- }).get("/", () => "Server running").get("/health", () => ({
53451
+ }).onError(({ code, error, set }) => handleRouteError({
53452
+ code,
53453
+ error,
53454
+ set
53455
+ })).get("/", () => "Server running").get("/health", () => ({
53378
53456
  status: "ok",
53379
53457
  copilotToken: !!authStore.copilotToken,
53380
53458
  modelsLoaded: !!modelCache.getModels(),
@@ -53547,7 +53625,7 @@ const start = defineCommand({
53547
53625
  "upstream-timeout": {
53548
53626
  type: "string",
53549
53627
  default: "1800",
53550
- description: "Upstream request timeout in seconds (0 to disable)"
53628
+ description: "Upstream request timeout in seconds (0 to disable). Enforced as a total-duration limit; both runtimes additionally apply their own ~300s idle timeout to fetch, which a steadily streaming response does not trip."
53551
53629
  },
53552
53630
  "upstream-queue-concurrency": {
53553
53631
  type: "string",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ghc-proxy",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.9.1",
5
5
  "description": "GitHub Copilot to OpenAI/Anthropic API proxy - Use Copilot with Claude Code, Cursor, and more",
6
6
  "author": "wxxb789 <wxxb789@outlook.com>",
7
7
  "homepage": "https://github.com/wxxb789/ghc-proxy",