Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
04714b8
fix(tools): preserve external tool contracts and policy semantics
samson910022 Sep 25, 2026
a830f9b
fix(tools): harden streamed tool-call handling
samson910022 Sep 25, 2026
3ad7471
fix(responses): align streamed output events with API contract
samson910022 Sep 25, 2026
b1c72f4
fix(responses): support Codex tool declarations and continuation
samson910022 Sep 25, 2026
f19acb8
chore(config): unify env-file-default merge across entries
samson910022 Sep 25, 2026
c56ce3c
fix(tools): fail closed when native tool discovery is unavailable
samson910022 Sep 25, 2026
528c801
test(api): lock Anthropic tool loop and Interactions contracts
samson910022 Sep 25, 2026
6d4e287
fix(api): unify non-stream tool errors and parallel semantics
samson910022 Sep 25, 2026
11d8d78
fix(docker): add missing empty ENV aliases for six-way symmetry
samson910022 Sep 25, 2026
46f46cf
fix(api): unify stream tool errors and ID-less tool tracking
samson910022 Sep 25, 2026
d63f62f
docs(api): clarify wire-compat confirmations and interactions store i…
samson910022 Sep 25, 2026
f84aa43
fix(api): unify messages/responses stream tool errors to 502 contract
samson910022 Sep 25, 2026
2620026
fix(collector): ignore replayed ID-less terminals, single-bucket mism…
samson910022 Sep 28, 2026
44e1e4f
docs(agents): correct stale file line references after tool-runtime g…
samson910022 Sep 28, 2026
4f5bd77
docs(changelog): scope fail-closed notes to chat/messages/responses, …
samson910022 Sep 28, 2026
ca8eafe
docs(routes): document fail-closed tool discovery, stream buffering, …
samson910022 Sep 28, 2026
32ed0b1
fix(docs): clarify external tool lists are exact-match only
samson910022 Sep 28, 2026
607798a
fix(docs): close light doc drift from review
samson910022 Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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=
Expand All @@ -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
Expand Down
45 changes: 24 additions & 21 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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`: `<entrydir>/config.json` → `<entrydir>/../config.json` → `cwd/config.json` (`index.ts:76-80`).
- Lookup order for `config.json`: `<entrydir>/config.json` → `<entrydir>/../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)

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading