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
-
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 3a52c98..2e05a07 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:-${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}
- 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 文档中心
-
+
> 欢迎查看 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 参考
-
+
---
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 @@
# ⚙️ 配置详解
-
+
---
@@ -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 @@
# 💻 开发指南
-
+
---
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 部署
-
+
---
@@ -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 @@
# 🚀 快速开始
-
+
---
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 @@
# 🔧 故障排查
-
+
---
diff --git a/entrypoint.sh b/entrypoint.sh
index 651a4b9..91641c5 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -45,7 +45,14 @@ 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.
+ # 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
while ! curl -s -m 5 http://127.0.0.1:${SERVER_PORT}/health > /dev/null; do
if [ $COUNT -ge $MAX_RETRIES ]; then
@@ -53,13 +60,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..4a52968 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;
}
@@ -1956,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;
@@ -2169,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;
@@ -2397,6 +2404,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 +2577,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 +2616,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 +2650,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 +2712,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 +2881,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 +3051,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();
}
@@ -3018,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;
@@ -3027,7 +3085,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 +3194,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 +3252,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 +3327,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 +3344,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');
@@ -3333,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;
@@ -3384,8 +3452,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 +3517,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 +3825,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 +3909,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..6b1ef76
--- /dev/null
+++ b/tests/stream-hardening.test.js
@@ -0,0 +1,174 @@
+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);
+
+ 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', () => {
+ 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);
+});