Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/** text eol=lf
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,12 @@ Streaming uses OpenCode's `client.event.subscribe()` SSE stream. Text deltas are

---

## Compatibility

The plugin ships a V1 plugin descriptor (`export default { id, server }`). OpenCode detects it (present in every release checked back to 1.15) and loads the plugin cleanly. On older builds the legacy loader probes every exported function, so you may see a harmless `failed to load plugin ... is not a function` line per worker in the startup log — the proxy still starts and serves normally.

---

## Limitations

- Media support depends on the selected model's advertised image, audio, video, and PDF/file capabilities
Expand Down
2 changes: 2 additions & 0 deletions dist/llm-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3389,6 +3389,7 @@ var OpenAIProxyPlugin = async ({ client }) => {
}
};
};
var index_default = { id: "opencode-llm-proxy", server: OpenAIProxyPlugin };
export {
OpenAIProxyPlugin,
applyAnthropicToolChoice,
Expand All @@ -3399,6 +3400,7 @@ export {
buildToolsMap,
createProxyFetchHandler,
createSseQueue,
index_default as default,
extractAssistantText,
extractGeminiSystemInstruction,
mapFinishReason,
Expand Down
10 changes: 10 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2770,3 +2770,13 @@ export const OpenAIProxyPlugin = async ({ client }) => {
},
}
}

// V1 plugin descriptor. opencode's plugin loader (readV1Plugin in
// packages/opencode/src/plugin/shared.ts) prefers a default export of this
// shape and invokes only `server`. Without it, the loader falls back to the
// legacy path, which invokes EVERY function-typed export as if it were the
// plugin — the pure helpers (buildPrompt, mapFinishReason, ...) then throw on
// the plugin-init argument and every worker logs "failed to load plugin"
// (see issue #86). Exporting the descriptor keeps all named exports intact
// for direct imports and tests.
export default { id: "opencode-llm-proxy", server: OpenAIProxyPlugin }
72 changes: 58 additions & 14 deletions index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict"
import { setTimeout as delay } from "node:timers/promises"
import { PassThrough } from "node:stream"

import * as mod from "./index.js"
import {
createProxyFetchHandler,
createSseQueue,
Expand Down Expand Up @@ -3438,21 +3439,25 @@ describe("mcp-tool-bridge runStdioServer", () => {
// OpenAIProxyPlugin (plugin entrypoint / server bootstrap)
// ---------------------------------------------------------------------------

// Shared Bun.serve mock: the plugin starts its HTTP server via Bun.serve, so
// tests stub globalThis.Bun and inspect the options it was called with.
const PROXY_STATE_KEY = "__opencodeOpenAIProxyState"

function withMockedBun(serve, run) {
const savedBun = globalThis.Bun
delete globalThis[PROXY_STATE_KEY]
globalThis.Bun = { serve }
return Promise.resolve()
.then(run)
.finally(() => {
if (savedBun === undefined) delete globalThis.Bun
else globalThis.Bun = savedBun
delete globalThis[PROXY_STATE_KEY]
})
}

describe("OpenAIProxyPlugin", () => {
const STATE_KEY = "__opencodeOpenAIProxyState"

function withMockedBun(serve, run) {
const savedBun = globalThis.Bun
delete globalThis[STATE_KEY]
globalThis.Bun = { serve }
return Promise.resolve()
.then(run)
.finally(() => {
if (savedBun === undefined) delete globalThis.Bun
else globalThis.Bun = savedBun
delete globalThis[STATE_KEY]
})
}
const STATE_KEY = PROXY_STATE_KEY

it("starts a Bun server and records it in global state", async () => {
const calls = []
Expand Down Expand Up @@ -3587,3 +3592,42 @@ describe("OpenAIProxyPlugin", () => {
)
})
})

// ---------------------------------------------------------------------------
// V1 plugin descriptor (default export)
// ---------------------------------------------------------------------------

describe("V1 plugin default export", () => {
it("exposes a { id, server } descriptor so opencode's loader skips the legacy fallback", async () => {
assert.ok(mod.default, "index.js must have a default export")
assert.equal(typeof mod.default, "object", "default export must be a V1 descriptor object")
assert.equal(mod.default.id, "opencode-llm-proxy")
assert.equal(typeof mod.default.server, "function", "descriptor.server must be the plugin factory")
})

it("descriptor.server starts the proxy when invoked, like the loader would", async () => {
const calls = []

await withMockedBun(
(opts) => {
calls.push(opts)
return { stopped: false }
},
async () => {
process.env.OPENCODE_LLM_PROXY_HOST = "127.0.0.1"
process.env.OPENCODE_LLM_PROXY_PORT = "4997"
try {
const result = await mod.default.server({ client: createClient() })

assert.equal(typeof result["chat.params"], "function")
assert.equal(calls.length, 1)
assert.equal(calls[0].port, 4997)
} finally {
delete process.env.OPENCODE_LLM_PROXY_HOST
delete process.env.OPENCODE_LLM_PROXY_PORT
}
},
)
})
})