diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8beda4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,65 @@ +name: Bug report +description: Something isn't working as expected +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. Please fill in as much detail as you can. + + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug. + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: How do we reproduce the issue? + placeholder: | + 1. Start opencode with the plugin loaded + 2. Send a request to POST /v1/chat/completions with ... + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behaviour + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: request + attributes: + label: Request / response (if applicable) + description: Paste the curl command or request body and the response you received. + render: bash + + - type: input + id: version + attributes: + label: opencode-llm-proxy version + placeholder: "e.g. 1.6.1" + validations: + required: true + + - type: input + id: runtime + attributes: + label: Runtime and OS + placeholder: "e.g. Node.js 22, macOS 14 / Bun 1.2, Ubuntu 24.04" + validations: + required: true + + - type: input + id: provider + attributes: + label: Provider / model + placeholder: "e.g. github-copilot/claude-sonnet-4.6" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..671cd64 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,43 @@ +name: Feature request +description: Suggest a new feature or improvement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! Please describe the use case clearly so we can understand what you need. + + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: Describe the situation where this would be useful. + placeholder: "e.g. I use the Vercel AI SDK and currently have to..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What would you like to see added or changed? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives you've considered + description: Any workarounds you're using today? + + - type: dropdown + id: api_format + attributes: + label: Which API format does this relate to? (if any) + options: + - OpenAI Chat Completions + - OpenAI Responses API + - Anthropic Messages API + - Google Gemini + - All / general + - Not API-format specific diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..575fbeb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing + +Thanks for your interest in contributing to opencode-llm-proxy. + +## Getting started + +```bash +git clone https://github.com/KochC/opencode-llm-proxy.git +cd opencode-llm-proxy +npm install +``` + +Run the tests: + +```bash +npm test +``` + +Run the linter: + +```bash +npm run lint +``` + +## How to contribute + +### Reporting bugs + +Open a [bug report](https://github.com/KochC/opencode-llm-proxy/issues/new?template=bug_report.yml). Include: + +- What you did +- What you expected +- What actually happened +- Your Node.js / Bun version and OS + +### Suggesting features + +Open a [feature request](https://github.com/KochC/opencode-llm-proxy/issues/new?template=feature_request.yml) describing the use case. + +### Submitting a pull request + +1. Fork the repo and create a branch from `dev` (not `main`) +2. Make your changes +3. Add or update tests in `index.test.js` — all 112+ tests must pass +4. Lint passes: `npm run lint` +5. Commit using [Conventional Commits](https://www.conventionalcommits.org/): + - `fix:` for bug fixes (triggers a patch release) + - `feat:` for new features (triggers a minor release) + - `docs:` / `chore:` / `test:` for everything else (no release) +6. Open a PR against the `dev` branch + +## Branch model + +``` +dev ──► main ──► npm (via Release Please) +``` + +- All work goes on `dev` +- `main` is release-only — only Release Please PRs merge directly there +- Do not open PRs against `main` + +## Tests + +Tests use the Node.js built-in test runner — no external framework needed. + +```bash +node --test # run once +node --test --watch # watch mode +node --test --experimental-test-coverage # with coverage +``` + +Tests mock the OpenCode SDK client entirely — no real LLM calls are made. + +## Code style + +ESLint enforces style. Run `npm run lint` before pushing. The config is in `eslint.config.js`. + +Key conventions in the codebase: + +- Pure functions are exported for testability (`normalizeMessages`, `buildPrompt`, etc.) +- Each API format (OpenAI, Anthropic, Gemini) has its own section in `index.js` +- Error responses mirror the format of the target API (OpenAI errors for `/v1/*`, Anthropic errors for `/v1/messages`, etc.) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bbf87e4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 KochC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +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. diff --git a/README.md b/README.md index 2077d16..98acc45 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ [![CI](https://github.com/KochC/opencode-llm-proxy/actions/workflows/ci.yml/badge.svg)](https://github.com/KochC/opencode-llm-proxy/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -**One local endpoint. Every model you have access to. Any API format.** +**One local endpoint. Every model you have access to. Any API format. Tool calling included.** opencode-llm-proxy is an [OpenCode](https://opencode.ai) plugin that starts a local HTTP server on `http://127.0.0.1:4010`. It translates between the API format your tool speaks and whichever LLM provider OpenCode has configured — so you never reconfigure the same models twice. ``` -Your tool (OpenAI / Anthropic / Gemini SDK) +Your tool (OpenAI / Anthropic / Gemini SDK, coding agent, etc.) │ ▼ http://127.0.0.1:4010 opencode-llm-proxy @@ -19,7 +19,7 @@ Your tool (OpenAI / Anthropic / Gemini SDK) GitHub Copilot · Anthropic · Gemini · Ollama · OpenRouter · Bedrock · … ``` -**Supported API formats — all with streaming:** +**Supported API formats — all with streaming and [tool/function calling](#tool-calling):** | Format | Endpoint | |---|---| @@ -28,6 +28,24 @@ Your tool (OpenAI / Anthropic / Gemini SDK) | Anthropic Messages API | `POST /v1/messages` | | Google Gemini | `POST /v1beta/models/:model:generateContent` | +**✨ Tool calling works with all four formats** — point a coding agent (Claude Code, Cursor, Continue, Cline, your own agent loop, ...) at the proxy and its `tools`/`tool_choice` calls are translated through to whatever model OpenCode has configured, with a real `tool_calls` / `tool_use` / `functionCall` response handed back. See [Tool calling](#tool-calling). + +--- + +## Contents + +- [Why](#why) +- [Quickstart](#quickstart) +- [Install](#install) +- [Configuration](#configuration) +- [Tool calling](#tool-calling) +- [Using with SDKs and tools](#using-with-sdks-and-tools) +- [Finding model IDs](#finding-model-ids) +- [API reference](#api-reference) +- [How it works](#how-it-works) +- [Limitations](#limitations) +- [License](#license) + --- ## Why @@ -41,6 +59,7 @@ Most LLM tools speak exactly one API dialect. OpenCode already manages connectio - You want to **swap models without code changes**. Your app talks to the proxy; you change the model in OpenCode config. - You want to **share your models on a LAN**. Expose the proxy on `0.0.0.0` and give teammates the URL. - You use the **Anthropic SDK** but want to route through GitHub Copilot or Bedrock. No code change in the SDK — just point it at the proxy. +- You're building or running a **coding agent** that needs real tool/function calling (read files, run shell commands, etc.) against whatever model OpenCode has configured. See [Tool calling](#tool-calling). --- @@ -110,6 +129,8 @@ curl -o .opencode/plugins/llm-proxy.js \ https://raw.githubusercontent.com/KochC/opencode-llm-proxy/main/index.js ``` +> Copying just `index.js` works for everything except [tool calling](#tool-calling), which also needs `mcp-tool-bridge.js` alongside it. Use the npm plugin install method if you want tool calling. + --- ## Configuration @@ -120,6 +141,7 @@ curl -o .opencode/plugins/llm-proxy.js \ | `OPENCODE_LLM_PROXY_PORT` | `4010` | TCP port. | | `OPENCODE_LLM_PROXY_TOKEN` | _(unset)_ | Bearer token required on every request. Unset = no auth. | | `OPENCODE_LLM_PROXY_CORS_ORIGIN` | `*` | `Access-Control-Allow-Origin` value for browser clients. | +| `OPENCODE_LLM_PROXY_TOOL_BRIDGE_POOL_SIZE` | `8` | Max concurrent in-flight requests using [tool calling](#tool-calling). | ```bash OPENCODE_LLM_PROXY_HOST=0.0.0.0 \ @@ -129,6 +151,67 @@ opencode --- +## Tool calling + +The proxy supports real tool/function calling on **all four API formats** — OpenAI function tools (`tools` on `/v1/chat/completions` and `/v1/responses`), Anthropic tools (`tools` on `/v1/messages`), and Gemini function declarations (`tools` on `:generateContent`/`:streamGenerateContent`). This is what lets coding agents and other tool-using clients work through the proxy, not just plain chat. + +```bash +curl http://127.0.0.1:4010/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "model": "github-copilot/claude-sonnet-4.6", + "messages": [{"role": "user", "content": "What is the weather in NYC?"}], + "tools": [{ + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"] + } + } + }] + }' +``` + +```json +{ + "choices": [{ + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": "call_...", + "type": "function", + "function": { "name": "get_weather", "arguments": "{\"city\":\"NYC\"}" } + }] + } + }] +} +``` + +Send the tool's result back on your next request (`role: "tool"` / `tool_result` / `functionResponse`, per your API's convention) alongside the full conversation history, same as any other multi-turn request — the proxy is stateless between calls either way. + +### How tool calling works under the hood + +OpenCode's own agent loop always executes tools itself, server-side, so there's no native concept of a "client-executed" tool call to hand off to. To bridge that gap, when a request includes `tools`: + +1. The proxy dynamically registers a small local [MCP](https://opencode.ai/docs/mcp-servers/) server whose tool list is exactly your declared tool schemas (see `mcp-tool-bridge.js`). +2. Only those tools are enabled for that one prompt call — every built-in OpenCode tool stays disabled, same as always. +3. As soon as the model proposes calling one of your tools, the proxy immediately aborts the OpenCode session (before the bridge's no-op handler is ever consulted) and translates the captured call name + arguments into your API's tool-call shape — `tool_calls` (OpenAI), `tool_use` (Anthropic), or a `functionCall` part (Gemini) — instead of a text answer. + +### Notes and current limitations + +- One tool call per turn — parallel/multiple simultaneous tool calls aren't supported. +- `tool_choice: "none"` (OpenAI/Gemini `mode: "NONE"`/Anthropic `type: "none"`) disables tool calling for that request; forcing a specific named tool is supported. +- Bridge servers are reused from a small fixed-size pool (`px_tools_0`, `px_tools_1`, ...) rather than registered fresh per request, since OpenCode's server API has no endpoint to deregister an MCP server once added. Configure the pool size with `OPENCODE_LLM_PROXY_TOOL_BRIDGE_POOL_SIZE` (default `8`) if you expect more than 8 concurrent in-flight tool-calling requests. +- The bridge process is spawned with `node`, so `node` must be on `PATH` wherever OpenCode is running. + +--- + ## Using with SDKs and tools ### OpenAI SDK (JS/TS) @@ -310,18 +393,18 @@ x-opencode-provider: anthropic Returns all models from all configured providers in OpenAI list format. ### POST /v1/chat/completions -OpenAI Chat Completions. Required fields: `model`, `messages`. Optional: `stream`, `temperature`, `max_tokens`. +OpenAI Chat Completions. Required fields: `model`, `messages`. Optional: `stream`, `temperature`, `max_tokens`, `tools`, `tool_choice`. ### POST /v1/responses -OpenAI Responses API. Required fields: `model`, `input`. Optional: `instructions`, `stream`, `max_output_tokens`. +OpenAI Responses API. Required fields: `model`, `input`. Optional: `instructions`, `stream`, `max_output_tokens`, `tools`, `tool_choice`. ### POST /v1/messages -Anthropic Messages API. Required fields: `model`, `messages`. Optional: `system`, `max_tokens`, `stream`. +Anthropic Messages API. Required fields: `model`, `messages`. Optional: `system` (string or array of `{type: "text", text: string}` content blocks), `max_tokens`, `stream`, `tools`, `tool_choice`. Errors are returned in Anthropic format: `{ "type": "error", "error": { "type": "...", "message": "..." } }`. ### POST /v1beta/models/:model:generateContent -Google Gemini non-streaming. Model name in URL path. Required field: `contents`. Optional: `systemInstruction`, `generationConfig`. +Google Gemini non-streaming. Model name in URL path. Required field: `contents`. Optional: `systemInstruction`, `generationConfig`, `tools`, `toolConfig`. ### POST /v1beta/models/:model:streamGenerateContent Same as above, returns newline-delimited JSON stream. @@ -345,9 +428,9 @@ Streaming uses OpenCode's `client.event.subscribe()` SSE stream. Text deltas are ## Limitations - Text only — image, audio, and file inputs are ignored -- No tool/function calling — all OpenCode tools are disabled for proxy sessions - No cross-request session state — send full conversation history on every request - Temperature and max tokens are advisory (passed as system prompt hints) +- Tool calling supports one call per turn — see [Tool calling](#tool-calling) above --- diff --git a/index.js b/index.js index e3532e9..e22e5e6 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,7 @@ +import { fileURLToPath } from "node:url" + const STATE_KEY = "__opencodeOpenAIProxyState" +const BRIDGE_SCRIPT_PATH = fileURLToPath(new URL("./mcp-tool-bridge.js", import.meta.url)) function getState() { if (!globalThis[STATE_KEY]) { @@ -114,11 +117,34 @@ export function toTextContent(content) { } export function normalizeMessages(messages) { + const toolNameByCallId = new Map() + return messages - .map((message) => ({ - role: message.role, - content: toTextContent(message.content).trim(), - })) + .map((message) => { + if (message.role === "assistant" && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { + const baseText = toTextContent(message.content).trim() + const callsText = message.tool_calls + .map((call) => { + const name = call.function?.name ?? call.name ?? "unknown_tool" + const args = call.function?.arguments ?? "" + if (call.id) toolNameByCallId.set(call.id, name) + return `[Called tool ${name} with arguments ${args}]` + }) + .join("\n") + return { role: message.role, content: [baseText, callsText].filter(Boolean).join("\n\n") } + } + + if (message.role === "tool") { + const name = toolNameByCallId.get(message.tool_call_id) ?? "tool" + const resultText = toTextContent(message.content).trim() + return { role: "tool", content: `[Result from tool ${name}]: ${resultText}` } + } + + return { + role: message.role, + content: toTextContent(message.content).trim(), + } + }) .filter((message) => message.content.length > 0) } @@ -129,8 +155,22 @@ export function normalizeResponseInput(input) { if (!Array.isArray(input)) return [] + const toolNameByCallId = new Map() + return input .map((item) => { + if (item?.type === "function_call") { + const name = item.name ?? "unknown_tool" + if (item.call_id) toolNameByCallId.set(item.call_id, name) + return { role: "assistant", content: `[Called tool ${name} with arguments ${item.arguments ?? ""}]` } + } + + if (item?.type === "function_call_output") { + const name = toolNameByCallId.get(item.call_id) ?? "tool" + const output = typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? "") + return { role: "tool", content: `[Result from tool ${name}]: ${output}` } + } + const role = item.role ?? item.type ?? "user" if (typeof item.content === "string") { return { role, content: item.content.trim() } @@ -227,7 +267,28 @@ export function extractAssistantText(parts) { .trim() } -async function executePrompt(client, request, model, messages, system) { +async function executePrompt(client, request, model, messages, system, callerTools = []) { + if (Array.isArray(callerTools) && callerTools.length > 0) { + // Tool-aware path: must watch the event stream (via runAgentTurn) rather than + // block on session.prompt, so we can intercept a proposed tool call instead of + // letting OpenCode's agent loop run to a final text answer. + const result = await runAgentTurn(client, model, messages, system, callerTools, () => {}) + return { + content: result.content, + toolCall: result.toolCall, + request, + sessionID: result.sessionID, + completion: { + data: { + info: { + finish: result.finish, + tokens: result.tokens, + }, + }, + }, + } + } + const tools = await getDisabledTools(client) const session = await client.session.create({ body: { @@ -263,77 +324,48 @@ async function executePrompt(client, request, model, messages, system) { return { content, + toolCall: null, completion, request, sessionID: session.data.id, } } -async function executePromptStreaming(client, model, messages, system, onChunk) { - const tools = await getDisabledTools(client) - const session = await client.session.create({ - body: { title: `Proxy: ${model.id}` }, - }) - const sessionID = session.data.id - const prompt = buildPrompt(messages) - - // Subscribe to the event stream before sending the prompt so we don't miss events. - const { stream } = await client.event.subscribe() - - await client.session.promptAsync({ - path: { id: sessionID }, - body: { - model: { providerID: model.providerID, modelID: model.modelID }, - system, - tools, - parts: [{ type: "text", text: prompt }], - }, - }) - - let errorMessage = null - - for await (const event of stream) { - if (event.type === "message.part.updated") { - const part = event.properties?.part - const delta = event.properties?.delta - if ( - part?.sessionID === sessionID && - part?.type === "text" && - typeof delta === "string" && - delta.length > 0 - ) { - onChunk(delta) - } - } else if (event.type === "session.error") { - if (!event.properties?.sessionID || event.properties.sessionID === sessionID) { - errorMessage = event.properties?.error?.message ?? "Model call failed." - } - } else if (event.type === "session.idle") { - if (event.properties?.sessionID === sessionID) { - break - } - } - } - - if (errorMessage) { - throw new Error(errorMessage) - } - - // Fetch final message to get token usage. - const messages_ = await client.session.messages({ path: { id: sessionID } }) - const assistantMsg = (messages_.data ?? []) - .filter((m) => m.role === "assistant") - .at(-1) - +async function executePromptStreaming(client, model, messages, system, onChunk, callerTools = []) { + const result = await runAgentTurn(client, model, messages, system, callerTools, onChunk) return { - sessionID, - tokens: assistantMsg?.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - finish: assistantMsg?.finish, + sessionID: result.sessionID, + tokens: result.tokens, + finish: result.finish, + toolCall: result.toolCall, } } function createChatCompletionResponse(result, model) { const now = Math.floor(Date.now() / 1000) + const tokensIn = result.completion.data.info?.tokens?.input ?? 0 + const tokensOut = result.completion.data.info?.tokens?.output ?? 0 + + const message = result.toolCall + ? { + role: "assistant", + content: null, + tool_calls: [ + { + id: result.toolCall.id, + type: "function", + function: { + name: result.toolCall.name, + arguments: JSON.stringify(result.toolCall.arguments ?? {}), + }, + }, + ], + } + : { + role: "assistant", + content: result.content, + } + return { id: `chatcmpl_${crypto.randomUUID().replace(/-/g, "")}`, object: "chat.completion", @@ -342,19 +374,14 @@ function createChatCompletionResponse(result, model) { choices: [ { index: 0, - finish_reason: mapFinishReason(result.completion.data.info?.finish), - message: { - role: "assistant", - content: result.content, - }, + finish_reason: result.toolCall ? "tool_calls" : mapFinishReason(result.completion.data.info?.finish), + message, }, ], usage: { - prompt_tokens: result.completion.data.info?.tokens?.input ?? 0, - completion_tokens: result.completion.data.info?.tokens?.output ?? 0, - total_tokens: - (result.completion.data.info?.tokens?.input ?? 0) + - (result.completion.data.info?.tokens?.output ?? 0), + prompt_tokens: tokensIn, + completion_tokens: tokensOut, + total_tokens: tokensIn + tokensOut, }, } } @@ -363,28 +390,41 @@ function createResponsesApiResponse(result, model) { const tokensIn = result.completion.data.info?.tokens?.input ?? 0 const tokensOut = result.completion.data.info?.tokens?.output ?? 0 + const output = result.toolCall + ? [ + { + id: `fc_${crypto.randomUUID().replace(/-/g, "")}`, + type: "function_call", + call_id: result.toolCall.id, + name: result.toolCall.name, + arguments: JSON.stringify(result.toolCall.arguments ?? {}), + status: "completed", + }, + ] + : [ + { + id: `msg_${crypto.randomUUID().replace(/-/g, "")}`, + type: "message", + status: "completed", + role: "assistant", + content: [ + { + type: "output_text", + text: result.content, + annotations: [], + }, + ], + }, + ] + return { id: `resp_${crypto.randomUUID().replace(/-/g, "")}`, object: "response", created_at: Math.floor(Date.now() / 1000), status: "completed", model: model.id, - output: [ - { - id: `msg_${crypto.randomUUID().replace(/-/g, "")}`, - type: "message", - status: "completed", - role: "assistant", - content: [ - { - type: "output_text", - text: result.content, - annotations: [], - }, - ], - }, - ], - output_text: result.content, + output, + output_text: result.toolCall ? "" : result.content, parallel_tool_calls: false, reasoning: { effort: result.request.reasoning?.effort ?? null, @@ -440,6 +480,314 @@ async function getDisabledTools(client) { return state.toolOffSwitch } +// --------------------------------------------------------------------------- +// Tool calling support +// +// OpenCode's own agent loop always executes tools itself, server-side, and has +// no concept of a "client-executed" tool call. To offer OpenAI/Anthropic/Gemini +// style tool calling (propose a call, hand control back to the caller, resume +// once they supply a result) we: +// +// 1. Dynamically register a tiny local MCP server ("bridge") whose tool list +// is exactly the caller's declared tool schemas (see mcp-tool-bridge.js). +// 2. Enable only those tool IDs for this one prompt call. +// 3. Watch OpenCode's event stream. As soon as the model proposes calling one +// of the bridge tools, the full call (name + arguments) is already present +// on the event (see ToolStatePending in OpenCode's SDK types) - we grab it +// and immediately abort the session before the bridge's harmless no-op +// tools/call handler would ever matter. +// 4. Translate the captured call into the caller's expected tool-call shape. +// +// Bridge servers are reused from a small fixed-size pool of slot names (rather +// than registered fresh per request) since OpenCode's server API exposes no way +// to remove/deregister an MCP server once added. +// --------------------------------------------------------------------------- + +function getToolBridgeState() { + const state = getState() + if (!state.toolBridge) { + const configured = Number.parseInt(process.env.OPENCODE_LLM_PROXY_TOOL_BRIDGE_POOL_SIZE ?? "", 10) + const poolSize = Number.isFinite(configured) && configured > 0 ? configured : 8 + state.toolBridge = { + freeSlots: Array.from({ length: poolSize }, (_, i) => `px_tools_${i}`), + waiters: [], + } + } + return state.toolBridge +} + +async function acquireBridgeSlot() { + const bridgeState = getToolBridgeState() + if (bridgeState.freeSlots.length > 0) { + return bridgeState.freeSlots.shift() + } + return new Promise((resolve) => { + bridgeState.waiters.push(resolve) + }) +} + +function releaseBridgeSlot(slotName) { + const bridgeState = getToolBridgeState() + if (bridgeState.waiters.length > 0) { + const resolve = bridgeState.waiters.shift() + resolve(slotName) + } else { + bridgeState.freeSlots.push(slotName) + } +} + +export function sanitizeToolName(name, seen = new Set()) { + let sanitized = String(name ?? "") + .replace(/[^a-zA-Z0-9_]/g, "_") + .slice(0, 60) + if (!sanitized) sanitized = "tool" + if (!/^[a-zA-Z_]/.test(sanitized)) sanitized = `t_${sanitized}` + + let candidate = sanitized + let suffix = 2 + while (seen.has(candidate)) { + candidate = `${sanitized}_${suffix}` + suffix++ + } + seen.add(candidate) + return candidate +} + +function normalizeParameters(parameters) { + if (parameters && typeof parameters === "object") return parameters + return { type: "object", properties: {} } +} + +export function parseOpenAITools(body) { + const list = [] + if (Array.isArray(body?.tools)) { + for (const entry of body.tools) { + if (!entry || entry.type !== "function") continue + // Chat Completions nests fields under `function`; the Responses API uses a flat shape. + const fn = entry.function ?? entry + if (typeof fn.name === "string" && fn.name) { + list.push({ + name: fn.name, + description: typeof fn.description === "string" ? fn.description : "", + parameters: normalizeParameters(fn.parameters), + }) + } + } + } else if (Array.isArray(body?.functions)) { + // Legacy (pre-2023-08) OpenAI `functions` field. + for (const fn of body.functions) { + if (fn && typeof fn.name === "string" && fn.name) { + list.push({ + name: fn.name, + description: typeof fn.description === "string" ? fn.description : "", + parameters: normalizeParameters(fn.parameters), + }) + } + } + } + return list +} + +export function applyOpenAIToolChoice(tools, toolChoice) { + if (toolChoice === "none") return [] + if (toolChoice && typeof toolChoice === "object") { + const name = toolChoice.function?.name ?? toolChoice.name + if (toolChoice.type === "function" && name) { + return tools.filter((tool) => tool.name === name) + } + } + return tools +} + +export function parseAnthropicTools(body) { + const list = [] + if (Array.isArray(body?.tools)) { + for (const tool of body.tools) { + if (tool && typeof tool.name === "string" && tool.name) { + list.push({ + name: tool.name, + description: typeof tool.description === "string" ? tool.description : "", + parameters: normalizeParameters(tool.input_schema), + }) + } + } + } + return list +} + +export function applyAnthropicToolChoice(tools, toolChoice) { + if (toolChoice?.type === "none") return [] + if (toolChoice?.type === "tool" && toolChoice.name) { + return tools.filter((tool) => tool.name === toolChoice.name) + } + return tools +} + +export function parseGeminiTools(body) { + const list = [] + if (Array.isArray(body?.tools)) { + for (const toolGroup of body.tools) { + const declarations = Array.isArray(toolGroup?.functionDeclarations) ? toolGroup.functionDeclarations : [] + for (const decl of declarations) { + if (decl && typeof decl.name === "string" && decl.name) { + list.push({ + name: decl.name, + description: typeof decl.description === "string" ? decl.description : "", + parameters: normalizeParameters(decl.parameters), + }) + } + } + } + } + return list +} + +export function applyGeminiToolChoice(tools, toolConfig) { + const mode = toolConfig?.functionCallingConfig?.mode + if (mode === "NONE") return [] + const allowed = toolConfig?.functionCallingConfig?.allowedFunctionNames + if (Array.isArray(allowed) && allowed.length > 0) { + return tools.filter((tool) => allowed.includes(tool.name)) + } + return tools +} + +async function registerToolBridge(client, tools) { + const slotName = await acquireBridgeSlot() + const seen = new Set() + const nameMap = new Map() // full bridge tool ID ("_") -> original caller-facing name + const bridgeTools = tools.map((tool) => { + const sanitized = sanitizeToolName(tool.name, seen) + nameMap.set(`${slotName}_${sanitized}`, tool.name) + return { name: sanitized, description: tool.description, parameters: tool.parameters } + }) + + try { + // Force a fresh respawn so the bridge process picks up this request's tool schema. + await client.mcp.disconnect({ path: { name: slotName } }) + } catch { + // Not previously connected; nothing to do. + } + + await client.mcp.add({ + body: { + name: slotName, + config: { + type: "local", + command: ["node", BRIDGE_SCRIPT_PATH], + environment: { + OPENCODE_LLM_PROXY_BRIDGE_TOOLS: JSON.stringify(bridgeTools), + }, + timeout: 10000, + }, + }, + }) + + const toolIDs = bridgeTools.map((tool) => `${slotName}_${tool.name}`) + return { slotName, toolIDs, nameMap } +} + +function releaseToolBridge(bridge) { + if (bridge) releaseBridgeSlot(bridge.slotName) +} + +async function runAgentTurn(client, model, messages, system, callerTools, onChunk) { + const baseTools = await getDisabledTools(client) + let toolsMap = baseTools + let bridge = null + + if (Array.isArray(callerTools) && callerTools.length > 0) { + bridge = await registerToolBridge(client, callerTools) + toolsMap = { ...baseTools } + for (const id of bridge.toolIDs) toolsMap[id] = true + } + + const session = await client.session.create({ body: { title: `Proxy: ${model.id}` } }) + const sessionID = session.data.id + const prompt = buildPrompt(messages) + const toolIDSet = bridge ? new Set(bridge.toolIDs) : null + + // Subscribe to the event stream before sending the prompt so we don't miss events. + const { stream } = await client.event.subscribe() + + await client.session.promptAsync({ + path: { id: sessionID }, + body: { + model: { providerID: model.providerID, modelID: model.modelID }, + system, + tools: toolsMap, + parts: [{ type: "text", text: prompt }], + }, + }) + + let errorMessage = null + let content = "" + let toolCall = null + + try { + for await (const event of stream) { + if (event.type === "message.part.updated") { + const part = event.properties?.part + const delta = event.properties?.delta + + if ( + part?.sessionID === sessionID && + part?.type === "text" && + typeof delta === "string" && + delta.length > 0 + ) { + content += delta + onChunk?.(delta) + } else if ( + toolIDSet && + part?.sessionID === sessionID && + part?.type === "tool" && + toolIDSet.has(part.tool) && + (part.state?.status === "pending" || part.state?.status === "running") + ) { + toolCall = { + id: part.callID, + name: bridge.nameMap.get(part.tool) ?? part.tool, + arguments: part.state.input ?? {}, + } + try { + await client.session.abort({ path: { id: sessionID } }) + } catch { + // Best effort - we're ending our own read loop regardless. + } + break + } + } else if (event.type === "session.error") { + if (!event.properties?.sessionID || event.properties.sessionID === sessionID) { + errorMessage = event.properties?.error?.message ?? "Model call failed." + } + break + } else if (event.type === "session.idle") { + if (event.properties?.sessionID === sessionID) { + break + } + } + } + } finally { + releaseToolBridge(bridge) + } + + if (errorMessage && !toolCall) { + throw new Error(errorMessage) + } + + const messagesResult = await client.session.messages({ path: { id: sessionID } }) + const assistantMsg = (messagesResult.data ?? []).filter((m) => m.role === "assistant").at(-1) + + return { + sessionID, + content, + toolCall, + tokens: assistantMsg?.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: toolCall ? "tool_calls" : assistantMsg?.finish, + } +} + async function listModels(client) { const result = await client.config.providers() const payload = result.data @@ -575,6 +923,8 @@ function createModelResponse(models) { // --------------------------------------------------------------------------- export function normalizeAnthropicMessages(messages) { + const toolNameByUseId = new Map() + return messages .map((message) => { let content = "" @@ -582,8 +932,30 @@ export function normalizeAnthropicMessages(messages) { content = message.content.trim() } else if (Array.isArray(message.content)) { content = message.content - .filter((block) => block && block.type === "text" && typeof block.text === "string") - .map((block) => block.text.trim()) + .map((block) => { + if (!block) return "" + if (block.type === "text" && typeof block.text === "string") { + return block.text.trim() + } + if (block.type === "tool_use") { + if (block.id) toolNameByUseId.set(block.id, block.name) + return `[Called tool ${block.name} with arguments ${JSON.stringify(block.input ?? {})}]` + } + if (block.type === "tool_result") { + const name = toolNameByUseId.get(block.tool_use_id) ?? "tool" + let resultText = "" + if (typeof block.content === "string") { + resultText = block.content + } else if (Array.isArray(block.content)) { + resultText = block.content + .filter((inner) => inner && inner.type === "text" && typeof inner.text === "string") + .map((inner) => inner.text) + .join("\n\n") + } + return `[Result from tool ${name}]: ${resultText}` + } + return "" + }) .filter(Boolean) .join("\n\n") } @@ -592,6 +964,22 @@ export function normalizeAnthropicMessages(messages) { .filter((message) => message.content.length > 0) } +export function normalizeAnthropicSystem(system) { + if (typeof system === "string") { + const trimmed = system.trim() + return trimmed || null + } + if (Array.isArray(system)) { + const text = system + .filter((block) => block && block.type === "text" && typeof block.text === "string") + .map((block) => block.text.trim()) + .filter(Boolean) + .join("\n\n") + return text || null + } + return null +} + export function mapFinishReasonToAnthropic(finish) { if (!finish) return "end_turn" if (finish.includes("length")) return "max_tokens" @@ -602,13 +990,24 @@ export function mapFinishReasonToAnthropic(finish) { function createAnthropicResponse(result, model) { const tokensIn = result.completion.data.info?.tokens?.input ?? 0 const tokensOut = result.completion.data.info?.tokens?.output ?? 0 + const content = result.toolCall + ? [ + { + type: "tool_use", + id: result.toolCall.id, + name: result.toolCall.name, + input: result.toolCall.arguments ?? {}, + }, + ] + : [{ type: "text", text: result.content }] + return { id: `msg_${crypto.randomUUID().replace(/-/g, "")}`, type: "message", role: "assistant", - content: [{ type: "text", text: result.content }], + content, model: model.id, - stop_reason: mapFinishReasonToAnthropic(result.completion.data.info?.finish), + stop_reason: result.toolCall ? "tool_use" : mapFinishReasonToAnthropic(result.completion.data.info?.finish), stop_sequence: null, usage: { input_tokens: tokensIn, output_tokens: tokensOut }, } @@ -643,7 +1042,17 @@ export function normalizeGeminiContents(contents) { const role = item.role === "model" ? "assistant" : (item.role ?? "user") const content = Array.isArray(item.parts) ? item.parts - .map((part) => (typeof part?.text === "string" ? part.text.trim() : "")) + .map((part) => { + if (!part) return "" + if (typeof part.text === "string") return part.text.trim() + if (part.functionCall) { + return `[Called tool ${part.functionCall.name} with arguments ${JSON.stringify(part.functionCall.args ?? {})}]` + } + if (part.functionResponse) { + return `[Result from tool ${part.functionResponse.name}]: ${JSON.stringify(part.functionResponse.response ?? {})}` + } + return "" + }) .filter(Boolean) .join("\n\n") : "" @@ -671,11 +1080,15 @@ export function mapFinishReasonToGemini(finish) { return "STOP" } -function createGeminiResponse(content, finish, tokens) { +function createGeminiResponse(content, finish, tokens, toolCall) { + const parts = toolCall + ? [{ functionCall: { name: toolCall.name, args: toolCall.arguments ?? {} } }] + : [{ text: content }] + return { candidates: [ { - content: { role: "model", parts: [{ text: content }] }, + content: { role: "model", parts }, finishReason: mapFinishReasonToGemini(finish), index: 0, }, @@ -757,6 +1170,7 @@ export function createProxyFetchHandler(client) { } const system = buildSystemPrompt(messages, body) + const callerTools = applyOpenAIToolChoice(parseOpenAITools(body), body.tool_choice) if (body.stream) { const completionID = `chatcmpl_${crypto.randomUUID().replace(/-/g, "")}` @@ -780,14 +1194,51 @@ export function createProxyFetchHandler(client) { }) queue.enqueue(`data: ${chunk}\n\n`) }, + callerTools, ) .then((streamResult) => { + if (streamResult.toolCall) { + const toolCallChunk = JSON.stringify({ + id: completionID, + object: "chat.completion.chunk", + created: now, + model: model.id, + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: streamResult.toolCall.id, + type: "function", + function: { + name: streamResult.toolCall.name, + arguments: JSON.stringify(streamResult.toolCall.arguments ?? {}), + }, + }, + ], + }, + finish_reason: null, + }, + ], + }) + queue.enqueue(`data: ${toolCallChunk}\n\n`) + } + const finalChunk = JSON.stringify({ id: completionID, object: "chat.completion.chunk", created: now, model: model.id, - choices: [{ index: 0, delta: {}, finish_reason: mapFinishReason(streamResult.finish) }], + choices: [ + { + index: 0, + delta: {}, + finish_reason: streamResult.toolCall ? "tool_calls" : mapFinishReason(streamResult.finish), + }, + ], usage: { prompt_tokens: streamResult.tokens.input, completion_tokens: streamResult.tokens.output, @@ -820,7 +1271,7 @@ export function createProxyFetchHandler(client) { } try { - const result = await executePrompt(client, body, model, messages, system) + const result = await executePrompt(client, body, model, messages, system, callerTools) return json(createChatCompletionResponse(result, model), 200, {}, request) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -859,6 +1310,7 @@ export function createProxyFetchHandler(client) { max_tokens: body.max_output_tokens, max_completion_tokens: body.max_output_tokens, }) + const callerTools = applyOpenAIToolChoice(parseOpenAITools(body), body.tool_choice) let model try { @@ -907,6 +1359,9 @@ export function createProxyFetchHandler(client) { ) let partIndex = 0 + // Accumulate delta tokens so we can populate `text` on output_text.done and content_part.done per the + // OpenAI Responses API SSE spec (https://platform.openai.com/docs/api-reference/responses-streaming). + let accumulatedText = "" const runPromise = executePromptStreaming( client, model, @@ -925,6 +1380,7 @@ export function createProxyFetchHandler(client) { ) partIndex++ } + accumulatedText += delta queue.enqueue( sseEvent("response.output_text.delta", { type: "response.output_text.delta", @@ -935,17 +1391,98 @@ export function createProxyFetchHandler(client) { }), ) }, + callerTools, ) .then((streamResult) => { + if (streamResult.toolCall) { + const args = JSON.stringify(streamResult.toolCall.arguments ?? {}) + const callItemID = `fc_${crypto.randomUUID().replace(/-/g, "")}` + queue.enqueue( + sseEvent("response.output_item.added", { + type: "response.output_item.added", + output_index: 0, + item: { + id: callItemID, + type: "function_call", + status: "in_progress", + call_id: streamResult.toolCall.id, + name: streamResult.toolCall.name, + arguments: "", + }, + }), + ) + queue.enqueue( + sseEvent("response.function_call_arguments.delta", { + type: "response.function_call_arguments.delta", + item_id: callItemID, + output_index: 0, + delta: args, + }), + ) + queue.enqueue( + sseEvent("response.function_call_arguments.done", { + type: "response.function_call_arguments.done", + item_id: callItemID, + output_index: 0, + arguments: args, + }), + ) + queue.enqueue( + sseEvent("response.output_item.done", { + type: "response.output_item.done", + output_index: 0, + item: { + id: callItemID, + type: "function_call", + status: "completed", + call_id: streamResult.toolCall.id, + name: streamResult.toolCall.name, + arguments: args, + }, + }), + ) + queue.enqueue( + sseEvent("response.completed", { + type: "response.completed", + response: { + id: responseID, + object: "response", + created_at: now, + status: "completed", + model: model.id, + usage: { + input_tokens: streamResult.tokens.input, + output_tokens: streamResult.tokens.output, + total_tokens: streamResult.tokens.input + streamResult.tokens.output, + }, + }, + }), + ) + return + } + queue.enqueue( sseEvent("response.output_text.done", { type: "response.output_text.done", item_id: itemID, output_index: 0, content_index: 0, - text: "", + text: accumulatedText, }), ) + if (partIndex > 0) { + // Only emit content_part.done if content_part.added was emitted (i.e. at least one delta arrived). + // Keeps the content-part lifecycle symmetric per the OpenAI Responses API spec. + queue.enqueue( + sseEvent("response.content_part.done", { + type: "response.content_part.done", + item_id: itemID, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: accumulatedText, annotations: [] }, + }), + ) + } queue.enqueue( sseEvent("response.output_item.done", { type: "response.output_item.done", @@ -1003,7 +1540,7 @@ export function createProxyFetchHandler(client) { } try { - const result = await executePrompt(client, body, model, messages, system) + const result = await executePrompt(client, body, model, messages, system, callerTools) return json(createResponsesApiResponse(result, model), 200, {}, request) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -1040,16 +1577,19 @@ export function createProxyFetchHandler(client) { return anthropicBadRequest("No text content was found in the supplied messages.", 400, request) } - // Prepend Anthropic top-level system string as a system message so buildSystemPrompt picks it up. - const allMessages = - typeof body.system === "string" && body.system.trim() - ? [{ role: "system", content: body.system.trim() }, ...messages] - : messages + // Prepend Anthropic top-level `system` (string or array-of-content-blocks, + // per the Messages API spec) as a system message so buildSystemPrompt + // picks it up. + const systemText = normalizeAnthropicSystem(body.system) + const allMessages = systemText + ? [{ role: "system", content: systemText }, ...messages] + : messages const system = buildSystemPrompt(allMessages, { temperature: body.temperature, max_tokens: body.max_tokens, }) + const callerTools = applyAnthropicToolChoice(parseAnthropicTools(body), body.tool_choice) let model try { @@ -1083,26 +1623,64 @@ export function createProxyFetchHandler(client) { usage: { input_tokens: 0, output_tokens: 0 }, }, })) - queue.enqueue(sseEvent("content_block_start", { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - })) + let textBlockStarted = false const runPromise = executePromptStreaming( client, model, messages, system, (delta) => { + if (!textBlockStarted) { + queue.enqueue(sseEvent("content_block_start", { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })) + textBlockStarted = true + } queue.enqueue(sseEvent("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: delta }, })) }, + callerTools, ) .then((streamResult) => { + if (streamResult.toolCall) { + if (textBlockStarted) { + queue.enqueue(sseEvent("content_block_stop", { type: "content_block_stop", index: 0 })) + } + const blockIndex = textBlockStarted ? 1 : 0 + const argsJson = JSON.stringify(streamResult.toolCall.arguments ?? {}) + queue.enqueue(sseEvent("content_block_start", { + type: "content_block_start", + index: blockIndex, + content_block: { type: "tool_use", id: streamResult.toolCall.id, name: streamResult.toolCall.name, input: {} }, + })) + queue.enqueue(sseEvent("content_block_delta", { + type: "content_block_delta", + index: blockIndex, + delta: { type: "input_json_delta", partial_json: argsJson }, + })) + queue.enqueue(sseEvent("content_block_stop", { type: "content_block_stop", index: blockIndex })) + queue.enqueue(sseEvent("message_delta", { + type: "message_delta", + delta: { stop_reason: "tool_use", stop_sequence: null }, + usage: { output_tokens: streamResult.tokens.output }, + })) + queue.enqueue(sseEvent("message_stop", { type: "message_stop" })) + return + } + + if (!textBlockStarted) { + queue.enqueue(sseEvent("content_block_start", { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })) + } queue.enqueue(sseEvent("content_block_stop", { type: "content_block_stop", index: 0 })) queue.enqueue(sseEvent("message_delta", { type: "message_delta", @@ -1131,7 +1709,7 @@ export function createProxyFetchHandler(client) { } try { - const result = await executePrompt(client, body, model, messages, system) + const result = await executePrompt(client, body, model, messages, system, callerTools) return json(createAnthropicResponse(result, model), 200, {}, request) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -1176,6 +1754,7 @@ export function createProxyFetchHandler(client) { temperature: body.generationConfig?.temperature, max_tokens: body.generationConfig?.maxOutputTokens, }) + const callerTools = applyGeminiToolChoice(parseGeminiTools(body), body.toolConfig) let model try { @@ -1200,10 +1779,13 @@ export function createProxyFetchHandler(client) { const chunk = JSON.stringify(createGeminiResponse(delta, null, null)) queue.enqueue(chunk + "\n") }, + callerTools, ) .then((streamResult) => { const finalChunk = JSON.stringify( - createGeminiResponse("", streamResult.finish, streamResult.tokens), + streamResult.toolCall + ? createGeminiResponse("", streamResult.finish, streamResult.tokens, streamResult.toolCall) + : createGeminiResponse("", streamResult.finish, streamResult.tokens), ) queue.enqueue(finalChunk + "\n") }) @@ -1248,10 +1830,10 @@ export function createProxyFetchHandler(client) { } try { - const result = await executePrompt(client, body, model, messages, system) + const result = await executePrompt(client, body, model, messages, system, callerTools) const finish = result.completion.data.info?.finish const tokens = result.completion.data.info?.tokens - return json(createGeminiResponse(result.content, finish, tokens), 200, {}, request) + return json(createGeminiResponse(result.content, finish, tokens, result.toolCall), 200, {}, request) } catch (error) { const message = error instanceof Error ? error.message : String(error) await safeLog(client, "error", "Gemini proxy call failed", { error: message, requestedModel: geminiModelName }) diff --git a/index.test.js b/index.test.js index f663ea0..1958758 100644 --- a/index.test.js +++ b/index.test.js @@ -14,9 +14,17 @@ import { resolveModel, normalizeAnthropicMessages, mapFinishReasonToAnthropic, + normalizeAnthropicSystem, normalizeGeminiContents, extractGeminiSystemInstruction, mapFinishReasonToGemini, + sanitizeToolName, + parseOpenAITools, + applyOpenAIToolChoice, + parseAnthropicTools, + applyAnthropicToolChoice, + parseGeminiTools, + applyGeminiToolChoice, } from "./index.js" // --------------------------------------------------------------------------- @@ -79,6 +87,21 @@ function createStreamingClient(chunks) { } } +function parseSseStream(text) { + // Parses SSE `event: \ndata: \n\n` chunks into an ordered array. + // Local to this test file; not exported. + return text + .split("\n\n") + .filter((block) => block.trim()) + .map((block) => { + const eventLine = block.match(/^event: (.+)$/m) + const dataLine = block.match(/^data: (.+)$/m) + if (!eventLine || !dataLine) return null + return { event: eventLine[1], data: JSON.parse(dataLine[1]) } + }) + .filter(Boolean) +} + test("OPTIONS preflight returns CORS headers", async () => { const handler = createProxyFetchHandler(createClient()) const request = new Request("http://127.0.0.1:4010/v1/models", { @@ -1107,6 +1130,68 @@ test("POST /v1/responses stream: true returns SSE lifecycle events", async () => assert.ok(text.includes("response.completed")) }) +test("POST /v1/responses stream: true emits content_part.done with accumulated text per OpenAI spec", async () => { + const events = [ + { + type: "message.part.updated", + properties: { + part: { sessionID: "sess-123", type: "text" }, + delta: "The answer", + }, + }, + { + type: "message.part.updated", + properties: { + part: { sessionID: "sess-123", type: "text" }, + delta: " is 42.", + }, + }, + { type: "session.idle", properties: { sessionID: "sess-123" } }, + ] + + const handler = createProxyFetchHandler(createStreamingClient(events)) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + stream: true, + input: "What is 6 times 7?", + }), + }) + + const response = await handler(request) + const text = await response.text() + const parsed = parseSseStream(text) + const names = parsed.map((e) => e.event) + + // Discriminator 1 (gap #3): output_text.done.text must be the accumulated content + const outputTextDone = parsed.find((e) => e.event === "response.output_text.done") + assert.ok(outputTextDone, "response.output_text.done event must be present") + assert.equal(outputTextDone.data.text, "The answer is 42.") + + // Discriminator 2 (gap #2): content_part.done must be present with populated part.text + const contentPartDone = parsed.find((e) => e.event === "response.content_part.done") + assert.ok(contentPartDone, "response.content_part.done event must be present") + assert.equal(contentPartDone.data.part.type, "output_text") + assert.equal(contentPartDone.data.part.text, "The answer is 42.") + assert.deepEqual(contentPartDone.data.part.annotations, []) + + // Ordering: output_text.done -> content_part.done -> output_item.done + const idxOutputTextDone = names.indexOf("response.output_text.done") + const idxContentPartDone = names.indexOf("response.content_part.done") + const idxOutputItemDone = names.indexOf("response.output_item.done") + assert.ok(idxOutputTextDone >= 0, "output_text.done must be in the stream") + assert.ok( + idxContentPartDone > idxOutputTextDone, + "content_part.done must follow output_text.done", + ) + assert.ok( + idxOutputItemDone > idxContentPartDone, + "output_item.done must follow content_part.done", + ) +}) + test("POST /v1/responses stream: true with session.error emits response.failed", async () => { const events = [ { @@ -1388,6 +1473,114 @@ test("POST /v1/messages system string is included in prompt", async () => { assert.ok(capturedSystem?.includes("You are a pirate.")) }) +test("POST /v1/messages system as content-block array is included in prompt", async () => { + let capturedSystem = null + const client = { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [{ id: "anthropic", models: { "claude-3-5-sonnet": { id: "claude-3-5-sonnet" } } }], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-ant-sys-arr" } }), + prompt: async ({ body }) => { + capturedSystem = body.system + return { + data: { + parts: [{ type: "text", text: "ok" }], + info: { tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "end_turn" }, + }, + } + }, + }, + } + + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "anthropic/claude-3-5-sonnet", + system: [{ type: "text", text: "You are a pirate." }], + messages: [{ role: "user", content: "Hello." }], + }), + }) + + await handler(request) + assert.ok(capturedSystem?.includes("You are a pirate.")) +}) + +test("POST /v1/messages system as multi-block array concatenates text", async () => { + let capturedSystem = null + const client = { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [{ id: "anthropic", models: { "claude-3-5-sonnet": { id: "claude-3-5-sonnet" } } }], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-ant-sys-multi" } }), + prompt: async ({ body }) => { + capturedSystem = body.system + return { + data: { + parts: [{ type: "text", text: "ok" }], + info: { tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "end_turn" }, + }, + } + }, + }, + } + + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "anthropic/claude-3-5-sonnet", + system: [ + { type: "text", text: "Line one." }, + { type: "text", text: "Line two." }, + ], + messages: [{ role: "user", content: "Hello." }], + }), + }) + + await handler(request) + assert.ok(capturedSystem?.includes("Line one.")) + assert.ok(capturedSystem?.includes("Line two.")) +}) + +test("normalizeAnthropicSystem handles string, array, and edge cases", () => { + assert.equal(normalizeAnthropicSystem("hello"), "hello") + assert.equal(normalizeAnthropicSystem(" hi "), "hi") + assert.equal(normalizeAnthropicSystem(""), null) + assert.equal(normalizeAnthropicSystem(" "), null) + assert.equal(normalizeAnthropicSystem([{ type: "text", text: "a" }]), "a") + assert.equal( + normalizeAnthropicSystem([ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ]), + "a\n\nb", + ) + assert.equal(normalizeAnthropicSystem([{ type: "image", source: {} }]), null) + assert.equal(normalizeAnthropicSystem([]), null) + assert.equal(normalizeAnthropicSystem([{ type: "text", text: "" }]), null) + assert.equal(normalizeAnthropicSystem(undefined), null) + assert.equal(normalizeAnthropicSystem(null), null) + assert.equal(normalizeAnthropicSystem(42), null) + assert.equal(normalizeAnthropicSystem([null, { type: "text", text: "x" }]), "x") +}) + test("POST /v1/messages missing model returns Anthropic error format", async () => { const handler = createProxyFetchHandler(createAnthropicClient()) const request = new Request("http://127.0.0.1:4010/v1/messages", { @@ -1686,3 +1879,439 @@ test("POST /v1beta/models/:model:streamGenerateContent returns NDJSON stream", a assert.ok(text.includes("Gem")) assert.ok(text.includes("ini")) }) + +// --------------------------------------------------------------------------- +// Unit: tool parsing / tool_choice helpers +// --------------------------------------------------------------------------- + +test("sanitizeToolName replaces invalid characters and de-duplicates", () => { + assert.equal(sanitizeToolName("get_weather"), "get_weather") + assert.equal(sanitizeToolName("get-weather.v2"), "get_weather_v2") + assert.equal(sanitizeToolName("123start"), "t_123start") + assert.equal(sanitizeToolName(""), "tool") + + const seen = new Set() + assert.equal(sanitizeToolName("dup", seen), "dup") + assert.equal(sanitizeToolName("dup", seen), "dup_2") + assert.equal(sanitizeToolName("dup", seen), "dup_3") +}) + +test("parseOpenAITools extracts function tools (Chat Completions nested shape)", () => { + const tools = parseOpenAITools({ + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + { type: "function", function: { name: "no_params" } }, + { type: "not_function", function: { name: "ignored" } }, + ], + }) + + assert.equal(tools.length, 2) + assert.equal(tools[0].name, "get_weather") + assert.equal(tools[0].description, "Get the weather") + assert.deepEqual(tools[0].parameters, { type: "object", properties: { city: { type: "string" } } }) + assert.equal(tools[1].name, "no_params") + assert.deepEqual(tools[1].parameters, { type: "object", properties: {} }) +}) + +test("parseOpenAITools extracts function tools (Responses API flat shape)", () => { + const tools = parseOpenAITools({ + tools: [ + { type: "function", name: "get_weather", description: "Get weather", parameters: { type: "object" } }, + ], + }) + + assert.equal(tools.length, 1) + assert.equal(tools[0].name, "get_weather") +}) + +test("parseOpenAITools supports legacy 'functions' field", () => { + const tools = parseOpenAITools({ functions: [{ name: "legacy_fn", description: "d" }] }) + assert.equal(tools.length, 1) + assert.equal(tools[0].name, "legacy_fn") +}) + +test("parseOpenAITools returns empty array when no tools present", () => { + assert.deepEqual(parseOpenAITools({}), []) +}) + +test("applyOpenAIToolChoice filters to a single named function, or none, or unchanged", () => { + const tools = [{ name: "a", description: "", parameters: {} }, { name: "b", description: "", parameters: {} }] + assert.deepEqual(applyOpenAIToolChoice(tools, "none"), []) + assert.deepEqual(applyOpenAIToolChoice(tools, "auto"), tools) + assert.deepEqual( + applyOpenAIToolChoice(tools, { type: "function", function: { name: "b" } }).map((t) => t.name), + ["b"], + ) + assert.deepEqual(applyOpenAIToolChoice(tools, { type: "function", name: "a" }).map((t) => t.name), ["a"]) +}) + +test("parseAnthropicTools extracts tools with input_schema", () => { + const tools = parseAnthropicTools({ + tools: [{ name: "get_weather", description: "d", input_schema: { type: "object" } }], + }) + assert.equal(tools.length, 1) + assert.equal(tools[0].name, "get_weather") + assert.deepEqual(tools[0].parameters, { type: "object" }) +}) + +test("applyAnthropicToolChoice supports none and named tool", () => { + const tools = [{ name: "a" }, { name: "b" }] + assert.deepEqual(applyAnthropicToolChoice(tools, { type: "none" }), []) + assert.deepEqual(applyAnthropicToolChoice(tools, { type: "tool", name: "a" }).map((t) => t.name), ["a"]) + assert.deepEqual(applyAnthropicToolChoice(tools, { type: "auto" }), tools) +}) + +test("parseGeminiTools flattens functionDeclarations across tool groups", () => { + const tools = parseGeminiTools({ + tools: [ + { functionDeclarations: [{ name: "get_weather", description: "d", parameters: { type: "object" } }] }, + { functionDeclarations: [{ name: "get_time" }] }, + ], + }) + assert.equal(tools.length, 2) + assert.equal(tools[0].name, "get_weather") + assert.equal(tools[1].name, "get_time") +}) + +test("applyGeminiToolChoice supports NONE mode and allowedFunctionNames", () => { + const tools = [{ name: "a" }, { name: "b" }] + assert.deepEqual(applyGeminiToolChoice(tools, { functionCallingConfig: { mode: "NONE" } }), []) + assert.deepEqual( + applyGeminiToolChoice(tools, { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["b"] } }).map( + (t) => t.name, + ), + ["b"], + ) + assert.deepEqual(applyGeminiToolChoice(tools, undefined), tools) +}) + +// --------------------------------------------------------------------------- +// Unit: tool-call round-tripping in conversation history normalizers +// --------------------------------------------------------------------------- + +test("normalizeMessages renders prior OpenAI tool_calls and tool results as text", () => { + const messages = normalizeMessages([ + { role: "user", content: "What's the weather in NYC?" }, + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather", arguments: '{"city":"NYC"}' } }], + }, + { role: "tool", tool_call_id: "call_1", content: "Sunny, 72F" }, + ]) + + assert.equal(messages.length, 3) + assert.ok(messages[1].content.includes("get_weather")) + assert.ok(messages[1].content.includes('{"city":"NYC"}')) + assert.ok(messages[2].content.includes("get_weather")) + assert.ok(messages[2].content.includes("Sunny, 72F")) +}) + +test("normalizeAnthropicMessages renders prior tool_use and tool_result blocks as text", () => { + const messages = normalizeAnthropicMessages([ + { role: "user", content: "What's the weather in NYC?" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { city: "NYC" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "Sunny, 72F" }], + }, + ]) + + assert.equal(messages.length, 3) + assert.ok(messages[1].content.includes("get_weather")) + assert.ok(messages[1].content.includes("NYC")) + assert.ok(messages[2].content.includes("get_weather")) + assert.ok(messages[2].content.includes("Sunny, 72F")) +}) + +test("normalizeGeminiContents renders prior functionCall and functionResponse parts as text", () => { + const messages = normalizeGeminiContents([ + { role: "user", parts: [{ text: "What's the weather in NYC?" }] }, + { role: "model", parts: [{ functionCall: { name: "get_weather", args: { city: "NYC" } } }] }, + { role: "user", parts: [{ functionResponse: { name: "get_weather", response: { temp: "72F" } } }] }, + ]) + + assert.equal(messages.length, 3) + assert.ok(messages[1].content.includes("get_weather")) + assert.ok(messages[2].content.includes("get_weather")) + assert.ok(messages[2].content.includes("72F")) +}) + +test("normalizeResponseInput renders prior function_call and function_call_output items as text", () => { + const messages = normalizeResponseInput([ + { role: "user", content: "What's the weather in NYC?" }, + { type: "function_call", call_id: "call_1", name: "get_weather", arguments: '{"city":"NYC"}' }, + { type: "function_call_output", call_id: "call_1", output: "Sunny, 72F" }, + ]) + + assert.equal(messages.length, 3) + assert.ok(messages[1].content.includes("get_weather")) + assert.ok(messages[2].content.includes("get_weather")) + assert.ok(messages[2].content.includes("Sunny, 72F")) +}) + +// --------------------------------------------------------------------------- +// Integration: end-to-end tool calling via the dynamic MCP bridge +// --------------------------------------------------------------------------- + +function createToolCallClient({ toolName, toolArgs, callID = "call_1", finish = "tool_calls", providers } = {}) { + let capturedSlotName = null + + return { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: providers ?? [{ id: "openai", models: { "gpt-4o": { id: "gpt-4o", name: "GPT-4o" } } }], + }, + }), + }, + mcp: { + disconnect: async () => { + throw new Error("not connected") + }, + add: async ({ body }) => { + capturedSlotName = body.name + assert.equal(body.config.type, "local") + assert.ok(Array.isArray(body.config.command)) + return { data: {} } + }, + }, + session: { + create: async () => ({ data: { id: "sess-tool-1" } }), + promptAsync: async () => {}, + abort: async () => ({ data: true }), + messages: async () => ({ + data: [ + { + role: "assistant", + tokens: { input: 5, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, + finish, + }, + ], + }), + }, + event: { + subscribe: async () => ({ + stream: (async function* () { + yield { + type: "message.part.updated", + properties: { + part: { + sessionID: "sess-tool-1", + type: "tool", + tool: `${capturedSlotName}_${toolName}`, + callID, + state: { status: "pending", input: toolArgs }, + }, + }, + } + })(), + }), + }, + } +} + +test("POST /v1/chat/completions returns tool_calls when the model calls a caller-supplied tool", async () => { + const client = createToolCallClient({ toolName: "get_weather", toolArgs: { city: "NYC" } }) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ role: "user", content: "What's the weather in NYC?" }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.equal(body.choices[0].finish_reason, "tool_calls") + assert.equal(body.choices[0].message.content, null) + assert.equal(body.choices[0].message.tool_calls[0].function.name, "get_weather") + assert.deepEqual(JSON.parse(body.choices[0].message.tool_calls[0].function.arguments), { city: "NYC" }) + assert.equal(body.choices[0].message.tool_calls[0].id, "call_1") +}) + +test("POST /v1/chat/completions stream: true emits tool_calls delta and finish_reason", async () => { + const client = createToolCallClient({ toolName: "get_weather", toolArgs: { city: "NYC" } }) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + stream: true, + messages: [{ role: "user", content: "What's the weather in NYC?" }], + tools: [{ type: "function", function: { name: "get_weather" } }], + }), + }) + + const response = await handler(request) + const text = await response.text() + + assert.ok(text.includes('"tool_calls"')) + assert.ok(text.includes("get_weather")) + assert.ok(text.includes('"finish_reason":"tool_calls"')) +}) + +test("POST /v1/messages returns tool_use content block when the model calls a tool", async () => { + const client = createToolCallClient({ toolName: "get_weather", toolArgs: { city: "NYC" }, callID: "toolu_1" }) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ role: "user", content: "What's the weather in NYC?" }], + tools: [{ name: "get_weather", description: "Get weather", input_schema: { type: "object" } }], + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.equal(body.stop_reason, "tool_use") + assert.equal(body.content[0].type, "tool_use") + assert.equal(body.content[0].name, "get_weather") + assert.equal(body.content[0].id, "toolu_1") + assert.deepEqual(body.content[0].input, { city: "NYC" }) +}) + +test("POST /v1/messages stream: true emits a tool_use content block", async () => { + const client = createToolCallClient({ toolName: "get_weather", toolArgs: { city: "NYC" }, callID: "toolu_1" }) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + stream: true, + messages: [{ role: "user", content: "What's the weather in NYC?" }], + tools: [{ name: "get_weather" }], + }), + }) + + const response = await handler(request) + const text = await response.text() + + assert.ok(text.includes("tool_use")) + assert.ok(text.includes("get_weather")) + assert.ok(text.includes('"stop_reason":"tool_use"')) +}) + +test("POST /v1beta/models/:model:generateContent returns a functionCall part", async () => { + const client = createToolCallClient({ + toolName: "get_weather", + toolArgs: { city: "NYC" }, + providers: [{ id: "google", models: { "gemini-2.0-flash": { id: "gemini-2.0-flash" } } }], + }) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1beta/models/gemini-2.0-flash:generateContent", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "What's the weather in NYC?" }] }], + tools: [{ functionDeclarations: [{ name: "get_weather", description: "Get weather" }] }], + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.ok(body.candidates[0].content.parts[0].functionCall) + assert.equal(body.candidates[0].content.parts[0].functionCall.name, "get_weather") + assert.deepEqual(body.candidates[0].content.parts[0].functionCall.args, { city: "NYC" }) +}) + +test("POST /v1/responses returns a function_call output item", async () => { + const client = createToolCallClient({ toolName: "get_weather", toolArgs: { city: "NYC" } }) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + input: "What's the weather in NYC?", + tools: [{ type: "function", name: "get_weather", description: "Get weather" }], + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.equal(body.output[0].type, "function_call") + assert.equal(body.output[0].name, "get_weather") + assert.deepEqual(JSON.parse(body.output[0].arguments), { city: "NYC" }) + assert.equal(body.output_text, "") +}) + +test("POST /v1/responses stream: true emits function_call SSE events", async () => { + const client = createToolCallClient({ toolName: "get_weather", toolArgs: { city: "NYC" } }) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + stream: true, + input: "What's the weather in NYC?", + tools: [{ type: "function", name: "get_weather" }], + }), + }) + + const response = await handler(request) + const text = await response.text() + + assert.ok(text.includes("response.function_call_arguments.done")) + assert.ok(text.includes("get_weather")) + assert.ok(text.includes('"type":"function_call"')) +}) + +test("tool_choice: none disables tool calling even when tools are supplied", async () => { + const events = [{ type: "session.idle", properties: { sessionID: "sess-123" } }] + const client = createStreamingClient(events) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + stream: true, + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "get_weather" } }], + tool_choice: "none", + }), + }) + + const response = await handler(request) + assert.equal(response.status, 200) + // No mcp/tool bridge client methods were exercised because callerTools resolved to []. + assert.equal(client.mcp, undefined) +}) diff --git a/mcp-tool-bridge.js b/mcp-tool-bridge.js new file mode 100644 index 0000000..98d53b0 --- /dev/null +++ b/mcp-tool-bridge.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// Minimal MCP (Model Context Protocol) stdio server used internally by opencode-llm-proxy +// to expose a proxy caller's OpenAI/Anthropic/Gemini tool schemas to OpenCode as if they +// were real MCP tools. +// +// This process is spawned by OpenCode itself as a "local" MCP server (see index.js +// registerToolBridge()). It never actually executes anything: the proxy detects the +// resulting tool-call event on OpenCode's event stream and aborts the session before +// tools/call would matter, so the response returned here is just a harmless placeholder. +// +// Protocol: JSON-RPC 2.0 messages, newline-delimited, over stdin/stdout. +// stdout MUST only ever contain JSON-RPC messages - all diagnostics go to stderr. + +const toolsJson = process.env.OPENCODE_LLM_PROXY_BRIDGE_TOOLS ?? "[]" + +let tools = [] +try { + const parsed = JSON.parse(toolsJson) + if (Array.isArray(parsed)) tools = parsed +} catch (error) { + process.stderr.write(`opencode-llm-proxy bridge: failed to parse tool schemas: ${error}\n`) +} + +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n") +} + +function respondResult(id, result) { + if (id === undefined || id === null) return + send({ jsonrpc: "2.0", id, result }) +} + +function respondError(id, code, message) { + if (id === undefined || id === null) return + send({ jsonrpc: "2.0", id, error: { code, message } }) +} + +function handleMessage(message) { + const { id, method, params } = message + + switch (method) { + case "initialize": { + respondResult(id, { + protocolVersion: params?.protocolVersion ?? "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "opencode-llm-proxy-bridge", version: "1.0.0" }, + }) + return + } + case "notifications/initialized": + // Notification, no response expected. + return + case "ping": { + respondResult(id, {}) + return + } + case "tools/list": { + respondResult(id, { + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + inputSchema: tool.parameters ?? { type: "object", properties: {} }, + })), + }) + return + } + case "tools/call": { + // Never actually reached in practice: the proxy aborts the OpenCode session as + // soon as it observes the tool-call part on the event stream, before this + // response would be consumed. Returned only as a safety net. + respondResult(id, { + content: [ + { + type: "text", + text: "(intercepted by opencode-llm-proxy; awaiting the external caller's tool result)", + }, + ], + }) + return + } + default: { + respondError(id, -32601, `Method not found: ${method}`) + } + } +} + +let buffer = "" +process.stdin.setEncoding("utf8") +process.stdin.on("data", (chunk) => { + buffer += chunk + let newlineIndex + while ((newlineIndex = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newlineIndex).trim() + buffer = buffer.slice(newlineIndex + 1) + if (!line) continue + try { + const message = JSON.parse(line) + handleMessage(message) + } catch (error) { + process.stderr.write(`opencode-llm-proxy bridge: failed to parse message: ${error}\n`) + } + } +}) + +process.stdin.on("end", () => { + process.exit(0) +}) diff --git a/package.json b/package.json index 88d496b..f882471 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-llm-proxy", "version": "1.6.1", - "description": "Local AI gateway for OpenCode — use any model via OpenAI, Anthropic, or Gemini API format", + "description": "Local AI gateway for OpenCode with tool/function calling — use any model via OpenAI, Anthropic, or Gemini API format", "main": "index.js", "type": "module", "engines": { @@ -32,7 +32,15 @@ "ai-gateway", "model-router", "openrouter", - "bedrock" + "bedrock", + "tool-calling", + "function-calling", + "tools", + "mcp", + "model-context-protocol", + "coding-agent", + "ai-agent", + "agentic" ], "author": "KochC", "license": "MIT",