From 32e04719b8b54ce705f8f9e5141a1b1be58910c6 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 18:32:50 +0800 Subject: [PATCH 1/3] feat(api): multi-key auth, free-limit proxy fallback, web_search grounding, Interactions API - P1: external multi-client keys (API_KEYS/OPENCODE_API_KEYS merge, timingSafeEqual, Bearer/x-api-key OR, empty falls back to no-auth) - P2: websearch allowlist alias normalization (web_fetch<->webfetch), messages request-level narrowing parity - P3: lazy fallback proxy pool (socks5-first, failover-rr, cooldown, loopback bypass) engaged only on 429 FreeUsageLimitError/GoUsageLimitError; stream paths degrade to poll; details/metrics exposure - P4: /v1/responses tools:[{type:web_search}] drives opencode websearch, returns web_search_call + url_citation (honest indices only) - P5: POST /v1beta|/v1/interactions thin layer (text + google_search grounding, previous_interaction_id, store flag, SSE contract) - chat/messages reject hosted web_search with 400 + pointer - engines: node >=22.19 (undici 8 floor) typecheck + build clean; 13 suites / 244 tests green --- .env.example | 10 + AGENTS.md | 4 +- Dockerfile | 6 + config.json.example | 7 +- docker-compose.yml | 6 + docs/api-reference.md | 34 +++ docs/configuration.md | 22 +- index.ts | 51 +++- package-lock.json | 12 +- package.json | 5 +- src/auth/keys.ts | 164 +++++++++++ src/config/proxy-config.ts | 36 ++- src/errors/upstream.ts | 65 ++++ src/proxy.ts | 69 ++++- src/routes/chat.ts | 131 +++++++-- src/routes/interactions.ts | 439 ++++++++++++++++++++++++++++ src/routes/messages.ts | 133 +++++++-- src/routes/responses.ts | 289 +++++++++++++++--- src/routes/system.ts | 24 +- src/search/grounding.ts | 180 ++++++++++++ src/stream/collector.ts | 8 +- src/tool-runtime/registry.ts | 5 +- src/types/config.ts | 10 + src/types/context.ts | 9 +- src/types/errors.ts | 2 + src/upstream-proxy/fallback.ts | 30 ++ src/upstream-proxy/pool.ts | 248 ++++++++++++++++ tests/auth-multikey.test.js | 177 +++++++++++ tests/hosted-search-routing.test.js | 132 +++++++++ tests/interactions.test.js | 222 ++++++++++++++ tests/proxy-fallback.test.js | 325 ++++++++++++++++++++ tests/responses-websearch.test.js | 207 +++++++++++++ tests/tool-alias.test.js | 127 ++++++++ 33 files changed, 3058 insertions(+), 131 deletions(-) create mode 100644 src/auth/keys.ts create mode 100644 src/routes/interactions.ts create mode 100644 src/search/grounding.ts create mode 100644 src/upstream-proxy/fallback.ts create mode 100644 src/upstream-proxy/pool.ts create mode 100644 tests/auth-multikey.test.js create mode 100644 tests/hosted-search-routing.test.js create mode 100644 tests/interactions.test.js create mode 100644 tests/proxy-fallback.test.js create mode 100644 tests/responses-websearch.test.js create mode 100644 tests/tool-alias.test.js diff --git a/.env.example b/.env.example index a2f075c..f93f491 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # Copy this file to .env before running docker compose. API_KEY=change-me +# Multi-key auth (A): comma-separated; any entry passes. Empty = no auth. +# Canonical list name first, legacy alias second (both merge, empty never blocks). +# OPENCODE_API_KEYS=key-alpha,key-beta +# API_KEYS=key-alpha,key-beta,key-gamma OPENCODE_SERVER_PASSWORD=change-me-too # Safer default. Keep true unless you really want model tool access. @@ -41,6 +45,12 @@ OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 # OPENCODE_INTERNAL_ALLOWED_TOOLS= # OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true # OPENCODE_TOOL_DISCOVERY_FIXTURE= +# Fallback proxy pool (P3): direct-only until a free-limit 429 engages it. +# Socks5 first-class; loopback targets always bypass. +# OPENCODE_UPSTREAM_PROXIES=socks5://user:pass@10.0.0.1:1080,http://user:pass@10.0.0.2:8080 +# OPENCODE_UPSTREAM_PROXY_STRATEGY=failover-rr +# OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS=300000 +# OPENCODE_UPSTREAM_PROXY_NO_PROXY=localhost,127.0.0.1,::1 # Diagnostics # OPENCODE_HEALTH_DETAILS_ENABLED=true # OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH=true diff --git a/AGENTS.md b/AGENTS.md index 2eef9eb..dd72710 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ ## 2. Tech stack & commands (single source of truth) -- `node >= 18`, TypeScript (`tsconfig.json`: `module/moduleResolution NodeNext`, `strict`, `noEmitOnError`), Express, Jest 30 + Supertest via `@swc/jest`, `tsx` for dev. +- `node >= 22.19` (undici 8 `engines` floor), TypeScript (`tsconfig.json`: `module/moduleResolution NodeNext`, `strict`, `noEmitOnError`), Express, Jest 30 + Supertest via `@swc/jest`, `tsx` for dev. - Run in this order: ```bash npm run typecheck # tsc --noEmit (covers index.ts + src/**/*.ts only; tests excluded by design) @@ -69,6 +69,8 @@ config.json.example / .env.example # examples only, never real secrets | Binary / zen | `OPENCODE_PATH` / `OPENCODE_ZEN_API_KEY` | `OPENCODE_PATH` / `ZEN_API_KEY` | `opencode` / `''` | | Backend mgmt | `OPENCODE_PROXY_MANAGE_BACKEND` | `MANAGE_BACKEND` | see known drift below | | Auth | `API_KEY` (env+file same name) | `API_KEY` | `''` (= no auth) | +| Auth multi-key (A) | `OPENCODE_API_KEYS` canonical, `API_KEYS` legacy alias (merge; empty never blocks) | `API_KEYS` | `(none, merges with API_KEY)` | +| Fallback proxies | `OPENCODE_UPSTREAM_PROXIES` / `OPENCODE_UPSTREAM_PROXY_STRATEGY` / `OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS` / `OPENCODE_UPSTREAM_PROXY_NO_PROXY` | `UPSTREAM_*` short forms | `(none)` / `failover-rr` / `300000` / `localhost,127.0.0.1,::1` | - `DISABLE_TOOLS` resolution: `OPENCODE_DISABLE_TOOLS > DISABLE_TOOLS > file > true` via `resolveDisableTools` (`src/config/proxy-config.ts:47-57`); covered by `tests/env-alias.test.js`. Never `??`-chain booleans by hand; call the helper. - `RETRY`: raw `env ?? file ?? 3`, normalized by `resolveMaxRetries` (`src/retry/policy.ts:51-58`, clamp 0–5, total attempts `1+n`, `2s×2ⁿ⁻¹` + 25% jitter, `retry-after` clamped to 30s). diff --git a/Dockerfile b/Dockerfile index c2b66d0..aa6fca6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,8 @@ EXPOSE 10000 10001 ENV OPENCODE_SERVER_PASSWORD= ENV API_KEY= +ENV API_KEYS= +ENV OPENCODE_API_KEYS= ENV BIND_HOST=0.0.0.0 ENV DISABLE_TOOLS=true ENV OPENCODE_DISABLE_TOOLS= @@ -74,6 +76,10 @@ ENV OPENCODE_PROXY_CLEANUP_INTERVAL_MS=43200000 ENV OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 ENV OPENCODE_PROXY_REQUEST_TIMEOUT_MS=180000 ENV OPENCODE_PROXY_RETRY_MAX_RETRIES=3 +ENV OPENCODE_UPSTREAM_PROXIES= +ENV OPENCODE_UPSTREAM_PROXY_STRATEGY=failover-rr +ENV OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS=300000 +ENV OPENCODE_UPSTREAM_PROXY_NO_PROXY=localhost,127.0.0.1,::1 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "10001"] diff --git a/config.json.example b/config.json.example index aa99efd..4419233 100644 --- a/config.json.example +++ b/config.json.example @@ -1,6 +1,7 @@ { "PORT": 10000, "API_KEY": "your-secret-api-key", + "API_KEYS": [], "BIND_HOST": "0.0.0.0", "DISABLE_TOOLS": true, "EXTERNAL_TOOLS_MODE": "proxy-bridge", @@ -26,5 +27,9 @@ "ZEN_API_KEY": "", "MANAGE_BACKEND": false, "REQUEST_TIMEOUT_MS": 180000, - "RETRY_MAX_RETRIES": 3 + "RETRY_MAX_RETRIES": 3, + "UPSTREAM_PROXIES": [], + "UPSTREAM_PROXY_STRATEGY": "failover-rr", + "UPSTREAM_PROXY_COOLDOWN_MS": 300000, + "UPSTREAM_PROXY_NO_PROXY": ["localhost", "127.0.0.1", "::1"] } diff --git a/docker-compose.yml b/docker-compose.yml index b43c4d1..0f94c68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-} - OPENCODE_ZEN_API_KEY=${OPENCODE_ZEN_API_KEY:-} - API_KEY=${API_KEY:-} + - API_KEYS=${API_KEYS:-} + - OPENCODE_API_KEYS=${OPENCODE_API_KEYS:-} - BIND_HOST=${BIND_HOST:-${OPENCODE_PROXY_BIND_HOST:-0.0.0.0}} - DISABLE_TOOLS=${DISABLE_TOOLS:-true} - OPENCODE_DISABLE_TOOLS=${OPENCODE_DISABLE_TOOLS:-} @@ -25,6 +27,10 @@ services: - OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=${OPENCODE_PROXY_CLEANUP_MAX_AGE_MS:-86400000} - OPENCODE_PROXY_REQUEST_TIMEOUT_MS=${OPENCODE_PROXY_REQUEST_TIMEOUT_MS:-180000} - OPENCODE_PROXY_RETRY_MAX_RETRIES=${OPENCODE_PROXY_RETRY_MAX_RETRIES:-3} + - OPENCODE_UPSTREAM_PROXIES=${OPENCODE_UPSTREAM_PROXIES:-} + - OPENCODE_UPSTREAM_PROXY_STRATEGY=${OPENCODE_UPSTREAM_PROXY_STRATEGY:-failover-rr} + - OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS=${OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS:-300000} + - OPENCODE_UPSTREAM_PROXY_NO_PROXY=${OPENCODE_UPSTREAM_PROXY_NO_PROXY:-localhost,127.0.0.1,::1} - OPENCODE_PROXY_PORT=${OPENCODE_PROXY_PORT:-10000} - OPENCODE_SERVER_PORT=${OPENCODE_SERVER_PORT:-10001} - OPENCODE_SERVER_URL=${OPENCODE_SERVER_URL:-} diff --git a/docs/api-reference.md b/docs/api-reference.md index 36cc564..be1094d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -203,6 +203,40 @@ curl -X POST http://127.0.0.1:10000/v1/responses \ 非流式 `responses` 响应会在 `response.output` 中返回 `type: "function_call"` 项;流式模式会发送 function_call 生命周期和参数增量事件。 +**服务端联网搜索(`web_search`):** + +```bash +curl -X POST http://127.0.0.1:10000/v1/responses \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "opencode-go/kimi-k2.5-free", + "input": "PostgreSQL 最新版本是多少?", + "tools": [{"type": "web_search"}] + }' +``` + +`tools: [{"type": "web_search"}]`(亦接受 `web_search_preview` / `google_search`)是显式授权:代理将其从外部 function 注册表剔除,转驱动 opencode 内置 `websearch`(需模型 provider 为 `opencode`/`opencode-go`,否则需 `OPENCODE_ENABLE_EXA=1`),并在输出中返回 `type: "web_search_call"` 项(含真实执行的 `action.query`)与 `url_citation` 引用(仅标注答案中实际出现的来源 URL,无编造)。流式模式会发送 `response.web_search_call.searching/completed` 事件,最终 `response.completed` 携带完整引用。 + +### 🔁 Interactions API(Gemini 兼容薄层) + +```http +POST /v1beta/interactions +POST /v1/interactions +``` + +| 参数 | 类型 | 必填 | 说明 | +|:-----|:-----|:-----|:-----| +| `model` / `agent` | string | ✅(二选一) | 模型 ID(`agent` 暂仅占位) | +| `input` | string \| array | ✅ | 输入文本/消息 | +| `system_instruction` | string | - | 系统指令 | +| `tools` | array | - | 仅支持 `{type: "google_search"}`(等价 `web_search`),其余 function 工具返回 400 | +| `previous_interaction_id` | string | - | 续写上轮会话(等价 responses 的 `previous_response_id`) | +| `stream` | boolean | - | SSE:`interaction.created` / `step.delta` / `interaction.completed`(无 `[DONE]`,错误为 `{type:'error'}` 事件;15s heartbeat) | +| `store` | boolean | - | `false` 则不持久化;仅删除本请求新建的会话,复用的父会话保留 | + +响应为 `Interaction` 资源:`{id, status, model, output_text, steps[], usage: {grounding_tool_count}}`,其中 `steps` 含 `google_search_call{queries}`、`google_search_result{sources}`(标注为 opencode websearch 代理结果,非 Google 原生)、`model_output{text, annotations}`。文本往返与 `web_search` 接地逻辑复用 Responses 管线;限流错误的重试与代理 fallback 和其余路由一致(engage + 换 session,最多 `maxAttempts` 次),普通错误直接返回。 + ### 🧭 推荐提示模板(OpenClaw / Claude Code) 在真实运行环境中,如果你希望第一跳**稳定先产出 tool call**,推荐把“调用工具”和“基于工具结果继续回答”拆成两步,而不是混在同一句里。 diff --git a/docs/configuration.md b/docs/configuration.md index 4d472a4..14e894a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,7 @@ | `PORT` / `OPENCODE_PROXY_PORT` | `10000` | 代理服务端口 | | `OPENCODE_SERVER_PORT` | `10001` | OpenCode 后端服务端口 | | `API_KEY` | - | Bearer Token 认证密钥 | +| `API_KEYS` / `OPENCODE_API_KEYS` | - | 多 client keys(逗号分隔,任一通过;与 `API_KEY` 合并;为空回退免认证) | | `BIND_HOST` | `0.0.0.0` | 绑定地址(`BIND_HOST` 优先,`OPENCODE_PROXY_BIND_HOST` 为后备) | | `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | OpenCode 后端地址 | | `OPENCODE_SERVER_PASSWORD` | - | OpenCode 后端密码 | @@ -33,7 +34,7 @@ | `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` | | `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具冲突隔离策略;当前仅支持 `namespace` | | `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;未显式配置 allowlist 时,启用后默认放行 `web_fetch` | -| `OPENCODE_INTERNAL_ALLOWED_TOOLS` | `(none)` | 当请求未传入 `tools` 时允许使用的 OpenCode 内置工具列表,逗号分隔 | +| `OPENCODE_INTERNAL_ALLOWED_TOOLS` | `(none)` | 当请求未传入 `tools` 时允许使用的 OpenCode 内置工具列表,逗号分隔(例 `websearch,webfetch`;`web_fetch` 等旧写法仍可匹配,大小写/分隔符不敏感) | | `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED` | `true` | 输出 internal allowlist 模式的调试/指标日志 | | `OPENCODE_TOOL_DISCOVERY_FIXTURE` | `(none)` | 集成测试/本地调试用的固定后端工具 ID 列表,逗号分隔 | | `OPENCODE_HEALTH_DETAILS_ENABLED` | `true` | 控制 `/health/details` 是否暴露 | @@ -51,6 +52,21 @@ > 重试退避移植自上游 `session/retry.ts`(`2s×2ⁿ⁻¹` +25% jitter),但 `retry-after` 等待 clamp 在 30s(上游近无界;网关面对自带超时的客户端不宜久睡)。旧部署注意:默认总尝试由 3 次变为 1+3=4 次,如需接近旧次数可设 `2`。 +### 免费额度 fallback 代理 + +平时直连、零开销;仅当上游返回免费/Go 配额耗尽(`429 + FreeUsageLimitError/GoUsageLimitError`)时自动切到代理并按冷却粘滞,普通 5xx 不触发。 + +> 两跳模型:代理作用于网关 → OpenCode 后端这一跳。若后端是远端地址(`OPENCODE_SERVER_URL` 非 loopback),切换出口 IP 可命中新的匿名/IP 配额;若是默认本地托管后端(`127.0.0.1`,恒直连 bypass),fallback 退化为同后端直接重试 + engaged 状态标记——此时如需改变后端 → Zen 的出口 IP,需给后端进程配 `HTTP(S)_PROXY`(`spawn` 会继承环境)。 + +| 变量 | 默认值 | 说明 | +|:-----|:-------|:-----| +| `OPENCODE_UPSTREAM_PROXIES` | `(none)` | 逗号分隔的代理 URL(`socks5://` 优先,亦支持 `http(s)://`;`config.json` 中用短键 `UPSTREAM_PROXIES` 数组) | +| `OPENCODE_UPSTREAM_PROXY_STRATEGY` | `failover-rr` | `failover-rr` / `round-robin` / `random`(连续限流即轮换下一个) | +| `OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS` | `300000` | engaged 粘滞时长(毫秒),到期回直连 | +| `OPENCODE_UPSTREAM_PROXY_NO_PROXY` | `localhost,127.0.0.1,::1` | 永不走代理的目标 host(默认后端 `127.0.0.1` 恒直连) | + +> 注意:`GET /health/details` 的 `internal_tools.fallback_proxies` 可观察 engaged 状态;`/metrics` 有 `opencode_fallback_proxy_engaged` gauge。流式 SSE 不走自定义 fetch(上游 SDK 缺口),fallback 自动降级为轮询。 + ### 调试配置 | 变量 | 默认值 | 说明 | @@ -67,6 +83,7 @@ { "PORT": 10000, "API_KEY": "your-secret-api-key", + "API_KEYS": ["key-alpha", "key-beta"], "BIND_HOST": "0.0.0.0", "DISABLE_TOOLS": true, "EXTERNAL_TOOLS_MODE": "proxy-bridge", @@ -113,7 +130,8 @@ OpenCode2API 现在支持把外部客户端传入的 OpenAI-compatible `tools` - 当请求 **未传入** `tools` 时,代理会进入 internal allowlist 模式,只允许 `OPENCODE_INTERNAL_ALLOWED_TOOLS` 中声明的 OpenCode 内置工具。 - `OPENCODE_INTERNAL_WEB_FETCH_ENABLED=true` 仅用于兼容旧配置:如果未显式配置 allowlist,则默认把 allowlist 视为 `web_fetch`。 -- 代理会读取后端工具列表,并通过精确匹配或 `.` / `/` 后缀匹配解析最终可用工具。 +- 代理会读取后端工具列表,并通过精确匹配、`.` / `/` 后缀匹配或大小写/分隔符不敏感匹配(如 `web_fetch` ↔ `webfetch`)解析最终可用工具。 +- 要启用上游搜索(`opencode`/`opencode-go` provider 自带 `websearch`,免额外 key):`OPENCODE_INTERNAL_ALLOWED_TOOLS=websearch,webfetch`,模型用 `opencode-go/`,并确保后端 `permission.websearch=allow`(默认 agent 已放行,无人值守勿设 `ask`)。 - 如果配置的 allowlist 在后端工具列表中一个也没有匹配到,代理会自动回退到“全部内置工具禁用”的安全模式。 - `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true` 时,会输出 internal allowlist 模式的调试/指标日志,记录模式选择、后端工具发现、allowlist 命中情况和降级原因,但不会记录工具输出内容。 - `OPENCODE_TOOL_DISCOVERY_FIXTURE` 可在集成测试或本地调试时绕过真实 `client.tool.ids()`,直接提供固定工具 ID 列表。 diff --git a/index.ts b/index.ts index 7970a19..f2aae35 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,8 @@ // 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 { mergeApiKeySources } from './src/auth/keys.js'; +import { DEFAULT_PROXY_COOLDOWN_MS, parseProxyList, parseProxyNoProxyList } from './src/upstream-proxy/pool.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -40,10 +42,16 @@ function parsePort(value: unknown, fallback: number): number { return Number.isFinite(n) && n > 0 ? n : fallback; } +/** Resolve the multi-key list for one layer: canonical list + legacy aliases merge (empty never blocks). */ +function parseApiKeys(...sources: unknown[]): string[] { + return mergeApiKeySources(...sources); +} + // Default configuration const defaultConfig = { PORT: parsePort(process.env['OPENCODE_PROXY_PORT'], 10000), API_KEY: '', + API_KEYS: [] as string[], 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), @@ -65,6 +73,13 @@ const defaultConfig = { 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), + UPSTREAM_PROXIES: parseProxyList([process.env['OPENCODE_UPSTREAM_PROXIES'], process.env['UPSTREAM_PROXIES']]), + UPSTREAM_PROXY_STRATEGY: 'failover-rr', + UPSTREAM_PROXY_COOLDOWN_MS: DEFAULT_PROXY_COOLDOWN_MS, + UPSTREAM_PROXY_NO_PROXY: parseProxyNoProxyList( + process.env['OPENCODE_UPSTREAM_PROXY_NO_PROXY'] ?? process.env['UPSTREAM_PROXY_NO_PROXY'], + ['localhost', '127.0.0.1', '::1'], + ), }; // Load config from file. @@ -110,12 +125,18 @@ function readFileNumber(key: string, fallback: number): number { } // Merge configs: env > file > default -const finalConfig: ProxyConfig = { - PORT: +// Auth layers resolve independently; the env layer wins as a whole so a stale +// file single-key can never pollute (or resurrect after rotation of) an +// explicitly configured env list. Empty strings never block lower sources. +const envApiKeys = parseApiKeys(process.env['OPENCODE_API_KEYS'], process.env['API_KEYS'], process.env['API_KEY']); +const fileApiKeys = parseApiKeys(fileConfig['API_KEYS'], fileConfig['API_KEY']); +const resolvedApiKeys = envApiKeys.length > 0 ? envApiKeys : fileApiKeys; +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), + API_KEY: resolvedApiKeys[0] ?? '', + API_KEYS: resolvedApiKeys, 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), @@ -196,6 +217,24 @@ const finalConfig: ProxyConfig = { parsePort(process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS'], 0) || readFileNumber('CLEANUP_MAX_AGE_MS', defaultConfig.CLEANUP_MAX_AGE_MS), OPENCODE_HOME_BASE: null, + // Fallback proxy pool (P3): env layer wins as a whole, then file, then default. + // Empty = direct-only (default, zero overhead; engagable only on free-limit). + UPSTREAM_PROXIES: (() => { + const env = parseProxyList([process.env['OPENCODE_UPSTREAM_PROXIES'], process.env['UPSTREAM_PROXIES']]); + if (env.length > 0) return env; + const file = parseProxyList(fileConfig['UPSTREAM_PROXIES']); + return file.length > 0 ? file : [...defaultConfig.UPSTREAM_PROXIES]; + })(), + UPSTREAM_PROXY_STRATEGY: + process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'] || + readFileString('UPSTREAM_PROXY_STRATEGY', defaultConfig.UPSTREAM_PROXY_STRATEGY), + UPSTREAM_PROXY_COOLDOWN_MS: + parsePort(process.env['OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS'], 0) || + readFileNumber('UPSTREAM_PROXY_COOLDOWN_MS', defaultConfig.UPSTREAM_PROXY_COOLDOWN_MS), + UPSTREAM_PROXY_NO_PROXY: parseProxyNoProxyList( + process.env['OPENCODE_UPSTREAM_PROXY_NO_PROXY'] ?? process.env['UPSTREAM_PROXY_NO_PROXY'] ?? fileConfig['UPSTREAM_PROXY_NO_PROXY'], + defaultConfig.UPSTREAM_PROXY_NO_PROXY, + ), }; // Validate required configuration @@ -223,6 +262,12 @@ 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( + ` - API Keys: ${finalConfig.API_KEYS.length > 0 ? `Configured (n=${finalConfig.API_KEYS.length})` : 'Not configured'}`, +); +console.log( + ` - Fallback Proxies: ${finalConfig.UPSTREAM_PROXIES.length > 0 ? `Configured (n=${finalConfig.UPSTREAM_PROXIES.length}, ${finalConfig.UPSTREAM_PROXY_STRATEGY})` : 'Direct-only'}`, +); 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}`); diff --git a/package-lock.json b/package-lock.json index 72d6792..6ced919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "axios": "^1.7.9", "body-parser": "^1.20.3", "cors": "^2.8.5", - "express": "^4.21.2" + "express": "^4.21.2", + "undici": "^8.10.2" }, "devDependencies": { "@swc/core": "^1.16.2", @@ -6227,6 +6228,15 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 48ca71c..7b68f2a 100644 --- a/package.json +++ b/package.json @@ -34,14 +34,15 @@ }, "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=22.19.0" }, "dependencies": { "@opencode-ai/sdk": "^1.18.29", "axios": "^1.7.9", "body-parser": "^1.20.3", "cors": "^2.8.5", - "express": "^4.21.2" + "express": "^4.21.2", + "undici": "^8.10.2" }, "devDependencies": { "@swc/core": "^1.16.2", diff --git a/src/auth/keys.ts b/src/auth/keys.ts new file mode 100644 index 0000000..8b0897f --- /dev/null +++ b/src/auth/keys.ts @@ -0,0 +1,164 @@ +// Pure multi-key auth helpers (no Express/SDK deps; safe for unit tests). +import { timingSafeEqual } from 'node:crypto'; + +/** Parse a single/list key source into a deduped, trimmed string array. */ +export function parseApiKeyList(value: unknown): string[] { + if (Array.isArray(value)) { + const out: string[] = []; + for (const entry of value as unknown[]) { + if (entry === undefined || entry === null) continue; + if (typeof entry === 'string') { + const t = entry.trim(); + if (t && !out.includes(t)) out.push(t); + continue; + } + if (typeof entry === 'object') { + const rec = entry as Record; + const k: unknown = rec['key']; + if (typeof k === 'string') { + const t = k.trim(); + if (t && !out.includes(t)) out.push(t); + } + continue; + } + // numbers/booleans are ignored (never become keys) + } + return out; + } + if (typeof value === 'string') { + const out: string[] = []; + for (const part of value.split(',')) { + const t = part.trim(); + if (t && !out.includes(t)) out.push(t); + } + return out; + } + return []; +} + +/** Merge several key sources in priority order, deduped (first wins). */ +export function mergeApiKeySources(...sources: unknown[]): string[] { + const out: string[] = []; + for (const src of sources) { + for (const k of parseApiKeyList(src)) { + if (!out.includes(k)) out.push(k); + } + } + return out; +} + +export interface MinimalHeaders { + authorization?: unknown; + 'x-api-key'?: unknown; +} + +export interface MinimalRequest { + headers: MinimalHeaders; +} + +/** Extract the presented token: `Authorization: Bearer ` (case-insensitive) or `x-api-key`. */ +export function extractRequestToken(req: MinimalRequest): string | null { + const tokens = getRequestTokens(req); + return tokens.length > 0 ? (tokens[0] as string) : null; +} + +/** + * Collect all presented tokens (Bearer first, then every x-api-key value). + * Preserves the legacy OR semantics: a request passes when ANY header matches. + */ +export function getRequestTokens(req: MinimalRequest): string[] { + const headers = (req as { headers?: Record })?.headers ?? {}; + const out: string[] = []; + const authHeader: unknown = headers['authorization']; + if (typeof authHeader === 'string') { + const m = authHeader.match(/^Bearer\s+(.+)$/i); + if (m && m[1] !== undefined) { + const t = m[1].trim(); + if (t) out.push(t); + } + } + const apiKeyHeader: unknown = headers['x-api-key']; + if (typeof apiKeyHeader === 'string') { + const t = apiKeyHeader.trim(); + if (t) out.push(t); + } + if (Array.isArray(apiKeyHeader)) { + for (const h of apiKeyHeader as unknown[]) { + if (typeof h === 'string' && h.trim()) out.push(h.trim()); + } + } + return out; +} + +function timingSafeCompare(a: string, b: string): boolean { + const ab = Buffer.from(a, 'utf8'); + const bb = Buffer.from(b, 'utf8'); + if (ab.length !== bb.length) return false; + try { + return timingSafeEqual(ab, bb); + } catch { + return false; + } +} + +/** Constant-time membership check; returns the matched index or -1. */ +export function findApiKeyIndex(token: string | null | undefined, keys: readonly string[]): number { + if (!token || typeof token !== 'string') return -1; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + if (typeof k !== 'string' || !k) continue; + if (timingSafeCompare(token, k)) return i; + } + return -1; +} + +export function isValidApiKey(token: string | null | undefined, keys: readonly string[]): boolean { + return findApiKeyIndex(token, keys) >= 0; +} + +/** + * OR-membership check across every presented token; returns the matched + * key index or -1. Either `Authorization: Bearer` or `x-api-key` may match + * (legacy OR semantics preserved). + */ +export function findMatchedApiKeyIndex(req: MinimalRequest, keys: readonly string[]): number { + if (!Array.isArray(keys) || keys.length === 0) return -1; + for (const token of getRequestTokens(req)) { + const idx = findApiKeyIndex(token, keys); + if (idx >= 0) return idx; + } + return -1; +} + +/** Shared verifier: empty key list = no auth; otherwise any presented token must match. */ +export function createApiKeyVerifier(keys: readonly string[]): { + keys: string[]; + isAuthorized: (req: MinimalRequest) => boolean; + matchedIndex: (req: MinimalRequest) => number; +} { + const list = Array.isArray(keys) ? keys.filter((k): k is string => typeof k === 'string' && k !== '') : []; + return { + keys: list, + isAuthorized: (req: MinimalRequest): boolean => { + if (list.length === 0) return true; + return findMatchedApiKeyIndex(req, list) >= 0; + }, + matchedIndex: (req: MinimalRequest): number => findMatchedApiKeyIndex(req, list), + }; +} + +/** + * Build the effective key list. + * NOTE: argument order is (singleKey, multiKeys) for call-site readability, + * but the merge priority is multiKeys-first so an explicit list wins + * positionally over the legacy single value; both remain valid. + */ +export function buildEffectiveApiKeys(singleKey: unknown, multiKeys: unknown): string[] { + return mergeApiKeySources(multiKeys, singleKey); +} + +/** Log-safe placeholder (never echoes key material, even for short keys). */ +export function maskApiKeyForLog(key: string): string { + if (!key) return '(empty)'; + return `***(len=${key.length})`; +} diff --git a/src/config/proxy-config.ts b/src/config/proxy-config.ts index 3b8932f..8cb82c9 100644 --- a/src/config/proxy-config.ts +++ b/src/config/proxy-config.ts @@ -1,5 +1,7 @@ // P4 TS: proxy config defaults + pure bool/config builders (ported from P3 .js, behavior identical). import type { DisableToolsOptions, ProxyConfig, ProxyConfigOptions } from '../types/config.js'; +import { mergeApiKeySources } from '../auth/keys.js'; +import { DEFAULT_PROXY_COOLDOWN_MS, DEFAULT_PROXY_NO_PROXY, parseProxyList, parseProxyNoProxyList } from '../upstream-proxy/pool.js'; export const DEFAULT_REQUEST_TIMEOUT_MS = 300000; export const DEFAULT_POLL_INTERVAL_MS = 500; @@ -132,9 +134,21 @@ export function buildProxyConfig(options: unknown = {}): ProxyConfig { ); } const useIsolatedRaw: unknown = opts['USE_ISOLATED_HOME']; + // Multi-key auth (A): caller options win as a whole layer; otherwise env. + // Both the list (API_KEYS/OPENCODE_API_KEYS, comma-separated) and the legacy + // single (API_KEY) merge with empty values ignored (never blocking). + // NOTE: `!== undefined` is intentional — an explicitly passed empty value + // isolates tests/Docker defaults from ambient env leakage. + const optsProvidedKeys = opts['API_KEYS'] !== undefined || opts['API_KEY'] !== undefined; + const resolvedApiKeys: string[] = optsProvidedKeys + ? mergeApiKeySources(opts['API_KEYS'], opts['API_KEY']) + : mergeApiKeySources(process.env['OPENCODE_API_KEYS'], process.env['API_KEYS'], process.env['API_KEY']); + // Display single = first effective key (same rule as index.ts prod path). + const resolvedApiKey: string = resolvedApiKeys[0] ?? ''; const config: ProxyConfig = { PORT: (opts['PORT'] as number) || 10000, - API_KEY: (opts['API_KEY'] as string) || '', + API_KEY: resolvedApiKey, + API_KEYS: resolvedApiKeys, 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'] || '', @@ -218,6 +232,26 @@ export function buildProxyConfig(options: unknown = {}): ProxyConfig { 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, + // Fallback proxy pool (P3): empty = direct-only (default, zero overhead). + // Library path mirrors index.ts: explicit opts layer wins as a whole, + // otherwise env (canonical OPENCODE_ name first, then legacy alias). + UPSTREAM_PROXIES: + opts['UPSTREAM_PROXIES'] !== undefined || opts['UPSTREAM_PROXY_URLS'] !== undefined + ? parseProxyList([opts['UPSTREAM_PROXIES'], opts['UPSTREAM_PROXY_URLS']].flatMap((v) => (Array.isArray(v) ? v : [v]))) + : parseProxyList([process.env['OPENCODE_UPSTREAM_PROXIES'], process.env['UPSTREAM_PROXIES']]), + UPSTREAM_PROXY_STRATEGY: + (typeof opts['UPSTREAM_PROXY_STRATEGY'] === 'string' && (opts['UPSTREAM_PROXY_STRATEGY'] as string)) || + process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'] || + 'failover-rr', + UPSTREAM_PROXY_COOLDOWN_MS: Number( + opts['UPSTREAM_PROXY_COOLDOWN_MS'] || process.env['OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS'] || DEFAULT_PROXY_COOLDOWN_MS, + ), + UPSTREAM_PROXY_NO_PROXY: parseProxyNoProxyList( + opts['UPSTREAM_PROXY_NO_PROXY'] !== undefined + ? opts['UPSTREAM_PROXY_NO_PROXY'] + : (process.env['OPENCODE_UPSTREAM_PROXY_NO_PROXY'] ?? process.env['UPSTREAM_PROXY_NO_PROXY']), + DEFAULT_PROXY_NO_PROXY, + ), }; return config; } diff --git a/src/errors/upstream.ts b/src/errors/upstream.ts index 89ebb3a..77d7d83 100644 --- a/src/errors/upstream.ts +++ b/src/errors/upstream.ts @@ -18,6 +18,67 @@ function collectMessageParts(error: unknown): string { return parts.join(' '); } +function readStatusCode(error: unknown): number | null { + const record = asRecord(error); + const data = asRecord(record['data']); + const direct: unknown = record['statusCode'] ?? data['statusCode'] ?? data['status']; + if (typeof direct === 'number' && Number.isFinite(direct)) return direct; + if (typeof direct === 'string') { + const n = Number(direct.trim()); + if (Number.isFinite(n) && n > 0) return n; + } + const candidates: unknown[] = [record['message'], data['message']]; + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue; + const m = candidate.match(/^\s*(\d{3})\s*:/); + if (m?.[1]) return Number(m[1]); + } + return null; +} + +function readResponseBodyText(error: unknown): string { + const record = asRecord(error); + const data = asRecord(record['data']); + const raw: unknown = record['responseBody'] ?? data['responseBody'] ?? null; + if (typeof raw === 'string') return raw.slice(0, 4096); + if (raw && typeof raw === 'object') { + try { + return JSON.stringify(raw).slice(0, 4096); + } catch { + return ''; + } + } + return ''; +} + +/** + * Classify free-tier/Go quota exhaustion that should trigger proxy fallback. + * + * Fingerprint (mirrors upstream `session/retry.ts` + Zen `zen/util/handler.ts`): + * status 429 AND body/type carrying `FreeUsageLimitError` (anonymous/IP daily + * quota) or `GoUsageLimitError` (Go subscription 5h/weekly/monthly quota). + * Plain 5xx / CreditsError-401 / generic 429s without the class marker do NOT + * match — those keep the existing direct-retry path and never engage proxies. + */ +export function parseFreeLimitKind(error: unknown): 'free' | 'go' | null { + if (!error) return null; + if (readStatusCode(error) !== 429) return null; + const record = asRecord(error); + const data = asRecord(record['data']); + const pickStr = (v: unknown): string => (typeof v === 'string' ? v : ''); + const haystack = `${collectMessageParts(error)} ${readResponseBodyText(error)} ${pickStr(data['limitName'])} ${pickStr(record['reason'])} ${pickStr(data['type'])} ${pickStr(data['code'])} ${pickStr(data['name'])}`; + if (/CreditsError|insufficient balance/i.test(haystack)) return null; + if (/GoUsageLimitError|account_rate_limit/i.test(haystack)) return 'go'; + if (/FreeUsageLimitError|free_tier_limit/i.test(haystack)) return 'free'; + if (/free usage (exceeded|limit)|usage limit reached|(5-hour|weekly|monthly) usage limit/i.test(haystack)) return 'free'; + return null; +} + +/** True when the error is free/Go quota exhaustion eligible for proxy fallback. */ +export function isFreeUsageLimitError(error: unknown): boolean { + return parseFreeLimitKind(error) !== null; +} + /** * Detect transient upstream provider failures that succeed on retry. * @@ -161,6 +222,10 @@ export function normalizeBackendError(raw: unknown): NormalizedUpstreamError { if (responseHeaders && typeof responseHeaders === 'object') { err.responseHeaders = responseHeaders as Record; } + // Preserve the raw body text so isFreeUsageLimitError can match the Zen + // error class (FreeUsageLimitError/GoUsageLimitError) after normalization. + const bodyText = readResponseBodyText(raw); + if (bodyText) err.responseBody = bodyText; 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; diff --git a/src/proxy.ts b/src/proxy.ts index 3c1ce32..b00b758 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,7 +7,7 @@ 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 { buildExternalToolRegistry, normalizeToolNameForMatch } 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'; @@ -26,9 +26,11 @@ import { } from './config/proxy-config.js'; import { buildBackendAuthHeaders, ensureBackend, backendState } from './backend/manager.js'; import { createCollector } from './stream/collector.js'; +import { createProxyPool } from './upstream-proxy/pool.js'; import { registerChatRoutes } from './routes/chat.js'; import { registerResponsesRoutes } from './routes/responses.js'; import { registerMessagesRoutes } from './routes/messages.js'; +import { registerInteractionsRoutes } from './routes/interactions.js'; import { registerSystemRoutes, registerNotFoundRoute } from './routes/system.js'; import type { ProxyConfig } from './types/config.js'; import type { @@ -41,6 +43,7 @@ import type { import type { ProxyClient, ProviderInfo, ModelInfo, ResolvedModel } from './types/client.js'; import type { ResponseStateEntry } from './types/backend.js'; import { asRecord, toErrorMessage } from './utils/guards.js'; +import { buildEffectiveApiKeys, createApiKeyVerifier } from './auth/keys.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'). @@ -49,6 +52,7 @@ export { normalizeBool, resolveDisableTools, withTimeout }; export function createApp(config: ProxyConfig): CreateAppResult { const { API_KEY, + API_KEYS = [], OPENCODE_SERVER_URL, OPENCODE_SERVER_PASSWORD, REQUEST_TIMEOUT_MS = DEFAULT_REQUEST_TIMEOUT_MS, @@ -68,6 +72,10 @@ export function createApp(config: ProxyConfig): CreateAppResult { CLEANUP_INTERVAL_MS, CLEANUP_MAX_AGE_MS, OPENCODE_HOME_BASE, + UPSTREAM_PROXIES = [], + UPSTREAM_PROXY_STRATEGY = 'failover-rr', + UPSTREAM_PROXY_COOLDOWN_MS = 300000, + UPSTREAM_PROXY_NO_PROXY = [], } = config; // Effective retry budget: total attempts = 1 + maxRetries. @@ -89,14 +97,20 @@ export function createApp(config: ProxyConfig): CreateAppResult { const rawClient: OpencodeClient = createOpencodeClient({ baseUrl: OPENCODE_SERVER_URL, headers: clientHeaders }); const client = rawClient as unknown as ProxyClient; + // Multi-key auth (A): legacy single + list merge; empty = no auth (unchanged). + // Single shared verifier instance (also exposed via ctx for system routes). + const effectiveApiKeys: string[] = buildEffectiveApiKeys(API_KEY, API_KEYS); + const apiKeyVerifier = createApiKeyVerifier(effectiveApiKeys); + // 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 (apiKeyVerifier.keys.length > 0) { + const matchedIndex = apiKeyVerifier.matchedIndex( + req as unknown as { headers: { authorization?: unknown; 'x-api-key'?: unknown } }, + ); + if (matchedIndex < 0) { if (req.path === '/v1/messages') { res.status(401).json({ type: 'error', error: { type: 'authentication_error', message: 'Unauthorized' } }); return; @@ -104,6 +118,7 @@ export function createApp(config: ProxyConfig): CreateAppResult { res.status(401).json({ error: { message: 'Unauthorized' } }); return; } + (req as unknown as Record)['apiKeyId'] = `key-${matchedIndex + 1}`; } next(); }); @@ -573,7 +588,13 @@ export function createApp(config: ProxyConfig): CreateAppResult { 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}`); + if (toolId === allowedToolName || toolId.endsWith(`.${allowedToolName}`) || toolId.endsWith(`/${allowedToolName}`)) + return true; + // Separator/case-insensitive fallback so legacy `web_fetch` matches the + // real backend id `webfetch` (and `web_search` matches `websearch`). + const a = normalizeToolNameForMatch(toolId); + const b = normalizeToolNameForMatch(allowedToolName); + return a !== '' && a === b; }; const resolveInternalAllowedToolIds = ( @@ -767,10 +788,41 @@ export function createApp(config: ProxyConfig): CreateAppResult { const collector = createCollector({ client, logDebug }); const { promptWithTimeout, pollForAssistantResponse, collectFromEvents, extractFromParts } = collector; + // P3: fallback proxy pool — direct-only until a free-limit error engages it. + // proxiedFetch auto-falls-back to direct while disengaged, so the proxy + // client behaves identically to the direct one until engagement. Loopback + // targets always bypass (see pool.ts), keeping the default managed backend + // direct. SSE subscribe ignores custom fetch (SDK gap): fallback attempts + // use poll, never collectFromEvents (see routes). + const proxyPool = createProxyPool({ + proxies: UPSTREAM_PROXIES, + strategy: UPSTREAM_PROXY_STRATEGY, + cooldownMs: UPSTREAM_PROXY_COOLDOWN_MS, + noProxy: UPSTREAM_PROXY_NO_PROXY, + logDebug, + }); + let proxyClient: ProxyClient | null = null; + let proxyPromptWithTimeout = promptWithTimeout; + let proxyPollForAssistantResponse = pollForAssistantResponse; + if (proxyPool.hasProxies()) { + const proxiedFetch = (input: unknown, init?: unknown): Promise => + proxyPool.proxiedFetch(input, init) as Promise; + const rawProxyClient: OpencodeClient = createOpencodeClient({ + baseUrl: OPENCODE_SERVER_URL, + headers: clientHeaders, + fetch: proxiedFetch as unknown as (req: globalThis.Request) => Promise, + }); + proxyClient = rawProxyClient as unknown as ProxyClient; + const proxyCollector = createCollector({ client: proxyClient, logDebug }); + proxyPromptWithTimeout = proxyCollector.promptWithTimeout; + proxyPollForAssistantResponse = proxyCollector.pollForAssistantResponse; + } + const ctx: AppContext = { client, config, API_KEY: String(API_KEY ?? ''), + API_KEYS: effectiveApiKeys, OPENCODE_SERVER_URL: String(OPENCODE_SERVER_URL ?? ''), OPENCODE_SERVER_PASSWORD: String(OPENCODE_SERVER_PASSWORD ?? ''), REQUEST_TIMEOUT_MS: Number(REQUEST_TIMEOUT_MS), @@ -839,6 +891,10 @@ export function createApp(config: ProxyConfig): CreateAppResult { collectFromEvents, pollForAssistantResponse, extractFromParts, + proxyPool, + proxyClient, + proxyPromptWithTimeout, + proxyPollForAssistantResponse, getCachedToolIds: () => cachedToolIds, getCachedToolIdsAt: () => cachedToolIdsAt, }; @@ -848,6 +904,7 @@ export function createApp(config: ProxyConfig): CreateAppResult { registerChatRoutes(app, ctx); registerResponsesRoutes(app, ctx); registerMessagesRoutes(app, ctx); + registerInteractionsRoutes(app, ctx); registerNotFoundRoute(app); return { app, client }; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index aae392f..7ef2f96 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -10,6 +10,8 @@ import { createExternalToolCallStreamParser, } from '../tool-runtime/parser.js'; import { isTransientUpstreamError, transformUpstreamError } from '../errors/upstream.js'; +import { engageFallbackForFreeLimit } from '../upstream-proxy/fallback.js'; +import { detectHostedSearchTools } from '../search/grounding.js'; import { withTimeout, DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, @@ -69,6 +71,10 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { promptWithTimeout, collectFromEvents, pollForAssistantResponse, + proxyPool, + proxyClient, + proxyPromptWithTimeout, + proxyPollForAssistantResponse, } = ctx; // Chat completions endpoint @@ -83,6 +89,29 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { let mID = 'kimi-k2.5-free'; let id = `chatcmpl-${crypto.randomUUID()}`; let keepaliveInterval: ReturnType | null = null; + // P3 fallback bundle: direct by default; switched to the proxy + // bundle for subsequent attempts once a free-limit error engages it. + let activeClient = client; + let activePromptWithTimeout = promptWithTimeout; + let activePollForAssistantResponse = pollForAssistantResponse; + let fallbackToProxy = false; + // Set once the fallback branch serves content, so the shared + // post-loop poll below is skipped (avoids a duplicate poll + + // duplicate tool_calls for markup-only answers). + let fallbackServed = false; + const switchToProxyBundle = (): boolean => { + if (!proxyClient) return false; + activeClient = proxyClient; + activePromptWithTimeout = proxyPromptWithTimeout; + activePollForAssistantResponse = proxyPollForAssistantResponse; + fallbackToProxy = true; + return true; + }; + const engageProxyFallback = (err: unknown): boolean => { + if (!engageFallbackForFreeLimit(err, proxyPool)) return false; + return switchToProxyBundle(); + }; + if (proxyPool.isEngaged()) switchToProxyBundle(); try { const body = asRecord((req as unknown as { body: unknown }).body); @@ -106,6 +135,19 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { res.status(400).json({ error: { message: 'messages array is required' } }); return; } + // Hosted search (web_search) is a Responses/Interactions API tool; + // on chat completions it would be silently dropped by the function + // registry, so fail loudly with a pointer instead. + if (detectHostedSearchTools(tools).requested) { + res.status(400).json({ + error: { + message: + 'web_search is not supported on /v1/chat/completions; use POST /v1/responses with tools:[{type:"web_search"}] or POST /v1beta/interactions with tools:[{type:"google_search"}]', + type: 'invalid_request_error', + }, + }); + return; + } const reasoningLevel = normalizeReasoningEffort( (reasoning_effort as string) || (asRecord(reasoning)['effort'] as string), @@ -319,7 +361,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { // Set active model try { - await client.config.update({ + await activeClient.config.update({ body: { activeModel: { providerID: pID, modelID: mID }, }, @@ -329,7 +371,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } // Create session (bounded: a hung backend must 504, not stall). - const sessionRes = (await withTimeout(client.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + const sessionRes = (await withTimeout(activeClient.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 }); @@ -474,12 +516,12 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (attempt > 1) { try { - await client.session.delete({ path: { id: sessionId } }); + await activeClient.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(), + activeClient.session.create(), REQUEST_TIMEOUT_MS, 'create session', )) as unknown; @@ -497,24 +539,50 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { 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; + if (fallbackToProxy) { + // SSE subscribe ignores custom fetch (SDK gap): prompt+poll + // through the proxy bundle instead of the event stream. + // Deltas go out immediately so the post-loop poll is skipped. + await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + const pres = (await activePollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + )) as { content: string; reasoning: string; error: unknown }; + if (pres.reasoning) sendDelta(pres.reasoning, true); + if (pres.content) sendDelta(pres.content, false); + // Only mark served when something arrived: error-only + // results must still reach the terminal poll/error report. + if (pres.content || pres.reasoning) fallbackServed = true; + collected = { + content: pres.content, + reasoning: pres.reasoning, + ...(pres.error ? { error: pres.error } : {}), + }; + } else { + 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 })); + activeClient.session.prompt(promptParams).catch((err: unknown) => logDebug('Prompt error:', toErrorMessage(err))); + collected = (await safeCollect) as Record; + } } catch (e: unknown) { logDebug('Stream error:', toErrorMessage(e)); + if (fallbackToProxy) collected = { __error: 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 && engageProxyFallback(attemptError)) { + lastStreamAttemptError = attemptError; + continue; + } if (attemptError && nothingStreamed && attempt < maxAttempts && isTransientUpstreamError(attemptError)) { console.warn( `[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, @@ -527,12 +595,18 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } const collectedR = asRecord(collected); + // NOTE: success and __error shapes are mutually exclusive here — + // the fallback branch above stores content (no __error) while + // transport exceptions store __error only — so this recovery poll + // must always run regardless of fallbackServed. if (collected && collectedR['__error']) { logDebug('SSE collect error, falling back to polling', { sessionId, error: toErrorMessage(collectedR['__error']), }); - const { content, reasoning, error } = await pollForAssistantResponse( + // The collect error itself may be the free-limit signal. + if (!fallbackToProxy) engageProxyFallback(collectedR['__error']); + const { content, reasoning, error } = await activePollForAssistantResponse( sessionId as string, REQUEST_TIMEOUT_MS, ); @@ -547,7 +621,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } } else if (collected && collectedR['noData']) { logDebug('Fallback to polling (stream)', { sessionId }); - const { content, reasoning, error } = await pollForAssistantResponse( + const { content, reasoning, error } = await activePollForAssistantResponse( sessionId as string, REQUEST_TIMEOUT_MS, ); @@ -562,7 +636,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } } else if (collected && collectedR['idleTimeout']) { logDebug('SSE idle timeout, polling for completion', { sessionId }); - const { content, reasoning, error } = await pollForAssistantResponse( + const { content, reasoning, error } = await activePollForAssistantResponse( sessionId as string, REQUEST_TIMEOUT_MS, ); @@ -587,6 +661,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { if ( collected && + !fallbackServed && !streamedContent && !streamedReasoning && ((collectedR['reasoning'] as string) || (collectedR['content'] as string)) @@ -595,9 +670,9 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { if (collectedR['content']) sendDelta(String(collectedR['content']), false); } - if (!streamedContent && !streamedReasoning) { + if (!fallbackServed && !streamedContent && !streamedReasoning) { logDebug('SSE returned empty, falling back to polling', { sessionId }); - const { content, reasoning, error } = await pollForAssistantResponse( + const { content, reasoning, error } = await activePollForAssistantResponse( sessionId as string, REQUEST_TIMEOUT_MS, ); @@ -612,7 +687,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } } 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( + const snapshot = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS).catch( () => null, ); if (snapshot && snapshot.content) { @@ -725,12 +800,12 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } if (attempt > 1) { try { - await client.session.delete({ path: { id: sessionId } }); + await activeClient.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(), + activeClient.session.create(), REQUEST_TIMEOUT_MS, 'create session', )) as unknown; @@ -742,9 +817,9 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } const attemptStart = Date.now(); try { - await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); logDebug('Prompt sent', { sessionId, ms: Date.now() - attemptStart, attempt }); - const collected = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + const collected = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); content = collected.content || ''; reasoning = collected.reasoning || ''; error = collected.error || null; @@ -753,6 +828,9 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { reasoning = ''; error = promptError; } + if (error && !content && !reasoning && attempt < maxAttempts && engageProxyFallback(error)) { + continue; + } if (error && !content && !reasoning && attempt < maxAttempts && isTransientUpstreamError(error)) { console.warn( `[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, @@ -830,6 +908,9 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } catch (error: unknown) { console.error('[Proxy] API Error:', toErrorMessage(error)); console.error('[Proxy] Error details:', error); + // Single-shot paths (and exhausted loops): engage the pool so the + // next request benefits even though this one cannot retry further. + if (!fallbackToProxy) engageProxyFallback(error); if (keepaliveInterval) clearInterval(keepaliveInterval); @@ -842,7 +923,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } if (sessionId) { try { - await client.session.delete({ path: { id: sessionId } }); + await activeClient.session.delete({ path: { id: sessionId } }); } catch (e: unknown) { console.error('[Proxy] Failed to cleanup session on error:', toErrorMessage(e)); } diff --git a/src/routes/interactions.ts b/src/routes/interactions.ts new file mode 100644 index 0000000..7da8ccd --- /dev/null +++ b/src/routes/interactions.ts @@ -0,0 +1,439 @@ +// P5: Gemini Interactions-compatible thin layer over opencode sessions. +// +// Maps a minimal Interactions subset onto the same backend primitives the +// other routes use (resolveRequestedModel / prompt+poll / websearch grant): +// POST /v1beta/interactions (alias: POST /v1/interactions) +// { model|agent, input, system_instruction, tools:[{type:google_search}], +// previous_interaction_id, stream, store } +// +// Scope notes (thin by design): +// - Text I/O + google_search grounding only. Client-executed function tools +// are rejected (400) — use /v1/responses for the external-tool bridge. +// - Bounded retry for free-limit errors only (engage proxy pool + rotate the +// session, capped by maxAttempts like the other routes); ordinary errors +// throw immediately with no generic transient backoff. +// - `store:false` skips persistence and deletes the session after responding. +import crypto from 'crypto'; +import { withTimeout } from '../config/proxy-config.js'; +import { computeRetryDelay } from '../retry/policy.js'; +import { normalizeBackendError, transformUpstreamError } from '../errors/upstream.js'; +import { engageFallbackForFreeLimit } from '../upstream-proxy/fallback.js'; +import { + buildCitationAnnotations, + buildWebSearchCallItems, + detectHostedSearchTools, + extractSearchEvidence, + SEARCH_GROUNDING_INSTRUCTION, +} from '../search/grounding.js'; +import { ensureBackend, sleep } from '../backend/manager.js'; +import type { Application, Request, Response } from 'express'; +import type { AppContext } from '../types/context.js'; +import { asRecord, toErrorMessage } from '../utils/guards.js'; + +function textOfContent(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; + return ''; + }) + .join(''); + } + return ''; +} + +/** Minimal input mapping: string | chat-like messages | text parts. */ +function normalizeInteractionsInput(input: unknown): Array<{ role: string; content: string }> { + if (typeof input === 'string') { + return input.trim() ? [{ role: 'user', content: input }] : []; + } + if (!Array.isArray(input)) return []; + const out: Array<{ role: string; content: string }> = []; + for (const item of input as unknown[]) { + if (typeof item === 'string') { + if (item.trim()) out.push({ role: 'user', content: item }); + continue; + } + const rec = asRecord(item); + if (typeof rec['role'] === 'string') { + const text = textOfContent(rec['content'] ?? rec['text']); + if (text) out.push({ role: String(rec['role']), content: text }); + continue; + } + const type = typeof rec['type'] === 'string' ? String(rec['type']) : ''; + if (type === 'text' || type === 'input_text' || type === 'output_text') { + const text = textOfContent(rec['text'] ?? rec['content']); + if (text) out.push({ role: 'user', content: text }); + } + // user_input/model_output/thought/function_* steps: text-carrying shapes + // land above via role/content; anything else is ignored (logged by caller). + } + return out; +} + +function normalizeSystemInstruction(value: unknown): string { + if (typeof value === 'string') return value; + const rec = asRecord(value); + return textOfContent(rec['parts'] ?? rec['content'] ?? rec['text']); +} + +export function registerInteractionsRoutes(app: Application, ctx: AppContext): void { + const { + client, + config, + REQUEST_TIMEOUT_MS, + maxAttempts, + resolveRequestedModel, + logDebug, + getResponseState, + storeResponseState, + buildSystemPrompt, + createRequestToolContext, + getToolOverridesForMode, + trackToolMode, + TOOL_MODE, + promptWithTimeout, + pollForAssistantResponse, + proxyPool, + proxyClient, + proxyPromptWithTimeout, + proxyPollForAssistantResponse, + } = ctx; + + const handle = async (req: Request, res: Response): Promise => { + // P3 bundle (direct unless the pool is already engaged). + let activeClient = client; + let activePromptWithTimeout = promptWithTimeout; + let activePollForAssistantResponse = pollForAssistantResponse; + let fallbackToProxy = false; + if (proxyPool.isEngaged() && proxyClient) { + activeClient = proxyClient; + activePromptWithTimeout = proxyPromptWithTimeout; + activePollForAssistantResponse = proxyPollForAssistantResponse; + } + const engageProxyFallback = (err: unknown): boolean => { + if (!engageFallbackForFreeLimit(err, proxyPool) || !proxyClient) return false; + activeClient = proxyClient; + activePromptWithTimeout = proxyPromptWithTimeout; + activePollForAssistantResponse = proxyPollForAssistantResponse; + fallbackToProxy = true; + return true; + }; + try { + const body = asRecord((req as unknown as { body: unknown }).body); + const model: unknown = body['model']; + const agent: unknown = body['agent']; + const input: unknown = body['input']; + const systemInstruction: unknown = body['system_instruction']; + const toolsRaw: unknown = body['tools']; + const tools: unknown[] = Array.isArray(toolsRaw) ? (toolsRaw as unknown[]) : []; + const stream = body['stream'] === true; + const store = body['store'] !== false; + const previousInteractionId: unknown = body['previous_interaction_id']; + + if (!model && !agent) { + res.status(400).json({ error: { message: 'model or agent is required', type: 'invalid_request_error' } }); + return; + } + if (!model && agent) { + logDebug('Interactions agent-only request; falling back to default model resolution', { agent }); + } + const messages = normalizeInteractionsInput(input); + if (messages.length === 0) { + res.status(400).json({ error: { message: 'input is required', type: 'invalid_request_error' } }); + return; + } + const unsupported = (tools as unknown[]).filter((def) => { + const t = typeof asRecord(def)['type'] === 'string' ? String(asRecord(def)['type']).toLowerCase() : ''; + return t !== 'google_search' && t !== 'web_search' && t !== 'web_search_preview'; + }); + if (unsupported.length > 0) { + res.status(400).json({ + error: { + message: 'unsupported tools: only google_search (web_search) grounding is available on this endpoint', + type: 'invalid_request_error', + }, + }); + return; + } + const hostedSearch = detectHostedSearchTools( + (tools as unknown[]).map((def) => { + const t = typeof asRecord(def)['type'] === 'string' ? String(asRecord(def)['type']).toLowerCase() : ''; + return t === 'google_search' ? { type: 'web_search' } : def; + }), + ); + + const previousState = + typeof previousInteractionId === 'string' && previousInteractionId + ? getResponseState(previousInteractionId) + : null; + if (previousInteractionId && !previousState) { + res.status(400).json({ error: { message: 'Invalid or expired previous_interaction_id', type: 'invalid_request_error' } }); + return; + } + + const resolvedModel = (await withTimeout( + resolveRequestedModel(typeof model === 'string' && model ? 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 activeClient.config.update({ + body: { activeModel: { providerID: pID, modelID: mID } }, + }); + } catch { + // ignore + } + + // Text-only turn: no function tools, so the bridge never engages. + const requestToolContext = createRequestToolContext([], undefined, undefined); + let toolMode: string = requestToolContext.mode; + let internalToolContext = requestToolContext.internal; + if (hostedSearch.requested) { + toolMode = TOOL_MODE.INTERNAL_ALLOWLIST; + internalToolContext = { + ...internalToolContext, + allowedToolNames: [...new Set([...internalToolContext.allowedToolNames, 'websearch'])], + resolutionPath: 'hosted-search-grant', + resultingMode: toolMode, + }; + } + // P1-P4 parity: internal-tool traffic stays observable. + trackToolMode(toolMode, { + route: stream ? '/v1beta/interactions(stream)' : '/v1beta/interactions', + }); + + const systemText = normalizeSystemInstruction(systemInstruction); + + const baseToolOverrides = (await withTimeout( + getToolOverridesForMode(toolMode, internalToolContext), + REQUEST_TIMEOUT_MS, + 'load tool overrides', + )) as Record | null; + let toolOverrides = baseToolOverrides; + if (hostedSearch.requested) { + const searchOverrides = (await withTimeout( + getToolOverridesForMode(TOOL_MODE.INTERNAL_ALLOWLIST, { allowedToolNames: ['websearch'] }), + REQUEST_TIMEOUT_MS, + 'load search overrides', + )) as Record | null; + if (searchOverrides) { + const merged: Record = { ...(baseToolOverrides ?? {}) }; + for (const [id, granted] of Object.entries(searchOverrides)) { + if (granted === true) merged[id] = true; + } + toolOverrides = merged; + } + } + + let sessionId: string | null = previousState?.sessionId || null; + // Only a session created by this request may be deleted by store:false; + // deleting a reused parent session would orphan its stored state. + let sessionNewlyCreated = false; + if (!sessionId) { + sessionId = + ((asRecord(asRecord(await withTimeout(activeClient.session.create(), REQUEST_TIMEOUT_MS, 'create session'))['data'])['id'] as string | undefined) ?? null); + if (!sessionId) throw new Error('Failed to create OpenCode session'); + sessionNewlyCreated = true; + } + + const parts: Record[] = []; + const systemTexts: string[] = []; + let fullPromptText = ''; + for (const msg of messages) { + if (msg.role === 'system') { + systemTexts.push(msg.content); + continue; + } + const text = msg.role === 'user' ? msg.content : `${msg.role.toUpperCase()}: ${msg.content}`; + parts.push({ type: 'text', text }); + fullPromptText += `${text}\n\n`; + } + const systemWithGuard = buildSystemPrompt( + [systemText, ...systemTexts, hostedSearch.requested ? SEARCH_GROUNDING_INSTRUCTION : ''] + .filter(Boolean) + .join('\n\n'), + null, + toolMode, + internalToolContext.allowedToolNames, + ); + const promptParams: { path: { id: string }; body: Record } = { + path: { id: sessionId as string }, + body: { + model: { providerID: pID, modelID: mID }, + ...(systemWithGuard ? { system: systemWithGuard } : {}), + parts, + }, + }; + if (toolOverrides && Object.keys(toolOverrides).length > 0) { + promptParams.body['tools'] = toolOverrides; + } + + // Thin retry: free-limit errors engage the proxy pool and rotate the + // session (bounded by maxAttempts like the other routes). Ordinary + // errors throw immediately — no generic transient backoff here. + const promptAndPoll = async (): Promise<{ + content: string; + reasoning: string; + error: unknown; + toolParts?: unknown; + }> => { + let lastErr: unknown = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (res.destroyed || res.writableEnded) throw new Error('Client disconnected'); + if (attempt > 1) { + try { + await activeClient.session.delete({ path: { id: sessionId as string } }); + } catch (e: unknown) { + logDebug('Failed to delete retried interaction session', { error: toErrorMessage(e) }); + } + const retryRes = (await withTimeout(activeClient.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + sessionId = (asRecord(asRecord(retryRes)['data'])['id'] as string | undefined) ?? null; + if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); + promptParams.path.id = sessionId; + await sleep(computeRetryDelay(attempt - 1, lastErr)); + } + try { + await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + const polled = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + if (polled.error && !polled.content && !polled.reasoning) throw normalizeBackendError(polled.error); + return polled; + } catch (e: unknown) { + lastErr = e; + if (attempt < maxAttempts && engageProxyFallback(e)) continue; + throw normalizeBackendError(e); + } + } + throw normalizeBackendError(lastErr ?? new Error('Upstream provider error')); + }; + + const interactionId = `intr_${crypto.randomUUID()}`; + const deleteEphemeralSession = async (): Promise => { + // Only sessions created by this request may be deleted; a reused + // parent session stays alive for its stored continuation chain. + if (store || !sessionNewlyCreated) return; + try { + await activeClient.session.delete({ path: { id: sessionId as string } }); + } catch (e: unknown) { + logDebug('Failed to delete unstored interaction session', { error: toErrorMessage(e) }); + } + }; + 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); + // Stream contract: interaction.created → step.delta* → interaction.completed + // (no [DONE]; errors arrive as {type:'error'} like /v1/messages). + let clientGone = false; + res.once('close', () => { + if (!res.writableEnded) clientGone = true; + }); + const heartbeat = setInterval(() => { + if (!res.destroyed && !res.writableEnded) res.write(': heartbeat\n\n'); + }, 15000); + if (typeof heartbeat.unref === 'function') heartbeat.unref(); + const emit = (payload: unknown): void => { + if (res.destroyed || res.writableEnded) return; + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + try { + emit({ type: 'interaction.created', interaction: { id: interactionId, status: 'in_progress', model: `${pID}/${mID}` } }); + const polled = await promptAndPoll(); + if (clientGone) { + await deleteEphemeralSession(); + return; + } + const text = polled.content || ''; + // Chunked deltas keep clients alive on long answers. + for (let i = 0; i < text.length; i += 500) { + emit({ type: 'step.delta', interaction_id: interactionId, delta: text.slice(i, i + 500) }); + } + const evidence = hostedSearch.requested ? extractSearchEvidence(polled.toolParts ?? []) : { queries: [] as string[], sources: [] as { url: string; title: string }[] }; + const searchCalls = buildWebSearchCallItems(evidence); + const annotations = buildCitationAnnotations(text, evidence.sources); + const steps: Record[] = []; + searchCalls.forEach((item) => { + steps.push({ type: 'google_search_call', queries: [item.action.query] }); + }); + if (evidence.sources.length > 0) { + steps.push({ + type: 'google_search_result', + sources: evidence.sources, + note: 'surrogate grounding via opencode websearch (exa/parallel), not Google', + }); + } + steps.push({ type: 'model_output', text, annotations }); + emit({ type: 'interaction.completed', interaction: { id: interactionId, status: 'completed', output_text: text, steps } }); + if (store) storeResponseState(interactionId, sessionId as string, `${pID}/${mID}`); + await deleteEphemeralSession(); + } finally { + clearInterval(heartbeat); + } + res.end(); + return; + } + + const polled = await promptAndPoll(); + const text = polled.content || ''; + const evidence = hostedSearch.requested ? extractSearchEvidence(polled.toolParts ?? []) : { queries: [] as string[], sources: [] as { url: string; title: string }[] }; + const searchCalls = buildWebSearchCallItems(evidence); + const annotations = buildCitationAnnotations(text, evidence.sources); + const steps: Record[] = []; + searchCalls.forEach((item) => { + steps.push({ type: 'google_search_call', queries: [item.action.query] }); + }); + if (evidence.sources.length > 0) { + steps.push({ + type: 'google_search_result', + sources: evidence.sources, + note: 'surrogate grounding via opencode websearch (exa/parallel), not Google', + }); + } + steps.push({ type: 'model_output', text, annotations }); + const groundingCount = searchCalls.length; + if (store) storeResponseState(interactionId, sessionId as string, `${pID}/${mID}`); + await deleteEphemeralSession(); + res.json({ + id: interactionId, + status: 'completed', + model: `${pID}/${mID}`, + output_text: text, + steps, + usage: { grounding_tool_count: [{ type: 'google_search', count: groundingCount }] }, + }); + return; + } catch (error: unknown) { + if (!fallbackToProxy) engageProxyFallback(error); + console.error('[Proxy] Interactions API Error:', toErrorMessage(error)); + const transformed = transformUpstreamError(error); + if (!res.headersSent) { + res.status(transformed.statusCode).json(transformed.error); + } else { + try { + res.write(`data: ${JSON.stringify({ type: 'error', error: transformed.error })}\n\n`); + } catch { + // ignore + } + res.end(); + } + return; + } + }; + + app.post('/v1beta/interactions', (req: Request, res: Response): void => { + void handle(req, res); + }); + app.post('/v1/interactions', (req: Request, res: Response): void => { + void handle(req, res); + }); +} diff --git a/src/routes/messages.ts b/src/routes/messages.ts index d9a592a..46de477 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -22,6 +22,7 @@ import { createExternalToolCallStreamParser, } from '../tool-runtime/parser.js'; import { isTransientUpstreamError, normalizeBackendError, transformUpstreamError } from '../errors/upstream.js'; +import { engageFallbackForFreeLimit } from '../upstream-proxy/fallback.js'; import { withTimeout, DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, @@ -57,6 +58,10 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void promptWithTimeout, collectFromEvents, pollForAssistantResponse, + proxyPool, + proxyClient, + proxyPromptWithTimeout, + proxyPollForAssistantResponse, } = ctx; app.post('/v1/messages', async (req: Request, res: Response): Promise => { @@ -64,6 +69,25 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void await lock(async (): Promise => { let sessionId: string | null = null; let keepaliveInterval: ReturnType | null = null; + // P3 fallback bundle: direct by default; adopts the proxy bundle when + // this request hits a free-limit error (or the pool is already engaged). + let activeClient = client; + let activePromptWithTimeout = promptWithTimeout; + let activePollForAssistantResponse = pollForAssistantResponse; + let fallbackToProxy = false; + const switchToProxyBundle = (): boolean => { + if (!proxyClient) return false; + activeClient = proxyClient; + activePromptWithTimeout = proxyPromptWithTimeout; + activePollForAssistantResponse = proxyPollForAssistantResponse; + fallbackToProxy = true; + return true; + }; + const engageProxyFallback = (err: unknown): boolean => { + if (!engageFallbackForFreeLimit(err, proxyPool)) return false; + return switchToProxyBundle(); + }; + if (proxyPool.isEngaged()) switchToProxyBundle(); try { const rawBody: unknown = (req as unknown as { body: unknown }).body; const validationError = validateMessagesRequest(rawBody); @@ -77,6 +101,25 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void const messagesRaw: unknown = body['messages']; const toolsRaw: unknown = body['tools']; const tools: unknown[] = Array.isArray(toolsRaw) ? (toolsRaw as unknown[]) : []; + // Anthropic server-side web_search would be silently dropped by the + // function-tool bridge; fail loudly with a pointer instead. + const hasHostedSearch = tools.some((def: unknown) => { + const r = asRecord(def); + const type = typeof r['type'] === 'string' ? String(r['type']) : ''; + const name = typeof r['name'] === 'string' ? String(r['name']) : ''; + return type.startsWith('web_search') || name === 'web_search'; + }); + if (hasHostedSearch) { + res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: + 'web_search is not supported on /v1/messages; use POST /v1/responses with tools:[{type:"web_search"}] or POST /v1beta/interactions with tools:[{type:"google_search"}]', + }, + }); + return; + } const tool_choice: unknown = body['tool_choice']; const requestStream: unknown = body['stream']; const temperature: unknown = body['temperature']; @@ -86,6 +129,7 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void const stop_sequences: unknown = body['stop_sequences']; const thinking: unknown = body['thinking']; const stream = Boolean(requestStream); + const requestOpencodeConfig: unknown = body['opencode']; const chatMessages = anthropicMessagesToChatMessages(messagesRaw); const systemText = extractSystemText(system); if (systemText) chatMessages.unshift({ role: 'system', content: systemText } as unknown as (typeof chatMessages)[number]); @@ -110,7 +154,7 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void const mID = String(asRecord(resolvedModel)['modelID']); const publicModel = `${pID}/${mID}`; - const requestToolContext = createRequestToolContext(chatTools, chatToolChoice, undefined); + const requestToolContext = createRequestToolContext(chatTools, chatToolChoice, requestOpencodeConfig); const toolMode: string = requestToolContext.mode; const externalToolContext = requestToolContext.external; const externalToolRegistry = externalToolContext.registry; @@ -207,11 +251,11 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void ); await ensureBackend(config); try { - await client.config.update({ body: { activeModel: { providerID: pID, modelID: mID } } }); + await activeClient.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; + const sessionRes = (await withTimeout(activeClient.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'); @@ -296,11 +340,11 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void } if (attempt > 1) { try { - await client.session.delete({ path: { id: sessionId } }); + await activeClient.session.delete({ path: { id: sessionId } }); } catch { // ignore } - const r = (await withTimeout(client.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + const r = (await withTimeout(activeClient.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; @@ -308,8 +352,8 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void await sleep(computeRetryDelay(attempt - 1, error)); } try { - await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); - const collected = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + const collected = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); content = collected.content || ''; reasoning = collected.reasoning || ''; error = collected.error || null; @@ -318,6 +362,7 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void reasoning = ''; error = promptError; } + if (error && !content && !reasoning && attempt < maxAttempts && engageProxyFallback(error)) continue; if (error && !content && !reasoning && attempt < maxAttempts && isTransientUpstreamError(error)) continue; break; } @@ -419,38 +464,61 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void 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); + if (fallbackToProxy) { + // SSE subscribe ignores custom fetch (SDK gap): prompt+poll + // through the proxy bundle instead of the event stream. try { - if (sessionId) await client.session.delete({ path: { id: sessionId } }); + await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + const pres = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + if (pres.error && !pres.content && !pres.reasoning) throw normalizeBackendError(pres.error); + if (res.destroyed || res.writableEnded) { + if (keepaliveInterval) clearInterval(keepaliveInterval); + return; + } + // Raw text: sendDelta parses tool calls and filters display markup. + if (pres.content) sendDelta(pres.content, false); + if (pres.reasoning) sendDelta(pres.reasoning, true); + collected = { content: pres.content, reasoning: pres.reasoning }; } catch (e: unknown) { - logDebug('Failed to cleanup cancelled messages session', { error: toErrorMessage(e) }); + throw normalizeBackendError(e); } - try { - if (!res.destroyed) res.end(); - } catch { - // ignore + } else { + 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 })); + activeClient.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 activeClient.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; } - return; - } if (raced['error'] && !rawContent && !rawReasoning) throw normalizeBackendError(raced['error']); if (raced['__error']) { - const polled = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + // The collect error itself may be the free-limit signal. + if (!fallbackToProxy) engageProxyFallback(raced['__error']); + const polled = await activePollForAssistantResponse(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); + // Raw text through sendDelta so tool calls are parsed and raw + // snapshots stay consistent with the fallback branch above. + if (polled.content && !rawContent) sendDelta(polled.content, false); + if (polled.reasoning && !rawReasoning) sendDelta(polled.reasoning, true); + } } 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 })); @@ -521,9 +589,10 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void return; } catch (error: unknown) { if (keepaliveInterval) clearInterval(keepaliveInterval); + if (!fallbackToProxy) engageProxyFallback(error); if (sessionId) { try { - await client.session.delete({ path: { id: sessionId } }); + await activeClient.session.delete({ path: { id: sessionId } }); } catch (e: unknown) { logDebug('Failed to cleanup messages session on error', { error: toErrorMessage(e) }); } diff --git a/src/routes/responses.ts b/src/routes/responses.ts index 80d7a24..fb152fe 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -10,6 +10,15 @@ import { createExternalToolCallStreamParser, } from '../tool-runtime/parser.js'; import { isTransientUpstreamError, normalizeBackendError, transformUpstreamError } from '../errors/upstream.js'; +import { engageFallbackForFreeLimit } from '../upstream-proxy/fallback.js'; +import { + buildCitationAnnotations, + buildWebSearchCallItems, + detectHostedSearchTools, + extractSearchEvidence, + SEARCH_GROUNDING_INSTRUCTION, + stripHostedSearchTools, +} from '../search/grounding.js'; import { withTimeout, DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, @@ -60,8 +69,14 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void createForcedToolCallRequester, trackToolMode, getToolOverridesForMode, + promptWithTimeout, collectFromEvents, pollForAssistantResponse, + TOOL_MODE, + proxyPool, + proxyClient, + proxyPromptWithTimeout, + proxyPollForAssistantResponse, } = ctx; app.post('/v1/responses', async (req: Request, res: Response): Promise => { @@ -73,6 +88,26 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void responsesKeepalive = null; } }; + // P3 fallback bundle: direct by default; adopts the proxy bundle when + // this request hits a free-limit error (or the pool is already engaged). + // NOTE: declared outside try so the terminal catch can read the flag. + let activeClient = client; + let activePromptWithTimeout = promptWithTimeout; + let activePollForAssistantResponse = pollForAssistantResponse; + let fallbackToProxy = false; + const switchToProxyBundle = (): boolean => { + if (!proxyClient) return false; + activeClient = proxyClient; + activePromptWithTimeout = proxyPromptWithTimeout; + activePollForAssistantResponse = proxyPollForAssistantResponse; + fallbackToProxy = true; + return true; + }; + const engageProxyFallback = (err: unknown): boolean => { + if (!engageFallbackForFreeLimit(err, proxyPool)) return false; + return switchToProxyBundle(); + }; + if (proxyPool.isEngaged()) switchToProxyBundle(); try { const body = asRecord((req as unknown as { body: unknown }).body); const model: unknown = body['model']; @@ -105,9 +140,35 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void null, ); - const requestToolContext = createRequestToolContext(tools, tool_choice, requestOpencodeConfig); - const toolMode: string = requestToolContext.mode; - const internalToolContext = requestToolContext.internal; + // P4: hosted search tools (web_search/google_search) are explicit client + // grants for server-side grounding: keep them out of the external + // function registry and drive opencode `websearch` instead. + const hostedSearch = detectHostedSearchTools(tools); + const bridgeTools = stripHostedSearchTools(tools); + const requestToolContext = createRequestToolContext(bridgeTools, tool_choice, requestOpencodeConfig); + let toolMode: string = requestToolContext.mode; + let internalToolContext = requestToolContext.internal; + if (hostedSearch.requested && toolMode === TOOL_MODE.DISABLED) { + toolMode = TOOL_MODE.INTERNAL_ALLOWLIST; + internalToolContext = { + ...internalToolContext, + allowedToolNames: ['websearch'], + requestedAllowlist: null, + deniedRequestedTools: [], + resolutionPath: 'hosted-search-grant', + resultingMode: toolMode, + metricsEnabled: internalToolContext.metricsEnabled, + }; + } else if (hostedSearch.requested && toolMode === TOOL_MODE.INTERNAL_ALLOWLIST) { + if (!internalToolContext.allowedToolNames.includes('websearch')) { + internalToolContext = { + ...internalToolContext, + allowedToolNames: [...internalToolContext.allowedToolNames, 'websearch'], + resolutionPath: 'hosted-search-grant', + resultingMode: toolMode, + }; + } + } trackToolMode(toolMode, { configuredAllowlist: internalToolContext.allowedToolNames, requestedAllowlist: internalToolContext.requestedAllowlist, @@ -287,7 +348,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void await ensureBackend(config); try { - await client.config.update({ + await activeClient.config.update({ body: { activeModel: { providerID: pID, modelID: mID } }, }); } catch { @@ -296,7 +357,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void let sessionId: string | null = previousState?.sessionId || null; if (!sessionId) { - const sessionRes = (await withTimeout(client.session.create(), REQUEST_TIMEOUT_MS, 'create session')) as unknown; + const sessionRes = (await withTimeout(activeClient.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'); @@ -325,17 +386,46 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } const systemWithGuard = buildSystemPrompt( - [instructions, ...systemChunks, externalToolContext.prompt].filter(Boolean).join('\n\n'), + [ + instructions, + ...systemChunks, + externalToolContext.prompt, + hostedSearch.requested ? SEARCH_GROUNDING_INSTRUCTION : '', + ] + .filter(Boolean) + .join('\n\n'), reasoningLevel, toolMode, internalToolContext.allowedToolNames, ); - const toolOverrides = (await withTimeout( + const baseToolOverrides = (await withTimeout( getToolOverridesForMode(toolMode, internalToolContext), REQUEST_TIMEOUT_MS, 'load tool overrides', )) as Record | null; + // P4: union the websearch grant onto any mode (bridge/disabled modes + // otherwise force all-false and would switch the grounding tool back + // off). Only `true` grants merge — never copy `false` entries, which + // would extinguish an existing allowlist (e.g. webfetch) or a backend + // without websearch (all-false disabled fallback). + let toolOverrides = baseToolOverrides; + if (hostedSearch.requested) { + const searchOverrides = (await withTimeout( + getToolOverridesForMode(TOOL_MODE.INTERNAL_ALLOWLIST, { + allowedToolNames: ['websearch'], + }), + REQUEST_TIMEOUT_MS, + 'load search overrides', + )) as Record | null; + if (searchOverrides) { + const merged: Record = { ...(baseToolOverrides ?? {}) }; + for (const [id, granted] of Object.entries(searchOverrides)) { + if (granted === true) merged[id] = true; + } + toolOverrides = merged; + } + } const makeForcedResponsesToolCallRequester = (): (() => Promise | null>) => createForcedToolCallRequester({ mode: externalToolChoice.mode, @@ -379,6 +469,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void const buildResponsesMessageOutputItem = ( text: unknown, messageId: string = `msg_${crypto.randomUUID()}`, + annotations: unknown[] = [], ): Record | null => { if (!text || (typeof text === 'string' && !text)) return null; if (typeof text === 'string' && !text.trim()) return null; @@ -392,7 +483,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void { type: 'output_text', text, - annotations: [], + annotations, }, ], }; @@ -552,47 +643,64 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void 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 + if (fallbackToProxy) { + // SSE subscribe ignores custom fetch (SDK gap): prompt through the + // proxy bundle; the poll below (line ~614) fills content/reasoning. + await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + } else { + 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 })); + activeClient.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 activeClient.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; } - return; + collected = raced as Record; } - collected = raced as Record; } catch (e: unknown) { collected = { __error: e }; } const collectedR = asRecord(collected); + // P4: retain tool parts from recovery polls for grounding evidence. + let streamSearchToolParts: unknown[] = []; + const keepStreamToolParts = (polled: { toolParts?: unknown }): void => { + if (hostedSearch.requested && Array.isArray(polled.toolParts) && polled.toolParts.length > 0) { + streamSearchToolParts = polled.toolParts; + } + }; if (!content && !reasoning) { - const polled = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + // The collect error itself may be the free-limit signal. + if (collectedR['__error'] && !fallbackToProxy) engageProxyFallback(collectedR['__error']); + const polled = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); if (polled.error && !polled.content && !polled.reasoning) throw normalizeBackendError(polled.error); + keepStreamToolParts(polled); 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); + const polled = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); if (polled.error && !polled.content && !polled.reasoning) throw normalizeBackendError(polled.error); + keepStreamToolParts(polled); const remainingReasoning = polled.reasoning && polled.reasoning.startsWith(rawReasoning) ? polled.reasoning.slice(rawReasoning.length) @@ -606,6 +714,56 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void if (!content && collectedR['content']) sendResponsesDelta(String(collectedR['content']), false); } + // P4: grounding evidence for citations + web_search_call items. + // Reuses recovery-poll parts when present; otherwise one capped + // best-effort read. Skipped unless the client asked for hosted search. + if (hostedSearch.requested && streamSearchToolParts.length === 0) { + try { + const evPoll = await activePollForAssistantResponse( + sessionId as string, + Math.min(15000, REQUEST_TIMEOUT_MS), + ); + if (Array.isArray(evPoll.toolParts)) streamSearchToolParts = evPoll.toolParts; + } catch { + // ignore — citations are best-effort + } + } + const streamEvidence = hostedSearch.requested + ? extractSearchEvidence(streamSearchToolParts) + : { queries: [] as string[], sources: [] as { url: string; title: string }[] }; + const streamSearchCallItems = buildWebSearchCallItems(streamEvidence); + const emitWebSearchCallItem = (item: { id: string }): void => { + const outputIndex = nextOutputIndex++; + emit({ + type: 'response.output_item.added', + sequence_number: nextSeq(), + output_index: outputIndex, + item: { id: item.id, type: 'web_search_call', status: 'in_progress' }, + }); + emit({ + type: 'response.web_search_call.searching', + sequence_number: nextSeq(), + output_index: outputIndex, + item_id: item.id, + }); + emit({ + type: 'response.web_search_call.completed', + sequence_number: nextSeq(), + output_index: outputIndex, + item_id: item.id, + }); + }; + // Search items first so emission order matches the final output array. + streamSearchCallItems.forEach((item) => { + emitWebSearchCallItem(item); + emit({ + type: 'response.output_item.done', + sequence_number: nextSeq(), + output_index: nextOutputIndex - 1, + item: { ...item }, + }); + }); + if (announcedReasoning) { emit({ type: 'response.reasoning_summary_text.done', @@ -626,6 +784,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void const hasMeaningfulContent = Boolean(content && content.trim()); if (announcedContent && hasMeaningfulContent) { + const contentAnnotations = buildCitationAnnotations(content, streamEvidence.sources); emit({ type: 'response.output_text.done', sequence_number: nextSeq(), @@ -640,20 +799,20 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void output_index: messageOutputIndex, content_index: contentIndex, item_id: outputItemId, - part: { type: 'output_text', text: content, annotations: [] }, + part: { type: 'output_text', text: content, annotations: contentAnnotations }, }); 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: [] }] }, + item: { id: outputItemId, type: 'message', status: 'completed', role: 'assistant', content: [{ type: 'output_text', text: content, annotations: contentAnnotations }] }, }); } - let polledForToolCalls: { content: string; reasoning: string; error: unknown } | null = null; + let polledForToolCalls: { content: string; reasoning: string; error: unknown; toolParts?: unknown } | null = null; if (externalToolRegistry.length > 0 && streamedToolCalls.length === 0) { try { - polledForToolCalls = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + polledForToolCalls = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); } catch { // ignore } @@ -704,9 +863,12 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void }); } const streamOutput: Record[] = []; + streamSearchCallItems.forEach((item) => streamOutput.push({ ...item })); + const streamAnnotations = buildCitationAnnotations(safeContent, streamEvidence.sources); const streamMessageOutputItem = buildResponsesMessageOutputItem( safeContent && safeContent.trim() ? safeContent : '', outputItemId, + streamAnnotations, ); if (streamMessageOutputItem) streamOutput.push(streamMessageOutputItem); validatedStreamedToolCalls.forEach((toolCall) => { @@ -759,6 +921,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void let promptParsedToolCalls: FinalToolCall[] = []; let polledFilled = false; let lastResponsesAttemptError: unknown = null; + let searchToolParts: unknown[] = []; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (res.writableEnded || res.destroyed) { logDebug('Client gone, stop retrying', { sessionId, attempt }); @@ -766,20 +929,20 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } if (attempt > 1) { try { - await client.session.delete({ path: { id: sessionId } }); + await activeClient.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; + const retrySessionRes = (await withTimeout(activeClient.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; + let polledResponse: { content: string; reasoning: string; error: unknown; toolParts?: unknown } | null = null; try { - responseRes = await withTimeout(client.session.prompt(promptParams), REQUEST_TIMEOUT_MS, 'prompt backend'); + responseRes = await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); const dataParts: unknown = asRecord(asRecord(responseRes)['data'])['parts']; responseParts = Array.isArray(dataParts) ? (dataParts as unknown[]) : []; promptContent = responseParts @@ -795,8 +958,12 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) : []; if (promptContent || promptReasoning) break; - polledResponse = await pollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + polledResponse = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); } catch (loopError: unknown) { + if (attempt < maxAttempts && engageProxyFallback(loopError)) { + lastResponsesAttemptError = loopError; + continue; + } if (attempt < maxAttempts && isTransientUpstreamError(loopError)) { console.warn( `[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, @@ -808,6 +975,10 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void throw normalizeBackendError(loopError); } if (polledResponse && polledResponse.error && !polledResponse.content && !polledResponse.reasoning) { + if (attempt < maxAttempts && engageProxyFallback(polledResponse.error)) { + lastResponsesAttemptError = polledResponse.error; + continue; + } if (attempt < maxAttempts && isTransientUpstreamError(polledResponse.error)) { console.warn( `[Proxy] Transient upstream error (attempt ${attempt}/${maxAttempts}), retrying:`, @@ -822,10 +993,27 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void content = polledResponse.content || content; reasoning = polledResponse.reasoning || reasoning; polledFilled = true; + if (Array.isArray(polledResponse.toolParts) && polledResponse.toolParts.length > 0) { + searchToolParts = polledResponse.toolParts; + } } break; } + // P4: the loop breaks early once prompt parts carry text, skipping the + // poll that surfaces server-side tool executions. One capped best-effort + // read so grounding evidence is not lost when the answer came with the prompt. + if (hostedSearch.requested && searchToolParts.length === 0 && sessionId) { + try { + const evPoll = await activePollForAssistantResponse(sessionId as string, Math.min(15000, REQUEST_TIMEOUT_MS)); + if (Array.isArray(evPoll.toolParts) && evPoll.toolParts.length > 0) { + searchToolParts = evPoll.toolParts; + } + } catch { + // ignore — citations are best-effort + } + } + if (!polledFilled) { content = promptParsedToolCalls.length > 0 ? '' : promptContent; reasoning = promptReasoning; @@ -866,11 +1054,19 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string; const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string; + // P4: server-side grounding evidence → web_search_call items + citations. + const searchEvidence = hostedSearch.requested + ? extractSearchEvidence(searchToolParts) + : { queries: [] as string[], sources: [] as { url: string; title: string }[] }; + const searchCallItems = buildWebSearchCallItems(searchEvidence); + const citationAnnotations = buildCitationAnnotations(safeContent, searchEvidence.sources); + 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); + searchCallItems.forEach((item) => output.push(item as unknown as Record)); + const messageOutputItem = buildResponsesMessageOutputItem(safeContent, undefined, citationAnnotations); if (messageOutputItem) output.push(messageOutputItem); validatedToolCalls.forEach((toolCall) => { const record = toolCall as unknown as Record; @@ -914,6 +1110,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } catch (error: unknown) { stopResponsesKeepalive(); console.error('[Proxy] Responses API Error:', toErrorMessage(error)); + if (!fallbackToProxy) engageProxyFallback(error); const transformed = transformUpstreamError(error); if (res.headersSent) { try { diff --git a/src/routes/system.ts b/src/routes/system.ts index 57d21d0..39904eb 100644 --- a/src/routes/system.ts +++ b/src/routes/system.ts @@ -1,6 +1,7 @@ // 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'; +import { buildEffectiveApiKeys, createApiKeyVerifier } from '../auth/keys.js'; function toErrorMessage(e: unknown): string { return e instanceof Error ? e.message : String((e as Record)?.['message'] ?? e); @@ -9,6 +10,7 @@ function toErrorMessage(e: unknown): string { export function registerSystemRoutes(app: Application, ctx: AppContext): void { const { API_KEY, + API_KEYS = [], INTERNAL_TOOL_METRICS_ENABLED, INTERNAL_TOOL_DISCOVERY_FIXTURE, HEALTH_DETAILS_ENABLED, @@ -22,13 +24,17 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { internalToolMetrics, getCachedToolIds, getCachedToolIdsAt, + proxyPool, } = ctx; + // ctx.API_KEYS is already the effective list built by createApp; the merge + // below is idempotent and only covers hand-built contexts in tests. + const effectiveApiKeys: string[] = + Array.isArray(API_KEYS) && API_KEYS.length > 0 ? [...API_KEYS] : buildEffectiveApiKeys(API_KEY, []); + const apiKeyVerifier = createApiKeyVerifier(effectiveApiKeys); 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), + if (apiKeyVerifier.keys.length === 0) return true; + return apiKeyVerifier.isAuthorized( + req as unknown as { headers: { authorization?: unknown; 'x-api-key'?: unknown } }, ); }; @@ -101,6 +107,7 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { 'resultingMode', ], }, + fallback_proxies: proxyPool ? proxyPool.getStatus() : null, }, }); }); @@ -117,6 +124,7 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { } const cached = getCachedToolIds(); + const proxyStatus = proxyPool ? proxyPool.getStatus() : null; 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', @@ -132,6 +140,12 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { '# 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}`, + '# HELP opencode_fallback_proxy_configured Number of configured fallback proxies.', + '# TYPE opencode_fallback_proxy_configured gauge', + `opencode_fallback_proxy_configured ${proxyStatus ? proxyStatus.configured : 0}`, + '# HELP opencode_fallback_proxy_engaged Whether the fallback proxy pool is engaged (1) or direct (0).', + '# TYPE opencode_fallback_proxy_engaged gauge', + `opencode_fallback_proxy_engaged ${proxyStatus && proxyStatus.engaged ? 1 : 0}`, ]; res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); diff --git a/src/search/grounding.ts b/src/search/grounding.ts new file mode 100644 index 0000000..1b24d03 --- /dev/null +++ b/src/search/grounding.ts @@ -0,0 +1,180 @@ +// Server-side search grounding helpers (pure; no Express/SDK deps). +// +// Maps OpenAI-style hosted search tools (`tools:[{type:web_search}]`) onto the +// opencode built-in `websearch` tool and rebuilds `web_search_call` output +// items + `url_citation` annotations from the backend tool parts. + +export const HOSTED_SEARCH_TOOL_TYPES = ['web_search', 'web_search_preview', 'google_search']; + +export const SEARCH_GROUNDING_INSTRUCTION = + 'A built-in web search tool (websearch) is enabled for this turn. ' + + 'When the request needs fresh or external facts, call websearch first and answer ' + + 'only from the conversation plus the search results. ' + + 'Include the source URLs you relied on verbatim in your answer so they can be cited.'; + +function asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record; + return {}; +} + +function toolTypeOf(def: unknown): string { + return typeof asRecord(def)['type'] === 'string' ? String(asRecord(def)['type']) : ''; +} + +export interface HostedSearchRequest { + requested: boolean; + kinds: string[]; +} + +/** Detect OpenAI/Gemini hosted search tools in a Responses `tools` array. */ +export function detectHostedSearchTools(tools: unknown): HostedSearchRequest { + if (!Array.isArray(tools)) return { requested: false, kinds: [] }; + const kinds: string[] = []; + for (const def of tools as unknown[]) { + const t = toolTypeOf(def).toLowerCase(); + if (HOSTED_SEARCH_TOOL_TYPES.includes(t) && !kinds.includes(t)) kinds.push(t); + } + return { requested: kinds.length > 0, kinds }; +} + +/** Remove hosted search defs so the external-tool registry stays function-only. */ +export function stripHostedSearchTools(tools: unknown): unknown[] { + if (!Array.isArray(tools)) return []; + return (tools as unknown[]).filter((def) => !HOSTED_SEARCH_TOOL_TYPES.includes(toolTypeOf(def).toLowerCase())); +} + +export interface SearchSource { + url: string; + title: string; +} + +export interface SearchEvidence { + queries: string[]; + sources: SearchSource[]; +} + +const URL_RE = /https?:\/\/[^\s<>"')\]]+/g; + +function cleanUrl(raw: string): string | null { + const m = raw.match(URL_RE); + if (!m || !m[0]) return null; + // Trim trailing punctuation the regex may have swallowed. + return m[0].replace(/[.,;:!?]+$/, ''); +} + +/** Extract http(s) URLs from free text, deduped, capped. */ +export function extractUrls(text: unknown, cap = 10): string[] { + if (typeof text !== 'string' || !text) return []; + const out: string[] = []; + for (const match of text.matchAll(URL_RE)) { + const u = cleanUrl(match[0]); + if (u && !out.includes(u)) out.push(u); + if (out.length >= cap) break; + } + return out; +} + +function titleFor(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +function inputQueryText(input: unknown): string { + if (typeof input === 'string') return input; + const rec = asRecord(input); + for (const key of ['query', 'q', 'question', 'text']) { + if (typeof rec[key] === 'string' && (rec[key] as string).trim()) return (rec[key] as string).trim(); + } + return ''; +} + +function outputText(output: unknown): string { + if (typeof output === 'string') return output; + if (output === null || output === undefined) return ''; + const rec = asRecord(output); + for (const key of ['text', 'content', 'result', 'output', 'summary']) { + if (typeof rec[key] === 'string' && (rec[key] as string)) return rec[key] as string; + } + try { + return JSON.stringify(output); + } catch { + return ''; + } +} + +/** + * Rebuild search evidence from backend `type:'tool'` parts. Only completed + * `websearch` executions count; anything else is ignored. URLs are taken + * exclusively from tool output text that the model could have seen — never + * invented — so downstream citations stay honest. + */ +export function extractSearchEvidence(toolParts: unknown): SearchEvidence { + const queries: string[] = []; + const sources: SearchSource[] = []; + if (!Array.isArray(toolParts)) return { queries, sources }; + for (const part of toolParts as unknown[]) { + const pr = asRecord(part); + if (pr['type'] !== 'tool') continue; + const toolName = String(pr['tool'] ?? ''); + if (toolName !== 'websearch') continue; + const state = asRecord(pr['state']); + if (state['status'] !== 'completed') continue; + const q = inputQueryText(state['input']); + if (q && !queries.includes(q)) queries.push(q); + for (const url of extractUrls(outputText(state['output']))) { + if (!sources.some((s) => s.url === url)) sources.push({ url, title: titleFor(url) }); + } + } + return { queries, sources }; +} + +export interface WebSearchCallItem { + id: string; + type: 'web_search_call'; + status: 'completed'; + action: { type: 'search'; query: string }; +} + +/** One `web_search_call` output item per executed query. */ +export function buildWebSearchCallItems(evidence: SearchEvidence, idPrefix = 'ws'): WebSearchCallItem[] { + return evidence.queries.map((query, i) => ({ + id: `${idPrefix}_${i + 1}`, + type: 'web_search_call', + status: 'completed', + action: { type: 'search', query }, + })); +} + +export interface CitationAnnotation { + type: 'url_citation'; + start_index: number; + end_index: number; + url: string; + title: string; +} + +/** + * Build `url_citation` annotations for source URLs verifiably present in the + * answer text (substring match). Sources absent from the text yield nothing — + * an empty list beats a hallucinated citation. + */ +export function buildCitationAnnotations(text: unknown, sources: SearchSource[], cap = 10): CitationAnnotation[] { + if (typeof text !== 'string' || !text) return []; + const out: CitationAnnotation[] = []; + for (const source of sources) { + if (out.length >= cap) break; + const idx = text.indexOf(source.url); + if (idx < 0) continue; + out.push({ + type: 'url_citation', + start_index: idx, + end_index: idx + source.url.length, + url: source.url, + title: source.title, + }); + } + return out; +} diff --git a/src/stream/collector.ts b/src/stream/collector.ts index 0b4d1b7..6e1b33f 100644 --- a/src/stream/collector.ts +++ b/src/stream/collector.ts @@ -115,7 +115,7 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { sessionId: string, timeoutMs: number, intervalMs: number = DEFAULT_POLL_INTERVAL_MS, - ): Promise<{ content: string; reasoning: string; error: unknown }> { + ): Promise<{ content: string; reasoning: string; error: unknown; toolParts: unknown[] }> { const pollStart = Date.now(); const startedAt = Date.now(); // Best-effort snapshot of the most recent in-flight assistant message. Polling @@ -123,7 +123,7 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { // 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; + let lastPartial: { content: string; reasoning: string; error: null; toolParts: unknown[] } | null = null; while (Date.now() - startedAt < timeoutMs) { const messagesRes: unknown = await client.session.messages({ path: { id: sessionId } }); const messages = extractMessagesList(messagesRes); @@ -170,10 +170,10 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { reasoningLen: reasoning.length, error: error ? (asRecord(error)['name'] as unknown) : null, }); - return { content, reasoning, error }; + return { content, reasoning, error, toolParts }; } if (content || reasoning) { - lastPartial = { content, reasoning, error: null }; + lastPartial = { content, reasoning, error: null, toolParts }; } break; } diff --git a/src/tool-runtime/registry.ts b/src/tool-runtime/registry.ts index cd18664..7d62b59 100644 --- a/src/tool-runtime/registry.ts +++ b/src/tool-runtime/registry.ts @@ -240,7 +240,10 @@ export function findExternalToolByName(registry: unknown, name: unknown): Extern return matches.length === 1 && matches[0] ? matches[0] : null; } -function normalizeToolNameForMatch(name: unknown): string { +// Shared with the internal-allowlist matcher (proxy.ts) so `web_fetch` and +// `webfetch` (likewise `web_search`/`websearch`) resolve identically on both +// the external-bridge and internal-allowlist paths. +export function normalizeToolNameForMatch(name: unknown): string { return String(name || '').toLowerCase().replace(/[^a-z0-9]/g, ''); } diff --git a/src/types/config.ts b/src/types/config.ts index 915008e..13837f8 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -3,6 +3,7 @@ export interface ProxyConfig { PORT: number; API_KEY: string; + API_KEYS: string[]; OPENCODE_SERVER_URL: string; OPENCODE_SERVER_PASSWORD: string; OPENCODE_PATH: string; @@ -30,6 +31,10 @@ export interface ProxyConfig { CLEANUP_MAX_AGE_MS: number; OPENCODE_HOME_BASE: string | null; RETRY_MAX_RETRIES?: unknown; + UPSTREAM_PROXIES: string[]; + UPSTREAM_PROXY_STRATEGY: string; + UPSTREAM_PROXY_COOLDOWN_MS: number; + UPSTREAM_PROXY_NO_PROXY: string[]; [key: string]: unknown; } @@ -37,6 +42,7 @@ export interface ProxyConfig { export interface ProxyConfigOptions { PORT?: unknown; API_KEY?: unknown; + API_KEYS?: unknown; OPENCODE_SERVER_URL?: unknown; OPENCODE_SERVER_PASSWORD?: unknown; OPENCODE_PATH?: unknown; @@ -69,6 +75,10 @@ export interface ProxyConfigOptions { CLEANUP_MAX_AGE_MS?: unknown; OPENCODE_HOME_BASE?: unknown; RETRY_MAX_RETRIES?: unknown; + UPSTREAM_PROXIES?: unknown; + UPSTREAM_PROXY_STRATEGY?: unknown; + UPSTREAM_PROXY_COOLDOWN_MS?: unknown; + UPSTREAM_PROXY_NO_PROXY?: unknown; PROMPT_MODE_ALIAS?: unknown; [key: string]: unknown; } diff --git a/src/types/context.ts b/src/types/context.ts index fe0865b..c757626 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,6 +1,7 @@ import type { ProxyConfig } from './config.js'; import type { ProxyClient, ResolvedModel, ModelInfo, ProviderInfo } from './client.js'; import type { ResponseStateEntry } from './backend.js'; +import type { UpstreamProxyPool } from '../upstream-proxy/pool.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'; @@ -55,7 +56,7 @@ export interface CollectorHandle { sessionId: string, timeoutMs: number, intervalMs?: number - ) => Promise<{ content: string; reasoning: string; error: unknown }>; + ) => Promise<{ content: string; reasoning: string; error: unknown; toolParts: unknown[] }>; collectFromEvents: ( sessionId: string, timeoutMs: number, @@ -78,6 +79,7 @@ export interface AppContext { client: ProxyClient; config: ProxyConfig; API_KEY: string; + API_KEYS: string[]; OPENCODE_SERVER_URL: string; OPENCODE_SERVER_PASSWORD: string; REQUEST_TIMEOUT_MS: number; @@ -150,6 +152,11 @@ export interface AppContext { collectFromEvents: CollectorHandle['collectFromEvents']; pollForAssistantResponse: CollectorHandle['pollForAssistantResponse']; extractFromParts: CollectorHandle['extractFromParts']; + /** Fallback proxy pool (P3): direct-only when unconfigured. */ + proxyPool: UpstreamProxyPool; + proxyClient: ProxyClient | null; + proxyPromptWithTimeout: CollectorHandle['promptWithTimeout']; + proxyPollForAssistantResponse: CollectorHandle['pollForAssistantResponse']; getCachedToolIds: () => string[] | null; getCachedToolIdsAt: () => number; } diff --git a/src/types/errors.ts b/src/types/errors.ts index ec403c6..a270dc3 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -7,6 +7,7 @@ export interface NormalizedUpstreamError extends Error { data?: unknown; availableModels?: unknown; responseHeaders?: Record | null; + responseBody?: string | null; isRetryable?: boolean; cause?: unknown; } @@ -20,6 +21,7 @@ export interface RawBackendErrorLike { statusCode?: unknown; isRetryable?: unknown; responseHeaders?: unknown; + responseBody?: unknown; data?: unknown; [key: string]: unknown; } diff --git a/src/upstream-proxy/fallback.ts b/src/upstream-proxy/fallback.ts new file mode 100644 index 0000000..a7b649d --- /dev/null +++ b/src/upstream-proxy/fallback.ts @@ -0,0 +1,30 @@ +// Free-limit → proxy-fallback glue (thin; routes call these at retry gates). +import { isFreeUsageLimitError, parseFreeLimitKind } from '../errors/upstream.js'; +import type { UpstreamProxyPool } from './pool.js'; + +/** + * True when this error should abandon the direct path and fail over to the + * proxy pool (pool must be configured; loopback targets bypass regardless). + */ +export function shouldFallbackToProxy(error: unknown, pool: UpstreamProxyPool | null | undefined): boolean { + if (!pool || !pool.hasProxies()) return false; + return isFreeUsageLimitError(error); +} + +/** + * Engage the pool (rotates to the next healthy proxy) and warn once. + * Returns true when fallback is active and the caller should retry via proxy. + */ +export function engageFallbackForFreeLimit( + error: unknown, + pool: UpstreamProxyPool | null | undefined, + log?: (...args: unknown[]) => void, +): boolean { + if (!shouldFallbackToProxy(error, pool)) return false; + const kind = parseFreeLimitKind(error); + const label = (pool as UpstreamProxyPool).engage(kind ?? 'free-limit'); + if (!label) return false; + const warn = typeof log === 'function' ? log : console.warn; + warn(`[Proxy] Free usage limit detected (${kind ?? 'free'}), failing over to proxy ${label}`); + return true; +} diff --git a/src/upstream-proxy/pool.ts b/src/upstream-proxy/pool.ts new file mode 100644 index 0000000..83a09bb --- /dev/null +++ b/src/upstream-proxy/pool.ts @@ -0,0 +1,248 @@ +// Lazy upstream-proxy pool: direct by default, proxy only while engaged. +// +// Engagement is triggered exclusively by free/Go quota exhaustion +// (see isFreeUsageLimitError in ../errors/upstream.ts). Ordinary 5xx / +// transport errors keep the existing direct-retry path and never engage. +// +// Loopback targets (localhost/127.0.0.1/::1) always bypass the proxy so the +// default managed backend (`http://127.0.0.1:10001`) is never proxied. +import { ProxyAgent, Socks5ProxyAgent, fetch as undiciFetch } from 'undici'; +import type { Dispatcher } from 'undici'; + +export type ProxyStrategy = 'failover-rr' | 'round-robin' | 'random'; + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); + +function maskProxyUrl(raw: string): string { + try { + const u = new URL(raw); + return `${u.protocol}//${u.host}`; + } catch { + return '(invalid)'; + } +} + +function isSupportedProxyUrl(raw: string): boolean { + try { + const u = new URL(raw.trim()); + return ['http:', 'https:', 'socks:', 'socks5:', 'socks5h:'].includes(u.protocol) && Boolean(u.hostname); + } catch { + return false; + } +} + +/** Parse comma-separated / array proxy sources into validated URL strings. + * Stable socks-first ordering: socks* entries keep their relative order and + * move ahead of http(s) ones (SOCKS5 is the preferred egress). */ +export function parseProxyList(value: unknown): string[] { + const raw: unknown[] = Array.isArray(value) ? (value as unknown[]) : [value]; + const out: string[] = []; + const push = (entry: unknown): void => { + if (typeof entry !== 'string') return; + for (const part of entry.split(',')) { + const t = part.trim(); + if (t && isSupportedProxyUrl(t) && !out.includes(t)) out.push(t); + } + }; + for (const entry of raw) { + if (Array.isArray(entry)) { + for (const nested of entry as unknown[]) push(nested); + continue; + } + push(entry); + } + const isSocks = (url: string): boolean => { + try { + const p = new URL(url).protocol; + return p === 'socks:' || p === 'socks5:' || p === 'socks5h:'; + } catch { + return false; + } + }; + // Array.prototype.sort is stable: relative order within each class is kept. + out.sort((a, b) => Number(!isSocks(a)) - Number(!isSocks(b))); + return out; +} + +export function parseProxyNoProxyList(value: unknown, fallback: string[]): string[] { + if (value === undefined || value === null || value === '') return [...fallback]; + const list = (Array.isArray(value) ? (value as unknown[]) : String(value).split(',')) + .map((e) => { + const t = String(e ?? '').trim().toLowerCase(); + return t.startsWith('[') && t.endsWith(']') ? t.slice(1, -1) : t; + }) + .filter(Boolean); + return list.length > 0 ? [...new Set(list)] : [...fallback]; +} + +export const DEFAULT_PROXY_NO_PROXY = ['localhost', '127.0.0.1', '::1']; +export const DEFAULT_PROXY_COOLDOWN_MS = 300000; + +export interface ProxyPoolOptions { + proxies?: unknown; + strategy?: unknown; + cooldownMs?: unknown; + noProxy?: unknown; + logDebug?: (...args: unknown[]) => void; +} + +export interface ProxyPoolStatus { + configured: number; + engaged: boolean; + engagedUntil: number | null; + current: string | null; + cooled: Array<{ proxy: string; until: number }>; +} + +export interface UpstreamProxyPool { + readonly size: number; + hasProxies: () => boolean; + isEngaged: () => boolean; + engage: (reason?: string) => string | null; + disengage: () => void; + proxiedFetch: (input: unknown, init?: unknown) => Promise; + getStatus: () => ProxyPoolStatus; +} + +function targetHost(input: unknown): string { + const normalize = (h: string): string => { + const t = h.trim().toLowerCase(); + // WHATWG URL keeps IPv6 brackets in hostname ("[::1]"); strip them so + // the loopback/no-proxy comparison hits. + return t.startsWith('[') && t.endsWith(']') ? t.slice(1, -1) : t; + }; + try { + if (typeof input === 'string') return normalize(new URL(input).hostname); + const req = input as { url?: unknown }; + if (typeof req?.url === 'string') return normalize(new URL(req.url).hostname); + } catch { + // fall through + } + return ''; +} + +export function createProxyPool(options: ProxyPoolOptions = {}): UpstreamProxyPool { + const proxies = parseProxyList(options.proxies); + const rawStrategy = typeof options.strategy === 'string' ? options.strategy.trim().toLowerCase() : ''; + const strategy: ProxyStrategy = + rawStrategy === 'round-robin' || rawStrategy === 'random' || rawStrategy === 'failover-rr' + ? (rawStrategy as ProxyStrategy) + : 'failover-rr'; + const cooldownMs = + typeof options.cooldownMs === 'number' && Number.isFinite(options.cooldownMs) && options.cooldownMs > 0 + ? Math.floor(options.cooldownMs) + : DEFAULT_PROXY_COOLDOWN_MS; + const noProxy = new Set(parseProxyNoProxyList(options.noProxy, DEFAULT_PROXY_NO_PROXY)); + const logDebug = typeof options.logDebug === 'function' ? options.logDebug : (): void => {}; + // NOTE: undici logs an ExperimentalWarning for SOCKS5 support on first use; + // expected and harmless (see pool docs). + + const dispatchers = new Map(); + const cooledUntil = new Map(); + let cursor = 0; + let engagedUntil = 0; + let current: string | null = null; + + const getDispatcher = (proxyUrl: string): Dispatcher => { + const hit = dispatchers.get(proxyUrl); + if (hit) return hit; + const protocol = new URL(proxyUrl).protocol; + const agent: Dispatcher = + protocol === 'socks:' || protocol === 'socks5:' || protocol === 'socks5h:' + ? ((new Socks5ProxyAgent(proxyUrl) as unknown) as Dispatcher) + : ((new ProxyAgent(proxyUrl) as unknown) as Dispatcher); + dispatchers.set(proxyUrl, agent); + return agent; + }; + + const healthy = (now: number): string[] => proxies.filter((p) => (cooledUntil.get(p) ?? 0) <= now); + + const pick = (): string | null => { + if (proxies.length === 0) return null; + const now = Date.now(); + let candidates = healthy(now); + if (candidates.length === 0) { + // All cooled: force the earliest-expiring one (never fully stall). + let earliest: string | null = null; + let earliestAt = Number.POSITIVE_INFINITY; + for (const p of proxies) { + const until = cooledUntil.get(p) ?? 0; + if (until < earliestAt) { + earliestAt = until; + earliest = p; + } + } + candidates = earliest ? [earliest] : []; + } + if (candidates.length === 0) return null; + if (strategy === 'random') { + return candidates[Math.floor(Math.random() * candidates.length)] as string; + } + const next = candidates[cursor % candidates.length] as string; + cursor += 1; + return next; + }; + + const cool = (proxyUrl: string, ms: number): void => { + cooledUntil.set(proxyUrl, Date.now() + ms); + }; + + const pool: UpstreamProxyPool = { + size: proxies.length, + hasProxies: (): boolean => proxies.length > 0, + isEngaged: (): boolean => proxies.length > 0 && Date.now() < engagedUntil, + engage: (reason = 'free-limit'): string | null => { + if (proxies.length === 0) return null; + // Always advance: successive free-limit hits rotate to the next healthy + // proxy (round-robin across retries/requests); sticky otherwise. + const selected = pick(); + if (selected) current = selected; + engagedUntil = Date.now() + cooldownMs; + logDebug('Upstream proxy engaged', { reason, proxy: current ? maskProxyUrl(current) : null, strategy }); + return current ? maskProxyUrl(current) : null; + }, + disengage: (): void => { + engagedUntil = 0; + current = null; + }, + proxiedFetch: async (input: unknown, init?: unknown): Promise => { + const direct = (): Promise => + (globalThis.fetch as (i: unknown, o?: unknown) => Promise)(input as never, init as never); + if (proxies.length === 0 || Date.now() >= engagedUntil) return direct(); + const host = targetHost(input); + if (!host || LOOPBACK_HOSTS.has(host) || noProxy.has(host)) return direct(); + let proxyUrl = current && (cooledUntil.get(current) ?? 0) <= Date.now() ? current : pick(); + if (!proxyUrl) return direct(); + current = proxyUrl; + try { + return await (undiciFetch as (i: unknown, o?: unknown) => Promise)(input as never, { + ...((init as Record) ?? {}), + dispatcher: getDispatcher(proxyUrl), + }); + } catch (err) { + // Transport failure through this proxy: cool it, fail open to direct + // for this call (quota errors are handled by callers, not here). + cool(proxyUrl, Math.min(cooldownMs, 60000)); + logDebug('Upstream proxy transport failed, failing open to direct', { + proxy: maskProxyUrl(proxyUrl), + error: err instanceof Error ? err.message : String(err), + }); + current = pick(); + return direct(); + } + }, + getStatus: (): ProxyPoolStatus => { + const now = Date.now(); + return { + configured: proxies.length, + engaged: proxies.length > 0 && now < engagedUntil, + engagedUntil: now < engagedUntil ? engagedUntil : null, + current: current ? maskProxyUrl(current) : null, + cooled: [...cooledUntil.entries()] + .filter(([, until]) => until > now) + .map(([proxy, until]) => ({ proxy: maskProxyUrl(proxy), until })), + }; + }, + }; + return pool; +} diff --git a/tests/auth-multikey.test.js b/tests/auth-multikey.test.js new file mode 100644 index 0000000..58ac35f --- /dev/null +++ b/tests/auth-multikey.test.js @@ -0,0 +1,177 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { + providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' } } }] + } + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: [] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'mk-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })), + sessionMessages: jest.fn(async () => ([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'ok' }] }])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ stream: (async function* () {})() })) +}; + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe } + })) +})); + +const { createApp } = await import('../src/proxy.js'); +const { buildProxyConfig } = await import('../src/config/proxy-config.js'); + +function baseConfig(overrides = {}) { + return { + PORT: 10000, + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false, + ...overrides, + }; +} + +describe('multi-key auth (A)', () => { + beforeEach(() => { jest.clearAllMocks(); }); + + test('any key in API_KEYS passes on /v1/models', async () => { + const app = createApp(baseConfig({ API_KEYS: ['k1', 'k2'] })).app; + for (const k of ['k1', 'k2']) { + const res = await request(app).get('/v1/models').set('Authorization', `Bearer ${k}`); + expect(res.statusCode).toBe(200); + } + const bad = await request(app).get('/v1/models').set('Authorization', 'Bearer nope'); + expect(bad.statusCode).toBe(401); + expect(bad.body).toEqual({ error: { message: 'Unauthorized' } }); + }); + + test('x-api-key header works and legacy API_KEY merges', async () => { + const app = createApp(baseConfig({ API_KEY: 'legacy', API_KEYS: ['k1'] })).app; + const r1 = await request(app).get('/v1/models').set('x-api-key', 'legacy'); + expect(r1.statusCode).toBe(200); + const r2 = await request(app).get('/v1/models').set('x-api-key', 'k1'); + expect(r2.statusCode).toBe(200); + }); + + test('Bearer scheme is case-insensitive and trims whitespace', async () => { + const app = createApp(baseConfig({ API_KEYS: ['k1'] })).app; + const res = await request(app).get('/v1/models').set('Authorization', 'bearer k1 '); + expect(res.statusCode).toBe(200); + }); + + test('empty config falls back to no-auth (unchanged)', async () => { + const app = createApp(baseConfig({ API_KEY: '', API_KEYS: [] })).app; + const res = await request(app).get('/v1/models'); + expect(res.statusCode).toBe(200); + }); + + test('/v1/messages keeps Anthropic 401 shape', async () => { + const app = createApp(baseConfig({ API_KEYS: ['k1'] })).app; + const res = await request(app).post('/v1/messages') + .send({ model: 'opencode/kimi-k2.5', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(401); + expect(res.body.type).toBe('error'); + expect(res.body.error.type).toBe('authentication_error'); + }); + + test('/health/details honors multi-key via shared verifier', async () => { + const app = createApp(baseConfig({ + API_KEYS: ['k1', 'k2'], + HEALTH_DETAILS_ENABLED: true, + HEALTH_DETAILS_REQUIRE_AUTH: true, + })).app; + const ok = await request(app).get('/health/details').set('Authorization', 'Bearer k2'); + expect(ok.statusCode).toBe(200); + const bad = await request(app).get('/health/details'); + expect(bad.statusCode).toBe(401); + }); + + test('buildProxyConfig resolves API_KEYS from opts and env', async () => { + const c1 = buildProxyConfig({ API_KEYS: 'a, b, a, ' }); + expect(c1.API_KEYS).toEqual(['a', 'b']); + expect(c1.API_KEY).toBe('a'); + // List-first merge order: explicit list wins positionally over legacy single. + const c1b = buildProxyConfig({ API_KEYS: 'b', API_KEY: 'a' }); + expect(c1b.API_KEYS).toEqual(['b', 'a']); + expect(c1b.API_KEY).toBe('b'); + const saved = { + canonical: process.env.OPENCODE_API_KEYS, + alias: process.env.API_KEYS, + single: process.env.API_KEY, + }; + delete process.env.OPENCODE_API_KEYS; + delete process.env.API_KEY; + process.env.API_KEYS = 'e1,e2'; + try { + const c2 = buildProxyConfig({}); + expect(c2.API_KEYS).toEqual(['e1', 'e2']); + // Canonical and alias merge; empty never blocks. + process.env.OPENCODE_API_KEYS = 'c0'; + const c3 = buildProxyConfig({}); + expect(c3.API_KEYS).toEqual(['c0', 'e1', 'e2']); + process.env.OPENCODE_API_KEYS = ''; + const c4 = buildProxyConfig({}); + expect(c4.API_KEYS).toEqual(['e1', 'e2']); + // Explicit empty opts isolate from ambient env. + const c5 = buildProxyConfig({ API_KEYS: '' }); + expect(c5.API_KEYS).toEqual([]); + } finally { + if (saved.canonical === undefined) delete process.env.OPENCODE_API_KEYS; + else process.env.OPENCODE_API_KEYS = saved.canonical; + if (saved.alias === undefined) delete process.env.API_KEYS; + else process.env.API_KEYS = saved.alias; + if (saved.single === undefined) delete process.env.API_KEY; + else process.env.API_KEY = saved.single; + } + }); + + test('Bearer with empty token is 401 when keys configured', async () => { + const app = createApp(baseConfig({ API_KEYS: ['k1'] })).app; + for (const h of ['Bearer', 'Bearer ']) { + const res = await request(app).get('/v1/models').set('Authorization', h); + expect(res.statusCode).toBe(401); + } + }); + + test('either header may match (legacy OR semantics)', async () => { + const app = createApp(baseConfig({ API_KEYS: ['k1'] })).app; + const res = await request(app).get('/v1/models') + .set('Authorization', 'Bearer wrong') + .set('x-api-key', 'k1'); + expect(res.statusCode).toBe(200); + }); + + test('legacy APIKEY_1 indexed env is never read', async () => { + const saved = process.env.APIKEY_1; + process.env.APIKEY_1 = 'evil-indexed'; + try { + const app = createApp(baseConfig({ API_KEY: '', API_KEYS: [] })).app; + const open = await request(app).get('/v1/models'); + expect(open.statusCode).toBe(200); + const evil = await request(app).get('/v1/models').set('Authorization', 'Bearer evil-indexed'); + expect(evil.statusCode).toBe(200); // no-auth mode: everything passes, indexed key grants nothing special + const locked = createApp(baseConfig({ API_KEYS: ['real'] })).app; + const rejected = await request(locked).get('/v1/models').set('Authorization', 'Bearer evil-indexed'); + expect(rejected.statusCode).toBe(401); + } finally { + if (saved === undefined) delete process.env.APIKEY_1; + else process.env.APIKEY_1 = saved; + } + }); + + test('pure key helpers: objects, non-strings, arrays', async () => { + const { parseApiKeyList, mergeApiKeySources, buildEffectiveApiKeys } = await import('../src/auth/keys.js'); + expect(parseApiKeyList([{ key: ' a ' }, { key: '' }, { nokey: 'x' }, 1, true, null])).toEqual(['a']); + expect(parseApiKeyList(42)).toEqual([]); + expect(mergeApiKeySources('b,a', 'a,c')).toEqual(['b', 'a', 'c']); + expect(buildEffectiveApiKeys('single', 'm1,m2')).toEqual(['m1', 'm2', 'single']); + }); +}); diff --git a/tests/hosted-search-routing.test.js b/tests/hosted-search-routing.test.js new file mode 100644 index 0000000..24294f3 --- /dev/null +++ b/tests/hosted-search-routing.test.js @@ -0,0 +1,132 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +// Hosted-search routing guards (chat/messages 400-guide) + 401 wire shapes. +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' } } }] }, + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: ['websearch', 'webfetch'] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'route-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })), + sessionMessages: jest.fn(async () => ([ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'ok' }] }, + ])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ stream: (async function* () {})() })), +}; + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe }, + })), +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + cb({ statusCode: 200, headers: {}, on: jest.fn() }); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + }), + }, +})); + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + cb(res); + return { on: jest.fn(), destroy: jest.fn() }; + }), + }, +})); + +const { createApp } = await import('../src/proxy.js'); + +function baseConfig(overrides = {}) { + return { + PORT: 10000, + API_KEY: 'k1', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false, + ...overrides, + }; +} + +describe('hosted-search routing guards', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMocks.toolIds.mockResolvedValue({ data: ['websearch', 'webfetch'] }); + }); + + test('chat completions rejects web_search with a pointer, not silent drop', async () => { + const app = createApp(baseConfig({ API_KEY: '' })).app; + const res = await request(app).post('/v1/chat/completions').send({ + model: 'opencode/kimi-k2.5', + messages: [{ role: 'user', content: 'hi' }], + tools: [{ type: 'web_search' }], + }); + expect(res.statusCode).toBe(400); + expect(res.body.error.type).toBe('invalid_request_error'); + expect(res.body.error.message).toContain('/v1/responses'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + + test('messages rejects Anthropic web_search with a pointer', async () => { + const app = createApp(baseConfig({ API_KEY: '' })).app; + const res = await request(app).post('/v1/messages').send({ + model: 'opencode/kimi-k2.5', + max_tokens: 100, + messages: [{ role: 'user', content: 'hi' }], + tools: [{ type: 'web_search_20260222', name: 'web_search', max_uses: 3 }], + }); + expect(res.statusCode).toBe(400); + expect(res.body.type).toBe('error'); + expect(res.body.error.type).toBe('invalid_request_error'); + }); + + test('plain function tools still bridge on chat/messages', async () => { + const app = createApp(baseConfig({ API_KEY: '' })).app; + const chat = await request(app).post('/v1/chat/completions').send({ + model: 'opencode/kimi-k2.5', + messages: [{ role: 'user', content: 'hi' }], + tools: [{ type: 'function', function: { name: 'f', parameters: { type: 'object' } } }], + }); + expect(chat.statusCode).toBe(200); + }); +}); + +describe('401 wire shapes', () => { + test('chat/responses/interactions share the generic shape; messages keeps Anthropic shape', async () => { + const app = createApp(baseConfig({})).app; + const chat = await request(app).post('/v1/chat/completions').send({ model: 'm', messages: [{ role: 'user', content: 'hi' }] }); + expect(chat.statusCode).toBe(401); + expect(chat.body).toEqual({ error: { message: 'Unauthorized' } }); + const resp = await request(app).post('/v1/responses').send({ model: 'm', input: 'hi' }); + expect(resp.statusCode).toBe(401); + expect(resp.body).toEqual({ error: { message: 'Unauthorized' } }); + const intr = await request(app).post('/v1beta/interactions').send({ model: 'm', input: 'hi' }); + expect(intr.statusCode).toBe(401); + expect(intr.body).toEqual({ error: { message: 'Unauthorized' } }); + const msg = await request(app).post('/v1/messages').send({ model: 'm', max_tokens: 5, messages: [{ role: 'user', content: 'hi' }] }); + expect(msg.statusCode).toBe(401); + expect(msg.body).toEqual({ type: 'error', error: { type: 'authentication_error', message: 'Unauthorized' } }); + }); + + test('/metrics honors multi-key auth', async () => { + const app = createApp(baseConfig({ API_KEYS: ['k1', 'k2'], METRICS_ENABLED: true, METRICS_REQUIRE_AUTH: true })).app; + const bad = await request(app).get('/metrics'); + expect(bad.statusCode).toBe(401); + const ok = await request(app).get('/metrics').set('x-api-key', 'k2'); + expect(ok.statusCode).toBe(200); + expect(ok.text).toContain('opencode_fallback_proxy_configured'); + }); +}); diff --git a/tests/interactions.test.js b/tests/interactions.test.js new file mode 100644 index 0000000..c635fe5 --- /dev/null +++ b/tests/interactions.test.js @@ -0,0 +1,222 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +const SEARCH_OUTPUT = 'PostgreSQL 18.3 was released. Notes: https://www.postgresql.org/docs/release/18.3/ .'; +const ANSWER_WITH_URL = 'Latest is PostgreSQL 18.3, see https://www.postgresql.org/docs/release/18.3/ for notes.'; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' } } }] }, + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: ['websearch', 'webfetch'] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'intr-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [] } })), + sessionMessages: jest.fn(async () => ([ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { type: 'text', text: ANSWER_WITH_URL }, + { + type: 'tool', + id: 'call_search_1', + tool: 'websearch', + state: { status: 'completed', input: { query: 'latest PostgreSQL release' }, output: SEARCH_OUTPUT }, + }, + ], + }, + ])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ stream: (async function* () {})() })), +}; + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe }, + })), +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + cb({ statusCode: 200, headers: {}, on: jest.fn() }); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + }), + }, +})); + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + cb(res); + return { on: jest.fn(), destroy: jest.fn() }; + }), + }, +})); + +const { createApp } = await import('../src/proxy.js'); + +function baseConfig(overrides = {}) { + return { + PORT: 10000, + API_KEY: '', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false, + ...overrides, + }; +} + +describe('POST /v1beta/interactions', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMocks.toolIds.mockResolvedValue({ data: ['websearch', 'webfetch'] }); + }); + + test('plain text input returns interaction with model_output step', async () => { + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'Hello', + }); + expect(res.statusCode).toBe(200); + expect(res.body.status).toBe('completed'); + expect(res.body.id).toMatch(/^intr_/); + expect(res.body.output_text).toContain('PostgreSQL'); + const out = res.body.steps.find((s) => s.type === 'model_output'); + expect(out).toBeDefined(); + expect(out.annotations).toEqual([]); + }); + + test('google_search tools drive websearch and surface steps', async () => { + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'Latest PostgreSQL release?', + tools: [{ type: 'google_search' }], + }); + expect(res.statusCode).toBe(200); + const call = res.body.steps.find((s) => s.type === 'google_search_call'); + expect(call).toEqual({ type: 'google_search_call', queries: ['latest PostgreSQL release'] }); + const result = res.body.steps.find((s) => s.type === 'google_search_result'); + expect(result.sources[0].url).toBe('https://www.postgresql.org/docs/release/18.3/'); + const out = res.body.steps.find((s) => s.type === 'model_output'); + expect(out.annotations.length).toBeGreaterThan(0); + expect(res.body.usage.grounding_tool_count).toEqual([{ type: 'google_search', count: 1 }]); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools.websearch).toBe(true); + }); + + test('/v1 alias behaves identically', async () => { + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1/interactions').send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(res.statusCode).toBe(200); + expect(res.body.id).toMatch(/^intr_/); + }); + + test('previous_interaction_id continues the session; store:false skips persistence', async () => { + const app = createApp(baseConfig({})).app; + const first = await request(app).post('/v1beta/interactions').send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + const second = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'follow-up', + previous_interaction_id: first.body.id, + }); + expect(second.statusCode).toBe(200); + // stored session reused: only one session.create across both calls + expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); + const bad = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'hi', + previous_interaction_id: 'intr_missing', + }); + expect(bad.statusCode).toBe(400); + const ephemeral = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'hi', + store: false, + }); + expect(ephemeral.statusCode).toBe(200); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); + }); + + test('store:false with previous_interaction_id keeps the shared parent session', async () => { + const app = createApp(baseConfig({})).app; + const first = await request(app).post('/v1beta/interactions').send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(first.statusCode).toBe(200); + jest.clearAllMocks(); + const second = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'follow-up', + previous_interaction_id: first.body.id, + store: false, + }); + expect(second.statusCode).toBe(200); + // Reused parent session must survive an ephemeral continuation. + expect(sdkMocks.sessionDelete).not.toHaveBeenCalled(); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + + test('validation: model/agent required, input required, function tools rejected', async () => { + const app = createApp(baseConfig({})).app; + expect((await request(app).post('/v1beta/interactions').send({ input: 'hi' })).statusCode).toBe(400); + expect((await request(app).post('/v1beta/interactions').send({ model: 'm' })).statusCode).toBe(400); + const fn = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'hi', + tools: [{ type: 'function', function: { name: 'x' } }], + }); + expect(fn.statusCode).toBe(400); + }); + + test('stream emits created/delta/completed without SSE subscription', async () => { + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'hi', + stream: true, + }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('interaction.created'); + expect(res.text).toContain('step.delta'); + expect(res.text).toContain('interaction.completed'); + expect(sdkMocks.eventSubscribe).not.toHaveBeenCalled(); + }); + + test('free-limit engages fallback and retries via proxy within the same request', async () => { + sdkMocks.sessionPrompt.mockRejectedValueOnce({ + name: 'APIError', + data: { + message: 'Rate limit exceeded. Please try again later.', + statusCode: 429, + isRetryable: true, + responseBody: '{"error":{"type":"FreeUsageLimitError"}}', + }, + }); + const app = createApp(baseConfig({ RETRY_MAX_RETRIES: 1, UPSTREAM_PROXIES: ['http://10.0.0.1:8080'] })).app; + const res = await request(app).post('/v1beta/interactions').send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(res.statusCode).toBe(200); + expect(sdkMocks.sessionPrompt.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + test('agent-only falls back to default model resolution', async () => { + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1beta/interactions').send({ agent: 'deep-research-preview', input: 'hi' }); + expect(res.statusCode).toBe(200); + expect(res.body.model).toBe('opencode/kimi-k2.5'); + }); + + test('auth applies like other routes', async () => { + const app = createApp(baseConfig({ API_KEY: 's3cret' })).app; + expect((await request(app).post('/v1beta/interactions').send({ model: 'm', input: 'hi' })).statusCode).toBe(401); + const ok = await request(app).post('/v1beta/interactions').set('Authorization', 'Bearer s3cret').send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(ok.statusCode).toBe(200); + }); +}); diff --git a/tests/proxy-fallback.test.js b/tests/proxy-fallback.test.js new file mode 100644 index 0000000..2a3f1eb --- /dev/null +++ b/tests/proxy-fallback.test.js @@ -0,0 +1,325 @@ +import { jest } from '@jest/globals'; +import request from 'supertest'; +import { + isFreeUsageLimitError, + parseFreeLimitKind, + normalizeBackendError, +} from '../src/errors/upstream.js'; +import { + createProxyPool, + parseProxyList, + parseProxyNoProxyList, +} from '../src/upstream-proxy/pool.js'; +import { + engageFallbackForFreeLimit, + shouldFallbackToProxy, +} from '../src/upstream-proxy/fallback.js'; + +function freeUsageError() { + // NOTE: no responseHeaders here — retry-after would force a 30s clamped + // sleep in route retry loops and stall integration tests. + return { + name: 'APIError', + data: { + message: 'Rate limit exceeded. Please try again later.', + statusCode: 429, + isRetryable: true, + responseBody: '{"type":"error","error":{"type":"FreeUsageLimitError","message":"Rate limit exceeded."}}', + }, + }; +} + +function goUsageError() { + return { + name: 'APIError', + data: { + message: '5-hour usage limit reached. Resets in 5 hours.', + statusCode: 429, + isRetryable: true, + responseBody: '{"type":"error","error":{"type":"GoUsageLimitError"},"metadata":{"workspace":"wrk_x","limitName":"5 hour"}}', + }, + }; +} + +describe('free-limit fingerprint', () => { + test('matches 429 + FreeUsageLimitError / GoUsageLimitError', () => { + expect(parseFreeLimitKind(freeUsageError())).toBe('free'); + expect(parseFreeLimitKind(goUsageError())).toBe('go'); + expect(isFreeUsageLimitError(freeUsageError())).toBe(true); + }); + + test('rejects non-quota errors', () => { + expect(isFreeUsageLimitError(null)).toBe(false); + expect(isFreeUsageLimitError({})).toBe(false); + // plain 429 without class marker + expect(isFreeUsageLimitError({ data: { message: 'slow down', statusCode: 429 } })).toBe(false); + // 401 billing line is never a free-limit fallback + expect( + isFreeUsageLimitError({ + data: { message: 'Insufficient balance', statusCode: 401, responseBody: '{"error":{"type":"CreditsError"}}' }, + }), + ).toBe(false); + // 500 overload keeps the direct path + expect(isFreeUsageLimitError({ data: { message: 'overloaded', statusCode: 500 } })).toBe(false); + // message-text match still requires 429 + expect(isFreeUsageLimitError({ data: { message: 'Free usage exceeded, subscribe to Go', statusCode: 500 } })).toBe(false); + }); + + test('string statusCode and data.type markers are accepted', () => { + expect(parseFreeLimitKind({ data: { message: 'x', statusCode: '429', type: 'FreeUsageLimitError' } })).toBe('free'); + expect(parseFreeLimitKind({ data: { message: 'x', statusCode: '429', code: 'GoUsageLimitError' } })).toBe('go'); + expect(parseFreeLimitKind({ data: { message: 'x', statusCode: '200', type: 'FreeUsageLimitError' } })).toBeNull(); + }); + + test('normalizeBackendError preserves responseBody for post-normalize matching', () => { + const normalized = normalizeBackendError(freeUsageError()); + expect(typeof normalized.responseBody).toBe('string'); + expect(normalized.responseBody).toContain('FreeUsageLimitError'); + expect(isFreeUsageLimitError(normalized)).toBe(true); + }); +}); + +describe('proxy list parsing', () => { + test('comma string, arrays, dedupe, invalid filtered', () => { + expect(parseProxyList('socks5://a:1080, http://b:8080')).toEqual(['socks5://a:1080', 'http://b:8080']); + expect(parseProxyList(['socks5://a:1080', 'socks5://a:1080', 'ftp://x', '', 42])).toEqual(['socks5://a:1080']); + expect(parseProxyList('')).toEqual([]); + expect(parseProxyList(undefined)).toEqual([]); + expect(parseProxyList('socks5h://u:p@h:1080')).toEqual(['socks5h://u:p@h:1080']); + }); + + test('socks-first stable ordering regardless of input order', () => { + expect(parseProxyList('http://b:8080, socks5://a:1080')).toEqual(['socks5://a:1080', 'http://b:8080']); + expect(parseProxyList('http://b:8080, https://c:8443, socks5://a:1080, socks5h://d:1080')).toEqual([ + 'socks5://a:1080', + 'socks5h://d:1080', + 'http://b:8080', + 'https://c:8443', + ]); + }); + + test('no-proxy defaults to loopback', () => { + expect(parseProxyNoProxyList(undefined, ['localhost'])).toEqual(['localhost']); + expect(parseProxyNoProxyList('', ['localhost'])).toEqual(['localhost']); + expect(parseProxyNoProxyList('Example.COM, a', [])).toEqual(['example.com', 'a']); + }); +}); + +describe('proxy pool (network-free)', () => { + let savedFetch; + let fetchCalls; + beforeEach(() => { + fetchCalls = []; + savedFetch = globalThis.fetch; + globalThis.fetch = jest.fn(async (...args) => { + fetchCalls.push(args); + return new Response('direct-ok'); + }); + }); + afterEach(() => { + globalThis.fetch = savedFetch; + }); + + test('empty pool is direct-only and never engages', async () => { + const pool = createProxyPool({}); + expect(pool.hasProxies()).toBe(false); + expect(pool.engage('x')).toBeNull(); + expect(pool.isEngaged()).toBe(false); + const res = await pool.proxiedFetch('https://example.com/api', {}); + expect(fetchCalls.length).toBe(1); + expect(await res.text()).toBe('direct-ok'); + }); + + test('loopback targets bypass even while engaged', async () => { + const pool = createProxyPool({ proxies: 'http://10.0.0.1:8080', cooldownMs: 60000 }); + expect(pool.engage('free')).toContain('http://'); + expect(pool.isEngaged()).toBe(true); + await pool.proxiedFetch('http://127.0.0.1:10001/health', {}); + await pool.proxiedFetch('http://localhost:10001/health', {}); + await pool.proxiedFetch('http://[::1]:10001/health', {}); + expect(fetchCalls.length).toBe(3); + const status = pool.getStatus(); + expect(status.configured).toBe(1); + expect(status.engaged).toBe(true); + expect(status.current).toBe('http://10.0.0.1:8080'); + }); + + test('engagement expires after cooldown', async () => { + const pool = createProxyPool({ proxies: 'http://10.0.0.1:8080', cooldownMs: 30 }); + pool.engage('free'); + expect(pool.isEngaged()).toBe(true); + await new Promise((r) => setTimeout(r, 60)); + expect(pool.isEngaged()).toBe(false); + pool.disengage(); + expect(pool.getStatus().engaged).toBe(false); + }); + + test('proxy credentials never appear in status labels', () => { + const pool = createProxyPool({ proxies: 'socks5://user:secret@10.0.0.9:1080' }); + expect(pool.engage('free')).toBe('socks5://10.0.0.9:1080'); + const status = pool.getStatus(); + expect(status.current).toBe('socks5://10.0.0.9:1080'); + expect(JSON.stringify(status)).not.toContain('secret'); + expect(JSON.stringify(status)).not.toContain('user@'); + }); + + test('fallback glue respects pool presence and fingerprint', () => { + const pool = createProxyPool({ proxies: 'socks5://10.0.0.2:1080' }); + expect(shouldFallbackToProxy(freeUsageError(), null)).toBe(false); + expect(shouldFallbackToProxy(freeUsageError(), createProxyPool({}))).toBe(false); + expect(shouldFallbackToProxy({ data: { statusCode: 500 } }, pool)).toBe(false); + expect(shouldFallbackToProxy(freeUsageError(), pool)).toBe(true); + expect(engageFallbackForFreeLimit(freeUsageError(), pool, () => {})).toBe(true); + expect(pool.isEngaged()).toBe(true); + }); +}); + +// Route-level: free-limit on attempt 1 engages the pool and retries via proxy. +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' } } }] }, + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: [] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'fb-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })), + sessionMessages: jest.fn(async () => ([ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'recovered' }] }, + ])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ stream: (async function* () {})() })), +}; + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe }, + })), +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + cb({ statusCode: 200, headers: {}, on: jest.fn() }); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + }), + }, +})); + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + cb(res); + return { on: jest.fn(), destroy: jest.fn() }; + }), + }, +})); + +const { createApp } = await import('../src/proxy.js'); + +describe('route fallback integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMocks.sessionPrompt.mockImplementation(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })); + }); + + test('messages non-stream retries via proxy after free-limit and reports engagement', async () => { + sdkMocks.sessionPrompt.mockRejectedValueOnce(freeUsageError()); + const app = createApp({ + PORT: 10000, + API_KEY: 'k1', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false, + RETRY_MAX_RETRIES: 1, + UPSTREAM_PROXIES: ['http://10.0.0.1:8080'], + UPSTREAM_PROXY_COOLDOWN_MS: 60000, + }).app; + const res = await request(app) + .post('/v1/messages') + .set('Authorization', 'Bearer k1') + .send({ model: 'opencode/kimi-k2.5', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + expect(sdkMocks.sessionPrompt.mock.calls.length).toBeGreaterThanOrEqual(2); + const details = await request(app).get('/health/details').set('Authorization', 'Bearer k1'); + expect(details.statusCode).toBe(200); + expect(details.body.internal_tools.fallback_proxies.configured).toBe(1); + expect(details.body.internal_tools.fallback_proxies.engaged).toBe(true); + }, 30000); + + test('stream paths skip SSE while engaged (messages + chat)', async () => { + // Arm engagement with one free-limit round-trip first. + sdkMocks.sessionPrompt.mockRejectedValueOnce(freeUsageError()); + const app = createApp({ + PORT: 10000, + API_KEY: 'k1', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false, + RETRY_MAX_RETRIES: 1, + UPSTREAM_PROXIES: ['http://10.0.0.1:8080'], + UPSTREAM_PROXY_COOLDOWN_MS: 60000, + }).app; + const arm = await request(app) + .post('/v1/messages') + .set('Authorization', 'Bearer k1') + .send({ model: 'opencode/kimi-k2.5', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }); + expect(arm.statusCode).toBe(200); + jest.clearAllMocks(); + const stream = await request(app) + .post('/v1/messages') + .set('Authorization', 'Bearer k1') + .send({ model: 'opencode/kimi-k2.5', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }], stream: true }); + expect(stream.statusCode).toBe(200); + expect(stream.text).toContain('message_stop'); + // Fallback never touches the event stream (SDK ignores custom fetch there). + expect(sdkMocks.eventSubscribe).not.toHaveBeenCalled(); + jest.clearAllMocks(); + const chatStream = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer k1') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }], stream: true }); + expect(chatStream.statusCode).toBe(200); + expect(chatStream.text).toContain('data: [DONE]'); + expect(sdkMocks.eventSubscribe).not.toHaveBeenCalled(); + jest.clearAllMocks(); + const respStream = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer k1') + .send({ model: 'opencode/kimi-k2.5', input: 'hi', stream: true }); + expect(respStream.statusCode).toBe(200); + expect(respStream.text).toContain('response.completed'); + expect(sdkMocks.eventSubscribe).not.toHaveBeenCalled(); + }, 60000); + + test('without proxies the same error surfaces as 429 without engagement', async () => { + sdkMocks.sessionPrompt.mockRejectedValueOnce(freeUsageError()); + sdkMocks.sessionMessages.mockResolvedValueOnce([ + { info: { role: 'assistant', error: freeUsageError(), finish: 'stop' }, parts: [] }, + ]); + const app = createApp({ + PORT: 10000, + API_KEY: 'k1', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 2000, + DISABLE_TOOLS: true, + DEBUG: false, + RETRY_MAX_RETRIES: 0, + }).app; + const res = await request(app) + .post('/v1/messages') + .set('Authorization', 'Bearer k1') + .send({ model: 'opencode/kimi-k2.5', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(429); + const details = await request(app).get('/health/details').set('Authorization', 'Bearer k1'); + expect(details.body.internal_tools.fallback_proxies.configured).toBe(0); + expect(details.body.internal_tools.fallback_proxies.engaged).toBe(false); + }, 30000); +}); diff --git a/tests/responses-websearch.test.js b/tests/responses-websearch.test.js new file mode 100644 index 0000000..1b0b0b3 --- /dev/null +++ b/tests/responses-websearch.test.js @@ -0,0 +1,207 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; +import { + buildCitationAnnotations, + buildWebSearchCallItems, + detectHostedSearchTools, + extractSearchEvidence, + extractUrls, + stripHostedSearchTools, +} from '../src/search/grounding.js'; + +const SEARCH_OUTPUT = 'PostgreSQL 18.3 was released. Notes: https://www.postgresql.org/docs/release/18.3/ .'; +const ANSWER_WITH_URL = 'Latest is PostgreSQL 18.3, see https://www.postgresql.org/docs/release/18.3/ for notes.'; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' } } }] }, + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: ['websearch', 'webfetch'] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'ws-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })), + sessionMessages: jest.fn(async () => ([ + { + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { type: 'text', text: ANSWER_WITH_URL }, + { + type: 'tool', + id: 'call_search_1', + tool: 'websearch', + state: { status: 'completed', input: { query: 'latest PostgreSQL release' }, output: SEARCH_OUTPUT }, + }, + ], + }, + ])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => { + const sessionId = 'ws-session'; + const mockEvents = [ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: sessionId }, delta: ANSWER_WITH_URL } }, + { type: 'message.updated', properties: { info: { sessionID: sessionId, finish: 'stop' } } }, + ]; + return { + stream: (async function* () { + for (const event of mockEvents) yield event; + })(), + }; + }), +}; + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe }, + })), +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + cb({ statusCode: 200, headers: {}, on: jest.fn() }); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + }), + }, +})); + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + const res = { statusCode: 200, headers: { 'content-type': 'image/png' }, on: jest.fn((e, h) => { if (e === 'data') h(Buffer.from('x')); if (e === 'end') h(); }) }; + cb(res); + return { on: jest.fn(), destroy: jest.fn() }; + }), + }, +})); + +const { createApp } = await import('../src/proxy.js'); + +function baseConfig(overrides = {}) { + return { + PORT: 10000, + API_KEY: '', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false, + ...overrides, + }; +} + +describe('grounding pure helpers', () => { + test('detect/strip hosted search tools', () => { + expect(detectHostedSearchTools([{ type: 'web_search' }]).requested).toBe(true); + expect(detectHostedSearchTools([{ type: 'Google_Search' }]).requested).toBe(true); + expect(detectHostedSearchTools([{ type: 'function', function: { name: 'x' } }]).requested).toBe(false); + expect(detectHostedSearchTools('nope').requested).toBe(false); + const mixed = [{ type: 'web_search' }, { type: 'function', function: { name: 'f' } }]; + expect(stripHostedSearchTools(mixed)).toHaveLength(1); + }); + + test('extractSearchEvidence only trusts completed websearch parts', () => { + const ev = extractSearchEvidence([ + { type: 'tool', tool: 'websearch', state: { status: 'completed', input: { query: 'q1' }, output: SEARCH_OUTPUT } }, + { type: 'tool', tool: 'websearch', state: { status: 'pending', input: { query: 'q2' }, output: '' } }, + { type: 'tool', tool: 'webfetch', state: { status: 'completed', input: {}, output: 'https://other.example/x' } }, + { type: 'text', text: 'hi' }, + ]); + expect(ev.queries).toEqual(['q1']); + expect(ev.sources.map((s) => s.url)).toEqual(['https://www.postgresql.org/docs/release/18.3/']); + }); + + test('buildWebSearchCallItems + citations (honest indices only)', () => { + const ev = { queries: ['q1'], sources: [{ url: 'https://a.example/1', title: 'a.example' }] }; + expect(buildWebSearchCallItems(ev)).toEqual([ + { id: 'ws_1', type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'q1' } }, + ]); + const text = 'see https://a.example/1 now, not https://missing.example/'; + const ann = buildCitationAnnotations(text, ev.sources); + expect(ann).toHaveLength(1); + expect(ann[0]).toMatchObject({ type: 'url_citation', url: 'https://a.example/1' }); + expect(text.slice(ann[0].start_index, ann[0].end_index)).toBe('https://a.example/1'); + expect(buildCitationAnnotations('no urls here', ev.sources)).toEqual([]); + expect(extractUrls('a https://x.example/1, b https://x.example/1.')).toEqual(['https://x.example/1']); + }); +}); + +describe('POST /v1/responses web_search grounding', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMocks.toolIds.mockResolvedValue({ data: ['websearch', 'webfetch'] }); + }); + + test('non-stream returns web_search_call + url_citation and enables websearch upstream', async () => { + // Empty prompt parts: the answer (with tool evidence) arrives via poll, + // mirroring a real tool-loop turn. + sdkMocks.sessionPrompt.mockResolvedValueOnce({ data: { parts: [] } }); + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1/responses').send({ + model: 'opencode/kimi-k2.5', + input: 'What is the latest PostgreSQL release?', + tools: [{ type: 'web_search' }], + }); + expect(res.statusCode).toBe(200); + const searchCalls = res.body.output.filter((o) => o.type === 'web_search_call'); + expect(searchCalls).toHaveLength(1); + expect(searchCalls[0].action).toEqual({ type: 'search', query: 'latest PostgreSQL release' }); + const message = res.body.output.find((o) => o.type === 'message'); + expect(message).toBeDefined(); + const annotations = message.content[0].annotations; + expect(annotations.length).toBeGreaterThan(0); + expect(annotations[0]).toMatchObject({ type: 'url_citation', url: 'https://www.postgresql.org/docs/release/18.3/' }); + // upstream prompt carried websearch:true + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools.websearch).toBe(true); + }); + + test('web_search unions onto an existing allowlist without extinguishing it', async () => { + const app = createApp(baseConfig({ INTERNAL_ALLOWED_TOOLS: ['webfetch'] })).app; + const res = await request(app).post('/v1/responses').send({ + model: 'opencode/kimi-k2.5', + input: 'Search the docs.', + tools: [{ type: 'web_search' }], + }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools.websearch).toBe(true); + expect(promptCall.body.tools.webfetch).toBe(true); + }); + + test('no web_search tools → no search items, annotations stay empty', async () => { + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1/responses').send({ + model: 'opencode/kimi-k2.5', + input: 'Hello', + }); + expect(res.statusCode).toBe(200); + expect(res.body.output.some((o) => o.type === 'web_search_call')).toBe(false); + const message = res.body.output.find((o) => o.type === 'message'); + expect(message.content[0].annotations).toEqual([]); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools?.websearch).not.toBe(true); + }); + + test('stream emits web_search_call events and completed output carries them', async () => { + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1/responses').send({ + model: 'opencode/kimi-k2.5', + input: 'What is the latest PostgreSQL release?', + tools: [{ type: 'web_search' }], + stream: true, + }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('response.web_search_call.completed'); + const completedLine = res.text.split('\n').find((l) => l.includes('response.completed')); + expect(completedLine).toBeDefined(); + const completed = JSON.parse(completedLine.slice(5).trim()); + const searchCalls = completed.response.output.filter((o) => o.type === 'web_search_call'); + expect(searchCalls).toHaveLength(1); + const message = completed.response.output.find((o) => o.type === 'message'); + expect(message.content[0].annotations.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/tool-alias.test.js b/tests/tool-alias.test.js new file mode 100644 index 0000000..2698d86 --- /dev/null +++ b/tests/tool-alias.test.js @@ -0,0 +1,127 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { + providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' } } }] + } + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: ['web_fetch', 'filesystem', 'bash'] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'alias-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })), + sessionMessages: jest.fn(async () => ([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'ok' }] }])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ stream: (async function* () {})() })) +}; + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe } + })) +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + const response = { statusCode: 200, headers: {}, on: jest.fn() }; + cb(response); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + }) + } +})); + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const cb = typeof options === 'function' ? options : callback; + const res = { + statusCode: 200, + headers: { 'content-type': 'image/png' }, + on: jest.fn((event, handler) => { + if (event === 'data') handler(Buffer.from('fake-image-data')); + if (event === 'end') handler(); + }) + }; + cb(res); + return { on: jest.fn(), destroy: jest.fn() }; + }) + } +})); + +const { createApp } = await import('../src/proxy.js'); +const { normalizeToolNameForMatch } = await import('../src/tool-runtime/registry.js'); + +function baseConfig(overrides = {}) { + return { + PORT: 10000, + API_KEY: '', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + DEBUG: false, + ...overrides, + }; +} + +describe('internal allowlist alias matching', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMocks.toolIds.mockResolvedValue({ data: ['web_fetch', 'filesystem', 'bash'] }); + }); + + test('normalizeToolNameForMatch is separator/case insensitive', () => { + expect(normalizeToolNameForMatch('web_fetch')).toBe('webfetch'); + expect(normalizeToolNameForMatch('webfetch')).toBe('webfetch'); + expect(normalizeToolNameForMatch('Web-Search')).toBe('websearch'); + expect(normalizeToolNameForMatch('')).toBe(''); + }); + + test("allowlist 'webfetch' matches backend 'web_fetch'", async () => { + const app = createApp(baseConfig({ INTERNAL_ALLOWED_TOOLS: ['webfetch'] })).app; + const res = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools).toEqual({ web_fetch: true, filesystem: false, bash: false }); + }); + + test("legacy 'web_fetch' still matches real backend 'webfetch'", async () => { + sdkMocks.toolIds.mockResolvedValue({ data: ['webfetch', 'filesystem'] }); + const app = createApp(baseConfig({ INTERNAL_ALLOWED_TOOLS: ['web_fetch'] })).app; + const res = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools).toEqual({ webfetch: true, filesystem: false }); + }); + + test("allowlist 'websearch' matches backend 'websearch'", async () => { + sdkMocks.toolIds.mockResolvedValue({ data: ['websearch', 'webfetch'] }); + const app = createApp(baseConfig({ INTERNAL_ALLOWED_TOOLS: ['websearch'] })).app; + const res = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools).toEqual({ websearch: true, webfetch: false }); + }); + + test('messages route honors request-level narrowing (parity with chat)', async () => { + const app = createApp(baseConfig({ INTERNAL_ALLOWED_TOOLS: ['webfetch', 'filesystem'] })).app; + const res = await request(app).post('/v1/messages') + .send({ + model: 'opencode/kimi-k2.5', + max_tokens: 100, + messages: [{ role: 'user', content: 'hi' }], + opencode: { internal_allowed_tools: ['filesystem'] }, + }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.tools).toEqual({ web_fetch: false, filesystem: true, bash: false }); + }); +}); From 9d1b423e6a1e7946f2c65211ab087c4d2371c755 Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 18:52:22 +0800 Subject: [PATCH 2/3] fix(review): address PR #12 findings - remove unread apiKeyId dead code, use isAuthorized verifier - shared isHostedSearchType (exact + versioned prefix), messages guard reuses it (covers google_search on /v1/messages) - normalize strategy/cooldown at config build time (truthful config+log) - document failover-rr/round-robin alias + store:false non-ephemerality - responses mixed-request toolMode metadata includes websearch - chat stream catch assigns collected unconditionally - tests: normalizers, versioned types, google_search 400 --- docs/api-reference.md | 2 +- index.ts | 12 +++++++----- src/config/proxy-config.ts | 13 +++++++------ src/proxy.ts | 5 ++--- src/routes/chat.ts | 2 +- src/routes/messages.ts | 17 ++++++++++------- src/routes/responses.ts | 9 +++++++++ src/search/grounding.ts | 12 +++++++++--- src/upstream-proxy/pool.ts | 27 ++++++++++++++++++--------- tests/hosted-search-routing.test.js | 7 +++++++ tests/proxy-fallback.test.js | 13 +++++++++++++ tests/responses-websearch.test.js | 1 + 12 files changed, 85 insertions(+), 35 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index be1094d..9c7d80b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -233,7 +233,7 @@ POST /v1/interactions | `tools` | array | - | 仅支持 `{type: "google_search"}`(等价 `web_search`),其余 function 工具返回 400 | | `previous_interaction_id` | string | - | 续写上轮会话(等价 responses 的 `previous_response_id`) | | `stream` | boolean | - | SSE:`interaction.created` / `step.delta` / `interaction.completed`(无 `[DONE]`,错误为 `{type:'error'}` 事件;15s heartbeat) | -| `store` | boolean | - | `false` 则不持久化;仅删除本请求新建的会话,复用的父会话保留 | +| `store` | boolean | - | `false` 则不持久化;仅删除本请求新建的会话,复用的父会话保留(注意:带 `previous_interaction_id` 的 `store:false` 轮次仍会追加到父会话历史,并非完全无痕) | 响应为 `Interaction` 资源:`{id, status, model, output_text, steps[], usage: {grounding_tool_count}}`,其中 `steps` 含 `google_search_call{queries}`、`google_search_result{sources}`(标注为 opencode websearch 代理结果,非 Google 原生)、`model_output{text, annotations}`。文本往返与 `web_search` 接地逻辑复用 Responses 管线;限流错误的重试与代理 fallback 和其余路由一致(engage + 换 session,最多 `maxAttempts` 次),普通错误直接返回。 diff --git a/index.ts b/index.ts index f2aae35..d453550 100644 --- a/index.ts +++ b/index.ts @@ -2,7 +2,7 @@ import { startProxy, normalizeBool, resolveDisableTools } from './src/proxy.js'; import { resolveMaxRetries } from './src/retry/policy.js'; import { mergeApiKeySources } from './src/auth/keys.js'; -import { DEFAULT_PROXY_COOLDOWN_MS, parseProxyList, parseProxyNoProxyList } from './src/upstream-proxy/pool.js'; +import { DEFAULT_PROXY_COOLDOWN_MS, normalizeProxyCooldownMs, normalizeProxyStrategy, parseProxyList, parseProxyNoProxyList } from './src/upstream-proxy/pool.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -225,12 +225,14 @@ const finalConfig: ProxyConfig = { PORT: const file = parseProxyList(fileConfig['UPSTREAM_PROXIES']); return file.length > 0 ? file : [...defaultConfig.UPSTREAM_PROXIES]; })(), - UPSTREAM_PROXY_STRATEGY: + UPSTREAM_PROXY_STRATEGY: normalizeProxyStrategy( process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'] || - readFileString('UPSTREAM_PROXY_STRATEGY', defaultConfig.UPSTREAM_PROXY_STRATEGY), - UPSTREAM_PROXY_COOLDOWN_MS: + readFileString('UPSTREAM_PROXY_STRATEGY', defaultConfig.UPSTREAM_PROXY_STRATEGY), + ), + UPSTREAM_PROXY_COOLDOWN_MS: normalizeProxyCooldownMs( parsePort(process.env['OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS'], 0) || - readFileNumber('UPSTREAM_PROXY_COOLDOWN_MS', defaultConfig.UPSTREAM_PROXY_COOLDOWN_MS), + readFileNumber('UPSTREAM_PROXY_COOLDOWN_MS', defaultConfig.UPSTREAM_PROXY_COOLDOWN_MS), + ), UPSTREAM_PROXY_NO_PROXY: parseProxyNoProxyList( process.env['OPENCODE_UPSTREAM_PROXY_NO_PROXY'] ?? process.env['UPSTREAM_PROXY_NO_PROXY'] ?? fileConfig['UPSTREAM_PROXY_NO_PROXY'], defaultConfig.UPSTREAM_PROXY_NO_PROXY, diff --git a/src/config/proxy-config.ts b/src/config/proxy-config.ts index 8cb82c9..0127042 100644 --- a/src/config/proxy-config.ts +++ b/src/config/proxy-config.ts @@ -1,7 +1,7 @@ // P4 TS: proxy config defaults + pure bool/config builders (ported from P3 .js, behavior identical). import type { DisableToolsOptions, ProxyConfig, ProxyConfigOptions } from '../types/config.js'; import { mergeApiKeySources } from '../auth/keys.js'; -import { DEFAULT_PROXY_COOLDOWN_MS, DEFAULT_PROXY_NO_PROXY, parseProxyList, parseProxyNoProxyList } from '../upstream-proxy/pool.js'; +import { DEFAULT_PROXY_COOLDOWN_MS, DEFAULT_PROXY_NO_PROXY, normalizeProxyCooldownMs, normalizeProxyStrategy, parseProxyList, parseProxyNoProxyList } from '../upstream-proxy/pool.js'; export const DEFAULT_REQUEST_TIMEOUT_MS = 300000; export const DEFAULT_POLL_INTERVAL_MS = 500; @@ -239,12 +239,13 @@ export function buildProxyConfig(options: unknown = {}): ProxyConfig { opts['UPSTREAM_PROXIES'] !== undefined || opts['UPSTREAM_PROXY_URLS'] !== undefined ? parseProxyList([opts['UPSTREAM_PROXIES'], opts['UPSTREAM_PROXY_URLS']].flatMap((v) => (Array.isArray(v) ? v : [v]))) : parseProxyList([process.env['OPENCODE_UPSTREAM_PROXIES'], process.env['UPSTREAM_PROXIES']]), - UPSTREAM_PROXY_STRATEGY: + UPSTREAM_PROXY_STRATEGY: normalizeProxyStrategy( (typeof opts['UPSTREAM_PROXY_STRATEGY'] === 'string' && (opts['UPSTREAM_PROXY_STRATEGY'] as string)) || - process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'] || - 'failover-rr', - UPSTREAM_PROXY_COOLDOWN_MS: Number( - opts['UPSTREAM_PROXY_COOLDOWN_MS'] || process.env['OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS'] || DEFAULT_PROXY_COOLDOWN_MS, + process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'] || + 'failover-rr', + ), + UPSTREAM_PROXY_COOLDOWN_MS: normalizeProxyCooldownMs( + opts['UPSTREAM_PROXY_COOLDOWN_MS'] ?? process.env['OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS'] ?? DEFAULT_PROXY_COOLDOWN_MS, ), UPSTREAM_PROXY_NO_PROXY: parseProxyNoProxyList( opts['UPSTREAM_PROXY_NO_PROXY'] !== undefined diff --git a/src/proxy.ts b/src/proxy.ts index b00b758..1a7e4e4 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -107,10 +107,10 @@ export function createApp(config: ProxyConfig): CreateAppResult { if (req.method === 'OPTIONS' || req.path === '/health' || req.path === '/' || req.path === '/health/details' || req.path === '/metrics') return next(); if (apiKeyVerifier.keys.length > 0) { - const matchedIndex = apiKeyVerifier.matchedIndex( + const authorized = apiKeyVerifier.isAuthorized( req as unknown as { headers: { authorization?: unknown; 'x-api-key'?: unknown } }, ); - if (matchedIndex < 0) { + if (!authorized) { if (req.path === '/v1/messages') { res.status(401).json({ type: 'error', error: { type: 'authentication_error', message: 'Unauthorized' } }); return; @@ -118,7 +118,6 @@ export function createApp(config: ProxyConfig): CreateAppResult { res.status(401).json({ error: { message: 'Unauthorized' } }); return; } - (req as unknown as Record)['apiKeyId'] = `key-${matchedIndex + 1}`; } next(); }); diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 7ef2f96..08f2992 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -572,7 +572,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } } catch (e: unknown) { logDebug('Stream error:', toErrorMessage(e)); - if (fallbackToProxy) collected = { __error: e }; + collected = { __error: e }; } const collectedRecord = asRecord(collected); diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 46de477..9ab9f3a 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -23,6 +23,7 @@ import { } from '../tool-runtime/parser.js'; import { isTransientUpstreamError, normalizeBackendError, transformUpstreamError } from '../errors/upstream.js'; import { engageFallbackForFreeLimit } from '../upstream-proxy/fallback.js'; +import { detectHostedSearchTools } from '../search/grounding.js'; import { withTimeout, DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, @@ -102,13 +103,15 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void const toolsRaw: unknown = body['tools']; const tools: unknown[] = Array.isArray(toolsRaw) ? (toolsRaw as unknown[]) : []; // Anthropic server-side web_search would be silently dropped by the - // function-tool bridge; fail loudly with a pointer instead. - const hasHostedSearch = tools.some((def: unknown) => { - const r = asRecord(def); - const type = typeof r['type'] === 'string' ? String(r['type']) : ''; - const name = typeof r['name'] === 'string' ? String(r['name']) : ''; - return type.startsWith('web_search') || name === 'web_search'; - }); + // function-tool bridge; fail loudly with a pointer instead. Uses the + // shared helper (covers versioned types like web_search_20260222) + // plus the bare `web_search` name for name-only defs. + const hasHostedSearch = + detectHostedSearchTools(tools).requested || + tools.some((def: unknown) => { + const r = asRecord(def); + return typeof r['name'] === 'string' && String(r['name']) === 'web_search'; + }); if (hasHostedSearch) { res.status(400).json({ type: 'error', diff --git a/src/routes/responses.ts b/src/routes/responses.ts index fb152fe..4b5ce87 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -168,6 +168,15 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void resultingMode: toolMode, }; } + } else if (hostedSearch.requested && toolMode === TOOL_MODE.EXTERNAL_BRIDGE) { + // Metadata only: the overrides union below already grants websearch, + // but without this trackToolMode/health would under-report it. + if (!internalToolContext.allowedToolNames.includes('websearch')) { + internalToolContext = { + ...internalToolContext, + allowedToolNames: [...internalToolContext.allowedToolNames, 'websearch'], + }; + } } trackToolMode(toolMode, { configuredAllowlist: internalToolContext.allowedToolNames, diff --git a/src/search/grounding.ts b/src/search/grounding.ts index 1b24d03..333fd8a 100644 --- a/src/search/grounding.ts +++ b/src/search/grounding.ts @@ -5,6 +5,12 @@ // items + `url_citation` annotations from the backend tool parts. export const HOSTED_SEARCH_TOOL_TYPES = ['web_search', 'web_search_preview', 'google_search']; +/** True for hosted search tool types, incl. versioned variants (e.g. Anthropic `web_search_20260222`). */ +export function isHostedSearchType(type: unknown): boolean { + if (typeof type !== 'string') return false; + const v = type.toLowerCase(); + return HOSTED_SEARCH_TOOL_TYPES.includes(v) || v.startsWith('web_search_') || v.startsWith('web_search-'); +} export const SEARCH_GROUNDING_INSTRUCTION = 'A built-in web search tool (websearch) is enabled for this turn. ' + @@ -26,13 +32,13 @@ export interface HostedSearchRequest { kinds: string[]; } -/** Detect OpenAI/Gemini hosted search tools in a Responses `tools` array. */ +/** Detect OpenAI/Gemini/Anthropic hosted search tools in a `tools` array. */ export function detectHostedSearchTools(tools: unknown): HostedSearchRequest { if (!Array.isArray(tools)) return { requested: false, kinds: [] }; const kinds: string[] = []; for (const def of tools as unknown[]) { const t = toolTypeOf(def).toLowerCase(); - if (HOSTED_SEARCH_TOOL_TYPES.includes(t) && !kinds.includes(t)) kinds.push(t); + if (isHostedSearchType(t) && !kinds.includes(t)) kinds.push(t); } return { requested: kinds.length > 0, kinds }; } @@ -40,7 +46,7 @@ export function detectHostedSearchTools(tools: unknown): HostedSearchRequest { /** Remove hosted search defs so the external-tool registry stays function-only. */ export function stripHostedSearchTools(tools: unknown): unknown[] { if (!Array.isArray(tools)) return []; - return (tools as unknown[]).filter((def) => !HOSTED_SEARCH_TOOL_TYPES.includes(toolTypeOf(def).toLowerCase())); + return (tools as unknown[]).filter((def) => !isHostedSearchType(toolTypeOf(def).toLowerCase())); } export interface SearchSource { diff --git a/src/upstream-proxy/pool.ts b/src/upstream-proxy/pool.ts index 83a09bb..3502207 100644 --- a/src/upstream-proxy/pool.ts +++ b/src/upstream-proxy/pool.ts @@ -10,6 +10,9 @@ import { ProxyAgent, Socks5ProxyAgent, fetch as undiciFetch } from 'undici'; import type { Dispatcher } from 'undici'; export type ProxyStrategy = 'failover-rr' | 'round-robin' | 'random'; +// NOTE: `failover-rr` and `round-robin` are aliases (both advance round-robin +// across engagements; transport failures always fail over to the next healthy +// proxy). Kept as separate names for forward compatibility. const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); @@ -77,6 +80,19 @@ export function parseProxyNoProxyList(value: unknown, fallback: string[]): strin export const DEFAULT_PROXY_NO_PROXY = ['localhost', '127.0.0.1', '::1']; export const DEFAULT_PROXY_COOLDOWN_MS = 300000; +export const DEFAULT_PROXY_STRATEGY: ProxyStrategy = 'failover-rr'; + +/** Allow-list the strategy at the config layer so the stored value is always real. */ +export function normalizeProxyStrategy(value: unknown): ProxyStrategy { + const v = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return v === 'round-robin' || v === 'random' || v === 'failover-rr' ? (v as ProxyStrategy) : DEFAULT_PROXY_STRATEGY; +} + +/** Clamp the cooldown at the config layer (positive finite, else default). */ +export function normalizeProxyCooldownMs(value: unknown): number { + const n = typeof value === 'number' ? value : parseInt(String(value ?? ''), 10); + return Number.isFinite(n) && (n as number) > 0 ? Math.floor(n as number) : DEFAULT_PROXY_COOLDOWN_MS; +} export interface ProxyPoolOptions { proxies?: unknown; @@ -123,15 +139,8 @@ function targetHost(input: unknown): string { export function createProxyPool(options: ProxyPoolOptions = {}): UpstreamProxyPool { const proxies = parseProxyList(options.proxies); - const rawStrategy = typeof options.strategy === 'string' ? options.strategy.trim().toLowerCase() : ''; - const strategy: ProxyStrategy = - rawStrategy === 'round-robin' || rawStrategy === 'random' || rawStrategy === 'failover-rr' - ? (rawStrategy as ProxyStrategy) - : 'failover-rr'; - const cooldownMs = - typeof options.cooldownMs === 'number' && Number.isFinite(options.cooldownMs) && options.cooldownMs > 0 - ? Math.floor(options.cooldownMs) - : DEFAULT_PROXY_COOLDOWN_MS; + const strategy = normalizeProxyStrategy(options.strategy); + const cooldownMs = normalizeProxyCooldownMs(options.cooldownMs); const noProxy = new Set(parseProxyNoProxyList(options.noProxy, DEFAULT_PROXY_NO_PROXY)); const logDebug = typeof options.logDebug === 'function' ? options.logDebug : (): void => {}; // NOTE: undici logs an ExperimentalWarning for SOCKS5 support on first use; diff --git a/tests/hosted-search-routing.test.js b/tests/hosted-search-routing.test.js index 24294f3..0bc9154 100644 --- a/tests/hosted-search-routing.test.js +++ b/tests/hosted-search-routing.test.js @@ -91,6 +91,13 @@ describe('hosted-search routing guards', () => { expect(res.statusCode).toBe(400); expect(res.body.type).toBe('error'); expect(res.body.error.type).toBe('invalid_request_error'); + const google = await request(app).post('/v1/messages').send({ + model: 'opencode/kimi-k2.5', + max_tokens: 100, + messages: [{ role: 'user', content: 'hi' }], + tools: [{ type: 'google_search' }], + }); + expect(google.statusCode).toBe(400); }); test('plain function tools still bridge on chat/messages', async () => { diff --git a/tests/proxy-fallback.test.js b/tests/proxy-fallback.test.js index 2a3f1eb..0ebfe77 100644 --- a/tests/proxy-fallback.test.js +++ b/tests/proxy-fallback.test.js @@ -103,6 +103,19 @@ describe('proxy list parsing', () => { expect(parseProxyNoProxyList('', ['localhost'])).toEqual(['localhost']); expect(parseProxyNoProxyList('Example.COM, a', [])).toEqual(['example.com', 'a']); }); + + test('strategy/cooldown normalize to truthful values', async () => { + const pool = await import('../src/upstream-proxy/pool.js'); + expect(pool.normalizeProxyStrategy('random')).toBe('random'); + expect(pool.normalizeProxyStrategy(' ROUND-ROBIN ')).toBe('round-robin'); + expect(pool.normalizeProxyStrategy('turbo')).toBe('failover-rr'); + expect(pool.normalizeProxyStrategy('')).toBe('failover-rr'); + expect(pool.normalizeProxyCooldownMs(60000)).toBe(60000); + expect(pool.normalizeProxyCooldownMs('15000')).toBe(15000); + expect(pool.normalizeProxyCooldownMs('garbage')).toBe(300000); + expect(pool.normalizeProxyCooldownMs(0)).toBe(300000); + expect(pool.normalizeProxyCooldownMs(-5)).toBe(300000); + }); }); describe('proxy pool (network-free)', () => { diff --git a/tests/responses-websearch.test.js b/tests/responses-websearch.test.js index 1b0b0b3..fe17e9e 100644 --- a/tests/responses-websearch.test.js +++ b/tests/responses-websearch.test.js @@ -97,6 +97,7 @@ describe('grounding pure helpers', () => { test('detect/strip hosted search tools', () => { expect(detectHostedSearchTools([{ type: 'web_search' }]).requested).toBe(true); expect(detectHostedSearchTools([{ type: 'Google_Search' }]).requested).toBe(true); + expect(detectHostedSearchTools([{ type: 'web_search_20260222' }]).requested).toBe(true); expect(detectHostedSearchTools([{ type: 'function', function: { name: 'x' } }]).requested).toBe(false); expect(detectHostedSearchTools('nope').requested).toBe(false); const mixed = [{ type: 'web_search' }, { type: 'function', function: { name: 'f' } }]; From eebe6f73061fc380ca10b7a34b1b767a84af24eb Mon Sep 17 00:00:00 2001 From: samson910022 Date: Wed, 9 Sep 2026 18:57:21 +0800 Subject: [PATCH 3/3] fix(ci): run unit tests on Node 24 and lazy-load undici CI pinned Node 20 but undici 8 requires >=22.19, crashing every suite at import time (webidl.util.markAsUncloneable). Bump CI to Node 24 and load undici lazily so direct-only deployments never pay for or depend on the proxy runtime: load failure fails open to direct. --- .github/workflows/ci.yml | 3 +- src/upstream-proxy/pool.ts | 45 ++++++++++++++++++++++---- tests/proxy-fallback-no-undici.test.js | 37 +++++++++++++++++++++ 3 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 tests/proxy-fallback-no-undici.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21ab651..216605e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,8 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 20 + # Must satisfy package.json engines (undici 8 needs >=22.19). + node-version: 24 cache: npm - run: npm ci - run: npm run typecheck diff --git a/src/upstream-proxy/pool.ts b/src/upstream-proxy/pool.ts index 3502207..7b71fea 100644 --- a/src/upstream-proxy/pool.ts +++ b/src/upstream-proxy/pool.ts @@ -6,7 +6,10 @@ // // Loopback targets (localhost/127.0.0.1/::1) always bypass the proxy so the // default managed backend (`http://127.0.0.1:10001`) is never proxied. -import { ProxyAgent, Socks5ProxyAgent, fetch as undiciFetch } from 'undici'; +// +// undici is loaded lazily (dynamic import) so hosts on older Node keep +// running direct-only: only actual proxy engagement needs it, and a load +// failure fails open to direct with a warning. import type { Dispatcher } from 'undici'; export type ProxyStrategy = 'failover-rr' | 'round-robin' | 'random'; @@ -152,14 +155,39 @@ export function createProxyPool(options: ProxyPoolOptions = {}): UpstreamProxyPo let engagedUntil = 0; let current: string | null = null; - const getDispatcher = (proxyUrl: string): Dispatcher => { + // Cached dynamic import: resolved at most once; a rejection (e.g. Node too + // old for undici) is memorized so every proxied call fails open to direct. + let undiciModule: { + ProxyAgent: new (url: string) => Dispatcher; + Socks5ProxyAgent: new (url: string) => Dispatcher; + fetch: (input: unknown, init?: unknown) => Promise; + } | null = null; + let undiciFailed = false; + const loadUndici = async (): Promise => { + if (undiciModule || undiciFailed) return undiciModule; + try { + const mod = (await import('undici')) as unknown as NonNullable; + undiciModule = mod; + return mod; + } catch (err) { + undiciFailed = true; + logDebug('Upstream proxy runtime unavailable, staying direct', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + }; + + const getDispatcher = async (proxyUrl: string): Promise => { const hit = dispatchers.get(proxyUrl); if (hit) return hit; + const mod = await loadUndici(); + if (!mod) return null; const protocol = new URL(proxyUrl).protocol; const agent: Dispatcher = protocol === 'socks:' || protocol === 'socks5:' || protocol === 'socks5h:' - ? ((new Socks5ProxyAgent(proxyUrl) as unknown) as Dispatcher) - : ((new ProxyAgent(proxyUrl) as unknown) as Dispatcher); + ? new mod.Socks5ProxyAgent(proxyUrl) + : new mod.ProxyAgent(proxyUrl); dispatchers.set(proxyUrl, agent); return agent; }; @@ -224,10 +252,13 @@ export function createProxyPool(options: ProxyPoolOptions = {}): UpstreamProxyPo if (!proxyUrl) return direct(); current = proxyUrl; try { - return await (undiciFetch as (i: unknown, o?: unknown) => Promise)(input as never, { + const mod = await loadUndici(); + const dispatcher = proxyUrl ? await getDispatcher(proxyUrl) : null; + if (!mod || !dispatcher) return direct(); + return await mod.fetch(input as never, { ...((init as Record) ?? {}), - dispatcher: getDispatcher(proxyUrl), - }); + dispatcher, + } as never); } catch (err) { // Transport failure through this proxy: cool it, fail open to direct // for this call (quota errors are handled by callers, not here). diff --git a/tests/proxy-fallback-no-undici.test.js b/tests/proxy-fallback-no-undici.test.js new file mode 100644 index 0000000..e5fa90a --- /dev/null +++ b/tests/proxy-fallback-no-undici.test.js @@ -0,0 +1,37 @@ +import { jest } from '@jest/globals'; + +// Simulates hosts where undici cannot load (e.g. Node < 22.19): engagement +// must fail open to direct instead of crashing the request. +jest.unstable_mockModule('undici', () => { + throw new Error('undici unavailable on this runtime'); +}); + +const { createProxyPool } = await import('../src/upstream-proxy/pool.js'); + +describe('proxy pool without undici runtime', () => { + let savedFetch; + let fetchCalls; + beforeEach(() => { + fetchCalls = []; + savedFetch = globalThis.fetch; + globalThis.fetch = (async (...args) => { + fetchCalls.push(args); + return new Response('direct-ok'); + }) as typeof fetch; + }); + afterEach(() => { + globalThis.fetch = savedFetch; + }); + + test('engaged non-loopback target fails open to direct', async () => { + const pool = createProxyPool({ proxies: 'socks5://10.0.0.9:1080', cooldownMs: 60000 }); + expect(pool.engage('free')).toContain('socks5://'); + expect(pool.isEngaged()).toBe(true); + const res = await pool.proxiedFetch('https://example.com/api', {}); + expect(fetchCalls.length).toBe(1); + expect(await res.text()).toBe('direct-ok'); + // Second call remembers the failure (no repeated import storms). + await pool.proxiedFetch('https://example.com/api', {}); + expect(fetchCalls.length).toBe(2); + }); +});