@openhoo/hoopilot 2.1.2 → 2.1.4

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 CHANGED
@@ -250,6 +250,8 @@ With the [keyless Docker quick start](#keyless-local-quick-start), no key is inv
250
250
 
251
251
  `codexx` does not start Hoopilot and does not alter your shell environment. It starts `codex` with a temporary `hoopilot` model provider pointed at `http://127.0.0.1:4141/v1`, uses the Responses API wire format, disables Responses WebSockets for that provider, maps `HOOPILOT_API_KEY` (or a random throwaway key when none is set) to `OPENAI_API_KEY` for the child process, disables Codex's managed network proxy (`-c permissions.workspace.network.enabled=false`), and removes standard proxy variables from the spawned Codex process.
252
252
 
253
+ `codexx` also sets Codex's temporary provider stream idle timeout to 120 seconds by default, so a silent Responses stream fails and Codex's normal stream retry path can run instead of leaving the turn apparently stuck. Override with `CODEXX_STREAM_IDLE_TIMEOUT_MS`, or set it to `0` to use Codex's own default.
254
+
253
255
  > **Why the network proxy is disabled.** Codex ships a managed network proxy that routes the agent's traffic through a local proxy (port `3128`) and enforces a domain allowlist. Because the local Hoopilot server isn't on that allowlist, leaving the proxy on makes Codex's request to Hoopilot fail with an instant `403` Squid error. The proxy has two independent gates — the `network_proxy` feature flag and the `permissions.workspace.network.enabled` config — and disabling only the feature is not always enough, so `codexx` turns off both (`--disable network_proxy` and `-c permissions.workspace.network.enabled=false`) and requests reach Hoopilot directly.
254
256
 
255
257
  `codexx` defaults to `gpt-5.5` with `model_reasoning_effort="xhigh"`. Before starting Codex, it checks `/v1/models` and reports if the logged-in Copilot account does not advertise the requested model. Set `CODEXX_MODEL` to one of the listed models, or log in with a Copilot account that has access to the default model.
@@ -387,6 +389,8 @@ Logging and update settings:
387
389
  | --- | --- |
388
390
  | `HOOPILOT_LOG_LEVEL` / `--log-level` | `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or `silent`. Default: `info`. |
389
391
  | `HOOPILOT_LOG_FORMAT` / `--log-format` | `pretty` or `json`. Default: `pretty`. |
392
+ | `HOOPILOT_UPSTREAM_TIMEOUT_MS` | Time to wait for Copilot response headers before returning `504 copilot_timeout`. Default: `120000`; set `0` to disable. |
393
+ | `HOOPILOT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS` | Time to wait for bytes on a Copilot response body before failing the stream so clients can retry. Default: `120000`; set `0` to disable. |
390
394
  | `HOOPILOT_NO_UPDATE_CHECK` / `--no-update-check` | Disable background update checks. `NO_UPDATE_NOTIFIER` is also honored. |
391
395
 
392
396
  `codexx` settings:
@@ -398,6 +402,7 @@ Logging and update settings:
398
402
  | `CODEXX_CODEX_BIN` | Codex executable to run. Default: `codex`. |
399
403
  | `CODEXX_MODEL` | Codex model to use. Default: `gpt-5.5`. |
400
404
  | `CODEXX_MODEL_REASONING_EFFORT` | Codex reasoning effort. Default: `xhigh`. |
405
+ | `CODEXX_STREAM_IDLE_TIMEOUT_MS` | Codex Responses stream idle timeout for the temporary Hoopilot provider. Default: `120000`; set `0` to use Codex's own default. |
401
406
  | `CODEXX_SKIP_MODEL_PREFLIGHT=1` | Skip the `/v1/models` availability check before starting Codex. |
402
407
 
403
408
  ## CLI reference
@@ -116,6 +116,7 @@ var DEFAULT_BASE_URL = "http://127.0.0.1:4141/v1";
116
116
  var DEFAULT_CODEX_BIN = "codex";
117
117
  var DEFAULT_MODEL = "gpt-5.5";
118
118
  var DEFAULT_REASONING_EFFORT = "xhigh";
119
+ var DEFAULT_STREAM_IDLE_TIMEOUT_MS = 12e4;
119
120
  var PROXY_ENV_KEYS = [
120
121
  "ALL_PROXY",
121
122
  "HTTPS_PROXY",
@@ -132,13 +133,18 @@ function buildCodexxInvocation(argv, env = process.env) {
132
133
  const command = envValue(env.CODEXX_CODEX_BIN) ?? DEFAULT_CODEX_BIN;
133
134
  const model = envValue(env.CODEXX_MODEL) ?? DEFAULT_MODEL;
134
135
  const reasoningEffort = envValue(env.CODEXX_MODEL_REASONING_EFFORT) ?? DEFAULT_REASONING_EFFORT;
135
- const providerConfig = [
136
+ const streamIdleTimeoutMs = parseStreamIdleTimeoutMs(env.CODEXX_STREAM_IDLE_TIMEOUT_MS);
137
+ const providerConfigParts = [
136
138
  '{ name = "Hoopilot"',
137
139
  `base_url = ${JSON.stringify(baseUrl)}`,
138
140
  'env_key = "OPENAI_API_KEY"',
139
141
  'wire_api = "responses"',
140
- "supports_websockets = false }"
141
- ].join(", ");
142
+ "supports_websockets = false"
143
+ ];
144
+ if (streamIdleTimeoutMs > 0) {
145
+ providerConfigParts.push(`stream_idle_timeout_ms = ${streamIdleTimeoutMs}`);
146
+ }
147
+ const providerConfig = `${providerConfigParts.join(", ")} }`;
142
148
  return {
143
149
  args: [
144
150
  // Codex ships a managed network proxy (codex-rs/network-proxy) that routes the
@@ -176,6 +182,17 @@ function buildCodexxInvocation(argv, env = process.env) {
176
182
  function generateEphemeralApiKey() {
177
183
  return `codexx-${crypto.randomUUID()}`;
178
184
  }
185
+ function parseStreamIdleTimeoutMs(rawValue) {
186
+ const raw = envValue(rawValue);
187
+ if (raw === void 0) {
188
+ return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
189
+ }
190
+ const value = Number(raw);
191
+ if (!Number.isInteger(value) || value < 0) {
192
+ throw new Error("CODEXX_STREAM_IDLE_TIMEOUT_MS must be a non-negative integer.");
193
+ }
194
+ return value;
195
+ }
179
196
  function withoutProxyEnv(env) {
180
197
  const next = { ...env };
181
198
  for (const key of PROXY_ENV_KEYS) {
@@ -261,6 +278,9 @@ Environment:
261
278
  CODEXX_MODEL Codex model to use. Default: ${DEFAULT_MODEL}
262
279
  CODEXX_MODEL_REASONING_EFFORT
263
280
  Codex reasoning effort. Default: ${DEFAULT_REASONING_EFFORT}
281
+ CODEXX_STREAM_IDLE_TIMEOUT_MS
282
+ Codex Responses stream idle timeout in milliseconds. Default:
283
+ ${DEFAULT_STREAM_IDLE_TIMEOUT_MS}; set 0 to use Codex's own default.
264
284
  CODEXX_SKIP_MODEL_PREFLIGHT
265
285
  Set to 1 to skip checking /v1/models before starting Codex.
266
286
 
@@ -295,4 +315,4 @@ export {
295
315
  main,
296
316
  verifyCodexxModel
297
317
  };
298
- //# sourceMappingURL=chunk-CYR6I4C3.js.map
318
+ //# sourceMappingURL=chunk-4ZG5QEYJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/codexx.ts","../src/util.ts"],"sourcesContent":["#!/usr/bin/env bun\n\nimport { spawn } from \"node:child_process\";\nimport { constants as osConstants } from \"node:os\";\nimport type { FetchLike } from \"./types\";\nimport {\n envValue,\n errorMessage,\n modelIdsFromResponse,\n trimTrailingSlash,\n truncatedResponseText,\n} from \"./util\";\n\nconst DEFAULT_BASE_URL = \"http://127.0.0.1:4141/v1\";\nconst DEFAULT_CODEX_BIN = \"codex\";\nconst DEFAULT_MODEL = \"gpt-5.5\";\nconst DEFAULT_REASONING_EFFORT = \"xhigh\";\nconst DEFAULT_STREAM_IDLE_TIMEOUT_MS = 120_000;\nconst PROXY_ENV_KEYS = [\n \"ALL_PROXY\",\n \"HTTPS_PROXY\",\n \"HTTP_PROXY\",\n \"NO_PROXY\",\n \"all_proxy\",\n \"https_proxy\",\n \"http_proxy\",\n \"no_proxy\",\n];\n\nexport interface CodexxInvocation {\n args: string[];\n baseUrl: string;\n command: string;\n env: NodeJS.ProcessEnv;\n model: string;\n}\n\nexport function buildCodexxInvocation(\n argv: string[],\n env: NodeJS.ProcessEnv = process.env,\n): CodexxInvocation {\n const baseUrl = envValue(env.CODEXX_BASE_URL) ?? DEFAULT_BASE_URL;\n // Never fall back to a public, predictable key: a shared constant like the old\n // \"local-key\" default is also a credential a malicious local/browser client\n // could guess. When no key is configured the local server is expected to run\n // unauthenticated, which accepts any value, so a random throwaway key is safe.\n const apiKey =\n envValue(env.CODEXX_API_KEY) ?? envValue(env.HOOPILOT_API_KEY) ?? generateEphemeralApiKey();\n const command = envValue(env.CODEXX_CODEX_BIN) ?? DEFAULT_CODEX_BIN;\n const model = envValue(env.CODEXX_MODEL) ?? DEFAULT_MODEL;\n const reasoningEffort = envValue(env.CODEXX_MODEL_REASONING_EFFORT) ?? DEFAULT_REASONING_EFFORT;\n const streamIdleTimeoutMs = parseStreamIdleTimeoutMs(env.CODEXX_STREAM_IDLE_TIMEOUT_MS);\n const providerConfigParts = [\n '{ name = \"Hoopilot\"',\n `base_url = ${JSON.stringify(baseUrl)}`,\n 'env_key = \"OPENAI_API_KEY\"',\n 'wire_api = \"responses\"',\n \"supports_websockets = false\",\n ];\n if (streamIdleTimeoutMs > 0) {\n providerConfigParts.push(`stream_idle_timeout_ms = ${streamIdleTimeoutMs}`);\n }\n const providerConfig = `${providerConfigParts.join(\", \")} }`;\n\n return {\n args: [\n // Codex ships a managed network proxy (codex-rs/network-proxy) that routes the\n // agent's traffic through a local proxy on :3128 and enforces a domain allowlist.\n // A host that is not allowlisted — like the local Hoopilot server — gets an instant\n // 403 (the Squid error page) and never reaches Hoopilot. It has two independent\n // gates: the `network_proxy` feature flag and the `permissions.workspace.network`\n // config. Disabling only the feature (`--disable network_proxy`) does not reliably\n // turn it off when the proxy is enabled through the permissions config, so set both\n // off: the feature flag via `--disable`, and the proxy itself via the config key\n // (when `enabled` is false the proxy no-ops and binds no listeners).\n \"--disable\",\n \"network_proxy\",\n \"-c\",\n \"permissions.workspace.network.enabled=false\",\n \"-c\",\n 'model_provider=\"hoopilot\"',\n \"-c\",\n `model_providers.hoopilot=${providerConfig}`,\n \"-m\",\n model,\n \"-c\",\n `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`,\n ...argv,\n ],\n baseUrl,\n command,\n env: withoutProxyEnv({\n ...env,\n OPENAI_API_KEY: apiKey,\n }),\n model,\n };\n}\n\n// A random, non-guessable placeholder key for when neither CODEXX_API_KEY nor\n// HOOPILOT_API_KEY is set. An unauthenticated local Hoopilot accepts any value;\n// a keyed server rejects it with a 401, which the model preflight surfaces.\nfunction generateEphemeralApiKey(): string {\n return `codexx-${crypto.randomUUID()}`;\n}\n\nfunction parseStreamIdleTimeoutMs(rawValue: string | undefined): number {\n const raw = envValue(rawValue);\n if (raw === undefined) {\n return DEFAULT_STREAM_IDLE_TIMEOUT_MS;\n }\n const value = Number(raw);\n if (!Number.isInteger(value) || value < 0) {\n throw new Error(\"CODEXX_STREAM_IDLE_TIMEOUT_MS must be a non-negative integer.\");\n }\n return value;\n}\n\nfunction withoutProxyEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n const next = { ...env };\n for (const key of PROXY_ENV_KEYS) {\n delete next[key];\n }\n return next;\n}\n\nexport async function main(argv = Bun.argv.slice(2), env = process.env): Promise<void> {\n if (argv.length === 1 && (argv[0] === \"--help\" || argv[0] === \"-h\")) {\n console.log(helpText());\n return;\n }\n\n const invocation = buildCodexxInvocation(argv, env);\n if (env.CODEXX_SKIP_MODEL_PREFLIGHT !== \"1\") {\n await verifyCodexxModel(invocation);\n }\n const child = spawn(invocation.command, invocation.args, {\n env: invocation.env,\n shell: process.platform === \"win32\",\n stdio: \"inherit\",\n });\n\n const exitCode = await new Promise<number>((resolve, reject) => {\n child.once(\"error\", reject);\n child.once(\"exit\", (code, signal) => {\n if (typeof code === \"number\") {\n resolve(code);\n return;\n }\n resolve(signal ? 128 + signalNumber(signal) : 1);\n });\n });\n\n process.exitCode = exitCode;\n}\n\nexport async function verifyCodexxModel(\n invocation: Pick<CodexxInvocation, \"baseUrl\" | \"env\" | \"model\">,\n fetcher: FetchLike = fetch,\n): Promise<void> {\n const modelsUrl = `${trimTrailingSlash(invocation.baseUrl)}/models`;\n const apiKey = invocation.env.OPENAI_API_KEY;\n if (apiKey === undefined) {\n throw new Error(\n \"verifyCodexxModel requires invocation.env.OPENAI_API_KEY; build the invocation with buildCodexxInvocation.\",\n );\n }\n let response: Response;\n try {\n response = await fetcher(modelsUrl, {\n headers: {\n accept: \"application/json\",\n authorization: `Bearer ${apiKey}`,\n },\n method: \"GET\",\n });\n } catch (error) {\n throw new Error(\n `Could not reach Hoopilot at ${modelsUrl}. Start Hoopilot first, or set CODEXX_SKIP_MODEL_PREFLIGHT=1 to skip this check. ${errorMessage(error)}`,\n );\n }\n\n if (!response.ok) {\n throw new Error(\n `Could not verify model ${JSON.stringify(invocation.model)} because ${modelsUrl} returned ${response.status}: ${await truncatedResponseText(response)}`,\n );\n }\n\n const models = modelIdsFromResponse(await response.json().catch(() => undefined));\n if (models.length > 0 && !models.includes(invocation.model)) {\n throw new Error(\n `The logged-in Copilot account does not advertise model ${JSON.stringify(invocation.model)} at ${modelsUrl}. Available models: ${models.join(\", \")}. After upgrading Hoopilot, rerun \"hoopilot login\" to refresh the Copilot OAuth token, or set CODEXX_MODEL to one of the advertised model IDs.`,\n );\n }\n}\n\nfunction helpText(): string {\n return `codexx\n\nRun Codex against an already-running local Hoopilot server.\n\nUsage:\n codexx [codex options] [prompt]\n\nEnvironment:\n CODEXX_BASE_URL OpenAI-compatible base URL. Default: ${DEFAULT_BASE_URL}\n CODEXX_API_KEY API key sent to the local Hoopilot server.\n HOOPILOT_API_KEY Used as the API key when CODEXX_API_KEY is unset. When\n neither is set, a random throwaway key is generated for\n an unauthenticated local server.\n CODEXX_CODEX_BIN Codex executable to run. Default: ${DEFAULT_CODEX_BIN}\n CODEXX_MODEL Codex model to use. Default: ${DEFAULT_MODEL}\n CODEXX_MODEL_REASONING_EFFORT\n Codex reasoning effort. Default: ${DEFAULT_REASONING_EFFORT}\n CODEXX_STREAM_IDLE_TIMEOUT_MS\n Codex Responses stream idle timeout in milliseconds. Default:\n ${DEFAULT_STREAM_IDLE_TIMEOUT_MS}; set 0 to use Codex's own default.\n CODEXX_SKIP_MODEL_PREFLIGHT\n Set to 1 to skip checking /v1/models before starting Codex.\n\ncodexx does not start Hoopilot and does not change your shell environment. It selects a temporary Hoopilot model provider with Responses WebSockets disabled, uses ${DEFAULT_MODEL} with ${DEFAULT_REASONING_EFFORT} reasoning by default, disables Codex's managed network proxy (permissions.workspace.network.enabled=false) so requests reach the local server instead of being blocked by its allowlist, and removes proxy variables only from the spawned Codex process.`;\n}\n\nfunction signalNumber(signal: NodeJS.Signals): number {\n return osConstants.signals[signal] ?? 1;\n}\n\nif (import.meta.main) {\n main().catch((error: unknown) => {\n console.error(errorMessage(error));\n process.exit(1);\n });\n}\n","import type { JsonObject, StreamingProxyMode } from \"./types\";\n\n/** Remove any trailing slashes from a URL or path string. */\nexport function trimTrailingSlash(value: string): string {\n return value.replace(/\\/+$/, \"\");\n}\n\n/** Treat blank environment variables as unset while preserving nonblank values. */\nexport function envValue(value: string | undefined): string | undefined {\n const trimmed = value?.trim();\n return trimmed ? trimmed : undefined;\n}\n\n/** True for HTTPS URLs, or HTTP only on loopback hosts used by local tests/dev. */\nexport function isHttpsOrLoopbackUrl(rawUrl: string): boolean {\n const url = parseUrl(rawUrl);\n if (!url) {\n return false;\n }\n return url.protocol === \"https:\" || isLoopbackHttpUrl(url);\n}\n\n/** Validate a base URL before sending a bearer/OAuth token to it. */\nexport function isTrustedTokenBaseUrl(\n rawUrl: string,\n allowedHttpsHosts: readonly string[],\n allowUnsafeHttps = false,\n): boolean {\n const url = parseUrl(rawUrl);\n if (!url) {\n return false;\n }\n if (url.username || url.password || url.search || url.hash) {\n return false;\n }\n if (url.pathname !== \"\" && url.pathname !== \"/\") {\n return false;\n }\n if (isLoopbackHttpUrl(url)) {\n return true;\n }\n if (url.protocol !== \"https:\") {\n return false;\n }\n const host = url.hostname.toLowerCase();\n return allowedHttpsHosts.includes(host) || allowUnsafeHttps;\n}\n\nfunction parseUrl(rawUrl: string): URL | undefined {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n return undefined;\n }\n return url;\n}\n\nconst LOOPBACK_HOSTNAMES = new Set([\"localhost\", \"127.0.0.1\", \"::1\", \"[::1]\"]);\n\n/** True for hostnames that always resolve to the local machine. */\nexport function isLoopbackHostname(host: string): boolean {\n return LOOPBACK_HOSTNAMES.has(host);\n}\n\nfunction isLoopbackHttpUrl(url: URL): boolean {\n return url.protocol === \"http:\" && isLoopbackHostname(url.hostname);\n}\n\n/** Read a response body as text, truncated to keep error messages bounded. */\nexport async function truncatedResponseText(response: Response, max = 500): Promise<string> {\n const text = await response.text();\n return text.slice(0, max);\n}\n\n/** Narrow an unknown value to a plain object, returning {} for arrays/primitives/null. */\nexport function asRecord(value: unknown): JsonObject {\n return value && typeof value === \"object\" && !Array.isArray(value) ? (value as JsonObject) : {};\n}\n\n/** Extract a human-readable message from an unknown thrown value. */\nexport function errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : String(error);\n}\n\n/** Return the first finite number among the candidates, else undefined. */\nexport function firstNumber(...values: unknown[]): number | undefined {\n for (const value of values) {\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return value;\n }\n }\n return undefined;\n}\n\n/** Generate a dash-free random identifier for synthesized response/message ids. */\nexport function randomId(): string {\n return crypto.randomUUID().replaceAll(\"-\", \"\");\n}\n\n/** Drop keys whose value is undefined so they are omitted from JSON output. */\nexport function removeUndefined<T extends object>(value: T): T {\n return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T;\n}\n\n/** Parse JSON, returning undefined instead of throwing on malformed input. */\nexport function safeJsonParse(text: string): unknown {\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n}\n\n/** Parse JSON into a plain object, returning undefined on malformed or non-object input. */\nexport function parseJsonObject(text: string): JsonObject | undefined {\n try {\n return asRecord(JSON.parse(text));\n } catch {\n return undefined;\n }\n}\n\n/**\n * Extract de-duplicated model IDs from an OpenAI-style `/models` response (an\n * object carrying a `data` array, or a bare array of model objects).\n */\nexport function modelIdsFromResponse(body: unknown): string[] {\n const record = asRecord(body);\n const data = Array.isArray(record.data) ? record.data : Array.isArray(body) ? body : [];\n const seen = new Set<string>();\n const ids: string[] = [];\n for (const model of data) {\n const id = asRecord(model).id;\n if (typeof id !== \"string\" || id.length === 0 || seen.has(id)) {\n continue;\n }\n seen.add(id);\n ids.push(id);\n }\n return ids;\n}\n\n/** Canonical set of accepted streaming-proxy modes, kept in sync with {@link StreamingProxyMode}. */\nexport const STREAMING_PROXY_MODES = [\n \"auto\",\n \"buffer\",\n \"live\",\n] as const satisfies readonly StreamingProxyMode[];\n\n/** Validate a stream-mode string against the allowed {@link StreamingProxyMode} values. */\nexport function parseStreamingProxyMode(value: string): StreamingProxyMode {\n if ((STREAMING_PROXY_MODES as readonly string[]).includes(value)) {\n return value as StreamingProxyMode;\n }\n throw new Error(`Invalid stream mode: ${value}. Expected ${STREAMING_PROXY_MODES.join(\", \")}.`);\n}\n"],"mappings":"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","names":[]}
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  safeJsonParse,
16
16
  trimTrailingSlash,
