diff --git a/CHANGELOG.md b/CHANGELOG.md index 3782619..c9118b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +> 分叉声明 / Fork notice:本项目自 `TiaraBasori/opencode2api` 的 `v1.5.0` +> 起由 `samson910022/OpenCode2API` 独立维护。`v1.5.0`(含)以前见上游历史; +> 此后变更见本节(`[Unreleased]`)及后续版本节。 + +### Changed + +- **独立维护声明**:README 致谢明确直接上游 `TiaraBasori/opencode2api`,LICENSE 追加上游归属,CHANGELOG 声明分叉点。 +- **仓库去耦合**:文档克隆/Issue 链接与 Docker 镜像指向 `samson910022/OpenCode2API`,`package.json` 补全维护元数据。 +- **Responses 示例模型**:`README`、`docs/api-reference.md`、`docs/getting-started.md` 的 `/v1/responses` 示例统一为 `opencode/muse-spark-1.3-contributor-free`(解析逻辑无需改动,裸名与带前缀均兼容)。 +- **SDK 对齐**:`@opencode-ai/sdk ^1.1.51` → `^1.18.29`(与本地 server `1.18.29` 对齐;v1 调用形状不变,零改码;139 tests 全绿;`cross-spawn@7.0.6` 为 SDK 新增 prod 依赖,既有包去 dev 标记,无新版本;`tool.ids` 仍走上游 experimental 路径,后续跟进)。 + +### Added + +- **Anthropic Messages API**:新增 `POST /v1/messages`(`max_tokens` 必填,支持 `system`/`tools(input_schema)`/`tool_choice{auto,any,tool,none}`/`thinking→reasoning`/`image`;`tool_use.id` 原样往返;非流式回 `message` 对象,流式回 `message_start/content_block_*/message_delta/message_stop` 无 `[DONE]`;认证同时支持 `x-api-key`;CORS 放行 `x-api-key/anthropic-version`)。新增 `src/converters/anthropic.js` 纯函数转换层与 `tests/messages-anthropic.test.js`(9 例)。附带修复 `EXTERNAL_TOOL_PREFIX` 缺 import 的 latent `ReferenceError`。 + ## [1.5.0] - 2026-04-18 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cad0e9e..51f7915 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,8 +65,8 @@ docs: update configuration documentation ```bash # Clone and install -git clone https://github.com/TiaraBasori/opencode2api.git -cd opencode2api +git clone https://github.com/samson910022/OpenCode2API.git +cd OpenCode2API npm install # Run tests diff --git a/LICENSE.md b/LICENSE.md index e08de8a..9fc7b59 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -18,4 +18,11 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. + +--- +Upstream attribution: +This project is a fork of https://github.com/TiaraBasori/opencode2api +by TiaraBasori and contributors. Upstream code is used under the MIT License. +Inspired in part by https://github.com/dxxzst/opencode-to-openai +and https://github.com/lucasliet/opencode-openai-proxy. diff --git a/README.md b/README.md index 5b24ae2..368bc46 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ | 特性 | 说明 | |:-----|:-----| | 🟢 **OpenAI 兼容** | `/v1/models`, `/v1/chat/completions`, `/v1/responses` | -| 📡 **流式输出** | Chat Completions 与 Responses API 的完整 SSE 流式支持 | +| 🟣 **Anthropic 兼容** | `/v1/messages`(含 `tool_use` / `thinking` / SSE 流式) | +| 📡 **流式输出** | Chat Completions、Responses 与 Messages API 的完整 SSE 流式支持 | | 🧠 **推理控制** | 支持 `reasoning_effort` 和 `reasoning: { "effort": "high" }` | | 🐳 **Docker 部署** | 一键部署,自动启动 OpenCode 后端 | | 🛡️ **工具安全** | 默认禁用工具调用 | @@ -32,8 +33,8 @@ ```bash # 1. 克隆并配置 -git clone https://github.com/TiaraBasori/opencode2api.git -cd opencode2api +git clone https://github.com/samson910022/OpenCode2API.git +cd OpenCode2API cp .env.example .env # 2. 编辑 .env 设置你的配置 @@ -56,8 +57,8 @@ npm install -g opencode-ai # Linux/macOS: curl -fsSL https://opencode.ai/install | bash # 2. 克隆并运行 -git clone https://github.com/TiaraBasori/opencode2api.git -cd opencode2api +git clone https://github.com/samson910022/OpenCode2API.git +cd OpenCode2API npm install cp config.json.example config.json npm start @@ -87,7 +88,7 @@ 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": "gpt5-nano", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "用一句话打招呼", "reasoning": {"effort": "high"}, "stream": true @@ -129,7 +130,7 @@ 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/big-pickle", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "查询东京天气", "stream": true, "tools": [ @@ -152,6 +153,23 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ }' ``` +### 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]`)。 + --- ## 📦 部署方式 @@ -258,6 +276,7 @@ OPENCODE_PROXY_AUTO_CLEANUP_CONVERSATIONS=true | `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`) | ### 模型名称格式 @@ -306,9 +325,20 @@ 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) -- [dxxzst/opencode-to-openai](https://github.com/dxxzst/opencode-to-openai) -- [lucasliet/opencode-openai-proxy](https://github.com/lucasliet/opencode-openai-proxy) +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/docker-compose.yml b/docker-compose.yml index 19598ef..3a52c98 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile - image: ghcr.io/tiarabasori/opencode2api:latest + image: ghcr.io/samson910022/opencode2api:latest container_name: opencode2api ports: - "${OPENCODE_PROXY_PORT:-10000}:${OPENCODE_PROXY_PORT:-10000}" diff --git a/docs/README.md b/docs/README.md index 02b03ec..2af77c9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,5 +24,5 @@ ## 🔗 相关链接 - 📄 [README](../README.md) -- 🐙 [GitHub 仓库](https://github.com/TiaraBasori/opencode2api) -- 🐛 [问题反馈](https://github.com/TiaraBasori/opencode2api/issues) +- 🐙 [GitHub 仓库](https://github.com/samson910022/OpenCode2API) +- 🐛 [问题反馈](https://github.com/samson910022/OpenCode2API/issues) diff --git a/docs/api-reference.md b/docs/api-reference.md index eb276ec..b89e4a5 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -165,7 +165,7 @@ 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/big-pickle", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "打招呼", "reasoning": {"effort": "high"}, "stream": true @@ -179,7 +179,7 @@ curl -X POST http://127.0.0.1:10000/v1/responses \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "opencode/big-pickle", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "东京现在天气怎么样?", "tools": [ { @@ -253,7 +253,7 @@ 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/big-pickle", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "查询东京天气", "stream": true, "tools": [ @@ -279,11 +279,71 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ - Chat Completions 会在 `chat.completion.chunk` 中返回 `delta.tool_calls` - Responses API 会返回 `response.output_item.added`、`response.function_call_arguments.delta`、`response.function_call_arguments.done`、`response.output_item.done` 等事件 +- Messages API 会返回 `message_start`、`content_block_start`、`content_block_delta`(`text_delta`/`thinking_delta`/`input_json_delta`)、`content_block_stop`、`message_delta`、`message_stop`(无 `[DONE]`) > 注意:代理内部会使用命名空间隔离同名工具,但这些内部名称不会作为公开 API 返回给客户端。 --- +### 🟣 Messages API(Anthropic 兼容) + +`POST /v1/messages`。认证可用 `Authorization: Bearer` 或 `x-api-key`;`max_tokens` 必填;首条 message 须为 `user`。 + +| 字段 | 说明 | +|:-----|:-----| +| `model` | 模型 ID(支持 `opencode/` 前缀或裸名) | +| `max_tokens` | 必填,最大输出 tokens | +| `system` | string 或 `[{type:'text', text}]` | +| `messages` | `user`/`assistant` 数组,content 支持 `text`、`image`(base64/url)、`tool_use`、`tool_result` | +| `tools` | `[{name, description, input_schema}]`(`input_schema` 即 JSON Schema) | +| `tool_choice` | `{type:'auto'|'any'|'tool'|'none', name?}`(`any`≈强制调用) | +| `thinking` | `{type:'enabled', budget_tokens}` 映射为推理强度(`disabled`→关闭) | +| `stream` | bool,流式返回 Anthropic SSE 事件 | +| `temperature` / `top_p` / `stop_sequences` | 透传(`stop_sequences`→`stop`);`top_k` 暂忽略 | + +**示例:** + +```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, + "messages": [{"role": "user", "content": "用一句话打招呼"}] + }' +``` + +**带工具的示例:** + +```bash +curl -X POST http://127.0.0.1:10000/v1/messages \ + -H "x-api-key: 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, + "messages": [{"role": "user", "content": "查询东京天气"}], + "tools": [ + { + "name": "weather_lookup", + "description": "Look up weather by city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"] + } + } + ] + }' +``` + +工具调用以 `content: [{type:'tool_use', id, name, input}]` 返回(`stop_reason: 'tool_use'`),下一轮把 `tool_use` + `tool_result` 回灌进 `messages` 即可继续。`thinking` 以无签名 `thinking` block 返回(多轮透传签名暂为占位空串)。 + +--- + ## 🔧 推理强度 | 输入值 | 映射结果 | diff --git a/docs/docker.md b/docs/docker.md index b8b6990..981a60e 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -11,8 +11,8 @@ ### 1️⃣ 克隆项目 ```bash -git clone https://github.com/TiaraBasori/opencode2api.git -cd opencode2api +git clone https://github.com/samson910022/OpenCode2API.git +cd OpenCode2API ``` ### 2️⃣ 配置环境变量 diff --git a/docs/getting-started.md b/docs/getting-started.md index ecf5056..922cb76 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,8 +30,8 @@ | 步骤 | 命令 | |:-----|:-----| -| 1. 克隆项目 | `git clone https://github.com/TiaraBasori/opencode2api.git` | -| 2. 进入目录 | `cd opencode2api` | +| 1. 克隆项目 | `git clone https://github.com/samson910022/OpenCode2API.git` | +| 2. 进入目录 | `cd OpenCode2API` | | 3. 复制配置 | `cp .env.example .env` | | 4. 启动服务 | `docker compose up -d` | @@ -39,8 +39,8 @@ | 步骤 | 命令 | |:-----|:-----| -| 1. 克隆项目 | `git clone https://github.com/TiaraBasori/opencode2api.git` | -| 2. 进入目录 | `cd opencode2api` | +| 1. 克隆项目 | `git clone https://github.com/samson910022/OpenCode2API.git` | +| 2. 进入目录 | `cd OpenCode2API` | | 3. 安装依赖 | `npm install` | | 4. 复制配置 | `cp config.json.example config.json` | | 5. 安装 CLI | `npm install -g opencode-ai` | @@ -82,7 +82,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "model": "gpt5-nano", + "model": "opencode/muse-spark-1.3-contributor-free", "input": "Say hi in one sentence.", "reasoning": {"effort": "high"}, "stream": true diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f034ca3..ed45ebe 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -137,4 +137,4 @@ OPENCODE_PROXY_DEBUG=true ## 🆘 获取帮助 -- 🐛 [GitHub Issues](https://github.com/TiaraBasori/opencode2api/issues) +- 🐛 [GitHub Issues](https://github.com/samson910022/OpenCode2API/issues) diff --git a/package-lock.json b/package-lock.json index 66a2eac..fa45936 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.5.0", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "^1.1.51", + "@opencode-ai/sdk": "^1.18.29", "axios": "^1.7.9", "body-parser": "^1.20.3", "cors": "^2.8.5", @@ -995,10 +995,13 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.53", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz", - "integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==", - "license": "MIT" + "version": "1.18.29", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.29.tgz", + "integrity": "sha512-4CS+FoLPkymTlcga8jxivGDDb2AbWMIIl3b8+myoe2wtv/1ANYCErslgz1xy5hTVHymWE6CtVNKzRuPU0ED57A==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", @@ -2042,7 +2045,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2853,8 +2855,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -3988,7 +3989,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -4278,7 +4278,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4290,7 +4289,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -4933,7 +4931,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, diff --git a/package.json b/package.json index 94b5a12..77df765 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,21 @@ "ai", "api" ], - "author": "", + "author": "samson910022", + "repository": { + "type": "git", + "url": "https://github.com/samson910022/OpenCode2API.git" + }, + "homepage": "https://github.com/samson910022/OpenCode2API#readme", + "bugs": { + "url": "https://github.com/samson910022/OpenCode2API/issues" + }, "license": "MIT", "engines": { "node": ">=18.0.0" }, "dependencies": { - "@opencode-ai/sdk": "^1.1.51", + "@opencode-ai/sdk": "^1.18.29", "axios": "^1.7.9", "body-parser": "^1.20.3", "cors": "^2.8.5", diff --git a/src/converters/anthropic.js b/src/converters/anthropic.js new file mode 100644 index 0000000..853e82a --- /dev/null +++ b/src/converters/anthropic.js @@ -0,0 +1,218 @@ +/** + * Anthropic Messages (/v1/messages) converters. + * + * Pure functions only: no express, no SDK imports. + * Borrowed patterns from CLIProxyAPI (openai/claude request/response) and + * LiteLLM (LiteLLMAnthropicMessagesAdapter + sanitize trio), adapted to this + * repo's external-bridge (text markup) model. + */ + +export function sanitizeClaudeToolId(id) { + const s = String(id || '').replace(/[^a-zA-Z0-9_-]/g, '_'); + if (s) return s; + return generateClaudeToolCallId(); +} + +export function generateClaudeToolCallId() { + try { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return `toolu_${globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; + } + } catch {} + return `toolu_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`; +} + +export function anthropicError(type, message, statusCode = 400) { + return { statusCode, body: { type: 'error', error: { type, message } } }; +} + +export function validateMessagesRequest(body = {}) { + if (!body || typeof body !== 'object') return anthropicError('invalid_request_error', 'request body must be an object'); + if (!body.model || typeof body.model !== 'string') return anthropicError('invalid_request_error', 'model is required'); + if (body.max_tokens === undefined || body.max_tokens === null) return anthropicError('invalid_request_error', 'max_tokens is required'); + if (typeof body.max_tokens !== 'number' || body.max_tokens <= 0) return anthropicError('invalid_request_error', 'max_tokens must be a positive number'); + if (!Array.isArray(body.messages) || body.messages.length === 0) return anthropicError('invalid_request_error', 'messages array is required'); + if (body.messages[0]?.role !== 'user') return anthropicError('invalid_request_error', 'first message must use role "user"'); + return null; +} + +function textOfBlock(block) { + if (!block) return ''; + if (typeof block === 'string') return block; + if (block.type === 'text') return block.text || ''; + return ''; +} + +export function extractSystemText(system) { + if (!system) return ''; + if (typeof system === 'string') return system; + if (Array.isArray(system)) return system.map(textOfBlock).filter(Boolean).join('\n\n'); + return ''; +} + +function normalizeToolArguments(args) { + if (args === undefined || args === null || args === '') return '{}'; + if (typeof args === 'string') return args; + try { + return JSON.stringify(args); + } catch { + return '{}'; + } +} + +/** + * Convert Anthropic messages[] to OpenAI-chat-like messages[] so the existing + * prompt builder (ROLE: text / ASSISTANT / TOOL_RESULT) can be reused. + * Preserves tool_use.id (toolu_xxx) verbatim for round-trip. + */ +export function anthropicMessagesToChatMessages(anthropicMessages = []) { + const chatMessages = []; + for (const m of anthropicMessages) { + const role = m?.role === 'assistant' ? 'assistant' : 'user'; + const content = m?.content; + if (typeof content === 'string') { + chatMessages.push({ role, content }); + continue; + } + if (!Array.isArray(content)) continue; + const textParts = []; + const toolCalls = []; + const ordered = []; + const flushText = () => { + if (textParts.length) { + ordered.push({ role, content: textParts.join('\n\n') }); + textParts.length = 0; + } + }; + for (const block of content) { + if (!block || typeof block !== 'object') continue; + if (block.type === 'text') { + if (block.text) textParts.push(block.text); + } else if (block.type === 'image') { + const src = block.source || {}; + if (src.type === 'base64' && src.data) { + const mime = src.media_type || 'image/png'; + // Flush pending text first so [text, image, text] keeps its order. + // No marker text: the image_url part alone carries the image downstream. + flushText(); + ordered.push({ + role, + content: [{ type: 'image_url', image_url: { url: `data:${mime};base64,${src.data}` } }] + }); + } else if (src.type === 'url' && src.url) { + flushText(); + ordered.push({ role, content: [{ type: 'image_url', image_url: { url: src.url } }] }); + } + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: block.id || generateClaudeToolCallId(), + type: 'function', + function: { name: block.name, arguments: normalizeToolArguments(block.input) } + }); + } else if (block.type === 'tool_result') { + const inner = Array.isArray(block.content) + ? block.content.map(textOfBlock).filter(Boolean).join('\n') + : (typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '')); + const prefix = block.is_error ? 'ERROR: ' : ''; + flushText(); + ordered.push({ + role: 'tool', + tool_call_id: block.tool_use_id, + name: 'unknown', + content: `${prefix}${inner}` + }); + } else if (block.type === 'thinking') { + // Thinking blocks from client history are not re-fed as reasoning; + // keep text if present to preserve context. + if (block.thinking) textParts.push(block.thinking); + } + } + if (toolCalls.length) { + chatMessages.push(...ordered); + chatMessages.push({ role: 'assistant', tool_calls: toolCalls, content: textParts.join('\n\n') || null }); + } else { + flushText(); + chatMessages.push(...ordered); + } + } + return chatMessages.filter((m) => m && (m.content || m.tool_calls)); +} + +export function anthropicToolsToChatTools(tools) { + if (!Array.isArray(tools)) return []; + return tools + .filter((t) => t && typeof t.name === 'string') + .map((t) => ({ + type: 'function', + function: { + name: t.name, + description: t.description || '', + parameters: t.input_schema || { type: 'object', properties: {} } + } + })); +} + +export function anthropicToolChoiceToChat(toolChoice) { + if (!toolChoice) return undefined; + if (typeof toolChoice === 'string') return toolChoice; + const t = String(toolChoice.type || '').toLowerCase(); + if (t === 'auto') return 'auto'; + if (t === 'none') return 'none'; + if (t === 'any') return 'required'; + if (t === 'tool' && toolChoice.name) return { type: 'function', function: { name: toolChoice.name } }; + if (t === 'tool') return 'required'; + return undefined; +} + +export function anthropicThinkingToReasoningEffort(thinking) { + if (!thinking || typeof thinking !== 'object') return null; + if (thinking.type === 'disabled') return 'none'; + if (thinking.type === 'enabled') { + const budget = typeof thinking.budget_tokens === 'number' ? thinking.budget_tokens : 0; + if (budget >= 24000) return 'high'; + if (budget >= 8000) return 'medium'; + return 'low'; + } + return null; +} + +export function mapFinishToStopReason(finish, hasToolCalls) { + if (hasToolCalls) return 'tool_use'; + if (finish === 'tool') return 'tool_use'; + if (finish === 'length' || finish === 'max_tokens') return 'max_tokens'; + if (finish === 'stop_sequence') return 'stop_sequence'; + return 'end_turn'; +} + +export function buildAnthropicMessage({ messageId, model, text, reasoning, toolCalls, stopReason, inputTokens, outputTokens }) { + const content = []; + if (reasoning) content.push({ type: 'thinking', thinking: reasoning, signature: '' }); + if (text) content.push({ type: 'text', text }); + for (const tc of toolCalls || []) { + let input = {}; + try { + input = JSON.parse(tc.function?.arguments || '{}'); + } catch { + input = {}; + } + content.push({ type: 'tool_use', id: tc.id, name: tc.function?.name || tc.name, input }); + } + return { + id: messageId, + type: 'message', + role: 'assistant', + model, + content, + stop_reason: stopReason, + stop_sequence: null, + usage: { input_tokens: inputTokens, output_tokens: outputTokens } + }; +} + +export function sseEvent(event, payload) { + return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; +} + +export function estimateTokens(text) { + return Math.ceil(String(text || '').length / 4); +} diff --git a/src/proxy.js b/src/proxy.js index 1b0c6af..6d3acd7 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -10,6 +10,19 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; import { buildExternalToolRegistry, findExternalToolByName } from './tool-runtime/registry.js'; +import { EXTERNAL_TOOL_PREFIX } from './tool-runtime/contracts.js'; +import { + validateMessagesRequest, + extractSystemText, + anthropicMessagesToChatMessages, + anthropicToolsToChatTools, + anthropicToolChoiceToChat, + anthropicThinkingToReasoningEffort, + mapFinishToStopReason, + buildAnthropicMessage, + sseEvent, + estimateTokens +} from './converters/anthropic.js'; import { buildToolExposure } from './tool-runtime/router.js'; import { evaluateToolPolicy } from './tool-runtime/policy.js'; import { validateToolCalls } from './tool-runtime/validator.js'; @@ -530,7 +543,7 @@ export function createApp(config) { app.use(cors({ origin: '*', methods: ['GET', 'POST', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] + allowedHeaders: ['Content-Type', 'Authorization', 'x-api-key', 'anthropic-version'] })); app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); @@ -548,12 +561,16 @@ export function createApp(config) { return false; }; - // Auth middleware + // Auth middleware (accepts Authorization: Bearer and x-api-key for Anthropic SDK compat) app.use((req, res, next) => { if (req.method === 'OPTIONS' || req.path === '/health' || req.path === '/' || req.path === '/health/details' || req.path === '/metrics') return next(); if (API_KEY && API_KEY.trim() !== '') { const authHeader = req.headers.authorization; - if (!authHeader || authHeader !== `Bearer ${API_KEY}`) { + const apiKeyHeader = req.headers['x-api-key']; + if (!((authHeader && authHeader === `Bearer ${API_KEY}`) || (apiKeyHeader && apiKeyHeader === API_KEY))) { + if (req.path === '/v1/messages') { + return res.status(401).json({ type: 'error', error: { type: 'authentication_error', message: 'Unauthorized' } }); + } return res.status(401).json({ error: { message: 'Unauthorized' } }); } } @@ -782,8 +799,8 @@ export function createApp(config) { }; const normalizeToolArguments = (args) => { + if (args === undefined || args === null || args === '') return '{}'; if (typeof args === 'string') return args; - if (args === undefined) return '{}'; try { return JSON.stringify(args); } catch (e) { @@ -813,7 +830,8 @@ export function createApp(config) { registry, exposure, toolChoice: exposure.toolChoice, - prompt: exposure.prompt + prompt: exposure.prompt, + reminder: exposure.reminder }; }; @@ -854,9 +872,10 @@ export function createApp(config) { ? createExternalToolContext(tools, toolChoice) : { registry: [], - exposure: { tools: [], toolChoice: { mode: 'auto', requiredTool: null }, prompt: '' }, + exposure: { tools: [], toolChoice: { mode: 'auto', requiredTool: null }, prompt: '', reminder: '' }, toolChoice: { mode: 'auto', requiredTool: null }, - prompt: '' + prompt: '', + reminder: '' }; return { @@ -2172,7 +2191,8 @@ export function createApp(config) { const hasValidBearerAuth = (req) => { if (!API_KEY || API_KEY.trim() === '') return true; const authHeader = req.headers.authorization; - return Boolean(authHeader && authHeader === `Bearer ${API_KEY}`); + const apiKeyHeader = req.headers['x-api-key']; + return Boolean((authHeader && authHeader === `Bearer ${API_KEY}`) || (apiKeyHeader && apiKeyHeader === API_KEY)); }; const shouldAllowOperationalEndpoint = (req, { enabled, requireAuth }) => { @@ -2960,6 +2980,353 @@ export function createApp(config) { } }); + // Anthropic Messages API (P0: non-stream + stream via shared opencode backend) + app.post('/v1/messages', async (req, res) => { + try { + await lock(async () => { + let sessionId = null; + let keepaliveInterval = null; + try { + const validationError = validateMessagesRequest(req.body); + if (validationError) { + return res.status(validationError.statusCode).json(validationError.body); + } + const { + model, system, messages, tools = [], tool_choice, + stream: requestStream, temperature, top_p, top_k, + max_tokens, stop_sequences, thinking + } = req.body; + const stream = Boolean(requestStream); + const chatMessages = anthropicMessagesToChatMessages(messages); + const systemText = extractSystemText(system); + if (systemText) chatMessages.unshift({ role: 'system', content: systemText }); + const chatTools = anthropicToolsToChatTools(tools); + const chatToolChoice = anthropicToolChoiceToChat(tool_choice); + const reasoningLevel = anthropicThinkingToReasoningEffort(thinking) + || normalizeReasoningEffort(undefined, null); + + const requestParams = { + temperature: typeof temperature === 'number' ? temperature : 0.7, + max_tokens: typeof max_tokens === 'number' ? max_tokens : null, + top_p: typeof top_p === 'number' ? top_p : 1.0, + stop: Array.isArray(stop_sequences) ? stop_sequences : null, + reasoning_effort: reasoningLevel + }; + if (top_k !== undefined) logDebug('Ignoring top_k (no opencode equivalent)', { top_k }); + + const resolvedModel = await resolveRequestedModel(model); + const pID = resolvedModel.providerID; + const mID = resolvedModel.modelID; + const publicModel = `${pID}/${mID}`; + + const requestToolContext = createRequestToolContext(chatTools, chatToolChoice, undefined); + const toolMode = requestToolContext.mode; + const externalToolContext = requestToolContext.external; + const externalToolRegistry = externalToolContext.registry; + const externalToolChoice = externalToolContext.toolChoice; + const internalToolContext = requestToolContext.internal; + trackToolMode(toolMode, { route: '/v1/messages' }); + + // Reuse chat-style prompt flattening for the converted messages. + const parts = []; + const systemChunks = []; + const assistantToolCalls = new Map(); + const formatRoleLine = (role, name, text) => `${String(role).toUpperCase()}${name ? `(${name})` : ''}: ${text}`; + for (const m of chatMessages) { + const role = (m?.role || 'user').toLowerCase(); + const content = m?.content; + if (role === 'system') { + const text = normalizeTextContent(content); + if (text) systemChunks.push(text); + continue; + } + if (role === 'assistant' && Array.isArray(m?.tool_calls) && m.tool_calls.length) { + const serialized = m.tool_calls.map((tc, i) => ({ + id: tc?.id || `toolu_${i + 1}`, + name: findExternalToolByName(externalToolRegistry, tc?.function?.name || tc?.name)?.namespacedName || tc?.function?.name || tc?.name, + arguments: normalizeToolArguments(tc?.function?.arguments ?? tc?.arguments) + })).filter((tc) => tc.name); + serialized.forEach((tc) => assistantToolCalls.set(tc.id, tc.name)); + if (serialized.length) parts.push({ type: 'text', text: `ASSISTANT: ${JSON.stringify(serialized)}` }); + } + if (role === 'tool') { + const text = normalizeTextContent(content); + if (text) { + const mapped = findExternalToolByName(externalToolRegistry, m?.name) + || findExternalToolByName(externalToolRegistry, assistantToolCalls.get(m?.tool_call_id)); + const toolName = mapped?.namespacedName || assistantToolCalls.get(m?.tool_call_id) || m?.name || `${EXTERNAL_TOOL_PREFIX}unknown`; + parts.push({ type: 'text', text: `TOOL_RESULT: ${JSON.stringify({ tool_call_id: m?.tool_call_id || toolName, name: toolName, content: text })}` }); + } + continue; + } + if (!content) continue; + if (typeof content === 'string') { + parts.push({ type: 'text', text: formatRoleLine(role, m?.name, content) }); + } else if (Array.isArray(content)) { + for (const part of content) { + if (!part) continue; + if (part.type === 'text') parts.push({ type: 'text', text: formatRoleLine(role, m?.name, part.text || '') }); + else if (part.type === 'image_url') { + const imageUrl = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url; + if (imageUrl) { + try { + const dataUri = await getImageDataUri(imageUrl); + parts.push({ type: 'file', mime: dataUri.split(';')[0].split(':')[1], url: dataUri, filename: 'image' }); + } catch (e) { logDebug('Skipping image', { error: e.message }); } + } + } + } + } + } + if (!parts.length) return res.status(400).json({ type: 'error', error: { type: 'invalid_request_error', message: 'messages must include at least one text message' } }); + + const systemWithGuard = buildSystemPrompt( + [systemChunks.join('\n\n'), externalToolContext.prompt].filter(Boolean).join('\n\n'), + requestParams.reasoning_effort, toolMode, internalToolContext.allowedToolNames + ); + await ensureBackend(config); + try { + await client.config.update({ body: { activeModel: { providerID: pID, modelID: mID } } }); + } catch (e) { logDebug('Failed to set active model', { error: e.message }); } + const sessionRes = await client.session.create(); + sessionId = sessionRes.data?.id; + if (!sessionId) throw new Error('Failed to create OpenCode session'); + + const promptParams = { + path: { id: sessionId }, + body: { + model: { providerID: pID, modelID: mID }, + system: systemWithGuard, + parts: externalToolContext.reminder ? [...parts, { type: 'text', text: externalToolContext.reminder }] : parts, + ...(requestParams.max_tokens && { max_tokens: requestParams.max_tokens }), + ...(requestParams.temperature !== undefined && { temperature: requestParams.temperature }), + ...(requestParams.top_p !== undefined && { top_p: requestParams.top_p }), + ...(requestParams.stop && { stop: requestParams.stop }) + } + }; + const toolOverrides = await getToolOverridesForMode(toolMode, internalToolContext); + if (toolOverrides && Object.keys(toolOverrides).length > 0) promptParams.body.tools = toolOverrides; + + const fullPromptText = parts.map((p) => p.text || '').join('\n\n'); + const messageId = `msg_${crypto.randomUUID().replace(/-/g, '').slice(0, 24)}`; + const inputTokens = estimateTokens(fullPromptText + (systemWithGuard || '')); + + const makeForcedMessagesToolCallRequester = () => createForcedToolCallRequester({ + mode: externalToolChoice.mode, + sessionId, + systemWithGuard, + requiredTool: externalToolChoice.requiredTool || externalToolRegistry[0]?.namespacedName, + providerID: pID, + modelID: mID, + toolOverrides, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + forbidThinkBlock: true + }); + let requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); + + const finalizeAnthropic = (content, reasoning, validatedToolCalls) => { + const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content)); + const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning)); + const publicCalls = toPublicToolCalls(validatedToolCalls); + // Map public tool calls back to Anthropic tool_use (preserve toolu_ ids from history when possible). + const anthropicTools = publicCalls.map((tc) => { + let input = {}; + try { input = JSON.parse(tc.function.arguments || '{}'); } catch { input = {}; } + return { id: tc.id, function: { name: tc.function.name, arguments: tc.function.arguments }, _input: input }; + }); + // Token counts are estimates (chars/4); the backend does not surface + // real usage or a truncation signal, so stop_reason is derived from + // tool calls only and never inferred from max_tokens. + const outputTokens = estimateTokens(safeContent + safeReasoning + JSON.stringify(anthropicTools)); + const stopReason = mapFinishToStopReason('stop', anthropicTools.length > 0); + return buildAnthropicMessage({ + messageId, model: publicModel, text: safeContent || '', + reasoning: safeReasoning || '', toolCalls: anthropicTools, + stopReason, inputTokens, outputTokens + }); + }; + + if (!stream) { + let content = ''; + let reasoning = ''; + let error = null; + for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt += 1) { + if (attempt > 1) { + try { await client.session.delete({ path: { id: sessionId } }); } catch {} + const r = await client.session.create(); + sessionId = r.data?.id; + promptParams.path.id = sessionId; + requestForcedMessagesToolCall = makeForcedMessagesToolCallRequester(); + await sleep(RETRY_BACKOFF_BASE_MS * attempt); + } + await promptWithTimeout(promptParams, REQUEST_TIMEOUT_MS); + const collected = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); + content = collected.content || ''; + reasoning = collected.reasoning || ''; + error = collected.error || null; + if (error && !content && !reasoning && attempt < RETRY_MAX_ATTEMPTS && isTransientUpstreamError(error)) continue; + break; + } + if (error && !content && !reasoning) { + const t = transformUpstreamError(error); + return res.status(502).json({ type: 'error', error: { type: 'api_error', message: t.error?.error?.message || t.error?.message || 'Upstream error' } }); + } + let parsed = externalToolRegistry.length > 0 ? parseExternalToolCallsFromText(externalToolRegistry, reasoning, content) : []; + if (parsed.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedMessagesToolCall(); + if (forcedResponse) { + content = forcedResponse.content || content; + reasoning = forcedResponse.reasoning || reasoning; + parsed = parseExternalToolCallsFromText(externalToolRegistry, reasoning, content); + } + } + const { validCalls } = finalizeValidatedToolCalls(parsed, externalToolRegistry); + return res.json(finalizeAnthropic(content, reasoning, validCalls)); + } + + // Stream: Anthropic SSE (message_start/content_block_*/message_delta/message_stop, no [DONE]) + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + keepaliveInterval = setInterval(() => { if (!res.destroyed) res.write(': keep-alive\n\n'); }, 15000); + const reqClosed = new Promise((resolve) => req.on('close', () => resolve(true))); + let streamedText = ''; + let streamedReasoning = ''; + let rawContent = ''; + let rawReasoning = ''; + const streamedToolCalls = []; + const filterContent = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: externalToolRegistry.length > 0 }); + const filterReasoning = createToolCallFilter({ disableTools: DISABLE_TOOLS, forceStrip: externalToolRegistry.length > 0 }); + const parseContent = createExternalToolCallStreamParser(externalToolRegistry); + const parseReason = createExternalToolCallStreamParser(externalToolRegistry); + let textBlockOpen = false; + let thinkingBlockOpen = false; + let nextBlockIndex = 0; + let textIndex = null; + let thinkingIndex = null; + const ensureTextIndex = () => { + if (textIndex === null) textIndex = nextBlockIndex++; + return textIndex; + }; + const ensureThinkingIndex = () => { + if (thinkingIndex === null) thinkingIndex = nextBlockIndex++; + return thinkingIndex; + }; + res.write(sseEvent('message_start', { type: 'message_start', message: { id: messageId, type: 'message', role: 'assistant', model: publicModel, content: [], stop_reason: null, usage: { input_tokens: inputTokens, output_tokens: 0 } } })); + const sendTextDelta = (delta) => { + if (!delta) return; + const idx = ensureTextIndex(); + if (!textBlockOpen) { + res.write(sseEvent('content_block_start', { type: 'content_block_start', index: idx, content_block: { type: 'text', text: '' } })); + textBlockOpen = true; + } + streamedText += delta; + res.write(sseEvent('content_block_delta', { type: 'content_block_delta', index: idx, delta: { type: 'text_delta', text: delta } })); + }; + const sendReasoningDelta = (delta) => { + if (!delta) return; + const idx = ensureThinkingIndex(); + if (!thinkingBlockOpen) { + res.write(sseEvent('content_block_start', { type: 'content_block_start', index: idx, content_block: { type: 'thinking', thinking: '', signature: '' } })); + thinkingBlockOpen = true; + } + streamedReasoning += delta; + res.write(sseEvent('content_block_delta', { type: 'content_block_delta', index: idx, delta: { type: 'thinking_delta', thinking: delta } })); + }; + const sendDelta = (delta, isReasoning = false) => { + if (!delta) return; + if (isReasoning) rawReasoning += delta; else rawContent += delta; + const parsedCalls = isReasoning ? parseReason(delta) : parseContent(delta); + parsedCalls.forEach((tc) => streamedToolCalls.push(tc)); + const filtered = isReasoning ? filterReasoning(delta) : filterContent(delta); + if (!filtered) return; + if (isReasoning) sendReasoningDelta(filtered); + else sendTextDelta(filtered); + }; + let collected = null; + const collectPromise = collectFromEvents(sessionId, REQUEST_TIMEOUT_MS, sendDelta, DEFAULT_EVENT_FIRST_DELTA_TIMEOUT_MS, DEFAULT_EVENT_IDLE_TIMEOUT_MS) + .catch((err) => ({ __error: err })); + client.session.prompt(promptParams).catch((err) => logDebug('Prompt error:', err.message)); + collected = await Promise.race([collectPromise, reqClosed.then(() => ({ __cancelled: true }))]); + if (collected?.__cancelled) { + if (keepaliveInterval) clearInterval(keepaliveInterval); + try { if (sessionId) await client.session.delete({ path: { id: sessionId } }); } catch (e) { logDebug('Failed to cleanup cancelled messages session', { error: e.message }); } + try { if (!res.destroyed) res.end(); } catch {} + return; + } + if (collected?.__error) { + const { content, reasoning } = await pollForAssistantResponse(sessionId, REQUEST_TIMEOUT_MS); + if (content && !rawContent) sendTextDelta(stripFunctionCallMarkup(content)); + if (reasoning && !rawReasoning) sendReasoningDelta(stripFunctionCallMarkup(reasoning)); + } + if (textBlockOpen) res.write(sseEvent('content_block_stop', { type: 'content_block_stop', index: textIndex })); + if (thinkingBlockOpen) res.write(sseEvent('content_block_stop', { type: 'content_block_stop', index: thinkingIndex })); + // Flush held buffers before final parse (mirrors /v1/chat/completions). + const flushedReasoningCalls = parseReason.flush ? parseReason.flush() : []; + const flushedContentCalls = parseContent.flush ? parseContent.flush() : []; + const flushedReasoningText = filterReasoning.flush ? filterReasoning.flush() : ''; + const flushedContentText = filterContent.flush ? filterContent.flush() : ''; + const finalReasoningText = rawReasoning + flushedReasoningText; + const finalContentText = rawContent + flushedContentText; + const parseStreamedToolCalls = () => { + if (externalToolRegistry.length === 0) return []; + const perChannel = [ + ...flushedReasoningCalls, + ...flushedContentCalls, + ...parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText, finalContentText) + ]; + if (perChannel.length > 0) return perChannel; + return parseExternalToolCallsFromText(externalToolRegistry, finalReasoningText + finalContentText); + }; + // Exclusive: incremental hits already describe the same markup that a + // full re-parse would find again, so never combine both (else 1 call -> 2 blocks). + let parsedToolCalls = streamedToolCalls.length > 0 + ? [...streamedToolCalls, ...flushedReasoningCalls, ...flushedContentCalls] + : parseStreamedToolCalls(); + if (parsedToolCalls.length === 0 && externalToolChoice.mode === 'required') { + const forcedResponse = await requestForcedMessagesToolCall(); + if (forcedResponse) { + parsedToolCalls = parseExternalToolCallsFromText( + externalToolRegistry, + forcedResponse.reasoning, + forcedResponse.content + ); + } + } + const { validCalls: finalValidated } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry); + let toolBlockIndex = nextBlockIndex; + for (const tc of toPublicToolCalls(finalValidated)) { + res.write(sseEvent('content_block_start', { type: 'content_block_start', index: toolBlockIndex, content_block: { type: 'tool_use', id: tc.id, name: tc.function.name, input: {} } })); + res.write(sseEvent('content_block_delta', { type: 'content_block_delta', index: toolBlockIndex, delta: { type: 'input_json_delta', partial_json: tc.function.arguments || '{}' } })); + res.write(sseEvent('content_block_stop', { type: 'content_block_stop', index: toolBlockIndex })); + toolBlockIndex += 1; + } + const outputTokens = estimateTokens(streamedText + streamedReasoning); + const stopReason = mapFinishToStopReason('stop', finalValidated.length > 0); + res.write(sseEvent('message_delta', { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: outputTokens } })); + res.write(sseEvent('message_stop', { type: 'message_stop' })); + if (keepaliveInterval) clearInterval(keepaliveInterval); + return res.end(); + } catch (error) { + if (keepaliveInterval) clearInterval(keepaliveInterval); + if (sessionId) { + try { await client.session.delete({ path: { id: sessionId } }); } catch (e) { logDebug('Failed to cleanup messages session on error', { error: e.message }); } + } + if (!res.headersSent) { + const t = transformUpstreamError(error); + return res.status(t.statusCode).json({ type: 'error', error: { type: 'api_error', message: t.error?.error?.message || t.error?.message || error.message } }); + } + try { + res.write(sseEvent('error', { type: 'error', error: { type: 'api_error', message: error.message } })); + } catch {} + return res.end(); + } + }, REQUEST_TIMEOUT_MS + 20000); + } catch (error) { + if (!res.headersSent) res.status(500).json({ type: 'error', error: { type: 'api_error', message: error.message } }); + } + }); + app.use((req, res) => { res.status(404).json({ error: { diff --git a/src/tool-runtime/registry.js b/src/tool-runtime/registry.js index 4164480..3abb625 100644 --- a/src/tool-runtime/registry.js +++ b/src/tool-runtime/registry.js @@ -33,10 +33,12 @@ export function normalizeToolDefinition(tool) { const definition = tool.function && typeof tool.function === 'object' ? tool.function : tool; const name = String(definition.name || '').trim(); if (!name) return null; + // Anthropic Messages shape uses `input_schema` instead of `parameters`. + const parameters = definition.parameters ?? definition.input_schema; return { name, description: definition.description, - parameters: definition.parameters, + parameters, enabled: definition.enabled, x_proxy_side_effect: definition.x_proxy_side_effect ?? tool.x_proxy_side_effect, x_proxy_risk_level: definition.x_proxy_risk_level ?? tool.x_proxy_risk_level, diff --git a/src/tool-runtime/router.js b/src/tool-runtime/router.js index f2af162..4915f29 100644 --- a/src/tool-runtime/router.js +++ b/src/tool-runtime/router.js @@ -11,6 +11,21 @@ export function normalizeExternalToolChoice(toolChoice, registry) { if (toolChoice === 'required') { return { mode: 'required', requiredTool: null }; } + // Anthropic Messages shape: { type: 'auto' | 'any' | 'tool' | 'none', name? }. + if (typeof toolChoice?.type === 'string' && !toolChoice?.function) { + const t = String(toolChoice.type).toLowerCase(); + if (t === 'auto') return { mode: 'auto', requiredTool: null }; + if (t === 'none') return { mode: 'none', requiredTool: null }; + if (t === 'any') return { mode: 'required', requiredTool: null }; + if (t === 'tool' && toolChoice.name) { + const mappedTool = findExternalToolByName(registry, toolChoice.name); + return { + mode: 'required', + requiredTool: mappedTool?.namespacedName || `${EXTERNAL_TOOL_PREFIX}${toolChoice.name}` + }; + } + if (t === 'tool') return { mode: 'required', requiredTool: null }; + } // Chat Completions sends { type:'function', function:{ name } }; the Responses API sends // { type:'function', name }. Accept both so a forced tool choice is not silently ignored. const requestedName = toolChoice?.function?.name || toolChoice?.name; diff --git a/tests/messages-anthropic.test.js b/tests/messages-anthropic.test.js new file mode 100644 index 0000000..baea2b0 --- /dev/null +++ b/tests/messages-anthropic.test.js @@ -0,0 +1,147 @@ +import request from 'supertest'; +import { jest } from '@jest/globals'; + +const sdkMocks = { + configProviders: jest.fn(async () => ({ + data: { + providers: [{ id: 'opencode', models: { 'muse-spark-1.3-contributor-free': { name: 'Muse Spark' }, 'kimi-k2.5': { name: 'Kimi' } } }] + } + })), + configUpdate: jest.fn(async () => ({})), + toolIds: jest.fn(async () => ({ data: [] })), + sessionCreate: jest.fn(async () => ({ data: { id: 'msg-session' } })), + sessionPrompt: jest.fn(async () => ({ data: { parts: [{ type: 'text', text: 'Hello!' }] } })), + sessionMessages: jest.fn(async () => ([{ info: { role: 'assistant', finish: 'stop' }, parts: [{ type: 'text', text: 'Hello!' }] }])), + sessionDelete: jest.fn(async () => ({})), + eventSubscribe: jest.fn(async () => ({ + stream: (async function* () { + yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: 'Hello' } }; + yield { type: 'message.part.updated', properties: { part: { type: 'text', sessionID: 'msg-session' }, delta: '!' } }; + yield { type: 'message.updated', properties: { info: { sessionID: 'msg-session', finish: 'stop' } } }; + })() + })) +}; + +jest.unstable_mockModule('https', () => ({ + default: { + get: jest.fn((url, options, callback) => { + const res = { statusCode: 200, headers: { 'content-type': 'image/png' }, on: jest.fn((event, handler) => { if (event === 'data') handler(Buffer.from('x')); if (event === 'end') handler(); }) }; + callback(res); + return { on: jest.fn(), destroy: jest.fn() }; + }) + } +})); + +jest.unstable_mockModule('http', () => ({ + default: { + get: jest.fn((url, options, callback) => { + // checkHealth path -> 200; image downloads -> empty + if (String(url).includes('/health')) { + const res = { statusCode: 200, headers: {}, on: jest.fn() }; + callback(res); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + } + const response = { statusCode: 200, headers: {}, on: jest.fn() }; + callback(response); + return { on: jest.fn(), destroy: jest.fn(), setTimeout: jest.fn() }; + }) + } +})); + +jest.unstable_mockModule('@opencode-ai/sdk', () => ({ + createOpencodeClient: jest.fn(() => ({ + config: { providers: sdkMocks.configProviders, update: sdkMocks.configUpdate }, + tool: { ids: sdkMocks.toolIds }, + session: { create: sdkMocks.sessionCreate, prompt: sdkMocks.sessionPrompt, messages: sdkMocks.sessionMessages, delete: sdkMocks.sessionDelete }, + event: { subscribe: sdkMocks.eventSubscribe } + })) +})); + +const { createApp } = await import('../src/proxy.js'); +import { + validateMessagesRequest, + anthropicMessagesToChatMessages, + anthropicToolsToChatTools, + anthropicToolChoiceToChat, + mapFinishToStopReason +} from '../src/converters/anthropic.js'; + +describe('Anthropic /v1/messages converters', () => { + test('validation requires model/max_tokens/messages', () => { + expect(validateMessagesRequest({})).not.toBeNull(); + expect(validateMessagesRequest({ model: 'm', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] })).toBeNull(); + expect(validateMessagesRequest({ model: 'm', max_tokens: 10, messages: [{ role: 'assistant', content: 'hi' }] }).statusCode).toBe(400); + }); + test('tool_use/tool_result round-trip preserves toolu_ id', () => { + const chat = anthropicMessagesToChatMessages([ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_abc', name: 'weather_lookup', input: { city: 'Tokyo' } }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_abc', content: 'sunny' }] } + ]); + const flat = JSON.stringify(chat); + expect(flat).toContain('toolu_abc'); + expect(flat).toContain('tool_call_id'); + }); + test('tools input_schema and tool_choice mapping', () => { + expect(anthropicToolsToChatTools([{ name: 'w', input_schema: { type: 'object' } }])[0].function.parameters).toEqual({ type: 'object' }); + expect(anthropicToolChoiceToChat({ type: 'any' })).toBe('required'); + expect(anthropicToolChoiceToChat({ type: 'tool', name: 'w' })).toEqual({ type: 'function', function: { name: 'w' } }); + expect(mapFinishToStopReason('stop', true)).toBe('tool_use'); + }); +}); + +describe('POST /v1/messages', () => { + let app; + beforeEach(() => { + jest.clearAllMocks(); + const config = { PORT: 10000, API_KEY: 'test-key', OPENCODE_SERVER_URL: 'http://127.0.0.1:10001', REQUEST_TIMEOUT_MS: 5000, DISABLE_TOOLS: true, DEBUG: false }; + app = createApp(config).app; + }); + test('non-stream returns Anthropic message shape', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + expect(res.body.type).toBe('message'); + expect(res.body.role).toBe('assistant'); + expect(Array.isArray(res.body.content)).toBe(true); + expect(res.body.stop_reason).toBe('end_turn'); + expect(res.body.usage.input_tokens).toBeGreaterThanOrEqual(0); + }); + test('401 without key', async () => { + const res = await request(app).post('/v1/messages') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(401); + expect(res.body.type).toBe('error'); + }); + test('tiny max_tokens does not fake max_tokens stop_reason (usage is estimated)', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + // Backend does not surface truncation; stop_reason comes from tool calls only. + expect(res.body.stop_reason).toBe('end_turn'); + }); + test('missing max_tokens -> 400', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(400); + }); + test('x-api-key auth works', async () => { + const res = await request(app).post('/v1/messages') + .set('x-api-key', 'test-key') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 10, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + }); + test('stream emits message_start/delta/stop without [DONE]', async () => { + const res = await request(app).post('/v1/messages') + .set('Authorization', 'Bearer test-key') + .set('Accept', 'text/event-stream') + .send({ model: 'opencode/muse-spark-1.3-contributor-free', max_tokens: 50, stream: true, messages: [{ role: 'user', content: 'hi' }] }); + expect(res.statusCode).toBe(200); + expect(res.text).toContain('event: message_start'); + expect(res.text).toContain('event: message_stop'); + expect(res.text).not.toContain('[DONE]'); + }); +});