diff --git a/.env.example b/.env.example index 60a7cdb..a810757 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,19 @@ DISABLE_TOOLS=true OPENCODE_EXTERNAL_TOOLS_MODE=proxy-bridge OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY=namespace +# External tool policy (canonical OPENCODE_EXTERNAL_TOOL_* > legacy raw alias > +# config.json short key > default; invalid values fall through to the next). +# OPENCODE_EXTERNAL_TOOL_POLICY_MODE=enforce +# OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL=low +# OPENCODE_EXTERNAL_TOOL_ALLOWLIST= +# OPENCODE_EXTERNAL_TOOL_DENYLIST= +# OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR= +# EXTERNAL_TOOL_POLICY_MODE=enforce +# EXTERNAL_TOOL_DEFAULT_RISK_LEVEL=low +# EXTERNAL_TOOL_ALLOWLIST= +# EXTERNAL_TOOL_DENYLIST= +# EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR= + # Helpful when running behind OpenClaw or other clients that already manage prompts. OPENCODE_PROXY_PROMPT_MODE=plugin-inject OPENCODE_PROXY_OMIT_SYSTEM_PROMPT=true @@ -27,7 +40,9 @@ OPENCODE_PROXY_CLEANUP_INTERVAL_MS=43200000 OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 # Optional overrides +# Leave an entry empty to fall through to config.json / the built-in default. # OPENCODE_PROXY_PORT=10000 +# PORT=10000 # OPENCODE_SERVER_PORT=10001 # OPENCODE_SERVER_URL=http://127.0.0.1:10001 # BIND_HOST=0.0.0.0 @@ -38,6 +53,7 @@ OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 # OPENCODE_PROXY_REQUEST_TIMEOUT_MS=180000 # Retry budget after the initial attempt (0-5, default 3; total attempts = 1+n) # OPENCODE_PROXY_RETRY_MAX_RETRIES=3 +# RETRY_MAX_RETRIES=3 # OPENCODE_PROXY_DEBUG=false # OPENCODE_PATH=opencode # OPENCODE_ZEN_API_KEY= @@ -49,9 +65,13 @@ OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 # 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 +# UPSTREAM_PROXIES=socks5://user:pass@10.0.0.1:1080,http://user:pass@10.0.0.2:8080 # OPENCODE_UPSTREAM_PROXY_STRATEGY=failover-rr +# UPSTREAM_PROXY_STRATEGY=failover-rr # OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS=300000 +# UPSTREAM_PROXY_COOLDOWN_MS=300000 # OPENCODE_UPSTREAM_PROXY_NO_PROXY=localhost,127.0.0.1,::1 +# 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 eb9ba06..6e01da0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,9 +7,9 @@ ## 1. Project overview - **What:** OpenAI-, Anthropic-, and Gemini-compatible gateway in front of a local [OpenCode](https://opencode.ai) runtime. Endpoints: `GET /health`, `GET /health/details`, `GET /metrics`, `GET /v1/models`, `POST /v1/chat/completions`, `POST /v1/responses`, `POST /v1/messages` (Anthropic-compatible), `POST /v1beta/interactions` (alias `POST /v1/interactions`, Gemini-compatible thin layer). -- **Production entry:** `index.ts` (env > file > default merge, `index.ts:113-199`, then `startProxy`, `index.ts:276`). Never import `index.ts` from tests — it boots the server on import. -- **Library entry:** `startProxy` / `createApp` in `src/proxy.ts` (`startProxy` at `src/proxy.ts:860-894`); test/library config builder is `buildProxyConfig` in `src/config/proxy-config.ts:97-223`. -- **Runtime matrix:** local dev `tsx watch index.ts` vs prod `node dist/index.js` vs Docker (multi-stage `Dockerfile`, backend owned by `entrypoint.sh`). `index.ts:76-80` probes three `config.json` locations to cover the `index.ts` ↔ `dist/index.js` directory shift. +- **Production entry:** `index.ts` (env > file > default merge, `index.ts:197-368`, then `startProxy`, `index.ts:463`). Never import `index.ts` from tests — it boots the server on import. +- **Library entry:** `startProxy` / `createApp` in `src/proxy.ts` (`startProxy` at `src/proxy.ts:1115-1149`); test/library config builder is `buildProxyConfig` in `src/config/proxy-config.ts:229-432` (shared pure resolvers at `:90-187`). +- **Runtime matrix:** local dev `tsx watch index.ts` vs prod `node dist/index.js` vs Docker (multi-stage `Dockerfile`, backend owned by `entrypoint.sh`). `index.ts:83-88` probes three `config.json` locations to cover the `index.ts` ↔ `dist/index.js` directory shift. ## 2. Tech stack & commands (single source of truth) @@ -28,11 +28,11 @@ ## 3. Directory structure ``` -index.ts # prod bootstrap + config merge (300 lines) -src/proxy.ts # createApp/startProxy (894 lines; god file, see §9) -src/config/proxy-config.ts # buildProxyConfig, normalizeBool, resolveDisableTools -src/routes/ # chat.ts (953) / responses.ts (1144) / messages.ts (635) / interactions.ts (439) / system.ts -src/tool-runtime/ # contracts / registry / router / parser (945) / validator / policy +index.ts # prod bootstrap + config merge (487 lines) +src/proxy.ts # createApp/startProxy (1149 lines; god file, see §9) +src/config/proxy-config.ts # buildProxyConfig + shared pure normalizers (bool/int/port/url/list/enum) +src/routes/ # chat.ts (1215) / responses.ts (1798) / messages.ts (978) / interactions.ts (534) / system.ts +src/tool-runtime/ # contracts / registry / router / parser (1934) / validator / policy src/backend/manager.ts # backend lifecycle + request lock/queue src/stream/collector.ts # prompt/poll/collect SSE pipeline src/retry/policy.ts # pure retry helpers (resolveMaxRetries, backoff+jitter) @@ -46,36 +46,39 @@ Dockerfile / docker-compose.yml / entrypoint.sh config.json.example / .env.example # examples only, never real secrets ``` -## 4. Configuration principle: env > file > default (+ canonical names) +## 4. Configuration principle: env canonical > env legacy alias > file > default -- Lookup order for `config.json`: `/config.json` → `/../config.json` → `cwd/config.json` (`index.ts:76-80`). +- Lookup order for `config.json`: `/config.json` → `/../config.json` → `cwd/config.json` (`index.ts:83-88`). - Merge order: **env canonical > env legacy alias > file short key > hardcoded default**. Invalid env values fall through to the next source (do not coerce garbage to `false`). - Canonical env names (use these in `.env` / compose / Dockerfile / docs; file keys are the short forms in `config.json.example`): | Meaning | Canonical env | File key | Default | |:--------|:--------------|:---------|:--------| -| Proxy port | `OPENCODE_PROXY_PORT` (legacy env `PORT` also read) | `PORT` | `10000` | -| Backend port (bakes default URL) | `OPENCODE_SERVER_PORT` | — (`OPENCODE_SERVER_URL` file key) | `10001` | -| Backend URL | `OPENCODE_SERVER_URL` | `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | +| Proxy port | `OPENCODE_PROXY_PORT` (legacy env `PORT` also read) | `PORT` | `10000` (strict `1..65535`) | +| Backend port (bakes default URL only) | `OPENCODE_SERVER_PORT` | — (`OPENCODE_SERVER_URL` file key) | `10001` (strict `1..65535`; `entrypoint.sh` re-validates) | +| Backend URL | `OPENCODE_SERVER_URL` | `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` (http/https only) | | Backend password | `OPENCODE_SERVER_PASSWORD` | `OPENCODE_SERVER_PASSWORD` | `''` | | Bind host | `BIND_HOST` primary, `OPENCODE_PROXY_BIND_HOST` fallback | `BIND_HOST` | `0.0.0.0` | | Tool master switch | `OPENCODE_DISABLE_TOOLS` (legacy `DISABLE_TOOLS` also read) | `DISABLE_TOOLS` | `true` | -| External bridge | `OPENCODE_EXTERNAL_TOOLS_MODE` / `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `EXTERNAL_TOOLS_MODE` / `EXTERNAL_TOOLS_CONFLICT_POLICY` | `proxy-bridge` / `namespace` | +| External bridge | `OPENCODE_EXTERNAL_TOOLS_MODE` / `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `EXTERNAL_TOOLS_MODE` / `EXTERNAL_TOOLS_CONFLICT_POLICY` | `proxy-bridge` / `namespace` (invalid env/file warns + falls through; explicit library `options` value throws) | +| External tool policy | `OPENCODE_EXTERNAL_TOOL_POLICY_MODE` / `OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL` / `OPENCODE_EXTERNAL_TOOL_ALLOWLIST` / `OPENCODE_EXTERNAL_TOOL_DENYLIST` / `OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR` (legacy raw env = bare `EXTERNAL_TOOL_*` also read) | bare `EXTERNAL_TOOL_*` (same names) | `enforce` / `low` / `(none)` / `(none)` / `(none)` | | Internal allowlist | `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` / `OPENCODE_INTERNAL_ALLOWED_TOOLS` / `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED` | `INTERNAL_*` short forms | `false` / `(none)` / `true` | | Discovery fixture (note asymmetric name) | `OPENCODE_TOOL_DISCOVERY_FIXTURE` (no `INTERNAL_` infix) | `INTERNAL_TOOL_DISCOVERY_FIXTURE` | `(none)` | | Health / metrics | `OPENCODE_HEALTH_DETAILS_ENABLED` / `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH` / `OPENCODE_METRICS_ENABLED` / `OPENCODE_METRICS_REQUIRE_AUTH` | bare short forms | `true` / `true` / `false` / `true` | | Isolated home | `OPENCODE_USE_ISOLATED_HOME` (bare `USE_ISOLATED_HOME` is file-only) | `USE_ISOLATED_HOME` | `false` | -| Prompt | `OPENCODE_PROXY_PROMPT_MODE` / `OPENCODE_PROXY_OMIT_SYSTEM_PROMPT` / `OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS` / `OPENCODE_PROXY_CLEANUP_INTERVAL_MS` / `OPENCODE_PROXY_CLEANUP_MAX_AGE_MS` / `OPENCODE_PROXY_REQUEST_TIMEOUT_MS` / `OPENCODE_PROXY_RETRY_MAX_RETRIES` / `OPENCODE_PROXY_DEBUG` | bare short forms | `standard` / `false` / `false` / `43200000` / `86400000` / `180000` / `3` / `false` | +| Prompt | `OPENCODE_PROXY_PROMPT_MODE` / `OPENCODE_PROXY_OMIT_SYSTEM_PROMPT` / `OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS` / `OPENCODE_PROXY_CLEANUP_INTERVAL_MS` / `OPENCODE_PROXY_CLEANUP_MAX_AGE_MS` / `OPENCODE_PROXY_REQUEST_TIMEOUT_MS` / `OPENCODE_PROXY_RETRY_MAX_RETRIES` (legacy raw env `RETRY_MAX_RETRIES` also read) / `OPENCODE_PROXY_DEBUG` | bare short forms | `standard` / `false` / `false` / `43200000` / `86400000` / `180000` / `3` / `false` | | 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` | +| Fallback proxies | `OPENCODE_UPSTREAM_PROXIES` / `OPENCODE_UPSTREAM_PROXY_STRATEGY` / `OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS` / `OPENCODE_UPSTREAM_PROXY_NO_PROXY` (legacy raw env `UPSTREAM_*` merged in **both** entries) | `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. Note: `true` still resolves `true` — the free-tier strip (`selectPromptToolOverrides`, `src/proxy.ts:75-91`) is a post-resolution prompt-body filter for `isFreeTierSuspectModel` only (guard text + output markup stripping stay active). -- Out-of-matrix backend identity (do NOT add to the six-way matrix): `OPENCODE_CLIENT` is read directly in `src/backend/manager.ts:41-44` (allowlist `cli/desktop/acp/app`, default `cli`, foreign values overwritten), never enters `ProxyConfig`/`buildProxyConfig`; documented in `docs/configuration.md` backend-identity subsection + `CHANGELOG.md`. Same for the `OPENCODE_API_KEY="public"` strip and best-effort jail `git init`. -- `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). -- **Known dual-entry drifts (do NOT silently "fix" defaults — they change behavior; unify explicitly with tests):** `REQUEST_TIMEOUT_MS` prod `180000` (`index.ts:179`, Dockerfile/compose/docs) vs library `300000` (`src/config/proxy-config.ts:4`); `MANAGE_BACKEND` prod `false` (`index.ts:49`) vs library `true` (`src/config/proxy-config.ts:159`); `OMIT_SYSTEM_PROMPT` prod unconditional `false` (`index.ts:184-187`) vs library auto-`true` under `plugin-inject` (`src/config/proxy-config.ts:208-211`); bool parsing strictness differs (`index.ts:12-22` loose vs `src/config/proxy-config.ts:24-37` strict vs `DEBUG`/`ISOLATED` exact-match). Any new env must update all six: `.env.example`, `config.json.example`, `Dockerfile`, `docker-compose.yml`, `index.ts`, `docs/configuration.md` + this table. +- `DISABLE_TOOLS` resolution: `OPENCODE_DISABLE_TOOLS > DISABLE_TOOLS > file > true` via `resolveDisableTools` (`src/config/proxy-config.ts:76-86`); covered by `tests/env-alias.test.js`. Never `??`-chain booleans by hand; call the helper. Note: `true` still resolves `true` — the free-tier strip (`selectPromptToolOverrides`, `src/proxy.ts:75-91`) is a post-resolution prompt-body filter for `isFreeTierSuspectModel` only (guard text + output markup stripping stay active). +- Out-of-matrix backend identity (do NOT add to the six-way matrix): `OPENCODE_CLIENT` is read directly in `src/backend/manager.ts:41-45` (allowlist `cli/desktop/acp/app`, default `cli`, foreign values overwritten), never enters `ProxyConfig`/`buildProxyConfig`; documented in `docs/configuration.md` backend-identity subsection + `CHANGELOG.md`. Same for the `OPENCODE_API_KEY="public"` strip and best-effort jail `git init`. +- `RETRY`: strict-merged by `resolveRetryCountSetting` (options > `OPENCODE_PROXY_RETRY_MAX_RETRIES` > legacy `RETRY_MAX_RETRIES` > file > `3`; partial numerics like `3abc`/`2.9` are invalid and fall through — never `parseInt`-truncated), then clamped by `resolveMaxRetries` (`src/retry/policy.ts:51-58`, clamp 0–5, total attempts `1+n`, `2s×2ⁿ⁻¹` + 25% jitter, `retry-after` clamped to 30s). Covered by `tests/env-alias.test.js` + `tests/retry-policy.test.js`. +- **Single merge contract:** every key goes through the pure resolvers in `src/config/proxy-config.ts` (`resolveStringSetting` / `resolveBoolSetting` / `resolveIntSetting` / `resolvePortSetting` / `resolveDurationSetting` / `resolveRetryCountSetting` / `resolveListSetting` / `resolveEnumSetting` / `resolveUrlSetting`, plus parsers `parseStrictInteger` / `parseStrictPort` / `parseHttpUrl` / `parseToolNameList`). `index.ts` and `buildProxyConfig` share them, so never re-implement a local `parseBool`/`parsePort`/list parser in either entry — add a resolver there instead. Recognized bool tokens are exactly `1/true/yes/y/on` and `0/false/no/n/off` (case-insensitive, trimmed); everything else is *unset* and falls through (no `Boolean()` coercion). +- Docker/Compose declare **empty** defaults (`ENV X=` / `${X:-}`) for every config item: a non-empty image/compose default would outrank a mounted `config.json` or a legacy alias (env > file > default). Code owns the documented defaults, so empty env + no file is behaviorally identical. Compose `ports` + `healthcheck` both use `${OPENCODE_PROXY_PORT:-${PORT:-10000}}` (never a hardcoded 10000). `entrypoint.sh` validates `OPENCODE_PROXY_PORT` (falling back to legacy `PORT`) and `OPENCODE_SERVER_PORT` against `1..65535`, warns, and exports the normalized value so an invalid port never reaches `opencode serve --port`. +- **Known dual-entry drifts (do NOT silently "fix" defaults — they change behavior; unify explicitly with tests):** `REQUEST_TIMEOUT_MS` prod `180000` (`index.ts:295`, docs) vs library `300000` (`src/config/proxy-config.ts:16`); `MANAGE_BACKEND` prod `false` (`index.ts:209-212`) vs library `true` (`src/config/proxy-config.ts:308`); `OMIT_SYSTEM_PROMPT` prod unconditional `false` (`index.ts:313-316`) vs library auto-`true` under `plugin-inject` (`src/config/proxy-config.ts:374-377`); bool parsing is now **unified** (both entries use `normalizeBool` through `resolveBoolSetting`; the old loose `index.ts` parser and the `DEBUG`/`USE_ISOLATED_HOME` exact-match chains are gone — behavior change: env/options `'yes'`/`'on'`/`'TRUE '` and numeric-string booleans are now recognized everywhere, garbage never coerces). Any new env must update all six: `.env.example`, `config.json.example`, `Dockerfile`, `docker-compose.yml`, `index.ts`, `docs/configuration.md` + this table. ## 5. Tool safety principle (default-deny) @@ -103,7 +106,7 @@ config.json.example / .env.example # examples only, never real secrets ## 8. Tests & build notes for agents - `tsconfig.json` includes only `index.ts` + `src/**/*` (tests excluded): `tsc` will NOT catch test-side API misuse. `tests/*.test.js` are JS run through `@swc/jest` (`jest.config.cjs`) importing `../src/*.js` paths mapped to `.ts`. Keep `import { jest } from '@jest/globals'` + `jest.unstable_mockModule` + top-level `await import(...)` patterns as-is (ESM order-sensitive). -- `tests/env-alias.test.js` mirrors the `index.ts:134-140` `DISABLE_TOOLS` chain — update the mirror if the chain changes. +- `tests/env-alias.test.js` mirrors the `index.ts` merge (it cannot import `index.ts`, which boots the server): the `DISABLE_TOOLS` chain plus the port/url/bool/policy-list/retry/upstream-strategy layer arrays. Update the mirrors when a layer array changes; the shared resolvers themselves are imported from `src/config/proxy-config.ts` and tested directly. - `tests/test-integration.sh` and `tests/test-streaming-real.sh` need a real backend/credentials and are not CI gates; do not "fix" them into the default `npm test` path. - `custom-bin/opencode` is a local-only binary; `Dockerfile` intentionally has no `custom-bin` logic (stock `npm i -g opencode-ai`). Do not reintroduce arch-specific binary coupling without an explicit request. diff --git a/CHANGELOG.md b/CHANGELOG.md index 654bbd6..449891d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **仓库去耦合**:文档克隆/Issue 链接与 Docker 镜像指向 `samson910022/OpenCode2API`,`package.json` 补全维护元数据。 - **Responses 示例模型**:`README`、`docs/api-reference.md`、`docs/getting-started.md` 的 `/v1/responses` 示例统一为 `opencode/muse-spark-1.3-contributor-free`(解析逻辑无需改动,裸名与带前缀均兼容)。 - **SDK 对齐**:`@opencode-ai/sdk ^1.1.51` → `^1.18.29`(与本地 server `1.18.29` 对齐;v1 调用形状不变,零改码;139 tests 全绿;`cross-spawn@7.0.6` 为 SDK 新增 prod 依赖,既有包去 dev 标记,无新版本;`tool.ids` 仍走上游 experimental 路径,后续跟进)。 +- **Unified env/file/default config merge (Phase 3A)**: `index.ts` and `buildProxyConfig` now share the strict resolvers in `src/config/proxy-config.ts` (`resolve*Setting`, `parseStrictInteger/Port`, `parseHttpUrl`, `parseToolNameList`); lookup stays env canonical > env legacy alias > file short key > hardcoded default with invalid values falling through. Library `resolveDisableTools` default is now `true` (was `false`), matching prod/docs; library consumers relying on the old tools-enabled default must now set `DISABLE_TOOLS=false` explicitly. Bool tokens are `1/true/yes/y/on` vs `0/false/no/n/off` everywhere. Docker/Compose keep empty defaults so code owns values; `entrypoint.sh` validates `OPENCODE_PROXY_PORT`/`PORT` and `OPENCODE_SERVER_PORT` against `1..65535` and unsets invalid proxy ports so `config.json` still applies. ### Added @@ -35,6 +36,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Translator parity fixes (N×N matrix review)**: chat array `content` parts no longer silently dropped (extract `text`/`content`); `responses` `incomplete` with `content_filter` reason round-trips instead of collapsing to `length` (non-stream + stream); `is_error` now round-trips via the `ERROR: ` text prefix on chat/messages/responses tool legs (strict `true`/`"true"` check; success text literally starting with `ERROR: ` round-trips as `is_error` by design); named `custom` tool declarations are kept so `custom_tool_call` invocations always have a matching declaration; ID generation centralized on `makeId(prefix)` (`toolu_` fallback separator canonicalized, prefix-only checks unaffected). N×N 翻译矩阵对齐修复。New `tests/translator-phase-fixes.test.js` (13 cases). - **后端错误透出**:`/v1/responses` 在后端 session 失败时曾 `throw` 纯对象,经 `transformUpstreamError` 被洗成 `500 Internal server error / code Object`、真实讯息丢失。新增 `normalizeBackendError`(纯对象→Error,保留 `data.message`/`name`/状态码推断),两处 `throw polled.error` 改用它;`transformUpstreamError` 纵深加固(默认分支改读 `data.message`/`name`,code 默认 `upstream_error` 不再用 `constructor.name`)。现 Credits 类错误正确回 `402 insufficient_quota` 并携带原文。新增 `tests/backend-error-surface.test.js`(4 例复现 LiteLLM 案例,含 messages)。 - **同族错误路径查漏**:responses 串流 idleTimeout 补 poll 失败检查(不再静默回空 `completed`);messages 非串流改用 transformed `status/type/code`(不再硬编码 `502/api_error`);messages 串流检查 `collected.error` 与 poll 失败(失败即抛正規化错误走 SSE error 事件,不再静默 200);`isTransientUpstreamError` 纳入 `name/code/type` 参与签名匹配(无 message 纯对象不再必判 false)。 +- **Docker six-way symmetry (Stage 1)**: `Dockerfile` adds the missing empty `ENV OPENCODE_PROXY_BIND_HOST=` plus the five legacy `ENV EXTERNAL_TOOL_*=` aliases so image defaults stay empty and never shadow a mounted `config.json` or legacy alias; behavior is value-identical (empty == unset). +- **Stream tool-error surface unification (Stage 2 + final)**: `/v1/chat/completions`, `/v1/messages`, and `/v1/responses` `stream:true` fail-closed tool errors (`parallel_external_tool_calls`, `external_tool_choice_*`, `invalid_external_tool_call`, `external_tool_policy_blocked`, `duplicate_external_tool_call_id`, `malformed_external_tool_call`) now return the same stable `code` as non-stream (chat: 502 JSON before headers / SSE `data: {error}` after; messages: 502 JSON / SSE `error` event, `api_error`; responses: 502 JSON / `response.failed` + `[DONE]`, `server_error`) instead of falling through to 500 `internal_error` via `transformUpstreamError`. +- **Collector ID-less tool tracking (Stage 2, review fix)**: counter becomes a per-tool-name `Set` (`noid:` / `noid:unknown`) so two concurrent ID-less backend tool parts no longer clear each other, while repeated `message.part.updated` snapshots of the same logical tool no longer inflate and pin the idle exemption until `REQUEST_TIMEOUT_MS`. Replayed terminal snapshots are ignored via a settled set (they must not delete a still-running bucket); name-mismatch fuzzy clearing applies to the single-bucket case only, multi-bucket ambiguity waits for the real terminal (waiting beats truncating). Locked by `tests/stream-hardening.test.js` (repeat, distinct-parallel, replay, mismatch both directions, same-name limitation). +- **Review confirmations (no behavior change)**: `stream:true` with any external tool contract intentionally buffers visible text until completion to avoid leaking `` markup incrementally (pure-text turns also batch — documented in route comments); non-stream `if (error != null)` intentionally fails even with partial content rather than returning partial as success on chat/messages/responses (retry still covers the no-output transient case; interactions is the deliberate exception — it only throws when there is no content at all, otherwise returns partial as `interaction.completed`); verified-empty tool discovery (`[]`) intentionally stays 503 fail-closed under `DISABLE_TOOLS=true` (a real backend always exposes tools; empty means unverifiable — locked by `tests/tool-alias.test.js`). +- **Explicit external-tool policy semantics (Stage 2, breaking note)**: `REQUIRE_CONFIRMATION` now fires only for proxy-authored `requiresConfirmation===true` or `EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR` hits (plus `report-only` bypass); the old implicit `WRITE/HIGH/CRITICAL`/`DELETE` inference is removed, so a `delete_repo` tool with an empty allowlist now returns `ALLOW` instead of being blocked. Allowlist / denylist / confirmation lists are exact-match only (no wildcard/glob support; matching is checked against original / namespaced / declared names). Operators relying on default-deny for destructive tools should list exact tool names explicitly, e.g. `EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR=delete_repo,write_file`. 白名单/黑名单/确认名单仅精确匹配,不支持 `*` 通配符(按原始名/命名空间名/声明名三者任一命中为准)。Parser stays fail-closed by design (`valid plus malformed` still rejects the batch with `malformed_external_tool_call`, locked by `tests/app.test.js`). +- **Wire-compat confirmations (Stage 3)**: upstream 503 now passes through as 503 `server_error` (was coerced to 502; `tool_discovery_unavailable` stays proxy-originated only, locked by `tests/backend-error-surface.test.js`); `/v1/messages` bare `{type:tool}` without `name` is 400 via `validateAnthropicToolChoice` (direct converter calls degrade to auto, locked by `tests/messages-anthropic.test.js`); empty `tool_result` is preserved as `TOOL_RESULT content:""` to keep turn alignment (prompt breaking: previously dropped, now one extra prompt line); chat/messages/responses empty-stream stays 500 `Upstream returned no assistant data` (fail-closed, non-stream empty stays 200 — documented inconsistency, unify later); interactions `storeResponseState` intentionally omits tool calls (text+grounding only); native discovery 30s cooldown fail-closed is by design (no second `tool.ids()` probe inside the window). ## [1.6.0] - 2026-09-08 diff --git a/Dockerfile b/Dockerfile index 1212bd8..8df375d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,39 +47,64 @@ RUN chown -R node:node /home/node/project EXPOSE 10000 10001 +# Config env defaults stay empty on purpose: a non-empty default would win over +# a mounted config.json (or a legacy alias) because the merge order is +# env canonical > env legacy alias > file > hardcoded default. Code owns the +# documented defaults, so empty env + no file behaves exactly like the old +# non-empty env. 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 PORT= +ENV OPENCODE_PROXY_PORT= +ENV OPENCODE_SERVER_PORT= +ENV OPENCODE_SERVER_URL= +ENV BIND_HOST= +ENV OPENCODE_PROXY_BIND_HOST= +ENV DISABLE_TOOLS= ENV OPENCODE_DISABLE_TOOLS= -ENV OPENCODE_PROXY_MANAGE_BACKEND=false -ENV OPENCODE_PATH=opencode +ENV OPENCODE_PROXY_MANAGE_BACKEND= +ENV OPENCODE_PATH= ENV OPENCODE_ZEN_API_KEY= -ENV OPENCODE_EXTERNAL_TOOLS_MODE=proxy-bridge -ENV OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY=namespace -ENV OPENCODE_INTERNAL_WEB_FETCH_ENABLED=false +ENV OPENCODE_EXTERNAL_TOOLS_MODE= +ENV OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY= +ENV OPENCODE_EXTERNAL_TOOL_POLICY_MODE= +ENV OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL= +ENV OPENCODE_EXTERNAL_TOOL_ALLOWLIST= +ENV OPENCODE_EXTERNAL_TOOL_DENYLIST= +ENV OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR= +ENV EXTERNAL_TOOL_POLICY_MODE= +ENV EXTERNAL_TOOL_DEFAULT_RISK_LEVEL= +ENV EXTERNAL_TOOL_ALLOWLIST= +ENV EXTERNAL_TOOL_DENYLIST= +ENV EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR= +ENV OPENCODE_INTERNAL_WEB_FETCH_ENABLED= ENV OPENCODE_INTERNAL_ALLOWED_TOOLS= -ENV OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true +ENV OPENCODE_INTERNAL_TOOL_METRICS_ENABLED= ENV OPENCODE_TOOL_DISCOVERY_FIXTURE= -ENV OPENCODE_HEALTH_DETAILS_ENABLED=true -ENV OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH=true -ENV OPENCODE_METRICS_ENABLED=false -ENV OPENCODE_METRICS_REQUIRE_AUTH=true -ENV OPENCODE_USE_ISOLATED_HOME=false -ENV OPENCODE_PROXY_DEBUG=false -ENV OPENCODE_PROXY_PROMPT_MODE=standard -ENV OPENCODE_PROXY_OMIT_SYSTEM_PROMPT=false -ENV OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=false -ENV OPENCODE_PROXY_CLEANUP_INTERVAL_MS=43200000 -ENV OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000 -ENV OPENCODE_PROXY_REQUEST_TIMEOUT_MS=180000 -ENV OPENCODE_PROXY_RETRY_MAX_RETRIES=3 +ENV OPENCODE_HEALTH_DETAILS_ENABLED= +ENV OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH= +ENV OPENCODE_METRICS_ENABLED= +ENV OPENCODE_METRICS_REQUIRE_AUTH= +ENV OPENCODE_USE_ISOLATED_HOME= +ENV OPENCODE_PROXY_DEBUG= +ENV OPENCODE_PROXY_PROMPT_MODE= +ENV OPENCODE_PROXY_OMIT_SYSTEM_PROMPT= +ENV OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS= +ENV OPENCODE_PROXY_CLEANUP_INTERVAL_MS= +ENV OPENCODE_PROXY_CLEANUP_MAX_AGE_MS= +ENV OPENCODE_PROXY_REQUEST_TIMEOUT_MS= +ENV OPENCODE_PROXY_RETRY_MAX_RETRIES= +ENV RETRY_MAX_RETRIES= 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 +ENV UPSTREAM_PROXIES= +ENV OPENCODE_UPSTREAM_PROXY_STRATEGY= +ENV UPSTREAM_PROXY_STRATEGY= +ENV OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS= +ENV UPSTREAM_PROXY_COOLDOWN_MS= +ENV OPENCODE_UPSTREAM_PROXY_NO_PROXY= +ENV UPSTREAM_PROXY_NO_PROXY= 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 4419233..0762a67 100644 --- a/config.json.example +++ b/config.json.example @@ -6,6 +6,11 @@ "DISABLE_TOOLS": true, "EXTERNAL_TOOLS_MODE": "proxy-bridge", "EXTERNAL_TOOLS_CONFLICT_POLICY": "namespace", + "EXTERNAL_TOOL_POLICY_MODE": "enforce", + "EXTERNAL_TOOL_DEFAULT_RISK_LEVEL": "low", + "EXTERNAL_TOOL_ALLOWLIST": [], + "EXTERNAL_TOOL_DENYLIST": [], + "EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR": [], "INTERNAL_WEB_FETCH_ENABLED": false, "INTERNAL_ALLOWED_TOOLS": [], "INTERNAL_TOOL_METRICS_ENABLED": true, diff --git a/docker-compose.yml b/docker-compose.yml index 450b79e..4042ce8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,12 @@ services: image: ghcr.io/samson910022/opencode2api:latest container_name: opencode2api ports: - - "${OPENCODE_PROXY_PORT:-10000}:${OPENCODE_PROXY_PORT:-10000}" + - "${OPENCODE_PROXY_PORT:-${PORT:-10000}}:${OPENCODE_PROXY_PORT:-${PORT:-10000}}" + # Every config entry defaults to empty on purpose: a non-empty compose + # default would win over a mounted config.json (or a legacy alias) because + # the merge order is env canonical > env legacy alias > file > hardcoded + # default. Code owns the documented defaults, so empty env + no file + # behaves exactly like the old non-empty env. environment: - PUID=${PUID:-1000} - PGID=${PGID:-1000} @@ -15,37 +20,54 @@ services: - 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} + - BIND_HOST=${BIND_HOST:-} + - OPENCODE_PROXY_BIND_HOST=${OPENCODE_PROXY_BIND_HOST:-} + - DISABLE_TOOLS=${DISABLE_TOOLS:-} - OPENCODE_DISABLE_TOOLS=${OPENCODE_DISABLE_TOOLS:-} - - OPENCODE_USE_ISOLATED_HOME=${OPENCODE_USE_ISOLATED_HOME:-false} - - OPENCODE_PROXY_DEBUG=${OPENCODE_PROXY_DEBUG:-false} - - OPENCODE_PROXY_PROMPT_MODE=${OPENCODE_PROXY_PROMPT_MODE:-standard} - - OPENCODE_PROXY_OMIT_SYSTEM_PROMPT=${OPENCODE_PROXY_OMIT_SYSTEM_PROMPT:-false} - - OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=${OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS:-false} - - OPENCODE_PROXY_CLEANUP_INTERVAL_MS=${OPENCODE_PROXY_CLEANUP_INTERVAL_MS:-43200000} - - OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=${OPENCODE_PROXY_CLEANUP_MAX_AGE_MS:-86400000} - - OPENCODE_PROXY_REQUEST_TIMEOUT_MS=${OPENCODE_PROXY_REQUEST_TIMEOUT_MS:-180000} - - OPENCODE_PROXY_RETRY_MAX_RETRIES=${OPENCODE_PROXY_RETRY_MAX_RETRIES:-3} + - OPENCODE_USE_ISOLATED_HOME=${OPENCODE_USE_ISOLATED_HOME:-} + - OPENCODE_PROXY_DEBUG=${OPENCODE_PROXY_DEBUG:-} + - OPENCODE_PROXY_PROMPT_MODE=${OPENCODE_PROXY_PROMPT_MODE:-} + - OPENCODE_PROXY_OMIT_SYSTEM_PROMPT=${OPENCODE_PROXY_OMIT_SYSTEM_PROMPT:-} + - OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=${OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS:-} + - OPENCODE_PROXY_CLEANUP_INTERVAL_MS=${OPENCODE_PROXY_CLEANUP_INTERVAL_MS:-} + - OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=${OPENCODE_PROXY_CLEANUP_MAX_AGE_MS:-} + - OPENCODE_PROXY_REQUEST_TIMEOUT_MS=${OPENCODE_PROXY_REQUEST_TIMEOUT_MS:-} + - OPENCODE_PROXY_RETRY_MAX_RETRIES=${OPENCODE_PROXY_RETRY_MAX_RETRIES:-} + - RETRY_MAX_RETRIES=${RETRY_MAX_RETRIES:-} - 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} + - UPSTREAM_PROXIES=${UPSTREAM_PROXIES:-} + - OPENCODE_UPSTREAM_PROXY_STRATEGY=${OPENCODE_UPSTREAM_PROXY_STRATEGY:-} + - UPSTREAM_PROXY_STRATEGY=${UPSTREAM_PROXY_STRATEGY:-} + - OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS=${OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS:-} + - UPSTREAM_PROXY_COOLDOWN_MS=${UPSTREAM_PROXY_COOLDOWN_MS:-} + - OPENCODE_UPSTREAM_PROXY_NO_PROXY=${OPENCODE_UPSTREAM_PROXY_NO_PROXY:-} + - UPSTREAM_PROXY_NO_PROXY=${UPSTREAM_PROXY_NO_PROXY:-} + - PORT=${PORT:-} + - OPENCODE_PROXY_PORT=${OPENCODE_PROXY_PORT:-} + - OPENCODE_SERVER_PORT=${OPENCODE_SERVER_PORT:-} - OPENCODE_SERVER_URL=${OPENCODE_SERVER_URL:-} - - OPENCODE_PATH=${OPENCODE_PATH:-opencode} - - OPENCODE_PROXY_MANAGE_BACKEND=${OPENCODE_PROXY_MANAGE_BACKEND:-false} - - OPENCODE_EXTERNAL_TOOLS_MODE=${OPENCODE_EXTERNAL_TOOLS_MODE:-proxy-bridge} - - OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY=${OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY:-namespace} - - OPENCODE_INTERNAL_WEB_FETCH_ENABLED=${OPENCODE_INTERNAL_WEB_FETCH_ENABLED:-false} + - OPENCODE_PATH=${OPENCODE_PATH:-} + - OPENCODE_PROXY_MANAGE_BACKEND=${OPENCODE_PROXY_MANAGE_BACKEND:-} + - OPENCODE_EXTERNAL_TOOLS_MODE=${OPENCODE_EXTERNAL_TOOLS_MODE:-} + - OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY=${OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY:-} + - OPENCODE_EXTERNAL_TOOL_POLICY_MODE=${OPENCODE_EXTERNAL_TOOL_POLICY_MODE:-} + - EXTERNAL_TOOL_POLICY_MODE=${EXTERNAL_TOOL_POLICY_MODE:-} + - OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL=${OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL:-} + - EXTERNAL_TOOL_DEFAULT_RISK_LEVEL=${EXTERNAL_TOOL_DEFAULT_RISK_LEVEL:-} + - OPENCODE_EXTERNAL_TOOL_ALLOWLIST=${OPENCODE_EXTERNAL_TOOL_ALLOWLIST:-} + - EXTERNAL_TOOL_ALLOWLIST=${EXTERNAL_TOOL_ALLOWLIST:-} + - OPENCODE_EXTERNAL_TOOL_DENYLIST=${OPENCODE_EXTERNAL_TOOL_DENYLIST:-} + - EXTERNAL_TOOL_DENYLIST=${EXTERNAL_TOOL_DENYLIST:-} + - OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR=${OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR:-} + - EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR=${EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR:-} + - OPENCODE_INTERNAL_WEB_FETCH_ENABLED=${OPENCODE_INTERNAL_WEB_FETCH_ENABLED:-} - OPENCODE_INTERNAL_ALLOWED_TOOLS=${OPENCODE_INTERNAL_ALLOWED_TOOLS:-} - - OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=${OPENCODE_INTERNAL_TOOL_METRICS_ENABLED:-true} + - OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=${OPENCODE_INTERNAL_TOOL_METRICS_ENABLED:-} - OPENCODE_TOOL_DISCOVERY_FIXTURE=${OPENCODE_TOOL_DISCOVERY_FIXTURE:-} - - OPENCODE_HEALTH_DETAILS_ENABLED=${OPENCODE_HEALTH_DETAILS_ENABLED:-true} - - OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH=${OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH:-true} - - OPENCODE_METRICS_ENABLED=${OPENCODE_METRICS_ENABLED:-false} - - OPENCODE_METRICS_REQUIRE_AUTH=${OPENCODE_METRICS_REQUIRE_AUTH:-true} + - OPENCODE_HEALTH_DETAILS_ENABLED=${OPENCODE_HEALTH_DETAILS_ENABLED:-} + - OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH=${OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH:-} + - OPENCODE_METRICS_ENABLED=${OPENCODE_METRICS_ENABLED:-} + - OPENCODE_METRICS_REQUIRE_AUTH=${OPENCODE_METRICS_REQUIRE_AUTH:-} volumes: - opencode-data:/home/node/.local/share/opencode - opencode-config:/home/node/.config/opencode @@ -66,7 +88,7 @@ services: healthcheck: # Shallow on purpose: /health needs no auth; /health/details would 401 # when HEALTH_DETAILS_REQUIRE_AUTH=true and has no credentials here. - test: ["CMD", "curl", "-f", "http://localhost:${OPENCODE_PROXY_PORT:-10000}/health"] + test: ["CMD", "curl", "-f", "http://localhost:${OPENCODE_PROXY_PORT:-${PORT:-10000}}/health"] interval: 30s timeout: 10s retries: 3 diff --git a/docs/README.md b/docs/README.md index ad2c4af..0c8b59e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,7 @@ Welcome to the OpenCode2API documentation. | 📄 [Main README](../README.md) | English canonical entrypoint | | 📄 [README (简体中文)](../README.zh-CN.md) | Simplified Chinese mirror of the main README | -Detailed endpoint diagnostics live with the configuration guide for now: `/health/details` and `/metrics` are specified in [Configuration](./configuration.md) and summarized in the [Main README](../README.md#api-reference). A dedicated section in [API Reference](./api-reference.md) is tracked below. +Detailed endpoint diagnostics live with the configuration guide for now: `/health/details` and `/metrics` are specified in [Configuration](./configuration.md) and summarized in the [Main README](../README.md#api-reference). --- @@ -38,8 +38,8 @@ Docs are migrating to **English-first**: the root [`README.md`](../README.md) is | Guide | Current language | English version | |:------|:-----------------|:----------------| | Getting Started | 简体中文 | tracked | -| Configuration | 简体中文 | tracked (canonical env names fixed; see `AGENTS.md` §4) | -| API Reference | 简体中文 + EN examples | tracked (needs `/health/details` + `/metrics` sections) | +| Configuration | 简体中文 | tracked (canonical env names documented; see `AGENTS.md` §4) | +| API Reference | 简体中文 + EN examples | tracked (needs English canonical + dedicated `/health/details` + `/metrics` sections, currently in Configuration) | | Docker Deployment | 简体中文 | tracked | | Troubleshooting | 简体中文 | tracked (smallest, do first) | | Development | 简体中文 | tracked (dedupe with `CONTRIBUTING.md`) | diff --git a/docs/api-reference.md b/docs/api-reference.md index aa83b80..b00f126 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -205,7 +205,7 @@ curl -X POST http://127.0.0.1:10000/v1/responses \ }' ``` -非流式 `responses` 响应会在 `response.output` 中返回 `type: "function_call"` 项;流式模式会发送 function_call 生命周期和参数增量事件。 +非流式 `responses` 响应会在 `response.output` 中返回 `type: "function_call"` 项;流式模式会发送 function_call 生命周期和参数增量事件。`previous_response_id` 续写只保留最近 128 条工具调用元数据(超出时丢弃最旧、保留最新;按 response ID 链作用域保存,内存态、30min TTL + 60s 清扫,重启动失);`function_call_output` 的 `call_id` 不在 state 且无 `name` 时返回 400 `unknown_tool_call_id`。 **服务端联网搜索(`web_search`):** diff --git a/docs/configuration.md b/docs/configuration.md index d2be906..323a948 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -8,7 +8,9 @@ ## 📌 配置方式 -> 配置优先级:**环境变量 > config.json > 默认值** +> 配置优先级:**canonical 环境变量 > 兼容别名(legacy raw env) > `config.json` > 程序内默认值** + +> 每一层都会先做严格正规化再比较:**无效值一律让位给下一层**(不会 `Boolean('garbage')` 之类的强制转换,也不会静默遮蔽下层)。详见 [合并与正规化规则](#-合并与正规化规则)。 > 📖 [Docs Index](./README.md) | 🏠 [Main README](../README.md) @@ -20,21 +22,27 @@ | 变量 | 默认值 | 说明 | |:-----|:-------|:-----| -| `PORT` / `OPENCODE_PROXY_PORT` | `10000` | 代理服务端口 | -| `OPENCODE_SERVER_PORT` | `10001` | OpenCode 后端服务端口 | +| `OPENCODE_PROXY_PORT` / `PORT` | `10000` | 代理服务端口(canonical 优先,legacy `PORT` 其次,`config.json` 短键 `PORT` 第三);严格校验 `1..65535`,无效值让位给下一层 | +| `OPENCODE_SERVER_PORT` | `10001` | OpenCode 后端服务端口;严格校验 `1..65535`,无效值回落 `10001`(不会把 `abc` 拼进 URL 或传给 `opencode serve --port`)。**只用于生成默认回环 URL** `http://127.0.0.1:`,显式 `OPENCODE_SERVER_URL` 永远优先 | +| `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | OpenCode 后端地址;只接受可解析的 `http(s)` URL,非法值让位给 `config.json`(短键同名)/ 默认值 | | `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 后端密码 | +| `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径(空白/非字符串让位给 `config.json` / 默认值) | ### 功能配置 | 变量 | 默认值 | 说明 | |:-----|:-------|:-----| | `OPENCODE_DISABLE_TOOLS` / `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认)。例外:① 疑似免费 Zen 模型(`opencode` provider 且 `-free` 后缀、`big-pickle`、`union-alpha`)剔除 prompt `tools` 映射中的 `false` 项(仅保留 `true` 项;全 `false` 则省略整个映射)以避开上游 `FreeTierError` 403(任意 `false` 均触发,`{}`/省略/`true`-only 通过),被剔除的工具回落服务端 agent 默认,此时仅靠 system prompt 禁用语 + 输出侧 markup 剥离承载禁用姿态;② `/v1/responses` 的 `web_search` 与 Interactions 的 `google_search` 会以 `hosted-search-grant` 单独放行 `websearch`(输出仅 `web_search_call` + 引文,不经过外部桥接) | -| `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` | -| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具冲突隔离策略;当前仅支持 `namespace` | +| `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge`。非法值(env 或 `config.json`)打印 `[Config] Warning` 后让位给下一层,不会静默遮蔽 `config.json`;library 侧显式传入的 `options.EXTERNAL_TOOLS_MODE` 非法则直接抛错(fail-fast) | +| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具冲突隔离策略;当前仅支持 `namespace`,处理规则同上 | +| `OPENCODE_EXTERNAL_TOOL_POLICY_MODE` | `enforce` | 外部工具策略模式:`enforce`(命中 `REQUIRE_CONFIRMATION_FOR` 时拦截)/ `report-only`(只记日志放行)。legacy raw env `EXTERNAL_TOOL_POLICY_MODE` 次之,`config.json` 短键同名第三 | +| `OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL` | `low` | 外部工具未声明风险时的兜底等级:`low`/`medium`/`high`/`critical`(大小写不敏感)。legacy raw env `EXTERNAL_TOOL_DEFAULT_RISK_LEVEL` 次之,`config.json` 短键同名第三 | +| `OPENCODE_EXTERNAL_TOOL_ALLOWLIST` | `(none)` | 外部工具白名单,逗号分隔;非空时不在名单内的外部工具调用一律拦截。legacy raw env `EXTERNAL_TOOL_ALLOWLIST` 次之,`config.json` 短键同名第三(数组写法) | +| `OPENCODE_EXTERNAL_TOOL_DENYLIST` | `(none)` | 外部工具黑名单,逗号分隔;优先级最高(命中即拒绝,不看白名单)。legacy raw env `EXTERNAL_TOOL_DENYLIST` 次之,`config.json` 短键同名第三 | +| `OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR` | `(none)` | 需要确认才执行的外部工具名单,逗号分隔(`report-only` 模式只记录不拦截)。legacy raw env `EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR` 次之,`config.json` 短键同名第三 | | `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;未显式配置 allowlist 时,启用后默认放行 `web_fetch` | | `OPENCODE_INTERNAL_ALLOWED_TOOLS` | `(none)` | 当请求未传入 `tools` 时允许使用的 OpenCode 内置工具列表,逗号分隔(例 `websearch,webfetch`;`web_fetch` 等旧写法仍可匹配,大小写/分隔符不敏感) | | `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED` | `true` | 输出 internal allowlist 模式的调试/指标日志 | @@ -43,14 +51,14 @@ | `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH` | `true` | 控制 `/health/details` 是否要求 Bearer 认证 | | `OPENCODE_METRICS_ENABLED` | `false` | 控制 Prometheus `/metrics` 是否暴露 | | `OPENCODE_METRICS_REQUIRE_AUTH` | `true` | 控制 `/metrics` 是否要求 Bearer 认证 | -| `OPENCODE_USE_ISOLATED_HOME` | `false` | 使用隔离的 OpenCode 配置目录(`config.json` 中用短键 `USE_ISOLATED_HOME`) | +| `OPENCODE_USE_ISOLATED_HOME` | `false` | 使用隔离的 OpenCode 配置目录(`config.json` 中用短键 `USE_ISOLATED_HOME`;裸 `USE_ISOLATED_HOME` 只是 file 短键,不读同名 env) | | `OPENCODE_PROXY_PROMPT_MODE` | `standard` | 提示词处理模式(`config.json` 中用短键 `PROMPT_MODE`) | | `OPENCODE_PROXY_OMIT_SYSTEM_PROMPT` | `false` | 忽略传入的 system prompt(`config.json` 中用短键 `OMIT_SYSTEM_PROMPT`) | | `OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS` | `false` | 自动清理会话存储(`config.json` 中用短键 `AUTO_CLEANUP_CONVERSATIONS`) | | `OPENCODE_PROXY_CLEANUP_INTERVAL_MS` | `43200000` | 清理间隔 (毫秒)(`config.json` 中用短键 `CLEANUP_INTERVAL_MS`) | | `OPENCODE_PROXY_CLEANUP_MAX_AGE_MS` | `86400000` | 最大存储时间 (毫秒)(`config.json` 中用短键 `CLEANUP_MAX_AGE_MS`) | | `OPENCODE_PROXY_REQUEST_TIMEOUT_MS` | `180000` | 请求超时时间 (毫秒)(`config.json` 中用短键 `REQUEST_TIMEOUT_MS`) | -| `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`)(`config.json` 中用短键 `RETRY_MAX_RETRIES`) | +| `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`)(`config.json` 中用短键 `RETRY_MAX_RETRIES`,legacy raw env `RETRY_MAX_RETRIES` 亦可)。合并时按**严格整数**解析:`'3abc'`/`'2.9'`/`'1e1'` 视为无效并让位给下一层(不会被 `parseInt` 截断成 `3`/`2`/`1`);0-5 的 clamp 仍由 `resolveMaxRetries` 统一执行 | > 重试退避移植自上游 `session/retry.ts`(`2s×2ⁿ⁻¹` +25% jitter),但 `retry-after` 等待 clamp 在 30s(上游近无界;网关面对自带超时的客户端不宜久睡)。旧部署注意:默认总尝试由 3 次变为 1+3=4 次,如需接近旧次数可设 `2`。 @@ -62,10 +70,10 @@ | 变量 | 默认值 | 说明 | |:-----|:-------|:-----| -| `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` 恒直连) | +| `OPENCODE_UPSTREAM_PROXIES` / `UPSTREAM_PROXIES` | `(none)` | 逗号分隔的代理 URL(`socks5://` 优先,亦支持 `http(s)://`;`config.json` 中用短键 `UPSTREAM_PROXIES` 数组) | +| `OPENCODE_UPSTREAM_PROXY_STRATEGY` / `UPSTREAM_PROXY_STRATEGY` | `failover-rr` | `failover-rr` / `round-robin` / `random`(连续限流即轮换下一个)。非法值让位给下一层(`config.json` 短键 `UPSTREAM_PROXY_STRATEGY`) | +| `OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS` / `UPSTREAM_PROXY_COOLDOWN_MS` | `300000` | engaged 粘滞时长(毫秒),到期回直连;非法/非正数让位给下一层(`config.json` 短键同名) | +| `OPENCODE_UPSTREAM_PROXY_NO_PROXY` / `UPSTREAM_PROXY_NO_PROXY` | `localhost,127.0.0.1,::1` | 永不走代理的目标 host(默认后端 `127.0.0.1` 恒直连);空字符串视为未设置,让位给别名/`config.json` | > 注意:`GET /health/details` 的 `internal_tools.fallback_proxies` 可观察 engaged 状态;`/metrics` 有 `opencode_fallback_proxy_engaged` gauge。流式 SSE 不走自定义 fetch(上游 SDK 缺口),fallback 自动降级为轮询。 @@ -86,8 +94,56 @@ --- +## 🧮 合并与正规化规则 + +配置矩阵的合并与正规化逻辑集中在 `src/config/proxy-config.ts` 的纯函数里(`resolveStringSetting` / `resolveBoolSetting` / `resolveIntSetting` / `resolvePortSetting` / `resolveDurationSetting` / `resolveRetryCountSetting` / `resolveListSetting` / `resolveEnumSetting` / `resolveUrlSetting`),`index.ts`(生产入口)与 `buildProxyConfig`(library 入口)共用同一套实现,因此两边行为一致、且可被单元测试直接覆盖(`index.ts` 本身不可 import,会启动服务)。 + +### 每层的判定顺序 + +1. caller options(仅 library:`startProxy(...)` / `buildProxyConfig(...)` 传入值) +2. env canonical(`OPENCODE_*`) +3. env legacy raw alias(如 `PORT`、`DISABLE_TOOLS`、`RETRY_MAX_RETRIES`、`UPSTREAM_PROXY_*`、`EXTERNAL_TOOL_*`) +4. `config.json` 短键 +5. 程序内硬编码默认值 + +任何一层取值后都会先经过正规化,**无效即视为未设置**并让位给下一层;空字符串/空数组以外的非法值(`'garbage'`、`'2'`、`'yes '` 之类拼写错误、`0`/负数端口、不可解析 URL)都不会强制转换,也不会静默遮蔽下层。 + +### 正规化细则 + +| 类型 | 认可的写法 | 无效示例(让位下一层) | +|:-----|:-----------|:-----------------------| +| 布尔 | `1/true/yes/y/on` → true,`0/false/no/n/off` → false(大小写不敏感、自动 trim);数字仅 `0`/`1` | `garbage`、`2`、`-1`、``(空串)、` ` | +| 整数(时长/重试等) | 整数字面量或纯整数字符串(可带 `+`/`-`、首尾空白) | `3abc`、`2.9`、`1e3`、`43200000ms`、空串 | +| 端口 | 严格 `1..65535` 的整数 | `0`、`65536`、`-1`、`100 01`、`abc` | +| URL | 可被 `URL` 解析且协议为 `http:`/`https:` | `not-a-url`、`127.0.0.1:10001`、`ftp://…` | +| 枚举 | 大小写不敏感命中允许集合 | `yolo`、`urgent`(→ 打印 warning 并让位) | +| 列表 | 逗号分隔字符串或字符串数组(trim + 去重 + 去空) | 非字符串/非数组(数字、对象);空字符串视为未设置,显式 `[]` 视为「明确为空」 | + +### 双入口差异(已知,勿静默「修正」) + +| 项 | 生产入口 `index.ts` | library 入口 `buildProxyConfig` | +|:---|:--------------------|:------------------------------| +| `REQUEST_TIMEOUT_MS` 默认 | `180000` | `300000` | +| `MANAGE_BACKEND` 默认 | `false` | `true` | +| `OMIT_SYSTEM_PROMPT` 默认 | 恒定 `false` | `PROMPT_MODE=plugin-inject` 时自动 `true` | + +library 入口会额外读取 env 矩阵(非法值同样让位):`OPENCODE_PROXY_PORT`/`PORT`、`OPENCODE_SERVER_PORT`(仅用于生成默认回环 URL)、`OPENCODE_SERVER_URL`、`OPENCODE_PATH`、`OPENCODE_PROXY_MANAGE_BACKEND`、`OPENCODE_USE_ISOLATED_HOME`、`OPENCODE_PROXY_RETRY_MAX_RETRIES`/`RETRY_MAX_RETRIES`,以及 `UPSTREAM_PROXIES`/`UPSTREAM_PROXY_STRATEGY`/`UPSTREAM_PROXY_COOLDOWN_MS`/`UPSTREAM_PROXY_NO_PROXY` 与 `EXTERNAL_TOOL_*` 别名。 + +### Docker / Compose 为什么一律留空 + +`Dockerfile` 的 `ENV` 与 `docker-compose.yml` 的 `environment` 对所有配置项都写成**空值**(`ENV X=` / `${X:-}`)。因为合并顺序是 env > file > default,镜像里写死非空默认值会**遮蔽挂载进来的 `config.json` 与 legacy 别名**(例如镜像内写死 `OPENCODE_PROXY_PORT=10000` 会让挂载的 `config.json` 里 `PORT: 8090` 永远不生效)。默认值由代码持有,因此「env 空 + 无 file」与旧的「env 非空默认值」表现完全一致。 + +`docker-compose.yml` 的 `ports` 映射与 `healthcheck` 统一使用 `${OPENCODE_PROXY_PORT:-${PORT:-10000}}`,因此 legacy `PORT` 也能正确映射并探活,不会固定打在 `10000`。 + +### entrypoint 端口校验 + +`entrypoint.sh` 会对 `OPENCODE_PROXY_PORT`(回退到合法 legacy `PORT`)与 `OPENCODE_SERVER_PORT` 做 `1..65535` 校验。`OPENCODE_PROXY_PORT` 为空或非法时不会 export,让 `config.json` 的 `PORT` 有机会生效,脚本内部仅用 `10000` 打日志;`OPENCODE_SERVER_PORT` 仍会 export 正规化后的值,保证 invalid 端口不会被传给 `opencode serve --port`。 + +--- + ## 📄 config.json 示例 + ```json { "PORT": 10000, @@ -97,6 +153,11 @@ "DISABLE_TOOLS": true, "EXTERNAL_TOOLS_MODE": "proxy-bridge", "EXTERNAL_TOOLS_CONFLICT_POLICY": "namespace", + "EXTERNAL_TOOL_POLICY_MODE": "enforce", + "EXTERNAL_TOOL_DEFAULT_RISK_LEVEL": "low", + "EXTERNAL_TOOL_ALLOWLIST": [], + "EXTERNAL_TOOL_DENYLIST": [], + "EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR": [], "INTERNAL_WEB_FETCH_ENABLED": false, "INTERNAL_ALLOWED_TOOLS": ["web_fetch"], "INTERNAL_TOOL_METRICS_ENABLED": true, @@ -146,6 +207,20 @@ OpenCode2API 现在支持把外部客户端传入的 OpenAI-compatible `tools` - 代理内部会使用类似 `external__web_fetch` 的命名空间名避免冲突。 - 这些内部命名空间名称不会作为公开 API 的一部分暴露给客户端。 +### 外部工具策略(allowlist / denylist / 确认) + +外部工具调用在执行前会过一层策略判定,五个开关都已正式接入配置矩阵(canonical `OPENCODE_EXTERNAL_TOOL_*` > legacy raw env `EXTERNAL_TOOL_*` > `config.json` 同名短键 > 默认值,非法值让位下一层): + +| 判定顺序 | 配置项 | 行为 | +|:---------|:-------|:-----| +| 1(最高) | `EXTERNAL_TOOL_DENYLIST` | 命中即拒绝(`tool_denied_by_policy`),不再看白名单 | +| 2 | `EXTERNAL_TOOL_ALLOWLIST` | 非空时,未命中即拒绝(`tool_not_allowed_by_policy`);为空表示不限制 | +| 3 | `EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR` | 命中(或工具自身声明 `requiresConfirmation`)且 `EXTERNAL_TOOL_POLICY_MODE=enforce` 时返回 `require_confirmation`,该工具调用不会执行 | +| 4 | `EXTERNAL_TOOL_POLICY_MODE` | `enforce`(默认,命中确认名单即拦截)/ `report-only`(只记 debug 日志并放行) | +| 兜底 | `EXTERNAL_TOOL_DEFAULT_RISK_LEVEL` | 工具未声明风险等级时返回的 `effectiveRisk`(默认 `low`) | + +> 名单按客户端声明名 / 命名空间名(`external__`)/ 原始名任一精确命中即可(exact-match only,不支持 `*` 通配符,例如用 `delete_repo,write_file` 而非 `delete_*,write_*`);三个列表默认为空,即不改变现有行为。library 调用方也可直接传 `EXTERNAL_TOOL_ALLOWLIST: [...]` 等 options 覆盖。外部桥接工具由客户端执行,代理仅返回调用契约;名称推断的 sideEffect/risk 只做元数据,不自动触发 confirmation,operator 需用显式 confirmation 名单、deny/allowlist 控制。 + ### 内置工具 allowlist - 当请求 **未传入** `tools` 时,代理会进入 internal allowlist 模式,只允许 `OPENCODE_INTERNAL_ALLOWED_TOOLS` 中声明的 OpenCode 内置工具。 diff --git a/docs/docker.md b/docs/docker.md index 5a6007e..bddc4df 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -90,7 +90,9 @@ docker run -d \ my-opencode2api ``` -> 后端 `10001` 仅容器内部使用,不要对外发布(compose 默认也只映射代理端口;改端口用 `OPENCODE_PROXY_PORT`,如 `8090`)。 +> 后端 `10001` 仅容器内部使用,不要对外发布(compose 默认也只映射代理端口;改端口用 `OPENCODE_PROXY_PORT`,legacy 别名 `PORT` 也生效,如 `8090`;`ports` 映射与 `healthcheck` 会同步跟随)。 + +> compose 里所有配置项都写成空值(`${X:-}`),默认值由代码持有:这样挂载进来的 `config.json` 与 legacy 别名不会被镜像/编排里的非空默认值遮蔽。端口类变量在 `entrypoint.sh` 启动时做 `1..65535` 校验,非法值告警后回落默认,不会把坏端口传给后端。 > 注意:镜像构建时**不会**把本地 `config.json` 烘焙进去(多阶段构建只复制 `dist/` 与依赖,避免把本地密钥带进镜像)。容器内配置请用环境变量,或 `-v ./config.json:/home/node/project/config.json:ro` 挂载(代理会按 `dist/` 同层 → 项目根 → 工作目录顺序查找)。 @@ -131,7 +133,7 @@ docker compose logs -f ```yaml healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:${OPENCODE_PROXY_PORT:-10000}/health"] + test: ["CMD", "curl", "-f", "http://localhost:${OPENCODE_PROXY_PORT:-${PORT:-10000}}/health"] interval: 30s timeout: 10s retries: 3 diff --git a/entrypoint.sh b/entrypoint.sh index 0767291..7d1fe42 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -17,8 +17,37 @@ chown -R node:node /home/node/.config/opencode chown -R node:node /home/node/project # Allow overriding via environment variables -PROXY_PORT=${OPENCODE_PROXY_PORT:-10000} -SERVER_PORT=${OPENCODE_SERVER_PORT:-10001} +# Ports are validated/normalized here so an invalid value never reaches +# `opencode serve --port`; canonical names are exported so the proxy merges +# the same value the container actually uses. +normalize_port() { + local label="$1" + local value="$2" + local fallback="$3" + if [[ "$value" =~ ^[0-9]+$ ]] && [ "$value" -ge 1 ] && [ "$value" -le 65535 ]; then + printf '%s\n' "$value" + return 0 + fi + if [ -n "$value" ]; then + echo "[Config] Warning: ${label}=\"${value}\" is not a valid port (1-65535); using ${fallback}" >&2 + fi + printf '%s\n' "$fallback" +} + +PROXY_PORT=$(normalize_port "OPENCODE_PROXY_PORT" "${OPENCODE_PROXY_PORT:-}" "") +if [ -z "$PROXY_PORT" ] && [[ "${PORT:-}" =~ ^[0-9]+$ ]] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ]; then + PROXY_PORT="$PORT" +elif [ -z "$PROXY_PORT" ] && [ -n "${OPENCODE_PROXY_PORT:-}${PORT:-}" ]; then + echo "[Config] Warning: OPENCODE_PROXY_PORT=\"${OPENCODE_PROXY_PORT:-}\" PORT=\"${PORT:-}\" is not a valid port (1-65535); leaving unset for config.json/file merge" >&2 +fi +SERVER_PORT=$(normalize_port "OPENCODE_SERVER_PORT" "${OPENCODE_SERVER_PORT:-}" 10001) +if [ -n "$PROXY_PORT" ]; then + export OPENCODE_PROXY_PORT="$PROXY_PORT" +else + unset OPENCODE_PROXY_PORT + PROXY_PORT=10000 +fi +export OPENCODE_SERVER_PORT="$SERVER_PORT" if [[ "${OPENCODE_PROXY_PROMPT_MODE:-standard}" == "plugin-inject" ]]; then echo "Preparing opencode2api plugin-inject prompt mode..." diff --git a/index.ts b/index.ts index d453550..659f759 100644 --- a/index.ts +++ b/index.ts @@ -2,7 +2,41 @@ 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, normalizeProxyCooldownMs, normalizeProxyStrategy, parseProxyList, parseProxyNoProxyList } from './src/upstream-proxy/pool.js'; +import { + parseProxyList, + parseProxyNoProxyList, + normalizeProxyCooldownMs, + DEFAULT_PROXY_COOLDOWN_MS, + DEFAULT_PROXY_NO_PROXY, + DEFAULT_PROXY_STRATEGY, + PROXY_STRATEGIES, +} from './src/upstream-proxy/pool.js'; +import { + DEFAULT_BIND_HOST, + DEFAULT_CLEANUP_INTERVAL_MS, + DEFAULT_CLEANUP_MAX_AGE_MS, + DEFAULT_EXTERNAL_TOOLS_CONFLICT_POLICY, + DEFAULT_EXTERNAL_TOOLS_MODE, + DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL, + DEFAULT_EXTERNAL_TOOL_POLICY_MODE, + DEFAULT_OPENCODE_PATH, + DEFAULT_PROMPT_MODE, + DEFAULT_PROXY_PORT, + DEFAULT_SERVER_PORT, + EXTERNAL_TOOLS_CONFLICT_POLICIES, + EXTERNAL_TOOLS_MODES, + EXTERNAL_TOOL_POLICY_MODES, + EXTERNAL_TOOL_RISK_LEVEL_VALUES, + parseStrictPort, + resolveBoolSetting, + resolveDurationSetting, + resolveEnumSetting, + resolveListSetting, + resolvePortSetting, + resolveRetryCountSetting, + resolveStringSetting, + resolveUrlSetting, +} from './src/config/proxy-config.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -11,77 +45,35 @@ import type { ProxyConfig } from './src/types/config.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -function parseBool(value: unknown, fallback: boolean): boolean { - if (typeof value === 'boolean') return value; - if (typeof value === 'number') return value === 1; - if (typeof value === 'string') { - const v = value.trim().toLowerCase(); - if (['1', 'true', 'yes', 'y', 'on'].includes(v)) return true; - if (['0', 'false', 'no', 'n', 'off'].includes(v)) return false; - } - if (value === undefined || value === null) return fallback; - return Boolean(value); +/** Resolve the multi-key list for one layer: canonical list + legacy aliases merge (empty never blocks). */ +function parseApiKeys(...sources: unknown[]): string[] { + return mergeApiKeySources(...sources); } -function parseToolAllowlist(value: unknown, fallback: string[] = []): string[] { - if (Array.isArray(value)) { - // NOTE: `||` (not `??`) matches the original JS verbatim: falsy entries - // (0/false) stringify to '' and are filtered out instead of becoming - // "0"/"false" allowlist entries. - return [...new Set((value as unknown[]).map((entry) => String((entry as unknown) || '').trim()).filter(Boolean))]; - } - if (typeof value === 'string') { - return [...new Set(value.split(',').map((entry) => entry.trim()).filter(Boolean))]; - } - if (value === undefined || value === null || value === '') return fallback; - return fallback; +function warnUnsupportedEnum(label: string, value: unknown, allowed: readonly string[], effective: string): void { + if (typeof value !== 'string' || !value.trim()) return; + const normalized = value.trim().toLowerCase(); + if (allowed.includes(normalized)) return; + console.warn( + `[Config] Warning: ${label}="${value.trim()}" is not supported (supported: ${allowed.join(', ')}); using "${effective}"`, + ); } -function parsePort(value: unknown, fallback: number): number { - const n = typeof value === 'string' || typeof value === 'number' ? parseInt(String(value), 10) : NaN; - return Number.isFinite(n) && n > 0 ? n : fallback; +function warnInvalidPort(label: string, value: unknown, effective: number): void { + if (value === undefined || value === null || value === '') return; + if (parseStrictPort(value) !== undefined) return; + console.warn(`[Config] Warning: ${label}="${String(value)}" is not a valid port (1-65535); using ${effective}`); } -/** Resolve the multi-key list for one layer: canonical list + legacy aliases merge (empty never blocks). */ -function parseApiKeys(...sources: unknown[]): string[] { - return mergeApiKeySources(...sources); +function warnInvalidUrl(label: string, value: unknown): void { + if (typeof value !== 'string' || !value.trim()) return; + try { + const protocol = new URL(value.trim()).protocol; + if (protocol === 'http:' || protocol === 'https:') return; + } catch {} + console.warn(`[Config] Warning: ${label}="${value.trim()}" is not a valid http(s) URL; falling back to the next source`); } -// 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), - OPENCODE_PATH: 'opencode', - BIND_HOST: '0.0.0.0', - DISABLE_TOOLS: true, - EXTERNAL_TOOLS_MODE: 'proxy-bridge', - EXTERNAL_TOOLS_CONFLICT_POLICY: 'namespace', - INTERNAL_WEB_FETCH_ENABLED: parseBool(process.env['OPENCODE_INTERNAL_WEB_FETCH_ENABLED'], false), - INTERNAL_ALLOWED_TOOLS: parseToolAllowlist(process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS'], []), - INTERNAL_TOOL_METRICS_ENABLED: parseBool(process.env['OPENCODE_INTERNAL_TOOL_METRICS_ENABLED'], true), - INTERNAL_TOOL_DISCOVERY_FIXTURE: parseToolAllowlist(process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE'], []), - HEALTH_DETAILS_ENABLED: parseBool(process.env['OPENCODE_HEALTH_DETAILS_ENABLED'], true), - HEALTH_DETAILS_REQUIRE_AUTH: parseBool(process.env['OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH'], true), - METRICS_ENABLED: parseBool(process.env['OPENCODE_METRICS_ENABLED'], false), - METRICS_REQUIRE_AUTH: parseBool(process.env['OPENCODE_METRICS_REQUIRE_AUTH'], true), - PROMPT_MODE: process.env['OPENCODE_PROXY_PROMPT_MODE'] || 'standard', - OMIT_SYSTEM_PROMPT: parseBool(process.env['OPENCODE_PROXY_OMIT_SYSTEM_PROMPT'], false), - AUTO_CLEANUP_CONVERSATIONS: parseBool(process.env['OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS'], false), - CLEANUP_INTERVAL_MS: parsePort(process.env['OPENCODE_PROXY_CLEANUP_INTERVAL_MS'], 43200000), - CLEANUP_MAX_AGE_MS: parsePort(process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS'], 86400000), - 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. // NOTE: the lookup covers both layouts because the compiled entry moves: // - dev (`tsx index.ts` / repo root): /config.json @@ -107,48 +99,125 @@ if (configPath) { } } -function readFileString(key: string, fallback: string): string { - const v: unknown = fileConfig[key]; - return typeof v === 'string' && v ? v : fallback; -} - -function readFileNumber(key: string, fallback: number): number { - const v: unknown = fileConfig[key]; - // NOTE: `0` falls through to fallback to match the original - // `fileConfig.X || default` semantics verbatim. - if (typeof v === 'number' && Number.isFinite(v) && v !== 0) return v; - if (typeof v === 'string' && v.trim() !== '') { - const n = parseInt(v, 10); - if (Number.isFinite(n)) return n; - } - return fallback; -} - -// Merge configs: env > file > default -// 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. +// Merge configs: env canonical > env legacy alias > config.json > default. +// Every layer is normalized before it is compared, so an invalid value falls +// through to the next one instead of coercing (no `Boolean('garbage')`) and no +// layer can silently shadow a lower one. 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), + +const serverPort = resolvePortSetting([process.env['OPENCODE_SERVER_PORT']], DEFAULT_SERVER_PORT); +warnInvalidPort('OPENCODE_SERVER_PORT', process.env['OPENCODE_SERVER_PORT'], serverPort); +const defaultServerUrl = `http://127.0.0.1:${serverPort}`; + +const externalToolsMode = resolveEnumSetting( + [process.env['OPENCODE_EXTERNAL_TOOLS_MODE'], fileConfig['EXTERNAL_TOOLS_MODE']], + EXTERNAL_TOOLS_MODES, + DEFAULT_EXTERNAL_TOOLS_MODE, +); +warnUnsupportedEnum('OPENCODE_EXTERNAL_TOOLS_MODE', process.env['OPENCODE_EXTERNAL_TOOLS_MODE'], EXTERNAL_TOOLS_MODES, externalToolsMode); +warnUnsupportedEnum('config.json EXTERNAL_TOOLS_MODE', fileConfig['EXTERNAL_TOOLS_MODE'], EXTERNAL_TOOLS_MODES, externalToolsMode); +const externalToolsConflictPolicy = resolveEnumSetting( + [process.env['OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY'], fileConfig['EXTERNAL_TOOLS_CONFLICT_POLICY']], + EXTERNAL_TOOLS_CONFLICT_POLICIES, + DEFAULT_EXTERNAL_TOOLS_CONFLICT_POLICY, +); +warnUnsupportedEnum( + 'OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY', + process.env['OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY'], + EXTERNAL_TOOLS_CONFLICT_POLICIES, + externalToolsConflictPolicy, +); +warnUnsupportedEnum( + 'config.json EXTERNAL_TOOLS_CONFLICT_POLICY', + fileConfig['EXTERNAL_TOOLS_CONFLICT_POLICY'], + EXTERNAL_TOOLS_CONFLICT_POLICIES, + externalToolsConflictPolicy, +); +const externalToolPolicyMode = resolveEnumSetting( + [ + process.env['OPENCODE_EXTERNAL_TOOL_POLICY_MODE'], + process.env['EXTERNAL_TOOL_POLICY_MODE'], + fileConfig['EXTERNAL_TOOL_POLICY_MODE'], + ], + EXTERNAL_TOOL_POLICY_MODES, + DEFAULT_EXTERNAL_TOOL_POLICY_MODE, +); +warnUnsupportedEnum( + 'OPENCODE_EXTERNAL_TOOL_POLICY_MODE', + process.env['OPENCODE_EXTERNAL_TOOL_POLICY_MODE'], + EXTERNAL_TOOL_POLICY_MODES, + externalToolPolicyMode, +); +warnUnsupportedEnum('EXTERNAL_TOOL_POLICY_MODE', process.env['EXTERNAL_TOOL_POLICY_MODE'], EXTERNAL_TOOL_POLICY_MODES, externalToolPolicyMode); +warnUnsupportedEnum( + 'config.json EXTERNAL_TOOL_POLICY_MODE', + fileConfig['EXTERNAL_TOOL_POLICY_MODE'], + EXTERNAL_TOOL_POLICY_MODES, + externalToolPolicyMode, +); +const externalToolDefaultRiskLevel = resolveEnumSetting( + [ + process.env['OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + process.env['EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + fileConfig['EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + ], + EXTERNAL_TOOL_RISK_LEVEL_VALUES, + DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL, +); +warnUnsupportedEnum( + 'OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL', + process.env['OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + EXTERNAL_TOOL_RISK_LEVEL_VALUES, + externalToolDefaultRiskLevel, +); +warnUnsupportedEnum( + 'EXTERNAL_TOOL_DEFAULT_RISK_LEVEL', + process.env['EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + EXTERNAL_TOOL_RISK_LEVEL_VALUES, + externalToolDefaultRiskLevel, +); +warnUnsupportedEnum( + 'config.json EXTERNAL_TOOL_DEFAULT_RISK_LEVEL', + fileConfig['EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + EXTERNAL_TOOL_RISK_LEVEL_VALUES, + externalToolDefaultRiskLevel, +); + +const proxyPort = resolvePortSetting( + [process.env['OPENCODE_PROXY_PORT'], process.env['PORT'], fileConfig['PORT']], + DEFAULT_PROXY_PORT, +); +warnInvalidPort('OPENCODE_PROXY_PORT', process.env['OPENCODE_PROXY_PORT'], proxyPort); +warnInvalidPort('PORT', process.env['PORT'], proxyPort); +warnInvalidUrl('OPENCODE_SERVER_URL', process.env['OPENCODE_SERVER_URL']); +warnInvalidUrl('config.json OPENCODE_SERVER_URL', fileConfig['OPENCODE_SERVER_URL']); + +const finalConfig: ProxyConfig = { + PORT: proxyPort, 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), - MANAGE_BACKEND: parseBool( - process.env['OPENCODE_PROXY_MANAGE_BACKEND'], - parseBool(fileConfig['MANAGE_BACKEND'], defaultConfig.MANAGE_BACKEND), + OPENCODE_SERVER_URL: resolveUrlSetting( + [process.env['OPENCODE_SERVER_URL'], fileConfig['OPENCODE_SERVER_URL']], + defaultServerUrl, + ), + OPENCODE_SERVER_PASSWORD: resolveStringSetting( + [process.env['OPENCODE_SERVER_PASSWORD'], fileConfig['OPENCODE_SERVER_PASSWORD']], + '', + ), + MANAGE_BACKEND: resolveBoolSetting( + [process.env['OPENCODE_PROXY_MANAGE_BACKEND'], fileConfig['MANAGE_BACKEND']], + false, + ), + OPENCODE_PATH: resolveStringSetting( + [process.env['OPENCODE_PATH'], fileConfig['OPENCODE_PATH']], + DEFAULT_OPENCODE_PATH, + ), + BIND_HOST: resolveStringSetting( + [process.env['BIND_HOST'], process.env['OPENCODE_PROXY_BIND_HOST'], fileConfig['BIND_HOST']], + DEFAULT_BIND_HOST, ), - OPENCODE_PATH: process.env['OPENCODE_PATH'] || readFileString('OPENCODE_PATH', defaultConfig.OPENCODE_PATH), - BIND_HOST: - process.env['BIND_HOST'] || - process.env['OPENCODE_PROXY_BIND_HOST'] || - readFileString('BIND_HOST', defaultConfig.BIND_HOST), // Single contract via resolveDisableTools: env canonical > env legacy // alias > file > default. Each source is normalized first so invalid // values ('', 'garbage') fall through instead of blocking lower sources. @@ -157,65 +226,106 @@ const finalConfig: ProxyConfig = { PORT: DISABLE_TOOLS: process.env['OPENCODE_DISABLE_TOOLS'], disableTools: process.env['DISABLE_TOOLS'], }, - (normalizeBool(fileConfig['DISABLE_TOOLS']) ?? defaultConfig.DISABLE_TOOLS) as boolean, + (normalizeBool(fileConfig['DISABLE_TOOLS']) ?? true) as boolean, + ), + EXTERNAL_TOOLS_MODE: externalToolsMode, + EXTERNAL_TOOLS_CONFLICT_POLICY: externalToolsConflictPolicy, + EXTERNAL_TOOL_POLICY_MODE: externalToolPolicyMode, + EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: externalToolDefaultRiskLevel, + EXTERNAL_TOOL_ALLOWLIST: resolveListSetting( + [ + process.env['OPENCODE_EXTERNAL_TOOL_ALLOWLIST'], + process.env['EXTERNAL_TOOL_ALLOWLIST'], + fileConfig['EXTERNAL_TOOL_ALLOWLIST'], + ], + [], + ), + EXTERNAL_TOOL_DENYLIST: resolveListSetting( + [ + process.env['OPENCODE_EXTERNAL_TOOL_DENYLIST'], + process.env['EXTERNAL_TOOL_DENYLIST'], + fileConfig['EXTERNAL_TOOL_DENYLIST'], + ], + [], ), - EXTERNAL_TOOLS_MODE: - process.env['OPENCODE_EXTERNAL_TOOLS_MODE'] || readFileString('EXTERNAL_TOOLS_MODE', defaultConfig.EXTERNAL_TOOLS_MODE), - EXTERNAL_TOOLS_CONFLICT_POLICY: - process.env['OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY'] || - readFileString('EXTERNAL_TOOLS_CONFLICT_POLICY', defaultConfig.EXTERNAL_TOOLS_CONFLICT_POLICY), - INTERNAL_WEB_FETCH_ENABLED: parseBool( - process.env['OPENCODE_INTERNAL_WEB_FETCH_ENABLED'], - parseBool(fileConfig['INTERNAL_WEB_FETCH_ENABLED'], defaultConfig.INTERNAL_WEB_FETCH_ENABLED), + EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: resolveListSetting( + [ + process.env['OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR'], + process.env['EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR'], + fileConfig['EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR'], + ], + [], ), - INTERNAL_ALLOWED_TOOLS: parseToolAllowlist( - process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS'], - parseToolAllowlist(fileConfig['INTERNAL_ALLOWED_TOOLS'], defaultConfig.INTERNAL_ALLOWED_TOOLS), + INTERNAL_WEB_FETCH_ENABLED: resolveBoolSetting( + [process.env['OPENCODE_INTERNAL_WEB_FETCH_ENABLED'], fileConfig['INTERNAL_WEB_FETCH_ENABLED']], + false, ), - INTERNAL_TOOL_METRICS_ENABLED: parseBool( - process.env['OPENCODE_INTERNAL_TOOL_METRICS_ENABLED'], - parseBool(fileConfig['INTERNAL_TOOL_METRICS_ENABLED'], defaultConfig.INTERNAL_TOOL_METRICS_ENABLED), + INTERNAL_ALLOWED_TOOLS: resolveListSetting( + [process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS'], fileConfig['INTERNAL_ALLOWED_TOOLS']], + [], ), - INTERNAL_TOOL_DISCOVERY_FIXTURE: parseToolAllowlist( - process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE'], - parseToolAllowlist(fileConfig['INTERNAL_TOOL_DISCOVERY_FIXTURE'], defaultConfig.INTERNAL_TOOL_DISCOVERY_FIXTURE), + INTERNAL_TOOL_METRICS_ENABLED: resolveBoolSetting( + [process.env['OPENCODE_INTERNAL_TOOL_METRICS_ENABLED'], fileConfig['INTERNAL_TOOL_METRICS_ENABLED']], + true, ), - HEALTH_DETAILS_ENABLED: parseBool( - process.env['OPENCODE_HEALTH_DETAILS_ENABLED'], - parseBool(fileConfig['HEALTH_DETAILS_ENABLED'], defaultConfig.HEALTH_DETAILS_ENABLED), + INTERNAL_TOOL_DISCOVERY_FIXTURE: resolveListSetting( + [process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE'], fileConfig['INTERNAL_TOOL_DISCOVERY_FIXTURE']], + [], ), - HEALTH_DETAILS_REQUIRE_AUTH: parseBool( - process.env['OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH'], - parseBool(fileConfig['HEALTH_DETAILS_REQUIRE_AUTH'], defaultConfig.HEALTH_DETAILS_REQUIRE_AUTH), + HEALTH_DETAILS_ENABLED: resolveBoolSetting( + [process.env['OPENCODE_HEALTH_DETAILS_ENABLED'], fileConfig['HEALTH_DETAILS_ENABLED']], + true, ), - METRICS_ENABLED: parseBool( - process.env['OPENCODE_METRICS_ENABLED'], - parseBool(fileConfig['METRICS_ENABLED'], defaultConfig.METRICS_ENABLED), + HEALTH_DETAILS_REQUIRE_AUTH: resolveBoolSetting( + [process.env['OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH'], fileConfig['HEALTH_DETAILS_REQUIRE_AUTH']], + true, ), - METRICS_REQUIRE_AUTH: parseBool( - process.env['OPENCODE_METRICS_REQUIRE_AUTH'], - parseBool(fileConfig['METRICS_REQUIRE_AUTH'], defaultConfig.METRICS_REQUIRE_AUTH), + METRICS_ENABLED: resolveBoolSetting( + [process.env['OPENCODE_METRICS_ENABLED'], fileConfig['METRICS_ENABLED']], + false, ), - USE_ISOLATED_HOME: parseBool(process.env['OPENCODE_USE_ISOLATED_HOME'], parseBool(fileConfig['USE_ISOLATED_HOME'], false)), - REQUEST_TIMEOUT_MS: parsePort(process.env['OPENCODE_PROXY_REQUEST_TIMEOUT_MS'], 0) || readFileNumber('REQUEST_TIMEOUT_MS', 180000), - RETRY_MAX_RETRIES: process.env['OPENCODE_PROXY_RETRY_MAX_RETRIES'] ?? fileConfig['RETRY_MAX_RETRIES'] ?? 3, - DEBUG: parseBool(process.env['OPENCODE_PROXY_DEBUG'], parseBool(fileConfig['DEBUG'], false)), - ZEN_API_KEY: process.env['OPENCODE_ZEN_API_KEY'] || readFileString('ZEN_API_KEY', ''), - PROMPT_MODE: process.env['OPENCODE_PROXY_PROMPT_MODE'] || readFileString('PROMPT_MODE', defaultConfig.PROMPT_MODE), - OMIT_SYSTEM_PROMPT: parseBool( - process.env['OPENCODE_PROXY_OMIT_SYSTEM_PROMPT'], - parseBool(fileConfig['OMIT_SYSTEM_PROMPT'], defaultConfig.OMIT_SYSTEM_PROMPT), + METRICS_REQUIRE_AUTH: resolveBoolSetting( + [process.env['OPENCODE_METRICS_REQUIRE_AUTH'], fileConfig['METRICS_REQUIRE_AUTH']], + true, ), - AUTO_CLEANUP_CONVERSATIONS: parseBool( - process.env['OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS'], - parseBool(fileConfig['AUTO_CLEANUP_CONVERSATIONS'], defaultConfig.AUTO_CLEANUP_CONVERSATIONS), + USE_ISOLATED_HOME: resolveBoolSetting( + [process.env['OPENCODE_USE_ISOLATED_HOME'], fileConfig['USE_ISOLATED_HOME']], + false, + ), + REQUEST_TIMEOUT_MS: resolveDurationSetting( + [process.env['OPENCODE_PROXY_REQUEST_TIMEOUT_MS'], fileConfig['REQUEST_TIMEOUT_MS']], + 180000, + ), + RETRY_MAX_RETRIES: resolveRetryCountSetting( + [ + process.env['OPENCODE_PROXY_RETRY_MAX_RETRIES'], + process.env['RETRY_MAX_RETRIES'], + fileConfig['RETRY_MAX_RETRIES'], + ], + 3, + ), + DEBUG: resolveBoolSetting([process.env['OPENCODE_PROXY_DEBUG'], fileConfig['DEBUG']], false), + ZEN_API_KEY: resolveStringSetting([process.env['OPENCODE_ZEN_API_KEY'], fileConfig['ZEN_API_KEY']], ''), + PROMPT_MODE: resolveStringSetting( + [process.env['OPENCODE_PROXY_PROMPT_MODE'], fileConfig['PROMPT_MODE']], + DEFAULT_PROMPT_MODE, + ), + OMIT_SYSTEM_PROMPT: resolveBoolSetting( + [process.env['OPENCODE_PROXY_OMIT_SYSTEM_PROMPT'], fileConfig['OMIT_SYSTEM_PROMPT']], + false, + ), + AUTO_CLEANUP_CONVERSATIONS: resolveBoolSetting( + [process.env['OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS'], fileConfig['AUTO_CLEANUP_CONVERSATIONS']], + false, + ), + CLEANUP_INTERVAL_MS: resolveDurationSetting( + [process.env['OPENCODE_PROXY_CLEANUP_INTERVAL_MS'], fileConfig['CLEANUP_INTERVAL_MS']], + DEFAULT_CLEANUP_INTERVAL_MS, + ), + CLEANUP_MAX_AGE_MS: resolveDurationSetting( + [process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS'], fileConfig['CLEANUP_MAX_AGE_MS']], + DEFAULT_CLEANUP_MAX_AGE_MS, ), - CLEANUP_INTERVAL_MS: - parsePort(process.env['OPENCODE_PROXY_CLEANUP_INTERVAL_MS'], 0) || - readFileNumber('CLEANUP_INTERVAL_MS', defaultConfig.CLEANUP_INTERVAL_MS), - CLEANUP_MAX_AGE_MS: - parsePort(process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS'], 0) || - readFileNumber('CLEANUP_MAX_AGE_MS', defaultConfig.CLEANUP_MAX_AGE_MS), OPENCODE_HOME_BASE: null, // 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). @@ -223,19 +333,37 @@ const finalConfig: ProxyConfig = { PORT: 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]; + return file.length > 0 ? file : []; })(), - UPSTREAM_PROXY_STRATEGY: normalizeProxyStrategy( - process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'] || - readFileString('UPSTREAM_PROXY_STRATEGY', defaultConfig.UPSTREAM_PROXY_STRATEGY), + UPSTREAM_PROXY_STRATEGY: resolveEnumSetting( + [ + process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'], + process.env['UPSTREAM_PROXY_STRATEGY'], + fileConfig['UPSTREAM_PROXY_STRATEGY'], + ], + PROXY_STRATEGIES, + DEFAULT_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), + resolveDurationSetting( + [ + process.env['OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS'], + process.env['UPSTREAM_PROXY_COOLDOWN_MS'], + fileConfig['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'] ?? fileConfig['UPSTREAM_PROXY_NO_PROXY'], - defaultConfig.UPSTREAM_PROXY_NO_PROXY, + resolveListSetting( + [ + process.env['OPENCODE_UPSTREAM_PROXY_NO_PROXY'], + process.env['UPSTREAM_PROXY_NO_PROXY'], + fileConfig['UPSTREAM_PROXY_NO_PROXY'], + ], + [], + ), + DEFAULT_PROXY_NO_PROXY, ), }; @@ -261,6 +389,7 @@ console.log('[Config] Starting with configuration:'); console.log(` - Port: ${finalConfig.PORT}`); console.log(` - Bind Host: ${finalConfig.BIND_HOST}`); console.log(` - Backend: ${finalConfig.OPENCODE_SERVER_URL}`); +console.log(` - Backend Port: ${serverPort} (OPENCODE_SERVER_PORT; only bakes the default loopback 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)'}`); @@ -274,6 +403,17 @@ console.log(` - Zen API Key: ${finalConfig.ZEN_API_KEY ? 'Configured' : 'Not co console.log(` - Disable Tools: ${finalConfig.DISABLE_TOOLS ? 'Yes' : 'No'}`); console.log(` - External Tools Mode: ${finalConfig.EXTERNAL_TOOLS_MODE}`); console.log(` - External Tools Conflict Policy: ${finalConfig.EXTERNAL_TOOLS_CONFLICT_POLICY}`); +console.log(` - External Tool Policy Mode: ${finalConfig.EXTERNAL_TOOL_POLICY_MODE}`); +console.log(` - External Tool Default Risk Level: ${finalConfig.EXTERNAL_TOOL_DEFAULT_RISK_LEVEL}`); +console.log( + ` - External Tool Allowlist: ${finalConfig.EXTERNAL_TOOL_ALLOWLIST.length ? finalConfig.EXTERNAL_TOOL_ALLOWLIST.join(', ') : '(none)'}`, +); +console.log( + ` - External Tool Denylist: ${finalConfig.EXTERNAL_TOOL_DENYLIST.length ? finalConfig.EXTERNAL_TOOL_DENYLIST.join(', ') : '(none)'}`, +); +console.log( + ` - External Tool Confirmation Required: ${finalConfig.EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR.length ? finalConfig.EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR.join(', ') : '(none)'}`, +); console.log(` - Internal web_fetch Enabled: ${finalConfig.INTERNAL_WEB_FETCH_ENABLED ? 'Yes' : 'No'}`); console.log( ` - Internal Allowed Tools: ${finalConfig.INTERNAL_ALLOWED_TOOLS.length ? finalConfig.INTERNAL_ALLOWED_TOOLS.join(', ') : '(none)'}`, @@ -289,7 +429,7 @@ console.log(` - Metrics Require Auth: ${finalConfig.METRICS_REQUIRE_AUTH ? 'Yes console.log(` - Use Isolated Home: ${finalConfig.USE_ISOLATED_HOME ? 'Yes' : 'No'}`); console.log(` - Request Timeout: ${finalConfig.REQUEST_TIMEOUT_MS}ms`); console.log( - ` - Max Retries: ${resolveMaxRetries(finalConfig.RETRY_MAX_RETRIES)} (raw: ${String(finalConfig.RETRY_MAX_RETRIES)}, total attempts 1+n)`, + ` - Max Retries: ${resolveMaxRetries(finalConfig.RETRY_MAX_RETRIES)} (merged: ${String(finalConfig.RETRY_MAX_RETRIES)}, total attempts 1+n)`, ); console.log(` - Prompt Mode: ${finalConfig.PROMPT_MODE}`); console.log(` - Omit System Prompt: ${finalConfig.OMIT_SYSTEM_PROMPT ? 'Yes' : 'No'}`); diff --git a/src/config/proxy-config.ts b/src/config/proxy-config.ts index 0127042..c231794 100644 --- a/src/config/proxy-config.ts +++ b/src/config/proxy-config.ts @@ -1,10 +1,37 @@ // 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, normalizeProxyCooldownMs, normalizeProxyStrategy, parseProxyList, parseProxyNoProxyList } from '../upstream-proxy/pool.js'; +import { + DEFAULT_PROXY_COOLDOWN_MS, + DEFAULT_PROXY_NO_PROXY, + DEFAULT_PROXY_STRATEGY, + PROXY_STRATEGIES, + normalizeProxyCooldownMs, + parseProxyList, + parseProxyNoProxyList, +} from '../upstream-proxy/pool.js'; +import { DEFAULT_MAX_RETRIES } from '../retry/policy.js'; +import { TOOL_RISK_LEVELS } from '../tool-runtime/contracts.js'; export const DEFAULT_REQUEST_TIMEOUT_MS = 300000; export const DEFAULT_POLL_INTERVAL_MS = 500; +export const DEFAULT_PROXY_PORT = 10000; +export const DEFAULT_SERVER_PORT = 10001; +export const DEFAULT_BIND_HOST = '0.0.0.0'; +export const DEFAULT_OPENCODE_PATH = 'opencode'; +export const DEFAULT_PROMPT_MODE = 'standard'; +export const DEFAULT_CLEANUP_INTERVAL_MS = 12 * 60 * 60 * 1000; +export const DEFAULT_CLEANUP_MAX_AGE_MS = 24 * 60 * 60 * 1000; +export const DEFAULT_EXTERNAL_TOOLS_MODE = 'proxy-bridge'; +export const DEFAULT_EXTERNAL_TOOLS_CONFLICT_POLICY = 'namespace'; +export const EXTERNAL_TOOLS_MODES: readonly string[] = ['proxy-bridge']; +export const EXTERNAL_TOOLS_CONFLICT_POLICIES: readonly string[] = ['namespace']; +export const EXTERNAL_TOOL_POLICY_MODES: readonly string[] = ['enforce', 'report-only']; +export const DEFAULT_EXTERNAL_TOOL_POLICY_MODE = 'enforce'; +export const EXTERNAL_TOOL_RISK_LEVEL_VALUES: readonly string[] = Object.values(TOOL_RISK_LEVELS); +export const DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: string = TOOL_RISK_LEVELS.LOW; +export const MAX_PORT = 65535; +export const MIN_PORT = 1; // Retry policy (ported from upstream opencode session/retry.ts; see src/retry/policy.ts). // Total attempts = 1 + maxRetries; maxRetries defaults to 3, configurable via // OPENCODE_PROXY_RETRY_MAX_RETRIES, hard-capped at 5 (upstream ceiling). @@ -46,7 +73,7 @@ export function normalizeBool(value: unknown): boolean | undefined { * Invalid values ('garbage', '', whitespace) are treated as unset and fall * through to the next source instead of coercing. */ -export function resolveDisableTools(options?: unknown, fallback: boolean = false): boolean { +export function resolveDisableTools(options?: unknown, fallback: boolean = true): boolean { const o = (options ?? {}) as DisableToolsOptions; const record = o as Record; return ( @@ -58,6 +85,107 @@ export function resolveDisableTools(options?: unknown, fallback: boolean = false ); } +export type ConfigLayers = readonly unknown[]; + +export function parseStrictInteger(value: unknown): number | undefined { + if (typeof value === 'number') return Number.isSafeInteger(value) ? value : undefined; + if (typeof value === 'string') { + const t = value.trim(); + if (!/^[+-]?\d+$/.test(t)) return undefined; + const n = Number(t); + return Number.isSafeInteger(n) ? n : undefined; + } + return undefined; +} + +export function parseStrictPort(value: unknown): number | undefined { + const n = parseStrictInteger(value); + return n !== undefined && n >= MIN_PORT && n <= MAX_PORT ? n : undefined; +} + +export function parseHttpUrl(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const t = value.trim(); + if (!t) return undefined; + try { + const protocol = new URL(t).protocol; + return protocol === 'http:' || protocol === 'https:' ? t : undefined; + } catch { + return undefined; + } +} + +export function parseToolNameList(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + return [...new Set((value as unknown[]).map((entry) => String(entry || '').trim()).filter(Boolean))]; + } + if (typeof value === 'string') { + if (!value.trim()) return undefined; + return [...new Set(value.split(',').map((entry) => entry.trim()).filter(Boolean))]; + } + return undefined; +} + +export function resolveStringSetting(layers: ConfigLayers, fallback: string): string { + for (const layer of layers) { + if (typeof layer === 'string' && layer.trim()) return layer.trim(); + } + return fallback; +} + +export function resolveBoolSetting(layers: ConfigLayers, fallback: boolean): boolean { + for (const layer of layers) { + const normalized = normalizeBool(layer); + if (normalized !== undefined) return normalized; + } + return fallback; +} + +export function resolveIntSetting(layers: ConfigLayers, min: number, max: number, fallback: number): number { + for (const layer of layers) { + const n = parseStrictInteger(layer); + if (n !== undefined && n >= min && n <= max) return n; + } + return fallback; +} + +export function resolvePortSetting(layers: ConfigLayers, fallback: number): number { + return resolveIntSetting(layers, MIN_PORT, MAX_PORT, fallback); +} + +export function resolveDurationSetting(layers: ConfigLayers, fallback: number): number { + return resolveIntSetting(layers, 1, Number.MAX_SAFE_INTEGER, fallback); +} + +export function resolveRetryCountSetting(layers: ConfigLayers, fallback: number): number { + return resolveIntSetting(layers, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER, fallback); +} + +export function resolveListSetting(layers: ConfigLayers, fallback: string[]): string[] { + for (const layer of layers) { + const parsed = parseToolNameList(layer); + if (parsed !== undefined) return parsed; + } + return fallback; +} + +export function resolveEnumSetting(layers: ConfigLayers, allowed: readonly string[], fallback: string): string { + for (const layer of layers) { + if (typeof layer !== 'string') continue; + const t = layer.trim().toLowerCase(); + if (t && allowed.includes(t)) return t; + } + return fallback; +} + +export function resolveUrlSetting(layers: ConfigLayers, fallback: string): string { + for (const layer of layers) { + const parsed = parseHttpUrl(layer); + if (parsed !== undefined) return parsed; + } + return fallback; +} + /** * Race a promise against a timeout. The real outcome always wins: fast * resolutions/rejections settle the race immediately; only a never-settling @@ -93,8 +221,10 @@ function readStringOption(options: Record, keys: string[]): str } /** - * Build the effective proxy config from caller options + env (extracted verbatim - * from startProxy preamble + config assembly; merge semantics unchanged). + * Build the effective proxy config from caller options + env. + * Layer order per key: caller options > env canonical > env legacy alias > + * hardcoded default. Every layer is normalized first, so an invalid value + * falls through to the next layer instead of coercing or shadowing it. */ export function buildProxyConfig(options: unknown = {}): ProxyConfig { // Also tolerate explicit null (the = {} default only covers undefined). @@ -102,38 +232,43 @@ export function buildProxyConfig(options: unknown = {}): ProxyConfig { const opts = raw as Record; const disableTools = resolveDisableTools(raw); - const promptMode = - readStringOption(opts, ['PROMPT_MODE', 'promptMode']) || - process.env['OPENCODE_PROXY_PROMPT_MODE'] || - 'standard'; - const externalToolsMode = - readStringOption(opts, ['EXTERNAL_TOOLS_MODE', 'externalToolsMode']) || - process.env['OPENCODE_EXTERNAL_TOOLS_MODE'] || - 'proxy-bridge'; - const externalToolsConflictPolicy = - readStringOption(opts, ['EXTERNAL_TOOLS_CONFLICT_POLICY', 'externalToolsConflictPolicy']) || - process.env['OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY'] || - 'namespace'; - // NOTE: `||` (not `??`) is intentional here to match the original JS merge - // semantics verbatim: falsy values ('', 0) fall through to the next source - // instead of becoming Number('')=0. The finite/positive guard below then - // clamps anything non-sane back to the hardcoded default. - const cleanupIntervalMs = Number( - opts['CLEANUP_INTERVAL_MS'] || process.env['OPENCODE_PROXY_CLEANUP_INTERVAL_MS'] || 12 * 60 * 60 * 1000, - ); - const cleanupMaxAgeMs = Number( - opts['CLEANUP_MAX_AGE_MS'] || process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS'] || 24 * 60 * 60 * 1000, + const promptMode = resolveStringSetting( + [opts['PROMPT_MODE'], opts['promptMode'], process.env['OPENCODE_PROXY_PROMPT_MODE']], + DEFAULT_PROMPT_MODE, ); - - if (externalToolsMode !== 'proxy-bridge') { - throw new Error(`Unsupported EXTERNAL_TOOLS_MODE: ${externalToolsMode}. Supported value: proxy-bridge`); + const externalToolsModeOption = readStringOption(opts, ['EXTERNAL_TOOLS_MODE', 'externalToolsMode']); + if (externalToolsModeOption !== undefined && !EXTERNAL_TOOLS_MODES.includes(externalToolsModeOption)) { + throw new Error( + `Unsupported EXTERNAL_TOOLS_MODE: ${externalToolsModeOption}. Supported value: ${EXTERNAL_TOOLS_MODES.join(', ')}`, + ); } - if (externalToolsConflictPolicy !== 'namespace') { + const externalToolsMode = + externalToolsModeOption ?? + resolveEnumSetting( + [process.env['OPENCODE_EXTERNAL_TOOLS_MODE']], + EXTERNAL_TOOLS_MODES, + DEFAULT_EXTERNAL_TOOLS_MODE, + ); + const externalToolsConflictPolicyOption = readStringOption(opts, [ + 'EXTERNAL_TOOLS_CONFLICT_POLICY', + 'externalToolsConflictPolicy', + ]); + if ( + externalToolsConflictPolicyOption !== undefined && + !EXTERNAL_TOOLS_CONFLICT_POLICIES.includes(externalToolsConflictPolicyOption) + ) { throw new Error( - `Unsupported EXTERNAL_TOOLS_CONFLICT_POLICY: ${externalToolsConflictPolicy}. Supported value: namespace`, + `Unsupported EXTERNAL_TOOLS_CONFLICT_POLICY: ${externalToolsConflictPolicyOption}. Supported value: ${EXTERNAL_TOOLS_CONFLICT_POLICIES.join(', ')}`, ); } - const useIsolatedRaw: unknown = opts['USE_ISOLATED_HOME']; + const externalToolsConflictPolicy = + externalToolsConflictPolicyOption ?? + resolveEnumSetting( + [process.env['OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY']], + EXTERNAL_TOOLS_CONFLICT_POLICIES, + DEFAULT_EXTERNAL_TOOLS_CONFLICT_POLICY, + ); + const serverPort = resolvePortSetting([process.env['OPENCODE_SERVER_PORT']], DEFAULT_SERVER_PORT); // 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). @@ -146,92 +281,117 @@ export function buildProxyConfig(options: unknown = {}): ProxyConfig { // 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, + PORT: resolvePortSetting([opts['PORT'], process.env['OPENCODE_PROXY_PORT'], process.env['PORT']], DEFAULT_PROXY_PORT), 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'] || '', - OPENCODE_PATH: (opts['OPENCODE_PATH'] as string) || 'opencode', - BIND_HOST: - (opts['BIND_HOST'] as string) || - (opts['bindHost'] as string) || - process.env['BIND_HOST'] || - process.env['OPENCODE_PROXY_BIND_HOST'] || - '0.0.0.0', - USE_ISOLATED_HOME: - typeof useIsolatedRaw === 'boolean' - ? useIsolatedRaw - : String(useIsolatedRaw ?? '').toLowerCase() === 'true' || - useIsolatedRaw === '1' || - String(process.env['OPENCODE_USE_ISOLATED_HOME'] ?? '').toLowerCase() === 'true' || - process.env['OPENCODE_USE_ISOLATED_HOME'] === '1', - REQUEST_TIMEOUT_MS: Number( - opts['REQUEST_TIMEOUT_MS'] || process.env['OPENCODE_PROXY_REQUEST_TIMEOUT_MS'] || DEFAULT_REQUEST_TIMEOUT_MS, + OPENCODE_SERVER_URL: resolveUrlSetting( + [opts['OPENCODE_SERVER_URL'], process.env['OPENCODE_SERVER_URL']], + `http://127.0.0.1:${serverPort}`, + ), + OPENCODE_SERVER_PASSWORD: resolveStringSetting( + [opts['OPENCODE_SERVER_PASSWORD'], process.env['OPENCODE_SERVER_PASSWORD']], + '', + ), + OPENCODE_PATH: resolveStringSetting([opts['OPENCODE_PATH'], process.env['OPENCODE_PATH']], DEFAULT_OPENCODE_PATH), + BIND_HOST: resolveStringSetting( + [opts['BIND_HOST'], opts['bindHost'], process.env['BIND_HOST'], process.env['OPENCODE_PROXY_BIND_HOST']], + DEFAULT_BIND_HOST, + ), + USE_ISOLATED_HOME: resolveBoolSetting( + [opts['USE_ISOLATED_HOME'], process.env['OPENCODE_USE_ISOLATED_HOME']], + false, ), - MANAGE_BACKEND: - normalizeBool(opts['MANAGE_BACKEND']) ?? normalizeBool(process.env['OPENCODE_PROXY_MANAGE_BACKEND']) ?? true, + REQUEST_TIMEOUT_MS: resolveDurationSetting( + [opts['REQUEST_TIMEOUT_MS'], process.env['OPENCODE_PROXY_REQUEST_TIMEOUT_MS']], + DEFAULT_REQUEST_TIMEOUT_MS, + ), + MANAGE_BACKEND: resolveBoolSetting([opts['MANAGE_BACKEND'], process.env['OPENCODE_PROXY_MANAGE_BACKEND']], true), DISABLE_TOOLS: disableTools, EXTERNAL_TOOLS_MODE: externalToolsMode, EXTERNAL_TOOLS_CONFLICT_POLICY: externalToolsConflictPolicy, - INTERNAL_WEB_FETCH_ENABLED: - normalizeBool(opts['INTERNAL_WEB_FETCH_ENABLED']) ?? - normalizeBool(process.env['OPENCODE_INTERNAL_WEB_FETCH_ENABLED']) ?? + EXTERNAL_TOOL_POLICY_MODE: resolveEnumSetting( + [opts['EXTERNAL_TOOL_POLICY_MODE'], process.env['OPENCODE_EXTERNAL_TOOL_POLICY_MODE'], process.env['EXTERNAL_TOOL_POLICY_MODE']], + EXTERNAL_TOOL_POLICY_MODES, + DEFAULT_EXTERNAL_TOOL_POLICY_MODE, + ), + EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: resolveEnumSetting( + [ + opts['EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + process.env['OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + process.env['EXTERNAL_TOOL_DEFAULT_RISK_LEVEL'], + ], + EXTERNAL_TOOL_RISK_LEVEL_VALUES, + DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL, + ), + EXTERNAL_TOOL_ALLOWLIST: resolveListSetting( + [opts['EXTERNAL_TOOL_ALLOWLIST'], process.env['OPENCODE_EXTERNAL_TOOL_ALLOWLIST'], process.env['EXTERNAL_TOOL_ALLOWLIST']], + [], + ), + EXTERNAL_TOOL_DENYLIST: resolveListSetting( + [opts['EXTERNAL_TOOL_DENYLIST'], process.env['OPENCODE_EXTERNAL_TOOL_DENYLIST'], process.env['EXTERNAL_TOOL_DENYLIST']], + [], + ), + EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: resolveListSetting( + [ + opts['EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR'], + process.env['OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR'], + process.env['EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR'], + ], + [], + ), + INTERNAL_WEB_FETCH_ENABLED: resolveBoolSetting( + [opts['INTERNAL_WEB_FETCH_ENABLED'], process.env['OPENCODE_INTERNAL_WEB_FETCH_ENABLED']], false, - INTERNAL_ALLOWED_TOOLS: Array.isArray(opts['INTERNAL_ALLOWED_TOOLS']) - ? (opts['INTERNAL_ALLOWED_TOOLS'] as string[]) - : typeof process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS'] === 'string' - ? String(process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS']) - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean) - : [], - INTERNAL_TOOL_METRICS_ENABLED: - normalizeBool(opts['INTERNAL_TOOL_METRICS_ENABLED']) ?? - normalizeBool(process.env['OPENCODE_INTERNAL_TOOL_METRICS_ENABLED']) ?? + ), + INTERNAL_ALLOWED_TOOLS: resolveListSetting( + [opts['INTERNAL_ALLOWED_TOOLS'], process.env['OPENCODE_INTERNAL_ALLOWED_TOOLS']], + [], + ), + INTERNAL_TOOL_METRICS_ENABLED: resolveBoolSetting( + [opts['INTERNAL_TOOL_METRICS_ENABLED'], process.env['OPENCODE_INTERNAL_TOOL_METRICS_ENABLED']], true, - INTERNAL_TOOL_DISCOVERY_FIXTURE: Array.isArray(opts['INTERNAL_TOOL_DISCOVERY_FIXTURE']) - ? (opts['INTERNAL_TOOL_DISCOVERY_FIXTURE'] as string[]) - : typeof process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE'] === 'string' - ? String(process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE']) - .split(',') - .map((entry) => entry.trim()) - .filter(Boolean) - : [], - HEALTH_DETAILS_ENABLED: - normalizeBool(opts['HEALTH_DETAILS_ENABLED']) ?? - normalizeBool(process.env['OPENCODE_HEALTH_DETAILS_ENABLED']) ?? + ), + INTERNAL_TOOL_DISCOVERY_FIXTURE: resolveListSetting( + [opts['INTERNAL_TOOL_DISCOVERY_FIXTURE'], process.env['OPENCODE_TOOL_DISCOVERY_FIXTURE']], + [], + ), + HEALTH_DETAILS_ENABLED: resolveBoolSetting( + [opts['HEALTH_DETAILS_ENABLED'], process.env['OPENCODE_HEALTH_DETAILS_ENABLED']], true, - HEALTH_DETAILS_REQUIRE_AUTH: - normalizeBool(opts['HEALTH_DETAILS_REQUIRE_AUTH']) ?? - normalizeBool(process.env['OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH']) ?? + ), + HEALTH_DETAILS_REQUIRE_AUTH: resolveBoolSetting( + [opts['HEALTH_DETAILS_REQUIRE_AUTH'], process.env['OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH']], true, - METRICS_ENABLED: - normalizeBool(opts['METRICS_ENABLED']) ?? normalizeBool(process.env['OPENCODE_METRICS_ENABLED']) ?? false, - METRICS_REQUIRE_AUTH: - normalizeBool(opts['METRICS_REQUIRE_AUTH']) ?? - normalizeBool(process.env['OPENCODE_METRICS_REQUIRE_AUTH']) ?? + ), + METRICS_ENABLED: resolveBoolSetting([opts['METRICS_ENABLED'], process.env['OPENCODE_METRICS_ENABLED']], false), + METRICS_REQUIRE_AUTH: resolveBoolSetting( + [opts['METRICS_REQUIRE_AUTH'], process.env['OPENCODE_METRICS_REQUIRE_AUTH']], true, - DEBUG: - String(opts['DEBUG'] ?? '').toLowerCase() === 'true' || - opts['DEBUG'] === '1' || - String(process.env['OPENCODE_PROXY_DEBUG'] ?? '').toLowerCase() === 'true' || - process.env['OPENCODE_PROXY_DEBUG'] === '1', - ZEN_API_KEY: (opts['ZEN_API_KEY'] as string) || process.env['OPENCODE_ZEN_API_KEY'] || '', + ), + DEBUG: resolveBoolSetting([opts['DEBUG'], process.env['OPENCODE_PROXY_DEBUG']], false), + ZEN_API_KEY: resolveStringSetting([opts['ZEN_API_KEY'], process.env['OPENCODE_ZEN_API_KEY']], ''), PROMPT_MODE: promptMode, - OMIT_SYSTEM_PROMPT: - normalizeBool(opts['OMIT_SYSTEM_PROMPT']) ?? - normalizeBool(process.env['OPENCODE_PROXY_OMIT_SYSTEM_PROMPT']) ?? + OMIT_SYSTEM_PROMPT: resolveBoolSetting( + [opts['OMIT_SYSTEM_PROMPT'], process.env['OPENCODE_PROXY_OMIT_SYSTEM_PROMPT']], promptMode === 'plugin-inject', - AUTO_CLEANUP_CONVERSATIONS: - normalizeBool(opts['AUTO_CLEANUP_CONVERSATIONS']) ?? - normalizeBool(process.env['OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS']) ?? + ), + AUTO_CLEANUP_CONVERSATIONS: resolveBoolSetting( + [opts['AUTO_CLEANUP_CONVERSATIONS'], process.env['OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS']], false, - CLEANUP_INTERVAL_MS: - Number.isFinite(cleanupIntervalMs) && cleanupIntervalMs > 0 ? cleanupIntervalMs : 12 * 60 * 60 * 1000, - CLEANUP_MAX_AGE_MS: - Number.isFinite(cleanupMaxAgeMs) && cleanupMaxAgeMs > 0 ? cleanupMaxAgeMs : 24 * 60 * 60 * 1000, - OPENCODE_HOME_BASE: (opts['OPENCODE_HOME_BASE'] as string | null) || null, + ), + CLEANUP_INTERVAL_MS: resolveDurationSetting( + [opts['CLEANUP_INTERVAL_MS'], process.env['OPENCODE_PROXY_CLEANUP_INTERVAL_MS']], + DEFAULT_CLEANUP_INTERVAL_MS, + ), + CLEANUP_MAX_AGE_MS: resolveDurationSetting( + [opts['CLEANUP_MAX_AGE_MS'], process.env['OPENCODE_PROXY_CLEANUP_MAX_AGE_MS']], + DEFAULT_CLEANUP_MAX_AGE_MS, + ), + OPENCODE_HOME_BASE: resolveStringSetting([opts['OPENCODE_HOME_BASE']], '') || null, + RETRY_MAX_RETRIES: resolveRetryCountSetting( + [opts['RETRY_MAX_RETRIES'], process.env['OPENCODE_PROXY_RETRY_MAX_RETRIES'], process.env['RETRY_MAX_RETRIES']], + DEFAULT_MAX_RETRIES, + ), // 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). @@ -239,18 +399,32 @@ 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: normalizeProxyStrategy( - (typeof opts['UPSTREAM_PROXY_STRATEGY'] === 'string' && (opts['UPSTREAM_PROXY_STRATEGY'] as string)) || - process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'] || - 'failover-rr', + UPSTREAM_PROXY_STRATEGY: resolveEnumSetting( + [ + opts['UPSTREAM_PROXY_STRATEGY'], + process.env['OPENCODE_UPSTREAM_PROXY_STRATEGY'], + process.env['UPSTREAM_PROXY_STRATEGY'], + ], + PROXY_STRATEGIES, + DEFAULT_PROXY_STRATEGY, ), UPSTREAM_PROXY_COOLDOWN_MS: normalizeProxyCooldownMs( - opts['UPSTREAM_PROXY_COOLDOWN_MS'] ?? process.env['OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS'] ?? DEFAULT_PROXY_COOLDOWN_MS, + resolveIntSetting( + [ + opts['UPSTREAM_PROXY_COOLDOWN_MS'], + process.env['OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS'], + process.env['UPSTREAM_PROXY_COOLDOWN_MS'], + ], + 1, + Number.MAX_SAFE_INTEGER, + 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']), + resolveListSetting( + [opts['UPSTREAM_PROXY_NO_PROXY'], process.env['OPENCODE_UPSTREAM_PROXY_NO_PROXY'], process.env['UPSTREAM_PROXY_NO_PROXY']], + [], + ), DEFAULT_PROXY_NO_PROXY, ), }; diff --git a/src/converters/anthropic.ts b/src/converters/anthropic.ts index 599cb34..d0aa754 100644 --- a/src/converters/anthropic.ts +++ b/src/converters/anthropic.ts @@ -42,6 +42,8 @@ export interface ChatTool { name: string; description: string; parameters: unknown; + enabled?: unknown; + [key: string]: unknown; }; } @@ -154,6 +156,27 @@ export function anthropicError(type: string, message: string, statusCode = 400): return { statusCode, body: { type: 'error', error: { type, message } } }; } +function validateAnthropicToolChoice(toolChoice: unknown): string | null { + if (toolChoice === undefined) return null; + if (typeof toolChoice === 'string') { + return ['auto', 'none', 'required'].includes(toolChoice) + ? null + : 'tool_choice must be auto, none, or required'; + } + if (!toolChoice || typeof toolChoice !== 'object' || Array.isArray(toolChoice)) { + return 'tool_choice must be an object'; + } + const choice = toolChoice as { type?: unknown; name?: unknown }; + const type = typeof choice.type === 'string' ? choice.type : ''; + if (type === 'auto' || type === 'none' || type === 'any' || type === 'required') return null; + if (type === 'tool') { + return typeof choice.name === 'string' && choice.name.trim() + ? null + : 'tool_choice.name is required when type is "tool"'; + } + return 'tool_choice.type is invalid'; +} + export function validateMessagesRequest(body: unknown = {}): AnthropicErrorShape | null { if (!body || typeof body !== 'object') return anthropicError('invalid_request_error', 'request body must be an object'); const req = body as AnthropicMessagesRequest; @@ -163,6 +186,8 @@ export function validateMessagesRequest(body: unknown = {}): AnthropicErrorShape if (!Array.isArray(req.messages) || req.messages.length === 0) return anthropicError('invalid_request_error', 'messages array is required'); const first = req.messages[0] as { role?: unknown } | null | undefined; if (first?.role !== 'user') return anthropicError('invalid_request_error', 'first message must use role "user"'); + const toolChoiceError = validateAnthropicToolChoice(req.tool_choice); + if (toolChoiceError) return anthropicError('invalid_request_error', toolChoiceError); return null; } @@ -275,21 +300,33 @@ export function anthropicMessagesToChatMessages(anthropicMessages: unknown = []) chatMessages.push(...ordered); } } - return chatMessages.filter((m) => m && (m.content || m.tool_calls)); + return chatMessages.filter((m) => m && (m.content || m.tool_calls || m.role === 'tool')); } export function anthropicToolsToChatTools(tools: unknown): ChatTool[] { if (!Array.isArray(tools)) return []; - return (tools as Array<{ name?: unknown; description?: unknown; input_schema?: unknown } | null | undefined>) - .filter((t) => t && typeof t.name === 'string') - .map((t) => ({ - type: 'function' as const, - function: { - name: (t as { name?: unknown }).name as string, - description: ((t as { description?: unknown }).description || '') as string, - parameters: ((t as { input_schema?: unknown }).input_schema || { type: 'object', properties: {} }) as unknown + return (tools as Array | null | undefined>) + .filter((t) => t && typeof t['name'] === 'string' && t['name']) + .map((t) => { + const definition = t as Record; + const functionDefinition: Record = { + name: definition['name'], + description: (definition['description'] || '') as string, + parameters: (definition['input_schema'] || { type: 'object', properties: {} }) as unknown, + }; + // `enabled` is a declaration field. The `__proxy_*`/`x_proxy_*` family + // is reserved for the proxy's own metadata and is dropped here: it + // must never travel from an Anthropic tool body into the registry. + for (const key of ['enabled']) { + if (Object.prototype.hasOwnProperty.call(definition, key)) { + functionDefinition[key] = definition[key]; + } } - })); + return { + type: 'function' as const, + function: functionDefinition as ChatTool['function'], + }; + }); } export function anthropicToolChoiceToChat(toolChoice: unknown): ChatToolChoice | undefined { @@ -299,9 +336,10 @@ export function anthropicToolChoiceToChat(toolChoice: unknown): ChatToolChoice | const t = String(choice.type || '').toLowerCase(); if (t === 'auto') return 'auto'; if (t === 'none') return 'none'; - if (t === 'any') return 'required'; - if (t === 'tool' && choice.name) return { type: 'function', function: { name: choice.name as string } }; - if (t === 'tool') return 'required'; + if (t === 'any' || t === 'required') return 'required'; + if (t === 'tool' && typeof choice.name === 'string' && choice.name.trim()) { + return { type: 'function', function: { name: choice.name as string } }; + } return undefined; } diff --git a/src/converters/chat-responses/request.ts b/src/converters/chat-responses/request.ts index 3e9e6f9..a21e3e4 100644 --- a/src/converters/chat-responses/request.ts +++ b/src/converters/chat-responses/request.ts @@ -42,7 +42,7 @@ function collectReasoningText(item: Record): string { } /** Mirrors Go qualifyResponsesNamespaceToolName (tools.go:268-280). */ -function qualifyToolName(namespace: unknown, child: unknown): string { +export function qualifyToolName(namespace: unknown, child: unknown): string { const ns = str(namespace).trim(); const name = str(child).trim(); if (!name || !ns || name.startsWith('mcp__')) return name; diff --git a/src/errors/upstream.ts b/src/errors/upstream.ts index 177086a..ca13334 100644 --- a/src/errors/upstream.ts +++ b/src/errors/upstream.ts @@ -51,6 +51,62 @@ function readResponseBodyText(error: unknown): string { return ''; } +/** + * Marker for a 400 the proxy authored itself. `transformUpstreamError` only + * forwards a 400 `code` that carries it: the OpenCode backend reports failures + * as `{name:'BadRequestError', data:{...}}`, and forwarding that verbatim would + * put an internal class name on the client wire. + */ +const LOCAL_INVALID_REQUEST = Symbol.for('opencode2proxy.localInvalidRequest'); + +export interface LocalInvalidRequestError extends Error { + statusCode: number; + code: string; +} + +/** Author a client-facing 400 whose `code` is safe to put on the wire verbatim. */ +export function createInvalidRequestError(message: string, code: string): LocalInvalidRequestError { + const error = new Error(message) as LocalInvalidRequestError; + error.statusCode = 400; + error.code = code; + Object.defineProperty(error, LOCAL_INVALID_REQUEST, { value: true, enumerable: false }); + return error; +} + +/** Marker for a proxy-authored tool-discovery failure. Only this marker may use the discovery wire contract. */ +const LOCAL_TOOL_DISCOVERY = Symbol.for('opencode2api.localToolDiscovery'); + +export interface LocalToolDiscoveryError extends Error { + statusCode: number; + code: string; + type: string; +} + +/** Author a tool-discovery failure that may use the discovery wire contract verbatim. */ +export function createToolDiscoveryUnavailableError( + message: string = 'Tool discovery unavailable; backend tool IDs could not be verified', +): LocalToolDiscoveryError { + const error = new Error(message) as LocalToolDiscoveryError; + error.statusCode = 503; + error.code = 'tool_discovery_unavailable'; + error.type = 'tool_discovery_unavailable'; + Object.defineProperty(error, LOCAL_TOOL_DISCOVERY, { value: true, enumerable: false }); + return error; +} + +/** True only for proxy-authored discovery failures, never for backend 503 responses. */ +function isLocalToolDiscoveryError(error: unknown): boolean { + return Boolean(error) && (typeof error === 'object' || typeof error === 'function') && Reflect.get(error as object, LOCAL_TOOL_DISCOVERY) === true; +} + +/** The wire-safe 400 code of a proxy-authored error, or null for anything else. */ +function localInvalidRequestCode(error: unknown): string | null { + if (!error || typeof error !== 'object') return null; + if (Reflect.get(error as object, LOCAL_INVALID_REQUEST) !== true) return null; + const code: unknown = (error as Record)['code']; + return typeof code === 'string' && code ? code : null; +} + /** * Classify free-tier/Go quota exhaustion that should trigger proxy fallback. * @@ -267,6 +323,18 @@ export function transformUpstreamError(error: unknown): TransformedUpstreamError let type = 'internal_error'; let code: string = (typeof typed['code'] === 'string' ? (typed['code'] as string) : 'upstream_error') || 'upstream_error'; + const discoveryMarker = String(typed['code'] ?? typed['type'] ?? ''); + if (discoveryMarker === 'tool_discovery_unavailable' && isLocalToolDiscoveryError(normalized)) { + return { + statusCode: 503, + error: { + message: normalized.message || 'Tool discovery unavailable', + type: 'tool_discovery_unavailable', + code: 'tool_discovery_unavailable', + }, + }; + } + // Handle timeout errors if (normalized.message && normalized.message.includes('Request timeout')) { statusCode = 504; @@ -352,11 +420,21 @@ export function transformUpstreamError(error: unknown): TransformedUpstreamError code = 'model_not_found'; message = upstreamMessage || 'Model not found'; } else if (statusCode === 400 || upstreamType === 'BadRequestError') { - // Bad request - map to 400 + // Bad request - map to 400. Only a locally authored error (see + // createInvalidRequestError) may name a specific code; anything that came + // back from the backend keeps the generic `invalid_request_error` so an + // upstream class name (BadRequestError, ...) never reaches the client. statusCode = 400; type = 'invalid_request_error'; - code = 'invalid_request_error'; + code = localInvalidRequestCode(normalized) ?? 'invalid_request_error'; message = upstreamMessage || 'Invalid request'; + } else if (statusCode === 503) { + // An unmarked upstream 503 keeps its status but never inherits the + // discovery wire contract, which is reserved for proxy-authored errors. + statusCode = 503; + type = 'server_error'; + code = 'server_error'; + message = upstreamMessage || 'Upstream provider error'; } else if (statusCode >= 500) { // Server errors from upstream - map to 502/503 statusCode = 502; diff --git a/src/proxy.ts b/src/proxy.ts index 95c7d5f..c44c308 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,12 +7,12 @@ 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, normalizeToolNameForMatch } from './tool-runtime/registry.js'; +import { buildExternalToolRegistry, findExternalToolByName, 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'; import { validateToolCalls } from './tool-runtime/validator.js'; -import { stripFunctionCallMarkup } from './tool-runtime/parser.js'; +import { stripFunctionCallMarkup, assertToolCallArtifactIntegrity } from './tool-runtime/parser.js'; import type { ExternalToolEntry } from './tool-runtime/registry.js'; import type { ValidatedToolCall } from './tool-runtime/validator.js'; import type { FinalToolCall } from './tool-runtime/parser.js'; @@ -41,7 +41,7 @@ import type { InternalToolMetrics, } from './types/context.js'; import type { ProxyClient, ProviderInfo, ModelInfo, ResolvedModel } from './types/client.js'; -import type { ResponseStateEntry } from './types/backend.js'; +import type { ResponseStateEntry, ResponseToolCallState } from './types/backend.js'; import { asRecord, toErrorMessage } from './utils/guards.js'; import { buildEffectiveApiKeys, createApiKeyVerifier } from './auth/keys.js'; import { defaultTranslatorRegistry } from './converters/registry.js'; @@ -257,6 +257,26 @@ export function createApp(config: ProxyConfig): CreateAppResult { const responseState = new Map(); const RESPONSE_STATE_TTL_MS = 30 * 60 * 1000; const RESPONSE_STATE_SWEEP_INTERVAL_MS = 60 * 1000; + const RESPONSE_TOOL_CALL_STATE_LIMIT = 128; + const normalizeResponseToolCallState = (value: unknown): ResponseToolCallState[] => { + if (!Array.isArray(value)) return []; + const calls: ResponseToolCallState[] = []; + for (const entry of value as unknown[]) { + const record = asRecord(entry); + const callId = typeof record['callId'] === 'string' ? (record['callId'] as string) : ''; + const name = typeof record['name'] === 'string' ? (record['name'] as string) : ''; + if (!callId || !name) continue; + calls.push({ callId, name }); + } + if (calls.length <= RESPONSE_TOOL_CALL_STATE_LIMIT) return calls; + const dropped = calls.length - RESPONSE_TOOL_CALL_STATE_LIMIT; + logDebug('Dropped the oldest continuation tool call metadata past the cap', { + limit: RESPONSE_TOOL_CALL_STATE_LIMIT, + dropped, + kept: RESPONSE_TOOL_CALL_STATE_LIMIT + }); + return calls.slice(-RESPONSE_TOOL_CALL_STATE_LIMIT); + }; const getResponseState = (responseId: unknown): ResponseStateEntry | null => { if (typeof responseId !== 'string') return null; const state = responseState.get(responseId); @@ -267,12 +287,13 @@ export function createApp(config: ProxyConfig): CreateAppResult { } return state; }; - const storeResponseState = (responseId: unknown, sessionId: unknown, model: unknown): void => { + const storeResponseState = (responseId: unknown, sessionId: unknown, model: unknown, toolCalls: unknown = []): void => { if (!responseId || !sessionId || typeof responseId !== 'string' || typeof sessionId !== 'string') return; responseState.set(responseId, { sessionId, model: typeof model === 'string' ? model : String(model ?? ''), expiresAt: Date.now() + RESPONSE_STATE_TTL_MS, + toolCalls: normalizeResponseToolCallState(toolCalls), }); }; const sweepResponseState = async (): Promise => { @@ -338,6 +359,7 @@ export function createApp(config: ProxyConfig): CreateAppResult { const buildSystemPrompt = ( systemMsg: unknown, + externalToolPrompt: unknown = '', reasoningEffort: unknown = null, toolMode: unknown = TOOL_MODE.DISABLED, internalAllowedTools: unknown = [], @@ -346,6 +368,9 @@ export function createApp(config: ProxyConfig): CreateAppResult { if (!OMIT_SYSTEM_PROMPT && typeof systemMsg === 'string' && systemMsg.trim()) { parts.push(systemMsg.trim()); } + if (typeof externalToolPrompt === 'string' && externalToolPrompt.trim()) { + parts.push(externalToolPrompt.trim()); + } if (reasoningEffort && reasoningEffort !== 'none') { parts.push(`[Reasoning Effort: ${String(reasoningEffort)}]`); } @@ -537,6 +562,56 @@ export function createApp(config: ProxyConfig): CreateAppResult { return { validCalls: allowedCalls, invalidCalls: invalidCalls as Array<{ call: unknown; validation: unknown }> }; }; + const finalizeStreamToolCalls = ( + parsedToolCalls: unknown, + registry: unknown, + toolChoice: unknown, + sourceText: unknown = undefined, + parallelToolCalls: unknown = true, + ): ValidatedToolCall[] => { + const calls = Array.isArray(parsedToolCalls) ? (parsedToolCalls as ValidatedToolCall[]) : []; + const choice = asRecord(toolChoice); + const mode = String(choice['mode'] || 'auto'); + const requiredTool = typeof choice['requiredTool'] === 'string' ? choice['requiredTool'] : null; + const parallelAllowed = parallelToolCalls !== false; + const fail = (code: string, message: string): never => { + const error = new Error(message) as Error & { code?: string }; + error.code = code; + throw error; + }; + + assertToolCallArtifactIntegrity(calls, registry, sourceText); + if (!parallelAllowed && calls.length > 1) { + fail('parallel_external_tool_calls', 'More than one external tool call was emitted for this turn.'); + } + if (mode === 'none' && calls.length > 0) { + fail('external_tool_choice_none', 'The model emitted a tool call when tool_choice was none.'); + } + if (mode === 'required' && calls.length === 0) { + fail('external_tool_choice_required', 'The model did not emit the required external tool call.'); + } + + const { validCalls, invalidCalls } = finalizeValidatedToolCalls(calls, registry); + if (invalidCalls.length > 0) { + fail('invalid_external_tool_call', 'The model emitted an invalid external tool call.'); + } + if (validCalls.length !== calls.length) { + fail('external_tool_policy_blocked', 'The model emitted a tool call blocked by policy.'); + } + if (requiredTool && validCalls.length > 0) { + const matched = validCalls.some((entry) => { + const record = asRecord(entry); + const fn = asRecord(record['function']); + const selected = findExternalToolByName(registry, fn['name']); + return Boolean(selected && selected.namespacedName === requiredTool); + }); + if (!matched) { + fail('external_tool_choice_mismatch', 'The model emitted a tool call that did not match tool_choice.'); + } + } + return validCalls; + }; + const toPublicToolCalls = (toolCalls: unknown): FinalToolCall[] => { if (!Array.isArray(toolCalls) || (toolCalls as unknown[]).length === 0) return []; return ((toolCalls as unknown[]) as ValidatedToolCall[]).map((toolCall) => { @@ -554,16 +629,24 @@ export function createApp(config: ProxyConfig): CreateAppResult { }; const TOOL_IDS_CACHE_MS = 5 * 60 * 1000; + const TOOL_DISCOVERY_TIMEOUT_MS = 10000; + const TOOL_DISCOVERY_FAILURE_COOLDOWN_MS = 30000; let cachedToolIds: string[] | null = null; let cachedToolIdsAt = 0; let cachedDisabledToolOverrides: Record | null = null; let cachedDisabledToolOverridesAt = 0; + let discoverySource = 'none'; + let discoveryLastSuccessAt = 0; + let discoveryLastErrorAt = 0; + let discoveryLastError: string | null = null; + let discoveryCooldownUntil = 0; const internalToolMetrics: InternalToolMetrics = { externalBridgeRequests: 0, internalAllowlistRequests: 0, disabledRequests: 0, discoveryFailures: 0, fallbackToDisabled: 0, + overrideOmitted: 0, }; const logInternalToolEvent = (event: unknown, details: unknown = {}): void => { @@ -593,27 +676,66 @@ export function createApp(config: ProxyConfig): CreateAppResult { }; const getBackendToolIds = async (): Promise => { - if (cachedToolIds && Date.now() - cachedToolIdsAt < TOOL_IDS_CACHE_MS) { - return cachedToolIds; + const now = Date.now(); + if (cachedToolIds && now - cachedToolIdsAt < TOOL_IDS_CACHE_MS) { + const cachedNormalized = normalizeBackendToolIds(cachedToolIds); + if (cachedNormalized.length > 0) return cachedToolIds; + if (!DISABLE_TOOLS) return cachedToolIds; } const fixtureIds = normalizeConfiguredToolNames(INTERNAL_TOOL_DISCOVERY_FIXTURE); if (fixtureIds.length > 0) { cachedToolIds = fixtureIds; cachedToolIdsAt = Date.now(); - logInternalToolEvent('backend-tool-ids-fixture-loaded', { count: fixtureIds.length, fixtureIds }); + discoverySource = 'fixture'; + discoveryLastSuccessAt = Date.now(); + logInternalToolEvent('backend-tool-ids-fixture-loaded', { count: fixtureIds.length }); return fixtureIds; } + if (now < discoveryCooldownUntil) { + return null; + } try { - const idsRes = (await client.tool.ids()) as unknown; - const data: unknown = asRecord(idsRes)['data'] ?? idsRes; - const ids = Array.isArray(data) ? (data as string[]) : []; - cachedToolIds = ids; + const idsRes = (await withTimeout(client.tool.ids(), TOOL_DISCOVERY_TIMEOUT_MS, 'tool discovery')) as unknown; + const idsRecord = asRecord(idsRes); + const tupleError: unknown = idsRecord['error']; + if (tupleError !== undefined && tupleError !== null) { + throw new Error(toErrorMessage(tupleError) || 'tool discovery returned error'); + } + const data: unknown = idsRecord['data'] ?? idsRes; + if (!Array.isArray(data)) { + throw new Error('tool discovery returned malformed payload'); + } + const normalized = normalizeBackendToolIds(data); + if (normalized.length === 0) { + // Fail-closed by design (locked by tests/tool-alias.test.js): an empty + // tool list under DISABLE_TOOLS=true is treated as unverifiable + // discovery, not as "zero tools, proceed". A real backend always exposes + // tools; empty almost certainly means a broken backend/proxy skew. + if (DISABLE_TOOLS) { + throw new Error('tool discovery returned empty ids'); + } + cachedToolIds = normalized; + cachedToolIdsAt = Date.now(); + discoverySource = 'live'; + discoveryLastSuccessAt = Date.now(); + logInternalToolEvent('backend-tool-ids-loaded', { count: 0 }); + return normalized; + } + cachedToolIds = normalized; cachedToolIdsAt = Date.now(); - logInternalToolEvent('backend-tool-ids-loaded', { count: ids.length }); - return ids; + discoverySource = 'live'; + discoveryLastSuccessAt = Date.now(); + cachedDisabledToolOverrides = null; + cachedDisabledToolOverridesAt = 0; + logInternalToolEvent('backend-tool-ids-loaded', { count: normalized.length }); + return normalized; } catch (e: unknown) { internalToolMetrics.discoveryFailures += 1; - logInternalToolEvent('backend-tool-ids-failed', { error: toErrorMessage(e) }); + discoveryLastError = toErrorMessage(e).slice(0, 500); + discoveryLastErrorAt = Date.now(); + discoveryCooldownUntil = Date.now() + TOOL_DISCOVERY_FAILURE_COOLDOWN_MS; + if (discoverySource !== 'live' && discoverySource !== 'fixture') discoverySource = 'none'; + logInternalToolEvent('backend-tool-ids-failed', { error: discoveryLastError }); return null; } }; @@ -668,16 +790,30 @@ export function createApp(config: ProxyConfig): CreateAppResult { }; const getDisabledToolOverrides = async (): Promise | null> => { - if (!DISABLE_TOOLS) return null; + if (!DISABLE_TOOLS) { + internalToolMetrics.overrideOmitted += 1; + logInternalToolEvent('disabled-tool-overrides-omitted', { reason: 'operator-allow' }); + return null; + } + const ids = await getBackendToolIds(); + if (!Array.isArray(ids)) { + cachedDisabledToolOverrides = null; + cachedDisabledToolOverridesAt = 0; + return null; + } + const normalized = normalizeBackendToolIds(ids); + if (normalized.length === 0) { + cachedDisabledToolOverrides = null; + cachedDisabledToolOverridesAt = 0; + return null; + } if (cachedDisabledToolOverrides && Date.now() - cachedDisabledToolOverridesAt < TOOL_IDS_CACHE_MS) { return cachedDisabledToolOverrides; } - const ids = await getBackendToolIds(); - if (!Array.isArray(ids)) return null; - const overrides = buildDisabledToolOverrides(ids); + const overrides = buildDisabledToolOverrides(normalized); cachedDisabledToolOverrides = overrides; cachedDisabledToolOverridesAt = Date.now(); - logInternalToolEvent('disabled-tool-overrides-loaded', { count: ids.length }); + logInternalToolEvent('disabled-tool-overrides-loaded', { count: normalized.length }); return overrides; }; @@ -696,10 +832,20 @@ export function createApp(config: ProxyConfig): CreateAppResult { return getDisabledToolOverrides(); } if (toolMode !== TOOL_MODE.INTERNAL_ALLOWLIST) { + if (!DISABLE_TOOLS) { + internalToolMetrics.overrideOmitted += 1; + } return null; } const ids = await getBackendToolIds(); - if (!Array.isArray(ids) || (ids as unknown[]).length === 0) return null; + const normalizedPre = normalizeBackendToolIds(ids); + if (!Array.isArray(ids) || normalizedPre.length === 0) { + if (!DISABLE_TOOLS) { + internalToolMetrics.overrideOmitted += 1; + logInternalToolEvent('internal-allowlist-omitted', { reason: 'operator-allow' }); + } + return null; + } const resolution = resolveInternalAllowedToolIds( ids, (ctxRecord['allowedToolNames'] ?? SERVER_INTERNAL_ALLOWED_TOOL_NAMES) as unknown, @@ -919,6 +1065,7 @@ export function createApp(config: ProxyConfig): CreateAppResult { resolveToolMode, createRequestToolContext: createRequestToolContext as unknown as AppContext['createRequestToolContext'], finalizeValidatedToolCalls, + finalizeStreamToolCalls, toPublicToolCalls, createForcedToolCallRequester: createForcedToolCallRequester as unknown as AppContext['createForcedToolCallRequester'], TOOL_IDS_CACHE_MS, @@ -944,6 +1091,10 @@ export function createApp(config: ProxyConfig): CreateAppResult { proxyPollForAssistantResponse, getCachedToolIds: () => cachedToolIds, getCachedToolIdsAt: () => cachedToolIdsAt, + getDiscoverySource: () => discoverySource, + getDiscoveryLastSuccessAt: () => discoveryLastSuccessAt, + getDiscoveryLastErrorAt: () => discoveryLastErrorAt, + getDiscoveryLastError: () => discoveryLastError, translators: ensureTranslatorsRegistered(defaultTranslatorRegistry()), }; diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 5054ba0..8628710 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -2,14 +2,17 @@ import crypto from 'crypto'; import { findExternalToolByName } from '../tool-runtime/registry.js'; import { EXTERNAL_TOOL_PREFIX } from '../tool-runtime/contracts.js'; +import { preflightExternalToolChoice } from '../tool-runtime/router.js'; import { computeRetryDelay } from '../retry/policy.js'; import { - stripFunctionCallMarkup, - parseExternalToolCallsFromText, + assertToolCallArtifactIntegrity, + stripExternalToolCallMarkupFromJoinedText, + parseExternalToolCallsFromJoinedText, + mergeToolCallArtifacts, createToolCallFilter, createExternalToolCallStreamParser, } from '../tool-runtime/parser.js'; -import { isTransientUpstreamError, transformUpstreamError } from '../errors/upstream.js'; +import { isTransientUpstreamError, normalizeBackendError, transformUpstreamError } from '../errors/upstream.js'; import { engageFallbackForFreeLimit } from '../upstream-proxy/fallback.js'; import { detectHostedSearchTools } from '../search/grounding.js'; import { @@ -60,11 +63,11 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { buildSystemPrompt, selectPromptToolOverrides, normalizeReasoningEffort, - stripFunctionCalls, normalizeTextContent, normalizeToolArguments, createRequestToolContext, finalizeValidatedToolCalls, + finalizeStreamToolCalls, toPublicToolCalls, createForcedToolCallRequester, trackToolMode, @@ -89,7 +92,12 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { let pID = 'opencode'; let mID = 'kimi-k2.5-free'; let id = `chatcmpl-${crypto.randomUUID()}`; - let keepaliveInterval: ReturnType | null = null; + let keepaliveInterval: ReturnType | null = null; + const requestAbortController = new AbortController(); + let clientDisconnected = false; + let disconnectedSessionCleaned = false; + let removeClientCloseListener = (): void => {}; + // P3 fallback bundle: direct by default; switched to the proxy // bundle for subsequent attempts once a free-limit error engages it. let activeClient = client; @@ -131,14 +139,12 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { const reasoning_effort: unknown = body['reasoning_effort']; const reasoning: unknown = body['reasoning']; const requestOpencodeConfig: unknown = body['opencode']; + const parallelToolCalls: boolean = body['parallel_tool_calls'] !== false; stream = Boolean(requestStream); if (!messages || !Array.isArray(messages) || (messages as unknown[]).length === 0) { 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: { @@ -149,6 +155,22 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { }); return; } + const unsupportedChatToolIndex = tools.findIndex((def) => { + const t = asRecord(def); + return t['type'] !== 'function'; + }); + if (unsupportedChatToolIndex >= 0) { + const rawType = asRecord(tools[unsupportedChatToolIndex])['type']; + const label = typeof rawType === 'string' && rawType.trim() ? rawType.trim() : 'missing'; + res.status(400).json({ + error: { + message: `Unsupported tool type "${label}" at tools[${unsupportedChatToolIndex}]. Only function tools are supported on this endpoint.`, + type: 'invalid_request_error', + code: 'unsupported_tool_type', + }, + }); + return; + } const reasoningLevel = normalizeReasoningEffort( (reasoning_effort as string) || (asRecord(reasoning)['effort'] as string), @@ -240,23 +262,21 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { if (role === 'tool') { const text = normalizeMessageContent(content); - if (text) { - const mappedTool = - findExternalToolByName(externalToolRegistry, mr['name']) || - findExternalToolByName(externalToolRegistry, assistantToolCalls.get(String(mr['tool_call_id'] ?? ''))); - const toolName = - mappedTool?.namespacedName || - assistantToolCalls.get(String(mr['tool_call_id'] ?? '')) || - (typeof mr['name'] === 'string' ? (mr['name'] as string) : `${EXTERNAL_TOOL_PREFIX}unknown`); - const toolCallId = - typeof mr['tool_call_id'] === 'string' - ? (mr['tool_call_id'] as string) - : `call_${String(toolName).replace(/[^a-zA-Z0-9_]/g, '_')}`; - parts.push({ - type: 'text', - text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: toolCallId, name: toolName, content: text })}`, - }); - } + const mappedTool = + findExternalToolByName(externalToolRegistry, mr['name']) || + findExternalToolByName(externalToolRegistry, assistantToolCalls.get(String(mr['tool_call_id'] ?? ''))); + const toolName = + mappedTool?.namespacedName || + assistantToolCalls.get(String(mr['tool_call_id'] ?? '')) || + (typeof mr['name'] === 'string' ? (mr['name'] as string) : `${EXTERNAL_TOOL_PREFIX}unknown`); + const toolCallId = + typeof mr['tool_call_id'] === 'string' + ? (mr['tool_call_id'] as string) + : `call_${String(toolName).replace(/[^a-zA-Z0-9_]/g, '_')}`; + parts.push({ + type: 'text', + text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: toolCallId, name: toolName, content: text })}`, + }); continue; } @@ -316,7 +336,17 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { const toolMode: string = requestToolContext.mode; const externalToolContext = requestToolContext.external; const externalToolRegistry = externalToolContext.registry; - const externalToolChoice = externalToolContext.toolChoice; + const toolChoicePreflight = preflightExternalToolChoice(tool_choice, externalToolRegistry); + if (!toolChoicePreflight.ok) { + res.status(400).json({ + error: { + message: toolChoicePreflight.message, + type: 'invalid_request_error', + }, + }); + return; + } + const externalToolChoice = toolChoicePreflight.normalized; const internalToolContext = requestToolContext.internal; trackToolMode(toolMode, { configuredAllowlist: internalToolContext.allowedToolNames, @@ -332,7 +362,8 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { externalToolRegistry, ); const systemWithGuard = buildSystemPrompt( - [systemMsg, externalToolContext.prompt].filter(Boolean).join('\n\n'), + systemMsg, + externalToolContext.prompt, requestParams['reasoning_effort'], toolMode, internalToolContext.allowedToolNames, @@ -401,6 +432,23 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { REQUEST_TIMEOUT_MS, 'load tool overrides', )) as Record | null; + // Fail-closed by design: null (discovery failed) or verified-empty (no + // tool IDs to disable) both 503. A real backend always exposes tools. + if (DISABLE_TOOLS && (!toolOverrides || Object.keys(toolOverrides).length === 0)) { + try { + await activeClient.session.delete({ path: { id: sessionId as string } }); + } catch (cleanupError: unknown) { + logDebug('Failed to cleanup session after tool discovery unavailable', { error: toErrorMessage(cleanupError) }); + } + res.status(503).json({ + error: { + message: 'Tool discovery unavailable; backend tool IDs could not be verified', + type: 'tool_discovery_unavailable', + code: 'tool_discovery_unavailable', + }, + }); + return; + } // Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted). const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); if (promptToolOverrides) { @@ -424,19 +472,39 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { res.setHeader('Content-Type', stream ? 'text/event-stream' : 'application/json'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); + if (stream) { + const flushHeaders = (res as unknown as { flushHeaders?: unknown }).flushHeaders; + if (typeof flushHeaders === 'function') (flushHeaders as () => void).call(res); + const onClientClose = (): void => { + if (res.writableEnded) return; + clientDisconnected = true; + requestAbortController.abort(); + }; + res.once('close', onClientClose); + removeClientCloseListener = (): void => { + res.removeListener('close', onClientClose); + }; + } if (stream) { - const shouldStripStreamingToolMarkup = externalToolRegistry.length > 0; - const filterContentDelta = createToolCallFilter({ + // Intentional: buffer visible text while any external tool contract is + // declared so `` / `` markup fragments are + // never streamed incrementally before the parser can classify them. + // Trade-off: pure-text turns with tools declared also emit in one batch + // at completion instead of incrementally. + const shouldBufferExternalStream = externalToolRegistry.length > 0; + let filterContentDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, - forceStrip: shouldStripStreamingToolMarkup, + forceStrip: shouldBufferExternalStream, + registry: externalToolRegistry, }); - const filterReasoningDelta = createToolCallFilter({ + let filterReasoningDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, - forceStrip: shouldStripStreamingToolMarkup, + forceStrip: shouldBufferExternalStream, + registry: externalToolRegistry, }); - const parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); - const parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + let parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + let parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); let streamedContent = ''; let streamedReasoning = ''; let rawStreamedContent = ''; @@ -457,44 +525,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { }; ensureKeepalive(); - const sendDelta = (delta: string, isReasoning: boolean = false): void => { - if (!delta) return; - if (isReasoning) rawStreamedReasoning += delta; - else rawStreamedContent += delta; - const parsedDeltaToolCalls = isReasoning - ? parseReasoningToolCalls(delta) - : parseContentToolCalls(delta); - parsedDeltaToolCalls.forEach((toolCall) => { - streamedToolCalls.push(toolCall); - res.write( - `data: ${JSON.stringify({ - id, - object: 'chat.completion.chunk', - created: Math.floor(Date.now() / 1000), - model: `${pID}/${mID}`, - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: streamedToolCalls.length - 1, - id: toolCall.id, - type: 'function', - function: { - name: toolCall.function.name, - arguments: toolCall.function.arguments, - }, - }, - ], - }, - finish_reason: null, - }, - ], - })}\n\n`, - ); - }); - const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); + const appendFilteredDelta = (filtered: string, isReasoning: boolean): void => { if (!filtered) return; if (isReasoning) { streamedReasoning += filtered; @@ -503,6 +534,9 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { streamedContent += filtered; completionTokens += Math.ceil(filtered.length / 4); } + }; + const writeVisibleDelta = (filtered: string, isReasoning: boolean): void => { + if (!filtered) return; const deltaField = isReasoning ? { reasoning_content: filtered } : { content: filtered }; const chunk = { id, @@ -513,9 +547,99 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { }; res.write(`data: ${JSON.stringify(chunk)}\n\n`); }; + const sendDelta = (delta: string, isReasoning: boolean = false): void => { + if (!delta) return; + if (isReasoning) rawStreamedReasoning += delta; + else rawStreamedContent += delta; + const parsedDeltaToolCalls = isReasoning + ? parseReasoningToolCalls(delta) + : parseContentToolCalls(delta); + parsedDeltaToolCalls.forEach((toolCall) => { + streamedToolCalls.push(toolCall); + }); + if (!shouldBufferExternalStream) { + const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); + if (!filtered) return; + appendFilteredDelta(filtered, isReasoning); + writeVisibleDelta(filtered, isReasoning); + } else { + const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); + appendFilteredDelta(filtered, isReasoning); + } + }; + + let collected: Record | null = null; + let lastStreamAttemptError: unknown = null; + let polledSnapshot: { content: string; reasoning: string; error: unknown; toolParts?: unknown } | null = null; + let snapshotPolled = false; + let snapshotPromise: Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown }> | null = null; + const pollSnapshot = async (): Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown }> => { + snapshotPolled = true; + if (!snapshotPromise) { + snapshotPromise = activePollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + undefined, + requestAbortController.signal, + ); + + } + return snapshotPromise; + }; + // Recovery snapshots repeat what the event stream already delivered. Only the + // suffix past the raw prefix may be replayed, otherwise every recovered turn + // duplicates text. A snapshot the raw prefix does not match is still sent in + // full rather than dropped. + const unsentSuffix = (full: unknown, raw: string): string => { + const text = typeof full === 'string' ? full : ''; + if (!text) return ''; + if (!raw) return text; + return text.startsWith(raw) ? text.slice(raw.length) : text; + }; + const replaySnapshot = (snapshot: { content: string; reasoning: string; error: unknown }): void => { + if (snapshot.error != null) throw snapshot.error; + const remainingReasoning = unsentSuffix(snapshot.reasoning, rawStreamedReasoning); + const remainingContent = unsentSuffix(snapshot.content, rawStreamedContent); + if (remainingReasoning) sendDelta(remainingReasoning, true); + if (remainingContent) sendDelta(remainingContent, false); + }; + const hasPartialOutput = (): boolean => { + const current = asRecord(collected); + return Boolean( + rawStreamedContent || + rawStreamedReasoning || + streamedToolCalls.length > 0 || + current['content'] || + current['reasoning'], + ); + }; + const recoverSnapshot = async ( + fallbackError: unknown = null, + ): Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown } | null> => { + if (clientDisconnected) return null; + let snapshot: { content: string; reasoning: string; error: unknown; toolParts?: unknown }; + try { + snapshot = await pollSnapshot(); + } catch (error: unknown) { + if (clientDisconnected) return null; + if (hasPartialOutput()) { + logDebug('Ignoring recovery poll failure after partial stream output', { error: toErrorMessage(error) }); + return null; + } + if (fallbackError != null) throw fallbackError; + throw error; + } + if (snapshot.error != null) throw snapshot.error; + if ( + fallbackError != null && + !snapshot.content && + !snapshot.reasoning && + !(Array.isArray(snapshot.toolParts) && snapshot.toolParts.length > 0) + ) throw fallbackError; + return snapshot; + + }; - let collected: Record | null = null; - let lastStreamAttemptError: unknown = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (attempt > 1) { try { @@ -534,12 +658,27 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { requestForcedChatToolCall = makeForcedChatToolCallRequester(); streamedContent = ''; streamedReasoning = ''; - rawStreamedContent = ''; - rawStreamedReasoning = ''; - streamedToolCalls.length = 0; - completionTokens = 0; - reasoningTokens = 0; - await sleep(computeRetryDelay(attempt - 1, lastStreamAttemptError)); + rawStreamedContent = ''; + rawStreamedReasoning = ''; + streamedToolCalls.length = 0; + polledSnapshot = null; + snapshotPolled = false; + snapshotPromise = null; + filterContentDelta = createToolCallFilter({ + disableTools: DISABLE_TOOLS, + forceStrip: shouldBufferExternalStream, + registry: externalToolRegistry, + }); + filterReasoningDelta = createToolCallFilter({ + disableTools: DISABLE_TOOLS, + forceStrip: shouldBufferExternalStream, + registry: externalToolRegistry, + }); + parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + completionTokens = 0; + reasoningTokens = 0; + await sleep(computeRetryDelay(attempt - 1, lastStreamAttemptError)); } try { if (fallbackToProxy) { @@ -547,12 +686,12 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { // 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); + const pres = (await pollSnapshot()) as { content: string; reasoning: string; error: unknown }; + polledSnapshot = pres; + const presReasoning = unsentSuffix(pres.reasoning, rawStreamedReasoning); + const presContent = unsentSuffix(pres.content, rawStreamedContent); + if (presReasoning) sendDelta(presReasoning, true); + if (presContent) sendDelta(presContent, 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; @@ -566,9 +705,11 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { sessionId as string, REQUEST_TIMEOUT_MS, sendDelta, - DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, - DEFAULT_EVENT_IDLE_TIMEOUT_MS, - ); + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, + requestAbortController.signal, + ); + 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; @@ -597,147 +738,191 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { break; } - 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']), - }); - // 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, - ); - if (error && !content && !reasoning) { - const er = asRecord(error); - sendDelta( - `[Proxy Error] ${String(er['name'] ?? 'OpenCodeError')}: ${String(asRecord(er['data'])['message'] ?? (er['message'] as string) ?? 'Unknown error')}`, - ); - } else { - if (reasoning) sendDelta(reasoning, true); - if (content) sendDelta(content, false); - } - } else if (collected && collectedR['noData']) { - logDebug('Fallback to polling (stream)', { sessionId }); - const { content, reasoning, error } = await activePollForAssistantResponse( - sessionId as string, - REQUEST_TIMEOUT_MS, - ); - if (error && !content && !reasoning) { - const er = asRecord(error); - sendDelta( - `[Proxy Error] ${String(er['name'] ?? 'OpenCodeError')}: ${String(asRecord(er['data'])['message'] ?? (er['message'] as string) ?? 'Unknown error')}`, - ); - } else { - if (reasoning) sendDelta(reasoning, true); - if (content) sendDelta(content, false); + const collectedR = asRecord(collected); + if (clientDisconnected || collectedR['cancelled']) { + if (sessionId) { + try { + await activeClient.session.delete({ path: { id: sessionId } }); + disconnectedSessionCleaned = true; + } catch (error: unknown) { + + logDebug('Failed to cleanup disconnected chat session', { sessionId, error: toErrorMessage(error) }); + } + } + return; } - } else if (collected && collectedR['idleTimeout']) { - logDebug('SSE idle timeout, polling for completion', { sessionId }); - const { content, reasoning, error } = await activePollForAssistantResponse( - sessionId as string, - REQUEST_TIMEOUT_MS, - ); - if (error && !content && !reasoning) { - const er = asRecord(error); - sendDelta( - `[Proxy Error] ${String(er['name'] ?? 'OpenCodeError')}: ${String(asRecord(er['data'])['message'] ?? (er['message'] as string) ?? 'Unknown error')}`, - ); - } else { - const remainingReasoning = - reasoning && reasoning.startsWith(rawStreamedReasoning) - ? reasoning.slice(rawStreamedReasoning.length) - : reasoning; - const remainingContent = - content && content.startsWith(rawStreamedContent) - ? content.slice(rawStreamedContent.length) - : content; - if (remainingReasoning) sendDelta(remainingReasoning, true); - if (remainingContent) sendDelta(remainingContent, false); + if (collectedR['error'] != null) throw collectedR['error']; + if (collectedR['__error']) { + logDebug('SSE collect error, falling back to polling', { + sessionId, + error: toErrorMessage(collectedR['__error']), + }); + if (!fallbackToProxy) engageProxyFallback(collectedR['__error']); + const snapshot = await recoverSnapshot(collectedR['__error']); + if (snapshot) { + polledSnapshot = snapshot; + replaySnapshot(snapshot); + } + } else if (collectedR['noData']) { + logDebug('Fallback to polling (stream)', { sessionId }); + const snapshot = await recoverSnapshot(); + if (snapshot) { + polledSnapshot = snapshot; + replaySnapshot(snapshot); + } + } else if (collectedR['idleTimeout']) { + logDebug('SSE idle timeout, polling for completion', { sessionId }); + const snapshot = await recoverSnapshot(); + if (snapshot) { + polledSnapshot = snapshot; + replaySnapshot(snapshot); + } } - } - if ( - collected && - !fallbackServed && - !streamedContent && - !streamedReasoning && - ((collectedR['reasoning'] as string) || (collectedR['content'] as string)) - ) { - if (collectedR['reasoning']) sendDelta(String(collectedR['reasoning']), true); - if (collectedR['content']) sendDelta(String(collectedR['content']), false); - } + if ( + collected && + !fallbackServed && + !streamedContent && + !streamedReasoning && + ((collectedR['reasoning'] as string) || (collectedR['content'] as string)) + ) { + replaySnapshot({ content: String(collectedR['content'] ?? ''), reasoning: String(collectedR['reasoning'] ?? ''), error: null }); + } - if (!fallbackServed && !streamedContent && !streamedReasoning) { - logDebug('SSE returned empty, falling back to polling', { sessionId }); - const { content, reasoning, error } = await activePollForAssistantResponse( - sessionId as string, - REQUEST_TIMEOUT_MS, + const hasValidTerminal = Boolean( + collected && + !collectedR['__error'] && + !collectedR['noData'] && + !collectedR['idleTimeout'] && + !collectedR['cancelled'] && + (rawStreamedContent || rawStreamedReasoning || streamedToolCalls.length > 0 || collectedR['content'] || collectedR['reasoning']), ); - if (error && !content && !reasoning) { - const er = asRecord(error); - sendDelta( - `[Proxy Error] ${String(er['name'] ?? 'OpenCodeError')}: ${String(asRecord(er['data'])['message'] ?? (er['message'] as string) ?? 'Unknown error')}`, - ); - } else { - if (reasoning) sendDelta(reasoning, true); - if (content) sendDelta(content, false); + + if (!fallbackServed && !rawStreamedContent && !rawStreamedReasoning && !hasValidTerminal) { + logDebug('SSE returned empty, falling back to polling', { sessionId }); + const snapshot = await recoverSnapshot(); + if (snapshot) { + polledSnapshot = snapshot; + replaySnapshot(snapshot); + } + } else if (streamedReasoning && !streamedContent) { + logDebug('Reasoning streamed but no content, reconciling from snapshot', { sessionId }); + const snapshot = await recoverSnapshot(); + if (snapshot) { + polledSnapshot = snapshot; + const remainingContent = unsentSuffix(snapshot.content, rawStreamedContent); + if (remainingContent) sendDelta(remainingContent, false); + } } - } else if (streamedReasoning && !streamedContent) { - logDebug('Reasoning streamed but no content, reconciling from snapshot', { sessionId }); - const snapshot = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS).catch( - () => null, - ); - if (snapshot && snapshot.content) { - const remainingContent = rawStreamedContent - ? snapshot.content.slice(rawStreamedContent.length) - : snapshot.content; - if (remainingContent) sendDelta(remainingContent, false); + + if (externalToolRegistry.length > 0 && !hasValidTerminal && !snapshotPolled) { + const snapshot = await recoverSnapshot(); + if (snapshot) { + polledSnapshot = snapshot; + replaySnapshot(snapshot); + } } - } - // Flush held buffers from the stream parsers and filters before final batch parse. + const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : []; const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : []; const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : ''; const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : ''; - const finalReasoningText = rawStreamedReasoning + flushedReasoningText; - const finalContentText = rawStreamedContent + flushedContentText; - - const parseStreamedToolCalls = (): FinalToolCall[] => { - if (externalToolRegistry.length === 0) return []; - const perChannel = [ - ...flushedReasoningCalls, - ...flushedContentCalls, - ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText), - ]; - if (perChannel.length > 0) return perChannel; - return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); - }; + if (!shouldBufferExternalStream) { + appendFilteredDelta(flushedReasoningText, true); + appendFilteredDelta(flushedContentText, false); + writeVisibleDelta(flushedReasoningText, true); + writeVisibleDelta(flushedContentText, false); + } else { + const visible = stripExternalToolCallMarkupFromJoinedText( + externalToolRegistry, + rawStreamedReasoning, + rawStreamedContent, + ); + streamedReasoning = visible.reasoning; + streamedContent = visible.content; + } - let parsedToolCalls: FinalToolCall[] = - streamedToolCalls.length > 0 ? streamedToolCalls : parseStreamedToolCalls(); - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedChatToolCall(); - if (forcedResponse) { - parsedToolCalls = parseExternalToolCallsFromText( - externalToolRegistry, - forcedResponse['reasoning'] as string, - forcedResponse['content'] as string, - ); + const snapshotReasoning = polledSnapshot?.reasoning ?? (typeof collectedR['reasoning'] === 'string' ? collectedR['reasoning'] as string : rawStreamedReasoning); + const snapshotContent = polledSnapshot?.content ?? (typeof collectedR['content'] === 'string' ? collectedR['content'] as string : rawStreamedContent); + const parseJoined = (reasoning: string, content: string): FinalToolCall[] => + externalToolRegistry.length > 0 + ? parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoning, content) + : []; + const streamSource = [ + [snapshotReasoning, snapshotContent], + [rawStreamedReasoning, rawStreamedContent] + ]; + let parsedToolCalls: FinalToolCall[] = externalToolRegistry.length > 0 + ? mergeToolCallArtifacts( + streamedToolCalls, + flushedReasoningCalls, + flushedContentCalls, + parseJoined(snapshotReasoning, snapshotContent), + parseJoined(rawStreamedReasoning, rawStreamedContent), + ) + : []; + let finalStreamedToolCalls: FinalToolCall[]; + try { + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, streamSource); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedChatToolCall(); + if (forcedResponse) { + const forcedReasoning = typeof forcedResponse['reasoning'] === 'string' ? forcedResponse['reasoning'] as string : ''; + const forcedContent = typeof forcedResponse['content'] === 'string' ? forcedResponse['content'] as string : ''; + parsedToolCalls = mergeToolCallArtifacts(parsedToolCalls, parseJoined(forcedReasoning, forcedContent)); + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, [forcedReasoning, forcedContent]); + } } + finalStreamedToolCalls = finalizeStreamToolCalls( + parsedToolCalls, + externalToolRegistry, + externalToolChoice, + streamSource, + parallelToolCalls, + ) as unknown as FinalToolCall[]; + } catch (toolError) { + const toolCode = (toolError as Error & { code?: string }).code; + const failClosedCodes = [ + 'parallel_external_tool_calls', + 'external_tool_choice_none', + 'external_tool_choice_required', + 'invalid_external_tool_call', + 'external_tool_policy_blocked', + 'external_tool_choice_mismatch', + 'duplicate_external_tool_call_id', + 'malformed_external_tool_call', + ]; + if (toolCode && failClosedCodes.includes(toolCode)) { + try { + if (sessionId) await activeClient.session.delete({ path: { id: sessionId } }); + } catch (_cleanupError) { + void _cleanupError; + } + if (keepaliveInterval) clearInterval(keepaliveInterval); + keepaliveInterval = null; + const toolMessage = toErrorMessage(toolError); + if (!res.headersSent) { + res.status(502).json({ + error: { + message: toolMessage, + type: 'server_error', + code: toolCode, + }, + }); + } else if (!res.destroyed) { + res.write(`data: ${JSON.stringify({ error: { message: toolMessage, type: 'server_error', code: toolCode } })}\n\n`); + res.end(); + } + return; + } + throw toolError; } - const { validCalls: validatedStreamedToolCalls } = finalizeValidatedToolCalls( - parsedToolCalls, - externalToolRegistry, - ); - const finalStreamedToolCalls = validatedStreamedToolCalls; - if (finalStreamedToolCalls.length > 0 && streamedToolCalls.length === 0) { + if (shouldBufferExternalStream) { + writeVisibleDelta(streamedReasoning, true); + writeVisibleDelta(streamedContent, false); + } + if (finalStreamedToolCalls.length > 0) { const toolCallDeltas = finalStreamedToolCalls.map((toolCall, index) => ({ index, id: toolCall.id, @@ -764,6 +949,10 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { ); } + if (!streamedReasoning.trim() && !streamedContent.trim() && finalStreamedToolCalls.length === 0) { + throw new Error('Upstream returned no assistant data'); + } + if (keepaliveInterval) clearInterval(keepaliveInterval); keepaliveInterval = null; @@ -822,7 +1011,13 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { try { await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); logDebug('Prompt sent', { sessionId, ms: Date.now() - attemptStart, attempt }); - const collected = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + const collected = await activePollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + undefined, + requestAbortController.signal, + ); + content = collected.content || ''; reasoning = collected.reasoning || ''; error = collected.error || null; @@ -843,8 +1038,16 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { } break; } - if (error && !content && !reasoning) { + // Fail-closed: any backend error fails even when partial content exists. + // Returning partial text as success would hide the upstream failure and + // diverge from stream paths (which throw on snapshot.error). Retry above + // already covers the no-output transient case. + if (error != null) { if (/^Request timeout after/.test(toErrorMessage(error))) throw error; + const normalizedDiscovery = normalizeBackendError(error); + if (normalizedDiscovery.statusCode === 503 && normalizedDiscovery.code === 'tool_discovery_unavailable') { + throw normalizedDiscovery; + } const er = asRecord(error); const ed = asRecord(er['data']); res.status(502).json({ @@ -855,21 +1058,65 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { }); return; } - let parsedToolCalls: FinalToolCall[] = - externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) - : []; - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedChatToolCall(); - if (forcedResponse) { - content = String(forcedResponse['content'] ?? content); - reasoning = String(forcedResponse['reasoning'] ?? reasoning); - parsedToolCalls = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); - } - } - const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); + let parsedToolCalls: FinalToolCall[] = externalToolRegistry.length > 0 + ? parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoning, content) + : []; + let validatedToolCalls: FinalToolCall[] = []; + try { + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, [reasoning, content]); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedChatToolCall(); + if (forcedResponse) { + content = String(forcedResponse['content'] ?? content); + reasoning = String(forcedResponse['reasoning'] ?? reasoning); + parsedToolCalls = parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoning, content); + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, [reasoning, content]); + } + } + validatedToolCalls = finalizeStreamToolCalls( + parsedToolCalls, + externalToolRegistry, + externalToolChoice, + [reasoning, content], + parallelToolCalls, + ) as unknown as FinalToolCall[]; + } catch (toolError) { + const toolCode = (toolError as Error & { code?: string }).code; + const failClosedCodes = [ + 'parallel_external_tool_calls', + 'external_tool_choice_none', + 'external_tool_choice_required', + 'invalid_external_tool_call', + 'external_tool_policy_blocked', + 'external_tool_choice_mismatch', + 'duplicate_external_tool_call_id', + 'malformed_external_tool_call', + ]; + if (toolCode && failClosedCodes.includes(toolCode)) { + try { + if (sessionId) await activeClient.session.delete({ path: { id: sessionId } }); + } catch (_cleanupError) { + void _cleanupError; + } + res.status(502).json({ + error: { + message: toErrorMessage(toolError), + type: 'server_error', + code: toolCode, + }, + }); + return; + } + throw toolError; + } + const joinedSafe = stripExternalToolCallMarkupFromJoinedText( + externalToolRegistry, + reasoning, + content, + true, + ); + const safeContent = joinedSafe.content; + const safeReasoning = joinedSafe.reasoning; const promptTokens = Math.ceil((fullPromptText || '').length / 4); const completionTokensCalc = Math.ceil((content || '').length / 4); @@ -921,19 +1168,31 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void { const transformed = transformUpstreamError(error); res.status(transformed.statusCode).json(transformed.error); } else if (!res.destroyed) { - res.write(`data: ${JSON.stringify({ error: { message: toErrorMessage(error) } })}\n\n`); + const transformed = transformUpstreamError(error); + res.write(`data: ${JSON.stringify({ error: transformed.error })}\n\n`); res.end(); } - if (sessionId) { - try { - await activeClient.session.delete({ path: { id: sessionId } }); - } catch (e: unknown) { + if (sessionId) { + try { + await activeClient.session.delete({ path: { id: sessionId } }); + if (clientDisconnected) disconnectedSessionCleaned = true; + } catch (e: unknown) { + console.error('[Proxy] Failed to cleanup session on error:', toErrorMessage(e)); } } - } finally { - if (keepaliveInterval) clearInterval(keepaliveInterval); - const es = eventStream as unknown as { close?: unknown } | null; + } finally { + removeClientCloseListener(); + if (clientDisconnected && sessionId && !disconnectedSessionCleaned) { + try { + await activeClient.session.delete({ path: { id: sessionId } }); + } catch (error: unknown) { + logDebug('Failed to cleanup disconnected chat session', { sessionId, error: toErrorMessage(error) }); + } + } + if (keepaliveInterval) clearInterval(keepaliveInterval); + const es = eventStream as unknown as { close?: unknown } | null; + if (es && typeof es['close'] === 'function') { (es['close'] as () => void)(); } diff --git a/src/routes/interactions.ts b/src/routes/interactions.ts index d8b176b..49c52b4 100644 --- a/src/routes/interactions.ts +++ b/src/routes/interactions.ts @@ -80,11 +80,37 @@ function normalizeSystemInstruction(value: unknown): string { return textOfContent(rec['parts'] ?? rec['content'] ?? rec['text']); } +function createAbortError(): Error { + const error = new Error('Request aborted'); + error.name = 'AbortError'; + return error; +} + +async function awaitWithAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + if (signal.aborted) throw createAbortError(); + let removeAbortListener = (): void => {}; + const abortPromise = new Promise((_, reject) => { + const onAbort = (): void => reject(createAbortError()); + removeAbortListener = (): void => signal.removeEventListener('abort', onAbort); + signal.addEventListener('abort', onAbort, { once: true }); + }); + try { + return await Promise.race([promise, abortPromise]); + } catch (error: unknown) { + if (signal.aborted) throw createAbortError(); + throw error; + } finally { + removeAbortListener(); + } +} + export function registerInteractionsRoutes(app: Application, ctx: AppContext): void { const { client, config, REQUEST_TIMEOUT_MS, + DISABLE_TOOLS, maxAttempts, resolveRequestedModel, logDebug, @@ -105,8 +131,12 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v proxyPollForAssistantResponse, } = ctx; - const handle = async (req: Request, res: Response): Promise => { - // P3 bundle (direct unless the pool is already engaged). + const handle = async (req: Request, res: Response): Promise => { + const requestAbortController = new AbortController(); + let clientGone = false; + let ownedSessionId: string | null = null; + // P3 bundle (direct unless the pool is already engaged). + let activeClient = client; let activePromptWithTimeout = promptWithTimeout; let activePollForAssistantResponse = pollForAssistantResponse; @@ -132,8 +162,15 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v 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 stream = body['stream'] === true; + const store = body['store'] !== false; + const onClientClose = (): void => { + if (res.writableEnded) return; + clientGone = true; + requestAbortController.abort(); + }; + res.once('close', onClientClose); + const previousInteractionId: unknown = body['previous_interaction_id']; if (!model && !agent) { @@ -235,6 +272,17 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v toolOverrides = merged; } } + // Fail-closed by design (see chat.ts): null or verified-empty both 503. + if (DISABLE_TOOLS && (!toolOverrides || Object.keys(toolOverrides).length === 0)) { + res.status(503).json({ + error: { + message: 'Tool discovery unavailable; backend tool IDs could not be verified', + type: 'tool_discovery_unavailable', + code: 'tool_discovery_unavailable', + }, + }); + return; + } let sessionId: string | null = previousState?.sessionId || null; // Only a session created by this request may be deleted by store:false; @@ -243,8 +291,10 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v 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; + if (!sessionId) throw new Error('Failed to create OpenCode session'); + sessionNewlyCreated = true; + ownedSessionId = sessionId; + } const parts: Record[] = []; @@ -260,9 +310,8 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v fullPromptText += `${text}\n\n`; } const systemWithGuard = buildSystemPrompt( - [systemText, ...systemTexts, hostedSearch.requested ? SEARCH_GROUNDING_INSTRUCTION : ''] - .filter(Boolean) - .join('\n\n'), + [systemText, ...systemTexts].filter(Boolean).join('\n\n'), + hostedSearch.requested ? SEARCH_GROUNDING_INSTRUCTION : '', null, toolMode, internalToolContext.allowedToolNames, @@ -284,6 +333,7 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v // 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 parentSessionId = previousState?.sessionId ?? null; const promptAndPoll = async (): Promise<{ content: string; reasoning: string; @@ -294,20 +344,35 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v 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 currentId = sessionId as string; + const shouldDelete = sessionNewlyCreated || (parentSessionId ? currentId !== parentSessionId : true); + if (shouldDelete) { + try { + await activeClient.session.delete({ path: { id: currentId } }); + if (ownedSessionId === currentId) ownedSessionId = null; + } 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)); + if (!sessionId) throw new Error('Failed to create OpenCode session for retry'); + sessionNewlyCreated = true; + ownedSessionId = sessionId; + promptParams.path.id = sessionId; + + await awaitWithAbort(sleep(computeRetryDelay(attempt - 1, lastErr)), requestAbortController.signal); + } try { - await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); - const polled = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + await awaitWithAbort(activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS), requestAbortController.signal); + const polled = await activePollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + undefined, + requestAbortController.signal, + ); + if (polled.error && !polled.content && !polled.reasoning) throw normalizeBackendError(polled.error); return polled; } catch (e: unknown) { @@ -320,14 +385,17 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v }; 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) }); + const deleteEphemeralSession = async (force: boolean = false): Promise => { + // Only sessions created by this request may be deleted; a reused + // parent session stays alive for its stored continuation chain. + if ((!force && store) || !sessionNewlyCreated) return; + + try { + await activeClient.session.delete({ path: { id: sessionId as string } }); + ownedSessionId = null; + } catch (e: unknown) { + logDebug('Failed to delete unstored interaction session', { error: toErrorMessage(e) }); + } }; if (stream) { @@ -338,11 +406,8 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v 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(() => { + const heartbeat = setInterval(() => { + if (!res.destroyed && !res.writableEnded) res.write(': heartbeat\n\n'); }, 15000); if (typeof heartbeat.unref === 'function') heartbeat.unref(); @@ -353,10 +418,11 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v try { emit({ type: 'interaction.created', interaction: { id: interactionId, status: 'in_progress', model: `${pID}/${mID}` } }); const polled = await promptAndPoll(); - if (clientGone) { - await deleteEphemeralSession(); - return; - } + if (clientGone) { + await deleteEphemeralSession(true); + return; + } + // Parity with chat/responses/messages: strip tool-call markup when // DISABLE_TOOLS so a free-tier strip (no false entries in the tools map, // see selectPromptToolOverrides) cannot leak call syntax to clients. @@ -382,8 +448,13 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v } 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(); + if (store) { + // Text+grounding only: no function tool calls to persist, default [] is intentional. + storeResponseState(interactionId, sessionId as string, `${pID}/${mID}`); + ownedSessionId = null; + } + await deleteEphemeralSession(); + } finally { clearInterval(heartbeat); } @@ -410,8 +481,13 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v } steps.push({ type: 'model_output', text, annotations }); const groundingCount = searchCalls.length; - if (store) storeResponseState(interactionId, sessionId as string, `${pID}/${mID}`); - await deleteEphemeralSession(); + if (store) { + // Text+grounding only: no function tool calls to persist, default [] is intentional. + storeResponseState(interactionId, sessionId as string, `${pID}/${mID}`); + ownedSessionId = null; + } + await deleteEphemeralSession(); + res.json({ id: interactionId, status: 'completed', @@ -421,8 +497,18 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v usage: { grounding_tool_count: [{ type: 'google_search', count: groundingCount }] }, }); return; - } catch (error: unknown) { - if (!fallbackToProxy) engageProxyFallback(error); + } catch (error: unknown) { + if (clientGone && ownedSessionId) { + try { + await activeClient.session.delete({ path: { id: ownedSessionId } }); + } catch (cleanupError: unknown) { + logDebug('Failed to cleanup disconnected interaction session', { error: toErrorMessage(cleanupError) }); + } + ownedSessionId = null; + return; + } + if (!fallbackToProxy) engageProxyFallback(error); + console.error('[Proxy] Interactions API Error:', toErrorMessage(error)); const transformed = transformUpstreamError(error); if (!res.headersSent) { diff --git a/src/routes/messages.ts b/src/routes/messages.ts index 85dddad..267daca 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -2,6 +2,7 @@ import crypto from 'crypto'; import { findExternalToolByName } from '../tool-runtime/registry.js'; import { EXTERNAL_TOOL_PREFIX } from '../tool-runtime/contracts.js'; +import { preflightExternalToolChoice } from '../tool-runtime/router.js'; import { computeRetryDelay } from '../retry/policy.js'; import { validateMessagesRequest, @@ -14,8 +15,10 @@ import { defaultTranslatorRegistry } from '../converters/registry.js'; import { ensureTranslatorsRegistered } from '../converters/wire.js'; import { resolveMessagesReasoningLevel } from '../converters/chat-messages/request.js'; import { - stripFunctionCallMarkup, - parseExternalToolCallsFromText, + assertToolCallArtifactIntegrity, + stripExternalToolCallMarkupFromJoinedText, + parseExternalToolCallsFromJoinedText, + mergeToolCallArtifacts, createToolCallFilter, createExternalToolCallStreamParser, } from '../tool-runtime/parser.js'; @@ -46,11 +49,11 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void buildSystemPrompt, selectPromptToolOverrides, normalizeReasoningEffort, - stripFunctionCalls, normalizeTextContent, normalizeToolArguments, createRequestToolContext, finalizeValidatedToolCalls, + finalizeStreamToolCalls, toPublicToolCalls, createForcedToolCallRequester, trackToolMode, @@ -69,8 +72,11 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void app.post('/v1/messages', async (req: Request, res: Response): Promise => { try { await lock(async (): Promise => { - let sessionId: string | null = null; - let keepaliveInterval: ReturnType | null = null; + let sessionId: string | null = null; + let keepaliveInterval: ReturnType | null = null; + const requestAbortController = new AbortController(); + let disconnectedSessionCleaned = false; + // 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; @@ -134,6 +140,9 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void const thinking: unknown = body['thinking']; const stream = Boolean(requestStream); const requestOpencodeConfig: unknown = body['opencode']; + const disableParallelRaw = body['disable_parallel_tool_use']; + const choiceDisableParallel = asRecord(tool_choice)['disable_parallel_tool_use']; + const parallelToolCalls: boolean = disableParallelRaw === true || choiceDisableParallel === true ? false : true; // Phase 2 wiring: inbound claude.request -> chat.request via the wired // N×N registry (thin wrapper over the same anthropic.ts pure layer, // so shapes match). Direct registry call (not the Safe wrapper): @@ -193,7 +202,18 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void const toolMode: string = requestToolContext.mode; const externalToolContext = requestToolContext.external; const externalToolRegistry = externalToolContext.registry; - const externalToolChoice = externalToolContext.toolChoice; + const toolChoicePreflight = preflightExternalToolChoice(chatToolChoice, externalToolRegistry); + if (!toolChoicePreflight.ok) { + res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: toolChoicePreflight.message, + }, + }); + return; + } + const externalToolChoice = toolChoicePreflight.normalized; const internalToolContext = requestToolContext.internal; trackToolMode(toolMode, { route: '/v1/messages' }); @@ -234,19 +254,17 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void } if (role === 'tool') { const text = normalizeTextContent(content); - if (text) { - const mapped = - findExternalToolByName(externalToolRegistry, mr['name']) || - findExternalToolByName(externalToolRegistry, assistantToolCalls.get(String(mr['tool_call_id'] ?? ''))); - const toolName = - mapped?.namespacedName || - assistantToolCalls.get(String(mr['tool_call_id'] ?? '')) || - (typeof mr['name'] === 'string' ? (mr['name'] as string) : `${EXTERNAL_TOOL_PREFIX}unknown`); - parts.push({ - type: 'text', - text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: mr['tool_call_id'] ?? toolName, name: toolName, content: text })}`, - }); - } + const mapped = + findExternalToolByName(externalToolRegistry, mr['name']) || + findExternalToolByName(externalToolRegistry, assistantToolCalls.get(String(mr['tool_call_id'] ?? ''))); + const toolName = + mapped?.namespacedName || + assistantToolCalls.get(String(mr['tool_call_id'] ?? '')) || + (typeof mr['name'] === 'string' ? (mr['name'] as string) : `${EXTERNAL_TOOL_PREFIX}unknown`); + parts.push({ + type: 'text', + text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: mr['tool_call_id'] ?? toolName, name: toolName, content: text })}`, + }); continue; } if (!content) continue; @@ -279,7 +297,8 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void } const systemWithGuard = buildSystemPrompt( - [systemChunks.join('\n\n'), externalToolContext.prompt].filter(Boolean).join('\n\n'), + systemChunks.join('\n\n'), + externalToolContext.prompt, requestParams['reasoning_effort'], toolMode, internalToolContext.allowedToolNames, @@ -311,6 +330,22 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void REQUEST_TIMEOUT_MS, 'load tool overrides', )) as Record | null; + // Fail-closed by design (see chat.ts): null or verified-empty both 503. + if (DISABLE_TOOLS && (!toolOverrides || Object.keys(toolOverrides).length === 0)) { + try { + await activeClient.session.delete({ path: { id: sessionId as string } }); + } catch (cleanupError: unknown) { + logDebug('Failed to cleanup session after tool discovery unavailable', { error: toErrorMessage(cleanupError) }); + } + res.status(503).json({ + type: 'error', + error: { + type: 'tool_discovery_unavailable', + message: 'Tool discovery unavailable; backend tool IDs could not be verified', + }, + }); + return; + } // Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted). const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID); if (promptToolOverrides) promptParams.body['tools'] = promptToolOverrides; @@ -338,8 +373,14 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void reasoning: unknown, validatedToolCalls: unknown, ): Record => { - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); + const joinedSafe = stripExternalToolCallMarkupFromJoinedText( + externalToolRegistry, + reasoning, + content, + true, + ); + const safeContent = joinedSafe.content; + const safeReasoning = joinedSafe.reasoning; const publicCalls = toPublicToolCalls(validatedToolCalls); const anthropicTools = publicCalls.map((tc) => { const tcr = tc as unknown as Record; @@ -390,7 +431,13 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void } try { await activePromptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); - const collected = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + const collected = await activePollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + undefined, + requestAbortController.signal, + ); + content = collected.content || ''; reasoning = collected.reasoning || ''; error = collected.error || null; @@ -403,7 +450,8 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void if (error && !content && !reasoning && attempt < maxAttempts && isTransientUpstreamError(error)) continue; break; } - if (error && !content && !reasoning) { + // Fail-closed: any backend error fails even with partial content (see chat.ts). + if (error != null) { const t = transformUpstreamError(error); res.status(t.statusCode).json({ type: 'error', @@ -415,41 +463,91 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void }); return; } - let parsed: FinalToolCall[] = - externalToolRegistry.length > 0 ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) : []; - if (parsed.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedMessagesToolCall(); - if (forcedResponse) { - content = String(forcedResponse['content'] ?? content); - reasoning = String(forcedResponse['reasoning'] ?? reasoning); - parsed = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); - } - } - const { validCalls } = finalizeValidatedToolCalls(parsed, externalToolRegistry); - res.json(finalizeAnthropic(content, reasoning, validCalls)); + let parsed: FinalToolCall[] = + externalToolRegistry.length > 0 ? parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoning, content) : []; + let validCalls: FinalToolCall[] = []; + try { + assertToolCallArtifactIntegrity(parsed, externalToolRegistry, [reasoning, content]); + if (parsed.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedMessagesToolCall(); + if (forcedResponse) { + content = String(forcedResponse['content'] ?? content); + reasoning = String(forcedResponse['reasoning'] ?? reasoning); + parsed = parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoning, content); + assertToolCallArtifactIntegrity(parsed, externalToolRegistry, [reasoning, content]); + } + } + validCalls = finalizeStreamToolCalls( + parsed, + externalToolRegistry, + externalToolChoice, + [reasoning, content], + parallelToolCalls, + ) as unknown as FinalToolCall[]; + } catch (toolError) { + const toolCode = (toolError as Error & { code?: string }).code; + const failClosedCodes = [ + 'parallel_external_tool_calls', + 'external_tool_choice_none', + 'external_tool_choice_required', + 'invalid_external_tool_call', + 'external_tool_policy_blocked', + 'external_tool_choice_mismatch', + 'duplicate_external_tool_call_id', + 'malformed_external_tool_call', + ]; + if (toolCode && failClosedCodes.includes(toolCode)) { + try { + if (sessionId) await activeClient.session.delete({ path: { id: sessionId } }); + } catch (_cleanupError) { + void _cleanupError; + } + res.status(502).json({ + type: 'error', + error: { + type: 'api_error', + message: toErrorMessage(toolError), + code: toolCode, + }, + }); + return; + } + throw toolError; + } + res.json(finalizeAnthropic(content, reasoning, validCalls)); return; } - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - keepaliveInterval = setInterval(() => { + 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); + keepaliveInterval = setInterval(() => { if (!res.destroyed && !res.writableEnded) res.write(': keep-alive\n\n'); }, 15000); - const resClosed = new Promise((resolve) => - res.once('close', () => { - if (!res.writableEnded) resolve(true); - }), - ); + const resClosed = new Promise((resolve) => + res.once('close', () => { + if (!res.writableEnded) { + requestAbortController.abort(); + resolve(true); + } + }), + ); + let streamedText = ''; let streamedReasoning = ''; let rawContent = ''; let rawReasoning = ''; - const streamedToolCalls: FinalToolCall[] = []; - const filterContent = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: externalToolRegistry.length > 0 }); - const filterReasoning = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: externalToolRegistry.length > 0 }); - const parseContent = createExternalToolCallStreamParser(externalToolRegistry); - const parseReason = createExternalToolCallStreamParser(externalToolRegistry); + const streamedToolCalls: FinalToolCall[] = []; + // Intentional: buffer while any external tool contract is declared so tool + // markup is never emitted as incremental `content_block_delta` before it + // can be classified. Pure-text turns with tools declared also batch. + const shouldBufferExternalStream = externalToolRegistry.length > 0; + const filterContent = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldBufferExternalStream, registry: externalToolRegistry }); + const filterReasoning = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldBufferExternalStream, registry: externalToolRegistry }); + const parseContent = createExternalToolCallStreamParser(externalToolRegistry); + const parseReason = createExternalToolCallStreamParser(externalToolRegistry); let textBlockOpen = false; let thinkingBlockOpen = false; let nextBlockIndex = 0; @@ -495,26 +593,87 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void else rawContent += delta; const parsedCalls = isReasoning ? parseReason(delta) : parseContent(delta); parsedCalls.forEach((tc) => streamedToolCalls.push(tc)); - const filtered = isReasoning ? filterReasoning(delta) : filterContent(delta); - if (!filtered) return; - if (isReasoning) sendReasoningDelta(filtered); - else sendTextDelta(filtered); + const filtered = isReasoning ? filterReasoning(delta) : filterContent(delta); + if (!filtered) return; + if (shouldBufferExternalStream) { + if (isReasoning) streamedReasoning += filtered; + else streamedText += filtered; + } else if (isReasoning) sendReasoningDelta(filtered); + else sendTextDelta(filtered); }; - let collected: Record | null = null; + let collected: Record | null = null; + let polledSnapshot: { content: string; reasoning: string; error: unknown; toolParts?: unknown } | null = null; + let snapshotPolled = false; + let snapshotPromise: Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown }> | null = null; + const pollSnapshot = async (): Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown }> => { + snapshotPolled = true; + if (!snapshotPromise) { + snapshotPromise = activePollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + undefined, + requestAbortController.signal, + ); + + } + return snapshotPromise; + }; + // Recovery snapshots repeat what the event stream already delivered, so only the + // suffix past the raw prefix may be replayed. A snapshot the raw prefix does not + // match is still sent in full rather than dropped. + const unsentSuffix = (full: unknown, raw: string): string => { + const text = typeof full === 'string' ? full : ''; + if (!text) return ''; + if (!raw) return text; + return text.startsWith(raw) ? text.slice(raw.length) : text; + }; + const hasPartialOutput = (): boolean => { + const current = asRecord(collected); + return Boolean(rawContent || rawReasoning || streamedToolCalls.length > 0 || current['content'] || current['reasoning']); + }; + const recoverSnapshot = async ( + fallbackError: unknown = null, + ): Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown } | null> => { + if (res.destroyed || res.writableEnded) return null; + let snapshot: { content: string; reasoning: string; error: unknown; toolParts?: unknown }; + try { + snapshot = await pollSnapshot(); + } catch (error: unknown) { + if (res.destroyed || res.writableEnded) return null; + if (hasPartialOutput()) { + logDebug('Ignoring messages recovery poll failure after partial stream output', { error: toErrorMessage(error) }); + return null; + } + if (fallbackError != null) throw normalizeBackendError(fallbackError); + throw normalizeBackendError(error); + } + if (snapshot.error != null) throw normalizeBackendError(snapshot.error); + if ( + fallbackError != null && + !snapshot.content && + !snapshot.reasoning && + !(Array.isArray(snapshot.toolParts) && snapshot.toolParts.length > 0) + ) throw normalizeBackendError(fallbackError); + return snapshot; + }; + if (fallbackToProxy) { // SSE subscribe ignores custom fetch (SDK gap): prompt+poll // through the proxy bundle instead of the event stream. try { 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); + const pres = await pollSnapshot(); + polledSnapshot = pres; + if (pres.error != null) 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); + const presContent = unsentSuffix(pres.content, rawContent); + const presReasoning = unsentSuffix(pres.reasoning, rawReasoning); + if (presContent) sendDelta(presContent, false); + if (presReasoning) sendDelta(presReasoning, true); collected = { content: pres.content, reasoning: pres.reasoning }; } catch (e: unknown) { throw normalizeBackendError(e); @@ -524,113 +683,263 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void sessionId as string, REQUEST_TIMEOUT_MS, sendDelta, - DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, - DEFAULT_EVENT_IDLE_TIMEOUT_MS, - ).catch((err: unknown) => ({ __error: err })); + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, + requestAbortController.signal, + ).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) }); + if (raced['__cancelled'] || requestAbortController.signal.aborted) { + if (keepaliveInterval) clearInterval(keepaliveInterval); + try { + if (sessionId) { + await activeClient.session.delete({ path: { id: sessionId } }); + disconnectedSessionCleaned = true; + } + } catch (e: unknown) { + logDebug('Failed to cleanup cancelled messages session', { error: toErrorMessage(e) }); + } + try { + if (!res.destroyed) res.end(); + } catch { + // ignore + } + return; + } + if (raced['error'] != null) throw normalizeBackendError(raced['error']); + const recoveryRecord = asRecord(collected); + const hasValidTerminal = Boolean( + collected && + !recoveryRecord['__error'] && + !recoveryRecord['noData'] && + !recoveryRecord['idleTimeout'] && + !recoveryRecord['cancelled'] && + (rawContent || rawReasoning || streamedToolCalls.length > 0 || recoveryRecord['content'] || recoveryRecord['reasoning']), + ); + if (recoveryRecord['__error']) { + if (!fallbackToProxy) engageProxyFallback(recoveryRecord['__error']); + const snapshot = await recoverSnapshot(recoveryRecord['__error']); + + if (snapshot) { + polledSnapshot = snapshot; + const remainingContent = unsentSuffix(snapshot.content, rawContent); + const remainingReasoning = unsentSuffix(snapshot.reasoning, rawReasoning); + if (remainingContent) sendDelta(remainingContent, false); + if (remainingReasoning) sendDelta(remainingReasoning, true); + } + } else if (recoveryRecord['noData'] || recoveryRecord['idleTimeout']) { + + const snapshot = await recoverSnapshot(); + if (snapshot) { + polledSnapshot = snapshot; + const remainingContent = unsentSuffix(snapshot.content, rawContent); + const remainingReasoning = unsentSuffix(snapshot.reasoning, rawReasoning); + if (remainingContent) sendDelta(remainingContent, false); + if (remainingReasoning) sendDelta(remainingReasoning, true); + } + } + if (externalToolRegistry.length > 0 && !hasValidTerminal && !snapshotPolled) { + const snapshot = await recoverSnapshot(); + if (snapshot) { + polledSnapshot = snapshot; + const remainingReasoning = unsentSuffix(snapshot.reasoning, rawReasoning); + const remainingContent = unsentSuffix(snapshot.content, rawContent); + if (remainingReasoning) sendDelta(remainingReasoning, true); + if (remainingContent) sendDelta(remainingContent, false); + } + } + } + if (requestAbortController.signal.aborted || res.destroyed || res.writableEnded) { + if (sessionId && !disconnectedSessionCleaned) { + try { + await activeClient.session.delete({ path: { id: sessionId } }); + disconnectedSessionCleaned = true; + } catch (e: unknown) { + logDebug('Failed to cleanup disconnected messages session', { error: toErrorMessage(e) }); + } + } + return; + } + + const flushedReasoningCalls = parseReason.flush ? parseReason.flush() : []; + const flushedContentCalls = parseContent.flush ? parseContent.flush() : []; + const flushedReasoningText = filterReasoning.flush ? filterReasoning.flush() : ''; + const flushedContentText = filterContent.flush ? filterContent.flush() : ''; + if (shouldBufferExternalStream) { + const visible = stripExternalToolCallMarkupFromJoinedText( + externalToolRegistry, + rawReasoning, + rawContent, + ); + streamedReasoning = visible.reasoning; + streamedText = visible.content; + } else { + if (flushedReasoningText) sendReasoningDelta(flushedReasoningText); + if (flushedContentText) sendTextDelta(flushedContentText); + } + + const collectedR = asRecord(collected); + if (collectedR['error'] != null) throw normalizeBackendError(collectedR['error']); + const snapshotReasoning = polledSnapshot?.reasoning ?? (typeof collectedR['reasoning'] === 'string' ? collectedR['reasoning'] as string : rawReasoning); + const snapshotContent = polledSnapshot?.content ?? (typeof collectedR['content'] === 'string' ? collectedR['content'] as string : rawContent); + const parseJoined = (reasoningText: string, contentText: string): FinalToolCall[] => + externalToolRegistry.length > 0 + ? parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoningText, contentText) + : []; + const streamSource = [ + [snapshotReasoning, snapshotContent], + [rawReasoning, rawContent] + ]; + let parsedToolCalls: FinalToolCall[] = externalToolRegistry.length > 0 + ? mergeToolCallArtifacts( + streamedToolCalls, + flushedReasoningCalls, + flushedContentCalls, + parseJoined(snapshotReasoning, snapshotContent), + parseJoined(rawReasoning, rawContent), + ) + : []; + let finalValidated: FinalToolCall[]; + try { + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, streamSource); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedMessagesToolCall(); + if (forcedResponse) { + const forcedReasoning = typeof forcedResponse['reasoning'] === 'string' ? forcedResponse['reasoning'] as string : ''; + const forcedContent = typeof forcedResponse['content'] === 'string' ? forcedResponse['content'] as string : ''; + parsedToolCalls = mergeToolCallArtifacts(parsedToolCalls, parseJoined(forcedReasoning, forcedContent)); + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, [forcedReasoning, forcedContent]); + } } - try { - if (!res.destroyed) res.end(); - } catch { - // ignore + finalValidated = finalizeStreamToolCalls( + parsedToolCalls, + externalToolRegistry, + externalToolChoice, + streamSource, + parallelToolCalls, + ) as unknown as FinalToolCall[]; + } catch (toolError) { + const toolCode = (toolError as Error & { code?: string }).code; + const failClosedCodes = [ + 'parallel_external_tool_calls', + 'external_tool_choice_none', + 'external_tool_choice_required', + 'invalid_external_tool_call', + 'external_tool_policy_blocked', + 'external_tool_choice_mismatch', + 'duplicate_external_tool_call_id', + 'malformed_external_tool_call', + ]; + if (toolCode && failClosedCodes.includes(toolCode)) { + try { + if (sessionId) await activeClient.session.delete({ path: { id: sessionId } }); + } catch (_cleanupError) { + void _cleanupError; + } + if (keepaliveInterval) clearInterval(keepaliveInterval); + const toolMessage = toErrorMessage(toolError); + if (!res.headersSent) { + res.status(502).json({ + type: 'error', + error: { type: 'api_error', message: toolMessage, code: toolCode }, + }); + } else if (!res.destroyed) { + try { + res.write( + sseEvent('error', { + type: 'error', + error: { type: 'api_error', message: toolMessage, code: toolCode }, + }), + ); + } catch { + // ignore + } + res.end(); + } + return; } - return; + throw toolError; } - if (raced['error'] && !rawContent && !rawReasoning) throw normalizeBackendError(raced['error']); - if (raced['__error']) { - // 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); - // 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 })); - const flushedReasoningCalls = parseReason.flush ? parseReason.flush() : []; - const flushedContentCalls = parseContent.flush ? parseContent.flush() : []; - const flushedReasoningText = filterReasoning.flush ? filterReasoning.flush() : ''; - const flushedContentText = filterContent.flush ? filterContent.flush() : ''; - const finalReasoningText = rawReasoning + flushedReasoningText; - const finalContentText = rawContent + flushedContentText; - const parseStreamedToolCalls = (): FinalToolCall[] => { - if (externalToolRegistry.length === 0) return []; - const perChannel = [ - ...flushedReasoningCalls, - ...flushedContentCalls, - ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText), - ]; - if (perChannel.length > 0) return perChannel; - return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); - }; - let parsedToolCalls: FinalToolCall[] = - streamedToolCalls.length > 0 - ? [...streamedToolCalls, ...flushedReasoningCalls, ...flushedContentCalls] - : parseStreamedToolCalls(); - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedMessagesToolCall(); - if (forcedResponse) { - parsedToolCalls = parseExternalToolCallsFromText( - externalToolRegistry, - forcedResponse['reasoning'] as string, - forcedResponse['content'] as string, - ); + if (!streamedText.trim() && !streamedReasoning.trim() && finalValidated.length === 0) { + throw new Error('Upstream returned no assistant data'); + } + if (shouldBufferExternalStream) { + if (streamedReasoning) { + const idx = ensureThinkingIndex(); + if (!thinkingBlockOpen) { + res.write(sseEvent('content_block_start', { type: 'content_block_start', index: idx, content_block: { type: 'thinking', thinking: '', signature: '' } })); + thinkingBlockOpen = true; + } + res.write(sseEvent('content_block_delta', { type: 'content_block_delta', index: idx, delta: { type: 'thinking_delta', thinking: streamedReasoning } })); + } + if (streamedText) { + const idx = ensureTextIndex(); + if (!textBlockOpen) { + res.write(sseEvent('content_block_start', { type: 'content_block_start', index: idx, content_block: { type: 'text', text: '' } })); + textBlockOpen = true; + } + res.write(sseEvent('content_block_delta', { type: 'content_block_delta', index: idx, delta: { type: 'text_delta', text: streamedText } })); + } + } + 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 })); + let toolBlockIndex = nextBlockIndex; + for (const tc of toPublicToolCalls(finalValidated)) { + const tcr = tc as unknown as Record; + const fn = asRecord(tcr['function']); + res.write( + sseEvent('content_block_start', { + type: 'content_block_start', + index: toolBlockIndex, + content_block: { type: 'tool_use', id: tcr['id'], name: fn['name'], input: {} }, + }), + ); + res.write( + sseEvent('content_block_delta', { + type: 'content_block_delta', + index: toolBlockIndex, + delta: { type: 'input_json_delta', partial_json: (fn['arguments'] as string) || '{}' }, + }), + ); + res.write(sseEvent('content_block_stop', { type: 'content_block_stop', index: toolBlockIndex })); + toolBlockIndex += 1; + } + const outputTokens = estimateTokens(streamedText + streamedReasoning); + const stopReason = mapFinishToStopReason('stop', finalValidated.length > 0); + res.write( + sseEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: { output_tokens: outputTokens }, + }), + ); + res.write(sseEvent('message_stop', { type: 'message_stop' })); + if (requestAbortController.signal.aborted || res.destroyed) { + if (sessionId && !disconnectedSessionCleaned) { + try { + await activeClient.session.delete({ path: { id: sessionId } }); + disconnectedSessionCleaned = true; + } catch (e: unknown) { + logDebug('Failed to cleanup disconnected messages session', { error: toErrorMessage(e) }); + } + } + return; } - } - const { validCalls: finalValidated } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - let toolBlockIndex = nextBlockIndex; - for (const tc of toPublicToolCalls(finalValidated)) { - const tcr = tc as unknown as Record; - const fn = asRecord(tcr['function']); - res.write( - sseEvent('content_block_start', { - type: 'content_block_start', - index: toolBlockIndex, - content_block: { type: 'tool_use', id: tcr['id'], name: fn['name'], input: {} }, - }), - ); - res.write( - sseEvent('content_block_delta', { - type: 'content_block_delta', - index: toolBlockIndex, - delta: { type: 'input_json_delta', partial_json: (fn['arguments'] as string) || '{}' }, - }), - ); - res.write(sseEvent('content_block_stop', { type: 'content_block_stop', index: toolBlockIndex })); - toolBlockIndex += 1; - } - const outputTokens = estimateTokens(streamedText + streamedReasoning); - const stopReason = mapFinishToStopReason('stop', finalValidated.length > 0); - res.write( - sseEvent('message_delta', { - type: 'message_delta', - delta: { stop_reason: stopReason, stop_sequence: null }, - usage: { output_tokens: outputTokens }, - }), - ); - res.write(sseEvent('message_stop', { type: 'message_stop' })); - if (keepaliveInterval) clearInterval(keepaliveInterval); - res.end(); - return; + if (keepaliveInterval) clearInterval(keepaliveInterval); + + res.end(); + return; } catch (error: unknown) { if (keepaliveInterval) clearInterval(keepaliveInterval); if (!fallbackToProxy) engageProxyFallback(error); if (sessionId) { - try { - await activeClient.session.delete({ path: { id: sessionId } }); - } catch (e: unknown) { + try { + await activeClient.session.delete({ path: { id: sessionId } }); + if (requestAbortController.signal.aborted) disconnectedSessionCleaned = true; + } 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 8e6df24..3282e22 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -1,15 +1,26 @@ // P4 TS: POST /v1/responses (ported from P3 .js, behavior identical). import crypto from 'crypto'; -import { findExternalToolByName } from '../tool-runtime/registry.js'; +import { + ADDITIONAL_TOOLS_ITEM_TYPE, + collectAdditionalToolSources, + expandToolDeclarationSources, + findExternalToolByExactName, + resolveExternalToolName, +} from '../tool-runtime/registry.js'; +import { qualifyToolName } from '../converters/chat-responses/request.js'; import { EXTERNAL_TOOL_PREFIX } from '../tool-runtime/contracts.js'; +import { preflightExternalToolChoice } from '../tool-runtime/router.js'; import { computeRetryDelay } from '../retry/policy.js'; import { - stripFunctionCallMarkup, - parseExternalToolCallsFromText, + assertToolCallArtifactIntegrity, + createDuplicateToolCallIdError, + stripExternalToolCallMarkupFromJoinedText, + parseExternalToolCallsFromJoinedText, + mergeToolCallArtifacts, createToolCallFilter, createExternalToolCallStreamParser, } from '../tool-runtime/parser.js'; -import { isTransientUpstreamError, normalizeBackendError, transformUpstreamError } from '../errors/upstream.js'; +import { isTransientUpstreamError, normalizeBackendError, transformUpstreamError, createInvalidRequestError, createToolDiscoveryUnavailableError } from '../errors/upstream.js'; import { engageFallbackForFreeLimit } from '../upstream-proxy/fallback.js'; import { buildCitationAnnotations, @@ -17,7 +28,6 @@ import { detectHostedSearchTools, extractSearchEvidence, SEARCH_GROUNDING_INSTRUCTION, - stripHostedSearchTools, } from '../search/grounding.js'; import { withTimeout, @@ -28,6 +38,7 @@ import { sleep, ensureBackend } from '../backend/manager.js'; import type { Application, Request, Response } from 'express'; import type { AppContext } from '../types/context.js'; import type { ExternalToolEntry } from '../tool-runtime/registry.js'; +import type { ResponseToolCallState } from '../types/backend.js'; import type { FinalToolCall } from '../tool-runtime/parser.js'; import { asRecord, toErrorMessage } from '../utils/guards.js'; @@ -47,6 +58,127 @@ interface NormalizedInputMessage { isToolCalls?: boolean; } +interface ResponsesOutputEntry { + index: number; + id: string; + type: string; + addedItem: Record; + doneItem: Record | null; + emittedDone: boolean; +} + +class ResponsesOutputAssembler { + private readonly entries: ResponsesOutputEntry[] = []; + private readonly byKey = new Map(); + + announce(item: Record): ResponsesOutputEntry { + const id = String(item['id'] ?? ''); + const type = String(item['type'] ?? ''); + const key = `${type}:${id}`; + const existing = this.byKey.get(key); + if (existing) return existing; + const entry: ResponsesOutputEntry = { + index: this.entries.length, + id, + type, + addedItem: item, + doneItem: null, + emittedDone: false, + }; + this.entries.push(entry); + this.byKey.set(key, entry); + return entry; + } + + get(type: string, id: string): ResponsesOutputEntry | null { + return this.byKey.get(`${type}:${id}`) ?? null; + } + + has(type: string, id: string): boolean { + return this.byKey.has(`${type}:${id}`); + } + + complete(type: string, id: string, item: Record): ResponsesOutputEntry | null { + const entry = this.get(type, id); + if (!entry) return null; + if (!entry.doneItem) entry.doneItem = item; + return entry; + } + + markDone(entry: ResponsesOutputEntry): void { + entry.emittedDone = true; + } + + list(): readonly ResponsesOutputEntry[] { + return this.entries; + } + + output(): Record[] { + return this.entries + .filter((entry) => entry.emittedDone && entry.doneItem !== null) + .map((entry) => entry.doneItem as Record); + } +} + +interface ResponsesStreamState { + id: string; + createdAt: number; + model: string; + tools: unknown[]; + parallelToolCalls: boolean; + sequenceNumber: number; + output: ResponsesOutputAssembler; + nextSequence: () => number; + emit: (payload: unknown) => void; + emitCreated: () => void; + adoptResolvedModel: (resolvedModel: string) => void; + finalize: (() => void) | null; + createdEmitted: boolean; +} + +interface ResponsesResponseIdentity { + id: string; + createdAt: number; + model: string; + tools: unknown[]; + parallelToolCalls: boolean; +} + +function buildResponsesResponseEnvelope(identity: ResponsesResponseIdentity): Record { + return { + id: identity.id, + object: 'response', + created: identity.createdAt, + created_at: identity.createdAt, + model: identity.model, + tools: identity.tools, + parallel_tool_calls: identity.parallelToolCalls, + }; +} + +const RESPONSES_OUTPUT_TYPE_ORDER = ['web_search_call', 'reasoning', 'message', 'function_call', 'custom_tool_call']; + +/** + * Map a Responses input role onto the OpenCode channel it may use. `developer` + * has no system channel here: it rides the user channel and keeps its own + * `DEVELOPER:` label, so a developer turn is never mistaken for the user's. + */ +function responsesInputRole(role: unknown): string { + if (typeof role !== 'string' || !role) return 'user'; + return role; +} + +function orderResponsesOutputItems(items: Record[]): Record[] { + const rank = (item: Record): number => { + const index = RESPONSES_OUTPUT_TYPE_ORDER.indexOf(String(item['type'])); + return index === -1 ? RESPONSES_OUTPUT_TYPE_ORDER.length : index; + }; + return items + .map((item, index) => ({ item, index })) + .sort((left, right) => rank(left.item) - rank(right.item) || left.index - right.index) + .map((entry) => entry.item); +} + export function registerResponsesRoutes(app: Application, ctx: AppContext): void { const { client, @@ -61,12 +193,12 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void buildSystemPrompt, selectPromptToolOverrides, normalizeReasoningEffort, - stripFunctionCalls, normalizeTextContent, normalizeToolArguments, normalizeToolResultContent, createRequestToolContext, finalizeValidatedToolCalls, + finalizeStreamToolCalls, createForcedToolCallRequester, trackToolMode, getToolOverridesForMode, @@ -81,8 +213,10 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } = ctx; app.post('/v1/responses', async (req: Request, res: Response): Promise => { - let responsesKeepalive: ReturnType | null = null; - let responsesResClosed: Promise | null = null; + let responsesKeepalive: ReturnType | null = null; + let responsesResClosed: Promise | null = null; + const responsesAbortController = new AbortController(); + const stopResponsesKeepalive = (): void => { if (responsesKeepalive) { clearInterval(responsesKeepalive); @@ -96,6 +230,18 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void let activePromptWithTimeout = promptWithTimeout; let activePollForAssistantResponse = pollForAssistantResponse; let fallbackToProxy = false; + let ownedResponsesSessionId: string | null = null; + let responsesStreamState: ResponsesStreamState | null = null; + const cleanupOwnedResponsesSession = async (): Promise => { + const ownedSessionId = ownedResponsesSessionId; + ownedResponsesSessionId = null; + if (!ownedSessionId) return; + try { + await activeClient.session.delete({ path: { id: ownedSessionId } }); + } catch (error: unknown) { + logDebug('Failed to cleanup owned responses session', { sessionId: ownedSessionId, error: toErrorMessage(error) }); + } + }; const switchToProxyBundle = (): boolean => { if (!proxyClient) return false; activeClient = proxyClient; @@ -108,6 +254,78 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void if (!engageFallbackForFreeLimit(err, proxyPool)) return false; return switchToProxyBundle(); }; + const initializeResponsesStream = ( + requestedModel: unknown, + previousModel: unknown, + requestTools: unknown[], + parallelToolCalls: boolean, + ): ResponsesStreamState => { + const initialModel = + typeof requestedModel === 'string' && requestedModel + ? requestedModel + : typeof previousModel === 'string' && previousModel + ? previousModel + : 'unknown'; + const state: ResponsesStreamState = { + id: `resp_${crypto.randomUUID()}`, + createdAt: Math.floor(Date.now() / 1000), + model: initialModel, + tools: requestTools, + parallelToolCalls, + sequenceNumber: 0, + output: new ResponsesOutputAssembler(), + nextSequence: () => 0, + emit: () => undefined, + emitCreated: () => undefined, + adoptResolvedModel: () => undefined, + finalize: null, + createdEmitted: false, + }; + const emit = (payload: unknown): void => { + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + state.nextSequence = () => state.sequenceNumber++; + state.emit = emit; + state.emitCreated = () => { + if (state.createdEmitted) return; + emit({ + type: 'response.created', + sequence_number: state.nextSequence(), + response: { + ...buildResponsesResponseEnvelope(state), + status: 'in_progress', + output: [], + error: null, + incomplete_details: null, + usage: null, + }, + }); + state.createdEmitted = true; + }; + state.adoptResolvedModel = (resolvedModel: string) => { + state.model = resolvedModel; + state.emitCreated(); + }; + responsesStreamState = state; + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + const flushHeaders = (res as unknown as { flushHeaders?: unknown }).flushHeaders; + if (typeof flushHeaders === 'function') (flushHeaders as () => void).call(res); + responsesKeepalive = setInterval(() => { + if (!res.destroyed && !res.writableEnded) res.write(': heartbeat\n\n'); + }, 15000); + responsesResClosed = new Promise((resolve) => + res.once('close', () => { + if (!res.writableEnded) { + responsesAbortController.abort(); + resolve(true); + } + }), + ); + return state; + }; + const getResponsesResClosed = (): Promise | null => responsesResClosed; if (proxyPool.isEngaged()) switchToProxyBundle(); try { const body = asRecord((req as unknown as { body: unknown }).body); @@ -118,12 +336,18 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void const max_output_tokens: unknown = body['max_output_tokens']; const toolsRaw: unknown = body['tools']; const tools: unknown[] = Array.isArray(toolsRaw) ? (toolsRaw as unknown[]) : []; + const parallelToolCalls: boolean = + typeof body['parallel_tool_calls'] === 'boolean' ? body['parallel_tool_calls'] : true; const tool_choice: unknown = body['tool_choice']; const instructions: unknown = body['instructions']; const temperature: unknown = body['temperature']; const top_p: unknown = body['top_p']; const streamRaw: unknown = body['stream']; const stream = Boolean(streamRaw); + const rejectInvalidRequest = (message: string, code: string): void => { + if (stream) throw createInvalidRequestError(message, code); + res.status(400).json({ error: { message, type: 'invalid_request_error', code } }); + }; const chatMessages: unknown = body['messages']; const prompt: unknown = body['prompt']; const previousResponseId: unknown = body['previous_response_id']; @@ -131,8 +355,9 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void const previousState = typeof previousResponseId === 'string' && previousResponseId ? getResponseState(previousResponseId) : null; + if (stream) responsesStreamState = initializeResponsesStream(model, previousState?.model, tools, parallelToolCalls); if (previousResponseId && !previousState) { - res.status(400).json({ error: { message: 'Invalid or expired previous_response_id' } }); + rejectInvalidRequest('Invalid or expired previous_response_id', 'invalid_previous_response_id'); return; } @@ -144,9 +369,28 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void // 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); + const fromChatMessages = Array.isArray(chatMessages) && (chatMessages as unknown[]).length; + const rawResponsesInput: unknown = fromChatMessages ? chatMessages : input; + const additionalToolSources = collectAdditionalToolSources( + rawResponsesInput, + fromChatMessages ? 'messages' : 'input', + ); + const additionalToolDeclarations = additionalToolSources.flatMap((source) => + Array.isArray(source.tools) ? (source.tools as unknown[]) : [], + ); + const hostedSearch = detectHostedSearchTools([...tools, ...additionalToolDeclarations]); + const declarationExpansion = expandToolDeclarationSources([{ path: 'tools', tools }, ...additionalToolSources]); + const customToolNames = new Set(declarationExpansion.customToolNames); + if (declarationExpansion.issues.length) { + const issues = declarationExpansion.issues; + const message = issues + .slice(0, 5) + .map((issue) => issue.message) + .join(' '); + rejectInvalidRequest(message, issues[0].code); + return; + } + const requestToolContext = createRequestToolContext(declarationExpansion.tools, tool_choice, requestOpencodeConfig); let toolMode: string = requestToolContext.mode; let internalToolContext = requestToolContext.internal; if (hostedSearch.requested && toolMode === TOOL_MODE.DISABLED) { @@ -201,8 +445,16 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void }); const externalToolContext = requestToolContext.external; const externalToolRegistry: ExternalToolEntry[] = externalToolContext.registry; - const externalToolChoice = externalToolContext.toolChoice; + const toolChoicePreflight = preflightExternalToolChoice(tool_choice, externalToolRegistry); + if (!toolChoicePreflight.ok) { + rejectInvalidRequest(toolChoicePreflight.message, toolChoicePreflight.code); + return; + } + const externalToolChoice = toolChoicePreflight.normalized; const assistantToolCalls = new Map(); + for (const entry of previousState?.toolCalls ?? []) { + if (entry.name) assistantToolCalls.set(entry.callId, entry.name); + } const rememberAssistantToolCall = (toolCallId: unknown, toolName: unknown): void => { if (!toolCallId || !toolName || typeof toolCallId !== 'string' || typeof toolName !== 'string') return; @@ -214,15 +466,23 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void const text = normalizeToolResultContent( ir['content'] ?? ir['output'] ?? ir['result'] ?? ir['text'], ); - if (!text) return null; const callIdRaw: unknown = ir['call_id'] ?? ir['tool_call_id']; + const remembered = assistantToolCalls.get(String(callIdRaw ?? '')); + const declaredName = typeof ir['name'] === 'string' ? (ir['name'] as string).trim() : ''; + if (typeof callIdRaw === 'string' && callIdRaw && !remembered && !declaredName) { + throw createInvalidRequestError( + `Unknown tool call id "${callIdRaw}". Provide the matching function_call item or a tool name.`, + 'unknown_tool_call_id', + ); + } const mappedTool = - findExternalToolByName(externalToolRegistry, ir['name']) || - findExternalToolByName(externalToolRegistry, assistantToolCalls.get(String(callIdRaw ?? ''))); + (declaredName ? resolveExternalToolName(externalToolRegistry, declaredName, ir['namespace']) : null) || + (remembered ? resolveExternalToolName(externalToolRegistry, remembered) : null); const toolName = mappedTool?.namespacedName || - assistantToolCalls.get(String(callIdRaw ?? '')) || - (typeof ir['name'] === 'string' ? (ir['name'] as string) : `${EXTERNAL_TOOL_PREFIX}unknown`); + remembered || + declaredName || + `${EXTERNAL_TOOL_PREFIX}unknown`; const toolCallId = typeof ir['call_id'] === 'string' ? (ir['call_id'] as string) @@ -238,7 +498,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void const toolCallsRaw: unknown = ir['tool_calls']; let sourceCalls: unknown[]; if (Array.isArray(toolCallsRaw)) sourceCalls = toolCallsRaw as unknown[]; - else if (ir['type'] === 'function_call') sourceCalls = [item]; + else if (ir['type'] === 'function_call' || ir['type'] === 'custom_tool_call') sourceCalls = [item]; else sourceCalls = []; if (!sourceCalls.length) return null; const serializedToolCalls = sourceCalls @@ -246,9 +506,11 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void const tcr = asRecord(toolCall); const fn = asRecord(tcr['function']); const rawName: unknown = fn['name'] ?? tcr['name']; - const mappedTool = findExternalToolByName(externalToolRegistry, rawName); + const namespace: unknown = tcr['namespace'] ?? ir['namespace']; + const mappedTool = resolveExternalToolName(externalToolRegistry, rawName, namespace); const namespacedName = - mappedTool?.namespacedName ?? (typeof rawName === 'string' ? rawName : null); + mappedTool?.namespacedName ?? + (typeof rawName === 'string' ? qualifyToolName(namespace, rawName) : ''); if (!namespacedName) return null; const toolCallId = typeof tcr['call_id'] === 'string' @@ -256,30 +518,60 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void : typeof tcr['id'] === 'string' ? (tcr['id'] as string) : `call_${index + 1}`; + const argsJson = + tcr['type'] === 'custom_tool_call' + ? JSON.stringify({ input: typeof tcr['input'] === 'string' ? (tcr['input'] as string) : '' }) + : normalizeToolArguments(fn['arguments'] ?? tcr['arguments']); rememberAssistantToolCall(toolCallId, namespacedName); - return { - id: toolCallId, - name: namespacedName, - arguments: normalizeToolArguments(fn['arguments'] ?? tcr['arguments']), - }; + return { id: toolCallId, name: namespacedName, arguments: argsJson }; }) .filter((v): v is { id: string; name: string; arguments: string } => v !== null); if (!serializedToolCalls.length) return null; return `ASSISTANT: ${JSON.stringify(serializedToolCalls)}`; }; + const buildResponsesToolCallState = (toolCalls: unknown): ResponseToolCallState[] => { + const calls: ResponseToolCallState[] = []; + if (!Array.isArray(toolCalls)) return calls; + for (const toolCall of toolCalls as unknown[]) { + const record = asRecord(toolCall); + const fn = asRecord(record['function']); + const clientName = typeof fn['name'] === 'string' ? (fn['name'] as string) : ''; + const callId = typeof record['id'] === 'string' ? (record['id'] as string) : ''; + if (!callId || !clientName) continue; + calls.push({ + callId, + name: findExternalToolByExactName(externalToolRegistry, clientName)?.namespacedName ?? clientName, + }); + } + return calls; + }; + + const buildStoredResponseToolCalls = (toolCalls: unknown): ResponseToolCallState[] => { + const merged = new Map(); + for (const entry of previousState?.toolCalls ?? []) merged.set(entry.callId, entry); + for (const entry of buildResponsesToolCallState(toolCalls)) merged.set(entry.callId, entry); + return [...merged.values()]; + }; + const buildResponsesInputMessages = (rawItems: unknown): NormalizedInputMessage[] => { const normalized: NormalizedInputMessage[] = []; if (!Array.isArray(rawItems)) return normalized; for (const item of rawItems as unknown[]) { if (!item) continue; const ir = asRecord(item); - if (ir['type'] === 'function_call_output' || ir['type'] === 'tool_result' || ir['role'] === 'tool') { + if (ir['type'] === ADDITIONAL_TOOLS_ITEM_TYPE) continue; + if ( + ir['type'] === 'function_call_output' || + ir['type'] === 'custom_tool_call_output' || + ir['type'] === 'tool_result' || + ir['role'] === 'tool' + ) { const toolResultLine = buildResponsesToolResultLine(item); if (toolResultLine) normalized.push({ role: 'tool', content: toolResultLine }); continue; } - if (ir['type'] === 'function_call') { + if (ir['type'] === 'function_call' || ir['type'] === 'custom_tool_call') { const line = buildResponsesAssistantToolCallsLine(item); if (line) normalized.push({ role: 'assistant', content: line, isToolCalls: true }); continue; @@ -290,9 +582,8 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void if (line) normalized.push({ role: 'assistant', content: line, isToolCalls: true }); } if (ir['type'] === 'message') { - const role = typeof ir['role'] === 'string' ? (ir['role'] as string) : 'user'; const content = normalizeTextContent(ir['content']); - if (content) normalized.push({ role, content }); + if (content) normalized.push({ role: responsesInputRole(ir['role']), content }); continue; } if (ir['type'] === 'input_text') { @@ -300,53 +591,46 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void continue; } const text = normalizeTextContent(ir['content'] ?? ir['text']); - if (text) normalized.push({ role: typeof ir['role'] === 'string' ? (ir['role'] as string) : 'user', content: text }); + if (text) normalized.push({ role: responsesInputRole(ir['role']), content: text }); } return normalized; }; let messages: NormalizedInputMessage[] = []; - if (Array.isArray(chatMessages) && (chatMessages as unknown[]).length) { - messages = buildResponsesInputMessages(chatMessages); - } else if (typeof prompt === 'string' && prompt.trim()) { - messages = [{ role: 'user', content: prompt }]; - } else if (typeof input === 'string') { - messages = [{ role: 'user', content: input }]; - } else if (Array.isArray(input)) { - messages = buildResponsesInputMessages(input); - } else if (input && typeof input === 'object') { - const ir = asRecord(input); - if (ir['type'] === 'message' || ir['type'] === 'function_call' || ir['type'] === 'function_call_output' || ir['type'] === 'tool_result') { - messages = buildResponsesInputMessages([input]); - } else { - const content = normalizeTextContent(ir['content'] ?? ir['text']); - if (content) { - messages = [{ role: typeof ir['role'] === 'string' ? (ir['role'] as string) : 'user', content }]; + try { + if (Array.isArray(chatMessages) && (chatMessages as unknown[]).length) { + messages = buildResponsesInputMessages(chatMessages); + } else if (typeof prompt === 'string' && prompt.trim()) { + messages = [{ role: 'user', content: prompt }]; + } else if (typeof input === 'string') { + messages = [{ role: 'user', content: input }]; + } else if (Array.isArray(input)) { + messages = buildResponsesInputMessages(input); + } else if (input && typeof input === 'object') { + const ir = asRecord(input); + if (['message', 'function_call', 'function_call_output', 'custom_tool_call', 'custom_tool_call_output', 'tool_result'].includes(String(ir['type']))) { + messages = buildResponsesInputMessages([input]); + } else { + const content = normalizeTextContent(ir['content'] ?? ir['text']); + if (content) { + messages = [{ role: responsesInputRole(ir['role']), content }]; + } } } + } catch (inputError) { + const code = (inputError as Error & { code?: string }).code; + if (typeof code === 'string' && code) { + rejectInvalidRequest(toErrorMessage(inputError), code); + return; + } + throw inputError; } if (!messages.length) { - res.status(400).json({ error: { message: 'input is required' } }); + rejectInvalidRequest('input is required', 'input_required'); return; } - if (stream) { - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - const flushHeaders = (res as unknown as { flushHeaders?: unknown }).flushHeaders; - if (typeof flushHeaders === 'function') (flushHeaders as () => void).call(res); - responsesKeepalive = setInterval(() => { - if (!res.destroyed && !res.writableEnded) res.write(': heartbeat\n\n'); - }, 15000); - responsesResClosed = new Promise((resolve) => - res.once('close', () => { - if (!res.writableEnded) resolve(true); - }), - ); - } - const resolvedModel = (await withTimeout( resolveRequestedModel(model ?? previousState?.model), REQUEST_TIMEOUT_MS, @@ -354,6 +638,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void )) as unknown as { providerID: string; modelID: string }; const pID = String(asRecord(resolvedModel)['providerID']); const mID = String(asRecord(resolvedModel)['modelID']); + if (responsesStreamState) responsesStreamState.adoptResolvedModel(`${pID}/${mID}`); await ensureBackend(config); @@ -372,6 +657,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void if (!sessionId) { throw new Error('Failed to create OpenCode session'); } + ownedResponsesSessionId = sessionId; } const parts: Record[] = []; @@ -396,14 +682,8 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } const systemWithGuard = buildSystemPrompt( - [ - instructions, - ...systemChunks, - externalToolContext.prompt, - hostedSearch.requested ? SEARCH_GROUNDING_INSTRUCTION : '', - ] - .filter(Boolean) - .join('\n\n'), + [instructions, ...systemChunks].filter(Boolean).join('\n\n'), + [externalToolContext.prompt, hostedSearch.requested ? SEARCH_GROUNDING_INSTRUCTION : ''].filter(Boolean).join('\n\n'), reasoningLevel, toolMode, internalToolContext.allowedToolNames, @@ -434,8 +714,36 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void if (granted === true) merged[id] = true; } toolOverrides = merged; + } else if (DISABLE_TOOLS) { + await cleanupOwnedResponsesSession(); + if (stream) { + throw createToolDiscoveryUnavailableError(); + } + res.status(503).json({ + error: { + message: 'Tool discovery unavailable; backend tool IDs could not be verified', + type: 'tool_discovery_unavailable', + code: 'tool_discovery_unavailable', + }, + }); + return; } } + // Fail-closed by design (see chat.ts): null or verified-empty both 503. + if (DISABLE_TOOLS && (!toolOverrides || Object.keys(toolOverrides).length === 0)) { + await cleanupOwnedResponsesSession(); + if (stream) { + throw createToolDiscoveryUnavailableError(); + } + res.status(503).json({ + error: { + message: 'Tool discovery unavailable; backend tool IDs could not be verified', + type: 'tool_discovery_unavailable', + code: 'tool_discovery_unavailable', + }, + }); + return; + } const makeForcedResponsesToolCallRequester = (): (() => Promise | null>) => createForcedToolCallRequester({ mode: externalToolChoice.mode, @@ -469,15 +777,6 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void let content = ''; let reasoning = ''; - const buildResponsesFunctionCallOutputItem = (toolCall: FinalToolCall): Record => ({ - id: toolCall.id, - type: 'function_call', - status: 'completed', - call_id: toolCall.id, - name: (toolCall.function as Record)['name'], - arguments: (toolCall.function as Record)['arguments'], - }); - const buildResponsesMessageOutputItem = ( text: unknown, messageId: string = `msg_${crypto.randomUUID()}`, @@ -501,6 +800,42 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void }; }; + /** + * Freeform payload of a custom tool call. A missing or non-string `input` + * is an argument error, not an empty string: the declared schema makes + * `input` required, and emitting `""` would hand the client a custom call + * the model never made. + */ + const customToolInputOf = (args: unknown, callName: string): string => { + if (typeof args === 'string') { + try { + return customToolInputOf(JSON.parse(args), callName); + } catch { + return args; + } + } + const value = args && typeof args === 'object' && !Array.isArray(args) ? asRecord(args)['input'] : undefined; + if (typeof value === 'string') return value; + throw createInvalidRequestError( + `Custom tool call ${callName} requires a string "input" argument.`, + 'invalid_custom_tool_input', + ); + }; + + const buildResponsesCustomToolCallItem = ( + callId: string, + name: string, + args: unknown, + status: string, + ): Record => ({ + id: callId, + type: 'custom_tool_call', + status, + call_id: callId, + name, + input: status === 'completed' ? customToolInputOf(args, name) : '', + }); + if (stream) { if (res.destroyed || res.writableEnded) { stopResponsesKeepalive(); @@ -511,147 +846,287 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } return; } - const responseId = `resp_${crypto.randomUUID()}`; - const messageOutputIndex = 0; - const reasoningOutputIndex = 1; + const responseState = responsesStreamState; + if (!responseState) throw new Error('Responses stream state was not initialized'); + const responseId = responseState.id; const contentIndex = 0; const outputItemId = `msg_${crypto.randomUUID()}`; const reasoningItemId = 'reasoning-0'; - let nextOutputIndex = 2; - let sequenceNumber = 0; - let announcedOutput = false; - let announcedContent = false; - let announcedReasoning = false; - const nextSeq = (): number => sequenceNumber++; - const emit = (payload: unknown): void => { - res.write(`data: ${JSON.stringify(payload)}\n\n`); - }; + const nextSeq = (): number => responseState.nextSequence(); + const emit = (payload: unknown): void => responseState.emit(payload); - const createdAt = Math.floor(Date.now() / 1000); - emit({ - type: 'response.created', - sequence_number: nextSeq(), - response: { id: responseId, object: 'response', created: createdAt, created_at: createdAt, status: 'in_progress', model: `${pID}/${mID}` }, - }); - - const shouldStripStreamingToolMarkup = externalToolRegistry.length > 0; - const filterContentDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldStripStreamingToolMarkup }); - const filterReasoningDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldStripStreamingToolMarkup }); - const parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); - const parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); - const streamedToolCalls: FinalToolCall[] = []; - let rawContent = ''; - let rawReasoning = ''; - const ensureOutputScaffold = (): void => { - if (!announcedOutput) { + const outputAssembler = responseState.output; + let announcedContent = false; + const ensureOutputScaffold = (): ResponsesOutputEntry => { + let entry = outputAssembler.get('message', outputItemId); + if (!entry) { + entry = outputAssembler.announce({ + id: outputItemId, + type: 'message', + status: 'in_progress', + role: 'assistant', + content: [], + }); emit({ type: 'response.output_item.added', sequence_number: nextSeq(), - output_index: messageOutputIndex, - item: { id: outputItemId, type: 'message', status: 'in_progress', role: 'assistant', content: [] }, + output_index: entry.index, + item: entry.addedItem, }); - announcedOutput = true; } if (!announcedContent) { emit({ type: 'response.content_part.added', sequence_number: nextSeq(), - output_index: messageOutputIndex, + output_index: entry.index, content_index: contentIndex, item_id: outputItemId, part: { type: 'output_text', text: '', annotations: [] }, }); announcedContent = true; } + return entry; + }; + const ensureReasoningScaffold = (): ResponsesOutputEntry => { + let entry = outputAssembler.get('reasoning', reasoningItemId); + if (entry) return entry; + entry = outputAssembler.announce({ + id: reasoningItemId, + type: 'reasoning', + status: 'in_progress', + summary: [{ type: 'summary_text', text: '' }], + }); + emit({ + type: 'response.output_item.added', + sequence_number: nextSeq(), + output_index: entry.index, + item: entry.addedItem, + }); + return entry; }; - const ensureReasoningScaffold = (): void => { - if (!announcedReasoning) { + const finalizeOutputItems = ( + finalContent: string = content, + finalReasoning: string = reasoning, + annotations: unknown[] = [], + ): void => { + const messageEntry = outputAssembler.get('message', outputItemId); + if (messageEntry) { + const messageItem = buildResponsesMessageOutputItem(finalContent, outputItemId, annotations) ?? { + id: outputItemId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: finalContent, annotations }], + }; + outputAssembler.complete('message', outputItemId, messageItem); + } + const reasoningEntry = outputAssembler.get('reasoning', reasoningItemId); + if (reasoningEntry) { + outputAssembler.complete('reasoning', reasoningItemId, { + id: reasoningItemId, + type: 'reasoning', + status: 'completed', + summary: [{ type: 'summary_text', text: finalReasoning }], + }); + } + for (const entry of outputAssembler.list()) { + if (entry.emittedDone) continue; + if (!entry.doneItem) { + if (entry.type === 'function_call') { + outputAssembler.complete(entry.type, entry.id, { + ...entry.addedItem, + status: 'completed', + arguments: '', + }); + } else if (entry.type === 'custom_tool_call') { + outputAssembler.complete(entry.type, entry.id, { + ...entry.addedItem, + status: 'completed', + input: '', + }); + } else if (entry.type === 'web_search_call') { + outputAssembler.complete(entry.type, entry.id, { + ...entry.addedItem, + status: 'completed', + }); + } + } + const doneItem = entry.doneItem; + if (!doneItem) continue; + if (entry.type === 'message' && announcedContent) { + const doneContent = Array.isArray(doneItem['content']) ? doneItem['content'] : []; + const part = asRecord(doneContent[0]); + emit({ + type: 'response.output_text.done', + sequence_number: nextSeq(), + output_index: entry.index, + content_index: contentIndex, + item_id: entry.id, + text: String(part['text'] ?? ''), + }); + emit({ + type: 'response.content_part.done', + sequence_number: nextSeq(), + output_index: entry.index, + content_index: contentIndex, + item_id: entry.id, + part, + }); + } else if (entry.type === 'reasoning') { + emit({ + type: 'response.reasoning_summary_text.done', + sequence_number: nextSeq(), + output_index: entry.index, + item_id: entry.id, + summary_index: 0, + text: finalReasoning, + }); + } else if (entry.type === 'function_call') { + emit({ + type: 'response.function_call_arguments.done', + sequence_number: nextSeq(), + output_index: entry.index, + item_id: entry.id, + arguments: String(doneItem['arguments'] ?? ''), + }); + } else if (entry.type === 'custom_tool_call') { + emit({ + type: 'response.custom_tool_call_input.done', + sequence_number: nextSeq(), + output_index: entry.index, + item_id: entry.id, + input: String(doneItem['input'] ?? ''), + }); + } emit({ - type: 'response.output_item.added', + type: 'response.output_item.done', sequence_number: nextSeq(), - output_index: reasoningOutputIndex, - item: { id: reasoningItemId, type: 'reasoning', status: 'in_progress', summary: [{ type: 'summary_text', text: '' }] }, + output_index: entry.index, + item: doneItem, }); - announcedReasoning = true; + outputAssembler.markDone(entry); } }; + responseState.finalize = () => finalizeOutputItems(); + // Intentional: buffer while any external tool contract is declared so tool + // markup is never streamed before classification. Pure-text turns also batch. + const shouldBufferExternalStream = externalToolRegistry.length > 0; + const deferVisibleOutput = shouldBufferExternalStream || hostedSearch.requested; + let filterContentDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldBufferExternalStream, registry: externalToolRegistry }); + let filterReasoningDelta = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: shouldBufferExternalStream, registry: externalToolRegistry }); + let parseContentToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + let parseReasoningToolCalls = createExternalToolCallStreamParser(externalToolRegistry); + const streamedToolCalls: FinalToolCall[] = []; + let rawContent = ''; + let rawReasoning = ''; const emitResponsesFunctionCall = (toolCall: FinalToolCall): void => { - const outputIndex = nextOutputIndex++; - const functionCallItem = buildResponsesFunctionCallOutputItem(toolCall); - streamedToolCalls.push(toolCall); + const record = toolCall as unknown as Record; + const fn = asRecord(record['function']); + const callId = String(record['id'] ?? ''); + const args = typeof fn['arguments'] === 'string' ? fn['arguments'] : ''; + if (outputAssembler.has('function_call', callId)) throw createDuplicateToolCallIdError(); + const doneItem = { + id: callId, + type: 'function_call', + status: 'completed', + call_id: callId, + name: fn['name'], + arguments: args, + }; + const addedItem = { + id: callId, + type: 'function_call', + status: 'in_progress', + call_id: callId, + name: fn['name'], + arguments: '', + }; + const entry = outputAssembler.announce(addedItem); emit({ type: 'response.output_item.added', sequence_number: nextSeq(), - output_index: outputIndex, - item: { ...functionCallItem, status: 'in_progress' }, + output_index: entry.index, + item: addedItem, }); emit({ type: 'response.function_call_arguments.delta', sequence_number: nextSeq(), - output_index: outputIndex, - item_id: toolCall.id, - delta: (toolCall.function as Record)['arguments'], + output_index: entry.index, + item_id: callId, + delta: args, }); + outputAssembler.complete('function_call', callId, doneItem); + }; + const emitResponsesCustomToolCall = (toolCall: FinalToolCall): void => { + const record = toolCall as unknown as Record; + const fn = asRecord(record['function']); + const callId = String(record['id'] ?? ''); + const name = String(fn['name'] ?? ''); + if (outputAssembler.has('custom_tool_call', callId)) throw createDuplicateToolCallIdError(); + const addedItem = buildResponsesCustomToolCallItem(callId, name, '', 'in_progress'); + const doneItem = buildResponsesCustomToolCallItem(callId, name, fn['arguments'], 'completed'); + const entry = outputAssembler.announce(addedItem); emit({ - type: 'response.function_call_arguments.done', + type: 'response.output_item.added', sequence_number: nextSeq(), - output_index: outputIndex, - item_id: toolCall.id, - arguments: (toolCall.function as Record)['arguments'], + output_index: entry.index, + item: addedItem, }); emit({ - type: 'response.output_item.done', + type: 'response.custom_tool_call_input.delta', sequence_number: nextSeq(), - output_index: outputIndex, - item: functionCallItem, + output_index: entry.index, + item_id: callId, + delta: String(doneItem['input'] ?? ''), }); + outputAssembler.complete('custom_tool_call', callId, doneItem); }; - const sendResponsesDelta = (delta: string, isReasoning: boolean = false): void => { - if (!delta) return; - if (isReasoning) rawReasoning += delta; - else rawContent += delta; - const parsedDeltaToolCalls = isReasoning ? parseReasoningToolCalls(delta) : parseContentToolCalls(delta); - if (parsedDeltaToolCalls.length > 0) { - const { validCalls: allowedDeltaToolCalls } = finalizeValidatedToolCalls(parsedDeltaToolCalls, externalToolRegistry); - allowedDeltaToolCalls.forEach((toolCall) => { - const fn = asRecord((toolCall as unknown as Record)['function']); - emitResponsesFunctionCall({ - id: String((toolCall as unknown as Record)['id']), - type: 'function', - function: { name: fn['name'], arguments: fn['arguments'] }, - } as unknown as FinalToolCall); - }); - } - const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); + const appendVisibleDelta = (filtered: string, isReasoning: boolean): void => { + if (!filtered) return; + if (isReasoning) reasoning += filtered; + else content += filtered; + }; + const writeVisibleDelta = (filtered: string, isReasoning: boolean): void => { if (!filtered) return; if (isReasoning) { - ensureReasoningScaffold(); - reasoning += filtered; + const entry = ensureReasoningScaffold(); emit({ type: 'response.reasoning_summary_text.delta', sequence_number: nextSeq(), - output_index: reasoningOutputIndex, + output_index: entry.index, item_id: reasoningItemId, summary_index: 0, delta: filtered, }); - } else { - if (!filtered.trim()) { - content += filtered; - return; - } - ensureOutputScaffold(); - content += filtered; + } else if (filtered.trim()) { + const entry = ensureOutputScaffold(); emit({ type: 'response.output_text.delta', sequence_number: nextSeq(), - output_index: messageOutputIndex, + output_index: entry.index, content_index: contentIndex, item_id: outputItemId, delta: filtered, }); } }; + const sendResponsesDelta = (delta: string, isReasoning: boolean = false): void => { + if (!delta) return; + if (isReasoning) rawReasoning += delta; + else rawContent += delta; + const parsedDeltaToolCalls = isReasoning ? parseReasoningToolCalls(delta) : parseContentToolCalls(delta); + parsedDeltaToolCalls.forEach((toolCall) => streamedToolCalls.push(toolCall)); + const filtered = isReasoning ? filterReasoningDelta(delta) : filterContentDelta(delta); + if (!filtered) return; + appendVisibleDelta(filtered, isReasoning); + if (!deferVisibleOutput) writeVisibleDelta(filtered, isReasoning); + }; + const unsentSuffix = (full: unknown, raw: string): string => { + const text = typeof full === 'string' ? full : ''; + if (!text) return ''; + if (!raw) return text; + return text.startsWith(raw) ? text.slice(raw.length) : text; + }; let collected: Record | null = null; try { @@ -664,22 +1139,20 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void 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; + DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, + DEFAULT_EVENT_IDLE_TIMEOUT_MS, + responsesAbortController.signal, + ); + + const safeCollect = collectPromise.catch((err: unknown) => ({ __error: err })); + activeClient.session.prompt(promptParams).catch((err: unknown) => logDebug('Responses prompt error:', toErrorMessage(err))); + const closePromise = getResponsesResClosed(); + const raced = closePromise + ? await Promise.race([safeCollect, closePromise.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 { @@ -693,237 +1166,327 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void collected = { __error: e }; } - const collectedR = asRecord(collected); - // P4: retain tool parts from recovery polls for grounding evidence. + const collectedR = asRecord(collected); + if (responsesAbortController.signal.aborted || collectedR['cancelled']) { + stopResponsesKeepalive(); + return; + } + if (collectedR['error'] != null) { + + throw normalizeBackendError(collectedR['error']); + } 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) { - // 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 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) - : polled.reasoning; - const remainingContent = - polled.content && polled.content.startsWith(rawContent) ? polled.content.slice(rawContent.length) : polled.content; - if (remainingReasoning) sendResponsesDelta(remainingReasoning, true); - if (remainingContent) sendResponsesDelta(remainingContent, false); - } else if (collected && ((collectedR['content'] as string) || (collectedR['reasoning'] as string))) { - if (!reasoning && collectedR['reasoning']) sendResponsesDelta(String(collectedR['reasoning']), true); - if (!content && collectedR['content']) sendResponsesDelta(String(collectedR['content']), false); - } + let recoverySnapshot: { content: string; reasoning: string; error: unknown; toolParts?: unknown } | null = null; + let recoveryPromise: Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown }> | null = null; + const pollRecoverySnapshot = (): Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown }> => { + if (!recoveryPromise) { + recoveryPromise = activePollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + undefined, + responsesAbortController.signal, + ); + } + return recoveryPromise; + }; + const hasPartialOutput = (): boolean => Boolean( + rawContent || rawReasoning || streamedToolCalls.length > 0 || content || reasoning, + ); + const hasValidTerminal = Boolean( + collected && + !collectedR['__error'] && + !collectedR['noData'] && + !collectedR['idleTimeout'] && + !collectedR['cancelled'] && + (rawContent || rawReasoning || streamedToolCalls.length > 0 || content || reasoning), + ); + const recoverSnapshot = async ( + fallbackError: unknown = null, + ): Promise<{ content: string; reasoning: string; error: unknown; toolParts?: unknown } | null> => { + if (responsesAbortController.signal.aborted) return null; + let polled: { content: string; reasoning: string; error: unknown; toolParts?: unknown }; + try { + polled = await pollRecoverySnapshot(); + } catch (error: unknown) { + if (responsesAbortController.signal.aborted) return null; + if (hasPartialOutput()) { + logDebug('Ignoring responses recovery poll failure after partial stream output', { error: toErrorMessage(error) }); + return null; + } + if (fallbackError != null) throw normalizeBackendError(fallbackError); + throw normalizeBackendError(error); + } + if (polled.error != null) throw normalizeBackendError(polled.error); + if ( + fallbackError != null && + !polled.content && + !polled.reasoning && + !(Array.isArray(polled.toolParts) && polled.toolParts.length > 0) + ) throw normalizeBackendError(fallbackError); + recoverySnapshot = polled; + return polled; + }; + const needsRecovery = Boolean( + collectedR['__error'] || collectedR['noData'] || collectedR['idleTimeout'] || !hasValidTerminal, + ); + if (needsRecovery) { + if (collectedR['__error'] && !fallbackToProxy) engageProxyFallback(collectedR['__error']); + const polled = await recoverSnapshot(collectedR['__error']); + if (polled) { + keepStreamToolParts(polled); + const remainingReasoning = unsentSuffix(polled.reasoning, rawReasoning); + const remainingContent = unsentSuffix(polled.content, rawContent); + if (remainingReasoning) sendResponsesDelta(remainingReasoning, true); + if (remainingContent) sendResponsesDelta(remainingContent, false); + } + } else if (collected && ((collectedR['content'] as string) || (collectedR['reasoning'] as string))) { + const remainingReasoning = unsentSuffix(collectedR['reasoning'], rawReasoning); + const remainingContent = unsentSuffix(collectedR['content'], rawContent); + if (remainingReasoning) sendResponsesDelta(remainingReasoning, true); + if (remainingContent) sendResponsesDelta(remainingContent, 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 (hostedSearch.requested && streamSearchToolParts.length === 0) { + try { + const evPoll = await activePollForAssistantResponse( + sessionId as string, + Math.min(15000, REQUEST_TIMEOUT_MS), + undefined, + responsesAbortController.signal, + ); + if (Array.isArray(evPoll.toolParts)) streamSearchToolParts = evPoll.toolParts; + } catch { + } + } - if (announcedReasoning) { - emit({ - type: 'response.reasoning_summary_text.done', - sequence_number: nextSeq(), - output_index: reasoningOutputIndex, - item_id: reasoningItemId, - summary_index: 0, - text: reasoning, - }); - emit({ - type: 'response.output_item.done', - sequence_number: nextSeq(), - output_index: reasoningOutputIndex, - item: { id: reasoningItemId, type: 'reasoning', status: 'completed', summary: [{ type: 'summary_text', text: reasoning }] }, - }); - } + if (responsesAbortController.signal.aborted) return; - const hasMeaningfulContent = Boolean(content && content.trim()); + let polledForToolCalls: { content: string; reasoning: string; error: unknown; toolParts?: unknown } | null = null; + if (externalToolRegistry.length > 0 && !hasValidTerminal && !recoveryPromise) { + const polled = await recoverSnapshot(); + if (polled) { + polledForToolCalls = polled; + if (shouldBufferExternalStream) { + const remainingReasoning = unsentSuffix(polled.reasoning, rawReasoning); + const remainingContent = unsentSuffix(polled.content, rawContent); + if (remainingReasoning) sendResponsesDelta(remainingReasoning, true); + if (remainingContent) sendResponsesDelta(remainingContent, false); + } + } + } - if (announcedContent && hasMeaningfulContent) { - const contentAnnotations = buildCitationAnnotations(content, streamEvidence.sources); - emit({ - type: 'response.output_text.done', - sequence_number: nextSeq(), - output_index: messageOutputIndex, - content_index: contentIndex, - item_id: outputItemId, - text: content, - }); - emit({ - type: 'response.content_part.done', - sequence_number: nextSeq(), - output_index: messageOutputIndex, - content_index: contentIndex, - item_id: outputItemId, - part: { type: 'output_text', text: content, annotations: 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: contentAnnotations }] }, - }); - } - let polledForToolCalls: { content: string; reasoning: string; error: unknown; toolParts?: unknown } | null = null; - if (externalToolRegistry.length > 0 && streamedToolCalls.length === 0) { - try { - polledForToolCalls = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); - } catch { - // ignore - } - } + const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : []; + const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : []; + const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : ''; + const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : ''; + appendVisibleDelta(flushedReasoningText, true); + appendVisibleDelta(flushedContentText, false); + if (shouldBufferExternalStream) { + const visible = stripExternalToolCallMarkupFromJoinedText( + externalToolRegistry, + rawReasoning, + rawContent, + ); + reasoning = visible.reasoning; + content = visible.content; + } else if (!deferVisibleOutput) { + writeVisibleDelta(flushedReasoningText, true); + writeVisibleDelta(flushedContentText, false); + } - const flushedReasoningCalls = parseReasoningToolCalls.flush ? parseReasoningToolCalls.flush() : []; - const flushedContentCalls = parseContentToolCalls.flush ? parseContentToolCalls.flush() : []; - const flushedReasoningText = filterReasoningDelta.flush ? filterReasoningDelta.flush() : ''; - const flushedContentText = filterContentDelta.flush ? filterContentDelta.flush() : ''; - const finalReasoningText = (polledForToolCalls?.reasoning || rawReasoning) + flushedReasoningText; - const finalContentText = (polledForToolCalls?.content || rawContent) + flushedContentText; - - const parseStreamedToolCalls = (): FinalToolCall[] => { - if (externalToolRegistry.length === 0) return []; - const perChannel = [ - ...flushedReasoningCalls, - ...flushedContentCalls, - ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText), - ]; - if (perChannel.length > 0) return perChannel; - return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); - }; + const recoveryForParse = recoverySnapshot as { content: string; reasoning: string; error: unknown; toolParts?: unknown } | null; + const snapshotReasoning = polledForToolCalls?.reasoning ?? recoveryForParse?.reasoning ?? (typeof collectedR['reasoning'] === 'string' ? collectedR['reasoning'] as string : rawReasoning); + const snapshotContent = polledForToolCalls?.content ?? recoveryForParse?.content ?? (typeof collectedR['content'] === 'string' ? collectedR['content'] as string : rawContent); - let parsedToolCalls: FinalToolCall[] = - streamedToolCalls.length > 0 ? streamedToolCalls : parseStreamedToolCalls(); - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedResponsesToolCall(); - if (forcedResponse) { - parsedToolCalls = parseExternalToolCallsFromText( + const parseJoined = (reasoningText: string, contentText: string): FinalToolCall[] => + externalToolRegistry.length > 0 + ? parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoningText, contentText) + : []; + const streamSource = [ + [snapshotReasoning, snapshotContent], + [rawReasoning, rawContent] + ]; + let parsedToolCalls: FinalToolCall[] = externalToolRegistry.length > 0 + ? mergeToolCallArtifacts( + streamedToolCalls, + flushedReasoningCalls, + flushedContentCalls, + parseJoined(snapshotReasoning, snapshotContent), + parseJoined(rawReasoning, rawContent), + ) + : []; + let validatedStreamedToolCalls: FinalToolCall[]; + try { + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, streamSource); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedResponsesToolCall(); + if (forcedResponse) { + const forcedReasoning = typeof forcedResponse['reasoning'] === 'string' ? forcedResponse['reasoning'] as string : ''; + const forcedContent = typeof forcedResponse['content'] === 'string' ? forcedResponse['content'] as string : ''; + parsedToolCalls = mergeToolCallArtifacts(parsedToolCalls, parseJoined(forcedReasoning, forcedContent)); + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, [forcedReasoning, forcedContent]); + } + } + validatedStreamedToolCalls = finalizeStreamToolCalls( + parsedToolCalls, externalToolRegistry, - forcedResponse['reasoning'] as string, - forcedResponse['content'] as string, - ); + externalToolChoice, + streamSource, + parallelToolCalls, + ) as unknown as FinalToolCall[]; + } catch (toolError) { + const toolCode = (toolError as Error & { code?: string }).code; + const failClosedCodes = [ + 'parallel_external_tool_calls', + 'external_tool_choice_none', + 'external_tool_choice_required', + 'invalid_external_tool_call', + 'external_tool_policy_blocked', + 'external_tool_choice_mismatch', + 'duplicate_external_tool_call_id', + 'malformed_external_tool_call', + ]; + if (toolCode && failClosedCodes.includes(toolCode)) { + await cleanupOwnedResponsesSession(); + stopResponsesKeepalive(); + const toolMessage = toErrorMessage(toolError); + const serverError = { message: toolMessage, type: 'server_error', code: toolCode }; + if (responsesStreamState) { + try { + const streamState = responsesStreamState; + if (!streamState.createdEmitted) streamState.emitCreated(); + if (streamState.finalize) { + try { + streamState.finalize(); + } catch (finalizeError: unknown) { + logDebug('Failed to finalize errored response stream', { error: toErrorMessage(finalizeError) }); + } + } + streamState.emit({ + type: 'response.failed', + sequence_number: streamState.nextSequence(), + response: { + ...buildResponsesResponseEnvelope(streamState), + completed_at: null, + status: 'failed', + output: streamState.output.output(), + error: serverError, + incomplete_details: null, + usage: null, + }, + }); + res.write('data: [DONE]\n\n'); + } catch (writeError: unknown) { + logDebug('Failed to report error on open response stream', { error: toErrorMessage(writeError) }); + } + res.end(); + } else if (!res.headersSent) { + res.status(502).json({ error: serverError }); + } + return; + } + throw toolError; } - } - const { validCalls: validatedStreamedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); - if (streamedToolCalls.length === 0) { + const joinedSafe = stripExternalToolCallMarkupFromJoinedText( + externalToolRegistry, + reasoning, + content, + true, + ); + const safeContent = joinedSafe.content; + const safeReasoning = joinedSafe.reasoning; + if (!safeContent.trim() && !safeReasoning.trim() && validatedStreamedToolCalls.length === 0) { + throw new Error('Upstream returned no assistant data'); + } + const streamEvidence = hostedSearch.requested + ? extractSearchEvidence(streamSearchToolParts) + : { queries: [] as string[], sources: [] as { url: string; title: string }[] }; + const streamSearchCallItems = buildWebSearchCallItems(streamEvidence); + const emitWebSearchCallItem = (item: { id: string; type: string; status: string; action: unknown }): void => { + const callId = String(item.id); + if (outputAssembler.has('web_search_call', callId)) return; + const addedItem = { + id: callId, + type: 'web_search_call', + status: 'in_progress', + }; + const entry = outputAssembler.announce(addedItem); + emit({ + type: 'response.output_item.added', + sequence_number: nextSeq(), + output_index: entry.index, + item: addedItem, + }); + emit({ + type: 'response.web_search_call.searching', + sequence_number: nextSeq(), + output_index: entry.index, + item_id: callId, + }); + emit({ + type: 'response.web_search_call.completed', + sequence_number: nextSeq(), + output_index: entry.index, + item_id: callId, + }); + outputAssembler.complete('web_search_call', callId, { ...item }); + }; + streamSearchCallItems.forEach((item) => emitWebSearchCallItem(item)); + + if (deferVisibleOutput) { + writeVisibleDelta(safeReasoning, true); + writeVisibleDelta(safeContent, false); + } + validatedStreamedToolCalls.forEach((toolCall) => { const record = toolCall as unknown as Record; const fn = asRecord(record['function']); - emitResponsesFunctionCall({ + const emitCall = customToolNames.has(String(fn['name'] ?? '')) + ? emitResponsesCustomToolCall + : emitResponsesFunctionCall; + emitCall({ id: String(record['id']), type: 'function', function: { name: fn['name'], arguments: fn['arguments'] }, } as unknown as FinalToolCall); }); - } - 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) => { - const record = toolCall as unknown as Record; - const fn = asRecord(record['function']); - streamOutput.push({ - id: String(record['id']), - type: 'function_call', - status: 'completed', - call_id: String(record['id']), - name: fn['name'], - arguments: fn['arguments'], - }); - }); - const promptTokens = Math.ceil(fullPromptText.length / 4); - const completionTokens = Math.ceil(content.length / 4); - const reasoningTokens = Math.ceil(reasoning.length / 4); - const completedAt = Math.floor(Date.now() / 1000); - const response = { - id: responseId, - object: 'response', - created: completedAt, - created_at: completedAt, - status: 'completed', - model: `${pID}/${mID}`, - reasoning: safeReasoning ? { effort: reasoningLevel, summary: safeReasoning.substring(0, 100) } : undefined, - output: streamOutput, - error: null, - incomplete_details: null, - usage: { - input_tokens: promptTokens, - output_tokens: completionTokens + reasoningTokens, - total_tokens: promptTokens + completionTokens + reasoningTokens, - input_tokens_details: { cached_tokens: 0 }, - output_tokens_details: { reasoning_tokens: reasoningTokens }, - }, - }; - emit({ type: 'response.completed', sequence_number: nextSeq(), response }); - res.write('data: [DONE]\n\n'); - storeResponseState(responseId, sessionId, `${pID}/${mID}`); - stopResponsesKeepalive(); - res.end(); - return; + + const streamAnnotations = buildCitationAnnotations(safeContent, streamEvidence.sources); + finalizeOutputItems(safeContent, safeReasoning, streamAnnotations); + const streamOutput = outputAssembler.output(); + const promptTokens = Math.ceil(fullPromptText.length / 4); + const completionTokens = Math.ceil(content.length / 4); + const reasoningTokens = Math.ceil(reasoning.length / 4); + const completedAt = Math.floor(Date.now() / 1000); + const response = { + ...buildResponsesResponseEnvelope(responseState), + completed_at: completedAt, + status: 'completed', + reasoning: safeReasoning ? { effort: reasoningLevel, summary: safeReasoning.substring(0, 100) } : undefined, + output: streamOutput, + error: null, + incomplete_details: null, + usage: { + input_tokens: promptTokens, + output_tokens: completionTokens + reasoningTokens, + total_tokens: promptTokens + completionTokens + reasoningTokens, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: reasoningTokens }, + }, + }; + emit({ type: 'response.completed', sequence_number: nextSeq(), response }); + res.write('data: [DONE]\n\n'); + storeResponseState(responseId, sessionId, `${pID}/${mID}`, buildStoredResponseToolCalls(validatedStreamedToolCalls)); + if (ownedResponsesSessionId === sessionId) ownedResponsesSessionId = null; + stopResponsesKeepalive(); + res.end(); + return; } let responseRes: unknown = null; @@ -940,14 +1503,21 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void return; } if (attempt > 1) { - try { - await activeClient.session.delete({ path: { id: sessionId } }); - } catch (e: unknown) { - logDebug('Failed to delete retried session', { sessionId, error: toErrorMessage(e) }); + const retriedSessionId = sessionId; + if (ownedResponsesSessionId === retriedSessionId) ownedResponsesSessionId = null; + // A `previous_response_id` parent session is shared with earlier turns; only + // a session this request created may be discarded on retry. + if (retriedSessionId && retriedSessionId !== previousState?.sessionId) { + try { + await activeClient.session.delete({ path: { id: retriedSessionId } }); + } catch (e: unknown) { + logDebug('Failed to delete retried session', { sessionId, error: toErrorMessage(e) }); + } } 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'); + ownedResponsesSessionId = sessionId; promptParams.path.id = sessionId; requestForcedResponsesToolCall = makeForcedResponsesToolCallRequester(); await sleep(computeRetryDelay(attempt - 1, lastResponsesAttemptError)); @@ -965,12 +1535,18 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void .filter((p) => asRecord(p)['type'] === 'reasoning') .map((p) => String(asRecord(p)['text'] ?? '')) .join('\n'); - promptParsedToolCalls = - externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, promptReasoning, promptContent) - : []; + promptParsedToolCalls = + externalToolRegistry.length > 0 + ? parseExternalToolCallsFromJoinedText(externalToolRegistry, promptReasoning, promptContent) + : []; if (promptContent || promptReasoning) break; - polledResponse = await activePollForAssistantResponse(sessionId as string, REQUEST_TIMEOUT_MS); + polledResponse = await activePollForAssistantResponse( + sessionId as string, + REQUEST_TIMEOUT_MS, + undefined, + responsesAbortController.signal, + ); + } catch (loopError: unknown) { if (attempt < maxAttempts && engageProxyFallback(loopError)) { lastResponsesAttemptError = loopError; @@ -986,7 +1562,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } throw normalizeBackendError(loopError); } - if (polledResponse && polledResponse.error && !polledResponse.content && !polledResponse.reasoning) { + if (polledResponse && polledResponse.error != null) { if (attempt < maxAttempts && engageProxyFallback(polledResponse.error)) { lastResponsesAttemptError = polledResponse.error; continue; @@ -1017,7 +1593,13 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void // 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)); + const evPoll = await activePollForAssistantResponse( + sessionId as string, + Math.min(15000, REQUEST_TIMEOUT_MS), + undefined, + responsesAbortController.signal, + ); + if (Array.isArray(evPoll.toolParts) && evPoll.toolParts.length > 0) { searchToolParts = evPoll.toolParts; } @@ -1027,16 +1609,16 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } if (!polledFilled) { - content = promptParsedToolCalls.length > 0 ? '' : promptContent; + content = promptContent; reasoning = promptReasoning; } let promptBasedToolCalls: FinalToolCall[] = promptParsedToolCalls; if (polledFilled) { - promptBasedToolCalls = - externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) - : []; + promptBasedToolCalls = + externalToolRegistry.length > 0 + ? parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoning, content) + : []; } if (!content && !reasoning && responseRes && promptBasedToolCalls.length === 0) { @@ -1048,23 +1630,64 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } } - let parsedToolCalls: FinalToolCall[] = - promptBasedToolCalls.length > 0 - ? promptBasedToolCalls - : externalToolRegistry.length > 0 - ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) - : []; - if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { - const forcedResponse = await requestForcedResponsesToolCall(); - if (forcedResponse) { - content = String(forcedResponse['content'] ?? content); - reasoning = String(forcedResponse['reasoning'] ?? reasoning); - parsedToolCalls = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); - } - } - const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); - const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); - const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); + let parsedToolCalls: FinalToolCall[] = + promptBasedToolCalls.length > 0 + ? promptBasedToolCalls + : externalToolRegistry.length > 0 + ? parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoning, content) + : []; + let validatedToolCalls: FinalToolCall[] = []; + try { + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, [reasoning, content]); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedResponsesToolCall(); + if (forcedResponse) { + content = String(forcedResponse['content'] ?? content); + reasoning = String(forcedResponse['reasoning'] ?? reasoning); + parsedToolCalls = parseExternalToolCallsFromJoinedText(externalToolRegistry, reasoning, content); + assertToolCallArtifactIntegrity(parsedToolCalls, externalToolRegistry, [reasoning, content]); + } + } + validatedToolCalls = finalizeStreamToolCalls( + parsedToolCalls, + externalToolRegistry, + externalToolChoice, + [reasoning, content], + parallelToolCalls, + ) as unknown as FinalToolCall[]; + } catch (toolError) { + const toolCode = (toolError as Error & { code?: string }).code; + const failClosedCodes = [ + 'parallel_external_tool_calls', + 'external_tool_choice_none', + 'external_tool_choice_required', + 'invalid_external_tool_call', + 'external_tool_policy_blocked', + 'external_tool_choice_mismatch', + 'duplicate_external_tool_call_id', + 'malformed_external_tool_call', + ]; + if (toolCode && failClosedCodes.includes(toolCode)) { + await cleanupOwnedResponsesSession(); + res.status(502).json({ + error: { + message: toErrorMessage(toolError), + type: 'server_error', + code: toolCode, + }, + }); + return; + } + throw toolError; + } + const joinedSafe = stripExternalToolCallMarkupFromJoinedText( + externalToolRegistry, + reasoning, + content, + true, + ); + const safeContent = joinedSafe.content; + const safeReasoning = joinedSafe.reasoning; // P4: server-side grounding evidence → web_search_call items + citations. const searchEvidence = hostedSearch.requested @@ -1083,27 +1706,36 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void validatedToolCalls.forEach((toolCall) => { const record = toolCall as unknown as Record; const fn = asRecord(record['function']); + const callId = String(record['id']); + const name = String(fn['name'] ?? ''); + if (customToolNames.has(name)) { + output.push(buildResponsesCustomToolCallItem(callId, name, fn['arguments'], 'completed')); + return; + } output.push({ - id: String(record['id']), + id: callId, type: 'function_call', status: 'completed', - call_id: String(record['id']), + call_id: callId, name: fn['name'], arguments: fn['arguments'], }); }); + const orderedOutput = orderResponsesOutputItems(output); const responseId = `resp_${crypto.randomUUID()}`; const createdAt = Math.floor(Date.now() / 1000); const response = { - id: responseId, - object: 'response', - created: createdAt, - created_at: createdAt, + ...buildResponsesResponseEnvelope({ + id: responseId, + createdAt, + model: `${pID}/${mID}`, + tools, + parallelToolCalls, + }), status: 'completed', - model: `${pID}/${mID}`, reasoning: safeReasoning ? { effort: reasoningLevel, summary: safeReasoning.substring(0, 100) } : undefined, - output, + output: orderedOutput, error: null, incomplete_details: null, usage: { @@ -1115,7 +1747,8 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void }, }; - storeResponseState(responseId, sessionId, `${pID}/${mID}`); + storeResponseState(responseId, sessionId, `${pID}/${mID}`, buildStoredResponseToolCalls(validatedToolCalls)); + if (ownedResponsesSessionId === sessionId) ownedResponsesSessionId = null; res.json(response); return; @@ -1124,15 +1757,31 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void console.error('[Proxy] Responses API Error:', toErrorMessage(error)); if (!fallbackToProxy) engageProxyFallback(error); const transformed = transformUpstreamError(error); - if (res.headersSent) { + if (responsesStreamState) { try { - const failedAt = Math.floor(Date.now() / 1000); - res.write( - `data: ${JSON.stringify({ - type: 'response.failed', - response: { id: `resp_${crypto.randomUUID()}`, object: 'response', created: failedAt, created_at: failedAt, status: 'failed', error: transformed.error }, - })}\n\n`, - ); + const streamState = responsesStreamState; + if (!streamState.createdEmitted) streamState.emitCreated(); + if (streamState.finalize) { + try { + streamState.finalize(); + } catch (finalizeError: unknown) { + logDebug('Failed to finalize errored response stream', { error: toErrorMessage(finalizeError) }); + } + } + const failedPayload = { + type: 'response.failed', + sequence_number: streamState.nextSequence(), + response: { + ...buildResponsesResponseEnvelope(streamState), + completed_at: null, + status: 'failed', + output: streamState.output.output(), + error: transformed.error, + incomplete_details: null, + usage: null, + }, + }; + streamState.emit(failedPayload); res.write('data: [DONE]\n\n'); } catch (writeError: unknown) { logDebug('Failed to report error on open response stream', { error: toErrorMessage(writeError) }); @@ -1142,6 +1791,8 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void } res.status(transformed.statusCode).json(transformed.error); return; + } finally { + await cleanupOwnedResponsesSession(); } }); } diff --git a/src/routes/system.ts b/src/routes/system.ts index 39904eb..db80deb 100644 --- a/src/routes/system.ts +++ b/src/routes/system.ts @@ -11,6 +11,7 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { const { API_KEY, API_KEYS = [], + DISABLE_TOOLS, INTERNAL_TOOL_METRICS_ENABLED, INTERNAL_TOOL_DISCOVERY_FIXTURE, HEALTH_DETAILS_ENABLED, @@ -24,6 +25,10 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { internalToolMetrics, getCachedToolIds, getCachedToolIdsAt, + getDiscoverySource, + getDiscoveryLastSuccessAt, + getDiscoveryLastErrorAt, + getDiscoveryLastError, proxyPool, } = ctx; // ctx.API_KEYS is already the effective list built by createApp; the merge @@ -82,6 +87,15 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { const metricsSnapshot = INTERNAL_TOOL_METRICS_ENABLED ? { ...internalToolMetrics } : null; const cached = getCachedToolIds(); const cachedAt = getCachedToolIdsAt(); + const discoverySource = typeof getDiscoverySource === 'function' ? getDiscoverySource() : 'none'; + const discoverySuccessAt = typeof getDiscoveryLastSuccessAt === 'function' ? getDiscoveryLastSuccessAt() : 0; + const discoveryErrorAt = typeof getDiscoveryLastErrorAt === 'function' ? getDiscoveryLastErrorAt() : 0; + const discoveryError = typeof getDiscoveryLastError === 'function' ? getDiscoveryLastError() : null; + const idsCount = cached ? cached.length : 0; + const cacheAgeMs = cachedAt ? Date.now() - cachedAt : null; + const liveVerified = discoverySource === 'live' && idsCount > 0; + const discoveryReady = liveVerified; + const fixtureConfigured = normalizeConfiguredToolNames(INTERNAL_TOOL_DISCOVERY_FIXTURE).length > 0; res.json({ status: 'ok', proxy: true, @@ -97,6 +111,23 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { tool_id_count: cached ? cached.length : 0, age_ms: cachedAt ? Date.now() - cachedAt : null, }, + tool_discovery: { + source: discoverySource, + live_verified: liveVerified, + ready: discoveryReady, + fixture_configured: fixtureConfigured, + disable_tools: Boolean(DISABLE_TOOLS), + ids_count: idsCount, + cache_age_ms: cacheAgeMs, + last_success_at: discoverySuccessAt || null, + last_error_at: discoveryErrorAt || null, + last_error: discoveryError, + override_omitted_total: internalToolMetrics.overrideOmitted ?? 0, + }, + readiness: { + tool_discovery_ready: discoveryReady, + live_backend_verified: liveVerified, + }, audit: { available: true, fields: [ @@ -124,7 +155,15 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { } const cached = getCachedToolIds(); + const cachedAt = getCachedToolIdsAt(); const proxyStatus = proxyPool ? proxyPool.getStatus() : null; + const metricsSource = typeof getDiscoverySource === 'function' ? getDiscoverySource() : 'none'; + const metricsSuccessAt = typeof getDiscoveryLastSuccessAt === 'function' ? getDiscoveryLastSuccessAt() : 0; + const metricsErrorAt = typeof getDiscoveryLastErrorAt === 'function' ? getDiscoveryLastErrorAt() : 0; + const metricsIdsCount = cached ? cached.length : 0; + const metricsCacheAge = cachedAt ? Date.now() - cachedAt : -1; + const metricsLiveVerified = metricsSource === 'live' && metricsIdsCount > 0 ? 1 : 0; + const metricsReady = metricsLiveVerified; 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', @@ -137,9 +176,33 @@ export function registerSystemRoutes(app: Application, ctx: AppContext): void { '# HELP opencode_internal_tool_fallback_disabled_total Count of allowlist resolutions that fell back to disabled.', '# TYPE opencode_internal_tool_fallback_disabled_total counter', `opencode_internal_tool_fallback_disabled_total ${internalToolMetrics.fallbackToDisabled}`, + '# HELP opencode_internal_tool_overrides_omitted_total Count of tool override omissions under operator allow.', + '# TYPE opencode_internal_tool_overrides_omitted_total counter', + `opencode_internal_tool_overrides_omitted_total ${internalToolMetrics.overrideOmitted ?? 0}`, '# 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_internal_tool_discovery_ids_count Number of discovered backend tool IDs.', + '# TYPE opencode_internal_tool_discovery_ids_count gauge', + `opencode_internal_tool_discovery_ids_count ${metricsIdsCount}`, + '# HELP opencode_internal_tool_discovery_cache_age_ms Age of cached backend tool IDs in milliseconds.', + '# TYPE opencode_internal_tool_discovery_cache_age_ms gauge', + `opencode_internal_tool_discovery_cache_age_ms ${metricsCacheAge}`, + '# HELP opencode_internal_tool_discovery_live_verified Whether live backend tool discovery is verified.', + '# TYPE opencode_internal_tool_discovery_live_verified gauge', + `opencode_internal_tool_discovery_live_verified ${metricsLiveVerified}`, + '# HELP opencode_internal_tool_discovery_ready Whether tool discovery readiness holds.', + '# TYPE opencode_internal_tool_discovery_ready gauge', + `opencode_internal_tool_discovery_ready ${metricsReady}`, + '# HELP opencode_internal_tool_discovery_source_info Tool discovery source.', + '# TYPE opencode_internal_tool_discovery_source_info gauge', + `opencode_internal_tool_discovery_source_info{source="${metricsSource}"} 1`, + '# HELP opencode_internal_tool_discovery_last_success_timestamp_seconds Last tool discovery success time.', + '# TYPE opencode_internal_tool_discovery_last_success_timestamp_seconds gauge', + `opencode_internal_tool_discovery_last_success_timestamp_seconds ${metricsSuccessAt ? Math.floor(metricsSuccessAt / 1000) : 0}`, + '# HELP opencode_internal_tool_discovery_last_error_timestamp_seconds Last tool discovery error time.', + '# TYPE opencode_internal_tool_discovery_last_error_timestamp_seconds gauge', + `opencode_internal_tool_discovery_last_error_timestamp_seconds ${metricsErrorAt ? Math.floor(metricsErrorAt / 1000) : 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}`, diff --git a/src/search/grounding.ts b/src/search/grounding.ts index 56940c9..472c9c8 100644 --- a/src/search/grounding.ts +++ b/src/search/grounding.ts @@ -19,6 +19,9 @@ export const SEARCH_GROUNDING_INSTRUCTION = 'only from the conversation plus the search results. ' + 'Include the source URLs you relied on verbatim in your answer so they can be cited.'; +/** Declaration type that nests further tool declarations (Codex namespace tree). */ +const NAMESPACE_TOOL_TYPE = 'namespace'; + function toolTypeOf(def: unknown): string { return typeof asRecord(def)['type'] === 'string' ? String(asRecord(def)['type']) : ''; } @@ -28,23 +31,31 @@ export interface HostedSearchRequest { kinds: string[]; } -/** Detect OpenAI/Gemini/Anthropic hosted search tools in a `tools` array. */ +/** + * Detect OpenAI/Gemini/Anthropic hosted search tools in a `tools` array. + * + * Recurses into namespace containers: a hosted grant nested in a namespace is + * the same grant, so it must not fall through as an unsupported declaration. + */ 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 record = asRecord(def); const t = toolTypeOf(def).toLowerCase(); - if (isHostedSearchType(t) && !kinds.includes(t)) kinds.push(t); + if (isHostedSearchType(t)) { + if (!kinds.includes(t)) kinds.push(t); + continue; + } + if (t === NAMESPACE_TOOL_TYPE && Array.isArray(record['tools'])) { + for (const kind of detectHostedSearchTools(record['tools']).kinds) { + if (!kinds.includes(kind)) kinds.push(kind); + } + } } 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) => !isHostedSearchType(toolTypeOf(def).toLowerCase())); -} - export interface SearchSource { url: string; title: string; diff --git a/src/stream/collector.ts b/src/stream/collector.ts index 765cd80..5b6bd67 100644 --- a/src/stream/collector.ts +++ b/src/stream/collector.ts @@ -98,6 +98,104 @@ function extractMessagesList(messagesRes: unknown): unknown[] { return []; } +function createAbortError(): Error { + const error = new Error('Request aborted'); + error.name = 'AbortError'; + return error; +} + +function toolPartStatus(part: unknown): unknown { + const record = asRecord(part); + const state = asRecord(record['state']); + return state['status'] ?? record['status']; +} + +function isActiveToolPart(part: unknown): boolean { + const status = toolPartStatus(part); + return status === 'pending' || status === 'running'; +} + +function readSessionEventError(properties: unknown): unknown { + const props = asRecord(properties); + const direct = props['error']; + if (direct != null) return direct; + const data = asRecord(props['data']); + if (data['error'] != null) return data['error']; + const message = props['message'] ?? data['message']; + if (message != null) { + return { name: typeof props['name'] === 'string' ? props['name'] : 'SessionError', data: { message: String(message) } }; + } + return { name: 'SessionError', data: { message: 'OpenCode session error' } }; +} + +async function readSessionMessages( + client: ProxyClient, + sessionId: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + if (signal?.aborted) throw createAbortError(); + const controller = new AbortController(); + const onAbort = (): void => controller.abort(); + signal?.addEventListener('abort', onAbort, { once: true }); + let timer: ReturnType | undefined; + let removeAbortListener = (): void => {}; + let request: Promise; + try { + request = Promise.resolve(client.session.messages({ path: { id: sessionId }, signal: controller.signal })); + } catch (error: unknown) { + request = Promise.reject(error); + } + request.catch(() => {}); + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error(`Request timeout after ${timeoutMs}ms (poll messages)`)); + }, Math.max(1, timeoutMs)); + }); + const abortPromise = signal + ? new Promise((_, reject) => { + const onExternalAbort = (): void => reject(createAbortError()); + removeAbortListener = (): void => signal.removeEventListener('abort', onExternalAbort); + if (signal.aborted) onExternalAbort(); + else signal.addEventListener('abort', onExternalAbort, { once: true }); + }) + : null; + try { + return await Promise.race(abortPromise ? [request, timeoutPromise, abortPromise] : [request, timeoutPromise]); + } catch (error: unknown) { + if (signal?.aborted) throw createAbortError(); + throw error; + } finally { + if (timer !== undefined) clearTimeout(timer); + removeAbortListener(); + signal?.removeEventListener('abort', onAbort); + } + +} + +async function waitForPollDelay(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + await sleep(ms); + return; + } + if (signal.aborted) throw createAbortError(); + await new Promise((resolve, reject) => { + let timer: ReturnType; + const onAbort = (): void => { + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + reject(createAbortError()); + }; + timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); +} + /** * Create per-instance collector closing over the OpenCode client + logDebug * (same closure semantics as the original createApp inner functions). @@ -116,6 +214,7 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { sessionId: string, timeoutMs: number, intervalMs: number = DEFAULT_POLL_INTERVAL_MS, + signal?: AbortSignal, ): Promise<{ content: string; reasoning: string; error: unknown; toolParts: unknown[] }> { // Drift signal only: backend-minted ses_* IDs should match the canonical // shape (see src/session/ids.ts). Never block on mismatch — the schema @@ -130,8 +229,13 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { // a timeout fallback and otherwise wait for the message to actually finish. 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); + if (signal?.aborted) throw createAbortError(); + const remainingMs = Math.max(1, timeoutMs - (Date.now() - startedAt)); + const messagesRes: unknown = await readSessionMessages(client, sessionId, remainingMs, signal); + const responseError = asRecord(messagesRes)['error']; + if (responseError != null) return { content: '', reasoning: '', error: responseError, toolParts: [] }; + const messages = extractMessagesList(messagesRes); + if (Array.isArray(messages) && messages.length) { for (let i = messages.length - 1; i >= 0; i -= 1) { const entry = asRecord(messages[i]); @@ -139,12 +243,16 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { if (info['role'] !== 'assistant') continue; const { content, reasoning, toolParts } = extractFromParts(entry['parts'] ?? []); const error: unknown = info['error'] ?? null; - // finish === 'tool' marks an intermediate turn that pauses for a tool - // result; the assistant is not done producing output yet. - const finish: unknown = info['finish']; - const time = asRecord(info['time']); - const finished = info['finish'] != null && finish !== 'tool'; - const done = Boolean(finished || time['completed'] || error); + // finish === 'tool' and 'tool-calls' mark an intermediate turn that pauses for a + // tool result; the assistant is not done producing output yet, even when the + // message snapshot already carries a completion timestamp. + const finish: unknown = info['finish']; + const time = asRecord(info['time']); + const activeToolParts = toolParts.filter((part) => isActiveToolPart(part)); + const awaitingTool = finish === 'tool' || finish === 'tool-calls' || activeToolParts.length > 0; + const finished = info['finish'] != null && !awaitingTool; + const done = Boolean(finished || (!awaitingTool && time['completed']) || error); + if (toolParts.length > 0) { logDebug('Polling found tool parts', { sessionId, @@ -183,7 +291,9 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { break; } } - await sleep(intervalMs); + const remainingAfterSnapshot = Math.max(1, timeoutMs - (Date.now() - startedAt)); + await waitForPollDelay(Math.min(intervalMs, remainingAfterSnapshot), signal); + } if (lastPartial) { logDebug('Polling timeout with partial response', { @@ -204,10 +314,31 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { onDelta?: ((delta: string, isReasoning?: boolean) => void) | null, firstDeltaTimeoutMs?: number | null, idleTimeoutMs?: number | null, + signal?: AbortSignal, ): Promise> { if (!isValidSessionId(sessionId)) logDebug('Unexpected backend session ID format', { sessionId }); + if (signal?.aborted) return { content: '', reasoning: '', cancelled: true }; const controller = new AbortController(); - const eventStreamResult: unknown = await client.event.subscribe({ signal: controller.signal }); + const CANCELLED = Symbol('collector-cancelled'); + let removeSubscribeAbortListener = (): void => {}; + const subscribePromise = Promise.resolve().then(() => client.event.subscribe({ signal: controller.signal })); + const subscribeAbortPromise = new Promise((resolve) => { + const onAbort = (): void => { + controller.abort(); + resolve(CANCELLED); + }; + if (signal?.aborted) onAbort(); + else signal?.addEventListener('abort', onAbort, { once: true }); + removeSubscribeAbortListener = (): void => signal?.removeEventListener('abort', onAbort); + }); + let eventStreamResult: unknown; + try { + eventStreamResult = await Promise.race([subscribePromise, subscribeAbortPromise]); + } finally { + removeSubscribeAbortListener(); + } + if (eventStreamResult === CANCELLED) return { content: '', reasoning: '', cancelled: true }; + const eventStream = asRecord(eventStreamResult)['stream'] as AsyncIterable; let finished = false; let content = ''; @@ -219,10 +350,75 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { // tool call is active, the stream must stay open even if no text deltas arrive // (the backend is executing the tool). Resolving early here is what previously // truncated streaming responses that relied on internal tool execution. - const activeToolCallIds = new Set(); - const startedAt = Date.now(); + const activeToolCallIds = new Set(); + // ID-less backend tool parts carry no stable `id` (older servers / some + // internal tools). A cumulative counter inflates here because + // `message.part.updated` fires repeatedly for the same logical tool while + // it stays pending (N pendings, 1 terminal) and `message.updated` + // snapshots re-deliver the same part on every update — pinning the idle + // exemption until REQUEST_TIMEOUT_MS. Track ID-less activity as a Set + // keyed by tool name instead: idempotent on repeats, concurrent across + // distinct tool names. Same-name concurrent ID-less tools share a bucket + // (accepted: rarer than repeat snapshots; degrades to boolean, not hang). + const activeNoidToolKeys = new Set(); + // Terminals already observed. `message.updated` snapshots re-deliver the + // same completed part on every update, so a replayed terminal must be + // ignored instead of falling through to the mismatch path below (which + // would delete an innocent still-running bucket). + const settledNoidKeys = new Set(); + const noidKeyFor = (pr: Record): string => { + const tool = pr['tool']; + return typeof tool === 'string' && tool.trim() ? `noid:${tool.trim()}` : 'noid:unknown'; + }; + const hasActiveTools = (): boolean => activeToolCallIds.size > 0 || activeNoidToolKeys.size > 0; + // Bucket count for debug logging only (not a tool count: same-name + // concurrent ID-less tools share one bucket by design). + const activeToolCount = (): number => activeToolCallIds.size + activeNoidToolKeys.size; + const trackToolState = (part: unknown): void => { + // Unknown statuses are ignored (neither active nor terminal) to avoid + // false idle exemption. + const pr = asRecord(part); + if (pr['type'] !== 'tool') return; + const status = toolPartStatus(pr); + const id = typeof pr['id'] === 'string' ? (pr['id'] as string) : null; + if (status === 'pending' || status === 'running') { + if (id) activeToolCallIds.add(id); + else { + const key = noidKeyFor(pr); + // A re-run of the same tool after completion is a new logical tool. + settledNoidKeys.delete(key); + activeNoidToolKeys.add(key); + } + } else if (status === 'completed' || status === 'error') { + if (id) activeToolCallIds.delete(id); + else { + const key = noidKeyFor(pr); + if (activeNoidToolKeys.has(key)) { + activeNoidToolKeys.delete(key); + settledNoidKeys.add(key); + } else if (settledNoidKeys.has(key)) { + // Replayed terminal snapshot for an already-settled tool: ignore. + } else if (activeNoidToolKeys.size === 1) { + // Single-bucket name mismatch between pending and terminal (e.g. + // one side omits `tool`): clear the only bucket so a single + // completion cannot pin the stream open forever. + const first = activeNoidToolKeys.values().next(); + if (!first.done) { + settledNoidKeys.add(first.value); + activeNoidToolKeys.delete(first.value); + } + } + // Multi-bucket ambiguity (size !== 1, unknown key): ignore rather + // than deleting an innocent tool. Truncating an answer via an early + // idle/stop is worse than waiting for the real terminal. + } + } + }; + const startedAt = Date.now(); + let removeCollectAbortListener = (): void => {}; + + const finishPromise = new Promise>((resolve, reject) => { - const finishPromise = new Promise>((resolve, reject) => { const timeoutId = setTimeout(() => { if (finished) return; finished = true; @@ -240,8 +436,23 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { }, firstDeltaTimeoutMs) : null; - let idleTimer: ReturnType | null = null; - const scheduleIdleTimer = (): void => { + let idleTimer: ReturnType | null = null; + const onSignalAbort = (): void => { + if (finished) return; + finished = true; + clearTimeout(timeoutId); + if (firstDeltaTimer) clearTimeout(firstDeltaTimer); + if (idleTimer) clearTimeout(idleTimer); + controller.abort(); + resolve({ content, reasoning, cancelled: true }); + }; + if (signal) { + signal.addEventListener('abort', onSignalAbort, { once: true }); + removeCollectAbortListener = (): void => signal.removeEventListener('abort', onSignalAbort); + if (signal.aborted) onSignalAbort(); + } + const scheduleIdleTimer = (): void => { + if (!idleTimeoutMs) return; if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { @@ -249,11 +460,11 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { // A tool call is still executing on the backend. Keep the stream open // and wait instead of cutting the response short; the follow-up text // (or the final completion) will arrive once the tool finishes. - if (activeToolCallIds.size > 0) { + if (hasActiveTools()) { logDebug('Event idle while internal tool call is active, continuing to wait', { sessionId, ms: Date.now() - startedAt, - activeTools: activeToolCallIds.size, + activeTools: activeToolCount(), }); scheduleIdleTimer(); return; @@ -274,18 +485,11 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { }, idleTimeoutMs); }; - const trackToolActivity = (part: unknown): void => { - const pr = asRecord(part); - if (pr['type'] !== 'tool') return; - const state = asRecord(pr['state']); - const status = state['status']; - const id = typeof pr['id'] === 'string' ? (pr['id'] as string) : null; - if (status === 'pending' || status === 'running') { - if (id) activeToolCallIds.add(id); - } else if (status === 'completed' || status === 'error') { - if (id) activeToolCallIds.delete(id); - } - // Tool activity means the session is still working; treat it as progress + const trackToolActivity = (part: unknown): void => { + trackToolState(part); + + // Tool activity means the session is still working; treat it as progress + // so the idle timer does not terminate the stream mid-execution. receivedDelta = true; if (firstDeltaTimer) clearTimeout(firstDeltaTimer); @@ -330,16 +534,34 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { (async (): Promise => { try { for await (const event of eventStream) { - const evType: unknown = (event as Record)['type']; - const props = asRecord((event as Record)['properties']); - if (evType === 'message.part.updated' && asRecord(props['part'])['sessionID'] === sessionId) { + const evType: unknown = (event as Record)['type']; + const props = asRecord((event as Record)['properties']); + if (finished) break; + if (evType === 'session.error' && (props['sessionID'] == null || props['sessionID'] === sessionId)) { + const sessionError = readSessionEventError(props); + finished = true; + clearTimeout(timeoutId); + if (firstDeltaTimer) clearTimeout(firstDeltaTimer); + if (idleTimer) clearTimeout(idleTimer); + controller.abort(); + logDebug('SSE session error', { sessionId, ms: Date.now() - startedAt }); + resolve({ content, reasoning, error: sessionError }); + break; + } + if (evType === 'message.part.updated' && asRecord(props['part'])['sessionID'] === sessionId) { + const part = asRecord(props['part']); rememberPartType(part); trackToolActivity(part); // Older OpenCode servers carried the streaming delta directly on // message.part.updated; newer servers emit message.part.delta. - const delta: unknown = props['delta']; - if (typeof delta === 'string' && delta) applyTextDelta(part['type'], delta); + const delta: unknown = props['delta']; + if ( + typeof delta === 'string' && + delta && + (part['type'] === 'text' || part['type'] === 'reasoning') + ) applyTextDelta(part['type'], delta); + continue; } if (evType === 'message.part.delta' && props['sessionID'] === sessionId) { @@ -363,7 +585,7 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { // this, the collector waits out the whole first-delta window before // polling rediscovers the same error. const infoError: unknown = info['error']; - if (infoError && !finish) { + if (infoError != null) { finished = true; clearTimeout(timeoutId); if (firstDeltaTimer) clearTimeout(firstDeltaTimer); @@ -381,19 +603,12 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { if (Array.isArray(info['parts'])) { for (const part of info['parts'] as unknown[]) { rememberPartType(part); - const pr = asRecord(part); - if (pr['type'] === 'tool') { - const status = asRecord(pr['state'])['status']; - const id = typeof pr['id'] === 'string' ? (pr['id'] as string) : null; - if (status === 'pending' || status === 'running') { - if (id) activeToolCallIds.add(id); - } else if (status === 'completed' || status === 'error') { - if (id) activeToolCallIds.delete(id); - } - } + trackToolState(part); + + } } - if (finish === 'tool') { + if (finish === 'tool' || finish === 'tool-calls') { // Assistant turn ended pending a tool call; keep waiting for the result. if (firstDeltaTimer) clearTimeout(firstDeltaTimer); scheduleIdleTimer(); @@ -403,10 +618,10 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { // Only treat the stream as completed when no tool call is still // pending. OpenCode may emit an intermediate 'stop' snapshot while a // tool call is in flight; resolving on it would drop the final answer. - if (activeToolCallIds.size > 0) { + if (hasActiveTools()) { logDebug('Ignoring intermediate stop while tools are active', { sessionId, - activeTools: activeToolCallIds.size, + activeTools: activeToolCount(), }); continue; } @@ -420,7 +635,7 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { ms: Date.now() - startedAt, deltaChars, }); - resolve({ content, reasoning }); + resolve(content || reasoning ? { content, reasoning } : { content, reasoning, noData: true }); } break; } @@ -440,9 +655,11 @@ export function createCollector(deps: CollectorDeps): CollectorHandle { try { return await finishPromise; - } finally { - controller.abort(); - } + } finally { + removeCollectAbortListener(); + controller.abort(); + } + } return { extractFromParts, promptWithTimeout, pollForAssistantResponse, collectFromEvents }; diff --git a/src/tool-runtime/parser.ts b/src/tool-runtime/parser.ts index 226a597..c45297f 100644 --- a/src/tool-runtime/parser.ts +++ b/src/tool-runtime/parser.ts @@ -65,6 +65,7 @@ export type ToolCallStreamParser = ((chunk: string) => FinalToolCall[]) & { flus interface ExtractedCalls { calls: RawToolCall[]; spans: TextSpan[]; + valid?: boolean; } interface JsonValueLocation { @@ -89,14 +90,149 @@ const RE = { invokeParam: new RegExp(`<${MARK}parameter\\s+name\\s*=\\s*["']([^"']+)["']([^>]*)>([\\s\\S]*?)`, 'g'), // Singular JSON wrapper. Plural is handled by dsmlContainer, which // falls through to JSON parsing when it holds no invoke blocks. - jsonWrapper: /([\s\S]*?)<\/tool_call\s*>/g, + jsonWrapper: /<\u200b?tool_call\s*>([\s\S]*?)<\/\u200b?tool_call\s*>/g, + // Opener/closer pairs counted separately so an envelope that never closes is still + // detected; the paired patterns above only match complete blocks. + dsmlOpen: new RegExp(`<${MARK}tool_calls\\s*>`, 'g'), + dsmlClose: new RegExp(``, 'g'), + jsonOpen: /<\u200b?tool_call\s*>/g, + jsonClose: /<\/\u200b?tool_call\s*>/g, codeFence: /^\s*```(?:[a-zA-Z0-9_-]*)\s*\n([\s\S]*?)\n?\s*```\s*$/, + jsonNameField: /["'](?:tool_name|tool|name)["']\s*:\s*["']([^"'\n]{1,200})["']/g, leadingNewline: /^\r?\n/, trailingNewline: /\r?\n[ \t]*$/ }; const escapeRegExp = (value: unknown): string => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +type ScannedTagName = 'canonical' | 'tool_calls' | 'tool_call' | 'invoke' | 'parameter' | 'function'; + +interface ScannedMarkupTag { + start: number; + end: number; + closing: boolean; + name: ScannedTagName; + raw: string; +} + +interface MarkupScan { + tags: ScannedMarkupTag[]; + unterminatedStart: number | null; +} + +interface MarkupBlock { + start: number; + end: number; + bodyStart: number; + bodyEnd: number; +} + +function maskDoubleQuotedStrings(source: string): string { + const chars = source.split(''); + let inString = false; + let escaped = false; + for (let index = 0; index < chars.length; index += 1) { + const ch = chars[index] as string; + if (inString) { + chars[index] = ' '; + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + chars[index] = ' '; + } + } + return chars.join(''); +} + +function matchAllOutsideStrings(source: string, pattern: RegExp): RegExpExecArray[] { + const masked = maskDoubleQuotedStrings(source); + return [...source.matchAll(pattern)].filter((match) => { + const index = match.index ?? 0; + return masked[index] === source[index]; + }); +} + +function classifyMarkupTag(raw: string): { closing: boolean; name: ScannedTagName } | null { + if (/^$/i.test(raw)) return { closing: false, name: 'canonical' }; + if (/^<\/function_calls>$/i.test(raw)) return { closing: true, name: 'canonical' }; + if (/^<\u200b?tool_call\s*>$/i.test(raw)) return { closing: false, name: 'tool_call' }; + if (/^<\/\u200b?tool_call\s*>$/i.test(raw)) return { closing: true, name: 'tool_call' }; + const dsmlToolCalls = new RegExp(`^<${MARK}tool_calls\\s*>$`, 'i'); + const dsmlToolCallsClose = new RegExp(`^$`, 'i'); + if (dsmlToolCalls.test(raw)) return { closing: false, name: 'tool_calls' }; + if (dsmlToolCallsClose.test(raw)) return { closing: true, name: 'tool_calls' }; + const dsmlInvoke = new RegExp(`^<${MARK}invoke\\b`, 'i'); + const dsmlInvokeClose = new RegExp(`^$`, 'i'); + if (dsmlInvoke.test(raw)) return { closing: false, name: 'invoke' }; + if (dsmlInvokeClose.test(raw)) return { closing: true, name: 'invoke' }; + const dsmlParameter = new RegExp(`^<${MARK}parameter\\b`, 'i'); + const dsmlParameterClose = new RegExp(`^$`, 'i'); + if (dsmlParameter.test(raw)) return { closing: false, name: 'parameter' }; + if (dsmlParameterClose.test(raw)) return { closing: true, name: 'parameter' }; + if (/^]+\s*>$/i.test(raw)) return { closing: false, name: 'function' }; + if (/^<\/function\s*>$/i.test(raw)) return { closing: true, name: 'function' }; + return null; +} + +function scanMarkupTags(source: string): MarkupScan { + const masked = maskDoubleQuotedStrings(source); + const tags: ScannedMarkupTag[] = []; + let unterminatedStart: number | null = null; + for (let index = 0; index < source.length;) { + if (masked[index] !== '<') { + index += 1; + continue; + } + const end = findTagEnd(source, index + 1); + if (end === -1) { + const remainder = masked.slice(index); + if (new RegExp(`^<(?:\\u200b?tool_call|${MARK}(?:tool_calls|invoke|parameter)|function_calls)`, 'i').test(remainder)) { + unterminatedStart = index; + } + break; + } + const raw = source.slice(index, end + 1); + const classified = classifyMarkupTag(raw); + if (classified) tags.push({ start: index, end: end + 1, raw, ...classified }); + index = end + 1; + } + return { tags, unterminatedStart }; +} + +function findMarkupBlocks(scan: MarkupScan, name: ScannedTagName): MarkupBlock[] { + const stack: ScannedMarkupTag[] = []; + const blocks: MarkupBlock[] = []; + for (const tag of scan.tags) { + if (tag.name !== name) continue; + if (!tag.closing) { + stack.push(tag); + continue; + } + const open = stack.pop(); + if (!open) continue; + blocks.push({ start: open.start, end: tag.end, bodyStart: open.end, bodyEnd: tag.start }); + } + return blocks.sort((a, b) => a.start - b.start); +} + +function hasBalancedMarkupTag(scan: MarkupScan, name: ScannedTagName): boolean { + const stack: ScannedMarkupTag[] = []; + for (const tag of scan.tags) { + if (tag.name !== name) continue; + if (!tag.closing) { + stack.push(tag); + continue; + } + if (!stack.length) return false; + stack.pop(); + } + return stack.length === 0; +} + /** Tool names accepted for the registry-gated formats, longest first so `; + if (Array.isArray(record['tool_calls'])) return record['tool_calls'] as unknown[]; + if (Array.isArray(record['invokes'])) return record['invokes'] as unknown[]; + } + return [parsed]; +} + +function isJsonMarkupPrefixOrSuffix(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return true; + if (/^```[\s\S]*```$/.test(trimmed)) return true; + return new RegExp(`^(?:\\s|)+$`, 'i').test(trimmed); +} + +function hasUnclosedJsonDelimiter(value: string): boolean { + const stack: string[] = []; + let inString = false; + let escaped = false; + for (const ch of value) { + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{' || ch === '[') stack.push(ch); + else if (ch === '}' || ch === ']') { + const expected: string = ch === '}' ? '{' : '['; + if (stack[stack.length - 1] === expected) stack.pop(); + } + } + return stack.length > 0 || inString; +} + +function parseJsonEnvelopeText(text: unknown): { parsed: unknown } | null { + const trimmed = String(text ?? '').trim(); + if (!trimmed) return null; + try { + return { parsed: JSON.parse(trimmed) as unknown }; + } catch { + const found = findFirstJsonValue(trimmed); + if (!found) return null; + const prefix = trimmed.slice(0, found.start).trim(); + if (hasUnclosedJsonDelimiter(prefix)) return null; + if (!isJsonMarkupPrefixOrSuffix(trimmed.slice(found.end))) return null; + try { + return { parsed: JSON.parse(found.json) as unknown }; + } catch { + return null; + } + } +} + +function rawCallsFromJsonEnvelopeText(text: unknown): RawToolCall[] { + const parsed = parseJsonEnvelopeText(text); + return parsed ? rawCallsFromJsonPayload(parsed.parsed) : []; +} + +function hasValidJsonCallEnvelope(text: unknown, registry?: unknown): boolean { + const parsed = parseJsonEnvelopeText(text); + if (!parsed) return false; + const candidates = jsonPayloadCandidates(parsed.parsed); + if (!candidates.length) return false; + const calls = candidates.map((candidate) => rawCallFromJson(candidate)); + if (calls.some((call) => call === null)) return false; + if (registry !== undefined && registry !== null && calls.some((call) => !findExternalToolByName(registry, call?.name))) return false; + return true; +} + +function rawCallsFromJsonFragments(text: unknown): RawToolCall[] { + const source = String(text ?? ''); + const calls: RawToolCall[] = []; + let offset = 0; + while (offset < source.length) { + const found = findFirstJsonValue(source.slice(offset)); + if (!found) break; + calls.push(...rawCallsFromJsonPayload(JSON.parse(found.json) as unknown)); + const next = offset + found.end; + if (next <= offset) break; + offset = next; + } + return calls; +} + /** Byte offset just past the JSON value starting at `start`, or -1 if it never closes. */ function findJsonEnd(text: string, start: number): number { const opener: string = text[start] as string; @@ -276,62 +503,225 @@ function findFirstJsonValue(text: string): JsonValueLocation | null { // --- format extractors ----------------------------------------------------- // Each returns { calls, spans } where spans are [start, end) ranges of consumed markup. -function extractCanonical(text: string): ExtractedCalls { +function hasSingleValidNestedCanonicalBody(body: string, registry?: unknown): boolean { + const nested = matchAllOutsideStrings(body, RE.canonicalBlock); + if (nested.length !== 1) return false; + const match = nested[0] as RegExpExecArray; + const start = match.index ?? 0; + return ( + body.slice(0, start).trim() === '' && + body.slice(start + match[0].length).trim() === '' && + hasValidJsonCallEnvelope(match[1], registry) + ); +} + +function extractCanonical(text: string, registry?: unknown): ExtractedCalls { const calls: RawToolCall[] = []; const spans: TextSpan[] = []; - for (const match of text.matchAll(RE.canonicalBlock)) { - const start: number = match.index ?? 0; - spans.push([start, start + match[0].length]); - calls.push(...rawCallsFromJsonText(match[1])); + let structurallyValid = hasValidCanonicalTagStructure(text); + const blocks = findMarkupBlocks(scanMarkupTags(text), 'canonical'); + for (const block of blocks) { + spans.push([block.start, block.end]); + const body = text.slice(block.bodyStart, block.bodyEnd); + if (hasValidJsonCallEnvelope(body, registry)) calls.push(...rawCallsFromJsonEnvelopeText(body)); + else if (!hasSingleValidNestedCanonicalBody(body, registry)) structurallyValid = false; } - return { calls, spans }; + return { calls: structurallyValid ? calls : [], spans, valid: structurallyValid }; } -function extractInvokeBlocks(segment: string): RawToolCall[] { +interface DsmlTagToken { + closing: boolean; + name: string; + start: number; + end: number; + raw: string; +} + +interface DsmlAnalysis { + hasMarkup: boolean; + valid: boolean; + calls: RawToolCall[]; + spans: TextSpan[]; +} + +function dsmlTagTokens(segment: string): { tokens: DsmlTagToken[]; unterminatedStart: number | null } { + const scan = scanMarkupTags(segment); + const dsmlSyntax = new RegExp(`<${MARK}(?:invoke|parameter)\\s+name\\s*=`, 'i').test(maskDoubleQuotedStrings(segment)); + const tokens: DsmlTagToken[] = []; + for (const tag of scan.tags) { + if (tag.name !== 'tool_calls' && tag.name !== 'invoke' && tag.name !== 'parameter') continue; + if (tag.name === 'parameter' && !dsmlSyntax) continue; + tokens.push({ closing: tag.closing, name: tag.name, start: tag.start, end: tag.end, raw: tag.raw }); + } + return { tokens, unterminatedStart: scan.unterminatedStart }; +} + +function dsmlInvokeName(raw: string): string | null { + const match = new RegExp(`^<${MARK}invoke\\s+name\\s*=\\s*(["'])([^"']+)\\1\\s*>$`, 'i').exec(raw); + return match?.[2] ?? null; +} + +function dsmlParameter(raw: string): { name: string; attrs: string } | null { + const match = new RegExp(`^<${MARK}parameter\\s+name\\s*=\\s*(["'])([^"']+)\\1([^>]*)>$`, 'i').exec(raw); + if (!match?.[2]) return null; + return { name: match[2], attrs: match[3] ?? '' }; +} + +function isDsmlClose(raw: string, name: string): boolean { + return new RegExp(`^$`, 'i').test(raw); +} + +function analyzeDsmlSegment(segment: string, allowOutsideText: boolean = false): DsmlAnalysis { + const scan = dsmlTagTokens(segment); + const hasMarkup = scan.tokens.length > 0 || scan.unterminatedStart !== null; + if (!hasMarkup) return { hasMarkup: false, valid: true, calls: [], spans: [] }; + const calls: RawToolCall[] = []; - for (const invoke of segment.matchAll(RE.invokeBlock)) { - const args: Record = {}; - for (const param of invoke[2].matchAll(RE.invokeParam)) { - args[param[1]] = coerceParamValue(trimParamValue(param[3]), param[2]); + const spans: TextSpan[] = []; + let valid = true; + let cursor = 0; + let invoke: { name: string; args: Record; start: number } | null = null; + let parameter: { name: string; attrs: string; contentStart: number } | null = null; + const firstMarkupStart = scan.unterminatedStart ?? scan.tokens[0]?.start ?? 0; + + for (const token of scan.tokens) { + if (!allowOutsideText && !parameter && segment.slice(cursor, token.start).trim()) valid = false; + if (token.name === 'tool_calls') valid = false; + if (token.closing) { + if (!isDsmlClose(token.raw, token.name)) valid = false; + if (token.name === 'parameter') { + if (!parameter || !invoke) { + valid = false; + } else { + invoke.args[parameter.name] = coerceParamValue( + trimParamValue(segment.slice(parameter.contentStart, token.start)), + parameter.attrs + ); + parameter = null; + } + } else if (token.name === 'invoke') { + if (!invoke || parameter) { + valid = false; + } else { + calls.push({ name: invoke.name, arguments: invoke.args }); + spans.push([invoke.start, token.end]); + invoke = null; + } + } + } else if (token.name === 'invoke') { + const name = dsmlInvokeName(token.raw); + if (invoke || parameter || !name) valid = false; + else invoke = { name, args: {}, start: token.start }; + } else if (token.name === 'parameter') { + const parsed = dsmlParameter(token.raw); + if (!invoke || parameter || !parsed) valid = false; + else parameter = { name: parsed.name, attrs: parsed.attrs, contentStart: token.end }; } - calls.push({ name: invoke[1], arguments: args }); + cursor = token.end; } - return calls; + + if (parameter) valid = false; + if (invoke) { + valid = false; + spans.push([invoke.start, segment.length]); + } + if (!allowOutsideText && segment.slice(cursor).trim()) valid = false; + if (!valid) spans.push([firstMarkupStart, segment.length]); + return { hasMarkup, valid, calls: valid ? calls : [], spans }; +} + +function extractInvokeBlocks(segment: string): RawToolCall[] { + return analyzeDsmlSegment(segment).calls; } /** DSML/plain `` containers, plus bare `` blocks outside any container. */ -function extractDsml(text: string): ExtractedCalls { +function dsmlRemainder(text: string): string { + const blocks = findMarkupBlocks(scanMarkupTags(text), 'tool_calls'); + let remainder = ''; + let cursor = 0; + for (const block of blocks) { + if (block.start < cursor) continue; + remainder += text.slice(cursor, block.start) + ' '.repeat(block.end - block.start); + cursor = block.end; + } + return remainder + text.slice(cursor); +} + +function extractDsml(text: string, registry?: unknown): ExtractedCalls { const calls: RawToolCall[] = []; const spans: TextSpan[] = []; - let remainder = text; - - for (const container of text.matchAll(RE.dsmlContainer)) { - const start: number = container.index ?? 0; - spans.push([start, start + container[0].length]); - const inner = extractInvokeBlocks(container[1]); - // A wrapper around plain JSON is also common. - calls.push(...(inner.length ? inner : rawCallsFromJsonText(container[1]))); - remainder = remainder.replace(container[0], ' '.repeat(container[0].length)); + let structurallyValid = true; + const scan = scanMarkupTags(text); + + for (const container of findMarkupBlocks(scan, 'tool_calls')) { + spans.push([container.start, container.end]); + const body = text.slice(container.bodyStart, container.bodyEnd); + const analysis = analyzeDsmlSegment(body); + if (analysis.hasMarkup) { + if ( + analysis.valid && + (registry === undefined || analysis.calls.every((call) => Boolean(findExternalToolByName(registry, call.name)))) + ) calls.push(...analysis.calls); + else structurallyValid = false; + } else { + const jsonCalls = rawCallsFromJsonEnvelopeText(body); + if ( + jsonCalls.length && + (registry === undefined || jsonCalls.every((call) => Boolean(findExternalToolByName(registry, call.name)))) + ) calls.push(...jsonCalls); + else structurallyValid = false; + } } - for (const invoke of remainder.matchAll(RE.invokeBlock)) { - const start: number = invoke.index ?? 0; - spans.push([start, start + invoke[0].length]); - calls.push(...extractInvokeBlocks(invoke[0])); + const naked = analyzeDsmlSegment(dsmlRemainder(text), true); + if (naked.hasMarkup) { + if ( + naked.valid && + (registry === undefined || naked.calls.every((call) => Boolean(findExternalToolByName(registry, call.name)))) + ) calls.push(...naked.calls); + else structurallyValid = false; + spans.push(...naked.spans); } - return { calls, spans }; + return { calls: structurallyValid ? calls : [], spans, valid: structurallyValid }; } -function extractJsonWrapper(text: string): ExtractedCalls { +interface NonJsonToolWrapperResult { + calls: RawToolCall[]; + spans: TextSpan[]; + valid: boolean; +} + +function extractNonJsonToolWrapper(registry: unknown, body: string): NonJsonToolWrapperResult { + const functionResult = extractFunctionEquals(body); + const tagResult = extractTagNamed(body, registryNames(registry), registrySchemas(registry)); + const calls = [...functionResult.calls, ...tagResult.calls]; + const spans = [...functionResult.spans, ...tagResult.spans]; + const valid = calls.length > 0 && !textOutsideSpans(body, spans).some((segment) => segment.trim()); + return { calls, spans, valid }; +} + +function extractJsonWrapper(text: string, registry?: unknown): ExtractedCalls { const calls: RawToolCall[] = []; const spans: TextSpan[] = []; - for (const match of text.matchAll(RE.jsonWrapper)) { - const start: number = match.index ?? 0; - spans.push([start, start + match[0].length]); - calls.push(...rawCallsFromJsonText(match[1])); + let structurallyValid = true; + for (const wrapper of findMarkupBlocks(scanMarkupTags(text), 'tool_call')) { + const body = text.slice(wrapper.bodyStart, wrapper.bodyEnd); + const bodyCalls = rawCallsFromJsonEnvelopeText(body); + if (bodyCalls.length) { + if (hasValidJsonCallEnvelope(body, registry)) { + calls.push(...bodyCalls); + spans.push([wrapper.start, wrapper.end]); + } else { + structurallyValid = false; + } + continue; + } + const nonJson = extractNonJsonToolWrapper(registry, body); + if (nonJson.valid) spans.push([wrapper.start, wrapper.end]); + else structurallyValid = false; } - return { calls, spans }; + return { calls: structurallyValid ? calls : [], spans, valid: structurallyValid }; } /** @@ -483,7 +873,7 @@ function extractTagNamed(text: string, names: string[], schemas: Map"'])`, 'g'); - for (const match of text.matchAll(opener)) { + for (const match of matchAllOutsideStrings(text, opener)) { const name: string = match[1]; const matchIndex: number = match.index ?? 0; const attrsStart = matchIndex + match[0].length; @@ -518,7 +908,8 @@ function extractTagNamed(text: string, names: string[], schemas: Map`; - const closeIdx = text.indexOf(closeTag, openEnd); + const closeMatch = matchAllOutsideStrings(text.slice(openEnd), new RegExp(``, 'gi'))[0]; + const closeIdx = closeMatch ? openEnd + (closeMatch.index ?? 0) : -1; const body = closeIdx === -1 ? text.slice(openEnd) : text.slice(openEnd, closeIdx); const json = findFirstJsonValue(body); const consumedEnd = closeIdx === -1 @@ -535,17 +926,23 @@ function extractTagNamed(text: string, names: string[], schemas: Map names.includes(call.name)); - if (!calls.length) return { calls: [], spans: [] }; - return { calls, spans: [[0, text.length]] }; + const parsed = parseJsonEnvelopeText(body); + if (!parsed) { + const known = rawCallsFromJsonFragments(body).some((call) => Boolean(findExternalToolByName(registry, call.name))) || hasKnownJsonNameHint(registry, body); + return known ? { calls: [], spans: [[0, text.length]] } : { calls: [], spans: [] }; + } + const candidates = jsonPayloadCandidates(parsed.parsed); + if (!candidates.length) return { calls: [], spans: [] }; + const calls = candidates.map((candidate) => rawCallFromJson(candidate)); + if (calls.some((call) => call === null || !findExternalToolByName(registry, call?.name))) { + const known = calls.some((call) => call !== null && Boolean(findExternalToolByName(registry, call.name))); + return known ? { calls: [], spans: [[0, text.length]] } : { calls: [], spans: [] }; + } + return { calls: calls as RawToolCall[], spans: [[0, text.length]] }; } /** @@ -588,29 +994,54 @@ function extractBareJson(text: string, names: string[]): ExtractedCalls { * whole `...` block for hiding. Name mapping (e.g. `webfetch` → * `web_fetch`) is resolved later against the request's registry. */ -function extractFunctionEquals(text: string): ExtractedCalls { +function functionEqualsAllowsEmptyArguments(name: string, registry: unknown): boolean { + if (registry === undefined || registry === null) return true; + const tool = findExternalToolByName(registry, name); + if (!tool) return false; + const parameters = tool.parameters && typeof tool.parameters === 'object' ? tool.parameters as Record : {}; + const required = parameters['required']; + return !Array.isArray(required) || required.length === 0; +} + +function extractFunctionEquals(text: string, registry?: unknown): ExtractedCalls { const calls: RawToolCall[] = []; const spans: TextSpan[] = []; + let structurallyValid = true; const openerRe = /]+)\s*>/gi; - for (const match of text.matchAll(openerRe)) { + for (const match of matchAllOutsideStrings(text, openerRe)) { const name: string = String(match[1]).trim(); if (!name) continue; const matchIndex: number = match.index ?? 0; const openEnd = matchIndex + match[0].length; - const closeIdx = text.indexOf('', openEnd); - const end = closeIdx === -1 ? text.length : closeIdx + ''.length; - const body = closeIdx === -1 ? text.slice(openEnd) : text.slice(openEnd, closeIdx); + const closeMatch = matchAllOutsideStrings(text.slice(openEnd), /<\/function\s*>/gi)[0]; + const closeIdx = closeMatch ? openEnd + (closeMatch.index ?? 0) : -1; + if (closeIdx === -1) { + spans.push([matchIndex, openEnd]); + structurallyValid = false; + continue; + } + const end = closeIdx + String(closeMatch[0]).length; + const body = text.slice(openEnd, closeIdx); const args: Record = {}; const paramRe = /]+)\s*>([\s\S]*?)<\/parameter\s*>/gi; - for (const param of body.matchAll(paramRe)) { + for (const param of matchAllOutsideStrings(body, paramRe)) { args[String(param[1]).trim()] = trimParamValue(param[2]); } + const openCount = (body.match(//gi) || []).length; + if (openCount !== closeCount || Object.keys(args).length !== openCount) structurallyValid = false; + if (!Object.keys(args).length) { + spans.push([matchIndex, end]); + if (functionEqualsAllowsEmptyArguments(name, registry)) calls.push({ name, arguments: {} }); + else structurallyValid = false; + continue; + } calls.push({ name, arguments: args }); spans.push([matchIndex, end]); } - return { calls, spans }; + return { calls, spans, valid: structurallyValid }; } /** Every format in one pass. `spans` cover all markup that should be hidden from users. */ @@ -621,39 +1052,237 @@ function collectAll(text: unknown, registry: unknown): ExtractedCalls { const schemas = registrySchemas(registry); const results: ExtractedCalls[] = [ - extractCanonical(source), - extractDsml(source), - extractJsonWrapper(source), - extractFunctionEquals(source), + extractCanonical(source, registry), + extractDsml(source, registry), + extractJsonWrapper(source, registry), + extractFunctionEquals(source, registry), extractTagNamed(source, names, schemas), - extractBareJson(source, names) + extractBareJson(source, registry) ]; - const seen = new Set(); + const seenExplicit = new Set(); const calls: RawToolCall[] = []; results.forEach((result) => { result.calls.forEach((call) => { - // Distinct formats can describe the same call (e.g. a tag wrapping JSON that - // also names the tool); keep one entry per name+arguments pair. - const key = `${call.name}::${JSON.stringify(call.arguments)}`; - if (seen.has(key)) return; - seen.add(key); + const explicit = typeof call.id === 'string' && Boolean(call.id); + if (explicit) { + const key = `id:${call.id}::${toolCallKey(call.name, call.arguments)}`; + if (seenExplicit.has(key)) return; + seenExplicit.add(key); + } calls.push(call); }); }); - return { calls, spans: results.flatMap((result) => result.spans) }; + const structurallyValid = results.every((result) => result.valid !== false); + return { calls: structurallyValid ? calls : [], spans: results.flatMap((result) => result.spans) }; +} + +const GENERATED_CALL_MARKER = '__opencode_generated_tool_call__'; + +type InternalFinalToolCall = FinalToolCall & { [GENERATED_CALL_MARKER]?: boolean }; + +function markGeneratedToolCall(call: FinalToolCall): FinalToolCall { + const marked = { ...call } as InternalFinalToolCall; + Object.defineProperty(marked, GENERATED_CALL_MARKER, { + value: true, + enumerable: false, + configurable: true, + }); + return marked; +} + +function isGeneratedToolCall(call: unknown): boolean { + return Boolean( + call && + typeof call === 'object' && + (call as Record)[GENERATED_CALL_MARKER] === true, + ); } function toFinalCalls(rawCalls: RawToolCall[], seed = 0): FinalToolCall[] { - return rawCalls.map((call, index) => ({ - id: typeof call.id === 'string' && call.id ? call.id : `call_${Date.now()}_${seed + index + 1}`, - type: 'function' as const, - function: { - name: call.name, - arguments: typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {}) + return rawCalls.map((call, index) => { + const finalCall: FinalToolCall = { + id: typeof call.id === 'string' && call.id ? call.id : `call_${Date.now()}_${seed + index + 1}`, + type: 'function' as const, + function: { + name: call.name, + arguments: typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {}) + } + }; + return typeof call.id === 'string' && call.id ? finalCall : markGeneratedToolCall(finalCall); + }); +} + +function stringifyToolArguments(args: unknown): string { + if (typeof args === 'string') return args; + try { + return JSON.stringify(args ?? {}) ?? '{}'; + } catch { + return '{}'; + } +} + +function canonicalizeJsonValue(value: unknown, seen: WeakSet = new WeakSet()): unknown { + if (Array.isArray(value)) { + if (seen.has(value)) throw new Error('circular tool arguments'); + seen.add(value); + const canonical = value.map((item) => canonicalizeJsonValue(item, seen)); + seen.delete(value); + return canonical; + } + if (value && typeof value === 'object') { + if (seen.has(value)) throw new Error('circular tool arguments'); + seen.add(value); + const record = value as Record; + const canonical: Record = Object.create(null) as Record; + Object.keys(record).sort().forEach((key) => { + canonical[key] = canonicalizeJsonValue(record[key], seen); + }); + seen.delete(value); + return canonical; + } + return value; +} + +function canonicalToolArguments(args: unknown): string { + let value = args; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + return stringifyToolArguments(args); + } + } + try { + return JSON.stringify(canonicalizeJsonValue(value)) ?? stringifyToolArguments(args); + } catch { + return stringifyToolArguments(args); + } +} + +function toolCallKey(name: string, args: unknown): string { + return `${name}\u0000${canonicalToolArguments(args)}`; +} + +export function mergeToolCallArtifacts(...artifacts: unknown[]): FinalToolCall[] { + const perArtifact: unknown[][] = []; + const visitInto = (target: unknown[], value: unknown): void => { + if (Array.isArray(value)) { + value.forEach((entry) => visitInto(target, entry)); + return; + } + if (value && typeof value === 'object') { + const nested = (value as Record)['calls']; + if (Array.isArray(nested)) { + nested.forEach((entry) => visitInto(target, entry)); + return; + } + target.push(value); + } + }; + artifacts.forEach((artifact) => { + const list: unknown[] = []; + visitInto(list, artifact); + perArtifact.push(list); + }); + const values: unknown[] = perArtifact.flat(); + + const reservedExplicitIds = new Set(); + values.forEach((value: unknown) => { + const record = value as Record; + const generated = isGeneratedToolCall(record); + const id = record['id']; + if (!generated && typeof id === 'string' && id) reservedExplicitIds.add(id); + }); + + const output: FinalToolCall[] = []; + const usedIds = new Set(); + const seenExplicitArtifacts = new Set(); + const generatedMaxPerKey = new Map(); + const generatedEmittedPerKey = new Map(); + const generatedCounts = new Map(); + perArtifact.forEach((list) => { + const perKey = new Map(); + list.forEach((value: unknown) => { + const record = value as Record; + const fn = record['function']; + const fnRecord = fn && typeof fn === 'object' && !Array.isArray(fn) + ? (fn as Record) + : {}; + const generated = isGeneratedToolCall(record) || (typeof record['id'] !== 'string' || !record['id']); + if (!generated) return; + const key = toolCallKey(String(fnRecord['name'] ?? ''), fnRecord['arguments'] ?? {}); + perKey.set(key, (perKey.get(key) ?? 0) + 1); + }); + perKey.forEach((count, key) => { + generatedMaxPerKey.set(key, Math.max(generatedMaxPerKey.get(key) ?? 0, count)); + }); + }); + + values.forEach((value: unknown) => { + const record = value as Record; + const fn = record['function']; + const fnRecord = fn && typeof fn === 'object' && !Array.isArray(fn) + ? (fn as Record) + : {}; + const name = String(fnRecord['name'] ?? ''); + const args = fnRecord['arguments'] ?? {}; + const generated = isGeneratedToolCall(record) || (typeof record['id'] !== 'string' || !record['id']); + const rawId = typeof record['id'] === 'string' ? record['id'] : ''; + + if (!generated) { + const key = `${rawId}\u0000${toolCallKey(name, args)}`; + if (seenExplicitArtifacts.has(key)) return; + seenExplicitArtifacts.add(key); + usedIds.add(rawId); + output.push({ + id: rawId, + type: 'function', + function: { + name, + arguments: stringifyToolArguments(args) + } + }); + return; + } + + const key = toolCallKey(name, args); + const allowed = generatedMaxPerKey.get(key) ?? 1; + const emitted = generatedEmittedPerKey.get(key) ?? 0; + if (emitted >= allowed) return; + generatedEmittedPerKey.set(key, emitted + 1); + const generatedBase = rawId || `call_${name.replace(/[^a-zA-Z0-9_]/g, '_') || 'tool'}`; + let id: string; + if (rawId) { + id = rawId; + let suffix = generatedCounts.get(rawId) || 0; + while (usedIds.has(id) || reservedExplicitIds.has(id)) { + suffix += 1; + id = `${rawId}_${suffix + 1}`; + } + generatedCounts.set(rawId, suffix); + } else { + let count = (generatedCounts.get(generatedBase) || 0) + 1; + id = `${generatedBase}_${count}`; + while (usedIds.has(id) || reservedExplicitIds.has(id)) { + count += 1; + id = `${generatedBase}_${count}`; + } + generatedCounts.set(generatedBase, count); } - })); + usedIds.add(id); + output.push(markGeneratedToolCall({ + id, + type: 'function', + function: { + name, + arguments: stringifyToolArguments(args) + } + })); + }); + + return output; } // --- public API ------------------------------------------------------------ @@ -668,6 +1297,23 @@ export function parseToolCallsFromText(...chunks: unknown[]): FinalToolCall[] { return toFinalCalls(calls); } +function mergeTextSpans(spans: TextSpan[]): TextSpan[] { + return [...spans].sort((a, b) => a[0] - b[0]).reduce((acc: TextSpan[], span: TextSpan) => { + const last: TextSpan | undefined = acc[acc.length - 1]; + if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]); + else acc.push([...span] as TextSpan); + return acc; + }, []); +} + +function stripTextSpans(text: string, spans: TextSpan[]): string { + if (!spans.length) return text; + return mergeTextSpans(spans).reduceRight((acc: string, span: TextSpan) => { + const [start, end] = span; + return acc.slice(0, start) + acc.slice(end); + }, text); +} + export interface StripMarkupOptions { registry?: unknown; [key: string]: unknown; @@ -683,25 +1329,72 @@ export function stripFunctionCallMarkup(text: string, trim: boolean = true, opti const opts: Record = options && typeof options === 'object' ? (options as unknown as Record) : {}; const { spans } = collectAll(text, opts['registry']); - - let cleaned = text; - if (spans.length) { - const merged: TextSpan[] = [...spans].sort((a, b) => a[0] - b[0]).reduce((acc: TextSpan[], span: TextSpan) => { - const last: TextSpan | undefined = acc[acc.length - 1]; - if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]); - else acc.push([...span] as TextSpan); - return acc; - }, []); - cleaned = merged.reduceRight((acc: string, span: TextSpan) => { - const [start, end] = span; - return acc.slice(0, start) + acc.slice(end); - }, cleaned); - } - - cleaned = cleaned.replace(RE.canonicalStrayTag, ''); + const cleaned = stripTextSpans(text, spans).replace(RE.canonicalStrayTag, ''); return trim ? cleaned.trim() : cleaned; } +export function stripExternalToolCallMarkupFromJoinedText( + registry: unknown, + reasoning: unknown, + content: unknown, + trim: boolean = false, +): { reasoning: string; content: string } { + const reasoningText = typeof reasoning === 'string' ? reasoning : ''; + const contentText = typeof content === 'string' ? content : ''; + const source = `${reasoningText}${contentText}`; + const { spans } = collectAll(source, registry); + // A bare tool-call payload normally occupies one whole channel while the other carries + // ordinary reasoning or text. Joined, the body no longer looks like bare JSON, so the + // registry-gated whole-body rule never fires and the raw payload would stay visible. + // Re-derive the payload per channel; a joined span that swallows such a channel span + // is dropped in favour of the narrower per-channel one so the sibling channel's text + // survives. Ordinary JSON and non-tool arrays produce no channel span and stay visible. + const names = registryNames(registry); + const channelSpans: TextSpan[] = []; + if (names.length) { + for (const span of extractBareJson(reasoningText, registry).spans) { + channelSpans.push([span[0], span[1]]); + } + for (const span of extractBareJson(contentText, registry).spans) { + channelSpans.push([reasoningText.length + span[0], reasoningText.length + span[1]]); + } + } + // A payload split across the two channels only completes in the joined body, so the + // joined whole-body span is the accurate one; narrowing it to the half that parses would + // leave the other half visible. + const joinedWholeBody = hasValidJsonCallEnvelope(source, registry); + const widenedSpans = channelSpans.length && !joinedWholeBody + ? spans.filter((span) => !channelSpans.some((inner) => inner[0] >= span[0] && inner[1] <= span[1])) + : spans; + const merged = mergeTextSpans([...widenedSpans, ...channelSpans]); + let reasoningOut = ''; + let contentOut = ''; + let cursor = 0; + const append = (start: number, end: number): void => { + if (end <= start) return; + const segment = source.slice(start, end); + if (start < reasoningText.length) { + const reasoningEnd = Math.min(end, reasoningText.length); + reasoningOut += segment.slice(0, reasoningEnd - start); + } + const contentStart = Math.max(start, reasoningText.length); + if (end > contentStart) { + contentOut += segment.slice(contentStart - start); + } + }; + merged.forEach(([start, end]) => { + append(cursor, start); + cursor = Math.max(cursor, end); + }); + append(cursor, source.length); + reasoningOut = reasoningOut.replace(RE.canonicalStrayTag, ''); + contentOut = contentOut.replace(RE.canonicalStrayTag, ''); + return { + reasoning: trim ? reasoningOut.trim() : reasoningOut, + content: trim ? contentOut.trim() : contentOut, + }; +} + /** Parse tool calls and map them onto the request's registry, dropping unknown tools. */ export function parseExternalToolCallsFromText(registry: unknown, ...chunks: unknown[]): FinalToolCall[] { if (!Array.isArray(registry) || (registry as unknown[]).length === 0) return []; @@ -718,7 +1411,7 @@ export function parseExternalToolCallsFromText(registry: unknown, ...chunks: unk if (!tool) return []; const nextCount = (counts.get(tool.namespacedName) || 0) + 1; counts.set(tool.namespacedName, nextCount); - return [{ + const finalCall: FinalToolCall = { id: typeof rawCall.id === 'string' && rawCall.id ? rawCall.id : `call_${tool.namespacedName.replace(/[^a-zA-Z0-9_]/g, '_')}_${nextCount}`, @@ -729,10 +1422,302 @@ export function parseExternalToolCallsFromText(registry: unknown, ...chunks: unk ? rawCall.arguments : JSON.stringify(rawCall.arguments ?? {}) } - }]; + }; + return typeof rawCall.id === 'string' && rawCall.id + ? [finalCall] + : [markGeneratedToolCall(finalCall)]; + }); +} + +export function parseExternalToolCallsFromJoinedText( + registry: unknown, + reasoning: unknown, + content: unknown, +): FinalToolCall[] { + if (!Array.isArray(registry) || (registry as unknown[]).length === 0) return []; + const reasoningText = typeof reasoning === 'string' ? reasoning : ''; + const contentText = typeof content === 'string' ? content : ''; + return mergeToolCallArtifacts( + parseExternalToolCallsFromText(registry, reasoningText), + parseExternalToolCallsFromText(registry, contentText), + parseExternalToolCallsFromText(registry, `${reasoningText}${contentText}`), + ); +} + +export function hasExternalToolCallMarkup(registry: unknown, text: unknown): boolean { + if (typeof text !== 'string' || !text) return false; + return collectAll(text, registry).spans.length > 0; +} + +/** + * Bare tool-call payload (format 5): the whole body is one call, an array of calls, or a + * `{tool_calls:[...]}` wrapper. Ordinary JSON and non-tool arrays are left alone, so the + * check is inert until at least one member resolves onto a registered tool. Once it is + * tool-call shaped the batch is all-or-nothing: every member must be a well-formed call + * for a registered tool, which also covers invalid and trailing members. + */ +function hasKnownJsonNameHint(registry: unknown, text: string): boolean { + if (!Array.isArray(registry) || registry.length === 0) return false; + for (const match of text.matchAll(RE.jsonNameField)) { + if (findExternalToolByName(registry, match[1])) return true; + } + return false; +} + +function hasMalformedBareJsonCall(registry: unknown, text: string, joined?: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + if (joined !== undefined && hasUnclosedJsonDelimiter(trimmed) && !hasUnclosedJsonDelimiter(joined)) return false; + const fenced = trimmed.match(RE.codeFence); + const body = (fenced ? String(fenced[1]) : trimmed).trim(); + if (!body || (body[0] !== '{' && body[0] !== '[')) { + return trimmed.startsWith('```') && hasKnownJsonNameHint(registry, trimmed); + } + + const parsed = parseJsonEnvelopeText(body); + const calls = parsed + ? jsonPayloadCandidates(parsed.parsed).map((candidate) => rawCallFromJson(candidate)).filter((call): call is RawToolCall => call !== null) + : []; + const known = calls.filter((call) => Boolean(findExternalToolByName(registry, call.name))); + if (known.length) return !hasValidJsonCallEnvelope(body, registry); + if (parsed) return false; + return rawCallsFromJsonFragments(body).some((call) => Boolean(findExternalToolByName(registry, call.name))) || hasKnownJsonNameHint(registry, body); +} + +function bareJsonLooksLikeToolCall(registry: unknown, text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + const fenced = trimmed.match(RE.codeFence); + const body = (fenced ? String(fenced[1]) : trimmed).trim(); + if (!body || (body[0] !== '{' && body[0] !== '[')) return false; + const parsed = parseJsonEnvelopeText(body); + if (!parsed) return hasKnownJsonNameHint(registry, body); + return jsonPayloadCandidates(parsed.parsed).some((candidate) => { + const call = rawCallFromJson(candidate); + return call !== null && Boolean(findExternalToolByName(registry, call.name)); }); } +function textOutsideSpans(source: string, spans: TextSpan[]): string[] { + if (!spans.length) return source ? [source] : []; + const segments: string[] = []; + let cursor = 0; + for (const [start, end] of mergeTextSpans(spans)) { + if (start > cursor) segments.push(source.slice(cursor, start)); + cursor = Math.max(cursor, end); + } + if (cursor < source.length) segments.push(source.slice(cursor)); + return segments; +} + +function hasStrayBareJsonToolPayload(registry: unknown, source: string, spans: TextSpan[]): boolean { + return textOutsideSpans(source, spans).some((segment) => bareJsonLooksLikeToolCall(registry, segment)); +} + +interface CanonicalTagToken { + start: number; + end: number; + closing: boolean; +} + +function canonicalTagTokens(source: string): CanonicalTagToken[] { + return scanMarkupTags(source) + .tags.filter((tag) => tag.name === 'canonical') + .map((tag) => ({ start: tag.start, end: tag.end, closing: tag.closing })); +} + +function hasValidCanonicalTagStructure(source: string): boolean { + const stack: CanonicalTagToken[] = []; + const nestedCounts = new Map(); + const invalidNestedPrefixes = new Set(); + let invalidClose = false; + let maxDepth = 0; + + for (const token of canonicalTagTokens(source)) { + if (token.closing) { + if (!stack.length) { + invalidClose = true; + continue; + } + stack.pop(); + continue; + } + const outer = stack[stack.length - 1]; + if (stack.length === 1 && outer) { + nestedCounts.set(outer, (nestedCounts.get(outer) || 0) + 1); + if (source.slice(outer.end, token.start).trim() !== '') invalidNestedPrefixes.add(outer); + } + stack.push(token); + maxDepth = Math.max(maxDepth, stack.length); + } + + if (invalidClose || maxDepth > 2) return false; + if (stack.length === 0) return true; + if (stack.length !== 1) return false; + const unmatched = stack[0]; + return unmatched !== undefined && nestedCounts.get(unmatched) === 1 && !invalidNestedPrefixes.has(unmatched); +} + +interface ToolCallSourceView { + joined: string; + channels: string[]; +} + +function buildToolCallSourceView(entries: unknown[]): ToolCallSourceView | null { + const channels = entries.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0); + return channels.length ? { joined: channels.join(''), channels } : null; +} + +function normalizeToolCallSource(source: unknown): ToolCallSourceView[] { + if (typeof source === 'string') { + const view = buildToolCallSourceView([source]); + return view ? [view] : []; + } + if (!Array.isArray(source)) return []; + if (source.some((entry) => Array.isArray(entry))) { + return source + .filter((entry): entry is unknown[] => Array.isArray(entry)) + .map((group) => buildToolCallSourceView(group)) + .filter((view): view is ToolCallSourceView => view !== null); + } + const view = buildToolCallSourceView(source); + return view ? [view] : []; +} + +function hasMalformedToolEnvelope(registry: unknown, text: unknown): boolean { + if (!Array.isArray(registry) || registry.length === 0) return false; + return normalizeToolCallSource(text).some( + (view) => + hasMalformedToolSource(registry, view.joined) || + view.channels.some((channel) => hasMalformedBareJsonCall(registry, channel, view.joined)) + ); +} + +function hasMalformedToolSource(registry: unknown, source: string): boolean { + const collected = collectAll(source, registry); + if (collected.calls.some((call) => !findExternalToolByName(registry as ExternalToolEntry[], call.name))) return true; + + if (!hasValidCanonicalTagStructure(source)) return true; + const scan = scanMarkupTags(source); + for (const block of findMarkupBlocks(scan, 'canonical')) { + const body = source.slice(block.bodyStart, block.bodyEnd); + if (hasValidJsonCallEnvelope(body, registry)) continue; + if (canonicalTagTokens(body).length > 0 && hasValidCanonicalTagStructure(body)) continue; + return true; + } + + if (!hasBalancedMarkupTag(scan, 'tool_calls')) return true; + for (const container of findMarkupBlocks(scan, 'tool_calls')) { + const body = source.slice(container.bodyStart, container.bodyEnd); + const analysis = analyzeDsmlSegment(body); + if (analysis.hasMarkup) { + if (!analysis.valid || analysis.calls.some((call) => !findExternalToolByName(registry, call.name))) return true; + } else if (!hasValidJsonCallEnvelope(body, registry)) return true; + } + const naked = analyzeDsmlSegment(dsmlRemainder(source), true); + if ( + naked.hasMarkup && + (!naked.valid || naked.calls.some((call) => !findExternalToolByName(registry, call.name))) + ) return true; + + if (!hasBalancedMarkupTag(scan, 'tool_call')) return true; + for (const wrapper of findMarkupBlocks(scan, 'tool_call')) { + const body = source.slice(wrapper.bodyStart, wrapper.bodyEnd); + const jsonCalls = rawCallsFromJsonEnvelopeText(body); + if (jsonCalls.length) { + if (!hasValidJsonCallEnvelope(body, registry)) return true; + continue; + } + const nonJson = extractNonJsonToolWrapper(registry, body); + if (!nonJson.valid) return true; + if (nonJson.calls.some((call) => !findExternalToolByName(registry, call.name))) return true; + } + + if (hasStrayBareJsonToolPayload(registry, source, collected.spans)) return true; + + const names = registryNames(registry); + const schemas = registrySchemas(registry); + if (names.length) { + const opener = new RegExp(`<(${names.map(escapeRegExp).join('|')})(?=[\\s/>"'])`, 'g'); + for (const match of matchAllOutsideStrings(source, opener)) { + const name = match[1]; + const matchIndex = match.index ?? 0; + const attrsStart = matchIndex + match[0].length; + const tagEnd = findTagEnd(source, attrsStart); + if (tagEnd === -1) return true; + const attrs = source.slice(attrsStart, tagEnd); + const attrArgs = argsFromAttrs(attrs); + if (attrArgs !== null) { + try { + JSON.parse(attrArgs); + continue; + } catch { + return true; + } + } + if (attrs.trim().endsWith('/')) continue; + const openEnd = tagEnd + 1; + const closeMatch = matchAllOutsideStrings(source.slice(openEnd), new RegExp(``, 'gi'))[0]; + const closeIdx = closeMatch ? openEnd + (closeMatch.index ?? 0) : -1; + const body = closeIdx === -1 ? source.slice(openEnd) : source.slice(openEnd, closeIdx); + if (findFirstJsonValue(body) || argsFromXmlChildren(body, schemas.get(name))) continue; + return true; + } + } + + for (const match of matchAllOutsideStrings(source, /]+)\s*>/gi)) { + const name = String(match[1]).trim(); + const openEnd = (match.index ?? 0) + match[0].length; + const closeMatch = matchAllOutsideStrings(source.slice(openEnd), /<\/function\s*>/gi)[0]; + const closeIdx = closeMatch ? openEnd + (closeMatch.index ?? 0) : -1; + if (closeIdx === -1) return true; + const body = source.slice(openEnd, closeIdx); + const completeParameters = matchAllOutsideStrings( + body, + /]+)\s*>([\s\S]*?)<\/parameter\s*>/gi, + ).length; + const maskedBody = maskDoubleQuotedStrings(body); + const parameterOpenCount = (maskedBody.match(//gi) || []).length; + if (completeParameters !== parameterOpenCount || completeParameters !== parameterCloseCount) return true; + if (completeParameters === 0 && !functionEqualsAllowsEmptyArguments(name, registry)) return true; + } + + return false; +} + +export function createDuplicateToolCallIdError(): Error & { code?: string } { + const error = new Error('The model emitted conflicting arguments for a duplicate external tool call id.') as Error & { code?: string }; + error.code = 'duplicate_external_tool_call_id'; + return error; +} + +export function assertToolCallArtifactIntegrity(calls: unknown, registry: unknown, sourceText: unknown): void { + const ids = new Map(); + if (Array.isArray(calls)) { + for (const call of calls) { + const record = call && typeof call === 'object' ? (call as Record) : {}; + const id = record['id']; + if (typeof id !== 'string' || !id) continue; + const fn = record['function']; + const fnRecord = fn && typeof fn === 'object' && !Array.isArray(fn) + ? (fn as Record) + : {}; + const semanticKey = toolCallKey(String(fnRecord['name'] ?? ''), fnRecord['arguments'] ?? {}); + const previous = ids.get(id); + if (previous !== undefined && previous !== semanticKey) { + throw createDuplicateToolCallIdError(); + } + ids.set(id, semanticKey); + } + } + if (hasMalformedToolEnvelope(registry, sourceText)) { + const error = new Error('The model emitted malformed external tool markup.') as Error & { code?: string }; + error.code = 'malformed_external_tool_call'; + throw error; + } +} + /** * Openers that may begin tool markup. Used to decide whether a partial chunk should be * withheld from the client until we know what it is. @@ -745,8 +1730,11 @@ function markerOpeners(registry: unknown): string[] { /** True when `candidate` is a prefix of an opener, or already contains one. */ function couldBeMarker(candidate: string, openers: string[]): boolean { - const lower = candidate.toLowerCase(); - return openers.some((opener) => opener.startsWith(lower) || lower.startsWith(opener)); + const lower = candidate.toLowerCase().replace(/\u200b/g, ''); + return openers.some((opener) => { + const normalized = opener.toLowerCase().replace(/\u200b/g, ''); + return normalized.startsWith(lower) || lower.startsWith(normalized); + }); } interface InlineBlock { @@ -758,7 +1746,7 @@ interface InlineBlock { const INLINE_BLOCKS: InlineBlock[] = [ { open: new RegExp(`^<${MARK}tool_calls\\s*>`, 'i'), close: new RegExp(``, 'i') }, { open: new RegExp(`^<${MARK}invoke\\s`, 'i'), close: new RegExp(``, 'i') }, - { open: /^/i, close: /<\/tool_call\s*>/i }, + { open: /^<\u200b?tool_call\s*>/i, close: /<\/\u200b?tool_call\s*>/i }, { open: new RegExp(`^${escapeRegExp(CANONICAL_OPEN)}`, 'i'), close: new RegExp(escapeRegExp(CANONICAL_CLOSE), 'i') } ]; @@ -768,12 +1756,12 @@ const INLINE_BLOCKS: InlineBlock[] = [ * channel that only receives the closer must drop it instead of printing it as prose. */ const KNOWN_CLOSE_TAG = new RegExp( - `^`, + `^`, 'i' ); /** A close tag truncated at a chunk boundary, e.g. " calls.map((call) => { + if (!isGeneratedToolCall(call)) return call; sequence += 1; - return { ...call, id: `${call.id}_${sequence}` }; + return markGeneratedToolCall({ ...call, id: `${call.id}_${sequence}` }); }); const parser = ((chunk: string): FinalToolCall[] => { diff --git a/src/tool-runtime/policy.ts b/src/tool-runtime/policy.ts index 3d0954b..0917b70 100644 --- a/src/tool-runtime/policy.ts +++ b/src/tool-runtime/policy.ts @@ -1,8 +1,4 @@ -import { - TOOL_POLICY_DECISIONS, - TOOL_RISK_LEVELS, - TOOL_SIDE_EFFECTS -} from './contracts.js'; +import { TOOL_POLICY_DECISIONS, TOOL_RISK_LEVELS } from './contracts.js'; /** Raw policy configuration source (env / file merge). */ export interface PolicyRawConfig { @@ -27,6 +23,7 @@ export interface PolicyContext { export interface PolicyToolLike { originalName?: unknown; namespacedName?: unknown; + declaredName?: unknown; requiresConfirmation?: unknown; sideEffect?: unknown; riskLevel?: unknown; @@ -104,22 +101,15 @@ export function evaluateToolPolicy(tool: unknown, args: unknown, context: unknow : {}; const policy = createPolicyContext(ctxRecord['config']); const candidate = tool as PolicyToolLike; - const toolNames: string[] = [candidate.originalName, candidate.namespacedName].filter( + const toolNames: string[] = [candidate.originalName, candidate.namespacedName, candidate.declaredName].filter( (name): name is string => typeof name === 'string' && Boolean(name) ); const inAllowlist = toolNames.some((name) => policy.allowlist.has(name)); const inDenylist = toolNames.some((name) => policy.denylist.has(name)); const requiresConfirmation = - Boolean(candidate.requiresConfirmation) || + candidate.requiresConfirmation === true || toolNames.some((name) => policy.confirmationRequired.has(name)); - if (inAllowlist) { - return { - status: TOOL_POLICY_DECISIONS.ALLOW, - effectiveRisk: candidate.riskLevel || policy.defaultRiskLevel - }; - } - if (inDenylist) { return { status: TOOL_POLICY_DECISIONS.DENY, @@ -128,16 +118,11 @@ export function evaluateToolPolicy(tool: unknown, args: unknown, context: unknow }; } - if (!inAllowlist && (candidate.sideEffect === TOOL_SIDE_EFFECTS.DELETE || candidate.riskLevel === TOOL_RISK_LEVELS.CRITICAL)) { + if (policy.allowlist.size > 0 && !inAllowlist) { return { - status: TOOL_POLICY_DECISIONS.REQUIRE_CONFIRMATION, - reason: `Tool ${String(candidate.originalName)} is high risk and requires confirmation.`, - confirmationPayload: { - toolName: candidate.originalName, - namespacedName: candidate.namespacedName, - argumentsPreview: args, - risk: candidate.riskLevel - } + status: TOOL_POLICY_DECISIONS.DENY, + code: 'tool_not_allowed_by_policy', + reason: `Tool ${String(candidate.originalName)} is not in the external tool allowlist.` }; } diff --git a/src/tool-runtime/registry.ts b/src/tool-runtime/registry.ts index 7d62b59..1372f17 100644 --- a/src/tool-runtime/registry.ts +++ b/src/tool-runtime/registry.ts @@ -5,15 +5,14 @@ import { normalizeRiskLevel, normalizeSideEffect } from './contracts.js'; +import { qualifyToolName } from '../converters/chat-responses/request.js'; +import { isHostedSearchType } from '../search/grounding.js'; import type { ToolRiskLevel, ToolSideEffect } from './contracts.js'; import { asRecord } from '../utils/guards.js'; -/** Shared extension fields carried alongside a tool definition. */ +/** Shared extension fields carried alongside a client tool definition. */ export interface ToolDefinitionExtras { enabled?: unknown; - x_proxy_side_effect?: unknown; - x_proxy_risk_level?: unknown; - x_proxy_requires_confirmation?: unknown; [key: string]: unknown; } @@ -60,6 +59,7 @@ export type IncomingToolDefinition = /** Normalized view produced by {@link normalizeToolDefinition}. */ export interface NormalizedToolDefinition { name: string; + declaredName: string; description: unknown; parameters: unknown; enabled: unknown; @@ -73,6 +73,7 @@ export interface ExternalToolEntry { id: string; originalName: string; namespacedName: string; + declaredName: string; description: string; parameters: Record; sideEffect: ToolSideEffect; @@ -84,6 +85,93 @@ export interface ExternalToolEntry { export type ToolRegistry = ExternalToolEntry[]; +export const FUNCTION_TOOL_TYPE = 'function'; +export const CUSTOM_TOOL_TYPE = 'custom'; +export const NAMESPACE_TOOL_TYPE = 'namespace'; +export const ADDITIONAL_TOOLS_ITEM_TYPE = 'additional_tools'; + +export const CUSTOM_TOOL_FALLBACK_NAME = 'custom_tool'; + +export const PROXY_LEAF_NAME_KEY = '__proxy_leaf_name'; + +/** + * Declaration keys the proxy reserves for itself. They are stripped from every + * client declaration and never read back out of a request body: the leaf name, + * the inferred side effect/risk and the confirmation flag are proxy-authored + * (expansion/registry), so a client cannot forge a `delete_all` tool into a + * harmless-looking `read`. + */ +export const INTERNAL_TOOL_METADATA_KEYS = [ + PROXY_LEAF_NAME_KEY, + 'x_proxy_side_effect', + 'x_proxy_risk_level', + 'x_proxy_requires_confirmation', +] as const; + +/** Declaration fields a namespace container passes down to its leaves. */ +const INHERITABLE_DECLARATION_KEYS = ['enabled'] as const; + +/** Values only the proxy may author. Held out-of-band so no body can carry them. */ +export interface InternalToolMetadata { + declaredName?: string; + sideEffect?: unknown; + riskLevel?: unknown; + requiresConfirmation?: unknown; +} + +const internalToolMetadata = new WeakMap(); + +function hasOwn(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function readInternalToolMetadata(source: unknown): InternalToolMetadata { + if (!source || typeof source !== 'object') return {}; + return internalToolMetadata.get(source as object) ?? {}; +} + +function attachInternalToolMetadata(target: Record, metadata: InternalToolMetadata): void { + internalToolMetadata.set(target, metadata); +} + +function stripInternalToolMetadataKeys(record: Record): Record { + const next: Record = {}; + for (const [key, value] of Object.entries(record)) { + if ((INTERNAL_TOOL_METADATA_KEYS as readonly string[]).includes(key)) continue; + next[key] = value; + } + return next; +} + +export const CUSTOM_TOOL_PARAMETERS: Record = { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'], +}; + +export const TOOL_DECLARATION_ERROR_CODES = { + UNSUPPORTED_TYPE: 'unsupported_tool_type', + INVALID_DECLARATION: 'invalid_tool_declaration', +} as const; + +export interface ToolDeclarationIssue { + code: string; + toolType: string; + path: string; + message: string; +} + +export interface ToolDeclarationSource { + path: string; + tools: unknown; +} + +export interface ToolDeclarationExpansion { + tools: Record[]; + issues: ToolDeclarationIssue[]; + customToolNames: string[]; +} + export interface RegistryIndex { byOriginalName: Map; byNamespacedName: Map; @@ -129,37 +217,48 @@ export function normalizeToolDefinition(tool: unknown): NormalizedToolDefinition : (candidate as unknown as Record); const name = String(definition['name'] || '').trim(); if (!name) return null; + const topLevel = candidate as unknown as Record; // Anthropic Messages shape uses `input_schema` instead of `parameters`. const parameters: unknown = definition['parameters'] ?? definition['input_schema']; - const topLevel = candidate as unknown as Record; + // Policy metadata comes from the proxy's own out-of-band channel only. A + // declaration that still carries `__proxy_leaf_name`/`x_proxy_*` (a client + // body sent straight to buildExternalToolRegistry, or a spoofed copy) is + // ignored here, so the leaf name, side effect and risk stay proxy-authored. + const internal = readInternalToolMetadata(candidate); return { name, + declaredName: resolveDeclaredLeafName(name, internal.declaredName), description: definition['description'], parameters, - enabled: definition['enabled'], - x_proxy_side_effect: definition['x_proxy_side_effect'] ?? topLevel['x_proxy_side_effect'], - x_proxy_risk_level: definition['x_proxy_risk_level'] ?? topLevel['x_proxy_risk_level'], - x_proxy_requires_confirmation: - definition['x_proxy_requires_confirmation'] ?? topLevel['x_proxy_requires_confirmation'] + enabled: definition['enabled'] ?? topLevel['enabled'], + x_proxy_side_effect: internal.sideEffect, + x_proxy_risk_level: internal.riskLevel, + x_proxy_requires_confirmation: internal.requiresConfirmation }; } -function inferSideEffect(definition: unknown = {}): ToolSideEffect { - const record = asRecord(definition); - const declared: unknown = record['x_proxy_side_effect']; - if (declared) return normalizeSideEffect(declared, TOOL_SIDE_EFFECTS.NONE); +function resolveDeclaredLeafName(name: string, annotated: unknown): string { + const declared = typeof annotated === 'string' ? annotated.trim() : ''; + if (!declared) return name; + if (name === declared) return declared; + if (name.endsWith(`__${declared}`)) return declared; + return name; +} - const name = String(record['name'] || '').toLowerCase(); +function inferSideEffect(declaredName: string, metadata: InternalToolMetadata): ToolSideEffect { + const declared: unknown = metadata.sideEffect; + if (declared !== undefined && declared !== null) return normalizeSideEffect(declared, TOOL_SIDE_EFFECTS.NONE); + + const name = String(declaredName || '').toLowerCase(); if (/^(get|list|search|find|read|fetch|lookup)/.test(name)) return TOOL_SIDE_EFFECTS.READ; if (/^(create|update|set|post|write|send)/.test(name)) return TOOL_SIDE_EFFECTS.WRITE; if (/^(delete|remove|destroy)/.test(name)) return TOOL_SIDE_EFFECTS.DELETE; return TOOL_SIDE_EFFECTS.NONE; } -function inferRiskLevel(definition: unknown = {}, sideEffect: ToolSideEffect = TOOL_SIDE_EFFECTS.NONE): ToolRiskLevel { - const record = asRecord(definition); - const declared: unknown = record['x_proxy_risk_level']; - if (declared) return normalizeRiskLevel(declared, TOOL_RISK_LEVELS.LOW); +function inferRiskLevel(sideEffect: ToolSideEffect, metadata: InternalToolMetadata): ToolRiskLevel { + const declared: unknown = metadata.riskLevel; + if (declared !== undefined && declared !== null) return normalizeRiskLevel(declared, TOOL_RISK_LEVELS.LOW); if (sideEffect === TOOL_SIDE_EFFECTS.DELETE || sideEffect === TOOL_SIDE_EFFECTS.PAYMENT) { return TOOL_RISK_LEVELS.CRITICAL; } @@ -169,16 +268,8 @@ function inferRiskLevel(definition: unknown = {}, sideEffect: ToolSideEffect = T return TOOL_RISK_LEVELS.LOW; } -function inferRequiresConfirmation( - definition: unknown = {}, - sideEffect: ToolSideEffect = TOOL_SIDE_EFFECTS.NONE, - riskLevel: ToolRiskLevel = TOOL_RISK_LEVELS.LOW -): boolean { - const record = asRecord(definition); - if (typeof record['x_proxy_requires_confirmation'] === 'boolean') { - return record['x_proxy_requires_confirmation'] as boolean; - } - return sideEffect === TOOL_SIDE_EFFECTS.WRITE || riskLevel === TOOL_RISK_LEVELS.HIGH || riskLevel === TOOL_RISK_LEVELS.CRITICAL; +function inferRequiresConfirmation(metadata: InternalToolMetadata): boolean { + return metadata.requiresConfirmation === true; } export function buildExternalToolRegistry(tools: unknown, options: unknown = {}): ExternalToolEntry[] { @@ -202,17 +293,21 @@ export function buildExternalToolRegistry(tools: unknown, options: unknown = {}) } seenNamespaced.add(namespacedName); - const sideEffect = inferSideEffect(definition); - const riskLevel = inferRiskLevel(definition, sideEffect); + // Inference runs on the proxy-authored bare leaf, so a namespace-qualified + // name (`gh__delete_repo`) can never dodge the `delete*` rule. + const metadata = readInternalToolMetadata(tool); + const sideEffect = inferSideEffect(definition.declaredName, metadata); + const riskLevel = inferRiskLevel(sideEffect, metadata); registry.push({ id: `external_tool_${index + 1}`, originalName, namespacedName, + declaredName: definition.declaredName, description: normalizeDescription(definition.description), parameters: normalizeParameters(definition.parameters), sideEffect, riskLevel, - requiresConfirmation: inferRequiresConfirmation(definition, sideEffect, riskLevel), + requiresConfirmation: inferRequiresConfirmation(metadata), enabled: definition.enabled !== false, sourceTool: tool }); @@ -247,6 +342,238 @@ export function normalizeToolNameForMatch(name: unknown): string { return String(name || '').toLowerCase().replace(/[^a-z0-9]/g, ''); } +/** + * Collect the `additional_tools` declarations a request carries. + * + * Accepts the raw request input: an array (`input[1].tools`) or the single + * object form, which the client wrote as `input.tools`. A malformed `tools` + * payload is still returned as a source so the expansion reports it instead of + * silently dropping the item. + */ +export function collectAdditionalToolSources(input: unknown, pathPrefix = 'input'): ToolDeclarationSource[] { + const indexed = Array.isArray(input); + const items: unknown[] = indexed + ? (input as unknown[]) + : input && typeof input === 'object' + ? [input] + : []; + const sources: ToolDeclarationSource[] = []; + items.forEach((item: unknown, index: number) => { + const record = asRecord(item); + if (record['type'] !== ADDITIONAL_TOOLS_ITEM_TYPE) return; + const itemPath = indexed ? `${pathPrefix}[${index}]` : pathPrefix; + sources.push({ path: `${itemPath}.tools`, tools: record['tools'] }); + }); + return sources; +} + +function declarationTypeOf(tool: unknown): string { + const raw = asRecord(tool)['type']; + return typeof raw === 'string' ? raw.trim() : ''; +} + +function declarationFunctionNameOf(tool: unknown): string { + const record = asRecord(tool); + const nested = asRecord(record['function']); + const name = record['name'] ?? nested['name']; + return typeof name === 'string' ? name.trim() : ''; +} + +function customDeclarationNameOf(tool: unknown): string { + const record = asRecord(tool); + const custom = record['custom']; + const customName = + custom && typeof custom === 'object' && !Array.isArray(custom) + ? String(asRecord(custom)['name'] ?? '').trim() + : typeof custom === 'string' + ? custom.trim() + : ''; + return declarationFunctionNameOf(tool) || customName || CUSTOM_TOOL_FALLBACK_NAME; +} + +function collectInheritedMetadata(container: Record, inherited: Record): Record { + const next: Record = { ...inherited }; + for (const key of INHERITABLE_DECLARATION_KEYS) { + // Presence, not `!== undefined`: an explicit `null` on a container must + // clear what an outer container declared, not fall through to it. + if (hasOwn(container, key)) next[key] = container[key]; + } + return next; +} + +function buildLeafDeclaration( + record: Record, + name: string, + leafName: string, + custom: boolean, + inherited: Record, +): Record { + const next = stripInternalToolMetadataKeys(record); + next['type'] = FUNCTION_TOOL_TYPE; + next['name'] = name; + if (custom) next['parameters'] = CUSTOM_TOOL_PARAMETERS; + const nested = asRecord(record['function']); + if (Object.keys(nested).length > 0) { + const nestedNext = stripInternalToolMetadataKeys(nested); + nestedNext['name'] = name; + next['function'] = nestedNext; + } + for (const key of INHERITABLE_DECLARATION_KEYS) { + if (!hasOwn(next, key) && hasOwn(inherited, key)) next[key] = inherited[key]; + } + // The bare leaf name is proxy-authored: nothing in the request body can set + // or override it, so risk/side-effect inference stays trustworthy. + attachInternalToolMetadata(next, { declaredName: leafName }); + return next; +} + +/** Deterministic serialization so two declarations compare by value, not key order. */ +function stableStringify(value: unknown): string { + if (value === undefined) return 'null'; + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + const entries = Object.entries(value as Record).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(',')}}`; +} + +/** + * Identity of a declaration as the registry will see it. Two declarations that + * land on the same name are only interchangeable when this matches, so a + * conflicting duplicate can be reported instead of silently losing a schema. + */ +function declarationSignature(record: Record, custom: boolean): string { + const nested = asRecord(record['function']); + const read = (key: string): unknown => (hasOwn(record, key) ? record[key] : nested[key]); + return stableStringify({ + custom, + description: normalizeDescription(read('description')), + parameters: custom ? CUSTOM_TOOL_PARAMETERS : normalizeParameters(read('parameters') ?? read('input_schema')), + enabled: read('enabled') !== false + }); +} + +function invalidDeclarationIssue(toolType: string, path: string, message: string): ToolDeclarationIssue { + return { code: TOOL_DECLARATION_ERROR_CODES.INVALID_DECLARATION, toolType, path, message }; +} + +export function expandToolDeclarationSources(sources: unknown): ToolDeclarationExpansion { + const list = Array.isArray(sources) ? (sources as unknown[]) : []; + const issues: ToolDeclarationIssue[] = []; + const customToolNames: string[] = []; + const expandedTools: Record[] = []; + const emitted = new Map(); + const walk = (items: unknown, path: string, namespacePath: string, inherited: Record): void => { + if (!Array.isArray(items)) return; + (items as unknown[]).forEach((tool: unknown, index: number) => { + const itemPath = `${path}[${index}]`; + const record = asRecord(tool); + const type = declarationTypeOf(tool); + if (isHostedSearchType(type)) return; + if (type === NAMESPACE_TOOL_TYPE) { + const namespaceName = typeof record['name'] === 'string' ? record['name'].trim() : ''; + const nextPath = qualifyToolName(namespacePath, namespaceName); + const children = record['tools']; + if (!nextPath) { + issues.push(invalidDeclarationIssue(type, itemPath, `Namespace tool at ${itemPath} requires a name.`)); + return; + } + if (!Array.isArray(children) || children.length === 0) { + issues.push( + invalidDeclarationIssue(type, itemPath, `Namespace tool at ${itemPath} declares no nested tools.`), + ); + return; + } + walk(children as unknown[], `${itemPath}.tools`, nextPath, collectInheritedMetadata(record, inherited)); + return; + } + if (type !== FUNCTION_TOOL_TYPE && type !== CUSTOM_TOOL_TYPE) { + const label = type || 'missing'; + issues.push({ + code: TOOL_DECLARATION_ERROR_CODES.UNSUPPORTED_TYPE, + toolType: label, + path: itemPath, + message: `Unsupported tool type "${label}" at ${itemPath}${namespacePath ? ` in namespace "${namespacePath}"` : ''}. Only function, custom and namespace tools are supported.`, + }); + return; + } + const custom = type === CUSTOM_TOOL_TYPE; + const leafName = custom ? customDeclarationNameOf(tool) : declarationFunctionNameOf(tool); + if (!leafName) { + issues.push(invalidDeclarationIssue(type, itemPath, `Function tool at ${itemPath} requires a name.`)); + return; + } + const name = namespacePath ? qualifyToolName(namespacePath, leafName) : leafName; + const signature = declarationSignature(record, custom); + const declared = emitted.get(name); + if (declared) { + if (declared.signature === signature) return; + issues.push( + invalidDeclarationIssue( + type, + itemPath, + `Tool "${name}" at ${itemPath} conflicts with the declaration at ${declared.path}. Only an identical repeated declaration is deduplicated.`, + ), + ); + return; + } + emitted.set(name, { signature, path: itemPath }); + if (custom) customToolNames.push(name); + expandedTools.push(buildLeafDeclaration(record, name, leafName, custom, inherited)); + }); + }; + for (const source of list) { + const entry = asRecord(source); + const path = typeof entry['path'] === 'string' && entry['path'] ? (entry['path'] as string) : 'tools'; + const declared = entry['tools']; + // A source whose payload is not an array (an `additional_tools` item with + // `tools: {}`, a string, nothing at all) is an invalid declaration, never a + // silently empty tool set. + if (declared !== undefined && !Array.isArray(declared)) { + issues.push( + invalidDeclarationIssue( + ADDITIONAL_TOOLS_ITEM_TYPE, + path, + `Additional tool declarations at ${path} must be an array.`, + ), + ); + continue; + } + walk(declared, path, '', {}); + } + return { tools: expandedTools, issues, customToolNames }; +} + +export function expandToolDeclarations(tools: unknown): ToolDeclarationExpansion { + return expandToolDeclarationSources([{ path: 'tools', tools }]); +} + +export function findExternalToolByExactName(registry: unknown, name: unknown): ExternalToolEntry | null { + if (typeof name !== 'string' || !name) return null; + if (!Array.isArray(registry)) return null; + return ( + (registry as ExternalToolEntry[]).find( + (tool) => tool.originalName === name || tool.namespacedName === name, + ) ?? null + ); +} + +export function resolveExternalToolName(registry: unknown, name: unknown, namespace?: unknown): ExternalToolEntry | null { + if (typeof name !== 'string' || !name.trim()) return null; + if (!Array.isArray(registry)) return null; + const raw = name.trim(); + const list = registry as ExternalToolEntry[]; + if (typeof namespace === 'string' && namespace.trim()) { + const qualified = qualifyToolName(namespace, raw); + if (qualified && qualified !== raw) { + const hinted = list.find((tool) => tool.originalName === qualified || tool.namespacedName === qualified); + if (hinted) return hinted; + } + } + return list.find((tool) => tool.originalName === raw || tool.namespacedName === raw) ?? null; +} + export function createRegistryIndex(registry: unknown): RegistryIndex { const byOriginalName = new Map(); const byNamespacedName = new Map(); diff --git a/src/tool-runtime/router.ts b/src/tool-runtime/router.ts index d4618bc..12c6e8d 100644 --- a/src/tool-runtime/router.ts +++ b/src/tool-runtime/router.ts @@ -1,5 +1,4 @@ -import { EXTERNAL_TOOL_PREFIX } from './contracts.js'; -import { findExternalToolByName } from './registry.js'; +import { resolveExternalToolName } from './registry.js'; import type { ExternalToolEntry } from './registry.js'; /** String shorthand accepted by Chat Completions (`auto` / `none` / `required`). */ @@ -15,7 +14,7 @@ export interface ChatNestedFunctionToolChoice { /** Responses API object shape: `{ type:'function', name }` (flat). */ export interface ChatFlatFunctionToolChoice { - type: 'function'; + type: 'function' | 'custom'; name?: unknown; function?: unknown; [key: string]: unknown; @@ -86,52 +85,107 @@ function asRegistryList(registry: unknown): ExternalToolEntry[] { return Array.isArray(registry) ? (registry as ExternalToolEntry[]) : []; } -export function normalizeExternalToolChoice(toolChoice: unknown, registry: unknown): NormalizedChoice { +export interface ToolChoicePreflightSuccess { + ok: true; + normalized: NormalizedChoice; +} + +export interface ToolChoicePreflightFailure { + ok: false; + code: string; + message: string; +} + +export type ToolChoicePreflightResult = ToolChoicePreflightSuccess | ToolChoicePreflightFailure; + +function preflightFailure(code: string, message: string): ToolChoicePreflightFailure { + return { ok: false, code, message }; +} + +function exactToolForName(registry: ExternalToolEntry[], name: unknown, namespace?: unknown): ExternalToolEntry | null { + return resolveExternalToolName(registry, name, namespace); +} + +export function preflightExternalToolChoice(toolChoice: unknown, registry: unknown): ToolChoicePreflightResult { const list = asRegistryList(registry); - if (!toolChoice || !Array.isArray(registry) || list.length === 0) { - return { mode: 'auto', requiredTool: null }; + const hasExternalSurface = list.some((tool) => tool.enabled !== false); + if (toolChoice === undefined) { + return { ok: true, normalized: { mode: 'auto', requiredTool: null } }; + } + if (typeof toolChoice === 'string') { + if (toolChoice === 'auto' || toolChoice === 'none' || toolChoice === 'required') { + return { + ok: true, + normalized: { mode: toolChoice === 'required' && !hasExternalSurface ? 'auto' : toolChoice, requiredTool: null }, + }; + } + return preflightFailure('invalid_tool_choice', `Invalid tool_choice: ${toolChoice}`); } - if (toolChoice === 'auto' || toolChoice === 'none') { - return { mode: toolChoice, requiredTool: null }; + if (!toolChoice || typeof toolChoice !== 'object' || Array.isArray(toolChoice)) { + return preflightFailure('invalid_tool_choice', 'tool_choice must be a valid string or object.'); + } + + const candidate = toolChoice as UnknownObjectToolChoice & Record; + const type = candidate['type']; + if (typeof type !== 'string') { + return preflightFailure('invalid_tool_choice', 'tool_choice.type is required.'); } - if (toolChoice === 'required') { - return { mode: 'required', requiredTool: null }; + if (type === 'auto' || type === 'none' || type === 'required') { + return { + ok: true, + normalized: { mode: type === 'required' && !hasExternalSurface ? 'auto' : type, requiredTool: null }, + }; } - if (toolChoice && typeof toolChoice === 'object' && !Array.isArray(toolChoice)) { - const candidate = toolChoice as UnknownObjectToolChoice & Record; - // Anthropic Messages shape: { type: 'auto' | 'any' | 'tool' | 'none', name? }. - if (typeof candidate['type'] === 'string' && candidate['function'] === undefined) { - const t = String(candidate['type']).toLowerCase(); - if (t === 'auto') return { mode: 'auto', requiredTool: null }; - if (t === 'none') return { mode: 'none', requiredTool: null }; - if (t === 'any') return { mode: 'required', requiredTool: null }; - if (t === 'tool' && candidate['name']) { - const requested = candidate['name'] as string; - const mappedTool = findExternalToolByName(list, requested); - return { - mode: 'required', - requiredTool: mappedTool?.namespacedName || `${EXTERNAL_TOOL_PREFIX}${String(requested)}` - }; + if (type === 'any') { + return { ok: true, normalized: { mode: hasExternalSurface ? 'required' : 'auto', requiredTool: null } }; + } + + let requestedName: unknown; + if (type === 'tool') { + requestedName = candidate['name']; + } else if (type === 'function' || type === 'custom') { + // `custom` names a freeform declaration, which the request converters expose + // as a function over the wrapped {input: string} schema — so it selects the + // same registry entry a `function` choice would. + const hasFunction = Object.prototype.hasOwnProperty.call(candidate, 'function'); + const hasDirectName = Object.prototype.hasOwnProperty.call(candidate, 'name'); + if (hasFunction) { + const fn = candidate['function']; + if (!fn || typeof fn !== 'object' || Array.isArray(fn)) { + return preflightFailure('invalid_tool_choice', 'tool_choice.function must contain a tool name.'); } - if (t === 'tool') return { mode: 'required', requiredTool: null }; - } - // Chat Completions sends { type:'function', function:{ name } }; the Responses API sends - // { type:'function', name }. Accept both so a forced tool choice is not silently ignored. - const fnRecord: unknown = candidate['function']; - const fnName: unknown = - fnRecord && typeof fnRecord === 'object' && !Array.isArray(fnRecord) - ? (fnRecord as Record)['name'] - : undefined; - const requestedName: unknown = fnName || candidate['name']; - if (candidate['type'] === 'function' && requestedName) { - const mappedTool = findExternalToolByName(list, requestedName); - return { - mode: 'required', - requiredTool: mappedTool?.namespacedName || `${EXTERNAL_TOOL_PREFIX}${String(requestedName)}` - }; + const fnRecord = fn as Record; + const hasNestedName = Object.prototype.hasOwnProperty.call(fnRecord, 'name'); + if (!hasNestedName || typeof fnRecord['name'] !== 'string' || !fnRecord['name']) { + return preflightFailure('invalid_tool_choice', 'tool_choice.function.name is required.'); + } + if (hasDirectName && candidate['name'] !== fnRecord['name']) { + return preflightFailure('invalid_tool_choice', 'tool_choice contains conflicting tool names.'); + } + requestedName = fnRecord['name']; + } else { + requestedName = hasDirectName ? candidate['name'] : undefined; } + } else { + return preflightFailure('invalid_tool_choice', `Unsupported tool_choice.type: ${type}`); } - return { mode: 'auto', requiredTool: null }; + + if (typeof requestedName !== 'string' || !requestedName) { + return preflightFailure('invalid_tool_choice', 'tool_choice.name is required for a specific tool choice.'); + } + const tool = exactToolForName(list, requestedName, candidate['namespace']); + if (!tool) { + return preflightFailure('unknown_tool', `tool_choice references an unknown tool: ${requestedName}`); + } + if (tool.enabled === false) { + return preflightFailure('tool_disabled', `tool_choice references a disabled tool: ${requestedName}`); + } + return { ok: true, normalized: { mode: 'required', requiredTool: tool.namespacedName } }; +} + +export function normalizeExternalToolChoice(toolChoice: unknown, registry: unknown): NormalizedChoice { + const result = preflightExternalToolChoice(toolChoice, registry); + return result.ok ? result.normalized : { mode: 'auto', requiredTool: null }; } export function buildExternalToolsPrompt(registry: unknown, toolChoice: unknown = null): string { @@ -197,10 +251,10 @@ export function buildExternalToolsReminder(registry: unknown, toolChoice: unknow } export function buildToolExposure(registry: unknown, toolChoice: unknown = null): ToolExposure { - const normalizedChoice = normalizeExternalToolChoice(toolChoice, registry); const exposedTools: ExternalToolEntry[] = Array.isArray(registry) ? (registry as ExternalToolEntry[]).filter((tool) => tool.enabled !== false) : []; + const normalizedChoice = normalizeExternalToolChoice(toolChoice, exposedTools); return { tools: exposedTools, toolChoice: normalizedChoice, diff --git a/src/types/backend.ts b/src/types/backend.ts index e82dcc1..542543b 100644 --- a/src/types/backend.ts +++ b/src/types/backend.ts @@ -7,11 +7,17 @@ export interface BackendState { jailRoot: string | null; } +export interface ResponseToolCallState { + callId: string; + name: string; +} + /** Stateful Responses continuation entry (previous_response_id). */ export interface SessionInfo { sessionId: string; model: string; expiresAt: number; + toolCalls?: ResponseToolCallState[]; } /** Alias kept for task naming (SessionInfo == response-state entry). */ diff --git a/src/types/config.ts b/src/types/config.ts index 13837f8..5617e24 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -14,6 +14,11 @@ export interface ProxyConfig { DISABLE_TOOLS: boolean; EXTERNAL_TOOLS_MODE: string; EXTERNAL_TOOLS_CONFLICT_POLICY: string; + EXTERNAL_TOOL_POLICY_MODE: string; + EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: string; + EXTERNAL_TOOL_ALLOWLIST: string[]; + EXTERNAL_TOOL_DENYLIST: string[]; + EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: string[]; INTERNAL_WEB_FETCH_ENABLED: boolean; INTERNAL_ALLOWED_TOOLS: string[]; INTERNAL_TOOL_METRICS_ENABLED: boolean; @@ -57,6 +62,11 @@ export interface ProxyConfigOptions { externalToolsMode?: unknown; EXTERNAL_TOOLS_CONFLICT_POLICY?: unknown; externalToolsConflictPolicy?: unknown; + EXTERNAL_TOOL_POLICY_MODE?: unknown; + EXTERNAL_TOOL_DEFAULT_RISK_LEVEL?: unknown; + EXTERNAL_TOOL_ALLOWLIST?: unknown; + EXTERNAL_TOOL_DENYLIST?: unknown; + EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR?: unknown; INTERNAL_WEB_FETCH_ENABLED?: unknown; INTERNAL_ALLOWED_TOOLS?: unknown; INTERNAL_TOOL_METRICS_ENABLED?: unknown; diff --git a/src/types/context.ts b/src/types/context.ts index 6b598e3..dd519fb 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -56,14 +56,16 @@ export interface CollectorHandle { pollForAssistantResponse: ( sessionId: string, timeoutMs: number, - intervalMs?: number + intervalMs?: number, + signal?: AbortSignal ) => Promise<{ content: string; reasoning: string; error: unknown; toolParts: unknown[] }>; collectFromEvents: ( sessionId: string, timeoutMs: number, onDelta?: ((delta: string, isReasoning?: boolean) => void) | null, firstDeltaTimeoutMs?: number | null, - idleTimeoutMs?: number | null + idleTimeoutMs?: number | null, + signal?: AbortSignal ) => Promise>; } @@ -73,6 +75,7 @@ export interface InternalToolMetrics { disabledRequests: number; discoveryFailures: number; fallbackToDisabled: number; + overrideOmitted: number; } /** Per-instance context threaded through route registrars (same names as closures). */ @@ -109,7 +112,7 @@ export interface AppContext { logDebug: (...args: unknown[]) => void; responseState: Map; getResponseState: (responseId: unknown) => ResponseStateEntry | null; - storeResponseState: (responseId: unknown, sessionId: unknown, model: unknown) => void; + storeResponseState: (responseId: unknown, sessionId: unknown, model: unknown, toolCalls?: unknown) => void; sweepResponseState: () => Promise; TOOL_MODE: ToolModeSet; TOOL_GUARD_MESSAGE: string; @@ -118,7 +121,7 @@ export interface AppContext { getEffectiveInternalAllowedTools: () => string[]; SERVER_INTERNAL_ALLOWED_TOOL_NAMES: string[]; buildInternalAllowlistPrompt: (allowedToolNames?: unknown) => string; - buildSystemPrompt: (systemMsg: unknown, reasoningEffort?: unknown, toolMode?: unknown, internalAllowedTools?: unknown) => string | undefined; + buildSystemPrompt: (systemMsg: unknown, externalToolPrompt?: unknown, reasoningEffort?: unknown, toolMode?: unknown, internalAllowedTools?: unknown) => string | undefined; selectPromptToolOverrides: (overrides: unknown, providerID?: unknown, modelID?: unknown) => Record | null; normalizeReasoningEffort: (value: unknown, fallback?: unknown) => string | null; stripFunctionCalls: (text: unknown, trim?: boolean) => string; @@ -132,6 +135,7 @@ export interface AppContext { parsedToolCalls: unknown, registry: unknown ) => { validCalls: ValidatedToolCall[]; invalidCalls: Array<{ call: unknown; validation: unknown }> }; + finalizeStreamToolCalls: (parsedToolCalls: unknown, registry: unknown, toolChoice: unknown, sourceText?: unknown, parallelToolCalls?: unknown) => ValidatedToolCall[]; toPublicToolCalls: (toolCalls: unknown) => FinalToolCall[]; createForcedToolCallRequester: (options: ForcedToolCallRequesterOptions) => () => Promise | null>; TOOL_IDS_CACHE_MS: number; @@ -161,6 +165,10 @@ export interface AppContext { proxyPollForAssistantResponse: CollectorHandle['pollForAssistantResponse']; getCachedToolIds: () => string[] | null; getCachedToolIdsAt: () => number; + getDiscoverySource: () => string; + getDiscoveryLastSuccessAt: () => number; + getDiscoveryLastErrorAt: () => number; + getDiscoveryLastError: () => string | null; /** Wired N×N translator registry (Phase 1 wiring; optional so manual test ctx still compiles). */ translators?: TranslatorRegistry; } diff --git a/src/upstream-proxy/pool.ts b/src/upstream-proxy/pool.ts index 7b71fea..6166883 100644 --- a/src/upstream-proxy/pool.ts +++ b/src/upstream-proxy/pool.ts @@ -83,12 +83,13 @@ 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 PROXY_STRATEGIES: readonly ProxyStrategy[] = ['failover-rr', 'round-robin', 'random']; 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; + return (PROXY_STRATEGIES as readonly string[]).includes(v) ? (v as ProxyStrategy) : DEFAULT_PROXY_STRATEGY; } /** Clamp the cooldown at the config layer (positive finite, else default). */ diff --git a/tests/app.test.js b/tests/app.test.js index a79d869..1dc3886 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -1,12 +1,23 @@ import request from 'supertest'; import { jest } from '@jest/globals'; -import { buildExternalToolRegistry } from '../src/tool-runtime/registry.js'; -import { normalizeExternalToolChoice, buildToolExposure } from '../src/tool-runtime/router.js'; +import { + INTERNAL_TOOL_METADATA_KEYS, + PROXY_LEAF_NAME_KEY, + buildExternalToolRegistry, + collectAdditionalToolSources, + expandToolDeclarationSources, + expandToolDeclarations, + findExternalToolByExactName, + findExternalToolByName, + resolveExternalToolName +} from '../src/tool-runtime/registry.js'; +import { qualifyToolName } from '../src/converters/chat-responses/request.js'; +import { normalizeExternalToolChoice, buildToolExposure, preflightExternalToolChoice } from '../src/tool-runtime/router.js'; import { evaluateToolPolicy } from '../src/tool-runtime/policy.js'; import { validateToolCall, validateToolCalls } from '../src/tool-runtime/validator.js'; -const sdkMocks = { - configProviders: jest.fn(async () => ({ +const sdkMockDefaults = { + configProviders: async () => ({ data: { providers: [ { @@ -14,20 +25,21 @@ const sdkMocks = { models: { 'kimi-k2.5': { name: 'Kimi k2.5', release_date: '2024-01-15' }, 'gpt-5-nano': { name: 'GPT-5 Nano', release_date: '2025-01-15' }, + 'gpt-4': { name: 'GPT-4', release_date: '2024-06-01' }, 'muse-spark-1.3-contributor-free': { name: 'Muse Spark', release_date: '2026-01-15' } } } ] } - })), - configUpdate: jest.fn(async () => ({})), - toolIds: jest.fn(async () => ({ + }), + configUpdate: async () => ({}), + toolIds: async () => ({ data: ['web_fetch', 'filesystem', 'bash'] - })), - sessionCreate: jest.fn(async () => ({ + }), + sessionCreate: async () => ({ data: { id: 'test-session-id' } - })), - sessionPrompt: jest.fn(async (args) => { + }), + sessionPrompt: async (args) => { const promptText = args.body.prompt || args.body.parts?.map(part => part.text || '').join(' ') || ''; const parts = [{ type: 'text', text: 'Mock response' }]; @@ -36,17 +48,17 @@ const sdkMocks = { } return { data: { parts } }; - }), - sessionMessages: jest.fn(async () => ([ + }, + sessionMessages: async () => ([ { info: { role: 'assistant', finish: 'stop' }, parts: [ { type: 'text', text: 'Mock response' } ] } - ])), - sessionDelete: jest.fn(async () => ({})), - eventSubscribe: jest.fn(async () => { + ]), + sessionDelete: async () => ({}), + eventSubscribe: async () => { const sessionId = 'test-session-id'; const mockEvents = [ { type: 'message.part.updated', properties: { part: { type: 'reasoning', sessionID: sessionId }, delta: 'Thinking...' } }, @@ -62,7 +74,18 @@ const sdkMocks = { } })() }; - }) + } +}; + +const sdkMocks = { + configProviders: jest.fn(sdkMockDefaults.configProviders), + configUpdate: jest.fn(sdkMockDefaults.configUpdate), + toolIds: jest.fn(sdkMockDefaults.toolIds), + sessionCreate: jest.fn(sdkMockDefaults.sessionCreate), + sessionPrompt: jest.fn(sdkMockDefaults.sessionPrompt), + sessionMessages: jest.fn(sdkMockDefaults.sessionMessages), + sessionDelete: jest.fn(sdkMockDefaults.sessionDelete), + eventSubscribe: jest.fn(sdkMockDefaults.eventSubscribe) }; jest.unstable_mockModule('https', () => ({ @@ -128,6 +151,77 @@ jest.unstable_mockModule('@opencode-ai/sdk', () => ({ const { createApp } = await import('../src/proxy.js'); +describe('Phase 1A tool policy and exact tool choice', () => { + test('infers side effect and risk from the bare leaf without inferring confirmation', () => { + const [tool] = buildExternalToolRegistry([{ + type: 'function', + function: { name: 'delete_ticket' } + }]); + + expect(tool.sideEffect).toBe('delete'); + expect(tool.riskLevel).toBe('critical'); + expect(tool.requiresConfirmation).toBe(false); + expect(evaluateToolPolicy(tool, {}, { config: {} })).toMatchObject({ status: 'allow' }); + }); + + test('requires confirmation only from the operator config, never a client annotation', () => { + const [spoofed] = buildExternalToolRegistry([{ + type: 'function', + function: { name: 'write_ticket', x_proxy_requires_confirmation: true } + }]); + const [plain] = buildExternalToolRegistry([{ + type: 'function', + function: { name: 'write_ticket' } + }]); + + expect(spoofed.requiresConfirmation).toBe(false); + expect(evaluateToolPolicy(spoofed, {}, { config: {} })).toMatchObject({ status: 'allow' }); + expect(evaluateToolPolicy(plain, {}, { + config: { EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: ['write_ticket'] } + })).toMatchObject({ status: 'require_confirmation' }); + }); + + test('denylist wins and a nonempty allowlist restricts tools', () => { + const [tool] = buildExternalToolRegistry([{ type: 'function', name: 'delete_ticket' }]); + const denied = evaluateToolPolicy(tool, {}, { + config: { + EXTERNAL_TOOL_ALLOWLIST: ['delete_ticket'], + EXTERNAL_TOOL_DENYLIST: ['delete_ticket'] + } + }); + const notAllowed = evaluateToolPolicy(tool, {}, { + config: { EXTERNAL_TOOL_ALLOWLIST: ['read_ticket'] } + }); + + expect(denied).toMatchObject({ status: 'deny', code: 'tool_denied_by_policy' }); + expect(notAllowed).toMatchObject({ status: 'deny', code: 'tool_not_allowed_by_policy' }); + }); + + test('preflights exact names and rejects unknown, disabled, and malformed choices', () => { + const registry = buildExternalToolRegistry([ + { type: 'function', name: 'read' }, + { type: 'function', name: 'off', enabled: false } + ]); + + expect(preflightExternalToolChoice({ type: 'function', name: 'read' }, registry)).toEqual({ + ok: true, + normalized: { mode: 'required', requiredTool: 'external__read' } + }); + expect(preflightExternalToolChoice({ type: 'function', name: 'external__read' }, registry)).toEqual({ + ok: true, + normalized: { mode: 'required', requiredTool: 'external__read' } + }); + expect(preflightExternalToolChoice({ type: 'function', name: 'READ' }, registry)).toMatchObject({ ok: false, code: 'unknown_tool' }); + expect(preflightExternalToolChoice({ type: 'function', name: 'off' }, registry)).toMatchObject({ ok: false, code: 'tool_disabled' }); + expect(preflightExternalToolChoice({ type: 'function' }, registry)).toMatchObject({ ok: false, code: 'invalid_tool_choice' }); + expect(preflightExternalToolChoice('none', registry)).toEqual({ ok: true, normalized: { mode: 'none', requiredTool: null } }); + expect(preflightExternalToolChoice('required', registry)).toEqual({ ok: true, normalized: { mode: 'required', requiredTool: null } }); + expect(preflightExternalToolChoice('required', [])).toEqual({ ok: true, normalized: { mode: 'auto', requiredTool: null } }); + expect(preflightExternalToolChoice('required', [registry[1]])).toEqual({ ok: true, normalized: { mode: 'auto', requiredTool: null } }); + expect(preflightExternalToolChoice({ type: 'any' }, [])).toEqual({ ok: true, normalized: { mode: 'auto', requiredTool: null } }); + }); +}); + describe('Proxy OpenAI API', () => { let app; @@ -138,25 +232,10 @@ describe('Proxy OpenAI API', () => { beforeEach(() => { jest.clearAllMocks(); - sdkMocks.toolIds.mockResolvedValue({ data: ['web_fetch', 'filesystem', 'bash'] }); - sdkMocks.sessionPrompt.mockImplementation(async (args) => { - const promptText = args.body.prompt || args.body.parts?.map(part => part.text || '').join(' ') || ''; - const parts = [{ type: 'text', text: 'Mock response' }]; - - if (promptText.includes('reasoning')) { - parts.unshift({ type: 'reasoning', text: 'Thinking process...' }); - } - - return { data: { parts } }; + Object.entries(sdkMockDefaults).forEach(([name, implementation]) => { + sdkMocks[name].mockReset(); + sdkMocks[name].mockImplementation(implementation); }); - sdkMocks.sessionMessages.mockImplementation(async () => ([ - { - info: { role: 'assistant', finish: 'stop' }, - parts: [ - { type: 'text', text: 'Mock response' } - ] - } - ])); const config = { PORT: 10000, API_KEY: 'test-key', @@ -256,6 +335,90 @@ describe('Proxy OpenAI API', () => { expect(promptCall.body.system).toContain('client_name'); }); + test('POST /v1/chat/completions keeps external tool schema when OMIT_SYSTEM_PROMPT is true', async () => { + const omitApp = createApp({ + PORT: 10000, + API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: true, + OMIT_SYSTEM_PROMPT: true, + DEBUG: false + }).app; + + const res = await request(omitApp) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + messages: [ + { role: 'system', content: 'user-system-secret' }, + { role: 'user', content: 'Read a.txt' } + ], + tools: [{ + type: 'function', + function: { + name: 'read', + parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } + } + }] + }); + + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.system).not.toContain('user-system-secret'); + expect(promptCall.body.system).toContain('external__read'); + expect(promptCall.body.system).toContain('"path"'); + }); + + test('POST /v1/chat/completions rejects an unknown specific tool choice', async () => { + sdkMocks.sessionPrompt.mockClear(); + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + messages: [{ role: 'user', content: 'Use a tool' }], + tools: [{ type: 'function', function: { name: 'read' } }], + tool_choice: { type: 'function', name: 'missing' } + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.type).toBe('invalid_request_error'); + expect(res.body.error.message).toContain('unknown tool'); + expect(sdkMocks.sessionPrompt).not.toHaveBeenCalled(); + }); + + test('POST /v1/chat/completions keeps a forged delete_all annotation out of the prompt contract', async () => { + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + messages: [{ role: 'user', content: 'Delete everything' }], + tools: [{ + type: 'function', + function: { + name: 'delete_all', + description: 'Delete everything', + parameters: { type: 'object', properties: {} }, + [PROXY_LEAF_NAME_KEY]: 'read', + x_proxy_side_effect: 'read', + x_proxy_risk_level: 'low', + x_proxy_requires_confirmation: false + } + }] + }); + + expect(res.statusCode).toBe(200); + const system = String(sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]?.body.system || ''); + expect(system).toContain('"side_effect":"delete"'); + expect(system).toContain('"risk_level":"critical"'); + expect(system).not.toContain('"risk_level":"low"'); + expect(system).not.toContain(PROXY_LEAF_NAME_KEY); + expect(system).not.toContain('x_proxy_'); + }); + test('POST /v1/chat/completions keeps external web_fetch isolated from internal tool semantics', async () => { sdkMocks.sessionMessages.mockResolvedValueOnce([ { @@ -508,7 +671,7 @@ describe('Proxy OpenAI API', () => { expect(promptCall.body.system).toContain('Tools are disabled'); }); - test('POST /v1/chat/completions strips tool-call markup from output when DISABLE_TOOLS=true', async () => { + test('POST /v1/chat/completions fails closed on invalid tool call instead of stripping to 200', async () => { const lockedApp = createApp({ PORT: 10000, API_KEY: 'test-key', @@ -523,7 +686,7 @@ describe('Proxy OpenAI API', () => { parts: [ { type: 'text', - text: 'Here is the summary. {"name":"bash","arguments":{"command":"ls"}}' + text: '{"name":"external__read","arguments":{}}' } ] } @@ -534,14 +697,13 @@ describe('Proxy OpenAI API', () => { .set('Authorization', 'Bearer test-key') .send({ model: 'opencode/kimi-k2.5', - messages: [{ role: 'user', content: 'List files' }] + messages: [{ role: 'user', content: 'List files' }], + tools: [{ type: 'function', function: { name: 'read', description: 'Read', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } } }] }); - expect(res.statusCode).toEqual(200); - const content = res.body.choices[0].message.content; - expect(content).toContain('Here is the summary.'); - expect(content).not.toContain(''); - expect(content).not.toContain('function_calls'); + expect(res.statusCode).toEqual(502); + expect(res.body.error.code).toBe('invalid_external_tool_call'); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); }); test('POST /v1/chat/completions applies request-level allowlist narrowing (intersection)', async () => { @@ -852,6 +1014,34 @@ describe('Proxy OpenAI API', () => { expect(promptCall.body.parts[1].text).toContain('{\\"city\\":\\"Tokyo\\"}'); }); + test('POST /v1/chat/completions preserves an explicit empty tool result', async () => { + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + tools: [{ type: 'function', function: { name: 'read' } }], + messages: [ + { role: 'user', content: 'Read a.txt' }, + { + role: 'assistant', + content: null, + tool_calls: [{ id: 'call_read_1', type: 'function', function: { name: 'read', arguments: '{}' } }] + }, + { role: 'tool', tool_call_id: 'call_read_1', name: 'read', content: '' } + ] + }); + + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: 'TOOL_RESULT: {"tool_call_id":"call_read_1","name":"external__read","content":""}' + }) + ])); + }); + test('GET /health returns status ok', async () => { const res = await request(app).get('/health'); expect(res.statusCode).toEqual(200); @@ -1393,7 +1583,7 @@ describe('Proxy OpenAI API', () => { expect(res.text).toContain('"name":"weather_lookup"'); }); - test('POST /v1/chat/completions strips denied external tool calls from non-stream output', async () => { + test('POST /v1/chat/completions fails closed on denied external tool calls', async () => { const restrictedApp = createApp({ PORT: 10000, API_KEY: 'test-key', @@ -1438,10 +1628,9 @@ describe('Proxy OpenAI API', () => { ] }); - expect(res.statusCode).toEqual(200); - expect(res.body.choices[0].finish_reason).toEqual('stop'); - expect(res.body.choices[0].message.tool_calls).toBeUndefined(); - expect(res.body.choices[0].message.content).toEqual(''); + expect(res.statusCode).toEqual(502); + expect(res.body.error.code).toBe('external_tool_policy_blocked'); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); }); test('POST /v1/responses returns assistant response', async () => { @@ -1831,6 +2020,30 @@ describe('Proxy OpenAI API', () => { }); }); + test('POST /v1/responses preserves an explicit empty function_call_output', async () => { + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + tools: [{ type: 'function', function: { name: 'read' } }], + input: [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Read a.txt' }] }, + { type: 'function_call', call_id: 'call_read_1', name: 'read', arguments: '{}' }, + { type: 'function_call_output', call_id: 'call_read_1', output: '' } + ] + }); + + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: 'TOOL_RESULT: {"tool_call_id":"call_read_1","name":"external__read","content":""}' + }) + ])); + }); + test('POST /v1/responses continues after function_call_output input and returns assistant text', async () => { sdkMocks.sessionPrompt.mockResolvedValueOnce({ data: { @@ -2017,7 +2230,7 @@ describe('Proxy OpenAI API', () => { expect(res.text).toContain('data: [DONE]'); }); - test('POST /v1/responses strips denied external function calls from streaming output', async () => { + test('POST /v1/responses fails closed for denied external function calls in streaming output', async () => { const restrictedApp = createApp({ PORT: 10000, API_KEY: 'test-key', @@ -2069,11 +2282,18 @@ describe('Proxy OpenAI API', () => { }); expect(res.statusCode).toEqual(200); - expect(res.text).not.toContain('"name":"delete_ticket"'); - expect(res.text).toContain('response.completed'); + const events = res.text + .split('\n') + .filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]') + .map((line) => JSON.parse(line.slice(6))); + expect(events.map((event) => event.type)).toEqual(['response.created', 'response.failed']); + expect(events.every((event) => event.response.output.length === 0)).toBe(true); + expect(res.text).not.toContain('external__delete_ticket'); + expect(res.text).toContain('response.failed'); + expect(res.text).not.toContain('response.completed'); }); - test('POST /v1/responses strips denied external function calls from non-stream output', async () => { + test('POST /v1/responses fails closed on denied external function calls', async () => { const restrictedApp = createApp({ PORT: 10000, API_KEY: 'test-key', @@ -2117,11 +2337,12 @@ describe('Proxy OpenAI API', () => { ] }); - expect(res.statusCode).toEqual(200); - expect(res.body.output).toEqual([]); + expect(res.statusCode).toEqual(502); + expect(res.body.error.code).toBe('external_tool_policy_blocked'); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); }); - test('POST /v1/responses strips tool-call markup from output when DISABLE_TOOLS=true', async () => { + test('POST /v1/responses fails closed on invalid tool call instead of stripping to 200', async () => { const lockedApp = createApp({ PORT: 10000, API_KEY: 'test-key', @@ -2136,7 +2357,7 @@ describe('Proxy OpenAI API', () => { parts: [ { type: 'text', - text: 'Search summary here. {"name":"webfetch","arguments":{"url":"https://example.com"}}' + text: '{"name":"external__read","arguments":{}}' } ] } @@ -2147,13 +2368,13 @@ describe('Proxy OpenAI API', () => { .set('Authorization', 'Bearer test-key') .send({ model: 'opencode/kimi-k2.5', - input: 'Summarize https://example.com' + input: 'Read a.txt', + tools: [{ type: 'function', name: 'read', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } }] }); - expect(res.statusCode).toEqual(200); - const flat = JSON.stringify(res.body.output); - expect(flat).toContain('Search summary here.'); - expect(flat).not.toContain(''); + expect(res.statusCode).toEqual(502); + expect(res.body.error.code).toBe('invalid_external_tool_call'); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); }); /** @@ -2340,6 +2561,2942 @@ describe('Proxy OpenAI API', () => { expect(sdkMocks.sessionDelete).toHaveBeenCalledWith({ path: { id: 'test-session-id' } }); }); describe('Proxy Responses API previous_response_id', () => { + describe('Phase 1B external stream finalization', () => { + const readTool = { + type: 'function', + function: { + name: 'read', + description: 'Read a file', + parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } + } + }; + const eventStream = (events) => async () => ({ stream: (async function* () { for (const event of events) yield event; })() }); + const eventStreamThenThrow = (events) => async () => ({ stream: (async function* () { + for (const event of events) yield event; + throw new Error('event stream cut'); + })() }); + const readSseData = (text) => text + .split('\n') + .filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]') + .map((line) => JSON.parse(line.slice(6))); + const chatStreamPayload = { model: 'opencode/kimi-k2.5', stream: true, messages: [{ role: 'user', content: 'Hello' }] }; + const messagesStreamPayload = { + model: 'opencode/kimi-k2.5', + max_tokens: 100, + stream: true, + messages: [{ role: 'user', content: 'Hello' }] + }; + const visibleTextFor = (path, text) => { + if (path === '/v1/responses') { + return readSseData(text) + .filter((event) => event.type === 'response.output_text.delta') + .map((event) => event.delta); + } + if (path === '/v1/messages') { + return readSseData(text) + .filter((event) => event.type === 'content_block_delta' && event.delta?.type === 'text_delta') + .map((event) => event.delta.text); + } + return readSseData(text) + .flatMap((event) => event.choices || []) + .flatMap((choice) => (choice.delta?.content ? [choice.delta.content] : [])); + }; + + test('non-stream parsing joins reasoning and content before extraction', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([{ + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { type: 'reasoning', text: '{"name":"read",' }, + { type: 'text', text: '"arguments":{"path":"a.txt"}}' } + ] + }]); + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] }); + expect(res.statusCode).toBe(200); + expect(res.body.choices[0].finish_reason).toBe('tool_calls'); + expect(res.body.choices[0].message.tool_calls[0].function.name).toBe('read'); + }); + + test.each([ + ['chat', '/v1/chat/completions'], + ['messages', '/v1/messages'] + ])('non-stream %s strips a cross-channel marker before splitting visible text', async (_label, path) => { + sdkMocks.sessionMessages.mockResolvedValueOnce([{ + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { type: 'reasoning', text: 'before {"name":"read",' }, + { type: 'text', text: '"arguments":{"path":"a.txt"}} after' } + ] + }]); + const payload = path === '/v1/chat/completions' + ? { model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] } + : { + model: 'opencode/kimi-k2.5', + max_tokens: 100, + messages: [{ role: 'user', content: 'Read a.txt' }], + tools: [{ name: 'read', description: 'Read a file', input_schema: readTool.function.parameters }] + }; + const res = await request(app) + .post(path) + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send(payload); + expect(res.statusCode).toBe(200); + if (path === '/v1/chat/completions') { + expect(res.body.choices[0].message.content).toBe('after'); + expect(res.body.choices[0].message.reasoning_content).toBe('before'); + } else { + expect(res.body.content.find((item) => item.type === 'text').text).toBe('after'); + expect(res.body.content.find((item) => item.type === 'thinking').thinking).toBe('before'); + } + }); + + test('non-stream responses strips a cross-channel marker before splitting visible text', async () => { + sdkMocks.sessionMessages.mockReset(); + sdkMocks.sessionPrompt.mockResolvedValueOnce({ + data: { + parts: [ + { type: 'reasoning', text: 'before {"name":"read",' }, + { type: 'text', text: '"arguments":{"path":"a.txt"}} after' } + ] + } + }); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }); + expect(res.statusCode).toBe(200); + expect(res.body.output.find((item) => item.type === 'function_call').name).toBe('read'); + expect(res.body.output.find((item) => item.type === 'message').content[0].text).toBe('after'); + expect(res.body.reasoning.summary).toBe('before'); + }); + + test('external chat stream sends buffered flush text and one validated call', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: 'Visible ' } }, + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"path":"a.txt"} tail' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', stream: true, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('Visible'); + expect(res.text).toContain('tail'); + expect(res.text).toContain('"tool_calls"'); + expect(res.text).not.toContain(''); + expect(res.text).toContain('data: [DONE]'); + }); + + test('buffered chat flush emits ordinary leading JSON once', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"ordinaryLeading":true}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', stream: true, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] }); + const visible = readSseData(res.text) + .flatMap((event) => event.choices || []) + .flatMap((choice) => choice.delta?.content ? [choice.delta.content] : []); + expect(visible).toEqual(['{"ordinaryLeading":true}']); + }); + + test('buffered messages flush emits ordinary leading JSON once', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"ordinaryLeading":true}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send({ + model: 'opencode/kimi-k2.5', + max_tokens: 100, + stream: true, + messages: [{ role: 'user', content: 'Read a.txt' }], + tools: [{ name: 'read', description: 'Read a file', input_schema: readTool.function.parameters }] + }); + const visible = readSseData(res.text) + .filter((event) => event.type === 'content_block_delta' && event.delta?.type === 'text_delta') + .map((event) => event.delta.text); + expect(visible).toEqual(['{"ordinaryLeading":true}']); + }); + + test('buffered responses flush emits an ordinary leading array once', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '[1,2,3]' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + stream: true, + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }); + const visible = readSseData(res.text) + .filter((event) => event.type === 'response.output_text.delta') + .map((event) => event.delta); + expect(visible).toEqual(['[1,2,3]']); + }); + + test.each([ + ['invalid arguments', '{"name":"read","arguments":{}}'], + ['malformed markup', 'prefix {"name":"read" arguments suffix'], + ['valid plus malformed', '{"name":"read","arguments":{"path":"a.txt"}}{"name":"read" arguments'], + ['valid plus invalid array member', '[{"name":"read","arguments":{"path":"a.txt"}},{"arguments":{"path":"b.txt"}}]'], + ['valid plus unknown', '{"name":"read","arguments":{"path":"a.txt"}}{"name":"missing","arguments":{}}'], + ['unclosed canonical opener', '{"name":"read","arguments":{"path":"a.txt"}}'], + ['unclosed DSML container', '{"name":"read","arguments":{"path":"a.txt"}}'], + ['unclosed DSML marker container', '<||DSML||tool_calls><||DSML||invoke name="read"><||DSML||parameter name="path" string="true">a.txt'], + ['unclosed tool_call wrapper', '{"name":"read","arguments":{"path":"a.txt"}}'], + ['valid DSML plus unclosed wrapper', 'a.txt{"name":"read"'], + ['bare array with an invalid member', '[{"name":"read","arguments":{"path":"a.txt"}},{"arguments":{"path":"b.txt"}}]'], + ['bare array with an unknown member', '[{"name":"read","arguments":{"path":"a.txt"}},{"name":"missing","arguments":{}}]'], + ['bare array with a trailing member', '[{"name":"read","arguments":{"path":"a.txt"}},"junk"]'] + ])('external chat stream fails closed for %s', async (_label, markup) => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: markup } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', stream: true, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('data: {"error"'); + expect(res.text).not.toContain('data: [DONE]'); + }); + + test('external chat stream rejects conflicting arguments under one explicit id', async () => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { + type: 'message.part.updated', + properties: { + part: { type: 'text', sessionID: 'test-session-id' }, + delta: '[{"id":"call_same","name":"read","arguments":{"path":"a.txt"}},{"id":"call_same","name":"read","arguments":{"path":"b.txt"}}]' + } + }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', stream: true, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('duplicate external tool call id'); + expect(res.text).not.toContain('data: [DONE]'); + }); + + test('external chat stream allows parallel calls with index and id', async () => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '[{"id":"call_p_a","name":"read","arguments":{"path":"a.txt"}},{"id":"call_p_b","name":"read","arguments":{"path":"b.txt"}}]' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', stream: true, messages: [{ role: 'user', content: 'Read both' }], tools: [readTool] }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('call_p_a'); + expect(res.text).toContain('call_p_b'); + expect(res.text).toContain('"index":0'); + expect(res.text).toContain('"index":1'); + expect(res.text).toContain('data: [DONE]'); + }); + + test('external chat stream rejects parallel calls when parallel_tool_calls is false', async () => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '[{"id":"call_p_a","name":"read","arguments":{"path":"a.txt"}},{"id":"call_p_b","name":"read","arguments":{"path":"b.txt"}}]' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', stream: true, messages: [{ role: 'user', content: 'Read both' }], tools: [readTool], parallel_tool_calls: false }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('data: {"error"'); + expect(res.text).not.toContain('data: [DONE]'); + }); + + test.each([ + ['custom', { type: 'custom', name: 'run_shell' }], + ['namespace', { type: 'namespace', name: 'gh', tools: [{ type: 'function', name: 'ping' }] }], + ['file_search', { type: 'file_search' }], + ['mcp', { type: 'mcp', server_label: 'github' }] + ])('chat rejects unsupported %s tool with 400', async (_label, tool) => { + const res = await request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }], tools: [readTool, tool] }); + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('unsupported_tool_type'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + + test('external responses stream fails closed before response.completed', async () => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"name":"read","arguments":{}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Read a.txt', stream: true, tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] }); + expect(res.statusCode).toBe(200); + const frames = readSseFrames(res.text); + expect(frames).toHaveLength(3); + expect(frames[0].type).toBe('response.created'); + expect(frames[1].type).toBe('response.failed'); + expect(frames[2]).toBe('[DONE]'); + const created = frames[0]; + const failed = frames[1]; + expect(failed.response.id).toBe(created.response.id); + expect(failed.response.created).toBe(created.response.created); + expect(failed.response.created_at).toBe(created.response.created_at); + expect(failed.response.model).toBe(created.response.model); + expect(created.response.output).toEqual([]); + expect(failed.response.output).toEqual([]); + expect(failed.response.completed_at).toBeNull(); + expect([created.sequence_number, failed.sequence_number]).toEqual([0, 1]); + expect(frames.some((frame) => frame && frame.type === 'response.output_item.added')).toBe(false); + expect(created.response.tools).toEqual([{ type: 'function', name: 'read', parameters: readTool.function.parameters }]); + expect(sdkMocks.sessionDelete).toHaveBeenCalledWith({ path: { id: 'test-session-id' } }); + }); + + test.each(['noData', 'collect error'])('responses %s recovery reuses its first poll snapshot', async (mode) => { + sdkMocks.sessionMessages.mockReset(); + sdkMocks.sessionMessages + .mockResolvedValueOnce([{ + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: '{"name":"read","arguments":{"path":"a.txt"}}' }] + }]) + .mockRejectedValueOnce(new Error('second transient poll failure')); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream( + mode === 'noData' + ? [{ type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } }] + : [] + )); + if (mode === 'collect error') { + sdkMocks.eventSubscribe.mockReset(); + sdkMocks.eventSubscribe.mockRejectedValueOnce(new Error('event stream unavailable')); + } + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + stream: true, + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('response.completed'); + expect(res.text).not.toContain('response.failed'); + expect(sdkMocks.sessionMessages).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['chat', '/v1/chat/completions', { ...chatStreamPayload, tools: [readTool] }], + ['messages', '/v1/messages', { ...messagesStreamPayload, tools: [{ name: 'read', description: 'Read a file', input_schema: readTool.function.parameters }] }], + ['responses', '/v1/responses', { model: 'opencode/kimi-k2.5', input: 'Read a file', stream: true, tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] }] + ])('%s keeps a completed external stream when the recovery poll fails', async (_label, path, payload) => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"name":"read","arguments":{"path":"a.txt"}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + sdkMocks.sessionMessages.mockRejectedValue(new Error('poll must not run')); + const res = await request(app) + .post(path) + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send(payload); + expect(res.statusCode).toBe(200); + expect(visibleTextFor(path, res.text)).toEqual([]); + expect(sdkMocks.sessionMessages).not.toHaveBeenCalled(); + expect(res.text).not.toContain('poll must not run'); + if (path === '/v1/chat/completions') expect(res.text).toContain('"tool_calls"'); + if (path === '/v1/messages') expect(res.text).toContain('tool_use'); + if (path === '/v1/responses') expect(res.text).toContain('response.completed'); + }); + + test.each([ + ['chat', '/v1/chat/completions', { ...chatStreamPayload, tools: [readTool] }], + ['messages', '/v1/messages', { ...messagesStreamPayload, tools: [{ name: 'read', description: 'Read a file', input_schema: readTool.function.parameters }] }], + ['responses', '/v1/responses', { model: 'opencode/kimi-k2.5', input: 'Read a file', stream: true, tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] }] + ])('%s preserves partial output when recovery polling fails', async (_label, path, payload) => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStreamThenThrow([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: 'partial answer' } } + ])); + sdkMocks.sessionMessages.mockRejectedValue(new Error('poll failed')); + const res = await request(app) + .post(path) + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send(payload); + expect(res.statusCode).toBe(200); + expect(visibleTextFor(path, res.text).join('')).toBe('partial answer'); + expect(res.text).not.toContain('poll failed'); + }); + + test('chat stream disconnect aborts collection and deletes its session', async () => { + sdkMocks.eventSubscribe.mockImplementationOnce(async () => ({ + stream: (async function* () { + await new Promise(() => {}); + })() + })); + await new Promise((resolve) => { + let settled = false; + let fallbackTimer; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(fallbackTimer); + resolve(); + }; + const pending = request(app) + .post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', stream: true, messages: [{ role: 'user', content: 'Hello' }], tools: [readTool] }); + pending.end(finish); + setTimeout(() => pending.abort(), 50); + fallbackTimer = setTimeout(finish, 250); + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(sdkMocks.sessionDelete).toHaveBeenCalledWith({ path: { id: 'test-session-id' } }); + }); + + test('responses forced-call failure keeps the created response identity and sequence', async () => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.sessionPrompt + .mockResolvedValueOnce({ data: { parts: [] } }) + .mockRejectedValueOnce(new Error('forced prompt failed')); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + stream: true, + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }], + tool_choice: { type: 'function', name: 'read' } + }); + const frames = readSseFrames(res.text); + expect(frames).toHaveLength(3); + expect(frames[0].type).toBe('response.created'); + expect(frames[1].type).toBe('response.failed'); + expect(frames[2]).toBe('[DONE]'); + const created = frames[0]; + const failed = frames[1]; + expect(failed.response.id).toBe(created.response.id); + expect(failed.response.created).toBe(created.response.created); + expect(failed.response.created_at).toBe(created.response.created_at); + expect(failed.response.model).toBe(created.response.model); + expect(failed.response.completed_at).toBeNull(); + expect([created.sequence_number, failed.sequence_number]).toEqual([0, 1]); + expect(sdkMocks.sessionDelete).toHaveBeenCalledWith({ path: { id: 'test-session-id' } }); + }); + + test('external messages stream joins channels and emits tool_use only after finalization', async () => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'reasoning', sessionID: 'test-session-id' }, delta: '{"name":"read",' } }, + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '"arguments":{"path":"a.txt"}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send({ + model: 'opencode/kimi-k2.5', + max_tokens: 100, + stream: true, + messages: [{ role: 'user', content: 'Read a.txt' }], + tools: [{ name: 'read', description: 'Read a file', input_schema: readTool.function.parameters }] + }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('tool_use'); + expect(res.text).toContain('message_stop'); + expect(res.text).not.toContain(''); + }); + + test.each([ + ['chat', '/v1/chat/completions'], + ['messages', '/v1/messages'], + ['responses', '/v1/responses'] + ])('non-stream %s hides a bare call that fills only one channel', async (_label, path) => { + const bare = '{"name":"read","arguments":{"path":"a.txt"}}'; + const parts = [ + { type: 'reasoning', text: 'let me check that' }, + { type: 'text', text: bare } + ]; + sdkMocks.sessionMessages.mockReset(); + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts }]); + sdkMocks.sessionPrompt.mockResolvedValueOnce({ data: { parts } }); + const payload = path === '/v1/chat/completions' + ? { model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] } + : path === '/v1/messages' + ? { + model: 'opencode/kimi-k2.5', + max_tokens: 100, + messages: [{ role: 'user', content: 'Read a.txt' }], + tools: [{ name: 'read', description: 'Read a file', input_schema: readTool.function.parameters }] + } + : { + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }; + const res = await request(app) + .post(path) + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send(payload); + expect(res.statusCode).toBe(200); + if (path === '/v1/chat/completions') { + expect(res.body.choices[0].message.tool_calls[0].function.name).toBe('read'); + expect(res.body.choices[0].message.content).toBeNull(); + expect(res.body.choices[0].message.reasoning_content).toBe('let me check that'); + } else if (path === '/v1/messages') { + expect(res.body.content.find((item) => item.type === 'tool_use').name).toBe('read'); + expect(res.body.content.find((item) => item.type === 'thinking').thinking).toBe('let me check that'); + expect(res.body.content.filter((item) => item.type === 'text')).toEqual([]); + } else { + expect(res.body.output.find((item) => item.type === 'function_call').name).toBe('read'); + expect(res.body.reasoning.summary).toBe('let me check that'); + expect(res.body.output.some((item) => item.type === 'message')).toBe(false); + } + }); + + test.each([ + ['chat', '/v1/chat/completions'], + ['messages', '/v1/messages'], + ['responses', '/v1/responses'] + ])('non-stream %s accepts a bare call split across the two channels', async (_label, path) => { + const parts = [ + { type: 'reasoning', text: '{"name":"read","arguments":' }, + { type: 'text', text: '{"path":"a.txt"}}' } + ]; + sdkMocks.sessionMessages.mockReset(); + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts }]); + sdkMocks.sessionPrompt.mockResolvedValueOnce({ data: { parts } }); + const payload = path === '/v1/chat/completions' + ? { model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] } + : path === '/v1/messages' + ? { + model: 'opencode/kimi-k2.5', + max_tokens: 100, + messages: [{ role: 'user', content: 'Read a.txt' }], + tools: [{ name: 'read', description: 'Read a file', input_schema: readTool.function.parameters }] + } + : { + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }; + const res = await request(app) + .post(path) + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send(payload); + expect(res.statusCode).toBe(200); + if (path === '/v1/chat/completions') { + expect(res.body.choices[0].message.tool_calls[0].function.name).toBe('read'); + expect(JSON.parse(res.body.choices[0].message.tool_calls[0].function.arguments)).toEqual({ path: 'a.txt' }); + expect(res.body.choices[0].message.content).toBeNull(); + expect(res.body.choices[0].message.reasoning_content ?? null).toBeNull(); + } else if (path === '/v1/messages') { + expect(res.body.content.find((item) => item.type === 'tool_use').name).toBe('read'); + expect(res.body.content.filter((item) => item.type === 'text')).toEqual([]); + expect(res.body.content.filter((item) => item.type === 'thinking')).toEqual([]); + } else { + expect(res.body.output.find((item) => item.type === 'function_call').name).toBe('read'); + expect(res.body.output.some((item) => item.type === 'message')).toBe(false); + expect(res.body.reasoning?.summary ?? []).toEqual([]); + } + }); + + test.each([ + ['chat', '/v1/chat/completions'], + ['messages', '/v1/messages'], + ['responses', '/v1/responses'] + ])('non-stream %s keeps an ordinary JSON body', async (_label, path) => { + const parts = [ + { type: 'reasoning', text: 'let me check that' }, + { type: 'text', text: '{"ordinaryLeading":true}' } + ]; + sdkMocks.sessionMessages.mockReset(); + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts }]); + sdkMocks.sessionPrompt.mockResolvedValueOnce({ data: { parts } }); + const payload = path === '/v1/chat/completions' + ? { model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'Read a.txt' }], tools: [readTool] } + : path === '/v1/messages' + ? { + model: 'opencode/kimi-k2.5', + max_tokens: 100, + messages: [{ role: 'user', content: 'Read a.txt' }], + tools: [{ name: 'read', description: 'Read a file', input_schema: readTool.function.parameters }] + } + : { + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }; + const res = await request(app) + .post(path) + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send(payload); + expect(res.statusCode).toBe(200); + if (path === '/v1/chat/completions') { + expect(res.body.choices[0].message.tool_calls).toBeUndefined(); + expect(res.body.choices[0].message.content).toBe('{"ordinaryLeading":true}'); + } else if (path === '/v1/messages') { + expect(res.body.content.some((item) => item.type === 'tool_use')).toBe(false); + expect(res.body.content.find((item) => item.type === 'text').text).toBe('{"ordinaryLeading":true}'); + } else { + expect(res.body.output.some((item) => item.type === 'function_call')).toBe(false); + expect(res.body.output.find((item) => item.type === 'message').content[0].text).toBe('{"ordinaryLeading":true}'); + } + }); + + test('responses stream transfers a newly created owned session to response state', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: 'done' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + sdkMocks.sessionCreate.mockClear(); + sdkMocks.sessionDelete.mockClear(); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + stream: true, + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }); + expect(res.text).toContain('response.completed'); + expect(sdkMocks.sessionCreate).toHaveBeenCalledTimes(1); + expect(sdkMocks.sessionDelete).not.toHaveBeenCalled(); + + const frames = readSseFrames(res.text); + expect(frames[0].type).toBe('response.created'); + expect(frames[frames.length - 2].type).toBe('response.completed'); + expect(frames[frames.length - 1]).toBe('[DONE]'); + const completed = frames[frames.length - 2]; + const followUp = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ input: 'And a follow-up', previous_response_id: completed.response.id }); + expect(followUp.statusCode).toBe(200); + expect(sdkMocks.sessionCreate).toHaveBeenCalledTimes(1); + expect(sdkMocks.sessionDelete).not.toHaveBeenCalled(); + }); + + test('responses non-stream transfers a newly created owned session to response state', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.sessionCreate.mockClear(); + sdkMocks.sessionDelete.mockClear(); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }); + expect(res.statusCode).toBe(200); + expect(res.body.id).toMatch(/^resp_/); + expect(sdkMocks.sessionCreate).toHaveBeenCalledTimes(1); + expect(sdkMocks.sessionDelete).not.toHaveBeenCalled(); + + const followUp = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ input: 'And a follow-up', previous_response_id: res.body.id }); + expect(followUp.statusCode).toBe(200); + expect(sdkMocks.sessionCreate).toHaveBeenCalledTimes(1); + expect(sdkMocks.sessionDelete).not.toHaveBeenCalled(); + }); + + test('responses stream error preserves a shared session', async () => { + const seed = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'seed' }); + expect(seed.statusCode).toBe(200); + sdkMocks.sessionDelete.mockClear(); + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"name":"read" arguments' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + input: 'Read a.txt', + previous_response_id: seed.body.id, + stream: true, + tools: [{ type: 'function', name: 'read', parameters: readTool.function.parameters }] + }); + expect(res.text).toContain('response.failed'); + expect(sdkMocks.sessionDelete).not.toHaveBeenCalled(); + }); + + test.each([ + ['chat', '/v1/chat/completions', chatStreamPayload], + ['messages', '/v1/messages', messagesStreamPayload], + ['responses', '/v1/responses', { model: 'opencode/kimi-k2.5', input: 'Hello', stream: true }] + ])('%s stream recovery replays only the unsent snapshot suffix', async (_label, path, payload) => { + sdkMocks.sessionMessages.mockResolvedValue([{ + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'text', text: 'Hello world' }] + }]); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStreamThenThrow([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: 'Hello ' } } + ])); + const res = await request(app) + .post(path) + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send(payload); + expect(res.statusCode).toBe(200); + expect(visibleTextFor(path, res.text).join('')).toBe('Hello world'); + }); + + test.each([ + ['chat', '/v1/chat/completions', chatStreamPayload, 'data: [DONE]'], + ['messages', '/v1/messages', messagesStreamPayload, 'message_stop'], + ['responses', '/v1/responses', { model: 'opencode/kimi-k2.5', input: 'Hello', stream: true }, 'response.completed'] + ])('%s stream recovery reports a backend error instead of a normal success', async (_label, path, payload, successMarker) => { + sdkMocks.sessionMessages.mockResolvedValue([{ + info: { role: 'assistant', finish: 'stop', error: { name: 'MessageAbortedError', data: { message: 'upstream aborted mid-turn' } } }, + parts: [] + }]); + sdkMocks.eventSubscribe.mockImplementationOnce(eventStreamThenThrow([])); + const res = await request(app) + .post(path) + .set('Authorization', 'Bearer test-key') + .set('anthropic-version', '2023-06-01') + .send(payload); + expect(res.text).toContain('upstream aborted mid-turn'); + expect(res.text).not.toContain('[Proxy Error]'); + expect(res.text).not.toContain(successMarker); + expect(visibleTextFor(path, res.text)).toEqual([]); + }); + const readSseFrames = (text) => { + const frames = []; + for (const block of text.split(/\r?\n\r?\n/)) { + const dataLines = block.split(/\r?\n/).filter((line) => line.startsWith('data:')); + if (dataLines.length === 0) continue; + const data = dataLines.map((line) => line.slice(5).replace(/^ /, '')).join('\n'); + frames.push(data === '[DONE]' ? '[DONE]' : JSON.parse(data)); + } + return frames; + }; + + const optionalReasoningSummaryEvents = [ + 'response.reasoning_summary_part.added', + 'response.reasoning_summary_part.done', + 'response.reasoning_summary_text.part.added' + ]; + + const assertPhase2SseContract = (text, expectation = {}) => { + const frames = readSseFrames(text); + const doneIndexes = frames.reduce((indexes, frame, index) => { + if (frame === '[DONE]') indexes.push(index); + return indexes; + }, []); + expect(doneIndexes).toEqual([frames.length - 1]); + const events = frames.slice(0, -1); + expect(events.length).toBeGreaterThan(0); + expect(events[0].type).toBe('response.created'); + const created = events[0]; + expect(created.response.status).toBe('in_progress'); + expect(created.response.output).toEqual([]); + expect(typeof created.response.id).toBe('string'); + expect(typeof created.response.created).toBe('number'); + expect(created.response.created_at).toBe(created.response.created); + expect(typeof created.response.model).toBe('string'); + expect(Array.isArray(created.response.tools)).toBe(true); + expect(typeof created.response.parallel_tool_calls).toBe('boolean'); + if (expectation.tools !== undefined) { + expect(created.response.tools).toEqual(expectation.tools); + expect(created.response.tools).not.toContainEqual(expect.objectContaining({ name: expect.stringMatching(/^external__/) })); + } + if (expectation.parallelToolCalls !== undefined) { + expect(created.response.parallel_tool_calls).toBe(expectation.parallelToolCalls); + } + const terminalTypes = events + .filter((event) => event.type === 'response.completed' || event.type === 'response.failed') + .map((event) => event.type); + expect(terminalTypes).toHaveLength(1); + const terminal = events[events.length - 1]; + expect(terminal.type).toBe(terminalTypes[0]); + expect(terminal.response.id).toBe(created.response.id); + expect(terminal.response.created).toBe(created.response.created); + expect(terminal.response.created_at).toBe(created.response.created_at); + expect(terminal.response.model).toBe(created.response.model); + expect(terminal.response.tools).toEqual(created.response.tools); + expect(terminal.response.parallel_tool_calls).toBe(created.response.parallel_tool_calls); + const required = events.filter((event) => !optionalReasoningSummaryEvents.includes(event.type)); + const sequenceNumbers = required.map((event) => event.sequence_number); + expect(sequenceNumbers).toEqual(required.map((_, index) => index)); + expect(new Set(sequenceNumbers).size).toBe(sequenceNumbers.length); + const added = events.filter((event) => event.type === 'response.output_item.added'); + const done = events.filter((event) => event.type === 'response.output_item.done'); + expect(added.length).toBeGreaterThan(0); + const addedKeys = added.map((event) => `${event.output_index}:${event.item.type}:${event.item.id}`); + expect(new Set(addedKeys).size).toBe(addedKeys.length); + expect(added.map((event) => event.output_index)).toEqual(added.map((_, index) => index)); + expect(done.map((event) => event.output_index)).toEqual(added.map((event) => event.output_index)); + expect(done.map((event) => event.item.id)).toEqual(added.map((event) => event.item.id)); + expect(done.map((event) => event.item.type)).toEqual(added.map((event) => event.item.type)); + expect(terminal.response.output).toEqual(done.map((event) => event.item)); + const addedPositions = added.map((event) => events.indexOf(event)); + const donePositions = done.map((event) => events.indexOf(event)); + expect(donePositions.every((position, index) => position > addedPositions[index])).toBe(true); + const deltaTypes = [ + 'response.output_text.delta', + 'response.reasoning_summary_text.delta', + 'response.function_call_arguments.delta' + ]; + const deltaKeys = events + .filter((event) => deltaTypes.includes(event.type)) + .map((event) => `${event.output_index}:${event.item_id}:${event.type}`); + expect(new Set(deltaKeys).size).toBe(deltaKeys.length); + return { frames, events, created, terminal, added, done }; + }; + + const setToolStream = (parts) => { + sdkMocks.eventSubscribe.mockImplementationOnce(eventStream(parts)); + }; + + test('Phase 2A tool-only output starts at zero and keeps function arguments in delta and done', async () => { + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"id":"call_phase2","name":"read","arguments":{"path":"a.txt"}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } }, + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Read a.txt', stream: true, tools: [readTool] }); + expect(res.statusCode).toBe(200); + const contract = assertPhase2SseContract(res.text); + expect(contract.events.map((event) => event.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.function_call_arguments.delta', + 'response.function_call_arguments.done', + 'response.output_item.done', + 'response.completed' + ]); + expect(contract.added.map((event) => event.item)).toEqual([ + { id: 'call_phase2', type: 'function_call', status: 'in_progress', call_id: 'call_phase2', name: 'read', arguments: '' } + ]); + expect(contract.events.filter((event) => event.type === 'response.function_call_arguments.delta').map((event) => [event.item_id, event.output_index, event.delta])).toEqual([ + ['call_phase2', 0, '{"path":"a.txt"}'] + ]); + expect(contract.events.filter((event) => event.type === 'response.function_call_arguments.done').map((event) => [event.item_id, event.output_index, event.arguments])).toEqual([ + ['call_phase2', 0, '{"path":"a.txt"}'] + ]); + expect(contract.done.map((event) => event.item)).toEqual([ + { id: 'call_phase2', type: 'function_call', status: 'completed', call_id: 'call_phase2', name: 'read', arguments: '{"path":"a.txt"}' } + ]); + expect(contract.terminal.response.output).toEqual(contract.done.map((event) => event.item)); + }); + + test('Phase 2A reasoning-first output indexes follow added order', async () => { + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'reasoning', sessionID: 'test-session-id' }, delta: 'Think first.' } }, + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"id":"call_reasoning_phase2","name":"read","arguments":{"path":"b.txt"}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } }, + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Read b.txt', stream: true, tools: [readTool] }); + expect(res.statusCode).toBe(200); + const contract = assertPhase2SseContract(res.text); + expect(contract.events.map((event) => event.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.reasoning_summary_text.delta', + 'response.output_item.added', + 'response.function_call_arguments.delta', + 'response.reasoning_summary_text.done', + 'response.output_item.done', + 'response.function_call_arguments.done', + 'response.output_item.done', + 'response.completed' + ]); + expect(contract.added.map((event) => [event.output_index, event.item])).toEqual([ + [0, { id: 'reasoning-0', type: 'reasoning', status: 'in_progress', summary: [{ type: 'summary_text', text: '' }] }], + [1, { id: 'call_reasoning_phase2', type: 'function_call', status: 'in_progress', call_id: 'call_reasoning_phase2', name: 'read', arguments: '' }] + ]); + expect(contract.events.filter((event) => event.type === 'response.reasoning_summary_text.delta').map((event) => [event.item_id, event.output_index, event.delta])).toEqual([ + ['reasoning-0', 0, 'Think first.'] + ]); + expect(contract.done.map((event) => event.item)).toEqual([ + { id: 'reasoning-0', type: 'reasoning', status: 'completed', summary: [{ type: 'summary_text', text: 'Think first.' }] }, + { id: 'call_reasoning_phase2', type: 'function_call', status: 'completed', call_id: 'call_reasoning_phase2', name: 'read', arguments: '{"path":"b.txt"}' } + ]); + expect(contract.terminal.response.output).toEqual(contract.done.map((event) => event.item)); + }); + + test('Phase 2A text-plus-tool output indexes follow added order', async () => { + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: 'Visible text.' } }, + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"id":"call_text_phase2","name":"read","arguments":{"path":"b.txt"}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } }, + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Read b.txt', stream: true, tools: [readTool] }); + expect(res.statusCode).toBe(200); + const contract = assertPhase2SseContract(res.text); + expect(contract.events.map((event) => event.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.content_part.added', + 'response.output_text.delta', + 'response.output_item.added', + 'response.function_call_arguments.delta', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.function_call_arguments.done', + 'response.output_item.done', + 'response.completed' + ]); + const messageId = contract.added[0].item.id; + expect(contract.added.map((event) => event.item)).toEqual([ + { id: messageId, type: 'message', status: 'in_progress', role: 'assistant', content: [] }, + { id: 'call_text_phase2', type: 'function_call', status: 'in_progress', call_id: 'call_text_phase2', name: 'read', arguments: '' } + ]); + expect(contract.events.filter((event) => event.type === 'response.output_text.delta').map((event) => [event.item_id, event.output_index, event.delta])).toEqual([ + [messageId, 0, 'Visible text.'] + ]); + expect(contract.done.map((event) => event.item)).toEqual([ + { id: messageId, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'Visible text.', annotations: [] }] }, + { id: 'call_text_phase2', type: 'function_call', status: 'completed', call_id: 'call_text_phase2', name: 'read', arguments: '{"path":"b.txt"}' } + ]); + expect(contract.terminal.response.output).toEqual(contract.done.map((event) => event.item)); + }); + + test('Phase 2A search-plus-tool output uses contiguous search and function indexes', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ + info: { role: 'assistant', finish: 'stop' }, + parts: [{ type: 'tool', tool: 'websearch', state: { status: 'completed', input: { query: 'phase2 query' }, output: 'source https://example.com/phase2' } }], + }]); + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"id":"call_search_phase2","name":"read","arguments":{"path":"c.txt"}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } }, + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Search and read', stream: true, tools: [{ type: 'web_search' }, readTool] }); + expect(res.statusCode).toBe(200); + const contract = assertPhase2SseContract(res.text); + expect(contract.events.map((event) => event.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.web_search_call.searching', + 'response.web_search_call.completed', + 'response.output_item.added', + 'response.function_call_arguments.delta', + 'response.output_item.done', + 'response.function_call_arguments.done', + 'response.output_item.done', + 'response.completed' + ]); + expect(contract.added.map((event) => event.item)).toEqual([ + { id: 'ws_1', type: 'web_search_call', status: 'in_progress' }, + { id: 'call_search_phase2', type: 'function_call', status: 'in_progress', call_id: 'call_search_phase2', name: 'read', arguments: '' } + ]); + expect(contract.done.map((event) => event.item)).toEqual([ + { id: 'ws_1', type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'phase2 query' } }, + { id: 'call_search_phase2', type: 'function_call', status: 'completed', call_id: 'call_search_phase2', name: 'read', arguments: '{"path":"c.txt"}' } + ]); + expect(contract.terminal.response.output).toEqual(contract.done.map((event) => event.item)); + }); + + test('Phase 2A search-plus-text output announces the search call first in stream and non-stream', async () => { + const answer = 'Search result at https://example.com/phase2'; + const expectedAnnotations = [{ + type: 'url_citation', + start_index: 17, + end_index: 43, + url: 'https://example.com/phase2', + title: 'example.com' + }]; + const expectedSearchItem = { + id: 'ws_1', + type: 'web_search_call', + status: 'completed', + action: { type: 'search', query: 'phase2 query' } + }; + const withFixedMessageId = (items) => items.map((item) => ( + item.type === 'message' ? { ...item, id: '' } : item + )); + const searchAnswerMessages = [{ + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { type: 'text', text: answer }, + { type: 'tool', tool: 'websearch', state: { status: 'completed', input: { query: 'phase2 query' }, output: 'source https://example.com/phase2' } }, + ], + }]; + + sdkMocks.sessionMessages.mockResolvedValue(searchAnswerMessages); + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: answer } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } }, + ]); + const streamed = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Search for phase 2', stream: true, tools: [{ type: 'web_search' }, readTool] }); + expect(streamed.statusCode).toBe(200); + const contract = assertPhase2SseContract(streamed.text, { tools: [{ type: 'web_search' }, readTool], parallelToolCalls: true }); + expect(contract.events.map((event) => event.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.web_search_call.searching', + 'response.web_search_call.completed', + 'response.output_item.added', + 'response.content_part.added', + 'response.output_text.delta', + 'response.output_item.done', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed' + ]); + expect(contract.added.map((event) => [event.output_index, event.item.type, event.item.id])).toEqual([ + [0, 'web_search_call', 'ws_1'], + [1, 'message', contract.added[1].item.id] + ]); + expect(contract.added[1].item.id).toMatch(/^msg_/); + expect(contract.done.map((event) => [event.output_index, event.item.type, event.item.id])).toEqual([ + [0, 'web_search_call', 'ws_1'], + [1, 'message', contract.added[1].item.id] + ]); + expect(contract.events.filter((event) => event.type === 'response.output_text.delta').map((event) => [event.output_index, event.delta])).toEqual([[1, answer]]); + expect(contract.events.filter((event) => event.type === 'response.output_text.done').map((event) => [event.output_index, event.text])).toEqual([[1, answer]]); + expect(contract.terminal.type).toBe('response.completed'); + expect(withFixedMessageId(contract.terminal.response.output)).toEqual([ + expectedSearchItem, + { + id: '', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: answer, annotations: expectedAnnotations }] + } + ]); + + sdkMocks.sessionMessages.mockResolvedValue(searchAnswerMessages); + sdkMocks.sessionPrompt.mockResolvedValueOnce({ data: { parts: [] } }); + const nonStreamed = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Search for phase 2', tools: [{ type: 'web_search' }, readTool] }); + expect(nonStreamed.statusCode).toBe(200); + expect(nonStreamed.body.status).toBe('completed'); + expect(nonStreamed.body.output.map((item) => item.type)).toEqual(['web_search_call', 'message']); + expect(nonStreamed.body.output[1].id).toMatch(/^msg_/); + expect(withFixedMessageId(nonStreamed.body.output)).toEqual(withFixedMessageId(contract.terminal.response.output)); + }); + + test('Phase 2A hosted search without external tools defers reasoning and text behind the search call', async () => { + const reasoningText = 'Look up the docs first.'; + const answer = 'Grounded at https://example.com/phase2'; + const expectedAnnotations = [{ + type: 'url_citation', + start_index: 12, + end_index: 38, + url: 'https://example.com/phase2', + title: 'example.com' + }]; + const expectedSearchItem = { + id: 'ws_1', + type: 'web_search_call', + status: 'completed', + action: { type: 'search', query: 'phase2 query' } + }; + const withFixedMessageId = (items) => items.map((item) => ( + item.type === 'message' ? { ...item, id: '' } : item + )); + const searchReasoningAnswerMessages = [{ + info: { role: 'assistant', finish: 'stop' }, + parts: [ + { type: 'reasoning', text: reasoningText }, + { type: 'text', text: answer }, + { type: 'tool', tool: 'websearch', state: { status: 'completed', input: { query: 'phase2 query' }, output: 'source https://example.com/phase2' } }, + ], + }]; + + sdkMocks.sessionMessages.mockResolvedValue(searchReasoningAnswerMessages); + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'reasoning', sessionID: 'test-session-id' }, delta: reasoningText } }, + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: answer } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } }, + ]); + const streamed = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Search for phase 2', stream: true, tools: [{ type: 'web_search' }] }); + expect(streamed.statusCode).toBe(200); + const contract = assertPhase2SseContract(streamed.text, { tools: [{ type: 'web_search' }], parallelToolCalls: true }); + expect(contract.events.map((event) => event.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.web_search_call.searching', + 'response.web_search_call.completed', + 'response.output_item.added', + 'response.reasoning_summary_text.delta', + 'response.output_item.added', + 'response.content_part.added', + 'response.output_text.delta', + 'response.output_item.done', + 'response.reasoning_summary_text.done', + 'response.output_item.done', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed' + ]); + const messageId = contract.added[2].item.id; + expect(contract.added.map((event) => [event.output_index, event.item.type, event.item.id])).toEqual([ + [0, 'web_search_call', 'ws_1'], + [1, 'reasoning', 'reasoning-0'], + [2, 'message', messageId] + ]); + expect(messageId).toMatch(/^msg_/); + expect(contract.done.map((event) => [event.output_index, event.item.type, event.item.id])).toEqual([ + [0, 'web_search_call', 'ws_1'], + [1, 'reasoning', 'reasoning-0'], + [2, 'message', messageId] + ]); + expect(contract.events.filter((event) => event.type === 'response.reasoning_summary_text.delta').map((event) => [event.output_index, event.item_id, event.delta])).toEqual([ + [1, 'reasoning-0', reasoningText] + ]); + expect(contract.events.filter((event) => event.type === 'response.output_text.delta').map((event) => [event.output_index, event.item_id, event.delta])).toEqual([ + [2, messageId, answer] + ]); + const searchAnnouncedFirst = contract.events + .filter((event) => event.type === 'response.output_item.added') + .map((event) => event.item.type); + expect(searchAnnouncedFirst.indexOf('web_search_call')).toBe(0); + expect(contract.events.findIndex((event) => event.type === 'response.output_text.delta')).toBeGreaterThan( + contract.events.findIndex((event) => event.type === 'response.web_search_call.completed') + ); + expect(contract.terminal.type).toBe('response.completed'); + expect(contract.terminal.response.output).toEqual(contract.done.map((event) => event.item)); + expect(withFixedMessageId(contract.terminal.response.output)).toEqual([ + expectedSearchItem, + { id: 'reasoning-0', type: 'reasoning', status: 'completed', summary: [{ type: 'summary_text', text: reasoningText }] }, + { + id: '', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: answer, annotations: expectedAnnotations }] + } + ]); + + sdkMocks.sessionMessages.mockResolvedValue(searchReasoningAnswerMessages); + sdkMocks.sessionPrompt.mockResolvedValueOnce({ data: { parts: [] } }); + const nonStreamed = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Search for phase 2', tools: [{ type: 'web_search' }] }); + expect(nonStreamed.statusCode).toBe(200); + expect(nonStreamed.body.status).toBe('completed'); + expect(nonStreamed.body.output.map((item) => item.type)).toEqual(['web_search_call', 'message']); + expect(nonStreamed.body.output[0]).toEqual(expectedSearchItem); + expect(nonStreamed.body.output[1].id).toMatch(/^msg_/); + expect(withFixedMessageId(nonStreamed.body.output)).toEqual([ + expectedSearchItem, + { + id: '', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: answer, annotations: expectedAnnotations }] + } + ]); + const withoutReasoning = contract.terminal.response.output.filter((item) => item.type !== 'reasoning'); + expect(withFixedMessageId(withoutReasoning)).toEqual(withFixedMessageId(nonStreamed.body.output)); + }); + + test('Phase 2A mid-stream failure finalizes the announced item and reports it as partial failed output', async () => { + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: 'Partial answer' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', error: { name: 'MessageAbortedError', data: { message: 'upstream aborted mid-turn' } } } } }, + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Hello', stream: true }); + expect(res.statusCode).toBe(200); + const contract = assertPhase2SseContract(res.text, { tools: [], parallelToolCalls: true }); + expect(contract.events.map((event) => event.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.content_part.added', + 'response.output_text.delta', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.failed' + ]); + const messageId = contract.added[0].item.id; + expect(messageId).toMatch(/^msg_/); + expect(contract.added.map((event) => [event.output_index, event.item.type, event.item.id])).toEqual([ + [0, 'message', messageId] + ]); + expect(contract.events.filter((event) => event.type === 'response.output_text.done').map((event) => [event.output_index, event.content_index, event.item_id, event.text])).toEqual([ + [0, 0, messageId, 'Partial answer'] + ]); + expect(contract.events.filter((event) => event.type === 'response.content_part.done').map((event) => [event.output_index, event.content_index, event.part])).toEqual([ + [0, 0, { type: 'output_text', text: 'Partial answer', annotations: [] }] + ]); + const partialMessageItem = { + id: messageId, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'Partial answer', annotations: [] }] + }; + expect(contract.done.map((event) => [event.output_index, event.item])).toEqual([ + [0, partialMessageItem] + ]); + expect(contract.terminal.type).toBe('response.failed'); + expect(contract.terminal.response.status).toBe('failed'); + expect(contract.terminal.response.completed_at).toBeNull(); + expect(contract.terminal.response.incomplete_details).toBeNull(); + expect(contract.terminal.response.usage).toBeNull(); + expect(contract.terminal.response.output).toEqual([partialMessageItem]); + expect(contract.terminal.response.error).toEqual({ + message: 'upstream aborted mid-turn', + type: 'internal_error', + code: 'MessageAbortedError' + }); + }); + + test.each([ + [ + 'missing input', + { model: 'opencode/kimi-k2.5', stream: true, tools: [readTool] }, + 'input is required', + 'input_required' + ], + [ + 'empty input array', + { model: 'opencode/kimi-k2.5', input: [], stream: true, tools: [readTool] }, + 'input is required', + 'input_required' + ], + [ + 'unknown tool_choice', + { + model: 'opencode/kimi-k2.5', + input: 'Use a missing tool', + stream: true, + tools: [readTool], + tool_choice: { type: 'function', name: 'missing' } + }, + 'tool_choice references an unknown tool: missing', + 'unknown_tool' + ], + [ + 'unknown previous_response_id', + { + model: 'opencode/kimi-k2.5', + input: 'Hello', + stream: true, + tools: [readTool], + previous_response_id: 'resp_phase2_missing' + }, + 'Invalid or expired previous_response_id', + 'invalid_previous_response_id' + ] + ])('Phase 2A preflight %s stays a 200 SSE created then failed then done lifecycle', async (_label, payload, errorMessage, errorCode) => { + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send(payload); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/event-stream'); + const frames = readSseFrames(res.text); + expect(frames).toHaveLength(3); + expect(frames[0].type).toBe('response.created'); + expect(frames[1].type).toBe('response.failed'); + expect(frames[2]).toBe('[DONE]'); + const created = frames[0]; + const failed = frames[1]; + expect(created.response.status).toBe('in_progress'); + expect(created.response.output).toEqual([]); + expect(created.response.model).toBe('opencode/kimi-k2.5'); + expect(failed.response.id).toBe(created.response.id); + expect(failed.response.created).toBe(created.response.created); + expect(failed.response.created_at).toBe(created.response.created_at); + expect(failed.response.model).toBe(created.response.model); + expect(failed.response.completed_at).toBeNull(); + expect(failed.response.status).toBe('failed'); + expect(failed.response.output).toEqual([]); + expect(failed.response.usage).toBeNull(); + expect(failed.response.incomplete_details).toBeNull(); + expect(failed.response.error).toEqual({ + message: errorMessage, + type: 'invalid_request_error', + code: errorCode + }); + expect([created.sequence_number, failed.sequence_number]).toEqual([0, 1]); + expect(created.response.tools).toEqual([readTool]); + expect(failed.response.tools).toEqual([readTool]); + expect(created.response.parallel_tool_calls).toBe(true); + expect(failed.response.parallel_tool_calls).toBe(true); + expect(frames.some((frame) => frame !== '[DONE]' && frame.type === 'response.output_item.added')).toBe(false); + expect(res.text).not.toContain('response.completed'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + expect(sdkMocks.eventSubscribe).not.toHaveBeenCalled(); + }); + + test.each([ + ['provider qualified', { model: 'opencode/kimi-k2.5' }, 'opencode/kimi-k2.5'], + ['bare alias', { model: 'kimi-k2.5' }, 'opencode/kimi-k2.5'], + ['gpt4 alias', { model: 'gpt4' }, 'opencode/gpt-4'], + ['no model', {}, 'opencode/kimi-k2.5'] + ])('Phase 2A %s request announces the resolved model in created and completed', async (_label, modelField, resolved) => { + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: 'Plain answer.' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ ...modelField, input: 'Hello', stream: true }); + expect(res.statusCode).toBe(200); + const contract = assertPhase2SseContract(res.text, { tools: [], parallelToolCalls: true }); + expect(contract.created.response.model).toBe(resolved); + expect(contract.terminal.response.model).toBe(resolved); + expect(contract.terminal.type).toBe('response.completed'); + expect(contract.terminal.response.output.map((item) => item.content[0].text)).toEqual(['Plain answer.']); + }); + + test('Phase 2A pre-resolve failure keeps the request model in created and failed', async () => { + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'kimi-k2.5', + input: 'Use a missing tool', + stream: true, + tools: [readTool], + tool_choice: { type: 'function', name: 'missing' } + }); + expect(res.statusCode).toBe(200); + const frames = readSseFrames(res.text); + expect(frames.map((frame) => (frame === '[DONE]' ? frame : frame.type))).toEqual([ + 'response.created', + 'response.failed', + '[DONE]' + ]); + expect(frames[0].response.model).toBe('kimi-k2.5'); + expect(frames[1].response.model).toBe('kimi-k2.5'); + }); + + test('Phase 2A pre-resolve failure without a model announces unknown', async () => { + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + input: 'Use a missing tool', + stream: true, + tools: [readTool], + tool_choice: { type: 'function', name: 'missing' } + }); + expect(res.statusCode).toBe(200); + const frames = readSseFrames(res.text); + expect(frames.map((frame) => (frame === '[DONE]' ? frame : frame.type))).toEqual([ + 'response.created', + 'response.failed', + '[DONE]' + ]); + expect(frames[0].response.model).toBe('unknown'); + expect(frames[1].response.model).toBe('unknown'); + }); + + test('Phase 2A post-resolve failure keeps the resolved model in created and failed', async () => { + sdkMocks.sessionCreate.mockRejectedValueOnce(new Error('backend refused the session')); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'gpt4', input: 'Hello', stream: true }); + expect(res.statusCode).toBe(200); + const frames = readSseFrames(res.text); + expect(frames.map((frame) => (frame === '[DONE]' ? frame : frame.type))).toEqual([ + 'response.created', + 'response.failed', + '[DONE]' + ]); + expect(frames[0].response.model).toBe('opencode/gpt-4'); + expect(frames[1].response.model).toBe('opencode/gpt-4'); + }); + + test('Phase 2A stream echoes request tools and parallel setting without internal metadata', async () => { + const requestedTools = [{ type: 'web_search' }, readTool]; + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"id":"call_echo","name":"read","arguments":{"path":"a.txt"}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + stream: true, + tools: requestedTools, + parallel_tool_calls: false, + opencode: { internal_allowed_tools: ['bash'] } + }); + expect(res.statusCode).toBe(200); + const contract = assertPhase2SseContract(res.text, { tools: requestedTools, parallelToolCalls: false }); + expect(contract.terminal.response.tools).toEqual(requestedTools); + expect(res.text).not.toContain('internal_allowed_tools'); + expect(res.text).not.toContain('external__'); + expect(res.text).not.toContain('"websearch"'); + }); + + test('Phase 2A stream defaults parallel tool calls to true when the request omits it', async () => { + setToolStream([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'test-session-id' }, delta: '{"id":"call_parallel_default","name":"read","arguments":{"path":"a.txt"}}' } }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Read a.txt', stream: true, tools: [readTool] }); + expect(res.statusCode).toBe(200); + const contract = assertPhase2SseContract(res.text, { tools: [readTool], parallelToolCalls: true }); + expect(contract.terminal.response.parallel_tool_calls).toBe(true); + }); + + test('Phase 2A non-stream response echoes request tools and parallel setting', async () => { + sdkMocks.sessionPrompt.mockResolvedValueOnce({ + data: { + parts: [{ type: 'text', text: '{"id":"call_ns_echo","name":"read","arguments":{"path":"a.txt"}}' }] + } + }); + const requestedTools = [{ type: 'web_search' }, readTool]; + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + tools: requestedTools, + parallel_tool_calls: false, + opencode: { internal_allowed_tools: ['bash'] } + }); + expect(res.statusCode).toBe(200); + expect(res.body.object).toBe('response'); + expect(res.body.model).toBe('opencode/kimi-k2.5'); + expect(res.body.tools).toEqual(requestedTools); + expect(res.body.parallel_tool_calls).toBe(false); + expect(res.body.output).toEqual([ + { id: 'call_ns_echo', type: 'function_call', status: 'completed', call_id: 'call_ns_echo', name: 'read', arguments: '{"path":"a.txt"}' } + ]); + expect(res.text).not.toContain('internal_allowed_tools'); + expect(res.text).not.toContain('external__'); + }); + + test('Phase 2A non-stream response reports no tools and true parallel calls by default', async () => { + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Hello' }); + expect(res.statusCode).toBe(200); + expect(res.body.tools).toEqual([]); + expect(res.body.parallel_tool_calls).toBe(true); + }); + + test('Phase 2A stream rejects conflicting arguments under one explicit function_call id', async () => { + setToolStream([ + { + type: 'message.part.updated', + properties: { + part: { type: 'text', sessionID: 'test-session-id' }, + delta: '[{"id":"call_conflict","name":"read","arguments":{"path":"a.txt"}},{"id":"call_conflict","name":"read","arguments":{"path":"b.txt"}}]' + } + }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ]); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Read a.txt', stream: true, tools: [readTool] }); + expect(res.statusCode).toBe(200); + const frames = readSseFrames(res.text); + expect(frames.map((frame) => (frame === '[DONE]' ? frame : frame.type))).toEqual([ + 'response.created', + 'response.failed', + '[DONE]' + ]); + const [created, failed] = frames; + expect(created.response.model).toBe('opencode/kimi-k2.5'); + expect(failed.response.model).toBe('opencode/kimi-k2.5'); + expect(failed.response.error.code).toBe('duplicate_external_tool_call_id'); + expect(failed.response.error.type).toBe('server_error'); + expect(failed.response.error.message).toContain('duplicate external tool call id'); + expect(failed.response.output).toEqual([]); + expect(frames.some((frame) => frame !== '[DONE]' && String(frame.type).startsWith('response.function_call'))).toBe(false); + expect(frames.some((frame) => frame !== '[DONE]' && frame.type === 'response.output_item.added')).toBe(false); + }); + + test('Phase 2A non-stream rejects conflicting arguments under one explicit function_call id', async () => { + sdkMocks.sessionPrompt.mockResolvedValueOnce({ + data: { + parts: [{ type: 'text', text: '[{"id":"call_conflict","name":"read","arguments":{"path":"a.txt"}},{"id":"call_conflict","name":"read","arguments":{"path":"b.txt"}}]' }] + } + }); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Read a.txt', tools: [readTool] }); + expect(res.statusCode).toBe(502); + expect(res.body.error.code).toBe('duplicate_external_tool_call_id'); + expect(res.body.error.message).toContain('duplicate external tool call id'); + expect(res.body.output).toBeUndefined(); + expect(res.text).not.toContain('"call_id":"call_conflict"'); + }); + + }); + + describe('Phase 2B Codex Lite tool declarations and continuation state', () => { + const liteRead = { + type: 'function', + name: 'read', + description: 'Read a file', + parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } + }; + const liteWrite = { + type: 'function', + name: 'write', + description: 'Write a file', + parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } + }; + const issueParams = { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] }; + const customParams = { + type: 'object', + properties: { input: { type: 'string' } }, + required: ['input'] + }; + const userItem = (text) => ({ type: 'message', role: 'user', content: [{ type: 'input_text', text }] }); + const additionalToolsItem = (tools) => ({ type: 'additional_tools', role: 'developer', tools }); + const namespaceTool = (name, children) => ({ type: 'namespace', name, description: `${name} namespace`, tools: children }); + const lastPromptBody = () => sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]?.body ?? {}; + const lastPromptText = () => (lastPromptBody().parts || []).map((part) => part.text || '').join('\n'); + const lastSystemPrompt = () => String(lastPromptBody().system || ''); + const toolListEntry = (namespacedName) => `"name":"${namespacedName}","client_name":`; + const readSseFramesLite = (text) => { + const frames = []; + for (const block of text.split(/\r?\n\r?\n/)) { + const dataLines = block.split(/\r?\n/).filter((line) => line.startsWith('data:')); + if (dataLines.length === 0) continue; + const data = dataLines.map((line) => line.slice(5).replace(/^ /, '')).join('\n'); + frames.push(data === '[DONE]' ? '[DONE]' : JSON.parse(data)); + } + return frames; + }; + const eventStreamLite = (events) => async () => ({ stream: (async function* () { for (const event of events) yield event; })() }); + const postResponses = (payload) => request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send(payload); + const replyWithToolCall = (callId, namespacedName, args) => sdkMocks.sessionPrompt.mockResolvedValueOnce({ + data: { parts: [{ type: 'text', text: `{"id":"${callId}","name":"${namespacedName}","arguments":${args}}` }] } + }); + const streamedCallReply = (callId, namespacedName, args) => sdkMocks.eventSubscribe.mockImplementationOnce(eventStreamLite([ + { + type: 'message.part.updated', + properties: { + part: { type: 'text', sessionID: 'test-session-id' }, + delta: `{"id":"${callId}","name":"${namespacedName}","arguments":${args}}` + } + }, + { type: 'message.updated', properties: { info: { sessionID: 'test-session-id', finish: 'stop' } } } + ])); + + describe('declaration expansion', () => { + test('collects additional_tools declarations with the path the client wrote', () => { + const items = [ + userItem('hi'), + additionalToolsItem([liteWrite]), + { type: 'additional_tools', role: 'developer' }, + { type: 'function_call', call_id: 'c1', name: 'read', arguments: '{}' } + ]; + expect(collectAdditionalToolSources(items)).toEqual([ + { path: 'input[1].tools', tools: [liteWrite] }, + { path: 'input[2].tools', tools: undefined } + ]); + expect(collectAdditionalToolSources(items, 'messages')).toEqual([ + { path: 'messages[1].tools', tools: [liteWrite] }, + { path: 'messages[2].tools', tools: undefined } + ]); + expect(collectAdditionalToolSources(additionalToolsItem([liteWrite]))).toEqual([ + { path: 'input.tools', tools: [liteWrite] } + ]); + expect(collectAdditionalToolSources('nope')).toEqual([]); + expect(collectAdditionalToolSources(null)).toEqual([]); + expect(collectAdditionalToolSources([])).toEqual([]); + }); + + test('rejects an additional_tools payload that is not an array instead of dropping it', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [userItem('Read a.txt'), { type: 'additional_tools', role: 'developer', tools: { read: liteRead } }], + tools: [liteRead] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toEqual({ + message: 'Additional tool declarations at input[1].tools must be an array.', + type: 'invalid_request_error', + code: 'invalid_tool_declaration' + }); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + + test('reports the object input path the client wrote, not an input[0] index', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: additionalToolsItem([{ type: 'local_shell' }]), + tools: [liteRead] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('unsupported_tool_type'); + expect(res.body.error.message).toContain('at input.tools[0]'); + expect(res.body.error.message).not.toContain('input[0]'); + }); + + test('shares the converter namespace qualification semantics', () => { + expect(qualifyToolName('gh', 'create_issue')).toBe('gh__create_issue'); + expect(qualifyToolName('gh', 'gh__create_issue')).toBe('gh__create_issue'); + expect(qualifyToolName('gh__', 'create_issue')).toBe('gh__create_issue'); + expect(qualifyToolName('a', qualifyToolName('a', 'b'))).toBe('a__b'); + expect(qualifyToolName('', 'read')).toBe('read'); + expect(qualifyToolName('gh', '')).toBe(''); + // mcp__ leaves are already namespace-qualified: passthrough. + expect(qualifyToolName('gh', 'mcp__github__create_issue')).toBe('mcp__github__create_issue'); + expect(expandToolDeclarations([ + namespaceTool('gh', [{ type: 'function', name: 'mcp__github__create_issue' }]) + ]).tools.map((tool) => tool.name)).toEqual(['mcp__github__create_issue']); + }); + + test('qualifies every namespace leaf and keeps the container out of the registry', () => { + const { tools, issues, customToolNames } = expandToolDeclarations([ + liteRead, + namespaceTool('gh', [ + { type: 'function', name: 'create_issue', description: 'Create', parameters: issueParams }, + namespaceTool('ops', [{ type: 'function', name: 'ping', parameters: { type: 'object', properties: {} } }]) + ]) + ]); + + expect(issues).toEqual([]); + expect(customToolNames).toEqual([]); + expect(tools.map((tool) => tool.name)).toEqual(['read', 'gh__create_issue', 'gh__ops__ping']); + expect(tools[1].description).toBe('Create'); + expect(tools[1].parameters).toEqual(issueParams); + expect(buildExternalToolRegistry(tools).map((tool) => tool.namespacedName)).toEqual([ + 'external__read', + 'external__gh__create_issue', + 'external__gh__ops__ping' + ]); + }); + + test('names a namespace leaf the same way no matter where it sits in the array', () => { + const declarations = [ + { type: 'function', name: 'create_issue' }, + liteRead, + namespaceTool('gh', [ + { type: 'function', name: 'create_issue' }, + { type: 'function', name: 'ping' } + ]) + ]; + const forward = expandToolDeclarations(declarations); + const reversed = expandToolDeclarations([...declarations].reverse()); + const nestedFirst = expandToolDeclarations([ + namespaceTool('gh', [{ type: 'function', name: 'create_issue' }]), + namespaceTool('gh', [{ type: 'function', name: 'ping' }]) + ]); + + const names = (expansion) => expansion.tools.map((tool) => tool.name).sort(); + expect(names(forward)).toEqual(['create_issue', 'gh__create_issue', 'gh__ping', 'read']); + expect(names(reversed)).toEqual(names(forward)); + expect(names(nestedFirst)).toEqual(['gh__create_issue', 'gh__ping']); + expect(buildExternalToolRegistry(reversed.tools).map((tool) => tool.originalName).sort()).toEqual( + buildExternalToolRegistry(forward.tools).map((tool) => tool.originalName).sort() + ); + expect(forward.tools.map((tool) => tool.name)).not.toContain('ping'); + }); + + test('dedupes an identical repeated declaration instead of inventing a _2 name', () => { + const { tools, issues } = expandToolDeclarationSources([ + { path: 'tools', tools: [{ type: 'function', name: 'create_issue' }, liteRead, liteRead] }, + { + path: 'input[2].tools', + tools: [ + namespaceTool('gh', [ + { type: 'function', name: 'create_issue' }, + { type: 'function', name: 'read' }, + { type: 'function', name: 'read' } + ]) + ] + }, + { path: 'input[4].tools', tools: [{ ...liteRead, parameters: { ...liteRead.parameters, required: ['path'] } }] } + ]); + + expect(issues).toEqual([]); + expect(tools.map((tool) => tool.name)).toEqual([ + 'create_issue', + 'read', + 'gh__create_issue', + 'gh__read' + ]); + expect(tools.map((tool) => tool.description)).toEqual([ + undefined, + 'Read a file', + undefined, + undefined + ]); + expect(tools.some((tool) => String(tool.name).includes('_2'))).toBe(false); + expect(buildExternalToolRegistry(tools).map((tool) => tool.namespacedName)).toEqual([ + 'external__create_issue', + 'external__read', + 'external__gh__create_issue', + 'external__gh__read' + ]); + }); + + test('rejects a colliding name whose schema or description disagrees', () => { + expect(expandToolDeclarationSources([ + { path: 'tools', tools: [liteRead] }, + { path: 'input[2].tools', tools: [{ ...liteRead, description: 'duplicate' }] } + ]).issues).toEqual([ + { + code: 'invalid_tool_declaration', + toolType: 'function', + path: 'input[2].tools[0]', + message: 'Tool "read" at input[2].tools[0] conflicts with the declaration at tools[0]. Only an identical repeated declaration is deduplicated.' + } + ]); + expect(expandToolDeclarationSources([ + { path: 'tools', tools: [{ type: 'function', name: 'ops__ping', description: 'Flat ping' }] }, + { + path: 'input[1].tools', + tools: [namespaceTool('ops', [{ type: 'function', name: 'ops__ping', description: 'Namespaced ping' }])] + } + ]).issues).toEqual([ + { + code: 'invalid_tool_declaration', + toolType: 'function', + path: 'input[1].tools[0].tools[0]', + message: 'Tool "ops__ping" at input[1].tools[0].tools[0] conflicts with the declaration at tools[0]. Only an identical repeated declaration is deduplicated.' + } + ]); + expect(expandToolDeclarationSources([ + { path: 'tools', tools: [{ type: 'function', name: 'gh__read', description: 'Local read', parameters: liteRead.parameters }] }, + { + path: 'input[1].tools', + tools: [namespaceTool('gh', [{ type: 'function', name: 'read', description: 'Remote read', parameters: liteRead.parameters }])] + } + ]).issues).toEqual([ + { + code: 'invalid_tool_declaration', + toolType: 'function', + path: 'input[1].tools[0].tools[0]', + message: 'Tool "gh__read" at input[1].tools[0].tools[0] conflicts with the declaration at tools[0]. Only an identical repeated declaration is deduplicated.' + } + ]); + // Same name and same contract, only the `enabled` hint differs: + // both expand to the same registry entry, so the first one wins. + expect(expandToolDeclarationSources([ + { path: 'tools', tools: [{ type: 'function', name: 'ping' }] }, + { path: 'input[2].tools', tools: [{ type: 'function', name: 'ping', enabled: true }] } + ]).issues).toEqual([]); + }); + + test('rejects a conflicting duplicate declaration over the wire', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [userItem('Read a.txt'), additionalToolsItem([{ ...liteRead, description: 'duplicate' }])], + tools: [liteRead] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('invalid_tool_declaration'); + expect(res.body.error.type).toBe('invalid_request_error'); + expect(res.body.error.message).toContain('conflicts with the declaration at tools[0]'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + + test('maps a custom declaration onto the wrapped function schema', () => { + const { tools, issues, customToolNames } = expandToolDeclarations([ + { type: 'custom', name: 'run_shell', description: 'Run a command', format: { type: 'text' } }, + { type: 'custom', custom: { name: 'apply_patch' } }, + { type: 'custom' }, + namespaceTool('gh', [{ type: 'custom', custom: 'scratch' }]) + ]); + + expect(issues).toEqual([]); + expect(customToolNames).toEqual(['run_shell', 'apply_patch', 'custom_tool', 'gh__scratch']); + expect(tools.map((tool) => tool.name)).toEqual(['run_shell', 'apply_patch', 'custom_tool', 'gh__scratch']); + expect(tools.every((tool) => tool.type === 'function')).toBe(true); + expect(tools.every((tool) => JSON.stringify(tool.parameters) === JSON.stringify(customParams))).toBe(true); + expect(buildExternalToolRegistry(tools).map((tool) => tool.namespacedName)).toEqual([ + 'external__run_shell', + 'external__apply_patch', + 'external__custom_tool', + 'external__gh__scratch' + ]); + }); + + test('reports every unsupported declaration with the path the client wrote', () => { + expect(expandToolDeclarationSources([ + { path: 'tools', tools: [{ type: 'file_search' }, { type: 'function', name: 'read' }] }, + { + path: 'input[1].tools', + tools: [namespaceTool('gh', [{ type: 'tool_search', name: 'find' }, { type: 'function' }])] + }, + { path: 'input[2].tools', tools: [{ name: 'typeless' }] } + ]).issues).toEqual([ + { + code: 'unsupported_tool_type', + toolType: 'file_search', + path: 'tools[0]', + message: 'Unsupported tool type "file_search" at tools[0]. Only function, custom and namespace tools are supported.' + }, + { + code: 'unsupported_tool_type', + toolType: 'tool_search', + path: 'input[1].tools[0].tools[0]', + message: 'Unsupported tool type "tool_search" at input[1].tools[0].tools[0] in namespace "gh". Only function, custom and namespace tools are supported.' + }, + { + code: 'invalid_tool_declaration', + toolType: 'function', + path: 'input[1].tools[0].tools[1]', + message: 'Function tool at input[1].tools[0].tools[1] requires a name.' + }, + { + code: 'unsupported_tool_type', + toolType: 'missing', + path: 'input[2].tools[0]', + message: 'Unsupported tool type "missing" at input[2].tools[0]. Only function, custom and namespace tools are supported.' + } + ]); + expect(expandToolDeclarations([ + { type: 'namespace', name: 'empty' }, + { type: 'namespace', tools: [{ type: 'function', name: 'orphan' }] } + ]).issues).toEqual([ + { + code: 'invalid_tool_declaration', + toolType: 'namespace', + path: 'tools[0]', + message: 'Namespace tool at tools[0] declares no nested tools.' + }, + { + code: 'invalid_tool_declaration', + toolType: 'namespace', + path: 'tools[1]', + message: 'Namespace tool at tools[1] requires a name.' + } + ]); + expect(expandToolDeclarations([])).toEqual({ tools: [], issues: [], customToolNames: [] }); + expect(expandToolDeclarationSources('nope')).toEqual({ tools: [], issues: [], customToolNames: [] }); + }); + + test('skips a hosted search grant in place, so every reported path keeps the client index', () => { + const hostedSearch = { type: 'web_search' }; + const localShell = { type: 'local_shell' }; + const unsupported = (path, namespace) => ({ + code: 'unsupported_tool_type', + toolType: 'local_shell', + path, + message: + 'Unsupported tool type "local_shell" at ' + + path + + (namespace ? ' in namespace "' + namespace + '"' : '') + + '. Only function, custom and namespace tools are supported.' + }); + + expect(expandToolDeclarationSources([ + { path: 'tools', tools: [hostedSearch, localShell] } + ]).issues).toEqual([unsupported('tools[1]')]); + expect(expandToolDeclarationSources([ + { path: 'tools', tools: [namespaceTool('ops', [hostedSearch, localShell])] } + ]).issues).toEqual([unsupported('tools[0].tools[1]', 'ops')]); + expect(expandToolDeclarationSources([ + { path: 'input[1].tools', tools: [hostedSearch, localShell] } + ]).issues).toEqual([unsupported('input[1].tools[1]')]); + }); + + test('treats a hosted search grant as no declaration at all, never as an empty namespace', () => { + const hostedOnly = expandToolDeclarations([ + { type: 'web_search_preview' }, + namespaceTool('search', [{ type: 'web_search' }]), + namespaceTool('ops', [{ type: 'Web_Search_20260222' }, { type: 'function', name: 'ping' }]) + ]); + + expect(hostedOnly.issues).toEqual([]); + expect(hostedOnly.tools.map((tool) => tool.name)).toEqual(['ops__ping']); + expect(buildExternalToolRegistry(hostedOnly.tools).map((tool) => tool.namespacedName)).toEqual([ + 'external__ops__ping' + ]); + }); + + test('rejects a bad top-level declaration at the index written after a hosted search grant', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [userItem('Run a shell command'), additionalToolsItem([{ type: 'web_search' }, { type: 'local_shell' }])], + tools: [{ type: 'web_search' }, { type: 'local_shell' }] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('unsupported_tool_type'); + expect(res.body.error.message).toContain('at tools[1]'); + expect(res.body.error.message).toContain('at input[1].tools[1]'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + + test('rejects a bad namespace leaf at the index written after a hosted search grant', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run a shell command', + tools: [namespaceTool('ops', [{ type: 'web_search' }, { type: 'local_shell' }])] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('unsupported_tool_type'); + expect(res.body.error.message).toContain('at tools[0].tools[1]'); + expect(res.body.error.message).not.toContain('tools[0].tools[0]'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + }); + + describe('namespace policy and risk', () => { + const deleteEntry = (namespaceMeta) => { + const container = namespaceTool('gh', [{ type: 'function', name: 'delete_repo' }]); + Object.assign(container, namespaceMeta || {}); + const { tools } = expandToolDeclarations([container]); + return buildExternalToolRegistry(tools)[0]; + }; + + test('infers risk from the bare leaf so qualification cannot downgrade it', () => { + const entry = deleteEntry(); + expect(entry.originalName).toBe('gh__delete_repo'); + expect(entry.declaredName).toBe('delete_repo'); + expect(entry.sideEffect).toBe('delete'); + expect(entry.riskLevel).toBe('critical'); + expect(evaluateToolPolicy(entry, {})).toEqual({ status: 'allow', effectiveRisk: 'critical' }); + }); + + test('ignores a leaf annotation that is not a real tail of the qualified name', () => { + const entry = buildExternalToolRegistry([{ + type: 'function', + name: 'delete_repo', + [PROXY_LEAF_NAME_KEY]: 'read' + }])[0]; + expect(entry.declaredName).toBe('delete_repo'); + expect(entry.sideEffect).toBe('delete'); + expect(entry.riskLevel).toBe('critical'); + }); + + test('never lets a client body forge the leaf name, risk or side effect of delete_all', () => { + const forged = { + type: 'function', + name: 'delete_all', + [PROXY_LEAF_NAME_KEY]: 'read', + x_proxy_side_effect: 'read', + x_proxy_risk_level: 'low', + x_proxy_requires_confirmation: false + }; + const [flat] = buildExternalToolRegistry([forged]); + expect(flat.declaredName).toBe('delete_all'); + expect(flat.sideEffect).toBe('delete'); + expect(flat.riskLevel).toBe('critical'); + expect(flat.requiresConfirmation).toBe(false); + + const [nested] = buildExternalToolRegistry([{ + type: 'function', + function: { + name: 'delete_all', + [PROXY_LEAF_NAME_KEY]: 'read', + x_proxy_side_effect: 'read', + x_proxy_risk_level: 'low' + } + }]); + expect(nested.sideEffect).toBe('delete'); + expect(nested.riskLevel).toBe('critical'); + + // The same forgery inside a namespace: expansion strips the keys + // and authors the bare leaf itself, so inference still fires. + const { tools, issues } = expandToolDeclarations([ + namespaceTool('gh', [{ ...forged, name: 'delete_all' }]) + ]); + expect(issues).toEqual([]); + expect(tools[0][PROXY_LEAF_NAME_KEY]).toBeUndefined(); + expect(tools[0].x_proxy_side_effect).toBeUndefined(); + expect(tools[0].x_proxy_risk_level).toBeUndefined(); + expect(tools[0].x_proxy_requires_confirmation).toBeUndefined(); + expect(INTERNAL_TOOL_METADATA_KEYS.every((key) => !(key in tools[0]))).toBe(true); + const [qualified] = buildExternalToolRegistry(tools); + expect(qualified.originalName).toBe('gh__delete_all'); + expect(qualified.declaredName).toBe('delete_all'); + expect(qualified.sideEffect).toBe('delete'); + expect(qualified.riskLevel).toBe('critical'); + }); + + test('carries a forged delete_all annotation into the prompt contract unlowered', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Delete everything', + tools: [ + namespaceTool('gh', [{ + type: 'function', + name: 'delete_all', + description: 'Delete everything', + parameters: { type: 'object', properties: {} }, + [PROXY_LEAF_NAME_KEY]: 'read', + x_proxy_side_effect: 'read', + x_proxy_risk_level: 'low' + }]) + ] + }); + + expect(res.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system).toContain('"side_effect":"delete"'); + expect(system).toContain('"risk_level":"critical"'); + expect(system).not.toContain('"side_effect":"read"'); + expect(system).not.toContain('"risk_level":"low"'); + expect(system).not.toContain(PROXY_LEAF_NAME_KEY); + expect(system).not.toContain('x_proxy_'); + }); + + test('matches the operator denylist, allowlist and confirmation set on the bare leaf', () => { + const entry = deleteEntry(); + for (const denied of ['delete_repo', 'gh__delete_repo', 'external__gh__delete_repo']) { + expect(evaluateToolPolicy(entry, {}, { config: { EXTERNAL_TOOL_DENYLIST: [denied] } })).toMatchObject({ + status: 'deny', + code: 'tool_denied_by_policy' + }); + } + expect(evaluateToolPolicy(entry, {}, { config: { EXTERNAL_TOOL_ALLOWLIST: ['read'] } })).toMatchObject({ + status: 'deny', + code: 'tool_not_allowed_by_policy' + }); + expect( + evaluateToolPolicy(entry, {}, { config: { EXTERNAL_TOOL_ALLOWLIST: ['delete_repo'] } }) + ).toMatchObject({ status: 'allow' }); + expect( + evaluateToolPolicy(entry, {}, { config: { EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: ['delete_repo'] } }) + ).toMatchObject({ status: 'require_confirmation' }); + }); + + test('ignores client-authored policy metadata on a namespace and its leaves', () => { + const { tools } = expandToolDeclarations([ + Object.assign(namespaceTool('gh', [ + { type: 'function', name: 'ping' }, + { type: 'function', name: 'post', x_proxy_side_effect: 'read', x_proxy_risk_level: 'low' } + ]), { x_proxy_side_effect: 'payment', x_proxy_risk_level: 'critical', x_proxy_requires_confirmation: true }) + ]); + const registry = buildExternalToolRegistry(tools); + const containerAnnotated = registry.find((tool) => tool.originalName === 'gh__ping'); + const leafAnnotated = registry.find((tool) => tool.originalName === 'gh__post'); + + // Nothing is inherited or overridden: both fall back to the + // name-based inference the registry always applies. + expect(containerAnnotated.sideEffect).toBe('none'); + expect(containerAnnotated.riskLevel).toBe('low'); + expect(containerAnnotated.requiresConfirmation).toBe(false); + expect(leafAnnotated.sideEffect).toBe('write'); + expect(leafAnnotated.riskLevel).toBe('medium'); + expect(leafAnnotated.requiresConfirmation).toBe(false); + expect(evaluateToolPolicy(containerAnnotated, {})).toMatchObject({ status: 'allow' }); + }); + + test('treats a disabled namespace container as a disabled set of leaves', () => { + const { tools } = expandToolDeclarations([ + Object.assign(namespaceTool('gh', [ + { type: 'function', name: 'ping' }, + { type: 'function', name: 'pong', enabled: true } + ]), { enabled: false }) + ]); + const registry = buildExternalToolRegistry(tools); + expect(registry.find((tool) => tool.originalName === 'gh__ping').enabled).toBe(false); + expect(registry.find((tool) => tool.originalName === 'gh__pong').enabled).toBe(true); + }); + + test('lets an explicit null leaf flag override what the container declared', () => { + const { tools, issues } = expandToolDeclarations([ + Object.assign(namespaceTool('gh', [ + { type: 'function', name: 'ping', enabled: null }, + { type: 'function', name: 'pong' }, + { type: 'function', name: 'pang', enabled: undefined } + ]), { enabled: false }) + ]); + expect(issues).toEqual([]); + const registry = buildExternalToolRegistry(tools); + // Presence wins over inheritance, so a declared `null` clears the + // container and an omitted key inherits it. + expect(registry.find((tool) => tool.originalName === 'gh__ping').enabled).toBe(true); + expect(registry.find((tool) => tool.originalName === 'gh__pong').enabled).toBe(false); + expect(registry.find((tool) => tool.originalName === 'gh__pang').enabled).toBe(true); + }); + + test('an operator denylist still blocks a namespaced leaf end to end', async () => { + const deniedApp = createApp({ + PORT: 10000, + API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: false, + DEBUG: false, + EXTERNAL_TOOL_DENYLIST: ['delete_repo'], + EXTERNAL_TOOL_POLICY_MODE: 'enforce' + }).app; + replyWithToolCall('call_delete', 'external__gh__delete_repo', '{"repo":"a"}'); + + const res = await request(deniedApp) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/kimi-k2.5', + input: 'Delete the repo', + tools: [namespaceTool('gh', [{ type: 'function', name: 'delete_repo' }])] + }); + + expect(res.statusCode).toBe(502); + expect(res.body.error.code).toBe('external_tool_policy_blocked'); + }); + }); + + describe('client-authored name resolution', () => { + const registry = buildExternalToolRegistry(expandToolDeclarations([ + { type: 'function', name: 'create_issue' }, + namespaceTool('gh', [{ type: 'function', name: 'create_issue' }, { type: 'function', name: 'ping' }]), + namespaceTool('ops', [{ type: 'function', name: 'create_issue' }]) + ]).tools); + + test('resolves only exact names or an exact namespace composition', () => { + expect(resolveExternalToolName(registry, 'create_issue').namespacedName).toBe('external__create_issue'); + expect(resolveExternalToolName(registry, 'gh__create_issue').namespacedName).toBe('external__gh__create_issue'); + expect(resolveExternalToolName(registry, 'ops__create_issue').namespacedName).toBe('external__ops__create_issue'); + expect(resolveExternalToolName(registry, 'external__gh__ping').namespacedName).toBe('external__gh__ping'); + expect(resolveExternalToolName(registry, 'create_issue', 'gh').namespacedName).toBe('external__gh__create_issue'); + expect(resolveExternalToolName(registry, 'create_issue', 'ops').namespacedName).toBe('external__ops__create_issue'); + expect(resolveExternalToolName(registry, 'ops__create_issue', 'gh').namespacedName).toBe('external__ops__create_issue'); + expect(resolveExternalToolName(registry, 'ping', 'gh').namespacedName).toBe('external__gh__ping'); + }); + + test('never guesses a prefix or a suffix for a client-authored name', () => { + expect(resolveExternalToolName(registry, 'nope__ping')).toBeNull(); + expect(resolveExternalToolName(registry, 'elsewhere__create_issue')).toBeNull(); + expect(resolveExternalToolName(registry, 'ping__')).toBeNull(); + expect(resolveExternalToolName(registry, 'ping', 'nope')).toBeNull(); + expect(resolveExternalToolName(registry, 'ping', 'ops')).toBeNull(); + expect(resolveExternalToolName(registry, ' ')).toBeNull(); + expect(resolveExternalToolName(registry, '')).toBeNull(); + expect(resolveExternalToolName(registry, undefined)).toBeNull(); + expect(resolveExternalToolName(registry, 42)).toBeNull(); + expect(resolveExternalToolName('nope', 'ping')).toBeNull(); + expect(findExternalToolByExactName(registry, 'nope__ping')).toBeNull(); + expect(findExternalToolByExactName(registry, 'ping')).toBeNull(); + }); + + test('keeps model-emitted fuzzy matching on the validator path', () => { + expect(resolveExternalToolName(registry, 'Gh__Ping')).toBeNull(); + expect(findExternalToolByName(registry, 'Gh__Ping').namespacedName).toBe('external__gh__ping'); + expect(findExternalToolByName(registry, 'externalghping').namespacedName).toBe('external__gh__ping'); + expect(findExternalToolByName(registry, 'createissue').namespacedName).toBe('external__create_issue'); + // A tie still resolves to nothing: `ping` only exists qualified here. + expect(findExternalToolByName(registry, 'ping')).toBeNull(); + }); + }); + + describe('additional_tools and namespace requests', () => { + test('merges additional_tools into the external registry and prompt contract', async () => { + replyWithToolCall('call_lite_write', 'external__write', '{"path":"a.txt"}'); + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [userItem('Read a.txt'), additionalToolsItem([liteWrite])], + tools: [liteRead] + }); + + expect(res.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system).toContain('external__read'); + expect(system).toContain('external__write'); + expect(system).toContain(toolListEntry('external__read')); + expect(system).toContain(toolListEntry('external__write')); + expect(system).toContain('"required":["path"]'); + expect(res.body.tools).toEqual([liteRead]); + const promptText = lastPromptText(); + expect(promptText).toContain('Read a.txt'); + expect(promptText).not.toContain('additional_tools'); + expect(promptText).not.toContain('"type":"function"'); + const call = res.body.output.find((item) => item.type === 'function_call'); + expect(call).toEqual({ + id: 'call_lite_write', + type: 'function_call', + status: 'completed', + call_id: 'call_lite_write', + name: 'write', + arguments: '{"path":"a.txt"}' + }); + }); + + test('dedupes an identical declaration across tools and additional_tools', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [userItem('Read a.txt'), additionalToolsItem([{ ...liteRead }])], + tools: [liteRead] + }); + + expect(res.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system.split(toolListEntry('external__read')).length - 1).toBe(1); + expect(system).not.toContain('external__read_2'); + }); + + test('flattens a namespace request and maps the emitted call back to the client name', async () => { + replyWithToolCall('call_ns_ping', 'external__gh__ops__ping', '{}'); + const declared = [ + namespaceTool('gh', [ + { type: 'function', name: 'create_issue', description: 'Create', parameters: issueParams }, + namespaceTool('ops', [{ type: 'function', name: 'ping', parameters: { type: 'object', properties: {} } }]) + ]) + ]; + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Ping the ops namespace', + tools: declared + }); + + expect(res.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system).toContain(toolListEntry('external__gh__create_issue')); + expect(system).toContain(toolListEntry('external__gh__ops__ping')); + expect(res.body.tools).toEqual(declared); + expect(res.body.output.find((item) => item.type === 'function_call')).toEqual({ + id: 'call_ns_ping', + type: 'function_call', + status: 'completed', + call_id: 'call_ns_ping', + name: 'gh__ops__ping', + arguments: '{}' + }); + }); + + test('keeps a colliding namespace leaf callable under its qualified name', async () => { + replyWithToolCall('call_ns_issue', 'external__gh__create_issue', '{"title":"x"}'); + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Create an issue', + tools: [ + { type: 'function', name: 'create_issue', description: 'Local', parameters: issueParams }, + namespaceTool('gh', [{ type: 'function', name: 'create_issue', description: 'Remote', parameters: issueParams }]) + ] + }); + + expect(res.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system).toContain(toolListEntry('external__create_issue')); + expect(system).toContain(toolListEntry('external__gh__create_issue')); + const call = res.body.output.find((item) => item.type === 'function_call'); + expect(call.name).toBe('gh__create_issue'); + expect(call.call_id).toBe('call_ns_issue'); + expect(JSON.parse(call.arguments)).toEqual({ title: 'x' }); + }); + + test.each([ + ['qualified namespace name', 'gh__create_issue', 'external__gh__create_issue', null], + ['namespace hint with a bare name', 'create_issue', 'external__gh__create_issue', 'gh'], + ['top-level name', 'create_issue', 'external__create_issue', null] + ])('tool_choice can require a namespace tool by its %s', async (_label, toolChoiceName, expected, namespace) => { + replyWithToolCall('call_req_ns', expected, '{"title":"x"}'); + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Create an issue', + tools: [ + { type: 'function', name: 'create_issue', description: 'Local', parameters: issueParams }, + namespaceTool('gh', [{ type: 'function', name: 'create_issue', description: 'Remote', parameters: issueParams }]) + ], + tool_choice: namespace + ? { type: 'function', name: toolChoiceName, namespace } + : { type: 'function', name: toolChoiceName } + }); + + expect(res.statusCode).toBe(200); + expect(lastSystemPrompt()).toContain(`You MUST call ${expected}`); + }); + + test('rejects a tool_choice that only matches after prefix or suffix guessing', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Ping', + tools: [namespaceTool('gh', [{ type: 'function', name: 'ping' }])], + tool_choice: { type: 'function', name: 'nope__ping' } + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toEqual({ + message: 'tool_choice references an unknown tool: nope__ping', + type: 'invalid_request_error', + code: 'unknown_tool' + }); + expect(sdkMocks.sessionPrompt).not.toHaveBeenCalled(); + }); + }); + + describe('unsupported declarations', () => { + test.each([ + ['file_search', { type: 'file_search' }], + ['mcp', { type: 'mcp', server_label: 'github' }], + ['code_interpreter', { type: 'code_interpreter', container: { type: 'auto' } }], + ['image_generation', { type: 'image_generation' }], + ['computer_use', { type: 'computer_use_preview' }], + ['local_shell', { type: 'local_shell' }], + ['tool_search', { type: 'tool_search', name: 'finder' }], + ['typeless declaration', { name: 'read', parameters: { type: 'object', properties: {} } }] + ])('rejects a %s tool instead of silently dropping it', async (_label, tool) => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + tools: [liteRead, tool] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('unsupported_tool_type'); + expect(res.body.error.type).toBe('invalid_request_error'); + expect(res.body.error.message).toContain( + `Unsupported tool type "${tool.type || 'missing'}" at tools[1]` + ); + expect(res.body.error.message).toContain('Only function, custom and namespace tools are supported.'); + expect(res.body.output).toBeUndefined(); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + + test('rejects an unsupported declaration inside an additional_tools item with its input path', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [userItem('Read a.txt'), additionalToolsItem([{ type: 'local_shell' }])], + tools: [liteRead] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.message).toContain( + 'Unsupported tool type "local_shell" at input[1].tools[0]' + ); + expect(sdkMocks.sessionPrompt).not.toHaveBeenCalled(); + }); + + test('rejects an unsupported nested namespace type with its namespace path', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Create an issue', + tools: [namespaceTool('gh', [{ type: 'code_interpreter' }])] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.message).toContain( + 'Unsupported tool type "code_interpreter" at tools[0].tools[0] in namespace "gh"' + ); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + + test('streams an unsupported tool type as created then failed then done with the same code', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + stream: true, + tools: [liteRead, { type: 'local_shell' }] + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('text/event-stream'); + const frames = readSseFramesLite(res.text); + expect(frames.map((frame) => (frame === '[DONE]' ? frame : frame.type))).toEqual([ + 'response.created', + 'response.failed', + '[DONE]' + ]); + expect(frames[0].response.status).toBe('in_progress'); + expect(frames[0].response.tools).toEqual([liteRead, { type: 'local_shell' }]); + expect(frames[1].response.status).toBe('failed'); + expect(frames[1].response.error).toEqual({ + message: 'Unsupported tool type "local_shell" at tools[1]. Only function, custom and namespace tools are supported.', + type: 'invalid_request_error', + code: 'unsupported_tool_type' + }); + expect(frames[1].response.output).toEqual([]); + expect(res.text).not.toContain('response.completed'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + expect(sdkMocks.eventSubscribe).not.toHaveBeenCalled(); + }); + }); + + describe('custom (freeform) tools', () => { + const customShell = { type: 'custom', name: 'run_shell', description: 'Run a command' }; + + test('advertises a custom declaration with the wrapped input schema', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run echo', + tools: [namespaceTool('gh', [customShell])] + }); + + expect(res.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system).toContain(toolListEntry('external__gh__run_shell')); + expect(system).toContain('"client_name":"gh__run_shell"'); + expect(system).toContain(JSON.stringify(customParams)); + expect(res.body.tools).toEqual([namespaceTool('gh', [customShell])]); + }); + + test('answers a custom tool call with a custom_tool_call item', async () => { + replyWithToolCall('call_custom_1', 'external__gh__run_shell', '{"input":"echo hi"}'); + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run echo hi', + tools: [namespaceTool('gh', [customShell])] + }); + + expect(res.statusCode).toBe(200); + expect(res.body.output).toEqual([{ + id: 'call_custom_1', + type: 'custom_tool_call', + status: 'completed', + call_id: 'call_custom_1', + name: 'gh__run_shell', + input: 'echo hi' + }]); + }); + + test.each([ + ['an empty object', '{}'], + ['a missing input key', '{"cmd":"echo hi"}'], + ['a non-string input', '{"input":42}'], + ['a null input', '{"input":null}'] + ])('fails closed on a custom tool call whose arguments are %s', async (_label, args) => { + replyWithToolCall('call_custom_bad', 'external__gh__run_shell', args); + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run echo hi', + tools: [namespaceTool('gh', [customShell])] + }); + + expect(res.statusCode).toBe(502); + expect(res.body.error.code).toBe('invalid_external_tool_call'); + }); + + test('a custom tool_choice names the custom function the request declared', async () => { + replyWithToolCall('call_custom_req', 'external__run_shell', '{"input":"echo hi"}'); + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run echo hi', + tools: [customShell, liteRead], + tool_choice: { type: 'custom', name: 'run_shell' } + }); + + expect(res.statusCode).toBe(200); + expect(lastSystemPrompt()).toContain('You MUST call external__run_shell'); + }); + + test('a custom tool_choice resolves a namespaced custom tool and rejects an unknown one', async () => { + replyWithToolCall('call_custom_ns_req', 'external__gh__run_shell', '{"input":"echo hi"}'); + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run echo hi', + tools: [namespaceTool('gh', [customShell])], + tool_choice: { type: 'custom', name: 'run_shell', namespace: 'gh' } + }); + expect(res.statusCode).toBe(200); + expect(lastSystemPrompt()).toContain('You MUST call external__gh__run_shell'); + + const unknown = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run echo hi', + tools: [customShell], + tool_choice: { type: 'custom', name: 'nope' } + }); + expect(unknown.statusCode).toBe(400); + expect(unknown.body.error).toEqual({ + message: 'tool_choice references an unknown tool: nope', + type: 'invalid_request_error', + code: 'unknown_tool' + }); + }); + + test('streams a custom tool call with the custom input events', async () => { + streamedCallReply('call_custom_stream', 'external__run_shell', '{"input":"echo hi"}'); + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run echo hi', + stream: true, + tools: [customShell] + }); + + expect(res.statusCode).toBe(200); + const frames = readSseFramesLite(res.text); + expect(frames.map((frame) => (frame === '[DONE]' ? frame : frame.type))).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.custom_tool_call_input.delta', + 'response.custom_tool_call_input.done', + 'response.output_item.done', + 'response.completed', + '[DONE]' + ]); + expect(frames[2].delta).toBe('echo hi'); + expect(frames[3].input).toBe('echo hi'); + expect(frames[4].item).toEqual({ + id: 'call_custom_stream', + type: 'custom_tool_call', + status: 'completed', + call_id: 'call_custom_stream', + name: 'run_shell', + input: 'echo hi' + }); + expect(frames[5].response.output).toEqual([frames[4].item]); + expect(res.text).not.toContain('response.function_call_arguments'); + }); + + test('replays a stateless custom_tool_call history', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [ + userItem('Run echo hi'), + { type: 'custom_tool_call', call_id: 'lite_custom_1', namespace: 'gh', name: 'run_shell', input: 'echo hi' }, + { type: 'custom_tool_call_output', call_id: 'lite_custom_1', output: 'hi' } + ], + tools: [namespaceTool('gh', [customShell])] + }); + + expect(res.statusCode).toBe(200); + const promptText = lastPromptText(); + expect(promptText).toContain( + 'ASSISTANT: [{"id":"lite_custom_1","name":"external__gh__run_shell","arguments":"{\\"input\\":\\"echo hi\\"}"}]' + ); + expect(promptText).toContain( + 'TOOL_RESULT: {"tool_call_id":"lite_custom_1","name":"external__gh__run_shell","content":"hi"}' + ); + expect(promptText).not.toContain('external__unknown'); + }); + + test('resolves a custom tool continuation from stored response metadata', async () => { + replyWithToolCall('call_custom_cont', 'external__gh__run_shell', '{"input":"echo hi"}'); + const first = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Run echo hi', + tools: [namespaceTool('gh', [customShell])] + }); + expect(first.statusCode).toBe(200); + expect(first.body.output[0].type).toBe('custom_tool_call'); + + const followUp = await postResponses({ + previous_response_id: first.body.id, + input: [{ type: 'custom_tool_call_output', call_id: 'call_custom_cont', output: 'hi' }] + }); + + expect(followUp.statusCode).toBe(200); + expect(lastPromptText()).toContain( + 'TOOL_RESULT: {"tool_call_id":"call_custom_cont","name":"external__gh__run_shell","content":"hi"}' + ); + }); + }); + + describe('input roles and history', () => { + test('keeps developer items on the user channel and system items on the system channel', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + instructions: 'REQUEST INSTRUCTIONS', + input: [ + userItem('List the files'), + { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'DEVELOPER RULES' }] }, + { type: 'message', role: 'system', content: [{ type: 'input_text', text: 'SYSTEM RULES' }] }, + additionalToolsItem([liteWrite]) + ] + }); + + expect(res.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system).toContain('REQUEST INSTRUCTIONS'); + expect(system).toContain('SYSTEM RULES'); + expect(system).not.toContain('DEVELOPER RULES'); + expect(system).toContain('external__write'); + const promptText = lastPromptText(); + expect(promptText).toContain('List the files'); + // The developer turn keeps the user channel but stays labelled, + // so it can never be read back as the user's own turn. + expect(promptText).toContain('DEVELOPER: DEVELOPER RULES'); + expect(promptText).not.toMatch(/^USER: DEVELOPER RULES$/m); + expect(promptText).not.toContain('SYSTEM RULES'); + expect(promptText).not.toContain('additional_tools'); + }); + + test.each([ + [ + 'plain function_call items', + { type: 'function_call', call_id: 'lite_call_1', name: 'create_issue', arguments: '{"title":"x"}' }, + 'external__create_issue' + ], + [ + 'namespace qualified function_call items', + { type: 'function_call', call_id: 'lite_call_1', namespace: 'gh', name: 'create_issue', arguments: '{"title":"x"}' }, + 'external__gh__create_issue' + ], + [ + 'namespace qualified name items', + { type: 'function_call', call_id: 'lite_call_1', name: 'gh__create_issue', arguments: '{"title":"x"}' }, + 'external__gh__create_issue' + ] + ])('replays a stateless tool history from %s', async (_label, callItem, expected) => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [ + userItem('Create an issue'), + callItem, + { type: 'function_call_output', call_id: 'lite_call_1', output: 'issue 7 created' } + ], + tools: [ + { type: 'function', name: 'create_issue', parameters: issueParams }, + namespaceTool('gh', [{ type: 'function', name: 'create_issue', parameters: issueParams }]) + ] + }); + + expect(res.statusCode).toBe(200); + const promptText = lastPromptText(); + expect(promptText).toContain( + `ASSISTANT: [{"id":"lite_call_1","name":"${expected}","arguments":"{\\"title\\":\\"x\\"}"}]` + ); + expect(promptText).toContain( + `TOOL_RESULT: {"tool_call_id":"lite_call_1","name":"${expected}","content":"issue 7 created"}` + ); + expect(promptText).not.toContain('external__unknown'); + }); + + test('leaves a name that only matches after guessing unresolved', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [{ type: 'function_call_output', call_id: 'lite_call_9', name: 'nope__ping', output: 'pong' }], + tools: [namespaceTool('gh', [{ type: 'function', name: 'ping' }])] + }); + + expect(res.statusCode).toBe(200); + const toolResultLines = lastPromptText().split('\n').filter((line) => line.startsWith('TOOL_RESULT: ')); + expect(toolResultLines).toEqual([ + 'TOOL_RESULT: {"tool_call_id":"lite_call_9","name":"nope__ping","content":"pong"}' + ]); + }); + + test('rejects a tool result whose call id is unknown and has no name', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [{ type: 'function_call_output', call_id: 'lite_call_8', name: ' ', output: 'body' }], + tools: [liteRead] + }); + + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('unknown_tool_call_id'); + }); + }); + + describe('continuation metadata', () => { + test('resolves a continuation tool result from stored response tool call metadata', async () => { + replyWithToolCall('call_lite_read', 'external__read', '{"path":"a.txt"}'); + const first = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Read a.txt', + tools: [liteRead] + }); + + expect(first.statusCode).toBe(200); + expect(first.body.output.find((item) => item.type === 'function_call')).toEqual({ + id: 'call_lite_read', + type: 'function_call', + status: 'completed', + call_id: 'call_lite_read', + name: 'read', + arguments: '{"path":"a.txt"}' + }); + + const followUp = await postResponses({ + previous_response_id: first.body.id, + input: [{ type: 'function_call_output', call_id: 'call_lite_read', output: 'file body' }] + }); + + expect(followUp.statusCode).toBe(200); + const promptText = lastPromptText(); + expect(promptText).toContain( + 'TOOL_RESULT: {"tool_call_id":"call_lite_read","name":"external__read","content":"file body"}' + ); + expect(promptText).not.toContain('external__unknown'); + expect(sdkMocks.sessionCreate).toHaveBeenCalledTimes(1); + }); + + test('keeps a namespace tool name across a metadata-driven continuation', async () => { + replyWithToolCall('call_ns_cont', 'external__gh__ops__ping', '{}'); + const first = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Ping ops', + tools: [namespaceTool('gh', [namespaceTool('ops', [{ type: 'function', name: 'ping' }])])] + }); + expect(first.statusCode).toBe(200); + expect(first.body.output.find((item) => item.type === 'function_call').name).toBe('gh__ops__ping'); + + const followUp = await postResponses({ + previous_response_id: first.body.id, + input: [{ type: 'function_call_output', call_id: 'call_ns_cont', output: 'pong' }] + }); + + expect(followUp.statusCode).toBe(200); + expect(lastPromptText()).toContain( + 'TOOL_RESULT: {"tool_call_id":"call_ns_cont","name":"external__gh__ops__ping","content":"pong"}' + ); + }); + + test('carries continuation metadata forward across chained turns', async () => { + replyWithToolCall('call_turn_one', 'external__read', '{"path":"a.txt"}'); + const first = await postResponses({ model: 'opencode/kimi-k2.5', input: 'Read a.txt', tools: [liteRead] }); + expect(first.statusCode).toBe(200); + + const second = await postResponses({ + previous_response_id: first.body.id, + input: [{ type: 'function_call_output', call_id: 'call_turn_one', output: 'file body' }] + }); + expect(second.statusCode).toBe(200); + expect(second.body.output.map((item) => item.type)).toEqual(['message']); + + const third = await postResponses({ + previous_response_id: second.body.id, + input: [{ type: 'function_call_output', call_id: 'call_turn_one', output: 'file body again' }] + }); + expect(third.statusCode).toBe(200); + expect(lastPromptText()).toContain( + 'TOOL_RESULT: {"tool_call_id":"call_turn_one","name":"external__read","content":"file body again"}' + ); + expect(sdkMocks.sessionCreate).toHaveBeenCalledTimes(1); + }); + + test('stores flattened namespace tool call metadata from a first streamed turn', async () => { + streamedCallReply('call_stream_ns', 'external__gh__ops__ping', '{}'); + const first = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Ping ops', + stream: true, + tools: [namespaceTool('gh', [namespaceTool('ops', [{ type: 'function', name: 'ping' }])])] + }); + + expect(first.statusCode).toBe(200); + const frames = readSseFramesLite(first.text); + const completed = frames.find((frame) => frame !== '[DONE]' && frame.type === 'response.completed'); + expect(completed.response.output).toEqual([ + { + id: 'call_stream_ns', + type: 'function_call', + status: 'completed', + call_id: 'call_stream_ns', + name: 'gh__ops__ping', + arguments: '{}' + } + ]); + + const followUp = await postResponses({ + previous_response_id: completed.response.id, + input: [{ type: 'function_call_output', call_id: 'call_stream_ns', output: 'pong' }] + }); + + expect(followUp.statusCode).toBe(200); + expect(lastPromptText()).toContain( + 'TOOL_RESULT: {"tool_call_id":"call_stream_ns","name":"external__gh__ops__ping","content":"pong"}' + ); + }); + + test('streams a metadata-driven continuation and still completes', async () => { + replyWithToolCall('call_stream_cont', 'external__read', '{"path":"a.txt"}'); + const first = await postResponses({ model: 'opencode/kimi-k2.5', input: 'Read a.txt', tools: [liteRead] }); + expect(first.statusCode).toBe(200); + + const res = await postResponses({ + previous_response_id: first.body.id, + stream: true, + input: [{ type: 'function_call_output', call_id: 'call_stream_cont', output: 'file body' }] + }); + + expect(res.statusCode).toBe(200); + const frames = readSseFramesLite(res.text); + expect(frames[0].type).toBe('response.created'); + expect(frames[frames.length - 1]).toBe('[DONE]'); + expect(res.text).toContain('response.completed'); + expect(lastPromptText()).toContain( + 'TOOL_RESULT: {"tool_call_id":"call_stream_cont","name":"external__read","content":"file body"}' + ); + }); + + test('rejects an unrecorded call id without a name', async () => { + const first = await postResponses({ model: 'opencode/kimi-k2.5', input: 'Hello' }); + expect(first.statusCode).toBe(200); + + const followUp = await postResponses({ + previous_response_id: first.body.id, + input: [{ type: 'function_call_output', call_id: 'call_never_issued', output: 'orphan' }] + }); + + expect(followUp.statusCode).toBe(400); + expect(followUp.body.error.code).toBe('unknown_tool_call_id'); + }); + + test('caps the stored continuation metadata to the most recent calls', async () => { + const calls = Array.from( + { length: 130 }, + (_unused, index) => + `{"id":"call_cap_${index}","name":"external__read","arguments":{"path":"a-${index}.txt"}}` + ).join(','); + sdkMocks.sessionPrompt.mockResolvedValueOnce({ + data: { parts: [{ type: 'text', text: `[${calls}]` }] } + }); + const first = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Read everything', + tools: [liteRead] + }); + expect(first.statusCode).toBe(200); + expect(first.body.output.filter((item) => item.type === 'function_call')).toHaveLength(130); + + const forgotten = await postResponses({ + previous_response_id: first.body.id, + input: [{ type: 'function_call_output', call_id: 'call_cap_0', output: 'oldest' }] + }); + expect(forgotten.statusCode).toBe(400); + expect(forgotten.body.error.code).toBe('unknown_tool_call_id'); + + const remembered = await postResponses({ + previous_response_id: first.body.id, + input: [{ type: 'function_call_output', call_id: 'call_cap_129', output: 'newest' }] + }); + expect(remembered.statusCode).toBe(200); + expect(lastPromptText()).toContain( + 'TOOL_RESULT: {"tool_call_id":"call_cap_129","name":"external__read","content":"newest"}' + ); + }); + + test('reports the dropped continuation metadata past the cap', async () => { + const debugApp = createApp({ + PORT: 10000, + API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, + DISABLE_TOOLS: false, + DEBUG: true + }).app; + const debugLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + let lines = []; + try { + const calls = Array.from( + { length: 130 }, + (_unused, index) => + `{"id":"call_cap_${index}","name":"external__read","arguments":{"path":"a-${index}.txt"}}` + ).join(','); + sdkMocks.sessionPrompt.mockResolvedValueOnce({ + data: { parts: [{ type: 'text', text: `[${calls}]` }] } + }); + const res = await request(debugApp) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'Read everything', tools: [liteRead] }); + expect(res.statusCode).toBe(200); + lines = debugLog.mock.calls.map((call) => call.map((value) => String(value)).join(' ')); + } finally { + debugLog.mockRestore(); + } + + expect(lines.some((line) => line.includes('Dropped the oldest continuation tool call metadata past the cap'))).toBe(true); + }); + }); + + test('skips hosted search declarations during expansion and never treats them as functions', async () => { + const res = await postResponses({ + model: 'opencode/kimi-k2.5', + input: [userItem('Search the docs'), additionalToolsItem([{ type: 'web_search' }, liteWrite])], + tools: [{ type: 'web_search' }, liteRead] + }); + + expect(res.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system).toContain(toolListEntry('external__read')); + expect(system).toContain(toolListEntry('external__write')); + expect(system).toContain('A built-in web search tool (websearch) is enabled for this turn'); + expect(system).not.toContain('custom_tool'); + expect(lastPromptText()).not.toContain('web_search'); + }); + + test('recognizes hosted search nested in a namespace instead of rejecting the tree', async () => { + const hostedOnly = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Search the docs', + tools: [namespaceTool('search', [{ type: 'web_search' }]), liteRead] + }); + + expect(hostedOnly.statusCode).toBe(200); + expect(lastSystemPrompt()).toContain('A built-in web search tool (websearch) is enabled for this turn'); + expect(lastSystemPrompt()).toContain(toolListEntry('external__read')); + expect(lastSystemPrompt()).not.toContain('external__search'); + + const mixed = await postResponses({ + model: 'opencode/kimi-k2.5', + input: 'Search and ping', + tools: [namespaceTool('gh', [{ type: 'web_search' }, { type: 'function', name: 'ping' }])] + }); + + expect(mixed.statusCode).toBe(200); + const system = lastSystemPrompt(); + expect(system).toContain('A built-in web search tool (websearch) is enabled for this turn'); + expect(system).toContain(toolListEntry('external__gh__ping')); + expect(system).not.toContain('web_search'); + }); + }); + test('chains follow-up turns onto the stored session without recreating it', async () => { sdkMocks.sessionMessages.mockReset(); sdkMocks.sessionMessages.mockResolvedValue([ diff --git a/tests/auth-multikey.test.js b/tests/auth-multikey.test.js index 58ac35f..f2ba3e6 100644 --- a/tests/auth-multikey.test.js +++ b/tests/auth-multikey.test.js @@ -8,7 +8,7 @@ const sdkMocks = { } })), configUpdate: jest.fn(async () => ({})), - toolIds: jest.fn(async () => ({ data: [] })), + toolIds: jest.fn(async () => ({ data: ['web_fetch', 'filesystem', 'bash'] })), 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' }] }])), diff --git a/tests/backend-error-surface.test.js b/tests/backend-error-surface.test.js index 7b3c8d0..6e7d854 100644 --- a/tests/backend-error-surface.test.js +++ b/tests/backend-error-surface.test.js @@ -20,7 +20,7 @@ const sdkMocks = { data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' }, 'muse-spark-1.3-contributor-free': { name: 'Muse Spark' } } }] } })), configUpdate: jest.fn(async () => ({})), - toolIds: jest.fn(async () => ({ data: [] })), + toolIds: jest.fn(async () => ({ data: ['web_fetch', 'filesystem', 'bash'] })), sessionCreate: jest.fn(async () => ({ data: { id: 'err-session' } })), sessionPrompt: jest.fn(async () => ({ data: { parts: [] } })), sessionMessages: jest.fn(async () => ([ @@ -67,11 +67,79 @@ jest.unstable_mockModule('@opencode-ai/sdk', () => ({ })); const { createApp } = await import('../src/proxy.js'); +const { createInvalidRequestError, transformUpstreamError } = await import('../src/errors/upstream.js'); + +const BACKEND_BAD_REQUEST_ERROR = { + name: 'BadRequestError', + data: { message: '400: prompt rejected by the provider', statusCode: 400 } +}; + +describe('400 code passthrough', () => { + test('a proxy-authored 400 keeps its own code', () => { + expect(transformUpstreamError(createInvalidRequestError('bad tool', 'unsupported_tool_type'))).toEqual({ + statusCode: 400, + error: { message: 'bad tool', type: 'invalid_request_error', code: 'unsupported_tool_type' } + }); + }); + + test('a backend 400 never leaks the upstream class name as the wire code', () => { + expect(transformUpstreamError(BACKEND_BAD_REQUEST_ERROR)).toEqual({ + statusCode: 400, + error: { + message: '400: prompt rejected by the provider', + type: 'invalid_request_error', + code: 'invalid_request_error' + } + }); + // Same for an Error that merely *claims* a 400 without the proxy marker. + const unmarked = new Error('provider said no'); + unmarked.statusCode = 400; + unmarked.code = 'BadRequestError'; + expect(transformUpstreamError(unmarked).error.code).toBe('invalid_request_error'); + }); + + test('responses surfaces a backend 400 as a generic invalid request', async () => { + const app = createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false, + RETRY_MAX_RETRIES: 0 + }).app; + sdkMocks.sessionMessages.mockResolvedValueOnce([ + { info: { role: 'assistant', finish: 'stop', error: BACKEND_BAD_REQUEST_ERROR }, parts: [] } + ]); + + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + + expect(res.statusCode).toBe(400); + expect(res.body.type).toBe('invalid_request_error'); + expect(res.body.code).toBe('invalid_request_error'); + expect(res.body.message).toContain('prompt rejected by the provider'); + expect(JSON.stringify(res.body)).not.toContain('BadRequestError'); + }); +}); describe('POST /v1/responses backend plain-object error', () => { let app; beforeEach(() => { jest.clearAllMocks(); + sdkMocks.sessionPrompt.mockReset(); + sdkMocks.sessionPrompt.mockImplementation(async () => ({ data: { parts: [] } })); + sdkMocks.sessionMessages.mockReset(); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop', error: BACKEND_CREDITS_ERROR }, parts: [] } + ])); + sdkMocks.eventSubscribe.mockReset(); + sdkMocks.eventSubscribe.mockImplementation(async () => ({ + stream: (async function* () { + yield { + type: 'message.updated', + properties: { info: { sessionID: 'err-session', finish: 'stop', error: BACKEND_CREDITS_ERROR } } + }; + })() + })); app = createApp({ PORT: 10000, API_KEY: 'test-key', OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', @@ -109,6 +177,27 @@ describe('POST /v1/responses backend plain-object error', () => { expect(res.text).not.toContain('"Object"'); }); + test('session.error is surfaced as a stream error', async () => { + sdkMocks.eventSubscribe.mockResolvedValueOnce({ + stream: (async function* () { + yield { + type: 'session.error', + properties: { + sessionID: 'err-session', + error: { name: 'SessionError', data: { message: 'session failed' } } + } + }; + })() + }); + const res = await request(app) + .post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi', stream: true }); + expect(res.text).toContain('session failed'); + expect(res.text).toContain('response.failed'); + expect(res.text).not.toContain('response.completed'); + }); + test('messages non-stream uses transformed status/type, not hardcoded 502', async () => { const res = await request(app).post('/v1/messages') .set('Authorization', 'Bearer test-key') @@ -262,4 +351,74 @@ describe('POST /v1/responses backend plain-object error', () => { expect(sdkMocks.sessionCreate.mock.calls.length).toBe(1); }); }); + + describe('503 discovery and upstream errors', () => { + test('a proxy-authored discovery failure keeps 503 and its wire contract', async () => { + const { createToolDiscoveryUnavailableError } = await import('../src/errors/upstream.js'); + expect(transformUpstreamError(createToolDiscoveryUnavailableError())).toEqual({ + statusCode: 503, + error: { + message: 'Tool discovery unavailable; backend tool IDs could not be verified', + type: 'tool_discovery_unavailable', + code: 'tool_discovery_unavailable' + } + }); + }); + + test('an unmarked upstream 503 keeps 503 but never uses the discovery contract', () => { + expect(transformUpstreamError({ + name: 'ServiceUnavailableError', + data: { message: '503: backend overloaded', statusCode: 503 } + })).toEqual({ + statusCode: 503, + error: { + message: '503: backend overloaded', + type: 'server_error', + code: 'server_error' + } + }); + }); + + test('chat non-stream preserves a discovery 503 through the shared transformer', async () => { + const chatApp = createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false, + RETRY_MAX_RETRIES: 0 + }).app; + sdkMocks.toolIds.mockRejectedValueOnce(new Error('discovery down')); + sdkMocks.sessionPrompt.mockResolvedValue({ data: { parts: [] } }); + const res = await request(chatApp).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ + error: { + message: 'Tool discovery unavailable; backend tool IDs could not be verified', + type: 'tool_discovery_unavailable', + code: 'tool_discovery_unavailable' + } + }); + expect(sdkMocks.sessionPrompt).not.toHaveBeenCalled(); + }); + + test('responses stream preserves a discovery 503 as response.failed', async () => { + const responsesApp = createApp({ + PORT: 10000, API_KEY: 'test-key', + OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', + REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false, + RETRY_MAX_RETRIES: 0 + }).app; + sdkMocks.toolIds.mockRejectedValueOnce(new Error('discovery down')); + sdkMocks.sessionPrompt.mockResolvedValue({ data: { parts: [] } }); + const res = await request(responsesApp).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/kimi-k2.5', input: 'hi', stream: true }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('"type":"response.failed"'); + expect(res.text).toContain('"code":"tool_discovery_unavailable"'); + expect(res.text).toContain('data: [DONE]'); + expect(sdkMocks.sessionPrompt).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/env-alias.test.js b/tests/env-alias.test.js index 699198c..609757b 100644 --- a/tests/env-alias.test.js +++ b/tests/env-alias.test.js @@ -1,9 +1,37 @@ import { resolveDisableTools, normalizeBool } from '../src/proxy.js'; +import { + buildProxyConfig, + parseStrictInteger, + parseStrictPort, + parseHttpUrl, + parseToolNameList, + resolveStringSetting, + resolveBoolSetting, + resolveIntSetting, + resolvePortSetting, + resolveDurationSetting, + resolveRetryCountSetting, + resolveListSetting, + resolveEnumSetting, + resolveUrlSetting, + EXTERNAL_TOOLS_MODES, + EXTERNAL_TOOLS_CONFLICT_POLICIES, + EXTERNAL_TOOL_POLICY_MODES, + EXTERNAL_TOOL_RISK_LEVEL_VALUES, + DEFAULT_EXTERNAL_TOOLS_MODE, + DEFAULT_EXTERNAL_TOOLS_CONFLICT_POLICY, + DEFAULT_EXTERNAL_TOOL_POLICY_MODE, + DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL, + DEFAULT_PROXY_PORT, + DEFAULT_SERVER_PORT, +} from '../src/config/proxy-config.js'; +import { resolveMaxRetries } from '../src/retry/policy.js'; +import { DEFAULT_PROXY_STRATEGY, PROXY_STRATEGIES } from '../src/upstream-proxy/pool.js'; // Regression tests: docker-compose/.env document DISABLE_TOOLS, but the code // only honored OPENCODE_DISABLE_TOOLS, so a legacy DISABLE_TOOLS=true was -// silently ignored. Note the unit default here is false; the production -// default (true) comes from index.ts passing defaultConfig as fallback. +// silently ignored. Both unit and production defaults are true (tools disabled); +// index.ts passes an explicit file fallback that also defaults to true. describe('resolveDisableTools', () => { const CANONICAL = 'OPENCODE_DISABLE_TOOLS'; const LEGACY = 'DISABLE_TOOLS'; @@ -25,10 +53,10 @@ describe('resolveDisableTools', () => { }); test('defaults to the fallback when nothing is set', () => { - expect(resolveDisableTools()).toBe(false); - expect(resolveDisableTools({})).toBe(false); + expect(resolveDisableTools()).toBe(true); + expect(resolveDisableTools({})).toBe(true); expect(resolveDisableTools({}, true)).toBe(true); - expect(resolveDisableTools(null)).toBe(false); + expect(resolveDisableTools(null)).toBe(true); expect(resolveDisableTools(null, true)).toBe(true); }); @@ -84,13 +112,13 @@ describe('resolveDisableTools', () => { process.env[LEGACY] = 'false'; expect(resolveDisableTools({})).toBe(false); delete process.env[LEGACY]; - expect(resolveDisableTools({})).toBe(false); + expect(resolveDisableTools({})).toBe(true); expect(resolveDisableTools({}, true)).toBe(true); } // Legacy layer alone with garbage also falls back (not coerced). for (const garbage of ['garbage', '', ' ']) { process.env[LEGACY] = garbage; - expect(resolveDisableTools({})).toBe(false); + expect(resolveDisableTools({})).toBe(true); expect(resolveDisableTools({}, true)).toBe(true); } // Invalid option values fall through to env as well. @@ -113,7 +141,7 @@ describe('resolveDisableTools', () => { process.env[CANONICAL] = 'false'; expect(resolveDisableTools({ DISABLE_TOOLS: 2 })).toBe(false); delete process.env[CANONICAL]; - expect(resolveDisableTools({ DISABLE_TOOLS: 2 })).toBe(false); + expect(resolveDisableTools({ DISABLE_TOOLS: 2 })).toBe(true); expect(resolveDisableTools({ DISABLE_TOOLS: 2 }, true)).toBe(true); }); }); @@ -150,3 +178,503 @@ describe('index.js DISABLE_TOOLS chain (mirrored)', () => { )).toBe(false); }); }); + +// Pure merge/normalizer helpers extracted from index.ts so both entry points +// (prod index.ts and library buildProxyConfig) share one strict contract: +// env canonical > env legacy alias > file short key > hardcoded default, with +// invalid values falling through instead of coercing. +describe('strict layer normalizers', () => { + test('parseStrictInteger rejects partial/garbage numerics', () => { + expect(parseStrictInteger('3')).toBe(3); + expect(parseStrictInteger(' 42 ')).toBe(42); + expect(parseStrictInteger('-1')).toBe(-1); + expect(parseStrictInteger(0)).toBe(0); + expect(parseStrictInteger('3abc')).toBeUndefined(); + expect(parseStrictInteger('3.5')).toBeUndefined(); + expect(parseStrictInteger('1e3')).toBeUndefined(); + expect(parseStrictInteger('')).toBeUndefined(); + expect(parseStrictInteger(' ')).toBeUndefined(); + expect(parseStrictInteger('garbage')).toBeUndefined(); + expect(parseStrictInteger(3.5)).toBeUndefined(); + expect(parseStrictInteger(NaN)).toBeUndefined(); + expect(parseStrictInteger(Infinity)).toBeUndefined(); + expect(parseStrictInteger(true)).toBeUndefined(); + expect(parseStrictInteger(null)).toBeUndefined(); + expect(parseStrictInteger(undefined)).toBeUndefined(); + expect(parseStrictInteger({})).toBeUndefined(); + }); + + test('parseStrictPort enforces 1..65535', () => { + expect(parseStrictPort('10000')).toBe(10000); + expect(parseStrictPort(1)).toBe(1); + expect(parseStrictPort(65535)).toBe(65535); + for (const bad of ['0', 0, '-1', 65536, '100000', '80.5', 'abc', '', null, undefined, {}]) { + expect(parseStrictPort(bad)).toBeUndefined(); + } + }); + + test('parseHttpUrl only accepts http(s) URLs', () => { + expect(parseHttpUrl('http://127.0.0.1:10001')).toBe('http://127.0.0.1:10001'); + expect(parseHttpUrl(' https://backend.example.com ')).toBe('https://backend.example.com'); + for (const bad of ['not-a-url', '127.0.0.1:10001', 'ftp://host', 'file:///tmp/x', '', ' ', 42, null, undefined]) { + expect(parseHttpUrl(bad)).toBeUndefined(); + } + }); + + test('parseToolNameList normalizes arrays/CSV and reports unset as undefined', () => { + expect(parseToolNameList('a, b ,a,,c')).toEqual(['a', 'b', 'c']); + expect(parseToolNameList(['a', ' b ', '', null, 0, false])).toEqual(['a', 'b']); + expect(parseToolNameList([])).toEqual([]); + expect(parseToolNameList('')).toBeUndefined(); + expect(parseToolNameList(' ')).toBeUndefined(); + expect(parseToolNameList(undefined)).toBeUndefined(); + expect(parseToolNameList(null)).toBeUndefined(); + expect(parseToolNameList(42)).toBeUndefined(); + }); + + test('string layers: canonical > legacy > file > default, invalid falls through', () => { + expect(resolveStringSetting(['canon', 'legacy', 'file'], 'def')).toBe('canon'); + expect(resolveStringSetting(['', 'legacy', 'file'], 'def')).toBe('legacy'); + expect(resolveStringSetting(['', ' ', 'file'], 'def')).toBe('file'); + expect(resolveStringSetting([undefined, null, '', ' '], 'def')).toBe('def'); + expect(resolveStringSetting([42, {}, 'file'], 'def')).toBe('file'); + expect(resolveStringSetting([' canon '], 'def')).toBe('canon'); + }); + + test('bool layers: only exact recognized spellings count, garbage never coerces', () => { + expect(resolveBoolSetting(['false'], true)).toBe(false); + expect(resolveBoolSetting(['garbage', 'true'], false)).toBe(true); + expect(resolveBoolSetting(['maybe', 'maybe', true], false)).toBe(true); + expect(resolveBoolSetting(['garbage'], true)).toBe(true); + expect(resolveBoolSetting([2], false)).toBe(false); + expect(resolveBoolSetting([0], true)).toBe(false); + expect(resolveBoolSetting(['0', 'true'], true)).toBe(false); + expect(resolveBoolSetting(['', 'on'], false)).toBe(true); + }); + + test('int/port/duration/retry layers clamp by range instead of coercing', () => { + expect(resolveIntSetting(['5'], 0, 10, 1)).toBe(5); + expect(resolveIntSetting(['99'], 0, 10, 1)).toBe(1); + expect(resolvePortSetting(['8080', '9090'], DEFAULT_PROXY_PORT)).toBe(8080); + expect(resolvePortSetting(['0', '9090'], DEFAULT_PROXY_PORT)).toBe(9090); + expect(resolvePortSetting(['abc'], DEFAULT_PROXY_PORT)).toBe(DEFAULT_PROXY_PORT); + expect(resolveDurationSetting(['3abc', 5000], 1)).toBe(5000); + expect(resolveDurationSetting(['0', '-5', 250], 1)).toBe(250); + expect(resolveRetryCountSetting(['0'], 3)).toBe(0); + expect(resolveRetryCountSetting(['-1', '4'], 3)).toBe(-1); + expect(resolveRetryCountSetting(['2.9'], 3)).toBe(3); + }); + + test('list layers: empty string is unset, empty array is explicit', () => { + expect(resolveListSetting(['a,b', 'c'], [])).toEqual(['a', 'b']); + expect(resolveListSetting(['', 'c'], [])).toEqual(['c']); + expect(resolveListSetting([['x', 'x', 'y']], [])).toEqual(['x', 'y']); + expect(resolveListSetting([undefined, null], ['fallback'])).toEqual(['fallback']); + }); + + test('enum layers: unrecognized values fall through, never shadow lower layers', () => { + expect(resolveEnumSetting(['report-only'], EXTERNAL_TOOL_POLICY_MODES, 'enforce')).toBe('report-only'); + expect(resolveEnumSetting(['Report-Only'], EXTERNAL_TOOL_POLICY_MODES, 'enforce')).toBe('report-only'); + expect(resolveEnumSetting(['garbage', 'enforce'], EXTERNAL_TOOL_POLICY_MODES, 'x')).toBe('enforce'); + expect(resolveEnumSetting(['', 'report-only'], EXTERNAL_TOOL_POLICY_MODES, 'x')).toBe('report-only'); + expect(resolveEnumSetting([42, {}], EXTERNAL_TOOL_POLICY_MODES, 'enforce')).toBe('enforce'); + expect(resolveEnumSetting(['nope'], EXTERNAL_TOOLS_MODES, DEFAULT_EXTERNAL_TOOLS_MODE)).toBe(DEFAULT_EXTERNAL_TOOLS_MODE); + expect(resolveEnumSetting(['nope'], EXTERNAL_TOOLS_CONFLICT_POLICIES, DEFAULT_EXTERNAL_TOOLS_CONFLICT_POLICY)) + .toBe(DEFAULT_EXTERNAL_TOOLS_CONFLICT_POLICY); + expect(resolveEnumSetting(['HIGH'], EXTERNAL_TOOL_RISK_LEVEL_VALUES, DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL)).toBe('high'); + expect(resolveEnumSetting(['urgent'], EXTERNAL_TOOL_RISK_LEVEL_VALUES, DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL)) + .toBe(DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL); + expect(EXTERNAL_TOOLS_MODES).toEqual(['proxy-bridge']); + expect(EXTERNAL_TOOLS_CONFLICT_POLICIES).toEqual(['namespace']); + expect(EXTERNAL_TOOL_POLICY_MODES).toEqual(['enforce', 'report-only']); + expect(EXTERNAL_TOOL_RISK_LEVEL_VALUES).toEqual(['low', 'medium', 'high', 'critical']); + }); + + test('url layers: invalid env falls through to file/default', () => { + expect(resolveUrlSetting(['https://a.example', 'http://b'], 'http://d')).toBe('https://a.example'); + expect(resolveUrlSetting(['nope', 'http://b'], 'http://d')).toBe('http://b'); + expect(resolveUrlSetting(['', 'garbage'], 'http://127.0.0.1:10001')).toBe('http://127.0.0.1:10001'); + }); +}); + +const withEnv = (vars, fn) => { + const saved = {}; + for (const key of Object.keys(vars)) { + saved[key] = process.env[key]; + if (vars[key] === undefined) delete process.env[key]; + else process.env[key] = vars[key]; + } + try { + return fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +}; + +describe('buildProxyConfig env/options matrix', () => { + test('port: options > canonical env > legacy PORT env > default', () => { + withEnv({ OPENCODE_PROXY_PORT: undefined, PORT: undefined }, () => { + expect(buildProxyConfig().PORT).toBe(DEFAULT_PROXY_PORT); + expect(buildProxyConfig({ PORT: 1234 }).PORT).toBe(1234); + withEnv({ PORT: 2345 }, () => { + expect(buildProxyConfig().PORT).toBe(2345); + withEnv({ OPENCODE_PROXY_PORT: '3456' }, () => { + expect(buildProxyConfig().PORT).toBe(3456); + expect(buildProxyConfig({ PORT: 1234 }).PORT).toBe(1234); + expect(buildProxyConfig({ PORT: '1234' }).PORT).toBe(1234); + }); + }); + }); + }); + + test('port: invalid values fall through instead of becoming NaN/0', () => { + withEnv({ OPENCODE_PROXY_PORT: 'garbage', PORT: '0' }, () => { + expect(buildProxyConfig().PORT).toBe(DEFAULT_PROXY_PORT); + }); + withEnv({ OPENCODE_PROXY_PORT: '70000', PORT: '8080' }, () => { + expect(buildProxyConfig().PORT).toBe(8080); + }); + expect(buildProxyConfig({ PORT: 'abc' }).PORT).toBe(DEFAULT_PROXY_PORT); + expect(buildProxyConfig({ PORT: 65536 }).PORT).toBe(DEFAULT_PROXY_PORT); + expect(buildProxyConfig({ PORT: -1 }).PORT).toBe(DEFAULT_PROXY_PORT); + }); + + test('backend url: options > env > OPENCODE_SERVER_PORT baked default > hardcoded default', () => { + withEnv({ OPENCODE_SERVER_URL: undefined, OPENCODE_SERVER_PORT: undefined }, () => { + expect(buildProxyConfig().OPENCODE_SERVER_URL).toBe(`http://127.0.0.1:${DEFAULT_SERVER_PORT}`); + withEnv({ OPENCODE_SERVER_PORT: '10009' }, () => { + expect(buildProxyConfig().OPENCODE_SERVER_URL).toBe('http://127.0.0.1:10009'); + withEnv({ OPENCODE_SERVER_URL: 'https://remote.example' }, () => { + expect(buildProxyConfig().OPENCODE_SERVER_URL).toBe('https://remote.example'); + expect(buildProxyConfig({ OPENCODE_SERVER_URL: 'http://opt.example' }).OPENCODE_SERVER_URL) + .toBe('http://opt.example'); + }); + }); + }); + }); + + test('backend url/path: invalid env falls through to the next layer', () => { + withEnv({ OPENCODE_SERVER_URL: 'not-a-url', OPENCODE_PATH: ' ' }, () => { + expect(buildProxyConfig().OPENCODE_SERVER_URL).toBe(`http://127.0.0.1:${DEFAULT_SERVER_PORT}`); + expect(buildProxyConfig().OPENCODE_PATH).toBe('opencode'); + withEnv({ OPENCODE_PATH: '/opt/opencode' }, () => { + expect(buildProxyConfig().OPENCODE_PATH).toBe('/opt/opencode'); + expect(buildProxyConfig({ OPENCODE_PATH: '/opt/other' }).OPENCODE_PATH).toBe('/opt/other'); + }); + }); + }); + + test('manage backend / isolated home: options > env > library default (drift preserved)', () => { + withEnv({ OPENCODE_PROXY_MANAGE_BACKEND: undefined, OPENCODE_USE_ISOLATED_HOME: undefined }, () => { + expect(buildProxyConfig().MANAGE_BACKEND).toBe(true); + expect(buildProxyConfig().USE_ISOLATED_HOME).toBe(false); + withEnv({ OPENCODE_PROXY_MANAGE_BACKEND: 'false', OPENCODE_USE_ISOLATED_HOME: 'true' }, () => { + expect(buildProxyConfig().MANAGE_BACKEND).toBe(false); + expect(buildProxyConfig().USE_ISOLATED_HOME).toBe(true); + withEnv({ OPENCODE_PROXY_MANAGE_BACKEND: 'garbage', OPENCODE_USE_ISOLATED_HOME: 'garbage' }, () => { + expect(buildProxyConfig().MANAGE_BACKEND).toBe(true); + expect(buildProxyConfig().USE_ISOLATED_HOME).toBe(false); + expect(buildProxyConfig({ MANAGE_BACKEND: false, USE_ISOLATED_HOME: true }).MANAGE_BACKEND).toBe(false); + expect(buildProxyConfig({ MANAGE_BACKEND: false, USE_ISOLATED_HOME: true }).USE_ISOLATED_HOME).toBe(true); + }); + }); + }); + }); + + test('request timeout keeps the library 300000 default and rejects partial numerics', () => { + withEnv({ OPENCODE_PROXY_REQUEST_TIMEOUT_MS: undefined }, () => { + expect(buildProxyConfig().REQUEST_TIMEOUT_MS).toBe(300000); + withEnv({ OPENCODE_PROXY_REQUEST_TIMEOUT_MS: '60000' }, () => { + expect(buildProxyConfig().REQUEST_TIMEOUT_MS).toBe(60000); + withEnv({ OPENCODE_PROXY_REQUEST_TIMEOUT_MS: '60000ms' }, () => { + expect(buildProxyConfig().REQUEST_TIMEOUT_MS).toBe(300000); + }); + }); + }); + }); + + test('retry: options > canonical env > legacy env > default, no partial numerics', () => { + withEnv({ OPENCODE_PROXY_RETRY_MAX_RETRIES: undefined, RETRY_MAX_RETRIES: undefined }, () => { + expect(buildProxyConfig().RETRY_MAX_RETRIES).toBe(3); + expect(resolveMaxRetries(buildProxyConfig().RETRY_MAX_RETRIES)).toBe(3); + withEnv({ RETRY_MAX_RETRIES: '1' }, () => { + expect(buildProxyConfig().RETRY_MAX_RETRIES).toBe(1); + withEnv({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '4' }, () => { + expect(buildProxyConfig().RETRY_MAX_RETRIES).toBe(4); + expect(buildProxyConfig({ RETRY_MAX_RETRIES: 2 }).RETRY_MAX_RETRIES).toBe(2); + expect(resolveMaxRetries(buildProxyConfig({ RETRY_MAX_RETRIES: 99 }).RETRY_MAX_RETRIES)).toBe(5); + }); + }); + }); + }); + + test('retry: invalid env values fall through and never parse partially', () => { + withEnv({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '3abc', RETRY_MAX_RETRIES: 'garbage' }, () => { + expect(buildProxyConfig().RETRY_MAX_RETRIES).toBe(3); + }); + withEnv({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '', RETRY_MAX_RETRIES: '2' }, () => { + expect(buildProxyConfig().RETRY_MAX_RETRIES).toBe(2); + }); + withEnv({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '2.9', RETRY_MAX_RETRIES: '' }, () => { + expect(buildProxyConfig().RETRY_MAX_RETRIES).toBe(3); + }); + expect(buildProxyConfig({ RETRY_MAX_RETRIES: '1x' }).RETRY_MAX_RETRIES).toBe(3); + expect(buildProxyConfig({ RETRY_MAX_RETRIES: 0 }).RETRY_MAX_RETRIES).toBe(0); + }); + + test('external tool policy lists: options > canonical env > legacy env > default', () => { + withEnv({ + OPENCODE_EXTERNAL_TOOL_ALLOWLIST: undefined, + OPENCODE_EXTERNAL_TOOL_DENYLIST: undefined, + OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: undefined, + EXTERNAL_TOOL_ALLOWLIST: undefined, + EXTERNAL_TOOL_DENYLIST: undefined, + EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: undefined, + }, () => { + const base = buildProxyConfig(); + expect(base.EXTERNAL_TOOL_POLICY_MODE).toBe(DEFAULT_EXTERNAL_TOOL_POLICY_MODE); + expect(base.EXTERNAL_TOOL_DEFAULT_RISK_LEVEL).toBe(DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL); + expect(base.EXTERNAL_TOOL_ALLOWLIST).toEqual([]); + expect(base.EXTERNAL_TOOL_DENYLIST).toEqual([]); + expect(base.EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR).toEqual([]); + withEnv({ + EXTERNAL_TOOL_ALLOWLIST: 'legacy_allow', + EXTERNAL_TOOL_DENYLIST: 'legacy_deny', + EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: 'legacy_confirm', + }, () => { + const legacy = buildProxyConfig(); + expect(legacy.EXTERNAL_TOOL_ALLOWLIST).toEqual(['legacy_allow']); + expect(legacy.EXTERNAL_TOOL_DENYLIST).toEqual(['legacy_deny']); + expect(legacy.EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR).toEqual(['legacy_confirm']); + withEnv({ + OPENCODE_EXTERNAL_TOOL_ALLOWLIST: ' canon_allow , canon_allow ,b', + OPENCODE_EXTERNAL_TOOL_DENYLIST: 'canon_deny', + OPENCODE_EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: 'canon_confirm', + }, () => { + const canonical = buildProxyConfig(); + expect(canonical.EXTERNAL_TOOL_ALLOWLIST).toEqual(['canon_allow', 'b']); + expect(canonical.EXTERNAL_TOOL_DENYLIST).toEqual(['canon_deny']); + expect(canonical.EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR).toEqual(['canon_confirm']); + const options = buildProxyConfig({ + EXTERNAL_TOOL_ALLOWLIST: ['opt_allow'], + EXTERNAL_TOOL_DENYLIST: ['opt_deny'], + EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR: ['opt_confirm'], + }); + expect(options.EXTERNAL_TOOL_ALLOWLIST).toEqual(['opt_allow']); + expect(options.EXTERNAL_TOOL_DENYLIST).toEqual(['opt_deny']); + expect(options.EXTERNAL_TOOL_REQUIRE_CONFIRMATION_FOR).toEqual(['opt_confirm']); + }); + }); + }); + }); + + test('external tool policy mode/risk: invalid values fall through to the default', () => { + withEnv({ + OPENCODE_EXTERNAL_TOOL_POLICY_MODE: undefined, + EXTERNAL_TOOL_POLICY_MODE: undefined, + OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: undefined, + EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: undefined, + }, () => { + withEnv({ OPENCODE_EXTERNAL_TOOL_POLICY_MODE: 'yolo', EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'urgent' }, () => { + expect(buildProxyConfig().EXTERNAL_TOOL_POLICY_MODE).toBe(DEFAULT_EXTERNAL_TOOL_POLICY_MODE); + expect(buildProxyConfig().EXTERNAL_TOOL_DEFAULT_RISK_LEVEL).toBe(DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL); + withEnv({ EXTERNAL_TOOL_POLICY_MODE: 'report-only', EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'HIGH' }, () => { + expect(buildProxyConfig().EXTERNAL_TOOL_POLICY_MODE).toBe('report-only'); + expect(buildProxyConfig().EXTERNAL_TOOL_DEFAULT_RISK_LEVEL).toBe('high'); + withEnv({ OPENCODE_EXTERNAL_TOOL_POLICY_MODE: 'enforce' }, () => { + expect(buildProxyConfig().EXTERNAL_TOOL_POLICY_MODE).toBe('enforce'); + }); + expect(buildProxyConfig({ EXTERNAL_TOOL_POLICY_MODE: 'report-only' }).EXTERNAL_TOOL_POLICY_MODE).toBe('report-only'); + expect(buildProxyConfig({ EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'critical' }).EXTERNAL_TOOL_DEFAULT_RISK_LEVEL) + .toBe('critical'); + }); + }); + }); + }); + + test('unsupported EXTERNAL_TOOLS_MODE from options fails fast, invalid env falls through', () => { + withEnv({ OPENCODE_EXTERNAL_TOOLS_MODE: undefined, OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY: undefined }, () => { + expect(() => buildProxyConfig({ EXTERNAL_TOOLS_MODE: 'passthrough' })).toThrow(/Unsupported EXTERNAL_TOOLS_MODE/); + expect(() => buildProxyConfig({ externalToolsMode: 'passthrough' })).toThrow(/Unsupported EXTERNAL_TOOLS_MODE/); + expect(() => buildProxyConfig({ EXTERNAL_TOOLS_CONFLICT_POLICY: 'drop' })).toThrow(/Unsupported EXTERNAL_TOOLS_CONFLICT_POLICY/); + withEnv({ OPENCODE_EXTERNAL_TOOLS_MODE: 'passthrough', OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY: 'drop' }, () => { + expect(buildProxyConfig().EXTERNAL_TOOLS_MODE).toBe(DEFAULT_EXTERNAL_TOOLS_MODE); + expect(buildProxyConfig().EXTERNAL_TOOLS_CONFLICT_POLICY).toBe(DEFAULT_EXTERNAL_TOOLS_CONFLICT_POLICY); + }); + expect(buildProxyConfig({ EXTERNAL_TOOLS_MODE: 'proxy-bridge' }).EXTERNAL_TOOLS_MODE).toBe('proxy-bridge'); + }); + }); + + test('upstream proxy legacy env aliases merge in the library path', () => { + withEnv({ + OPENCODE_UPSTREAM_PROXY_STRATEGY: undefined, + UPSTREAM_PROXY_STRATEGY: undefined, + OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS: undefined, + UPSTREAM_PROXY_COOLDOWN_MS: undefined, + OPENCODE_UPSTREAM_PROXY_NO_PROXY: undefined, + UPSTREAM_PROXY_NO_PROXY: undefined, + OPENCODE_UPSTREAM_PROXIES: undefined, + UPSTREAM_PROXIES: undefined, + }, () => { + expect(buildProxyConfig().UPSTREAM_PROXY_STRATEGY).toBe('failover-rr'); + expect(buildProxyConfig().UPSTREAM_PROXY_COOLDOWN_MS).toBe(300000); + expect(buildProxyConfig().UPSTREAM_PROXY_NO_PROXY).toEqual(['localhost', '127.0.0.1', '::1']); + withEnv({ + UPSTREAM_PROXY_STRATEGY: 'random', + UPSTREAM_PROXY_COOLDOWN_MS: '60000', + UPSTREAM_PROXY_NO_PROXY: 'backend.internal,10.0.0.1', + UPSTREAM_PROXIES: 'socks5://10.0.0.1:1080', + }, () => { + const legacy = buildProxyConfig(); + expect(legacy.UPSTREAM_PROXY_STRATEGY).toBe('random'); + expect(legacy.UPSTREAM_PROXY_COOLDOWN_MS).toBe(60000); + expect(legacy.UPSTREAM_PROXY_NO_PROXY).toEqual(['backend.internal', '10.0.0.1']); + expect(legacy.UPSTREAM_PROXIES).toEqual(['socks5://10.0.0.1:1080']); + withEnv({ OPENCODE_UPSTREAM_PROXY_STRATEGY: 'round-robin' }, () => { + expect(buildProxyConfig().UPSTREAM_PROXY_STRATEGY).toBe('round-robin'); + expect(buildProxyConfig({ UPSTREAM_PROXY_STRATEGY: 'random' }).UPSTREAM_PROXY_STRATEGY).toBe('random'); + }); + withEnv({ OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS: 'garbage' }, () => { + expect(buildProxyConfig().UPSTREAM_PROXY_COOLDOWN_MS).toBe(60000); + }); + withEnv({ OPENCODE_UPSTREAM_PROXY_NO_PROXY: '' }, () => { + expect(buildProxyConfig().UPSTREAM_PROXY_NO_PROXY).toEqual(['backend.internal', '10.0.0.1']); + }); + }); + }); + }); +}); + +// index.ts cannot be imported (it boots the server), so pin the exact layer +// arrays it passes to the shared normalizers. +describe('index.ts merge chain (mirrored)', () => { + const file = (config) => config ?? {}; + + const resolveIndexPort = (env, fileConfig) => resolvePortSetting( + [env.OPENCODE_PROXY_PORT, env.PORT, file(fileConfig).PORT], + DEFAULT_PROXY_PORT, + ); + const resolveIndexServerPort = (env) => resolvePortSetting([env.OPENCODE_SERVER_PORT], DEFAULT_SERVER_PORT); + const resolveIndexUrl = (env, fileConfig) => resolveUrlSetting( + [env.OPENCODE_SERVER_URL, file(fileConfig).OPENCODE_SERVER_URL], + `http://127.0.0.1:${resolveIndexServerPort(env)}`, + ); + const resolveIndexPolicyMode = (env, fileConfig) => resolveEnumSetting( + [env.OPENCODE_EXTERNAL_TOOL_POLICY_MODE, env.EXTERNAL_TOOL_POLICY_MODE, file(fileConfig).EXTERNAL_TOOL_POLICY_MODE], + EXTERNAL_TOOL_POLICY_MODES, + DEFAULT_EXTERNAL_TOOL_POLICY_MODE, + ); + const resolveIndexRiskLevel = (env, fileConfig) => resolveEnumSetting( + [ + env.OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL, + env.EXTERNAL_TOOL_DEFAULT_RISK_LEVEL, + file(fileConfig).EXTERNAL_TOOL_DEFAULT_RISK_LEVEL, + ], + EXTERNAL_TOOL_RISK_LEVEL_VALUES, + DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL, + ); + const resolveIndexAllowlist = (env, fileConfig) => resolveListSetting( + [env.OPENCODE_EXTERNAL_TOOL_ALLOWLIST, env.EXTERNAL_TOOL_ALLOWLIST, file(fileConfig).EXTERNAL_TOOL_ALLOWLIST], + [], + ); + const resolveIndexRetries = (env, fileConfig) => resolveRetryCountSetting( + [env.OPENCODE_PROXY_RETRY_MAX_RETRIES, env.RETRY_MAX_RETRIES, file(fileConfig).RETRY_MAX_RETRIES], + 3, + ); + const resolveIndexBool = (env, fileConfig, canonical, fileKey, fallback) => resolveBoolSetting( + [env[canonical], file(fileConfig)[fileKey]], + fallback, + ); + const resolveIndexStrategy = (env, fileConfig) => resolveEnumSetting( + [env.OPENCODE_UPSTREAM_PROXY_STRATEGY, env.UPSTREAM_PROXY_STRATEGY, file(fileConfig).UPSTREAM_PROXY_STRATEGY], + PROXY_STRATEGIES, + DEFAULT_PROXY_STRATEGY, + ); + + test('PORT: canonical > legacy > file > default, invalid falls through', () => { + expect(resolveIndexPort({}, {})).toBe(DEFAULT_PROXY_PORT); + expect(resolveIndexPort({}, { PORT: 11000 })).toBe(11000); + expect(resolveIndexPort({ PORT: '12000' }, { PORT: 11000 })).toBe(12000); + expect(resolveIndexPort({ OPENCODE_PROXY_PORT: '13000', PORT: '12000' }, { PORT: 11000 })).toBe(13000); + expect(resolveIndexPort({ OPENCODE_PROXY_PORT: '0' }, { PORT: 11000 })).toBe(11000); + expect(resolveIndexPort({ OPENCODE_PROXY_PORT: 'abc', PORT: '' }, { PORT: 11000 })).toBe(11000); + expect(resolveIndexPort({ OPENCODE_PROXY_PORT: '65536' }, { PORT: 11000 })).toBe(11000); + }); + + test('OPENCODE_SERVER_PORT validates 1..65535 and only bakes the default URL', () => { + expect(resolveIndexServerPort({})).toBe(DEFAULT_SERVER_PORT); + expect(resolveIndexServerPort({ OPENCODE_SERVER_PORT: '10002' })).toBe(10002); + for (const bad of ['0', '65536', 'abc', '100 01', '']) { + expect(resolveIndexServerPort({ OPENCODE_SERVER_PORT: bad })).toBe(DEFAULT_SERVER_PORT); + } + expect(resolveIndexUrl({ OPENCODE_SERVER_PORT: '10002' }, {})).toBe('http://127.0.0.1:10002'); + expect(resolveIndexUrl({ OPENCODE_SERVER_PORT: '10002', OPENCODE_SERVER_URL: 'https://x.example' }, {})) + .toBe('https://x.example'); + expect(resolveIndexUrl({ OPENCODE_SERVER_URL: 'nope' }, { OPENCODE_SERVER_URL: 'http://file.example' })) + .toBe('http://file.example'); + }); + + test('file bools use the same strict parser as env', () => { + expect(resolveIndexBool({}, { HEALTH_DETAILS_ENABLED: false }, 'OPENCODE_HEALTH_DETAILS_ENABLED', 'HEALTH_DETAILS_ENABLED', true)) + .toBe(false); + expect(resolveIndexBool({}, { HEALTH_DETAILS_ENABLED: 'garbage' }, 'OPENCODE_HEALTH_DETAILS_ENABLED', 'HEALTH_DETAILS_ENABLED', true)) + .toBe(true); + expect(resolveIndexBool({ OPENCODE_HEALTH_DETAILS_ENABLED: 'yes' }, { HEALTH_DETAILS_ENABLED: false }, 'OPENCODE_HEALTH_DETAILS_ENABLED', 'HEALTH_DETAILS_ENABLED', true)) + .toBe(true); + expect(resolveIndexBool({ OPENCODE_HEALTH_DETAILS_ENABLED: 'maybe' }, { HEALTH_DETAILS_ENABLED: false }, 'OPENCODE_HEALTH_DETAILS_ENABLED', 'HEALTH_DETAILS_ENABLED', true)) + .toBe(false); + expect(resolveIndexBool({}, {}, 'OPENCODE_METRICS_ENABLED', 'METRICS_ENABLED', false)).toBe(false); + expect(resolveIndexBool({ OPENCODE_METRICS_ENABLED: 'garbage' }, {}, 'OPENCODE_METRICS_ENABLED', 'METRICS_ENABLED', false)).toBe(false); + }); + + test('external tool policy: canonical > legacy > file > default', () => { + expect(resolveIndexPolicyMode({}, {})).toBe(DEFAULT_EXTERNAL_TOOL_POLICY_MODE); + expect(resolveIndexPolicyMode({}, { EXTERNAL_TOOL_POLICY_MODE: 'report-only' })).toBe('report-only'); + expect(resolveIndexPolicyMode({ EXTERNAL_TOOL_POLICY_MODE: 'enforce' }, { EXTERNAL_TOOL_POLICY_MODE: 'report-only' })).toBe('enforce'); + expect(resolveIndexPolicyMode({ OPENCODE_EXTERNAL_TOOL_POLICY_MODE: 'report-only', EXTERNAL_TOOL_POLICY_MODE: 'enforce' }, {})) + .toBe('report-only'); + expect(resolveIndexPolicyMode({ OPENCODE_EXTERNAL_TOOL_POLICY_MODE: 'nope' }, { EXTERNAL_TOOL_POLICY_MODE: 'report-only' })) + .toBe('report-only'); + expect(resolveIndexPolicyMode({ OPENCODE_EXTERNAL_TOOL_POLICY_MODE: 'nope', EXTERNAL_TOOL_POLICY_MODE: '' }, { EXTERNAL_TOOL_POLICY_MODE: 'yolo' })) + .toBe(DEFAULT_EXTERNAL_TOOL_POLICY_MODE); + }); + + test('external tool policy risk level and allowlist merge the same way', () => { + expect(resolveIndexRiskLevel({}, {})).toBe(DEFAULT_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL); + expect(resolveIndexRiskLevel({ EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'medium' }, { EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'high' })).toBe('medium'); + expect(resolveIndexRiskLevel({ OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'CRITICAL', EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'low' }, {})) + .toBe('critical'); + expect(resolveIndexRiskLevel({ OPENCODE_EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'nope' }, { EXTERNAL_TOOL_DEFAULT_RISK_LEVEL: 'low' })).toBe('low'); + expect(resolveIndexAllowlist({}, {})).toEqual([]); + expect(resolveIndexAllowlist({ EXTERNAL_TOOL_ALLOWLIST: 'a' }, { EXTERNAL_TOOL_ALLOWLIST: ['b'] })).toEqual(['a']); + expect(resolveIndexAllowlist({ OPENCODE_EXTERNAL_TOOL_ALLOWLIST: ' x , y ', EXTERNAL_TOOL_ALLOWLIST: 'a' }, {})).toEqual(['x', 'y']); + expect(resolveIndexAllowlist({ OPENCODE_EXTERNAL_TOOL_ALLOWLIST: '' }, { EXTERNAL_TOOL_ALLOWLIST: 'a' })).toEqual(['a']); + }); + + test('RETRY_MAX_RETRIES: canonical > legacy > file > default, no partial numerics', () => { + expect(resolveIndexRetries({}, {})).toBe(3); + expect(resolveIndexRetries({}, { RETRY_MAX_RETRIES: 1 })).toBe(1); + expect(resolveIndexRetries({ RETRY_MAX_RETRIES: '2' }, { RETRY_MAX_RETRIES: 1 })).toBe(2); + expect(resolveIndexRetries({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '4', RETRY_MAX_RETRIES: '2' }, {})).toBe(4); + expect(resolveIndexRetries({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '4abc' }, { RETRY_MAX_RETRIES: '2' })).toBe(2); + expect(resolveIndexRetries({ OPENCODE_PROXY_RETRY_MAX_RETRIES: 'garbage', RETRY_MAX_RETRIES: '' }, { RETRY_MAX_RETRIES: 0 })).toBe(0); + // Out-of-range values stay raw here; the 0-5 clamp stays owned by + // resolveMaxRetries so the merge never silently reinterprets them. + expect(resolveIndexRetries({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '-1' }, {})).toBe(-1); + expect(resolveMaxRetries(resolveIndexRetries({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '-1' }, {}))).toBe(0); + expect(resolveMaxRetries(resolveIndexRetries({ OPENCODE_PROXY_RETRY_MAX_RETRIES: '99' }, {}))).toBe(5); + }); + + test('UPSTREAM_PROXY_* legacy env aliases merge ahead of the file', () => { + expect(resolveIndexStrategy({}, {})).toBe('failover-rr'); + expect(resolveIndexStrategy({}, { UPSTREAM_PROXY_STRATEGY: 'random' })).toBe('random'); + expect(resolveIndexStrategy({ UPSTREAM_PROXY_STRATEGY: 'round-robin' }, { UPSTREAM_PROXY_STRATEGY: 'random' })).toBe('round-robin'); + expect(resolveIndexStrategy({ OPENCODE_UPSTREAM_PROXY_STRATEGY: 'random', UPSTREAM_PROXY_STRATEGY: 'round-robin' }, {})) + .toBe('random'); + expect(resolveIndexStrategy({ OPENCODE_UPSTREAM_PROXY_STRATEGY: 'garbage', UPSTREAM_PROXY_STRATEGY: 'random' }, {})) + .toBe('random'); + }); +}); diff --git a/tests/interactions.test.js b/tests/interactions.test.js index 0edfc3b..a421425 100644 --- a/tests/interactions.test.js +++ b/tests/interactions.test.js @@ -239,6 +239,25 @@ describe('POST /v1beta/interactions', () => { expect(res.text).not.toContain(''); }); + test('tool discovery unavailable fails closed instead of prompting without overrides', async () => { + const app = createApp(baseConfig({})).app; + sdkMocks.toolIds.mockRejectedValueOnce(new Error('discovery down')); + const res = await request(app).post('/v1beta/interactions').send({ + model: 'opencode/kimi-k2.5', + input: 'hi', + }); + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ + error: { + message: 'Tool discovery unavailable; backend tool IDs could not be verified', + type: 'tool_discovery_unavailable', + code: 'tool_discovery_unavailable', + }, + }); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + expect(sdkMocks.sessionPrompt).not.toHaveBeenCalled(); + }); + test('free-limit engages fallback and retries via proxy within the same request', async () => { sdkMocks.sessionPrompt.mockRejectedValueOnce({ name: 'APIError', @@ -268,4 +287,79 @@ describe('POST /v1beta/interactions', () => { 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); }); + test('stream google_search emits ordered steps without DONE', 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' }], stream: true }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('interaction.created'); + expect(res.text).toContain('step.delta'); + expect(res.text).toContain('google_search_call'); + expect(res.text).toContain('google_search_result'); + expect(res.text).toContain('model_output'); + expect(res.text).toContain('interaction.completed'); + expect(res.text).not.toContain('[DONE]'); + const idxCreated = res.text.indexOf('interaction.created'); + const idxDelta = res.text.indexOf('step.delta'); + const idxCall = res.text.indexOf('google_search_call'); + const idxResult = res.text.indexOf('google_search_result'); + const idxOutput = res.text.indexOf('model_output'); + const idxCompleted = res.text.indexOf('interaction.completed'); + expect(idxCreated).toBeGreaterThanOrEqual(0); + expect(idxCreated).toBeLessThan(idxDelta); + expect(idxDelta).toBeLessThan(idxCompleted); + expect(idxCompleted).toBeLessThan(idxCall); + expect(idxCall).toBeLessThan(idxResult); + expect(idxResult).toBeLessThan(idxOutput); + expect(sdkMocks.eventSubscribe).not.toHaveBeenCalled(); + }); + test('stream error surfaces as error event without DONE or completed', async () => { + const app = createApp(baseConfig({})).app; + sdkMocks.sessionPrompt.mockRejectedValueOnce(new Error('backend down')); + 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('"type":"error"'); + expect(res.text).not.toContain('interaction.completed'); + expect(res.text).not.toContain('[DONE]'); + }); + test('function tools stay rejected without touching backend', async () => { + const app = createApp(baseConfig({})).app; + jest.clearAllMocks(); + sdkMocks.toolIds.mockResolvedValue({ data: ['websearch', 'webfetch'] }); + const res = await request(app).post('/v1beta/interactions').send({ model: 'opencode/kimi-k2.5', input: 'hi', tools: [{ type: 'function', function: { name: 'x' } }] }); + expect(res.statusCode).toBe(400); + expect(res.body.error.type).toBe('invalid_request_error'); + expect(JSON.stringify(res.body)).toContain('google_search'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + expect(sdkMocks.sessionPrompt).not.toHaveBeenCalled(); + expect(sdkMocks.sessionMessages).not.toHaveBeenCalled(); + }); + test('stream previous_interaction_id reuses stored 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(); + sdkMocks.toolIds.mockResolvedValue({ data: ['websearch', 'webfetch'] }); + const second = await request(app).post('/v1beta/interactions').send({ model: 'opencode/kimi-k2.5', input: 'follow-up', previous_interaction_id: first.body.id, stream: true }); + expect(second.statusCode).toBe(200); + expect(second.text).toContain('interaction.completed'); + expect(second.text).not.toContain('[DONE]'); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); + test('stream store false deletes owned session but keeps shared parent', async () => { + const app = createApp(baseConfig({})).app; + jest.clearAllMocks(); + sdkMocks.toolIds.mockResolvedValue({ data: ['websearch', 'webfetch'] }); + const ephemeral = await request(app).post('/v1beta/interactions').send({ model: 'opencode/kimi-k2.5', input: 'hi', stream: true, store: false }); + expect(ephemeral.statusCode).toBe(200); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); + const first = await request(app).post('/v1beta/interactions').send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(first.statusCode).toBe(200); + jest.clearAllMocks(); + sdkMocks.toolIds.mockResolvedValue({ data: ['websearch', 'webfetch'] }); + const second = await request(app).post('/v1beta/interactions').send({ model: 'opencode/kimi-k2.5', input: 'follow-up', previous_interaction_id: first.body.id, stream: true, store: false }); + expect(second.statusCode).toBe(200); + expect(sdkMocks.sessionDelete).not.toHaveBeenCalled(); + expect(sdkMocks.sessionCreate).not.toHaveBeenCalled(); + }); }); diff --git a/tests/messages-anthropic.test.js b/tests/messages-anthropic.test.js index baea2b0..da84577 100644 --- a/tests/messages-anthropic.test.js +++ b/tests/messages-anthropic.test.js @@ -8,7 +8,7 @@ const sdkMocks = { } })), configUpdate: jest.fn(async () => ({})), - toolIds: jest.fn(async () => ({ data: [] })), + toolIds: jest.fn(async () => ({ data: ['web_fetch', 'filesystem', 'bash'] })), sessionCreate: jest.fn(async () => ({ data: { id: 'msg-session' } })), sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'Hello!' }] } })), sessionMessages: jest.fn(async () => ([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Hello!' }] }])), @@ -66,6 +66,52 @@ import { mapFinishToStopReason } from '../src/converters/anthropic.js'; +function parseMessagesSseFrames(text) { + const frames = []; + const blocks = String(text || '').split('\n\n'); + for (const block of blocks) { + const trimmed = block.trim(); + if (!trimmed) continue; + if (trimmed.startsWith(':')) continue; + const lines = block.split('\n'); + let eventName = null; + let dataText = null; + for (const line of lines) { + if (line.startsWith('event:')) eventName = line.slice(6).trim(); + else if (line.startsWith('data:')) dataText = line.slice(5).trim(); + } + if (!eventName || dataText === null) continue; + let data = null; + try { + data = JSON.parse(dataText); + } catch { + data = dataText; + } + frames.push({ event: eventName, data }); + } + return frames; +} + +function messagesToolStarts(frames) { + return frames.filter((f) => f.event === 'content_block_start' && f.data && f.data.content_block && f.data.content_block.type === 'tool_use'); +} + +function messagesInputDeltas(frames) { + return frames.filter((f) => f.event === 'content_block_delta' && f.data && f.data.delta && f.data.delta.type === 'input_json_delta'); +} + +function messagesThinkingStarts(frames) { + return frames.filter((f) => f.event === 'content_block_start' && f.data && f.data.content_block && f.data.content_block.type === 'thinking'); +} + +function messagesThinkingDeltas(frames) { + return frames.filter((f) => f.event === 'content_block_delta' && f.data && f.data.delta && f.data.delta.type === 'thinking_delta'); +} + +function messagesTextDeltas(frames) { + return frames.filter((f) => f.event === 'content_block_delta' && f.data && f.data.delta && f.data.delta.type === 'text_delta'); +} + describe('Anthropic /v1/messages converters', () => { test('validation requires model/max_tokens/messages', () => { expect(validateMessagesRequest({})).not.toBeNull(); @@ -88,6 +134,36 @@ describe('Anthropic /v1/messages converters', () => { expect(anthropicToolChoiceToChat({ type: 'tool', name: 'w' })).toEqual({ type: 'function', function: { name: 'w' } }); expect(mapFinishToStopReason('stop', true)).toBe('tool_use'); }); + + test('preserves enabled but drops proxy-reserved metadata in converted tools', () => { + const converted = anthropicToolsToChatTools([{ + name: 'w', + input_schema: { type: 'object' }, + enabled: false, + x_proxy_side_effect: 'write', + x_proxy_risk_level: 'high', + x_proxy_requires_confirmation: true + }]); + + expect(converted[0].function.enabled).toBe(false); + expect(converted[0].function.x_proxy_side_effect).toBeUndefined(); + expect(converted[0].function.x_proxy_risk_level).toBeUndefined(); + expect(converted[0].function.x_proxy_requires_confirmation).toBeUndefined(); + expect(JSON.stringify(converted)).not.toContain('x_proxy_'); + }); + + test('does not convert a tool choice without a name to required', () => { + expect(anthropicToolChoiceToChat({ type: 'tool' })).toBeUndefined(); + expect(validateMessagesRequest({ + model: 'm', + max_tokens: 10, + messages: [{ role: 'user', content: 'hi' }], + tool_choice: { type: 'tool' } + })).toMatchObject({ + statusCode: 400, + body: { type: 'error', error: { type: 'invalid_request_error' } } + }); + }); }); describe('POST /v1/messages', () => { @@ -97,6 +173,31 @@ describe('POST /v1/messages', () => { const config = { PORT: 10000, API_KEY: 'test-key', OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false }; app = createApp(config).app; }); + test('preserves an explicit empty tool_result in non-stream input', async () => { + const res = await request(app) + .post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/muse-spark-1.3-contributor-free', + max_tokens: 100, + tools: [{ name: 'read', input_schema: { type: 'object' } }], + messages: [ + { role: 'user', content: 'Read a.txt' }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_read_1', name: 'read', input: {} }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_read_1', content: '' }] } + ] + }); + + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)?.[0]; + expect(promptCall.body.parts).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: 'TOOL_RESULT: {"tool_call_id":"toolu_read_1","name":"external__read","content":""}' + }) + ])); + }); + test('non-stream returns Anthropic message shape', async () => { const res = await request(app).post('/v1/messages') .set('Authorization', 'Bearer test-key') @@ -114,6 +215,23 @@ describe('POST /v1/messages', () => { expect(res.statusCode).toBe(401); expect(res.body.type).toBe('error'); }); + test('missing tool_choice name returns an invalid request', async () => { + const res = await request(app) + .post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ + model: 'opencode/muse-spark-1.3-contributor-free', + max_tokens: 10, + messages: [{ role: 'user', content: 'hi' }], + tool_choice: { type: 'tool' } + }); + + expect(res.statusCode).toBe(400); + expect(res.body.type).toBe('error'); + expect(res.body.error.type).toBe('invalid_request_error'); + expect(res.body.error.message).toContain('tool_choice.name'); + }); + test('tiny max_tokens does not fake max_tokens stop_reason (usage is estimated)', async () => { const res = await request(app).post('/v1/messages') .set('Authorization', 'Bearer test-key') @@ -144,4 +262,379 @@ describe('POST /v1/messages', () => { expect(res.text).toContain('event: message_stop'); expect(res.text).not.toContain('[DONE]'); }); + test('tools reach prompt with namespaced guard and user line', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)[0]; + expect(String(promptCall.body.system)).toContain('external__read'); + const flat = (promptCall.body.parts || []).map((p) => String(p.text || '')).join(' '); + expect(flat).toContain('USER'); + expect(flat).toContain('hi'); + expect(JSON.stringify(res.body)).not.toContain('external__'); + }); + test('non-stream tool_use output preserves id and reports tool_use stop', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: '[{"id":"toolu_read_1","name":"external__read","arguments":{"path":"a.txt"}}]' }] }]); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } }] }); + expect(res.statusCode).toBe(200); + expect(res.body.stop_reason).toBe('tool_use'); + const toolBlock = (res.body.content || []).find((c) => c.type === 'tool_use'); + expect(toolBlock).toBeDefined(); + expect(toolBlock.id).toBe('toolu_read_1'); + expect(toolBlock.name).toBe('read'); + expect(toolBlock.input).toEqual({ path: 'a.txt' }); + expect(JSON.stringify(res.body)).not.toContain('external__'); + expect(JSON.stringify(res.body)).not.toContain(''); + }); + test('tool_use plus tool_result continuation maps to prompt parts in order', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object' } }], messages: [{ role: 'user', content: 'Read a.txt' }, { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_read_1', name: 'read', input: {} }] }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_read_1', content: 'file body' }] }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)[0]; + const flat = (promptCall.body.parts || []).map((p) => String(p.text || '')).join('\n\n'); + expect(flat).toContain('ASSISTANT: '); + expect(flat).toContain('toolu_read_1'); + expect(flat).toContain('TOOL_RESULT:'); + expect(flat).toContain('external__read'); + expect(flat).toContain('file body'); + expect(flat.indexOf('ASSISTANT:')).toBeLessThan(flat.indexOf('TOOL_RESULT:')); + }); + test('stream tool_use emits input_json_delta lifecycle and message deltas', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(async () => ({ stream: (async function* () { yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: '[{"id":"toolu_stream_1","name":"external__read","arguments":{"path":"a.txt"}}]' } }; yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; })() })); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, stream: true, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }] }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('tool_use'); + expect(res.text).toContain('input_json_delta'); + expect(res.text).toContain('toolu_stream_1'); + expect(res.text).toContain('event: message_delta'); + expect(res.text).toContain('event: message_stop'); + expect(res.text).toContain('event: content_block_stop'); + const idxStart = res.text.indexOf('content_block_start'); + const idxDelta = res.text.indexOf('input_json_delta'); + const idxStop = res.text.indexOf('content_block_stop'); + const idxMsgDelta = res.text.indexOf('message_delta'); + const idxMsgStop = res.text.indexOf('message_stop'); + expect(idxStart).toBeGreaterThanOrEqual(0); + expect(idxStart).toBeLessThan(idxDelta); + expect(idxDelta).toBeLessThan(idxStop); + expect(idxStop).toBeLessThan(idxMsgDelta); + expect(idxMsgDelta).toBeLessThan(idxMsgStop); + expect(res.text).not.toContain('[DONE]'); + expect(res.text).not.toContain('external__read'); + expect(res.text).not.toContain(''); + }); + test('reasoning surfaces as thinking block with empty signature', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'reasoning', text: 'plan step' }, { type: 'text', text: 'done' }] }]); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + const thinking = (res.body.content || []).find((c) => c.type === 'thinking'); + expect(thinking).toBeDefined(); + expect(thinking.thinking).toBe('plan step'); + expect(thinking.signature).toBe(''); + const textBlock = (res.body.content || []).find((c) => c.type === 'text'); + expect(textBlock.text).toBe('done'); + expect(res.body.stop_reason).toBe('end_turn'); + }); + test('parallel tool calls preserve ids and order on messages', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: '[{"id":"toolu_p_a","name":"external__read","arguments":{"path":"a.txt"}},{"id":"toolu_p_b","name":"external__read","arguments":{"path":"b.txt"}}]' }] }]); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, messages: [{ role: 'user', content: 'Read both' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }] }); + expect(res.statusCode).toBe(200); + expect(res.body.stop_reason).toBe('tool_use'); + const uses = (res.body.content || []).filter((c) => c.type === 'tool_use'); + expect(uses.map((c) => c.id)).toEqual(['toolu_p_a', 'toolu_p_b']); + expect(uses.map((c) => c.name)).toEqual(['read', 'read']); + expect(uses[0].input).toEqual({ path: 'a.txt' }); + expect(uses[1].input).toEqual({ path: 'b.txt' }); + expect(JSON.stringify(res.body)).not.toContain('external__'); + }); + test('parallel tool calls preserve ids and order on chat', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: '[{"id":"call_p_a","name":"external__read","arguments":{"path":"a.txt"}},{"id":"call_p_b","name":"external__read","arguments":{"path":"b.txt"}}]' }] }]); + const res = await request(app).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', messages: [{ role: 'user', content: 'Read both' }], tools: [{ type: 'function', function: { name: 'read', description: 'Read', parameters: { type: 'object', properties: { path: { type: 'string' } } } } }] }); + expect(res.statusCode).toBe(200); + expect(res.body.choices[0].finish_reason).toBe('tool_calls'); + expect(res.body.choices[0].message.tool_calls.map((c) => c.id)).toEqual(['call_p_a', 'call_p_b']); + expect(res.body.choices[0].message.tool_calls.map((c) => c.function.name)).toEqual(['read', 'read']); + expect(JSON.parse(res.body.choices[0].message.tool_calls[0].function.arguments)).toEqual({ path: 'a.txt' }); + expect(JSON.parse(res.body.choices[0].message.tool_calls[1].function.arguments)).toEqual({ path: 'b.txt' }); + expect(JSON.stringify(res.body)).not.toContain('external__'); + }); + test('parallel tool calls preserve ids and order on responses', async () => { + sdkMocks.sessionPrompt.mockResolvedValueOnce({ data: { parts: [{ type: 'text', text: '[{"id":"call_p_a","name":"external__read","arguments":{"path":"a.txt"}},{"id":"call_p_b","name":"external__read","arguments":{"path":"b.txt"}}]' }] } }); + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', input: 'Read both', tools: [{ type: 'function', name: 'read', description: 'Read', parameters: { type: 'object', properties: { path: { type: 'string' } } } }] }); + expect(res.statusCode).toBe(200); + const calls = (res.body.output || []).filter((o) => o.type === 'function_call'); + expect(calls.map((c) => c.call_id)).toEqual(['call_p_a', 'call_p_b']); + expect(calls.map((c) => c.name)).toEqual(['read', 'read']); + expect(JSON.parse(calls[0].arguments)).toEqual({ path: 'a.txt' }); + expect(JSON.parse(calls[1].arguments)).toEqual({ path: 'b.txt' }); + expect(JSON.stringify(res.body)).not.toContain('external__'); + }); + test('parallel continuation maps both tool results in order on chat', async () => { + const res = await request(app).post('/v1/chat/completions') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', messages: [{ role: 'user', content: 'Read both' }, { role: 'assistant', content: null, tool_calls: [{ id: 'call_p_a', type: 'function', function: { name: 'read', arguments: JSON.stringify({ path: 'a.txt' }) } }, { id: 'call_p_b', type: 'function', function: { name: 'read', arguments: JSON.stringify({ path: 'b.txt' }) } }] }, { role: 'tool', tool_call_id: 'call_p_a', name: 'read', content: 'body-a' }, { role: 'tool', tool_call_id: 'call_p_b', name: 'read', content: 'body-b' }], tools: [{ type: 'function', function: { name: 'read', parameters: { type: 'object' } } }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)[0]; + const flat = (promptCall.body.parts || []).map((p) => String(p.text || '')).join('\n\n'); + expect(flat).toContain('call_p_a'); + expect(flat).toContain('call_p_b'); + expect(flat).toContain('body-a'); + expect(flat).toContain('body-b'); + expect(flat.indexOf('call_p_a')).toBeLessThan(flat.indexOf('call_p_b')); + }); + test('parallel continuation maps both tool results in order on responses', async () => { + const res = await request(app).post('/v1/responses') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Read both' }] }, { type: 'function_call', call_id: 'call_p_a', name: 'read', arguments: JSON.stringify({ path: 'a.txt' }) }, { type: 'function_call', call_id: 'call_p_b', name: 'read', arguments: JSON.stringify({ path: 'b.txt' }) }, { type: 'function_call_output', call_id: 'call_p_a', output: 'body-a' }, { type: 'function_call_output', call_id: 'call_p_b', output: 'body-b' }], tools: [{ type: 'function', name: 'read', parameters: { type: 'object' } }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)[0]; + const flat = (promptCall.body.parts || []).map((p) => String(p.text || '')).join('\n\n'); + expect(flat).toContain('call_p_a'); + expect(flat).toContain('call_p_b'); + expect(flat).toContain('body-a'); + expect(flat).toContain('body-b'); + expect(flat.indexOf('call_p_a')).toBeLessThan(flat.indexOf('call_p_b')); + }); + test('parallel continuation maps both tool results in order on messages', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, tools: [{ name: 'read', input_schema: { type: 'object' } }], messages: [{ role: 'user', content: 'Read both' }, { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_p_a', name: 'read', input: { path: 'a.txt' } }, { type: 'tool_use', id: 'toolu_p_b', name: 'read', input: { path: 'b.txt' } }] }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_p_a', content: 'body-a' }, { type: 'tool_result', tool_use_id: 'toolu_p_b', content: 'body-b' }] }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)[0]; + const flat = (promptCall.body.parts || []).map((p) => String(p.text || '')).join('\n\n'); + expect(flat).toContain('toolu_p_a'); + expect(flat).toContain('toolu_p_b'); + expect(flat).toContain('body-a'); + expect(flat).toContain('body-b'); + expect(flat.indexOf('toolu_p_a')).toBeLessThan(flat.indexOf('toolu_p_b')); + }); + test('stream single tool_use emits precise blocks and suppresses text', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(async () => ({ stream: (async function* () { yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: '[{"id":"toolu_stream_1","name":"external__read","arguments":{"path":"a.txt"}}]' } }; yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; })() })); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, stream: true, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } }] }); + expect(res.statusCode).toBe(200); + const frames = parseMessagesSseFrames(res.text); + const toolStarts = messagesToolStarts(frames); + expect(toolStarts).toHaveLength(1); + expect(toolStarts[0].data.content_block.id).toBe('toolu_stream_1'); + expect(toolStarts[0].data.content_block.name).toBe('read'); + expect(toolStarts[0].data.content_block.input).toEqual({}); + expect(toolStarts[0].data.index).toBe(0); + const toolDeltas = messagesInputDeltas(frames); + expect(toolDeltas).toHaveLength(1); + expect(toolDeltas[0].data.index).toBe(toolStarts[0].data.index); + expect(JSON.parse(toolDeltas[0].data.delta.partial_json)).toEqual({ path: 'a.txt' }); + expect(toolDeltas[0].data.delta.partial_json).toBe(JSON.stringify({ path: 'a.txt' })); + expect(messagesTextDeltas(frames)).toHaveLength(0); + expect(frames.filter((f) => f.event === 'content_block_start' && f.data.content_block && f.data.content_block.type === 'text')).toHaveLength(0); + expect(res.text).not.toContain(''); + expect(res.text).not.toContain('external__'); + const eventSeq = frames.map((f) => f.event); + expect(eventSeq[0]).toBe('message_start'); + expect(eventSeq[eventSeq.length - 1]).toBe('message_stop'); + expect(eventSeq.indexOf('content_block_start')).toBeLessThan(eventSeq.indexOf('content_block_delta')); + expect(eventSeq.indexOf('content_block_delta')).toBeLessThan(eventSeq.indexOf('content_block_stop')); + expect(eventSeq.indexOf('content_block_stop')).toBeLessThan(eventSeq.indexOf('message_delta')); + expect(eventSeq.indexOf('message_delta')).toBeLessThan(eventSeq.indexOf('message_stop')); + const msgDelta = frames.find((f) => f.event === 'message_delta'); + expect(msgDelta.data.delta.stop_reason).toBe('tool_use'); + expect(res.text).not.toContain('[DONE]'); + }); + test('stream thinking emits ordered blocks with empty signature', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(async () => ({ stream: (async function* () { yield { type: 'message.part.updated', properties: { part: { type: 'reasoning', sessionID: 'msg-session' }, delta: 'plan ' } }; yield { type: 'message.part.updated', properties: { part: { type: 'reasoning', sessionID: 'msg-session' }, delta: 'step' } }; yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; })() })); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 50, stream: true, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + const frames = parseMessagesSseFrames(res.text); + const thinkStarts = messagesThinkingStarts(frames); + expect(thinkStarts).toHaveLength(1); + expect(thinkStarts[0].data.content_block.signature).toBe(''); + expect(thinkStarts[0].data.content_block.thinking).toBe(''); + expect(thinkStarts[0].data.index).toBe(0); + const thinkDeltas = messagesThinkingDeltas(frames); + expect(thinkDeltas.length).toBeGreaterThanOrEqual(1); + expect(thinkDeltas.map((f) => f.data.delta.thinking).join('')).toBe('plan step'); + for (const d of thinkDeltas) expect(d.data.index).toBe(thinkStarts[0].data.index); + expect(frames.filter((f) => f.event === 'content_block_stop' && f.data.index === thinkStarts[0].data.index)).toHaveLength(1); + expect(messagesTextDeltas(frames)).toHaveLength(0); + expect(messagesToolStarts(frames)).toHaveLength(0); + const eventSeq = frames.map((f) => f.event); + expect(eventSeq[0]).toBe('message_start'); + expect(eventSeq[eventSeq.length - 1]).toBe('message_stop'); + expect(eventSeq.indexOf('content_block_start')).toBeLessThan(eventSeq.indexOf('content_block_delta')); + expect(eventSeq.indexOf('content_block_delta')).toBeLessThan(eventSeq.indexOf('content_block_stop')); + expect(eventSeq.indexOf('content_block_stop')).toBeLessThan(eventSeq.indexOf('message_delta')); + expect(eventSeq.indexOf('message_delta')).toBeLessThan(eventSeq.indexOf('message_stop')); + expect(frames.find((f) => f.event === 'message_delta').data.delta.stop_reason).toBe('end_turn'); + expect(res.text).not.toContain('[DONE]'); + }); + test('stream thinking coexists with tool_use and keeps index order', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(async () => ({ stream: (async function* () { yield { type: 'message.part.updated', properties: { part: { type: 'reasoning', sessionID: 'msg-session' }, delta: 'plan step' } }; yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: '[{"id":"toolu_mix_1","name":"external__read","arguments":{"path":"a.txt"}}]' } }; yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; })() })); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, stream: true, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }] }); + expect(res.statusCode).toBe(200); + const frames = parseMessagesSseFrames(res.text); + const thinkStarts = messagesThinkingStarts(frames); + expect(thinkStarts).toHaveLength(1); + expect(thinkStarts[0].data.content_block.signature).toBe(''); + const thinkDeltas = messagesThinkingDeltas(frames); + expect(thinkDeltas).toHaveLength(1); + expect(thinkDeltas[0].data.delta.thinking).toBe('plan step'); + expect(thinkDeltas[0].data.index).toBe(thinkStarts[0].data.index); + const toolStarts = messagesToolStarts(frames); + expect(toolStarts).toHaveLength(1); + expect(toolStarts[0].data.content_block.name).toBe('read'); + expect(toolStarts[0].data.content_block.id).toBe('toolu_mix_1'); + expect(toolStarts[0].data.index).toBeGreaterThan(thinkStarts[0].data.index); + const toolDeltas = messagesInputDeltas(frames); + expect(toolDeltas).toHaveLength(1); + expect(JSON.parse(toolDeltas[0].data.delta.partial_json)).toEqual({ path: 'a.txt' }); + expect(toolDeltas[0].data.index).toBe(toolStarts[0].data.index); + expect(messagesTextDeltas(frames)).toHaveLength(0); + expect(res.text).not.toContain(''); + expect(res.text).not.toContain('external__'); + expect(frames.find((f) => f.event === 'message_delta').data.delta.stop_reason).toBe('tool_use'); + expect(res.text).not.toContain('[DONE]'); + }); + test('stream parallel tool_use keeps order indexes and partial_json', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(async () => ({ stream: (async function* () { yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: '[{"id":"toolu_p_a","name":"external__read","arguments":{"path":"a.txt"}},{"id":"toolu_p_b","name":"external__read","arguments":{"path":"b.txt"}}]' } }; yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; })() })); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, stream: true, messages: [{ role: 'user', content: 'Read both' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }] }); + expect(res.statusCode).toBe(200); + const frames = parseMessagesSseFrames(res.text); + const toolStarts = messagesToolStarts(frames); + expect(toolStarts.map((f) => f.data.content_block.id)).toEqual(['toolu_p_a', 'toolu_p_b']); + expect(toolStarts.map((f) => f.data.content_block.name)).toEqual(['read', 'read']); + expect(toolStarts[1].data.index).toBe(toolStarts[0].data.index + 1); + const toolDeltas = messagesInputDeltas(frames); + expect(toolDeltas).toHaveLength(2); + expect(toolDeltas[0].data.index).toBe(toolStarts[0].data.index); + expect(toolDeltas[1].data.index).toBe(toolStarts[1].data.index); + expect(JSON.parse(toolDeltas[0].data.delta.partial_json)).toEqual({ path: 'a.txt' }); + expect(JSON.parse(toolDeltas[1].data.delta.partial_json)).toEqual({ path: 'b.txt' }); + expect(messagesTextDeltas(frames)).toHaveLength(0); + expect(res.text).not.toContain(''); + expect(res.text).not.toContain('external__'); + expect(frames.find((f) => f.event === 'message_delta').data.delta.stop_reason).toBe('tool_use'); + expect(res.text).not.toContain('[DONE]'); + }); + test('parallel continuation maps ids arguments and results with namespace', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, tools: [{ name: 'read', input_schema: { type: 'object' } }], messages: [{ role: 'user', content: 'Read both' }, { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_p_a', name: 'read', input: { path: 'a.txt' } }, { type: 'tool_use', id: 'toolu_p_b', name: 'read', input: { path: 'b.txt' } }] }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_p_a', content: 'body-a' }, { type: 'tool_result', tool_use_id: 'toolu_p_b', content: 'body-b' }] }] }); + expect(res.statusCode).toBe(200); + const promptCall = sdkMocks.sessionPrompt.mock.calls.at(-1)[0]; + const flat = (promptCall.body.parts || []).map((p) => String(p.text || '')).join('\n\n'); + expect(flat).toContain('ASSISTANT: '); + expect(flat).toContain('TOOL_RESULT:'); + expect(flat).toContain('external__read'); + const openTag = ''; + const closeTag = ''; + const assistantCalls = JSON.parse(flat.slice(flat.indexOf(openTag) + openTag.length, flat.indexOf(closeTag))); + expect(assistantCalls.map((c) => c.id)).toEqual(['toolu_p_a', 'toolu_p_b']); + expect(assistantCalls.map((c) => c.name)).toEqual(['external__read', 'external__read']); + expect(JSON.parse(assistantCalls[0].arguments)).toEqual({ path: 'a.txt' }); + expect(JSON.parse(assistantCalls[1].arguments)).toEqual({ path: 'b.txt' }); + const chunks = flat.split('TOOL_RESULT:').slice(1); + expect(chunks).toHaveLength(2); + const firstResult = JSON.parse(chunks[0].trim().split('\n\n')[0]); + const secondResult = JSON.parse(chunks[1].trim().split('\n\n')[0]); + expect(firstResult.tool_call_id).toBe('toolu_p_a'); + expect(secondResult.tool_call_id).toBe('toolu_p_b'); + expect(firstResult.name).toBe('external__read'); + expect(secondResult.name).toBe('external__read'); + expect(firstResult.content).toBe('body-a'); + expect(secondResult.content).toBe('body-b'); + expect(firstResult.tool_call_id).toBe(assistantCalls[0].id); + expect(secondResult.tool_call_id).toBe(assistantCalls[1].id); + expect(flat.indexOf('ASSISTANT:')).toBeLessThan(flat.indexOf('TOOL_RESULT:')); + expect(flat.indexOf('toolu_p_a')).toBeLessThan(flat.indexOf('toolu_p_b')); + }); + test('tool_choice any forces tool_use on non-stream when first turn is empty', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'hello no tool' }] }]); + sdkMocks.sessionMessages.mockResolvedValueOnce([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: '[{"id":"toolu_req_1","name":"external__read","arguments":{"path":"a.txt"}}]' }] }]); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }], tool_choice: { type: 'any' } }); + expect(res.statusCode).toBe(200); + expect(res.body.stop_reason).toBe('tool_use'); + const use = (res.body.content || []).find((c) => c.type === 'tool_use'); + expect(use).toBeDefined(); + expect(use.id).toBe('toolu_req_1'); + expect(use.name).toBe('read'); + expect(use.input).toEqual({ path: 'a.txt' }); + expect(JSON.stringify(res.body)).not.toContain('external__'); + expect(JSON.stringify(res.body)).not.toContain(''); + expect(sdkMocks.sessionPrompt).toHaveBeenCalledTimes(2); + }); + test('tool_choice tool forces tool_use on stream when first events lack tools', async () => { + sdkMocks.sessionMessages.mockResolvedValueOnce([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: '[{"id":"toolu_req_s1","name":"external__read","arguments":{"path":"b.txt"}}]' }] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(async () => ({ stream: (async function* () { yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: 'hello' } }; yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; })() })); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, stream: true, messages: [{ role: 'user', content: 'Read b.txt' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }], tool_choice: { type: 'tool', name: 'read' } }); + expect(res.statusCode).toBe(200); + const frames = parseMessagesSseFrames(res.text); + const toolStarts = messagesToolStarts(frames); + expect(toolStarts).toHaveLength(1); + expect(toolStarts[0].data.content_block.id).toBe('toolu_req_s1'); + expect(toolStarts[0].data.content_block.name).toBe('read'); + const toolDeltas = messagesInputDeltas(frames); + expect(toolDeltas).toHaveLength(1); + expect(JSON.parse(toolDeltas[0].data.delta.partial_json)).toEqual({ path: 'b.txt' }); + expect(res.text).not.toContain('external__'); + expect(res.text).not.toContain(''); + expect(frames.find((f) => f.event === 'message_delta').data.delta.stop_reason).toBe('tool_use'); + expect(res.text).not.toContain('[DONE]'); + }); + test('tool_choice required without tool on non-stream returns explicit contract', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'hello no tool' }] }]); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object' } }], tool_choice: { type: 'any' } }); + expect(res.statusCode).toBe(502); + expect(res.body.type).toBe('error'); + expect(res.body.error.code).toBe('external_tool_choice_required'); + expect(String(res.body.error.message)).toContain('required external tool call'); + }); + test('tool_choice required without tool on stream returns explicit contract', async () => { + sdkMocks.sessionMessages.mockResolvedValue([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'hello no tool' }] }]); + sdkMocks.eventSubscribe.mockImplementationOnce(async () => ({ stream: (async function* () { yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: 'hello no tool' } }; yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; })() })); + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, stream: true, messages: [{ role: 'user', content: 'Read a.txt' }], tools: [{ name: 'read', description: 'Read', input_schema: { type: 'object' } }], tool_choice: { type: 'any' } }); + expect(res.statusCode).toBe(200); + const frames = parseMessagesSseFrames(res.text); + expect(frames.some((f) => f.event === 'error')).toBe(true); + const errFrame = frames.find((f) => f.event === 'error'); + expect(errFrame.data.error.code).toBe('external_tool_choice_required'); + expect(errFrame.data.error.type).toBe('api_error'); + expect(messagesToolStarts(frames)).toHaveLength(0); + expect(messagesInputDeltas(frames)).toHaveLength(0); + expect(res.text).not.toContain('[DONE]'); + expect(res.text).not.toContain('external__'); + }); }); diff --git a/tests/parser-foreign-formats.test.js b/tests/parser-foreign-formats.test.js index 10be95c..a586075 100644 --- a/tests/parser-foreign-formats.test.js +++ b/tests/parser-foreign-formats.test.js @@ -2,6 +2,10 @@ import { describe, expect, test } from '@jest/globals'; import { buildExternalToolRegistry } from '../src/tool-runtime/registry.js'; import { parseExternalToolCallsFromText, + parseExternalToolCallsFromJoinedText, + mergeToolCallArtifacts, + assertToolCallArtifactIntegrity, + stripExternalToolCallMarkupFromJoinedText, stripFunctionCallMarkup, createToolCallFilter, createExternalToolCallStreamParser @@ -333,10 +337,9 @@ describe('XML child element arguments', () => { expect(stripFunctionCallMarkup(text, true, { registry: xmlRegistry })).toBe('Let me look:'); }); - test('still yields empty arguments when the body has no recognizable children', () => { + test('does not call a named tag whose body is neither JSON nor declared XML', () => { const text = 'just some prose'; - const { args } = firstCall(parseExternalToolCallsFromText(xmlRegistry, text)); - expect(args).toEqual({}); + expect(parseExternalToolCallsFromText(xmlRegistry, text)).toEqual([]); }); }); @@ -524,6 +527,10 @@ describe('streaming: tool calls are extracted mid-stream', () => { expect(calls).toHaveLength(2); expect(calls[0].id).not.toBe(calls[1].id); }); + + test.each(['', ''])('does not flush an empty call for partial tag %s', (partial) => { + expect(runParser([partial])).toEqual([]); + }); }); describe('cross-channel blocks', () => { @@ -552,6 +559,28 @@ describe('cross-channel blocks', () => { const calls = parseExternalToolCallsFromText(registry, '{"name":"bash","arguments":{"command":"ls"}}'); expect(calls).toHaveLength(1); }); + + test('named tag split between reasoning and content completes in the joined view', () => { + const calls = parseExternalToolCallsFromJoinedText( + registry, + 'before ', + '{"file":"a.txt"} after' + ); + expect(calls).toHaveLength(1); + expect(calls[0].function.name).toBe('read'); + expect(JSON.parse(calls[0].function.arguments)).toEqual({ file: 'a.txt' }); + }); + + test('function-equals tag split between reasoning and content completes in the joined view', () => { + const calls = parseExternalToolCallsFromJoinedText( + registry, + 'before ', + 'a.txt after' + ); + expect(calls).toHaveLength(1); + expect(calls[0].function.name).toBe('read'); + expect(JSON.parse(calls[0].function.arguments)).toEqual({ file: 'a.txt' }); + }); }); describe('rawCallsFromJsonText JSON scan fallback', () => { @@ -605,4 +634,417 @@ describe('/ markup (Qwen/GLM native dialect)', () expect(stripped).not.toContain(''); expect(stripped).toContain('Let me run a command'); }); + + test('joined parsing keeps a cross-channel call and per-channel calls', () => { + const reasoning = '{"name":"bash","arguments":{"command":"ls"'; + const content = '}}'; + const calls = parseExternalToolCallsFromJoinedText(registry, reasoning, content); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0].function.arguments)).toEqual({ command: 'ls' }); + }); + + test('joining does not double-count a complete channel call', () => { + const content = '{"name":"bash","arguments":{"command":"ls"}}'; + const calls = parseExternalToolCallsFromJoinedText(registry, '', content); + expect(calls).toHaveLength(1); + }); + + test('merging retains different explicit ids and deduplicates generated artifacts', () => { + const first = parseExternalToolCallsFromText(registry, '{"id":"call_a","name":"bash","arguments":{"command":"ls"}}'); + const second = parseExternalToolCallsFromText(registry, '{"id":"call_b","name":"bash","arguments":{"command":"ls"}}'); + const generated = parseExternalToolCallsFromText(registry, '{"name":"bash","arguments":{"command":"pwd"}}'); + const merged = mergeToolCallArtifacts(first, second, generated, generated); + expect(merged.map((call) => call.id)).toEqual(['call_a', 'call_b', expect.any(String)]); + expect(new Set(merged.map((call) => call.id)).size).toBe(3); + }); + + test('collect and merge retain same-call explicit ids and same-id argument conflicts', () => { + const calls = parseExternalToolCallsFromText( + registry, + '[{"id":"call_a","name":"read","arguments":{"file":"a.txt"}},{"id":"call_b","name":"read","arguments":{"file":"a.txt"}},{"id":"call_a","name":"read","arguments":{"file":"b.txt"}}]' + ); + const merged = mergeToolCallArtifacts(calls); + expect(merged.map((call) => call.id)).toEqual(['call_a', 'call_b', 'call_a']); + expect(merged.map((call) => JSON.parse(call.function.arguments))).toEqual([ + { file: 'a.txt' }, + { file: 'a.txt' }, + { file: 'b.txt' } + ]); + expect(() => assertToolCallArtifactIntegrity(merged, registry, '')).toThrow(/duplicate external tool call id/); + }); + + test('does not deduplicate a generated artifact against an explicit call', () => { + const explicit = parseExternalToolCallsFromText(registry, '{"id":"call_explicit","name":"read","arguments":{"file":"a.txt"}}'); + const generated = parseExternalToolCallsFromText(registry, '{"name":"read","arguments":{"file":"a.txt"}}'); + expect(mergeToolCallArtifacts(explicit, generated)).toHaveLength(2); + }); + + test('deduplicates explicit ids with canonical argument key order', () => { + const first = parseExternalToolCallsFromText(registry, '{"id":"same","name":"read","arguments":{"file":"a.txt","offset":1}}'); + const second = parseExternalToolCallsFromText(registry, '{"id":"same","name":"read","arguments":{"offset":1,"file":"a.txt"}}'); + const merged = mergeToolCallArtifacts(first, second); + expect(merged).toHaveLength(1); + expect(() => assertToolCallArtifactIntegrity(merged, registry, '')).not.toThrow(); + }); + + test('generated artifacts with different arguments receive unique merged ids', () => { + const first = parseExternalToolCallsFromText(registry, '{"name":"read","arguments":{"file":"a.txt"}}'); + const second = parseExternalToolCallsFromText(registry, '{"name":"read","arguments":{"file":"b.txt"}}'); + const merged = mergeToolCallArtifacts(first, second); + expect(merged).toHaveLength(2); + expect(new Set(merged.map((call) => call.id)).size).toBe(2); + }); + + test('retains different explicit ids with the same arguments', () => { + const calls = parseExternalToolCallsFromText( + registry, + '[{"id":"call_a","name":"read","arguments":{"file":"a.txt"}},{"id":"call_b","name":"read","arguments":{"file":"a.txt"}}]' + ); + const merged = mergeToolCallArtifacts(calls); + expect(merged.map((call) => call.id)).toEqual(['call_a', 'call_b']); + }); + + test('retains generated parallel calls with identical arguments', () => { + const calls = parseExternalToolCallsFromText( + registry, + '[{"name":"read","arguments":{"file":"a.txt"}},{"name":"read","arguments":{"file":"a.txt"}}]' + ); + expect(calls).toHaveLength(2); + const merged = mergeToolCallArtifacts(calls); + expect(merged).toHaveLength(2); + expect(new Set(merged.map((call) => call.id)).size).toBe(2); + }); + + test('deduplicates a repeated generated artifact to a single call', () => { + const single = parseExternalToolCallsFromText(registry, '{"name":"read","arguments":{"file":"a.txt"}}'); + const merged = mergeToolCallArtifacts(single, single); + expect(merged).toHaveLength(1); + }); +}); + +describe('malformed envelope gate', () => { + const gate = (text) => { + try { + assertToolCallArtifactIntegrity( + parseExternalToolCallsFromJoinedText(registry, '', text), + registry, + text + ); + return null; + } catch (error) { + return error.code; + } + }; + + test.each([ + ['an unclosed DSML container', '{"name":"bash","arguments":{"command":"ls"}}'], + [ + 'an unclosed DSML marker container', + '<||DSML||tool_calls><||DSML||invoke name="bash"><||DSML||parameter name="command" string="true">ls' + ], + ['an unclosed wrapper', '{"name":"bash","arguments":{"command":"ls"}}'], + [ + 'a bare array with an invalid member', + '[{"name":"bash","arguments":{"command":"ls"}},{"arguments":{"command":"pwd"}}]' + ], + [ + 'a bare array with an unknown member', + '[{"name":"bash","arguments":{"command":"ls"}},{"name":"missing","arguments":{}}]' + ], + ['a bare array with a trailing member', '[{"name":"bash","arguments":{"command":"ls"}},"junk"]'], + [ + 'a valid canonical block followed by an unclosed DSML container', + '{"name":"bash","arguments":{"command":"ls"}}{"name":"bash"' + ], + [ + 'a valid DSML container followed by an unclosed wrapper', + 'ls{"name":"bash"' + ], + [ + 'a valid bare call followed by a bare array with a bad member', + '{"name":"bash","arguments":{"command":"ls"}}\n[{"name":"bash","arguments":{"command":"pwd"}},7]' + ] + ])('rejects %s', (_label, text) => { + expect(gate(text)).toBe('malformed_external_tool_call'); + }); + + test('rejects a nested canonical exception followed by another unclosed block', () => { + const text = '{"name":"bash","arguments":{"command":"ls"}}{"name":"bash"'; + expect(gate(text)).toBe('malformed_external_tool_call'); + }); + + test.each([ + ['', ''], + ['<\u200btool_call>', '<\u200btool_call>'] + ])('does not treat %s inside a JSON string as an envelope', (_label, marker) => { + const text = `{"name":"external__bash","arguments":{"command":"printf '%s'"}}`; + const marked = text.replace('printf', `printf ${marker}`); + const calls = parseExternalToolCallsFromText(registry, marked); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0].function.arguments).command).toContain(marker); + expect(gate(marked)).toBeNull(); + }); + + test('accepts a complete zero-argument function dialect call', () => { + const pingRegistry = buildExternalToolRegistry([{ + type: 'function', + function: { name: 'ping', parameters: { type: 'object', properties: {}, required: [] } } + }]); + const text = ''; + const calls = parseExternalToolCallsFromText(pingRegistry, text); + expect(calls).toHaveLength(1); + expect(calls[0].function.name).toBe('ping'); + expect(JSON.parse(calls[0].function.arguments)).toEqual({}); + expect(() => assertToolCallArtifactIntegrity(calls, pingRegistry, text)).not.toThrow(); + }); + + test.each([ + ['an ordinary JSON object', '{"ordinaryLeading":true}'], + ['an ordinary array', '[1,2,3]'], + ['a named JSON body outside the registry', '{"name":"totally_other","arguments":{}}'], + ['a valid canonical block', '{"name":"bash","arguments":{"command":"ls"}}'], + ['an explicit nested canonical wrapper', '\n\n{"name":"bash","arguments":{"command":"ls"}}\n'], + ['a prefixed canonical payload', 'calling tool: {"name":"bash","arguments":{"command":"pwd"}}'], + ['a valid bare call', '{"name":"bash","arguments":{"command":"ls"}}'], + ['a valid bare array', '[{"name":"bash","arguments":{"command":"ls"}},{"name":"read","arguments":{"file":"a.txt"}}]'], + [ + 'a valid DSML container', + '<||DSML||tool_calls><||DSML||invoke name="bash"><||DSML||parameter name="command" string="true">ls' + ], + ['prose', 'just some prose about running commands'] + ])('accepts %s', (_label, text) => { + expect(gate(text)).toBeNull(); + }); + + test.each([ + [ + 'canonical JSON with a conflicting payload', + '[{"id":"same","name":"bash","arguments":{"command":"ls"}},{"id":"same","name":"bash","arguments":{"command":"pwd"}}]' + ], + [ + 'canonical JSON with an unknown trailing payload', + '{"name":"bash","arguments":{"command":"ls"}}{"name":"missing","arguments":{}}' + ], + [ + 'canonical JSON with malformed trailing text', + '{"name":"bash","arguments":{"command":"ls"}}{"name":"bash" arguments' + ], + [ + 'DSML with a later unclosed invoke', + 'lsa.txt' + ], + [ + 'DSML with an unclosed parameter', + 'ls' + ], + [ + 'tool wrapper JSON with an unknown trailing payload', + '{"name":"bash","arguments":{"command":"ls"}}{"name":"missing","arguments":{}}' + ], + [ + 'tool wrapper JSON with malformed trailing text', + '{"name":"bash","arguments":{"command":"ls"}} trailing' + ], + [ + 'tool wrapper function call with trailing text', + 'ls trailing' + ], + [ + 'tool wrapper tag call with trailing text', + '{"command":"ls"} trailing' + ], + [ + 'bare array without its closing bracket', + '[{"name":"bash","arguments":{"command":"ls"}}' + ], + [ + 'bare array followed by a second payload', + '[{"name":"bash","arguments":{"command":"ls"}}] {"name":"bash","arguments":{"command":"pwd"}}' + ], + [ + 'bare array with an unknown member', + '[{"name":"bash","arguments":{"command":"ls"}},{"name":"missing","arguments":{}}]' + ], + [ + 'a valid canonical block followed by a second bare payload', + '{"name":"bash","arguments":{"command":"ls"}}{"name":"bash","arguments":{"command":"pwd"}}' + ], + [ + 'a valid DSML container followed by a second bare payload', + 'ls{"name":"bash","arguments":{"command":"pwd"}}' + ], + [ + 'a valid wrapper followed by a second bare payload', + '{"name":"bash","arguments":{"command":"ls"}}{"name":"bash","arguments":{"command":"pwd"}}' + ], + [ + 'a bare payload truncated behind a fuzzy tool alias', + '{"name":"externalbash","arguments":{"command":"ls"' + ], + [ + 'a bare array truncated behind a fuzzy tool alias', + '[{"name":"externalbash","arguments":{"command":' + ] + ])('rejects %s', (_label, text) => { + expect(gate(text)).not.toBeNull(); + }); + + test('does not emit or hide a non-JSON wrapper call with trailing content', () => { + const text = 'a.txt trailing'; + expect(parseExternalToolCallsFromText(registry, text)).toEqual([]); + expect(stripFunctionCallMarkup(text, false, { registry })).toContain('trailing'); + }); + + test('never parses a second bare payload after a canonical block', () => { + const text = '{"name":"bash","arguments":{"command":"ls"}}{"name":"bash","arguments":{"command":"pwd"}}'; + expect(gate(text)).toBe('malformed_external_tool_call'); + expect(parseExternalToolCallsFromText(registry, text)).toHaveLength(1); + }); + + test('resolves a fuzzy tool alias in a bare payload through the registry', () => { + const calls = parseExternalToolCallsFromText(registry, '{"name":"externalbash","arguments":{"command":"ls"}}'); + expect(calls).toHaveLength(1); + expect(calls[0].function.name).toBe('bash'); + expect(gate('{"name":"externalbash","arguments":{"command":"ls"}}')).toBeNull(); + }); + + test('does not leak an unclosed naked invoke', () => { + const text = 'ls'; + expect(parseExternalToolCallsFromText(registry, text)).toEqual([]); + expect(stripFunctionCallMarkup(text)).toBe(''); + expect(gate(text)).toBe('malformed_external_tool_call'); + }); +}); + +describe('joined bare tool-call sanitizer', () => { + const bare = '{"name":"bash","arguments":{"command":"ls"}}'; + + test('removes a bare call from the content channel and keeps reasoning text', () => { + expect(stripExternalToolCallMarkupFromJoinedText(registry, 'let me check that', bare)).toEqual({ + reasoning: 'let me check that', + content: '' + }); + }); + + test('removes a bare call from the reasoning channel and keeps content text', () => { + expect(stripExternalToolCallMarkupFromJoinedText(registry, bare, 'here is the answer')).toEqual({ + reasoning: '', + content: 'here is the answer' + }); + }); + + test('removes a bare call from the content channel with a trailing space', () => { + expect(stripExternalToolCallMarkupFromJoinedText(registry, 'thinking', ` ${bare} `, true)).toEqual({ + reasoning: 'thinking', + content: '' + }); + }); + + test('removes a bare call that is the only content of both channels', () => { + expect(stripExternalToolCallMarkupFromJoinedText(registry, bare, 'plain tail')).toEqual({ + reasoning: '', + content: 'plain tail' + }); + }); + + test.each([ + ['an ordinary JSON object', '{"ordinaryLeading":true}'], + ['a non-tool array', '[1,2,3]'], + ['a named JSON body outside the registry', '{"name":"totally_other","arguments":{}}'] + ])('keeps %s in the content channel', (_label, text) => { + expect(stripExternalToolCallMarkupFromJoinedText(registry, 'thinking', text)).toEqual({ + reasoning: 'thinking', + content: text + }); + }); + + test('keeps an ordinary JSON body in the reasoning channel', () => { + expect(stripExternalToolCallMarkupFromJoinedText(registry, '{"ordinaryLeading":true}', 'answer')).toEqual({ + reasoning: '{"ordinaryLeading":true}', + content: 'answer' + }); + }); + + test('still strips a bare call from the joined single-channel view', () => { + expect(stripExternalToolCallMarkupFromJoinedText(registry, '', bare, true)).toEqual({ + reasoning: '', + content: '' + }); + }); +}); + +describe('bare payload source views', () => { + const bare = '{"name":"bash","arguments":{"command":"ls"}}'; + const gateChannels = (channels) => { + try { + assertToolCallArtifactIntegrity( + parseExternalToolCallsFromJoinedText(registry, channels[0] ?? '', channels[1] ?? ''), + registry, + channels + ); + return null; + } catch (error) { + return error.code; + } + }; + const gateGroups = (groups) => { + try { + assertToolCallArtifactIntegrity( + mergeToolCallArtifacts(...groups.map((group) => parseExternalToolCallsFromJoinedText(registry, group[0] ?? '', group[1] ?? ''))), + registry, + groups + ); + return null; + } catch (error) { + return error.code; + } + }; + + test('accepts a bare payload split across the reasoning and content channels', () => { + const reasoning = '{"name":"bash","arguments":{"command":'; + const content = '"ls"}}'; + const calls = parseExternalToolCallsFromJoinedText(registry, reasoning, content); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0].function.arguments)).toEqual({ command: 'ls' }); + expect(gateChannels([reasoning, content])).toBeNull(); + expect(stripExternalToolCallMarkupFromJoinedText(registry, reasoning, content, true)).toEqual({ + reasoning: '', + content: '' + }); + }); + + test('rejects a split bare payload that never closes in the joined view', () => { + expect(gateChannels(['{"name":"bash","arguments":{"command":', '"ls"'])).toBe('malformed_external_tool_call'); + }); + + test('keeps ordinary JSON and arrays in either channel', () => { + expect(gateChannels(['{"ordinaryLeading":true}', '[1,2,3]'])).toBeNull(); + expect(stripExternalToolCallMarkupFromJoinedText(registry, '{"ordinaryLeading":true}', '[1,2,3]', true)).toEqual({ + reasoning: '{"ordinaryLeading":true}', + content: '[1,2,3]' + }); + }); + + test('keeps a bare payload that owns one channel while the other carries prose', () => { + expect(gateChannels([bare, 'the result is 42'])).toBeNull(); + expect(stripExternalToolCallMarkupFromJoinedText(registry, bare, 'the result is 42', true)).toEqual({ + reasoning: '', + content: 'the result is 42' + }); + }); + + test('separates the joined view from the per-channel view', () => { + const opener = '{"name":"bash","arguments":{"command":"ls"}}'; + const closer = ''; + expect(gateChannels([opener, closer])).toBeNull(); + expect(gateGroups([[opener], [closer]])).toBe('malformed_external_tool_call'); + }); + + test('checks each grouped document on its own joined view', () => { + expect(gateGroups([[bare], ['answer text']])).toBeNull(); + expect(gateGroups([['{"ordinaryLeading":true}'], [bare]])).toBeNull(); + expect( + gateGroups([['{"name":"bash","arguments":{"command":"ls"}}{"name":"bash","arguments":{"command":"pwd"}}']]) + ).toBe('malformed_external_tool_call'); + }); }); diff --git a/tests/proxy-fallback.test.js b/tests/proxy-fallback.test.js index 0ebfe77..7ee8953 100644 --- a/tests/proxy-fallback.test.js +++ b/tests/proxy-fallback.test.js @@ -193,7 +193,7 @@ const sdkMocks = { data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' } } }] }, })), configUpdate: jest.fn(async () => ({})), - toolIds: jest.fn(async () => ({ data: [] })), + toolIds: jest.fn(async () => ({ data: ['web_fetch', 'filesystem', 'bash'] })), sessionCreate: jest.fn(async () => ({ data: { id: 'fb-session' } })), sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })), sessionMessages: jest.fn(async () => ([ @@ -236,10 +236,19 @@ jest.unstable_mockModule('https', () => ({ const { createApp } = await import('../src/proxy.js'); describe('route fallback integration', () => { - beforeEach(() => { - jest.clearAllMocks(); - sdkMocks.sessionPrompt.mockImplementation(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })); - }); + beforeEach(() => { + jest.clearAllMocks(); + sdkMocks.sessionCreate.mockReset(); + sdkMocks.sessionCreate.mockImplementation(async () => ({ data: { id: 'fb-session' } })); + sdkMocks.sessionPrompt.mockReset(); + sdkMocks.sessionPrompt.mockImplementation(async () => ({ data: { parts: [{ type: 'text', text: 'ok' }] } })); + sdkMocks.sessionMessages.mockReset(); + sdkMocks.sessionMessages.mockImplementation(async () => ([ + { info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'recovered' }] } + ])); + sdkMocks.eventSubscribe.mockReset(); + sdkMocks.eventSubscribe.mockImplementation(async () => ({ stream: (async function* () {})() })); + }); test('messages non-stream retries via proxy after free-limit and reports engagement', async () => { sdkMocks.sessionPrompt.mockRejectedValueOnce(freeUsageError()); diff --git a/tests/responses-websearch.test.js b/tests/responses-websearch.test.js index fe17e9e..4483ddf 100644 --- a/tests/responses-websearch.test.js +++ b/tests/responses-websearch.test.js @@ -6,7 +6,6 @@ import { 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/ .'; @@ -94,14 +93,14 @@ function baseConfig(overrides = {}) { } describe('grounding pure helpers', () => { - test('detect/strip hosted search tools', () => { + test('detect 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' } }]; - expect(stripHostedSearchTools(mixed)).toHaveLength(1); + expect(detectHostedSearchTools(mixed).kinds).toEqual(['web_search']); }); test('extractSearchEvidence only trusts completed websearch parts', () => { @@ -193,6 +192,7 @@ describe('POST /v1/responses web_search grounding', () => { model: 'opencode/kimi-k2.5', input: 'What is the latest PostgreSQL release?', tools: [{ type: 'web_search' }], + tool_choice: 'required', stream: true, }); expect(res.statusCode).toBe(200); @@ -205,4 +205,17 @@ describe('POST /v1/responses web_search grounding', () => { const message = completed.response.output.find((o) => o.type === 'message'); expect(message.content[0].annotations.length).toBeGreaterThan(0); }); + + test('internal-only required choice does not require an external call', 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: 'Use an internal tool if needed.', + tool_choice: 'required', + stream: true, + }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('response.completed'); + expect(res.text).not.toContain('response.failed'); + }); }); diff --git a/tests/retry-policy.test.js b/tests/retry-policy.test.js index 055e980..b88aaf0 100644 --- a/tests/retry-policy.test.js +++ b/tests/retry-policy.test.js @@ -8,6 +8,7 @@ import { MAX_DELAY_NO_HEADERS_MS, MAX_HEADER_DELAY_MS } from '../src/retry/policy.js'; +import { buildProxyConfig } from '../src/config/proxy-config.js'; describe('resolveMaxRetries', () => { test('defaults to 3 for missing/invalid values', () => { @@ -79,3 +80,46 @@ describe('computeRetryDelay', () => { expect(computeRetryDelay(1, { responseHeaders: { 'retry-after': '700' } }, 0)).toBe(MAX_HEADER_DELAY_MS); }); }); + +describe('retry budget merge (buildProxyConfig)', () => { + const CANONICAL = 'OPENCODE_PROXY_RETRY_MAX_RETRIES'; + const LEGACY = 'RETRY_MAX_RETRIES'; + let savedCanonical; + let savedLegacy; + + beforeEach(() => { + savedCanonical = process.env[CANONICAL]; + savedLegacy = process.env[LEGACY]; + delete process.env[CANONICAL]; + delete process.env[LEGACY]; + }); + + afterEach(() => { + if (savedCanonical === undefined) delete process.env[CANONICAL]; + else process.env[CANONICAL] = savedCanonical; + if (savedLegacy === undefined) delete process.env[LEGACY]; + else process.env[LEGACY] = savedLegacy; + }); + + test('buildProxyConfig keeps RETRY_MAX_RETRIES and it feeds resolveMaxRetries', () => { + expect(buildProxyConfig().RETRY_MAX_RETRIES).toBe(DEFAULT_MAX_RETRIES); + process.env[LEGACY] = '1'; + expect(resolveMaxRetries(buildProxyConfig().RETRY_MAX_RETRIES)).toBe(1); + process.env[CANONICAL] = '5'; + expect(resolveMaxRetries(buildProxyConfig().RETRY_MAX_RETRIES)).toBe(5); + process.env[CANONICAL] = '99'; + expect(resolveMaxRetries(buildProxyConfig().RETRY_MAX_RETRIES)).toBe(ABSOLUTE_MAX_RETRIES); + }); + + // The merge rejects partial numerics ("3abc"), which parseInt would accept. + test('partial numerics never reach the effective retry budget', () => { + for (const bad of ['3abc', '2.9', 'garbage', '', ' ', '1e1']) { + process.env[CANONICAL] = bad; + process.env[LEGACY] = '2'; + expect(resolveMaxRetries(buildProxyConfig().RETRY_MAX_RETRIES)).toBe(2); + } + process.env[CANONICAL] = '3abc'; + delete process.env[LEGACY]; + expect(resolveMaxRetries(buildProxyConfig().RETRY_MAX_RETRIES)).toBe(DEFAULT_MAX_RETRIES); + }); +}); diff --git a/tests/stream-hardening.test.js b/tests/stream-hardening.test.js index 6b1ef76..c39cb1b 100644 --- a/tests/stream-hardening.test.js +++ b/tests/stream-hardening.test.js @@ -10,7 +10,7 @@ const sdkMocks = { data: { providers: [{ id: 'opencode', models: { 'big-pickle': { name: 'Big Pickle' } } }] } })), configUpdate: jest.fn(async () => ({})), - toolIds: jest.fn(async () => ({ data: [] })), + toolIds: jest.fn(async () => ({ data: ['web_fetch', 'filesystem', 'bash'] })), sessionCreate: jest.fn(async () => ({ data: { id: SESSION_ID } })), sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'Mock response' }] } })), sessionMessages: jest.fn(async () => ([ @@ -55,6 +55,7 @@ jest.unstable_mockModule('@opencode-ai/sdk', () => ({ })); const { createApp, withTimeout } = await import('../src/proxy.js'); +const { createCollector } = await import('../src/stream/collector.js'); describe('withTimeout', () => { test('resolves with the value on success', async () => { @@ -172,3 +173,299 @@ describe('POST /v1/messages stream does not cancel itself', () => { expect(res.text).toContain('message_stop'); }, 20000); }); + +describe('collector terminal state', () => { + const run = (events, firstDeltaTimeoutMs = null) => { + const client = { + event: { + subscribe: async () => ({ + stream: (async function* () { + for (const event of events) { + if (typeof event.delay === 'number') { + await new Promise((resolve) => setTimeout(resolve, event.delay)); + } + yield event; + } + })() + }) + } + }; + return createCollector({ client, logDebug: () => {} }) + .collectFromEvents('collector-session', 1000, null, firstDeltaTimeoutMs); + }; + + test('gives finish plus error priority over completion', async () => { + const result = await run([ + { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'collector-session' }, delta: 'partial' } }, + { type: 'message.updated', properties: { info: { sessionID: 'collector-session', finish: 'stop', error: { name: 'MessageAbortedError' } } } } + ]); + expect(result.error).toMatchObject({ name: 'MessageAbortedError' }); + expect(result.noData).toBeUndefined(); + }); + + test('marks a completed empty message as noData', async () => { + const result = await run([ + { type: 'message.updated', properties: { info: { sessionID: 'collector-session', finish: 'stop' } } } + ]); + expect(result).toMatchObject({ content: '', reasoning: '', noData: true }); + }); + + test.each([['tool'], ['tool-calls']])( + 'keeps the stream open when a turn finishes with %s', + async (finish) => { + const result = await run([ + { type: 'message.updated', properties: { info: { sessionID: 'collector-session', finish } } }, + { + delay: 150, + type: 'message.part.updated', + properties: { part: { type: 'text', sessionID: 'collector-session' }, delta: 'after the tool' } + }, + { type: 'message.updated', properties: { info: { sessionID: 'collector-session', finish: 'stop' } } } + ], 60); + expect(result).toMatchObject({ content: 'after the tool' }); + expect(result.noData).toBeUndefined(); + } + ); +}); + +describe('collector ID-less tool tracking', () => { + const run = (events, firstDeltaTimeoutMs = null) => { + const client = { + event: { + subscribe: async () => ({ + stream: (async function* () { + for (const event of events) { + if (typeof event.delay === 'number') { + await new Promise((resolve) => setTimeout(resolve, event.delay)); + } + yield event; + } + })() + }) + } + }; + return createCollector({ client, logDebug: () => {} }) + .collectFromEvents('collector-session', 1000, null, firstDeltaTimeoutMs); + }; + const noidPart = (tool, status) => ({ + type: 'message.part.updated', + properties: { + part: tool == null + ? { type: 'tool', sessionID: 'collector-session', state: { status } } + : { type: 'tool', tool, sessionID: 'collector-session', state: { status } }, + }, + }); + const textDelta = (delta) => ({ + type: 'message.part.updated', + properties: { part: { type: 'text', sessionID: 'collector-session' }, delta }, + }); + const stop = () => ({ + type: 'message.updated', + properties: { info: { sessionID: 'collector-session', finish: 'stop' } }, + }); + + test('repeat pending snapshots of one ID-less tool resolve on its terminal', async () => { + const result = await run([ + noidPart('web_fetch', 'pending'), + noidPart('web_fetch', 'pending'), + noidPart('web_fetch', 'pending'), + noidPart('web_fetch', 'completed'), + textDelta('done'), + stop(), + ]); + expect(result).toMatchObject({ content: 'done' }); + expect(result.noData).toBeUndefined(); + }); + + test('distinct ID-less tools stay active until each completes', async () => { + const result = await run([ + noidPart('web_fetch', 'pending'), + noidPart('bash', 'pending'), + noidPart('web_fetch', 'completed'), + // Intermediate stop while bash is still running must be ignored. + stop(), + textDelta('final'), + noidPart('bash', 'completed'), + stop(), + ]); + expect(result).toMatchObject({ content: 'final' }); + expect(result.noData).toBeUndefined(); + }); + + test('replayed terminal snapshot does not clear a still-running tool', async () => { + const result = await run([ + noidPart('web_fetch', 'pending'), + noidPart('bash', 'pending'), + noidPart('web_fetch', 'completed'), + // Snapshot replay re-delivers the settled web_fetch terminal alongside + // bash. Pending-first order matters: the replayed terminal is processed + // last, so a naive drop-one-bucket fallback deletes bash's bucket. + { + type: 'message.updated', + properties: { + info: { + sessionID: 'collector-session', + parts: [ + { type: 'tool', tool: 'bash', state: { status: 'pending' } }, + { type: 'tool', tool: 'web_fetch', state: { status: 'completed' } }, + ], + }, + }, + }, + // Intermediate stop while bash is still running must be ignored. + stop(), + textDelta('final'), + noidPart('bash', 'completed'), + stop(), + ]); + expect(result).toMatchObject({ content: 'final' }); + expect(result.noData).toBeUndefined(); + }); + + test('single-bucket mismatch clears without hanging (named pending, unnamed terminal)', async () => { + const result = await run([ + noidPart('web_fetch', 'pending'), + noidPart(undefined, 'completed'), + textDelta('done'), + stop(), + ]); + expect(result).toMatchObject({ content: 'done' }); + expect(result.noData).toBeUndefined(); + }); + + test('single-bucket mismatch clears without hanging (unnamed pending, named terminal)', async () => { + const result = await run([ + noidPart(undefined, 'pending'), + noidPart('web_fetch', 'completed'), + textDelta('done'), + stop(), + ]); + expect(result).toMatchObject({ content: 'done' }); + expect(result.noData).toBeUndefined(); + }); + + test('same-name concurrent ID-less tools share one bucket (documented limitation)', async () => { + const result = await run([ + noidPart('web_fetch', 'pending'), + noidPart('web_fetch', 'pending'), + noidPart('web_fetch', 'completed'), + textDelta('done'), + stop(), + ]); + expect(result).toMatchObject({ content: 'done' }); + expect(result.noData).toBeUndefined(); + }); +}); + +describe('collector event error and tool deltas', () => { + const run = (events, signal) => { + const client = { + event: { + subscribe: async () => ({ + stream: (async function* () { + for (const event of events) yield event; + })() + }) + } + }; + return createCollector({ client, logDebug: () => {} }) + .collectFromEvents('collector-session', 1000, null, null, null, signal); + }; + + test('does not emit a tool part delta as assistant content', async () => { + const result = await run([ + { type: 'message.part.updated', properties: { part: { id: 'tool-1', type: 'tool', sessionID: 'collector-session', state: { status: 'completed' } }, delta: 'LEAK' } }, + { type: 'message.updated', properties: { info: { sessionID: 'collector-session', finish: 'stop' } } } + ]); + expect(result).toMatchObject({ content: '', reasoning: '', noData: true }); + expect(JSON.stringify(result)).not.toContain('LEAK'); + }); + + test('returns session.error as an error result', async () => { + const result = await run([ + { type: 'session.error', properties: { sessionID: 'collector-session', error: { name: 'SessionBoom', data: { message: 'session failed' } } } } + ]); + expect(result.error).toMatchObject({ name: 'SessionBoom' }); + expect(result.noData).toBeUndefined(); + }); + + test('cancels an active event wait through the supplied signal', async () => { + const controller = new AbortController(); + const pending = new Promise(() => {}); + const client = { + event: { + subscribe: async () => ({ + stream: (async function* () { + await pending; + })() + }) + } + }; + const resultPromise = createCollector({ client, logDebug: () => {} }) + .collectFromEvents('collector-session', 5000, null, null, null, controller.signal); + controller.abort(); + await expect(resultPromise).resolves.toMatchObject({ cancelled: true }); + }); +}); + +describe('collector polling finish classification', () => { + const run = (snapshots, timeoutMs = 1000) => { + let call = 0; + const client = { + session: { + messages: async () => { + const next = snapshots[Math.min(call, snapshots.length - 1)]; + call += 1; + return next; + } + } + }; + return createCollector({ client, logDebug: () => {} }).pollForAssistantResponse('collector-session', timeoutMs, 1); + }; + const assistant = (info, text) => [{ info: { role: 'assistant', ...info }, parts: [{ type: 'text', text }] }]; + + test.each([['tool'], ['tool-calls']])( + 'does not finish on time.completed while a turn awaits %s', + async (finish) => { + const result = await run([ + assistant({ finish, time: { completed: 1 } }, 'Calling a tool.'), + assistant({ finish: 'stop', time: { completed: 2 } }, 'Done: 42.') + ]); + expect(result.content).toBe('Done: 42.'); + } + ); + + test('still finishes on time.completed for a terminal turn', async () => { + const result = await run([assistant({ time: { completed: 1 } }, 'Answer without a finish reason.')]); + expect(result.content).toBe('Answer without a finish reason.'); + }); + + test.each(['pending', 'running'])('does not finish while a tool part is %s', async (status) => { + let call = 0; + const client = { + session: { + messages: async () => { + call += 1; + const finish = call === 1 ? 'stop' : 'stop'; + return [{ + info: { role: 'assistant', finish, time: { completed: call } }, + parts: [ + { type: 'text', text: call === 1 ? 'working' : 'done' }, + { type: 'tool', id: 'tool-1', state: { status: call === 1 ? status : 'completed' } } + ] + }]; + } + } + }; + const result = await createCollector({ client, logDebug: () => {} }).pollForAssistantResponse('collector-session', 1000, 1); + expect(result.content).toBe('done'); + expect(call).toBeGreaterThan(1); + }); + + test('bounds a hanging session.messages request', async () => { + const client = { session: { messages: () => new Promise(() => {}) } }; + await expect( + createCollector({ client, logDebug: () => {} }).pollForAssistantResponse('collector-session', 40, 1) + ).rejects.toThrow('Request timeout after'); + }); +}); diff --git a/tests/tool-alias.test.js b/tests/tool-alias.test.js index 2698d86..cb4bd27 100644 --- a/tests/tool-alias.test.js +++ b/tests/tool-alias.test.js @@ -125,3 +125,225 @@ describe('internal allowlist alias matching', () => { expect(promptCall.body.tools).toEqual({ web_fetch: false, filesystem: true, bash: false }); }); }); + +describe('tool discovery unavailable fails closed', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMocks.toolIds.mockReset(); + sdkMocks.toolIds.mockResolvedValue({ data: ['web_fetch', 'filesystem', 'bash'] }); + }); + + test('disabled path rejects on ids rejection with 503 and cleans session', async () => { + sdkMocks.toolIds.mockRejectedValueOnce(new Error('backend down')); + const app = createApp(baseConfig({})).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(503); + expect(JSON.stringify(res.body)).toContain('tool_discovery_unavailable'); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); + expect(sdkMocks.sessionPrompt).not.toHaveBeenCalled(); + }); + + test('disabled path rejects on error tuple with 503', async () => { + sdkMocks.toolIds.mockResolvedValueOnce({ data: null, error: { message: 'boom' } }); + const app = createApp(baseConfig({})).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(503); + expect(JSON.stringify(res.body)).toContain('tool_discovery_unavailable'); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); + }); + + test('disabled path rejects on malformed payload with 503', async () => { + sdkMocks.toolIds.mockResolvedValueOnce({ data: { unexpected: true } }); + const app = createApp(baseConfig({})).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(503); + expect(JSON.stringify(res.body)).toContain('tool_discovery_unavailable'); + }); + + test('disabled path rejects on empty array with 503', async () => { + sdkMocks.toolIds.mockResolvedValueOnce({ data: [] }); + const app = createApp(baseConfig({})).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(503); + expect(JSON.stringify(res.body)).toContain('tool_discovery_unavailable'); + }); + + test('external bridge path rejects on empty ids with 503', async () => { + sdkMocks.toolIds.mockResolvedValueOnce({ data: [] }); + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1/chat/completions') + .send({ + model: 'opencode/kimi-k2.5', + messages: [{ role: 'user', content: 'hi' }], + tools: [{ type: 'function', function: { name: 'lookup' } }], + }); + expect(res.statusCode).toBe(503); + expect(JSON.stringify(res.body)).toContain('tool_discovery_unavailable'); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); + }); + + test('internal allowlist path rejects on rejection with 503', async () => { + sdkMocks.toolIds.mockRejectedValueOnce(new Error('down')); + 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(503); + expect(JSON.stringify(res.body)).toContain('tool_discovery_unavailable'); + }); + + test('messages disabled path rejects with anthropic shape and cleans session', async () => { + sdkMocks.toolIds.mockResolvedValueOnce({ data: [] }); + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1/messages') + .send({ model: 'opencode/kimi-k2.5', max_tokens: 50, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(503); + expect(res.body.type).toBe('error'); + expect(res.body.error.type).toBe('tool_discovery_unavailable'); + expect(sdkMocks.sessionDelete).toHaveBeenCalled(); + }); + + test('responses disabled path rejects with 503', async () => { + sdkMocks.toolIds.mockResolvedValueOnce({ data: [] }); + const app = createApp(baseConfig({})).app; + const res = await request(app).post('/v1/responses') + .send({ model: 'opencode/kimi-k2.5', input: 'hi' }); + expect(res.statusCode).toBe(503); + expect(JSON.stringify(res.body)).toContain('tool_discovery_unavailable'); + }); + + test('operator allow continues on discovery failure and omits observably', async () => { + sdkMocks.toolIds.mockRejectedValueOnce(new Error('down')); + const app = createApp(baseConfig({ DISABLE_TOOLS: false })).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).toBeUndefined(); + const details = await request(app).get('/health/details'); + expect(details.statusCode).toBe(200); + expect(details.body.internal_tools.tool_discovery.override_omitted_total).toBeGreaterThanOrEqual(1); + }); + + test('empty ids never poison cache', async () => { + sdkMocks.toolIds.mockResolvedValueOnce({ data: [] }); + const app = createApp(baseConfig({})).app; + const first = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(first.statusCode).toBe(503); + const details = await request(app).get('/health/details'); + expect(details.body.internal_tools.cache.tool_ids_cached).toBe(false); + expect(details.body.internal_tools.tool_discovery.ids_count).toBe(0); + expect(details.body.internal_tools.tool_discovery.source).toBe('none'); + sdkMocks.toolIds.mockReset(); + sdkMocks.toolIds.mockResolvedValue({ data: ['web_fetch'] }); + const fresh = createApp(baseConfig({})).app; + const second = await request(fresh).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(second.statusCode).toBe(200); + }); + + test('failure circuit avoids hammering backend', async () => { + sdkMocks.toolIds.mockRejectedValue(new Error('down')); + const app = createApp(baseConfig({})).app; + const first = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(first.statusCode).toBe(503); + const callsAfterFirst = sdkMocks.toolIds.mock.calls.length; + const second = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(second.statusCode).toBe(503); + expect(sdkMocks.toolIds.mock.calls.length).toBe(callsAfterFirst); + }); +}); + +describe('tool discovery readiness', () => { + beforeEach(() => { + jest.clearAllMocks(); + sdkMocks.toolIds.mockReset(); + sdkMocks.toolIds.mockResolvedValue({ data: ['web_fetch', 'filesystem', 'bash'] }); + }); + + test('health stays liveness while details expose live readiness', async () => { + const app = createApp(baseConfig({})).app; + const live = await request(app).get('/health'); + expect(live.statusCode).toBe(200); + expect(live.body.status).toBe('ok'); + const chat = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(chat.statusCode).toBe(200); + const details = await request(app).get('/health/details'); + expect(details.statusCode).toBe(200); + const discovery = details.body.internal_tools.tool_discovery; + expect(discovery.source).toBe('live'); + expect(discovery.live_verified).toBe(true); + expect(discovery.ready).toBe(true); + expect(discovery.ids_count).toBe(3); + expect(typeof discovery.cache_age_ms).toBe('number'); + expect(discovery.last_success_at).toBeTruthy(); + expect(discovery.override_omitted_total).toBeDefined(); + expect(details.body.internal_tools.readiness.tool_discovery_ready).toBe(true); + }); + + test('fixture never poses as live proof', async () => { + const app = createApp(baseConfig({ INTERNAL_TOOL_DISCOVERY_FIXTURE: ['web_fetch'] })).app; + const chat = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'hi' }] }); + expect(chat.statusCode).toBe(200); + expect(sdkMocks.toolIds).not.toHaveBeenCalled(); + const details = await request(app).get('/health/details'); + expect(details.statusCode).toBe(200); + const discovery = details.body.internal_tools.tool_discovery; + expect(discovery.source).toBe('fixture'); + expect(discovery.live_verified).toBe(false); + expect(discovery.ready).toBe(false); + expect(discovery.fixture_configured).toBe(true); + expect(details.body.internal_tools.readiness.live_backend_verified).toBe(false); + }); + + test('no live backend reports not ready without faking', async () => { + sdkMocks.toolIds.mockRejectedValue(new Error('down')); + const app = createApp(baseConfig({})).app; + const details = await request(app).get('/health/details'); + expect(details.statusCode).toBe(200); + const discovery = details.body.internal_tools.tool_discovery; + expect(discovery.source).toBe('none'); + expect(discovery.live_verified).toBe(false); + expect(discovery.ready).toBe(false); + expect(discovery.ids_count).toBe(0); + }); + + test('failure records last error without tool payload', async () => { + sdkMocks.toolIds.mockRejectedValueOnce(new Error('connect refused')); + const app = createApp(baseConfig({})).app; + const failed = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'topsecretinput' }] }); + expect(failed.statusCode).toBe(503); + const details = await request(app).get('/health/details'); + const discovery = details.body.internal_tools.tool_discovery; + expect(discovery.last_error_at).toBeTruthy(); + expect(typeof discovery.last_error).toBe('string'); + expect(JSON.stringify(details.body)).not.toContain('topsecretinput'); + }); + + test('metrics expose discovery readiness without secrets', async () => { + const app = createApp(baseConfig({ METRICS_ENABLED: true, METRICS_REQUIRE_AUTH: false })).app; + const chat = await request(app).post('/v1/chat/completions') + .send({ model: 'opencode/kimi-k2.5', messages: [{ role: 'user', content: 'secretpayload' }] }); + expect(chat.statusCode).toBe(200); + const res = await request(app).get('/metrics'); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('opencode_internal_tool_discovery_ids_count'); + expect(res.text).toContain('opencode_internal_tool_discovery_cache_age_ms'); + expect(res.text).toContain('opencode_internal_tool_discovery_live_verified'); + expect(res.text).toContain('opencode_internal_tool_discovery_ready'); + expect(res.text).toContain('opencode_internal_tool_discovery_source_info'); + expect(res.text).toContain('opencode_internal_tool_discovery_last_success_timestamp_seconds'); + expect(res.text).toContain('opencode_internal_tool_discovery_last_error_timestamp_seconds'); + expect(res.text).toContain('opencode_internal_tool_overrides_omitted_total'); + expect(res.text).not.toContain('secretpayload'); + }); +}); diff --git a/tests/translator-integration.test.js b/tests/translator-integration.test.js index 7f49f91..b9a3103 100644 --- a/tests/translator-integration.test.js +++ b/tests/translator-integration.test.js @@ -6,7 +6,7 @@ const sdkMocks = { data: { providers: [{ id: 'opencode', models: { 'kimi-k2.5': { name: 'Kimi' } } }] }, })), configUpdate: jest.fn(async () => ({})), - toolIds: jest.fn(async () => ({ data: [] })), + toolIds: jest.fn(async () => ({ data: ['web_fetch', 'filesystem', 'bash'] })), sessionCreate: jest.fn(async () => ({ data: { id: 'xlat-session' } })), sessionPrompt: jest.fn(async () => ({ data: { parts: [] } })), sessionMessages: jest.fn(async () => ([