17
17
  truncatedResponseText
18
- } from "./chunk-CYR6I4C3.js";
18
+ } from "./chunk-4ZG5QEYJ.js";
19
19
 
20
20
  // src/cli.ts
21
21
  import { spawn } from "child_process";
@@ -176,6 +176,14 @@ var COPILOT_USAGE_API_VERSION = "2025-04-01";
176
176
  var EDITOR_PLUGIN_VERSION = "hoopilot/0.1.0";
177
177
  var EDITOR_VERSION = "Hoopilot/0.1.0";
178
178
  var HOOPILOT_USER_AGENT = "hoopilot/0.1.0";
179
+ var DEFAULT_UPSTREAM_TIMEOUT_MS = 12e4;
180
+ var DEFAULT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS = 12e4;
181
+ var CopilotUpstreamTimeoutError = class extends Error {
182
+ constructor(message) {
183
+ super(message);
184
+ this.name = "CopilotUpstreamTimeoutError";
185
+ }
186
+ };
179
187
  function applyCopilotHeaders(headers, token) {
180
188
  headers.set("accept", headers.get("accept") ?? "application/json");
181
189
  headers.set("authorization", `Bearer ${token}`);
@@ -228,6 +236,8 @@ var CopilotClient = class {
228
236
  #allowUnsafeUpstream;
229
237
  #fetch;
230
238
  #githubApiBaseUrl;
239
+ #upstreamStreamIdleTimeoutMs;
240
+ #upstreamTimeoutMs;
231
241
  constructor(options = {}) {
232
242
  this.#auth = new CopilotAuth(options);
233
243
  this.#allowUnsafeUpstream = envValue(options.env?.HOOPILOT_ALLOW_UNSAFE_UPSTREAM) === "1";
@@ -235,6 +245,18 @@ var CopilotClient = class {
235
245
  this.#githubApiBaseUrl = trimTrailingSlash(
236
246
  options.githubApiBaseUrl ?? envValue(options.env?.HOOPILOT_GITHUB_API_BASE_URL) ?? DEFAULT_GITHUB_API_BASE_URL
237
247
  );
248
+ this.#upstreamTimeoutMs = parseTimeoutMs(
249
+ options.upstreamTimeoutMs,
250
+ options.env?.HOOPILOT_UPSTREAM_TIMEOUT_MS,
251
+ DEFAULT_UPSTREAM_TIMEOUT_MS,
252
+ "HOOPILOT_UPSTREAM_TIMEOUT_MS"
253
+ );
254
+ this.#upstreamStreamIdleTimeoutMs = parseTimeoutMs(
255
+ options.upstreamStreamIdleTimeoutMs,
256
+ options.env?.HOOPILOT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS,
257
+ DEFAULT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS,
258
+ "HOOPILOT_UPSTREAM_STREAM_IDLE_TIMEOUT_MS"
259
+ );
238
260
  }
