diff --git a/README.md b/README.md index 98acc45..bb1d7a6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Your tool (OpenAI / Anthropic / Gemini SDK, coding agent, etc.) - [Configuration](#configuration) - [Tool calling](#tool-calling) - [Using with SDKs and tools](#using-with-sdks-and-tools) + - [n8n](#n8n) - [Finding model IDs](#finding-model-ids) - [API reference](#api-reference) - [How it works](#how-it-works) @@ -60,6 +61,7 @@ Most LLM tools speak exactly one API dialect. OpenCode already manages connectio - 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). +- You run **n8n** (self-hosted or in Docker, possibly on a different machine on your LAN) and want its AI nodes to use whatever models OpenCode already has authenticated access to — GitHub Copilot, Anthropic, Bedrock, local Ollama models, etc. — without giving n8n its own separate API keys. Point n8n's native OpenAI/Anthropic credentials at the proxy. See [n8n](#n8n). --- @@ -317,6 +319,24 @@ print(response.content) > Running Open WebUI in Docker? Use `http://host.docker.internal:4010/v1` and set `OPENCODE_LLM_PROXY_HOST=0.0.0.0`. +### n8n + +The proxy lets [n8n](https://n8n.io)'s native AI nodes use whatever models OpenCode already has authenticated access to — GitHub Copilot, Anthropic, Bedrock, local Ollama models, etc. — without configuring separate API keys in n8n at all. This works with n8n's regular LangChain-based Chat Model nodes, **including real tool/function calling** (e.g. an "AI Agent" node with a Tool attached) since [Tool calling](#tool-calling) support was added. + +1. In OpenCode, expose the proxy on your LAN instead of just localhost, and set a bearer token since it'll be network-reachable: + ```bash + OPENCODE_LLM_PROXY_HOST=0.0.0.0 \ + OPENCODE_LLM_PROXY_TOKEN=some-long-random-token \ + opencode + ``` +2. In n8n, create a credential: + - **OpenAI**: Base URL `http://:4010/v1`, API Key = your token + - **Anthropic**: Base URL `http://:4010` (no `/v1` — the node adds `/v1/messages` itself), API Key = your token +3. Add an **OpenAI Chat Model** (or **Anthropic Chat Model**) node using that credential. The model dropdown calls `GET /v1/models` on the proxy, so it auto-populates with every model OpenCode has connected (`github-copilot/claude-sonnet-5`, `anthropic/claude-3-5-sonnet`, `ollama/qwen2.5-coder`, ...) — pick one directly, no manual typing needed. +4. Wire it into a **Basic LLM Chain** node for simple prompt/response use, or an **AI Agent** node (with Tools attached, e.g. an HTTP Request Tool) for agentic tool-using workflows. + +> n8n running in Docker on a **different machine** on your LAN (a common setup)? Use that machine's actual LAN IP for `` — not `localhost`/`host.docker.internal`, which only resolve to the OpenCode host if Docker is running on that same machine. Make sure your firewall allows incoming connections to the `opencode` binary (macOS's Application Firewall in particular will silently drop connections from an app it hasn't been told to allow, even with the port open). + ### Chatbox Settings → AI Provider → OpenAI API → set **API Host** to `http://127.0.0.1:4010`. diff --git a/index.js b/index.js index b2f2fb0..f9ebbe9 100644 --- a/index.js +++ b/index.js @@ -511,13 +511,19 @@ function getToolBridgeState() { state.toolBridge = { freeSlots: Array.from({ length: poolSize }, (_, i) => `px_tools_${i}`), waiters: [], - // Every bridge tool ID ever registered across the pool's lifetime, from any - // slot. Needed because OpenCode has no endpoint to deregister an MCP server, - // so a slot reused for a later request stays connected under its old tool - // schema until it's next reused - see buildToolsMap() below for why this - // must be tracked and explicitly disabled per-turn, not just left out of the - // map. - knownToolIDs: new Set(), + // Maps slot name -> the bridge tool IDs currently assigned to that slot (from + // whichever turn most recently registered it). Needed because OpenCode has no + // endpoint to deregister an MCP server, so a slot reused for a later request + // stays connected under its old tool schema until it's next reused - see + // buildToolsMap() below for why this must be tracked and explicitly disabled + // per-turn, not just left out of the map. + // + // Keyed by slot (not an ever-growing set of every tool ID ever seen): each + // slot's entry is *replaced*, not accumulated, every time that slot is + // reused, so this stays bounded by the pool size regardless of how many + // requests/unique tool schemas a long-lived process handles over its + // lifetime. + slotToolIDs: new Map(), } } return state.toolBridge @@ -661,42 +667,54 @@ export function applyGeminiToolChoice(tools, toolConfig) { export 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. - } + 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 } + }) - await client.mcp.add({ - body: { - name: slotName, - config: { - type: "local", - command: ["node", BRIDGE_SCRIPT_PATH], - environment: { - OPENCODE_LLM_PROXY_BRIDGE_TOOLS: JSON.stringify(bridgeTools), + 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, }, - timeout: 10000, }, - }, - }) + }) - const toolIDs = bridgeTools.map((tool) => `${slotName}_${tool.name}`) - const bridgeState = getToolBridgeState() - for (const id of toolIDs) bridgeState.knownToolIDs.add(id) - return { slotName, toolIDs, nameMap } + const toolIDs = bridgeTools.map((tool) => `${slotName}_${tool.name}`) + const bridgeState = getToolBridgeState() + bridgeState.slotToolIDs.set(slotName, toolIDs) + return { slotName, toolIDs, nameMap } + } catch (error) { + // If anything above fails after we've already acquired the slot (most likely + // client.mcp.add() failing to spawn/register the bridge process), the caller + // never gets a bridge object back to release via releaseToolBridge() in its + // normal finally block - runAgentTurn() only knows about `bridge` once this + // function successfully returns. Without this, the slot would be lost from the + // pool forever, and repeated failures would eventually exhaust it and hang all + // future tool-calling requests in acquireBridgeSlot(). + releaseBridgeSlot(slotName) + throw error + } } -function releaseToolBridge(bridge) { +export function releaseToolBridge(bridge) { if (bridge) releaseBridgeSlot(bridge.slotName) } @@ -717,7 +735,9 @@ export function buildToolsMap(baseTools, bridge) { const toolsMap = { ...baseTools } if (!bridge) return toolsMap const bridgeState = getToolBridgeState() - for (const id of bridgeState.knownToolIDs) toolsMap[id] = false + for (const ids of bridgeState.slotToolIDs.values()) { + for (const id of ids) toolsMap[id] = false + } for (const id of bridge.toolIDs) toolsMap[id] = true return toolsMap } diff --git a/index.test.js b/index.test.js index 7adeea1..add5f6a 100644 --- a/index.test.js +++ b/index.test.js @@ -1,5 +1,6 @@ -import test, { describe, it } from "node:test" +import test, { describe, it, after } from "node:test" import assert from "node:assert/strict" +import { setTimeout as delay } from "node:timers/promises" import { createProxyFetchHandler, @@ -26,6 +27,7 @@ import { parseGeminiTools, applyGeminiToolChoice, registerToolBridge, + releaseToolBridge, buildToolsMap, } from "./index.js" @@ -2273,6 +2275,16 @@ describe("buildToolsMap / registerToolBridge slot isolation", () => { "test setup assumption: the two turns must land on different bridge tool IDs", ) + // Release both acquired slots back to the shared pool once we're done with them - + // otherwise, since the pool is a fixed-size global resource shared across the whole + // test process, leaking slots here could exhaust it and make later + // acquireBridgeSlot() calls (in other tests, or a re-run) hang forever waiting for + // a free slot. + after(() => { + releaseToolBridge(bridge1) + releaseToolBridge(bridge2) + }) + const baseTools = { bash: false, read: false, edit: false } const toolsMap = buildToolsMap(baseTools, bridge2) @@ -2294,6 +2306,56 @@ describe("buildToolsMap / registerToolBridge slot isolation", () => { assert.deepEqual(toolsMap, baseTools) assert.notEqual(toolsMap, baseTools, "must be a copy, not the same object") }) + + // Regression test for: if registerToolBridge() throws after acquireBridgeSlot() has + // already handed out a slot (e.g. client.mcp.add() fails to spawn/register the + // bridge process), the slot must still be released back to the pool. Otherwise the + // caller never gets a bridge object back to release via the normal + // releaseToolBridge()-in-a-finally path in runAgentTurn() - the slot would be lost + // forever, and enough repeated failures would eventually exhaust the whole pool and + // hang every future tool-calling request in acquireBridgeSlot(). + it("releases the acquired slot back to the pool when registration fails after acquiring it", async () => { + let shouldFail = true + const client = { + mcp: { + disconnect: async () => { + throw new Error("not connected") + }, + add: async () => { + if (shouldFail) throw new Error("simulated client.mcp.add failure") + return { data: {} } + }, + }, + } + const tools = [{ name: "flaky_tool", description: "", parameters: { type: "object", properties: {} } }] + + // We can't directly inspect the pool's internals from here, but repeating the same + // failure many times in a row (comfortably more than the pool size, so this would + // exhaust it if any single one leaked its slot) and then confirming one more + // registration still succeeds - rather than hanging forever waiting for a free + // slot in acquireBridgeSlot() - only holds if every failure released its slot. + // Wrap the whole sequence in a timeout race so a regression fails this test + // clearly and quickly instead of hanging the suite: node:test has no default + // per-test timeout, and a leaked-slot regression would hang acquireBridgeSlot() + // forever with nothing else to catch it. + const timeout = delay(2000).then(() => { + throw new Error("timed out - one or more slots were leaked, not released") + }) + + const bridge = await Promise.race([ + (async () => { + for (let i = 0; i < 20; i++) { + await assert.rejects(() => registerToolBridge(client, tools), /simulated client\.mcp\.add failure/) + } + shouldFail = false + return registerToolBridge(client, tools) + })(), + timeout, + ]) + + assert.ok(bridge.slotName) + after(() => releaseToolBridge(bridge)) + }) }) test("POST /v1beta/models/:model:generateContent returns a functionCall part", async () => {