From 453a2288ea76c4629fea37cf38facdd0afac28ca Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 06:06:21 +0800 Subject: [PATCH 1/2] fix(review): env alias compat, stream hardening, small-host guardrails, docs + 1.6.0 - Phase 1: DISABLE_TOOLS legacy alias via shared normalizeBool/resolveDisableTools (canonical > legacy > file > default, invalid falls through); tests/env-alias.test.js - Phase 2: compose RETRY/BIND_HOST/OPENCODE_DISABLE_TOOLS passthrough, mem/cpu/pids limits, log rotation, start_period 200s; Dockerfile RETRY default; entrypoint backend wait ~180s - Phase 3: responses/chat/messages preflight + non-stream prompt bounded by withTimeout (Request timeout -> 504); responses stream early SSE headers + heartbeat + res-based disconnect cancel; promptWithTimeout unified; tests/stream-hardening.test.js - Phase 4: docs (docker/configuration), CHANGELOG 1.6.0 with Breaking section, version badges 1.6.0 --- .env.example | 1 + CHANGELOG.md | 22 ++++ Dockerfile | 1 + README.md | 2 +- docker-compose.yml | 23 +++- docs/README.md | 2 +- docs/api-reference.md | 2 +- docs/configuration.md | 6 +- docs/development.md | 2 +- docs/docker.md | 45 ++++---- docs/getting-started.md | 2 +- docs/troubleshooting.md | 2 +- entrypoint.sh | 10 +- index.js | 15 ++- package-lock.json | 4 +- package.json | 2 +- src/proxy.js | 193 ++++++++++++++++++++++++++------- tests/env-alias.test.js | 152 ++++++++++++++++++++++++++ tests/stream-hardening.test.js | 149 +++++++++++++++++++++++++ 19 files changed, 549 insertions(+), 86 deletions(-) create mode 100644 tests/env-alias.test.js create mode 100644 tests/stream-hardening.test.js diff --git a/.env.example b/.env.example index f35f580..cecff67 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 # Optional overrides # OPENCODE_PROXY_PORT=10000 # OPENCODE_SERVER_PORT=10001 +# BIND_HOST=0.0.0.0 # OPENCODE_USE_ISOLATED_HOME=false # OPENCODE_PROXY_REQUEST_TIMEOUT_MS=180000 # Retry budget after the initial attempt (0-5, default 3; total attempts = 1+n) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79bae44..dc6d2c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **后端错误透出**:`/v1/responses` 在后端 session 失败时曾 `throw` 纯对象,经 `transformUpstreamError` 被洗成 `500 Internal server error / code Object`、真实讯息丢失。新增 `normalizeBackendError`(纯对象→Error,保留 `data.message`/`name`/状态码推断),两处 `throw polled.error` 改用它;`transformUpstreamError` 纵深加固(默认分支改读 `data.message`/`name`,code 默认 `upstream_error` 不再用 `constructor.name`)。现 Credits 类错误正确回 `402 insufficient_quota` 并携带原文。新增 `tests/backend-error-surface.test.js`(4 例复现 LiteLLM 案例,含 messages)。 - **同族错误路径查漏**:responses 串流 idleTimeout 补 poll 失败检查(不再静默回空 `completed`);messages 非串流改用 transformed `status/type/code`(不再硬编码 `502/api_error`);messages 串流检查 `collected.error` 与 poll 失败(失败即抛正規化错误走 SSE error 事件,不再静默 200);`isTransientUpstreamError` 纳入 `name/code/type` 参与签名匹配(无 message 纯对象不再必判 false)。 +## [1.6.0] - 2026-09-08 + +### ⚠️ Breaking / 行為變更 + +- **`DISABLE_TOOLS=false` 從被忽略變為真正生效**:舊版只讀 `OPENCODE_DISABLE_TOOLS`,經 compose/`.env` 設 `DISABLE_TOOLS=false` 的用戶實際跑的是預設 `true`(工具禁用)。本版起該別名正式生效,升級後工具會真正啟用並可執行後端操作。要保持禁用請顯式設 `DISABLE_TOOLS=true`(或改用 `OPENCODE_DISABLE_TOOLS=true`)或刪行回預設。 +- **串流錯誤語義(僅 `/v1/responses` 改變傳輸形態)**:`stream:true` 的 `/v1/responses` 在 headers 已 flush(早於 preflight)之後出錯時,改經 SSE `response.failed` 事件 + `data: [DONE]` 回報(HTTP 狀態固定為 200)。`/v1/chat/completions` 串流維持原形態(`data: {"error":…}` 後結束),`/v1/messages` 串流維持 `error` 事件後結束(無 `[DONE]`);且 chat/messages 的 preflight 失敗仍在 headers 送出前發生,故仍回 JSON 狀態碼(行為不變)。只看 HTTP 狀態碼的 responses 串流客戶端請改為同時監聽流內 `response.failed`;`stream:false` 的 JSON 狀態碼語義三路由皆不變(含超時 → `504`)。 + +### Added + +- **Docker 多架构发布**:`ghcr.io/samson910022/opencode2api:latest` 同时推送 `linux/amd64` + `linux/arm64`(此前已上线但未记入 changelog;ARM 机器可直接 pull,无需本地 build)。 +- **环境变量兼容别名**:`docker-compose`/`.env` 沿用的 `DISABLE_TOOLS` 正式生效(此前只有 `OPENCODE_DISABLE_TOOLS` 被读取)。解析收敛到共用的 `normalizeBool` + `resolveDisableTools`(无效值让位:canonical env > legacy env > `config.json` > 默认;數字僅 `0/1` 有效,其餘視為未設),`index.js` 直接調用同一 helper 不再手寫 `??` 鏈,附 `tests/env-alias.test.js`。 +- **串流强健(responses/chat/messages 三路由)**:串流 `/v1/responses` 在 preflight 等待前即 flush SSE 头 + 15s heartbeat(中转不再看到零字节 stall;chat/messages 的 headers 仍在 preflight 之後送出,其 preflight 失敗維持 JSON 狀態碼);三路由的 `resolve/session/tool-overrides` preflight 與非串流 `prompt` 全部经 `withTimeout` 限界(真错误立即透出,超时经 `Request timeout` 映射為 `504`);客户端断开经 `res 'close'` + `!writableEnded` 取消并清理 session(`req 'close'` 在 body 解析完即觸發,不可用);`promptWithTimeout` 統一改調 `withTimeout`(舊 inline race 洩漏 timer 且晚拒絕可致 unhandled rejection)。附 `tests/stream-hardening.test.js`。 +- **小机部署加固(可調)**:compose 加 `mem_limit 768m`/`mem_reservation 256m`/`cpus 1.5`/`pids_limit 256`、日志轮转、`start_period 200s`,补 `OPENCODE_PROXY_RETRY_MAX_RETRIES`/`BIND_HOST`/`OPENCODE_DISABLE_TOOLS` 透传;`Dockerfile` 补 `RETRY` 默认;entrypoint 后端等待 30s→约 180s(覆盖小机冷启动)。`mem_limit` 是部署行為變更(非 API breaking):此前無上限只會變慢,現超限會被 OOM kill;大機請用 `docker-compose.override.yml` 放寬(見 `docs/docker.md`「覆寫資源限制」)。 + +### Fixed + +- **`DISABLE_TOOLS=false` 被静默忽略**:见上 Breaking(生产默认仍为 `true`,只在显式设 `false`/别名时行为变化)。 +- **断线侦测误用 `req 'close'`**:server 端该事件在请求体解析完即触发(实测 +1ms),不能用于串流断线;改用 `res 'close'` + `!writableEnded`(`/v1/messages` 旧 pattern 因监听挂得晚而等于失效,一并修复;監聽器改 `once` 避免殘留)。 +- **`withTimeout` 超時誤回 500**:超時訊息現為 `Request timeout after …(label)`,可被 `transformUpstreamError` 正確映射為 `504`(此前自訂訊息繞過映射);`createApp` 補 `REQUEST_TIMEOUT_MS` 預設,非有限值回退到 `DEFAULT_REQUEST_TIMEOUT_MS`。 +- **`BIND_HOST` 命名分裂**:`startProxy` 後備鏈與 `index.js` 合併皆支援 `BIND_HOST` 優先、`OPENCODE_PROXY_BIND_HOST` 後備(此前 `startProxy` 只有後者,經正常 `index.js` 路徑時後備不可達)。 +- **文件範例收斂(非 breaking)**:`docs/docker.md` 的 `docker run` 範例刪除 `-p 10001:10001`(後端僅容器內部使用;運行時無變更,舊映射仍可用,建議改走代理端口)。 + ## [1.5.0] - 2026-04-18 ### Added diff --git a/Dockerfile b/Dockerfile index 97035fa..97b7025 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,6 +41,7 @@ ENV OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=false ENV OPENCODE_PROXY_CLEANUP_INTERVAL_MS=43200000 ENV OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 ENV OPENCODE_PROXY_REQUEST_TIMEOUT_MS=180000 +ENV OPENCODE_PROXY_RETRY_MAX_RETRIES=3 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "10001"] diff --git a/README.md b/README.md index 35f5651..8039bf4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OpenCode2API

