diff --git a/.dockerignore b/.dockerignore index 6d396a0..c2a722e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,4 +10,5 @@ tests .DS_Store *.log .env -.env.* \ No newline at end of file +.env.* +dist/ \ No newline at end of file diff --git a/.env.example b/.env.example index 96cb922..cecff67 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,10 @@ 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) +# OPENCODE_PROXY_RETRY_MAX_RETRIES=3 # OPENCODE_PROXY_DEBUG=false # OPENCODE_ZEN_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..21ab651 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-test: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run build + - run: npm test -- --runInBand + + shellcheck: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: bash syntax check + run: | + bash -n entrypoint.sh + bash -n tests/test-integration.sh + bash -n tests/test-streaming-real.sh + - name: Run ShellCheck + run: | + if ! command -v shellcheck >/dev/null; then + sudo apt-get update && sudo apt-get install -y shellcheck + fi + shellcheck -S warning entrypoint.sh tests/test-integration.sh tests/test-streaming-real.sh + + docker-build: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + file: ./Dockerfile + push: false + load: true + tags: opencode2api:ci + cache-from: type=gha + cache-to: type=gha,mode=max + # Minimal smoke: run the compiled proxy directly (bypass opencode + # backend wait) and probe the auth-free shallow /health endpoint. + # `node dist/index.js` hits the entrypoint else-branch + # (exec gosu node "$@") so no backend credentials are needed. + - name: Smoke test built image (/health) + run: | + docker run -d --name opencode2api-smoke -p 10000:10000 opencode2api:ci node dist/index.js + ok=0 + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:10000/health; then + echo + echo "smoke: /health 200" + ok=1 + break + fi + sleep 2 + done + docker logs opencode2api-smoke || true + docker rm -f opencode2api-smoke || true + test "$ok" = "1" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a651c9b..1766e04 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -8,24 +8,28 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: tiarabasori/opencode2api + IMAGE_NAME: ${{ github.repository }} jobs: build-and-push: runs-on: ubuntu-latest + timeout-minutes: 60 permissions: contents: read packages: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to the Container registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -33,7 +37,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -43,10 +47,11 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . file: ./Dockerfile + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index ebca538..cb68b3e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ yarn-debug.log* yarn-error.log* .env config.json +dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3782619..dc6d2c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +> 分叉声明 / Fork notice:本项目自 `TiaraBasori/opencode2api` 的 `v1.5.0` +> 起由 `samson910022/OpenCode2API` 独立维护。`v1.5.0`(含)以前见上游历史; +> 此后变更见本节(`[Unreleased]`)及后续版本节。 + +### Changed + +- **独立维护声明**:README 致谢明确直接上游 `TiaraBasori/opencode2api`,LICENSE 追加上游归属,CHANGELOG 声明分叉点。 +- **仓库去耦合**:文档克隆/Issue 链接与 Docker 镜像指向 `samson910022/OpenCode2API`,`package.json` 补全维护元数据。 +- **Responses 示例模型**:`README`、`docs/api-reference.md`、`docs/getting-started.md` 的 `/v1/responses` 示例统一为 `opencode/muse-spark-1.3-contributor-free`(解析逻辑无需改动,裸名与带前缀均兼容)。 +- **SDK 对齐**:`@opencode-ai/sdk ^1.1.51` → `^1.18.29`(与本地 server `1.18.29` 对齐;v1 调用形状不变,零改码;139 tests 全绿;`cross-spawn@7.0.6` 为 SDK 新增 prod 依赖,既有包去 dev 标记,无新版本;`tool.ids` 仍走上游 experimental 路径,后续跟进)。 + +### Added + +- **Anthropic Messages API**:新增 `POST /v1/messages`(`max_tokens` 必填,支持 `system`/`tools(input_schema)`/`tool_choice{auto,any,tool,none}`/`thinking→reasoning`/`image`;`tool_use.id` 原样往返;非流式回 `message` 对象,流式回 `message_start/content_block_*/message_delta/message_stop` 无 `[DONE]`;认证同时支持 `x-api-key`;CORS 放行 `x-api-key/anthropic-version`)。新增 `src/converters/anthropic.js` 纯函数转换层与 `tests/messages-anthropic.test.js`(9 例)。附带修复 `EXTERNAL_TOOL_PREFIX` 缺 import 的 latent `ReferenceError`。 +- **官方对齐重试策略**:移植上游 `session/retry.ts`(`2s×2ⁿ⁻¹` 指数退避 +25% jitter;优先 `retry-after-ms`/`retry-after`,header 等待 clamp 30s;`5xx` 强制、`isRetryable`、`ContextOverflow` 永不)。重试次数 `n` 经 `OPENCODE_PROXY_RETRY_MAX_RETRIES` 控制(默认 3,总尝试 1+n,上限对齐官方 5)。chat 双路由与 messages 非串流改用新退避;responses 非串流新增同结构重试迴圈(仅无产出且 transient 时;responses/messages 串流保持单次尝试)。三处非串流的 prompt/poll 抛错同样走 transient 重试路由(传输层 throw 不再直接逃逸);真鉴权失败(invalid api key/unauthorized/authentication failed)立即透出不再烧退避。新增 `src/retry/policy.js` 与 `tests/retry-policy.test.js`(11 例)及接线/回归测试 14 例。 + +### Fixed + +- **后端错误透出**:`/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/CONTRIBUTING.md b/CONTRIBUTING.md index cad0e9e..b04f7c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,14 +65,19 @@ docs: update configuration documentation ```bash # Clone and install -git clone https://github.com/TiaraBasori/opencode2api.git -cd opencode2api +git clone https://github.com/samson910022/OpenCode2API.git +cd OpenCode2API npm install +# Typecheck / build (TS source: index.ts + src/**/*.ts, output: dist/) +npm run typecheck +npm run build + # Run tests npm test -# Start locally +# Start locally (dev: tsx watch index.ts / prod: node dist/index.js) +npm run dev npm start ``` @@ -80,6 +85,8 @@ npm start - Unit tests: `npm run test:unit` - Integration tests: `npm run test:integration` +- Typecheck: `npm run typecheck` +- Build: `npm run build` - All tests: `npm test -- --runInBand` ## Code Review Process diff --git a/Dockerfile b/Dockerfile index 97035fa..9bcfd1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,20 @@ +# P5: multi-stage — builder compiles TS -> dist, runtime ships only prod deps + dist. +# Local `dist/` stays in .dockerignore (never enters context); the runtime +# COPY --from=builder bypasses the ignore because it copies from the build +# stage filesystem, not from the build context. +FROM node:lts-slim AS builder + +WORKDIR /build + +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY index.ts ./ +COPY src ./src +RUN npm run build + +# ---------------- runtime ---------------- FROM node:lts-slim RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -23,9 +40,10 @@ RUN chmod +x /usr/local/bin/entrypoint.sh WORKDIR /home/node/project COPY package*.json ./ -RUN npm install --production +RUN npm ci --omit=dev -COPY . . +COPY --from=builder /build/dist ./dist +RUN chown -R node:node /home/node/project EXPOSE 10000 10001 @@ -41,6 +59,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/LICENSE.md b/LICENSE.md index e08de8a..9fc7b59 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -18,4 +18,11 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. + +--- +Upstream attribution: +This project is a fork of https://github.com/TiaraBasori/opencode2api +by TiaraBasori and contributors. Upstream code is used under the MIT License. +Inspired in part by https://github.com/dxxzst/opencode-to-openai +and https://github.com/lucasliet/opencode-openai-proxy. diff --git a/README.md b/README.md index 5b24ae2..76a8289 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # OpenCode2API

- Version + Version License Node

@@ -17,7 +17,8 @@ | 特性 | 说明 | |:-----|:-----| | 🟢 **OpenAI 兼容** | `/v1/models`, `/v1/chat/completions`, `/v1/responses` | -| 📡 **流式输出** | Chat Completions 与 Responses API 的完整 SSE 流式支持 | +| 🟣 **Anthropic 兼容** | `/v1/messages`(含 `tool_use` / `thinking` / SSE 流式) | +| 📡 **流式输出** | Chat Completions、Responses 与 Messages API 的完整 SSE 流式支持 | | 🧠 **推理控制** | 支持 `reasoning_effort` 和 `reasoning: { "effort": "high" }` | | 🐳 **Docker 部署** | 一键部署,自动启动 OpenCode 后端 | | 🛡️ **工具安全** | 默认禁用工具调用 | @@ -32,8 +33,8 @@ ```bash # 1. 克隆并配置 -git clone https://github.com/TiaraBasori/opencode2api.git -cd opencode2api +git clone https://github.com/samson910022/OpenCode2API.git +cd OpenCode2API cp .env.example .env # 2. 编辑 .env 设置你的配置 @@ -56,10 +57,11 @@ npm install -g opencode-ai # Linux/macOS: curl -fsSL https://opencode.ai/install | bash # 2. 克隆并运行 -git clone https://github.com/TiaraBasori/opencode2api.git -cd opencode2api +git clone https://github.com/samson910022/OpenCode2API.git +cd OpenCode2API npm install cp config.json.example config.json +npm run build npm start ``` @@ -87,7 +89,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "gpt5-nano", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "用一句话打招呼", "reasoning": {"effort": "high"}, "stream": true @@ -129,7 +131,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "opencode/big-pickle", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "查询东京天气", "stream": true, "tools": [ @@ -152,6 +154,23 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ }' ``` +### Messages API (Anthropic 兼容) + +```bash +curl -X POST http://127.0.0.1:10000/v1/messages \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "opencode/muse-spark-1.3-contributor-free", + "max_tokens": 1024, + "system": "You are a helpful assistant.", + "messages": [{"role": "user", "content": "用一句话打招呼"}] + }' +``` + +> 也可用 `x-api-key: YOUR_API_KEY` 代替 `Authorization: Bearer`;`max_tokens` 必填;流式时返回 `message_start/content_block_start/content_block_delta/content_block_stop/message_delta/message_stop`(无 `[DONE]`)。 + --- ## 📦 部署方式 @@ -191,6 +210,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ | `OPENCODE_PROXY_CLEANUP_INTERVAL_MS` | `43200000` | 清理间隔 (毫秒) | | `OPENCODE_PROXY_CLEANUP_MAX_AGE_MS` | `86400000` | 最大存储时间 (毫秒) | | `OPENCODE_PROXY_REQUEST_TIMEOUT_MS` | `180000` | 请求超时时间 (毫秒) | +| `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`) | | `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | OpenCode 后端地址 | | `OPENCODE_SERVER_PASSWORD` | - | OpenCode 后端密码 | | `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径 | @@ -258,6 +278,7 @@ OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=true | `GET` | `/v1/models` | 获取可用模型列表 | | `POST` | `/v1/chat/completions` | Chat Completions API | | `POST` | `/v1/responses` | Responses API | +| `POST` | `/v1/messages` | Anthropic Messages API(`max_tokens` 必填,支持 `x-api-key`) | ### 模型名称格式 @@ -291,6 +312,18 @@ USE_ISOLATED_HOME=false # 让 OpenCode 复用本地登录态 ## 🔨 开发 ```bash +# 类型检查(tsc --noEmit) +npm run typecheck + +# 构建(tsc -> dist/,入口 dist/index.js) +npm run build + +# 本地开发(tsx watch,源码入口 index.ts) +npm run dev + +# 生产启动(运行构建产物) +npm start + # 运行测试 npm test -- --runInBand @@ -298,6 +331,8 @@ npm test -- --runInBand docker compose up -d --build ``` +> TypeScript 源码:`index.ts` + `src/**/*.ts`;构建产物:`dist/`(`dist/index.js` 为运行时入口,本地 `dist/` 不进 git/docker context)。 + --- ## 📄 许可证 @@ -306,9 +341,20 @@ MIT · 详见 [LICENSE](./LICENSE.md) --- -## 🙏 致谢 +## 🙏 致谢 / Acknowledgments + +本项目由 [samson910022/OpenCode2API](https://github.com/samson910022/OpenCode2API) 独立维护,复刻(Fork)自 +[TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api): + +- [TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api) — 直接上游,本项目的起点(MIT) + +另受以下开源项目启发: -感谢以下开源项目: +- [dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai) — 早期设计参考(MIT) +- [lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy) — 早期设计参考(MIT) -- [dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai) -- [lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy) +This project is independently maintained at [samson910022/OpenCode2API](https://github.com/samson910022/OpenCode2API), +forked from [TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api) and inspired by +[dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai) and +[lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy). +Upstream code remains under its original MIT license; see [LICENSE](./LICENSE.md). diff --git a/config.json.example b/config.json.example index c00cdec..d467a4c 100644 --- a/config.json.example +++ b/config.json.example @@ -14,5 +14,6 @@ "DEBUG": false, "OPENCODE_SERVER_URL": "http://127.0.0.1:10001", "OPENCODE_PATH": "opencode", - "REQUEST_TIMEOUT_MS": 180000 + "REQUEST_TIMEOUT_MS": 180000, + "RETRY_MAX_RETRIES": 3 } diff --git a/docker-compose.yml b/docker-compose.yml index 19598ef..2e05a07 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile - image: ghcr.io/tiarabasori/opencode2api:latest + image: ghcr.io/samson910022/opencode2api:latest container_name: opencode2api ports: - "${OPENCODE_PROXY_PORT:-10000}:${OPENCODE_PROXY_PORT:-10000}" @@ -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 02b03ec..8a33d34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # OpenCode2API 文档中心

- Version + Version

> 欢迎查看 OpenCode2API 文档 @@ -24,5 +24,5 @@ ## 🔗 相关链接 - 📄 [README](../README.md) -- 🐙 [GitHub 仓库](https://github.com/TiaraBasori/opencode2api) -- 🐛 [问题反馈](https://github.com/TiaraBasori/opencode2api/issues) +- 🐙 [GitHub 仓库](https://github.com/samson910022/OpenCode2API) +- 🐛 [问题反馈](https://github.com/samson910022/OpenCode2API/issues) diff --git a/docs/api-reference.md b/docs/api-reference.md index eb276ec..36cc564 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,7 +1,7 @@ # 🔌 API 参考

- Version + Version

--- @@ -165,7 +165,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "opencode/big-pickle", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "打招呼", "reasoning": {"effort": "high"}, "stream": true @@ -179,7 +179,7 @@ curl -X POST http://127.0.0.1:10000/v1/responses \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "opencode/big-pickle", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "东京现在天气怎么样?", "tools": [ { @@ -253,7 +253,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "opencode/big-pickle", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "查询东京天气", "stream": true, "tools": [ @@ -279,11 +279,71 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ - Chat Completions 会在 `chat.completion.chunk` 中返回 `delta.tool_calls` - Responses API 会返回 `response.output_item.added`、`response.function_call_arguments.delta`、`response.function_call_arguments.done`、`response.output_item.done` 等事件 +- Messages API 会返回 `message_start`、`content_block_start`、`content_block_delta`(`text_delta`/`thinking_delta`/`input_json_delta`)、`content_block_stop`、`message_delta`、`message_stop`(无 `[DONE]`) > 注意:代理内部会使用命名空间隔离同名工具,但这些内部名称不会作为公开 API 返回给客户端。 --- +### 🟣 Messages API(Anthropic 兼容) + +`POST /v1/messages`。认证可用 `Authorization: Bearer` 或 `x-api-key`;`max_tokens` 必填;首条 message 须为 `user`。 + +| 字段 | 说明 | +|:-----|:-----| +| `model` | 模型 ID(支持 `opencode/` 前缀或裸名) | +| `max_tokens` | 必填,最大输出 tokens | +| `system` | string 或 `[{type:'text', text}]` | +| `messages` | `user`/`assistant` 数组,content 支持 `text`、`image`(base64/url)、`tool_use`、`tool_result` | +| `tools` | `[{name, description, input_schema}]`(`input_schema` 即 JSON Schema) | +| `tool_choice` | `{type:'auto'|'any'|'tool'|'none', name?}`(`any`≈强制调用) | +| `thinking` | `{type:'enabled', budget_tokens}` 映射为推理强度(`disabled`→关闭) | +| `stream` | bool,流式返回 Anthropic SSE 事件 | +| `temperature` / `top_p` / `stop_sequences` | 透传(`stop_sequences`→`stop`);`top_k` 暂忽略 | + +**示例:** + +```bash +curl -X POST http://127.0.0.1:10000/v1/messages \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "opencode/muse-spark-1.3-contributor-free", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "用一句话打招呼"}] + }' +``` + +**带工具的示例:** + +```bash +curl -X POST http://127.0.0.1:10000/v1/messages \ + -H "x-api-key: YOUR_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "opencode/muse-spark-1.3-contributor-free", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "查询东京天气"}], + "tools": [ + { + "name": "weather_lookup", + "description": "Look up weather by city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + ] + }' +``` + +工具调用以 `content: [{type:'tool_use', id, name, input}]` 返回(`stop_reason: 'tool_use'`),下一轮把 `tool_use` + `tool_result` 回灌进 `messages` 即可继续。`thinking` 以无签名 `thinking` block 返回(多轮透传签名暂为占位空串)。 + +--- + ## 🔧 推理强度 | 输入值 | 映射结果 | diff --git a/docs/configuration.md b/docs/configuration.md index a84231f..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` | @@ -47,6 +47,9 @@ | `CLEANUP_INTERVAL_MS` | `43200000` | 清理间隔 (毫秒) | | `CLEANUP_MAX_AGE_MS` | `86400000` | 最大存储时间 (毫秒) | | `REQUEST_TIMEOUT_MS` | `180000` | 请求超时时间 (毫秒) | +| `RETRY_MAX_RETRIES` / `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`) | + +> 重试退避移植自上游 `session/retry.ts`(`2s×2ⁿ⁻¹` +25% jitter),但 `retry-after` 等待 clamp 在 30s(上游近无界;网关面对自带超时的客户端不宜久睡)。旧部署注意:默认总尝试由 3 次变为 1+3=4 次,如需接近旧次数可设 `2`。 ### 调试配置 @@ -81,7 +84,8 @@ "DEBUG": false, "OPENCODE_SERVER_URL": "http://127.0.0.1:10001", "OPENCODE_PATH": "opencode", - "REQUEST_TIMEOUT_MS": 180000 + "REQUEST_TIMEOUT_MS": 180000, + "RETRY_MAX_RETRIES": 3 } ``` diff --git a/docs/development.md b/docs/development.md index e73923f..10120d7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,7 +1,7 @@ # 💻 开发指南

- Version + Version

--- @@ -41,6 +41,11 @@ curl -fsSL https://opencode.ai/install | bash ### 启动开发服务器 ```bash +# 开发(tsx watch,直接跑 TS 源码 index.ts) +npm run dev + +# 生产(先构建再跑产物 dist/index.js) +npm run build npm start ``` @@ -58,6 +63,10 @@ cp config.json.example config.json | 命令 | 说明 | |:-----|:-----| +| `npm run dev` | 开发模式(tsx watch `index.ts`) | +| `npm run typecheck` | 类型检查(`tsc --noEmit`) | +| `npm run build` | 构建到 `dist/`(入口 `dist/index.js`) | +| `npm start` | 生产启动(`node dist/index.js`) | | `npm test -- --runInBand` | 运行所有测试 | | `npm run test:unit` | 运行单元测试 | | `npm run test:integration` | 运行集成测试 | @@ -103,16 +112,16 @@ chore: update build/ci ``` OpenCode2API/ -├── src/ -│ └── proxy.js # 核心代理逻辑 +├── index.ts # 入口(TS 源码) +├── src/**/*.ts # 核心代理逻辑(TS) +├── dist/ # 构建产物(tsc 输出,运行时入口 dist/index.js,不进 git) ├── tests/ │ ├── app.test.js # 单元测试 │ ├── test-integration.sh # 集成测试 │ └── test-streaming-real.sh # 流式测试 ├── docs/ # 文档 -├── index.js # 入口文件 ├── package.json # 项目配置 -├── Dockerfile # Docker 镜像 +├── Dockerfile # Docker 镜像(多阶段:builder -> runtime) └── docker-compose.yml # Docker Compose ``` diff --git a/docs/docker.md b/docs/docker.md index b8b6990..1fbc80b 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,7 +1,7 @@ # 🐳 Docker 部署

- Version + Version

--- @@ -11,8 +11,8 @@ ### 1️⃣ 克隆项目 ```bash -git clone https://github.com/TiaraBasori/opencode2api.git -cd opencode2api +git clone https://github.com/samson910022/OpenCode2API.git +cd OpenCode2API ``` ### 2️⃣ 配置环境变量 @@ -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 ecf5056..cdc6999 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ # 🚀 快速开始

- Version + Version

--- @@ -30,8 +30,8 @@ | 步骤 | 命令 | |:-----|:-----| -| 1. 克隆项目 | `git clone https://github.com/TiaraBasori/opencode2api.git` | -| 2. 进入目录 | `cd opencode2api` | +| 1. 克隆项目 | `git clone https://github.com/samson910022/OpenCode2API.git` | +| 2. 进入目录 | `cd OpenCode2API` | | 3. 复制配置 | `cp .env.example .env` | | 4. 启动服务 | `docker compose up -d` | @@ -39,12 +39,13 @@ | 步骤 | 命令 | |:-----|:-----| -| 1. 克隆项目 | `git clone https://github.com/TiaraBasori/opencode2api.git` | -| 2. 进入目录 | `cd opencode2api` | +| 1. 克隆项目 | `git clone https://github.com/samson910022/OpenCode2API.git` | +| 2. 进入目录 | `cd OpenCode2API` | | 3. 安装依赖 | `npm install` | | 4. 复制配置 | `cp config.json.example config.json` | | 5. 安装 CLI | `npm install -g opencode-ai` | -| 6. 启动服务 | `npm start` | +| 6. 构建产物 | `npm run build` | +| 7. 启动服务 | `npm start` | --- @@ -82,7 +83,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "gpt5-nano", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "Say hi in one sentence.", "reasoning": {"effort": "high"}, "stream": true diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f034ca3..489f0b7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,7 +1,7 @@ # 🔧 故障排查

- Version + Version

--- @@ -137,4 +137,4 @@ OPENCODE_PROXY_DEBUG=true ## 🆘 获取帮助 -- 🐛 [GitHub Issues](https://github.com/TiaraBasori/opencode2api/issues) +- 🐛 [GitHub Issues](https://github.com/samson910022/OpenCode2API/issues) diff --git a/entrypoint.sh b/entrypoint.sh index 7593cb0..0767291 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -45,27 +45,34 @@ 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 http://127.0.0.1:${SERVER_PORT}/health > /dev/null; do + while ! curl -s -m 5 http://127.0.0.1:${SERVER_PORT}/health > /dev/null; do if [ $COUNT -ge $MAX_RETRIES ]; then echo "Timeout waiting for OpenCode Server." 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!" echo "Starting OpenAI Proxy on port ${PROXY_PORT}..." - exec gosu node node index.js + exec gosu node node dist/index.js else exec gosu node "$@" fi \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index 831e316..0000000 --- a/index.js +++ /dev/null @@ -1,186 +0,0 @@ -import { startProxy } from './src/proxy.js'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -function parseBool(value, fallback) { - 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; - } - if (value === undefined || value === null) return fallback; - return Boolean(value); -} - -function parseToolAllowlist(value, fallback = []) { - if (Array.isArray(value)) { - return [...new Set(value.map((entry) => String(entry || '').trim()).filter(Boolean))]; - } - if (typeof value === 'string') { - return [...new Set(value.split(',').map((entry) => entry.trim()).filter(Boolean))]; - } - if (value === undefined || value === null || value === '') return fallback; - return fallback; -} - -// Default configuration -const defaultConfig = { - PORT: parseInt(process.env.OPENCODE_PROXY_PORT) || 10000, - API_KEY: '', - OPENCODE_SERVER_URL: `http://127.0.0.1:${process.env.OPENCODE_SERVER_PORT || 10001}`, - OPENCODE_SERVER_PASSWORD: process.env.OPENCODE_SERVER_PASSWORD || '', - MANAGE_BACKEND: parseBool(process.env.OPENCODE_PROXY_MANAGE_BACKEND, false), - OPENCODE_PATH: 'opencode', - BIND_HOST: '0.0.0.0', - DISABLE_TOOLS: true, - EXTERNAL_TOOLS_MODE: 'proxy-bridge', - EXTERNAL_TOOLS_CONFLICT_POLICY: 'namespace', - INTERNAL_WEB_FETCH_ENABLED: parseBool(process.env.OPENCODE_INTERNAL_WEB_FETCH_ENABLED, false), - INTERNAL_ALLOWED_TOOLS: parseToolAllowlist(process.env.OPENCODE_INTERNAL_ALLOWED_TOOLS, []), - INTERNAL_TOOL_METRICS_ENABLED: parseBool(process.env.OPENCODE_INTERNAL_TOOL_METRICS_ENABLED, true), - INTERNAL_TOOL_DISCOVERY_FIXTURE: parseToolAllowlist(process.env.OPENCODE_TOOL_DISCOVERY_FIXTURE, []), - HEALTH_DETAILS_ENABLED: parseBool(process.env.OPENCODE_HEALTH_DETAILS_ENABLED, true), - HEALTH_DETAILS_REQUIRE_AUTH: parseBool(process.env.OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH, true), - METRICS_ENABLED: parseBool(process.env.OPENCODE_METRICS_ENABLED, false), - METRICS_REQUIRE_AUTH: parseBool(process.env.OPENCODE_METRICS_REQUIRE_AUTH, true), - PROMPT_MODE: process.env.OPENCODE_PROXY_PROMPT_MODE || 'standard', - OMIT_SYSTEM_PROMPT: parseBool(process.env.OPENCODE_PROXY_OMIT_SYSTEM_PROMPT, false), - AUTO_CLEANUP_CONVERSATIONS: parseBool(process.env.OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS, false), - CLEANUP_INTERVAL_MS: parseInt(process.env.OPENCODE_PROXY_CLEANUP_INTERVAL_MS) || 43200000, - CLEANUP_MAX_AGE_MS: parseInt(process.env.OPENCODE_PROXY_CLEANUP_MAX_AGE_MS) || 86400000 -}; - -// Load config from file -const configPath = path.join(__dirname, 'config.json'); -let fileConfig = {}; - -if (fs.existsSync(configPath)) { - try { - const content = fs.readFileSync(configPath, 'utf8'); - fileConfig = JSON.parse(content); - console.log('[Config] Loaded from config.json'); - } catch (err) { - console.error('[Config] Error parsing config.json:', err.message); - } -} - -// Merge configs: env > file > default -const finalConfig = { - PORT: parseInt(process.env.OPENCODE_PROXY_PORT) || parseInt(process.env.PORT) || fileConfig.PORT || defaultConfig.PORT, - API_KEY: process.env.API_KEY || fileConfig.API_KEY || defaultConfig.API_KEY, - OPENCODE_SERVER_URL: process.env.OPENCODE_SERVER_URL || fileConfig.OPENCODE_SERVER_URL || defaultConfig.OPENCODE_SERVER_URL, - 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)), - 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)), - INTERNAL_ALLOWED_TOOLS: parseToolAllowlist(process.env.OPENCODE_INTERNAL_ALLOWED_TOOLS, parseToolAllowlist(fileConfig.INTERNAL_ALLOWED_TOOLS, defaultConfig.INTERNAL_ALLOWED_TOOLS)), - INTERNAL_TOOL_METRICS_ENABLED: parseBool(process.env.OPENCODE_INTERNAL_TOOL_METRICS_ENABLED, parseBool(fileConfig.INTERNAL_TOOL_METRICS_ENABLED, defaultConfig.INTERNAL_TOOL_METRICS_ENABLED)), - INTERNAL_TOOL_DISCOVERY_FIXTURE: parseToolAllowlist(process.env.OPENCODE_TOOL_DISCOVERY_FIXTURE, parseToolAllowlist(fileConfig.INTERNAL_TOOL_DISCOVERY_FIXTURE, defaultConfig.INTERNAL_TOOL_DISCOVERY_FIXTURE)), - HEALTH_DETAILS_ENABLED: parseBool(process.env.OPENCODE_HEALTH_DETAILS_ENABLED, parseBool(fileConfig.HEALTH_DETAILS_ENABLED, defaultConfig.HEALTH_DETAILS_ENABLED)), - HEALTH_DETAILS_REQUIRE_AUTH: parseBool(process.env.OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH, parseBool(fileConfig.HEALTH_DETAILS_REQUIRE_AUTH, defaultConfig.HEALTH_DETAILS_REQUIRE_AUTH)), - METRICS_ENABLED: parseBool(process.env.OPENCODE_METRICS_ENABLED, parseBool(fileConfig.METRICS_ENABLED, defaultConfig.METRICS_ENABLED)), - METRICS_REQUIRE_AUTH: parseBool(process.env.OPENCODE_METRICS_REQUIRE_AUTH, parseBool(fileConfig.METRICS_REQUIRE_AUTH, defaultConfig.METRICS_REQUIRE_AUTH)), - USE_ISOLATED_HOME: parseBool(process.env.OPENCODE_USE_ISOLATED_HOME, parseBool(fileConfig.USE_ISOLATED_HOME, false)), - REQUEST_TIMEOUT_MS: parseInt(process.env.OPENCODE_PROXY_REQUEST_TIMEOUT_MS) || fileConfig.REQUEST_TIMEOUT_MS || 180000, - DEBUG: parseBool(process.env.OPENCODE_PROXY_DEBUG, parseBool(fileConfig.DEBUG, false)), - ZEN_API_KEY: process.env.OPENCODE_ZEN_API_KEY || fileConfig.ZEN_API_KEY || '', - PROMPT_MODE: process.env.OPENCODE_PROXY_PROMPT_MODE || fileConfig.PROMPT_MODE || defaultConfig.PROMPT_MODE, - OMIT_SYSTEM_PROMPT: parseBool(process.env.OPENCODE_PROXY_OMIT_SYSTEM_PROMPT, parseBool(fileConfig.OMIT_SYSTEM_PROMPT, defaultConfig.OMIT_SYSTEM_PROMPT)), - AUTO_CLEANUP_CONVERSATIONS: parseBool(process.env.OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS, parseBool(fileConfig.AUTO_CLEANUP_CONVERSATIONS, defaultConfig.AUTO_CLEANUP_CONVERSATIONS)), - CLEANUP_INTERVAL_MS: parseInt(process.env.OPENCODE_PROXY_CLEANUP_INTERVAL_MS) || fileConfig.CLEANUP_INTERVAL_MS || defaultConfig.CLEANUP_INTERVAL_MS, - CLEANUP_MAX_AGE_MS: parseInt(process.env.OPENCODE_PROXY_CLEANUP_MAX_AGE_MS) || fileConfig.CLEANUP_MAX_AGE_MS || defaultConfig.CLEANUP_MAX_AGE_MS -}; - -// Validate required configuration -if (!finalConfig.OPENCODE_PATH) { - console.error('[Error] OPENCODE_PATH is not set. Please configure it in config.json or environment variable.'); - process.exit(1); -} - -// Check if opencode is available -import { execSync } from 'child_process'; -try { - execSync(`"${finalConfig.OPENCODE_PATH}" --version`, { stdio: 'ignore' }); -} catch (e) { - console.warn(`[Warning] Cannot verify OpenCode installation: ${finalConfig.OPENCODE_PATH}`); - console.warn('[Warning] Please ensure OpenCode is installed:'); - console.warn(' Windows: npm install -g opencode-ai'); - console.warn(' Linux/macOS: curl -fsSL https://opencode.ai/install | bash'); - console.warn('[Warning] Or specify the full path in config.json:'); - console.warn(' { "OPENCODE_PATH": "C:\\\\Users\\\\YourName\\\\AppData\\\\Roaming\\\\npm\\\\opencode.cmd" }'); -} - -console.log('[Config] Starting with configuration:'); -console.log(` - Port: ${finalConfig.PORT}`); -console.log(` - Bind Host: ${finalConfig.BIND_HOST}`); -console.log(` - Backend: ${finalConfig.OPENCODE_SERVER_URL}`); -console.log(` - Backend Password: ${finalConfig.OPENCODE_SERVER_PASSWORD ? 'Configured' : 'Not configured'}`); -console.log(` - OpenCode Path: ${finalConfig.OPENCODE_PATH}`); -console.log(` - API Key: ${finalConfig.API_KEY ? 'Configured' : 'Not configured (no auth)'}`); -console.log(` - Zen API Key: ${finalConfig.ZEN_API_KEY ? 'Configured' : 'Not configured'}`); -console.log(` - Disable Tools: ${finalConfig.DISABLE_TOOLS ? 'Yes' : 'No'}`); -console.log(` - External Tools Mode: ${finalConfig.EXTERNAL_TOOLS_MODE}`); -console.log(` - External Tools Conflict Policy: ${finalConfig.EXTERNAL_TOOLS_CONFLICT_POLICY}`); -console.log(` - Internal web_fetch Enabled: ${finalConfig.INTERNAL_WEB_FETCH_ENABLED ? 'Yes' : 'No'}`); -console.log(` - Internal Allowed Tools: ${finalConfig.INTERNAL_ALLOWED_TOOLS.length ? finalConfig.INTERNAL_ALLOWED_TOOLS.join(', ') : '(none)'}`); -console.log(` - Internal Tool Metrics Enabled: ${finalConfig.INTERNAL_TOOL_METRICS_ENABLED ? 'Yes' : 'No'}`); -console.log(` - Internal Tool Discovery Fixture: ${finalConfig.INTERNAL_TOOL_DISCOVERY_FIXTURE.length ? finalConfig.INTERNAL_TOOL_DISCOVERY_FIXTURE.join(', ') : '(none)'}`); -console.log(` - Health Details Enabled: ${finalConfig.HEALTH_DETAILS_ENABLED ? 'Yes' : 'No'}`); -console.log(` - Health Details Require Auth: ${finalConfig.HEALTH_DETAILS_REQUIRE_AUTH ? 'Yes' : 'No'}`); -console.log(` - Metrics Enabled: ${finalConfig.METRICS_ENABLED ? 'Yes' : 'No'}`); -console.log(` - Metrics Require Auth: ${finalConfig.METRICS_REQUIRE_AUTH ? 'Yes' : 'No'}`); -console.log(` - Use Isolated Home: ${finalConfig.USE_ISOLATED_HOME ? 'Yes' : 'No'}`); -console.log(` - Request Timeout: ${finalConfig.REQUEST_TIMEOUT_MS}ms`); -console.log(` - Prompt Mode: ${finalConfig.PROMPT_MODE}`); -console.log(` - Omit System Prompt: ${finalConfig.OMIT_SYSTEM_PROMPT ? 'Yes' : 'No'}`); -console.log(` - Auto Cleanup Conversations: ${finalConfig.AUTO_CLEANUP_CONVERSATIONS ? 'Yes' : 'No'}`); -console.log(` - Cleanup Interval: ${finalConfig.CLEANUP_INTERVAL_MS}ms`); -console.log(` - Cleanup Max Age: ${finalConfig.CLEANUP_MAX_AGE_MS}ms`); -console.log(` - Debug: ${finalConfig.DEBUG ? 'Yes' : 'No'}`); - -// A rejected promise inside a request handler must not take the whole proxy down. -// Node's default --unhandled-rejections=throw turns one bad request into a process -// exit, which reads to users as "the service stopped working after a while". -process.on('unhandledRejection', (reason) => { - const detail = reason instanceof Error - ? `${reason.name}: ${reason.message}` - : typeof reason === 'string' ? reason : JSON.stringify(reason); - console.error('[Proxy] Unhandled rejection (request dropped, server continues):', detail); - if (reason instanceof Error && reason.stack) console.error(reason.stack); -}); - -// Start the proxy -try { - const proxy = startProxy(finalConfig); - - // Handle graceful shutdown - process.on('SIGINT', () => { - console.log('\n[Shutdown] Received SIGINT, shutting down gracefully...'); - proxy.killBackend(); - proxy.server.close(() => { - console.log('[Shutdown] Server closed'); - process.exit(0); - }); - }); - - process.on('SIGTERM', () => { - console.log('\n[Shutdown] Received SIGTERM, shutting down gracefully...'); - proxy.killBackend(); - proxy.server.close(() => { - console.log('[Shutdown] Server closed'); - process.exit(0); - }); - }); -} catch (error) { - console.error('[Fatal] Failed to start proxy:', error.message); - process.exit(1); -} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..a22268a --- /dev/null +++ b/index.ts @@ -0,0 +1,285 @@ +// P4 TS: root entrypoint (ported from index.js, behavior identical). +import { startProxy, normalizeBool, resolveDisableTools } from './src/proxy.js'; +import { resolveMaxRetries } from './src/retry/policy.js'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { execSync } from 'child_process'; +import type { ProxyConfig } from './src/types/config.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function parseBool(value: unknown, fallback: boolean): boolean { + 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; + } + if (value === undefined || value === null) return fallback; + return Boolean(value); +} + +function parseToolAllowlist(value: unknown, fallback: string[] = []): string[] { + if (Array.isArray(value)) { + return [...new Set((value as unknown[]).map((entry) => String(entry ?? '').trim()).filter(Boolean))]; + } + if (typeof value === 'string') { + return [...new Set(value.split(',').map((entry) => entry.trim()).filter(Boolean))]; + } + if (value === undefined || value === null || value === '') return fallback; + return fallback; +} + +function parsePort(value: unknown, fallback: number): number { + const n = typeof value === 'string' || typeof value === 'number' ? parseInt(String(value), 10) : NaN; + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +// Default configuration +const defaultConfig = { + PORT: parsePort(process.env['OPENCODE_PROXY_PORT'], 10000), + API_KEY: '', + OPENCODE_SERVER_URL: `http://127.0.0.1:${process.env['OPENCODE_SERVER_PORT'] || 10001}`, + OPENCODE_SERVER_PASSWORD: process.env['OPENCODE_SERVER_PASSWORD'] || '', + MANAGE_BACKEND: parseBool(process.env['OPENCODE_PROXY_MANAGE_BACKEND'], false), + OPENCODE_PATH: 'opencode', + BIND_HOST: '0.0.0.0', + DISABLE_TOOLS: true, + EXTERNAL_TOOLS_MODE: 'proxy-bridge', + EXTERNAL_TOOLS_CONFLICT_POLICY: 'namespace', + INTERNAL_WEB_FETCH_ENABLED: parseBool(process.env['OPENCODE_INTERNAL_WEB_FETCH_ENABLED'], false), + INTERNAL_ALLOWED_TOOLS: parseToolAllowlist(process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS'], []), + INTERNAL_TOOL_METRICS_ENABLED: parseBool(process.env['OPENCODE_INTERNAL_TOOL_METRICS_ENABLED'], true), + INTERNAL_TOOL_DISCOVERY_FIXTURE: parseToolAllowlist(process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE'], []), + HEALTH_DETAILS_ENABLED: parseBool(process.env['OPENCODE_HEALTH_DETAILS_ENABLED'], true), + HEALTH_DETAILS_REQUIRE_AUTH: parseBool(process.env['OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH'], true), + METRICS_ENABLED: parseBool(process.env['OPENCODE_METRICS_ENABLED'], false), + METRICS_REQUIRE_AUTH: parseBool(process.env['OPENCODE_METRICS_REQUIRE_AUTH'], true), + PROMPT_MODE: process.env['OPENCODE_PROXY_PROMPT_MODE'] || 'standard', + OMIT_SYSTEM_PROMPT: parseBool(process.env['OPENCODE_PROXY_OMIT_SYSTEM_PROMPT'], false), + AUTO_CLEANUP_CONVERSATIONS: parseBool(process.env['OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS'], false), + CLEANUP_INTERVAL_MS: parsePort(process.env['OPENCODE_PROXY_CLEANUP_INTERVAL_MS'], 43200000), + CLEANUP_MAX_AGE_MS: parsePort(process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS'], 86400000), +}; + +// Load config from file +const configPath = path.join(__dirname, 'config.json'); +let fileConfig: Record = {}; + +if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, 'utf8'); + fileConfig = JSON.parse(content) as Record; + console.log('[Config] Loaded from config.json'); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error('[Config] Error parsing config.json:', msg); + } +} + +function readFileString(key: string, fallback: string): string { + const v: unknown = fileConfig[key]; + return typeof v === 'string' && v ? v : fallback; +} + +function readFileNumber(key: string, fallback: number): number { + const v: unknown = fileConfig[key]; + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() !== '') { + const n = parseInt(v, 10); + if (Number.isFinite(n)) return n; + } + return fallback; +} + +// Merge configs: env > file > default +const finalConfig: ProxyConfig = { + PORT: + parsePort(process.env['OPENCODE_PROXY_PORT'], NaN) || + parsePort(process.env['PORT'], NaN) || + readFileNumber('PORT', defaultConfig.PORT), + API_KEY: process.env['API_KEY'] || readFileString('API_KEY', defaultConfig.API_KEY), + OPENCODE_SERVER_URL: process.env['OPENCODE_SERVER_URL'] || readFileString('OPENCODE_SERVER_URL', defaultConfig.OPENCODE_SERVER_URL), + OPENCODE_SERVER_PASSWORD: + process.env['OPENCODE_SERVER_PASSWORD'] || readFileString('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'] || readFileString('OPENCODE_PATH', defaultConfig.OPENCODE_PATH), + BIND_HOST: + process.env['BIND_HOST'] || + process.env['OPENCODE_PROXY_BIND_HOST'] || + readFileString('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) as boolean, + ), + EXTERNAL_TOOLS_MODE: + process.env['OPENCODE_EXTERNAL_TOOLS_MODE'] || readFileString('EXTERNAL_TOOLS_MODE', defaultConfig.EXTERNAL_TOOLS_MODE), + EXTERNAL_TOOLS_CONFLICT_POLICY: + process.env['OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY'] || + readFileString('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), + ), + INTERNAL_ALLOWED_TOOLS: parseToolAllowlist( + process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS'], + parseToolAllowlist(fileConfig['INTERNAL_ALLOWED_TOOLS'], defaultConfig.INTERNAL_ALLOWED_TOOLS), + ), + INTERNAL_TOOL_METRICS_ENABLED: parseBool( + process.env['OPENCODE_INTERNAL_TOOL_METRICS_ENABLED'], + parseBool(fileConfig['INTERNAL_TOOL_METRICS_ENABLED'], defaultConfig.INTERNAL_TOOL_METRICS_ENABLED), + ), + INTERNAL_TOOL_DISCOVERY_FIXTURE: parseToolAllowlist( + process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE'], + parseToolAllowlist(fileConfig['INTERNAL_TOOL_DISCOVERY_FIXTURE'], defaultConfig.INTERNAL_TOOL_DISCOVERY_FIXTURE), + ), + HEALTH_DETAILS_ENABLED: parseBool( + process.env['OPENCODE_HEALTH_DETAILS_ENABLED'], + parseBool(fileConfig['HEALTH_DETAILS_ENABLED'], defaultConfig.HEALTH_DETAILS_ENABLED), + ), + HEALTH_DETAILS_REQUIRE_AUTH: parseBool( + process.env['OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH'], + parseBool(fileConfig['HEALTH_DETAILS_REQUIRE_AUTH'], defaultConfig.HEALTH_DETAILS_REQUIRE_AUTH), + ), + METRICS_ENABLED: parseBool( + process.env['OPENCODE_METRICS_ENABLED'], + parseBool(fileConfig['METRICS_ENABLED'], defaultConfig.METRICS_ENABLED), + ), + METRICS_REQUIRE_AUTH: parseBool( + process.env['OPENCODE_METRICS_REQUIRE_AUTH'], + parseBool(fileConfig['METRICS_REQUIRE_AUTH'], defaultConfig.METRICS_REQUIRE_AUTH), + ), + USE_ISOLATED_HOME: parseBool(process.env['OPENCODE_USE_ISOLATED_HOME'], parseBool(fileConfig['USE_ISOLATED_HOME'], false)), + REQUEST_TIMEOUT_MS: parsePort(process.env['OPENCODE_PROXY_REQUEST_TIMEOUT_MS'], 0) || readFileNumber('REQUEST_TIMEOUT_MS', 180000), + RETRY_MAX_RETRIES: process.env['OPENCODE_PROXY_RETRY_MAX_RETRIES'] ?? fileConfig['RETRY_MAX_RETRIES'] ?? 3, + DEBUG: parseBool(process.env['OPENCODE_PROXY_DEBUG'], parseBool(fileConfig['DEBUG'], false)), + ZEN_API_KEY: process.env['OPENCODE_ZEN_API_KEY'] || readFileString('ZEN_API_KEY', ''), + PROMPT_MODE: process.env['OPENCODE_PROXY_PROMPT_MODE'] || readFileString('PROMPT_MODE', defaultConfig.PROMPT_MODE), + OMIT_SYSTEM_PROMPT: parseBool( + process.env['OPENCODE_PROXY_OMIT_SYSTEM_PROMPT'], + parseBool(fileConfig['OMIT_SYSTEM_PROMPT'], defaultConfig.OMIT_SYSTEM_PROMPT), + ), + AUTO_CLEANUP_CONVERSATIONS: parseBool( + process.env['OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS'], + parseBool(fileConfig['AUTO_CLEANUP_CONVERSATIONS'], defaultConfig.AUTO_CLEANUP_CONVERSATIONS), + ), + CLEANUP_INTERVAL_MS: + parsePort(process.env['OPENCODE_PROXY_CLEANUP_INTERVAL_MS'], 0) || + readFileNumber('CLEANUP_INTERVAL_MS', defaultConfig.CLEANUP_INTERVAL_MS), + CLEANUP_MAX_AGE_MS: + parsePort(process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS'], 0) || + readFileNumber('CLEANUP_MAX_AGE_MS', defaultConfig.CLEANUP_MAX_AGE_MS), + OPENCODE_HOME_BASE: null, +}; + +// Validate required configuration +if (!finalConfig.OPENCODE_PATH) { + console.error('[Error] OPENCODE_PATH is not set. Please configure it in config.json or environment variable.'); + process.exit(1); +} + +// Check if opencode is available +try { + execSync(`"${finalConfig.OPENCODE_PATH}" --version`, { stdio: 'ignore' }); +} catch { + console.warn(`[Warning] Cannot verify OpenCode installation: ${finalConfig.OPENCODE_PATH}`); + console.warn('[Warning] Please ensure OpenCode is installed:'); + console.warn(' Windows: npm install -g opencode-ai'); + console.warn(' Linux/macOS: curl -fsSL https://opencode.ai/install | bash'); + console.warn('[Warning] Or specify the full path in config.json:'); + console.warn(' { "OPENCODE_PATH": "C:\\\\Users\\\\YourName\\\\AppData\\\\Roaming\\\\npm\\\\opencode.cmd" }'); +} + +console.log('[Config] Starting with configuration:'); +console.log(` - Port: ${finalConfig.PORT}`); +console.log(` - Bind Host: ${finalConfig.BIND_HOST}`); +console.log(` - Backend: ${finalConfig.OPENCODE_SERVER_URL}`); +console.log(` - Backend Password: ${finalConfig.OPENCODE_SERVER_PASSWORD ? 'Configured' : 'Not configured'}`); +console.log(` - OpenCode Path: ${finalConfig.OPENCODE_PATH}`); +console.log(` - API Key: ${finalConfig.API_KEY ? 'Configured' : 'Not configured (no auth)'}`); +console.log(` - Zen API Key: ${finalConfig.ZEN_API_KEY ? 'Configured' : 'Not configured'}`); +console.log(` - Disable Tools: ${finalConfig.DISABLE_TOOLS ? 'Yes' : 'No'}`); +console.log(` - External Tools Mode: ${finalConfig.EXTERNAL_TOOLS_MODE}`); +console.log(` - External Tools Conflict Policy: ${finalConfig.EXTERNAL_TOOLS_CONFLICT_POLICY}`); +console.log(` - Internal web_fetch Enabled: ${finalConfig.INTERNAL_WEB_FETCH_ENABLED ? 'Yes' : 'No'}`); +console.log( + ` - Internal Allowed Tools: ${finalConfig.INTERNAL_ALLOWED_TOOLS.length ? finalConfig.INTERNAL_ALLOWED_TOOLS.join(', ') : '(none)'}`, +); +console.log(` - Internal Tool Metrics Enabled: ${finalConfig.INTERNAL_TOOL_METRICS_ENABLED ? 'Yes' : 'No'}`); +console.log( + ` - Internal Tool Discovery Fixture: ${finalConfig.INTERNAL_TOOL_DISCOVERY_FIXTURE.length ? finalConfig.INTERNAL_TOOL_DISCOVERY_FIXTURE.join(', ') : '(none)'}`, +); +console.log(` - Health Details Enabled: ${finalConfig.HEALTH_DETAILS_ENABLED ? 'Yes' : 'No'}`); +console.log(` - Health Details Require Auth: ${finalConfig.HEALTH_DETAILS_REQUIRE_AUTH ? 'Yes' : 'No'}`); +console.log(` - Metrics Enabled: ${finalConfig.METRICS_ENABLED ? 'Yes' : 'No'}`); +console.log(` - Metrics Require Auth: ${finalConfig.METRICS_REQUIRE_AUTH ? 'Yes' : 'No'}`); +console.log(` - Use Isolated Home: ${finalConfig.USE_ISOLATED_HOME ? 'Yes' : 'No'}`); +console.log(` - Request Timeout: ${finalConfig.REQUEST_TIMEOUT_MS}ms`); +console.log( + ` - Max Retries: ${resolveMaxRetries(finalConfig.RETRY_MAX_RETRIES)} (raw: ${String(finalConfig.RETRY_MAX_RETRIES)}, total attempts 1+n)`, +); +console.log(` - Prompt Mode: ${finalConfig.PROMPT_MODE}`); +console.log(` - Omit System Prompt: ${finalConfig.OMIT_SYSTEM_PROMPT ? 'Yes' : 'No'}`); +console.log(` - Auto Cleanup Conversations: ${finalConfig.AUTO_CLEANUP_CONVERSATIONS ? 'Yes' : 'No'}`); +console.log(` - Cleanup Interval: ${finalConfig.CLEANUP_INTERVAL_MS}ms`); +console.log(` - Cleanup Max Age: ${finalConfig.CLEANUP_MAX_AGE_MS}ms`); +console.log(` - Debug: ${finalConfig.DEBUG ? 'Yes' : 'No'}`); + +// A rejected promise inside a request handler must not take the whole proxy down. +// Node's default --unhandled-rejections=throw turns one bad request into a process +// exit, which reads to users as "the service stopped working after a while". +process.on('unhandledRejection', (reason: unknown) => { + const detail = + reason instanceof Error + ? `${reason.name}: ${reason.message}` + : typeof reason === 'string' + ? reason + : (() => { + try { + return JSON.stringify(reason); + } catch { + return String(reason); + } + })(); + console.error('[Proxy] Unhandled rejection (request dropped, server continues):', detail); + if (reason instanceof Error && reason.stack) console.error(reason.stack); +}); + +// Start the proxy +try { + const proxy = startProxy(finalConfig); + + // Handle graceful shutdown + process.on('SIGINT', () => { + console.log('\n[Shutdown] Received SIGINT, shutting down gracefully...'); + proxy.killBackend(); + proxy.server.close(() => { + console.log('[Shutdown] Server closed'); + process.exit(0); + }); + }); + + process.on('SIGTERM', () => { + console.log('\n[Shutdown] Received SIGTERM, shutting down gracefully...'); + proxy.killBackend(); + proxy.server.close(() => { + console.log('[Shutdown] Server closed'); + process.exit(0); + }); + }); +} catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error('[Fatal] Failed to start proxy:', msg); + process.exit(1); +} diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..0d9297b --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,28 @@ +/** Jest ESM + TS wiring (P1 fix). + * + * - `type: "module"` package must use `.cjs` so Jest can load config via require(). + * - `NODE_OPTIONS=--experimental-vm-modules` (see `npm test`) keeps + * `jest.unstable_mockModule` + top-level await `import('../src/proxy.js')` working. + * - `moduleNameMapper` strips NodeNext `.js` suffix so `.js` specifiers resolve + * to sibling `.ts` sources (`policy.js` -> `policy.ts`). + * - transform uses @swc/jest only (no ts-jest typecheck); `tsc --noEmit` owns typecheck. + */ +module.exports = { + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.[tj]sx?$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript' }, + target: 'es2022', + }, + module: { type: 'es6' }, + }, + ], + }, +}; diff --git a/package-lock.json b/package-lock.json index 66a2eac..72d6792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,32 @@ { "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.1.51", + "@opencode-ai/sdk": "^1.18.29", "axios": "^1.7.9", "body-parser": "^1.20.3", "cors": "^2.8.5", "express": "^4.21.2" }, "devDependencies": { + "@swc/core": "^1.16.2", + "@swc/jest": "^0.2.39", + "@types/body-parser": "^1.19.6", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.25", + "@types/node": "^20.19.43", + "@types/supertest": "^7.2.1", "jest": "^30.2.0", - "supertest": "^7.2.2" + "supertest": "^7.2.2", + "tsx": "^4.23.13", + "typescript": "^7.0.2" }, "engines": { "node": ">=18.0.0" @@ -561,243 +570,540 @@ "tslib": "^2.4.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], "dev": true, - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/@jest/console": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", - "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@jest/types": "30.3.0", - "@types/node": "*", - "chalk": "^4.1.2", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "slash": "^3.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@jest/core": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", - "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@jest/console": "30.3.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.3.0", - "jest-config": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-resolve-dependencies": "30.3.0", - "jest-runner": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "jest-watcher": "30.3.0", - "pretty-format": "30.3.0", - "slash": "^3.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@jest/environment": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", - "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", - "@types/node": "*", - "jest-mock": "30.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@jest/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "expect": "30.3.0", - "jest-snapshot": "30.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@jest/expect-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", - "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], "dev": true, - "dependencies": { - "@jest/get-type": "30.1.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@jest/fake-timers": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", - "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@jest/types": "30.3.0", - "@sinonjs/fake-timers": "^15.0.0", - "@types/node": "*", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@jest/globals": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", - "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], "dev": true, - "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/types": "30.3.0", - "jest-mock": "30.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], "dev": true, - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/reporters": { + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", - "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "dev": true, + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", "dev": true, "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", - "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", + "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", + "ci-info": "^4.2.0", "exit-x": "^0.2.2", - "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", - "istanbul-reports": "^3.1.3", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", "jest-util": "30.3.0", - "jest-worker": "30.3.0", - "slash": "^3.0.0", - "string-length": "^4.0.2", - "v8-to-istanbul": "^9.0.1" + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -811,354 +1117,1366 @@ } } }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "node_modules/@jest/create-cache-key-function": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-30.5.1.tgz", + "integrity": "sha512-5qkif//qhlbt54255O/M2JtUZNlnefOzE12bi7FlApOIYO4pqrZcAwhNCfP4jOGFubez8uoLgY9czWFjYY57ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@jest/pattern": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.5.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@jest/schemas": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/@jest/types": { + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/create-cache-key-function/node_modules/jest-regex-util": { + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "dev": true, + "dependencies": { + "expect": "30.3.0", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "dev": true, + "dependencies": { + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", + "@types/node": "*", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "dev": true, + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "dev": true, + "dependencies": { + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "dev": true, + "dependencies": { + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "dev": true, + "dependencies": { + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "dev": true, + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.29", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.29.tgz", + "integrity": "sha512-4CS+FoLPkymTlcga8jxivGDDb2AbWMIIl3b8+myoe2wtv/1ANYCErslgz1xy5hTVHymWE6CtVNKzRuPU0ED57A==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.0.tgz", + "integrity": "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@swc/core": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz", + "integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.28" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.16.2", + "@swc/core-darwin-x64": "1.16.2", + "@swc/core-linux-arm-gnueabihf": "1.16.2", + "@swc/core-linux-arm64-gnu": "1.16.2", + "@swc/core-linux-arm64-musl": "1.16.2", + "@swc/core-linux-ppc64-gnu": "1.16.2", + "@swc/core-linux-s390x-gnu": "1.16.2", + "@swc/core-linux-x64-gnu": "1.16.2", + "@swc/core-linux-x64-musl": "1.16.2", + "@swc/core-win32-arm64-msvc": "1.16.2", + "@swc/core-win32-ia32-msvc": "1.16.2", + "@swc/core-win32-x64-msvc": "1.16.2" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz", + "integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz", + "integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz", + "integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz", + "integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz", + "integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz", + "integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz", + "integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz", + "integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz", + "integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz", + "integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz", + "integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz", + "integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/jest": { + "version": "0.2.39", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.39.tgz", + "integrity": "sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^30.0.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "dev": true, + "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/node": "*" } }, - "node_modules/@jest/snapshot-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", - "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" } }, - "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/@jest/test-result": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", - "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, "dependencies": { - "@jest/console": "30.3.0", - "@jest/types": "30.3.0", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/istanbul-lib-coverage": "*" } }, - "node_modules/@jest/test-sequencer": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", - "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "dependencies": { - "@jest/test-result": "30.3.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/istanbul-lib-report": "*" } }, - "node_modules/@jest/transform": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", - "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.3.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "undici-types": "~6.21.0" } }, - "node_modules/@jest/types": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", - "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@types/send": "<1" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@types/mime": "^1", + "@types/node": "*" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", "dev": true, - "engines": { - "node": ">=6.0.0" + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "Apache-2.0", "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" + "node": ">=16.20.0" } }, - "node_modules/@opencode-ai/sdk": { - "version": "1.1.53", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz", - "integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==", - "license": "MIT" + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], "dev": true, - "dependencies": { - "@noble/hashes": "^1.1.5" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "Apache-2.0", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" + "node": ">=16.20.0" } }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" + "node": ">=16.20.0" } }, - "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", - "dev": true - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], "dev": true, - "dependencies": { - "type-detect": "4.0.8" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.0.tgz", - "integrity": "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==", + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.1" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], "dev": true, + "license": "Apache-2.0", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@babel/types": "^7.28.2" + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@types/istanbul-lib-coverage": "*" + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "@types/istanbul-lib-report": "*" + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "undici-types": "~7.18.0" + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "@types/yargs-parser": "*" + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", @@ -2042,7 +3360,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2241,6 +3558,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2853,8 +4212,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -3585,6 +4943,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -3988,7 +5353,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -4278,7 +5642,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4290,7 +5653,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -4778,6 +6140,25 @@ "dev": true, "optional": true }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -4811,11 +6192,47 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", @@ -4933,7 +6350,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, diff --git a/package.json b/package.json index 94b5a12..b087a3d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "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", + "main": "dist/index.js", "type": "module", "scripts": { - "start": "node index.js", + "start": "node dist/index.js", + "dev": "tsx watch index.ts", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit", "test": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand --detectOpenHandles", "test:unit": "npm test", "test:integration": "bash tests/test-integration.sh" @@ -20,20 +23,37 @@ "ai", "api" ], - "author": "", + "author": "samson910022", + "repository": { + "type": "git", + "url": "https://github.com/samson910022/OpenCode2API.git" + }, + "homepage": "https://github.com/samson910022/OpenCode2API#readme", + "bugs": { + "url": "https://github.com/samson910022/OpenCode2API/issues" + }, "license": "MIT", "engines": { "node": ">=18.0.0" }, "dependencies": { - "@opencode-ai/sdk": "^1.1.51", + "@opencode-ai/sdk": "^1.18.29", "axios": "^1.7.9", "body-parser": "^1.20.3", "cors": "^2.8.5", "express": "^4.21.2" }, "devDependencies": { + "@swc/core": "^1.16.2", + "@swc/jest": "^0.2.39", + "@types/body-parser": "^1.19.6", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.25", + "@types/node": "^20.19.43", + "@types/supertest": "^7.2.1", "jest": "^30.2.0", - "supertest": "^7.2.2" + "supertest": "^7.2.2", + "tsx": "^4.23.13", + "typescript": "^7.0.2" } } diff --git a/src/backend/manager.ts b/src/backend/manager.ts new file mode 100644 index 0000000..578377a --- /dev/null +++ b/src/backend/manager.ts @@ -0,0 +1,521 @@ +// P4 TS: backend lifecycle + process queue/lock + opencode binary resolution (ported from P3 .js). +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import http from 'http'; +import { spawn } from 'child_process'; +import type { ChildProcess } from 'child_process'; +import type { ProxyConfig } from '../types/config.js'; +import type { BackendState, OpencodeResolveResult } from '../types/backend.js'; + +interface QueuedTask { + task: () => Promise; + timeout: number; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +} + +const queue: QueuedTask[] = []; +let isProcessing = false; + +const STARTUP_WAIT_ITERATIONS = 60; +const STARTUP_WAIT_INTERVAL_MS = 2000; +const STARTING_WAIT_ITERATIONS = 120; +const STARTING_WAIT_INTERVAL_MS = 1000; + +const OPENCODE_BASENAME = 'opencode'; + +export function splitPathEnv(): string[] { + const raw: string = process.env['PATH'] || ''; + return raw.split(path.delimiter).filter(Boolean); +} + +export function sleep(ms: unknown): Promise { + const numeric = Number(ms); + const safe = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0; + return new Promise((resolve) => setTimeout(resolve, safe)); +} + +export function pushDir(list: string[], dir: unknown): void { + if (!dir || typeof dir !== 'string' || !dir) return; + if (!list.includes(dir)) list.push(dir); +} + +export function pushExistingDir(list: string[], dir: unknown): void { + if (!dir || typeof dir !== 'string' || !dir) return; + if (!fs.existsSync(dir)) return; + if (!list.includes(dir)) list.push(dir); +} + +export function addVersionedDirs(list: string[], baseDir: unknown, subpath: unknown): void { + if (typeof baseDir !== 'string' || !baseDir || !fs.existsSync(baseDir)) return; + let entries: import('fs').Dirent[] = []; + try { + entries = fs.readdirSync(baseDir, { withFileTypes: true }); + } catch { + return; + } + entries.forEach((entry) => { + if (!entry.isDirectory()) return; + const full = path.join(baseDir, entry.name, typeof subpath === 'string' ? subpath : ''); + pushExistingDir(list, full); + }); +} + +export function prefixToBin(prefix: unknown): string | null { + if (!prefix || typeof prefix !== 'string' || !prefix) return null; + return process.platform === 'win32' ? prefix : path.join(prefix, 'bin'); +} + +export function getOpencodeCandidateNames(): string[] { + if (process.platform === 'win32') { + return [`${OPENCODE_BASENAME}.cmd`, `${OPENCODE_BASENAME}.exe`, `${OPENCODE_BASENAME}.bat`, OPENCODE_BASENAME]; + } + return [OPENCODE_BASENAME]; +} + +export function findExecutableInDirs(dirs: unknown, names: unknown): string | null { + if (!Array.isArray(dirs) || !Array.isArray(names)) return null; + for (const dir of dirs as unknown[]) { + if (typeof dir !== 'string') continue; + for (const name of names as unknown[]) { + if (typeof name !== 'string') continue; + const full = path.join(dir, name); + if (fs.existsSync(full)) { + return full; + } + } + } + return null; +} + +export function resolveOpencodePath(requestedPath: unknown): OpencodeResolveResult { + const input = typeof requestedPath === 'string' ? requestedPath.trim() : ''; + const names = getOpencodeCandidateNames(); + + if (input) { + const looksLikePath = path.isAbsolute(input) || input.includes('/') || input.includes('\\'); + if (looksLikePath) { + if (fs.existsSync(input)) return { path: input, source: 'config' }; + const resolved = path.resolve(process.cwd(), input); + if (fs.existsSync(resolved)) return { path: resolved, source: 'config' }; + } + } + + const pathDirs = splitPathEnv(); + const fromPath = findExecutableInDirs(pathDirs, names); + if (fromPath) return { path: fromPath, source: 'PATH' }; + + const extraDirs: string[] = []; + if (process.env['OPENCODE_HOME']) { + pushDir(extraDirs, path.join(process.env['OPENCODE_HOME'], 'bin')); + } + if (process.env['OPENCODE_DIR']) { + pushDir(extraDirs, path.join(process.env['OPENCODE_DIR'], 'bin')); + } + pushDir(extraDirs, prefixToBin(process.env['npm_config_prefix'] ?? process.env['NPM_CONFIG_PREFIX'])); + pushDir(extraDirs, process.env['PNPM_HOME']); + if (process.env['YARN_GLOBAL_FOLDER']) { + pushDir(extraDirs, path.join(process.env['YARN_GLOBAL_FOLDER'], 'bin')); + } + if (process.env['VOLTA_HOME']) { + pushDir(extraDirs, path.join(process.env['VOLTA_HOME'], 'bin')); + } + pushDir(extraDirs, process.env['NVM_BIN']); + pushDir(extraDirs, path.dirname(process.execPath)); + + const home = os.homedir(); + if (home) { + pushDir(extraDirs, path.join(home, '.opencode', 'bin')); + pushDir(extraDirs, path.join(home, '.local', 'bin')); + pushDir(extraDirs, path.join(home, '.npm-global', 'bin')); + pushDir(extraDirs, path.join(home, '.npm', 'bin')); + pushDir(extraDirs, path.join(home, '.pnpm-global', 'bin')); + pushDir(extraDirs, path.join(home, '.local', 'share', 'pnpm')); + pushDir(extraDirs, path.join(home, '.fnm', 'node-versions', 'v1', 'installations')); + pushDir(extraDirs, path.join(home, '.asdf', 'shims')); + } + + if (process.platform === 'win32') { + pushDir(extraDirs, process.env['APPDATA'] ? path.join(process.env['APPDATA'], 'npm') : null); + pushDir(extraDirs, process.env['LOCALAPPDATA'] ? path.join(process.env['LOCALAPPDATA'], 'pnpm') : null); + pushDir(extraDirs, process.env['NVM_HOME']); + pushDir(extraDirs, process.env['NVM_SYMLINK']); + pushDir(extraDirs, process.env['ProgramFiles'] ? path.join(process.env['ProgramFiles'], 'nodejs') : null); + pushDir( + extraDirs, + process.env['ProgramFiles(x86)'] ? path.join(process.env['ProgramFiles(x86)'] as string, 'nodejs') : null, + ); + } else { + pushDir(extraDirs, '/usr/local/bin'); + pushDir(extraDirs, '/usr/bin'); + pushDir(extraDirs, '/bin'); + pushDir(extraDirs, '/opt/homebrew/bin'); + pushDir(extraDirs, '/snap/bin'); + } + + // nvm (unix) versions + const nvmDir: string | null = (process.env['NVM_DIR'] as string) || (home ? path.join(home, '.nvm') : null); + if (nvmDir) { + addVersionedDirs(extraDirs, path.join(nvmDir, 'versions', 'node'), 'bin'); + } + + // asdf nodejs installs + const asdfDir: string | null = + (process.env['ASDF_DATA_DIR'] as string) || (home ? path.join(home, '.asdf') : null); + if (asdfDir) { + addVersionedDirs(extraDirs, path.join(asdfDir, 'installs', 'nodejs'), 'bin'); + } + + // fnm installs + if (home) { + addVersionedDirs(extraDirs, path.join(home, '.fnm', 'node-versions', 'v1'), 'installation' + path.sep + 'bin'); + } + + const fromExtras = findExecutableInDirs(extraDirs, names); + if (fromExtras) return { path: fromExtras, source: 'known-locations' }; + + return { path: null, source: 'not-found' }; +} + +export function processQueue(): void { + if (isProcessing || queue.length === 0) return; + isProcessing = true; + const next = queue.shift(); + if (!next) { + isProcessing = false; + return; + } + const { task, timeout, resolve, reject } = next; + let settled = false; + const timeoutMs = timeout || 120000; + const timeoutId = setTimeout(() => { + if (settled) return; + settled = true; + reject(new Error(`Request timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + Promise.resolve() + .then(() => task()) + .then((result: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + resolve(result); + }) + .catch((err: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + reject(err); + }) + .finally(() => { + isProcessing = false; + if (queue.length > 0) { + queueMicrotask(processQueue); + } + }); +} + +export function lock(task: unknown, timeout: unknown = 120000): Promise { + const fn = task as () => Promise; + const ms = typeof timeout === 'number' && Number.isFinite(timeout) ? timeout : 120000; + return new Promise((resolve, reject) => { + queue.push({ task: fn, timeout: ms, resolve, reject }); + processQueue(); + }); +} + +/** + * Robust Health Check Helper + */ +export function buildBackendAuthHeaders(password: unknown = ''): Record | undefined { + if (!password || typeof password !== 'string' || !password) return undefined; + const token = Buffer.from(`opencode:${password}`).toString('base64'); + return { Authorization: `Basic ${token}` }; +} + +export function checkHealth(serverUrl: unknown, password: unknown = ''): Promise { + return new Promise((resolve, reject) => { + const headers = buildBackendAuthHeaders(password); + const url = String(serverUrl); + const onResponse = (res: import('http').IncomingMessage): void => { + if (res.statusCode === 200) resolve(true); + else reject(new Error(`Status ${res.statusCode}`)); + }; + // Preserve 3-arg call shape (url, options|undefined, callback): the Jest + // http mock expects (url, options, callback) and real http.get accepts + // undefined options. A 2-arg call would shift the mock's callback to + // undefined and break health checks in tests. + const options = (headers ? { headers } : undefined) as unknown as import('http').RequestOptions; + const req = http.get(`${url}/health`, options, onResponse); + req.on('error', (e: unknown) => reject(e)); + req.setTimeout(2000, () => { + req.destroy(); + reject(new Error('Timeout')); + }); + }); +} + +/** + * Cleanup temporary directories + */ +export function cleanupTempDirs(): void { + // Only cleanup jail directories on non-Windows platforms + // On Windows, we don't use isolated jail to avoid path issues + if (process.platform === 'win32') return; + + const jailRoot = path.join(os.tmpdir(), 'opencode-proxy-jail'); + try { + if (fs.existsSync(jailRoot)) { + fs.rmSync(jailRoot, { recursive: true, force: true }); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + console.error('[Cleanup] Failed to remove temp dirs:', msg); + } +} + +// Register cleanup on exit +process.on('exit', cleanupTempDirs); + +// Handle signals - Unix-like systems +if (process.platform !== 'win32') { + process.on('SIGINT', () => { + console.log('\n[Shutdown] Received SIGINT, cleaning up...'); + cleanupTempDirs(); + process.exit(0); + }); + process.on('SIGTERM', () => { + console.log('\n[Shutdown] Received SIGTERM, cleaning up...'); + cleanupTempDirs(); + process.exit(0); + }); +} +// Note: Windows signal handling is limited, cleanup is handled via process.on('exit') + +// Backend management state (per-instance) +export const backendState = new Map(); + +/** + * Backend Lifecycle Management + */ +export async function ensureBackend(config: unknown): Promise { + const cfg = config as ProxyConfig; + const { + OPENCODE_SERVER_URL, + OPENCODE_PATH, + USE_ISOLATED_HOME, + ZEN_API_KEY, + OPENCODE_SERVER_PASSWORD, + MANAGE_BACKEND, + PROMPT_MODE, + } = cfg; + const stateKey = String(OPENCODE_SERVER_URL); + + if (!backendState.has(stateKey)) { + backendState.set(stateKey, { + isStarting: false, + process: null, + jailRoot: null, + }); + } + + const state = backendState.get(stateKey) as BackendState; + + if (state.isStarting) { + // Wait for startup to complete + for (let i = 0; i < STARTING_WAIT_ITERATIONS; i++) { + await new Promise((r) => setTimeout(r, STARTING_WAIT_INTERVAL_MS)); + try { + await checkHealth(OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD); + return; + } catch { + // keep waiting + } + } + throw new Error('Backend startup timeout'); + } + + try { + await checkHealth(OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD); + } catch (err: unknown) { + if (!MANAGE_BACKEND) { + for (let i = 0; i < STARTUP_WAIT_ITERATIONS; i++) { + await new Promise((r) => setTimeout(r, STARTUP_WAIT_INTERVAL_MS)); + try { + await checkHealth(OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD); + return; + } catch { + // keep waiting + } + } + throw err; + } + + state.isStarting = true; + console.log(`[Proxy] OpenCode backend not found at ${OPENCODE_SERVER_URL}. Starting...`); + + // Kill existing process if any + if (state.process) { + try { + (state.process as ChildProcess).kill(); + } catch { + // ignore + } + } + + // Cleanup old temp dir + if (state.jailRoot && fs.existsSync(state.jailRoot)) { + try { + fs.rmSync(state.jailRoot, { recursive: true, force: true }); + } catch { + // ignore + } + } + + const isWindows = process.platform === 'win32'; + const useIsolatedHome = + typeof USE_ISOLATED_HOME === 'boolean' + ? USE_ISOLATED_HOME + : String(process.env['OPENCODE_USE_ISOLATED_HOME'] ?? '').toLowerCase() === 'true' || + process.env['OPENCODE_USE_ISOLATED_HOME'] === '1'; + + // On Windows, don't use isolated fake-home to avoid path issues + // On Unix-like systems, use jail for isolation + const salt = Math.random().toString(36).substring(7); + const jailRoot = path.join(os.tmpdir(), 'opencode-proxy-jail', salt); + state.jailRoot = jailRoot; + cfg.OPENCODE_HOME_BASE = jailRoot; + const workspace = path.join(jailRoot, 'empty-workspace'); + + let envVars: Record; + let cwd: string; + + if (isWindows) { + // Windows: use normal user home to avoid opencode storage path issues + fs.mkdirSync(workspace, { recursive: true }); + cwd = workspace; + envVars = { + ...process.env, + OPENCODE_PROJECT_DIR: workspace, + }; + console.log('[Proxy] Running on Windows, using standard user home directory'); + } else { + fs.mkdirSync(workspace, { recursive: true }); + cwd = workspace; + + if (useIsolatedHome) { + // Unix-like: use isolated fake-home + const fakeHome = path.join(jailRoot, 'fake-home'); + + // Create necessary opencode directories + const opencodeDir = path.join(fakeHome, '.local', 'share', 'opencode'); + const storageDir = path.join(opencodeDir, 'storage'); + const messageDir = path.join(storageDir, 'message'); + const sessionDir = path.join(storageDir, 'session'); + + [fakeHome, opencodeDir, storageDir, messageDir, sessionDir].forEach((d) => { + if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); + }); + + envVars = { + ...process.env, + HOME: fakeHome, + USERPROFILE: fakeHome, + OPENCODE_PROJECT_DIR: workspace, + }; + + if (PROMPT_MODE === 'plugin-inject') { + const configDir = path.join(fakeHome, '.config', 'opencode'); + const pluginDir = path.join(configDir, 'plugin', 'opencode2api-empty'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'index.js'), + `export const Opencode2apiEmptyPlugin = async () => ({})\nexport default Opencode2apiEmptyPlugin\n`, + 'utf8', + ); + fs.writeFileSync( + path.join(configDir, 'opencode.json'), + JSON.stringify( + { + plugin: [path.join(pluginDir, 'index.js')], + instructions: [], + theme: 'system', + }, + null, + 2, + ), + 'utf8', + ); + console.log('[Proxy] Using plugin-inject prompt mode'); + } + console.log('[Proxy] Using isolated home for OpenCode'); + } else { + envVars = { + ...process.env, + OPENCODE_PROJECT_DIR: workspace, + }; + console.log('[Proxy] Using real HOME for OpenCode (isolation disabled)'); + } + } + + const [, , portStr] = String(OPENCODE_SERVER_URL).split(':'); + const port = portStr ? String(portStr).split('/')[0] : '10001'; + const resolved = resolveOpencodePath(OPENCODE_PATH); + const opencodeBin = resolved.path || (OPENCODE_PATH as string) || OPENCODE_BASENAME; + if (resolved.path) { + console.log(`[Proxy] Using OpenCode binary: ${opencodeBin} (source: ${resolved.source})`); + } else { + console.warn(`[Proxy] Unable to resolve OpenCode binary for '${OPENCODE_PATH}'. Using as-is.`); + } + + // Cross-platform spawn options + const useShell = + process.platform === 'win32' || !resolved.path || opencodeBin.endsWith('.cmd') || opencodeBin.endsWith('.bat'); + const spawnOptions = { + stdio: 'inherit' as const, + cwd, + env: envVars, + shell: useShell, // Use shell only when needed (e.g., Windows .cmd or unresolved PATH) + }; + + const spawnArgs = ['serve', '--port', String(port), '--hostname', '127.0.0.1']; + if (ZEN_API_KEY) { + spawnArgs.push('--password', String(ZEN_API_KEY)); + } + state.process = spawn(opencodeBin, spawnArgs, spawnOptions); + + // Handle spawn errors + state.process.on('error', (spawnErr: unknown) => { + const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr); + console.error(`[Proxy] Failed to spawn OpenCode: ${msg}`); + const code: unknown = (spawnErr as Record)['code']; + if (code === 'ENOENT') { + console.error(`[Proxy] Command '${OPENCODE_PATH}' not found. Please ensure OpenCode is installed and in your PATH.`); + console.error(`[Proxy] You can specify the full path in config.json using 'OPENCODE_PATH'`); + } + }); + + // Wait for backend to be ready + let started = false; + for (let i = 0; i < STARTUP_WAIT_ITERATIONS; i++) { + await new Promise((r) => setTimeout(r, STARTUP_WAIT_INTERVAL_MS)); + try { + await checkHealth(OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD); + console.log('[Proxy] OpenCode backend ready.'); + started = true; + break; + } catch { + // keep waiting + } + } + + state.isStarting = false; + + if (!started) { + console.warn('[Proxy] Backend start timed out.'); + throw new Error('Backend start timeout'); + } + } +} diff --git a/src/config/proxy-config.ts b/src/config/proxy-config.ts new file mode 100644 index 0000000..e8807b3 --- /dev/null +++ b/src/config/proxy-config.ts @@ -0,0 +1,219 @@ +// P4 TS: proxy config defaults + pure bool/config builders (ported from P3 .js, behavior identical). +import type { DisableToolsOptions, ProxyConfig, ProxyConfigOptions } from '../types/config.js'; + +export const DEFAULT_REQUEST_TIMEOUT_MS = 300000; +export const DEFAULT_POLL_INTERVAL_MS = 500; +// Retry policy (ported from upstream opencode session/retry.ts; see src/retry/policy.ts). +// Total attempts = 1 + maxRetries; maxRetries defaults to 3, configurable via +// OPENCODE_PROXY_RETRY_MAX_RETRIES, hard-capped at 5 (upstream ceiling). +// Delays are exponential with jitter and honor retry-after headers. +// Reasoning models can take well over 10s before emitting their first token. +// A short window here makes the event stream give up and fall back to polling on +// every request, which loses true streaming. Configurable for slow backends. +export const DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS: number = + Number(process.env['OPENCODE2API_EVENT_FIRST_DELTA_TIMEOUT_MS']) || 30000; +export const DEFAULT_EVENT_IDLE_TIMEOUT_MS: number = + Number(process.env['OPENCODE2API_EVENT_IDLE_TIMEOUT_MS']) || 8000; + +/** + * 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 normalizeBool(value: unknown): boolean | undefined { + 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; +} + +/** + * 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?: unknown, fallback: boolean = false): boolean { + const o = (options ?? {}) as DisableToolsOptions; + const record = o as Record; + return ( + normalizeBool(record['DISABLE_TOOLS']) ?? + normalizeBool(record['disableTools']) ?? + normalizeBool(process.env['OPENCODE_DISABLE_TOOLS']) ?? + 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: unknown, timeoutMs: unknown, label: string = 'operation'): Promise { + const numericMs = Number(timeoutMs); + const ms = Number.isFinite(numericMs) ? numericMs : DEFAULT_REQUEST_TIMEOUT_MS; + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Request timeout after ${ms}ms (${label})`)), ms); + }); + const candidate = promise as { catch?: unknown } | null | undefined; + if (candidate && typeof candidate.catch === 'function') { + (candidate as Promise).catch(() => {}); + } + return Promise.race([promise as Promise, timeoutPromise]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + +function readStringOption(options: Record, keys: string[]): string | undefined { + for (const key of keys) { + const v: unknown = options[key]; + if (typeof v === 'string' && v) return v; + } + return undefined; +} + +/** + * Build the effective proxy config from caller options + env (extracted verbatim + * from startProxy preamble + config assembly; merge semantics unchanged). + */ +export function buildProxyConfig(options: unknown = {}): ProxyConfig { + // Also tolerate explicit null (the = {} default only covers undefined). + const raw: ProxyConfigOptions = (options ?? {}) as ProxyConfigOptions; + const opts = raw as Record; + const disableTools = resolveDisableTools(raw); + + const promptMode = + readStringOption(opts, ['PROMPT_MODE', 'promptMode']) || + process.env['OPENCODE_PROXY_PROMPT_MODE'] || + 'standard'; + const externalToolsMode = + readStringOption(opts, ['EXTERNAL_TOOLS_MODE', 'externalToolsMode']) || + process.env['OPENCODE_EXTERNAL_TOOLS_MODE'] || + 'proxy-bridge'; + const externalToolsConflictPolicy = + readStringOption(opts, ['EXTERNAL_TOOLS_CONFLICT_POLICY', 'externalToolsConflictPolicy']) || + process.env['OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY'] || + 'namespace'; + const cleanupIntervalMs = Number( + opts['CLEANUP_INTERVAL_MS'] ?? process.env['OPENCODE_PROXY_CLEANUP_INTERVAL_MS'] ?? 12 * 60 * 60 * 1000, + ); + const cleanupMaxAgeMs = Number( + opts['CLEANUP_MAX_AGE_MS'] ?? process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS'] ?? 24 * 60 * 60 * 1000, + ); + + if (externalToolsMode !== 'proxy-bridge') { + throw new Error(`Unsupported EXTERNAL_TOOLS_MODE: ${externalToolsMode}. Supported value: proxy-bridge`); + } + if (externalToolsConflictPolicy !== 'namespace') { + throw new Error( + `Unsupported EXTERNAL_TOOLS_CONFLICT_POLICY: ${externalToolsConflictPolicy}. Supported value: namespace`, + ); + } + const useIsolatedRaw: unknown = opts['USE_ISOLATED_HOME']; + const config: ProxyConfig = { + PORT: (opts['PORT'] as number) || 10000, + API_KEY: (opts['API_KEY'] as string) || '', + OPENCODE_SERVER_URL: (opts['OPENCODE_SERVER_URL'] as string) || 'http://127.0.0.1:10001', + OPENCODE_SERVER_PASSWORD: + (opts['OPENCODE_SERVER_PASSWORD'] as string) || process.env['OPENCODE_SERVER_PASSWORD'] || '', + OPENCODE_PATH: (opts['OPENCODE_PATH'] as string) || 'opencode', + BIND_HOST: + (opts['BIND_HOST'] as string) || + (opts['bindHost'] as string) || + process.env['BIND_HOST'] || + process.env['OPENCODE_PROXY_BIND_HOST'] || + '0.0.0.0', + USE_ISOLATED_HOME: + typeof useIsolatedRaw === 'boolean' + ? useIsolatedRaw + : String(useIsolatedRaw ?? '').toLowerCase() === 'true' || + useIsolatedRaw === '1' || + String(process.env['OPENCODE_USE_ISOLATED_HOME'] ?? '').toLowerCase() === 'true' || + process.env['OPENCODE_USE_ISOLATED_HOME'] === '1', + REQUEST_TIMEOUT_MS: Number( + opts['REQUEST_TIMEOUT_MS'] ?? process.env['OPENCODE_PROXY_REQUEST_TIMEOUT_MS'] ?? DEFAULT_REQUEST_TIMEOUT_MS, + ), + MANAGE_BACKEND: + normalizeBool(opts['MANAGE_BACKEND']) ?? normalizeBool(process.env['OPENCODE_PROXY_MANAGE_BACKEND']) ?? true, + DISABLE_TOOLS: disableTools, + EXTERNAL_TOOLS_MODE: externalToolsMode, + EXTERNAL_TOOLS_CONFLICT_POLICY: externalToolsConflictPolicy, + INTERNAL_WEB_FETCH_ENABLED: + normalizeBool(opts['INTERNAL_WEB_FETCH_ENABLED']) ?? + normalizeBool(process.env['OPENCODE_INTERNAL_WEB_FETCH_ENABLED']) ?? + false, + INTERNAL_ALLOWED_TOOLS: Array.isArray(opts['INTERNAL_ALLOWED_TOOLS']) + ? (opts['INTERNAL_ALLOWED_TOOLS'] as string[]) + : typeof process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS'] === 'string' + ? String(process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS']) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + : [], + INTERNAL_TOOL_METRICS_ENABLED: + normalizeBool(opts['INTERNAL_TOOL_METRICS_ENABLED']) ?? + normalizeBool(process.env['OPENCODE_INTERNAL_TOOL_METRICS_ENABLED']) ?? + true, + INTERNAL_TOOL_DISCOVERY_FIXTURE: Array.isArray(opts['INTERNAL_TOOL_DISCOVERY_FIXTURE']) + ? (opts['INTERNAL_TOOL_DISCOVERY_FIXTURE'] as string[]) + : typeof process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE'] === 'string' + ? String(process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE']) + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + : [], + HEALTH_DETAILS_ENABLED: + normalizeBool(opts['HEALTH_DETAILS_ENABLED']) ?? + normalizeBool(process.env['OPENCODE_HEALTH_DETAILS_ENABLED']) ?? + true, + HEALTH_DETAILS_REQUIRE_AUTH: + normalizeBool(opts['HEALTH_DETAILS_REQUIRE_AUTH']) ?? + normalizeBool(process.env['OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH']) ?? + true, + METRICS_ENABLED: + normalizeBool(opts['METRICS_ENABLED']) ?? normalizeBool(process.env['OPENCODE_METRICS_ENABLED']) ?? false, + METRICS_REQUIRE_AUTH: + normalizeBool(opts['METRICS_REQUIRE_AUTH']) ?? + normalizeBool(process.env['OPENCODE_METRICS_REQUIRE_AUTH']) ?? + true, + DEBUG: + String(opts['DEBUG'] ?? '').toLowerCase() === 'true' || + opts['DEBUG'] === '1' || + String(process.env['OPENCODE_PROXY_DEBUG'] ?? '').toLowerCase() === 'true' || + process.env['OPENCODE_PROXY_DEBUG'] === '1', + ZEN_API_KEY: (opts['ZEN_API_KEY'] as string) || process.env['OPENCODE_ZEN_API_KEY'] || '', + PROMPT_MODE: promptMode, + OMIT_SYSTEM_PROMPT: + normalizeBool(opts['OMIT_SYSTEM_PROMPT']) ?? + normalizeBool(process.env['OPENCODE_PROXY_OMIT_SYSTEM_PROMPT']) ?? + promptMode === 'plugin-inject', + AUTO_CLEANUP_CONVERSATIONS: + normalizeBool(opts['AUTO_CLEANUP_CONVERSATIONS']) ?? + normalizeBool(process.env['OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS']) ?? + false, + CLEANUP_INTERVAL_MS: + Number.isFinite(cleanupIntervalMs) && cleanupIntervalMs > 0 ? cleanupIntervalMs : 12 * 60 * 60 * 1000, + CLEANUP_MAX_AGE_MS: + Number.isFinite(cleanupMaxAgeMs) && cleanupMaxAgeMs > 0 ? cleanupMaxAgeMs : 24 * 60 * 60 * 1000, + OPENCODE_HOME_BASE: (opts['OPENCODE_HOME_BASE'] as string | null) || null, + }; + return config; +} diff --git a/src/converters/anthropic.ts b/src/converters/anthropic.ts new file mode 100644 index 0000000..0beaa66 --- /dev/null +++ b/src/converters/anthropic.ts @@ -0,0 +1,360 @@ +/** + * Anthropic Messages (/v1/messages) converters. + * + * Pure functions only: no express, no SDK imports. + * Borrowed patterns from CLIProxyAPI (openai/claude request/response) and + * LiteLLM (LiteLLMAnthropicMessagesAdapter + sanitize trio), adapted to this + * repo's external-bridge (text markup) model. + */ + +export interface ChatToolCallFunction { + name: string; + arguments: string; +} + +export interface ChatToolCall { + id: string; + type: 'function'; + function: ChatToolCallFunction; +} + +export interface ChatContentPart { + type: string; + text?: string; + image_url?: { url: string }; +} + +export type ChatMessageContent = string | null | ChatContentPart[]; + +export interface ChatMessage { + role: 'user' | 'assistant' | 'tool'; + content: ChatMessageContent; + tool_calls?: ChatToolCall[]; + tool_call_id?: string; + name?: string; +} + +export interface ChatTool { + type: 'function'; + function: { + name: string; + description: string; + parameters: unknown; + }; +} + +export type ChatToolChoice = string | { type: 'function'; function: { name: string } }; + +export interface AnthropicTextBlock { + type: 'text'; + text?: unknown; +} + +export interface AnthropicImageBlock { + type: 'image'; + source?: { + type?: unknown; + data?: unknown; + media_type?: unknown; + url?: unknown; + } | null; +} + +export interface AnthropicToolUseBlock { + type: 'tool_use'; + id?: unknown; + name?: unknown; + input?: unknown; +} + +export interface AnthropicToolResultBlock { + type: 'tool_result'; + tool_use_id?: unknown; + content?: unknown; + is_error?: unknown; +} + +export interface AnthropicThinkingBlock { + type: 'thinking'; + thinking?: unknown; + signature?: unknown; +} + +export interface AnthropicUnknownBlock { + type: string; +} + +export type AnthropicBlock = + | AnthropicTextBlock + | AnthropicImageBlock + | AnthropicToolUseBlock + | AnthropicToolResultBlock + | AnthropicThinkingBlock + | AnthropicUnknownBlock; + +export interface AnthropicMessagesRequest { + model?: unknown; + max_tokens?: unknown; + messages?: unknown; + system?: unknown; + tools?: unknown; + tool_choice?: unknown; + thinking?: unknown; + stream?: unknown; + [key: string]: unknown; +} + +export interface AnthropicErrorShape { + statusCode: number; + body: { type: 'error'; error: { type: string; message: string } }; +} + +export interface AnthropicMessage { + id: string; + type: 'message'; + role: 'assistant'; + model: string; + content: AnthropicBlock[]; + stop_reason: unknown; + stop_sequence: null; + usage: { input_tokens: unknown; output_tokens: unknown }; +} + +export interface BuildAnthropicMessageArgs { + messageId: string; + model: string; + text?: unknown; + reasoning?: unknown; + toolCalls?: Array<{ + id?: unknown; + name?: unknown; + function?: { name?: unknown; arguments?: unknown } | null; + }> | null; + stopReason?: unknown; + inputTokens?: unknown; + outputTokens?: unknown; +} + +export function sanitizeClaudeToolId(id: unknown): string { + const s = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '_'); + if (s) return s; + return generateClaudeToolCallId(); +} + +export function generateClaudeToolCallId(): string { + try { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return `toolu_${globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; + } + } catch {} + return `toolu_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`; +} + +export function anthropicError(type: string, message: string, statusCode = 400): AnthropicErrorShape { + return { statusCode, body: { type: 'error', error: { type, message } } }; +} + +export function validateMessagesRequest(body: unknown = {}): AnthropicErrorShape | null { + if (!body || typeof body !== 'object') return anthropicError('invalid_request_error', 'request body must be an object'); + const req = body as AnthropicMessagesRequest; + if (!req.model || typeof req.model !== 'string') return anthropicError('invalid_request_error', 'model is required'); + if (req.max_tokens === undefined || req.max_tokens === null) return anthropicError('invalid_request_error', 'max_tokens is required'); + if (typeof req.max_tokens !== 'number' || req.max_tokens <= 0) return anthropicError('invalid_request_error', 'max_tokens must be a positive number'); + if (!Array.isArray(req.messages) || req.messages.length === 0) return anthropicError('invalid_request_error', 'messages array is required'); + const first = req.messages[0] as { role?: unknown } | null | undefined; + if (first?.role !== 'user') return anthropicError('invalid_request_error', 'first message must use role "user"'); + return null; +} + +function textOfBlock(block: unknown): string { + if (!block) return ''; + if (typeof block === 'string') return block; + if (typeof block === 'object') { + const b = block as { type?: unknown; text?: unknown }; + if (b.type === 'text') return (b.text || '') as string; + } + return ''; +} + +export function extractSystemText(system: unknown): string { + if (!system) return ''; + if (typeof system === 'string') return system; + if (Array.isArray(system)) return system.map(textOfBlock).filter(Boolean).join('\n\n'); + return ''; +} + +function normalizeToolArguments(args: unknown): string { + if (args === undefined || args === null || args === '') return '{}'; + if (typeof args === 'string') return args; + try { + return JSON.stringify(args); + } catch { + return '{}'; + } +} + +/** + * Convert Anthropic messages[] to OpenAI-chat-like messages[] so the existing + * prompt builder (ROLE: text / ASSISTANT / TOOL_RESULT) can be reused. + * Preserves tool_use.id (toolu_xxx) verbatim for round-trip. + */ +export function anthropicMessagesToChatMessages(anthropicMessages: unknown = []): ChatMessage[] { + const chatMessages: ChatMessage[] = []; + const list = anthropicMessages as Array<{ role?: unknown; content?: unknown } | null | undefined>; + for (const m of list) { + const role = m?.role === 'assistant' ? 'assistant' : 'user'; + const content = m?.content; + if (typeof content === 'string') { + chatMessages.push({ role, content }); + continue; + } + if (!Array.isArray(content)) continue; + const textParts: string[] = []; + const toolCalls: ChatToolCall[] = []; + const ordered: ChatMessage[] = []; + const flushText = (): void => { + if (textParts.length) { + ordered.push({ role, content: textParts.join('\n\n') }); + textParts.length = 0; + } + }; + const blocks = content as AnthropicBlock[]; + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + if (block.type === 'text') { + const textBlock = block as AnthropicTextBlock; + if (textBlock.text) textParts.push(textBlock.text as string); + } else if (block.type === 'image') { + const imageBlock = block as AnthropicImageBlock; + const src = (imageBlock.source || {}) as { type?: unknown; data?: unknown; media_type?: unknown; url?: unknown }; + if (src.type === 'base64' && src.data) { + const mime = (src.media_type || 'image/png') as string; + // Flush pending text first so [text, image, text] keeps its order. + // No marker text: the image_url part alone carries the image downstream. + flushText(); + ordered.push({ + role, + content: [{ type: 'image_url', image_url: { url: `data:${mime};base64,${src.data}` } }] + }); + } else if (src.type === 'url' && src.url) { + flushText(); + ordered.push({ role, content: [{ type: 'image_url', image_url: { url: src.url as string } }] }); + } + } else if (block.type === 'tool_use') { + const toolUseBlock = block as AnthropicToolUseBlock; + toolCalls.push({ + id: (toolUseBlock.id || generateClaudeToolCallId()) as string, + type: 'function', + function: { name: toolUseBlock.name as string, arguments: normalizeToolArguments(toolUseBlock.input) } + }); + } else if (block.type === 'tool_result') { + const resultBlock = block as AnthropicToolResultBlock; + const inner = Array.isArray(resultBlock.content) + ? resultBlock.content.map(textOfBlock).filter(Boolean).join('\n') + : (typeof resultBlock.content === 'string' ? resultBlock.content : JSON.stringify(resultBlock.content ?? '')); + const prefix = resultBlock.is_error ? 'ERROR: ' : ''; + flushText(); + ordered.push({ + role: 'tool', + tool_call_id: resultBlock.tool_use_id as string, + name: 'unknown', + content: `${prefix}${inner}` + }); + } else if (block.type === 'thinking') { + // Thinking blocks from client history are not re-fed as reasoning; + // keep text if present to preserve context. + const thinkingBlock = block as AnthropicThinkingBlock; + if (thinkingBlock.thinking) textParts.push(thinkingBlock.thinking as string); + } + } + if (toolCalls.length) { + chatMessages.push(...ordered); + chatMessages.push({ role: 'assistant', tool_calls: toolCalls, content: textParts.join('\n\n') || null }); + } else { + flushText(); + chatMessages.push(...ordered); + } + } + return chatMessages.filter((m) => m && (m.content || m.tool_calls)); +} + +export function anthropicToolsToChatTools(tools: unknown): ChatTool[] { + if (!Array.isArray(tools)) return []; + return (tools as Array<{ name?: unknown; description?: unknown; input_schema?: unknown } | null | undefined>) + .filter((t) => t && typeof t.name === 'string') + .map((t) => ({ + type: 'function' as const, + function: { + name: (t as { name?: unknown }).name as string, + description: ((t as { description?: unknown }).description || '') as string, + parameters: ((t as { input_schema?: unknown }).input_schema || { type: 'object', properties: {} }) as unknown + } + })); +} + +export function anthropicToolChoiceToChat(toolChoice: unknown): ChatToolChoice | undefined { + if (!toolChoice) return undefined; + if (typeof toolChoice === 'string') return toolChoice; + const choice = toolChoice as { type?: unknown; name?: unknown }; + const t = String(choice.type || '').toLowerCase(); + if (t === 'auto') return 'auto'; + if (t === 'none') return 'none'; + if (t === 'any') return 'required'; + if (t === 'tool' && choice.name) return { type: 'function', function: { name: choice.name as string } }; + if (t === 'tool') return 'required'; + return undefined; +} + +export function anthropicThinkingToReasoningEffort(thinking: unknown): string | null { + if (!thinking || typeof thinking !== 'object') return null; + const th = thinking as { type?: unknown; budget_tokens?: unknown }; + if (th.type === 'disabled') return 'none'; + if (th.type === 'enabled') { + const budget = typeof th.budget_tokens === 'number' ? th.budget_tokens : 0; + if (budget >= 24000) return 'high'; + if (budget >= 8000) return 'medium'; + return 'low'; + } + return null; +} + +export function mapFinishToStopReason(finish: unknown, hasToolCalls: unknown): string { + if (hasToolCalls) return 'tool_use'; + if (finish === 'tool') return 'tool_use'; + if (finish === 'length' || finish === 'max_tokens') return 'max_tokens'; + if (finish === 'stop_sequence') return 'stop_sequence'; + return 'end_turn'; +} + +export function buildAnthropicMessage({ messageId, model, text, reasoning, toolCalls, stopReason, inputTokens, outputTokens }: BuildAnthropicMessageArgs): AnthropicMessage { + const content: AnthropicBlock[] = []; + if (reasoning) content.push({ type: 'thinking', thinking: reasoning, signature: '' }); + if (text) content.push({ type: 'text', text }); + for (const tc of toolCalls || []) { + let input: unknown = {}; + try { + input = JSON.parse((tc.function?.arguments || '{}') as string) as unknown; + } catch { + input = {}; + } + content.push({ type: 'tool_use', id: tc.id, name: tc.function?.name || tc.name, input }); + } + return { + id: messageId, + type: 'message', + role: 'assistant', + model, + content, + stop_reason: stopReason, + stop_sequence: null, + usage: { input_tokens: inputTokens, output_tokens: outputTokens } + }; +} + +export function sseEvent(event: string, payload: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; +} + +export function estimateTokens(text: unknown): number { + return Math.ceil(String(text || '').length / 4); +} diff --git a/src/errors/upstream.ts b/src/errors/upstream.ts new file mode 100644 index 0000000..a8d1fca --- /dev/null +++ b/src/errors/upstream.ts @@ -0,0 +1,303 @@ +// P4 TS: upstream error classification/normalization (ported from P3 .js, behavior identical). +import type { NormalizedUpstreamError, RawBackendErrorLike, TransformedUpstreamError } from '../types/errors.js'; + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function readStringField(record: Record, key: string): string | null { + const v: unknown = record[key]; + return typeof v === 'string' ? v : null; +} + +function collectMessageParts(error: unknown): string { + const record = asRecord(error); + const data = asRecord(record['data']); + const parts: string[] = []; + const candidates: unknown[] = [record['message'], data['message'], record['name'], record['code'], record['type']]; + for (const part of candidates) { + if (typeof part === 'string') parts.push(part); + } + return parts.join(' '); +} + +/** + * Detect transient upstream provider failures that succeed on retry. + * + * The upstream (OpenCode Zen) occasionally mislabels throttling as billing + * errors: a worker hits its request limit and returns + * `401: {"message":"Insufficient balance...","type":"CreditsError"}` even + * though the account is fine — the very next attempt succeeds. These errors + * are surfaced to clients as bogus "insufficient balance" failures (issue #5). + * Match them (plus generic rate-limit/5xx signatures) so the proxy can retry + * with backoff before giving up. + */ +export function isTransientUpstreamError(error: unknown): boolean { + if (!error) return false; + const message = collectMessageParts(error); + if (!message) return false; + + // Upstream opencode never retries context overflow (retry.ts); neither do we. + // A 500 carrying a context message must surface immediately, not burn retries. + if (/context.?length|context.?overflow|context_length_exceeded/i.test(message)) return false; + + const record = asRecord(error); + const data = asRecord(record['data']); + // Provider-marked retryable (SDK ApiError.data.isRetryable) is transient, + // whatever the status code. Explicit false defers to the checks below. + if (record['isRetryable'] === true || data['isRetryable'] === true) return true; + + // Genuine auth failures must surface immediately: a misconfigured key + // should not burn the full backoff. The issue-#5 mislabeled billing case + // ("Insufficient balance"/CreditsError) never carries these strings, so + // it still retries. Placed after isRetryable so a provider-explicit + // retryable flag keeps winning (upstream-faithful). + if (/invalid[_\s]?api[_\s]?key|unauthorized|authentication (failed|error)|api key (expired|invalid|incorrect)/i.test(message)) return false; + + const transientSignatures: RegExp[] = [ + /insufficient balance/i, + /credits?error/i, + /rate.?limit/i, + /too many requests/i, + /worker request limit/i, + /overloaded/i, + /temporarily unavailable/i, + /internal server error/i, + /bad gateway/i, + /service unavailable/i, + /stream error/i, + // Transport-layer flakes (upstream retry.ts message patterns): the main + // reason opencode "retries many times". + /terminated/i, + /fetch failed/i, + /network error/i, + /upstream connect/i, + /econnreset/i, + /etimedout/i, + /eai_again/i, + /getaddrinfo/i, + /socket hang up/i, + /zlib error/i, + /header.?timeout/i, + /try again (?:later|in\b)/i, + /try your request again/i, + /retry your request/i, + /resource exhausted/i, + /(?:currently|temporarily) at capacity/i, + ]; + if (transientSignatures.some((re) => re.test(message))) return true; + + // Upstream errors arrive as ": {json}" strings; SDK errors may also + // carry a numeric status on the object itself. Only accept a leading + // ":" prefix — a loose \b(\d{3}): match misfires on embedded + // numbers like "retry after 300: ...". + const recordStatus: unknown = record['statusCode'] ?? data['status']; + let status: number | null = typeof recordStatus === 'number' ? recordStatus : null; + if (typeof status !== 'number') { + const statusCandidates: string[] = []; + const msg: unknown = record['message']; + const dataMsg: unknown = data['message']; + if (typeof msg === 'string') statusCandidates.push(msg); + if (typeof dataMsg === 'string') statusCandidates.push(dataMsg); + for (const candidate of statusCandidates) { + const prefixMatch = candidate.match(/^\s*(\d{3})\s*:/); + if (prefixMatch?.[1]) { + status = Number(prefixMatch[1]); + break; + } + } + } + if (typeof status === 'number') { + if (status === 401 || status === 402 || status === 429) return true; + if (status >= 500) return true; + } + return false; +} + +/** + * Normalize a backend error into a real Error instance. + * + * The OpenCode backend surfaces failures as plain objects + * ({name, data:{message, statusCode?}}, see SDK types.gen AssistantMessage + * error union), never as Error instances. Throwing one raw makes + * transformUpstreamError fall back to 500/"Internal server error"/code + * "Object" and drops the real message. Normalizing once at the throw + * site (or poll exit) keeps every downstream reader working. + */ +export function normalizeBackendError(raw: unknown): NormalizedUpstreamError { + if (raw instanceof Error) return raw as NormalizedUpstreamError; + const record = asRecord(raw); + const data = asRecord(record['data']); + let message: unknown = data['message'] ?? record['message'] ?? record['name'] ?? null; + if (typeof message !== 'string' || !message) { + try { + const jsoned: unknown = JSON.stringify(raw); + message = typeof jsoned === 'string' ? jsoned : String(record['name'] ?? 'Upstream provider error'); + if (!message) message = String(record['name'] ?? 'Upstream provider error'); + } catch { + message = String(record['name'] ?? 'Upstream provider error'); + } + } + const messageStr = String(message); + let statusCode: unknown = record['statusCode'] ?? data['statusCode'] ?? data['status'] ?? null; + if (typeof statusCode !== 'number') { + const m = messageStr.match(/^\s*(\d{3})\s*:/); + if (m?.[1]) statusCode = Number(m[1]); + } + if (typeof statusCode !== 'number') { + const s = messageStr.toLowerCase(); + if (/insufficient balance|credits?error|insufficient credits|billing|quota exceeded|credit limit/.test(s)) statusCode = 402; + else if (/rate.?limit|too many requests|worker request limit/.test(s)) statusCode = 429; + else if (/invalid api key|unauthorized|authentication/.test(s)) statusCode = 401; + } + const err = new Error(messageStr) as NormalizedUpstreamError; + if (typeof statusCode === 'number') err.statusCode = statusCode; + const codeRaw: unknown = record['code'] ?? record['type'] ?? record['name'] ?? 'upstream_error'; + const typeRaw: unknown = record['type'] ?? record['code'] ?? record['name'] ?? 'upstream_error'; + err.code = typeof codeRaw === 'string' ? codeRaw : String(codeRaw); + err.type = typeof typeRaw === 'string' ? typeRaw : String(typeRaw); + if (record['data'] !== undefined) err.data = record['data']; + // Preserve provider retry signals for the retry policy (see src/retry/policy.ts): + // responseHeaders (retry-after-ms / retry-after) and isRetryable live on + // ApiError.data in the SDK type union. + const responseHeaders: unknown = record['responseHeaders'] ?? data['responseHeaders'] ?? null; + if (responseHeaders && typeof responseHeaders === 'object') { + err.responseHeaders = responseHeaders as Record; + } + if (typeof record['isRetryable'] === 'boolean') err.isRetryable = record['isRetryable'] as boolean; + else if (typeof data['isRetryable'] === 'boolean') err.isRetryable = data['isRetryable'] as boolean; + err.cause = raw; + return err; +} + +/** + * Transform upstream provider errors to OpenAI-compatible format + */ +export function transformUpstreamError(error: unknown): TransformedUpstreamError { + // Defense in depth: callers may hand us a raw backend plain object + // ({name, data:{message}}) instead of an Error. Normalize first so the + // mapping below sees message/statusCode/code instead of falling back + // to 500/"Internal server error"/code "Object". + const normalized = normalizeBackendError(error); + const typed = normalized as NormalizedUpstreamError & Record; + // Default fallback + let statusCode = 500; + let message: string = normalized.message || 'Internal server error'; + let type = 'internal_error'; + let code: string = (typeof typed['code'] === 'string' ? (typed['code'] as string) : 'upstream_error') || 'upstream_error'; + + // Handle timeout errors + if (normalized.message && normalized.message.includes('Request timeout')) { + statusCode = 504; + type = 'timeout'; + code = 'timeout'; + message = 'Request timeout'; + } else if (normalized.message && normalized.message.includes('ENOENT')) { + // Handle file access errors (Windows compatibility) + statusCode = 500; + type = 'internal_error'; + code = 'file_access_error'; + message = 'OpenCode backend file access error. This may be a Windows compatibility issue. Please try restarting the service.'; + } else if (typeof typed['statusCode'] === 'number') { + // Handle upstream provider errors (from OpenCode SDK) + statusCode = typed['statusCode'] as number; + + // Map upstream error types to OpenAI-compatible types + const upstreamType = String(typed['code'] ?? typed['type'] ?? ''); + const upstreamMessage = normalized.message || ''; + + // Billing/credit errors - map to 402 Payment Required + if ( + upstreamType === 'CreditsError' || + upstreamType === 'InsufficientBalanceError' || + upstreamMessage.toLowerCase().includes('insufficient balance') || + upstreamMessage.toLowerCase().includes('insufficient credits') || + upstreamMessage.toLowerCase().includes('billing') || + upstreamMessage.toLowerCase().includes('quota exceeded') || + upstreamMessage.toLowerCase().includes('credit limit') + ) { + statusCode = 402; + type = 'insufficient_quota'; + code = 'insufficient_quota'; + message = upstreamMessage || 'Insufficient balance or quota exceeded'; + } else if ( + upstreamType === 'RateLimitError' || + upstreamType === 'TooManyRequestsError' || + statusCode === 429 || + upstreamMessage.toLowerCase().includes('rate limit') || + upstreamMessage.toLowerCase().includes('too many requests') + ) { + // Rate limit errors - map to 429 + statusCode = 429; + type = 'rate_limit_exceeded'; + code = 'rate_limit_exceeded'; + message = upstreamMessage || 'Rate limit exceeded'; + } else if ( + upstreamType === 'AuthenticationError' || + upstreamType === 'InvalidAPIKeyError' || + statusCode === 401 || + upstreamMessage.toLowerCase().includes('invalid api key') || + upstreamMessage.toLowerCase().includes('unauthorized') || + upstreamMessage.toLowerCase().includes('authentication') + ) { + // Authentication errors - keep as 401 + statusCode = 401; + type = 'invalid_api_key'; + code = 'invalid_api_key'; + message = upstreamMessage || 'Invalid API key'; + } else if ( + upstreamType === 'PermissionError' || + statusCode === 403 || + upstreamMessage.toLowerCase().includes('permission denied') || + upstreamMessage.toLowerCase().includes('access denied') + ) { + // Permission errors - map to 403 + statusCode = 403; + type = 'permission_denied'; + code = 'permission_denied'; + message = upstreamMessage || 'Permission denied'; + } else if ( + upstreamType === 'NotFoundError' || + statusCode === 404 || + upstreamMessage.toLowerCase().includes('model not found') || + upstreamMessage.toLowerCase().includes('does not exist') + ) { + // Model not found - map to 404 + statusCode = 404; + type = 'model_not_found'; + code = 'model_not_found'; + message = upstreamMessage || 'Model not found'; + } else if (statusCode === 400 || upstreamType === 'BadRequestError') { + // Bad request - map to 400 + statusCode = 400; + type = 'invalid_request_error'; + code = 'invalid_request_error'; + message = upstreamMessage || 'Invalid request'; + } else if (statusCode >= 500) { + // Server errors from upstream - map to 502/503 + statusCode = 502; + type = 'server_error'; + code = 'server_error'; + message = upstreamMessage || 'Upstream provider error'; + } else { + // Default: pass through with mapped type + type = upstreamType.toLowerCase().replace(/error$/, '_error') || 'upstream_error'; + code = upstreamType; + message = upstreamMessage; + } + } + + const availableModels: unknown = (normalized as RawBackendErrorLike & { availableModels?: unknown }).availableModels; + return { + statusCode, + error: { + message, + type, + ...(code ? { code } : {}), + ...(availableModels !== undefined ? { available_models: availableModels } : {}), + }, + }; +} diff --git a/src/proxy.js b/src/proxy.js deleted file mode 100644 index c6e1719..0000000 --- a/src/proxy.js +++ /dev/null @@ -1,3292 +0,0 @@ -import express from 'express'; -import cors from 'cors'; -import bodyParser from 'body-parser'; -import { spawn } from 'child_process'; -import crypto from 'crypto'; -import { createOpencodeClient } from '@opencode-ai/sdk'; -import http from 'http'; -import https from 'https'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { buildExternalToolRegistry, findExternalToolByName } from './tool-runtime/registry.js'; -import { buildToolExposure } from './tool-runtime/router.js'; -import { evaluateToolPolicy } from './tool-runtime/policy.js'; -import { validateToolCalls } from './tool-runtime/validator.js'; -import { - stripFunctionCallMarkup, - parseExternalToolCallsFromText, - createToolCallFilter, - createExternalToolCallStreamParser -} from './tool-runtime/parser.js'; - -/** - * Detect transient upstream provider failures that succeed on retry. - * - * The upstream (OpenCode Zen) occasionally mislabels throttling as billing - * errors: a worker hits its request limit and returns - * `401: {"message":"Insufficient balance...","type":"CreditsError"}` even - * though the account is fine — the very next attempt succeeds. These errors - * are surfaced to clients as bogus "insufficient balance" failures (issue #5). - * Match them (plus generic rate-limit/5xx signatures) so the proxy can retry - * with backoff before giving up. - * @param {Error|object|null} error - Assistant message error or thrown error - * @returns {boolean} true when the error looks transient - */ -function isTransientUpstreamError(error) { - if (!error) return false; - const message = [error.message, error.data?.message] - .filter((part) => typeof part === 'string') - .join(' '); - if (!message) return false; - - const transientSignatures = [ - /insufficient balance/i, - /credits?error/i, - /rate.?limit/i, - /too many requests/i, - /worker request limit/i, - /overloaded/i, - /temporarily unavailable/i, - /internal server error/i, - /bad gateway/i, - /service unavailable/i, - /stream error/i - ]; - if (transientSignatures.some((re) => re.test(message))) return true; - - // Upstream errors arrive as ": {json}" strings; SDK errors may also - // carry a numeric status on the object itself. - const statusMatch = message.match(/\b(\d{3}):/); - const status = statusMatch - ? Number(statusMatch[1]) - : (error.statusCode || error.data?.status || null); - if (typeof status === 'number') { - if (status === 401 || status === 402 || status === 429) return true; - if (status >= 500) return true; - } - return false; -} - -/** - * Transform upstream provider errors to OpenAI-compatible format - * @param {Error} error - The error from the upstream provider - * @returns {{statusCode: number, error: {message: string, type: string, code?: string}}} OpenAI-compatible error response - */ -function transformUpstreamError(error) { - // Default fallback - let statusCode = 500; - let message = error.message || 'Internal server error'; - let type = 'internal_error'; - let code = error.code || error.constructor.name; - - // Handle timeout errors - if (error.message && error.message.includes('Request timeout')) { - statusCode = 504; - type = 'timeout'; - code = 'timeout'; - message = 'Request timeout'; - } - // Handle file access errors (Windows compatibility) - else if (error.message && error.message.includes('ENOENT')) { - statusCode = 500; - type = 'internal_error'; - code = 'file_access_error'; - message = 'OpenCode backend file access error. This may be a Windows compatibility issue. Please try restarting the service.'; - } - // Handle upstream provider errors (from OpenCode SDK) - else if (error.statusCode) { - statusCode = error.statusCode; - - // Map upstream error types to OpenAI-compatible types - const upstreamType = error.code || error.type || ''; - const upstreamMessage = error.message || ''; - - // Billing/credit errors - map to 402 Payment Required - if (upstreamType === 'CreditsError' || - upstreamType === 'InsufficientBalanceError' || - upstreamMessage.toLowerCase().includes('insufficient balance') || - upstreamMessage.toLowerCase().includes('insufficient credits') || - upstreamMessage.toLowerCase().includes('billing') || - upstreamMessage.toLowerCase().includes('quota exceeded') || - upstreamMessage.toLowerCase().includes('credit limit')) { - statusCode = 402; - type = 'insufficient_quota'; - code = 'insufficient_quota'; - message = upstreamMessage || 'Insufficient balance or quota exceeded'; - } - // Rate limit errors - map to 429 - else if (upstreamType === 'RateLimitError' || - upstreamType === 'TooManyRequestsError' || - statusCode === 429 || - upstreamMessage.toLowerCase().includes('rate limit') || - upstreamMessage.toLowerCase().includes('too many requests')) { - statusCode = 429; - type = 'rate_limit_exceeded'; - code = 'rate_limit_exceeded'; - message = upstreamMessage || 'Rate limit exceeded'; - } - // Authentication errors - keep as 401 - else if (upstreamType === 'AuthenticationError' || - upstreamType === 'InvalidAPIKeyError' || - statusCode === 401 || - upstreamMessage.toLowerCase().includes('invalid api key') || - upstreamMessage.toLowerCase().includes('unauthorized') || - upstreamMessage.toLowerCase().includes('authentication')) { - statusCode = 401; - type = 'invalid_api_key'; - code = 'invalid_api_key'; - message = upstreamMessage || 'Invalid API key'; - } - // Permission errors - map to 403 - else if (upstreamType === 'PermissionError' || - statusCode === 403 || - upstreamMessage.toLowerCase().includes('permission denied') || - upstreamMessage.toLowerCase().includes('access denied')) { - statusCode = 403; - type = 'permission_denied'; - code = 'permission_denied'; - message = upstreamMessage || 'Permission denied'; - } - // Model not found - map to 404 - else if (upstreamType === 'NotFoundError' || - statusCode === 404 || - upstreamMessage.toLowerCase().includes('model not found') || - upstreamMessage.toLowerCase().includes('does not exist')) { - statusCode = 404; - type = 'model_not_found'; - code = 'model_not_found'; - message = upstreamMessage || 'Model not found'; - } - // Bad request - map to 400 - else if (statusCode === 400 || upstreamType === 'BadRequestError') { - statusCode = 400; - type = 'invalid_request_error'; - code = 'invalid_request_error'; - message = upstreamMessage || 'Invalid request'; - } - // Server errors from upstream - map to 502/503 - else if (statusCode >= 500) { - statusCode = 502; - type = 'server_error'; - code = 'server_error'; - message = upstreamMessage || 'Upstream provider error'; - } - // Default: pass through with mapped type - else { - type = upstreamType.toLowerCase().replace(/error$/, '_error') || 'upstream_error'; - code = upstreamType; - message = upstreamMessage; - } - } - - return { - statusCode, - error: { - message, - type, - ...(code && { code }), - ...(error.availableModels && { available_models: error.availableModels }) - } - }; -} - -// --- Mutex Logic with Timeout --- -async function getImageDataUri(url) { - if (url.startsWith('data:')) { - return url; - } - - if (!url.startsWith('http://') && !url.startsWith('https://')) { - throw new Error(`Invalid URL scheme: ${url}`); - } - - return new Promise((resolve, reject) => { - const protocol = url.startsWith('https') ? https : http; - - const req = protocol.get(url, { timeout: 10000 }, (res) => { - if (res.statusCode !== 200) { - return reject(new Error(`Failed to fetch image: HTTP ${res.statusCode}`)); - } - - const contentType = res.headers['content-type'] || 'image/jpeg'; - const chunks = []; - - res.on('data', (chunk) => chunks.push(chunk)); - res.on('end', () => { - try { - const buffer = Buffer.concat(chunks); - const base64 = buffer.toString('base64'); - resolve(`data:${contentType};base64,${base64}`); - } catch (e) { - reject(new Error(`Failed to encode image: ${e.message}`)); - } - }); - }); - - req.on('error', (e) => reject(e)); - req.on('timeout', () => { - req.destroy(); - reject(new Error('Image fetch timeout')); - }); - }); -} - -// --- Mutex Logic with Timeout --- -const queue = []; -let isProcessing = false; - -const STARTUP_WAIT_ITERATIONS = 60; -const STARTUP_WAIT_INTERVAL_MS = 2000; -const STARTING_WAIT_ITERATIONS = 120; -const STARTING_WAIT_INTERVAL_MS = 1000; -const DEFAULT_REQUEST_TIMEOUT_MS = 300000; -const DEFAULT_POLL_INTERVAL_MS = 500; -// Backoff base for transient upstream error retries (issue #5): 800ms, 1600ms. -const RETRY_BACKOFF_BASE_MS = 800; -const RETRY_MAX_ATTEMPTS = 3; -// Reasoning models can take well over 10s before emitting their first token. -// A short window here makes the event stream give up and fall back to polling on -// every request, which loses true streaming. Configurable for slow backends. -const DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS = Number(process.env.OPENCODE2API_EVENT_FIRST_DELTA_TIMEOUT_MS) || 30000; -const DEFAULT_EVENT_IDLE_TIMEOUT_MS = Number(process.env.OPENCODE2API_EVENT_IDLE_TIMEOUT_MS) || 8000; - -const OPENCODE_BASENAME = 'opencode'; - -function splitPathEnv() { - const raw = process.env.PATH || ''; - return raw.split(path.delimiter).filter(Boolean); -} - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function pushDir(list, dir) { - if (!dir) return; - if (!list.includes(dir)) list.push(dir); -} - -function pushExistingDir(list, dir) { - if (!dir) return; - if (!fs.existsSync(dir)) return; - if (!list.includes(dir)) list.push(dir); -} - -function addVersionedDirs(list, baseDir, subpath) { - if (!baseDir || !fs.existsSync(baseDir)) return; - let entries = []; - try { - entries = fs.readdirSync(baseDir, { withFileTypes: true }); - } catch (e) { - return; - } - entries.forEach((entry) => { - if (!entry.isDirectory()) return; - const full = path.join(baseDir, entry.name, subpath || ''); - pushExistingDir(list, full); - }); -} - -function prefixToBin(prefix) { - if (!prefix) return null; - return process.platform === 'win32' ? prefix : path.join(prefix, 'bin'); -} - -function getOpencodeCandidateNames() { - if (process.platform === 'win32') { - return [`${OPENCODE_BASENAME}.cmd`, `${OPENCODE_BASENAME}.exe`, `${OPENCODE_BASENAME}.bat`, OPENCODE_BASENAME]; - } - return [OPENCODE_BASENAME]; -} - -function findExecutableInDirs(dirs, names) { - for (const dir of dirs) { - for (const name of names) { - const full = path.join(dir, name); - if (fs.existsSync(full)) { - return full; - } - } - } - return null; -} - -function resolveOpencodePath(requestedPath) { - const input = (requestedPath || '').trim(); - const names = getOpencodeCandidateNames(); - - if (input) { - const looksLikePath = path.isAbsolute(input) || input.includes('/') || input.includes('\\'); - if (looksLikePath) { - if (fs.existsSync(input)) return { path: input, source: 'config' }; - const resolved = path.resolve(process.cwd(), input); - if (fs.existsSync(resolved)) return { path: resolved, source: 'config' }; - } - } - - const pathDirs = splitPathEnv(); - const fromPath = findExecutableInDirs(pathDirs, names); - if (fromPath) return { path: fromPath, source: 'PATH' }; - - const extraDirs = []; - if (process.env.OPENCODE_HOME) { - pushDir(extraDirs, path.join(process.env.OPENCODE_HOME, 'bin')); - } - if (process.env.OPENCODE_DIR) { - pushDir(extraDirs, path.join(process.env.OPENCODE_DIR, 'bin')); - } - pushDir(extraDirs, prefixToBin(process.env.npm_config_prefix || process.env.NPM_CONFIG_PREFIX)); - pushDir(extraDirs, process.env.PNPM_HOME); - if (process.env.YARN_GLOBAL_FOLDER) { - pushDir(extraDirs, path.join(process.env.YARN_GLOBAL_FOLDER, 'bin')); - } - if (process.env.VOLTA_HOME) { - pushDir(extraDirs, path.join(process.env.VOLTA_HOME, 'bin')); - } - pushDir(extraDirs, process.env.NVM_BIN); - pushDir(extraDirs, path.dirname(process.execPath)); - - const home = os.homedir(); - if (home) { - pushDir(extraDirs, path.join(home, '.opencode', 'bin')); - pushDir(extraDirs, path.join(home, '.local', 'bin')); - pushDir(extraDirs, path.join(home, '.npm-global', 'bin')); - pushDir(extraDirs, path.join(home, '.npm', 'bin')); - pushDir(extraDirs, path.join(home, '.pnpm-global', 'bin')); - pushDir(extraDirs, path.join(home, '.local', 'share', 'pnpm')); - pushDir(extraDirs, path.join(home, '.fnm', 'node-versions', 'v1', 'installations')); - pushDir(extraDirs, path.join(home, '.asdf', 'shims')); - } - - if (process.platform === 'win32') { - pushDir(extraDirs, process.env.APPDATA ? path.join(process.env.APPDATA, 'npm') : null); - pushDir(extraDirs, process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'pnpm') : null); - pushDir(extraDirs, process.env.NVM_HOME); - pushDir(extraDirs, process.env.NVM_SYMLINK); - pushDir(extraDirs, process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'nodejs') : null); - pushDir(extraDirs, process.env['ProgramFiles(x86)'] ? path.join(process.env['ProgramFiles(x86)'], 'nodejs') : null); - } else { - pushDir(extraDirs, '/usr/local/bin'); - pushDir(extraDirs, '/usr/bin'); - pushDir(extraDirs, '/bin'); - pushDir(extraDirs, '/opt/homebrew/bin'); - pushDir(extraDirs, '/snap/bin'); - } - - // nvm (unix) versions - const nvmDir = process.env.NVM_DIR || (home ? path.join(home, '.nvm') : null); - if (nvmDir) { - addVersionedDirs(extraDirs, path.join(nvmDir, 'versions', 'node'), 'bin'); - } - - // asdf nodejs installs - const asdfDir = process.env.ASDF_DATA_DIR || (home ? path.join(home, '.asdf') : null); - if (asdfDir) { - addVersionedDirs(extraDirs, path.join(asdfDir, 'installs', 'nodejs'), 'bin'); - } - - // fnm installs - if (home) { - addVersionedDirs(extraDirs, path.join(home, '.fnm', 'node-versions', 'v1'), 'installation' + path.sep + 'bin'); - } - - const fromExtras = findExecutableInDirs(extraDirs, names); - if (fromExtras) return { path: fromExtras, source: 'known-locations' }; - - return { path: null, source: 'not-found' }; -} - -function processQueue() { - if (isProcessing || queue.length === 0) return; - isProcessing = true; - const { task, timeout, resolve, reject } = queue.shift(); - let settled = false; - const timeoutMs = timeout || 120000; - const timeoutId = setTimeout(() => { - if (settled) return; - settled = true; - reject(new Error(`Request timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - Promise.resolve() - .then(() => task()) - .then((result) => { - if (settled) return; - settled = true; - clearTimeout(timeoutId); - resolve(result); - }) - .catch((err) => { - if (settled) return; - settled = true; - clearTimeout(timeoutId); - reject(err); - }) - .finally(() => { - isProcessing = false; - if (queue.length > 0) { - queueMicrotask(processQueue); - } - }); -} - -function lock(task, timeout = 120000) { - return new Promise((resolve, reject) => { - queue.push({ task, timeout, resolve, reject }); - processQueue(); - }); -} - -/** - * Robust Health Check Helper - */ -function buildBackendAuthHeaders(password = '') { - if (!password) return undefined; - const token = Buffer.from(`opencode:${password}`).toString('base64'); - return { Authorization: `Basic ${token}` }; -} - -function checkHealth(serverUrl, password = '') { - return new Promise((resolve, reject) => { - const headers = buildBackendAuthHeaders(password); - const options = headers ? { headers } : undefined; - const req = http.get(`${serverUrl}/health`, options, (res) => { - if (res.statusCode === 200) resolve(true); - else reject(new Error(`Status ${res.statusCode}`)); - }); - req.on('error', (e) => reject(e)); - req.setTimeout(2000, () => { - req.destroy(); - reject(new Error('Timeout')); - }); - }); -} - -/** - * Cleanup temporary directories - */ -function cleanupTempDirs() { - // Only cleanup jail directories on non-Windows platforms - // On Windows, we don't use isolated jail to avoid path issues - if (process.platform === 'win32') return; - - const jailRoot = path.join(os.tmpdir(), 'opencode-proxy-jail'); - try { - if (fs.existsSync(jailRoot)) { - fs.rmSync(jailRoot, { recursive: true, force: true }); - } - } catch (e) { - console.error('[Cleanup] Failed to remove temp dirs:', e.message); - } -} - -// Register cleanup on exit -process.on('exit', cleanupTempDirs); - -// Handle signals - Unix-like systems -if (process.platform !== 'win32') { - process.on('SIGINT', () => { - console.log('\n[Shutdown] Received SIGINT, cleaning up...'); - cleanupTempDirs(); - process.exit(0); - }); - process.on('SIGTERM', () => { - console.log('\n[Shutdown] Received SIGTERM, cleaning up...'); - cleanupTempDirs(); - process.exit(0); - }); -} -// Note: Windows signal handling is limited, cleanup is handled via process.on('exit') - -/** - * Create Express app with proper configuration - */ -export function createApp(config) { - const { - API_KEY, - OPENCODE_SERVER_URL, - OPENCODE_SERVER_PASSWORD, - REQUEST_TIMEOUT_MS, - DEBUG, - DISABLE_TOOLS, - INTERNAL_WEB_FETCH_ENABLED, - INTERNAL_ALLOWED_TOOLS = [], - INTERNAL_TOOL_METRICS_ENABLED = true, - INTERNAL_TOOL_DISCOVERY_FIXTURE = [], - HEALTH_DETAILS_ENABLED = true, - HEALTH_DETAILS_REQUIRE_AUTH = true, - METRICS_ENABLED = false, - METRICS_REQUIRE_AUTH = true, - PROMPT_MODE, - OMIT_SYSTEM_PROMPT, - AUTO_CLEANUP_CONVERSATIONS, - CLEANUP_INTERVAL_MS, - CLEANUP_MAX_AGE_MS, - OPENCODE_HOME_BASE - } = config; - - const app = express(); - app.use(cors({ - origin: '*', - methods: ['GET', 'POST', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] - })); - app.use(bodyParser.json({ limit: '50mb' })); - app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); - - const clientHeaders = buildBackendAuthHeaders(OPENCODE_SERVER_PASSWORD); - const client = createOpencodeClient({ baseUrl: OPENCODE_SERVER_URL, headers: clientHeaders }); - - const isOperationalEndpointBypassed = (req) => { - if (req.path === '/health/details') { - return HEALTH_DETAILS_ENABLED && !HEALTH_DETAILS_REQUIRE_AUTH; - } - if (req.path === '/metrics') { - return METRICS_ENABLED && !METRICS_REQUIRE_AUTH; - } - return false; - }; - - // Auth middleware - app.use((req, res, next) => { - if (req.method === 'OPTIONS' || req.path === '/health' || req.path === '/' || req.path === '/health/details' || req.path === '/metrics') return next(); - if (API_KEY && API_KEY.trim() !== '') { - const authHeader = req.headers.authorization; - if (!authHeader || authHeader !== `Bearer ${API_KEY}`) { - return res.status(401).json({ error: { message: 'Unauthorized' } }); - } - } - next(); - }); - - const getProvidersList = async () => { - const providersRes = await client.config.providers(); - const providersRaw = providersRes.data?.providers || []; - return Array.isArray(providersRaw) - ? providersRaw - : Object.entries(providersRaw).map(([id, info]) => ({ ...info, id })); - }; - - const buildModelsList = (providersList) => { - const models = []; - providersList.forEach((p) => { - if (p.models) { - Object.entries(p.models).forEach(([mId, mData]) => { - models.push({ - id: `${p.id}/${mId}`, - name: typeof mData === 'object' ? (mData.name || mData.label || mId) : mId, - object: 'model', - created: (mData && mData.release_date) - ? Math.floor(new Date(mData.release_date).getTime() / 1000) - : 1704067200, - owned_by: p.id - }); - }); - } - }); - return models; - }; - - const normalizeModelID = (modelID) => { - if (!modelID || typeof modelID !== 'string') return modelID; - return modelID - .replace(/^gpt(\d)/i, 'gpt-$1') - .replace(/^o(\d)/i, 'o$1'); - }; - - const resolveRequestedModel = async (requestedModel) => { - const providersList = await getProvidersList(); - const models = buildModelsList(providersList); - const fallbackModel = models[0]?.id || 'opencode/kimi-k2.5-free'; - let [providerID, modelID] = (requestedModel || fallbackModel).split('/'); - if (!modelID) { - modelID = providerID; - providerID = 'opencode'; - } - const originalModelID = modelID; - const normalizedModelID = normalizeModelID(modelID); - const candidateModelIDs = [...new Set([modelID, normalizedModelID].filter(Boolean))]; - const exact = models.find((m) => candidateModelIDs.some((candidate) => m.id === `${providerID}/${candidate}`)); - if (exact) { - const [, resolvedModelID] = exact.id.split('/'); - return { - providerID, - modelID: resolvedModelID, - models, - resolved: exact.id, - ...(resolvedModelID !== originalModelID && { aliasFrom: `${providerID}/${originalModelID}` }) - }; - } - const sameProvider = models.filter((m) => m.owned_by === providerID); - const suffixMatch = sameProvider.find((m) => candidateModelIDs.some((candidate) => m.id.endsWith(`/${candidate}-free`) || m.id.endsWith(`/${candidate}`))); - if (suffixMatch) { - const [, resolvedModelID] = suffixMatch.id.split('/'); - return { providerID, modelID: resolvedModelID, models, resolved: suffixMatch.id, aliasFrom: `${providerID}/${originalModelID}` }; - } - const error = new Error(`Model not found: ${providerID}/${modelID}`); - error.statusCode = 400; - error.code = 'model_not_found'; - error.availableModels = models.map((m) => m.id); - throw error; - }; - - // Models endpoint - app.get('/v1/models', async (_req, res) => { - try { - const models = buildModelsList(await getProvidersList()); - res.json({ object: 'list', data: models }); - } catch (error) { - console.error('[Proxy] Model Fetch Error:', error.message); - res.json({ object: 'list', data: [{ id: 'opencode/kimi-k2.5-free', object: 'model' }] }); - } - }); - - const logDebug = (...args) => { - if (DEBUG) { - console.log('[Proxy][Debug]', ...args); - } - }; - - // Responses API state store (previous_response_id): maps a returned response id - // to the OpenCode session that produced it, so stateful clients can continue a - // conversation without resending full history. Entries expire after - // RESPONSE_STATE_TTL_MS; the sweep then best-effort deletes the upstream session - // once no live entry still references it, so statefulness never leaks sessions. - const responseState = new Map(); - const RESPONSE_STATE_TTL_MS = 30 * 60 * 1000; - const RESPONSE_STATE_SWEEP_INTERVAL_MS = 60 * 1000; - const getResponseState = (responseId) => { - const state = responseState.get(responseId); - if (!state) return null; - if (state.expiresAt <= Date.now()) { - responseState.delete(responseId); - return null; - } - return state; - }; - const storeResponseState = (responseId, sessionId, model) => { - if (!responseId || !sessionId) return; - responseState.set(responseId, { - sessionId, - model, - expiresAt: Date.now() + RESPONSE_STATE_TTL_MS - }); - }; - const sweepResponseState = async () => { - const now = Date.now(); - const expired = []; - for (const [id, state] of responseState.entries()) { - if (state.expiresAt <= now) { - expired.push(state); - responseState.delete(id); - } - } - if (!expired.length) return; - const liveSessionIds = new Set([...responseState.values()].map((s) => s.sessionId)); - for (const state of expired) { - if (liveSessionIds.has(state.sessionId)) continue; - try { - await client.session.delete({ path: { id: state.sessionId } }); - } catch (e) { - logDebug('Failed to delete expired response session', { sessionId: state.sessionId, error: e.message }); - } - } - }; - const responseStateSweepTimer = setInterval(() => { - sweepResponseState().catch(() => {}); - }, RESPONSE_STATE_SWEEP_INTERVAL_MS); - if (typeof responseStateSweepTimer.unref === 'function') responseStateSweepTimer.unref(); - - const TOOL_MODE = Object.freeze({ - DISABLED: 'disabled', - EXTERNAL_BRIDGE: 'external-bridge', - INTERNAL_ALLOWLIST: 'internal-allowlist' - }); - - const TOOL_GUARD_MESSAGE = 'Tools are disabled. Do not call tools or function calls. Answer directly from the conversation and general knowledge. If external or real-time data is required, say so and ask the user to enable tools.'; - const EXTERNAL_TOOL_GUARD_MESSAGE = 'OpenCode internal tools remain disabled. If an external tool contract is present, use only that contract and never call or mention OpenCode internal tools.'; - - const normalizeConfiguredToolNames = (entries = []) => [...new Set( - entries - .map((entry) => String(entry || '').trim()) - .filter(Boolean) - )]; - - const getEffectiveInternalAllowedTools = () => { - const configuredTools = normalizeConfiguredToolNames(INTERNAL_ALLOWED_TOOLS); - if (configuredTools.length > 0) return configuredTools; - if (INTERNAL_WEB_FETCH_ENABLED) return ['web_fetch']; - return []; - }; - - const SERVER_INTERNAL_ALLOWED_TOOL_NAMES = getEffectiveInternalAllowedTools(); - - const buildInternalAllowlistPrompt = (allowedToolNames = []) => { - if (allowedToolNames.length > 0) { - return `OpenCode internal tool access is limited for this turn. You may use only these built-in tools when truly required: ${allowedToolNames.join(', ')}. Do not mention or attempt any other internal tools. If the required internal tools are unavailable, answer directly and say live tool access is unavailable.`; - } - return 'OpenCode internal tools are unavailable for this turn. Answer directly without attempting tool usage.'; - }; - - const buildSystemPrompt = (systemMsg, reasoningEffort = null, toolMode = TOOL_MODE.DISABLED, internalAllowedTools = []) => { - const parts = []; - if (!OMIT_SYSTEM_PROMPT && systemMsg && systemMsg.trim()) { - parts.push(systemMsg.trim()); - } - if (reasoningEffort && reasoningEffort !== 'none') { - parts.push(`[Reasoning Effort: ${reasoningEffort}]`); - } - if (toolMode === TOOL_MODE.INTERNAL_ALLOWLIST) { - parts.push(buildInternalAllowlistPrompt(internalAllowedTools)); - } else if (DISABLE_TOOLS && PROMPT_MODE !== 'plugin-inject') { - parts.push(toolMode === TOOL_MODE.EXTERNAL_BRIDGE ? EXTERNAL_TOOL_GUARD_MESSAGE : TOOL_GUARD_MESSAGE); - } - const finalPrompt = parts.join('\n\n').trim(); - return finalPrompt || undefined; - }; - - const normalizeReasoningEffort = (value, fallback = null) => { - if (!value || typeof value !== 'string') return fallback; - const effortMap = { - 'none': 'none', - 'minimal': 'none', - 'low': 'low', - 'medium': 'medium', - 'high': 'high', - 'xhigh': 'high' - }; - return effortMap[value.toLowerCase()] || fallback; - }; - - const stripFunctionCalls = (text, trim = true) => { - if (!DISABLE_TOOLS || !text) return text; - return stripFunctionCallMarkup(text, trim); - }; - - const normalizeTextContent = (content) => { - - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content.map((part) => { - if (typeof part === 'string') return part; - if (part && typeof part.text === 'string') return part.text; - if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') return part?.text || ''; - return ''; - }).join(''); - } - if (content && typeof content.text === 'string') return content.text; - if (content === null || content === undefined) return ''; - if (typeof content === 'number' || typeof content === 'boolean') return String(content); - return ''; - }; - - const normalizeToolArguments = (args) => { - if (typeof args === 'string') return args; - if (args === undefined) return '{}'; - try { - return JSON.stringify(args); - } catch (e) { - return '{}'; - } - }; - - const normalizeToolResultContent = (content) => { - const text = normalizeTextContent(content); - if (text) return text; - if (content === null || content === undefined) return ''; - if (typeof content === 'object') { - try { - return JSON.stringify(content); - } catch (e) { - return ''; - } - } - return String(content); - }; - - - const createExternalToolContext = (tools, toolChoice) => { - const registry = buildExternalToolRegistry(tools); - const exposure = buildToolExposure(registry, toolChoice); - return { - registry, - exposure, - toolChoice: exposure.toolChoice, - prompt: exposure.prompt - }; - }; - - const resolveToolMode = (tools = [], effectiveInternalAllowlist = []) => { - if (Array.isArray(tools) && tools.length > 0) { - return TOOL_MODE.EXTERNAL_BRIDGE; - } - if (effectiveInternalAllowlist.length > 0) { - return TOOL_MODE.INTERNAL_ALLOWLIST; - } - return TOOL_MODE.DISABLED; - }; - - const createRequestToolContext = (tools, toolChoice, requestOpencodeConfig = undefined) => { - let effectiveInternalAllowlist = SERVER_INTERNAL_ALLOWED_TOOL_NAMES; - let requestInternalAllowlist = null; - - if (requestOpencodeConfig && typeof requestOpencodeConfig === 'object') { - if (Array.isArray(requestOpencodeConfig.internal_allowed_tools)) { - requestInternalAllowlist = requestOpencodeConfig.internal_allowed_tools - .map(name => String(name || '').trim()) - .filter(Boolean); - } - } - - if (requestInternalAllowlist !== null) { - effectiveInternalAllowlist = SERVER_INTERNAL_ALLOWED_TOOL_NAMES.filter(name => - requestInternalAllowlist.includes(name) - ); - } - - const deniedRequestedTools = requestInternalAllowlist - ? requestInternalAllowlist.filter(name => !SERVER_INTERNAL_ALLOWED_TOOL_NAMES.includes(name)) - : []; - - const mode = resolveToolMode(tools, effectiveInternalAllowlist); - const external = mode === TOOL_MODE.EXTERNAL_BRIDGE - ? createExternalToolContext(tools, toolChoice) - : { - registry: [], - exposure: { tools: [], toolChoice: { mode: 'auto', requiredTool: null }, prompt: '' }, - toolChoice: { mode: 'auto', requiredTool: null }, - prompt: '' - }; - - return { - mode, - external, - internal: { - allowedToolNames: effectiveInternalAllowlist, - requestedAllowlist: requestInternalAllowlist, - deniedRequestedTools, - resolutionPath: requestInternalAllowlist ? 'request-intersection' : 'server-default', - resultingMode: mode, - metricsEnabled: INTERNAL_TOOL_METRICS_ENABLED - } - }; - - - return { - mode, - external, - internal: { - allowedToolNames: effectiveInternalAllowlist, - requestedAllowlist: requestInternalAllowlist, - deniedRequestedTools, - resolutionPath: requestInternalAllowlist ? 'request-intersection' : 'server-default', - resultingMode: mode, - metricsEnabled: INTERNAL_TOOL_METRICS_ENABLED - } - }; - }; - - const finalizeValidatedToolCalls = (parsedToolCalls, registry) => { - const { validCalls, invalidCalls } = validateToolCalls(parsedToolCalls, registry); - invalidCalls.forEach(({ call, validation }) => { - logDebug('Rejected external tool call', { - tool: call?.function?.name, - errors: validation?.errors?.map((error) => error.message) - }); - }); - const allowedCalls = []; - validCalls.forEach((toolCall) => { - const policyDecision = evaluateToolPolicy(toolCall.tool, toolCall.validatedArguments, { config }); - if (policyDecision.status === 'allow') { - allowedCalls.push(toolCall); - return; - } - logDebug('Blocked external tool call', { - tool: toolCall.function.name, - status: policyDecision.status, - reason: policyDecision.reason - }); - }); - return { validCalls: allowedCalls, invalidCalls }; - }; - - const toPublicToolCalls = (toolCalls) => { - if (!Array.isArray(toolCalls) || toolCalls.length === 0) return []; - return toolCalls.map((toolCall) => ({ - id: toolCall.id, - type: 'function', - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments - } - })); - }; - - const createForcedToolCallRequester = ({ - mode, - sessionId, - systemWithGuard, - requiredTool, - providerID, - modelID, - toolOverrides, - requestTimeoutMs, - forbidThinkBlock = false - }) => async () => { - if (mode !== 'required') return null; - if (!requiredTool) return null; - const forcedPromptParams = { - path: { id: sessionId }, - body: { - model: { providerID, modelID }, - ...(systemWithGuard ? { system: systemWithGuard } : {}), - parts: [{ - type: 'text', - text: `SYSTEM: Your previous reply did not emit the required external tool call. Reply now with ONLY {\"name\":\"${requiredTool}\",\"arguments\":{}} or an array inside .... Do not output any prose, reasoning, markdown${forbidThinkBlock ? ', or block' : ''}. Infer the correct arguments from the conversation so far.` - }] - } - }; - if (toolOverrides && Object.keys(toolOverrides).length > 0) { - forcedPromptParams.body.tools = toolOverrides; - } - await promptWithTimeout(forcedPromptParams, requestTimeoutMs); - return pollForAssistantResponse(sessionId, requestTimeoutMs); - }; - - const TOOL_IDS_CACHE_MS = 5 * 60 * 1000; - let cachedToolIds = null; - let cachedToolIdsAt = 0; - let cachedDisabledToolOverrides = null; - let cachedDisabledToolOverridesAt = 0; - const internalToolMetrics = { - externalBridgeRequests: 0, - internalAllowlistRequests: 0, - disabledRequests: 0, - discoveryFailures: 0, - fallbackToDisabled: 0 - }; - - const logInternalToolEvent = (event, details = {}) => { - if (!DEBUG && !INTERNAL_TOOL_METRICS_ENABLED) return; - const payload = { - event, - ...details - }; - if (INTERNAL_TOOL_METRICS_ENABLED) { - payload.metrics = { ...internalToolMetrics }; - } - logDebug('Internal tool event', payload); - }; - - const trackToolMode = (toolMode, details = {}) => { - if (toolMode === TOOL_MODE.EXTERNAL_BRIDGE) { - internalToolMetrics.externalBridgeRequests += 1; - } else if (toolMode === TOOL_MODE.INTERNAL_ALLOWLIST) { - internalToolMetrics.internalAllowlistRequests += 1; - } else { - internalToolMetrics.disabledRequests += 1; - } - logInternalToolEvent('tool-mode-selected', { - toolMode, - ...details - }); - }; - - const getBackendToolIds = async () => { - if (cachedToolIds && Date.now() - cachedToolIdsAt < TOOL_IDS_CACHE_MS) { - return cachedToolIds; - } - const fixtureIds = normalizeConfiguredToolNames(INTERNAL_TOOL_DISCOVERY_FIXTURE); - if (fixtureIds.length > 0) { - cachedToolIds = fixtureIds; - cachedToolIdsAt = Date.now(); - logInternalToolEvent('backend-tool-ids-fixture-loaded', { count: fixtureIds.length, fixtureIds }); - return fixtureIds; - } - try { - const idsRes = await client.tool.ids(); - const ids = Array.isArray(idsRes?.data) - ? idsRes.data - : Array.isArray(idsRes) - ? idsRes - : []; - cachedToolIds = ids; - cachedToolIdsAt = Date.now(); - logInternalToolEvent('backend-tool-ids-loaded', { count: ids.length }); - return ids; - } catch (e) { - internalToolMetrics.discoveryFailures += 1; - logInternalToolEvent('backend-tool-ids-failed', { error: e.message }); - return null; - } - }; - - const buildDisabledToolOverrides = (ids = []) => { - const overrides = {}; - ids.forEach((id) => { - overrides[id] = false; - }); - return overrides; - }; - - const normalizeBackendToolIds = (ids = []) => ids.filter((id) => typeof id === 'string' && id.trim()); - - const matchesAllowedToolName = (toolId, allowedToolName) => { - if (!toolId || !allowedToolName) return false; - return toolId === allowedToolName || toolId.endsWith(`.${allowedToolName}`) || toolId.endsWith(`/${allowedToolName}`); - }; - - const resolveInternalAllowedToolIds = (ids = [], allowedToolNames = []) => { - const normalizedIds = normalizeBackendToolIds(ids); - const normalizedAllowedNames = normalizeConfiguredToolNames(allowedToolNames); - const matchedToolIds = new Set(); - const unmatchedAllowedNames = []; - - normalizedAllowedNames.forEach((allowedToolName) => { - const matches = normalizedIds.filter((toolId) => matchesAllowedToolName(toolId, allowedToolName)); - if (matches.length === 0) { - unmatchedAllowedNames.push(allowedToolName); - return; - } - matches.forEach((match) => matchedToolIds.add(match)); - }); - - return { - normalizedIds, - normalizedAllowedNames, - matchedToolIds: [...matchedToolIds], - unmatchedAllowedNames - }; - }; - - const getDisabledToolOverrides = async () => { - if (!DISABLE_TOOLS) return null; - if (cachedDisabledToolOverrides && Date.now() - cachedDisabledToolOverridesAt < TOOL_IDS_CACHE_MS) { - return cachedDisabledToolOverrides; - } - const ids = await getBackendToolIds(); - if (!Array.isArray(ids)) return null; - const overrides = buildDisabledToolOverrides(ids); - cachedDisabledToolOverrides = overrides; - cachedDisabledToolOverridesAt = Date.now(); - logInternalToolEvent('disabled-tool-overrides-loaded', { count: ids.length }); - return overrides; - }; - - const getToolOverridesForMode = async (toolMode, internalContext = {}) => { - if (toolMode === TOOL_MODE.EXTERNAL_BRIDGE || toolMode === TOOL_MODE.DISABLED) { - if (toolMode === TOOL_MODE.DISABLED) { - logInternalToolEvent('internal-tools-disabled', { - configuredAllowlist: internalContext.allowedToolNames || SERVER_INTERNAL_ALLOWED_TOOL_NAMES - }); - } - return getDisabledToolOverrides(); - } - if (toolMode !== TOOL_MODE.INTERNAL_ALLOWLIST) { - return null; - } - const ids = await getBackendToolIds(); - if (!Array.isArray(ids) || ids.length === 0) return null; - const resolution = resolveInternalAllowedToolIds(ids, internalContext.allowedToolNames || SERVER_INTERNAL_ALLOWED_TOOL_NAMES); - const { normalizedIds, normalizedAllowedNames, matchedToolIds, unmatchedAllowedNames } = resolution; - if (matchedToolIds.length === 0) { - internalToolMetrics.fallbackToDisabled += 1; - logInternalToolEvent('internal-allowlist-unavailable', { - configuredAllowlist: normalizedAllowedNames, - availableToolIds: normalizedIds, - unmatchedAllowlist: unmatchedAllowedNames, - fallback: 'disabled' - }); - return buildDisabledToolOverrides(normalizedIds); - } - const overrides = {}; - normalizedIds.forEach((id) => { - overrides[id] = matchedToolIds.includes(id); - }); - logInternalToolEvent('internal-allowlist-overrides-loaded', { - configuredAllowlist: normalizedAllowedNames, - matchedToolIds, - unmatchedAllowlist: unmatchedAllowedNames, - availableToolIdsCount: normalizedIds.length - }); - return overrides; - }; - - 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]); - } - - const getCleanupRoots = () => { - const roots = []; - const add = (dir) => { - if (!dir) return; - if (!roots.includes(dir)) roots.push(dir); - }; - add(OPENCODE_HOME_BASE ? path.join(OPENCODE_HOME_BASE, '.local', 'share', 'opencode', 'storage') : null); - add('/home/node/.local/share/opencode/storage'); - return roots; - }; - - const cleanupConversationFiles = async () => { - if (!AUTO_CLEANUP_CONVERSATIONS) return { removed: 0, scanned: 0 }; - const now = Date.now(); - let removed = 0; - let scanned = 0; - for (const storageRoot of getCleanupRoots()) { - for (const sub of ['message', 'session']) { - const dir = path.join(storageRoot, sub); - if (!fs.existsSync(dir)) continue; - let entries = []; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch (e) { - continue; - } - for (const entry of entries) { - const full = path.join(dir, entry.name); - let stat; - try { - stat = fs.statSync(full); - } catch (e) { - continue; - } - scanned += 1; - const mtime = stat.mtimeMs || stat.ctimeMs || now; - if (now - mtime < CLEANUP_MAX_AGE_MS) continue; - try { - fs.rmSync(full, { recursive: true, force: true }); - removed += 1; - } catch (e) { - logDebug('Cleanup remove failed', { full, error: e.message }); - } - } - } - } - if (removed > 0) { - logDebug('Conversation cleanup completed', { removed, scanned, maxAgeMs: CLEANUP_MAX_AGE_MS }); - } - return { removed, scanned }; - }; - - if (AUTO_CLEANUP_CONVERSATIONS) { - setTimeout(() => { - cleanupConversationFiles().catch((e) => logDebug('Cleanup run failed', { error: e.message })); - }, 3000); - const cleanupTimer = setInterval(() => { - cleanupConversationFiles().catch((e) => logDebug('Cleanup run failed', { error: e.message })); - }, CLEANUP_INTERVAL_MS); - if (cleanupTimer.unref) cleanupTimer.unref(); - } - - function extractFromParts(parts) { - if (!Array.isArray(parts)) return { content: '', reasoning: '', toolParts: [] }; - const content = parts.filter(p => p.type === 'text').map(p => p.text).join(''); - const reasoning = parts.filter(p => p.type === 'reasoning').map(p => p.text).join(''); - const toolParts = parts.filter(p => p.type === 'tool'); - return { content, reasoning, toolParts }; - } - - async function pollForAssistantResponse(sessionId, timeoutMs, intervalMs = DEFAULT_POLL_INTERVAL_MS) { - const pollStart = Date.now(); - const startedAt = Date.now(); - // Best-effort snapshot of the most recent in-flight assistant message. Polling - // observes partial messages: a reasoning model emits its reasoning part first and - // the text part only afterwards, so returning on the first non-empty snapshot - // truncates the answer to the reasoning alone. Keep the partial around purely as - // a timeout fallback and otherwise wait for the message to actually finish. - let lastPartial = null; - while (Date.now() - startedAt < timeoutMs) { - const messagesRes = await client.session.messages({ path: { id: sessionId } }); - const messages = messagesRes?.data || messagesRes || []; - if (Array.isArray(messages) && messages.length) { - for (let i = messages.length - 1; i >= 0; i -= 1) { - const entry = messages[i]; - const info = entry?.info; - if (info?.role !== 'assistant') continue; - const { content, reasoning, toolParts } = extractFromParts(entry?.parts || []); - const error = info?.error || null; - // finish === 'tool' marks an intermediate turn that pauses for a tool - // result; the assistant is not done producing output yet. - const finished = info.finish && info.finish !== 'tool'; - const done = Boolean(finished || info.time?.completed || error); - if (toolParts.length > 0) { - logDebug('Polling found tool parts', { - sessionId, - count: toolParts.length, - parts: toolParts.map(p => ({ - id: p.id, - tool: p.tool, - status: p.state?.status, - input: p.state?.input - })) - }); - } - if (done) { - if (error) { - console.error('[Proxy] OpenCode assistant error:', error); - } - logDebug('Polling completed', { - sessionId, - ms: Date.now() - pollStart, - done, - contentLen: content.length, - reasoningLen: reasoning.length, - error: error ? error.name : null - }); - return { content, reasoning, error }; - } - if (content || reasoning) { - lastPartial = { content, reasoning, error: null }; - } - break; - } - } - await sleep(intervalMs); - } - if (lastPartial) { - logDebug('Polling timeout with partial response', { - sessionId, - ms: Date.now() - pollStart, - contentLen: lastPartial.content.length, - reasoningLen: lastPartial.reasoning.length - }); - return lastPartial; - } - logDebug('Polling timeout', { sessionId, ms: Date.now() - pollStart }); - throw new Error(`Request timeout after ${timeoutMs}ms`); - } - - async function collectFromEvents(sessionId, timeoutMs, onDelta, firstDeltaTimeoutMs, idleTimeoutMs) { - const controller = new AbortController(); - const eventStreamResult = await client.event.subscribe({ signal: controller.signal }); - const eventStream = eventStreamResult.stream; - let finished = false; - let content = ''; - let reasoning = ''; - let receivedDelta = false; - let deltaChars = 0; - let firstDeltaAt = null; - // Tracks internal OpenCode tool calls that are still pending/running. While any - // tool call is active, the stream must stay open even if no text deltas arrive - // (the backend is executing the tool). Resolving early here is what previously - // truncated streaming responses that relied on internal tool execution. - const activeToolCallIds = new Set(); - const startedAt = Date.now(); - - const finishPromise = new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - if (finished) return; - finished = true; - controller.abort(); - reject(new Error(`Request timeout after ${timeoutMs}ms`)); - }, timeoutMs); - - const firstDeltaTimer = firstDeltaTimeoutMs - ? setTimeout(() => { - if (finished || receivedDelta) return; - finished = true; - controller.abort(); - logDebug('No event data received', { sessionId, ms: Date.now() - startedAt }); - resolve({ content: '', reasoning: '', noData: true }); - }, firstDeltaTimeoutMs) - : null; - - let idleTimer = null; - const scheduleIdleTimer = () => { - if (!idleTimeoutMs) return; - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(() => { - if (finished) return; - // A tool call is still executing on the backend. Keep the stream open - // and wait instead of cutting the response short; the follow-up text - // (or the final completion) will arrive once the tool finishes. - if (activeToolCallIds.size > 0) { - logDebug('Event idle while internal tool call is active, continuing to wait', { - sessionId, - ms: Date.now() - startedAt, - activeTools: activeToolCallIds.size - }); - scheduleIdleTimer(); - return; - } - finished = true; - controller.abort(); - logDebug('Event idle timeout', { - sessionId, - ms: Date.now() - startedAt, - deltaChars - }); - resolve({ - content, - reasoning, - idleTimeout: true, - receivedDelta - }); - }, idleTimeoutMs); - }; - - const trackToolActivity = (part) => { - if (!part || part.type !== 'tool') return; - const status = part.state?.status; - if (status === 'pending' || status === 'running') { - if (part.id) activeToolCallIds.add(part.id); - } else if (status === 'completed' || status === 'error') { - if (part.id) activeToolCallIds.delete(part.id); - } - // Tool activity means the session is still working; treat it as progress - // so the idle timer does not terminate the stream mid-execution. - receivedDelta = true; - if (firstDeltaTimer) clearTimeout(firstDeltaTimer); - scheduleIdleTimer(); - }; - - // Newer OpenCode servers stream deltas as `message.part.delta` events that - // carry only a `partID` (no `part.type`). The part type is announced by the - // preceding `message.part.updated` event, so we key partID -> type here and - // resolve each delta against it. Without this, reasoning and answer text can - // never be told apart and the answer is mis-routed (or dropped) entirely. - const partTypeById = new Map(); - const rememberPartType = (part) => { - if (part && part.id && typeof part.type === 'string') { - partTypeById.set(part.id, part.type); - } - }; - const applyTextDelta = (partType, delta) => { - receivedDelta = true; - if (firstDeltaTimer) clearTimeout(firstDeltaTimer); - scheduleIdleTimer(); - if (!firstDeltaAt) { - firstDeltaAt = Date.now(); - logDebug('SSE first delta', { - sessionId, - ms: firstDeltaAt - startedAt, - type: partType - }); - } - if (partType === 'reasoning') { - reasoning += delta; - if (onDelta) onDelta(delta, true); - } else { - content += delta; - if (onDelta) onDelta(delta, false); - } - deltaChars += delta.length; - }; - - (async () => { - try { - for await (const event of eventStream) { - if (event.type === 'message.part.updated' && event.properties.part?.sessionID === sessionId) { - const { part, delta } = event.properties; - rememberPartType(part); - trackToolActivity(part); - // Older OpenCode servers carried the streaming delta directly on - // message.part.updated; newer servers emit message.part.delta. - if (delta) applyTextDelta(part.type, delta); - continue; - } - if (event.type === 'message.part.delta' && event.properties?.sessionID === sessionId) { - const { partID, delta, field } = event.properties; - // Text and reasoning deltas both stream through field === 'text'. - // Tool-input deltas surface via message.part.updated tool state. - if (typeof delta === 'string' && field === 'text') { - const partType = partTypeById.get(partID); - if (partType === 'reasoning' || partType === 'text') { - applyTextDelta(partType, delta); - } - } - continue; - } - if (event.type === 'message.updated' && - event.properties.info?.sessionID === sessionId) { - const info = event.properties.info; - const finish = info.finish; - // An aborted or failed message never produces another delta. Without - // this, the collector waits out the whole first-delta window before - // polling rediscovers the same error. - if (info.error && !finished) { - finished = true; - clearTimeout(timeoutId); - if (firstDeltaTimer) clearTimeout(firstDeltaTimer); - if (idleTimer) clearTimeout(idleTimer); - logDebug('SSE upstream message error', { - sessionId, - ms: Date.now() - startedAt, - error: info.error.name || 'UnknownError' - }); - resolve({ content, reasoning, error: info.error }); - break; - } - // Reconcile active tool calls from the full message snapshot so we - // detect pending tools even when only message.updated fires. - if (Array.isArray(info.parts)) { - for (const part of info.parts) { - rememberPartType(part); - if (part && part.type === 'tool') { - const status = part.state?.status; - if (status === 'pending' || status === 'running') { - if (part.id) activeToolCallIds.add(part.id); - } else if (status === 'completed' || status === 'error') { - if (part.id) activeToolCallIds.delete(part.id); - } - } - } - } - if (finish === 'tool') { - // Assistant turn ended pending a tool call; keep waiting for the result. - if (firstDeltaTimer) clearTimeout(firstDeltaTimer); - scheduleIdleTimer(); - continue; - } - if (finish === 'stop') { - // Only treat the stream as completed when no tool call is still - // pending. OpenCode may emit an intermediate 'stop' snapshot while a - // tool call is in flight; resolving on it would drop the final answer. - if (activeToolCallIds.size > 0) { - logDebug('Ignoring intermediate stop while tools are active', { - sessionId, - activeTools: activeToolCallIds.size - }); - continue; - } - if (!finished) { - finished = true; - clearTimeout(timeoutId); - if (firstDeltaTimer) clearTimeout(firstDeltaTimer); - if (idleTimer) clearTimeout(idleTimer); - logDebug('SSE completed', { - sessionId, - ms: Date.now() - startedAt, - deltaChars - }); - resolve({ content, reasoning }); - } - break; - } - } - } - } catch (e) { - if (!finished) { - finished = true; - clearTimeout(timeoutId); - if (firstDeltaTimer) clearTimeout(firstDeltaTimer); - if (idleTimer) clearTimeout(idleTimer); - reject(e); - } - } - })(); - }); - - try { - return await finishPromise; - } finally { - controller.abort(); - } - } - - // Chat completions endpoint - app.post('/v1/chat/completions', async (req, res) => { - try { - await lock(async () => { - let sessionId = null; - let eventStream = null; - let stream = false; - let pID = 'opencode'; - let mID = 'kimi-k2.5-free'; - let id = `chatcmpl-${crypto.randomUUID()}`; - let keepaliveInterval = null; - - try { - const { messages, model, tools = [], tool_choice, stream: requestStream, temperature, max_tokens, top_p, frequency_penalty, presence_penalty, stop, reasoning_effort, reasoning, opencode: requestOpencodeConfig } = req.body; - stream = Boolean(requestStream); - if (!messages || !Array.isArray(messages) || messages.length === 0) { - return res.status(400).json({ error: { message: 'messages array is required' } }); - } - - const reasoningLevel = normalizeReasoningEffort( - reasoning_effort || reasoning?.effort, - null - ); - - const requestParams = { - temperature: typeof temperature === 'number' ? temperature : 0.7, - max_tokens: typeof max_tokens === 'number' ? max_tokens : null, - top_p: typeof top_p === 'number' ? top_p : 1.0, - frequency_penalty: typeof frequency_penalty === 'number' ? frequency_penalty : 0, - presence_penalty: typeof presence_penalty === 'number' ? presence_penalty : 0, - stop: Array.isArray(stop) ? stop : (stop ? [stop] : null), - reasoning_effort: reasoningLevel - }; - - logDebug('Request params', { temperature: requestParams.temperature, max_tokens: requestParams.max_tokens, top_p: requestParams.top_p, reasoning_effort: reasoningLevel }); - - const resolvedModel = await resolveRequestedModel(model); - pID = resolvedModel.providerID; - mID = resolvedModel.modelID; - if (resolvedModel.aliasFrom) { - logDebug('Resolved model alias', { from: resolvedModel.aliasFrom, to: resolvedModel.resolved }); - } - - const normalizeMessageContent = (content) => normalizeTextContent(content); - - const buildPromptParts = async (rawMessages, externalToolRegistry = []) => { - const parts = []; - const systemChunks = []; - const userContents = []; - const assistantToolCalls = new Map(); - const formatRoleLine = (role, name, text) => { - const roleLabel = role.toUpperCase(); - const nameSuffix = name ? `(${name})` : ''; - return `${roleLabel}${nameSuffix}: ${text}`; - }; - - for (const m of rawMessages) { - const role = (m?.role || 'user').toLowerCase(); - const content = m?.content; - - if (role === 'system') { - const text = normalizeMessageContent(content); - if (text) systemChunks.push(text); - continue; - } - - if (role === 'assistant' && Array.isArray(m?.tool_calls) && m.tool_calls.length) { - const serializedToolCalls = m.tool_calls.map((toolCall, index) => ({ - id: toolCall?.id || `call_${index + 1}`, - name: findExternalToolByName(externalToolRegistry, toolCall?.function?.name || toolCall?.name)?.namespacedName || toolCall?.function?.name || toolCall?.name, - arguments: normalizeToolArguments(toolCall?.function?.arguments ?? toolCall?.arguments) - })).filter((toolCall) => toolCall.name); - if (serializedToolCalls.length) { - serializedToolCalls.forEach((toolCall) => { - assistantToolCalls.set(toolCall.id, toolCall.name); - }); - parts.push({ - type: 'text', - text: `ASSISTANT: ${JSON.stringify(serializedToolCalls)}` - }); - } - } - - if (role === 'tool') { - const text = normalizeMessageContent(content); - if (text) { - const mappedTool = findExternalToolByName(externalToolRegistry, m?.name) - || findExternalToolByName(externalToolRegistry, assistantToolCalls.get(m?.tool_call_id)); - const toolName = mappedTool?.namespacedName || assistantToolCalls.get(m?.tool_call_id) || m?.name || `${EXTERNAL_TOOL_PREFIX}unknown`; - const toolCallId = m?.tool_call_id || `call_${toolName.replace(/[^a-zA-Z0-9_]/g, '_')}`; - parts.push({ - type: 'text', - text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: toolCallId, name: toolName, content: text })}` - }); - } - continue; - } - - if (!content) continue; - - if (typeof content === 'string') { - if (role === 'user') userContents.push(content); - parts.push({ - type: 'text', - text: formatRoleLine(role, m?.name, content) - }); - } else if (Array.isArray(content)) { - for (const part of content) { - if (!part) continue; - - if (part.type === 'text') { - const text = part.text || ''; - if (role === 'user') userContents.push(text); - parts.push({ - type: 'text', - text: formatRoleLine(role, m?.name, text) - }); - } else if (part.type === 'image_url') { - const imageUrl = typeof part.image_url === 'string' - ? part.image_url - : part.image_url?.url; - if (imageUrl) { - try { - const dataUri = await getImageDataUri(imageUrl); - const mime = dataUri.split(';')[0].split(':')[1]; - parts.push({ - type: 'file', - mime: mime, - url: dataUri, - filename: 'image' - }); - } catch (imgErr) { - console.warn('[Proxy] Skipping image due to error:', imgErr.message); - } - } - } - } - } - } - - return { - parts, - system: systemChunks.join('\n\n'), - fullPromptText: parts.map(p => p.text).join('\n\n'), - lastUserMsg: userContents[userContents.length - 1] || '' - }; - }; - - const requestToolContext = createRequestToolContext(tools, tool_choice, requestOpencodeConfig); - const toolMode = requestToolContext.mode; - const externalToolContext = requestToolContext.external; - const externalToolRegistry = externalToolContext.registry; - const externalToolChoice = externalToolContext.toolChoice; - const internalToolContext = requestToolContext.internal; - trackToolMode(toolMode, { - configuredAllowlist: internalToolContext.allowedToolNames, - requestedAllowlist: internalToolContext.requestedAllowlist, - deniedRequestedTools: internalToolContext.deniedRequestedTools, - resolutionPath: internalToolContext.resolutionPath, - resultingMode: internalToolContext.resultingMode, - route: '/v1/chat/completions' - }); - - const { parts, system: systemMsg, fullPromptText, lastUserMsg } = await buildPromptParts(messages, externalToolRegistry); - const systemWithGuard = buildSystemPrompt( - [systemMsg, externalToolContext.prompt].filter(Boolean).join('\n\n'), - requestParams.reasoning_effort, - toolMode, - internalToolContext.allowedToolNames - ); - if (!parts.length) { - return res.status(400).json({ error: { message: 'messages must include at least one non-system text message' } }); - } - logDebug('Request start', { - model: `${pID}/${mID}`, - stream: Boolean(stream), - userMessages: messages.length, - system: Boolean(systemMsg), - lastUserLength: lastUserMsg?.length || 0, - parts: parts.length, - disableTools: DISABLE_TOOLS, - toolMode, - internalAllowedTools: internalToolContext.allowedToolNames, - requestedInternalTools: internalToolContext.requestedAllowlist, - deniedRequestedTools: internalToolContext.deniedRequestedTools, - resolutionPath: internalToolContext.resolutionPath, - resultingMode: internalToolContext.resultingMode - }); - - // Ensure backend is running - await ensureBackend(config); - - // Set active model - try { - await client.config.update({ - body: { - activeModel: { providerID: pID, modelID: mID } - } - }); - } catch (confError) { - logDebug('Failed to set active model:', confError.message); - } - - // Create session - const sessionRes = await client.session.create(); - sessionId = sessionRes.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session'); - logDebug('Session created', { sessionId }); - - id = `chatcmpl-${crypto.randomUUID()}`; - keepaliveInterval = null; - let completionTokens = 0; - let reasoningTokens = 0; - - const promptParams = { - path: { id: sessionId }, - body: { - model: { providerID: pID, modelID: mID }, - system: systemWithGuard, - // Append a short contract reminder as the last part so the model - // sees it immediately before generating. With the contract only in - // the 16KB+ system prompt it gets buried; position matters a lot for - // compliance. deepseek-v4-flash-free: 50% → 100% call rate. - parts: externalToolContext.reminder - ? [...parts, { type: 'text', text: externalToolContext.reminder }] - : parts, - ...(requestParams.max_tokens && { max_tokens: requestParams.max_tokens }), - ...(requestParams.temperature !== undefined && { temperature: requestParams.temperature }), - ...(requestParams.top_p !== undefined && { top_p: requestParams.top_p }), - ...(requestParams.stop && { stop: requestParams.stop }) - } - }; - const toolOverrides = await getToolOverridesForMode(toolMode, internalToolContext); - if (toolOverrides && Object.keys(toolOverrides).length > 0) { - promptParams.body.tools = toolOverrides; - } - - const makeForcedChatToolCallRequester = () => createForcedToolCallRequester({ - mode: externalToolChoice.mode, - sessionId, - systemWithGuard, - requiredTool: externalToolChoice.requiredTool || externalToolRegistry[0]?.namespacedName, - providerID: pID, - modelID: mID, - toolOverrides, - requestTimeoutMs: REQUEST_TIMEOUT_MS, - forbidThinkBlock: true - }); - let requestForcedChatToolCall = makeForcedChatToolCallRequester(); - - res.setHeader('Content-Type', stream ? 'text/event-stream' : 'application/json'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - - if (stream) { - const shouldStripStreamingToolMarkup = externalToolRegistry.length > 0; - const filterContentDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldStripStreamingToolMarkup }); - const filterReasoningDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldStripStreamingToolMarkup }); - const parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); - const parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); - let streamedContent = ''; - let streamedReasoning = ''; - let rawStreamedContent = ''; - let rawStreamedReasoning = ''; - const streamedToolCalls = []; - keepaliveInterval = null; - completionTokens = 0; - reasoningTokens = 0; - - const ensureKeepalive = () => { - if (!keepaliveInterval) { - keepaliveInterval = setInterval(() => { - if (!res.destroyed) { - res.write(': keepalive\n\n'); - } - }, 15000); - } - }; - ensureKeepalive(); - - const sendDelta = (delta, isReasoning = false) => { - if (!delta) return; - if (isReasoning) rawStreamedReasoning += delta; - else rawStreamedContent += delta; - const parsedDeltaToolCalls = isReasoning - ? parseReasoningToolCalls(delta) - : parseContentToolCalls(delta); - parsedDeltaToolCalls.forEach((toolCall) => { - streamedToolCalls.push(toolCall); - res.write(`data: ${JSON.stringify({ - id, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: `${pID}/${mID}`, - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: streamedToolCalls.length - 1, - id: toolCall.id, - type: 'function', - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments - } - }] - }, - finish_reason: null - }] - })}\n\n`); - }); - const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); - if (!filtered) return; - if (isReasoning) { - streamedReasoning += filtered; - reasoningTokens += Math.ceil(filtered.length / 4); - } else { - streamedContent += filtered; - completionTokens += Math.ceil(filtered.length / 4); - } - // Reasoning and answer are streamed as separate fields so clients - // that read `reasoning_content` (DeepSeek/Qwen-style) see the thinking - // without it polluting `content`. - const deltaField = isReasoning - ? { reasoning_content: filtered } - : { content: filtered }; - const chunk = { - id, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: `${pID}/${mID}`, - choices: [{ index: 0, delta: deltaField, finish_reason: null }] - }; - res.write(`data: ${JSON.stringify(chunk)}\n\n`); - }; - - let collected = null; - for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt += 1) { - if (attempt > 1) { - // Retry on a fresh session: the failed attempt left an errored - // assistant message in the old one, and re-prompting the same - // session would append a duplicate user turn to the context. - // Safe to rotate because nothing has been streamed yet. - 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(); - sessionId = retrySessionRes.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); - promptParams.path.id = sessionId; - requestForcedChatToolCall = makeForcedChatToolCallRequester(); - streamedContent = ''; - streamedReasoning = ''; - rawStreamedContent = ''; - rawStreamedReasoning = ''; - streamedToolCalls.length = 0; - completionTokens = 0; - reasoningTokens = 0; - await sleep(RETRY_BACKOFF_BASE_MS * attempt); - } - try { - const collectPromise = collectFromEvents( - sessionId, - REQUEST_TIMEOUT_MS, - sendDelta, - DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, - DEFAULT_EVENT_IDLE_TIMEOUT_MS - ); - const safeCollect = collectPromise.catch((err) => ({ __error: err })); - client.session.prompt(promptParams).catch(err => logDebug('Prompt error:', err.message)); - collected = await safeCollect; - } catch (e) { - logDebug('Stream error:', e.message); - } - - const attemptError = collected?.error || collected?.__error || null; - const nothingStreamed = !rawStreamedContent - && !rawStreamedReasoning - && streamedToolCalls.length === 0; - if ( - attemptError - && nothingStreamed - && attempt < RETRY_MAX_ATTEMPTS - && isTransientUpstreamError(attemptError) - ) { - console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${RETRY_MAX_ATTEMPTS}), retrying:`, attemptError.data?.message || attemptError.message || attemptError.name || 'unknown'); - continue; - } - break; - } - - if (collected && collected.__error) { - logDebug('SSE collect error, falling back to polling', { - sessionId, - error: collected.__error?.message - }); - const { content, reasoning, error } = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - if (error && !content && !reasoning) { - sendDelta(`[Proxy Error] ${error.name || 'OpenCodeError'}: ${error.data?.message || error.message || 'Unknown error'}`); - } else { - if (reasoning) sendDelta(reasoning, true); - if (content) sendDelta(content, false); - } - } else if (collected && collected.noData) { - logDebug('Fallback to polling (stream)', { sessionId }); - const { content, reasoning, error } = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - if (error && !content && !reasoning) { - sendDelta(`[Proxy Error] ${error.name || 'OpenCodeError'}: ${error.data?.message || error.message || 'Unknown error'}`); - } else { - if (reasoning) sendDelta(reasoning, true); - if (content) sendDelta(content, false); - } - } else if (collected && collected.idleTimeout) { - logDebug('SSE idle timeout, polling for completion', { sessionId }); - const { content, reasoning, error } = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - if (error && !content && !reasoning) { - sendDelta(`[Proxy Error] ${error.name || 'OpenCodeError'}: ${error.data?.message || error.message || 'Unknown error'}`); - } else { - const remainingReasoning = reasoning && reasoning.startsWith(rawStreamedReasoning) - ? reasoning.slice(rawStreamedReasoning.length) - : reasoning; - const remainingContent = content && content.startsWith(rawStreamedContent) - ? content.slice(rawStreamedContent.length) - : content; - if (remainingReasoning) sendDelta(remainingReasoning, true); - if (remainingContent) sendDelta(remainingContent, false); - } - } - - if (collected && !streamedContent && !streamedReasoning && (collected.reasoning || collected.content)) { - if (collected.reasoning) sendDelta(collected.reasoning, true); - if (collected.content) sendDelta(collected.content, false); - } - - if (!streamedContent && !streamedReasoning) { - logDebug('SSE returned empty, falling back to polling', { sessionId }); - const { content, reasoning, error } = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - if (error && !content && !reasoning) { - sendDelta(`[Proxy Error] ${error.name || 'OpenCodeError'}: ${error.data?.message || error.message || 'Unknown error'}`); - } else { - if (reasoning) sendDelta(reasoning, true); - if (content) sendDelta(content, false); - } - } else if (streamedReasoning && !streamedContent) { - // Reconciliation for reasoning models: the reasoning streamed but the - // answer text never arrived because every delta was tagged as reasoning - // (issue #9). The message snapshot separates the two correctly, so - // recover the missing answer from it instead of returning empty content. - logDebug('Reasoning streamed but no content, reconciling from snapshot', { sessionId }); - const snapshot = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS).catch(() => null); - if (snapshot && snapshot.content) { - const remainingContent = rawStreamedContent - ? snapshot.content.slice(rawStreamedContent.length) - : snapshot.content; - if (remainingContent) sendDelta(remainingContent, false); - } - } - - // Flush held buffers from the stream parsers and filters before final batch parse. - const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : []; - const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : []; - const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : ''; - const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : ''; - const finalReasoningText = rawStreamedReasoning + flushedReasoningText; - const finalContentText = rawStreamedContent + flushedContentText; - - // Parse each channel, then retry on the two joined. Models sometimes open a - // block in reasoning and close it in content, leaving neither channel with a - // complete block. The joined retry only runs when nothing was found, so a - // block contained in one channel is never counted twice. - const parseStreamedToolCalls = () => { - if (externalToolRegistry.length === 0) return []; - const perChannel = [ - ...flushedReasoningCalls, - ...flushedContentCalls, - ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText) - ]; - if (perChannel.length > 0) return perChannel; - return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); - }; - - let parsedToolCalls = streamedToolCalls.length > 0 - ? streamedToolCalls - : parseStreamedToolCalls(); - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedChatToolCall(); - if (forcedResponse) { - parsedToolCalls = parseExternalToolCallsFromText( - externalToolRegistry, - forcedResponse.reasoning, - forcedResponse.content - ); - } - } - const { validCalls: validatedStreamedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const finalStreamedToolCalls = validatedStreamedToolCalls; - if (finalStreamedToolCalls.length > 0 && streamedToolCalls.length === 0) { - const toolCallDeltas = finalStreamedToolCalls.map((toolCall, index) => ({ - index, - id: toolCall.id, - type: 'function', - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments - } - })); - res.write(`data: ${JSON.stringify({ - id, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: `${pID}/${mID}`, - choices: [{ - index: 0, - delta: { tool_calls: toolCallDeltas }, - finish_reason: null - }] - })}\n\n`); - } - - if (keepaliveInterval) clearInterval(keepaliveInterval); - - const promptTokens = Math.ceil((fullPromptText || '').length / 4); - const totalTokens = promptTokens + completionTokens + reasoningTokens; - - res.write(`data: ${JSON.stringify({ - id, - choices: [{ index: 0, delta: {}, finish_reason: finalStreamedToolCalls.length > 0 ? 'tool_calls' : 'stop' }], - usage: { - prompt_tokens: promptTokens, - completion_tokens: completionTokens + reasoningTokens, - total_tokens: totalTokens, - completion_tokens_details: { - reasoning_tokens: reasoningTokens - } - } - })}\n\n`); - res.write('data: [DONE]\n\n'); - res.end(); - } else { - let content = ''; - let reasoning = ''; - let error = null; - for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt += 1) { - if (attempt > 1) { - // Retry on a fresh session: the failed attempt left an errored - // assistant message in the old one, and re-prompting the same - // session would append a duplicate user turn to the context. - 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(); - sessionId = retrySessionRes.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); - promptParams.path.id = sessionId; - requestForcedChatToolCall = makeForcedChatToolCallRequester(); - await sleep(RETRY_BACKOFF_BASE_MS * attempt); - } - const attemptStart = Date.now(); - await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); - logDebug('Prompt sent', { sessionId, ms: Date.now() - attemptStart, attempt }); - const collected = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - content = collected.content || ''; - reasoning = collected.reasoning || ''; - error = collected.error || null; - // Bounded retry for upstream throttling mislabeled as billing - // errors (401 CreditsError etc.); only when nothing usable was - // produced, so real failures still surface after RETRY_MAX_ATTEMPTS. - if ( - error - && !content - && !reasoning - && attempt < RETRY_MAX_ATTEMPTS - && isTransientUpstreamError(error) - ) { - console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${RETRY_MAX_ATTEMPTS}), retrying:`, error.data?.message || error.message || error.name || 'unknown'); - continue; - } - break; - } - if (error && !content && !reasoning) { - return res.status(502).json({ - error: { - message: error.data?.message || error.message || 'OpenCode provider error', - type: error.name || 'OpenCodeError' - } - }); - } - let parsedToolCalls = externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) - : []; - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedChatToolCall(); - if (forcedResponse) { - content = forcedResponse.content || content; - reasoning = forcedResponse.reasoning || reasoning; - parsedToolCalls = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); - } - } - const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); - - const promptTokens = Math.ceil((fullPromptText || '').length / 4); - const completionTokensCalc = Math.ceil((content || '').length / 4); - const reasoningTokensCalc = Math.ceil((reasoning || '').length / 4); - const totalTokens = promptTokens + completionTokensCalc + reasoningTokensCalc; - - const publicValidatedToolCalls = toPublicToolCalls(validatedToolCalls); - // Reasoning is emitted in its own `reasoning_content` field so clients - // can surface the thinking without it being wrapped in tags and - // mixed into the answer `content`. - const assistantMessage = { - role: 'assistant', - content: publicValidatedToolCalls.length > 0 - ? (safeContent || null) - : safeContent, - ...(safeReasoning ? { reasoning_content: safeReasoning } : {}) - }; - if (publicValidatedToolCalls.length > 0) { - assistantMessage.tool_calls = publicValidatedToolCalls; - } - - res.json({ - id: `chatcmpl-${crypto.randomUUID()}`, - object: 'chat.completion', - created: Math.floor(Date.now() / 1000), - model: `${pID}/${mID}`, - choices: [{ - index: 0, - message: assistantMessage, - finish_reason: publicValidatedToolCalls.length > 0 ? 'tool_calls' : 'stop' - }], - usage: { - prompt_tokens: promptTokens, - completion_tokens: completionTokensCalc + reasoningTokensCalc, - total_tokens: totalTokens, - completion_tokens_details: { - reasoning_tokens: reasoningTokensCalc - } - } - }); - } - } catch (error) { - console.error('[Proxy] API Error:', error.message); - console.error('[Proxy] Error details:', error); - - if (keepaliveInterval) clearInterval(keepaliveInterval); - - if (!res.headersSent) { - const transformed = transformUpstreamError(error); - res.status(transformed.statusCode).json(transformed.error); - } else if (!res.destroyed) { - res.write(`data: ${JSON.stringify({ error: { message: error.message } })}\n\n`); - res.end(); - } - if (sessionId) { - try { - await client.session.delete({ path: { id: sessionId } }); - } catch (e) { - console.error('[Proxy] Failed to cleanup session on error:', e.message); - } - } - } finally { - if (typeof keepaliveInterval !== 'undefined' && keepaliveInterval) clearInterval(keepaliveInterval); - if (eventStream && eventStream.close) { - eventStream.close(); - } - } - }, REQUEST_TIMEOUT_MS + 20000); - } catch (error) { - console.error('[Proxy] Request Handler Error:', error.message); - if (!res.headersSent) { - res.status(500).json({ error: { message: error.message, type: error.constructor.name } }); - } - } - }); - - const hasValidBearerAuth = (req) => { - if (!API_KEY || API_KEY.trim() === '') return true; - const authHeader = req.headers.authorization; - return Boolean(authHeader && authHeader === `Bearer ${API_KEY}`); - }; - - const shouldAllowOperationalEndpoint = (req, { enabled, requireAuth }) => { - if (!enabled) return false; - if (!requireAuth) return true; - return hasValidBearerAuth(req); - }; - - app.get('/health', (_req, res) => res.json({ - status: 'ok', - proxy: true - })); - - app.get('/health/details', (req, res) => { - if (!shouldAllowOperationalEndpoint(req, { - enabled: HEALTH_DETAILS_ENABLED, - requireAuth: HEALTH_DETAILS_REQUIRE_AUTH - })) { - return res.status(HEALTH_DETAILS_ENABLED ? 401 : 404).json({ - error: { message: HEALTH_DETAILS_ENABLED ? 'Unauthorized' : 'Not found' } - }); - } - const metricsSnapshot = INTERNAL_TOOL_METRICS_ENABLED ? { ...internalToolMetrics } : null; - res.json({ - status: 'ok', - proxy: true, - internal_tools: { - config: { - allowed_tools: SERVER_INTERNAL_ALLOWED_TOOL_NAMES, - metrics_enabled: INTERNAL_TOOL_METRICS_ENABLED, - discovery_fixture: normalizeConfiguredToolNames(INTERNAL_TOOL_DISCOVERY_FIXTURE) - }, - metrics: metricsSnapshot, - cache: { - tool_ids_cached: !!cachedToolIds, - tool_id_count: cachedToolIds ? cachedToolIds.length : 0, - age_ms: cachedToolIdsAt ? Date.now() - cachedToolIdsAt : null - }, - audit: { - available: true, - fields: [ - 'requestedAllowlist', - 'allowedToolNames', - 'deniedRequestedTools', - 'resolutionPath', - 'resultingMode' - ] - } - } - }); - }); - - app.get('/metrics', (req, res) => { - if (!shouldAllowOperationalEndpoint(req, { - enabled: METRICS_ENABLED, - requireAuth: METRICS_REQUIRE_AUTH - })) { - return res.status(METRICS_ENABLED ? 401 : 404).send(METRICS_ENABLED ? 'Unauthorized' : 'Not found'); - } - - const metricsLines = [ - '# HELP opencode_internal_tool_mode_requests_total Count of internal tool mode selections by mode.', - '# TYPE opencode_internal_tool_mode_requests_total counter', - `opencode_internal_tool_mode_requests_total{mode="external_bridge"} ${internalToolMetrics.externalBridgeRequests}`, - `opencode_internal_tool_mode_requests_total{mode="internal_allowlist"} ${internalToolMetrics.internalAllowlistRequests}`, - `opencode_internal_tool_mode_requests_total{mode="disabled"} ${internalToolMetrics.disabledRequests}`, - '# HELP opencode_internal_tool_discovery_failures_total Count of backend tool discovery failures.', - '# TYPE opencode_internal_tool_discovery_failures_total counter', - `opencode_internal_tool_discovery_failures_total ${internalToolMetrics.discoveryFailures}`, - '# HELP opencode_internal_tool_fallback_disabled_total Count of allowlist resolutions that fell back to disabled.', - '# TYPE opencode_internal_tool_fallback_disabled_total counter', - `opencode_internal_tool_fallback_disabled_total ${internalToolMetrics.fallbackToDisabled}`, - '# HELP opencode_internal_tool_cache_ids Number of cached backend tool IDs.', - '# TYPE opencode_internal_tool_cache_ids gauge', - `opencode_internal_tool_cache_ids ${cachedToolIds ? cachedToolIds.length : 0}` - ]; - - res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); - res.send(`${metricsLines.join('\n')}\n`); - }); - - app.post('/v1/responses', async (req, res) => { - try { - const { - model, - input, - reasoning_effort, - reasoning: requestReasoning, - max_output_tokens, - tools = [], - tool_choice, - instructions, - temperature, - top_p, - stream = false, - messages: chatMessages, - prompt, - previous_response_id: previousResponseId, - opencode: requestOpencodeConfig - } = req.body; - - // Stateful continuation: reuse the session behind a previous response so the - // client only needs to send the new turn (OpenAI Responses API semantics). - const previousState = previousResponseId ? getResponseState(previousResponseId) : null; - if (previousResponseId && !previousState) { - return res.status(400).json({ error: { message: 'Invalid or expired previous_response_id' } }); - } - - const reasoningLevel = normalizeReasoningEffort( - reasoning_effort || requestReasoning?.effort, - null - ); - - const requestToolContext = createRequestToolContext(tools, tool_choice, requestOpencodeConfig); - const toolMode = requestToolContext.mode; - const internalToolContext = requestToolContext.internal; - trackToolMode(toolMode, { - configuredAllowlist: internalToolContext.allowedToolNames, - requestedAllowlist: internalToolContext.requestedAllowlist, - deniedRequestedTools: internalToolContext.deniedRequestedTools, - resolutionPath: internalToolContext.resolutionPath, - resultingMode: internalToolContext.resultingMode, - route: '/v1/responses' - }); - logDebug('Responses API request', { - model, - reasoning_effort: reasoning_effort || requestReasoning?.effort, - reasoningLevel, - max_output_tokens, - toolMode, - internalAllowedTools: internalToolContext.allowedToolNames, - requestedInternalTools: internalToolContext.requestedAllowlist, - deniedRequestedTools: internalToolContext.deniedRequestedTools, - resolutionPath: internalToolContext.resolutionPath, - resultingMode: internalToolContext.resultingMode - }); - const externalToolContext = requestToolContext.external; - const externalToolRegistry = externalToolContext.registry; - const externalToolChoice = externalToolContext.toolChoice; - const assistantToolCalls = new Map(); - - const rememberAssistantToolCall = (toolCallId, toolName) => { - if (!toolCallId || !toolName) return; - assistantToolCalls.set(toolCallId, toolName); - }; - - const buildResponsesToolResultLine = (item = {}) => { - const text = normalizeToolResultContent(item?.content ?? item?.output ?? item?.result ?? item?.text); - if (!text) return null; - const mappedTool = findExternalToolByName(externalToolRegistry, item?.name) - || findExternalToolByName(externalToolRegistry, assistantToolCalls.get(item?.call_id || item?.tool_call_id)); - const toolName = mappedTool?.namespacedName || assistantToolCalls.get(item?.call_id || item?.tool_call_id) || item?.name || `${EXTERNAL_TOOL_PREFIX}unknown`; - const toolCallId = item?.call_id || item?.tool_call_id || `call_${toolName.replace(/[^a-zA-Z0-9_]/g, '_')}`; - rememberAssistantToolCall(toolCallId, toolName); - return `TOOL_RESULT: ${JSON.stringify({ tool_call_id: toolCallId, name: toolName, content: text })}`; - }; - - const buildResponsesAssistantToolCallsLine = (item = {}) => { - const sourceCalls = Array.isArray(item?.tool_calls) - ? item.tool_calls - : item?.type === 'function_call' - ? [item] - : []; - if (!sourceCalls.length) return null; - const serializedToolCalls = sourceCalls.map((toolCall, index) => { - const rawName = toolCall?.function?.name || toolCall?.name; - const mappedTool = findExternalToolByName(externalToolRegistry, rawName); - const namespacedName = mappedTool?.namespacedName || rawName; - if (!namespacedName) return null; - const toolCallId = toolCall?.call_id || toolCall?.id || `call_${index + 1}`; - rememberAssistantToolCall(toolCallId, namespacedName); - return { - id: toolCallId, - name: namespacedName, - arguments: normalizeToolArguments(toolCall?.arguments ?? toolCall?.function?.arguments) - }; - }).filter(Boolean); - if (!serializedToolCalls.length) return null; - return `ASSISTANT: ${JSON.stringify(serializedToolCalls)}`; - }; - - const buildResponsesInputMessages = (rawItems) => { - const normalized = []; - if (!Array.isArray(rawItems)) return normalized; - for (const item of rawItems) { - if (!item) continue; - - if (item.type === 'function_call_output' || item.type === 'tool_result' || item.role === 'tool') { - const toolResultLine = buildResponsesToolResultLine(item); - if (toolResultLine) normalized.push({ role: 'tool', content: toolResultLine }); - continue; - } - - if (item.type === 'function_call') { - const assistantToolCallsLine = buildResponsesAssistantToolCallsLine(item); - if (assistantToolCallsLine) normalized.push({ role: 'assistant', content: assistantToolCallsLine, isToolCalls: true }); - continue; - } - - if (item.role === 'assistant' && Array.isArray(item?.tool_calls) && item.tool_calls.length) { - const assistantToolCallsLine = buildResponsesAssistantToolCallsLine(item); - if (assistantToolCallsLine) normalized.push({ role: 'assistant', content: assistantToolCallsLine, isToolCalls: true }); - } - - if (item.type === 'message') { - const role = item.role || 'user'; - const content = normalizeTextContent(item.content); - if (content) normalized.push({ role, content }); - continue; - } - - if (item.type === 'input_text') { - if (item.text) normalized.push({ role: 'user', content: item.text }); - continue; - } - - const text = normalizeTextContent(item.content || item.text); - if (text) normalized.push({ role: item.role || 'user', content: text }); - } - return normalized; - }; - - let messages = []; - if (Array.isArray(chatMessages) && chatMessages.length) { - messages = buildResponsesInputMessages(chatMessages); - } else if (typeof prompt === 'string' && prompt.trim()) { - messages = [{ role: 'user', content: prompt }]; - } else if (typeof input === 'string') { - messages = [{ role: 'user', content: input }]; - } else if (Array.isArray(input)) { - messages = buildResponsesInputMessages(input); - } else if (input && typeof input === 'object') { - if (input.type === 'message' || input.type === 'function_call' || input.type === 'function_call_output' || input.type === 'tool_result') { - messages = buildResponsesInputMessages([input]); - } else { - const content = normalizeTextContent(input.content || input.text); - if (content) { - messages = [{ role: input.role || 'user', content }]; - } - } - } - - if (!messages.length) { - return res.status(400).json({ error: { message: 'input is required' } }); - } - - const resolvedModel = await resolveRequestedModel(model || previousState?.model); - const pID = resolvedModel.providerID; - const mID = resolvedModel.modelID; - - await ensureBackend(config); - - try { - await client.config.update({ - body: { activeModel: { providerID: pID, modelID: mID } } - }); - } catch (e) { } - - // Continue the stored session when chaining from previous_response_id; - // otherwise start a fresh one. - let sessionId = previousState?.sessionId || null; - if (!sessionId) { - const sessionRes = await client.session.create(); - sessionId = sessionRes.data?.id; - if (!sessionId) { - throw new Error('Failed to create OpenCode session'); - } - } - - const parts = []; - const systemChunks = []; - let fullPromptText = ''; - const formatResponsesRoleLine = (role, text) => `${String(role || 'user').toUpperCase()}: ${text}`; - for (const msg of messages) { - if (msg.role === 'system') { - if (msg.content) systemChunks.push(msg.content); - continue; - } - if (!msg.content) continue; - const text = msg.role === 'tool' || String(msg.content).startsWith('ASSISTANT: ') || String(msg.content).startsWith('TOOL_RESULT: ') - ? msg.content - : msg.role === 'user' - ? msg.content - : formatResponsesRoleLine(msg.role, msg.content); - parts.push({ type: 'text', text }); - fullPromptText += `${text}\n\n`; - } - - const systemWithGuard = buildSystemPrompt( - [instructions, ...systemChunks, externalToolContext.prompt].filter(Boolean).join('\n\n'), - reasoningLevel, - toolMode, - internalToolContext.allowedToolNames - ); - - const requestForcedResponsesToolCall = createForcedToolCallRequester({ - mode: externalToolChoice.mode, - sessionId, - systemWithGuard, - requiredTool: externalToolChoice.requiredTool || externalToolRegistry[0]?.namespacedName, - providerID: pID, - modelID: mID, - toolOverrides: await getToolOverridesForMode(toolMode, internalToolContext), - requestTimeoutMs: REQUEST_TIMEOUT_MS, - forbidThinkBlock: false - }); - - const promptParams = { - path: { id: sessionId }, - body: { - model: { providerID: pID, modelID: mID }, - ...(systemWithGuard ? { system: systemWithGuard } : {}), - parts: externalToolContext.reminder - ? [...parts, { type: 'text', text: externalToolContext.reminder }] - : parts, - ...(max_output_tokens && { max_tokens: max_output_tokens }), - ...(temperature !== undefined && { temperature }), - ...(top_p !== undefined && { top_p }) - } - }; - const toolOverrides = await getToolOverridesForMode(toolMode, internalToolContext); - if (toolOverrides && Object.keys(toolOverrides).length > 0) { - promptParams.body.tools = toolOverrides; - } - - let content = ''; - let reasoning = ''; - const buildResponsesFunctionCallOutputItem = (toolCall) => ({ - id: toolCall.id, - type: 'function_call', - status: 'completed', - call_id: toolCall.id, - name: toolCall.function.name, - arguments: toolCall.function.arguments - }); - - const buildResponsesMessageOutputItem = (text) => { - if (!text) return null; - return { - type: 'message', - role: 'assistant', - status: 'completed', - content: [ - { - type: 'output_text', - text - } - ] - }; - }; - - if (stream) { - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - const responseId = `resp_${crypto.randomUUID()}`; - const messageOutputIndex = 0; - const reasoningOutputIndex = 1; - const contentIndex = 0; - const outputItemId = `msg_${crypto.randomUUID()}`; - const reasoningItemId = 'reasoning-0'; - let nextOutputIndex = 2; - let sequenceNumber = 0; - let announcedOutput = false; - let announcedContent = false; - let announcedReasoning = false; - const nextSeq = () => sequenceNumber++; - const emit = (payload) => res.write(`data: ${JSON.stringify(payload)}\n\n`); - - emit({ - type: 'response.created', - sequence_number: nextSeq(), - response: { id: responseId, object: 'response', created: Math.floor(Date.now() / 1000), model: `${pID}/${mID}` } - }); - - const shouldStripStreamingToolMarkup = externalToolRegistry.length > 0; - const filterContentDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldStripStreamingToolMarkup }); - const filterReasoningDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldStripStreamingToolMarkup }); - const parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); - const parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); - const streamedToolCalls = []; - let rawContent = ''; - let rawReasoning = ''; - const ensureOutputScaffold = () => { - if (!announcedOutput) { - emit({ - type: 'response.output_item.added', - sequence_number: nextSeq(), - output_index: messageOutputIndex, - item: { - id: outputItemId, - type: 'message', - status: 'in_progress', - role: 'assistant', - content: [] - } - }); - announcedOutput = true; - } - if (!announcedContent) { - emit({ - type: 'response.content_part.added', - sequence_number: nextSeq(), - output_index: messageOutputIndex, - content_index: contentIndex, - item_id: outputItemId, - part: { type: 'output_text', text: '' } - }); - announcedContent = true; - } - }; - const ensureReasoningScaffold = () => { - if (!announcedReasoning) { - emit({ - type: 'response.output_item.added', - sequence_number: nextSeq(), - output_index: reasoningOutputIndex, - item: { - id: reasoningItemId, - type: 'reasoning', - status: 'in_progress', - summary: [{ type: 'summary_text', text: '' }] - } - }); - announcedReasoning = true; - } - }; - const emitResponsesFunctionCall = (toolCall) => { - const outputIndex = nextOutputIndex++; - const functionCallItem = buildResponsesFunctionCallOutputItem(toolCall); - streamedToolCalls.push(toolCall); - emit({ - type: 'response.output_item.added', - sequence_number: nextSeq(), - output_index: outputIndex, - item: { - ...functionCallItem, - status: 'in_progress' - } - }); - emit({ - type: 'response.function_call_arguments.delta', - sequence_number: nextSeq(), - output_index: outputIndex, - item_id: toolCall.id, - delta: toolCall.function.arguments - }); - emit({ - type: 'response.function_call_arguments.done', - sequence_number: nextSeq(), - output_index: outputIndex, - item_id: toolCall.id, - arguments: toolCall.function.arguments - }); - emit({ - type: 'response.output_item.done', - sequence_number: nextSeq(), - output_index: outputIndex, - item: functionCallItem - }); - }; - const sendResponsesDelta = (delta, isReasoning = false) => { - if (!delta) return; - if (isReasoning) rawReasoning += delta; - else rawContent += delta; - const parsedDeltaToolCalls = isReasoning - ? parseReasoningToolCalls(delta) - : parseContentToolCalls(delta); - if (parsedDeltaToolCalls.length > 0) { - const { validCalls: allowedDeltaToolCalls } = finalizeValidatedToolCalls(parsedDeltaToolCalls, externalToolRegistry); - allowedDeltaToolCalls.forEach((toolCall) => emitResponsesFunctionCall(toolCall)); - } - const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); - if (!filtered) return; - if (isReasoning) { - ensureReasoningScaffold(); - reasoning += filtered; - emit({ - type: 'response.reasoning_summary_text.delta', - sequence_number: nextSeq(), - output_index: reasoningOutputIndex, - item_id: reasoningItemId, - summary_index: 0, - delta: filtered - }); - } else { - if (!filtered.trim()) { - content += filtered; - return; - } - ensureOutputScaffold(); - content += filtered; - emit({ - type: 'response.output_text.delta', - sequence_number: nextSeq(), - output_index: messageOutputIndex, - content_index: contentIndex, - item_id: outputItemId, - delta: filtered - }); - } - }; - - let collected = null; - try { - const collectPromise = collectFromEvents( - sessionId, - REQUEST_TIMEOUT_MS, - sendResponsesDelta, - DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, - DEFAULT_EVENT_IDLE_TIMEOUT_MS - ); - const safeCollect = collectPromise.catch((err) => ({ __error: err })); - client.session.prompt(promptParams).catch(err => logDebug('Responses prompt error:', err.message)); - collected = await safeCollect; - } catch (e) { - collected = { __error: e }; - } - - if (!content && !reasoning) { - const polled = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - if (polled.error && !polled.content && !polled.reasoning) throw polled.error; - if (polled.reasoning) sendResponsesDelta(polled.reasoning, true); - if (polled.content) sendResponsesDelta(polled.content, false); - } else if (collected && collected.idleTimeout) { - const polled = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - const remainingReasoning = polled.reasoning && polled.reasoning.startsWith(rawReasoning) - ? polled.reasoning.slice(rawReasoning.length) - : polled.reasoning; - const remainingContent = polled.content && polled.content.startsWith(rawContent) - ? polled.content.slice(rawContent.length) - : polled.content; - if (remainingReasoning) sendResponsesDelta(remainingReasoning, true); - if (remainingContent) sendResponsesDelta(remainingContent, false); - } else if (collected && (collected.content || collected.reasoning)) { - if (!reasoning && collected.reasoning) sendResponsesDelta(collected.reasoning, true); - if (!content && collected.content) sendResponsesDelta(collected.content, false); - } - - if (announcedReasoning) { - emit({ - type: 'response.reasoning_summary_text.done', - sequence_number: nextSeq(), - output_index: reasoningOutputIndex, - item_id: reasoningItemId, - summary_index: 0, - text: reasoning - }); - emit({ - type: 'response.output_item.done', - sequence_number: nextSeq(), - output_index: reasoningOutputIndex, - item: { - id: reasoningItemId, - type: 'reasoning', - status: 'completed', - summary: [{ type: 'summary_text', text: reasoning }] - } - }); - } - - const hasMeaningfulContent = Boolean(content && content.trim()); - - if (announcedContent && hasMeaningfulContent) { - emit({ - type: 'response.output_text.done', - sequence_number: nextSeq(), - output_index: messageOutputIndex, - content_index: contentIndex, - item_id: outputItemId, - text: content - }); - emit({ - type: 'response.content_part.done', - sequence_number: nextSeq(), - output_index: messageOutputIndex, - content_index: contentIndex, - item_id: outputItemId, - part: { type: 'output_text', text: content } - }); - emit({ - type: 'response.output_item.done', - sequence_number: nextSeq(), - output_index: messageOutputIndex, - item: { - id: outputItemId, - type: 'message', - status: 'completed', - role: 'assistant', - content: [{ type: 'output_text', text: content }] - } - }); - } - - let polledForToolCalls = null; - if (externalToolRegistry.length > 0 && streamedToolCalls.length === 0) { - try { - polledForToolCalls = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - } catch (e) { } - } - - // Flush held buffers from the stream parsers and filters before final batch parse. - const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : []; - const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : []; - const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : ''; - const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : ''; - const finalReasoningText = (polledForToolCalls?.reasoning || rawReasoning) + flushedReasoningText; - const finalContentText = (polledForToolCalls?.content || rawContent) + flushedContentText; - - // Parse each channel, then retry on the two joined. See the matching comment - // in /v1/chat/completions for why the joined retry is gated on finding nothing. - const parseStreamedToolCalls = () => { - if (externalToolRegistry.length === 0) return []; - const perChannel = [ - ...flushedReasoningCalls, - ...flushedContentCalls, - ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText) - ]; - if (perChannel.length > 0) return perChannel; - return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); - }; - - let parsedToolCalls = streamedToolCalls.length > 0 - ? streamedToolCalls - : parseStreamedToolCalls(); - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedResponsesToolCall(); - if (forcedResponse) { - parsedToolCalls = parseExternalToolCallsFromText( - externalToolRegistry, - forcedResponse.reasoning, - forcedResponse.content - ); - } - } - const { validCalls: validatedStreamedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); - if (streamedToolCalls.length === 0) { - validatedStreamedToolCalls.forEach((toolCall) => { - emitResponsesFunctionCall(toolCall); - }); - } - const streamOutput = []; - const streamMessageOutputItem = buildResponsesMessageOutputItem(safeContent && safeContent.trim() ? safeContent : ''); - if (streamMessageOutputItem) streamOutput.push(streamMessageOutputItem); - validatedStreamedToolCalls.forEach((toolCall) => { - streamOutput.push(buildResponsesFunctionCallOutputItem(toolCall)); - }); - const promptTokens = Math.ceil(fullPromptText.length / 4); - const completionTokens = Math.ceil(content.length / 4); - const reasoningTokens = Math.ceil(reasoning.length / 4); - const response = { - id: responseId, - object: 'response', - created: Math.floor(Date.now() / 1000), - model: `${pID}/${mID}`, - reasoning: safeReasoning ? { effort: reasoningLevel, summary: safeReasoning.substring(0, 100) } : undefined, - output: streamOutput, - usage: { - input_tokens: promptTokens, - output_tokens: completionTokens + reasoningTokens, - total_tokens: promptTokens + completionTokens + reasoningTokens, - input_tokens_details: { cached_tokens: 0 }, - output_tokens_details: { reasoning_tokens: reasoningTokens } - } - }; - emit({ type: 'response.completed', sequence_number: nextSeq(), response }); - res.write('data: [DONE]\n\n'); - storeResponseState(responseId, sessionId, `${pID}/${mID}`); - return res.end(); - } - - const responseRes = await client.session.prompt(promptParams); - const responseParts = responseRes.data?.parts || []; - const promptContent = responseParts.filter(p => p.type === 'text').map(p => p.text).join('\n'); - const promptReasoning = responseParts.filter(p => p.type === 'reasoning').map(p => p.text).join('\n'); - const promptParsedToolCalls = externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) - : []; - - content = promptParsedToolCalls.length > 0 ? '' : promptContent; - reasoning = promptReasoning; - - let promptBasedToolCalls = promptParsedToolCalls; - const shouldPollForResponses = !promptContent && !promptReasoning; - if (shouldPollForResponses) { - const polledResponse = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - if (polledResponse.error && !polledResponse.content && !polledResponse.reasoning) { - throw polledResponse.error; - } - content = polledResponse.content || content; - reasoning = polledResponse.reasoning || reasoning; - promptBasedToolCalls = externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) - : []; - } - - if (!content && !reasoning && responseRes.data && promptBasedToolCalls.length === 0) { - const data = responseRes.data; - content = typeof data === 'string' ? data : data?.message || JSON.stringify(data); - } - - let parsedToolCalls = promptBasedToolCalls.length > 0 - ? promptBasedToolCalls - : (externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) - : []); - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedResponsesToolCall(); - if (forcedResponse) { - content = forcedResponse.content || content; - reasoning = forcedResponse.reasoning || reasoning; - parsedToolCalls = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); - } - } - const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); - - const promptTokens = Math.ceil(fullPromptText.length / 4); - const completionTokens = Math.ceil(content.length / 4); - const reasoningTokens = Math.ceil(reasoning.length / 4); - const output = []; - const messageOutputItem = buildResponsesMessageOutputItem(safeContent); - if (messageOutputItem) output.push(messageOutputItem); - validatedToolCalls.forEach((toolCall) => { - output.push(buildResponsesFunctionCallOutputItem(toolCall)); - }); - - const responseId = `resp_${crypto.randomUUID()}`; - const response = { - id: responseId, - object: 'response', - created: Math.floor(Date.now() / 1000), - model: `${pID}/${mID}`, - reasoning: safeReasoning ? { effort: reasoningLevel, summary: safeReasoning.substring(0, 100) } : undefined, - output, - usage: { - input_tokens: promptTokens, - output_tokens: completionTokens + reasoningTokens, - total_tokens: promptTokens + completionTokens + reasoningTokens, - input_tokens_details: { cached_tokens: 0 }, - output_tokens_details: { reasoning_tokens: reasoningTokens } - } - }; - - storeResponseState(responseId, sessionId, `${pID}/${mID}`); - - return res.json(response); - } catch (error) { - 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 - // escapes this async handler as an unhandled rejection, which terminates the whole - // process under Node's default --unhandled-rejections=throw. Report the failure on - // the already-open stream instead. - if (res.headersSent) { - try { - res.write(`data: ${JSON.stringify({ - type: 'response.failed', - response: { error: transformed.error.error || transformed.error } - })}\n\n`); - res.write('data: [DONE]\n\n'); - } catch (writeError) { - logDebug('Failed to report error on open response stream', { error: writeError.message }); - } - return res.end(); - } - return res.status(transformed.statusCode).json(transformed.error); - } - }); - - app.use((req, res) => { - res.status(404).json({ - error: { - message: `Route not found: ${req.method} ${req.path}`, - type: 'not_found_error' - } - }); - }); - - return { app, client }; -} - -// Backend management state (per-instance) -const backendState = new Map(); - -/** - * Backend Lifecycle Management - */ -async function ensureBackend(config) { - const { - OPENCODE_SERVER_URL, - OPENCODE_PATH, - USE_ISOLATED_HOME, - ZEN_API_KEY, - OPENCODE_SERVER_PASSWORD, - MANAGE_BACKEND, - PROMPT_MODE - } = config; - const stateKey = OPENCODE_SERVER_URL; - - if (!backendState.has(stateKey)) { - backendState.set(stateKey, { - isStarting: false, - process: null, - jailRoot: null - }); - } - - const state = backendState.get(stateKey); - - if (state.isStarting) { - // Wait for startup to complete - for (let i = 0; i < STARTING_WAIT_ITERATIONS; i++) { - await new Promise(r => setTimeout(r, STARTING_WAIT_INTERVAL_MS)); - try { - await checkHealth(OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD); - return; - } catch (e) { } - } - throw new Error('Backend startup timeout'); - } - - try { - await checkHealth(OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD); - } catch (err) { - if (!MANAGE_BACKEND) { - for (let i = 0; i < STARTUP_WAIT_ITERATIONS; i++) { - await new Promise(r => setTimeout(r, STARTUP_WAIT_INTERVAL_MS)); - try { - await checkHealth(OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD); - return; - } catch (e) { } - } - throw err; - } - - state.isStarting = true; - console.log(`[Proxy] OpenCode backend not found at ${OPENCODE_SERVER_URL}. Starting...`); - - // Kill existing process if any - if (state.process) { - try { - state.process.kill(); - } catch (e) { } - } - - // Cleanup old temp dir - if (state.jailRoot && fs.existsSync(state.jailRoot)) { - try { - fs.rmSync(state.jailRoot, { recursive: true, force: true }); - } catch (e) { } - } - - const isWindows = process.platform === 'win32'; - const useIsolatedHome = typeof USE_ISOLATED_HOME === 'boolean' - ? USE_ISOLATED_HOME - : String(process.env.OPENCODE_USE_ISOLATED_HOME || '').toLowerCase() === 'true' || - process.env.OPENCODE_USE_ISOLATED_HOME === '1'; - - // On Windows, don't use isolated fake-home to avoid path issues - // On Unix-like systems, use jail for isolation - const salt = Math.random().toString(36).substring(7); - const jailRoot = path.join(os.tmpdir(), 'opencode-proxy-jail', salt); - state.jailRoot = jailRoot; - config.OPENCODE_HOME_BASE = jailRoot; - const workspace = path.join(jailRoot, 'empty-workspace'); - - let envVars; - let cwd; - - if (isWindows) { - // Windows: use normal user home to avoid opencode storage path issues - fs.mkdirSync(workspace, { recursive: true }); - cwd = workspace; - envVars = { - ...process.env, - OPENCODE_PROJECT_DIR: workspace - }; - console.log('[Proxy] Running on Windows, using standard user home directory'); - } else { - fs.mkdirSync(workspace, { recursive: true }); - cwd = workspace; - - if (useIsolatedHome) { - // Unix-like: use isolated fake-home - const fakeHome = path.join(jailRoot, 'fake-home'); - - // Create necessary opencode directories - const opencodeDir = path.join(fakeHome, '.local', 'share', 'opencode'); - const storageDir = path.join(opencodeDir, 'storage'); - const messageDir = path.join(storageDir, 'message'); - const sessionDir = path.join(storageDir, 'session'); - - [fakeHome, opencodeDir, storageDir, messageDir, sessionDir].forEach(d => { - if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true }); - }); - - envVars = { - ...process.env, - HOME: fakeHome, - USERPROFILE: fakeHome, - OPENCODE_PROJECT_DIR: workspace - }; - - if (PROMPT_MODE === 'plugin-inject') { - const configDir = path.join(fakeHome, '.config', 'opencode'); - const pluginDir = path.join(configDir, 'plugin', 'opencode2api-empty'); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.writeFileSync(path.join(pluginDir, 'index.js'), `export const Opencode2apiEmptyPlugin = async () => ({})\nexport default Opencode2apiEmptyPlugin\n`, 'utf8'); - fs.writeFileSync( - path.join(configDir, 'opencode.json'), - JSON.stringify({ - plugin: [path.join(pluginDir, 'index.js')], - instructions: [], - theme: 'system' - }, null, 2), - 'utf8' - ); - console.log('[Proxy] Using plugin-inject prompt mode'); - } - console.log('[Proxy] Using isolated home for OpenCode'); - } else { - envVars = { - ...process.env, - OPENCODE_PROJECT_DIR: workspace - }; - console.log('[Proxy] Using real HOME for OpenCode (isolation disabled)'); - } - } - - const [, , portStr] = OPENCODE_SERVER_URL.split(':'); - const port = portStr ? portStr.split('/')[0] : '10001'; - const resolved = resolveOpencodePath(OPENCODE_PATH); - const opencodeBin = resolved.path || OPENCODE_PATH || OPENCODE_BASENAME; - if (resolved.path) { - console.log(`[Proxy] Using OpenCode binary: ${opencodeBin} (source: ${resolved.source})`); - } else { - console.warn(`[Proxy] Unable to resolve OpenCode binary for '${OPENCODE_PATH}'. Using as-is.`); - } - - // Cross-platform spawn options - const useShell = process.platform === 'win32' || !resolved.path || - opencodeBin.endsWith('.cmd') || opencodeBin.endsWith('.bat'); - const spawnOptions = { - stdio: 'inherit', - cwd: cwd, - env: envVars, - shell: useShell // Use shell only when needed (e.g., Windows .cmd or unresolved PATH) - }; - - const spawnArgs = ['serve', '--port', port, '--hostname', '127.0.0.1']; - if (ZEN_API_KEY) { - spawnArgs.push('--password', ZEN_API_KEY); - } - state.process = spawn(opencodeBin, spawnArgs, spawnOptions); - - // Handle spawn errors - state.process.on('error', (err) => { - console.error(`[Proxy] Failed to spawn OpenCode: ${err.message}`); - if (err.code === 'ENOENT') { - console.error(`[Proxy] Command '${OPENCODE_PATH}' not found. Please ensure OpenCode is installed and in your PATH.`); - console.error(`[Proxy] You can specify the full path in config.json using 'OPENCODE_PATH'`); - } - }); - - // Wait for backend to be ready - let started = false; - for (let i = 0; i < STARTUP_WAIT_ITERATIONS; i++) { - await new Promise(r => setTimeout(r, STARTUP_WAIT_INTERVAL_MS)); - try { - await checkHealth(OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD); - console.log('[Proxy] OpenCode backend ready.'); - started = true; - break; - } catch (e) { } - } - - state.isStarting = false; - - if (!started) { - console.warn('[Proxy] Backend start timed out.'); - throw new Error('Backend start timeout'); - } - } -} - -/** - * Starts the OpenCode-to-OpenAI Proxy server. - */ -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; - } - return undefined; - }; - - const disableTools = - normalizeBool(options.DISABLE_TOOLS) ?? - normalizeBool(options.disableTools) ?? - normalizeBool(process.env.OPENCODE_DISABLE_TOOLS) ?? - false; - - 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'; - const externalToolsConflictPolicy = options.EXTERNAL_TOOLS_CONFLICT_POLICY || options.externalToolsConflictPolicy || process.env.OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY || 'namespace'; - const cleanupIntervalMs = Number(options.CLEANUP_INTERVAL_MS || process.env.OPENCODE_PROXY_CLEANUP_INTERVAL_MS || 12 * 60 * 60 * 1000); - const cleanupMaxAgeMs = Number(options.CLEANUP_MAX_AGE_MS || process.env.OPENCODE_PROXY_CLEANUP_MAX_AGE_MS || 24 * 60 * 60 * 1000); - - if (externalToolsMode !== 'proxy-bridge') { - throw new Error(`Unsupported EXTERNAL_TOOLS_MODE: ${externalToolsMode}. Supported value: proxy-bridge`); - } - if (externalToolsConflictPolicy !== 'namespace') { - throw new Error(`Unsupported EXTERNAL_TOOLS_CONFLICT_POLICY: ${externalToolsConflictPolicy}. Supported value: namespace`); - } - - const config = { - PORT: options.PORT || 10000, - API_KEY: options.API_KEY || '', - 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', - USE_ISOLATED_HOME: typeof options.USE_ISOLATED_HOME === 'boolean' - ? options.USE_ISOLATED_HOME - : String(options.USE_ISOLATED_HOME || '').toLowerCase() === 'true' || - options.USE_ISOLATED_HOME === '1' || - String(process.env.OPENCODE_USE_ISOLATED_HOME || '').toLowerCase() === 'true' || - process.env.OPENCODE_USE_ISOLATED_HOME === '1', - REQUEST_TIMEOUT_MS: Number(options.REQUEST_TIMEOUT_MS || process.env.OPENCODE_PROXY_REQUEST_TIMEOUT_MS || DEFAULT_REQUEST_TIMEOUT_MS), - MANAGE_BACKEND: normalizeBool(options.MANAGE_BACKEND) ?? - normalizeBool(process.env.OPENCODE_PROXY_MANAGE_BACKEND) ?? - true, - DISABLE_TOOLS: disableTools, - EXTERNAL_TOOLS_MODE: externalToolsMode, - EXTERNAL_TOOLS_CONFLICT_POLICY: externalToolsConflictPolicy, - INTERNAL_WEB_FETCH_ENABLED: normalizeBool(options.INTERNAL_WEB_FETCH_ENABLED) ?? - normalizeBool(process.env.OPENCODE_INTERNAL_WEB_FETCH_ENABLED) ?? - false, - INTERNAL_ALLOWED_TOOLS: Array.isArray(options.INTERNAL_ALLOWED_TOOLS) - ? options.INTERNAL_ALLOWED_TOOLS - : typeof process.env.OPENCODE_INTERNAL_ALLOWED_TOOLS === 'string' - ? process.env.OPENCODE_INTERNAL_ALLOWED_TOOLS.split(',').map(entry => entry.trim()).filter(Boolean) - : [], - INTERNAL_TOOL_METRICS_ENABLED: normalizeBool(options.INTERNAL_TOOL_METRICS_ENABLED) ?? - normalizeBool(process.env.OPENCODE_INTERNAL_TOOL_METRICS_ENABLED) ?? - true, - INTERNAL_TOOL_DISCOVERY_FIXTURE: Array.isArray(options.INTERNAL_TOOL_DISCOVERY_FIXTURE) - ? options.INTERNAL_TOOL_DISCOVERY_FIXTURE - : typeof process.env.OPENCODE_TOOL_DISCOVERY_FIXTURE === 'string' - ? process.env.OPENCODE_TOOL_DISCOVERY_FIXTURE.split(',').map(entry => entry.trim()).filter(Boolean) - : [], - HEALTH_DETAILS_ENABLED: normalizeBool(options.HEALTH_DETAILS_ENABLED) ?? - normalizeBool(process.env.OPENCODE_HEALTH_DETAILS_ENABLED) ?? - true, - HEALTH_DETAILS_REQUIRE_AUTH: normalizeBool(options.HEALTH_DETAILS_REQUIRE_AUTH) ?? - normalizeBool(process.env.OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH) ?? - true, - METRICS_ENABLED: normalizeBool(options.METRICS_ENABLED) ?? - normalizeBool(process.env.OPENCODE_METRICS_ENABLED) ?? - false, - METRICS_REQUIRE_AUTH: normalizeBool(options.METRICS_REQUIRE_AUTH) ?? - normalizeBool(process.env.OPENCODE_METRICS_REQUIRE_AUTH) ?? - true, - DEBUG: String(options.DEBUG || '').toLowerCase() === 'true' || - options.DEBUG === '1' || - String(process.env.OPENCODE_PROXY_DEBUG || '').toLowerCase() === 'true' || - process.env.OPENCODE_PROXY_DEBUG === '1', - ZEN_API_KEY: options.ZEN_API_KEY || process.env.OPENCODE_ZEN_API_KEY || '', - PROMPT_MODE: promptMode, - OMIT_SYSTEM_PROMPT: normalizeBool(options.OMIT_SYSTEM_PROMPT) ?? - normalizeBool(process.env.OPENCODE_PROXY_OMIT_SYSTEM_PROMPT) ?? - promptMode === 'plugin-inject', - AUTO_CLEANUP_CONVERSATIONS: normalizeBool(options.AUTO_CLEANUP_CONVERSATIONS) ?? - normalizeBool(process.env.OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS) ?? - false, - CLEANUP_INTERVAL_MS: Number.isFinite(cleanupIntervalMs) && cleanupIntervalMs > 0 ? cleanupIntervalMs : 12 * 60 * 60 * 1000, - CLEANUP_MAX_AGE_MS: Number.isFinite(cleanupMaxAgeMs) && cleanupMaxAgeMs > 0 ? cleanupMaxAgeMs : 24 * 60 * 60 * 1000, - OPENCODE_HOME_BASE: options.OPENCODE_HOME_BASE || null - }; - - const { app } = createApp(config); - - const server = app.listen(config.PORT, config.BIND_HOST, async () => { - console.log(`[Proxy] Active at http://${config.BIND_HOST}:${config.PORT}`); - try { - await ensureBackend(config); - } catch (error) { - console.error('[Proxy] Backend warmup failed:', error.message); - } - }); - - return { - server, - killBackend: () => { - const state = backendState.get(config.OPENCODE_SERVER_URL); - if (state && state.process) { - state.process.kill(); - } - // Cleanup temp dir (only on non-Windows where we use jail) - if (state && state.jailRoot && process.platform !== 'win32') { - try { - fs.rmSync(state.jailRoot, { recursive: true, force: true }); - } catch (e) { } - } - } - }; -} - -// --- Mutex Logic with Timeout --- diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..892c173 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,960 @@ +// P4 TS: thin proxy core (ported from P3 .js, behavior identical). +import express from 'express'; +import cors from 'cors'; +import bodyParser from 'body-parser'; +import fs from 'fs'; +import path from 'path'; +import { createOpencodeClient } from '@opencode-ai/sdk'; +import type { OpencodeClient } from '@opencode-ai/sdk'; +import type { Application, Request, Response, NextFunction } from 'express'; +import { buildExternalToolRegistry, findExternalToolByName } from './tool-runtime/registry.js'; +import { EXTERNAL_TOOL_PREFIX } from './tool-runtime/contracts.js'; +import { resolveMaxRetries, computeRetryDelay } from './retry/policy.js'; +import { + validateMessagesRequest, + extractSystemText, + anthropicMessagesToChatMessages, + anthropicToolsToChatTools, + anthropicToolChoiceToChat, + anthropicThinkingToReasoningEffort, + mapFinishToStopReason, + buildAnthropicMessage, + sseEvent, + estimateTokens, +} from './converters/anthropic.js'; +import { buildToolExposure } from './tool-runtime/router.js'; +import { evaluateToolPolicy } from './tool-runtime/policy.js'; +import { validateToolCalls } from './tool-runtime/validator.js'; +import { + stripFunctionCallMarkup, + parseExternalToolCallsFromText, + createToolCallFilter, + createExternalToolCallStreamParser, +} from './tool-runtime/parser.js'; +import type { ExternalToolEntry } from './tool-runtime/registry.js'; +import type { ValidatedToolCall } from './tool-runtime/validator.js'; +import type { FinalToolCall } from './tool-runtime/parser.js'; + +import { + normalizeBool, + resolveDisableTools, + withTimeout, + buildProxyConfig, + DEFAULT_REQUEST_TIMEOUT_MS, +} from './config/proxy-config.js'; +import { buildBackendAuthHeaders, ensureBackend, backendState } from './backend/manager.js'; +import { createCollector } from './stream/collector.js'; +import { registerChatRoutes } from './routes/chat.js'; +import { registerResponsesRoutes } from './routes/responses.js'; +import { registerMessagesRoutes } from './routes/messages.js'; +import { registerSystemRoutes, registerNotFoundRoute } from './routes/system.js'; +import type { ProxyConfig } from './types/config.js'; +import type { + AppContext, + CreateAppResult, + StartProxyResult, + ForcedToolCallRequesterOptions, + InternalToolMetrics, +} from './types/context.js'; +import type { ProxyClient, ProviderInfo, ModelInfo, ResolvedModel } from './types/client.js'; +import type { ResponseStateEntry } from './types/backend.js'; + +// P4: thin re-exports to preserve original import paths +// (tests/env-alias.test.js, stream-hardening.test.js import these from '../src/proxy.js'). +export { normalizeBool, resolveDisableTools, withTimeout }; + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function toErrorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + const r = asRecord(e); + const m: unknown = r['message']; + return typeof m === 'string' ? m : String(e); +} + +export function createApp(config: ProxyConfig): CreateAppResult { + const { + API_KEY, + OPENCODE_SERVER_URL, + OPENCODE_SERVER_PASSWORD, + REQUEST_TIMEOUT_MS = DEFAULT_REQUEST_TIMEOUT_MS, + DEBUG, + DISABLE_TOOLS, + INTERNAL_WEB_FETCH_ENABLED, + INTERNAL_ALLOWED_TOOLS = [], + INTERNAL_TOOL_METRICS_ENABLED = true, + INTERNAL_TOOL_DISCOVERY_FIXTURE = [], + HEALTH_DETAILS_ENABLED = true, + HEALTH_DETAILS_REQUIRE_AUTH = true, + METRICS_ENABLED = false, + METRICS_REQUIRE_AUTH = true, + PROMPT_MODE, + OMIT_SYSTEM_PROMPT, + AUTO_CLEANUP_CONVERSATIONS, + CLEANUP_INTERVAL_MS, + CLEANUP_MAX_AGE_MS, + OPENCODE_HOME_BASE, + } = config; + + // Effective retry budget: total attempts = 1 + maxRetries. + const maxRetries = resolveMaxRetries(config.RETRY_MAX_RETRIES); + const maxAttempts = maxRetries + 1; + + const app: Application = express(); + app.use( + cors({ + origin: '*', + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'anthropic-version'], + }), + ); + app.use(bodyParser.json({ limit: '50mb' })); + app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); + + const clientHeaders = buildBackendAuthHeaders(OPENCODE_SERVER_PASSWORD); + const rawClient: OpencodeClient = createOpencodeClient({ baseUrl: OPENCODE_SERVER_URL, headers: clientHeaders }); + const client = rawClient as unknown as ProxyClient; + + const isOperationalEndpointBypassed = (req: Request): boolean => { + if (req.path === '/health/details') { + return Boolean(HEALTH_DETAILS_ENABLED) && !HEALTH_DETAILS_REQUIRE_AUTH; + } + if (req.path === '/metrics') { + return Boolean(METRICS_ENABLED) && !METRICS_REQUIRE_AUTH; + } + return false; + }; + void isOperationalEndpointBypassed; + + // Auth middleware (accepts Authorization: Bearer and x-api-key for Anthropic SDK compat) + app.use((req: Request, res: Response, next: NextFunction): void => { + if (req.method === 'OPTIONS' || req.path === '/health' || req.path === '/' || req.path === '/health/details' || req.path === '/metrics') + return next(); + if (API_KEY && (API_KEY as string).trim() !== '') { + const authHeader: unknown = req.headers.authorization; + const apiKeyHeader: unknown = req.headers['x-api-key']; + if (!((authHeader && authHeader === `Bearer ${API_KEY}`) || (apiKeyHeader && apiKeyHeader === API_KEY))) { + if (req.path === '/v1/messages') { + res.status(401).json({ type: 'error', error: { type: 'authentication_error', message: 'Unauthorized' } }); + return; + } + res.status(401).json({ error: { message: 'Unauthorized' } }); + return; + } + } + next(); + }); + + const getProvidersList = async (): Promise => { + const providersRes = (await client.config.providers()) as unknown; + const providersRaw: unknown = asRecord(asRecord(providersRes)['data'])['providers'] ?? []; + if (Array.isArray(providersRaw)) return providersRaw as ProviderInfo[]; + return Object.entries(asRecord(providersRaw)).map(([id, info]) => ({ ...(asRecord(info) as object), id }) as ProviderInfo); + }; + + const buildModelsList = (providersList: unknown): ModelInfo[] => { + const models: ModelInfo[] = []; + const list = Array.isArray(providersList) ? (providersList as ProviderInfo[]) : []; + list.forEach((p) => { + const modelsRaw: unknown = (p as Record)['models']; + if (modelsRaw && typeof modelsRaw === 'object') { + Object.entries(modelsRaw as Record).forEach(([mId, mData]) => { + const md = asRecord(mData); + models.push({ + id: `${p.id}/${mId}`, + name: typeof mData === 'object' ? ((md['name'] ?? md['label'] ?? mId) as string) : mId, + object: 'model', + created: md['release_date'] ? Math.floor(new Date(String(md['release_date'])).getTime() / 1000) : 1704067200, + owned_by: p.id, + }); + }); + } + }); + return models; + }; + + const normalizeModelID = (modelID: unknown): unknown => { + if (!modelID || typeof modelID !== 'string') return modelID; + return modelID.replace(/^gpt(\d)/i, 'gpt-$1').replace(/^o(\d)/i, 'o$1'); + }; + + const resolveRequestedModel = async (requestedModel: unknown): Promise => { + const providersList = await getProvidersList(); + const models = buildModelsList(providersList); + const fallbackModel = models[0]?.id || 'opencode/kimi-k2.5-free'; + const requestedStr = typeof requestedModel === 'string' && requestedModel ? requestedModel : fallbackModel; + let [providerID, modelID] = requestedStr.split('/'); + if (!modelID) { + modelID = providerID as string; + providerID = 'opencode'; + } + const originalModelID = modelID as string; + const normalizedModelID = normalizeModelID(modelID) as string; + const candidateModelIDs = [...new Set([modelID, normalizedModelID].filter(Boolean))] as string[]; + const exact = models.find((m) => candidateModelIDs.some((candidate) => m.id === `${providerID}/${candidate}`)); + if (exact) { + const [, resolvedModelID] = exact.id.split('/'); + return { + providerID: providerID as string, + modelID: resolvedModelID as string, + models, + resolved: exact.id, + ...(resolvedModelID !== originalModelID ? { aliasFrom: `${providerID}/${originalModelID}` } : {}), + }; + } + const sameProvider = models.filter((m) => m.owned_by === providerID); + const suffixMatch = sameProvider.find((m) => + candidateModelIDs.some((candidate) => m.id.endsWith(`/${candidate}-free`) || m.id.endsWith(`/${candidate}`)), + ); + if (suffixMatch) { + const [, resolvedModelID] = suffixMatch.id.split('/'); + return { + providerID: providerID as string, + modelID: resolvedModelID as string, + models, + resolved: suffixMatch.id, + aliasFrom: `${providerID}/${originalModelID}`, + }; + } + const error = new Error(`Model not found: ${providerID}/${modelID}`) as Error & { + statusCode?: number; + code?: string; + availableModels?: string[]; + }; + error.statusCode = 400; + error.code = 'model_not_found'; + error.availableModels = models.map((m) => m.id); + throw error; + }; + + const logDebug = (...args: unknown[]): void => { + if (DEBUG) { + console.log('[Proxy][Debug]', ...args); + } + }; + + const responseState = new Map(); + const RESPONSE_STATE_TTL_MS = 30 * 60 * 1000; + const RESPONSE_STATE_SWEEP_INTERVAL_MS = 60 * 1000; + const getResponseState = (responseId: unknown): ResponseStateEntry | null => { + if (typeof responseId !== 'string') return null; + const state = responseState.get(responseId); + if (!state) return null; + if (state.expiresAt <= Date.now()) { + responseState.delete(responseId); + return null; + } + return state; + }; + const storeResponseState = (responseId: unknown, sessionId: unknown, model: unknown): void => { + if (!responseId || !sessionId || typeof responseId !== 'string' || typeof sessionId !== 'string') return; + responseState.set(responseId, { + sessionId, + model: typeof model === 'string' ? model : String(model ?? ''), + expiresAt: Date.now() + RESPONSE_STATE_TTL_MS, + }); + }; + const sweepResponseState = async (): Promise => { + const now = Date.now(); + const expired: ResponseStateEntry[] = []; + for (const [id, state] of responseState.entries()) { + if (state.expiresAt <= now) { + expired.push(state); + responseState.delete(id); + } + } + if (!expired.length) return; + const liveSessionIds = new Set([...responseState.values()].map((s) => s.sessionId)); + for (const state of expired) { + if (liveSessionIds.has(state.sessionId)) continue; + try { + await client.session.delete({ path: { id: state.sessionId } }); + } catch (e: unknown) { + logDebug('Failed to delete expired response session', { sessionId: state.sessionId, error: toErrorMessage(e) }); + } + } + }; + const responseStateSweepTimer = setInterval(() => { + sweepResponseState().catch(() => {}); + }, RESPONSE_STATE_SWEEP_INTERVAL_MS); + if (typeof responseStateSweepTimer.unref === 'function') responseStateSweepTimer.unref(); + + const TOOL_MODE = Object.freeze({ + DISABLED: 'disabled', + EXTERNAL_BRIDGE: 'external-bridge', + INTERNAL_ALLOWLIST: 'internal-allowlist', + }); + + const TOOL_GUARD_MESSAGE = + 'Tools are disabled. Do not call tools or function calls. Answer directly from the conversation and general knowledge. If external or real-time data is required, say so and ask the user to enable tools.'; + const EXTERNAL_TOOL_GUARD_MESSAGE = + 'OpenCode internal tools remain disabled. If an external tool contract is present, use only that contract and never call or mention OpenCode internal tools.'; + + const normalizeConfiguredToolNames = (entries: unknown = []): string[] => [ + ...new Set( + (Array.isArray(entries) ? (entries as unknown[]) : []) + .map((entry) => String(entry ?? '').trim()) + .filter(Boolean), + ), + ]; + + const getEffectiveInternalAllowedTools = (): string[] => { + const configuredTools = normalizeConfiguredToolNames(INTERNAL_ALLOWED_TOOLS); + if (configuredTools.length > 0) return configuredTools; + if (INTERNAL_WEB_FETCH_ENABLED) return ['web_fetch']; + return []; + }; + + const SERVER_INTERNAL_ALLOWED_TOOL_NAMES = getEffectiveInternalAllowedTools(); + + const buildInternalAllowlistPrompt = (allowedToolNames: unknown = []): string => { + const list = Array.isArray(allowedToolNames) ? (allowedToolNames as string[]) : []; + if (list.length > 0) { + return `OpenCode internal tool access is limited for this turn. You may use only these built-in tools when truly required: ${list.join(', ')}. Do not mention or attempt any other internal tools. If the required internal tools are unavailable, answer directly and say live tool access is unavailable.`; + } + return 'OpenCode internal tools are unavailable for this turn. Answer directly without attempting tool usage.'; + }; + + const buildSystemPrompt = ( + systemMsg: unknown, + reasoningEffort: unknown = null, + toolMode: unknown = TOOL_MODE.DISABLED, + internalAllowedTools: unknown = [], + ): string | undefined => { + const parts: string[] = []; + if (!OMIT_SYSTEM_PROMPT && typeof systemMsg === 'string' && systemMsg.trim()) { + parts.push(systemMsg.trim()); + } + if (reasoningEffort && reasoningEffort !== 'none') { + parts.push(`[Reasoning Effort: ${String(reasoningEffort)}]`); + } + if (toolMode === TOOL_MODE.INTERNAL_ALLOWLIST) { + parts.push(buildInternalAllowlistPrompt(internalAllowedTools)); + } else if (DISABLE_TOOLS && PROMPT_MODE !== 'plugin-inject') { + parts.push(toolMode === TOOL_MODE.EXTERNAL_BRIDGE ? EXTERNAL_TOOL_GUARD_MESSAGE : TOOL_GUARD_MESSAGE); + } + const finalPrompt = parts.join('\n\n').trim(); + return finalPrompt || undefined; + }; + + const normalizeReasoningEffort = (value: unknown, fallback: unknown = null): string | null => { + if (!value || typeof value !== 'string') return fallback as string | null; + const effortMap: Record = { + none: 'none', + minimal: 'none', + low: 'low', + medium: 'medium', + high: 'high', + xhigh: 'high', + }; + return effortMap[value.toLowerCase()] ?? (fallback as string | null); + }; + + const stripFunctionCalls = (text: unknown, trim: boolean = true): unknown => { + if (!DISABLE_TOOLS || !text) return text; + return stripFunctionCallMarkup(String(text), trim); + }; + + const normalizeTextContent = (content: unknown): string => { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return (content as unknown[]) + .map((part) => { + if (typeof part === 'string') return part; + const pr = asRecord(part); + if (typeof pr['text'] === 'string') return pr['text'] as string; + if (pr['type'] === 'input_text' || pr['type'] === 'output_text' || pr['type'] === 'text') + return String(pr['text'] ?? ''); + return ''; + }) + .join(''); + } + const cr = asRecord(content); + if (typeof cr['text'] === 'string') return cr['text'] as string; + if (content === null || content === undefined) return ''; + if (typeof content === 'number' || typeof content === 'boolean') return String(content); + return ''; + }; + + const normalizeToolArguments = (args: unknown): string => { + if (args === undefined || args === null || args === '') return '{}'; + if (typeof args === 'string') return args; + try { + return JSON.stringify(args); + } catch { + return '{}'; + } + }; + + const normalizeToolResultContent = (content: unknown): string => { + const text = normalizeTextContent(content); + if (text) return text; + if (content === null || content === undefined) return ''; + if (typeof content === 'object') { + try { + return JSON.stringify(content); + } catch { + return ''; + } + } + return String(content); + }; + + const createExternalToolContext = ( + tools: unknown, + toolChoice: unknown, + ): AppContext['createExternalToolContext'] extends (...args: never[]) => infer R ? R : never => { + const registry = buildExternalToolRegistry(tools); + const exposure = buildToolExposure(registry, toolChoice); + return { + registry, + exposure, + toolChoice: exposure.toolChoice, + prompt: exposure.prompt, + reminder: exposure.reminder, + } as unknown as AppContext['createExternalToolContext'] extends (...args: never[]) => infer R ? R : never; + }; + + const resolveToolMode = (tools: unknown = [], effectiveInternalAllowlist: unknown = []): string => { + if (Array.isArray(tools) && (tools as unknown[]).length > 0) { + return TOOL_MODE.EXTERNAL_BRIDGE; + } + const allowlist = Array.isArray(effectiveInternalAllowlist) ? (effectiveInternalAllowlist as unknown[]) : []; + if (allowlist.length > 0) { + return TOOL_MODE.INTERNAL_ALLOWLIST; + } + return TOOL_MODE.DISABLED; + }; + + const createRequestToolContext = ( + tools: unknown, + toolChoice: unknown, + requestOpencodeConfig: unknown = undefined, + ): AppContext['createRequestToolContext'] extends (...args: never[]) => infer R ? R : never => { + let effectiveInternalAllowlist = SERVER_INTERNAL_ALLOWED_TOOL_NAMES; + let requestInternalAllowlist: string[] | null = null; + + if (requestOpencodeConfig && typeof requestOpencodeConfig === 'object') { + const cfgRecord = asRecord(requestOpencodeConfig); + if (Array.isArray(cfgRecord['internal_allowed_tools'])) { + requestInternalAllowlist = ((cfgRecord['internal_allowed_tools'] as unknown[]) as unknown[]) + .map((name) => String(name ?? '').trim()) + .filter(Boolean); + } + } + + if (requestInternalAllowlist !== null) { + effectiveInternalAllowlist = SERVER_INTERNAL_ALLOWED_TOOL_NAMES.filter((name) => + (requestInternalAllowlist as string[]).includes(name), + ); + } + + const deniedRequestedTools = requestInternalAllowlist + ? requestInternalAllowlist.filter((name) => !SERVER_INTERNAL_ALLOWED_TOOL_NAMES.includes(name)) + : []; + + const mode = resolveToolMode(tools, effectiveInternalAllowlist); + const external = + mode === TOOL_MODE.EXTERNAL_BRIDGE + ? createExternalToolContext(tools, toolChoice) + : { + registry: [], + exposure: { tools: [], toolChoice: { mode: 'auto', requiredTool: null }, prompt: '', reminder: '' }, + toolChoice: { mode: 'auto', requiredTool: null }, + prompt: '', + reminder: '', + }; + + return { + mode, + external, + internal: { + allowedToolNames: effectiveInternalAllowlist, + requestedAllowlist: requestInternalAllowlist, + deniedRequestedTools, + resolutionPath: requestInternalAllowlist ? 'request-intersection' : 'server-default', + resultingMode: mode, + metricsEnabled: Boolean(INTERNAL_TOOL_METRICS_ENABLED), + }, + } as unknown as AppContext['createRequestToolContext'] extends (...args: never[]) => infer R ? R : never; + }; + + const finalizeValidatedToolCalls = ( + parsedToolCalls: unknown, + registry: unknown, + ): { validCalls: ValidatedToolCall[]; invalidCalls: Array<{ call: unknown; validation: unknown }> } => { + const { validCalls, invalidCalls } = validateToolCalls(parsedToolCalls, registry); + invalidCalls.forEach(({ call, validation }) => { + const callRecord = asRecord(call); + const fn = asRecord(callRecord['function']); + const validationRecord = asRecord(validation); + const errorsRaw: unknown = validationRecord['errors']; + logDebug('Rejected external tool call', { + tool: fn['name'], + errors: Array.isArray(errorsRaw) + ? (errorsRaw as unknown[]).map((error) => asRecord(error)['message']) + : undefined, + }); + }); + const allowedCalls: ValidatedToolCall[] = []; + validCalls.forEach((toolCall) => { + const policyDecision = evaluateToolPolicy(toolCall.tool, toolCall.validatedArguments, { config }); + if (policyDecision.status === 'allow') { + allowedCalls.push(toolCall); + return; + } + const tcRecord = asRecord(toolCall); + const fn = asRecord(tcRecord['function']); + const pdRecord = asRecord(policyDecision); + logDebug('Blocked external tool call', { + tool: fn['name'], + status: pdRecord['status'], + reason: pdRecord['reason'], + }); + }); + return { validCalls: allowedCalls, invalidCalls: invalidCalls as Array<{ call: unknown; validation: unknown }> }; + }; + + const toPublicToolCalls = (toolCalls: unknown): FinalToolCall[] => { + if (!Array.isArray(toolCalls) || (toolCalls as unknown[]).length === 0) return []; + return ((toolCalls as unknown[]) as ValidatedToolCall[]).map((toolCall) => { + const record = toolCall as unknown as Record; + const fn = asRecord(record['function']); + return { + id: String(record['id']), + type: 'function', + function: { + name: String(fn['name']), + arguments: String(fn['arguments']), + }, + } as unknown as FinalToolCall; + }); + }; + + const TOOL_IDS_CACHE_MS = 5 * 60 * 1000; + let cachedToolIds: string[] | null = null; + let cachedToolIdsAt = 0; + let cachedDisabledToolOverrides: Record | null = null; + let cachedDisabledToolOverridesAt = 0; + const internalToolMetrics: InternalToolMetrics = { + externalBridgeRequests: 0, + internalAllowlistRequests: 0, + disabledRequests: 0, + discoveryFailures: 0, + fallbackToDisabled: 0, + }; + + const logInternalToolEvent = (event: unknown, details: unknown = {}): void => { + if (!DEBUG && !INTERNAL_TOOL_METRICS_ENABLED) return; + const payload: Record = { + event, + ...(asRecord(details) as object), + }; + if (INTERNAL_TOOL_METRICS_ENABLED) { + payload['metrics'] = { ...internalToolMetrics }; + } + logDebug('Internal tool event', payload); + }; + + const trackToolMode = (toolMode: unknown, details: unknown = {}): void => { + if (toolMode === TOOL_MODE.EXTERNAL_BRIDGE) { + internalToolMetrics.externalBridgeRequests += 1; + } else if (toolMode === TOOL_MODE.INTERNAL_ALLOWLIST) { + internalToolMetrics.internalAllowlistRequests += 1; + } else { + internalToolMetrics.disabledRequests += 1; + } + logInternalToolEvent('tool-mode-selected', { + toolMode, + ...(asRecord(details) as object), + }); + }; + + const getBackendToolIds = async (): Promise => { + if (cachedToolIds && Date.now() - cachedToolIdsAt < TOOL_IDS_CACHE_MS) { + return cachedToolIds; + } + const fixtureIds = normalizeConfiguredToolNames(INTERNAL_TOOL_DISCOVERY_FIXTURE); + if (fixtureIds.length > 0) { + cachedToolIds = fixtureIds; + cachedToolIdsAt = Date.now(); + logInternalToolEvent('backend-tool-ids-fixture-loaded', { count: fixtureIds.length, fixtureIds }); + return fixtureIds; + } + try { + const idsRes = (await client.tool.ids()) as unknown; + const data: unknown = asRecord(idsRes)['data'] ?? idsRes; + const ids = Array.isArray(data) ? (data as string[]) : []; + cachedToolIds = ids; + cachedToolIdsAt = Date.now(); + logInternalToolEvent('backend-tool-ids-loaded', { count: ids.length }); + return ids; + } catch (e: unknown) { + internalToolMetrics.discoveryFailures += 1; + logInternalToolEvent('backend-tool-ids-failed', { error: toErrorMessage(e) }); + return null; + } + }; + + const buildDisabledToolOverrides = (ids: unknown = []): Record => { + const overrides: Record = {}; + const list = Array.isArray(ids) ? (ids as unknown[]) : []; + list.forEach((id) => { + if (typeof id === 'string') overrides[id] = false; + }); + return overrides; + }; + + const normalizeBackendToolIds = (ids: unknown = []): string[] => + (Array.isArray(ids) ? (ids as unknown[]) : []).filter((id): id is string => typeof id === 'string' && Boolean(id.trim())); + + const matchesAllowedToolName = (toolId: unknown, allowedToolName: unknown): boolean => { + if (!toolId || !allowedToolName || typeof toolId !== 'string' || typeof allowedToolName !== 'string') return false; + return toolId === allowedToolName || toolId.endsWith(`.${allowedToolName}`) || toolId.endsWith(`/${allowedToolName}`); + }; + + const resolveInternalAllowedToolIds = ( + ids: unknown = [], + allowedToolNames: unknown = [], + ): { normalizedIds: string[]; normalizedAllowedNames: string[]; matchedToolIds: string[]; unmatchedAllowedNames: string[] } => { + const normalizedIds = normalizeBackendToolIds(ids); + const normalizedAllowedNames = normalizeConfiguredToolNames(allowedToolNames); + const matchedToolIds = new Set(); + const unmatchedAllowedNames: string[] = []; + + normalizedAllowedNames.forEach((allowedToolName) => { + const matches = normalizedIds.filter((toolId) => matchesAllowedToolName(toolId, allowedToolName)); + if (matches.length === 0) { + unmatchedAllowedNames.push(allowedToolName); + return; + } + matches.forEach((match) => matchedToolIds.add(match)); + }); + + return { + normalizedIds, + normalizedAllowedNames, + matchedToolIds: [...matchedToolIds], + unmatchedAllowedNames, + }; + }; + + const getDisabledToolOverrides = async (): Promise | null> => { + if (!DISABLE_TOOLS) return null; + if (cachedDisabledToolOverrides && Date.now() - cachedDisabledToolOverridesAt < TOOL_IDS_CACHE_MS) { + return cachedDisabledToolOverrides; + } + const ids = await getBackendToolIds(); + if (!Array.isArray(ids)) return null; + const overrides = buildDisabledToolOverrides(ids); + cachedDisabledToolOverrides = overrides; + cachedDisabledToolOverridesAt = Date.now(); + logInternalToolEvent('disabled-tool-overrides-loaded', { count: ids.length }); + return overrides; + }; + + const getToolOverridesForMode = async ( + toolMode: unknown, + internalContext: unknown = {}, + ): Promise | null> => { + const ctxRecord = asRecord(internalContext); + if (toolMode === TOOL_MODE.EXTERNAL_BRIDGE || toolMode === TOOL_MODE.DISABLED) { + if (toolMode === TOOL_MODE.DISABLED) { + const allowed = ctxRecord['allowedToolNames'] ?? SERVER_INTERNAL_ALLOWED_TOOL_NAMES; + logInternalToolEvent('internal-tools-disabled', { + configuredAllowlist: allowed, + }); + } + return getDisabledToolOverrides(); + } + if (toolMode !== TOOL_MODE.INTERNAL_ALLOWLIST) { + return null; + } + const ids = await getBackendToolIds(); + if (!Array.isArray(ids) || (ids as unknown[]).length === 0) return null; + const resolution = resolveInternalAllowedToolIds( + ids, + (ctxRecord['allowedToolNames'] ?? SERVER_INTERNAL_ALLOWED_TOOL_NAMES) as unknown, + ); + const { normalizedIds, normalizedAllowedNames, matchedToolIds, unmatchedAllowedNames } = resolution; + if (matchedToolIds.length === 0) { + internalToolMetrics.fallbackToDisabled += 1; + logInternalToolEvent('internal-allowlist-unavailable', { + configuredAllowlist: normalizedAllowedNames, + availableToolIds: normalizedIds, + unmatchedAllowlist: unmatchedAllowedNames, + fallback: 'disabled', + }); + return buildDisabledToolOverrides(normalizedIds); + } + const overrides: Record = {}; + normalizedIds.forEach((id) => { + overrides[id] = matchedToolIds.includes(id); + }); + logInternalToolEvent('internal-allowlist-overrides-loaded', { + configuredAllowlist: normalizedAllowedNames, + matchedToolIds, + unmatchedAllowlist: unmatchedAllowedNames, + availableToolIdsCount: normalizedIds.length, + }); + return overrides; + }; + + const getCleanupRoots = (): string[] => { + const roots: string[] = []; + const add = (dir: unknown): void => { + if (!dir || typeof dir !== 'string') return; + if (!roots.includes(dir)) roots.push(dir); + }; + add(OPENCODE_HOME_BASE ? path.join(String(OPENCODE_HOME_BASE), '.local', 'share', 'opencode', 'storage') : null); + add('/home/node/.local/share/opencode/storage'); + return roots; + }; + + const cleanupConversationFiles = async (): Promise<{ removed: number; scanned: number }> => { + if (!AUTO_CLEANUP_CONVERSATIONS) return { removed: 0, scanned: 0 }; + const now = Date.now(); + let removed = 0; + let scanned = 0; + for (const storageRoot of getCleanupRoots()) { + for (const sub of ['message', 'session']) { + const dir = path.join(storageRoot, sub); + if (!fs.existsSync(dir)) continue; + let entries: import('fs').Dirent[] = []; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + let stat: import('fs').Stats; + try { + stat = fs.statSync(full); + } catch { + continue; + } + scanned += 1; + const mtime = stat.mtimeMs || stat.ctimeMs || now; + if (now - mtime < Number(CLEANUP_MAX_AGE_MS)) continue; + try { + fs.rmSync(full, { recursive: true, force: true }); + removed += 1; + } catch (e: unknown) { + logDebug('Cleanup remove failed', { full, error: toErrorMessage(e) }); + } + } + } + } + if (removed > 0) { + logDebug('Conversation cleanup completed', { removed, scanned, maxAgeMs: CLEANUP_MAX_AGE_MS }); + } + return { removed, scanned }; + }; + + if (AUTO_CLEANUP_CONVERSATIONS) { + setTimeout(() => { + cleanupConversationFiles().catch((e: unknown) => logDebug('Cleanup run failed', { error: toErrorMessage(e) })); + }, 3000); + const cleanupTimer = setInterval(() => { + cleanupConversationFiles().catch((e: unknown) => logDebug('Cleanup run failed', { error: toErrorMessage(e) })); + }, Number(CLEANUP_INTERVAL_MS)); + if (cleanupTimer.unref) cleanupTimer.unref(); + } + + const createForcedToolCallRequester = + (options: ForcedToolCallRequesterOptions): (() => Promise | null>) => { + const { + mode, + sessionId, + systemWithGuard, + requiredTool, + providerID, + modelID, + toolOverrides, + requestTimeoutMs, + forbidThinkBlock = false, + } = options; + return async (): Promise | null> => { + if (mode !== 'required') return null; + if (!requiredTool) return null; + const forcedPromptParams: { path: { id: string }; body: Record } = { + path: { id: sessionId }, + body: { + model: { providerID, modelID }, + ...(systemWithGuard ? { system: systemWithGuard } : {}), + parts: [ + { + type: 'text', + text: `SYSTEM: Your previous reply did not emit the required external tool call. Reply now with ONLY {"name":"${requiredTool}","arguments":{}} or an array inside .... Do not output any prose, reasoning, markdown${forbidThinkBlock ? ', or block' : ''}. Infer the correct arguments from the conversation so far.`, + }, + ], + }, + }; + if (toolOverrides && Object.keys(toolOverrides).length > 0) { + forcedPromptParams.body['tools'] = toolOverrides; + } + await promptWithTimeout(forcedPromptParams, requestTimeoutMs); + return pollForAssistantResponse(sessionId, requestTimeoutMs) as Promise>; + }; + }; + + // P3: per-instance stream collector (prompt/poll/collect close over client+logDebug, + // same closure semantics as the original createApp inner functions). + const collector = createCollector({ client, logDebug }); + const { promptWithTimeout, pollForAssistantResponse, collectFromEvents, extractFromParts } = collector; + + const ctx: AppContext = { + client, + config, + API_KEY: String(API_KEY ?? ''), + OPENCODE_SERVER_URL: String(OPENCODE_SERVER_URL ?? ''), + OPENCODE_SERVER_PASSWORD: String(OPENCODE_SERVER_PASSWORD ?? ''), + REQUEST_TIMEOUT_MS: Number(REQUEST_TIMEOUT_MS), + DEBUG: Boolean(DEBUG), + DISABLE_TOOLS: Boolean(DISABLE_TOOLS), + INTERNAL_WEB_FETCH_ENABLED: Boolean(INTERNAL_WEB_FETCH_ENABLED), + INTERNAL_ALLOWED_TOOLS: Array.isArray(INTERNAL_ALLOWED_TOOLS) ? (INTERNAL_ALLOWED_TOOLS as string[]) : [], + INTERNAL_TOOL_METRICS_ENABLED: Boolean(INTERNAL_TOOL_METRICS_ENABLED), + INTERNAL_TOOL_DISCOVERY_FIXTURE: Array.isArray(INTERNAL_TOOL_DISCOVERY_FIXTURE) + ? (INTERNAL_TOOL_DISCOVERY_FIXTURE as string[]) + : [], + HEALTH_DETAILS_ENABLED: Boolean(HEALTH_DETAILS_ENABLED), + HEALTH_DETAILS_REQUIRE_AUTH: Boolean(HEALTH_DETAILS_REQUIRE_AUTH), + METRICS_ENABLED: Boolean(METRICS_ENABLED), + METRICS_REQUIRE_AUTH: Boolean(METRICS_REQUIRE_AUTH), + PROMPT_MODE: String(PROMPT_MODE ?? 'standard'), + OMIT_SYSTEM_PROMPT: Boolean(OMIT_SYSTEM_PROMPT), + AUTO_CLEANUP_CONVERSATIONS: Boolean(AUTO_CLEANUP_CONVERSATIONS), + CLEANUP_INTERVAL_MS: Number(CLEANUP_INTERVAL_MS), + CLEANUP_MAX_AGE_MS: Number(CLEANUP_MAX_AGE_MS), + OPENCODE_HOME_BASE: (OPENCODE_HOME_BASE as string | null) ?? null, + maxRetries, + maxAttempts, + getProvidersList, + buildModelsList, + normalizeModelID, + resolveRequestedModel, + logDebug, + responseState, + getResponseState, + storeResponseState, + sweepResponseState, + TOOL_MODE: TOOL_MODE as unknown as AppContext['TOOL_MODE'], + TOOL_GUARD_MESSAGE, + EXTERNAL_TOOL_GUARD_MESSAGE, + normalizeConfiguredToolNames, + getEffectiveInternalAllowedTools, + SERVER_INTERNAL_ALLOWED_TOOL_NAMES, + buildInternalAllowlistPrompt, + buildSystemPrompt, + normalizeReasoningEffort, + stripFunctionCalls, + normalizeTextContent, + normalizeToolArguments, + normalizeToolResultContent, + createExternalToolContext: createExternalToolContext as unknown as AppContext['createExternalToolContext'], + resolveToolMode, + createRequestToolContext: createRequestToolContext as unknown as AppContext['createRequestToolContext'], + finalizeValidatedToolCalls, + toPublicToolCalls, + createForcedToolCallRequester: createForcedToolCallRequester as unknown as AppContext['createForcedToolCallRequester'], + TOOL_IDS_CACHE_MS, + internalToolMetrics, + logInternalToolEvent, + trackToolMode, + getBackendToolIds, + buildDisabledToolOverrides, + normalizeBackendToolIds, + matchesAllowedToolName, + resolveInternalAllowedToolIds, + getDisabledToolOverrides, + getToolOverridesForMode, + getCleanupRoots, + cleanupConversationFiles, + promptWithTimeout, + collectFromEvents, + pollForAssistantResponse, + extractFromParts, + getCachedToolIds: () => cachedToolIds, + getCachedToolIdsAt: () => cachedToolIdsAt, + }; + + // P3: register routes (404 last to preserve catch-all order). + registerSystemRoutes(app, ctx); + registerChatRoutes(app, ctx); + registerResponsesRoutes(app, ctx); + registerMessagesRoutes(app, ctx); + registerNotFoundRoute(app); + + // Reference retained converter/tool-runtime helpers to preserve original module + // wiring (no behavior change; prevents unused-import churn across P3 splits). + void [ + findExternalToolByName, + EXTERNAL_TOOL_PREFIX, + computeRetryDelay, + validateMessagesRequest, + extractSystemText, + anthropicMessagesToChatMessages, + anthropicToolsToChatTools, + anthropicToolChoiceToChat, + anthropicThinkingToReasoningEffort, + mapFinishToStopReason, + buildAnthropicMessage, + sseEvent, + estimateTokens, + buildToolExposure, + evaluateToolPolicy, + validateToolCalls, + stripFunctionCallMarkup, + parseExternalToolCallsFromText, + createToolCallFilter, + createExternalToolCallStreamParser, + ]; + + return { app, client }; +} + +/** + * Starts the OpenCode-to-OpenAI Proxy server. + */ +export function startProxy(options: unknown = {}): StartProxyResult { + // buildProxyConfig tolerates explicit null and preserves merge semantics verbatim. + const config = buildProxyConfig((options ?? {}) as unknown); + + const { app } = createApp(config); + + const server = app.listen(config.PORT, config.BIND_HOST, () => { + void (async (): Promise => { + console.log(`[Proxy] Active at http://${config.BIND_HOST}:${config.PORT}`); + try { + await ensureBackend(config); + } catch (error: unknown) { + console.error('[Proxy] Backend warmup failed:', toErrorMessage(error)); + } + })(); + }); + + return { + server, + killBackend: () => { + const state = backendState.get(config.OPENCODE_SERVER_URL); + if (state?.process) { + state.process.kill(); + } + // Cleanup temp dir (only on non-Windows where we use jail) + if (state?.jailRoot && process.platform !== 'win32') { + try { + fs.rmSync(state.jailRoot, { recursive: true, force: true }); + } catch { + // ignore + } + } + }, + }; +} diff --git a/src/retry/policy.ts b/src/retry/policy.ts new file mode 100644 index 0000000..be234c6 --- /dev/null +++ b/src/retry/policy.ts @@ -0,0 +1,108 @@ +/** + * Retry policy ported from upstream opencode + * (`packages/opencode/src/session/retry.ts`), adapted for this gateway. + * + * Upstream reference values: + * - RETRY_INITIAL_DELAY = 2000ms, RETRY_BACKOFF_FACTOR = 2, + * RETRY_JITTER_FACTOR = 0.25, RETRY_MAX_RETRIES = 5 + * - delay(): retry-after-ms > retry-after (seconds) > retry-after (HTTP date) + * > exponential backoff; capped at 30s without headers. + * + * Gateway deviations (deliberate, documented): + * - All waits are capped at 30s (MAX_DELAY_NO_HEADERS_MS / + * MAX_HEADER_DELAY_MS). Upstream caps headerless backoff at 30s too, but + * honors header-directed waits almost unboundedly (up to INT32_MAX — it + * once waited 700s). A gateway fronting clients with their own timeouts + * (e.g. LiteLLM) must not sleep for hours, so header values are clamped. + * - Max retries resolve through resolveMaxRetries(): default 3 (this proxy's + * historical behavior), configurable via OPENCODE_PROXY_RETRY_MAX_RETRIES, + * hard-capped at ABSOLUTE_MAX_RETRIES = 5 (upstream ceiling). + * + * All functions are pure (no express/SDK imports) for unit testing. + */ + +export const DEFAULT_MAX_RETRIES = 3; +export const ABSOLUTE_MAX_RETRIES = 5; + +export const RETRY_INITIAL_DELAY_MS = 2000; +export const RETRY_BACKOFF_FACTOR = 2; +export const RETRY_JITTER_FACTOR = 0.25; +export const MAX_DELAY_NO_HEADERS_MS = 30000; +export const MAX_HEADER_DELAY_MS = 30000; + +/** Raw response-header bag carrying provider retry hints (case-insensitive). */ +export type RetryHeaders = Record; + +/** Minimal shape of an error that may carry provider retry signals. */ +export interface RetryErrorLike { + responseHeaders?: RetryHeaders | null; + data?: { responseHeaders?: RetryHeaders | null } | null; +} + +/** Raw retry configuration source (e.g. env / file / defaults merge). */ +export interface RetryPolicyConfig { + maxRetries?: unknown; +} + +/** + * Resolve the configured max-retry count to an integer in [0, 5]. + * Non-numeric / missing values fall back to DEFAULT_MAX_RETRIES (3). + */ +export function resolveMaxRetries(value: unknown): number { + const n = typeof value === 'number' ? value : parseInt(value as string, 10); + if (!Number.isFinite(n)) return DEFAULT_MAX_RETRIES; + const floored = Math.floor(n); + if (floored < 0) return 0; + if (floored > ABSOLUTE_MAX_RETRIES) return ABSOLUTE_MAX_RETRIES; + return floored; +} + +/** + * Parse a provider retry hint from response headers into milliseconds. + * Priority (upstream order): retry-after-ms > retry-after (seconds) > + * retry-after (HTTP date). Invalid / past values yield null (caller falls + * back to exponential). Header names are matched case-insensitively. + */ +export function parseRetryAfterMs(headers: unknown): number | null { + if (!headers || typeof headers !== 'object') return null; + const record = headers as Record; + const lookup = (name: string): unknown => { + for (const key of Object.keys(record)) { + if (key.toLowerCase() === name) return record[key]; + } + return undefined; + }; + const msRaw = lookup('retry-after-ms'); + if (msRaw !== undefined) { + const ms = Number.parseFloat(msRaw as string); + if (!Number.isNaN(ms) && ms >= 0) return Math.ceil(ms); + } + const afterRaw = lookup('retry-after'); + if (afterRaw !== undefined && afterRaw !== null && afterRaw !== '') { + const seconds = Number.parseFloat(afterRaw as string); + if (!Number.isNaN(seconds) && seconds >= 0) return Math.ceil(seconds * 1000); + const dateMs = Date.parse(afterRaw as string) - Date.now(); + if (!Number.isNaN(dateMs) && dateMs > 0) return Math.ceil(dateMs); + } + return null; +} + +function exponentialDelay(attempt: number, random: number): number { + const base = RETRY_INITIAL_DELAY_MS * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1); + return Math.ceil(base + base * RETRY_JITTER_FACTOR * random); +} + +/** + * Compute how long to wait before retry number `attempt` (1-based: the wait + * preceding the 2nd overall attempt is attempt=1 → ~2000ms). + * error may carry responseHeaders directly or under .data (backend shape). + */ +export function computeRetryDelay(attempt: unknown, error: unknown = null, random: unknown = Math.random()): number { + const safeAttempt = typeof attempt === 'number' && Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 1; + const safeRandom = typeof random === 'number' && random >= 0 && random <= 1 ? random : Math.random(); + const candidate = error as RetryErrorLike | null | undefined; + const headers = candidate?.responseHeaders ?? candidate?.data?.responseHeaders ?? null; + const hinted = parseRetryAfterMs(headers); + if (hinted !== null) return Math.min(hinted, MAX_HEADER_DELAY_MS); + return Math.min(exponentialDelay(safeAttempt, safeRandom), MAX_DELAY_NO_HEADERS_MS); +} diff --git a/src/routes/chat.ts b/src/routes/chat.ts new file mode 100644 index 0000000..4183dca --- /dev/null +++ b/src/routes/chat.ts @@ -0,0 +1,885 @@ +// P4 TS: POST /v1/chat/completions (ported from P3 .js, behavior identical). +import crypto from 'crypto'; +import { findExternalToolByName } from '../tool-runtime/registry.js'; +import { EXTERNAL_TOOL_PREFIX } from '../tool-runtime/contracts.js'; +import { computeRetryDelay } from '../retry/policy.js'; +import { + stripFunctionCallMarkup, + parseExternalToolCallsFromText, + createToolCallFilter, + createExternalToolCallStreamParser, +} from '../tool-runtime/parser.js'; +import { isTransientUpstreamError, transformUpstreamError } from '../errors/upstream.js'; +import { + withTimeout, + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, +} from '../config/proxy-config.js'; +import { sleep, lock, ensureBackend } from '../backend/manager.js'; +import { getImageDataUri } from '../stream/collector.js'; +import type { Application, Request, Response } from 'express'; +import type { AppContext } from '../types/context.js'; +import type { ExternalToolEntry } from '../tool-runtime/registry.js'; +import type { FinalToolCall } from '../tool-runtime/parser.js'; + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? (value as unknown[]) : []; +} + +function toErrorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + const r = asRecord(e); + const m: unknown = r['message']; + return typeof m === 'string' ? m : String(e); +} + +function readDataMessage(e: unknown): string { + const r = asRecord(e); + const data = asRecord(r['data']); + const dm: unknown = data['message']; + if (typeof dm === 'string') return dm; + const m: unknown = r['message']; + if (typeof m === 'string') return m; + const n: unknown = r['name']; + if (typeof n === 'string') return n; + return 'unknown'; +} + +function readErrorName(e: unknown): string { + const r = asRecord(e); + const n: unknown = r['name']; + return typeof n === 'string' ? n : 'OpenCodeError'; +} + +export function registerChatRoutes(app: Application, ctx: AppContext): void { + const { + client, + config, + REQUEST_TIMEOUT_MS, + DISABLE_TOOLS, + PROMPT_MODE, + maxAttempts, + resolveRequestedModel, + logDebug, + buildSystemPrompt, + normalizeReasoningEffort, + stripFunctionCalls, + normalizeTextContent, + normalizeToolArguments, + createRequestToolContext, + finalizeValidatedToolCalls, + toPublicToolCalls, + createForcedToolCallRequester, + trackToolMode, + getToolOverridesForMode, + promptWithTimeout, + collectFromEvents, + pollForAssistantResponse, + } = ctx; + + // Chat completions endpoint + app.post('/v1/chat/completions', (req: Request, res: Response): void => { + void (async (): Promise => { + try { + await lock(async (): Promise => { + let sessionId: string | null = null; + let eventStream: { close?: unknown } | null = null; + let stream = false; + let pID = 'opencode'; + let mID = 'kimi-k2.5-free'; + let id = `chatcmpl-${crypto.randomUUID()}`; + let keepaliveInterval: ReturnType | null = null; + + try { + const body = asRecord((req as unknown as { body: unknown }).body); + const messages: unknown = body['messages']; + const model: unknown = body['model']; + const toolsRaw: unknown = body['tools']; + const tools: unknown[] = Array.isArray(toolsRaw) ? (toolsRaw as unknown[]) : []; + const tool_choice: unknown = body['tool_choice']; + const requestStream: unknown = body['stream']; + const temperature: unknown = body['temperature']; + const max_tokens: unknown = body['max_tokens']; + const top_p: unknown = body['top_p']; + const frequency_penalty: unknown = body['frequency_penalty']; + const presence_penalty: unknown = body['presence_penalty']; + const stop: unknown = body['stop']; + const reasoning_effort: unknown = body['reasoning_effort']; + const reasoning: unknown = body['reasoning']; + const requestOpencodeConfig: unknown = body['opencode']; + stream = Boolean(requestStream); + if (!messages || !Array.isArray(messages) || (messages as unknown[]).length === 0) { + res.status(400).json({ error: { message: 'messages array is required' } }); + return; + } + + const reasoningLevel = normalizeReasoningEffort( + (reasoning_effort as string) || (asRecord(reasoning)['effort'] as string), + null, + ); + + const requestParams: Record = { + temperature: typeof temperature === 'number' ? temperature : 0.7, + max_tokens: typeof max_tokens === 'number' ? max_tokens : null, + top_p: typeof top_p === 'number' ? top_p : 1.0, + frequency_penalty: typeof frequency_penalty === 'number' ? frequency_penalty : 0, + presence_penalty: typeof presence_penalty === 'number' ? presence_penalty : 0, + stop: Array.isArray(stop) ? stop : stop ? [stop] : null, + reasoning_effort: reasoningLevel, + }; + + logDebug('Request params', { + temperature: requestParams['temperature'], + max_tokens: requestParams['max_tokens'], + top_p: requestParams['top_p'], + reasoning_effort: reasoningLevel, + }); + + const resolvedModel = await withTimeout( + resolveRequestedModel(model), + REQUEST_TIMEOUT_MS, + 'resolve model', + ) as unknown as { providerID: string; modelID: string; aliasFrom?: string; resolved?: string }; + const resolvedRecord = asRecord(resolvedModel); + pID = String(resolvedRecord['providerID'] ?? pID); + mID = String(resolvedRecord['modelID'] ?? mID); + if (resolvedRecord['aliasFrom']) { + logDebug('Resolved model alias', { from: resolvedRecord['aliasFrom'], to: resolvedRecord['resolved'] }); + } + + const normalizeMessageContent = (content: unknown): string => normalizeTextContent(content); + + const buildPromptParts = async ( + rawMessages: unknown, + externalToolRegistry: ExternalToolEntry[] = [], + ): Promise<{ parts: Record[]; system: string; fullPromptText: string; lastUserMsg: string }> => { + const parts: Record[] = []; + const systemChunks: string[] = []; + const userContents: string[] = []; + const assistantToolCalls = new Map(); + const formatRoleLine = (role: unknown, name: unknown, text: unknown): string => { + const roleLabel = String(role ?? '').toUpperCase(); + const nameSuffix = name ? `(${String(name)})` : ''; + return `${roleLabel}${nameSuffix}: ${String(text ?? '')}`; + }; + + for (const m of asArray(rawMessages)) { + const mr = asRecord(m); + const role = String(mr['role'] ?? 'user').toLowerCase(); + const content: unknown = mr['content']; + + if (role === 'system') { + const text = normalizeMessageContent(content); + if (text) systemChunks.push(text); + continue; + } + + if (role === 'assistant' && Array.isArray(mr['tool_calls']) && (mr['tool_calls'] as unknown[]).length) { + const serializedToolCalls = ((mr['tool_calls'] as unknown[]) as unknown[]) + .map((toolCall: unknown, index: number) => { + const tcr = asRecord(toolCall); + const fn = asRecord(tcr['function']); + const rawName: unknown = fn['name'] ?? tcr['name']; + const mapped = findExternalToolByName(externalToolRegistry, rawName); + const name: unknown = mapped?.namespacedName ?? fn['name'] ?? tcr['name']; + return { + id: typeof tcr['id'] === 'string' ? (tcr['id'] as string) : `call_${index + 1}`, + name: name as string, + arguments: normalizeToolArguments(fn['arguments'] ?? tcr['arguments']), + }; + }) + .filter((toolCall) => (toolCall as { name: unknown }).name); + if (serializedToolCalls.length) { + serializedToolCalls.forEach((toolCall) => { + const tc = toolCall as { id: string; name: string }; + assistantToolCalls.set(tc.id, tc.name); + }); + parts.push({ + type: 'text', + text: `ASSISTANT: ${JSON.stringify(serializedToolCalls)}`, + }); + } + } + + if (role === 'tool') { + const text = normalizeMessageContent(content); + if (text) { + const mappedTool = + findExternalToolByName(externalToolRegistry, mr['name']) || + findExternalToolByName(externalToolRegistry, assistantToolCalls.get(String(mr['tool_call_id'] ?? ''))); + const toolName = + mappedTool?.namespacedName || + assistantToolCalls.get(String(mr['tool_call_id'] ?? '')) || + (typeof mr['name'] === 'string' ? (mr['name'] as string) : `${EXTERNAL_TOOL_PREFIX}unknown`); + const toolCallId = + typeof mr['tool_call_id'] === 'string' + ? (mr['tool_call_id'] as string) + : `call_${String(toolName).replace(/[^a-zA-Z0-9_]/g, '_')}`; + parts.push({ + type: 'text', + text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: toolCallId, name: toolName, content: text })}`, + }); + } + continue; + } + + if (!content) continue; + + if (typeof content === 'string') { + if (role === 'user') userContents.push(content); + parts.push({ + type: 'text', + text: formatRoleLine(role, mr['name'], content), + }); + } else if (Array.isArray(content)) { + for (const part of content as unknown[]) { + if (!part) continue; + const pr = asRecord(part); + if (pr['type'] === 'text') { + const text = typeof pr['text'] === 'string' ? (pr['text'] as string) : ''; + if (role === 'user') userContents.push(text); + parts.push({ + type: 'text', + text: formatRoleLine(role, mr['name'], text), + }); + } else if (pr['type'] === 'image_url') { + const imageUrlRaw: unknown = pr['image_url']; + const imageUrl = + typeof imageUrlRaw === 'string' + ? imageUrlRaw + : (asRecord(imageUrlRaw)['url'] as string | undefined); + if (imageUrl) { + try { + const dataUri = await getImageDataUri(imageUrl); + const mime = String(dataUri.split(';')[0]?.split(':')[1] ?? 'image/jpeg'); + parts.push({ + type: 'file', + mime, + url: dataUri, + filename: 'image', + }); + } catch (imgErr: unknown) { + console.warn('[Proxy] Skipping image due to error:', toErrorMessage(imgErr)); + } + } + } + } + } + } + + return { + parts, + system: systemChunks.join('\n\n'), + fullPromptText: parts.map((p) => String(p['text'] ?? '')).join('\n\n'), + lastUserMsg: userContents[userContents.length - 1] || '', + }; + }; + + const requestToolContext = createRequestToolContext(tools, tool_choice, requestOpencodeConfig); + const toolMode: string = requestToolContext.mode; + const externalToolContext = requestToolContext.external; + const externalToolRegistry = externalToolContext.registry; + const externalToolChoice = externalToolContext.toolChoice; + const internalToolContext = requestToolContext.internal; + trackToolMode(toolMode, { + configuredAllowlist: internalToolContext.allowedToolNames, + requestedAllowlist: internalToolContext.requestedAllowlist, + deniedRequestedTools: internalToolContext.deniedRequestedTools, + resolutionPath: internalToolContext.resolutionPath, + resultingMode: internalToolContext.resultingMode, + route: '/v1/chat/completions', + }); + + const { parts, system: systemMsg, fullPromptText, lastUserMsg } = await buildPromptParts( + messages, + externalToolRegistry, + ); + const systemWithGuard = buildSystemPrompt( + [systemMsg, externalToolContext.prompt].filter(Boolean).join('\n\n'), + requestParams['reasoning_effort'], + toolMode, + internalToolContext.allowedToolNames, + ); + if (!parts.length) { + res.status(400).json({ error: { message: 'messages must include at least one non-system text message' } }); + return; + } + logDebug('Request start', { + model: `${pID}/${mID}`, + stream: Boolean(stream), + userMessages: (messages as unknown[]).length, + system: Boolean(systemMsg), + lastUserLength: lastUserMsg?.length || 0, + parts: parts.length, + disableTools: DISABLE_TOOLS, + toolMode, + internalAllowedTools: internalToolContext.allowedToolNames, + requestedInternalTools: internalToolContext.requestedAllowlist, + deniedRequestedTools: internalToolContext.deniedRequestedTools, + resolutionPath: internalToolContext.resolutionPath, + resultingMode: internalToolContext.resultingMode, + }); + + // Ensure backend is running + await ensureBackend(config); + + // Set active model + try { + await client.config.update({ + body: { + activeModel: { providerID: pID, modelID: mID }, + }, + }); + } catch (confError: unknown) { + logDebug('Failed to set active model:', toErrorMessage(confError)); + } + + // Create session (bounded: a hung backend must 504, not stall). + const sessionRes = (await withTimeout(client.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + sessionId = (asRecord(asRecord(sessionRes)['data'])['id'] as string | undefined) ?? null; + if (!sessionId) throw new Error('Failed to create OpenCode session'); + logDebug('Session created', { sessionId }); + + id = `chatcmpl-${crypto.randomUUID()}`; + keepaliveInterval = null; + let completionTokens = 0; + let reasoningTokens = 0; + + const promptParams: { path: { id: string }; body: Record } = { + path: { id: sessionId }, + body: { + model: { providerID: pID, modelID: mID }, + system: systemWithGuard, + parts: externalToolContext.reminder + ? [...parts, { type: 'text', text: externalToolContext.reminder }] + : parts, + ...(requestParams['max_tokens'] ? { max_tokens: requestParams['max_tokens'] } : {}), + ...(requestParams['temperature'] !== undefined ? { temperature: requestParams['temperature'] } : {}), + ...(requestParams['top_p'] !== undefined ? { top_p: requestParams['top_p'] } : {}), + ...(requestParams['stop'] ? { stop: requestParams['stop'] } : {}), + }, + }; + const toolOverrides = (await withTimeout( + getToolOverridesForMode(toolMode, internalToolContext), + REQUEST_TIMEOUT_MS, + 'load tool overrides', + )) as Record | null; + if (toolOverrides && Object.keys(toolOverrides).length > 0) { + promptParams.body['tools'] = toolOverrides; + } + + const makeForcedChatToolCallRequester = (): (() => Promise | null>) => + createForcedToolCallRequester({ + mode: externalToolChoice.mode, + sessionId: sessionId as string, + systemWithGuard, + requiredTool: externalToolChoice.requiredTool ?? externalToolRegistry[0]?.namespacedName, + providerID: pID, + modelID: mID, + toolOverrides, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + forbidThinkBlock: true, + }); + let requestForcedChatToolCall = makeForcedChatToolCallRequester(); + + res.setHeader('Content-Type', stream ? 'text/event-stream' : 'application/json'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + if (stream) { + const shouldStripStreamingToolMarkup = externalToolRegistry.length > 0; + const filterContentDelta = createToolCallFilter({ + disableTools: DISABLE_TOOLS, + forceStrip: shouldStripStreamingToolMarkup, + }); + const filterReasoningDelta = createToolCallFilter({ + disableTools: DISABLE_TOOLS, + forceStrip: shouldStripStreamingToolMarkup, + }); + const parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + const parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + let streamedContent = ''; + let streamedReasoning = ''; + let rawStreamedContent = ''; + let rawStreamedReasoning = ''; + const streamedToolCalls: FinalToolCall[] = []; + keepaliveInterval = null; + completionTokens = 0; + reasoningTokens = 0; + + const ensureKeepalive = (): void => { + if (!keepaliveInterval) { + keepaliveInterval = setInterval(() => { + if (!res.destroyed) { + res.write(': keepalive\n\n'); + } + }, 15000); + } + }; + ensureKeepalive(); + + const sendDelta = (delta: string, isReasoning: boolean = false): void => { + if (!delta) return; + if (isReasoning) rawStreamedReasoning += delta; + else rawStreamedContent += delta; + const parsedDeltaToolCalls = isReasoning + ? parseReasoningToolCalls(delta) + : parseContentToolCalls(delta); + parsedDeltaToolCalls.forEach((toolCall) => { + streamedToolCalls.push(toolCall); + res.write( + `data: ${JSON.stringify({ + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: `${pID}/${mID}`, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: streamedToolCalls.length - 1, + id: toolCall.id, + type: 'function', + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments, + }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n`, + ); + }); + const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); + if (!filtered) return; + if (isReasoning) { + streamedReasoning += filtered; + reasoningTokens += Math.ceil(filtered.length / 4); + } else { + streamedContent += filtered; + completionTokens += Math.ceil(filtered.length / 4); + } + const deltaField = isReasoning ? { reasoning_content: filtered } : { content: filtered }; + const chunk = { + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: `${pID}/${mID}`, + choices: [{ index: 0, delta: deltaField, finish_reason: null }], + }; + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + }; + + let collected: Record | null = null; + let lastStreamAttemptError: unknown = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (attempt > 1) { + try { + await client.session.delete({ path: { id: sessionId } }); + } catch (e: unknown) { + logDebug('Failed to delete retried session', { sessionId, error: toErrorMessage(e) }); + } + const retrySessionRes = (await withTimeout( + client.session.create(), + REQUEST_TIMEOUT_MS, + 'create session', + )) as unknown; + sessionId = (asRecord(asRecord(retrySessionRes)['data'])['id'] as string | undefined) ?? null; + if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); + promptParams.path.id = sessionId; + requestForcedChatToolCall = makeForcedChatToolCallRequester(); + streamedContent = ''; + streamedReasoning = ''; + rawStreamedContent = ''; + rawStreamedReasoning = ''; + streamedToolCalls.length = 0; + completionTokens = 0; + reasoningTokens = 0; + await sleep(computeRetryDelay(attempt - 1, lastStreamAttemptError)); + } + try { + const collectPromise = collectFromEvents( + sessionId as string, + REQUEST_TIMEOUT_MS, + sendDelta, + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, + ); + const safeCollect = collectPromise.catch((err: unknown) => ({ __error: err })); + client.session.prompt(promptParams).catch((err: unknown) => logDebug('Prompt error:', toErrorMessage(err))); + collected = (await safeCollect) as Record; + } catch (e: unknown) { + logDebug('Stream error:', toErrorMessage(e)); + } + + const collectedRecord = asRecord(collected); + const attemptError: unknown = collectedRecord['error'] ?? collectedRecord['__error'] ?? null; + const nothingStreamed = + !rawStreamedContent && !rawStreamedReasoning && streamedToolCalls.length === 0; + if (attemptError && nothingStreamed && attempt < maxAttempts && isTransientUpstreamError(attemptError)) { + console.warn( + `[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, + readDataMessage(attemptError), + ); + lastStreamAttemptError = attemptError; + continue; + } + break; + } + + const collectedR = asRecord(collected); + if (collected && collectedR['__error']) { + logDebug('SSE collect error, falling back to polling', { + sessionId, + error: toErrorMessage(collectedR['__error']), + }); + const { content, reasoning, error } = await pollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + ); + if (error && !content && !reasoning) { + const er = asRecord(error); + sendDelta( + `[Proxy Error] ${String(er['name'] ?? 'OpenCodeError')}: ${String(asRecord(er['data'])['message'] ?? (er['message'] as string) ?? 'Unknown error')}`, + ); + } else { + if (reasoning) sendDelta(reasoning, true); + if (content) sendDelta(content, false); + } + } else if (collected && collectedR['noData']) { + logDebug('Fallback to polling (stream)', { sessionId }); + const { content, reasoning, error } = await pollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + ); + if (error && !content && !reasoning) { + const er = asRecord(error); + sendDelta( + `[Proxy Error] ${String(er['name'] ?? 'OpenCodeError')}: ${String(asRecord(er['data'])['message'] ?? (er['message'] as string) ?? 'Unknown error')}`, + ); + } else { + if (reasoning) sendDelta(reasoning, true); + if (content) sendDelta(content, false); + } + } else if (collected && collectedR['idleTimeout']) { + logDebug('SSE idle timeout, polling for completion', { sessionId }); + const { content, reasoning, error } = await pollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + ); + if (error && !content && !reasoning) { + const er = asRecord(error); + sendDelta( + `[Proxy Error] ${String(er['name'] ?? 'OpenCodeError')}: ${String(asRecord(er['data'])['message'] ?? (er['message'] as string) ?? 'Unknown error')}`, + ); + } else { + const remainingReasoning = + reasoning && reasoning.startsWith(rawStreamedReasoning) + ? reasoning.slice(rawStreamedReasoning.length) + : reasoning; + const remainingContent = + content && content.startsWith(rawStreamedContent) + ? content.slice(rawStreamedContent.length) + : content; + if (remainingReasoning) sendDelta(remainingReasoning, true); + if (remainingContent) sendDelta(remainingContent, false); + } + } + + if ( + collected && + !streamedContent && + !streamedReasoning && + ((collectedR['reasoning'] as string) || (collectedR['content'] as string)) + ) { + if (collectedR['reasoning']) sendDelta(String(collectedR['reasoning']), true); + if (collectedR['content']) sendDelta(String(collectedR['content']), false); + } + + if (!streamedContent && !streamedReasoning) { + logDebug('SSE returned empty, falling back to polling', { sessionId }); + const { content, reasoning, error } = await pollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + ); + if (error && !content && !reasoning) { + const er = asRecord(error); + sendDelta( + `[Proxy Error] ${String(er['name'] ?? 'OpenCodeError')}: ${String(asRecord(er['data'])['message'] ?? (er['message'] as string) ?? 'Unknown error')}`, + ); + } else { + if (reasoning) sendDelta(reasoning, true); + if (content) sendDelta(content, false); + } + } else if (streamedReasoning && !streamedContent) { + logDebug('Reasoning streamed but no content, reconciling from snapshot', { sessionId }); + const snapshot = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS).catch( + () => null, + ); + if (snapshot && snapshot.content) { + const remainingContent = rawStreamedContent + ? snapshot.content.slice(rawStreamedContent.length) + : snapshot.content; + if (remainingContent) sendDelta(remainingContent, false); + } + } + + // Flush held buffers from the stream parsers and filters before final batch parse. + const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : []; + const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : []; + const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : ''; + const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : ''; + const finalReasoningText = rawStreamedReasoning + flushedReasoningText; + const finalContentText = rawStreamedContent + flushedContentText; + + const parseStreamedToolCalls = (): FinalToolCall[] => { + if (externalToolRegistry.length === 0) return []; + const perChannel = [ + ...flushedReasoningCalls, + ...flushedContentCalls, + ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText), + ]; + if (perChannel.length > 0) return perChannel; + return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); + }; + + let parsedToolCalls: FinalToolCall[] = + streamedToolCalls.length > 0 ? streamedToolCalls : parseStreamedToolCalls(); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedChatToolCall(); + if (forcedResponse) { + parsedToolCalls = parseExternalToolCallsFromText( + externalToolRegistry, + forcedResponse['reasoning'] as string, + forcedResponse['content'] as string, + ); + } + } + const { validCalls: validatedStreamedToolCalls } = finalizeValidatedToolCalls( + parsedToolCalls, + externalToolRegistry, + ); + const finalStreamedToolCalls = validatedStreamedToolCalls; + if (finalStreamedToolCalls.length > 0 && streamedToolCalls.length === 0) { + const toolCallDeltas = finalStreamedToolCalls.map((toolCall, index) => ({ + index, + id: toolCall.id, + type: 'function', + function: { + name: (toolCall.function as Record)['name'], + arguments: (toolCall.function as Record)['arguments'], + }, + })); + res.write( + `data: ${JSON.stringify({ + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: `${pID}/${mID}`, + choices: [ + { + index: 0, + delta: { tool_calls: toolCallDeltas }, + finish_reason: null, + }, + ], + })}\n\n`, + ); + } + + if (keepaliveInterval) clearInterval(keepaliveInterval); + keepaliveInterval = null; + + const promptTokens = Math.ceil((fullPromptText || '').length / 4); + const totalTokens = promptTokens + completionTokens + reasoningTokens; + + res.write( + `data: ${JSON.stringify({ + id, + choices: [ + { + index: 0, + delta: {}, + finish_reason: finalStreamedToolCalls.length > 0 ? 'tool_calls' : 'stop', + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens + reasoningTokens, + total_tokens: totalTokens, + completion_tokens_details: { + reasoning_tokens: reasoningTokens, + }, + }, + })}\n\n`, + ); + res.write('data: [DONE]\n\n'); + res.end(); + } else { + let content = ''; + let reasoning = ''; + let error: unknown = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (res.writableEnded || res.destroyed) { + logDebug('Client gone, stop retrying', { sessionId, attempt }); + return; + } + if (attempt > 1) { + try { + await client.session.delete({ path: { id: sessionId } }); + } catch (e: unknown) { + logDebug('Failed to delete retried session', { sessionId, error: toErrorMessage(e) }); + } + const retrySessionRes = (await withTimeout( + client.session.create(), + REQUEST_TIMEOUT_MS, + 'create session', + )) as unknown; + sessionId = (asRecord(asRecord(retrySessionRes)['data'])['id'] as string | undefined) ?? null; + if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); + promptParams.path.id = sessionId; + requestForcedChatToolCall = makeForcedChatToolCallRequester(); + await sleep(computeRetryDelay(attempt - 1, error)); + } + const attemptStart = Date.now(); + try { + await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + logDebug('Prompt sent', { sessionId, ms: Date.now() - attemptStart, attempt }); + const collected = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + content = collected.content || ''; + reasoning = collected.reasoning || ''; + error = collected.error || null; + } catch (promptError: unknown) { + content = ''; + reasoning = ''; + error = promptError; + } + if (error && !content && !reasoning && attempt < maxAttempts && isTransientUpstreamError(error)) { + console.warn( + `[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, + readDataMessage(error), + ); + continue; + } + break; + } + if (error && !content && !reasoning) { + if (/^Request timeout after/.test(toErrorMessage(error))) throw error; + const er = asRecord(error); + const ed = asRecord(er['data']); + res.status(502).json({ + error: { + message: String(ed['message'] ?? er['message'] ?? 'OpenCode provider error'), + type: String(er['name'] ?? 'OpenCodeError'), + }, + }); + return; + } + let parsedToolCalls: FinalToolCall[] = + externalToolRegistry.length > 0 + ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) + : []; + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedChatToolCall(); + if (forcedResponse) { + content = String(forcedResponse['content'] ?? content); + reasoning = String(forcedResponse['reasoning'] ?? reasoning); + parsedToolCalls = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); + } + } + const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + + const promptTokens = Math.ceil((fullPromptText || '').length / 4); + const completionTokensCalc = Math.ceil((content || '').length / 4); + const reasoningTokensCalc = Math.ceil((reasoning || '').length / 4); + const totalTokens = promptTokens + completionTokensCalc + reasoningTokensCalc; + + const publicValidatedToolCalls = toPublicToolCalls(validatedToolCalls); + const assistantMessage: Record = { + role: 'assistant', + content: publicValidatedToolCalls.length > 0 ? safeContent || null : safeContent, + ...(safeReasoning ? { reasoning_content: safeReasoning } : {}), + }; + if (publicValidatedToolCalls.length > 0) { + assistantMessage['tool_calls'] = publicValidatedToolCalls; + } + + res.json({ + id: `chatcmpl-${crypto.randomUUID()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: `${pID}/${mID}`, + choices: [ + { + index: 0, + message: assistantMessage, + finish_reason: publicValidatedToolCalls.length > 0 ? 'tool_calls' : 'stop', + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokensCalc + reasoningTokensCalc, + total_tokens: totalTokens, + completion_tokens_details: { + reasoning_tokens: reasoningTokensCalc, + }, + }, + }); + } + } catch (error: unknown) { + console.error('[Proxy] API Error:', toErrorMessage(error)); + console.error('[Proxy] Error details:', error); + + if (keepaliveInterval) clearInterval(keepaliveInterval); + + if (!res.headersSent) { + const transformed = transformUpstreamError(error); + res.status(transformed.statusCode).json(transformed.error); + } else if (!res.destroyed) { + res.write(`data: ${JSON.stringify({ error: { message: toErrorMessage(error) } })}\n\n`); + res.end(); + } + if (sessionId) { + try { + await client.session.delete({ path: { id: sessionId } }); + } catch (e: unknown) { + console.error('[Proxy] Failed to cleanup session on error:', toErrorMessage(e)); + } + } + } finally { + if (keepaliveInterval) clearInterval(keepaliveInterval); + const es = eventStream as unknown as { close?: unknown } | null; + if (es && typeof es['close'] === 'function') { + (es['close'] as () => void)(); + } + } + }, REQUEST_TIMEOUT_MS + 20000); + } catch (error: unknown) { + console.error('[Proxy] Request Handler Error:', toErrorMessage(error)); + if (!res.headersSent) { + const er = asRecord(error); + res.status(500).json({ + error: { + message: toErrorMessage(error), + type: error instanceof Error ? error.constructor.name : String(er['name'] ?? 'Error'), + }, + }); + } + } + })(); + }); +} diff --git a/src/routes/messages.ts b/src/routes/messages.ts new file mode 100644 index 0000000..9e27172 --- /dev/null +++ b/src/routes/messages.ts @@ -0,0 +1,576 @@ +// P4 TS: POST /v1/messages (Anthropic, ported from P3 .js, behavior identical). +import crypto from 'crypto'; +import { findExternalToolByName } from '../tool-runtime/registry.js'; +import { EXTERNAL_TOOL_PREFIX } from '../tool-runtime/contracts.js'; +import { computeRetryDelay } from '../retry/policy.js'; +import { + validateMessagesRequest, + extractSystemText, + anthropicMessagesToChatMessages, + anthropicToolsToChatTools, + anthropicToolChoiceToChat, + anthropicThinkingToReasoningEffort, + mapFinishToStopReason, + buildAnthropicMessage, + sseEvent, + estimateTokens, +} from '../converters/anthropic.js'; +import { + stripFunctionCallMarkup, + parseExternalToolCallsFromText, + createToolCallFilter, + createExternalToolCallStreamParser, +} from '../tool-runtime/parser.js'; +import { isTransientUpstreamError, normalizeBackendError, transformUpstreamError } from '../errors/upstream.js'; +import { + withTimeout, + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, +} from '../config/proxy-config.js'; +import { sleep, lock, ensureBackend } from '../backend/manager.js'; +import { getImageDataUri } from '../stream/collector.js'; +import type { Application, Request, Response } from 'express'; +import type { AppContext } from '../types/context.js'; +import type { FinalToolCall } from '../tool-runtime/parser.js'; + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function toErrorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + const r = asRecord(e); + const m: unknown = r['message']; + return typeof m === 'string' ? m : String(e); +} + +export function registerMessagesRoutes(app: Application, ctx: AppContext): void { + const { + client, + config, + REQUEST_TIMEOUT_MS, + DISABLE_TOOLS, + maxAttempts, + resolveRequestedModel, + logDebug, + buildSystemPrompt, + normalizeReasoningEffort, + stripFunctionCalls, + normalizeTextContent, + normalizeToolArguments, + createRequestToolContext, + finalizeValidatedToolCalls, + toPublicToolCalls, + createForcedToolCallRequester, + trackToolMode, + getToolOverridesForMode, + promptWithTimeout, + collectFromEvents, + pollForAssistantResponse, + } = ctx; + + app.post('/v1/messages', async (req: Request, res: Response): Promise => { + try { + await lock(async (): Promise => { + let sessionId: string | null = null; + let keepaliveInterval: ReturnType | null = null; + try { + const rawBody: unknown = (req as unknown as { body: unknown }).body; + const validationError = validateMessagesRequest(rawBody); + if (validationError) { + res.status(validationError.statusCode).json(validationError.body); + return; + } + const body = asRecord(rawBody); + const model: unknown = body['model']; + const system: unknown = body['system']; + const messagesRaw: unknown = body['messages']; + const toolsRaw: unknown = body['tools']; + const tools: unknown[] = Array.isArray(toolsRaw) ? (toolsRaw as unknown[]) : []; + const tool_choice: unknown = body['tool_choice']; + const requestStream: unknown = body['stream']; + const temperature: unknown = body['temperature']; + const top_p: unknown = body['top_p']; + const top_k: unknown = body['top_k']; + const max_tokens: unknown = body['max_tokens']; + const stop_sequences: unknown = body['stop_sequences']; + const thinking: unknown = body['thinking']; + const stream = Boolean(requestStream); + const chatMessages = anthropicMessagesToChatMessages(messagesRaw); + const systemText = extractSystemText(system); + if (systemText) chatMessages.unshift({ role: 'system', content: systemText } as unknown as (typeof chatMessages)[number]); + const chatTools = anthropicToolsToChatTools(tools); + const chatToolChoice = anthropicToolChoiceToChat(tool_choice); + const reasoningLevel = anthropicThinkingToReasoningEffort(thinking) || normalizeReasoningEffort(undefined, null); + + const requestParams: Record = { + temperature: typeof temperature === 'number' ? temperature : 0.7, + max_tokens: typeof max_tokens === 'number' ? max_tokens : null, + top_p: typeof top_p === 'number' ? top_p : 1.0, + stop: Array.isArray(stop_sequences) ? stop_sequences : null, + reasoning_effort: reasoningLevel, + }; + if (top_k !== undefined) logDebug('Ignoring top_k (no opencode equivalent)', { top_k }); + + const resolvedModel = (await withTimeout(resolveRequestedModel(model), REQUEST_TIMEOUT_MS, 'resolve model')) as unknown as { + providerID: string; + modelID: string; + }; + const pID = String(asRecord(resolvedModel)['providerID']); + const mID = String(asRecord(resolvedModel)['modelID']); + const publicModel = `${pID}/${mID}`; + + const requestToolContext = createRequestToolContext(chatTools, chatToolChoice, undefined); + const toolMode: string = requestToolContext.mode; + const externalToolContext = requestToolContext.external; + const externalToolRegistry = externalToolContext.registry; + const externalToolChoice = externalToolContext.toolChoice; + const internalToolContext = requestToolContext.internal; + trackToolMode(toolMode, { route: '/v1/messages' }); + + const parts: Record[] = []; + const systemChunks: string[] = []; + const assistantToolCalls = new Map(); + const formatRoleLine = (role: unknown, name: unknown, text: unknown): string => + `${String(role).toUpperCase()}${name ? `(${String(name)})` : ''}: ${String(text ?? '')}`; + for (const m of chatMessages) { + const mr = asRecord(m); + const role = String(mr['role'] ?? 'user').toLowerCase(); + const content: unknown = mr['content']; + if (role === 'system') { + const text = normalizeTextContent(content); + if (text) systemChunks.push(text); + continue; + } + const toolCallsRaw: unknown = mr['tool_calls']; + if (role === 'assistant' && Array.isArray(toolCallsRaw) && (toolCallsRaw as unknown[]).length) { + const serialized = ((toolCallsRaw as unknown[]) as unknown[]) + .map((tc: unknown, i: number) => { + const tcr = asRecord(tc); + const fn = asRecord(tcr['function']); + const rawName: unknown = fn['name'] ?? tcr['name']; + const mapped = findExternalToolByName(externalToolRegistry, rawName); + return { + id: typeof tcr['id'] === 'string' ? (tcr['id'] as string) : `toolu_${i + 1}`, + name: (mapped?.namespacedName ?? (fn['name'] as string) ?? (tcr['name'] as string)) as string, + arguments: normalizeToolArguments(fn['arguments'] ?? tcr['arguments']), + }; + }) + .filter((tc) => (tc as { name: unknown }).name); + serialized.forEach((tc) => { + const s = tc as { id: string; name: string }; + assistantToolCalls.set(s.id, s.name); + }); + if (serialized.length) parts.push({ type: 'text', text: `ASSISTANT: ${JSON.stringify(serialized)}` }); + } + if (role === 'tool') { + const text = normalizeTextContent(content); + if (text) { + const mapped = + findExternalToolByName(externalToolRegistry, mr['name']) || + findExternalToolByName(externalToolRegistry, assistantToolCalls.get(String(mr['tool_call_id'] ?? ''))); + const toolName = + mapped?.namespacedName || + assistantToolCalls.get(String(mr['tool_call_id'] ?? '')) || + (typeof mr['name'] === 'string' ? (mr['name'] as string) : `${EXTERNAL_TOOL_PREFIX}unknown`); + parts.push({ + type: 'text', + text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: mr['tool_call_id'] ?? toolName, name: toolName, content: text })}`, + }); + } + continue; + } + if (!content) continue; + if (typeof content === 'string') { + parts.push({ type: 'text', text: formatRoleLine(role, mr['name'], content) }); + } else if (Array.isArray(content)) { + for (const part of content as unknown[]) { + if (!part) continue; + const pr = asRecord(part); + if (pr['type'] === 'text') parts.push({ type: 'text', text: formatRoleLine(role, mr['name'], (pr['text'] as string) || '') }); + else if (pr['type'] === 'image_url') { + const imageUrlRaw: unknown = pr['image_url']; + const imageUrl = + typeof imageUrlRaw === 'string' ? imageUrlRaw : (asRecord(imageUrlRaw)['url'] as string | undefined); + if (imageUrl) { + try { + const dataUri = await getImageDataUri(imageUrl); + parts.push({ type: 'file', mime: String(dataUri.split(';')[0]?.split(':')[1]), url: dataUri, filename: 'image' }); + } catch (e: unknown) { + logDebug('Skipping image', { error: toErrorMessage(e) }); + } + } + } + } + } + } + if (!parts.length) { + res.status(400).json({ type: 'error', error: { type: 'invalid_request_error', message: 'messages must include at least one text message' } }); + return; + } + + const systemWithGuard = buildSystemPrompt( + [systemChunks.join('\n\n'), externalToolContext.prompt].filter(Boolean).join('\n\n'), + requestParams['reasoning_effort'], + toolMode, + internalToolContext.allowedToolNames, + ); + await ensureBackend(config); + try { + await client.config.update({ body: { activeModel: { providerID: pID, modelID: mID } } }); + } catch (e: unknown) { + logDebug('Failed to set active model', { error: toErrorMessage(e) }); + } + const sessionRes = (await withTimeout(client.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + sessionId = (asRecord(asRecord(sessionRes)['data'])['id'] as string | undefined) ?? null; + if (!sessionId) throw new Error('Failed to create OpenCode session'); + + const promptParams: { path: { id: string }; body: Record } = { + path: { id: sessionId }, + body: { + model: { providerID: pID, modelID: mID }, + system: systemWithGuard, + parts: externalToolContext.reminder ? [...parts, { type: 'text', text: externalToolContext.reminder }] : parts, + ...(requestParams['max_tokens'] ? { max_tokens: requestParams['max_tokens'] } : {}), + ...(requestParams['temperature'] !== undefined ? { temperature: requestParams['temperature'] } : {}), + ...(requestParams['top_p'] !== undefined ? { top_p: requestParams['top_p'] } : {}), + ...(requestParams['stop'] ? { stop: requestParams['stop'] } : {}), + }, + }; + const toolOverrides = (await withTimeout( + getToolOverridesForMode(toolMode, internalToolContext), + REQUEST_TIMEOUT_MS, + 'load tool overrides', + )) as Record | null; + if (toolOverrides && Object.keys(toolOverrides).length > 0) promptParams.body['tools'] = toolOverrides; + + const fullPromptText = parts.map((p) => String(p['text'] ?? '')).join('\n\n'); + const messageId = `msg_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; + const inputTokens = estimateTokens(fullPromptText + (systemWithGuard || '')); + + const makeForcedMessagesToolCallRequester = (): (() => Promise | null>) => + createForcedToolCallRequester({ + mode: externalToolChoice.mode, + sessionId: sessionId as string, + systemWithGuard, + requiredTool: externalToolChoice.requiredTool ?? externalToolRegistry[0]?.namespacedName, + providerID: pID, + modelID: mID, + toolOverrides, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + forbidThinkBlock: true, + }); + let requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); + + const finalizeAnthropic = ( + content: unknown, + reasoning: unknown, + validatedToolCalls: unknown, + ): Record => { + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + const publicCalls = toPublicToolCalls(validatedToolCalls); + const anthropicTools = publicCalls.map((tc) => { + const tcr = tc as unknown as Record; + const fn = asRecord(tcr['function']); + let input: unknown = {}; + try { + input = JSON.parse(String(fn['arguments'] || '{}')) as unknown; + } catch { + input = {}; + } + return { id: tcr['id'], function: { name: fn['name'], arguments: fn['arguments'] }, _input: input }; + }); + const outputTokens = estimateTokens(safeContent + safeReasoning + JSON.stringify(anthropicTools)); + const stopReason = mapFinishToStopReason('stop', anthropicTools.length > 0); + return buildAnthropicMessage({ + messageId, + model: publicModel, + text: safeContent || '', + reasoning: safeReasoning || '', + toolCalls: anthropicTools as unknown as Array<{ id?: unknown; name?: unknown; function?: { name?: unknown; arguments?: unknown } | null }>, + stopReason, + inputTokens, + outputTokens, + }) as unknown as Record; + }; + + if (!stream) { + let content = ''; + let reasoning = ''; + let error: unknown = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (res.writableEnded || res.destroyed) { + logDebug('Client gone, stop retrying', { sessionId, attempt }); + return; + } + if (attempt > 1) { + try { + await client.session.delete({ path: { id: sessionId } }); + } catch { + // ignore + } + const r = (await withTimeout(client.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + sessionId = (asRecord(asRecord(r)['data'])['id'] as string | undefined) ?? null; + if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); + promptParams.path.id = sessionId; + requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); + await sleep(computeRetryDelay(attempt - 1, error)); + } + try { + await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + const collected = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + content = collected.content || ''; + reasoning = collected.reasoning || ''; + error = collected.error || null; + } catch (promptError: unknown) { + content = ''; + reasoning = ''; + error = promptError; + } + if (error && !content && !reasoning && attempt < maxAttempts && isTransientUpstreamError(error)) continue; + break; + } + if (error && !content && !reasoning) { + const t = transformUpstreamError(error); + res.status(t.statusCode).json({ + type: 'error', + error: { + type: t.error.type || 'api_error', + message: t.error.message || 'Upstream error', + ...(t.error.code ? { code: t.error.code } : {}), + }, + }); + return; + } + let parsed: FinalToolCall[] = + externalToolRegistry.length > 0 ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) : []; + if (parsed.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedMessagesToolCall(); + if (forcedResponse) { + content = String(forcedResponse['content'] ?? content); + reasoning = String(forcedResponse['reasoning'] ?? reasoning); + parsed = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); + } + } + const { validCalls } = finalizeValidatedToolCalls(parsed, externalToolRegistry); + res.json(finalizeAnthropic(content, reasoning, validCalls)); + return; + } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + keepaliveInterval = setInterval(() => { + if (!res.destroyed && !res.writableEnded) res.write(': keep-alive\n\n'); + }, 15000); + const resClosed = new Promise((resolve) => + res.once('close', () => { + if (!res.writableEnded) resolve(true); + }), + ); + let streamedText = ''; + let streamedReasoning = ''; + let rawContent = ''; + let rawReasoning = ''; + const streamedToolCalls: FinalToolCall[] = []; + const filterContent = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: externalToolRegistry.length > 0 }); + const filterReasoning = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: externalToolRegistry.length > 0 }); + const parseContent = createExternalToolCallStreamParser(externalToolRegistry); + const parseReason = createExternalToolCallStreamParser(externalToolRegistry); + let textBlockOpen = false; + let thinkingBlockOpen = false; + let nextBlockIndex = 0; + let textIndex: number | null = null; + let thinkingIndex: number | null = null; + const ensureTextIndex = (): number => { + if (textIndex === null) textIndex = nextBlockIndex++; + return textIndex; + }; + const ensureThinkingIndex = (): number => { + if (thinkingIndex === null) thinkingIndex = nextBlockIndex++; + return thinkingIndex; + }; + res.write( + sseEvent('message_start', { + type: 'message_start', + message: { id: messageId, type: 'message', role: 'assistant', model: publicModel, content: [], stop_reason: null, usage: { input_tokens: inputTokens, output_tokens: 0 } }, + }), + ); + const sendTextDelta = (delta: string): void => { + if (!delta) return; + const idx = ensureTextIndex(); + if (!textBlockOpen) { + res.write(sseEvent('content_block_start', { type: 'content_block_start', index: idx, content_block: { type: 'text', text: '' } })); + textBlockOpen = true; + } + streamedText += delta; + res.write(sseEvent('content_block_delta', { type: 'content_block_delta', index: idx, delta: { type: 'text_delta', text: delta } })); + }; + const sendReasoningDelta = (delta: string): void => { + if (!delta) return; + const idx = ensureThinkingIndex(); + if (!thinkingBlockOpen) { + res.write(sseEvent('content_block_start', { type: 'content_block_start', index: idx, content_block: { type: 'thinking', thinking: '', signature: '' } })); + thinkingBlockOpen = true; + } + streamedReasoning += delta; + res.write(sseEvent('content_block_delta', { type: 'content_block_delta', index: idx, delta: { type: 'thinking_delta', thinking: delta } })); + }; + const sendDelta = (delta: string, isReasoning: boolean = false): void => { + if (!delta) return; + if (isReasoning) rawReasoning += delta; + else rawContent += delta; + const parsedCalls = isReasoning ? parseReason(delta) : parseContent(delta); + parsedCalls.forEach((tc) => streamedToolCalls.push(tc)); + const filtered = isReasoning ? filterReasoning(delta) : filterContent(delta); + if (!filtered) return; + if (isReasoning) sendReasoningDelta(filtered); + else sendTextDelta(filtered); + }; + let collected: Record | null = null; + const collectPromise = collectFromEvents( + sessionId as string, + REQUEST_TIMEOUT_MS, + sendDelta, + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, + ).catch((err: unknown) => ({ __error: err })); + client.session.prompt(promptParams).catch((err: unknown) => logDebug('Prompt error:', toErrorMessage(err))); + const raced = (await Promise.race([collectPromise, resClosed.then(() => ({ __cancelled: true }))])) as Record; + collected = raced; + if (raced['__cancelled']) { + if (keepaliveInterval) clearInterval(keepaliveInterval); + try { + if (sessionId) await client.session.delete({ path: { id: sessionId } }); + } catch (e: unknown) { + logDebug('Failed to cleanup cancelled messages session', { error: toErrorMessage(e) }); + } + try { + if (!res.destroyed) res.end(); + } catch { + // ignore + } + return; + } + if (raced['error'] && !rawContent && !rawReasoning) throw normalizeBackendError(raced['error']); + if (raced['__error']) { + const polled = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + if (polled.error && !polled.content && !polled.reasoning && !rawContent && !rawReasoning) + throw normalizeBackendError(polled.error); + const { content, reasoning } = polled; + if (content && !rawContent) sendTextDelta(stripFunctionCallMarkup(content) as string); + if (reasoning && !rawReasoning) sendReasoningDelta(stripFunctionCallMarkup(reasoning) as string); + } + if (textBlockOpen) res.write(sseEvent('content_block_stop', { type: 'content_block_stop', index: textIndex })); + if (thinkingBlockOpen) res.write(sseEvent('content_block_stop', { type: 'content_block_stop', index: thinkingIndex })); + const flushedReasoningCalls = parseReason.flush ? parseReason.flush() : []; + const flushedContentCalls = parseContent.flush ? parseContent.flush() : []; + const flushedReasoningText = filterReasoning.flush ? filterReasoning.flush() : ''; + const flushedContentText = filterContent.flush ? filterContent.flush() : ''; + const finalReasoningText = rawReasoning + flushedReasoningText; + const finalContentText = rawContent + flushedContentText; + const parseStreamedToolCalls = (): FinalToolCall[] => { + if (externalToolRegistry.length === 0) return []; + const perChannel = [ + ...flushedReasoningCalls, + ...flushedContentCalls, + ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText), + ]; + if (perChannel.length > 0) return perChannel; + return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); + }; + let parsedToolCalls: FinalToolCall[] = + streamedToolCalls.length > 0 + ? [...streamedToolCalls, ...flushedReasoningCalls, ...flushedContentCalls] + : parseStreamedToolCalls(); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedMessagesToolCall(); + if (forcedResponse) { + parsedToolCalls = parseExternalToolCallsFromText( + externalToolRegistry, + forcedResponse['reasoning'] as string, + forcedResponse['content'] as string, + ); + } + } + const { validCalls: finalValidated } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); + let toolBlockIndex = nextBlockIndex; + for (const tc of toPublicToolCalls(finalValidated)) { + const tcr = tc as unknown as Record; + const fn = asRecord(tcr['function']); + res.write( + sseEvent('content_block_start', { + type: 'content_block_start', + index: toolBlockIndex, + content_block: { type: 'tool_use', id: tcr['id'], name: fn['name'], input: {} }, + }), + ); + res.write( + sseEvent('content_block_delta', { + type: 'content_block_delta', + index: toolBlockIndex, + delta: { type: 'input_json_delta', partial_json: (fn['arguments'] as string) || '{}' }, + }), + ); + res.write(sseEvent('content_block_stop', { type: 'content_block_stop', index: toolBlockIndex })); + toolBlockIndex += 1; + } + const outputTokens = estimateTokens(streamedText + streamedReasoning); + const stopReason = mapFinishToStopReason('stop', finalValidated.length > 0); + res.write( + sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: { output_tokens: outputTokens }, + }), + ); + res.write(sseEvent('message_stop', { type: 'message_stop' })); + if (keepaliveInterval) clearInterval(keepaliveInterval); + res.end(); + return; + } catch (error: unknown) { + if (keepaliveInterval) clearInterval(keepaliveInterval); + if (sessionId) { + try { + await client.session.delete({ path: { id: sessionId } }); + } catch (e: unknown) { + logDebug('Failed to cleanup messages session on error', { error: toErrorMessage(e) }); + } + } + if (!res.headersSent) { + const t = transformUpstreamError(error); + res.status(t.statusCode).json({ + type: 'error', + error: { + type: t.error.type || 'api_error', + message: t.error.message || toErrorMessage(error) || 'Upstream error', + ...(t.error.code ? { code: t.error.code } : {}), + }, + }); + return; + } + try { + const t = transformUpstreamError(error); + res.write( + sseEvent('error', { + type: 'error', + error: { type: t.error.type || 'api_error', message: t.error.message, ...(t.error.code ? { code: t.error.code } : {}) }, + }), + ); + } catch { + // ignore + } + res.end(); + return; + } + }, REQUEST_TIMEOUT_MS + 20000); + } catch (error: unknown) { + if (!res.headersSent) + res.status(500).json({ type: 'error', error: { type: 'api_error', message: toErrorMessage(error) } }); + } + }); +} diff --git a/src/routes/responses.ts b/src/routes/responses.ts new file mode 100644 index 0000000..84759bb --- /dev/null +++ b/src/routes/responses.ts @@ -0,0 +1,951 @@ +// P4 TS: POST /v1/responses (ported from P3 .js, behavior identical). +import crypto from 'crypto'; +import { findExternalToolByName } from '../tool-runtime/registry.js'; +import { EXTERNAL_TOOL_PREFIX } from '../tool-runtime/contracts.js'; +import { computeRetryDelay } from '../retry/policy.js'; +import { + stripFunctionCallMarkup, + parseExternalToolCallsFromText, + createToolCallFilter, + createExternalToolCallStreamParser, +} from '../tool-runtime/parser.js'; +import { isTransientUpstreamError, normalizeBackendError, transformUpstreamError } from '../errors/upstream.js'; +import { + withTimeout, + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, +} from '../config/proxy-config.js'; +import { sleep, ensureBackend } from '../backend/manager.js'; +import type { Application, Request, Response } from 'express'; +import type { AppContext } from '../types/context.js'; +import type { ExternalToolEntry } from '../tool-runtime/registry.js'; +import type { FinalToolCall } from '../tool-runtime/parser.js'; + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function toErrorMessage(e: unknown): string { + if (e instanceof Error) return e.message; + const r = asRecord(e); + const m: unknown = r['message']; + return typeof m === 'string' ? m : String(e); +} + +function readDataMessage(e: unknown): string { + const r = asRecord(e); + const data = asRecord(r['data']); + const dm: unknown = data['message']; + if (typeof dm === 'string') return dm; + const m: unknown = r['message']; + if (typeof m === 'string') return m; + return 'unknown'; +} + +interface NormalizedInputMessage { + role: string; + content: string; + isToolCalls?: boolean; +} + +export function registerResponsesRoutes(app: Application, ctx: AppContext): void { + const { + client, + config, + REQUEST_TIMEOUT_MS, + DISABLE_TOOLS, + maxAttempts, + resolveRequestedModel, + logDebug, + getResponseState, + storeResponseState, + buildSystemPrompt, + normalizeReasoningEffort, + stripFunctionCalls, + normalizeTextContent, + normalizeToolArguments, + normalizeToolResultContent, + createRequestToolContext, + finalizeValidatedToolCalls, + createForcedToolCallRequester, + trackToolMode, + getToolOverridesForMode, + collectFromEvents, + pollForAssistantResponse, + } = ctx; + + app.post('/v1/responses', async (req: Request, res: Response): Promise => { + let responsesKeepalive: ReturnType | null = null; + let responsesResClosed: Promise | null = null; + const stopResponsesKeepalive = (): void => { + if (responsesKeepalive) { + clearInterval(responsesKeepalive); + responsesKeepalive = null; + } + }; + try { + const body = asRecord((req as unknown as { body: unknown }).body); + const model: unknown = body['model']; + const input: unknown = body['input']; + const reasoning_effort: unknown = body['reasoning_effort']; + const requestReasoning: unknown = body['reasoning']; + const max_output_tokens: unknown = body['max_output_tokens']; + const toolsRaw: unknown = body['tools']; + const tools: unknown[] = Array.isArray(toolsRaw) ? (toolsRaw as unknown[]) : []; + const tool_choice: unknown = body['tool_choice']; + const instructions: unknown = body['instructions']; + const temperature: unknown = body['temperature']; + const top_p: unknown = body['top_p']; + const streamRaw: unknown = body['stream']; + const stream = Boolean(streamRaw); + const chatMessages: unknown = body['messages']; + const prompt: unknown = body['prompt']; + const previousResponseId: unknown = body['previous_response_id']; + const requestOpencodeConfig: unknown = body['opencode']; + + const previousState = + typeof previousResponseId === 'string' && previousResponseId ? getResponseState(previousResponseId) : null; + if (previousResponseId && !previousState) { + res.status(400).json({ error: { message: 'Invalid or expired previous_response_id' } }); + return; + } + + const reasoningLevel = normalizeReasoningEffort( + (reasoning_effort as string) || (asRecord(requestReasoning)['effort'] as string), + null, + ); + + const requestToolContext = createRequestToolContext(tools, tool_choice, requestOpencodeConfig); + const toolMode: string = requestToolContext.mode; + const internalToolContext = requestToolContext.internal; + trackToolMode(toolMode, { + configuredAllowlist: internalToolContext.allowedToolNames, + requestedAllowlist: internalToolContext.requestedAllowlist, + deniedRequestedTools: internalToolContext.deniedRequestedTools, + resolutionPath: internalToolContext.resolutionPath, + resultingMode: internalToolContext.resultingMode, + route: '/v1/responses', + }); + logDebug('Responses API request', { + model, + reasoning_effort: (reasoning_effort as string) || asRecord(requestReasoning)['effort'], + reasoningLevel, + max_output_tokens, + toolMode, + internalAllowedTools: internalToolContext.allowedToolNames, + requestedInternalTools: internalToolContext.requestedAllowlist, + deniedRequestedTools: internalToolContext.deniedRequestedTools, + resolutionPath: internalToolContext.resolutionPath, + resultingMode: internalToolContext.resultingMode, + }); + const externalToolContext = requestToolContext.external; + const externalToolRegistry: ExternalToolEntry[] = externalToolContext.registry; + const externalToolChoice = externalToolContext.toolChoice; + const assistantToolCalls = new Map(); + + const rememberAssistantToolCall = (toolCallId: unknown, toolName: unknown): void => { + if (!toolCallId || !toolName || typeof toolCallId !== 'string' || typeof toolName !== 'string') return; + assistantToolCalls.set(toolCallId, toolName); + }; + + const buildResponsesToolResultLine = (item: unknown = {}): string | null => { + const ir = asRecord(item); + const text = normalizeToolResultContent( + ir['content'] ?? ir['output'] ?? ir['result'] ?? ir['text'], + ); + if (!text) return null; + const callIdRaw: unknown = ir['call_id'] ?? ir['tool_call_id']; + const mappedTool = + findExternalToolByName(externalToolRegistry, ir['name']) || + findExternalToolByName(externalToolRegistry, assistantToolCalls.get(String(callIdRaw ?? ''))); + const toolName = + mappedTool?.namespacedName || + assistantToolCalls.get(String(callIdRaw ?? '')) || + (typeof ir['name'] === 'string' ? (ir['name'] as string) : `${EXTERNAL_TOOL_PREFIX}unknown`); + const toolCallId = + typeof ir['call_id'] === 'string' + ? (ir['call_id'] as string) + : typeof ir['tool_call_id'] === 'string' + ? (ir['tool_call_id'] as string) + : `call_${String(toolName).replace(/[^a-zA-Z0-9_]/g, '_')}`; + rememberAssistantToolCall(toolCallId, toolName); + return `TOOL_RESULT: ${JSON.stringify({ tool_call_id: toolCallId, name: toolName, content: text })}`; + }; + + const buildResponsesAssistantToolCallsLine = (item: unknown = {}): string | null => { + const ir = asRecord(item); + const toolCallsRaw: unknown = ir['tool_calls']; + let sourceCalls: unknown[]; + if (Array.isArray(toolCallsRaw)) sourceCalls = toolCallsRaw as unknown[]; + else if (ir['type'] === 'function_call') sourceCalls = [item]; + else sourceCalls = []; + if (!sourceCalls.length) return null; + const serializedToolCalls = sourceCalls + .map((toolCall: unknown, index: number) => { + const tcr = asRecord(toolCall); + const fn = asRecord(tcr['function']); + const rawName: unknown = fn['name'] ?? tcr['name']; + const mappedTool = findExternalToolByName(externalToolRegistry, rawName); + const namespacedName = + mappedTool?.namespacedName ?? (typeof rawName === 'string' ? rawName : null); + if (!namespacedName) return null; + const toolCallId = + typeof tcr['call_id'] === 'string' + ? (tcr['call_id'] as string) + : typeof tcr['id'] === 'string' + ? (tcr['id'] as string) + : `call_${index + 1}`; + rememberAssistantToolCall(toolCallId, namespacedName); + return { + id: toolCallId, + name: namespacedName, + arguments: normalizeToolArguments(fn['arguments'] ?? tcr['arguments']), + }; + }) + .filter((v): v is { id: string; name: string; arguments: string } => v !== null); + if (!serializedToolCalls.length) return null; + return `ASSISTANT: ${JSON.stringify(serializedToolCalls)}`; + }; + + const buildResponsesInputMessages = (rawItems: unknown): NormalizedInputMessage[] => { + const normalized: NormalizedInputMessage[] = []; + if (!Array.isArray(rawItems)) return normalized; + for (const item of rawItems as unknown[]) { + if (!item) continue; + const ir = asRecord(item); + if (ir['type'] === 'function_call_output' || ir['type'] === 'tool_result' || ir['role'] === 'tool') { + const toolResultLine = buildResponsesToolResultLine(item); + if (toolResultLine) normalized.push({ role: 'tool', content: toolResultLine }); + continue; + } + if (ir['type'] === 'function_call') { + const line = buildResponsesAssistantToolCallsLine(item); + if (line) normalized.push({ role: 'assistant', content: line, isToolCalls: true }); + continue; + } + const toolCallsRaw: unknown = ir['tool_calls']; + if (ir['role'] === 'assistant' && Array.isArray(toolCallsRaw) && (toolCallsRaw as unknown[]).length) { + const line = buildResponsesAssistantToolCallsLine(item); + if (line) normalized.push({ role: 'assistant', content: line, isToolCalls: true }); + } + if (ir['type'] === 'message') { + const role = typeof ir['role'] === 'string' ? (ir['role'] as string) : 'user'; + const content = normalizeTextContent(ir['content']); + if (content) normalized.push({ role, content }); + continue; + } + if (ir['type'] === 'input_text') { + if (ir['text']) normalized.push({ role: 'user', content: String(ir['text']) }); + continue; + } + const text = normalizeTextContent(ir['content'] ?? ir['text']); + if (text) normalized.push({ role: typeof ir['role'] === 'string' ? (ir['role'] as string) : 'user', content: text }); + } + return normalized; + }; + + let messages: NormalizedInputMessage[] = []; + if (Array.isArray(chatMessages) && (chatMessages as unknown[]).length) { + messages = buildResponsesInputMessages(chatMessages); + } else if (typeof prompt === 'string' && prompt.trim()) { + messages = [{ role: 'user', content: prompt }]; + } else if (typeof input === 'string') { + messages = [{ role: 'user', content: input }]; + } else if (Array.isArray(input)) { + messages = buildResponsesInputMessages(input); + } else if (input && typeof input === 'object') { + const ir = asRecord(input); + if (ir['type'] === 'message' || ir['type'] === 'function_call' || ir['type'] === 'function_call_output' || ir['type'] === 'tool_result') { + messages = buildResponsesInputMessages([input]); + } else { + const content = normalizeTextContent(ir['content'] ?? ir['text']); + if (content) { + messages = [{ role: typeof ir['role'] === 'string' ? (ir['role'] as string) : 'user', content }]; + } + } + } + + if (!messages.length) { + res.status(400).json({ error: { message: 'input is required' } }); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + const flushHeaders = (res as unknown as { flushHeaders?: unknown }).flushHeaders; + if (typeof flushHeaders === 'function') (flushHeaders as () => void).call(res); + responsesKeepalive = setInterval(() => { + if (!res.destroyed && !res.writableEnded) res.write(': heartbeat\n\n'); + }, 15000); + 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', + )) as unknown as { providerID: string; modelID: string }; + const pID = String(asRecord(resolvedModel)['providerID']); + const mID = String(asRecord(resolvedModel)['modelID']); + + await ensureBackend(config); + + try { + await client.config.update({ + body: { activeModel: { providerID: pID, modelID: mID } }, + }); + } catch { + // ignore + } + + let sessionId: string | null = previousState?.sessionId || null; + if (!sessionId) { + const sessionRes = (await withTimeout(client.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + sessionId = (asRecord(asRecord(sessionRes)['data'])['id'] as string | undefined) ?? null; + if (!sessionId) { + throw new Error('Failed to create OpenCode session'); + } + } + + const parts: Record[] = []; + const systemChunks: string[] = []; + let fullPromptText = ''; + const formatResponsesRoleLine = (role: unknown, text: unknown): string => + `${String(role || 'user').toUpperCase()}: ${String(text ?? '')}`; + for (const msg of messages) { + if (msg.role === 'system') { + if (msg.content) systemChunks.push(msg.content); + continue; + } + if (!msg.content) continue; + const text = + msg.role === 'tool' || msg.content.startsWith('ASSISTANT: ') || msg.content.startsWith('TOOL_RESULT: ') + ? msg.content + : msg.role === 'user' + ? msg.content + : formatResponsesRoleLine(msg.role, msg.content); + parts.push({ type: 'text', text }); + fullPromptText += `${text}\n\n`; + } + + const systemWithGuard = buildSystemPrompt( + [instructions, ...systemChunks, externalToolContext.prompt].filter(Boolean).join('\n\n'), + reasoningLevel, + toolMode, + internalToolContext.allowedToolNames, + ); + + const toolOverrides = (await withTimeout( + getToolOverridesForMode(toolMode, internalToolContext), + REQUEST_TIMEOUT_MS, + 'load tool overrides', + )) as Record | null; + const makeForcedResponsesToolCallRequester = (): (() => Promise | null>) => + createForcedToolCallRequester({ + mode: externalToolChoice.mode, + sessionId: sessionId as string, + systemWithGuard, + requiredTool: externalToolChoice.requiredTool ?? externalToolRegistry[0]?.namespacedName, + providerID: pID, + modelID: mID, + toolOverrides, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + forbidThinkBlock: false, + }); + let requestForcedResponsesToolCall = makeForcedResponsesToolCallRequester(); + + const promptParams: { path: { id: string }; body: Record } = { + path: { id: sessionId as string }, + body: { + model: { providerID: pID, modelID: mID }, + ...(systemWithGuard ? { system: systemWithGuard } : {}), + parts: externalToolContext.reminder ? [...parts, { type: 'text', text: externalToolContext.reminder }] : parts, + ...(max_output_tokens ? { max_tokens: max_output_tokens } : {}), + ...(temperature !== undefined ? { temperature } : {}), + ...(top_p !== undefined ? { top_p } : {}), + }, + }; + if (toolOverrides && Object.keys(toolOverrides).length > 0) { + promptParams.body['tools'] = toolOverrides; + } + + let content = ''; + let reasoning = ''; + const buildResponsesFunctionCallOutputItem = (toolCall: FinalToolCall): Record => ({ + id: toolCall.id, + type: 'function_call', + status: 'completed', + call_id: toolCall.id, + name: (toolCall.function as Record)['name'], + arguments: (toolCall.function as Record)['arguments'], + }); + + const buildResponsesMessageOutputItem = ( + text: unknown, + messageId: string = `msg_${crypto.randomUUID()}`, + ): Record | null => { + if (!text || (typeof text === 'string' && !text)) return null; + if (typeof text === 'string' && !text.trim()) return null; + if (!text) return null; + return { + id: messageId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [ + { + type: 'output_text', + text, + annotations: [], + }, + ], + }; + }; + + if (stream) { + if (res.destroyed || res.writableEnded) { + stopResponsesKeepalive(); + try { + if (!res.destroyed) res.end(); + } catch { + // ignore + } + return; + } + const responseId = `resp_${crypto.randomUUID()}`; + const messageOutputIndex = 0; + const reasoningOutputIndex = 1; + const contentIndex = 0; + const outputItemId = `msg_${crypto.randomUUID()}`; + const reasoningItemId = 'reasoning-0'; + let nextOutputIndex = 2; + let sequenceNumber = 0; + let announcedOutput = false; + let announcedContent = false; + let announcedReasoning = false; + const nextSeq = (): number => sequenceNumber++; + const emit = (payload: unknown): void => { + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + + const createdAt = Math.floor(Date.now() / 1000); + emit({ + type: 'response.created', + sequence_number: nextSeq(), + response: { id: responseId, object: 'response', created: createdAt, created_at: createdAt, status: 'in_progress', model: `${pID}/${mID}` }, + }); + + const shouldStripStreamingToolMarkup = externalToolRegistry.length > 0; + const filterContentDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldStripStreamingToolMarkup }); + const filterReasoningDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldStripStreamingToolMarkup }); + const parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + const parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + const streamedToolCalls: FinalToolCall[] = []; + let rawContent = ''; + let rawReasoning = ''; + const ensureOutputScaffold = (): void => { + if (!announcedOutput) { + emit({ + type: 'response.output_item.added', + sequence_number: nextSeq(), + output_index: messageOutputIndex, + item: { id: outputItemId, type: 'message', status: 'in_progress', role: 'assistant', content: [] }, + }); + announcedOutput = true; + } + if (!announcedContent) { + emit({ + type: 'response.content_part.added', + sequence_number: nextSeq(), + output_index: messageOutputIndex, + content_index: contentIndex, + item_id: outputItemId, + part: { type: 'output_text', text: '', annotations: [] }, + }); + announcedContent = true; + } + }; + const ensureReasoningScaffold = (): void => { + if (!announcedReasoning) { + emit({ + type: 'response.output_item.added', + sequence_number: nextSeq(), + output_index: reasoningOutputIndex, + item: { id: reasoningItemId, type: 'reasoning', status: 'in_progress', summary: [{ type: 'summary_text', text: '' }] }, + }); + announcedReasoning = true; + } + }; + const emitResponsesFunctionCall = (toolCall: FinalToolCall): void => { + const outputIndex = nextOutputIndex++; + const functionCallItem = buildResponsesFunctionCallOutputItem(toolCall); + streamedToolCalls.push(toolCall); + emit({ + type: 'response.output_item.added', + sequence_number: nextSeq(), + output_index: outputIndex, + item: { ...functionCallItem, status: 'in_progress' }, + }); + emit({ + type: 'response.function_call_arguments.delta', + sequence_number: nextSeq(), + output_index: outputIndex, + item_id: toolCall.id, + delta: (toolCall.function as Record)['arguments'], + }); + emit({ + type: 'response.function_call_arguments.done', + sequence_number: nextSeq(), + output_index: outputIndex, + item_id: toolCall.id, + arguments: (toolCall.function as Record)['arguments'], + }); + emit({ + type: 'response.output_item.done', + sequence_number: nextSeq(), + output_index: outputIndex, + item: functionCallItem, + }); + }; + const sendResponsesDelta = (delta: string, isReasoning: boolean = false): void => { + if (!delta) return; + if (isReasoning) rawReasoning += delta; + else rawContent += delta; + const parsedDeltaToolCalls = isReasoning ? parseReasoningToolCalls(delta) : parseContentToolCalls(delta); + if (parsedDeltaToolCalls.length > 0) { + const { validCalls: allowedDeltaToolCalls } = finalizeValidatedToolCalls(parsedDeltaToolCalls, externalToolRegistry); + allowedDeltaToolCalls.forEach((toolCall) => { + const fn = asRecord((toolCall as unknown as Record)['function']); + emitResponsesFunctionCall({ + id: String((toolCall as unknown as Record)['id']), + type: 'function', + function: { name: fn['name'], arguments: fn['arguments'] }, + } as unknown as FinalToolCall); + }); + } + const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); + if (!filtered) return; + if (isReasoning) { + ensureReasoningScaffold(); + reasoning += filtered; + emit({ + type: 'response.reasoning_summary_text.delta', + sequence_number: nextSeq(), + output_index: reasoningOutputIndex, + item_id: reasoningItemId, + summary_index: 0, + delta: filtered, + }); + } else { + if (!filtered.trim()) { + content += filtered; + return; + } + ensureOutputScaffold(); + content += filtered; + emit({ + type: 'response.output_text.delta', + sequence_number: nextSeq(), + output_index: messageOutputIndex, + content_index: contentIndex, + item_id: outputItemId, + delta: filtered, + }); + } + }; + + let collected: Record | null = null; + try { + const collectPromise = collectFromEvents( + sessionId as string, + REQUEST_TIMEOUT_MS, + sendResponsesDelta, + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, + ); + const safeCollect = collectPromise.catch((err: unknown) => ({ __error: err })); + client.session.prompt(promptParams).catch((err: unknown) => logDebug('Responses prompt error:', toErrorMessage(err))); + const raced = responsesResClosed + ? await Promise.race([safeCollect, responsesResClosed.then(() => ({ __cancelled: true }))]) + : await safeCollect; + const racedRecord = asRecord(raced); + if (racedRecord['__cancelled']) { + stopResponsesKeepalive(); + try { + if (sessionId) await client.session.delete({ path: { id: sessionId } }); + } catch (e: unknown) { + logDebug('Failed to cleanup cancelled responses session', { error: toErrorMessage(e) }); + } + try { + if (!res.destroyed) res.end(); + } catch { + // ignore + } + return; + } + collected = raced as Record; + } catch (e: unknown) { + collected = { __error: e }; + } + + const collectedR = asRecord(collected); + if (!content && !reasoning) { + const polled = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + if (polled.error && !polled.content && !polled.reasoning) throw normalizeBackendError(polled.error); + if (polled.reasoning) sendResponsesDelta(polled.reasoning, true); + if (polled.content) sendResponsesDelta(polled.content, false); + } else if (collected && collectedR['idleTimeout']) { + const polled = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + if (polled.error && !polled.content && !polled.reasoning) throw normalizeBackendError(polled.error); + const remainingReasoning = + polled.reasoning && polled.reasoning.startsWith(rawReasoning) + ? polled.reasoning.slice(rawReasoning.length) + : polled.reasoning; + const remainingContent = + polled.content && polled.content.startsWith(rawContent) ? polled.content.slice(rawContent.length) : polled.content; + if (remainingReasoning) sendResponsesDelta(remainingReasoning, true); + if (remainingContent) sendResponsesDelta(remainingContent, false); + } else if (collected && ((collectedR['content'] as string) || (collectedR['reasoning'] as string))) { + if (!reasoning && collectedR['reasoning']) sendResponsesDelta(String(collectedR['reasoning']), true); + if (!content && collectedR['content']) sendResponsesDelta(String(collectedR['content']), false); + } + + if (announcedReasoning) { + emit({ + type: 'response.reasoning_summary_text.done', + sequence_number: nextSeq(), + output_index: reasoningOutputIndex, + item_id: reasoningItemId, + summary_index: 0, + text: reasoning, + }); + emit({ + type: 'response.output_item.done', + sequence_number: nextSeq(), + output_index: reasoningOutputIndex, + item: { id: reasoningItemId, type: 'reasoning', status: 'completed', summary: [{ type: 'summary_text', text: reasoning }] }, + }); + } + + const hasMeaningfulContent = Boolean(content && content.trim()); + + if (announcedContent && hasMeaningfulContent) { + emit({ + type: 'response.output_text.done', + sequence_number: nextSeq(), + output_index: messageOutputIndex, + content_index: contentIndex, + item_id: outputItemId, + text: content, + }); + emit({ + type: 'response.content_part.done', + sequence_number: nextSeq(), + output_index: messageOutputIndex, + content_index: contentIndex, + item_id: outputItemId, + part: { type: 'output_text', text: content, annotations: [] }, + }); + emit({ + type: 'response.output_item.done', + sequence_number: nextSeq(), + output_index: messageOutputIndex, + item: { id: outputItemId, type: 'message', status: 'completed', role: 'assistant', content: [{ type: 'output_text', text: content, annotations: [] }] }, + }); + } + + let polledForToolCalls: { content: string; reasoning: string; error: unknown } | null = null; + if (externalToolRegistry.length > 0 && streamedToolCalls.length === 0) { + try { + polledForToolCalls = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + } catch { + // ignore + } + } + + const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : []; + const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : []; + const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : ''; + const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : ''; + const finalReasoningText = (polledForToolCalls?.reasoning || rawReasoning) + flushedReasoningText; + const finalContentText = (polledForToolCalls?.content || rawContent) + flushedContentText; + + const parseStreamedToolCalls = (): FinalToolCall[] => { + if (externalToolRegistry.length === 0) return []; + const perChannel = [ + ...flushedReasoningCalls, + ...flushedContentCalls, + ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText), + ]; + if (perChannel.length > 0) return perChannel; + return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); + }; + + let parsedToolCalls: FinalToolCall[] = + streamedToolCalls.length > 0 ? streamedToolCalls : parseStreamedToolCalls(); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedResponsesToolCall(); + if (forcedResponse) { + parsedToolCalls = parseExternalToolCallsFromText( + externalToolRegistry, + forcedResponse['reasoning'] as string, + forcedResponse['content'] as string, + ); + } + } + const { validCalls: validatedStreamedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + if (streamedToolCalls.length === 0) { + validatedStreamedToolCalls.forEach((toolCall) => { + const record = toolCall as unknown as Record; + const fn = asRecord(record['function']); + emitResponsesFunctionCall({ + id: String(record['id']), + type: 'function', + function: { name: fn['name'], arguments: fn['arguments'] }, + } as unknown as FinalToolCall); + }); + } + const streamOutput: Record[] = []; + const streamMessageOutputItem = buildResponsesMessageOutputItem( + safeContent && safeContent.trim() ? safeContent : '', + outputItemId, + ); + if (streamMessageOutputItem) streamOutput.push(streamMessageOutputItem); + validatedStreamedToolCalls.forEach((toolCall) => { + const record = toolCall as unknown as Record; + const fn = asRecord(record['function']); + streamOutput.push({ + id: String(record['id']), + type: 'function_call', + status: 'completed', + call_id: String(record['id']), + name: fn['name'], + arguments: fn['arguments'], + }); + }); + const promptTokens = Math.ceil(fullPromptText.length / 4); + const completionTokens = Math.ceil(content.length / 4); + const reasoningTokens = Math.ceil(reasoning.length / 4); + const completedAt = Math.floor(Date.now() / 1000); + const response = { + id: responseId, + object: 'response', + created: completedAt, + created_at: completedAt, + status: 'completed', + model: `${pID}/${mID}`, + reasoning: safeReasoning ? { effort: reasoningLevel, summary: safeReasoning.substring(0, 100) } : undefined, + output: streamOutput, + error: null, + incomplete_details: null, + usage: { + input_tokens: promptTokens, + output_tokens: completionTokens + reasoningTokens, + total_tokens: promptTokens + completionTokens + reasoningTokens, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: reasoningTokens }, + }, + }; + emit({ type: 'response.completed', sequence_number: nextSeq(), response }); + res.write('data: [DONE]\n\n'); + storeResponseState(responseId, sessionId, `${pID}/${mID}`); + stopResponsesKeepalive(); + res.end(); + return; + } + + let responseRes: unknown = null; + let responseParts: unknown[] = []; + let promptContent = ''; + let promptReasoning = ''; + let promptParsedToolCalls: FinalToolCall[] = []; + let polledFilled = false; + let lastResponsesAttemptError: unknown = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (res.writableEnded || res.destroyed) { + logDebug('Client gone, stop retrying', { sessionId, attempt }); + return; + } + if (attempt > 1) { + try { + await client.session.delete({ path: { id: sessionId } }); + } catch (e: unknown) { + logDebug('Failed to delete retried session', { sessionId, error: toErrorMessage(e) }); + } + const retrySessionRes = (await withTimeout(client.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + sessionId = (asRecord(asRecord(retrySessionRes)['data'])['id'] as string | undefined) ?? null; + if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); + promptParams.path.id = sessionId; + requestForcedResponsesToolCall = makeForcedResponsesToolCallRequester(); + await sleep(computeRetryDelay(attempt - 1, lastResponsesAttemptError)); + } + let polledResponse: { content: string; reasoning: string; error: unknown } | null = null; + try { + responseRes = await withTimeout(client.session.prompt(promptParams), REQUEST_TIMEOUT_MS, 'prompt backend'); + const dataParts: unknown = asRecord(asRecord(responseRes)['data'])['parts']; + responseParts = Array.isArray(dataParts) ? (dataParts as unknown[]) : []; + promptContent = responseParts + .filter((p) => asRecord(p)['type'] === 'text') + .map((p) => String(asRecord(p)['text'] ?? '')) + .join('\n'); + promptReasoning = responseParts + .filter((p) => asRecord(p)['type'] === 'reasoning') + .map((p) => String(asRecord(p)['text'] ?? '')) + .join('\n'); + promptParsedToolCalls = + externalToolRegistry.length > 0 + ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) + : []; + if (promptContent || promptReasoning) break; + polledResponse = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + } catch (loopError: unknown) { + if (attempt < maxAttempts && isTransientUpstreamError(loopError)) { + console.warn( + `[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, + readDataMessage(loopError), + ); + lastResponsesAttemptError = loopError; + continue; + } + throw normalizeBackendError(loopError); + } + if (polledResponse && polledResponse.error && !polledResponse.content && !polledResponse.reasoning) { + if (attempt < maxAttempts && isTransientUpstreamError(polledResponse.error)) { + console.warn( + `[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, + readDataMessage(polledResponse.error), + ); + lastResponsesAttemptError = polledResponse.error; + continue; + } + throw normalizeBackendError(polledResponse.error); + } + if (polledResponse) { + content = polledResponse.content || content; + reasoning = polledResponse.reasoning || reasoning; + polledFilled = true; + } + break; + } + + if (!polledFilled) { + content = promptParsedToolCalls.length > 0 ? '' : promptContent; + reasoning = promptReasoning; + } + + let promptBasedToolCalls: FinalToolCall[] = promptParsedToolCalls; + if (polledFilled) { + promptBasedToolCalls = + externalToolRegistry.length > 0 + ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) + : []; + } + + if (!content && !reasoning && responseRes && promptBasedToolCalls.length === 0) { + const data: unknown = asRecord(responseRes)['data']; + if (typeof data === 'string') content = data; + else { + const dr = asRecord(data); + content = typeof dr['message'] === 'string' ? (dr['message'] as string) : JSON.stringify(data); + } + } + + let parsedToolCalls: FinalToolCall[] = + promptBasedToolCalls.length > 0 + ? promptBasedToolCalls + : externalToolRegistry.length > 0 + ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) + : []; + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedResponsesToolCall(); + if (forcedResponse) { + content = String(forcedResponse['content'] ?? content); + reasoning = String(forcedResponse['reasoning'] ?? reasoning); + parsedToolCalls = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); + } + } + const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + + const promptTokens = Math.ceil(fullPromptText.length / 4); + const completionTokens = Math.ceil(content.length / 4); + const reasoningTokens = Math.ceil(reasoning.length / 4); + const output: Record[] = []; + const messageOutputItem = buildResponsesMessageOutputItem(safeContent); + if (messageOutputItem) output.push(messageOutputItem); + validatedToolCalls.forEach((toolCall) => { + const record = toolCall as unknown as Record; + const fn = asRecord(record['function']); + output.push({ + id: String(record['id']), + type: 'function_call', + status: 'completed', + call_id: String(record['id']), + name: fn['name'], + arguments: fn['arguments'], + }); + }); + + const responseId = `resp_${crypto.randomUUID()}`; + const createdAt = Math.floor(Date.now() / 1000); + const response = { + id: responseId, + object: 'response', + created: createdAt, + created_at: createdAt, + status: 'completed', + model: `${pID}/${mID}`, + reasoning: safeReasoning ? { effort: reasoningLevel, summary: safeReasoning.substring(0, 100) } : undefined, + output, + error: null, + incomplete_details: null, + usage: { + input_tokens: promptTokens, + output_tokens: completionTokens + reasoningTokens, + total_tokens: promptTokens + completionTokens + reasoningTokens, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: reasoningTokens }, + }, + }; + + storeResponseState(responseId, sessionId, `${pID}/${mID}`); + + res.json(response); + return; + } catch (error: unknown) { + stopResponsesKeepalive(); + console.error('[Proxy] Responses API Error:', toErrorMessage(error)); + const transformed = transformUpstreamError(error); + if (res.headersSent) { + try { + const failedAt = Math.floor(Date.now() / 1000); + res.write( + `data: ${JSON.stringify({ + type: 'response.failed', + response: { id: `resp_${crypto.randomUUID()}`, object: 'response', created: failedAt, created_at: failedAt, status: 'failed', error: transformed.error }, + })}\n\n`, + ); + res.write('data: [DONE]\n\n'); + } catch (writeError: unknown) { + logDebug('Failed to report error on open response stream', { error: toErrorMessage(writeError) }); + } + res.end(); + return; + } + res.status(transformed.statusCode).json(transformed.error); + return; + } + }); +} diff --git a/src/routes/system.ts b/src/routes/system.ts new file mode 100644 index 0000000..57d21d0 --- /dev/null +++ b/src/routes/system.ts @@ -0,0 +1,151 @@ +// P4 TS: system routes (models/health/details/metrics/404) (ported from P3 .js, behavior identical). +import type { Application, Request, Response } from 'express'; +import type { AppContext } from '../types/context.js'; + +function toErrorMessage(e: unknown): string { + return e instanceof Error ? e.message : String((e as Record)?.['message'] ?? e); +} + +export function registerSystemRoutes(app: Application, ctx: AppContext): void { + const { + API_KEY, + INTERNAL_TOOL_METRICS_ENABLED, + INTERNAL_TOOL_DISCOVERY_FIXTURE, + HEALTH_DETAILS_ENABLED, + HEALTH_DETAILS_REQUIRE_AUTH, + METRICS_ENABLED, + METRICS_REQUIRE_AUTH, + SERVER_INTERNAL_ALLOWED_TOOL_NAMES, + getProvidersList, + buildModelsList, + normalizeConfiguredToolNames, + internalToolMetrics, + getCachedToolIds, + getCachedToolIdsAt, + } = ctx; + const hasValidBearerAuth = (req: Request): boolean => { + if (!API_KEY || API_KEY.trim() === '') return true; + const authHeader: unknown = req.headers.authorization; + const apiKeyHeader: unknown = req.headers['x-api-key']; + return Boolean( + (authHeader && authHeader === `Bearer ${API_KEY}`) || (apiKeyHeader && apiKeyHeader === API_KEY), + ); + }; + + const shouldAllowOperationalEndpoint = ( + req: Request, + opts: { enabled: unknown; requireAuth: unknown }, + ): boolean => { + if (!opts.enabled) return false; + if (!opts.requireAuth) return true; + return hasValidBearerAuth(req); + }; + + // Models endpoint + app.get('/v1/models', (_req: Request, res: Response): void => { + void (async (): Promise => { + try { + const models = buildModelsList(await getProvidersList()); + res.json({ object: 'list', data: models }); + } catch (error: unknown) { + console.error('[Proxy] Model Fetch Error:', toErrorMessage(error)); + res.json({ object: 'list', data: [{ id: 'opencode/kimi-k2.5-free', object: 'model' }] }); + } + })(); + }); + + app.get('/health', (_req: Request, res: Response): void => { + res.json({ + status: 'ok', + proxy: true, + }); + }); + + app.get('/health/details', (req: Request, res: Response): void => { + if ( + !shouldAllowOperationalEndpoint(req, { + enabled: HEALTH_DETAILS_ENABLED, + requireAuth: HEALTH_DETAILS_REQUIRE_AUTH, + }) + ) { + res.status(HEALTH_DETAILS_ENABLED ? 401 : 404).json({ + error: { message: HEALTH_DETAILS_ENABLED ? 'Unauthorized' : 'Not found' }, + }); + return; + } + const metricsSnapshot = INTERNAL_TOOL_METRICS_ENABLED ? { ...internalToolMetrics } : null; + const cached = getCachedToolIds(); + const cachedAt = getCachedToolIdsAt(); + res.json({ + status: 'ok', + proxy: true, + internal_tools: { + config: { + allowed_tools: SERVER_INTERNAL_ALLOWED_TOOL_NAMES, + metrics_enabled: INTERNAL_TOOL_METRICS_ENABLED, + discovery_fixture: normalizeConfiguredToolNames(INTERNAL_TOOL_DISCOVERY_FIXTURE), + }, + metrics: metricsSnapshot, + cache: { + tool_ids_cached: !!cached, + tool_id_count: cached ? cached.length : 0, + age_ms: cachedAt ? Date.now() - cachedAt : null, + }, + audit: { + available: true, + fields: [ + 'requestedAllowlist', + 'allowedToolNames', + 'deniedRequestedTools', + 'resolutionPath', + 'resultingMode', + ], + }, + }, + }); + }); + + app.get('/metrics', (req: Request, res: Response): void => { + if ( + !shouldAllowOperationalEndpoint(req, { + enabled: METRICS_ENABLED, + requireAuth: METRICS_REQUIRE_AUTH, + }) + ) { + res.status(METRICS_ENABLED ? 401 : 404).send(METRICS_ENABLED ? 'Unauthorized' : 'Not found'); + return; + } + + const cached = getCachedToolIds(); + const metricsLines = [ + '# HELP opencode_internal_tool_mode_requests_total Count of internal tool mode selections by mode.', + '# TYPE opencode_internal_tool_mode_requests_total counter', + `opencode_internal_tool_mode_requests_total{mode="external_bridge"} ${internalToolMetrics.externalBridgeRequests}`, + `opencode_internal_tool_mode_requests_total{mode="internal_allowlist"} ${internalToolMetrics.internalAllowlistRequests}`, + `opencode_internal_tool_mode_requests_total{mode="disabled"} ${internalToolMetrics.disabledRequests}`, + '# HELP opencode_internal_tool_discovery_failures_total Count of backend tool discovery failures.', + '# TYPE opencode_internal_tool_discovery_failures_total counter', + `opencode_internal_tool_discovery_failures_total ${internalToolMetrics.discoveryFailures}`, + '# HELP opencode_internal_tool_fallback_disabled_total Count of allowlist resolutions that fell back to disabled.', + '# TYPE opencode_internal_tool_fallback_disabled_total counter', + `opencode_internal_tool_fallback_disabled_total ${internalToolMetrics.fallbackToDisabled}`, + '# HELP opencode_internal_tool_cache_ids Number of cached backend tool IDs.', + '# TYPE opencode_internal_tool_cache_ids gauge', + `opencode_internal_tool_cache_ids ${cached ? cached.length : 0}`, + ]; + + res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + res.send(`${metricsLines.join('\n')}\n`); + }); +} + +export function registerNotFoundRoute(app: Application): void { + app.use((req: Request, res: Response): void => { + res.status(404).json({ + error: { + message: `Route not found: ${req.method} ${req.path}`, + type: 'not_found_error', + }, + }); + }); +} diff --git a/src/stream/collector.ts b/src/stream/collector.ts new file mode 100644 index 0000000..c5c6f7a --- /dev/null +++ b/src/stream/collector.ts @@ -0,0 +1,449 @@ +// P4 TS: streaming collector (poll/event) + image fetch + prompt timeout (ported from P3 .js). +import http from 'http'; +import https from 'https'; +import { sleep } from '../backend/manager.js'; +import { withTimeout, DEFAULT_POLL_INTERVAL_MS } from '../config/proxy-config.js'; +import type { ProxyClient } from '../types/client.js'; +import type { CollectorHandle } from '../types/context.js'; + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function toErrorMessage(e: unknown): string { + return e instanceof Error ? e.message : String((e as Record)?.['message'] ?? e); +} + +interface CollectorDeps { + client: ProxyClient; + logDebug: (...args: unknown[]) => void; +} + +interface BackendPart { + id?: unknown; + type?: unknown; + text?: unknown; + tool?: unknown; + state?: unknown; + sessionID?: unknown; + [key: string]: unknown; +} + +interface BackendEvent { + type?: unknown; + properties?: unknown; +} + +// --- Mutex Logic with Timeout --- +export async function getImageDataUri(url: unknown): Promise { + const urlStr = String(url ?? ''); + if (urlStr.startsWith('data:')) { + return urlStr; + } + + if (!urlStr.startsWith('http://') && !urlStr.startsWith('https://')) { + throw new Error(`Invalid URL scheme: ${urlStr}`); + } + + return new Promise((resolve, reject) => { + const protocol = urlStr.startsWith('https') ? https : http; + + const req = protocol.get(urlStr, { timeout: 10000 }, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`Failed to fetch image: HTTP ${res.statusCode}`)); + return; + } + + const contentType = (res.headers['content-type'] as string) || 'image/jpeg'; + const chunks: Buffer[] = []; + + res.on('data', (chunk: unknown) => chunks.push(chunk as Buffer)); + res.on('end', () => { + try { + const buffer = Buffer.concat(chunks); + const base64 = buffer.toString('base64'); + resolve(`data:${contentType};base64,${base64}`); + } catch (e: unknown) { + reject(new Error(`Failed to encode image: ${toErrorMessage(e)}`)); + } + }); + }); + + req.on('error', (e: unknown) => reject(e instanceof Error ? e : new Error(String(e)))); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Image fetch timeout')); + }); + }); +} + +export function extractFromParts(parts: unknown): { content: string; reasoning: string; toolParts: BackendPart[] } { + if (!Array.isArray(parts)) return { content: '', reasoning: '', toolParts: [] }; + const list = parts as BackendPart[]; + const content = list + .filter((p) => (p as Record)['type'] === 'text') + .map((p) => String((p as Record)['text'] ?? '')) + .join(''); + const reasoning = list + .filter((p) => (p as Record)['type'] === 'reasoning') + .map((p) => String((p as Record)['text'] ?? '')) + .join(''); + const toolParts = list.filter((p) => (p as Record)['type'] === 'tool'); + return { content, reasoning, toolParts }; +} + +function extractMessagesList(messagesRes: unknown): unknown[] { + const record = asRecord(messagesRes); + const data: unknown = record['data'] ?? messagesRes; + if (Array.isArray(data)) return data as unknown[]; + if (Array.isArray(messagesRes)) return messagesRes as unknown[]; + return []; +} + +/** + * Create per-instance collector closing over the OpenCode client + logDebug + * (same closure semantics as the original createApp inner functions). + */ +export function createCollector(deps: CollectorDeps): CollectorHandle { + const { client, logDebug } = deps; + async function promptWithTimeout(promptParams: unknown, timeoutMs: unknown): Promise { + // 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'); + } + + async function pollForAssistantResponse( + sessionId: string, + timeoutMs: number, + intervalMs: number = DEFAULT_POLL_INTERVAL_MS, + ): Promise<{ content: string; reasoning: string; error: unknown }> { + const pollStart = Date.now(); + const startedAt = Date.now(); + // Best-effort snapshot of the most recent in-flight assistant message. Polling + // observes partial messages: a reasoning model emits its reasoning part first and + // the text part only afterwards, so returning on the first non-empty snapshot + // truncates the answer to the reasoning alone. Keep the partial around purely as + // a timeout fallback and otherwise wait for the message to actually finish. + let lastPartial: { content: string; reasoning: string; error: null } | null = null; + while (Date.now() - startedAt < timeoutMs) { + const messagesRes: unknown = await client.session.messages({ path: { id: sessionId } }); + const messages = extractMessagesList(messagesRes); + if (Array.isArray(messages) && messages.length) { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const entry = asRecord(messages[i]); + const info = asRecord(entry['info']); + if (info['role'] !== 'assistant') continue; + const { content, reasoning, toolParts } = extractFromParts(entry['parts'] ?? []); + const error: unknown = info['error'] ?? null; + // finish === 'tool' marks an intermediate turn that pauses for a tool + // result; the assistant is not done producing output yet. + const finish: unknown = info['finish']; + const time = asRecord(info['time']); + const finished = info['finish'] != null && finish !== 'tool'; + const done = Boolean(finished || time['completed'] || error); + if (toolParts.length > 0) { + logDebug('Polling found tool parts', { + sessionId, + count: toolParts.length, + parts: toolParts.map((p) => { + const pr = asRecord(p); + const state = asRecord(pr['state']); + return { + id: pr['id'], + tool: pr['tool'], + status: state['status'], + input: state['input'], + }; + }), + }); + } + if (done) { + if (error) { + const errRecord = asRecord(error); + console.error('[Proxy] OpenCode assistant error:', error); + void errRecord; + } + logDebug('Polling completed', { + sessionId, + ms: Date.now() - pollStart, + done, + contentLen: content.length, + reasoningLen: reasoning.length, + error: error ? (asRecord(error)['name'] as unknown) : null, + }); + return { content, reasoning, error }; + } + if (content || reasoning) { + lastPartial = { content, reasoning, error: null }; + } + break; + } + } + await sleep(intervalMs); + } + if (lastPartial) { + logDebug('Polling timeout with partial response', { + sessionId, + ms: Date.now() - pollStart, + contentLen: lastPartial.content.length, + reasoningLen: lastPartial.reasoning.length, + }); + return lastPartial; + } + logDebug('Polling timeout', { sessionId, ms: Date.now() - pollStart }); + throw new Error(`Request timeout after ${timeoutMs}ms`); + } + + async function collectFromEvents( + sessionId: string, + timeoutMs: number, + onDelta?: ((delta: string, isReasoning?: boolean) => void) | null, + firstDeltaTimeoutMs?: number | null, + idleTimeoutMs?: number | null, + ): Promise> { + const controller = new AbortController(); + const eventStreamResult: unknown = await client.event.subscribe({ signal: controller.signal }); + const eventStream = asRecord(eventStreamResult)['stream'] as AsyncIterable; + let finished = false; + let content = ''; + let reasoning = ''; + let receivedDelta = false; + let deltaChars = 0; + let firstDeltaAt: number | null = null; + // Tracks internal OpenCode tool calls that are still pending/running. While any + // tool call is active, the stream must stay open even if no text deltas arrive + // (the backend is executing the tool). Resolving early here is what previously + // truncated streaming responses that relied on internal tool execution. + const activeToolCallIds = new Set(); + const startedAt = Date.now(); + + const finishPromise = new Promise>((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (finished) return; + finished = true; + controller.abort(); + reject(new Error(`Request timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + const firstDeltaTimer = firstDeltaTimeoutMs + ? setTimeout(() => { + if (finished || receivedDelta) return; + finished = true; + controller.abort(); + logDebug('No event data received', { sessionId, ms: Date.now() - startedAt }); + resolve({ content: '', reasoning: '', noData: true }); + }, firstDeltaTimeoutMs) + : null; + + let idleTimer: ReturnType | null = null; + const scheduleIdleTimer = (): void => { + if (!idleTimeoutMs) return; + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (finished) return; + // A tool call is still executing on the backend. Keep the stream open + // and wait instead of cutting the response short; the follow-up text + // (or the final completion) will arrive once the tool finishes. + if (activeToolCallIds.size > 0) { + logDebug('Event idle while internal tool call is active, continuing to wait', { + sessionId, + ms: Date.now() - startedAt, + activeTools: activeToolCallIds.size, + }); + scheduleIdleTimer(); + return; + } + finished = true; + controller.abort(); + logDebug('Event idle timeout', { + sessionId, + ms: Date.now() - startedAt, + deltaChars, + }); + resolve({ + content, + reasoning, + idleTimeout: true, + receivedDelta, + }); + }, idleTimeoutMs); + }; + + const trackToolActivity = (part: unknown): void => { + const pr = asRecord(part); + if (pr['type'] !== 'tool') return; + const state = asRecord(pr['state']); + const status = state['status']; + const id = typeof pr['id'] === 'string' ? (pr['id'] as string) : null; + if (status === 'pending' || status === 'running') { + if (id) activeToolCallIds.add(id); + } else if (status === 'completed' || status === 'error') { + if (id) activeToolCallIds.delete(id); + } + // Tool activity means the session is still working; treat it as progress + // so the idle timer does not terminate the stream mid-execution. + receivedDelta = true; + if (firstDeltaTimer) clearTimeout(firstDeltaTimer); + scheduleIdleTimer(); + }; + + // Newer OpenCode servers stream deltas as `message.part.delta` events that + // carry only a `partID` (no `part.type`). The part type is announced by the + // preceding `message.part.updated` event, so we key partID -> type here and + // resolve each delta against it. Without this, reasoning and answer text can + // never be told apart and the answer is mis-routed (or dropped) entirely. + const partTypeById = new Map(); + const rememberPartType = (part: unknown): void => { + const pr = asRecord(part); + if (pr['id'] != null && typeof pr['type'] === 'string') { + partTypeById.set(String(pr['id']), pr['type'] as string); + } + }; + const applyTextDelta = (partType: unknown, delta: unknown): void => { + const deltaStr = typeof delta === 'string' ? delta : String(delta ?? ''); + receivedDelta = true; + if (firstDeltaTimer) clearTimeout(firstDeltaTimer); + scheduleIdleTimer(); + if (!firstDeltaAt) { + firstDeltaAt = Date.now(); + logDebug('SSE first delta', { + sessionId, + ms: firstDeltaAt - startedAt, + type: partType, + }); + } + if (partType === 'reasoning') { + reasoning += deltaStr; + if (onDelta) onDelta(deltaStr, true); + } else { + content += deltaStr; + if (onDelta) onDelta(deltaStr, false); + } + deltaChars += deltaStr.length; + }; + + (async (): Promise => { + try { + for await (const event of eventStream) { + const evType: unknown = (event as Record)['type']; + const props = asRecord((event as Record)['properties']); + if (evType === 'message.part.updated' && asRecord(props['part'])['sessionID'] === sessionId) { + const part = asRecord(props['part']); + rememberPartType(part); + trackToolActivity(part); + // Older OpenCode servers carried the streaming delta directly on + // message.part.updated; newer servers emit message.part.delta. + const delta: unknown = props['delta']; + if (typeof delta === 'string' && delta) applyTextDelta(part['type'], delta); + continue; + } + if (evType === 'message.part.delta' && props['sessionID'] === sessionId) { + const partID: unknown = props['partID']; + const delta: unknown = props['delta']; + const field: unknown = props['field']; + // Text and reasoning deltas both stream through field === 'text'. + // Tool-input deltas surface via message.part.updated tool state. + if (typeof delta === 'string' && field === 'text') { + const partType = typeof partID === 'string' ? partTypeById.get(partID) : undefined; + if (partType === 'reasoning' || partType === 'text') { + applyTextDelta(partType, delta); + } + } + continue; + } + if (evType === 'message.updated' && asRecord(props['info'])['sessionID'] === sessionId) { + const info = asRecord(props['info']); + const finish: unknown = info['finish']; + // An aborted or failed message never produces another delta. Without + // this, the collector waits out the whole first-delta window before + // polling rediscovers the same error. + const infoError: unknown = info['error']; + if (infoError && !finish) { + finished = true; + clearTimeout(timeoutId); + if (firstDeltaTimer) clearTimeout(firstDeltaTimer); + if (idleTimer) clearTimeout(idleTimer); + logDebug('SSE upstream message error', { + sessionId, + ms: Date.now() - startedAt, + error: (asRecord(infoError)['name'] as unknown) || 'UnknownError', + }); + resolve({ content, reasoning, error: infoError }); + break; + } + // Reconcile active tool calls from the full message snapshot so we + // detect pending tools even when only message.updated fires. + if (Array.isArray(info['parts'])) { + for (const part of info['parts'] as unknown[]) { + rememberPartType(part); + const pr = asRecord(part); + if (pr['type'] === 'tool') { + const status = asRecord(pr['state'])['status']; + const id = typeof pr['id'] === 'string' ? (pr['id'] as string) : null; + if (status === 'pending' || status === 'running') { + if (id) activeToolCallIds.add(id); + } else if (status === 'completed' || status === 'error') { + if (id) activeToolCallIds.delete(id); + } + } + } + } + if (finish === 'tool') { + // Assistant turn ended pending a tool call; keep waiting for the result. + if (firstDeltaTimer) clearTimeout(firstDeltaTimer); + scheduleIdleTimer(); + continue; + } + if (finish === 'stop') { + // Only treat the stream as completed when no tool call is still + // pending. OpenCode may emit an intermediate 'stop' snapshot while a + // tool call is in flight; resolving on it would drop the final answer. + if (activeToolCallIds.size > 0) { + logDebug('Ignoring intermediate stop while tools are active', { + sessionId, + activeTools: activeToolCallIds.size, + }); + continue; + } + if (!finished) { + finished = true; + clearTimeout(timeoutId); + if (firstDeltaTimer) clearTimeout(firstDeltaTimer); + if (idleTimer) clearTimeout(idleTimer); + logDebug('SSE completed', { + sessionId, + ms: Date.now() - startedAt, + deltaChars, + }); + resolve({ content, reasoning }); + } + break; + } + } + } + } catch (e: unknown) { + if (!finished) { + finished = true; + clearTimeout(timeoutId); + if (firstDeltaTimer) clearTimeout(firstDeltaTimer); + if (idleTimer) clearTimeout(idleTimer); + reject(e); + } + } + })(); + }); + + try { + return await finishPromise; + } finally { + controller.abort(); + } + } + + return { extractFromParts, promptWithTimeout, pollForAssistantResponse, collectFromEvents }; +} diff --git a/src/tool-runtime/contracts.js b/src/tool-runtime/contracts.js deleted file mode 100644 index 415a91d..0000000 --- a/src/tool-runtime/contracts.js +++ /dev/null @@ -1,45 +0,0 @@ -export const EXTERNAL_TOOL_PREFIX = 'external__'; - -export const TOOL_RISK_LEVELS = { - LOW: 'low', - MEDIUM: 'medium', - HIGH: 'high', - CRITICAL: 'critical' -}; - -export const TOOL_SIDE_EFFECTS = { - NONE: 'none', - READ: 'read', - WRITE: 'write', - DELETE: 'delete', - EXTERNAL_NOTIFICATION: 'external_notification', - PAYMENT: 'payment' -}; - -export const TOOL_POLICY_DECISIONS = { - ALLOW: 'allow', - DENY: 'deny', - REQUIRE_CONFIRMATION: 'require_confirmation' -}; - -export const VALIDATION_STATUSES = { - VALID: 'valid', - REPAIRABLE: 'repairable', - REJECTED: 'rejected' -}; - -export function normalizeRiskLevel(value, fallback = TOOL_RISK_LEVELS.LOW) { - if (!value || typeof value !== 'string') return fallback; - const normalized = value.trim().toLowerCase(); - return Object.values(TOOL_RISK_LEVELS).includes(normalized) ? normalized : fallback; -} - -export function normalizeSideEffect(value, fallback = TOOL_SIDE_EFFECTS.NONE) { - if (!value || typeof value !== 'string') return fallback; - const normalized = value.trim().toLowerCase(); - return Object.values(TOOL_SIDE_EFFECTS).includes(normalized) ? normalized : fallback; -} - -export function createValidationError(code, message, path = []) { - return { code, message, path }; -} diff --git a/src/tool-runtime/contracts.ts b/src/tool-runtime/contracts.ts new file mode 100644 index 0000000..634589d --- /dev/null +++ b/src/tool-runtime/contracts.ts @@ -0,0 +1,59 @@ +export const EXTERNAL_TOOL_PREFIX = 'external__'; + +export const TOOL_RISK_LEVELS = { + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', + CRITICAL: 'critical' +} as const; + +export type ToolRiskLevel = (typeof TOOL_RISK_LEVELS)[keyof typeof TOOL_RISK_LEVELS]; + +export const TOOL_SIDE_EFFECTS = { + NONE: 'none', + READ: 'read', + WRITE: 'write', + DELETE: 'delete', + EXTERNAL_NOTIFICATION: 'external_notification', + PAYMENT: 'payment' +} as const; + +export type ToolSideEffect = (typeof TOOL_SIDE_EFFECTS)[keyof typeof TOOL_SIDE_EFFECTS]; + +export const TOOL_POLICY_DECISIONS = { + ALLOW: 'allow', + DENY: 'deny', + REQUIRE_CONFIRMATION: 'require_confirmation' +} as const; + +export type PolicyDecision = (typeof TOOL_POLICY_DECISIONS)[keyof typeof TOOL_POLICY_DECISIONS]; + +export const VALIDATION_STATUSES = { + VALID: 'valid', + REPAIRABLE: 'repairable', + REJECTED: 'rejected' +} as const; + +export type ValidationStatus = (typeof VALIDATION_STATUSES)[keyof typeof VALIDATION_STATUSES]; + +export interface ValidationError { + code: string; + message: string; + path: string[]; +} + +export function normalizeRiskLevel(value: unknown, fallback: ToolRiskLevel = TOOL_RISK_LEVELS.LOW): ToolRiskLevel { + if (!value || typeof value !== 'string') return fallback; + const normalized = value.trim().toLowerCase(); + return (Object.values(TOOL_RISK_LEVELS) as string[]).includes(normalized) ? (normalized as ToolRiskLevel) : fallback; +} + +export function normalizeSideEffect(value: unknown, fallback: ToolSideEffect = TOOL_SIDE_EFFECTS.NONE): ToolSideEffect { + if (!value || typeof value !== 'string') return fallback; + const normalized = value.trim().toLowerCase(); + return (Object.values(TOOL_SIDE_EFFECTS) as string[]).includes(normalized) ? (normalized as ToolSideEffect) : fallback; +} + +export function createValidationError(code: string, message: string, path: string[] = []): ValidationError { + return { code, message, path }; +} diff --git a/src/tool-runtime/parser.js b/src/tool-runtime/parser.js deleted file mode 100644 index e1fc1d5..0000000 --- a/src/tool-runtime/parser.js +++ /dev/null @@ -1,820 +0,0 @@ -import { findExternalToolByName } from './registry.js'; - -/** - * Tool-call markup parsing. - * - * The proxy asks models to emit tool calls as `{json}`. - * Models served by OpenCode's free tier frequently ignore that contract and fall back to - * whatever markup their own training used. Observed alternatives, all captured verbatim - * from live responses, are listed in FOREIGN FORMATS below. - * - * Rather than teach every call site about each dialect, everything is normalized to the - * canonical `` form at the parser boundary. Downstream code is unchanged. - * - * FOREIGN FORMATS - * 1. DSML (DeepSeek native) - * <||DSML||tool_calls> - * <||DSML||invoke name="external__bash"> - * <||DSML||parameter name="command" string="true">ls -la - * - * - * The `|` is U+FF5C (fullwidth vertical line), not an ASCII pipe. The marker is - * treated as optional so plain ``/`` markup parses too. - * 2. JSON wrapper: {"name":...,"arguments":{...}} - * 3. Tag with attrs: - * 4. Tag with body: {"command":"ls"} - * {...} - * \n{"command":"ls"} - * 5. Bare JSON: {"name":"external__bash","arguments":{"command":"ls"}} - * - * AMBIGUITY POLICY - * Formats 1, 2 and the canonical form carry their own delimiters, so they are recognized - * unconditionally. Formats 3-5 are only recognized when the name matches a tool in the - * request's registry, because `` or a JSON snippet in prose must never be - * mistaken for a tool call. Format 5 additionally requires the JSON to span the entire - * message body, so payload examples quoted mid-sentence are ignored. - */ - -// U+FF5C fullwidth vertical line, or an ASCII pipe, around an optional DSML tag. -// Matches "||DSML||", "|DSML|", or nothing at all. -const MARK = '[\\uFF5C|]*(?:DSML)?[\\uFF5C|]*'; - -const CANONICAL_OPEN = ''; -const CANONICAL_CLOSE = ''; - -const RE = { - canonicalBlock: /([\s\S]*?)<\/function_calls>/g, - canonicalStrayTag: /<\/?function_calls>/g, - // DSML/plain container. Contents are parsed for invoke blocks. - dsmlContainer: new RegExp(`<${MARK}tool_calls\\s*>([\\s\\S]*?)`, 'g'), - invokeBlock: new RegExp(`<${MARK}invoke\\s+name\\s*=\\s*["']([^"']+)["']\\s*>([\\s\\S]*?)`, 'g'), - invokeParam: new RegExp(`<${MARK}parameter\\s+name\\s*=\\s*["']([^"']+)["']([^>]*)>([\\s\\S]*?)`, 'g'), - // Singular JSON wrapper. Plural is handled by dsmlContainer, which - // falls through to JSON parsing when it holds no invoke blocks. - jsonWrapper: /([\s\S]*?)<\/tool_call\s*>/g, - codeFence: /^\s*```(?:[a-zA-Z0-9_-]*)\s*\n([\s\S]*?)\n?\s*```\s*$/, - leadingNewline: /^\r?\n/, - trailingNewline: /\r?\n[ \t]*$/ -}; - -const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - -/** Tool names accepted for the registry-gated formats, longest first so ` { - if (tool?.namespacedName) names.add(tool.namespacedName); - if (tool?.originalName) names.add(tool.originalName); - }); - return [...names].sort((a, b) => b.length - a.length); -} - -/** Map every accepted tool name (namespaced and original) to its declared JSON schema. */ -function registrySchemas(registry) { - const schemas = new Map(); - if (!Array.isArray(registry)) return schemas; - registry.forEach((tool) => { - if (!tool?.parameters) return; - if (tool.namespacedName) schemas.set(tool.namespacedName, tool.parameters); - if (tool.originalName) schemas.set(tool.originalName, tool.parameters); - }); - return schemas; -} - -/** - * DSML parameter bodies usually sit on their own line. Drop one leading and one trailing - * newline so `\nvalue\n` yields "value", while interior whitespace - * and intentional trailing spaces inside multi-line values survive untouched. - */ -function trimParamValue(raw) { - return String(raw ?? '') - .replace(RE.leadingNewline, '') - .replace(RE.trailingNewline, ''); -} - -/** `string="true"` forces a string; otherwise numbers/booleans/objects are decoded. */ -function coerceParamValue(value, attrs) { - if (/string\s*=\s*["']true["']/i.test(attrs || '')) return value; - const trimmed = value.trim(); - if (!trimmed) return value; - if (!/^(-?\d|true$|false$|null$|\{|\[|")/.test(trimmed)) return value; - try { - return JSON.parse(trimmed); - } catch { - return value; - } -} - -/** Pull `{ id?, name, arguments }` out of the many JSON shapes models produce. */ -function rawCallFromJson(node) { - if (!node || typeof node !== 'object') return null; - const name = node?.function?.name || node?.name || node?.tool_name || node?.tool; - if (!name || typeof name !== 'string') return null; - let args = node?.function?.arguments ?? node?.arguments ?? node?.parameters ?? node?.args ?? {}; - if (typeof args === 'string') { - const trimmed = args.trim(); - if (!trimmed) { - args = {}; - } else { - try { - args = JSON.parse(trimmed); - } catch { - return { id: node?.id, name, arguments: trimmed }; - } - } - } - return { id: node?.id, name, arguments: args }; -} - -/** Expand a decoded JSON payload, which may be a single call, an array, or a wrapper object. */ -function rawCallsFromJsonPayload(parsed) { - const candidates = Array.isArray(parsed) - ? parsed - : Array.isArray(parsed?.tool_calls) - ? parsed.tool_calls - : Array.isArray(parsed?.invokes) - ? parsed.invokes - : [parsed]; - return candidates.map(rawCallFromJson).filter(Boolean); -} - -function rawCallsFromJsonText(text) { - const trimmed = String(text ?? '').trim(); - if (!trimmed) return []; - try { - return rawCallsFromJsonPayload(JSON.parse(trimmed)); - } catch { - // The body may be prefixed by stray markup — a nested tag copied - // from the contract reminder, or a tool wrapper tag emitted by the model before - // the JSON payload. Scan for the first balanced JSON value and try again. - const found = findFirstJsonValue(trimmed); - if (!found) return []; - try { - return rawCallsFromJsonPayload(JSON.parse(found.json)); - } catch { - return []; - } - } -} - -/** Byte offset just past the JSON value starting at `start`, or -1 if it never closes. */ -function findJsonEnd(text, start) { - const opener = text[start]; - if (opener !== '{' && opener !== '[') return -1; - const closer = opener === '{' ? '}' : ']'; - let depth = 0; - let inString = false; - let escaped = false; - for (let i = start; i < text.length; i += 1) { - const ch = text[i]; - if (inString) { - if (escaped) escaped = false; - else if (ch === '\\') escaped = true; - else if (ch === '"') inString = false; - continue; - } - if (ch === '"') inString = true; - else if (ch === opener) depth += 1; - else if (ch === closer) { - depth -= 1; - if (depth === 0) return i + 1; - } - } - return -1; -} - -/** First balanced JSON object/array in `text`, as `{ json, start, end }`. */ -function findFirstJsonValue(text) { - for (let i = 0; i < text.length; i += 1) { - if (text[i] !== '{' && text[i] !== '[') continue; - const end = findJsonEnd(text, i); - if (end === -1) continue; - const json = text.slice(i, end); - try { - JSON.parse(json); - return { json, start: i, end }; - } catch { - // Keep scanning; this brace was not the start of a valid value. - } - } - return null; -} - -// --- format extractors ----------------------------------------------------- -// Each returns { calls, spans } where spans are [start, end) ranges of consumed markup. - -function extractCanonical(text) { - const calls = []; - const spans = []; - for (const match of text.matchAll(RE.canonicalBlock)) { - spans.push([match.index, match.index + match[0].length]); - calls.push(...rawCallsFromJsonText(match[1])); - } - return { calls, spans }; -} - -function extractInvokeBlocks(segment) { - const calls = []; - for (const invoke of segment.matchAll(RE.invokeBlock)) { - const args = {}; - for (const param of invoke[2].matchAll(RE.invokeParam)) { - args[param[1]] = coerceParamValue(trimParamValue(param[3]), param[2]); - } - calls.push({ name: invoke[1], arguments: args }); - } - return calls; -} - -/** DSML/plain `` containers, plus bare `` blocks outside any container. */ -function extractDsml(text) { - const calls = []; - const spans = []; - let remainder = text; - - for (const container of text.matchAll(RE.dsmlContainer)) { - spans.push([container.index, container.index + container[0].length]); - const inner = extractInvokeBlocks(container[1]); - // A wrapper around plain JSON is also common. - calls.push(...(inner.length ? inner : rawCallsFromJsonText(container[1]))); - remainder = remainder.replace(container[0], ' '.repeat(container[0].length)); - } - - for (const invoke of remainder.matchAll(RE.invokeBlock)) { - spans.push([invoke.index, invoke.index + invoke[0].length]); - calls.push(...extractInvokeBlocks(invoke[0])); - } - - return { calls, spans }; -} - -function extractJsonWrapper(text) { - const calls = []; - const spans = []; - for (const match of text.matchAll(RE.jsonWrapper)) { - spans.push([match.index, match.index + match[0].length]); - calls.push(...rawCallsFromJsonText(match[1])); - } - return { calls, spans }; -} - -/** - * Pull the arguments attribute value out of a tag's attribute text. Handles quoted - * values (`arguments='{...}'`) and bare ones (`arguments={...}`), which models emit - * interchangeably. - */ -function argsFromAttrs(attrs) { - const marker = attrs.match(/(?:arguments|parameters|args|input)\s*=\s*/i); - if (!marker) return null; - const start = marker.index + marker[0].length; - const opener = attrs[start]; - - if (opener === '"' || opener === "'") { - let i = start + 1; - let out = ''; - while (i < attrs.length && attrs[i] !== opener) { - if (attrs[i] === '\\' && i + 1 < attrs.length) { - out += attrs[i + 1]; - i += 2; - continue; - } - out += attrs[i]; - i += 1; - } - return out; - } - - if (opener === '{' || opener === '[') { - const end = findJsonEnd(attrs, start); - if (end !== -1) return attrs.slice(start, end); - } - return null; -} - -/** - * Coerce an XML child element's text using the declared JSON-schema type. A schema type of - * `string` is honoured verbatim so a path like `123.txt` or a body of JSON-looking text is - * not silently turned into a number or an object. - */ -function coerceSchemaValue(value, schema) { - const type = Array.isArray(schema?.type) ? schema.type[0] : schema?.type; - if (type === 'string') return value; - const trimmed = value.trim(); - if (!trimmed) return value; - if (type === 'number' || type === 'integer') { - const num = Number(trimmed); - return Number.isFinite(num) ? num : value; - } - if (type === 'boolean') { - if (/^true$/i.test(trimmed)) return true; - if (/^false$/i.test(trimmed)) return false; - return value; - } - if (type === 'object' || type === 'array') { - try { - return JSON.parse(trimmed); - } catch { - return value; - } - } - // Untyped: fall back to the permissive decoding used elsewhere. - return coerceParamValue(value, ''); -} - -/** - * Arguments carried as XML child elements rather than JSON: - * - * - * a.txt - * 10 - * - * - * Widely used by Cline/Roo-style harnesses, so many models emit it from training even when - * asked for JSON. Only child names declared in the tool's own schema are accepted, which - * keeps prose containing angle brackets from being mistaken for arguments. - */ -function argsFromXmlChildren(body, parameters) { - const properties = parameters && typeof parameters === 'object' ? parameters.properties : null; - if (!properties || typeof properties !== 'object') return null; - const allowed = Object.keys(properties); - if (!allowed.length) return null; - - const args = {}; - let matched = 0; - for (const key of allowed) { - const re = new RegExp(`<${escapeRegExp(key)}\\s*>([\\s\\S]*?)`, 'i'); - const found = body.match(re); - if (!found) continue; - matched += 1; - args[key] = coerceSchemaValue(trimParamValue(found[1]), properties[key]); - } - return matched > 0 ? args : null; -} - -/** - * End index of a tag's attribute region, skipping over quoted strings and balanced JSON - * so that a `>` inside an attribute value (`arguments={"command":"ls > out"}`) does not - * terminate the tag early. - */ -function findTagEnd(text, from) { - let i = from; - while (i < text.length) { - const ch = text[i]; - if (ch === '"' || ch === "'") { - const quote = ch; - i += 1; - while (i < text.length && text[i] !== quote) { - if (text[i] === '\\') i += 1; - i += 1; - } - i += 1; - continue; - } - if (ch === '{' || ch === '[') { - const end = findJsonEnd(text, i); - if (end !== -1) { - i = end; - continue; - } - } - if (ch === '>') return i; - i += 1; - } - return -1; -} - -/** - * Registry-gated tag formats: ``, `body`, and an unclosed - * `` followed by a JSON body. - */ -function extractTagNamed(text, names, schemas = new Map()) { - const calls = []; - const spans = []; - if (!names.length) return { calls, spans }; - - const opener = new RegExp(`<(${names.map(escapeRegExp).join('|')})(?=[\\s/>"'])`, 'g'); - - for (const match of text.matchAll(opener)) { - const name = match[1]; - const attrsStart = match.index + match[0].length; - const tagEnd = findTagEnd(text, attrsStart); - if (tagEnd === -1) continue; - const attrs = text.slice(attrsStart, tagEnd); - const openEnd = tagEnd + 1; - - // Arguments carried as an attribute: arguments='{"a":1}' or arguments={"a":1} - const attrArgs = argsFromAttrs(attrs); - if (attrArgs !== null) { - const parsed = rawCallsFromJsonText(attrArgs); - if (parsed.length) { - calls.push(...parsed.map((call) => ({ ...call, name: call.name || name }))); - } else { - try { - calls.push({ name, arguments: JSON.parse(attrArgs) }); - } catch { - calls.push({ name, arguments: {} }); - } - } - spans.push([match.index, openEnd]); - continue; - } - - if (attrs.trim().endsWith('/')) { - calls.push({ name, arguments: {} }); - spans.push([match.index, openEnd]); - continue; - } - - // Body may be wrapped in a matching close tag, or simply trail the opener. - const closeTag = ``; - const closeIdx = text.indexOf(closeTag, openEnd); - const body = closeIdx === -1 ? text.slice(openEnd) : text.slice(openEnd, closeIdx); - const json = findFirstJsonValue(body); - const consumedEnd = closeIdx === -1 - ? (json ? openEnd + json.end : openEnd) - : closeIdx + closeTag.length; - - if (json) { - const parsed = rawCallsFromJsonText(json.json); - const named = parsed.filter((call) => call.name); - if (named.length) { - calls.push(...named); - } else { - try { - calls.push({ name, arguments: JSON.parse(json.json) }); - } catch { - calls.push({ name, arguments: {} }); - } - } - } else { - // No JSON body. Arguments may still be present as XML child elements named - // after the schema's properties; dropping them here produced tool calls with - // empty arguments, which fail validation for any tool with required fields. - const xmlArgs = argsFromXmlChildren(body, schemas.get(name)); - calls.push({ name, arguments: xmlArgs || {} }); - } - spans.push([match.index, consumedEnd]); - } - - return { calls, spans }; -} - -/** - * Registry-gated bare JSON. Requires the JSON to be the whole message body (optionally - * inside one code fence) so that payloads quoted inside prose are left alone. - */ -function extractBareJson(text, names) { - if (!names.length) return { calls: [], spans: [] }; - const trimmed = text.trim(); - if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[' && !trimmed.startsWith('```'))) { - return { calls: [], spans: [] }; - } - - const fenced = trimmed.match(RE.codeFence); - const body = (fenced ? fenced[1] : trimmed).trim(); - if (!body || (body[0] !== '{' && body[0] !== '[')) return { calls: [], spans: [] }; - - const calls = rawCallsFromJsonText(body).filter((call) => names.includes(call.name)); - if (!calls.length) return { calls: [], spans: [] }; - return { calls, spans: [[0, text.length]] }; -} - -/** - * `` / `value` markup, the native tool-call - * dialect of Qwen/GLM-family models and some OpenCode free-tier models: - * - * - * - * https://example.com - * html - * - * - * - * The surrounding `` container is already consumed by extractJsonWrapper - * (which strips it as a span even when the body is not JSON), so here we only recognise - * the `` opener, collect its `` children, and mark the - * whole `...` block for hiding. Name mapping (e.g. `webfetch` → - * `web_fetch`) is resolved later against the request's registry. - */ -function extractFunctionEquals(text) { - const calls = []; - const spans = []; - const openerRe = /]+)\s*>/gi; - for (const match of text.matchAll(openerRe)) { - const name = match[1].trim(); - if (!name) continue; - const openEnd = match.index + match[0].length; - const closeIdx = text.indexOf('', openEnd); - const end = closeIdx === -1 ? text.length : closeIdx + ''.length; - const body = closeIdx === -1 ? text.slice(openEnd) : text.slice(openEnd, closeIdx); - - const args = {}; - const paramRe = /]+)\s*>([\s\S]*?)<\/parameter\s*>/gi; - for (const param of body.matchAll(paramRe)) { - args[param[1].trim()] = trimParamValue(param[2]); - } - - calls.push({ name, arguments: args }); - spans.push([match.index, end]); - } - return { calls, spans }; -} - -/** Every format in one pass. `spans` cover all markup that should be hidden from users. */ -function collectAll(text, registry) { - const source = typeof text === 'string' ? text : ''; - if (!source) return { calls: [], spans: [] }; - const names = registryNames(registry); - const schemas = registrySchemas(registry); - - const results = [ - extractCanonical(source), - extractDsml(source), - extractJsonWrapper(source), - extractFunctionEquals(source), - extractTagNamed(source, names, schemas), - extractBareJson(source, names) - ]; - - const seen = new Set(); - const calls = []; - results.forEach((result) => { - result.calls.forEach((call) => { - // Distinct formats can describe the same call (e.g. a tag wrapping JSON that - // also names the tool); keep one entry per name+arguments pair. - const key = `${call.name}::${JSON.stringify(call.arguments)}`; - if (seen.has(key)) return; - seen.add(key); - calls.push(call); - }); - }); - - return { calls, spans: results.flatMap((result) => result.spans) }; -} - -function toFinalCalls(rawCalls, seed = 0) { - return rawCalls.map((call, index) => ({ - id: call.id || `call_${Date.now()}_${seed + index + 1}`, - type: 'function', - function: { - name: call.name, - arguments: typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {}) - } - })); -} - -// --- public API ------------------------------------------------------------ - -/** Canonical `` blocks only. Kept for callers that must not guess. */ -export function parseToolCallsFromText(...chunks) { - const calls = []; - chunks.forEach((chunk) => { - if (!chunk || typeof chunk !== 'string') return; - calls.push(...extractCanonical(chunk).calls); - }); - return toFinalCalls(calls); -} - -/** - * Remove tool-call markup from user-visible text. - * Registry-gated formats are only stripped when `options.registry` is supplied; the - * self-delimiting formats are always stripped. - */ -export function stripFunctionCallMarkup(text, trim = true, options = {}) { - if (!text) return text; - const { spans } = collectAll(text, options.registry); - - let cleaned = text; - if (spans.length) { - const merged = [...spans].sort((a, b) => a[0] - b[0]).reduce((acc, span) => { - const last = acc[acc.length - 1]; - if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]); - else acc.push([...span]); - return acc; - }, []); - cleaned = merged.reduceRight((acc, [start, end]) => acc.slice(0, start) + acc.slice(end), cleaned); - } - - cleaned = cleaned.replace(RE.canonicalStrayTag, ''); - return trim ? cleaned.trim() : cleaned; -} - -/** Parse tool calls and map them onto the request's registry, dropping unknown tools. */ -export function parseExternalToolCallsFromText(registry, ...chunks) { - if (!Array.isArray(registry) || registry.length === 0) return []; - const rawCalls = []; - chunks.forEach((chunk) => { - if (!chunk || typeof chunk !== 'string') return; - rawCalls.push(...collectAll(chunk, registry).calls); - }); - - const counts = new Map(); - return rawCalls.flatMap((rawCall) => { - const tool = findExternalToolByName(registry, rawCall.name); - if (!tool) return []; - const nextCount = (counts.get(tool.namespacedName) || 0) + 1; - counts.set(tool.namespacedName, nextCount); - return [{ - id: rawCall.id || `call_${tool.namespacedName.replace(/[^a-zA-Z0-9_]/g, '_')}_${nextCount}`, - type: 'function', - function: { - name: tool.originalName, - arguments: typeof rawCall.arguments === 'string' - ? rawCall.arguments - : JSON.stringify(rawCall.arguments ?? {}) - } - }]; - }); -} - -/** - * Openers that may begin tool markup. Used to decide whether a partial chunk should be - * withheld from the client until we know what it is. - */ -function markerOpeners(registry) { - const openers = [' openers.push(`<${name.toLowerCase()}`)); - return openers; -} - -/** True when `candidate` is a prefix of an opener, or already contains one. */ -function couldBeMarker(candidate, openers) { - const lower = candidate.toLowerCase(); - return openers.some((opener) => opener.startsWith(lower) || lower.startsWith(opener)); -} - -/** Complete self-delimiting blocks are dropped inline; other formats wait for flush(). */ -const INLINE_BLOCKS = [ - { open: new RegExp(`^<${MARK}tool_calls\\s*>`, 'i'), close: new RegExp(``, 'i') }, - { open: new RegExp(`^<${MARK}invoke\\s`, 'i'), close: new RegExp(``, 'i') }, - { open: /^/i, close: /<\/tool_call\s*>/i }, - { open: new RegExp(`^${escapeRegExp(CANONICAL_OPEN)}`, 'i'), close: new RegExp(escapeRegExp(CANONICAL_CLOSE), 'i') } -]; - -/** - * Close tags for the known block formats. OpenCode streams reasoning and content as - * separate channels, so a model can open a block in one and close it in the other. The - * channel that only receives the closer must drop it instead of printing it as prose. - */ -const KNOWN_CLOSE_TAG = new RegExp( - `^`, - 'i' -); - -/** A close tag truncated at a chunk boundary, e.g. " chunk; - passthrough.flush = () => ''; - return passthrough; - } - - const openers = markerOpeners(registry); - let buffer = ''; - let emittedVisible = false; - let held = false; - - const filter = (chunk) => { - if (!chunk) return ''; - buffer += chunk; - let output = ''; - - while (buffer.length) { - if (held) return output; - - const inline = matchInlineBlock(buffer); - if (inline?.pending) return output; - if (inline) { - buffer = buffer.slice(inline.end); - continue; - } - - // Orphaned close tag from a block that opened in the other channel. - const orphanClose = buffer.match(KNOWN_CLOSE_TAG); - if (orphanClose) { - buffer = buffer.slice(orphanClose[0].length); - continue; - } - if (PARTIAL_CLOSE_TAG.test(buffer)) return output; - - // A leading `{` may be a whole-body JSON call; hold it until flush decides. - if (!emittedVisible && !output.trim() && /^\s*[{[]/.test(buffer)) { - held = true; - return output; - } - - const markerIdx = buffer.indexOf('<'); - if (markerIdx === -1) { - output += buffer; - buffer = ''; - break; - } - - if (markerIdx > 0) { - output += buffer.slice(0, markerIdx); - buffer = buffer.slice(markerIdx); - continue; - } - - if (!couldBeMarker(buffer, openers)) { - output += buffer[0]; - buffer = buffer.slice(1); - continue; - } - - // Either an incomplete opener or a registry-gated format. Hold for flush(). - const complete = /^<[^\s/>]+[^>]*>/.test(buffer); - if (!complete) return output; - held = true; - return output; - } - - if (output.trim()) emittedVisible = true; - return output; - }; - - filter.flush = () => { - const remaining = buffer; - buffer = ''; - held = false; - if (!remaining) return ''; - // Strip whatever markup is actually present and release the rest. Also drop a - // trailing orphaned close tag that was still being buffered when the stream ended. - const stripped = stripFunctionCallMarkup(remaining, false, { registry }); - return KNOWN_CLOSE_TAG.test(stripped.trim()) ? '' : stripped; - }; - - return filter; -} - -/** - * Streaming tool-call extractor. Self-delimiting blocks surface as soon as they close; - * registry-gated formats surface from `flush()` at end of stream. - */ -export function createExternalToolCallStreamParser(registry) { - if (!Array.isArray(registry) || registry.length === 0) { - const noop = () => []; - noop.flush = () => []; - return noop; - } - - const openers = markerOpeners(registry); - let buffer = ''; - let sequence = 0; - - const withUniqueIds = (calls) => calls.map((call) => { - sequence += 1; - return { ...call, id: `${call.id}_${sequence}` }; - }); - - const parser = (chunk) => { - if (!chunk) return []; - buffer += chunk; - const calls = []; - - while (buffer.length) { - const markerIdx = buffer.search(/<[^\s]/); - if (markerIdx === -1) break; - - const candidate = buffer.slice(markerIdx); - const inline = matchInlineBlock(candidate); - if (inline?.pending) break; - if (inline) { - const block = candidate.slice(0, inline.end); - calls.push(...withUniqueIds(parseExternalToolCallsFromText(registry, block))); - buffer = candidate.slice(inline.end); - continue; - } - - if (couldBeMarker(candidate, openers)) break; - buffer = candidate.slice(1); - } - - return calls; - }; - - parser.flush = () => { - const remaining = buffer; - buffer = ''; - if (!remaining.trim()) return []; - return withUniqueIds(parseExternalToolCallsFromText(registry, remaining)); - }; - - return parser; -} diff --git a/src/tool-runtime/parser.ts b/src/tool-runtime/parser.ts new file mode 100644 index 0000000..226a597 --- /dev/null +++ b/src/tool-runtime/parser.ts @@ -0,0 +1,945 @@ +import { findExternalToolByName } from './registry.js'; +import type { ExternalToolEntry } from './registry.js'; + +/** + * Tool-call markup parsing. + * + * The proxy asks models to emit tool calls as `{json}`. + * Models served by OpenCode's free tier frequently ignore that contract and fall back to + * whatever markup their own training used. Observed alternatives, all captured verbatim + * from live responses, are listed in FOREIGN FORMATS below. + * + * Rather than teach every call site about each dialect, everything is normalized to the + * canonical `` form at the parser boundary. Downstream code is unchanged. + * + * FOREIGN FORMATS + * 1. DSML (DeepSeek native) + * <||DSML||tool_calls> + * <||DSML||invoke name="external__bash"> + * <||DSML||parameter name="command" string="true">ls -la + * + * + * The `|` is U+FF5C (fullwidth vertical line), not an ASCII pipe. The marker is + * treated as optional so plain ``/`` markup parses too. + * 2. JSON wrapper: {"name":...,"arguments":{...}} + * 3. Tag with attrs: + * 4. Tag with body: {"command":"ls"} + * {...} + * \n{"command":"ls"} + * 5. Bare JSON: {"name":"external__bash","arguments":{"command":"ls"}} + * + * AMBIGUITY POLICY + * Formats 1, 2 and the canonical form carry their own delimiters, so they are recognized + * unconditionally. Formats 3-5 are only recognized when the name matches a tool in the + * request's registry, because `` or a JSON snippet in prose must never be + * mistaken for a tool call. Format 5 additionally requires the JSON to span the entire + * message body, so payload examples quoted mid-sentence are ignored. + */ + +/** A single raw tool call before id/type normalization. */ +export interface RawToolCall { + id?: unknown; + name: string; + arguments: unknown; +} + +/** Normalized tool call in OpenAI `tool_calls` shape. */ +export interface FinalToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +/** Consumed markup range as `[start, end)`. */ +export type TextSpan = [number, number]; + +/** Streaming text filter with an end-of-stream `flush()`. */ +export type ToolCallFilter = ((chunk: string) => string) & { flush: () => string }; + +/** Streaming tool-call extractor with an end-of-stream `flush()`. */ +export type ToolCallStreamParser = ((chunk: string) => FinalToolCall[]) & { flush: () => FinalToolCall[] }; + +interface ExtractedCalls { + calls: RawToolCall[]; + spans: TextSpan[]; +} + +interface JsonValueLocation { + json: string; + start: number; + end: number; +} + +// U+FF5C fullwidth vertical line, or an ASCII pipe, around an optional DSML tag. +// Matches "||DSML||", "|DSML|", or nothing at all. +const MARK = '[\\uFF5C|]*(?:DSML)?[\\uFF5C|]*'; + +const CANONICAL_OPEN = ''; +const CANONICAL_CLOSE = ''; + +const RE = { + canonicalBlock: /([\s\S]*?)<\/function_calls>/g, + canonicalStrayTag: /<\/?function_calls>/g, + // DSML/plain container. Contents are parsed for invoke blocks. + dsmlContainer: new RegExp(`<${MARK}tool_calls\\s*>([\\s\\S]*?)`, 'g'), + invokeBlock: new RegExp(`<${MARK}invoke\\s+name\\s*=\\s*["']([^"']+)["']\\s*>([\\s\\S]*?)`, 'g'), + invokeParam: new RegExp(`<${MARK}parameter\\s+name\\s*=\\s*["']([^"']+)["']([^>]*)>([\\s\\S]*?)`, 'g'), + // Singular JSON wrapper. Plural is handled by dsmlContainer, which + // falls through to JSON parsing when it holds no invoke blocks. + jsonWrapper: /([\s\S]*?)<\/tool_call\s*>/g, + codeFence: /^\s*```(?:[a-zA-Z0-9_-]*)\s*\n([\s\S]*?)\n?\s*```\s*$/, + leadingNewline: /^\r?\n/, + trailingNewline: /\r?\n[ \t]*$/ +}; + +const escapeRegExp = (value: unknown): string => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** Tool names accepted for the registry-gated formats, longest first so `(); + for (const tool of registry as unknown[]) { + if (tool && typeof tool === 'object' && !Array.isArray(tool)) { + const record = tool as Record; + if (typeof record['namespacedName'] === 'string' && record['namespacedName']) { + names.add(record['namespacedName'] as string); + } + if (typeof record['originalName'] === 'string' && record['originalName']) { + names.add(record['originalName'] as string); + } + } + } + return [...names].sort((a, b) => b.length - a.length); +} + +/** Map every accepted tool name (namespaced and original) to its declared JSON schema. */ +function registrySchemas(registry: unknown): Map { + const schemas = new Map(); + if (!Array.isArray(registry)) return schemas; + for (const tool of registry as unknown[]) { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) continue; + const record = tool as Record; + if (!record['parameters']) continue; + if (typeof record['namespacedName'] === 'string' && record['namespacedName']) { + schemas.set(record['namespacedName'] as string, record['parameters']); + } + if (typeof record['originalName'] === 'string' && record['originalName']) { + schemas.set(record['originalName'] as string, record['parameters']); + } + } + return schemas; +} + +/** + * DSML parameter bodies usually sit on their own line. Drop one leading and one trailing + * newline so `\nvalue\n` yields "value", while interior whitespace + * and intentional trailing spaces inside multi-line values survive untouched. + */ +function trimParamValue(raw: unknown): string { + return String(raw ?? '') + .replace(RE.leadingNewline, '') + .replace(RE.trailingNewline, ''); +} + +/** `string="true"` forces a string; otherwise numbers/booleans/objects are decoded. */ +function coerceParamValue(value: string, attrs: unknown): unknown { + if (/string\s*=\s*["']true["']/i.test(String(attrs || ''))) return value; + const trimmed = value.trim(); + if (!trimmed) return value; + if (!/^(-?\d|true$|false$|null$|\{|\[|")/.test(trimmed)) return value; + try { + const decoded: unknown = JSON.parse(trimmed); + return decoded; + } catch { + return value; + } +} + +/** Pull `{ id?, name, arguments }` out of the JSON shapes models produce. */ +function rawCallFromJson(node: unknown): RawToolCall | null { + if (!node || typeof node !== 'object' || Array.isArray(node)) return null; + const record = node as Record; + const fnRaw: unknown = record['function']; + const fnRecord: Record = + fnRaw && typeof fnRaw === 'object' && !Array.isArray(fnRaw) ? (fnRaw as Record) : {}; + const nameRaw: unknown = fnRecord['name'] ?? record['name'] ?? record['tool_name'] ?? record['tool']; + if (!nameRaw || typeof nameRaw !== 'string') return null; + let args: unknown = fnRecord['arguments'] ?? record['arguments'] ?? record['parameters'] ?? record['args'] ?? {}; + if (typeof args === 'string') { + const trimmed = args.trim(); + if (!trimmed) { + args = {}; + } else { + try { + const decoded: unknown = JSON.parse(trimmed); + args = decoded; + } catch { + return { id: record['id'], name: nameRaw, arguments: trimmed }; + } + } + } + return { id: record['id'], name: nameRaw, arguments: args }; +} + +/** Expand a decoded JSON payload, which may be a single call, an array, or a wrapper object. */ +function rawCallsFromJsonPayload(parsed: unknown): RawToolCall[] { + let candidates: unknown[]; + if (Array.isArray(parsed)) { + candidates = parsed as unknown[]; + } else if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const record = parsed as Record; + if (Array.isArray(record['tool_calls'])) { + candidates = record['tool_calls'] as unknown[]; + } else if (Array.isArray(record['invokes'])) { + candidates = record['invokes'] as unknown[]; + } else { + candidates = [parsed]; + } + } else { + candidates = [parsed]; + } + const out: RawToolCall[] = []; + for (const candidate of candidates) { + const call = rawCallFromJson(candidate); + if (call) out.push(call); + } + return out; +} + +function rawCallsFromJsonText(text: unknown): RawToolCall[] { + const trimmed = String(text ?? '').trim(); + if (!trimmed) return []; + try { + const parsed: unknown = JSON.parse(trimmed); + return rawCallsFromJsonPayload(parsed); + } catch { + // The body may be prefixed by stray markup — a nested tag copied + // from the contract reminder, or a tool wrapper tag emitted by the model before + // the JSON payload. Scan for the first balanced JSON value and try again. + const found = findFirstJsonValue(trimmed); + if (!found) return []; + try { + const parsed: unknown = JSON.parse(found.json); + return rawCallsFromJsonPayload(parsed); + } catch { + return []; + } + } +} + +/** Byte offset just past the JSON value starting at `start`, or -1 if it never closes. */ +function findJsonEnd(text: string, start: number): number { + const opener: string = text[start] as string; + if (opener !== '{' && opener !== '[') return -1; + const closer = opener === '{' ? '}' : ']'; + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < text.length; i += 1) { + const ch: string = text[i] as string; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === opener) depth += 1; + else if (ch === closer) { + depth -= 1; + if (depth === 0) return i + 1; + } + } + return -1; +} + +/** First balanced JSON object/array in `text`, as `{ json, start, end }`. */ +function findFirstJsonValue(text: string): JsonValueLocation | null { + for (let i = 0; i < text.length; i += 1) { + if (text[i] !== '{' && text[i] !== '[') continue; + const end = findJsonEnd(text, i); + if (end === -1) continue; + const json = text.slice(i, end); + try { + JSON.parse(json); + return { json, start: i, end }; + } catch { + // Keep scanning; this brace was not the start of a valid value. + } + } + return null; +} + +// --- format extractors ----------------------------------------------------- +// Each returns { calls, spans } where spans are [start, end) ranges of consumed markup. + +function extractCanonical(text: string): ExtractedCalls { + const calls: RawToolCall[] = []; + const spans: TextSpan[] = []; + for (const match of text.matchAll(RE.canonicalBlock)) { + const start: number = match.index ?? 0; + spans.push([start, start + match[0].length]); + calls.push(...rawCallsFromJsonText(match[1])); + } + return { calls, spans }; +} + +function extractInvokeBlocks(segment: string): RawToolCall[] { + const calls: RawToolCall[] = []; + for (const invoke of segment.matchAll(RE.invokeBlock)) { + const args: Record = {}; + for (const param of invoke[2].matchAll(RE.invokeParam)) { + args[param[1]] = coerceParamValue(trimParamValue(param[3]), param[2]); + } + calls.push({ name: invoke[1], arguments: args }); + } + return calls; +} + +/** DSML/plain `` containers, plus bare `` blocks outside any container. */ +function extractDsml(text: string): ExtractedCalls { + const calls: RawToolCall[] = []; + const spans: TextSpan[] = []; + let remainder = text; + + for (const container of text.matchAll(RE.dsmlContainer)) { + const start: number = container.index ?? 0; + spans.push([start, start + container[0].length]); + const inner = extractInvokeBlocks(container[1]); + // A wrapper around plain JSON is also common. + calls.push(...(inner.length ? inner : rawCallsFromJsonText(container[1]))); + remainder = remainder.replace(container[0], ' '.repeat(container[0].length)); + } + + for (const invoke of remainder.matchAll(RE.invokeBlock)) { + const start: number = invoke.index ?? 0; + spans.push([start, start + invoke[0].length]); + calls.push(...extractInvokeBlocks(invoke[0])); + } + + return { calls, spans }; +} + +function extractJsonWrapper(text: string): ExtractedCalls { + const calls: RawToolCall[] = []; + const spans: TextSpan[] = []; + for (const match of text.matchAll(RE.jsonWrapper)) { + const start: number = match.index ?? 0; + spans.push([start, start + match[0].length]); + calls.push(...rawCallsFromJsonText(match[1])); + } + return { calls, spans }; +} + +/** + * Pull the arguments attribute value out of a tag's attribute text. Handles quoted + * values (`arguments='{...}'`) and bare ones (`arguments={...}`), which models emit + * interchangeably. + */ +function argsFromAttrs(attrs: string): string | null { + const marker = attrs.match(/(?:arguments|parameters|args|input)\s*=\s*/i); + if (!marker) return null; + const markerIndex: number = marker.index ?? 0; + const start = markerIndex + marker[0].length; + const opener: string = attrs[start] as string; + + if (opener === '"' || opener === "'") { + let i = start + 1; + let out = ''; + while (i < attrs.length && attrs[i] !== opener) { + if (attrs[i] === '\\' && i + 1 < attrs.length) { + out += attrs[i + 1]; + i += 2; + continue; + } + out += attrs[i]; + i += 1; + } + return out; + } + + if (opener === '{' || opener === '[') { + const end = findJsonEnd(attrs, start); + if (end !== -1) return attrs.slice(start, end); + } + return null; +} + +/** + * Coerce an XML child element's text using the declared JSON-schema type. A schema type of + * `string` is honoured verbatim so a path like `123.txt` or a body of JSON-looking text is + * not silently turned into a number or an object. + */ +function coerceSchemaValue(value: string, schema: unknown): unknown { + const schemaRecord: Record = + schema && typeof schema === 'object' && !Array.isArray(schema) ? (schema as Record) : {}; + const rawType: unknown = schemaRecord['type']; + const type: unknown = Array.isArray(rawType) ? (rawType as unknown[])[0] : rawType; + if (type === 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + if (type === 'number' || type === 'integer') { + const num = Number(trimmed); + return Number.isFinite(num) ? num : value; + } + if (type === 'boolean') { + if (/^true$/i.test(trimmed)) return true; + if (/^false$/i.test(trimmed)) return false; + return value; + } + if (type === 'object' || type === 'array') { + try { + const decoded: unknown = JSON.parse(trimmed); + return decoded; + } catch { + return value; + } + } + // Untyped: fall back to the permissive decoding used elsewhere. + return coerceParamValue(value, ''); +} + +/** + * Arguments carried as XML child elements rather than JSON: + * + * + * a.txt + * 10 + * + * + * Widely used by Cline/Roo-style harnesses, so models emit it from training even when + * asked for JSON. Only child names declared in the tool's own schema are accepted, which + * keeps prose containing angle brackets from being mistaken for arguments. + */ +function argsFromXmlChildren(body: string, parameters: unknown): Record | null { + const paramsRecord: Record | null = + parameters && typeof parameters === 'object' && !Array.isArray(parameters) + ? (parameters as Record) + : null; + const propertiesRaw: unknown = paramsRecord ? paramsRecord['properties'] : null; + const properties: Record | null = + propertiesRaw && typeof propertiesRaw === 'object' && !Array.isArray(propertiesRaw) + ? (propertiesRaw as Record) + : null; + if (!properties || typeof properties !== 'object') return null; + const allowed = Object.keys(properties); + if (!allowed.length) return null; + + const args: Record = {}; + let matched = 0; + for (const key of allowed) { + const re = new RegExp(`<${escapeRegExp(key)}\\s*>([\\s\\S]*?)`, 'i'); + const found = body.match(re); + if (!found) continue; + matched += 1; + args[key] = coerceSchemaValue(trimParamValue(found[1]), properties[key]); + } + return matched > 0 ? args : null; +} + +/** + * End index of a tag's attribute region, skipping over quoted strings and balanced JSON + * so that a `>` inside an attribute value (`arguments={"command":"ls > out"}`) does not + * terminate the tag early. + */ +function findTagEnd(text: string, from: number): number { + let i = from; + while (i < text.length) { + const ch: string = text[i] as string; + if (ch === '"' || ch === "'") { + const quote = ch; + i += 1; + while (i < text.length && text[i] !== quote) { + if (text[i] === '\\') i += 1; + i += 1; + } + i += 1; + continue; + } + if (ch === '{' || ch === '[') { + const end = findJsonEnd(text, i); + if (end !== -1) { + i = end; + continue; + } + } + if (ch === '>') return i; + i += 1; + } + return -1; +} + +/** + * Registry-gated tag formats: ``, `body`, and an unclosed + * `` followed by a JSON body. + */ +function extractTagNamed(text: string, names: string[], schemas: Map = new Map()): ExtractedCalls { + const calls: RawToolCall[] = []; + const spans: TextSpan[] = []; + if (!names.length) return { calls, spans }; + + const opener = new RegExp(`<(${names.map(escapeRegExp).join('|')})(?=[\\s/>"'])`, 'g'); + + for (const match of text.matchAll(opener)) { + const name: string = match[1]; + const matchIndex: number = match.index ?? 0; + const attrsStart = matchIndex + match[0].length; + const tagEnd = findTagEnd(text, attrsStart); + if (tagEnd === -1) continue; + const attrs = text.slice(attrsStart, tagEnd); + const openEnd = tagEnd + 1; + + // Arguments carried as an attribute: arguments='{"a":1}' or arguments={"a":1} + const attrArgs = argsFromAttrs(attrs); + if (attrArgs !== null) { + const parsed = rawCallsFromJsonText(attrArgs); + if (parsed.length) { + calls.push(...parsed.map((call) => ({ ...call, name: call.name || name }))); + } else { + try { + const decoded: unknown = JSON.parse(attrArgs); + calls.push({ name, arguments: decoded }); + } catch { + calls.push({ name, arguments: {} }); + } + } + spans.push([matchIndex, openEnd]); + continue; + } + + if (attrs.trim().endsWith('/')) { + calls.push({ name, arguments: {} }); + spans.push([matchIndex, openEnd]); + continue; + } + + // Body may be wrapped in a matching close tag, or simply trail the opener. + const closeTag = ``; + const closeIdx = text.indexOf(closeTag, openEnd); + const body = closeIdx === -1 ? text.slice(openEnd) : text.slice(openEnd, closeIdx); + const json = findFirstJsonValue(body); + const consumedEnd = closeIdx === -1 + ? (json ? openEnd + json.end : openEnd) + : closeIdx + closeTag.length; + + if (json) { + const parsed = rawCallsFromJsonText(json.json); + const named = parsed.filter((call) => call.name); + if (named.length) { + calls.push(...named); + } else { + try { + const decoded: unknown = JSON.parse(json.json); + calls.push({ name, arguments: decoded }); + } catch { + calls.push({ name, arguments: {} }); + } + } + } else { + // No JSON body. Arguments may still be present as XML child elements named + // after the schema's properties; dropping them here produced tool calls with + // empty arguments, which fail validation for any tool with required fields. + const xmlArgs = argsFromXmlChildren(body, schemas.get(name)); + calls.push({ name, arguments: xmlArgs || {} }); + } + spans.push([matchIndex, consumedEnd]); + } + + return { calls, spans }; +} + +/** + * Registry-gated bare JSON. Requires the JSON to be the whole message body (optionally + * inside one code fence) so that payloads quoted inside prose are left alone. + */ +function extractBareJson(text: string, names: string[]): ExtractedCalls { + if (!names.length) return { calls: [], spans: [] }; + const trimmed = text.trim(); + if (!trimmed || (trimmed[0] !== '{' && trimmed[0] !== '[' && !trimmed.startsWith('```'))) { + return { calls: [], spans: [] }; + } + + const fenced = trimmed.match(RE.codeFence); + const body = (fenced ? String(fenced[1]) : trimmed).trim(); + if (!body || (body[0] !== '{' && body[0] !== '[')) return { calls: [], spans: [] }; + + const calls = rawCallsFromJsonText(body).filter((call) => names.includes(call.name)); + if (!calls.length) return { calls: [], spans: [] }; + return { calls, spans: [[0, text.length]] }; +} + +/** + * `` / `value` markup, the native tool-call + * dialect of Qwen/GLM-family models and some OpenCode free-tier models: + * + * + * + * https://example.com + * html + * + * + * + * The surrounding `` container is already consumed by extractJsonWrapper + * (which strips it as a span even when the body is not JSON), so here we only recognise + * the `` opener, collect its `` children, and mark the + * whole `...` block for hiding. Name mapping (e.g. `webfetch` → + * `web_fetch`) is resolved later against the request's registry. + */ +function extractFunctionEquals(text: string): ExtractedCalls { + const calls: RawToolCall[] = []; + const spans: TextSpan[] = []; + const openerRe = /]+)\s*>/gi; + for (const match of text.matchAll(openerRe)) { + const name: string = String(match[1]).trim(); + if (!name) continue; + const matchIndex: number = match.index ?? 0; + const openEnd = matchIndex + match[0].length; + const closeIdx = text.indexOf('', openEnd); + const end = closeIdx === -1 ? text.length : closeIdx + ''.length; + const body = closeIdx === -1 ? text.slice(openEnd) : text.slice(openEnd, closeIdx); + + const args: Record = {}; + const paramRe = /]+)\s*>([\s\S]*?)<\/parameter\s*>/gi; + for (const param of body.matchAll(paramRe)) { + args[String(param[1]).trim()] = trimParamValue(param[2]); + } + + calls.push({ name, arguments: args }); + spans.push([matchIndex, end]); + } + return { calls, spans }; +} + +/** Every format in one pass. `spans` cover all markup that should be hidden from users. */ +function collectAll(text: unknown, registry: unknown): ExtractedCalls { + const source = typeof text === 'string' ? text : ''; + if (!source) return { calls: [], spans: [] }; + const names = registryNames(registry); + const schemas = registrySchemas(registry); + + const results: ExtractedCalls[] = [ + extractCanonical(source), + extractDsml(source), + extractJsonWrapper(source), + extractFunctionEquals(source), + extractTagNamed(source, names, schemas), + extractBareJson(source, names) + ]; + + const seen = new Set(); + const calls: RawToolCall[] = []; + results.forEach((result) => { + result.calls.forEach((call) => { + // Distinct formats can describe the same call (e.g. a tag wrapping JSON that + // also names the tool); keep one entry per name+arguments pair. + const key = `${call.name}::${JSON.stringify(call.arguments)}`; + if (seen.has(key)) return; + seen.add(key); + calls.push(call); + }); + }); + + return { calls, spans: results.flatMap((result) => result.spans) }; +} + +function toFinalCalls(rawCalls: RawToolCall[], seed = 0): FinalToolCall[] { + return rawCalls.map((call, index) => ({ + id: typeof call.id === 'string' && call.id ? call.id : `call_${Date.now()}_${seed + index + 1}`, + type: 'function' as const, + function: { + name: call.name, + arguments: typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {}) + } + })); +} + +// --- public API ------------------------------------------------------------ + +/** Canonical `` blocks only. Kept for callers that must not guess. */ +export function parseToolCallsFromText(...chunks: unknown[]): FinalToolCall[] { + const calls: RawToolCall[] = []; + chunks.forEach((chunk: unknown) => { + if (!chunk || typeof chunk !== 'string') return; + calls.push(...extractCanonical(chunk).calls); + }); + return toFinalCalls(calls); +} + +export interface StripMarkupOptions { + registry?: unknown; + [key: string]: unknown; +} + +/** + * Remove tool-call markup from user-visible text. + * Registry-gated formats are only stripped when `options.registry` is supplied; the + * self-delimiting formats are always stripped. + */ +export function stripFunctionCallMarkup(text: string, trim: boolean = true, options: StripMarkupOptions = {}): string { + if (!text) return text; + const opts: Record = + options && typeof options === 'object' ? (options as unknown as Record) : {}; + const { spans } = collectAll(text, opts['registry']); + + let cleaned = text; + if (spans.length) { + const merged: TextSpan[] = [...spans].sort((a, b) => a[0] - b[0]).reduce((acc: TextSpan[], span: TextSpan) => { + const last: TextSpan | undefined = acc[acc.length - 1]; + if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]); + else acc.push([...span] as TextSpan); + return acc; + }, []); + cleaned = merged.reduceRight((acc: string, span: TextSpan) => { + const [start, end] = span; + return acc.slice(0, start) + acc.slice(end); + }, cleaned); + } + + cleaned = cleaned.replace(RE.canonicalStrayTag, ''); + return trim ? cleaned.trim() : cleaned; +} + +/** Parse tool calls and map them onto the request's registry, dropping unknown tools. */ +export function parseExternalToolCallsFromText(registry: unknown, ...chunks: unknown[]): FinalToolCall[] { + if (!Array.isArray(registry) || (registry as unknown[]).length === 0) return []; + const list = registry as ExternalToolEntry[]; + const rawCalls: RawToolCall[] = []; + chunks.forEach((chunk: unknown) => { + if (!chunk || typeof chunk !== 'string') return; + rawCalls.push(...collectAll(chunk, registry).calls); + }); + + const counts = new Map(); + return rawCalls.flatMap((rawCall) => { + const tool = findExternalToolByName(list, rawCall.name); + if (!tool) return []; + const nextCount = (counts.get(tool.namespacedName) || 0) + 1; + counts.set(tool.namespacedName, nextCount); + return [{ + id: typeof rawCall.id === 'string' && rawCall.id + ? rawCall.id + : `call_${tool.namespacedName.replace(/[^a-zA-Z0-9_]/g, '_')}_${nextCount}`, + type: 'function' as const, + function: { + name: tool.originalName, + arguments: typeof rawCall.arguments === 'string' + ? rawCall.arguments + : JSON.stringify(rawCall.arguments ?? {}) + } + }]; + }); +} + +/** + * Openers that may begin tool markup. Used to decide whether a partial chunk should be + * withheld from the client until we know what it is. + */ +function markerOpeners(registry: unknown): string[] { + const openers = [' openers.push(`<${name.toLowerCase()}`)); + return openers; +} + +/** True when `candidate` is a prefix of an opener, or already contains one. */ +function couldBeMarker(candidate: string, openers: string[]): boolean { + const lower = candidate.toLowerCase(); + return openers.some((opener) => opener.startsWith(lower) || lower.startsWith(opener)); +} + +interface InlineBlock { + open: RegExp; + close: RegExp; +} + +/** Complete self-delimiting blocks are dropped inline; other formats wait for flush(). */ +const INLINE_BLOCKS: InlineBlock[] = [ + { open: new RegExp(`^<${MARK}tool_calls\\s*>`, 'i'), close: new RegExp(``, 'i') }, + { open: new RegExp(`^<${MARK}invoke\\s`, 'i'), close: new RegExp(``, 'i') }, + { open: /^/i, close: /<\/tool_call\s*>/i }, + { open: new RegExp(`^${escapeRegExp(CANONICAL_OPEN)}`, 'i'), close: new RegExp(escapeRegExp(CANONICAL_CLOSE), 'i') } +]; + +/** + * Close tags for the known block formats. OpenCode streams reasoning and content as + * separate channels, so a model can open a block in one and close it in the other. The + * channel that only receives the closer must drop it instead of printing it as prose. + */ +const KNOWN_CLOSE_TAG = new RegExp( + `^`, + 'i' +); + +/** A close tag truncated at a chunk boundary, e.g. " = + options && typeof options === 'object' ? (options as unknown as Record) : {}; + const disableTools: unknown = opts['disableTools']; + const forceStrip: unknown = opts['forceStrip'] ?? false; + const registry: unknown = opts['registry'] ?? null; + if (!disableTools && !forceStrip) { + const passthrough = ((chunk: string): string => chunk) as ToolCallFilter; + passthrough.flush = (): string => ''; + return passthrough; + } + + const openers = markerOpeners(registry); + let buffer = ''; + let emittedVisible = false; + let held = false; + + const filter = ((chunk: string): string => { + if (!chunk) return ''; + buffer += chunk; + let output = ''; + + while (buffer.length) { + if (held) return output; + + const inline = matchInlineBlock(buffer); + if (inline && 'pending' in inline) return output; + if (inline && 'end' in inline) { + buffer = buffer.slice(inline.end); + continue; + } + + // Orphaned close tag from a block that opened in the other channel. + const orphanClose = buffer.match(KNOWN_CLOSE_TAG); + if (orphanClose) { + buffer = buffer.slice(orphanClose[0].length); + continue; + } + if (PARTIAL_CLOSE_TAG.test(buffer)) return output; + + // A leading `{` may be a whole-body JSON call; hold it until flush decides. + if (!emittedVisible && !output.trim() && /^\s*[{[]/.test(buffer)) { + held = true; + return output; + } + + const markerIdx = buffer.indexOf('<'); + if (markerIdx === -1) { + output += buffer; + buffer = ''; + break; + } + + if (markerIdx > 0) { + output += buffer.slice(0, markerIdx); + buffer = buffer.slice(markerIdx); + continue; + } + + if (!couldBeMarker(buffer, openers)) { + output += buffer[0]; + buffer = buffer.slice(1); + continue; + } + + // Either an incomplete opener or a registry-gated format. Hold for flush(). + const complete = /^<[^\s/>]+[^>]*>/.test(buffer); + if (!complete) return output; + held = true; + return output; + } + + if (output.trim()) emittedVisible = true; + return output; + }) as ToolCallFilter; + + filter.flush = (): string => { + const remaining = buffer; + buffer = ''; + held = false; + if (!remaining) return ''; + // Strip whatever markup is actually present and release the rest. Also drop a + // trailing orphaned close tag that was still being buffered when the stream ended. + const stripped = stripFunctionCallMarkup(remaining, false, { registry }); + return KNOWN_CLOSE_TAG.test(stripped.trim()) ? '' : stripped; + }; + + return filter; +} + +/** + * Streaming tool-call extractor. Self-delimiting blocks surface as soon as they close; + * registry-gated formats surface from `flush()` at end of stream. + */ +export function createExternalToolCallStreamParser(registry: unknown): ToolCallStreamParser { + if (!Array.isArray(registry) || (registry as unknown[]).length === 0) { + const noop = ((..._args: unknown[]): FinalToolCall[] => []) as ToolCallStreamParser; + noop.flush = (): FinalToolCall[] => []; + return noop; + } + const list = registry as ExternalToolEntry[]; + + const openers = markerOpeners(registry); + let buffer = ''; + let sequence = 0; + + const withUniqueIds = (calls: FinalToolCall[]): FinalToolCall[] => calls.map((call) => { + sequence += 1; + return { ...call, id: `${call.id}_${sequence}` }; + }); + + const parser = ((chunk: string): FinalToolCall[] => { + if (!chunk) return []; + buffer += chunk; + const calls: FinalToolCall[] = []; + + while (buffer.length) { + const markerIdx = buffer.search(/<[^\s]/); + if (markerIdx === -1) break; + + const candidate = buffer.slice(markerIdx); + const inline = matchInlineBlock(candidate); + if (inline && 'pending' in inline) break; + if (inline && 'end' in inline) { + const block = candidate.slice(0, inline.end); + calls.push(...withUniqueIds(parseExternalToolCallsFromText(list, block))); + buffer = candidate.slice(inline.end); + continue; + } + + if (couldBeMarker(candidate, openers)) break; + buffer = candidate.slice(1); + } + + return calls; + }) as ToolCallStreamParser; + + parser.flush = (): FinalToolCall[] => { + const remaining = buffer; + buffer = ''; + if (!remaining.trim()) return []; + return withUniqueIds(parseExternalToolCallsFromText(list, remaining)); + }; + + return parser; +} diff --git a/src/tool-runtime/policy.js b/src/tool-runtime/policy.js deleted file mode 100644 index d870f1b..0000000 --- a/src/tool-runtime/policy.js +++ /dev/null @@ -1,83 +0,0 @@ -import { - TOOL_POLICY_DECISIONS, - TOOL_RISK_LEVELS, - TOOL_SIDE_EFFECTS -} from './contracts.js'; - -function toSet(values) { - if (!Array.isArray(values)) return new Set(); - return new Set(values.filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim())); -} - -export function createPolicyContext(config = {}) { - return { - mode: config.EXTERNAL_TOOL_POLICY_MODE || 'enforce', - defaultRiskLevel: config.EXTERNAL_TOOL_DEFAULT_RISK_LEVEL || TOOL_RISK_LEVELS.LOW, - allowlist: toSet(config.EXTERNAL_TOOL_ALLOWLIST || []), - denylist: toSet(config.EXTERNAL_TOOL_DENYLIST || []), - confirmationRequired: toSet(config.EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR || []) - }; -} - -export function evaluateToolPolicy(tool, args, context = {}) { - if (!tool) { - return { - status: TOOL_POLICY_DECISIONS.DENY, - code: 'unknown_tool', - reason: 'Tool is not registered for this request.' - }; - } - - const policy = createPolicyContext(context.config); - const toolNames = [tool.originalName, tool.namespacedName].filter(Boolean); - const inAllowlist = toolNames.some((name) => policy.allowlist.has(name)); - const inDenylist = toolNames.some((name) => policy.denylist.has(name)); - const requiresConfirmation = tool.requiresConfirmation - || toolNames.some((name) => policy.confirmationRequired.has(name)); - - if (inAllowlist) { - return { - status: TOOL_POLICY_DECISIONS.ALLOW, - effectiveRisk: tool.riskLevel || policy.defaultRiskLevel - }; - } - - if (inDenylist) { - return { - status: TOOL_POLICY_DECISIONS.DENY, - code: 'tool_denied_by_policy', - reason: `Tool ${tool.originalName} is denied by policy.` - }; - } - - if (!inAllowlist && (tool.sideEffect === TOOL_SIDE_EFFECTS.DELETE || tool.riskLevel === TOOL_RISK_LEVELS.CRITICAL)) { - return { - status: TOOL_POLICY_DECISIONS.REQUIRE_CONFIRMATION, - reason: `Tool ${tool.originalName} is high risk and requires confirmation.`, - confirmationPayload: { - toolName: tool.originalName, - namespacedName: tool.namespacedName, - argumentsPreview: args, - risk: tool.riskLevel - } - }; - } - - if (requiresConfirmation && policy.mode !== 'report-only') { - return { - status: TOOL_POLICY_DECISIONS.REQUIRE_CONFIRMATION, - reason: `Tool ${tool.originalName} requires confirmation before execution.`, - confirmationPayload: { - toolName: tool.originalName, - namespacedName: tool.namespacedName, - argumentsPreview: args, - risk: tool.riskLevel - } - }; - } - - return { - status: TOOL_POLICY_DECISIONS.ALLOW, - effectiveRisk: tool.riskLevel || policy.defaultRiskLevel - }; -} diff --git a/src/tool-runtime/policy.ts b/src/tool-runtime/policy.ts new file mode 100644 index 0000000..3d0954b --- /dev/null +++ b/src/tool-runtime/policy.ts @@ -0,0 +1,161 @@ +import { + TOOL_POLICY_DECISIONS, + TOOL_RISK_LEVELS, + TOOL_SIDE_EFFECTS +} from './contracts.js'; + +/** Raw policy configuration source (env / file merge). */ +export interface PolicyRawConfig { + EXTERNAL_TOOL_POLICY_MODE?: unknown; + EXTERNAL_TOOL_DEFAULT_RISK_LEVEL?: unknown; + EXTERNAL_TOOL_ALLOWLIST?: unknown; + EXTERNAL_TOOL_DENYLIST?: unknown; + EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR?: unknown; + [key: string]: unknown; +} + +/** Normalized policy context derived from raw config. */ +export interface PolicyContext { + mode: string; + defaultRiskLevel: unknown; + allowlist: Set; + denylist: Set; + confirmationRequired: Set; +} + +/** Minimal tool surface evaluated by the policy (structural subset of ExternalToolEntry). */ +export interface PolicyToolLike { + originalName?: unknown; + namespacedName?: unknown; + requiresConfirmation?: unknown; + sideEffect?: unknown; + riskLevel?: unknown; +} + +/** Wrapper carrying the raw config, mirroring the JS `{ config }` convention. */ +export interface PolicyEvaluateContext { + config?: unknown; + [key: string]: unknown; +} + +export interface PolicyAllowResult { + status: typeof TOOL_POLICY_DECISIONS.ALLOW; + effectiveRisk: unknown; +} + +export interface PolicyDenyResult { + status: typeof TOOL_POLICY_DECISIONS.DENY; + code: string; + reason: string; +} + +export interface PolicyConfirmationPayload { + toolName: unknown; + namespacedName: unknown; + argumentsPreview: unknown; + risk: unknown; +} + +export interface PolicyRequireConfirmationResult { + status: typeof TOOL_POLICY_DECISIONS.REQUIRE_CONFIRMATION; + reason: string; + confirmationPayload: PolicyConfirmationPayload; +} + +export type PolicyResult = PolicyAllowResult | PolicyDenyResult | PolicyRequireConfirmationResult; + +function toSet(values: unknown): Set { + if (!Array.isArray(values)) return new Set(); + const out = new Set(); + for (const value of values as unknown[]) { + if (typeof value === 'string' && value.trim()) out.add(value.trim()); + } + return out; +} + +export function createPolicyContext(config: unknown = {}): PolicyContext { + const raw: Record = + config && typeof config === 'object' && !Array.isArray(config) + ? (config as Record) + : {}; + const modeRaw: unknown = raw['EXTERNAL_TOOL_POLICY_MODE']; + const riskRaw: unknown = raw['EXTERNAL_TOOL_DEFAULT_RISK_LEVEL']; + return { + mode: typeof modeRaw === 'string' && modeRaw ? modeRaw : 'enforce', + defaultRiskLevel: riskRaw || TOOL_RISK_LEVELS.LOW, + allowlist: toSet(raw['EXTERNAL_TOOL_ALLOWLIST']), + denylist: toSet(raw['EXTERNAL_TOOL_DENYLIST']), + confirmationRequired: toSet(raw['EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR']) + }; +} + +export function evaluateToolPolicy(tool: unknown, args: unknown, context: unknown = {}): PolicyResult { + if (!tool) { + return { + status: TOOL_POLICY_DECISIONS.DENY, + code: 'unknown_tool', + reason: 'Tool is not registered for this request.' + }; + } + + const ctxRecord: Record = + context && typeof context === 'object' && !Array.isArray(context) + ? (context as Record) + : {}; + const policy = createPolicyContext(ctxRecord['config']); + const candidate = tool as PolicyToolLike; + const toolNames: string[] = [candidate.originalName, candidate.namespacedName].filter( + (name): name is string => typeof name === 'string' && Boolean(name) + ); + const inAllowlist = toolNames.some((name) => policy.allowlist.has(name)); + const inDenylist = toolNames.some((name) => policy.denylist.has(name)); + const requiresConfirmation = + Boolean(candidate.requiresConfirmation) || + toolNames.some((name) => policy.confirmationRequired.has(name)); + + if (inAllowlist) { + return { + status: TOOL_POLICY_DECISIONS.ALLOW, + effectiveRisk: candidate.riskLevel || policy.defaultRiskLevel + }; + } + + if (inDenylist) { + return { + status: TOOL_POLICY_DECISIONS.DENY, + code: 'tool_denied_by_policy', + reason: `Tool ${String(candidate.originalName)} is denied by policy.` + }; + } + + if (!inAllowlist && (candidate.sideEffect === TOOL_SIDE_EFFECTS.DELETE || candidate.riskLevel === TOOL_RISK_LEVELS.CRITICAL)) { + return { + status: TOOL_POLICY_DECISIONS.REQUIRE_CONFIRMATION, + reason: `Tool ${String(candidate.originalName)} is high risk and requires confirmation.`, + confirmationPayload: { + toolName: candidate.originalName, + namespacedName: candidate.namespacedName, + argumentsPreview: args, + risk: candidate.riskLevel + } + }; + } + + if (requiresConfirmation && policy.mode !== 'report-only') { + return { + status: TOOL_POLICY_DECISIONS.REQUIRE_CONFIRMATION, + reason: `Tool ${String(candidate.originalName)} requires confirmation before execution.`, + confirmationPayload: { + toolName: candidate.originalName, + namespacedName: candidate.namespacedName, + argumentsPreview: args, + risk: candidate.riskLevel + } + }; + } + + return { + status: TOOL_POLICY_DECISIONS.ALLOW, + effectiveRisk: candidate.riskLevel || policy.defaultRiskLevel + }; +} diff --git a/src/tool-runtime/registry.js b/src/tool-runtime/registry.js deleted file mode 100644 index 4164480..0000000 --- a/src/tool-runtime/registry.js +++ /dev/null @@ -1,146 +0,0 @@ -import { - EXTERNAL_TOOL_PREFIX, - TOOL_RISK_LEVELS, - TOOL_SIDE_EFFECTS, - normalizeRiskLevel, - normalizeSideEffect -} from './contracts.js'; - -function normalizeDescription(value) { - return typeof value === 'string' ? value.trim() : ''; -} - -function normalizeParameters(parameters) { - if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { - return parameters; - } - return { type: 'object', properties: {} }; -} - -/** - * Normalizes the two function-tool shapes the proxy receives. - * - * Chat Completions nests the definition: { type:'function', function:{ name, parameters } } - * The Responses API keeps it flat: { type:'function', name, parameters } - * - * Callers used to read `tool.function.name` directly, so every Responses-API tool was - * silently dropped from the registry. An empty registry means no tool contract reaches the - * prompt and no tool-call markup is ever parsed back out, which is indistinguishable from - * a model that simply refuses to call tools. - */ -export function normalizeToolDefinition(tool) { - if (!tool || tool.type !== 'function') return null; - const definition = tool.function && typeof tool.function === 'object' ? tool.function : tool; - const name = String(definition.name || '').trim(); - if (!name) return null; - return { - name, - description: definition.description, - parameters: definition.parameters, - enabled: definition.enabled, - x_proxy_side_effect: definition.x_proxy_side_effect ?? tool.x_proxy_side_effect, - x_proxy_risk_level: definition.x_proxy_risk_level ?? tool.x_proxy_risk_level, - x_proxy_requires_confirmation: - definition.x_proxy_requires_confirmation ?? tool.x_proxy_requires_confirmation - }; -} - -function inferSideEffect(definition = {}) { - const declared = definition.x_proxy_side_effect; - if (declared) return normalizeSideEffect(declared, TOOL_SIDE_EFFECTS.NONE); - - const name = String(definition.name || '').toLowerCase(); - if (/^(get|list|search|find|read|fetch|lookup)/.test(name)) return TOOL_SIDE_EFFECTS.READ; - if (/^(create|update|set|post|write|send)/.test(name)) return TOOL_SIDE_EFFECTS.WRITE; - if (/^(delete|remove|destroy)/.test(name)) return TOOL_SIDE_EFFECTS.DELETE; - return TOOL_SIDE_EFFECTS.NONE; -} - -function inferRiskLevel(definition = {}, sideEffect = TOOL_SIDE_EFFECTS.NONE) { - const declared = definition.x_proxy_risk_level; - if (declared) return normalizeRiskLevel(declared, TOOL_RISK_LEVELS.LOW); - if (sideEffect === TOOL_SIDE_EFFECTS.DELETE || sideEffect === TOOL_SIDE_EFFECTS.PAYMENT) { - return TOOL_RISK_LEVELS.CRITICAL; - } - if (sideEffect === TOOL_SIDE_EFFECTS.WRITE || sideEffect === TOOL_SIDE_EFFECTS.EXTERNAL_NOTIFICATION) { - return TOOL_RISK_LEVELS.MEDIUM; - } - return TOOL_RISK_LEVELS.LOW; -} - -function inferRequiresConfirmation(definition = {}, sideEffect = TOOL_SIDE_EFFECTS.NONE, riskLevel = TOOL_RISK_LEVELS.LOW) { - if (typeof definition.x_proxy_requires_confirmation === 'boolean') { - return definition.x_proxy_requires_confirmation; - } - return sideEffect === TOOL_SIDE_EFFECTS.WRITE || riskLevel === TOOL_RISK_LEVELS.HIGH || riskLevel === TOOL_RISK_LEVELS.CRITICAL; -} - -export function buildExternalToolRegistry(tools, options = {}) { - if (!Array.isArray(tools) || tools.length === 0) return []; - const prefix = options.prefix || EXTERNAL_TOOL_PREFIX; - const registry = []; - const seenNamespaced = new Set(); - - tools.forEach((tool, index) => { - const definition = normalizeToolDefinition(tool); - if (!definition) return; - const originalName = definition.name; - - let namespacedName = `${prefix}${originalName}`; - let counter = 2; - while (seenNamespaced.has(namespacedName)) { - namespacedName = `${prefix}${originalName}_${counter}`; - counter += 1; - } - seenNamespaced.add(namespacedName); - - const sideEffect = inferSideEffect(definition); - const riskLevel = inferRiskLevel(definition, sideEffect); - registry.push({ - id: `external_tool_${index + 1}`, - originalName, - namespacedName, - description: normalizeDescription(definition.description), - parameters: normalizeParameters(definition.parameters), - sideEffect, - riskLevel, - requiresConfirmation: inferRequiresConfirmation(definition, sideEffect, riskLevel), - enabled: definition.enabled !== false, - sourceTool: tool - }); - }); - - return registry; -} - -export function findExternalToolByName(registry, name) { - if (!name || !Array.isArray(registry)) return null; - const exact = registry.find((tool) => tool.namespacedName === name || tool.originalName === name); - if (exact) return exact; - - // Models frequently drop separators or change case when emitting a tool name - // (e.g. the request declares `web_fetch` but the model writes `webfetch`). - // Fall back to a separator/case-insensitive match, but only when it is - // unambiguous — an exact match always wins and a tie resolves to nothing. - const normalized = normalizeToolNameForMatch(name); - if (!normalized) return null; - const matches = registry.filter((tool) => - normalizeToolNameForMatch(tool.namespacedName) === normalized || - normalizeToolNameForMatch(tool.originalName) === normalized - ); - return matches.length === 1 ? matches[0] : null; -} - -function normalizeToolNameForMatch(name) { - return String(name || '').toLowerCase().replace(/[^a-z0-9]/g, ''); -} - -export function createRegistryIndex(registry) { - const byOriginalName = new Map(); - const byNamespacedName = new Map(); - (registry || []).forEach((tool) => { - byOriginalName.set(tool.originalName, tool); - byNamespacedName.set(tool.namespacedName, tool); - }); - return { byOriginalName, byNamespacedName }; -} diff --git a/src/tool-runtime/registry.ts b/src/tool-runtime/registry.ts new file mode 100644 index 0000000..12478fb --- /dev/null +++ b/src/tool-runtime/registry.ts @@ -0,0 +1,262 @@ +import { + EXTERNAL_TOOL_PREFIX, + TOOL_RISK_LEVELS, + TOOL_SIDE_EFFECTS, + normalizeRiskLevel, + normalizeSideEffect +} from './contracts.js'; +import type { ToolRiskLevel, ToolSideEffect } from './contracts.js'; + +/** Shared extension fields carried alongside a tool definition. */ +export interface ToolDefinitionExtras { + enabled?: unknown; + x_proxy_side_effect?: unknown; + x_proxy_risk_level?: unknown; + x_proxy_requires_confirmation?: unknown; + [key: string]: unknown; +} + +/** Function payload nested under `tool.function` (Chat Completions shape). */ +export interface ChatFunctionPayload extends ToolDefinitionExtras { + name?: unknown; + description?: unknown; + parameters?: unknown; + input_schema?: unknown; +} + +/** Shape 1 — Chat Completions nests the definition under `function`. */ +export interface ChatFunctionNestedTool extends ToolDefinitionExtras { + type: 'function'; + function: ChatFunctionPayload; +} + +/** Shape 2 — Responses API keeps the definition flat. */ +export interface ResponsesFlatTool extends ToolDefinitionExtras { + type: 'function'; + name?: unknown; + description?: unknown; + parameters?: unknown; + input_schema?: unknown; + function?: unknown; +} + +/** Shape 3 — Anthropic Messages shape uses `input_schema` instead of `parameters`. */ +export interface AnthropicInputSchemaTool extends ToolDefinitionExtras { + type: 'function'; + name?: unknown; + description?: unknown; + input_schema?: unknown; + parameters?: unknown; + function?: unknown; +} + +/** The three function-tool shapes the proxy receives. */ +export type IncomingToolDefinition = + | ChatFunctionNestedTool + | ResponsesFlatTool + | AnthropicInputSchemaTool; + +/** Normalized view produced by {@link normalizeToolDefinition}. */ +export interface NormalizedToolDefinition { + name: string; + description: unknown; + parameters: unknown; + enabled: unknown; + x_proxy_side_effect: unknown; + x_proxy_risk_level: unknown; + x_proxy_requires_confirmation: unknown; +} + +/** A single namespaced external tool in the request registry. */ +export interface ExternalToolEntry { + id: string; + originalName: string; + namespacedName: string; + description: string; + parameters: Record; + sideEffect: ToolSideEffect; + riskLevel: ToolRiskLevel; + requiresConfirmation: boolean; + enabled: boolean; + sourceTool: unknown; +} + +export type ToolRegistry = ExternalToolEntry[]; + +export interface RegistryIndex { + byOriginalName: Map; + byNamespacedName: Map; +} + +export interface BuildRegistryOptions { + prefix?: unknown; + [key: string]: unknown; +} + +function normalizeDescription(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeParameters(parameters: unknown): Record { + if (parameters && typeof parameters === 'object' && !Array.isArray(parameters)) { + return parameters as Record; + } + return { type: 'object', properties: {} }; +} + +/** + * Normalizes the two function-tool shapes the proxy receives. + * + * Chat Completions nests the definition: { type:'function', function:{ name, parameters } } + * The Responses API keeps it flat: { type:'function', name, parameters } + * + * Callers used to read `tool.function.name` directly, so every Responses-API tool was + * silently dropped from the registry. An empty registry means no tool contract reaches the + * prompt and no tool-call markup is ever parsed back out, which is indistinguishable from + * a model that simply refuses to call tools. + */ +export function normalizeToolDefinition(tool: unknown): NormalizedToolDefinition | null { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) return null; + const candidate = tool as IncomingToolDefinition & Record; + if (candidate['type'] !== 'function') return null; + // Narrow the union: Chat shape carries `function` as an object; the flat and + // Anthropic shapes are used directly. + const maybeFn: unknown = (candidate as ChatFunctionNestedTool).function; + const definition: Record = + maybeFn && typeof maybeFn === 'object' && !Array.isArray(maybeFn) + ? (maybeFn as Record) + : (candidate as unknown as Record); + const name = String(definition['name'] || '').trim(); + if (!name) return null; + // Anthropic Messages shape uses `input_schema` instead of `parameters`. + const parameters: unknown = definition['parameters'] ?? definition['input_schema']; + const topLevel = candidate as unknown as Record; + return { + name, + description: definition['description'], + parameters, + enabled: definition['enabled'], + x_proxy_side_effect: definition['x_proxy_side_effect'] ?? topLevel['x_proxy_side_effect'], + x_proxy_risk_level: definition['x_proxy_risk_level'] ?? topLevel['x_proxy_risk_level'], + x_proxy_requires_confirmation: + definition['x_proxy_requires_confirmation'] ?? topLevel['x_proxy_requires_confirmation'] + }; +} + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function inferSideEffect(definition: unknown = {}): ToolSideEffect { + const record = asRecord(definition); + const declared: unknown = record['x_proxy_side_effect']; + if (declared) return normalizeSideEffect(declared, TOOL_SIDE_EFFECTS.NONE); + + const name = String(record['name'] || '').toLowerCase(); + if (/^(get|list|search|find|read|fetch|lookup)/.test(name)) return TOOL_SIDE_EFFECTS.READ; + if (/^(create|update|set|post|write|send)/.test(name)) return TOOL_SIDE_EFFECTS.WRITE; + if (/^(delete|remove|destroy)/.test(name)) return TOOL_SIDE_EFFECTS.DELETE; + return TOOL_SIDE_EFFECTS.NONE; +} + +function inferRiskLevel(definition: unknown = {}, sideEffect: ToolSideEffect = TOOL_SIDE_EFFECTS.NONE): ToolRiskLevel { + const record = asRecord(definition); + const declared: unknown = record['x_proxy_risk_level']; + if (declared) return normalizeRiskLevel(declared, TOOL_RISK_LEVELS.LOW); + if (sideEffect === TOOL_SIDE_EFFECTS.DELETE || sideEffect === TOOL_SIDE_EFFECTS.PAYMENT) { + return TOOL_RISK_LEVELS.CRITICAL; + } + if (sideEffect === TOOL_SIDE_EFFECTS.WRITE || sideEffect === TOOL_SIDE_EFFECTS.EXTERNAL_NOTIFICATION) { + return TOOL_RISK_LEVELS.MEDIUM; + } + return TOOL_RISK_LEVELS.LOW; +} + +function inferRequiresConfirmation( + definition: unknown = {}, + sideEffect: ToolSideEffect = TOOL_SIDE_EFFECTS.NONE, + riskLevel: ToolRiskLevel = TOOL_RISK_LEVELS.LOW +): boolean { + const record = asRecord(definition); + if (typeof record['x_proxy_requires_confirmation'] === 'boolean') { + return record['x_proxy_requires_confirmation'] as boolean; + } + return sideEffect === TOOL_SIDE_EFFECTS.WRITE || riskLevel === TOOL_RISK_LEVELS.HIGH || riskLevel === TOOL_RISK_LEVELS.CRITICAL; +} + +export function buildExternalToolRegistry(tools: unknown, options: unknown = {}): ExternalToolEntry[] { + if (!Array.isArray(tools) || tools.length === 0) return []; + const opts = asRecord(options); + const prefixRaw: unknown = opts['prefix']; + const prefix = prefixRaw ? String(prefixRaw) : EXTERNAL_TOOL_PREFIX; + const registry: ExternalToolEntry[] = []; + const seenNamespaced = new Set(); + + (tools as unknown[]).forEach((tool: unknown, index: number) => { + const definition = normalizeToolDefinition(tool); + if (!definition) return; + const originalName = definition.name; + + let namespacedName = `${prefix}${originalName}`; + let counter = 2; + while (seenNamespaced.has(namespacedName)) { + namespacedName = `${prefix}${originalName}_${counter}`; + counter += 1; + } + seenNamespaced.add(namespacedName); + + const sideEffect = inferSideEffect(definition); + const riskLevel = inferRiskLevel(definition, sideEffect); + registry.push({ + id: `external_tool_${index + 1}`, + originalName, + namespacedName, + description: normalizeDescription(definition.description), + parameters: normalizeParameters(definition.parameters), + sideEffect, + riskLevel, + requiresConfirmation: inferRequiresConfirmation(definition, sideEffect, riskLevel), + enabled: definition.enabled !== false, + sourceTool: tool + }); + }); + + return registry; +} + +export function findExternalToolByName(registry: unknown, name: unknown): ExternalToolEntry | null { + if (!name || !Array.isArray(registry)) return null; + const list = registry as ExternalToolEntry[]; + const exact = list.find((tool) => tool.namespacedName === name || tool.originalName === name); + if (exact) return exact; + + // Models frequently drop separators or change case when emitting a tool name + // (e.g. the request declares `web_fetch` but the model writes `webfetch`). + // Fall back to a separator/case-insensitive match, but only when it is + // unambiguous — an exact match always wins and a tie resolves to nothing. + const normalized = normalizeToolNameForMatch(name); + if (!normalized) return null; + const matches = list.filter((tool) => + normalizeToolNameForMatch(tool.namespacedName) === normalized || + normalizeToolNameForMatch(tool.originalName) === normalized + ); + return matches.length === 1 && matches[0] ? matches[0] : null; +} + +function normalizeToolNameForMatch(name: unknown): string { + return String(name || '').toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +export function createRegistryIndex(registry: unknown): RegistryIndex { + const byOriginalName = new Map(); + const byNamespacedName = new Map(); + const list: ExternalToolEntry[] = Array.isArray(registry) ? (registry as ExternalToolEntry[]) : []; + list.forEach((tool) => { + byOriginalName.set(tool.originalName, tool); + byNamespacedName.set(tool.namespacedName, tool); + }); + return { byOriginalName, byNamespacedName }; +} diff --git a/src/tool-runtime/router.js b/src/tool-runtime/router.js deleted file mode 100644 index f2af162..0000000 --- a/src/tool-runtime/router.js +++ /dev/null @@ -1,94 +0,0 @@ -import { EXTERNAL_TOOL_PREFIX } from './contracts.js'; -import { findExternalToolByName } from './registry.js'; - -export function normalizeExternalToolChoice(toolChoice, registry) { - if (!toolChoice || !Array.isArray(registry) || registry.length === 0) { - return { mode: 'auto', requiredTool: null }; - } - if (toolChoice === 'auto' || toolChoice === 'none') { - return { mode: toolChoice, requiredTool: null }; - } - if (toolChoice === 'required') { - return { mode: 'required', requiredTool: null }; - } - // Chat Completions sends { type:'function', function:{ name } }; the Responses API sends - // { type:'function', name }. Accept both so a forced tool choice is not silently ignored. - const requestedName = toolChoice?.function?.name || toolChoice?.name; - if (toolChoice?.type === 'function' && requestedName) { - const mappedTool = findExternalToolByName(registry, requestedName); - return { - mode: 'required', - requiredTool: mappedTool?.namespacedName || `${EXTERNAL_TOOL_PREFIX}${requestedName}` - }; - } - return { mode: 'auto', requiredTool: null }; -} - -export function buildExternalToolsPrompt(registry, toolChoice = null) { - if (!Array.isArray(registry) || registry.length === 0) return ''; - const normalizedChoice = normalizeExternalToolChoice(toolChoice, registry); - const choiceInstructions = []; - if (normalizedChoice.mode === 'required') { - if (normalizedChoice.requiredTool) { - choiceInstructions.push(`Tool use is REQUIRED for this turn. You MUST call ${normalizedChoice.requiredTool} before giving any final answer.`); - } else { - choiceInstructions.push('Tool use is REQUIRED for this turn. You MUST call an external tool before giving any final answer.'); - } - } else if (normalizedChoice.mode === 'none') { - choiceInstructions.push('Tool use is disabled for this turn. Do not emit .'); - } - - return [ - 'External tools are virtualized by this proxy. They are not OpenCode tools.', - 'When you need an external tool, your entire assistant reply MUST be ONLY one or more ... blocks.', - 'Do NOT output , explanations, markdown, prose, or any text before or after blocks when making a tool call.', - 'Each block must contain JSON with this exact shape:', - '{"name":"external__tool_name","arguments":{}}', - 'Arguments must be a valid JSON object that matches the declared schema.', - 'Use only the namespaced names listed below. Do not use original client tool names inside function calls.', - 'If tool results are later provided as TOOL_RESULT messages, use those results to continue normally.', - ...choiceInstructions, - `Available external tools: ${JSON.stringify(registry.map((tool) => ({ - name: tool.namespacedName, - client_name: tool.originalName, - description: tool.description, - parameters: tool.parameters, - risk_level: tool.riskLevel, - side_effect: tool.sideEffect, - requires_confirmation: tool.requiresConfirmation - })))}`, - ].join('\n'); -} - -/** - * Short imperative restatement of the markup contract, meant to be appended as the final - * prompt part rather than buried in the system prompt. - * - * Position matters more than wording here. With the contract only in the system prompt, - * deepseek-v4-flash-free emitted parseable markup in 4/8 runs of an obvious single-tool - * request; with this reminder as the last thing before generation it was 8/8. Harnesses - * like pi send system prompts of 16KB or more and the contract gets lost inside them. - */ -export function buildExternalToolsReminder(registry, toolChoice = null) { - if (!Array.isArray(registry) || registry.length === 0) return ''; - const normalizedChoice = normalizeExternalToolChoice(toolChoice, registry); - if (normalizedChoice.mode === 'none') return ''; - const exampleName = normalizedChoice.requiredTool || registry[0].namespacedName; - return [ - 'REMINDER: External tools are called by emitting markup, not through any native tool API.', - `To call one, your entire reply must be ONLY {"name":"${exampleName}","arguments":{...}}`, - 'with no prose, no markdown and no block. Otherwise answer normally.', - `Available names: ${registry.map((tool) => tool.namespacedName).join(', ')}` - ].join('\n'); -} - -export function buildToolExposure(registry, toolChoice = null) { - const normalizedChoice = normalizeExternalToolChoice(toolChoice, registry); - const exposedTools = Array.isArray(registry) ? registry.filter((tool) => tool.enabled !== false) : []; - return { - tools: exposedTools, - toolChoice: normalizedChoice, - prompt: buildExternalToolsPrompt(exposedTools, toolChoice), - reminder: buildExternalToolsReminder(exposedTools, toolChoice) - }; -} diff --git a/src/tool-runtime/router.ts b/src/tool-runtime/router.ts new file mode 100644 index 0000000..d4618bc --- /dev/null +++ b/src/tool-runtime/router.ts @@ -0,0 +1,210 @@ +import { EXTERNAL_TOOL_PREFIX } from './contracts.js'; +import { findExternalToolByName } from './registry.js'; +import type { ExternalToolEntry } from './registry.js'; + +/** String shorthand accepted by Chat Completions (`auto` / `none` / `required`). */ +export type ChatStringToolChoice = 'auto' | 'none' | 'required'; + +/** Chat Completions object shape: `{ type:'function', function:{ name } }` (nested). */ +export interface ChatNestedFunctionToolChoice { + type: 'function'; + function?: { name?: unknown; [key: string]: unknown } | null; + name?: unknown; + [key: string]: unknown; +} + +/** Responses API object shape: `{ type:'function', name }` (flat). */ +export interface ChatFlatFunctionToolChoice { + type: 'function'; + name?: unknown; + function?: unknown; + [key: string]: unknown; +} + +/** Anthropic Messages shapes: `{ type: 'auto' | 'any' | 'tool' | 'none', name? }`. */ +export interface AnthropicAutoToolChoice { + type: 'auto'; + name?: unknown; + [key: string]: unknown; +} + +export interface AnthropicNoneToolChoice { + type: 'none'; + name?: unknown; + [key: string]: unknown; +} + +export interface AnthropicAnyToolChoice { + type: 'any'; + name?: unknown; + [key: string]: unknown; +} + +export interface AnthropicSpecificToolChoice { + type: 'tool'; + name?: unknown; + [key: string]: unknown; +} + +/** Fallback for case-variant or otherwise unknown object shapes. */ +export interface UnknownObjectToolChoice { + type?: unknown; + name?: unknown; + function?: unknown; + [key: string]: unknown; +} + +/** + * Every `tool_choice` shape the proxy accepts: Chat string/object forms plus + * Anthropic Messages object forms. + */ +export type ExternalToolChoice = + | ChatStringToolChoice + | ChatNestedFunctionToolChoice + | ChatFlatFunctionToolChoice + | AnthropicAutoToolChoice + | AnthropicNoneToolChoice + | AnthropicAnyToolChoice + | AnthropicSpecificToolChoice + | UnknownObjectToolChoice; + +export type ToolChoiceMode = 'auto' | 'none' | 'required'; + +export interface NormalizedChoice { + mode: ToolChoiceMode; + requiredTool: string | null; +} + +export interface ToolExposure { + tools: ExternalToolEntry[]; + toolChoice: NormalizedChoice; + prompt: string; + reminder: string; +} + +function asRegistryList(registry: unknown): ExternalToolEntry[] { + return Array.isArray(registry) ? (registry as ExternalToolEntry[]) : []; +} + +export function normalizeExternalToolChoice(toolChoice: unknown, registry: unknown): NormalizedChoice { + const list = asRegistryList(registry); + if (!toolChoice || !Array.isArray(registry) || list.length === 0) { + return { mode: 'auto', requiredTool: null }; + } + if (toolChoice === 'auto' || toolChoice === 'none') { + return { mode: toolChoice, requiredTool: null }; + } + if (toolChoice === 'required') { + return { mode: 'required', requiredTool: null }; + } + if (toolChoice && typeof toolChoice === 'object' && !Array.isArray(toolChoice)) { + const candidate = toolChoice as UnknownObjectToolChoice & Record; + // Anthropic Messages shape: { type: 'auto' | 'any' | 'tool' | 'none', name? }. + if (typeof candidate['type'] === 'string' && candidate['function'] === undefined) { + const t = String(candidate['type']).toLowerCase(); + if (t === 'auto') return { mode: 'auto', requiredTool: null }; + if (t === 'none') return { mode: 'none', requiredTool: null }; + if (t === 'any') return { mode: 'required', requiredTool: null }; + if (t === 'tool' && candidate['name']) { + const requested = candidate['name'] as string; + const mappedTool = findExternalToolByName(list, requested); + return { + mode: 'required', + requiredTool: mappedTool?.namespacedName || `${EXTERNAL_TOOL_PREFIX}${String(requested)}` + }; + } + if (t === 'tool') return { mode: 'required', requiredTool: null }; + } + // Chat Completions sends { type:'function', function:{ name } }; the Responses API sends + // { type:'function', name }. Accept both so a forced tool choice is not silently ignored. + const fnRecord: unknown = candidate['function']; + const fnName: unknown = + fnRecord && typeof fnRecord === 'object' && !Array.isArray(fnRecord) + ? (fnRecord as Record)['name'] + : undefined; + const requestedName: unknown = fnName || candidate['name']; + if (candidate['type'] === 'function' && requestedName) { + const mappedTool = findExternalToolByName(list, requestedName); + return { + mode: 'required', + requiredTool: mappedTool?.namespacedName || `${EXTERNAL_TOOL_PREFIX}${String(requestedName)}` + }; + } + } + return { mode: 'auto', requiredTool: null }; +} + +export function buildExternalToolsPrompt(registry: unknown, toolChoice: unknown = null): string { + const list = asRegistryList(registry); + if (!Array.isArray(registry) || list.length === 0) return ''; + const normalizedChoice = normalizeExternalToolChoice(toolChoice, registry); + const choiceInstructions: string[] = []; + if (normalizedChoice.mode === 'required') { + if (normalizedChoice.requiredTool) { + choiceInstructions.push(`Tool use is REQUIRED for this turn. You MUST call ${normalizedChoice.requiredTool} before giving any final answer.`); + } else { + choiceInstructions.push('Tool use is REQUIRED for this turn. You MUST call an external tool before giving any final answer.'); + } + } else if (normalizedChoice.mode === 'none') { + choiceInstructions.push('Tool use is disabled for this turn. Do not emit .'); + } + + return [ + 'External tools are virtualized by this proxy. They are not OpenCode tools.', + 'When you need an external tool, your entire assistant reply MUST be ONLY one or more ... blocks.', + 'Do NOT output , explanations, markdown, prose, or any text before or after blocks when making a tool call.', + 'Each block must contain JSON with this exact shape:', + '{"name":"external__tool_name","arguments":{}}', + 'Arguments must be a valid JSON object that matches the declared schema.', + 'Use only the namespaced names listed below. Do not use original client tool names inside function calls.', + 'If tool results are later provided as TOOL_RESULT messages, use those results to continue normally.', + ...choiceInstructions, + `Available external tools: ${JSON.stringify(list.map((tool) => ({ + name: tool.namespacedName, + client_name: tool.originalName, + description: tool.description, + parameters: tool.parameters, + risk_level: tool.riskLevel, + side_effect: tool.sideEffect, + requires_confirmation: tool.requiresConfirmation + })))}` + ].join('\n'); +} + +/** + * Short imperative restatement of the markup contract, meant to be appended as the final + * prompt part rather than buried in the system prompt. + * + * Position matters more than wording here. With the contract only in the system prompt, + * deepseek-v4-flash-free emitted parseable markup in 4/8 runs of an obvious single-tool + * request; with this reminder as the last thing before generation it was 8/8. Harnesses + * like pi send system prompts of 16KB or more and the contract gets lost inside them. + */ +export function buildExternalToolsReminder(registry: unknown, toolChoice: unknown = null): string { + const list = asRegistryList(registry); + if (!Array.isArray(registry) || list.length === 0) return ''; + const normalizedChoice = normalizeExternalToolChoice(toolChoice, registry); + if (normalizedChoice.mode === 'none') return ''; + const first = list[0]; + if (!first) return ''; + const exampleName = normalizedChoice.requiredTool || first.namespacedName; + return [ + 'REMINDER: External tools are called by emitting markup, not through any native tool API.', + `To call one, your entire reply must be ONLY {"name":"${exampleName}","arguments":{...}}`, + 'with no prose, no markdown and no block. Otherwise answer normally.', + `Available names: ${list.map((tool) => tool.namespacedName).join(', ')}` + ].join('\n'); +} + +export function buildToolExposure(registry: unknown, toolChoice: unknown = null): ToolExposure { + const normalizedChoice = normalizeExternalToolChoice(toolChoice, registry); + const exposedTools: ExternalToolEntry[] = Array.isArray(registry) + ? (registry as ExternalToolEntry[]).filter((tool) => tool.enabled !== false) + : []; + return { + tools: exposedTools, + toolChoice: normalizedChoice, + prompt: buildExternalToolsPrompt(exposedTools, toolChoice), + reminder: buildExternalToolsReminder(exposedTools, toolChoice) + }; +} diff --git a/src/tool-runtime/validator.js b/src/tool-runtime/validator.js deleted file mode 100644 index 2ae3ea1..0000000 --- a/src/tool-runtime/validator.js +++ /dev/null @@ -1,132 +0,0 @@ -import { VALIDATION_STATUSES, createValidationError } from './contracts.js'; -import { findExternalToolByName } from './registry.js'; - -function safeParseJsonObject(raw) { - if (raw === undefined || raw === null || raw === '') { - return { ok: true, value: {} }; - } - if (typeof raw === 'object') { - return Array.isArray(raw) - ? { ok: false, error: 'arguments must be a JSON object' } - : { ok: true, value: raw }; - } - if (typeof raw !== 'string') { - return { ok: false, error: 'arguments must be a JSON string or object' }; - } - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { ok: false, error: 'arguments must decode to a JSON object' }; - } - return { ok: true, value: parsed }; - } catch (error) { - return { ok: false, error: error.message }; - } -} - -function validateAgainstSchema(args, schema = {}) { - const errors = []; - const normalizedSchema = schema && typeof schema === 'object' ? schema : {}; - const properties = normalizedSchema.properties && typeof normalizedSchema.properties === 'object' - ? normalizedSchema.properties - : {}; - const required = Array.isArray(normalizedSchema.required) ? normalizedSchema.required : []; - - required.forEach((key) => { - if (!(key in args) || args[key] === undefined || args[key] === null || args[key] === '') { - errors.push(createValidationError('missing_required_field', `Missing required field: ${key}`, [key])); - } - }); - - Object.entries(properties).forEach(([key, definition]) => { - if (!(key in args) || args[key] === undefined || args[key] === null) return; - const value = args[key]; - const expectedType = definition?.type; - if (expectedType === 'string' && typeof value !== 'string') { - errors.push(createValidationError('invalid_type', `Field ${key} must be a string`, [key])); - } - if (expectedType === 'number' && typeof value !== 'number') { - errors.push(createValidationError('invalid_type', `Field ${key} must be a number`, [key])); - } - if (expectedType === 'integer' && !Number.isInteger(value)) { - errors.push(createValidationError('invalid_type', `Field ${key} must be an integer`, [key])); - } - if (expectedType === 'boolean' && typeof value !== 'boolean') { - errors.push(createValidationError('invalid_type', `Field ${key} must be a boolean`, [key])); - } - if (expectedType === 'object' && (!value || typeof value !== 'object' || Array.isArray(value))) { - errors.push(createValidationError('invalid_type', `Field ${key} must be an object`, [key])); - } - if (Array.isArray(definition?.enum) && !definition.enum.includes(value)) { - errors.push(createValidationError('invalid_enum', `Field ${key} must be one of: ${definition.enum.join(', ')}`, [key])); - } - }); - - return errors; -} - -export function validateToolCall(parsedCall, registry) { - const tool = findExternalToolByName(registry, parsedCall?.function?.name); - if (!tool) { - return { - status: VALIDATION_STATUSES.REJECTED, - errors: [createValidationError('unknown_tool', `Unknown external tool: ${parsedCall?.function?.name || 'unknown'}`)], - tool: null - }; - } - - const parsedArgs = safeParseJsonObject(parsedCall?.function?.arguments); - if (!parsedArgs.ok) { - return { - status: VALIDATION_STATUSES.REPAIRABLE, - errors: [createValidationError('invalid_arguments_json', `Invalid JSON arguments for ${tool.originalName}: ${parsedArgs.error}`)], - tool - }; - } - - const schemaErrors = validateAgainstSchema(parsedArgs.value, tool.parameters); - if (schemaErrors.length > 0) { - return { - status: VALIDATION_STATUSES.REJECTED, - errors: schemaErrors, - tool - }; - } - - return { - status: VALIDATION_STATUSES.VALID, - normalizedArguments: parsedArgs.value, - tool - }; -} - -export function validateToolCalls(parsedCalls, registry) { - if (!Array.isArray(parsedCalls) || parsedCalls.length === 0) { - return { validCalls: [], invalidCalls: [] }; - } - - const validCalls = []; - const invalidCalls = []; - parsedCalls.forEach((call) => { - const validation = validateToolCall(call, registry); - if (validation.status === VALIDATION_STATUSES.VALID) { - validCalls.push({ - ...call, - validatedArguments: validation.normalizedArguments, - function: { - ...call.function, - arguments: JSON.stringify(validation.normalizedArguments) - }, - tool: validation.tool, - validation - }); - return; - } - invalidCalls.push({ - call, - validation - }); - }); - - return { validCalls, invalidCalls }; -} diff --git a/src/tool-runtime/validator.ts b/src/tool-runtime/validator.ts new file mode 100644 index 0000000..0526475 --- /dev/null +++ b/src/tool-runtime/validator.ts @@ -0,0 +1,230 @@ +import { VALIDATION_STATUSES, createValidationError } from './contracts.js'; +import type { ValidationError } from './contracts.js'; +import { findExternalToolByName } from './registry.js'; +import type { ExternalToolEntry } from './registry.js'; + +/** A single parsed tool call awaiting validation. */ +export interface ParsedCallFunction { + name?: unknown; + arguments?: unknown; + [key: string]: unknown; +} + +export interface ParsedCall { + id?: unknown; + type?: unknown; + function?: ParsedCallFunction | null; + [key: string]: unknown; +} + +/** `valid` branch of the validation discriminated union. */ +export interface ValidValidationResult { + status: typeof VALIDATION_STATUSES.VALID; + normalizedArguments: Record; + tool: ExternalToolEntry; +} + +/** `repairable` branch (malformed JSON arguments, tool known). */ +export interface RepairableValidationResult { + status: typeof VALIDATION_STATUSES.REPAIRABLE; + errors: ValidationError[]; + tool: ExternalToolEntry; +} + +/** `rejected` branch (unknown tool or schema violations). */ +export interface RejectedValidationResult { + status: typeof VALIDATION_STATUSES.REJECTED; + errors: ValidationError[]; + tool: ExternalToolEntry | null; +} + +export type ValidationResult = ValidValidationResult | RepairableValidationResult | RejectedValidationResult; + +export type SafeParseSuccess = { ok: true; value: Record }; +export type SafeParseFailure = { ok: false; error: string }; +export type SafeParseResult = SafeParseSuccess | SafeParseFailure; + +/** A validated call, enriched with normalized arguments and its registry entry. */ +export interface ValidatedToolCall { + id?: unknown; + type?: unknown; + function: { name?: unknown; arguments: string; [key: string]: unknown }; + validatedArguments: Record; + tool: ExternalToolEntry; + validation: ValidValidationResult; + [key: string]: unknown; +} + +export interface InvalidCallEntry { + call: ParsedCall; + validation: ValidationResult; +} + +export interface ValidateToolCallsResult { + validCalls: ValidatedToolCall[]; + invalidCalls: InvalidCallEntry[]; +} + +function safeParseJsonObject(raw: unknown): SafeParseResult { + if (raw === undefined || raw === null || raw === '') { + return { ok: true, value: {} }; + } + if (typeof raw === 'object') { + return Array.isArray(raw) + ? { ok: false, error: 'arguments must be a JSON object' } + : { ok: true, value: raw as Record }; + } + if (typeof raw !== 'string') { + return { ok: false, error: 'arguments must be a JSON string or object' }; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, error: 'arguments must decode to a JSON object' }; + } + return { ok: true, value: parsed as Record }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: message }; + } +} + +function validateAgainstSchema(args: Record, schema: unknown = {}): ValidationError[] { + const errors: ValidationError[] = []; + const normalizedSchema: Record = + schema && typeof schema === 'object' && !Array.isArray(schema) + ? (schema as Record) + : {}; + const rawProperties: unknown = normalizedSchema['properties']; + const properties: Record = + rawProperties && typeof rawProperties === 'object' && !Array.isArray(rawProperties) + ? (rawProperties as Record) + : {}; + const rawRequired: unknown = normalizedSchema['required']; + const required: unknown[] = Array.isArray(rawRequired) ? (rawRequired as unknown[]) : []; + + required.forEach((key: unknown) => { + const field = String(key); + if (!(field in args) || args[field] === undefined || args[field] === null || args[field] === '') { + errors.push(createValidationError('missing_required_field', `Missing required field: ${String(key)}`, [String(key)])); + } + }); + + Object.entries(properties).forEach(([key, definition]: [string, unknown]) => { + if (!(key in args) || args[key] === undefined || args[key] === null) return; + const value: unknown = args[key]; + const defRecord: Record = + definition && typeof definition === 'object' && !Array.isArray(definition) + ? (definition as Record) + : {}; + const expectedType: unknown = defRecord['type']; + if (expectedType === 'string' && typeof value !== 'string') { + errors.push(createValidationError('invalid_type', `Field ${key} must be a string`, [key])); + } + if (expectedType === 'number' && typeof value !== 'number') { + errors.push(createValidationError('invalid_type', `Field ${key} must be a number`, [key])); + } + if (expectedType === 'integer' && !Number.isInteger(value)) { + errors.push(createValidationError('invalid_type', `Field ${key} must be an integer`, [key])); + } + if (expectedType === 'boolean' && typeof value !== 'boolean') { + errors.push(createValidationError('invalid_type', `Field ${key} must be a boolean`, [key])); + } + if (expectedType === 'object' && (!value || typeof value !== 'object' || Array.isArray(value))) { + errors.push(createValidationError('invalid_type', `Field ${key} must be an object`, [key])); + } + const enumRaw: unknown = defRecord['enum']; + if (Array.isArray(enumRaw) && !(enumRaw as unknown[]).includes(value)) { + errors.push(createValidationError('invalid_enum', `Field ${key} must be one of: ${(enumRaw as unknown[]).join(', ')}`, [key])); + } + }); + + return errors; +} + +function readFunctionName(parsedCall: unknown): unknown { + if (!parsedCall || typeof parsedCall !== 'object' || Array.isArray(parsedCall)) return undefined; + const fn: unknown = (parsedCall as Record)['function']; + if (!fn || typeof fn !== 'object' || Array.isArray(fn)) return undefined; + return (fn as Record)['name']; +} + +function readFunctionArguments(parsedCall: unknown): unknown { + if (!parsedCall || typeof parsedCall !== 'object' || Array.isArray(parsedCall)) return undefined; + const fn: unknown = (parsedCall as Record)['function']; + if (!fn || typeof fn !== 'object' || Array.isArray(fn)) return undefined; + return (fn as Record)['arguments']; +} + +export function validateToolCall(parsedCall: unknown, registry: unknown): ValidationResult { + const requestedName: unknown = readFunctionName(parsedCall); + const tool = findExternalToolByName(registry, requestedName); + if (!tool) { + return { + status: VALIDATION_STATUSES.REJECTED, + errors: [createValidationError('unknown_tool', `Unknown external tool: ${String(requestedName || 'unknown')}`)], + tool: null + }; + } + + const parsedArgs = safeParseJsonObject(readFunctionArguments(parsedCall)); + if (!parsedArgs.ok) { + const failure = parsedArgs as SafeParseFailure; + return { + status: VALIDATION_STATUSES.REPAIRABLE, + errors: [createValidationError('invalid_arguments_json', `Invalid JSON arguments for ${tool.originalName}: ${failure.error}`)], + tool + }; + } + + const schemaErrors = validateAgainstSchema(parsedArgs.value, tool.parameters); + if (schemaErrors.length > 0) { + return { + status: VALIDATION_STATUSES.REJECTED, + errors: schemaErrors, + tool + }; + } + + return { + status: VALIDATION_STATUSES.VALID, + normalizedArguments: parsedArgs.value, + tool + }; +} + +export function validateToolCalls(parsedCalls: unknown, registry: unknown): ValidateToolCallsResult { + if (!Array.isArray(parsedCalls) || parsedCalls.length === 0) { + return { validCalls: [], invalidCalls: [] }; + } + + const validCalls: ValidatedToolCall[] = []; + const invalidCalls: InvalidCallEntry[] = []; + (parsedCalls as unknown[]).forEach((call: unknown) => { + const validation = validateToolCall(call, registry); + const record: Record = + call && typeof call === 'object' && !Array.isArray(call) ? (call as Record) : {}; + if (validation.status === VALIDATION_STATUSES.VALID) { + const fnRaw: unknown = record['function']; + const fnRecord: Record = + fnRaw && typeof fnRaw === 'object' && !Array.isArray(fnRaw) ? (fnRaw as Record) : {}; + validCalls.push({ + ...record, + validatedArguments: validation.normalizedArguments, + function: { + ...fnRecord, + arguments: JSON.stringify(validation.normalizedArguments) + }, + tool: validation.tool, + validation + }); + return; + } + invalidCalls.push({ + call: (call ?? {}) as ParsedCall, + validation + }); + }); + + return { validCalls, invalidCalls }; +} diff --git a/src/types/anthropic.ts b/src/types/anthropic.ts new file mode 100644 index 0000000..a0e6cb5 --- /dev/null +++ b/src/types/anthropic.ts @@ -0,0 +1,54 @@ +/** Anthropic Messages DTOs (proxy-level view, strict shapes live in converters). */ + +export interface AnthropicRequestBody { + model?: unknown; + system?: unknown; + messages?: unknown; + tools?: unknown; + tool_choice?: unknown; + stream?: unknown; + temperature?: unknown; + top_p?: unknown; + top_k?: unknown; + max_tokens?: unknown; + stop_sequences?: unknown; + thinking?: unknown; + [key: string]: unknown; +} + +export interface ResponsesRequestBody { + model?: unknown; + input?: unknown; + reasoning_effort?: unknown; + reasoning?: unknown; + max_output_tokens?: unknown; + tools?: unknown; + tool_choice?: unknown; + instructions?: unknown; + temperature?: unknown; + top_p?: unknown; + stream?: unknown; + messages?: unknown; + prompt?: unknown; + previous_response_id?: unknown; + opencode?: unknown; + [key: string]: unknown; +} + +export interface ChatCompletionsRequestBody { + messages?: unknown; + model?: unknown; + tools?: unknown; + tool_choice?: unknown; + stream?: unknown; + temperature?: unknown; + max_tokens?: unknown; + top_p?: unknown; + frequency_penalty?: unknown; + presence_penalty?: unknown; + stop?: unknown; + reasoning_effort?: unknown; + reasoning?: unknown; + opencode?: unknown; + [key: string]: unknown; +} diff --git a/src/types/backend.ts b/src/types/backend.ts new file mode 100644 index 0000000..e82dcc1 --- /dev/null +++ b/src/types/backend.ts @@ -0,0 +1,24 @@ +import type { ChildProcess } from 'node:child_process'; + +/** Per-backend lifecycle state (keyed by OPENCODE_SERVER_URL). */ +export interface BackendState { + isStarting: boolean; + process: ChildProcess | null; + jailRoot: string | null; +} + +/** Stateful Responses continuation entry (previous_response_id). */ +export interface SessionInfo { + sessionId: string; + model: string; + expiresAt: number; +} + +/** Alias kept for task naming (SessionInfo == response-state entry). */ +export type ResponseStateEntry = SessionInfo; + +/** Result of resolving the opencode binary location. */ +export interface OpencodeResolveResult { + path: string | null; + source: string; +} diff --git a/src/types/chat.ts b/src/types/chat.ts new file mode 100644 index 0000000..6d1b0d3 --- /dev/null +++ b/src/types/chat.ts @@ -0,0 +1,48 @@ +/** Chat Completions DTOs (OpenAI-compatible, proxy-level view). */ + +export interface ChatFunctionDefinition { + name?: unknown; + description?: unknown; + parameters?: unknown; + arguments?: unknown; + [key: string]: unknown; +} + +export interface ChatTool { + type?: unknown; + function?: ChatFunctionDefinition | null; + name?: unknown; + [key: string]: unknown; +} + +export type ChatToolChoice = string | Record | null | undefined; + +export interface ChatMessageContentPart { + type?: unknown; + text?: unknown; + image_url?: unknown; + [key: string]: unknown; +} + +export interface ChatMessage { + role?: unknown; + content?: unknown; + tool_calls?: unknown; + tool_call_id?: unknown; + name?: unknown; + [key: string]: unknown; +} + +export interface PromptPart { + type: string; + text?: string; + mime?: string; + url?: string; + filename?: string; + [key: string]: unknown; +} + +export interface PromptParams { + path: { id: string }; + body: Record; +} diff --git a/src/types/client.ts b/src/types/client.ts new file mode 100644 index 0000000..0de5383 --- /dev/null +++ b/src/types/client.ts @@ -0,0 +1,54 @@ +/** Minimal OpenCode SDK surface used by the proxy (structural subset). */ + +export interface SessionNamespace { + create: (args?: unknown) => Promise; + prompt: (args: unknown) => Promise; + messages: (args: unknown) => Promise; + delete: (args: unknown) => Promise; +} + +export interface ConfigNamespace { + providers: (args?: unknown) => Promise; + update: (args: unknown) => Promise; +} + +export interface ToolNamespace { + ids: (args?: unknown) => Promise; +} + +export interface EventNamespace { + subscribe: (args: unknown) => Promise; +} + +/** Structural client used across routes/collector (real SDK cast to this). */ +export interface ProxyClient { + session: SessionNamespace; + config: ConfigNamespace; + tool: ToolNamespace; + event: EventNamespace; +} + +/** Provider entry used to build the /v1/models list. */ +export interface ProviderInfo { + id: string; + models?: Record; + [key: string]: unknown; +} + +/** Normalized model list entry served by GET /v1/models. */ +export interface ModelInfo { + id: string; + name: unknown; + object: string; + created: number; + owned_by: string; +} + +/** Result of resolving a requested model string to provider/model IDs. */ +export interface ResolvedModel { + providerID: string; + modelID: string; + models: ModelInfo[]; + resolved: string; + aliasFrom?: string; +} diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..915008e --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,81 @@ +/** Central proxy configuration (effective config for createApp). */ + +export interface ProxyConfig { + PORT: number; + API_KEY: string; + OPENCODE_SERVER_URL: string; + OPENCODE_SERVER_PASSWORD: string; + OPENCODE_PATH: string; + BIND_HOST: string; + USE_ISOLATED_HOME: boolean; + REQUEST_TIMEOUT_MS: number; + MANAGE_BACKEND: boolean; + DISABLE_TOOLS: boolean; + EXTERNAL_TOOLS_MODE: string; + EXTERNAL_TOOLS_CONFLICT_POLICY: string; + INTERNAL_WEB_FETCH_ENABLED: boolean; + INTERNAL_ALLOWED_TOOLS: string[]; + INTERNAL_TOOL_METRICS_ENABLED: boolean; + INTERNAL_TOOL_DISCOVERY_FIXTURE: string[]; + HEALTH_DETAILS_ENABLED: boolean; + HEALTH_DETAILS_REQUIRE_AUTH: boolean; + METRICS_ENABLED: boolean; + METRICS_REQUIRE_AUTH: boolean; + DEBUG: boolean; + ZEN_API_KEY: string; + PROMPT_MODE: string; + OMIT_SYSTEM_PROMPT: boolean; + AUTO_CLEANUP_CONVERSATIONS: boolean; + CLEANUP_INTERVAL_MS: number; + CLEANUP_MAX_AGE_MS: number; + OPENCODE_HOME_BASE: string | null; + RETRY_MAX_RETRIES?: unknown; + [key: string]: unknown; +} + +/** Caller overrides accepted by buildProxyConfig/startProxy (null tolerated). */ +export interface ProxyConfigOptions { + PORT?: unknown; + API_KEY?: unknown; + OPENCODE_SERVER_URL?: unknown; + OPENCODE_SERVER_PASSWORD?: unknown; + OPENCODE_PATH?: unknown; + BIND_HOST?: unknown; + bindHost?: unknown; + USE_ISOLATED_HOME?: unknown; + REQUEST_TIMEOUT_MS?: unknown; + MANAGE_BACKEND?: unknown; + DISABLE_TOOLS?: unknown; + disableTools?: unknown; + EXTERNAL_TOOLS_MODE?: unknown; + externalToolsMode?: unknown; + EXTERNAL_TOOLS_CONFLICT_POLICY?: unknown; + externalToolsConflictPolicy?: unknown; + INTERNAL_WEB_FETCH_ENABLED?: unknown; + INTERNAL_ALLOWED_TOOLS?: unknown; + INTERNAL_TOOL_METRICS_ENABLED?: unknown; + INTERNAL_TOOL_DISCOVERY_FIXTURE?: unknown; + HEALTH_DETAILS_ENABLED?: unknown; + HEALTH_DETAILS_REQUIRE_AUTH?: unknown; + METRICS_ENABLED?: unknown; + METRICS_REQUIRE_AUTH?: unknown; + DEBUG?: unknown; + ZEN_API_KEY?: unknown; + PROMPT_MODE?: unknown; + promptMode?: unknown; + OMIT_SYSTEM_PROMPT?: unknown; + AUTO_CLEANUP_CONVERSATIONS?: unknown; + CLEANUP_INTERVAL_MS?: unknown; + CLEANUP_MAX_AGE_MS?: unknown; + OPENCODE_HOME_BASE?: unknown; + RETRY_MAX_RETRIES?: unknown; + PROMPT_MODE_ALIAS?: unknown; + [key: string]: unknown; +} + +/** Options bag for resolveDisableTools (both naming conventions). */ +export interface DisableToolsOptions { + DISABLE_TOOLS?: unknown; + disableTools?: unknown; + [key: string]: unknown; +} diff --git a/src/types/context.ts b/src/types/context.ts new file mode 100644 index 0000000..fe0865b --- /dev/null +++ b/src/types/context.ts @@ -0,0 +1,165 @@ +import type { ProxyConfig } from './config.js'; +import type { ProxyClient, ResolvedModel, ModelInfo, ProviderInfo } from './client.js'; +import type { ResponseStateEntry } from './backend.js'; +import type { ExternalToolEntry } from '../tool-runtime/registry.js'; +import type { ValidatedToolCall } from '../tool-runtime/validator.js'; +import type { FinalToolCall } from '../tool-runtime/parser.js'; + +export type ToolModeName = string; + +export interface ToolModeSet { + DISABLED: string; + EXTERNAL_BRIDGE: string; + INTERNAL_ALLOWLIST: string; +} + +export interface InternalToolContext { + allowedToolNames: string[]; + requestedAllowlist: string[] | null; + deniedRequestedTools: string[]; + resolutionPath: string; + resultingMode: string; + metricsEnabled: boolean; +} + +export interface ExternalToolContext { + registry: ExternalToolEntry[]; + exposure: { tools: ExternalToolEntry[]; toolChoice: { mode: string; requiredTool: string | null }; prompt: string; reminder: string }; + toolChoice: { mode: string; requiredTool: string | null }; + prompt: string; + reminder: string; +} + +export interface RequestToolContext { + mode: string; + external: ExternalToolContext; + internal: InternalToolContext; +} + +export interface ForcedToolCallRequesterOptions { + mode: unknown; + sessionId: string; + systemWithGuard: string | undefined; + requiredTool: string | undefined; + providerID: string; + modelID: string; + toolOverrides: Record | null; + requestTimeoutMs: number; + forbidThinkBlock?: boolean; +} + +export interface CollectorHandle { + extractFromParts: (parts: unknown) => { content: string; reasoning: string; toolParts: unknown[] }; + promptWithTimeout: (promptParams: unknown, timeoutMs: unknown) => Promise; + pollForAssistantResponse: ( + sessionId: string, + timeoutMs: number, + intervalMs?: number + ) => Promise<{ content: string; reasoning: string; error: unknown }>; + collectFromEvents: ( + sessionId: string, + timeoutMs: number, + onDelta?: ((delta: string, isReasoning?: boolean) => void) | null, + firstDeltaTimeoutMs?: number | null, + idleTimeoutMs?: number | null + ) => Promise>; +} + +export interface InternalToolMetrics { + externalBridgeRequests: number; + internalAllowlistRequests: number; + disabledRequests: number; + discoveryFailures: number; + fallbackToDisabled: number; +} + +/** Per-instance context threaded through route registrars (same names as closures). */ +export interface AppContext { + client: ProxyClient; + config: ProxyConfig; + API_KEY: string; + OPENCODE_SERVER_URL: string; + OPENCODE_SERVER_PASSWORD: string; + REQUEST_TIMEOUT_MS: number; + DEBUG: boolean; + DISABLE_TOOLS: boolean; + INTERNAL_WEB_FETCH_ENABLED: boolean; + INTERNAL_ALLOWED_TOOLS: string[]; + INTERNAL_TOOL_METRICS_ENABLED: boolean; + INTERNAL_TOOL_DISCOVERY_FIXTURE: string[]; + HEALTH_DETAILS_ENABLED: boolean; + HEALTH_DETAILS_REQUIRE_AUTH: boolean; + METRICS_ENABLED: boolean; + METRICS_REQUIRE_AUTH: boolean; + PROMPT_MODE: string; + OMIT_SYSTEM_PROMPT: boolean; + AUTO_CLEANUP_CONVERSATIONS: boolean; + CLEANUP_INTERVAL_MS: number; + CLEANUP_MAX_AGE_MS: number; + OPENCODE_HOME_BASE: string | null; + maxRetries: number; + maxAttempts: number; + getProvidersList: () => Promise; + buildModelsList: (providersList: unknown) => ModelInfo[]; + normalizeModelID: (modelID: unknown) => unknown; + resolveRequestedModel: (requestedModel: unknown) => Promise; + logDebug: (...args: unknown[]) => void; + responseState: Map; + getResponseState: (responseId: unknown) => ResponseStateEntry | null; + storeResponseState: (responseId: unknown, sessionId: unknown, model: unknown) => void; + sweepResponseState: () => Promise; + TOOL_MODE: ToolModeSet; + TOOL_GUARD_MESSAGE: string; + EXTERNAL_TOOL_GUARD_MESSAGE: string; + normalizeConfiguredToolNames: (entries?: unknown) => string[]; + getEffectiveInternalAllowedTools: () => string[]; + SERVER_INTERNAL_ALLOWED_TOOL_NAMES: string[]; + buildInternalAllowlistPrompt: (allowedToolNames?: unknown) => string; + buildSystemPrompt: (systemMsg: unknown, reasoningEffort?: unknown, toolMode?: unknown, internalAllowedTools?: unknown) => string | undefined; + normalizeReasoningEffort: (value: unknown, fallback?: unknown) => string | null; + stripFunctionCalls: (text: unknown, trim?: boolean) => unknown; + normalizeTextContent: (content: unknown) => string; + normalizeToolArguments: (args: unknown) => string; + normalizeToolResultContent: (content: unknown) => string; + createExternalToolContext: (tools: unknown, toolChoice: unknown) => ExternalToolContext; + resolveToolMode: (tools?: unknown, effectiveInternalAllowlist?: unknown) => string; + createRequestToolContext: (tools: unknown, toolChoice: unknown, requestOpencodeConfig?: unknown) => RequestToolContext; + finalizeValidatedToolCalls: ( + parsedToolCalls: unknown, + registry: unknown + ) => { validCalls: ValidatedToolCall[]; invalidCalls: Array<{ call: unknown; validation: unknown }> }; + toPublicToolCalls: (toolCalls: unknown) => FinalToolCall[]; + createForcedToolCallRequester: (options: ForcedToolCallRequesterOptions) => () => Promise | null>; + TOOL_IDS_CACHE_MS: number; + internalToolMetrics: InternalToolMetrics; + logInternalToolEvent: (event: unknown, details?: unknown) => void; + trackToolMode: (toolMode: unknown, details?: unknown) => void; + getBackendToolIds: () => Promise; + buildDisabledToolOverrides: (ids?: unknown) => Record; + normalizeBackendToolIds: (ids?: unknown) => string[]; + matchesAllowedToolName: (toolId: unknown, allowedToolName: unknown) => boolean; + resolveInternalAllowedToolIds: ( + ids?: unknown, + allowedToolNames?: unknown + ) => { normalizedIds: string[]; normalizedAllowedNames: string[]; matchedToolIds: string[]; unmatchedAllowedNames: string[] }; + getDisabledToolOverrides: () => Promise | null>; + getToolOverridesForMode: (toolMode: unknown, internalContext?: unknown) => Promise | null>; + getCleanupRoots: () => string[]; + cleanupConversationFiles: () => Promise<{ removed: number; scanned: number }>; + promptWithTimeout: CollectorHandle['promptWithTimeout']; + collectFromEvents: CollectorHandle['collectFromEvents']; + pollForAssistantResponse: CollectorHandle['pollForAssistantResponse']; + extractFromParts: CollectorHandle['extractFromParts']; + getCachedToolIds: () => string[] | null; + getCachedToolIdsAt: () => number; +} + +export interface CreateAppResult { + app: import('express').Application; + client: ProxyClient; +} + +export interface StartProxyResult { + server: import('node:http').Server; + killBackend: () => void; +} diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..ec403c6 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,37 @@ +/** Normalized upstream error (Error instance with provider fields). */ + +export interface NormalizedUpstreamError extends Error { + statusCode?: number; + code?: string; + type?: string; + data?: unknown; + availableModels?: unknown; + responseHeaders?: Record | null; + isRetryable?: boolean; + cause?: unknown; +} + +/** Raw backend failure shape (plain object, never an Error instance). */ +export interface RawBackendErrorLike { + message?: unknown; + name?: unknown; + code?: unknown; + type?: unknown; + statusCode?: unknown; + isRetryable?: unknown; + responseHeaders?: unknown; + data?: unknown; + [key: string]: unknown; +} + +export interface TransformedUpstreamErrorBody { + message: string; + type: string; + code?: string; + available_models?: unknown; +} + +export interface TransformedUpstreamError { + statusCode: number; + error: TransformedUpstreamErrorBody; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..eb4d331 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,32 @@ +export type { ProxyConfig, ProxyConfigOptions, DisableToolsOptions } from './config.js'; +export type { + NormalizedUpstreamError, + RawBackendErrorLike, + TransformedUpstreamError, + TransformedUpstreamErrorBody, +} from './errors.js'; +export type { BackendState, SessionInfo, ResponseStateEntry, OpencodeResolveResult } from './backend.js'; +export type { + ProxyClient, + SessionNamespace, + ConfigNamespace, + ToolNamespace, + EventNamespace, + ProviderInfo, + ModelInfo, + ResolvedModel, +} from './client.js'; +export type { ChatTool, ChatToolChoice, ChatMessage, ChatMessageContentPart, PromptPart, PromptParams } from './chat.js'; +export type { AnthropicRequestBody, ResponsesRequestBody, ChatCompletionsRequestBody } from './anthropic.js'; +export type { + AppContext, + CollectorHandle, + CreateAppResult, + StartProxyResult, + ExternalToolContext, + InternalToolContext, + RequestToolContext, + ForcedToolCallRequesterOptions, + InternalToolMetrics, + ToolModeSet, +} from './context.js'; diff --git a/tests/app.test.js b/tests/app.test.js index 577d37d..53e031a 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -1586,15 +1586,23 @@ describe('Proxy OpenAI API', () => { }); expect(res.statusCode).toEqual(200); + expect(res.body.object).toEqual('response'); + expect(res.body.status).toEqual('completed'); + expect(res.body.created_at).toEqual(expect.any(Number)); + expect(res.body.created).toEqual(res.body.created_at); + expect(res.body.error).toBeNull(); + expect(res.body.incomplete_details).toBeNull(); expect(res.body.output).toEqual([ { + id: expect.any(String), type: 'message', role: 'assistant', status: 'completed', content: [ { type: 'output_text', - text: 'Fetched via internal allowlist tools' + text: 'Fetched via internal allowlist tools', + annotations: [] } ] } @@ -1808,15 +1816,22 @@ describe('Proxy OpenAI API', () => { expect(res.statusCode).toEqual(200); expect(res.body.object).toEqual('response'); + expect(res.body.status).toEqual('completed'); + expect(res.body.created_at).toEqual(expect.any(Number)); + expect(res.body.created).toEqual(res.body.created_at); + expect(res.body.error).toBeNull(); + expect(res.body.incomplete_details).toBeNull(); expect(res.body.output).toEqual([ { + id: expect.any(String), type: 'message', role: 'assistant', status: 'completed', content: [ { type: 'output_text', - text: 'The weather in Tokyo is 22°C and sunny.' + text: 'The weather in Tokyo is 22°C and sunny.', + annotations: [] } ] } @@ -2163,7 +2178,7 @@ describe('Proxy OpenAI API', () => { properties: { info: { sessionID: sessionId, - error: { name: 'CreditsError', data: { message: '401: {"message":"Insufficient balance. Manage your billing here: https://example.ai","type":"CreditsError","param":"","code":null}' } } + error: { name: 'CreditsError', data: { message: '401: {"message":"Insufficient balance. Manage your billing here: https://example.ai","type":"CreditsError","param":"","code":null}', responseHeaders: { 'retry-after-ms': '10' } } } } } }]; @@ -2199,7 +2214,7 @@ describe('Proxy OpenAI API', () => { .mockResolvedValueOnce([{ info: { role: 'assistant', - error: { name: 'CreditsError', data: { message: '401: Insufficient balance' } } + error: { name: 'CreditsError', data: { message: '401: Insufficient balance', responseHeaders: { 'retry-after-ms': '10' } } } }, parts: [] }]) diff --git a/tests/backend-error-surface.test.js b/tests/backend-error-surface.test.js new file mode 100644 index 0000000..7b3c8d0 --- /dev/null +++ b/tests/backend-error-surface.test.js @@ -0,0 +1,265 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +// Regression tests for the LiteLLM 500 case: +// backend `info.error` is a plain object ({name, data:{message}}), never an +// Error. Responses route used to `throw` it raw, and transformUpstreamError +// mangled it into 500 {"message":"Internal server error","code":"Object"}. + +const BACKEND_CREDITS_ERROR = { + name: 'CreditsError', + data: { + message: '401: {"message":"Insufficient balance, please recharge","type":"CreditsError"}', + // Fast retry hint so the suite never waits out real backoff delays. + responseHeaders: { 'retry-after-ms': '10' } + } +}; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' }, 'muse-spark-1.3-contributor-free': { name: 'Muse Spark' } } }] } + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: [] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'err-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [] } })), + sessionMessages: jest.fn(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: BACKEND_CREDITS_ERROR }, parts: [] } + ])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ + stream: (async function* () { + yield { + type: 'message.updated', + properties: { info: { sessionID: 'err-session', finish: 'stop', error: BACKEND_CREDITS_ERROR } } + }; + })() + })) +}; + +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 } = await import('../src/proxy.js'); + +describe('POST /v1/responses backend plain-object error', () => { + let app; + beforeEach(() => { + jest.clearAllMocks(); + app = createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false + }).app; + }); + + test('non-stream surfaces backend message as 402, never 500/Object', async () => { + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'muse-spark-1.3-contributor-free', input: 'test from litellm' }); + expect(res.statusCode).toBe(402); + expect(JSON.stringify(res.body)).toContain('Insufficient balance'); + expect(JSON.stringify(res.body)).not.toContain('"Object"'); + expect(res.body.error?.message).not.toBe('Internal server error'); + }); + + test('unknown backend error keeps message, no Object code', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([ + { info: { role: 'assistant', finish: 'stop', error: { name: 'UnknownError', data: { message: 'boom-backend' } } }, parts: [] } + ]); + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(res.statusCode).toBe(500); + expect(JSON.stringify(res.body)).toContain('boom-backend'); + expect(JSON.stringify(res.body)).not.toContain('"Object"'); + }); + + test('stream surfaces backend message in SSE failure, no Object code', async () => { + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi', stream: true }); + expect(res.text).toContain('Insufficient balance'); + expect(res.text).not.toContain('"Object"'); + }); + + test('messages non-stream uses transformed status/type, not hardcoded 502', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(402); + expect(res.body.error?.type).toBe('insufficient_quota'); + expect(JSON.stringify(res.body)).toContain('Insufficient balance'); + expect(JSON.stringify(res.body)).not.toContain('"Object"'); + }); + + test('responses non-stream retries transient failure then succeeds', async () => { + sdkMocks.sessionMessages + .mockResolvedValueOnce([ + { info: { role: 'assistant', finish: 'stop', error: BACKEND_CREDITS_ERROR }, parts: [] } + ]) + .mockResolvedValue([ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'recovered' }] } + ]); + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(res.statusCode).toBe(200); + expect(JSON.stringify(res.body)).toContain('recovered'); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(2); + }); + + describe('RETRY_MAX_RETRIES wiring (chat non-stream)', () => { + const fastTransientError = { + name: 'CreditsError', + data: { + message: '429: {"message":"Too many requests","type":"CreditsError"}', + responseHeaders: { 'retry-after-ms': '10' } + } + }; + const buildApp = (retries) => createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false, + RETRY_MAX_RETRIES: retries + }).app; + + test('default retries transient errors (1 initial + retries)', async () => { + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: fastTransientError }, parts: [] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + // Chat surfaces backend failures as 502 with the real message (existing behavior). + expect(res.statusCode).toBe(502); + expect(JSON.stringify(res.body)).toContain('Too many requests'); + // 1 initial session + 3 retries on fresh sessions (deterministic mock) + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(4); + }); + + test('RETRY_MAX_RETRIES=0 disables retry (single attempt)', async () => { + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: fastTransientError }, parts: [] } + ])); + const chatApp = buildApp(0); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(502); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + }); + + test('RETRY_MAX_RETRIES clamps to upstream ceiling of 5', async () => { + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: fastTransientError }, parts: [] } + ])); + const chatApp = buildApp(99); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(502); + // 1 initial + exactly 5 retries after clamping 99 -> 5 + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(6); + }); + }); + + describe('prompt-layer throw + genuine auth (chat non-stream)', () => { + const buildApp = (retries) => createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false, + RETRY_MAX_RETRIES: retries + }).app; + + test('prompt throw with transient signature retries then succeeds', async () => { + const throwErr = new Error('fetch failed: socket hang up'); + throwErr.responseHeaders = { 'retry-after-ms': '10' }; + sdkMocks.sessionPrompt + .mockRejectedValueOnce(throwErr) + .mockResolvedValue({ data: { parts: [] } }); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'recovered-prompt' }] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + expect(JSON.stringify(res.body)).toContain('recovered-prompt'); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(2); + }); + + test('genuine auth failure surfaces immediately without retry', async () => { + sdkMocks.sessionPrompt.mockResolvedValue({ data: { parts: [] } }); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: { name: 'ProviderAuthError', data: { message: 'Invalid API key for provider' } } }, parts: [] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(502); + expect(JSON.stringify(res.body)).toContain('Invalid API key'); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + }); + + test('genuine auth failure with 401 status still skips retry', async () => { + sdkMocks.sessionPrompt.mockResolvedValue({ data: { parts: [] } }); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: { name: 'ProviderAuthError', data: { message: '401: Invalid API key', status: 401 } } }, parts: [] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + // Without the early-false, status 401 alone would trigger retries. + expect(res.statusCode).toBe(502); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + }); + + test.each([ + ['Authentication error', '401: Authentication error'], + ['expired key', 'API key expired'], + ['underscore code', 'invalid_api_key'] + ])('auth variant skips retry: %s', async (_label, msg) => { + sdkMocks.sessionPrompt.mockResolvedValue({ data: { parts: [] } }); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: { name: 'ProviderAuthError', data: { message: msg } } }, parts: [] } + ])); + const chatApp = buildApp(undefined); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(502); + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + }); + }); +}); diff --git a/tests/env-alias.test.js b/tests/env-alias.test.js new file mode 100644 index 0000000..699198c --- /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.ts 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.ts cannot be imported directly (it starts the server), so mirror its +// exact resolution call here to pin the env > file > default contract. +// index.ts 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/messages-anthropic.test.js b/tests/messages-anthropic.test.js new file mode 100644 index 0000000..baea2b0 --- /dev/null +++ b/tests/messages-anthropic.test.js @@ -0,0 +1,147 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { + providers: [{ id: 'opencode', models: { 'muse-spark-1.3-contributor-free': { name: 'Muse Spark' }, 'kimi-k2.5': { name: 'Kimi' } } }] + } + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: [] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'msg-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'Hello!' }] } })), + sessionMessages: jest.fn(async () => ([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Hello!' }] }])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ + stream: (async function* () { + yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: 'Hello' } }; + yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: '!' } }; + yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; + })() + })) +}; + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const res = { statusCode: 200, headers: { 'content-type': 'image/png' }, on: jest.fn((event, handler) => { if (event === 'data') handler(Buffer.from('x')); if (event === 'end') handler(); }) }; + callback(res); + return { on: jest.fn(), destroy: jest.fn() }; + }) + } +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + // checkHealth path -> 200; image downloads -> empty + if (String(url).includes('/health')) { + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + callback(res); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + } + const response = { statusCode: 200, headers: {}, on: jest.fn() }; + callback(response); + 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 } = await import('../src/proxy.js'); +import { + validateMessagesRequest, + anthropicMessagesToChatMessages, + anthropicToolsToChatTools, + anthropicToolChoiceToChat, + mapFinishToStopReason +} from '../src/converters/anthropic.js'; + +describe('Anthropic /v1/messages converters', () => { + test('validation requires model/max_tokens/messages', () => { + expect(validateMessagesRequest({})).not.toBeNull(); + expect(validateMessagesRequest({ model: 'm', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] })).toBeNull(); + expect(validateMessagesRequest({ model: 'm', max_tokens: 10, messages: [{ role: 'assistant', content: 'hi' }] }).statusCode).toBe(400); + }); + test('tool_use/tool_result round-trip preserves toolu_ id', () => { + const chat = anthropicMessagesToChatMessages([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_abc', name: 'weather_lookup', input: { city: 'Tokyo' } }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_abc', content: 'sunny' }] } + ]); + const flat = JSON.stringify(chat); + expect(flat).toContain('toolu_abc'); + expect(flat).toContain('tool_call_id'); + }); + test('tools input_schema and tool_choice mapping', () => { + expect(anthropicToolsToChatTools([{ name: 'w', input_schema: { type: 'object' } }])[0].function.parameters).toEqual({ type: 'object' }); + expect(anthropicToolChoiceToChat({ type: 'any' })).toBe('required'); + expect(anthropicToolChoiceToChat({ type: 'tool', name: 'w' })).toEqual({ type: 'function', function: { name: 'w' } }); + expect(mapFinishToStopReason('stop', true)).toBe('tool_use'); + }); +}); + +describe('POST /v1/messages', () => { + let app; + beforeEach(() => { + jest.clearAllMocks(); + const config = { PORT: 10000, API_KEY: 'test-key', OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false }; + app = createApp(config).app; + }); + test('non-stream returns Anthropic message shape', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + expect(res.body.type).toBe('message'); + expect(res.body.role).toBe('assistant'); + expect(Array.isArray(res.body.content)).toBe(true); + expect(res.body.stop_reason).toBe('end_turn'); + expect(res.body.usage.input_tokens).toBeGreaterThanOrEqual(0); + }); + test('401 without key', async () => { + const res = await request(app).post('/v1/messages') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(401); + expect(res.body.type).toBe('error'); + }); + test('tiny max_tokens does not fake max_tokens stop_reason (usage is estimated)', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + // Backend does not surface truncation; stop_reason comes from tool calls only. + expect(res.body.stop_reason).toBe('end_turn'); + }); + test('missing max_tokens -> 400', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(400); + }); + test('x-api-key auth works', async () => { + const res = await request(app).post('/v1/messages') + .set('x-api-key', 'test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + }); + test('stream emits message_start/delta/stop without [DONE]', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .set('Accept', 'text/event-stream') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 50, stream: true, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('event: message_start'); + expect(res.text).toContain('event: message_stop'); + expect(res.text).not.toContain('[DONE]'); + }); +}); diff --git a/tests/retry-policy.test.js b/tests/retry-policy.test.js new file mode 100644 index 0000000..055e980 --- /dev/null +++ b/tests/retry-policy.test.js @@ -0,0 +1,81 @@ +import { + resolveMaxRetries, + parseRetryAfterMs, + computeRetryDelay, + DEFAULT_MAX_RETRIES, + ABSOLUTE_MAX_RETRIES, + RETRY_INITIAL_DELAY_MS, + MAX_DELAY_NO_HEADERS_MS, + MAX_HEADER_DELAY_MS +} from '../src/retry/policy.js'; + +describe('resolveMaxRetries', () => { + test('defaults to 3 for missing/invalid values', () => { + expect(resolveMaxRetries(undefined)).toBe(DEFAULT_MAX_RETRIES); + expect(resolveMaxRetries(null)).toBe(DEFAULT_MAX_RETRIES); + expect(resolveMaxRetries('')).toBe(DEFAULT_MAX_RETRIES); + expect(resolveMaxRetries('abc')).toBe(DEFAULT_MAX_RETRIES); + expect(resolveMaxRetries(NaN)).toBe(DEFAULT_MAX_RETRIES); + expect(DEFAULT_MAX_RETRIES).toBe(3); + }); + test('accepts 0-5, clamps beyond the upstream ceiling', () => { + expect(resolveMaxRetries(0)).toBe(0); + expect(resolveMaxRetries(1)).toBe(1); + expect(resolveMaxRetries(5)).toBe(5); + expect(resolveMaxRetries(99)).toBe(ABSOLUTE_MAX_RETRIES); + expect(resolveMaxRetries(-2)).toBe(0); + expect(resolveMaxRetries('4')).toBe(4); + expect(resolveMaxRetries(2.9)).toBe(2); + expect(ABSOLUTE_MAX_RETRIES).toBe(5); + }); +}); + +describe('parseRetryAfterMs', () => { + test('returns null for missing/invalid headers', () => { + expect(parseRetryAfterMs(null)).toBeNull(); + expect(parseRetryAfterMs({})).toBeNull(); + expect(parseRetryAfterMs({ 'retry-after': 'not-a-number' })).toBeNull(); + expect(parseRetryAfterMs({ 'retry-after-ms': 'abc' })).toBeNull(); + }); + test('retry-after-ms wins (upstream priority)', () => { + expect(parseRetryAfterMs({ 'retry-after-ms': '1500', 'retry-after': '30' })).toBe(1500); + }); + test('retry-after seconds convert to ms', () => { + expect(parseRetryAfterMs({ 'retry-after': '30' })).toBe(30000); + }); + test('retry-after HTTP date uses remaining time, past dates rejected', () => { + const future = new Date(Date.now() + 20000).toUTCString(); + const d = parseRetryAfterMs({ 'retry-after': future }); + expect(d).toBeGreaterThanOrEqual(19000); + expect(d).toBeLessThanOrEqual(20000); + const past = new Date(Date.now() - 5000).toUTCString(); + expect(parseRetryAfterMs({ 'retry-after': past })).toBeNull(); + }); + test('header names are case-insensitive', () => { + expect(parseRetryAfterMs({ 'Retry-After': '2' })).toBe(2000); + }); +}); + +describe('computeRetryDelay', () => { + test('exponential sequence without headers (random=0)', () => { + expect(computeRetryDelay(1, null, 0)).toBe(RETRY_INITIAL_DELAY_MS); + expect(computeRetryDelay(2, null, 0)).toBe(4000); + expect(computeRetryDelay(3, null, 0)).toBe(8000); + expect(computeRetryDelay(4, null, 0)).toBe(16000); + }); + test('caps at 30s without headers', () => { + expect(computeRetryDelay(10, null, 0)).toBe(MAX_DELAY_NO_HEADERS_MS); + expect(computeRetryDelay(10, null, 1)).toBe(MAX_DELAY_NO_HEADERS_MS); + }); + test('jitter stays within +25%', () => { + expect(computeRetryDelay(1, null, 1)).toBe(2500); + const d = computeRetryDelay(2, null, 0.5); + expect(d).toBeGreaterThan(4000); + expect(d).toBeLessThanOrEqual(5000); + }); + test('honors retry-after headers, clamped to gateway max', () => { + expect(computeRetryDelay(1, { responseHeaders: { 'retry-after-ms': '1500' } }, 0)).toBe(1500); + expect(computeRetryDelay(1, { data: { responseHeaders: { 'retry-after': '5' } } }, 0)).toBe(5000); + expect(computeRetryDelay(1, { responseHeaders: { 'retry-after': '700' } }, 0)).toBe(MAX_HEADER_DELAY_MS); + }); +}); 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); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..10e9014 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowJs": true, + "checkJs": false, + "strict": true, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "outDir": "dist", + "rootDir": ".", + "declaration": false, + "sourceMap": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["index.ts", "src/**/*"], + "exclude": ["node_modules", "dist", "tests", "custom-bin", "docs"] +}