Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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).

---

Expand Down Expand Up @@ -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://<opencode-host-ip>:4010/v1`, API Key = your token
- **Anthropic**: Base URL `http://<opencode-host-ip>: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 `<opencode-host-ip>` — 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`.
Expand Down
94 changes: 57 additions & 37 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ("<slot>_<sanitized>") -> 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 ("<slot>_<sanitized>") -> 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)
}

Expand All @@ -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
}
Expand Down
64 changes: 63 additions & 1 deletion index.test.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,6 +27,7 @@ import {
parseGeminiTools,
applyGeminiToolChoice,
registerToolBridge,
releaseToolBridge,
buildToolsMap,
} from "./index.js"

Expand Down Expand Up @@ -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)

Expand All @@ -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 () => {
Expand Down