diff --git a/.env.example b/.env.example
index cecff67..a2f075c 100644
--- a/.env.example
+++ b/.env.example
@@ -26,9 +26,25 @@ OPENCODE_PROXY_CLEANUP_MAX_AGE_MS=86400000
# OPENCODE_PROXY_PORT=10000
# OPENCODE_SERVER_PORT=10001
# BIND_HOST=0.0.0.0
+# OPENCODE_PROXY_BIND_HOST=0.0.0.0
+# Canonical tool switch (legacy DISABLE_TOOLS also read; canonical wins)
+# OPENCODE_DISABLE_TOOLS=true
# OPENCODE_USE_ISOLATED_HOME=false
# OPENCODE_PROXY_REQUEST_TIMEOUT_MS=180000
# Retry budget after the initial attempt (0-5, default 3; total attempts = 1+n)
# OPENCODE_PROXY_RETRY_MAX_RETRIES=3
# OPENCODE_PROXY_DEBUG=false
+# OPENCODE_PATH=opencode
# OPENCODE_ZEN_API_KEY=
+# Internal allowlist (only applies when a request carries no tools)
+# OPENCODE_INTERNAL_WEB_FETCH_ENABLED=false
+# OPENCODE_INTERNAL_ALLOWED_TOOLS=
+# OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true
+# OPENCODE_TOOL_DISCOVERY_FIXTURE=
+# Diagnostics
+# OPENCODE_HEALTH_DETAILS_ENABLED=true
+# OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH=true
+# OPENCODE_METRICS_ENABLED=false
+# OPENCODE_METRICS_REQUIRE_AUTH=true
+# Backend management (default false; Docker entrypoint owns the backend)
+# OPENCODE_PROXY_MANAGE_BACKEND=false
diff --git a/.gitignore b/.gitignore
index cb68b3e..23c83dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,25 @@
-node_modules
+node_modules/
.DS_Store
+Thumbs.db
+*~
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
+.env.*
+!.env.example
config.json
dist/
+coverage/
+lcov.info
+.nyc_output/
+.vscode/
+.idea/
+.opencode/
+opencode.json
+*.tgz
+*.tar.gz
+*.tsbuildinfo
+custom-bin/
+docs/host-deployments.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..2eef9eb
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,120 @@
+# AGENTS.md — opencode2api agent operating manual
+
+> **Language:** English canonical. Docs under `docs/` may remain Simplified Chinese during the EN-first migration (see §7); env names and defaults below are normative in English.
+>
+> 🏠 [README](./README.md) | [简体中文](./README.zh-CN.md) | 📖 [Docs](./docs/README.md) | 🤝 [CONTRIBUTING](./CONTRIBUTING.md)
+
+## 1. Project overview
+
+- **What:** OpenAI-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).
+- **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.
+
+## 2. Tech stack & commands (single source of truth)
+
+- `node >= 18`, TypeScript (`tsconfig.json`: `module/moduleResolution NodeNext`, `strict`, `noEmitOnError`), Express, Jest 30 + Supertest via `@swc/jest`, `tsx` for dev.
+- Run in this order:
+ ```bash
+ npm run typecheck # tsc --noEmit (covers index.ts + src/**/*.ts only; tests excluded by design)
+ npm run build # tsc -p tsconfig.json -> dist/ (entry dist/index.js)
+ npm test -- --runInBand # jest ESM; needs NODE_OPTIONS=--experimental-vm-modules (in script)
+ npm run test:integration # bash tests/test-integration.sh — needs Docker + real backend/credentials, NOT run in CI
+ ```
+- Aliases: `npm run test:unit` === `npm test` (full jest suite, name is historical). `npm run dev` === `tsx watch index.ts`. `npm start` === `node dist/index.js` (rebuild first; a stale `dist/` will silently run old code).
+- **There is no `lint` script.** Do not run or document `npm run lint` (`docs/development.md` previously mentioned it by mistake).
+- Docker: `docker compose up -d --build`, `docker compose logs -f`. Health: `/health` (no auth, used by compose healthcheck) vs `/health/details` (auth-gated by default).
+
+## 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 (872) / responses.ts (938) / messages.ts (563) / system.ts
+src/tool-runtime/ # contracts / registry / router / parser (945) / 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)
+src/errors/upstream.ts # normalizeBackendError, transformUpstreamError, isTransient*
+src/converters/anthropic.ts # pure Anthropic<->chat converters
+src/types/ # shared types (re-exported via src/types/index.ts)
+src/utils/guards.ts # shared unknown-guards (asRecord/toErrorMessage)
+tests/*.test.js # JS tests importing TS sources via @swc/jest (see §8)
+docs/ # EN index + Chinese guides (EN migration tracked in docs/README.md)
+Dockerfile / docker-compose.yml / entrypoint.sh
+config.json.example / .env.example # examples only, never real secrets
+```
+
+## 4. Configuration principle: env > file > default (+ canonical names)
+
+- Lookup order for `config.json`: `/config.json` → `/../config.json` → `cwd/config.json` (`index.ts:76-80`).
+- 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` |
+| 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` |
+| 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` |
+| 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) |
+
+- `DISABLE_TOOLS` resolution: `OPENCODE_DISABLE_TOOLS > DISABLE_TOOLS > file > true` via `resolveDisableTools` (`src/config/proxy-config.ts:47-57`); covered by `tests/env-alias.test.js`. Never `??`-chain booleans by hand; call the helper.
+- `RETRY`: raw `env ?? file ?? 3`, normalized by `resolveMaxRetries` (`src/retry/policy.ts:51-58`, clamp 0–5, total attempts `1+n`, `2s×2ⁿ⁻¹` + 25% jitter, `retry-after` clamped to 30s).
+- **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.
+
+## 5. Tool safety principle (default-deny)
+
+- `DISABLE_TOOLS=true` default. External client `tools` go through the proxy bridge and are namespaced (`external__*`, never registered as OpenCode built-ins).
+- Internal allowlist applies **only when the request carries no `tools`**; empty/unmatched allowlist falls back to all-builtins-disabled (safe). Request-level `opencode.internal_allowed_tools` can only narrow: `effective = server ∩ request`.
+- `OPENCODE_INTERNAL_WEB_FETCH_ENABLED=true` is a legacy shortcut for allowlist `web_fetch` when no explicit allowlist is configured.
+- Never log tool outputs; `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED` logs mode/hits/downgrade reasons only.
+- Prod posture: `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH=true`, `OPENCODE_METRICS_ENABLED=false`.
+
+## 6. Secrets & prohibited actions
+
+- **NEVER commit, force-add, print back, or paste:** `.env`, `.env.*` (except tracked `.env.example`), `config.json`, `*.log`, `coverage/`, `dist/`, `node_modules/`, `.opencode/`, `opencode.json`, `custom-bin/`, `docs/host-deployments.md`. A local `.env` with real keys exists on dev hosts — do not `cat` it, do not quote it in issues, do not `git add -f` it.
+- `.env.*` is ignored with `!.env.example` kept tracked (`.gitignore:10-11`). Before `git add`, run `git check-ignore -v ` for anything unfamiliar.
+- `custom-bin/` + `docs/host-deployments.md` are local-only via `.gitignore` (propagates on clone; the old `.git/info/exclude` entries are redundant backups, not the source of truth).
+- Container images never bake `config.json`/`.env` (`.dockerignore` + multi-stage `COPY --from=builder`); containers are configured via env or an explicit `-v ./config.json:...:ro` mount.
+- Before any commit/push, inspect `git status --short`, `git diff --stat`, and `git log --oneline -10`; stage only intended files. No `--no-verify`, no empty commits, no force-push unless explicitly requested.
+
+## 7. Docs language: EN-first
+
+- Root `README.md` is the English canonical homepage; `README.zh-CN.md` is its Simplified Chinese mirror. Keep the top language bars bidirectional.
+- `docs/README.md` is the English index; `docs/README.zh-CN.md` is its Chinese mirror. Per-guide bodies may stay Chinese during migration; the index tracks per-file English status — do not add `English | 中文` bars pointing at files that do not exist yet.
+- Never link `docs/host-deployments.md` from tracked docs (it is git-ignored and 404s on GitHub); mention it only as inline code.
+- `CONTRIBUTING.md` stays English; `CHANGELOG.md` new entries are EN-first with Chinese counterpart after.
+
+## 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/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.
+
+## 9. Architecture notes (maintainability / extensibility)
+
+- `src/proxy.ts:createApp` is a god closure (~800 lines) assembling `AppContext` (50+ fields, `src/types/context.ts`); the three routes (`chat`/`responses`/`messages`, ~2400 lines combined) duplicate the preflight → stream/non-stream → retry → usage/cleanup template. Prefer **pure moves + thin wrappers** over behavior changes.
+- Shared unknown-guards (`asRecord`, `toErrorMessage`) live in `src/utils/guards.ts` — import from there, do not add new copies. Exception: `src/routes/system.ts` and `src/stream/collector.ts` keep their own narrower `toErrorMessage` variant (see `guards.ts` header); do not "unify" it.
+- Pure layers to preserve: `src/retry/policy.ts`, `src/converters/anthropic.ts`, `src/errors/upstream.ts` (no Express/SDK deps). Route error exits must keep their wire shapes: chat non-stream JSON, responses-stream `response.failed` SSE + `[DONE]`, messages-stream `error` event without `[DONE]`.
+- High-risk no-touch list without explicit approval + regression tests: timeout/retry constants, `transformUpstreamError` mappings, `isTransientUpstreamError` matchers, parser ambiguity policy, prompt guard text/order, model alias rules, policy evaluation order, backend spawn/jail/HOME isolation, collector `finish==='tool'`/idle-exemption logic, bool-fallthrough semantics.
+
+## 10. Commit convention & PR checklist
+
+- Conventional Commits per `CONTRIBUTING.md:38-62`: `feat|fix|docs|style|refactor|test|chore`, e.g. `feat(api): add streaming support for Responses API`. Squash before merge.
+- [ ] `typecheck + build + test -- --runInBand` green
+- [ ] six-way config matrix (§4) updated if any env changed
+- [ ] no secrets in diff; `git status/diff/log --oneline -10` inspected; new files `check-ignore` verified
+- [ ] docs language bars + index updated; no links to git-ignored files
diff --git a/Dockerfile b/Dockerfile
index 9bcfd1c..c2b66d0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -51,6 +51,20 @@ ENV OPENCODE_SERVER_PASSWORD=
ENV API_KEY=
ENV BIND_HOST=0.0.0.0
ENV DISABLE_TOOLS=true
+ENV OPENCODE_DISABLE_TOOLS=
+ENV OPENCODE_PROXY_MANAGE_BACKEND=false
+ENV OPENCODE_PATH=opencode
+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_INTERNAL_ALLOWED_TOOLS=
+ENV OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true
+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
diff --git a/README.md b/README.md
index 76a8289..42753ed 100644
--- a/README.md
+++ b/README.md
@@ -6,57 +6,59 @@
-> 📖 [文档](./docs/README.md) | 🚀 [快速开始](#-快速开始)
+> **Language:** [English](./README.md) | [简体中文](./README.zh-CN.md)
+>
+> 📖 [Docs](./docs/README.md) | 🚀 [Quick Start](#quick-start) | 🐛 [Issues](https://github.com/samson910022/OpenCode2API/issues)
-将本地 [OpenCode](https://opencode.ai) 运行时转换为 OpenAI 兼容 API 网关。在任何 OpenAI 客户端中使用免费模型 (GPT, Nemotron, MiniMax)。
+Turn a local [OpenCode](https://opencode.ai) runtime into an OpenAI-compatible API gateway. Use free models (GPT, Nemotron, MiniMax) from any OpenAI client.
---
-## ✨ 功能特性
+## ✨ Features
-| 特性 | 说明 |
+| Feature | Description |
|:-----|:-----|
-| 🟢 **OpenAI 兼容** | `/v1/models`, `/v1/chat/completions`, `/v1/responses` |
-| 🟣 **Anthropic 兼容** | `/v1/messages`(含 `tool_use` / `thinking` / SSE 流式) |
-| 📡 **流式输出** | Chat Completions、Responses 与 Messages API 的完整 SSE 流式支持 |
-| 🧠 **推理控制** | 支持 `reasoning_effort` 和 `reasoning: { "effort": "high" }` |
-| 🐳 **Docker 部署** | 一键部署,自动启动 OpenCode 后端 |
-| 🛡️ **工具安全** | 默认禁用工具调用 |
-| 🔧 **外部工具桥接** | 支持外部客户端传入 `tools`,由代理桥接为 OpenAI-compatible `tool_calls` / `function_call`,避免命中 OpenCode 内置工具 |
-| 🌐 **内置 web_fetch 透传** | 当请求未传入 `tools` 且显式开启特性时,仅允许 OpenCode 内置 `web_fetch` 参与该次请求 |
+| 🟢 **OpenAI compatible** | `/v1/models`, `/v1/chat/completions`, `/v1/responses` |
+| 🟣 **Anthropic compatible** | `/v1/messages` (with `tool_use` / `thinking` / SSE streaming) |
+| 📡 **Streaming** | Full SSE streaming for Chat Completions, Responses, and Messages APIs |
+| 🧠 **Reasoning control** | Supports `reasoning_effort` and `reasoning: { "effort": "high" }` |
+| 🐳 **Docker deploy** | One-command deploy, auto-starts the OpenCode backend |
+| 🛡️ **Tool safety** | Tool calling disabled by default |
+| 🔧 **External tool bridge** | External `tools` from clients are bridged by the proxy into OpenAI-compatible `tool_calls` / `function_call`, without hitting OpenCode built-in tools |
+| 🌐 **Built-in web_fetch passthrough** | When a request carries no `tools` and the feature is explicitly enabled, only the OpenCode built-in `web_fetch` may participate in that request |
---
-## 🚀 快速开始
+## 🚀 Quick Start
-### Docker 部署 (推荐)
+### Docker deploy (recommended)
```bash
-# 1. 克隆并配置
+# 1. Clone and configure
git clone https://github.com/samson910022/OpenCode2API.git
cd OpenCode2API
cp .env.example .env
-# 2. 编辑 .env 设置你的配置
-# 必填: API_KEY, OPENCODE_SERVER_PASSWORD
+# 2. Edit .env and set your config
+# Required: API_KEY, OPENCODE_SERVER_PASSWORD
-# 3. 启动
+# 3. Start
docker compose up -d
-# 4. 测试
+# 4. Test
curl http://127.0.0.1:10000/health
```
-> 默认 `docker-compose.yml` 不会把宿主机项目目录挂载到容器内,因为那会覆盖镜像里已安装的 `node_modules`,导致容器依赖宿主机先执行 `npm install`。如果你需要本地源码热更新,建议单独使用开发专用的 Compose 覆盖配置。
+> The default `docker-compose.yml` does not mount the host project directory into the container, because that would shadow the image's installed `node_modules` and force the container to rely on a host-side `npm install`. For live local-source reload, use a dedicated dev Compose override instead.
-### Node.js (本地开发)
+### Node.js (local dev)
```bash
-# 1. 安装 OpenCode CLI
+# 1. Install the OpenCode CLI
npm install -g opencode-ai
# Linux/macOS: curl -fsSL https://opencode.ai/install | bash
-# 2. 克隆并运行
+# 2. Clone and run
git clone https://github.com/samson910022/OpenCode2API.git
cd OpenCode2API
npm install
@@ -67,7 +69,7 @@ npm start
---
-## 💡 使用示例
+## 💡 Usage Examples
### Chat Completions
@@ -77,12 +79,12 @@ curl -X POST http://127.0.0.1:10000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "opencode/big-pickle",
- "messages": [{"role": "user", "content": "你好!"}],
+ "messages": [{"role": "user", "content": "Hello!"}],
"stream": false
}'
```
-### Responses API (带推理)
+### Responses API (with reasoning)
```bash
curl -N -X POST http://127.0.0.1:10000/v1/responses \
@@ -90,13 +92,13 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "opencode/muse-spark-1.3-contributor-free",
- "input": "用一句话打招呼",
+ "input": "Say hi in one sentence",
"reasoning": {"effort": "high"},
"stream": true
}'
```
-### Chat Completions + 外部工具
+### Chat Completions + external tools
```bash
curl -X POST http://127.0.0.1:10000/v1/chat/completions \
@@ -104,7 +106,7 @@ curl -X POST http://127.0.0.1:10000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "opencode/big-pickle",
- "messages": [{"role": "user", "content": "帮我获取 https://example.com 的标题"}],
+ "messages": [{"role": "user", "content": "Fetch the title of https://example.com"}],
"tools": [
{
"type": "function",
@@ -124,7 +126,7 @@ curl -X POST http://127.0.0.1:10000/v1/chat/completions \
}'
```
-### Responses API + 外部工具流式
+### Responses API + external tools (streaming)
```bash
curl -N -X POST http://127.0.0.1:10000/v1/responses \
@@ -132,7 +134,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \
-H "Content-Type: application/json" \
-d '{
"model": "opencode/muse-spark-1.3-contributor-free",
- "input": "查询东京天气",
+ "input": "Look up the weather in Tokyo",
"stream": true,
"tools": [
{
@@ -154,7 +156,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \
}'
```
-### Messages API (Anthropic 兼容)
+### Messages API (Anthropic compatible)
```bash
curl -X POST http://127.0.0.1:10000/v1/messages \
@@ -165,61 +167,61 @@ curl -X POST http://127.0.0.1:10000/v1/messages \
"model": "opencode/muse-spark-1.3-contributor-free",
"max_tokens": 1024,
"system": "You are a helpful assistant.",
- "messages": [{"role": "user", "content": "用一句话打招呼"}]
+ "messages": [{"role": "user", "content": "Say hi in one sentence"}]
}'
```
-> 也可用 `x-api-key: YOUR_API_KEY` 代替 `Authorization: Bearer`;`max_tokens` 必填;流式时返回 `message_start/content_block_start/content_block_delta/content_block_stop/message_delta/message_stop`(无 `[DONE]`)。
+> You may use `x-api-key: YOUR_API_KEY` instead of `Authorization: Bearer`; `max_tokens` is required; on stream the API returns `message_start/content_block_start/content_block_delta/content_block_stop/message_delta/message_stop` (no `[DONE]`).
---
-## 📦 部署方式
+## 📦 Deployment Modes
-| 模式 | 说明 | 适用场景 |
+| Mode | Description | Best for |
|:-----|:-----|:---------|
-| 🐳 **Docker** | 完整栈,自动启动 OpenCode 后端 | 生产环境,最简配置 |
-| 💻 **独立 Node** | 手动管理后端 | 开发、自定义集成 |
+| 🐳 **Docker** | Full stack, auto-starts the OpenCode backend | Production, minimal config |
+| 💻 **Standalone Node** | You manage the backend yourself | Development, custom integrations |
---
-## ⚙️ 配置
+## ⚙️ Configuration
-### 快速参考
+### Quick reference
-| 环境变量 | 默认值 | 说明 |
+| Env var | Default | Description |
|:--------|:-------|:------|
-| `PORT` / `OPENCODE_PROXY_PORT` | `10000` | 代理服务端口 |
-| `OPENCODE_SERVER_PORT` | `10001` | OpenCode 后端服务端口 |
-| `API_KEY` | - | Bearer Token 认证密钥 |
-| `BIND_HOST` | `0.0.0.0` | 绑定地址 |
-| `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用 |
-| `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` |
-| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具与 OpenCode 内置工具的冲突隔离策略;当前仅支持 `namespace` |
-| `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;当未显式配置 allowlist 时,启用后默认放行 `web_fetch` |
-| `OPENCODE_INTERNAL_ALLOWED_TOOLS` | `(none)` | 当请求未传入 `tools` 时,允许使用的 OpenCode 内置工具列表,逗号分隔 |
-| `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED` | `true` | 输出 internal allowlist 模式的调试/指标日志 |
-| `OPENCODE_TOOL_DISCOVERY_FIXTURE` | `(none)` | 集成测试/本地调试用的后端工具 ID 固定列表,逗号分隔 |
-| `OPENCODE_HEALTH_DETAILS_ENABLED` | `true` | 控制 `/health/details` 是否暴露 |
-| `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH` | `true` | 控制 `/health/details` 是否要求 Bearer 认证 |
-| `OPENCODE_METRICS_ENABLED` | `false` | 控制 `/metrics` 是否暴露 |
-| `OPENCODE_METRICS_REQUIRE_AUTH` | `true` | 控制 `/metrics` 是否要求 Bearer 认证 |
-| `USE_ISOLATED_HOME` | `false` | 使用隔离的 OpenCode 配置目录 |
-| `OPENCODE_PROXY_PROMPT_MODE` | `standard` | 提示词处理模式 |
-| `OPENCODE_PROXY_OMIT_SYSTEM_PROMPT` | `false` | 忽略传入的 system prompt |
-| `OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS` | `false` | 自动清理会话存储 |
-| `OPENCODE_PROXY_CLEANUP_INTERVAL_MS` | `43200000` | 清理间隔 (毫秒) |
-| `OPENCODE_PROXY_CLEANUP_MAX_AGE_MS` | `86400000` | 最大存储时间 (毫秒) |
-| `OPENCODE_PROXY_REQUEST_TIMEOUT_MS` | `180000` | 请求超时时间 (毫秒) |
-| `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`) |
-| `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | OpenCode 后端地址 |
-| `OPENCODE_SERVER_PASSWORD` | - | OpenCode 后端密码 |
-| `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径 |
-| `OPENCODE_ZEN_API_KEY` | - | Zen API Key 透传 |
-| `DEBUG` / `OPENCODE_PROXY_DEBUG` | `false` | 调试日志 |
-
-> 📄 完整配置参考: [配置详解](./docs/configuration.md)
-
-### 推荐生产配置
+| `PORT` / `OPENCODE_PROXY_PORT` | `10000` | Proxy listen port |
+| `OPENCODE_SERVER_PORT` | `10001` | OpenCode backend port |
+| `API_KEY` | - | Bearer token secret |
+| `BIND_HOST` | `0.0.0.0` | Bind address |
+| `DISABLE_TOOLS` | `true` | Disable OpenCode tool calling |
+| `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | External tool bridge mode; only `proxy-bridge` is supported |
+| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | Conflict isolation for external vs built-in tools; only `namespace` is supported |
+| `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | Legacy shortcut; when no allowlist is configured, enabling it defaults the allowlist to `web_fetch` |
+| `OPENCODE_INTERNAL_ALLOWED_TOOLS` | `(none)` | OpenCode built-in tools allowed when a request carries no `tools`, comma-separated |
+| `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED` | `true` | Emit debug/metric logs for internal-allowlist mode |
+| `OPENCODE_TOOL_DISCOVERY_FIXTURE` | `(none)` | Fixed backend tool-ID list for integration tests / local debugging, comma-separated |
+| `OPENCODE_HEALTH_DETAILS_ENABLED` | `true` | Whether `/health/details` is exposed |
+| `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH` | `true` | Whether `/health/details` requires Bearer auth |
+| `OPENCODE_METRICS_ENABLED` | `false` | Whether `/metrics` is exposed |
+| `OPENCODE_METRICS_REQUIRE_AUTH` | `true` | Whether `/metrics` requires Bearer auth |
+| `OPENCODE_USE_ISOLATED_HOME` | `false` | Use an isolated OpenCode config directory (`USE_ISOLATED_HOME` in `config.json`) |
+| `OPENCODE_PROXY_PROMPT_MODE` | `standard` | Prompt handling mode |
+| `OPENCODE_PROXY_OMIT_SYSTEM_PROMPT` | `false` | Drop the incoming system prompt |
+| `OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS` | `false` | Auto-clean conversation storage |
+| `OPENCODE_PROXY_CLEANUP_INTERVAL_MS` | `43200000` | Cleanup interval (ms) |
+| `OPENCODE_PROXY_CLEANUP_MAX_AGE_MS` | `86400000` | Max retention (ms) |
+| `OPENCODE_PROXY_REQUEST_TIMEOUT_MS` | `180000` | Request timeout (ms) |
+| `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | Retries after the first attempt (0-5, total attempts 1+n; exponential backoff+jitter, honors `retry-after`) |
+| `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | OpenCode backend address |
+| `OPENCODE_SERVER_PASSWORD` | - | OpenCode backend password |
+| `OPENCODE_PATH` | `opencode` | OpenCode binary path |
+| `OPENCODE_ZEN_API_KEY` | - | Zen API key passthrough |
+| `OPENCODE_PROXY_DEBUG` | `false` | Debug logs (`DEBUG` in `config.json`) |
+
+> 📄 Full reference: [Configuration](./docs/configuration.md)
+
+### Recommended production config
```env
API_KEY=your-secret-key
@@ -239,122 +241,114 @@ OPENCODE_PROXY_OMIT_SYSTEM_PROMPT=true
OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=true
```
+### External tool bridge
-### 外部工具桥接说明
+- External `tools` sent by clients are never registered as OpenCode built-in tools.
+- The proxy virtualizes them for the model and reshapes model output into OpenAI-compatible `tool_calls` / `function_call`.
+- Same-name conflicts are isolated via an internal namespace (e.g. a client-side `web_fetch` never triggers the in-container OpenCode tool).
+- Internal namespace names (e.g. `external__web_fetch`) are proxy implementation details, not public API.
-- 外部客户端传入的 `tools` 不会被注册为 OpenCode 内置工具。
-- 代理会把这些工具虚拟化后交给模型使用,并把模型输出重新整理为 OpenAI-compatible `tool_calls` / `function_call`。
-- 同名冲突默认通过内部命名空间隔离处理,例如客户端的 `web_fetch` 不会误触发 OpenCode 容器内工具。
-- 内部命名空间名(如 `external__web_fetch`)是代理内部实现细节,不属于公开 API。
+### Built-in tool allowlist
-### 内置工具 allowlist 说明
+- When a request carries **no** `tools`, the proxy enters internal-allowlist mode and only enables the OpenCode built-in tools listed in `OPENCODE_INTERNAL_ALLOWED_TOOLS`.
+- `OPENCODE_INTERNAL_WEB_FETCH_ENABLED=true` is only a legacy shortcut: when `OPENCODE_INTERNAL_ALLOWED_TOOLS` is unset, the allowlist defaults to `web_fetch`.
+- The proxy reads the backend tool list and resolves the final set via exact or `.` / `/` suffix match.
+- If nothing in the allowlist matches the backend tool list, the proxy falls back to the safe mode with all built-in tools disabled.
+- With `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true`, the proxy logs internal-allowlist debug/metric info (mode selection, backend discovery, allowlist hits, downgrade reasons) without logging tool outputs.
+- `OPENCODE_TOOL_DISCOVERY_FIXTURE` bypasses the real `client.tool.ids()` with a fixed tool-ID list for integration tests / local debugging.
+- Once a client explicitly passes `tools`, the request returns to the external-tool bridge path and OpenCode built-in tools stay disabled.
-- 当请求 **未传入** `tools` 时,代理会进入 internal allowlist 模式,并只允许 `OPENCODE_INTERNAL_ALLOWED_TOOLS` 中声明的 OpenCode 内置工具。
-- `OPENCODE_INTERNAL_WEB_FETCH_ENABLED=true` 仅作为兼容旧配置的快捷方式:当未显式配置 `OPENCODE_INTERNAL_ALLOWED_TOOLS` 时,会默认把 allowlist 视为 `web_fetch`。
-- 代理会读取后端工具列表,并通过精确匹配或 `.` / `/` 后缀匹配解析最终可用工具。
-- 如果配置的 allowlist 在后端工具列表中一个也匹配不到,代理会自动回退到“全部内置工具禁用”的安全模式。
-- `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true` 时,代理会输出 internal allowlist 模式的调试/指标日志,包括模式选择、后端工具发现、allowlist 命中结果和降级原因,但不会记录工具返回内容。
-- `OPENCODE_TOOL_DISCOVERY_FIXTURE` 可用于集成测试或本地调试,绕过真实 `client.tool.ids()` 返回固定工具 ID 列表。
-- 一旦客户端显式传入 `tools`,请求立即回到现有外部工具桥接逻辑,OpenCode 内置工具继续保持禁用。
+### Structured diagnostics & Prometheus metrics
-### 结构化诊断与 Prometheus 指标
-
-- `/health` 始终保持为轻量健康检查接口。
-- `/health/details` 返回结构化 JSON 诊断数据,可通过 `OPENCODE_HEALTH_DETAILS_ENABLED` 控制是否暴露,并通过 `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH` 控制是否要求 Bearer 认证。
-- `/metrics` 返回 Prometheus 文本格式指标,可通过 `OPENCODE_METRICS_ENABLED` 控制是否暴露,并通过 `OPENCODE_METRICS_REQUIRE_AUTH` 控制是否要求 Bearer 认证。
-- `/metrics` 目前暴露 internal tool mode、tool discovery failures、fallback count 和 tool-id cache 数量等指标。
+- `/health` stays a lightweight health check.
+- `/health/details` returns structured JSON diagnostics; exposure via `OPENCODE_HEALTH_DETAILS_ENABLED`, auth via `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH`.
+- `/metrics` returns Prometheus text metrics; exposure via `OPENCODE_METRICS_ENABLED`, auth via `OPENCODE_METRICS_REQUIRE_AUTH`.
+- `/metrics` currently exposes internal tool mode, tool discovery failures, fallback counts, and tool-ID cache size.
---
-## 🔌 API 参考
+## 🔌 API Reference
-### 端点
+### Endpoints
-| 方法 | 路径 | 说明 |
+| Method | Path | Description |
|:-----|:-----|:-----|
-| `GET` | `/health` | 健康检查 |
-| `GET` | `/health/details` | 结构化诊断接口(可配置开关/鉴权) |
-| `GET` | `/metrics` | Prometheus 指标接口(可配置开关/鉴权) |
-| `GET` | `/v1/models` | 获取可用模型列表 |
+| `GET` | `/health` | Health check |
+| `GET` | `/health/details` | Structured diagnostics (configurable exposure/auth) |
+| `GET` | `/metrics` | Prometheus metrics (configurable exposure/auth) |
+| `GET` | `/v1/models` | List available models |
| `POST` | `/v1/chat/completions` | Chat Completions API |
| `POST` | `/v1/responses` | Responses API |
-| `POST` | `/v1/messages` | Anthropic Messages API(`max_tokens` 必填,支持 `x-api-key`) |
+| `POST` | `/v1/messages` | Anthropic Messages API (`max_tokens` required, supports `x-api-key`) |
-### 模型名称格式
+### Model name formats
-- 直接使用: `opencode/big-pickle`
-- 带别名: `gpt5-nano` (自动解析为 `gpt-5-nano`)
-- 带前缀: `opencode/gpt5-nano`
+- Direct: `opencode/big-pickle`
+- Aliased: `gpt5-nano` (auto-resolved to `gpt-5-nano`)
+- Prefixed: `opencode/gpt5-nano`
-> 📖 详见 [API 参考文档](./docs/api-reference.md)
+> 📖 See [API Reference](./docs/api-reference.md)
---
-## 🔧 故障排查
+## 🔧 Troubleshooting
-### 请求卡住但 `/v1/models` 正常
+### Requests hang but `/v1/models` works
```bash
-USE_ISOLATED_HOME=false # 让 OpenCode 复用本地登录态
+OPENCODE_USE_ISOLATED_HOME=false # Let OpenCode reuse the local login state
```
-### 模型找不到
-- 查看可用模型: `curl http://127.0.0.1:10000/v1/models`
-- 确认模型 ID 完全匹配
+### Model not found
+- List models: `curl http://127.0.0.1:10000/v1/models`
+- Confirm the model ID matches exactly
-### 没有推理输出
-- 使用 `stream: true` 的 Responses API
-- 发送 `reasoning.effort` 或 `reasoning_effort`
+### No reasoning output
+- Use the Responses API with `stream: true`
+- Send `reasoning.effort` or `reasoning_effort`
-> 📖 完整指南: [故障排查](./docs/troubleshooting.md)
+> 📖 Full guide: [Troubleshooting](./docs/troubleshooting.md)
---
-## 🔨 开发
+## 🔨 Development
```bash
-# 类型检查(tsc --noEmit)
+# Typecheck (tsc --noEmit)
npm run typecheck
-# 构建(tsc -> dist/,入口 dist/index.js)
+# Build (tsc -> dist/, entry dist/index.js)
npm run build
-# 本地开发(tsx watch,源码入口 index.ts)
+# Local dev (tsx watch, source entry index.ts)
npm run dev
-# 生产启动(运行构建产物)
+# Production start (run build output)
npm start
-# 运行测试
+# Run tests
npm test -- --runInBand
-# Docker 开发
+# Docker dev
docker compose up -d --build
```
-> TypeScript 源码:`index.ts` + `src/**/*.ts`;构建产物:`dist/`(`dist/index.js` 为运行时入口,本地 `dist/` 不进 git/docker context)。
+> TypeScript sources: `index.ts` + `src/**/*.ts`; build output: `dist/` (`dist/index.js` is the runtime entry; local `dist/` is not committed and stays out of the Docker context).
---
-## 📄 许可证
+## 📄 License
-MIT · 详见 [LICENSE](./LICENSE.md)
+MIT · See [LICENSE](./LICENSE.md)
---
-## 🙏 致谢 / Acknowledgments
-
-本项目由 [samson910022/OpenCode2API](https://github.com/samson910022/OpenCode2API) 独立维护,复刻(Fork)自
-[TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api):
-
-- [TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api) — 直接上游,本项目的起点(MIT)
-
-另受以下开源项目启发:
-
-- [dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai) — 早期设计参考(MIT)
-- [lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy) — 早期设计参考(MIT)
+## 🙏 Acknowledgments
This project is independently maintained at [samson910022/OpenCode2API](https://github.com/samson910022/OpenCode2API),
forked from [TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api) and inspired by
[dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai) and
[lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy).
Upstream code remains under its original MIT license; see [LICENSE](./LICENSE.md).
+
+本项目由 [samson910022/OpenCode2API](https://github.com/samson910022/OpenCode2API) 独立维护,复刻(Fork)自
+[TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api);另受以下开源项目启发:[dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai)、[lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy)。中文版见 [README.zh-CN.md](./README.zh-CN.md)。
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000..0abe64e
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,362 @@
+# OpenCode2API
+
+
+
+
+
+
+
+> **語言:** [English](./README.md) | [简体中文](./README.zh-CN.md)
+>
+> 📖 [文档](./docs/README.zh-CN.md)([English index](./docs/README.md)) | 🚀 [快速开始](#-快速开始) | 🐛 [Issues](https://github.com/samson910022/OpenCode2API/issues)
+
+将本地 [OpenCode](https://opencode.ai) 运行时转换为 OpenAI 兼容 API 网关。在任何 OpenAI 客户端中使用免费模型 (GPT, Nemotron, MiniMax)。
+
+---
+
+## ✨ 功能特性
+
+| 特性 | 说明 |
+|:-----|:-----|
+| 🟢 **OpenAI 兼容** | `/v1/models`, `/v1/chat/completions`, `/v1/responses` |
+| 🟣 **Anthropic 兼容** | `/v1/messages`(含 `tool_use` / `thinking` / SSE 流式) |
+| 📡 **流式输出** | Chat Completions、Responses 与 Messages API 的完整 SSE 流式支持 |
+| 🧠 **推理控制** | 支持 `reasoning_effort` 和 `reasoning: { "effort": "high" }` |
+| 🐳 **Docker 部署** | 一键部署,自动启动 OpenCode 后端 |
+| 🛡️ **工具安全** | 默认禁用工具调用 |
+| 🔧 **外部工具桥接** | 支持外部客户端传入 `tools`,由代理桥接为 OpenAI-compatible `tool_calls` / `function_call`,避免命中 OpenCode 内置工具 |
+| 🌐 **内置 web_fetch 透传** | 当请求未传入 `tools` 且显式开启特性时,仅允许 OpenCode 内置 `web_fetch` 参与该次请求 |
+
+---
+
+## 🚀 快速开始
+
+### Docker 部署 (推荐)
+
+```bash
+# 1. 克隆并配置
+git clone https://github.com/samson910022/OpenCode2API.git
+cd OpenCode2API
+cp .env.example .env
+
+# 2. 编辑 .env 设置你的配置
+# 必填: API_KEY, OPENCODE_SERVER_PASSWORD
+
+# 3. 启动
+docker compose up -d
+
+# 4. 测试
+curl http://127.0.0.1:10000/health
+```
+
+> 默认 `docker-compose.yml` 不会把宿主机项目目录挂载到容器内,因为那会覆盖镜像里已安装的 `node_modules`,导致容器依赖宿主机先执行 `npm install`。如果你需要本地源码热更新,建议单独使用开发专用的 Compose 覆盖配置。
+
+### Node.js (本地开发)
+
+```bash
+# 1. 安装 OpenCode CLI
+npm install -g opencode-ai
+# Linux/macOS: curl -fsSL https://opencode.ai/install | bash
+
+# 2. 克隆并运行
+git clone https://github.com/samson910022/OpenCode2API.git
+cd OpenCode2API
+npm install
+cp config.json.example config.json
+npm run build
+npm start
+```
+
+---
+
+## 💡 使用示例
+
+### Chat Completions
+
+```bash
+curl -X POST http://127.0.0.1:10000/v1/chat/completions \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "opencode/big-pickle",
+ "messages": [{"role": "user", "content": "你好!"}],
+ "stream": false
+ }'
+```
+
+### Responses API (带推理)
+
+```bash
+curl -N -X POST http://127.0.0.1:10000/v1/responses \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "opencode/muse-spark-1.3-contributor-free",
+ "input": "用一句话打招呼",
+ "reasoning": {"effort": "high"},
+ "stream": true
+ }'
+```
+
+### Chat Completions + 外部工具
+
+```bash
+curl -X POST http://127.0.0.1:10000/v1/chat/completions \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "opencode/big-pickle",
+ "messages": [{"role": "user", "content": "帮我获取 https://example.com 的标题"}],
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "web_fetch",
+ "description": "Fetch a URL and return its content summary",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "url": {"type": "string"}
+ },
+ "required": ["url"]
+ }
+ }
+ }
+ ]
+ }'
+```
+
+### Responses API + 外部工具流式
+
+```bash
+curl -N -X POST http://127.0.0.1:10000/v1/responses \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "opencode/muse-spark-1.3-contributor-free",
+ "input": "查询东京天气",
+ "stream": true,
+ "tools": [
+ {
+ "type": "function",
+ "function": {
+ "name": "weather_lookup",
+ "description": "Look up weather by city",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "city": {"type": "string"},
+ "unit": {"type": "string"}
+ },
+ "required": ["city"]
+ }
+ }
+ }
+ ]
+ }'
+```
+
+### Messages API (Anthropic 兼容)
+
+```bash
+curl -X POST http://127.0.0.1:10000/v1/messages \
+ -H "Authorization: Bearer YOUR_API_KEY" \
+ -H "anthropic-version: 2023-06-01" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "opencode/muse-spark-1.3-contributor-free",
+ "max_tokens": 1024,
+ "system": "You are a helpful assistant.",
+ "messages": [{"role": "user", "content": "用一句话打招呼"}]
+ }'
+```
+
+> 也可用 `x-api-key: YOUR_API_KEY` 代替 `Authorization: Bearer`;`max_tokens` 必填;流式时返回 `message_start/content_block_start/content_block_delta/content_block_stop/message_delta/message_stop`(无 `[DONE]`)。
+
+---
+
+## 📦 部署方式
+
+| 模式 | 说明 | 适用场景 |
+|:-----|:-----|:---------|
+| 🐳 **Docker** | 完整栈,自动启动 OpenCode 后端 | 生产环境,最简配置 |
+| 💻 **独立 Node** | 手动管理后端 | 开发、自定义集成 |
+
+---
+
+## ⚙️ 配置
+
+### 快速参考
+
+| 环境变量 | 默认值 | 说明 |
+|:--------|:-------|:------|
+| `PORT` / `OPENCODE_PROXY_PORT` | `10000` | 代理服务端口 |
+| `OPENCODE_SERVER_PORT` | `10001` | OpenCode 后端服务端口 |
+| `API_KEY` | - | Bearer Token 认证密钥 |
+| `BIND_HOST` | `0.0.0.0` | 绑定地址 |
+| `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用 |
+| `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` |
+| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具与 OpenCode 内置工具的冲突隔离策略;当前仅支持 `namespace` |
+| `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;当未显式配置 allowlist 时,启用后默认放行 `web_fetch` |
+| `OPENCODE_INTERNAL_ALLOWED_TOOLS` | `(none)` | 当请求未传入 `tools` 时,允许使用的 OpenCode 内置工具列表,逗号分隔 |
+| `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED` | `true` | 输出 internal allowlist 模式的调试/指标日志 |
+| `OPENCODE_TOOL_DISCOVERY_FIXTURE` | `(none)` | 集成测试/本地调试用的后端工具 ID 固定列表,逗号分隔 |
+| `OPENCODE_HEALTH_DETAILS_ENABLED` | `true` | 控制 `/health/details` 是否暴露 |
+| `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH` | `true` | 控制 `/health/details` 是否要求 Bearer 认证 |
+| `OPENCODE_METRICS_ENABLED` | `false` | 控制 `/metrics` 是否暴露 |
+| `OPENCODE_METRICS_REQUIRE_AUTH` | `true` | 控制 `/metrics` 是否要求 Bearer 认证 |
+| `OPENCODE_USE_ISOLATED_HOME` | `false` | 使用隔离的 OpenCode 配置目录(`config.json` 中用短键 `USE_ISOLATED_HOME`) |
+| `OPENCODE_PROXY_PROMPT_MODE` | `standard` | 提示词处理模式 |
+| `OPENCODE_PROXY_OMIT_SYSTEM_PROMPT` | `false` | 忽略传入的 system prompt |
+| `OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS` | `false` | 自动清理会话存储 |
+| `OPENCODE_PROXY_CLEANUP_INTERVAL_MS` | `43200000` | 清理间隔 (毫秒) |
+| `OPENCODE_PROXY_CLEANUP_MAX_AGE_MS` | `86400000` | 最大存储时间 (毫秒) |
+| `OPENCODE_PROXY_REQUEST_TIMEOUT_MS` | `180000` | 请求超时时间 (毫秒) |
+| `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`) |
+| `OPENCODE_SERVER_URL` | `http://127.0.0.1:10001` | OpenCode 后端地址 |
+| `OPENCODE_SERVER_PASSWORD` | - | OpenCode 后端密码 |
+| `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径 |
+| `OPENCODE_ZEN_API_KEY` | - | Zen API Key 透传 |
+| `OPENCODE_PROXY_DEBUG` | `false` | 调试日志(`config.json` 中用短键 `DEBUG`) |
+
+> 📄 完整配置参考: [配置详解](./docs/configuration.md)
+
+### 推荐生产配置
+
+```env
+API_KEY=your-secret-key
+OPENCODE_SERVER_PASSWORD=your-password
+DISABLE_TOOLS=true
+OPENCODE_EXTERNAL_TOOLS_MODE=proxy-bridge
+OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY=namespace
+OPENCODE_INTERNAL_ALLOWED_TOOLS=web_fetch
+OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true
+OPENCODE_TOOL_DISCOVERY_FIXTURE=
+OPENCODE_HEALTH_DETAILS_ENABLED=true
+OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH=true
+OPENCODE_METRICS_ENABLED=false
+OPENCODE_METRICS_REQUIRE_AUTH=true
+OPENCODE_PROXY_PROMPT_MODE=plugin-inject
+OPENCODE_PROXY_OMIT_SYSTEM_PROMPT=true
+OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=true
+```
+
+
+### 外部工具桥接说明
+
+- 外部客户端传入的 `tools` 不会被注册为 OpenCode 内置工具。
+- 代理会把这些工具虚拟化后交给模型使用,并把模型输出重新整理为 OpenAI-compatible `tool_calls` / `function_call`。
+- 同名冲突默认通过内部命名空间隔离处理,例如客户端的 `web_fetch` 不会误触发 OpenCode 容器内工具。
+- 内部命名空间名(如 `external__web_fetch`)是代理内部实现细节,不属于公开 API。
+
+### 内置工具 allowlist 说明
+
+- 当请求 **未传入** `tools` 时,代理会进入 internal allowlist 模式,并只允许 `OPENCODE_INTERNAL_ALLOWED_TOOLS` 中声明的 OpenCode 内置工具。
+- `OPENCODE_INTERNAL_WEB_FETCH_ENABLED=true` 仅作为兼容旧配置的快捷方式:当未显式配置 `OPENCODE_INTERNAL_ALLOWED_TOOLS` 时,会默认把 allowlist 视为 `web_fetch`。
+- 代理会读取后端工具列表,并通过精确匹配或 `.` / `/` 后缀匹配解析最终可用工具。
+- 如果配置的 allowlist 在后端工具列表中一个也匹配不到,代理会自动回退到“全部内置工具禁用”的安全模式。
+- `OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=true` 时,代理会输出 internal allowlist 模式的调试/指标日志,包括模式选择、后端工具发现、allowlist 命中结果和降级原因,但不会记录工具返回内容。
+- `OPENCODE_TOOL_DISCOVERY_FIXTURE` 可用于集成测试或本地调试,绕过真实 `client.tool.ids()` 返回固定工具 ID 列表。
+- 一旦客户端显式传入 `tools`,请求立即回到现有外部工具桥接逻辑,OpenCode 内置工具继续保持禁用。
+
+### 结构化诊断与 Prometheus 指标
+
+- `/health` 始终保持为轻量健康检查接口。
+- `/health/details` 返回结构化 JSON 诊断数据,可通过 `OPENCODE_HEALTH_DETAILS_ENABLED` 控制是否暴露,并通过 `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH` 控制是否要求 Bearer 认证。
+- `/metrics` 返回 Prometheus 文本格式指标,可通过 `OPENCODE_METRICS_ENABLED` 控制是否暴露,并通过 `OPENCODE_METRICS_REQUIRE_AUTH` 控制是否要求 Bearer 认证。
+- `/metrics` 目前暴露 internal tool mode、tool discovery failures、fallback count 和 tool-id cache 数量等指标。
+
+---
+
+## 🔌 API 参考
+
+### 端点
+
+| 方法 | 路径 | 说明 |
+|:-----|:-----|:-----|
+| `GET` | `/health` | 健康检查 |
+| `GET` | `/health/details` | 结构化诊断接口(可配置开关/鉴权) |
+| `GET` | `/metrics` | Prometheus 指标接口(可配置开关/鉴权) |
+| `GET` | `/v1/models` | 获取可用模型列表 |
+| `POST` | `/v1/chat/completions` | Chat Completions API |
+| `POST` | `/v1/responses` | Responses API |
+| `POST` | `/v1/messages` | Anthropic Messages API(`max_tokens` 必填,支持 `x-api-key`) |
+
+### 模型名称格式
+
+- 直接使用: `opencode/big-pickle`
+- 带别名: `gpt5-nano` (自动解析为 `gpt-5-nano`)
+- 带前缀: `opencode/gpt5-nano`
+
+> 📖 详见 [API 参考文档](./docs/api-reference.md)
+
+---
+
+## 🔧 故障排查
+
+### 请求卡住但 `/v1/models` 正常
+```bash
+OPENCODE_USE_ISOLATED_HOME=false # 让 OpenCode 复用本地登录态
+```
+
+### 模型找不到
+- 查看可用模型: `curl http://127.0.0.1:10000/v1/models`
+- 确认模型 ID 完全匹配
+
+### 没有推理输出
+- 使用 `stream: true` 的 Responses API
+- 发送 `reasoning.effort` 或 `reasoning_effort`
+
+> 📖 完整指南: [故障排查](./docs/troubleshooting.md)
+
+---
+
+## 🔨 开发
+
+```bash
+# 类型检查(tsc --noEmit)
+npm run typecheck
+
+# 构建(tsc -> dist/,入口 dist/index.js)
+npm run build
+
+# 本地开发(tsx watch,源码入口 index.ts)
+npm run dev
+
+# 生产启动(运行构建产物)
+npm start
+
+# 运行测试
+npm test -- --runInBand
+
+# Docker 开发
+docker compose up -d --build
+```
+
+> TypeScript 源码:`index.ts` + `src/**/*.ts`;构建产物:`dist/`(`dist/index.js` 为运行时入口,本地 `dist/` 不进 git/docker context)。
+
+---
+
+## 📄 许可证
+
+MIT · 详见 [LICENSE](./LICENSE.md)
+
+---
+
+## 🙏 致谢 / Acknowledgments
+
+本项目由 [samson910022/OpenCode2API](https://github.com/samson910022/OpenCode2API) 独立维护,复刻(Fork)自
+[TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api):
+
+- [TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api) — 直接上游,本项目的起点(MIT)
+
+另受以下开源项目启发:
+
+- [dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai) — 早期设计参考(MIT)
+- [lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy) — 早期设计参考(MIT)
+
+This project is independently maintained at [samson910022/OpenCode2API](https://github.com/samson910022/OpenCode2API),
+forked from [TiaraBasori/opencode2api](https://github.com/TiaraBasori/opencode2api) and inspired by
+[dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai) and
+[lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy).
+Upstream code remains under its original MIT license; see [LICENSE](./LICENSE.md).
diff --git a/config.json.example b/config.json.example
index d467a4c..aa99efd 100644
--- a/config.json.example
+++ b/config.json.example
@@ -5,6 +5,14 @@
"DISABLE_TOOLS": true,
"EXTERNAL_TOOLS_MODE": "proxy-bridge",
"EXTERNAL_TOOLS_CONFLICT_POLICY": "namespace",
+ "INTERNAL_WEB_FETCH_ENABLED": false,
+ "INTERNAL_ALLOWED_TOOLS": [],
+ "INTERNAL_TOOL_METRICS_ENABLED": true,
+ "INTERNAL_TOOL_DISCOVERY_FIXTURE": [],
+ "HEALTH_DETAILS_ENABLED": true,
+ "HEALTH_DETAILS_REQUIRE_AUTH": true,
+ "METRICS_ENABLED": false,
+ "METRICS_REQUIRE_AUTH": true,
"USE_ISOLATED_HOME": false,
"PROMPT_MODE": "standard",
"OMIT_SYSTEM_PROMPT": false,
@@ -13,7 +21,10 @@
"CLEANUP_MAX_AGE_MS": 86400000,
"DEBUG": false,
"OPENCODE_SERVER_URL": "http://127.0.0.1:10001",
+ "OPENCODE_SERVER_PASSWORD": "",
"OPENCODE_PATH": "opencode",
+ "ZEN_API_KEY": "",
+ "MANAGE_BACKEND": false,
"REQUEST_TIMEOUT_MS": 180000,
"RETRY_MAX_RETRIES": 3
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 2e05a07..b43c4d1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -27,6 +27,19 @@ services:
- OPENCODE_PROXY_RETRY_MAX_RETRIES=${OPENCODE_PROXY_RETRY_MAX_RETRIES:-3}
- OPENCODE_PROXY_PORT=${OPENCODE_PROXY_PORT:-10000}
- OPENCODE_SERVER_PORT=${OPENCODE_SERVER_PORT:-10001}
+ - 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_INTERNAL_ALLOWED_TOOLS=${OPENCODE_INTERNAL_ALLOWED_TOOLS:-}
+ - OPENCODE_INTERNAL_TOOL_METRICS_ENABLED=${OPENCODE_INTERNAL_TOOL_METRICS_ENABLED:-true}
+ - 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}
volumes:
- opencode-data:/home/node/.local/share/opencode
- opencode-config:/home/node/.config/opencode
diff --git a/docs/README.md b/docs/README.md
index 8a33d34..ef721b6 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,28 +1,55 @@
-# OpenCode2API 文档中心
+# OpenCode2API Docs
-> 欢迎查看 OpenCode2API 文档
+> **Language:** [English](./README.md) | [简体中文](./README.zh-CN.md) *(per-guide Chinese versions, see Translation status below)*
+>
+> 🏠 [Main README](../README.md) | [简体中文 README](../README.zh-CN.md) | 🐛 [Issues](https://github.com/samson910022/OpenCode2API/issues)
+
+Welcome to the OpenCode2API documentation.
---
-## 📚 目录
+## 📚 Index
-| 章节 | 说明 |
+| Section | Description |
|:-----|:-----|
-| 📖 [Getting Started](./getting-started.md) | 快速开始指南 |
-| ⚙️ [Configuration](./configuration.md) | 配置选项详解 |
-| 🔌 [API Reference](./api-reference.md) | API 接口文档 |
-| 🐳 [Docker Deployment](./docker.md) | Docker 部署指南 |
-| 🔧 [Troubleshooting](./troubleshooting.md) | 常见问题排查 |
-| 💻 [Development](./development.md) | 开发指南 |
+| 📖 [Getting Started](./getting-started.md) | Quick start guide |
+| ⚙️ [Configuration](./configuration.md) | Env vars + `config.json` reference |
+| 🔌 [API Reference](./api-reference.md) | Endpoints, auth, streaming, errors |
+| 🐳 [Docker Deployment](./docker.md) | Compose + `docker run` + multi-arch |
+| 🔧 [Troubleshooting](./troubleshooting.md) | FAQ + debug mode |
+| 💻 [Development](./development.md) | Build + test + layout |
+| 🏗️ [Architecture](./architecture.md) | Runtime shape + extension points |
+| 📄 [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.
+
+---
+
+## 🌐 Translation status
+
+Docs are migrating to **English-first**: the root [`README.md`](../README.md) is the English canonical homepage and [`README.zh-CN.md`](../README.zh-CN.md) is its Simplified Chinese mirror. The per-guide files below remain **Simplified Chinese for now**; their English canonical versions are tracked as follow-ups and must fix the naming drifts first (env `OPENCODE_*` vs file short keys, see `docs/configuration.md`).
+
+| 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) |
+| Docker Deployment | 简体中文 | tracked |
+| Troubleshooting | 简体中文 | tracked (smallest, do first) |
+| Development | 简体中文 | tracked (dedupe with `CONTRIBUTING.md`) |
+
+> Note: `docs/host-deployments.md` and `custom-bin/` are **local-only** (git-ignored via `.gitignore` / `.git/info/exclude`, never committed). They are intentionally absent from this index.
---
-## 🔗 相关链接
+## 🔗 Related links
-- 📄 [README](../README.md)
-- 🐙 [GitHub 仓库](https://github.com/samson910022/OpenCode2API)
-- 🐛 [问题反馈](https://github.com/samson910022/OpenCode2API/issues)
+- 📄 [README (English)](../README.md)
+- 📄 [README (简体中文)](../README.zh-CN.md)
+- 🐙 [GitHub repo](https://github.com/samson910022/OpenCode2API)
+- 🐛 [Issues](https://github.com/samson910022/OpenCode2API/issues)
diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md
new file mode 100644
index 0000000..3b09a65
--- /dev/null
+++ b/docs/README.zh-CN.md
@@ -0,0 +1,36 @@
+# OpenCode2API 文档中心
+
+
+
+
+
+> **語言:** [English](./README.md) | [简体中文](./README.zh-CN.md)
+>
+> 🏠 [主 README(English)](../README.md) | [主 README(简体中文)](../README.zh-CN.md)
+>
+> 欢迎查看 OpenCode2API 文档
+
+---
+
+## 📚 目录
+
+| 章节 | 说明 |
+|:-----|:-----|
+| 📖 [Getting Started](./getting-started.md) | 快速开始指南 |
+| ⚙️ [Configuration](./configuration.md) | 配置选项详解 |
+| 🔌 [API Reference](./api-reference.md) | API 接口文档 |
+| 🐳 [Docker Deployment](./docker.md) | Docker 部署指南 |
+| 🔧 [Troubleshooting](./troubleshooting.md) | 常见问题排查 |
+| 💻 [Development](./development.md) | 开发指南 |
+| 🏗️ [Architecture](./architecture.md) | 架构说明(英文) |
+
+> 说明:`docs/host-deployments.md` 与 `custom-bin/` 为本地专用(经 `.gitignore` / `.git/info/exclude` 忽略,不提交),故不在此索引中。
+
+---
+
+## 🔗 相关链接
+
+- 📄 [README (English)](../README.md)
+- 📄 [README (简体中文)](../README.zh-CN.md)
+- 🐙 [GitHub 仓库](https://github.com/samson910022/OpenCode2API)
+- 🐛 [问题反馈](https://github.com/samson910022/OpenCode2API/issues)
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..c9a9b5d
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,100 @@
+# Architecture
+
+> **Language:** English only for now (Chinese translation tracked).
+>
+> 📖 [Docs Index](./README.md) | 🏠 [Main README](../README.md)
+
+This note describes the runtime shape of opencode2api, where the
+duplication hotspots are, and how to extend the gateway without breaking
+wire behavior. It is a companion to `AGENTS.md` §9.
+
+---
+
+## 1. Runtime shape
+
+```
+index.ts (env > file > default merge + bootstrap)
+ └─> startProxy(opts) [src/proxy.ts]
+ ├─> buildProxyConfig(opts) [src/config/proxy-config.ts]
+ └─> createApp(config) [src/proxy.ts]
+ ├─> registerSystemRoutes [src/routes/system.ts] (models / health / metrics, no lock)
+ ├─> registerChatRoutes [src/routes/chat.ts] (POST /v1/chat/completions, request lock)
+ ├─> registerResponsesRoutes[src/routes/responses.ts] (POST /v1/responses, cancel race)
+ └─> registerMessagesRoutes [src/routes/messages.ts] (POST /v1/messages, Anthropic converters first)
+```
+
+Cross-cutting helpers (all routes depend on them one-way; no cycles):
+
+| Layer | File | Role |
+|:------|:-----|:-----|
+| Backend | `src/backend/manager.ts` | lifecycle + request lock/queue + `opencode` binary lookup |
+| Stream | `src/stream/collector.ts` | prompt → poll → collect-from-events SSE pipeline |
+| Errors | `src/errors/upstream.ts` | `normalizeBackendError`, `transformUpstreamError`, `isTransient*` |
+| Retry | `src/retry/policy.ts` | pure `resolveMaxRetries` / backoff+jitter / `retry-after` parsing |
+| Converters | `src/converters/anthropic.ts` | pure Anthropic ↔ chat shapes |
+| Tool runtime | `src/tool-runtime/` | `contracts → registry → router → parser → validator → policy` |
+| Guards | `src/utils/guards.ts` | shared `asRecord` / `toErrorMessage` (single source) |
+
+`retry/policy`, `converters/anthropic`, and `errors/upstream` are pure
+(no Express/SDK imports) — keep them that way.
+
+## 2. Request template (all three main routes)
+
+Every `POST` route repeats the same template with small per-protocol
+differences:
+
+1. `resolveRequestedModel` (under `withTimeout`)
+2. `createRequestToolContext` + `trackToolMode`
+3. `buildSystemPrompt` (system + external prompt + reasoning + tool mode)
+4. `ensureBackend` → `client.config.update({ activeModel })` → `session.create`
+5. `getToolOverridesForMode` → prompt → poll/collect → render
+6. usage estimate (`Math.ceil(len / 4)` or `estimateTokens`) + session cleanup
+
+Known intentional differences — do not "unify" them without approval:
+stream `flushHeaders` timing (responses flushes before preflight, chat /
+messages after), `lock()` wrapping (chat + messages only), cancel via
+`res 'close'`, `forbidThinkBlock` per route, and the three error-exit
+shapes (chat non-stream JSON; responses-stream `response.failed` SSE +
+`[DONE]`; messages-stream `error` event without `[DONE]`).
+
+## 3. Extension points
+
+- **New route:** add `src/routes/xxx.ts` exporting
+ `registerXxxRoutes(app, ctx)`, wire it in `src/proxy.ts` before the
+ 404 fallback, add request/response types under `src/types/`. Copy the
+ §2 template; do not refactor the existing three routes in the same
+ change.
+- **New converter** (e.g. another vendor protocol): add a pure module
+ next to `src/converters/anthropic.ts` (`toChat` / `fromChat` /
+ `estimate`), keep route glue (choice mapping, id round-trip) in the
+ route file.
+- **New tool dialect:** extend `src/tool-runtime/parser.ts` (regex +
+ extractor + registration in the collector) and add fixtures to
+ `tests/parser-foreign-formats.test.js`. The ambiguity policy is
+ registry-gated — loosening it turns prose into phantom tool calls.
+- **New policy dimension:** add constants in `contracts.ts`, read them
+ in `policy.ts` via the existing context, and thread config through
+ `ProxyConfig` + `index.ts` merge + `proxy-config.ts` merge + the
+ six-way matrix (`AGENTS.md` §4).
+
+## 4. No-touch list (needs explicit approval + regression tests)
+
+Timeout/retry constants, `transformUpstreamError` mappings,
+`isTransientUpstreamError` matchers, parser ambiguity policy, prompt
+guard text/order, model alias rules, policy evaluation order, backend
+spawn/jail/HOME isolation, collector `finish === 'tool'` /
+idle-exemption logic, and bool-fallthrough semantics. See `AGENTS.md`
+§9 for the full list.
+
+## 5. Roadmap (accepted, not yet implemented)
+
+- Extract shared `preflight(ctx, body)` (resolve model → tool context →
+ system prompt → ensureBackend → create session → tool overrides).
+- Extract a `stream-kit` (SSE headers + 15s keepalive + filter/parser
+ pair + collect + flush/parse + usage) with per-route differences kept
+ as parameters, not unified.
+- Extract `withSessionRetry(maxAttempts, fn)` for the non-stream
+ delete → create → `computeRetryDelay` sleep skeleton.
+- Unify the three config-merge dialects behind one module **without**
+ changing the documented default drifts (`AGENTS.md` §4) — defaults
+ change behavior and need their own tested proposal.
diff --git a/docs/configuration.md b/docs/configuration.md
index 5d44034..4d472a4 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -40,14 +40,14 @@
| `OPENCODE_HEALTH_DETAILS_REQUIRE_AUTH` | `true` | 控制 `/health/details` 是否要求 Bearer 认证 |
| `OPENCODE_METRICS_ENABLED` | `false` | 控制 Prometheus `/metrics` 是否暴露 |
| `OPENCODE_METRICS_REQUIRE_AUTH` | `true` | 控制 `/metrics` 是否要求 Bearer 认证 |
-| `USE_ISOLATED_HOME` | `false` | 使用隔离的 OpenCode 配置目录 |
-| `PROMPT_MODE` | `standard` | 提示词处理模式 |
-| `OMIT_SYSTEM_PROMPT` | `false` | 忽略传入的 system prompt |
-| `AUTO_CLEANUP_CONVERSATIONS` | `false` | 自动清理会话存储 |
-| `CLEANUP_INTERVAL_MS` | `43200000` | 清理间隔 (毫秒) |
-| `CLEANUP_MAX_AGE_MS` | `86400000` | 最大存储时间 (毫秒) |
-| `REQUEST_TIMEOUT_MS` | `180000` | 请求超时时间 (毫秒) |
-| `RETRY_MAX_RETRIES` / `OPENCODE_PROXY_RETRY_MAX_RETRIES` | `3` | 首次失败后重试次数 (0-5,总尝试 1+n;退避指数+jitter 并优先 `retry-after`) |
+| `OPENCODE_USE_ISOLATED_HOME` | `false` | 使用隔离的 OpenCode 配置目录(`config.json` 中用短键 `USE_ISOLATED_HOME`) |
+| `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`) |
> 重试退避移植自上游 `session/retry.ts`(`2s×2ⁿ⁻¹` +25% jitter),但 `retry-after` 等待 clamp 在 30s(上游近无界;网关面对自带超时的客户端不宜久睡)。旧部署注意:默认总尝试由 3 次变为 1+3=4 次,如需接近旧次数可设 `2`。
@@ -55,7 +55,7 @@
| 变量 | 默认值 | 说明 |
|:-----|:-------|:-----|
-| `DEBUG` / `OPENCODE_PROXY_DEBUG` | `false` | 开启调试日志 |
+| `OPENCODE_PROXY_DEBUG` | `false` | 开启调试日志(`config.json` 中用短键 `DEBUG`) |
| `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径 |
| `OPENCODE_ZEN_API_KEY` | - | Zen API Key 透传 |
@@ -99,8 +99,8 @@ OpenCode2API 现在支持把外部客户端传入的 OpenAI-compatible `tools`
| 配置项 | 支持值 | 说明 |
|:------|:------|:-----|
-| `OPENCODE_EXTERNAL_TOOLS_MODE` / `EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 由代理虚拟化外部工具,并返回 OpenAI-compatible tool calling 结果 |
-| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` / `EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 使用代理内部命名空间隔离同名冲突 |
+| `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 由代理虚拟化外部工具,并返回 OpenAI-compatible tool calling 结果(`config.json` 中用短键 `EXTERNAL_TOOLS_MODE`) |
+| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 使用代理内部命名空间隔离同名冲突(`config.json` 中用短键 `EXTERNAL_TOOLS_CONFLICT_POLICY`) |
### 工具冲突策略
@@ -129,7 +129,7 @@ OpenCode2API 现在支持把外部客户端传入的 OpenAI-compatible `tools`
**示例:**
```json
{
- "model": "opencode/kimi-k2.5",
+ "model": "opencode/muse-spark-1.3-contributor-free",
"messages": [{"role": "user", "content": "Fetch this URL"}],
"opencode": {
"internal_allowed_tools": ["web_fetch"]
@@ -218,7 +218,7 @@ OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=true
| 模式 | 说明 |
|:-----|:-----|
| **standard** (默认) | 标准模式,完整处理提示词 |
-| **plugin-inject** | 插件注入模式,减小模型侧提示词大小,通常与 `OMIT_SYSTEM_PROMPT=true` 配合使用 |
+| **plugin-inject** | 插件注入模式,减小模型侧提示词大小,通常与 `OPENCODE_PROXY_OMIT_SYSTEM_PROMPT=true` 配合使用 |
---
diff --git a/docs/development.md b/docs/development.md
index 10120d7..aae0254 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -87,11 +87,7 @@ docker compose logs -f
### 格式化
-项目使用 ESLint (如有配置):
-
-```bash
-npm run lint
-```
+项目暂无代码风格检查脚本;以 `npm run typecheck` + 现有测试为门禁。
### 提交规范
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 489f0b7..60183f0 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -13,11 +13,9 @@
| 项目 | 说明 |
|:-----|:-----|
| **症状** | 模型列表接口正常,但实际请求无响应 |
-| **解决方案** | 设置 `USE_ISOLATED_HOME=false` 让 OpenCode 复用本机登录态 |
+| **解决方案** | 设置 `OPENCODE_USE_ISOLATED_HOME=false` 让 OpenCode 复用本机登录态(`config.json` 中用短键 `USE_ISOLATED_HOME`) |
```bash
-USE_ISOLATED_HOME=false
-# 或
OPENCODE_USE_ISOLATED_HOME=false
```
@@ -126,8 +124,6 @@ curl -H "Authorization: Bearer YOUR_API_KEY" ...
```bash
# 环境变量
-DEBUG=true
-# 或
OPENCODE_PROXY_DEBUG=true
```
diff --git a/src/errors/upstream.ts b/src/errors/upstream.ts
index a8d1fca..89ebb3a 100644
--- a/src/errors/upstream.ts
+++ b/src/errors/upstream.ts
@@ -1,12 +1,6 @@
// P4 TS: upstream error classification/normalization (ported from P3 .js, behavior identical).
import type { NormalizedUpstreamError, RawBackendErrorLike, TransformedUpstreamError } from '../types/errors.js';
-
-function asRecord(value: unknown): Record {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- return value as Record;
- }
- return {};
-}
+import { asRecord } from '../utils/guards.js';
function readStringField(record: Record, key: string): string | null {
const v: unknown = record[key];
diff --git a/src/proxy.ts b/src/proxy.ts
index c0d566e..3c1ce32 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -40,25 +40,12 @@ import type {
} from './types/context.js';
import type { ProxyClient, ProviderInfo, ModelInfo, ResolvedModel } from './types/client.js';
import type { ResponseStateEntry } from './types/backend.js';
+import { asRecord, toErrorMessage } from './utils/guards.js';
// P4: thin re-exports to preserve original import paths
// (tests/env-alias.test.js, stream-hardening.test.js import these from '../src/proxy.js').
export { normalizeBool, resolveDisableTools, withTimeout };
-function asRecord(value: unknown): Record {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- return value as Record;
- }
- return {};
-}
-
-function toErrorMessage(e: unknown): string {
- if (e instanceof Error) return e.message;
- const r = asRecord(e);
- const m: unknown = r['message'];
- return typeof m === 'string' ? m : String(e);
-}
-
export function createApp(config: ProxyConfig): CreateAppResult {
const {
API_KEY,
diff --git a/src/routes/chat.ts b/src/routes/chat.ts
index 4183dca..aae392f 100644
--- a/src/routes/chat.ts
+++ b/src/routes/chat.ts
@@ -21,25 +21,12 @@ import type { Application, Request, Response } from 'express';
import type { AppContext } from '../types/context.js';
import type { ExternalToolEntry } from '../tool-runtime/registry.js';
import type { FinalToolCall } from '../tool-runtime/parser.js';
-
-function asRecord(value: unknown): Record {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- return value as Record;
- }
- return {};
-}
+import { asRecord, toErrorMessage } from '../utils/guards.js';
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? (value as unknown[]) : [];
}
-function toErrorMessage(e: unknown): string {
- if (e instanceof Error) return e.message;
- const r = asRecord(e);
- const m: unknown = r['message'];
- return typeof m === 'string' ? m : String(e);
-}
-
function readDataMessage(e: unknown): string {
const r = asRecord(e);
const data = asRecord(r['data']);
diff --git a/src/routes/messages.ts b/src/routes/messages.ts
index 9e27172..d9a592a 100644
--- a/src/routes/messages.ts
+++ b/src/routes/messages.ts
@@ -32,20 +32,7 @@ import { getImageDataUri } from '../stream/collector.js';
import type { Application, Request, Response } from 'express';
import type { AppContext } from '../types/context.js';
import type { FinalToolCall } from '../tool-runtime/parser.js';
-
-function asRecord(value: unknown): Record {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- return value as Record;
- }
- return {};
-}
-
-function toErrorMessage(e: unknown): string {
- if (e instanceof Error) return e.message;
- const r = asRecord(e);
- const m: unknown = r['message'];
- return typeof m === 'string' ? m : String(e);
-}
+import { asRecord, toErrorMessage } from '../utils/guards.js';
export function registerMessagesRoutes(app: Application, ctx: AppContext): void {
const {
diff --git a/src/routes/responses.ts b/src/routes/responses.ts
index 84759bb..80d7a24 100644
--- a/src/routes/responses.ts
+++ b/src/routes/responses.ts
@@ -20,20 +20,7 @@ import type { Application, Request, Response } from 'express';
import type { AppContext } from '../types/context.js';
import type { ExternalToolEntry } from '../tool-runtime/registry.js';
import type { FinalToolCall } from '../tool-runtime/parser.js';
-
-function asRecord(value: unknown): Record {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- return value as Record;
- }
- return {};
-}
-
-function toErrorMessage(e: unknown): string {
- if (e instanceof Error) return e.message;
- const r = asRecord(e);
- const m: unknown = r['message'];
- return typeof m === 'string' ? m : String(e);
-}
+import { asRecord, toErrorMessage } from '../utils/guards.js';
function readDataMessage(e: unknown): string {
const r = asRecord(e);
diff --git a/src/stream/collector.ts b/src/stream/collector.ts
index c5c6f7a..0b4d1b7 100644
--- a/src/stream/collector.ts
+++ b/src/stream/collector.ts
@@ -5,13 +5,7 @@ import { sleep } from '../backend/manager.js';
import { withTimeout, DEFAULT_POLL_INTERVAL_MS } from '../config/proxy-config.js';
import type { ProxyClient } from '../types/client.js';
import type { CollectorHandle } from '../types/context.js';
-
-function asRecord(value: unknown): Record {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- return value as Record;
- }
- return {};
-}
+import { asRecord } from '../utils/guards.js';
function toErrorMessage(e: unknown): string {
return e instanceof Error ? e.message : String((e as Record)?.['message'] ?? e);
diff --git a/src/tool-runtime/registry.ts b/src/tool-runtime/registry.ts
index 12478fb..cd18664 100644
--- a/src/tool-runtime/registry.ts
+++ b/src/tool-runtime/registry.ts
@@ -6,6 +6,7 @@ import {
normalizeSideEffect
} from './contracts.js';
import type { ToolRiskLevel, ToolSideEffect } from './contracts.js';
+import { asRecord } from '../utils/guards.js';
/** Shared extension fields carried alongside a tool definition. */
export interface ToolDefinitionExtras {
@@ -143,13 +144,6 @@ export function normalizeToolDefinition(tool: unknown): NormalizedToolDefinition
};
}
-function asRecord(value: unknown): Record {
- if (value && typeof value === 'object' && !Array.isArray(value)) {
- return value as Record;
- }
- return {};
-}
-
function inferSideEffect(definition: unknown = {}): ToolSideEffect {
const record = asRecord(definition);
const declared: unknown = record['x_proxy_side_effect'];
diff --git a/src/utils/guards.ts b/src/utils/guards.ts
new file mode 100644
index 0000000..c2517eb
--- /dev/null
+++ b/src/utils/guards.ts
@@ -0,0 +1,25 @@
+// Shared unknown-guards (single source; do not duplicate per file).
+//
+// `asRecord` is byte-identical to the former per-file copies in
+// proxy.ts / routes/* / stream/collector.ts / errors/upstream.ts /
+// tool-runtime/registry.ts. `toErrorMessage` matches the variant used by
+// proxy.ts and the three main routes (chat/responses/messages):
+// non-Error objects are read via `asRecord` and only a string `message`
+// field is used, otherwise the whole value is stringified.
+// NOTE: routes/system.ts and stream/collector.ts intentionally keep their
+// own narrower `toErrorMessage` (stringifies a non-string `message`
+// field instead); do not "unify" them here — that would change behavior.
+
+export function asRecord(value: unknown): Record {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return value as Record;
+ }
+ return {};
+}
+
+export function toErrorMessage(e: unknown): string {
+ if (e instanceof Error) return e.message;
+ const r = asRecord(e);
+ const m: unknown = r['message'];
+ return typeof m === 'string' ? m : String(e);
+}