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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e58ac3..21ab651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: node-version: 20 cache: npm - run: npm ci + - run: npm run typecheck + - run: npm run build - run: npm test -- --runInBand shellcheck: @@ -60,5 +62,27 @@ jobs: 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/.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/CONTRIBUTING.md b/CONTRIBUTING.md index 51f7915..b04f7c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,10 +69,15 @@ 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 97b7025..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 diff --git a/README.md b/README.md index 8039bf4..76a8289 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ git clone https://github.com/samson910022/OpenCode2API.git cd OpenCode2API npm install cp config.json.example config.json +npm run build npm start ``` @@ -311,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 @@ -318,6 +331,8 @@ npm test -- --runInBand docker compose up -d --build ``` +> TypeScript 源码:`index.ts` + `src/**/*.ts`;构建产物:`dist/`(`dist/index.js` 为运行时入口,本地 `dist/` 不进 git/docker context)。 + --- ## 📄 许可证 diff --git a/docs/development.md b/docs/development.md index c7c4f42..10120d7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 1fbc80b..bcd595d 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -92,6 +92,8 @@ docker run -d \ > 后端 `10001` 仅容器内部使用,不要对外发布(compose 默认也只映射代理端口;改端口用 `OPENCODE_PROXY_PORT`,如 `8090`)。 +> 注意:镜像构建时**不会**把本地 `config.json` 烘焙进去(多阶段构建只复制 `dist/` 与依赖,避免把本地密钥带进镜像)。容器内配置请用环境变量,或 `-v ./config.json:/home/node/project/config.json:ro` 挂载(代理会按 `dist/` 同层 → 项目根 → 工作目录顺序查找)。 + ### 多架构 `ghcr.io/samson910022/opencode2api:latest` 同时含 `linux/amd64` 与 `linux/arm64`,ARM 机器可直接 pull,无需本地 build(仅自带 `custom-bin/opencode` 二进制时才需按架构重建)。 diff --git a/docs/getting-started.md b/docs/getting-started.md index 1d83eb8..cdc6999 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -44,7 +44,8 @@ | 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` | --- diff --git a/entrypoint.sh b/entrypoint.sh index 91641c5..0767291 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -72,7 +72,7 @@ if [[ "$1" == "opencode" && "$2" == "serve" ]]; then 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 ebfa2a8..0000000 --- a/index.js +++ /dev/null @@ -1,198 +0,0 @@ -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'; - -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 || process.env.OPENCODE_PROXY_BIND_HOST || fileConfig.BIND_HOST || defaultConfig.BIND_HOST, - // Single contract via resolveDisableTools: env canonical > env legacy - // alias > file > default. Each source is normalized first so invalid - // values ('', 'garbage') fall through instead of blocking lower sources. - DISABLE_TOOLS: resolveDisableTools( - { - DISABLE_TOOLS: process.env.OPENCODE_DISABLE_TOOLS, - disableTools: process.env.DISABLE_TOOLS, - }, - normalizeBool(fileConfig.DISABLE_TOOLS) ?? defaultConfig.DISABLE_TOOLS, - ), - EXTERNAL_TOOLS_MODE: process.env.OPENCODE_EXTERNAL_TOOLS_MODE || fileConfig.EXTERNAL_TOOLS_MODE || defaultConfig.EXTERNAL_TOOLS_MODE, - EXTERNAL_TOOLS_CONFLICT_POLICY: process.env.OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY || fileConfig.EXTERNAL_TOOLS_CONFLICT_POLICY || defaultConfig.EXTERNAL_TOOLS_CONFLICT_POLICY, - INTERNAL_WEB_FETCH_ENABLED: parseBool(process.env.OPENCODE_INTERNAL_WEB_FETCH_ENABLED, parseBool(fileConfig.INTERNAL_WEB_FETCH_ENABLED, defaultConfig.INTERNAL_WEB_FETCH_ENABLED)), - 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, - 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 || 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(` - Max Retries: ${resolveMaxRetries(finalConfig.RETRY_MAX_RETRIES)} (raw: ${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) => { - 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..7970a19 --- /dev/null +++ b/index.ts @@ -0,0 +1,300 @@ +// 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)) { + // NOTE: `||` (not `??`) matches the original JS verbatim: falsy entries + // (0/false) stringify to '' and are filtered out instead of becoming + // "0"/"false" allowlist entries. + return [...new Set((value as unknown[]).map((entry) => String((entry as unknown) || '').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. +// NOTE: the lookup covers both layouts because the compiled entry moves: +// - dev (`tsx index.ts` / repo root): /config.json +// - prod (`node dist/index.js`): /dist/config.json (sibling) and +// /config.json (parent) — Docker mounts config at WORKDIR root, +// and .dockerignore keeps local dist/ out of the image. +const configCandidates = [ + path.join(__dirname, 'config.json'), + path.join(__dirname, '..', 'config.json'), + path.join(process.cwd(), 'config.json'), +]; +const configPath = configCandidates.find((p) => fs.existsSync(p)); +let fileConfig: Record = {}; + +if (configPath) { + try { + const content = fs.readFileSync(configPath, 'utf8'); + fileConfig = JSON.parse(content) as Record; + console.log(`[Config] Loaded from ${configPath}`); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[Config] Error parsing ${configPath}:`, 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]; + // NOTE: `0` falls through to fallback to match the original + // `fileConfig.X || default` semantics verbatim. + if (typeof v === 'number' && Number.isFinite(v) && v !== 0) 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 7930b4c..72d6792 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,17 @@ "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,608 +570,1914 @@ "tslib": "^2.4.0" } }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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": ">=18" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "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" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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" + } + }, + "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, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "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, + "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" + "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/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": { + "@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" + }, + "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/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": ">=12" + "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/@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/@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, - "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" - }, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=10" } }, - "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/@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": ">=8" + "node": ">=10" } }, - "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/@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, - "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": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" } }, - "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/@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, - "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": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], "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": ">=10" } }, - "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/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", "dev": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } + "license": "Apache-2.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==", + "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/fake-timers": "30.3.0", - "@jest/types": "30.3.0", - "@types/node": "*", - "jest-mock": "30.3.0" + "@jest/create-cache-key-function": "^30.0.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" } }, - "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/@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": { - "expect": "30.3.0", - "jest-snapshot": "30.3.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@swc/counter": "^0.1.3" } }, - "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/@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": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "tslib": "^2.4.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==", + "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": { - "@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" + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" } }, - "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/@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, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "dependencies": { + "@babel/types": "^7.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==", + "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": { - "@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" + "@babel/parser": "^7.1.0", + "@babel/types": "^7.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==", + "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": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "@babel/types": "^7.28.2" } }, - "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==", + "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": { - "@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 - } + "@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/@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/@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.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/@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/@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, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "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-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, - "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-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": ">=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-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, + "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-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": { - "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-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, - "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-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, + "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-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/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-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/types": "^7.0.0" + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "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-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/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-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": { - "@babel/types": "^7.28.2" + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "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-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-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-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": { - "@types/istanbul-lib-report": "*" + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "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-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": { - "undici-types": "~7.18.0" + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "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-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, - "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/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2243,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", @@ -3586,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", @@ -4776,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", @@ -4809,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", diff --git a/package.json b/package.json index 7e3ecef..b087a3d 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,13 @@ "name": "opencode2api", "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" @@ -41,7 +44,16 @@ "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..cafb773 --- /dev/null +++ b/src/backend/manager.ts @@ -0,0 +1,523 @@ +// 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 { + // NOTE: falsy check (not typeof) matches the original JS verbatim: truthy + // non-strings (e.g. numeric 123 from config.json) are coerced via String(). + if (!password) return undefined; + const token = Buffer.from(`opencode:${String(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..3b8932f --- /dev/null +++ b/src/config/proxy-config.ts @@ -0,0 +1,223 @@ +// 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'; + // NOTE: `||` (not `??`) is intentional here to match the original JS merge + // semantics verbatim: falsy values ('', 0) fall through to the next source + // instead of becoming Number('')=0. The finite/positive guard below then + // clamps anything non-sane back to the hardcoded default. + 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.js b/src/converters/anthropic.js deleted file mode 100644 index 853e82a..0000000 --- a/src/converters/anthropic.js +++ /dev/null @@ -1,218 +0,0 @@ -/** - * 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 function sanitizeClaudeToolId(id) { - const s = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '_'); - if (s) return s; - return generateClaudeToolCallId(); -} - -export function generateClaudeToolCallId() { - 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, message, statusCode = 400) { - return { statusCode, body: { type: 'error', error: { type, message } } }; -} - -export function validateMessagesRequest(body = {}) { - if (!body || typeof body !== 'object') return anthropicError('invalid_request_error', 'request body must be an object'); - if (!body.model || typeof body.model !== 'string') return anthropicError('invalid_request_error', 'model is required'); - if (body.max_tokens === undefined || body.max_tokens === null) return anthropicError('invalid_request_error', 'max_tokens is required'); - if (typeof body.max_tokens !== 'number' || body.max_tokens <= 0) return anthropicError('invalid_request_error', 'max_tokens must be a positive number'); - if (!Array.isArray(body.messages) || body.messages.length === 0) return anthropicError('invalid_request_error', 'messages array is required'); - if (body.messages[0]?.role !== 'user') return anthropicError('invalid_request_error', 'first message must use role "user"'); - return null; -} - -function textOfBlock(block) { - if (!block) return ''; - if (typeof block === 'string') return block; - if (block.type === 'text') return block.text || ''; - return ''; -} - -export function extractSystemText(system) { - 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) { - 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 = []) { - const chatMessages = []; - for (const m of anthropicMessages) { - 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 = []; - const toolCalls = []; - const ordered = []; - const flushText = () => { - if (textParts.length) { - ordered.push({ role, content: textParts.join('\n\n') }); - textParts.length = 0; - } - }; - for (const block of content) { - if (!block || typeof block !== 'object') continue; - if (block.type === 'text') { - if (block.text) textParts.push(block.text); - } else if (block.type === 'image') { - const src = block.source || {}; - if (src.type === 'base64' && src.data) { - const mime = src.media_type || 'image/png'; - // 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 } }] }); - } - } else if (block.type === 'tool_use') { - toolCalls.push({ - id: block.id || generateClaudeToolCallId(), - type: 'function', - function: { name: block.name, arguments: normalizeToolArguments(block.input) } - }); - } else if (block.type === 'tool_result') { - const inner = Array.isArray(block.content) - ? block.content.map(textOfBlock).filter(Boolean).join('\n') - : (typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '')); - const prefix = block.is_error ? 'ERROR: ' : ''; - flushText(); - ordered.push({ - role: 'tool', - tool_call_id: block.tool_use_id, - 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. - if (block.thinking) textParts.push(block.thinking); - } - } - 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) { - if (!Array.isArray(tools)) return []; - return tools - .filter((t) => t && typeof t.name === 'string') - .map((t) => ({ - type: 'function', - function: { - name: t.name, - description: t.description || '', - parameters: t.input_schema || { type: 'object', properties: {} } - } - })); -} - -export function anthropicToolChoiceToChat(toolChoice) { - if (!toolChoice) return undefined; - if (typeof toolChoice === 'string') return toolChoice; - const t = String(toolChoice.type || '').toLowerCase(); - if (t === 'auto') return 'auto'; - if (t === 'none') return 'none'; - if (t === 'any') return 'required'; - if (t === 'tool' && toolChoice.name) return { type: 'function', function: { name: toolChoice.name } }; - if (t === 'tool') return 'required'; - return undefined; -} - -export function anthropicThinkingToReasoningEffort(thinking) { - if (!thinking || typeof thinking !== 'object') return null; - if (thinking.type === 'disabled') return 'none'; - if (thinking.type === 'enabled') { - const budget = typeof thinking.budget_tokens === 'number' ? thinking.budget_tokens : 0; - if (budget >= 24000) return 'high'; - if (budget >= 8000) return 'medium'; - return 'low'; - } - return null; -} - -export function mapFinishToStopReason(finish, hasToolCalls) { - 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 }) { - const content = []; - if (reasoning) content.push({ type: 'thinking', thinking: reasoning, signature: '' }); - if (text) content.push({ type: 'text', text }); - for (const tc of toolCalls || []) { - let input = {}; - try { - input = JSON.parse(tc.function?.arguments || '{}'); - } 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, payload) { - return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; -} - -export function estimateTokens(text) { - return Math.ceil(String(text || '').length / 4); -} 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 4a52968..0000000 --- a/src/proxy.js +++ /dev/null @@ -1,3999 +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 { 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'; - -/** - * 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; - // Backend failures arrive as plain objects ({name, data:{message}}); include - // name/code/type so signature matching works even without a message string. - const message = [error.message, error.data?.message, error.name, error.code, error.type] - .filter((part) => typeof part === 'string') - .join(' '); - 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; - - // Provider-marked retryable (SDK ApiError.data.isRetryable) is transient, - // whatever the status code. Explicit false defers to the checks below. - if (error.isRetryable === true || error.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 = [ - /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: ...". - let status = error.statusCode ?? error.data?.status ?? null; - if (typeof status !== 'number') { - const statusCandidates = [error.message, error.data?.message].filter((part) => typeof part === 'string'); - for (const candidate of statusCandidates) { - const prefixMatch = candidate.match(/^\s*(\d{3})\s*:/); - if (prefixMatch) { 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. - */ -function normalizeBackendError(raw) { - if (raw instanceof Error) return raw; - let message = raw?.data?.message ?? raw?.message ?? raw?.name ?? null; - if (typeof message !== 'string' || !message) { - try { - message = JSON.stringify(raw) ?? String(raw?.name || 'Upstream provider error'); - } catch { - message = String(raw?.name || 'Upstream provider error'); - } - } - let statusCode = raw?.statusCode ?? raw?.data?.statusCode ?? raw?.data?.status ?? null; - if (typeof statusCode !== 'number') { - const m = String(message).match(/^\s*(\d{3})\s*:/); - if (m) statusCode = Number(m[1]); - } - if (typeof statusCode !== 'number') { - const s = String(message).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(message); - if (typeof statusCode === 'number') err.statusCode = statusCode; - err.code = raw?.code ?? raw?.type ?? raw?.name ?? 'upstream_error'; - err.type = raw?.type ?? raw?.code ?? raw?.name ?? 'upstream_error'; - if (raw?.data !== undefined) err.data = raw.data; - // Preserve provider retry signals for the retry policy (see src/retry/policy.js): - // responseHeaders (retry-after-ms / retry-after) and isRetryable live on - // ApiError.data in the SDK type union. - const responseHeaders = raw?.responseHeaders ?? raw?.data?.responseHeaders ?? null; - if (responseHeaders && typeof responseHeaders === 'object') err.responseHeaders = responseHeaders; - if (typeof raw?.isRetryable === 'boolean') err.isRetryable = raw.isRetryable; - else if (typeof raw?.data?.isRetryable === 'boolean') err.isRetryable = raw.data.isRetryable; - err.cause = raw; - return err; -} - -/** - * 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) { - // 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); - error = normalized; - // Default fallback - let statusCode = 500; - let message = error.message || 'Internal server error'; - let type = 'internal_error'; - let code = error.code || 'upstream_error'; - - // 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; -// Retry policy (ported from upstream opencode session/retry.ts; see src/retry/policy.js). -// 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. -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 = 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 = 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 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 (accepts Authorization: Bearer and x-api-key for Anthropic SDK compat) - 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; - const apiKeyHeader = req.headers['x-api-key']; - if (!((authHeader && authHeader === `Bearer ${API_KEY}`) || (apiKeyHeader && apiKeyHeader === API_KEY))) { - if (req.path === '/v1/messages') { - return res.status(401).json({ type: 'error', error: { type: 'authentication_error', message: 'Unauthorized' } }); - } - 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 (args === undefined || args === null || args === '') return '{}'; - if (typeof args === 'string') return args; - 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, - reminder: exposure.reminder - }; - }; - - 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: '', 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: 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) { - // Single timeout implementation: withTimeout clears its timer and - // swallows late rejections (old inline race leaked timers and could - // surface unhandled rejections). Message keeps 'Request timeout' so - // transformUpstreamError maps it to 504. - return withTimeout(client.session.prompt(promptParams), timeoutMs, 'prompt backend'); - } - - const getCleanupRoots = () => { - 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 withTimeout( - resolveRequestedModel(model), REQUEST_TIMEOUT_MS, 'resolve 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 (bounded: a hung backend must 504, not stall). - const sessionRes = await withTimeout( - client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); - sessionId = sessionRes.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session'); - logDebug('Session created', { sessionId }); - - 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 withTimeout( - getToolOverridesForMode(toolMode, internalToolContext), - REQUEST_TIMEOUT_MS, 'load tool overrides'); - 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; - let lastStreamAttemptError = null; - for (let attempt = 1; attempt <= maxAttempts; 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 withTimeout( - client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); - sessionId = retrySessionRes.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); - promptParams.path.id = sessionId; - requestForcedChatToolCall = makeForcedChatToolCallRequester(); - streamedContent = ''; - streamedReasoning = ''; - rawStreamedContent = ''; - rawStreamedReasoning = ''; - streamedToolCalls.length = 0; - completionTokens = 0; - reasoningTokens = 0; - await sleep(computeRetryDelay(attempt - 1, lastStreamAttemptError)); - } - 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 < maxAttempts - && isTransientUpstreamError(attemptError) - ) { - console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, attemptError.data?.message || attemptError.message || attemptError.name || 'unknown'); - lastStreamAttemptError = attemptError; - 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 <= maxAttempts; attempt += 1) { - // The route watchdog may have already answered (502) and deleted - // the session; stop feeding the backend once the client is gone. - if (res.writableEnded || res.destroyed) { - logDebug('Client gone, stop retrying', { sessionId, attempt }); - return; - } - 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 withTimeout( - client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); - sessionId = retrySessionRes.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); - promptParams.path.id = sessionId; - 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, REQUEST_TIMEOUT_MS); - content = collected.content || ''; - reasoning = collected.reasoning || ''; - error = collected.error || null; - } catch (promptError) { - // Transport-level throw (fetch failed, ECONNRESET, prompt - // timeout): route through the same transient check as a - // poll-observed error. Non-matching throws (e.g. our own - // request timeout) fall through to surfacing below. - content = ''; - reasoning = ''; - error = promptError; - } - // Bounded retry for upstream throttling mislabeled as billing - // errors (401 CreditsError etc.); only when nothing usable was - // produced, so real failures still surface after maxAttempts. - if ( - error - && !content - && !reasoning - && attempt < maxAttempts - && isTransientUpstreamError(error) - ) { - console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, error.data?.message || error.message || error.name || 'unknown'); - continue; - } - break; - } - if (error && !content && !reasoning) { - // Prompt/poll timeout throws must propagate to the route catch - // so transformUpstreamError maps them to 504/timeout, exactly - // like the pre-try/catch behavior. - if (/^Request timeout after/.test(error.message || '')) throw error; - 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; - const apiKeyHeader = req.headers['x-api-key']; - return Boolean((authHeader && authHeader === `Bearer ${API_KEY}`) || (apiKeyHeader && apiKeyHeader === 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) => { - // Declared outside try so catch can stop the heartbeat on errors. - let responsesKeepalive = null; - let responsesResClosed = null; - const stopResponsesKeepalive = () => { - if (responsesKeepalive) { - clearInterval(responsesKeepalive); - responsesKeepalive = null; - } - }; - try { - const { - model, - 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' } }); - } - - // Stream hardening: flush SSE headers + heartbeat BEFORE the slow - // backend awaits below, so middleboxes never see a zero-byte stall - // while resolve/session/subscribe are stuck on an overloaded host. - // (Non-stream responses skip this and answer with a single JSON.) - if (stream) { - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - if (typeof res.flushHeaders === 'function') res.flushHeaders(); - responsesKeepalive = setInterval(() => { - if (!res.destroyed && !res.writableEnded) res.write(': heartbeat\n\n'); - }, 15000); - // NOTE: req 'close' fires as soon as the (small) request body is - // parsed, long before the handler finishes, so it must NOT be - // used here. res 'close' with !writableEnded is the true - // client-disconnect signal. - responsesResClosed = new Promise((resolve) => res.once('close', () => { - if (!res.writableEnded) resolve(true); - })); - } - - const resolvedModel = await withTimeout( - resolveRequestedModel(model || previousState?.model), - REQUEST_TIMEOUT_MS, 'resolve model'); - const pID = resolvedModel.providerID; - const mID = resolvedModel.modelID; - - 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 withTimeout( - client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); - 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 toolOverrides = await withTimeout( - getToolOverridesForMode(toolMode, internalToolContext), - REQUEST_TIMEOUT_MS, 'load tool overrides'); - const makeForcedResponsesToolCallRequester = () => createForcedToolCallRequester({ - mode: externalToolChoice.mode, - sessionId, - 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: 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 }) - } - }; - 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, messageId = `msg_${crypto.randomUUID()}`) => { - if (!text) return null; - return { - id: messageId, - type: 'message', - role: 'assistant', - status: 'completed', - content: [ - { - type: 'output_text', - text, - annotations: [] - } - ] - }; - }; - - if (stream) { - // Headers + heartbeat already started before the preflight - // awaits above; response.created is the first semantic event. - // The client may have gone away during the bounded preflight - // waits — stop before emitting anything further. - if (res.destroyed || res.writableEnded) { - stopResponsesKeepalive(); - try { if (!res.destroyed) res.end(); } catch {} - return; - } - const responseId = `resp_${crypto.randomUUID()}`; - const messageOutputIndex = 0; - const reasoningOutputIndex = 1; - 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`); - - 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 = []; - 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: '', annotations: [] } - }); - 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 = responsesResClosed - ? await Promise.race([safeCollect, responsesResClosed.then(() => ({ __cancelled: true }))]) - : await safeCollect; - if (collected?.__cancelled) { - stopResponsesKeepalive(); - try { if (sessionId) await client.session.delete({ path: { id: sessionId } }); } catch (e) { logDebug('Failed to cleanup cancelled responses session', { error: e.message }); } - try { if (!res.destroyed) res.end(); } catch {} - return; - } - } catch (e) { - collected = { __error: e }; - } - - if (!content && !reasoning) { - const polled = await pollForAssistantResponse(sessionId, 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 && collected.idleTimeout) { - const polled = await pollForAssistantResponse(sessionId, 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 && (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, 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 = 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 : '', outputItemId); - 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 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(); - return res.end(); - } - - let responseRes = null; - let responseParts = []; - let promptContent = ''; - let promptReasoning = ''; - let promptParsedToolCalls = []; - let polledFilled = false; - let lastResponsesAttemptError = null; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - // Stop once the client is gone (route watchdog may have answered already). - if (res.writableEnded || res.destroyed) { - logDebug('Client gone, stop retrying', { sessionId, attempt }); - return; - } - if (attempt > 1) { - // Same fresh-session rotation as chat: the failed attempt left an - // errored assistant message behind, and re-prompting it 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 withTimeout( - client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); - sessionId = retrySessionRes.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); - promptParams.path.id = sessionId; - requestForcedResponsesToolCall = makeForcedResponsesToolCallRequester(); - await sleep(computeRetryDelay(attempt - 1, lastResponsesAttemptError)); - } - let polledResponse = null; - try { - // Bounded like chat/messages: a hung backend must surface - // as an error, never as a silent zero-byte stall. - // (withTimeout, not promptWithTimeout: a late backend - // rejection must stay handled to avoid crashing the process.) - responseRes = await withTimeout(client.session.prompt(promptParams), REQUEST_TIMEOUT_MS, 'prompt backend'); - responseParts = responseRes.data?.parts || []; - promptContent = responseParts.filter(p => p.type === 'text').map(p => p.text).join('\n'); - promptReasoning = responseParts.filter(p => p.type === 'reasoning').map(p => p.text).join('\n'); - promptParsedToolCalls = externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) - : []; - if (promptContent || promptReasoning) break; - polledResponse = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - } catch (loopError) { - // Transport-level throw (fetch failed, ECONNRESET, poll timeout): - // same transient routing as a poll-observed error. - if (attempt < maxAttempts && isTransientUpstreamError(loopError)) { - console.warn(`[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, loopError.data?.message || loopError.message || loopError.name || 'unknown'); - 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:`, polledResponse.error.data?.message || polledResponse.error.message || polledResponse.error.name || 'unknown'); - 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 = promptParsedToolCalls; - if (polledFilled) { - 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 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}`); - - return res.json(response); - } catch (error) { - stopResponsesKeepalive(); - console.error('[Proxy] Responses API Error:', error?.message || error?.data?.message || error?.name || error); - const transformed = transformUpstreamError(error); - // Once the SSE headers are out, res.json() throws ERR_HTTP_HEADERS_SENT. That throw - // 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 { - 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) { - logDebug('Failed to report error on open response stream', { error: writeError.message }); - } - return res.end(); - } - return res.status(transformed.statusCode).json(transformed.error); - } - }); - - // Anthropic Messages API (P0: non-stream + stream via shared opencode backend) - app.post('/v1/messages', async (req, res) => { - try { - await lock(async () => { - let sessionId = null; - let keepaliveInterval = null; - try { - const validationError = validateMessagesRequest(req.body); - if (validationError) { - return res.status(validationError.statusCode).json(validationError.body); - } - const { - model, system, messages, tools = [], tool_choice, - stream: requestStream, temperature, top_p, top_k, - max_tokens, stop_sequences, thinking - } = req.body; - const stream = Boolean(requestStream); - const chatMessages = anthropicMessagesToChatMessages(messages); - const systemText = extractSystemText(system); - if (systemText) chatMessages.unshift({ role: 'system', content: systemText }); - const chatTools = anthropicToolsToChatTools(tools); - const chatToolChoice = anthropicToolChoiceToChat(tool_choice); - const reasoningLevel = anthropicThinkingToReasoningEffort(thinking) - || normalizeReasoningEffort(undefined, 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, - 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'); - const pID = resolvedModel.providerID; - const mID = resolvedModel.modelID; - const publicModel = `${pID}/${mID}`; - - const requestToolContext = createRequestToolContext(chatTools, chatToolChoice, undefined); - const toolMode = requestToolContext.mode; - const externalToolContext = requestToolContext.external; - const externalToolRegistry = externalToolContext.registry; - const externalToolChoice = externalToolContext.toolChoice; - const internalToolContext = requestToolContext.internal; - trackToolMode(toolMode, { route: '/v1/messages' }); - - // Reuse chat-style prompt flattening for the converted messages. - const parts = []; - const systemChunks = []; - const assistantToolCalls = new Map(); - const formatRoleLine = (role, name, text) => `${String(role).toUpperCase()}${name ? `(${name})` : ''}: ${text}`; - for (const m of chatMessages) { - const role = (m?.role || 'user').toLowerCase(); - const content = m?.content; - if (role === 'system') { - const text = normalizeTextContent(content); - if (text) systemChunks.push(text); - continue; - } - if (role === 'assistant' && Array.isArray(m?.tool_calls) && m.tool_calls.length) { - const serialized = m.tool_calls.map((tc, i) => ({ - id: tc?.id || `toolu_${i + 1}`, - name: findExternalToolByName(externalToolRegistry, tc?.function?.name || tc?.name)?.namespacedName || tc?.function?.name || tc?.name, - arguments: normalizeToolArguments(tc?.function?.arguments ?? tc?.arguments) - })).filter((tc) => tc.name); - serialized.forEach((tc) => assistantToolCalls.set(tc.id, tc.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, m?.name) - || findExternalToolByName(externalToolRegistry, assistantToolCalls.get(m?.tool_call_id)); - const toolName = mapped?.namespacedName || assistantToolCalls.get(m?.tool_call_id) || m?.name || `${EXTERNAL_TOOL_PREFIX}unknown`; - parts.push({ type: 'text', text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: m?.tool_call_id || toolName, name: toolName, content: text })}` }); - } - continue; - } - if (!content) continue; - if (typeof content === 'string') { - 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') parts.push({ type: 'text', text: formatRoleLine(role, m?.name, part.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); - parts.push({ type: 'file', mime: dataUri.split(';')[0].split(':')[1], url: dataUri, filename: 'image' }); - } catch (e) { logDebug('Skipping image', { error: e.message }); } - } - } - } - } - } - if (!parts.length) return res.status(400).json({ type: 'error', error: { type: 'invalid_request_error', message: 'messages must include at least one text message' } }); - - 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) { logDebug('Failed to set active model', { error: e.message }); } - const sessionRes = await withTimeout( - client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); - sessionId = sessionRes.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session'); - - const promptParams = { - 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'); - if (toolOverrides && Object.keys(toolOverrides).length > 0) promptParams.body.tools = toolOverrides; - - const fullPromptText = parts.map((p) => p.text || '').join('\n\n'); - const messageId = `msg_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; - const inputTokens = estimateTokens(fullPromptText + (systemWithGuard || '')); - - const makeForcedMessagesToolCallRequester = () => createForcedToolCallRequester({ - mode: externalToolChoice.mode, - sessionId, - systemWithGuard, - requiredTool: externalToolChoice.requiredTool || externalToolRegistry[0]?.namespacedName, - providerID: pID, - modelID: mID, - toolOverrides, - requestTimeoutMs: REQUEST_TIMEOUT_MS, - forbidThinkBlock: true - }); - let requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); - - const finalizeAnthropic = (content, reasoning, validatedToolCalls) => { - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); - const publicCalls = toPublicToolCalls(validatedToolCalls); - // Map public tool calls back to Anthropic tool_use (preserve toolu_ ids from history when possible). - const anthropicTools = publicCalls.map((tc) => { - let input = {}; - try { input = JSON.parse(tc.function.arguments || '{}'); } catch { input = {}; } - return { id: tc.id, function: { name: tc.function.name, arguments: tc.function.arguments }, _input: input }; - }); - // Token counts are estimates (chars/4); the backend does not surface - // real usage or a truncation signal, so stop_reason is derived from - // tool calls only and never inferred from max_tokens. - 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, - stopReason, inputTokens, outputTokens - }); - }; - - if (!stream) { - let content = ''; - let reasoning = ''; - let error = null; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - // Stop once the client is gone (route watchdog may have answered already). - 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 {} - const r = await withTimeout( - client.session.create(), REQUEST_TIMEOUT_MS, 'create session'); - sessionId = r.data?.id; - if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); - promptParams.path.id = sessionId; - requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); - await sleep(computeRetryDelay(attempt - 1, error)); - } - try { - await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); - const collected = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); - content = collected.content || ''; - reasoning = collected.reasoning || ''; - error = collected.error || null; - } catch (promptError) { - // Transport-level throw (fetch failed, ECONNRESET, prompt - // timeout): same transient routing as poll errors. - content = ''; - reasoning = ''; - error = promptError; - } - if (error && !content && !reasoning && attempt < maxAttempts && isTransientUpstreamError(error)) continue; - break; - } - if (error && !content && !reasoning) { - const t = transformUpstreamError(error); - return 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 }) - } - }); - } - let parsed = externalToolRegistry.length > 0 ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) : []; - if (parsed.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedMessagesToolCall(); - if (forcedResponse) { - content = forcedResponse.content || content; - reasoning = forcedResponse.reasoning || reasoning; - parsed = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); - } - } - const { validCalls } = finalizeValidatedToolCalls(parsed, externalToolRegistry); - return res.json(finalizeAnthropic(content, reasoning, validCalls)); - } - - // Stream: Anthropic SSE (message_start/content_block_*/message_delta/message_stop, no [DONE]) - 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); - // NOTE: req 'close' fires as soon as the request body is parsed - // (before slow backend work finishes), so a listener attached - // here would never observe a mid-stream disconnect. res 'close' - // with !writableEnded is the true client-disconnect signal. - const resClosed = new Promise((resolve) => res.once('close', () => { - if (!res.writableEnded) resolve(true); - })); - let streamedText = ''; - let streamedReasoning = ''; - let rawContent = ''; - let rawReasoning = ''; - const streamedToolCalls = []; - 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 = null; - let thinkingIndex = null; - const ensureTextIndex = () => { - if (textIndex === null) textIndex = nextBlockIndex++; - return textIndex; - }; - const ensureThinkingIndex = () => { - 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) => { - 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) => { - 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, isReasoning = false) => { - 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 = null; - const collectPromise = collectFromEvents(sessionId, REQUEST_TIMEOUT_MS, sendDelta, DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, DEFAULT_EVENT_IDLE_TIMEOUT_MS) - .catch((err) => ({ __error: err })); - client.session.prompt(promptParams).catch((err) => logDebug('Prompt error:', err.message)); - collected = await Promise.race([collectPromise, resClosed.then(() => ({ __cancelled: true }))]); - if (collected?.__cancelled) { - if (keepaliveInterval) clearInterval(keepaliveInterval); - try { if (sessionId) await client.session.delete({ path: { id: sessionId } }); } catch (e) { logDebug('Failed to cleanup cancelled messages session', { error: e.message }); } - try { if (!res.destroyed) res.end(); } catch {} - return; - } - if (collected?.error && !rawContent && !rawReasoning) throw normalizeBackendError(collected.error); - if (collected?.__error) { - const polled = await pollForAssistantResponse(sessionId, 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)); - if (reasoning && !rawReasoning) sendReasoningDelta(stripFunctionCallMarkup(reasoning)); - } - 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 })); - // Flush held buffers before final parse (mirrors /v1/chat/completions). - 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 = () => { - if (externalToolRegistry.length === 0) return []; - const perChannel = [ - ...flushedReasoningCalls, - ...flushedContentCalls, - ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText) - ]; - if (perChannel.length > 0) return perChannel; - return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); - }; - // Exclusive: incremental hits already describe the same markup that a - // full re-parse would find again, so never combine both (else 1 call -> 2 blocks). - let parsedToolCalls = 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, - forcedResponse.content - ); - } - } - const { validCalls: finalValidated } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - let toolBlockIndex = nextBlockIndex; - for (const tc of toPublicToolCalls(finalValidated)) { - res.write(sseEvent('content_block_start', { type: 'content_block_start', index: toolBlockIndex, content_block: { type: 'tool_use', id: tc.id, name: tc.function.name, input: {} } })); - res.write(sseEvent('content_block_delta', { type: 'content_block_delta', index: toolBlockIndex, delta: { type: 'input_json_delta', partial_json: tc.function.arguments || '{}' } })); - 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); - return res.end(); - } catch (error) { - if (keepaliveInterval) clearInterval(keepaliveInterval); - if (sessionId) { - try { await client.session.delete({ path: { id: sessionId } }); } catch (e) { logDebug('Failed to cleanup messages session on error', { error: e.message }); } - } - if (!res.headersSent) { - const t = transformUpstreamError(error); - return res.status(t.statusCode).json({ - type: 'error', - error: { - type: t.error.type || 'api_error', - message: t.error.message || error.message || 'Upstream error', - ...(t.error.code && { code: t.error.code }) - } - }); - } - 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 {} - return res.end(); - } - }, REQUEST_TIMEOUT_MS + 20000); - } catch (error) { - if (!res.headersSent) res.status(500).json({ type: 'error', error: { type: 'api_error', message: error.message } }); - } - }); - - 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'); - } - } -} - -/** - * 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) { - 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, fallback = false) { - const o = options ?? {}; - return normalizeBool(o.DISABLE_TOOLS) ?? - normalizeBool(o.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, timeoutMs, label = 'operation') { - const ms = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : DEFAULT_REQUEST_TIMEOUT_MS; - let timer; - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Request timeout after ${ms}ms (${label})`)), ms); - }); - if (promise && typeof promise.catch === 'function') promise.catch(() => {}); - return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer)); -} - -/** - * Starts the OpenCode-to-OpenAI Proxy server. - */ -export function startProxy(options = {}) { - // Also tolerate explicit null (the = {} default only covers undefined). - options = options ?? {}; - const disableTools = resolveDisableTools(options); - - const promptMode = options.PROMPT_MODE || options.promptMode || process.env.OPENCODE_PROXY_PROMPT_MODE || 'standard'; - const externalToolsMode = options.EXTERNAL_TOOLS_MODE || options.externalToolsMode || process.env.OPENCODE_EXTERNAL_TOOLS_MODE || 'proxy-bridge'; - 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.BIND_HOST || process.env.OPENCODE_PROXY_BIND_HOST || '0.0.0.0', - USE_ISOLATED_HOME: typeof options.USE_ISOLATED_HOME === 'boolean' - ? options.USE_ISOLATED_HOME - : String(options.USE_ISOLATED_HOME || '').toLowerCase() === 'true' || - 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..c0d566e --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,906 @@ +// 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 } from './tool-runtime/registry.js'; +import { resolveMaxRetries } from './retry/policy.js'; +import { buildToolExposure } from './tool-runtime/router.js'; +import { evaluateToolPolicy } from './tool-runtime/policy.js'; +import { validateToolCalls } from './tool-runtime/validator.js'; +import { stripFunctionCallMarkup } 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; + + // 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); + + 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.js b/src/retry/policy.ts similarity index 69% rename from src/retry/policy.js rename to src/retry/policy.ts index df6b27b..be234c6 100644 --- a/src/retry/policy.js +++ b/src/retry/policy.ts @@ -30,12 +30,26 @@ 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) { - const n = typeof value === 'number' ? value : parseInt(value, 10); +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; @@ -49,30 +63,31 @@ export function resolveMaxRetries(value) { * retry-after (HTTP date). Invalid / past values yield null (caller falls * back to exponential). Header names are matched case-insensitively. */ -export function parseRetryAfterMs(headers) { +export function parseRetryAfterMs(headers: unknown): number | null { if (!headers || typeof headers !== 'object') return null; - const lookup = (name) => { - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === name) return headers[key]; + 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); + 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); + const seconds = Number.parseFloat(afterRaw as string); if (!Number.isNaN(seconds) && seconds >= 0) return Math.ceil(seconds * 1000); - const dateMs = Date.parse(afterRaw) - Date.now(); + const dateMs = Date.parse(afterRaw as string) - Date.now(); if (!Number.isNaN(dateMs) && dateMs > 0) return Math.ceil(dateMs); } return null; } -function exponentialDelay(attempt, random) { +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); } @@ -82,10 +97,11 @@ function exponentialDelay(attempt, random) { * preceding the 2nd overall attempt is attempt=1 → ~2000ms). * error may carry responseHeaders directly or under .data (backend shape). */ -export function computeRetryDelay(attempt, error = null, random = Math.random()) { - const safeAttempt = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 1; +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 headers = error?.responseHeaders ?? error?.data?.responseHeaders ?? null; + 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 3abb625..0000000 --- a/src/tool-runtime/registry.js +++ /dev/null @@ -1,148 +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; - // Anthropic Messages shape uses `input_schema` instead of `parameters`. - const parameters = definition.parameters ?? definition.input_schema; - return { - name, - description: definition.description, - 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 4915f29..0000000 --- a/src/tool-runtime/router.js +++ /dev/null @@ -1,109 +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 }; - } - // Anthropic Messages shape: { type: 'auto' | 'any' | 'tool' | 'none', name? }. - if (typeof toolChoice?.type === 'string' && !toolChoice?.function) { - const t = String(toolChoice.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' && toolChoice.name) { - const mappedTool = findExternalToolByName(registry, toolChoice.name); - return { - mode: 'required', - requiredTool: mappedTool?.namespacedName || `${EXTERNAL_TOOL_PREFIX}${toolChoice.name}` - }; - } - 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 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/env-alias.test.js b/tests/env-alias.test.js index 700eadc..699198c 100644 --- a/tests/env-alias.test.js +++ b/tests/env-alias.test.js @@ -3,7 +3,7 @@ import { resolveDisableTools, normalizeBool } from '../src/proxy.js'; // Regression tests: docker-compose/.env document DISABLE_TOOLS, but the code // only honored OPENCODE_DISABLE_TOOLS, so a legacy DISABLE_TOOLS=true was // silently ignored. Note the unit default here is false; the production -// default (true) comes from index.js passing defaultConfig as fallback. +// default (true) comes from index.ts passing defaultConfig as fallback. describe('resolveDisableTools', () => { const CANONICAL = 'OPENCODE_DISABLE_TOOLS'; const LEGACY = 'DISABLE_TOOLS'; @@ -118,9 +118,9 @@ describe('resolveDisableTools', () => { }); }); -// index.js cannot be imported directly (it starts the server), so mirror its +// 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.js calls resolveDisableTools({DISABLE_TOOLS: canonical, +// index.ts calls resolveDisableTools({DISABLE_TOOLS: canonical, // disableTools: legacy}, normalizeBool(file) ?? default). describe('index.js DISABLE_TOOLS chain (mirrored)', () => { const resolveIndexChain = (env, fileConfig, defaultValue) => 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"] +}