- Version + Version License Node

diff --git a/docker-compose.yml b/docker-compose.yml index 3a52c98..7a3bbb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,9 @@ services: - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-} - OPENCODE_ZEN_API_KEY=${OPENCODE_ZEN_API_KEY:-} - API_KEY=${API_KEY:-} - - BIND_HOST=0.0.0.0 + - BIND_HOST=${BIND_HOST:-0.0.0.0} - DISABLE_TOOLS=${DISABLE_TOOLS:-true} + - OPENCODE_DISABLE_TOOLS=${OPENCODE_DISABLE_TOOLS:-} - OPENCODE_USE_ISOLATED_HOME=${OPENCODE_USE_ISOLATED_HOME:-false} - OPENCODE_PROXY_DEBUG=${OPENCODE_PROXY_DEBUG:-false} - OPENCODE_PROXY_PROMPT_MODE=${OPENCODE_PROXY_PROMPT_MODE:-standard} @@ -23,18 +24,36 @@ services: - OPENCODE_PROXY_CLEANUP_INTERVAL_MS=${OPENCODE_PROXY_CLEANUP_INTERVAL_MS:-43200000} - OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=${OPENCODE_PROXY_CLEANUP_MAX_AGE_MS:-86400000} - OPENCODE_PROXY_REQUEST_TIMEOUT_MS=${OPENCODE_PROXY_REQUEST_TIMEOUT_MS:-180000} + - OPENCODE_PROXY_RETRY_MAX_RETRIES=${OPENCODE_PROXY_RETRY_MAX_RETRIES:-3} - OPENCODE_PROXY_PORT=${OPENCODE_PROXY_PORT:-10000} - OPENCODE_SERVER_PORT=${OPENCODE_SERVER_PORT:-10001} volumes: - opencode-data:/home/node/.local/share/opencode - opencode-config:/home/node/.config/opencode restart: unless-stopped + # Guard small (1GB) hosts: cap memory/CPU so one slow request cannot wedge + # the whole machine (see docs/host-deployments.md §9). Raw `docker run` + # hosts should pass the equivalents: --memory=768m --memory-reservation=256m + # --cpus=1.5 --pids-limit=256. + mem_limit: 768m + mem_reservation: 256m + cpus: 1.5 + pids_limit: 256 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" healthcheck: + # Shallow on purpose: /health needs no auth; /health/details would 401 + # when HEALTH_DETAILS_REQUIRE_AUTH=true and has no credentials here. test: ["CMD", "curl", "-f", "http://localhost:${OPENCODE_PROXY_PORT:-10000}/health"] interval: 30s timeout: 10s retries: 3 - start_period: 60s + # Must cover entrypoint backend wait (~180s on cold small hosts) plus + # probe intervals; 120s flapped unhealthy during normal warmup. + start_period: 200s volumes: opencode-data: diff --git a/docs/README.md b/docs/README.md index 2af77c9..8a33d34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # OpenCode2API 文档中心

- Version + Version

> 欢迎查看 OpenCode2API 文档 diff --git a/docs/api-reference.md b/docs/api-reference.md index b89e4a5..36cc564 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,7 +1,7 @@ # 🔌 API 参考

- Version + Version

--- diff --git a/docs/configuration.md b/docs/configuration.md index 51abd41..5d44034 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,7 +1,7 @@ # ⚙️ 配置详解

- Version + Version