239
261
  /**
240
262
  * Fetch the Copilot account's quota / premium-request usage from the GitHub
@@ -253,7 +275,7 @@ var CopilotClient = class {
253
275
  }
254
276
  const access = await this.#auth.getAccess();
255
277
  const headers = applyGithubApiHeaders(new Headers(), access.token);
256
- return this.#fetch(`${this.#githubApiBaseUrl}/copilot_internal/user`, {
278
+ return this.#fetchWithTimeout(`${this.#githubApiBaseUrl}/copilot_internal/user`, {
257
279
  headers,
258
280
  method: "GET",
259
281
  signal
@@ -300,12 +322,139 @@ var CopilotClient = class {
300
322
  );
301
323
  }
302
324
  const headers = applyCopilotHeaders(new Headers(init.headers), access.token);
303
- return this.#fetch(`${access.apiBaseUrl}${path}`, {
325
+ return this.#fetchWithTimeout(`${access.apiBaseUrl}${path}`, {
304
326
  ...init,
305
327
  headers
306
328
  });
307
329
  }
330
+ async #fetchWithTimeout(input, init) {
331
+ const timeout = abortSignalWithTimeout(init.signal ?? void 0, this.#upstreamTimeoutMs);
332
+ try {
333
+ const response = await this.#fetch(input, {
334
+ ...init,
335
+ signal: timeout.signal
336
+ });
337
+ return responseWithStreamIdleTimeout(response, this.#upstreamStreamIdleTimeoutMs, input);
338
+ } catch (error) {
339
+ if (timeout.timedOut()) {
340
+ throw new CopilotUpstreamTimeoutError(
341
+ `Copilot upstream request timed out after ${this.#upstreamTimeoutMs} ms before response headers arrived.`
342
+ );
343
+ }
344
+ throw error;
345
+ } finally {
346
+ timeout.cleanup();
347
+ }
348
+ }
308
349
  };
350
+ function parseTimeoutMs(optionValue2, envRaw, fallback, name) {
351
+ const raw = optionValue2 ?? envValue(envRaw);
352
+ if (raw === void 0) {
353
+ return fallback;
354
+ }
355
+ const value = typeof raw === "number" ? raw : Number(raw);
356
+ if (!Number.isInteger(value) || value < 0) {
357
+ throw new Error(`${name} must be a non-negative integer number of milliseconds.`);
358
+ }
359
+ return value;
360
+ }
361
+ function abortSignalWithTimeout(parent, timeoutMs) {
362
+ if (timeoutMs === 0) {
363
+ return { cleanup: () => {
364
+ }, signal: parent, timedOut: () => false };
365
+ }
366
+ const controller = new AbortController();
367
+ let timedOut = false;
368
+ const timer = setTimeout(() => {
369
+ if (controller.signal.aborted) {
370
+ return;
371
+ }
372
+ timedOut = true;
373
+ controller.abort(
374
+ new CopilotUpstreamTimeoutError(`Copilot upstream request timed out after ${timeoutMs} ms.`)
375
+ );
376
+ }, timeoutMs);
377
+ const onAbort = () => controller.abort(parent?.reason);
378
+ if (parent?.aborted) {
379
+ controller.abort(parent.reason);
380
+ } else {
381
+ parent?.addEventListener("abort", onAbort, { once: true });
382
+ }
383
+ return {
384
+ cleanup: () => {
385
+ clearTimeout(timer);
386
+ parent?.removeEventListener("abort", onAbort);
387
+ },
388
+ signal: controller.signal,
389
+ timedOut: () => timedOut
390
+ };
391
+ }
392
+ function responseWithStreamIdleTimeout(response, idleTimeoutMs, input) {
393
+ if (!response.body || idleTimeoutMs === 0) {
394
+ return response;
395
+ }
396
+ return new Response(streamWithIdleTimeout(response.body, idleTimeoutMs, input), {
397
+ headers: response.headers,
398
+ status: response.status,
399
+ statusText: response.statusText
400
+ });
401
+ }
402
+ function streamWithIdleTimeout(body, idleTimeoutMs, input) {
403
+ const reader = body.getReader();
404
+ let released = false;
405
+ const release = () => {
406
+ if (!released) {
407
+ released = true;
408
+ reader.releaseLock();
409
+ }
410
+ };
411
+ return new ReadableStream({
412
+ async pull(controller) {
413
+ let timer;
414
+ const read = reader.read();
415
+ read.catch(() => {
416
+ });
417
+ try {
418
+ const result = await Promise.race([
419
+ read,
420
+ new Promise((_, reject) => {
421
+ timer = setTimeout(() => {
422
+ reject(
423
+ new CopilotUpstreamTimeoutError(
424
+ `Copilot upstream stream was idle for ${idleTimeoutMs} ms while reading ${input}.`
425
+ )
426
+ );
427
+ }, idleTimeoutMs);
428
+ })
429
+ ]);
430
+ if (timer) {
431
+ clearTimeout(timer);
432
+ }
433
+ if (result.done) {
434
+ controller.close();
435
+ release();
436
+ return;
437
+ }
438
+ controller.enqueue(result.value);
439
+ } catch (error) {
440
+ if (timer) {
441
+ clearTimeout(timer);
442
+ }
443
+ await reader.cancel(error).catch(() => {
444
+ });
445
+ controller.error(error);
446
+ release();
447
+ }
448
+ },
449
+ async cancel(reason) {
450
+ try {
451
+ await reader.cancel(reason);
452
+ } finally {
453
+ release();
454
+ }
455
+ }
456
+ });
457
+ }
309
458
  function normalizeCopilotUsage(body) {
310
459
  const record = asRecord(body);
311
460
  const quotas = {};
@@ -688,7 +837,7 @@ function normalizeRequestedModel(model) {
688
837
  }
689
838
  function responsesCompactionResult(upstreamText, isSse) {
690
839
  const summary = compactionSummaryText(upstreamText, isSse);
691
- return { output: [compactionSummaryMessageItem(summary)] };
840
+ return { output: [compactionSummaryOutputMessageItem(summary)] };
692
841
  }
693
842
  function isResponsesCompactionRequest(request) {
694
843
  return responseInputItems(request.input).some(
@@ -975,8 +1124,14 @@ function contentToText(content) {
975
1124
  }
976
1125
  return "";
977
1126
  }
978
- function compactionSummaryMessageItem(text, id = `msg_${randomId()}`) {
979
- return {
1127
+ function compactionSummaryOutputMessageItem(text) {
1128
+ return compactionSummaryMessageItem(text, `msg_${randomId()}`);
1129
+ }
1130
+ function compactionSummaryInputMessageItem(text) {
1131
+ return compactionSummaryMessageItem(text);
1132
+ }
1133
+ function compactionSummaryMessageItem(text, id) {
1134
+ return removeUndefined({
980
1135
  content: [
981
1136
  {
982
1137
  text: `${COMPACTION_SUMMARY_PREFIX}
@@ -987,7 +1142,7 @@ ${text}`,
987
1142
  id,
988
1143
  role: "user",
989
1144
  type: "message"
990
- };
1145
+ });
991
1146
  }
992
1147
  function compactionOutputItem(text, id = `cmpct_${randomId()}`) {
993
1148
  return {
@@ -1011,7 +1166,7 @@ function normalizeCompactionInputForCopilot(input, options) {
1011
1166
  if (type === "compaction" || type === "compaction_summary" || type === "context_compaction") {
1012
1167
  const text = contentToText(record.encrypted_content);
1013
1168
  if (text) {
1014
- normalized.push(compactionSummaryMessageItem(text));
1169
+ normalized.push(compactionSummaryInputMessageItem(text));
1015
1170
  }
1016
1171
  continue;
1017
1172
  }
@@ -1056,6 +1211,7 @@ function extractTokenUsage(usage) {
1056
1211
  asRecord(record.output_tokens_details).reasoning_tokens
1057
1212
  );
1058
1213
  const cached = firstNumber(
1214
+ record.cache_read_input_tokens,
1059
1215
  asRecord(record.prompt_tokens_details).cached_tokens,
1060
1216
  asRecord(record.input_tokens_details).cached_tokens
1061
1217
  );
@@ -1201,9 +1357,10 @@ var AnthropicCompatibilityError = class extends Error {
1201
1357
  }
1202
1358
  };
1203
1359
  function anthropicMessagesToResponsesRequest(request) {
1204
- return removeUndefined({
1205
- input: anthropicMessagesToResponsesInput(request.messages),
1206
- instructions: anthropicSystemToInstructions(request.system),
1360
+ const system = anthropicSystemToResponses(request.system);
1361
+ const response = removeUndefined({
1362
+ input: [...system.input, ...anthropicMessagesToResponsesInput(request.messages)],
1363
+ instructions: system.instructions,
1207
1364
  max_output_tokens: typeof request.max_tokens === "number" && Number.isFinite(request.max_tokens) ? request.max_tokens : void 0,
1208
1365
  metadata: request.metadata,
1209
1366
  model: normalizeRequestedModel(request.model),
@@ -1216,6 +1373,8 @@ function anthropicMessagesToResponsesRequest(request) {
1216
1373
  tools: anthropicTools(request.tools),
1217
1374
  top_p: request.top_p
1218
1375
  });
1376
+ applyCacheControlToLastBlock(response, anthropicCacheControl(request.cache_control));
1377
+ return response;
1219
1378
  }
1220
1379
  function responsesResponseToAnthropicMessage(response, fallbackModel) {
1221
1380
  const content = anthropicContentFromResponsesOutput(response);
@@ -1312,6 +1471,7 @@ function anthropicMessagesToResponsesInput(messages) {
1312
1471
  throw new AnthropicCompatibilityError("Anthropic Messages requests require messages[].");
1313
1472
  }
1314
1473
  const input = [];
1474
+ let fallbackToolCallIndex = 0;
1315
1475
  for (const message of messages) {
1316
1476
  const record = asRecord(message);
1317
1477
  const role = anthropicRole(record.role);
@@ -1333,10 +1493,13 @@ function anthropicMessagesToResponsesInput(messages) {
1333
1493
  if (type === "text") {
1334
1494
  const text = textValue(part.text);
1335
1495
  if (text) {
1336
- messageParts.push({
1337
- text,
1338
- type: role === "assistant" ? "output_text" : "input_text"
1339
- });
1496
+ messageParts.push(
1497
+ removeUndefined({
1498
+ cache_control: anthropicCacheControl(part.cache_control),
1499
+ text,
1500
+ type: role === "assistant" ? "output_text" : "input_text"
1501
+ })
1502
+ );
1340
1503
  }
1341
1504
  continue;
1342
1505
  }
@@ -1351,21 +1514,27 @@ function anthropicMessagesToResponsesInput(messages) {
1351
1514
  }
1352
1515
  if (type === "tool_use") {
1353
1516
  flushMessage();
1354
- input.push({
1355
- arguments: JSON.stringify(asRecord(part.input)),
1356
- call_id: textValue(part.id) || `call_${randomId()}`,
1357
- name: textValue(part.name),
1358
- type: "function_call"
1359
- });
1517
+ input.push(
1518
+ removeUndefined({
1519
+ arguments: JSON.stringify(asRecord(part.input)),
1520
+ cache_control: anthropicCacheControl(part.cache_control),
1521
+ call_id: textValue(part.id) || `call_hoopilot_${fallbackToolCallIndex++}`,
1522
+ name: textValue(part.name),
1523
+ type: "function_call"
1524
+ })
1525
+ );
1360
1526
  continue;
1361
1527
  }
1362
1528
  if (type === "tool_result") {
1363
1529
  flushMessage();
1364
- input.push({
1365
- call_id: textValue(part.tool_use_id),
1366
- output: anthropicToolResultOutput(part.content),
1367
- type: "function_call_output"
1368
- });
1530
+ input.push(
1531
+ removeUndefined({
1532
+ cache_control: anthropicCacheControl(part.cache_control),
1533
+ call_id: textValue(part.tool_use_id),
1534
+ output: anthropicToolResultOutput(part.content),
1535
+ type: "function_call_output"
1536
+ })
1537
+ );
1369
1538
  continue;
1370
1539
  }
1371
1540
  if (type === "thinking" || type === "redacted_thinking") {
@@ -1412,22 +1581,24 @@ function anthropicImageToResponsesPart(part) {
1412
1581
  if (!data) {
1413
1582
  throw new AnthropicCompatibilityError("Anthropic base64 image content requires source.data.");
1414
1583
  }
1415
- return {
1584
+ return removeUndefined({
1585
+ cache_control: anthropicCacheControl(part.cache_control),
1416
1586
  detail: "auto",
1417
1587
  image_url: `data:${mediaType};base64,${data}`,
1418
1588
  type: "input_image"
1419
- };
1589
+ });
1420
1590
  }
1421
1591
  if (sourceType === "url") {
1422
1592
  const url = textValue(source.url);
1423
1593
  if (!url) {
1424
1594
  throw new AnthropicCompatibilityError("Anthropic URL image content requires source.url.");
1425
1595
  }
1426
- return {
1596
+ return removeUndefined({
1597
+ cache_control: anthropicCacheControl(part.cache_control),
1427
1598
  detail: "auto",
1428
1599
  image_url: url,
1429
1600
  type: "input_image"
1430
- };
1601
+ });
1431
1602
  }
1432
1603
  throw new AnthropicCompatibilityError(
1433
1604
  `Anthropic image source type "${sourceType || "unknown"}" is not supported.`
@@ -1448,15 +1619,42 @@ function anthropicToolResultOutput(content) {
1448
1619
  }
1449
1620
  return typeof content === "object" ? JSON.stringify(content) : String(content);
1450
1621
  }
1451
- function anthropicSystemToInstructions(system) {
1622
+ function anthropicSystemToResponses(system) {
1452
1623
  if (typeof system === "string") {
1453
- return system || void 0;
1624
+ return { input: [], instructions: system || void 0 };
1454
1625
  }
1455
1626
  if (!Array.isArray(system)) {
1627
+ return { input: [] };
1628
+ }
1629
+ const parts = system.map((part) => anthropicSystemPartToResponsesPart(part)).filter((part) => part !== void 0);
1630
+ if (parts.length === 0) {
1631
+ return { input: [] };
1632
+ }
1633
+ if (parts.some((part) => part.cache_control !== void 0)) {
1634
+ return {
1635
+ input: [
1636
+ {
1637
+ content: parts,
1638
+ role: "system",
1639
+ type: "message"
1640
+ }
1641
+ ]
1642
+ };
1643
+ }
1644
+ const text = parts.map((part) => textValue(part.text)).filter(Boolean).join("\n");
1645
+ return { input: [], instructions: text || void 0 };
1646
+ }
1647
+ function anthropicSystemPartToResponsesPart(part) {
1648
+ const record = asRecord(part);
1649
+ const text = textValue(record.text) || textValue(part);
1650
+ if (!text) {
1456
1651
  return void 0;
1457
1652
  }
1458
- const text = system.map((part) => textValue(asRecord(part).text) || textValue(part)).filter(Boolean).join("\n");
1459
- return text || void 0;
1653
+ return removeUndefined({
1654
+ cache_control: anthropicCacheControl(record.cache_control),
1655
+ text,
1656
+ type: "input_text"
1657
+ });
1460
1658
  }
1461
1659
  function anthropicTools(tools) {
1462
1660
  if (!Array.isArray(tools)) {
@@ -1465,6 +1663,7 @@ function anthropicTools(tools) {
1465
1663
  const converted = tools.map((tool) => {
1466
1664
  const record = asRecord(tool);
1467
1665
  return removeUndefined({
1666
+ cache_control: anthropicCacheControl(record.cache_control),
1468
1667
  description: record.description,
1469
1668
  name: record.name,
1470
1669
  parameters: record.input_schema,
@@ -1474,6 +1673,55 @@ function anthropicTools(tools) {
1474
1673
  });
1475
1674
  return converted.length > 0 ? converted : void 0;
1476
1675
  }
1676
+ function anthropicCacheControl(value) {
1677
+ if (value === void 0 || value === null) {
1678
+ return void 0;
1679
+ }
1680
+ const record = asRecord(value);
1681
+ const type = textValue(record.type);
1682
+ if (type !== "ephemeral") {
1683
+ throw new AnthropicCompatibilityError(
1684
+ `Anthropic cache_control type "${type || "unknown"}" is not supported.`
1685
+ );
1686
+ }
1687
+ const ttl = textValue(record.ttl);
1688
+ if (ttl && ttl !== "5m" && ttl !== "1h") {
1689
+ throw new AnthropicCompatibilityError(`Anthropic cache_control ttl "${ttl}" is not supported.`);
1690
+ }
1691
+ return removeUndefined({
1692
+ ttl: ttl || void 0,
1693
+ type
1694
+ });
1695
+ }
1696
+ function applyCacheControlToLastBlock(request, cacheControl) {
1697
+ if (!cacheControl) {
1698
+ return;
1699
+ }
1700
+ const input = Array.isArray(request.input) ? request.input : [];
1701
+ for (let itemIndex = input.length - 1; itemIndex >= 0; itemIndex -= 1) {
1702
+ const item = asRecord(input[itemIndex]);
1703
+ const content = Array.isArray(item.content) ? item.content : [];
1704
+ for (let partIndex = content.length - 1; partIndex >= 0; partIndex -= 1) {
1705
+ const part = asRecord(content[partIndex]);
1706
+ if (part.cache_control === void 0 && isCacheableResponsesPart(part)) {
1707
+ part.cache_control = cacheControl;
1708
+ return;
1709
+ }
1710
+ }
1711
+ }
1712
+ const tools = Array.isArray(request.tools) ? request.tools : [];
1713
+ for (let index = tools.length - 1; index >= 0; index -= 1) {
1714
+ const tool = asRecord(tools[index]);
1715
+ if (tool.cache_control === void 0) {
1716
+ tool.cache_control = cacheControl;
1717
+ return;
1718
+ }
1719
+ }
1720
+ }
1721
+ function isCacheableResponsesPart(part) {
1722
+ const type = textValue(part.type);
1723
+ return type === "input_text" || type === "output_text" || type === "text" || type === "input_image";
1724
+ }
1477
1725
  function anthropicToolChoice(toolChoice) {
1478
1726
  if (toolChoice === void 0 || toolChoice === null) {
1479
1727
  return void 0;
@@ -3446,6 +3694,13 @@ function buildApp(deps) {
3446
3694
  );
3447
3695
  return jsonError(413, "request_too_large", message);
3448
3696
  }
3697
+ if (error instanceof CopilotUpstreamTimeoutError) {
3698
+ logger.warn(
3699
+ { err: errorDetails(error), event: "copilot.request.timeout" },
3700
+ "copilot upstream request timed out"
3701
+ );
3702
+ return jsonError(504, "copilot_timeout", message);
3703
+ }
3449
3704
  logger.error({ err: errorDetails(error), event: "http.request.failed" }, "request failed");
3450
3705
  return jsonError(500, "internal_error", message);
3451
3706
  }).get("/", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/healthz", () => jsonResponse({ name: "hoopilot", object: "health", status: "ok" })).get("/metrics", () => metricsResponse(metrics)).get("/v1/usage", ({ request }) => handleUsage(metrics, readUsage, request.signal)).get(