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 @@ Node

-> 📖 [文档](./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 + +

+ Version + License + Node +

+ +> **語言:** [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

Version

-> 欢迎查看 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 文档中心 + +

+ Version +

+ +> **語言:** [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); +}