--- @@ -21,7 +21,7 @@ | `PORT` / `OPENCODE_PROXY_PORT` | `10000` | 代理服务端口 | | `OPENCODE_SERVER_PORT` | `10001` | OpenCode 后端服务端口 | | `API_KEY` | - | Bearer Token 认证密钥 | -| `BIND_HOST` | `0.0.0.0` | 绑定地址 | +| `BIND_HOST` | `0.0.0.0` | 绑定地址(`BIND_HOST` 优先,`OPENCODE_PROXY_BIND_HOST` 为后备) | | `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | OpenCode 后端地址 | | `OPENCODE_SERVER_PASSWORD` | - | OpenCode 后端密码 | @@ -29,7 +29,7 @@ | 变量 | 默认值 | 说明 | |:-----|:-------|:-----| -| `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用 | +| `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认) | | `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` | | `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具冲突隔离策略;当前仅支持 `namespace` | | `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;未显式配置 allowlist 时,启用后默认放行 `web_fetch` | diff --git a/docs/development.md b/docs/development.md index e73923f..c7c4f42 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,7 +1,7 @@ # 💻 开发指南

- Version + Version

--- diff --git a/docs/docker.md b/docs/docker.md index 981a60e..1fbc80b 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,7 +1,7 @@ # 🐳 Docker 部署

- Version + Version

--- @@ -83,7 +83,6 @@ docker build -t my-opencode2api . ```bash docker run -d \ -p 10000:10000 \ - -p 10001:10001 \ -e API_KEY=your-key \ -e OPENCODE_SERVER_PASSWORD=your-password \ -v opencode-data:/home/node/.local/share/opencode \ @@ -91,45 +90,37 @@ docker run -d \ my-opencode2api ``` ---- +> 后端 `10001` 仅容器内部使用,不要对外发布(compose 默认也只映射代理端口;改端口用 `OPENCODE_PROXY_PORT`,如 `8090`)。 + +### 多架构 -## 📝 生产部署建议 +`ghcr.io/samson910022/opencode2api:latest` 同时含 `linux/amd64` 与 `linux/arm64`,ARM 机器可直接 pull,无需本地 build(仅自带 `custom-bin/opencode` 二进制时才需按架构重建)。 -### 移除源码挂载 +### 覆寫資源限制 -如果不需要在容器内修改代码,可以移除项目目录的挂载: +compose 預設 `mem_limit 768m`(單容器含 proxy+後端)。大機要放寬或取消,用 override 檔: ```yaml -# docker-compose.yml -volumes: - - opencode-data:/home/node/.local/share/opencode - - opencode-config:/home/node/.config/opencode - # 移除这一行 - # - .:/home/node/project +# docker-compose.override.yml +services: + opencode2api: + mem_limit: 2g + mem_reservation: 512m + cpus: 4.0 ``` --- ## 📊 日志管理 +compose 已内置 json-file 轮转(`max-size: 10m`、`max-file: 3`);裸 `docker run` 请自行加上 `--log-opt max-size=10m --log-opt max-file=3`。 + ### 查看日志 ```bash docker compose logs -f ``` -### 日志轮转 - -推荐使用 Docker 的日志驱动配置: - -```yaml -logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" -``` - --- ## ✅ 健康检查 @@ -138,13 +129,15 @@ logging: ```yaml healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:10000/health"] + test: ["CMD", "curl", "-f", "http://localhost:${OPENCODE_PROXY_PORT:-10000}/health"] interval: 30s timeout: 10s retries: 3 - start_period: 60s + start_period: 200s ``` +> `/health` 是浅检查(只确认代理活着,不测后端;`start_period` 200s 覆盖 entrypoint 约 180s 的小机冷启动等待)。小机(1GB)另有 `mem_limit 768m` / `pids_limit 256` 保护,裸 `docker run` 请加等价旗(见 `docker-compose.yml` 注释)。 + --- ## ❓ 常见问题 diff --git a/docs/getting-started.md b/docs/getting-started.md index 922cb76..1d83eb8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ # 🚀 快速开始

- Version + Version

--- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ed45ebe..489f0b7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,7 +1,7 @@ # 🔧 故障排查

- Version + Version

--- diff --git a/entrypoint.sh b/entrypoint.sh index 651a4b9..d144415 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -45,7 +45,9 @@ if [[ "$1" == "opencode" && "$2" == "serve" ]]; then SERVER_PID=$! echo "Waiting for OpenCode Server to become available..." - MAX_RETRIES=30 + # Cold start on small (1GB) hosts can take minutes; do not kill early. + MAX_RETRIES=90 + RETRY_DELAY=2 COUNT=0 while ! curl -s -m 5 http://127.0.0.1:${SERVER_PORT}/health > /dev/null; do if [ $COUNT -ge $MAX_RETRIES ]; then @@ -53,13 +55,13 @@ if [[ "$1" == "opencode" && "$2" == "serve" ]]; then kill $SERVER_PID 2>/dev/null exit 1 fi - + if ! kill -0 $SERVER_PID 2>/dev/null; then echo "OpenCode Server process died unexpectedly." exit 1 fi - - sleep 1 + + sleep $RETRY_DELAY COUNT=$((COUNT+1)) done echo "OpenCode Server is up!" diff --git a/index.js b/index.js index 514f523..ebfa2a8 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -import { startProxy } from './src/proxy.js'; +import { startProxy, normalizeBool, resolveDisableTools } from './src/proxy.js'; import { resolveMaxRetries } from './src/retry/policy.js'; import fs from 'fs'; import path from 'path'; @@ -78,8 +78,17 @@ const finalConfig = { OPENCODE_SERVER_PASSWORD: process.env.OPENCODE_SERVER_PASSWORD || fileConfig.OPENCODE_SERVER_PASSWORD || defaultConfig.OPENCODE_SERVER_PASSWORD, MANAGE_BACKEND: parseBool(process.env.OPENCODE_PROXY_MANAGE_BACKEND, parseBool(fileConfig.MANAGE_BACKEND, defaultConfig.MANAGE_BACKEND)), OPENCODE_PATH: process.env.OPENCODE_PATH || fileConfig.OPENCODE_PATH || defaultConfig.OPENCODE_PATH, - BIND_HOST: process.env.BIND_HOST || fileConfig.BIND_HOST || defaultConfig.BIND_HOST, - DISABLE_TOOLS: parseBool(process.env.OPENCODE_DISABLE_TOOLS, parseBool(fileConfig.DISABLE_TOOLS, defaultConfig.DISABLE_TOOLS)), + BIND_HOST: process.env.BIND_HOST || process.env.OPENCODE_PROXY_BIND_HOST || fileConfig.BIND_HOST || defaultConfig.BIND_HOST, + // Single contract via resolveDisableTools: env canonical > env legacy + // alias > file > default. Each source is normalized first so invalid + // values ('', 'garbage') fall through instead of blocking lower sources. + DISABLE_TOOLS: resolveDisableTools( + { + DISABLE_TOOLS: process.env.OPENCODE_DISABLE_TOOLS, + disableTools: process.env.DISABLE_TOOLS, + }, + normalizeBool(fileConfig.DISABLE_TOOLS) ?? defaultConfig.DISABLE_TOOLS, + ), EXTERNAL_TOOLS_MODE: process.env.OPENCODE_EXTERNAL_TOOLS_MODE || fileConfig.EXTERNAL_TOOLS_MODE || defaultConfig.EXTERNAL_TOOLS_MODE, EXTERNAL_TOOLS_CONFLICT_POLICY: process.env.OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY || fileConfig.EXTERNAL_TOOLS_CONFLICT_POLICY || defaultConfig.EXTERNAL_TOOLS_CONFLICT_POLICY, INTERNAL_WEB_FETCH_ENABLED: parseBool(process.env.OPENCODE_INTERNAL_WEB_FETCH_ENABLED, parseBool(fileConfig.INTERNAL_WEB_FETCH_ENABLED, defaultConfig.INTERNAL_WEB_FETCH_ENABLED)), diff --git a/package-lock.json b/package-lock.json index fa45936..7930b4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode2api", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode2api", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@opencode-ai/sdk": "^1.18.29", diff --git a/package.json b/package.json index 77df765..7e3ecef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode2api", - "version": "1.5.0", + "version": "1.6.0", "description": "An OpenAI-compatible API gateway for the OpenCode CLI, enabling the use of free models (Kimi, GLM, MiniMax) in standard AI clients.", "main": "index.js", "type": "module", diff --git a/src/proxy.js b/src/proxy.js index 3baf841..870558c 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -616,7 +616,7 @@ export function createApp(config) { API_KEY, OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD, - REQUEST_TIMEOUT_MS, + REQUEST_TIMEOUT_MS = DEFAULT_REQUEST_TIMEOUT_MS, DEBUG, DISABLE_TOOLS, INTERNAL_WEB_FETCH_ENABLED, @@ -1233,10 +1233,11 @@ export function createApp(config) { }; async function promptWithTimeout(promptParams, timeoutMs) { - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`Request timeout after ${timeoutMs}ms`)), timeoutMs); - }); - return Promise.race([client.session.prompt(promptParams), timeoutPromise]); + // Single timeout implementation: withTimeout clears its timer and + // swallows late rejections (old inline race leaked timers and could + // surface unhandled rejections). Message keeps 'Request timeout' so + // transformUpstreamError maps it to 504. + return withTimeout(client.session.prompt(promptParams), timeoutMs, 'prompt backend'); } const getCleanupRoots = () => { @@ -1643,7 +1644,8 @@ export function createApp(config) { logDebug('Request params', { temperature: requestParams.temperature, max_tokens: requestParams.max_tokens, top_p: requestParams.top_p, reasoning_effort: reasoningLevel }); - const resolvedModel = await resolveRequestedModel(model); + const resolvedModel = await withTimeout( + resolveRequestedModel(model), REQUEST_TIMEOUT_MS, 'resolve model'); pID = resolvedModel.providerID; mID = resolvedModel.modelID; if (resolvedModel.aliasFrom) { @@ -1810,8 +1812,9 @@ export function createApp(config) { logDebug('Failed to set active model:', confError.message); } - // Create session - const sessionRes = await client.session.create(); + // Create session (bounded: a hung backend must 504, not stall). + const sessionRes = await withTimeout( + client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); sessionId = sessionRes.data?.id; if (!sessionId) throw new Error('Failed to create OpenCode session'); logDebug('Session created', { sessionId }); @@ -1839,7 +1842,9 @@ export function createApp(config) { ...(requestParams.stop && { stop: requestParams.stop }) } }; - const toolOverrides = await getToolOverridesForMode(toolMode, internalToolContext); + const toolOverrides = await withTimeout( + getToolOverridesForMode(toolMode, internalToolContext), + REQUEST_TIMEOUT_MS, 'load tool overrides'); if (toolOverrides && Object.keys(toolOverrides).length > 0) { promptParams.body.tools = toolOverrides; } @@ -2397,6 +2402,15 @@ export function createApp(config) { }); app.post('/v1/responses', async (req, res) => { + // Declared outside try so catch can stop the heartbeat on errors. + let responsesKeepalive = null; + let responsesResClosed = null; + const stopResponsesKeepalive = () => { + if (responsesKeepalive) { + clearInterval(responsesKeepalive); + responsesKeepalive = null; + } + }; try { const { model, @@ -2561,7 +2575,30 @@ export function createApp(config) { return res.status(400).json({ error: { message: 'input is required' } }); } - const resolvedModel = await resolveRequestedModel(model || previousState?.model); + // Stream hardening: flush SSE headers + heartbeat BEFORE the slow + // backend awaits below, so middleboxes never see a zero-byte stall + // while resolve/session/subscribe are stuck on an overloaded host. + // (Non-stream responses skip this and answer with a single JSON.) + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + if (typeof res.flushHeaders === 'function') res.flushHeaders(); + responsesKeepalive = setInterval(() => { + if (!res.destroyed && !res.writableEnded) res.write(': heartbeat\n\n'); + }, 15000); + // NOTE: req 'close' fires as soon as the (small) request body is + // parsed, long before the handler finishes, so it must NOT be + // used here. res 'close' with !writableEnded is the true + // client-disconnect signal. + responsesResClosed = new Promise((resolve) => res.once('close', () => { + if (!res.writableEnded) resolve(true); + })); + } + + const resolvedModel = await withTimeout( + resolveRequestedModel(model || previousState?.model), + REQUEST_TIMEOUT_MS, 'resolve model'); const pID = resolvedModel.providerID; const mID = resolvedModel.modelID; @@ -2577,7 +2614,8 @@ export function createApp(config) { // otherwise start a fresh one. let sessionId = previousState?.sessionId || null; if (!sessionId) { - const sessionRes = await client.session.create(); + const sessionRes = await withTimeout( + client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); sessionId = sessionRes.data?.id; if (!sessionId) { throw new Error('Failed to create OpenCode session'); @@ -2610,7 +2648,9 @@ export function createApp(config) { internalToolContext.allowedToolNames ); - const toolOverrides = await getToolOverridesForMode(toolMode, internalToolContext); + const toolOverrides = await withTimeout( + getToolOverridesForMode(toolMode, internalToolContext), + REQUEST_TIMEOUT_MS, 'load tool overrides'); const makeForcedResponsesToolCallRequester = () => createForcedToolCallRequester({ mode: externalToolChoice.mode, sessionId, @@ -2670,9 +2710,15 @@ export function createApp(config) { }; if (stream) { - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); + // Headers + heartbeat already started before the preflight + // awaits above; response.created is the first semantic event. + // The client may have gone away during the bounded preflight + // waits — stop before emitting anything further. + if (res.destroyed || res.writableEnded) { + stopResponsesKeepalive(); + try { if (!res.destroyed) res.end(); } catch {} + return; + } const responseId = `resp_${crypto.randomUUID()}`; const messageOutputIndex = 0; const reasoningOutputIndex = 1; @@ -2833,7 +2879,15 @@ export function createApp(config) { ); const safeCollect = collectPromise.catch((err) => ({ __error: err })); client.session.prompt(promptParams).catch(err => logDebug('Responses prompt error:', err.message)); - collected = await safeCollect; + collected = responsesResClosed + ? await Promise.race([safeCollect, responsesResClosed.then(() => ({ __cancelled: true }))]) + : await safeCollect; + if (collected?.__cancelled) { + stopResponsesKeepalive(); + try { if (sessionId) await client.session.delete({ path: { id: sessionId } }); } catch (e) { logDebug('Failed to cleanup cancelled responses session', { error: e.message }); } + try { if (!res.destroyed) res.end(); } catch {} + return; + } } catch (e) { collected = { __error: e }; } @@ -2995,6 +3049,7 @@ export function createApp(config) { emit({ type: 'response.completed', sequence_number: nextSeq(), response }); res.write('data: [DONE]\n\n'); storeResponseState(responseId, sessionId, `${pID}/${mID}`); + stopResponsesKeepalive(); return res.end(); } @@ -3027,7 +3082,11 @@ export function createApp(config) { } let polledResponse = null; try { - responseRes = await client.session.prompt(promptParams); + // Bounded like chat/messages: a hung backend must surface + // as an error, never as a silent zero-byte stall. + // (withTimeout, not promptWithTimeout: a late backend + // rejection must stay handled to avoid crashing the process.) + responseRes = await withTimeout(client.session.prompt(promptParams), REQUEST_TIMEOUT_MS, 'prompt backend'); responseParts = responseRes.data?.parts || []; promptContent = responseParts.filter(p => p.type === 'text').map(p => p.text).join('\n'); promptReasoning = responseParts.filter(p => p.type === 'reasoning').map(p => p.text).join('\n'); @@ -3132,6 +3191,7 @@ export function createApp(config) { return res.json(response); } catch (error) { + stopResponsesKeepalive(); console.error('[Proxy] Responses API Error:', error?.message || error?.data?.message || error?.name || error); const transformed = transformUpstreamError(error); // Once the SSE headers are out, res.json() throws ERR_HTTP_HEADERS_SENT. That throw @@ -3189,7 +3249,8 @@ export function createApp(config) { }; if (top_k !== undefined) logDebug('Ignoring top_k (no opencode equivalent)', { top_k }); - const resolvedModel = await resolveRequestedModel(model); + const resolvedModel = await withTimeout( + resolveRequestedModel(model), REQUEST_TIMEOUT_MS, 'resolve model'); const pID = resolvedModel.providerID; const mID = resolvedModel.modelID; const publicModel = `${pID}/${mID}`; @@ -3263,7 +3324,8 @@ export function createApp(config) { try { await client.config.update({ body: { activeModel: { providerID: pID, modelID: mID } } }); } catch (e) { logDebug('Failed to set active model', { error: e.message }); } - const sessionRes = await client.session.create(); + const sessionRes = await withTimeout( + client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); sessionId = sessionRes.data?.id; if (!sessionId) throw new Error('Failed to create OpenCode session'); @@ -3279,7 +3341,9 @@ export function createApp(config) { ...(requestParams.stop && { stop: requestParams.stop }) } }; - const toolOverrides = await getToolOverridesForMode(toolMode, internalToolContext); + const toolOverrides = await withTimeout( + getToolOverridesForMode(toolMode, internalToolContext), + REQUEST_TIMEOUT_MS, 'load tool overrides'); if (toolOverrides && Object.keys(toolOverrides).length > 0) promptParams.body.tools = toolOverrides; const fullPromptText = parts.map((p) => p.text || '').join('\n\n'); @@ -3384,8 +3448,14 @@ export function createApp(config) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); - keepaliveInterval = setInterval(() => { if (!res.destroyed) res.write(': keep-alive\n\n'); }, 15000); - const reqClosed = new Promise((resolve) => req.on('close', () => resolve(true))); + keepaliveInterval = setInterval(() => { if (!res.destroyed && !res.writableEnded) res.write(': keep-alive\n\n'); }, 15000); + // NOTE: req 'close' fires as soon as the request body is parsed + // (before slow backend work finishes), so a listener attached + // here would never observe a mid-stream disconnect. res 'close' + // with !writableEnded is the true client-disconnect signal. + const resClosed = new Promise((resolve) => res.once('close', () => { + if (!res.writableEnded) resolve(true); + })); let streamedText = ''; let streamedReasoning = ''; let rawContent = ''; @@ -3443,7 +3513,7 @@ export function createApp(config) { const collectPromise = collectFromEvents(sessionId, REQUEST_TIMEOUT_MS, sendDelta, DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, DEFAULT_EVENT_IDLE_TIMEOUT_MS) .catch((err) => ({ __error: err })); client.session.prompt(promptParams).catch((err) => logDebug('Prompt error:', err.message)); - collected = await Promise.race([collectPromise, reqClosed.then(() => ({ __cancelled: true }))]); + collected = await Promise.race([collectPromise, resClosed.then(() => ({ __cancelled: true }))]); if (collected?.__cancelled) { if (keepaliveInterval) clearInterval(keepaliveInterval); try { if (sessionId) await client.session.delete({ path: { id: sessionId } }); } catch (e) { logDebug('Failed to cleanup cancelled messages session', { error: e.message }); } @@ -3751,25 +3821,70 @@ async function ensureBackend(config) { } /** - * Starts the OpenCode-to-OpenAI Proxy server. + * Normalize a boolean-ish value. Returns true/false for recognized spellings, + * undefined for missing/invalid values so callers can fall through with ??. + * Numbers: only 0/1 are recognized (like '0'/'1'); any other number is + * treated as unset so it never blocks a lower-priority source. */ -export function startProxy(options) { - const normalizeBool = (value) => { - if (typeof value === 'boolean') return value; - if (typeof value === 'number') return value === 1; - if (typeof value === 'string') { - const v = value.trim().toLowerCase(); - if (['1', 'true', 'yes', 'y', 'on'].includes(v)) return true; - if (['0', 'false', 'no', 'n', 'off'].includes(v)) return false; - } +export function normalizeBool(value) { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (value === 1) return true; + if (value === 0) return false; return undefined; - }; + } + if (typeof value === 'string') { + const v = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(v)) return true; + if (['0', 'false', 'no', 'n', 'off'].includes(v)) return false; + } + return undefined; +} - const disableTools = - normalizeBool(options.DISABLE_TOOLS) ?? - normalizeBool(options.disableTools) ?? +/** + * Resolve the effective disable-tools flag. + * Precedence: options.DISABLE_TOOLS > options.disableTools > + * env OPENCODE_DISABLE_TOOLS (canonical) > env DISABLE_TOOLS (legacy alias + * used by docker-compose/.env) > fallback. + * Invalid values ('garbage', '', whitespace) are treated as unset and fall + * through to the next source instead of coercing. + */ +export function resolveDisableTools(options, fallback = false) { + const o = options ?? {}; + return normalizeBool(o.DISABLE_TOOLS) ?? + normalizeBool(o.disableTools) ?? normalizeBool(process.env.OPENCODE_DISABLE_TOOLS) ?? - false; + normalizeBool(process.env.DISABLE_TOOLS) ?? + fallback; +} + +/** + * Race a promise against a timeout. The real outcome always wins: fast + * resolutions/rejections settle the race immediately; only a never-settling + * promise loses to the timer. A no-op handler is attached so a late rejection + * can never surface as an unhandled rejection (which would terminate the + * process under Node's default --unhandled-rejections=throw). + * The timeout error message keeps the 'Request timeout' prefix so + * transformUpstreamError maps it to 504 (not 500); the label is appended + * for diagnosability. + */ +export function withTimeout(promise, timeoutMs, label = 'operation') { + const ms = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : DEFAULT_REQUEST_TIMEOUT_MS; + let timer; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Request timeout after ${ms}ms (${label})`)), ms); + }); + if (promise && typeof promise.catch === 'function') promise.catch(() => {}); + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); +} + +/** + * Starts the OpenCode-to-OpenAI Proxy server. + */ +export function startProxy(options = {}) { + // Also tolerate explicit null (the = {} default only covers undefined). + options = options ?? {}; + const disableTools = resolveDisableTools(options); const promptMode = options.PROMPT_MODE || options.promptMode || process.env.OPENCODE_PROXY_PROMPT_MODE || 'standard'; const externalToolsMode = options.EXTERNAL_TOOLS_MODE || options.externalToolsMode || process.env.OPENCODE_EXTERNAL_TOOLS_MODE || 'proxy-bridge'; @@ -3790,7 +3905,7 @@ export function startProxy(options) { OPENCODE_SERVER_URL: options.OPENCODE_SERVER_URL || 'http://127.0.0.1:10001', OPENCODE_SERVER_PASSWORD: options.OPENCODE_SERVER_PASSWORD || process.env.OPENCODE_SERVER_PASSWORD || '', OPENCODE_PATH: options.OPENCODE_PATH || 'opencode', - BIND_HOST: options.BIND_HOST || options.bindHost || process.env.OPENCODE_PROXY_BIND_HOST || '0.0.0.0', + BIND_HOST: options.BIND_HOST || options.bindHost || process.env.BIND_HOST || process.env.OPENCODE_PROXY_BIND_HOST || '0.0.0.0', USE_ISOLATED_HOME: typeof options.USE_ISOLATED_HOME === 'boolean' ? options.USE_ISOLATED_HOME : String(options.USE_ISOLATED_HOME || '').toLowerCase() === 'true' || diff --git a/tests/env-alias.test.js b/tests/env-alias.test.js new file mode 100644 index 0000000..700eadc --- /dev/null +++ b/tests/env-alias.test.js @@ -0,0 +1,152 @@ +import { resolveDisableTools, normalizeBool } from '../src/proxy.js'; + +// Regression tests: docker-compose/.env document DISABLE_TOOLS, but the code +// only honored OPENCODE_DISABLE_TOOLS, so a legacy DISABLE_TOOLS=true was +// silently ignored. Note the unit default here is false; the production +// default (true) comes from index.js passing defaultConfig as fallback. +describe('resolveDisableTools', () => { + const CANONICAL = 'OPENCODE_DISABLE_TOOLS'; + const LEGACY = 'DISABLE_TOOLS'; + let savedCanonical; + let savedLegacy; + + beforeEach(() => { + savedCanonical = process.env[CANONICAL]; + savedLegacy = process.env[LEGACY]; + delete process.env[CANONICAL]; + delete process.env[LEGACY]; + }); + + afterEach(() => { + if (savedCanonical === undefined) delete process.env[CANONICAL]; + else process.env[CANONICAL] = savedCanonical; + if (savedLegacy === undefined) delete process.env[LEGACY]; + else process.env[LEGACY] = savedLegacy; + }); + + test('defaults to the fallback when nothing is set', () => { + expect(resolveDisableTools()).toBe(false); + expect(resolveDisableTools({})).toBe(false); + expect(resolveDisableTools({}, true)).toBe(true); + expect(resolveDisableTools(null)).toBe(false); + expect(resolveDisableTools(null, true)).toBe(true); + }); + + // Primary regression detector: legacy=true must take effect. + // (Old code had no DISABLE_TOOLS branch, so this returned false.) + test('honors legacy DISABLE_TOOLS=true (primary regression)', () => { + process.env[LEGACY] = 'true'; + expect(resolveDisableTools({})).toBe(true); + }); + + test('honors legacy DISABLE_TOOLS=false', () => { + process.env[LEGACY] = 'false'; + expect(resolveDisableTools({})).toBe(false); + }); + + test('canonical env wins over the legacy alias', () => { + process.env[CANONICAL] = 'false'; + process.env[LEGACY] = 'true'; + expect(resolveDisableTools({})).toBe(false); + process.env[CANONICAL] = 'true'; + process.env[LEGACY] = 'false'; + expect(resolveDisableTools({})).toBe(true); + }); + + test('explicit options win over both env vars', () => { + process.env[CANONICAL] = 'true'; + process.env[LEGACY] = 'true'; + expect(resolveDisableTools({ DISABLE_TOOLS: false })).toBe(false); + process.env[CANONICAL] = 'false'; + process.env[LEGACY] = 'false'; + expect(resolveDisableTools({ DISABLE_TOOLS: true })).toBe(true); + expect(resolveDisableTools({ disableTools: true })).toBe(true); + expect(resolveDisableTools({ DISABLE_TOOLS: 'false', disableTools: true })).toBe(false); + }); + + test('parses common bool spellings', () => { + for (const v of ['1', 'true', 'yes', 'y', 'on', ' TRUE ']) { + process.env[LEGACY] = v; + expect(resolveDisableTools({})).toBe(true); + } + for (const v of ['0', 'false', 'no', 'n', 'off', ' FALSE ']) { + process.env[LEGACY] = v; + expect(resolveDisableTools({})).toBe(false); + } + }); + + // Invalid values mean "unset" and fall through; they must NOT coerce. + test('invalid values fall through to the next source', () => { + for (const garbage of ['garbage', '', ' ', '2', 'truthy']) { + process.env[CANONICAL] = garbage; + process.env[LEGACY] = 'true'; + expect(resolveDisableTools({})).toBe(true); + process.env[LEGACY] = 'false'; + expect(resolveDisableTools({})).toBe(false); + delete process.env[LEGACY]; + expect(resolveDisableTools({})).toBe(false); + expect(resolveDisableTools({}, true)).toBe(true); + } + // Legacy layer alone with garbage also falls back (not coerced). + for (const garbage of ['garbage', '', ' ']) { + process.env[LEGACY] = garbage; + expect(resolveDisableTools({})).toBe(false); + expect(resolveDisableTools({}, true)).toBe(true); + } + // Invalid option values fall through to env as well. + process.env[CANONICAL] = 'true'; + for (const garbage of ['garbage', '', ' ']) { + expect(resolveDisableTools({ DISABLE_TOOLS: garbage })).toBe(true); + } + expect(resolveDisableTools({ DISABLE_TOOLS: 'garbage', disableTools: 'false' })).toBe(false); + }); + + test('numbers 0/1 parse, other numbers fall through like invalid strings', () => { + // 1/0 behave like '1'/'0'; any other number is "unset" and falls + // through instead of coercing to false and blocking env. + expect(resolveDisableTools({ DISABLE_TOOLS: 1 })).toBe(true); + expect(resolveDisableTools({ DISABLE_TOOLS: 0 })).toBe(false); + process.env[CANONICAL] = 'true'; + expect(resolveDisableTools({ DISABLE_TOOLS: 2 })).toBe(true); + expect(resolveDisableTools({ DISABLE_TOOLS: -1 })).toBe(true); + expect(resolveDisableTools({ DISABLE_TOOLS: 0 })).toBe(false); + process.env[CANONICAL] = 'false'; + expect(resolveDisableTools({ DISABLE_TOOLS: 2 })).toBe(false); + delete process.env[CANONICAL]; + expect(resolveDisableTools({ DISABLE_TOOLS: 2 })).toBe(false); + expect(resolveDisableTools({ DISABLE_TOOLS: 2 }, true)).toBe(true); + }); +}); + +// index.js cannot be imported directly (it starts the server), so mirror its +// exact resolution call here to pin the env > file > default contract. +// index.js calls resolveDisableTools({DISABLE_TOOLS: canonical, +// disableTools: legacy}, normalizeBool(file) ?? default). +describe('index.js DISABLE_TOOLS chain (mirrored)', () => { + const resolveIndexChain = (env, fileConfig, defaultValue) => + resolveDisableTools( + { + DISABLE_TOOLS: env.OPENCODE_DISABLE_TOOLS, + disableTools: env.DISABLE_TOOLS, + }, + normalizeBool(fileConfig.DISABLE_TOOLS) ?? defaultValue, + ); + + test('env > file > default', () => { + expect(resolveIndexChain({}, {}, true)).toBe(true); + expect(resolveIndexChain({}, { DISABLE_TOOLS: false }, true)).toBe(false); + expect(resolveIndexChain({ DISABLE_TOOLS: 'false' }, { DISABLE_TOOLS: true }, true)).toBe(false); + expect(resolveIndexChain({ OPENCODE_DISABLE_TOOLS: 'false' }, { DISABLE_TOOLS: true }, true)).toBe(false); + expect(resolveIndexChain( + { OPENCODE_DISABLE_TOOLS: 'true', DISABLE_TOOLS: 'false' }, + { DISABLE_TOOLS: false }, false, + )).toBe(true); + }); + + test('invalid env falls through to file, not default', () => { + expect(resolveIndexChain( + { OPENCODE_DISABLE_TOOLS: 'garbage', DISABLE_TOOLS: '' }, + { DISABLE_TOOLS: false }, true, + )).toBe(false); + }); +}); diff --git a/tests/stream-hardening.test.js b/tests/stream-hardening.test.js new file mode 100644 index 0000000..44b7c70 --- /dev/null +++ b/tests/stream-hardening.test.js @@ -0,0 +1,149 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +// Hardening for slow/overloaded hosts: /v1/responses must never sit at zero +// bytes silently (no headers, no error) when the backend is stuck. +const SESSION_ID = 'harden-session'; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { providers: [{ id: 'opencode', models: { 'big-pickle': { name: 'Big Pickle' } } }] } + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: [] })), + sessionCreate: jest.fn(async () => ({ data: { id: SESSION_ID } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'Mock response' }] } })), + sessionMessages: jest.fn(async () => ([ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Mock response' }] } + ])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ + stream: (async function* () { + yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: SESSION_ID }, delta: 'Mock response' } }; + yield { type: 'message.updated', properties: { info: { sessionID: SESSION_ID, finish: 'stop' } } }; + })() + })) +}; + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + callback(res); + return { on: jest.fn(), destroy: jest.fn() }; + }) + } +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + callback(res); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + }) + } +})); + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe } + })) +})); + +const { createApp, withTimeout } = await import('../src/proxy.js'); + +describe('withTimeout', () => { + test('resolves with the value on success', async () => { + await expect(withTimeout(Promise.resolve('ok'), 1000, 't')).resolves.toBe('ok'); + }); + + test('rejects with a 504-mappable Request timeout message', async () => { + const slow = new Promise(() => {}); + await expect(withTimeout(slow, 50, 'load tool overrides')).rejects.toThrow('Request timeout after 50ms (load tool overrides)'); + }); + + test('a fast rejection wins with the real error, not a timeout', async () => { + const started = Date.now(); + await expect(withTimeout(Promise.reject(new Error('invalid model')), 5000, 'resolve model')) + .rejects.toThrow('invalid model'); + expect(Date.now() - started).toBeLessThan(1000); + }); + + test('a late rejection never surfaces as unhandled', async () => { + let unhandled = null; + const listener = (err) => { unhandled = err; }; + process.on('unhandledRejection', listener); + try { + let rejectLate; + const loser = new Promise((_, reject) => { rejectLate = reject; }); + await expect(withTimeout(loser, 20, 't')).rejects.toThrow('Request timeout'); + rejectLate(new Error('boom-after-race')); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(unhandled).toBeNull(); + } finally { + process.removeListener('unhandledRejection', listener); + } + }); +}); + +describe('POST /v1/responses stream hardening', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const buildApp = (overrides = {}) => createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, RETRY_MAX_RETRIES: 0, + DISABLE_TOOLS: true, DEBUG: false, ...overrides + }).app; + + test('slow preflight still yields a complete SSE stream', async () => { + sdkMocks.sessionCreate.mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 400)); + return { data: { id: SESSION_ID } }; + }); + const res = await request(buildApp()).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'big-pickle', input: 'hi', stream: true }); + expect(res.headers['content-type']).toContain('text/event-stream'); + expect(res.text).toContain('response.created'); + expect(res.text).toContain('response.completed'); + expect(res.text).toContain('data: [DONE]'); + }); + test('hung backend prompt surfaces a 504 timeout instead of stalling', async () => { + sdkMocks.sessionPrompt.mockImplementationOnce(() => new Promise(() => {})); + const started = Date.now(); + const res = await request(buildApp({ REQUEST_TIMEOUT_MS: 400 })).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'big-pickle', input: 'hi' }); + expect(Date.now() - started).toBeLessThan(15000); + expect(res.statusCode).toBe(504); + expect(JSON.stringify(res.body)).not.toContain('"Object"'); + }, 20000); +}); + +describe('POST /v1/messages stream does not cancel itself', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('stream completes even though the request body closed long ago', async () => { + const app = createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, RETRY_MAX_RETRIES: 0, + DISABLE_TOOLS: true, DEBUG: false + }).app; + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send({ model: 'big-pickle', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }], stream: true }); + expect(res.headers['content-type']).toContain('text/event-stream'); + expect(res.text).toContain('message_stop'); + }, 20000); +}); From de41460dc51f34d65e7674e1fbfb36582b775a16 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 06:13:54 +0800 Subject: [PATCH 2/2] fix(review): bound retry session.create, compose BIND fallback, entrypoint comment - Wrap all 4 retry-path session.create (chat stream/non-stream, responses + messages non-stream) in withTimeout so a hung backend 504s instead of stalling past REQUEST_TIMEOUT_MS; regression test - Compose BIND_HOST chains OPENCODE_PROXY_BIND_HOST fallback instead of shadowing it with a hard default - Entrypoint wait comment distinguishes fast-refused (~180s, covered by start_period 200s) from hung-backend worst case (~630s, wedged) --- docker-compose.yml | 2 +- entrypoint.sh | 5 +++++ src/proxy.js | 12 ++++++++---- tests/stream-hardening.test.js | 25 +++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7a3bbb4..2e05a07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-} - OPENCODE_ZEN_API_KEY=${OPENCODE_ZEN_API_KEY:-} - API_KEY=${API_KEY:-} - - BIND_HOST=${BIND_HOST:-0.0.0.0} + - BIND_HOST=${BIND_HOST:-${OPENCODE_PROXY_BIND_HOST:-0.0.0.0}} - DISABLE_TOOLS=${DISABLE_TOOLS:-true} - OPENCODE_DISABLE_TOOLS=${OPENCODE_DISABLE_TOOLS:-} - OPENCODE_USE_ISOLATED_HOME=${OPENCODE_USE_ISOLATED_HOME:-false} diff --git a/entrypoint.sh b/entrypoint.sh index d144415..91641c5 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -46,6 +46,11 @@ if [[ "$1" == "opencode" && "$2" == "serve" ]]; then echo "Waiting for OpenCode Server to become available..." # Cold start on small (1GB) hosts can take minutes; do not kill early. + # Budget: 90 x 2s sleep ~= 180s when the backend refuses fast (normal + # cold start; covered by compose start_period 200s). If the backend + # accepts but hangs every probe, each round costs up to curl -m 5 + 2s + # sleep (worst case ~630s) — that means the backend itself is wedged, + # not slow, and the container will exit 1 so it can be rescheduled. MAX_RETRIES=90 RETRY_DELAY=2 COUNT=0 diff --git a/src/proxy.js b/src/proxy.js index 870558c..4a52968 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -1961,7 +1961,8 @@ export function createApp(config) { } catch (e) { logDebug('Failed to delete retried session', { sessionId, error: e.message }); } - const retrySessionRes = await client.session.create(); + const retrySessionRes = await withTimeout( + client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); sessionId = retrySessionRes.data?.id; if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); promptParams.path.id = sessionId; @@ -2174,7 +2175,8 @@ export function createApp(config) { } catch (e) { logDebug('Failed to delete retried session', { sessionId, error: e.message }); } - const retrySessionRes = await client.session.create(); + const retrySessionRes = await withTimeout( + client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); sessionId = retrySessionRes.data?.id; if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); promptParams.path.id = sessionId; @@ -3073,7 +3075,8 @@ export function createApp(config) { try { await client.session.delete({ path: { id: sessionId } }); } catch (e) { logDebug('Failed to delete retried session', { sessionId, error: e.message }); } - const retrySessionRes = await client.session.create(); + const retrySessionRes = await withTimeout( + client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); sessionId = retrySessionRes.data?.id; if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); promptParams.path.id = sessionId; @@ -3397,7 +3400,8 @@ export function createApp(config) { } if (attempt > 1) { try { await client.session.delete({ path: { id: sessionId } }); } catch {} - const r = await client.session.create(); + const r = await withTimeout( + client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); sessionId = r.data?.id; if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); promptParams.path.id = sessionId; diff --git a/tests/stream-hardening.test.js b/tests/stream-hardening.test.js index 44b7c70..6b1ef76 100644 --- a/tests/stream-hardening.test.js +++ b/tests/stream-hardening.test.js @@ -125,6 +125,31 @@ describe('POST /v1/responses stream hardening', () => { expect(res.statusCode).toBe(504); expect(JSON.stringify(res.body)).not.toContain('"Object"'); }, 20000); + + test('hung retry session.create surfaces a 504 instead of stalling', async () => { + // Attempt 1 fails transiently (fast backoff via retry-after-ms), + // then the retry's session.create hangs: must 504, not stall. + sdkMocks.sessionCreate + .mockImplementationOnce(async () => ({ data: { id: SESSION_ID } })) + .mockImplementationOnce(() => new Promise(() => {})); + sdkMocks.sessionPrompt.mockImplementationOnce(async () => { + throw { + name: 'CreditsError', + data: { + message: '429: {"message":"Too many requests","type":"CreditsError"}', + responseHeaders: { 'retry-after-ms': '10' } + } + }; + }); + const started = Date.now(); + const res = await request(buildApp({ REQUEST_TIMEOUT_MS: 400, RETRY_MAX_RETRIES: 1 })) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'big-pickle', input: 'hi' }); + expect(Date.now() - started).toBeLessThan(15000); + expect(res.statusCode).toBe(504); + expect(JSON.stringify(res.body)).not.toContain('"Object"'); + }, 20000); }); describe('POST /v1/messages stream does not cancel itself', () => {