From b3295e6fad046e3d299e77f42fe63211b16ee46f Mon Sep 17 00:00:00 2001 From: KochC Date: Fri, 27 Mar 2026 15:44:50 +0100 Subject: [PATCH 1/6] feat: expand tests, add ESLint, document env vars - Add 17 new integration tests: CORS edge cases (disallowed origins, no-origin header, OPTIONS for disallowed origin), auth (401/pass-through), and error handling (400/502/404) for /v1/chat/completions Closes #14, closes #16 - Add ESLint with flat config, npm run lint script, and Lint job in CI Closes #15 - Improve README with quickstart section, npm install instructions, and corrected package name; add type column to env vars table Closes #17 --- .github/workflows/ci.yml | 22 + .npmignore | 2 + README.md | 82 +++- eslint.config.js | 48 ++ index.test.js | 214 ++++++++- package-lock.json | 933 +++++++++++++++++++++++++++++++++++++++ package.json | 5 + 7 files changed, 1285 insertions(+), 21 deletions(-) create mode 100644 eslint.config.js create mode 100644 package-lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c072617..65673fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,25 @@ on: - dev jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Install dev dependencies + run: npm install + + - name: Run lint + run: npm run lint + test: name: Test (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest @@ -28,5 +47,8 @@ jobs: with: node-version: ${{ matrix.node-version }} + - name: Install dev dependencies + run: npm install + - name: Run tests run: npm test diff --git a/.npmignore b/.npmignore index 2f9c7a0..c1c7e82 100644 --- a/.npmignore +++ b/.npmignore @@ -3,4 +3,6 @@ index.test.js .release-please-manifest.json release-please-config.json CHANGELOG.md +eslint.config.js +node_modules/ diff --git a/README.md b/README.md index c7fe331..90a676b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,62 @@ -# opencode-openai-proxy +# opencode-llm-proxy An [OpenCode](https://opencode.ai) plugin that starts a local OpenAI-compatible HTTP server backed by your OpenCode providers. -Any tool or application that speaks the OpenAI Chat Completions or Responses API can use it — including the Agile-V Studio platform, LangChain, custom scripts, etc. +Any tool or application that speaks the OpenAI Chat Completions or Responses API can use it — including LangChain, custom scripts, local frontends, etc. + +## Quickstart + +```bash +# 1. Install the npm package +npm install opencode-llm-proxy + +# 2. Register the plugin in your opencode.json +# (or use one of the manual install methods below) +``` + +Add to `opencode.json`: + +```json +{ + "plugin": ["opencode-llm-proxy"] +} +``` + +Then start OpenCode — the proxy starts automatically: + +```bash +opencode +# Proxy is now listening on http://127.0.0.1:4010 +``` + +Send a request: + +```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": "Hello!"}] + }' +``` ## Install -### As a global OpenCode plugin (recommended) +### As an npm plugin (recommended) + +```bash +npm install opencode-llm-proxy +``` + +Add to `opencode.json`: + +```json +{ + "plugin": ["opencode-llm-proxy"] +} +``` + +### As a global OpenCode plugin Copy `index.js` to your global plugin directory: @@ -24,16 +74,6 @@ Copy `index.js` to your project's plugin directory: cp index.js .opencode/plugins/openai-proxy.js ``` -### As an npm plugin - -Add it to your `opencode.json`: - -```json -{ - "plugin": ["opencode-openai-proxy"] -} -``` - ## Usage Start OpenCode normally. The proxy server starts automatically in the background: @@ -80,14 +120,16 @@ curl http://127.0.0.1:4010/v1/responses \ ## Configuration -| Environment variable | Default | Description | -|---|---|---| -| `OPENCODE_LLM_PROXY_HOST` | `127.0.0.1` | Bind host. Set to `0.0.0.0` to expose on LAN. | -| `OPENCODE_LLM_PROXY_PORT` | `4010` | Bind port. | -| `OPENCODE_LLM_PROXY_TOKEN` | _(none)_ | Optional bearer token. If set, all requests must include `Authorization: Bearer `. | -| `OPENCODE_LLM_PROXY_CORS_ORIGIN` | `*` | CORS `Access-Control-Allow-Origin` header value. Use a specific origin if browser clients send credentials. | +All configuration is done through environment variables. No configuration file is needed. + +| Variable | Type | Default | Description | +|---|---|---|---| +| `OPENCODE_LLM_PROXY_HOST` | string | `127.0.0.1` | Bind address. Set to `0.0.0.0` to expose on LAN. | +| `OPENCODE_LLM_PROXY_PORT` | integer | `4010` | TCP port the proxy listens on. | +| `OPENCODE_LLM_PROXY_TOKEN` | string | _(unset)_ | Optional bearer token. When set, every request must include `Authorization: Bearer `. Unset means no authentication required. | +| `OPENCODE_LLM_PROXY_CORS_ORIGIN` | string | `*` | Value of the `Access-Control-Allow-Origin` response header. Use a specific origin (e.g. `https://app.example.com`) when browser clients send credentials. | -The proxy answers browser preflight requests and adds CORS headers on success and error responses for `/health`, `/v1/models`, `/v1/chat/completions`, and `/v1/responses`. +The proxy adds CORS headers to all responses and handles `OPTIONS` preflight requests automatically. ### LAN example diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..45f5a7e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,48 @@ +import js from "@eslint/js" + +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + // Node.js globals + process: "readonly", + globalThis: "readonly", + crypto: "readonly", + // Bun globals (used in OpenAIProxyPlugin) + Bun: "readonly", + // Web API globals available in both Node and Bun + Request: "readonly", + Response: "readonly", + URL: "readonly", + }, + }, + rules: { + "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + "no-console": "warn", + }, + }, + { + // Relax rules for the test file + files: ["*.test.js"], + languageOptions: { + globals: { + // node:test globals + describe: "readonly", + it: "readonly", + before: "readonly", + after: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + }, + }, + rules: { + "no-unused-vars": "off", + }, + }, + { + ignores: ["node_modules/"], + }, +] diff --git a/index.test.js b/index.test.js index e381807..e4241f7 100644 --- a/index.test.js +++ b/index.test.js @@ -92,8 +92,220 @@ test("configured origin is returned for normal requests", async () => { } }) +test("disallowed origin does not receive its own origin back", async () => { + process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN = "https://allowed.example.com" + + try { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/health", { + headers: { Origin: "https://evil.example.com" }, + }) + + const response = await handler(request) + + // The header must be the configured origin, not the request's origin + assert.equal(response.headers.get("access-control-allow-origin"), "https://allowed.example.com") + assert.notEqual(response.headers.get("access-control-allow-origin"), "https://evil.example.com") + } finally { + delete process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN + } +}) + +test("request with no Origin header is handled gracefully", async () => { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/health") + + const response = await handler(request) + + assert.equal(response.status, 200) + // CORS header is still present (wildcard default) even without an Origin + assert.equal(response.headers.get("access-control-allow-origin"), "*") +}) + +test("OPTIONS preflight for disallowed origin returns configured origin, not request origin", async () => { + process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN = "https://allowed.example.com" + + try { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "OPTIONS", + headers: { + Origin: "https://evil.example.com", + "Access-Control-Request-Method": "POST", + }, + }) + + const response = await handler(request) + + assert.equal(response.status, 204) + assert.equal(response.headers.get("access-control-allow-origin"), "https://allowed.example.com") + assert.notEqual(response.headers.get("access-control-allow-origin"), "https://evil.example.com") + } finally { + delete process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN + } +}) + +// --------------------------------------------------------------------------- +// Integration: authentication +// --------------------------------------------------------------------------- + +test("missing token returns 401 when token is configured", async () => { + process.env.OPENCODE_LLM_PROXY_TOKEN = "secret-token" + + try { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/health") + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 401) + assert.equal(body.error.type, "invalid_request_error") + assert.ok(response.headers.get("www-authenticate")?.includes("Bearer")) + } finally { + delete process.env.OPENCODE_LLM_PROXY_TOKEN + } +}) + +test("wrong token returns 401", async () => { + process.env.OPENCODE_LLM_PROXY_TOKEN = "secret-token" + + try { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/health", { + headers: { Authorization: "Bearer wrong-token" }, + }) + + const response = await handler(request) + + assert.equal(response.status, 401) + } finally { + delete process.env.OPENCODE_LLM_PROXY_TOKEN + } +}) + +test("correct token passes through", async () => { + process.env.OPENCODE_LLM_PROXY_TOKEN = "secret-token" + + try { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/health", { + headers: { Authorization: "Bearer secret-token" }, + }) + + const response = await handler(request) + + assert.equal(response.status, 200) + } finally { + delete process.env.OPENCODE_LLM_PROXY_TOKEN + } +}) + +test("no token configured allows all requests through", async () => { + delete process.env.OPENCODE_LLM_PROXY_TOKEN + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/health") + + const response = await handler(request) + + assert.equal(response.status, 200) +}) + // --------------------------------------------------------------------------- -// Unit: toTextContent +// Integration: /v1/chat/completions error handling +// --------------------------------------------------------------------------- + +test("malformed JSON body returns 400", async () => { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ not valid json", + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.equal(body.error.type, "invalid_request_error") +}) + +test("missing model field returns 400", async () => { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.ok(body.error.message.includes("model")) +}) + +test("missing messages field returns 400", async () => { + const handler = createProxyFetchHandler(createClient()) + 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" }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.ok(body.error.message.includes("messages")) +}) + +test("stream: true returns 400 (not implemented)", async () => { + const handler = createProxyFetchHandler(createClient()) + 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" }], + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.ok(body.error.message.toLowerCase().includes("stream")) +}) + +test("unknown model returns 502", async () => { + const handler = createProxyFetchHandler(createClient()) // client returns no providers + const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "nonexistent-model", + messages: [{ role: "user", content: "hi" }], + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 502) + assert.ok(body.error.message.includes("nonexistent-model")) +}) + +test("unknown route returns 404", async () => { + const handler = createProxyFetchHandler(createClient()) + const request = new Request("http://127.0.0.1:4010/unknown-path") + + const response = await handler(request) + + assert.equal(response.status, 404) +}) + // --------------------------------------------------------------------------- describe("toTextContent", () => { it("returns a string unchanged", () => { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..210fc17 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,933 @@ +{ + "name": "opencode-llm-proxy", + "version": "1.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-llm-proxy", + "version": "1.2.0", + "license": "MIT", + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.1.0" + }, + "peerDependencies": { + "opencode-ai": "*" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/opencode-ai": { + "version": "1.3.3", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "opencode": "bin/opencode" + }, + "optionalDependencies": { + "opencode-darwin-arm64": "1.3.3", + "opencode-darwin-x64": "1.3.3", + "opencode-darwin-x64-baseline": "1.3.3", + "opencode-linux-arm64": "1.3.3", + "opencode-linux-arm64-musl": "1.3.3", + "opencode-linux-x64": "1.3.3", + "opencode-linux-x64-baseline": "1.3.3", + "opencode-linux-x64-baseline-musl": "1.3.3", + "opencode-linux-x64-musl": "1.3.3", + "opencode-windows-arm64": "1.3.3", + "opencode-windows-x64": "1.3.3", + "opencode-windows-x64-baseline": "1.3.3" + } + }, + "node_modules/opencode-darwin-arm64": { + "version": "1.3.3", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/opencode-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.3.3.tgz", + "integrity": "sha512-/AmjZ2hu7pVRKpj7t6siiiW3xo68enjRUmfAOI+grIAdX64oh+95xf/l7hsf2TLIWjRev+9kOBjUVMQTQNu2VA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/opencode-darwin-x64-baseline": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.3.3.tgz", + "integrity": "sha512-4Hp1Sr99BL3Poa+kz9ZNp0Lt9uwIoT8OYF/f10jNdMUZLBNSijVIiSH0zH3KyBKMRvNl0ZcWbOvKGTaRh9X0Kg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/opencode-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.3.3.tgz", + "integrity": "sha512-i2/PR9lMPpn0RjELiAKcdLkDjtBHP+l/HVxNFB7l3E9jXT2V/WohOZOGlegIsYmm6bB10/qU0UrzPlRLLJY1kg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/opencode-linux-arm64-musl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.3.3.tgz", + "integrity": "sha512-N4pBzZDeTq4noc4/SwIm4roGb6OtDt9XOOE9p6OsB+4JhCuBIUcyMW71EQCIFlH197vmcJfWCo/AdCa3OS6uHA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/opencode-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.3.3.tgz", + "integrity": "sha512-BpqYkbk8adAvnXTNFOjs5gxOsbqA/+l7J0PRIQtvslwRgVnrPMQoCXeD9okSXaVxMvyil4mWdodalY3wkY5LWg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/opencode-linux-x64-baseline": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.3.3.tgz", + "integrity": "sha512-9dY89V7tKNzyOsbH9pIQORCxPGImwnDvoyMZ1s1NtXDCXz/ZJWfzYOcVWBZZA7frRcwnwIueZjrz0aARDAtLdg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/opencode-linux-x64-baseline-musl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.3.3.tgz", + "integrity": "sha512-QXiIDscOCDN0z80SrO/L4Oi/f8fxs0c4zV12eUA13zw8MITLjOzdRoBIIv8jwwgjLC7rJd/PE8CIkiB5xMjuXQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/opencode-linux-x64-musl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.3.3.tgz", + "integrity": "sha512-mJlzR+VOv+zqxLbpd4JhUF9ElbN/9ebQ395onTUDZU3vGtTvqz5Z3cZm8R7Xd+GNs5f/AkPUNyYqlHrmT85RKw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/opencode-windows-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.3.3.tgz", + "integrity": "sha512-1GeiiZocPzE0mBp6cgON/180DN1v+jT0YH4mKEY1nof5V0CWS5WazvciPdGDTf0mfnvz+FfrDpxFxpA8HVU+SA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/opencode-windows-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.3.3.tgz", + "integrity": "sha512-pE7VJNy3s3nMgdhbbZIGW9f/kHxgdV3sqAxM5kJd8WVOetQQ7DxUH52+rwYs0DqyoX9lZqIY2SnWCRFxio5Qtw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/opencode-windows-x64-baseline": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.3.3.tgz", + "integrity": "sha512-+S6ADlSdB3Cf+JfkVeJ1087lM6BeCslROfNUt3I2Y6hktqwOKRnlhI/dz/SySaGeQD0gysgYcQ7PzZPQpPNa/w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index f67e23b..a20f16c 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "test": "node --test --experimental-test-coverage", + "lint": "eslint .", "start": "node index.js" }, "keywords": [ @@ -23,5 +24,9 @@ }, "peerDependencies": { "opencode-ai": "*" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.1.0" } } From cdaba5ddd4702e63ff83e885c539aaf4d62ebb63 Mon Sep 17 00:00:00 2001 From: KochC Date: Fri, 27 Mar 2026 16:08:57 +0100 Subject: [PATCH 2/6] feat: implement SSE streaming and support all opencode providers - Implement streaming for POST /v1/chat/completions (issue #11): subscribe to opencode event stream, pipe message.part.updated deltas as SSE chat.completion.chunk events, finish on session.idle - Implement streaming for POST /v1/responses (issue #11): emit response.created / output_text.delta / response.completed events - Fix provider-agnostic system prompt hint (issue #12): remove 'OpenAI-compatible' wording so non-OpenAI models are not confused - Add TextEncoder and ReadableStream to ESLint globals - Add streaming integration tests (happy path, unknown model, session.error) --- eslint.config.js | 2 + index.js | 395 ++++++++++++++++++++++++++++++++++++++++++++--- index.test.js | 125 ++++++++++++++- 3 files changed, 495 insertions(+), 27 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 45f5a7e..b887cf9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,8 @@ export default [ Request: "readonly", Response: "readonly", URL: "readonly", + TextEncoder: "readonly", + ReadableStream: "readonly", }, }, rules: { diff --git a/index.js b/index.js index c00a412..3c905fb 100644 --- a/index.js +++ b/index.js @@ -178,7 +178,7 @@ export function buildSystemPrompt(messages, request) { .map((message) => message.content) const hints = [ - "You are answering through an OpenAI-compatible proxy backed by OpenCode.", + "You are answering through a proxy backed by OpenCode.", "Return only the assistant's reply content.", ] @@ -268,6 +268,69 @@ async function executePrompt(client, request, model, messages, system) { } } +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) + + return { + sessionID, + tokens: assistantMsg?.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: assistantMsg?.finish, + } +} + function createChatCompletionResponse(result, model) { const now = Math.floor(Date.now() / 1000) return { @@ -424,6 +487,33 @@ export async function resolveModel(client, requestedModel, providerOverride) { throw new Error(`Unknown model '${requestedModel}'. Call GET /v1/models to inspect available IDs.`) } +function sseResponse(corsHeadersObj, generator) { + const encoder = new TextEncoder() + const body = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of generator) { + controller.enqueue(encoder.encode(chunk)) + } + } catch { + // Stream errors are surfaced via SSE data before this point. + } finally { + controller.close() + } + }, + }) + + return new Response(body, { + status: 200, + headers: { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + ...corsHeadersObj, + }, + }) +} + function createModelResponse(models) { return { object: "list", @@ -473,10 +563,6 @@ export function createProxyFetchHandler(client) { return badRequest("Request body must be valid JSON.", 400, request) } - if (body.stream) { - return badRequest("Streaming is not implemented yet.", 400, request) - } - if (!body.model) { return badRequest("The 'model' field is required.", 400, request) } @@ -490,10 +576,111 @@ export function createProxyFetchHandler(client) { return badRequest("No text content was found in the supplied messages.", 400, request) } + let model try { const providerOverride = request.headers.get("x-opencode-provider") - const model = await resolveModel(client, body.model, providerOverride) - const system = buildSystemPrompt(messages, body) + model = await resolveModel(client, body.model, providerOverride) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await safeLog(client, "error", "Proxy completion failed", { + error: message, + requestedModel: body.model, + }) + return badRequest(message, 502, request) + } + + const system = buildSystemPrompt(messages, body) + + if (body.stream) { + const completionID = `chatcmpl_${crypto.randomUUID().replace(/-/g, "")}` + const now = Math.floor(Date.now() / 1000) + + const chunks = [] + let resolve = null + let done = false + + function enqueue(value) { + chunks.push(value) + if (resolve) { + const r = resolve + resolve = null + r() + } + } + + async function* generateSse() { + const runPromise = executePromptStreaming( + client, + model, + messages, + system, + (delta) => { + const chunk = JSON.stringify({ + id: completionID, + object: "chat.completion.chunk", + created: now, + model: model.id, + choices: [{ index: 0, delta: { role: "assistant", content: delta }, finish_reason: null }], + }) + enqueue(`data: ${chunk}\n\n`) + }, + ) + .then((streamResult) => { + const finalChunk = JSON.stringify({ + id: completionID, + object: "chat.completion.chunk", + created: now, + model: model.id, + choices: [{ index: 0, delta: {}, finish_reason: mapFinishReason(streamResult.finish) }], + usage: { + prompt_tokens: streamResult.tokens.input, + completion_tokens: streamResult.tokens.output, + total_tokens: streamResult.tokens.input + streamResult.tokens.output, + }, + }) + enqueue(`data: ${finalChunk}\n\ndata: [DONE]\n\n`) + }) + .catch(async (err) => { + const streamError = err instanceof Error ? err.message : String(err) + await safeLog(client, "error", "Proxy streaming completion failed", { + error: streamError, + requestedModel: body.model, + }) + const errChunk = JSON.stringify({ + error: { message: streamError, type: "server_error" }, + }) + enqueue(`data: ${errChunk}\n\ndata: [DONE]\n\n`) + }) + .finally(() => { + done = true + if (resolve) { + const r = resolve + resolve = null + r() + } + }) + + while (true) { + while (chunks.length > 0) { + yield chunks.shift() + } + if (done) break + await new Promise((r) => { + resolve = r + }) + } + // Drain any remaining chunks + while (chunks.length > 0) { + yield chunks.shift() + } + + await runPromise + } + + return sseResponse(corsHeaders(request), generateSse()) + } + + try { const result = await executePrompt(client, body, model, messages, system) return json(createChatCompletionResponse(result, model), 200, {}, request) } catch (error) { @@ -514,10 +701,6 @@ export function createProxyFetchHandler(client) { return badRequest("Request body must be valid JSON.", 400, request) } - if (body.stream) { - return badRequest("Streaming is not implemented yet.", 400, request) - } - if (!body.model) { return badRequest("The 'model' field is required.", 400, request) } @@ -527,19 +710,187 @@ export function createProxyFetchHandler(client) { return badRequest("The 'input' field must contain at least one text message.", 400, request) } + const instructionMessages = + typeof body.instructions === "string" && body.instructions.trim() + ? [{ role: "system", content: body.instructions.trim() }, ...messages] + : messages + + const system = buildSystemPrompt(instructionMessages, { + temperature: body.temperature, + max_tokens: body.max_output_tokens, + max_completion_tokens: body.max_output_tokens, + }) + + let model try { const providerOverride = request.headers.get("x-opencode-provider") - const model = await resolveModel(client, body.model, providerOverride) - const system = buildSystemPrompt( - typeof body.instructions === "string" && body.instructions.trim() - ? [{ role: "system", content: body.instructions.trim() }, ...messages] - : messages, - { - temperature: body.temperature, - max_tokens: body.max_output_tokens, - max_completion_tokens: body.max_output_tokens, - }, - ) + model = await resolveModel(client, body.model, providerOverride) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await safeLog(client, "error", "Proxy responses call failed", { + error: message, + requestedModel: body.model, + }) + return badRequest(message, 502, request) + } + + if (body.stream) { + const responseID = `resp_${crypto.randomUUID().replace(/-/g, "")}` + const itemID = `msg_${crypto.randomUUID().replace(/-/g, "")}` + const now = Math.floor(Date.now() / 1000) + + const chunks = [] + let resolve = null + let done = false + + function enqueue(value) { + chunks.push(value) + if (resolve) { + const r = resolve + resolve = null + r() + } + } + + function sseEvent(eventType, data) { + return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n` + } + + async function* generateSse() { + enqueue( + sseEvent("response.created", { + type: "response.created", + response: { + id: responseID, + object: "response", + created_at: now, + status: "in_progress", + model: model.id, + output: [], + }, + }), + ) + enqueue( + sseEvent("response.output_item.added", { + type: "response.output_item.added", + output_index: 0, + item: { id: itemID, type: "message", status: "in_progress", role: "assistant", content: [] }, + }), + ) + + let partIndex = 0 + const runPromise = executePromptStreaming( + client, + model, + messages, + system, + (delta) => { + if (partIndex === 0) { + enqueue( + sseEvent("response.content_part.added", { + type: "response.content_part.added", + item_id: itemID, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }), + ) + partIndex++ + } + enqueue( + sseEvent("response.output_text.delta", { + type: "response.output_text.delta", + item_id: itemID, + output_index: 0, + content_index: 0, + delta, + }), + ) + }, + ) + .then((streamResult) => { + enqueue( + sseEvent("response.output_text.done", { + type: "response.output_text.done", + item_id: itemID, + output_index: 0, + content_index: 0, + text: "", + }), + ) + enqueue( + sseEvent("response.output_item.done", { + type: "response.output_item.done", + output_index: 0, + item: { id: itemID, type: "message", status: "completed", role: "assistant" }, + }), + ) + 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, + }, + }, + }), + ) + }) + .catch(async (err) => { + const errMsg = err instanceof Error ? err.message : String(err) + await safeLog(client, "error", "Proxy streaming responses call failed", { + error: errMsg, + requestedModel: body.model, + }) + enqueue( + sseEvent("response.failed", { + type: "response.failed", + response: { + id: responseID, + object: "response", + created_at: now, + status: "failed", + error: { message: errMsg, code: "server_error" }, + }, + }), + ) + }) + .finally(() => { + done = true + if (resolve) { + const r = resolve + resolve = null + r() + } + }) + + while (true) { + while (chunks.length > 0) { + yield chunks.shift() + } + if (done) break + await new Promise((r) => { + resolve = r + }) + } + while (chunks.length > 0) { + yield chunks.shift() + } + + await runPromise + } + + return sseResponse(corsHeaders(request), generateSse()) + } + + try { const result = await executePrompt(client, body, model, messages, system) return json(createResponsesApiResponse(result, model), 200, {}, request) } catch (error) { diff --git a/index.test.js b/index.test.js index e4241f7..264f53c 100644 --- a/index.test.js +++ b/index.test.js @@ -32,6 +32,47 @@ function createClient() { } } +function createStreamingClient(chunks) { + async function* makeStream() { + for (const chunk of chunks) { + yield chunk + } + } + + return { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [ + { + id: "openai", + models: { "gpt-4o": { id: "gpt-4o", name: "GPT-4o" } }, + }, + ], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-123" } }), + promptAsync: async () => {}, + messages: async () => ({ + data: [ + { + role: "assistant", + tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "end_turn", + }, + ], + }), + }, + event: { + subscribe: async () => ({ stream: makeStream() }), + }, + } +} + test("OPTIONS preflight returns CORS headers", async () => { const handler = createProxyFetchHandler(createClient()) const request = new Request("http://127.0.0.1:4010/v1/models", { @@ -260,8 +301,26 @@ test("missing messages field returns 400", async () => { assert.ok(body.error.message.includes("messages")) }) -test("stream: true returns 400 (not implemented)", async () => { - const handler = createProxyFetchHandler(createClient()) +test("stream: true returns SSE response", async () => { + const events = [ + { + type: "message.part.updated", + properties: { + part: { sessionID: "sess-123", type: "text" }, + delta: "Hello", + }, + }, + { + type: "message.part.updated", + properties: { + part: { sessionID: "sess-123", type: "text" }, + delta: " world", + }, + }, + { type: "session.idle", properties: { sessionID: "sess-123" } }, + ] + + const handler = createProxyFetchHandler(createStreamingClient(events)) const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" }, @@ -272,11 +331,67 @@ test("stream: true returns 400 (not implemented)", async () => { }), }) + const response = await handler(request) + + assert.equal(response.status, 200) + assert.ok(response.headers.get("content-type")?.includes("text/event-stream")) + + const text = await response.text() + assert.ok(text.includes("chat.completion.chunk")) + assert.ok(text.includes("Hello")) + assert.ok(text.includes(" world")) + assert.ok(text.includes("[DONE]")) +}) + +test("stream: true with unknown model returns 502", async () => { + const handler = createProxyFetchHandler(createClient()) // no providers + const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "nonexistent-model", + stream: true, + messages: [{ role: "user", content: "hi" }], + }), + }) + const response = await handler(request) const body = await response.json() - assert.equal(response.status, 400) - assert.ok(body.error.message.toLowerCase().includes("stream")) + assert.equal(response.status, 502) + assert.ok(body.error.message.includes("nonexistent-model")) +}) + +test("stream: true propagates session.error into the SSE stream", async () => { + const events = [ + { + type: "session.error", + properties: { + sessionID: "sess-123", + error: { message: "Model overloaded" }, + }, + }, + { type: "session.idle", properties: { sessionID: "sess-123" } }, + ] + + const handler = createProxyFetchHandler(createStreamingClient(events)) + 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" }], + }), + }) + + const response = await handler(request) + assert.equal(response.status, 200) + assert.ok(response.headers.get("content-type")?.includes("text/event-stream")) + + const text = await response.text() + assert.ok(text.includes("server_error") || text.includes("Model overloaded")) + assert.ok(text.includes("[DONE]")) }) test("unknown model returns 502", async () => { @@ -446,7 +561,7 @@ describe("buildSystemPrompt", () => { it("always includes the proxy hint lines", () => { const result = buildSystemPrompt([], {}) - assert.ok(result.includes("OpenAI-compatible proxy")) + assert.ok(result.includes("proxy backed by OpenCode")) assert.ok(result.includes("Return only the assistant")) }) From 41b05727570fb6848bb3507901e774c9ba7d2a50 Mon Sep 17 00:00:00 2001 From: KochC Date: Fri, 27 Mar 2026 16:42:56 +0100 Subject: [PATCH 3/6] feat: refactor SSE queue, expand test coverage, fix package metadata - Extract createSseQueue() helper, eliminating duplicated SSE queue pattern in /v1/chat/completions and /v1/responses streaming branches (closes #34) - Add tests for GET /v1/models happy path, empty providers, and error path (closes #33) - Add tests for POST /v1/responses: happy path, validation, streaming, session.error (closes #32) - Fix package.json description to be provider-agnostic (closes #35) - Add engines field declaring bun >=1.0.0 requirement (closes #35) - Line coverage: 55% -> 89%, function coverage: 83% -> 94% --- index.js | 131 ++++++++---------- index.test.js | 369 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 +- 3 files changed, 432 insertions(+), 73 deletions(-) diff --git a/index.js b/index.js index 3c905fb..c9c1918 100644 --- a/index.js +++ b/index.js @@ -487,6 +487,48 @@ export async function resolveModel(client, requestedModel, providerOverride) { throw new Error(`Unknown model '${requestedModel}'. Call GET /v1/models to inspect available IDs.`) } +export function createSseQueue() { + const chunks = [] + let resolve = null + let done = false + + function enqueue(value) { + chunks.push(value) + if (resolve) { + const r = resolve + resolve = null + r() + } + } + + function finish() { + done = true + if (resolve) { + const r = resolve + resolve = null + r() + } + } + + async function* generateChunks() { + while (true) { + while (chunks.length > 0) { + yield chunks.shift() + } + if (done) break + await new Promise((r) => { + resolve = r + }) + } + // Drain any remaining chunks + while (chunks.length > 0) { + yield chunks.shift() + } + } + + return { enqueue, finish, generateChunks } +} + function sseResponse(corsHeadersObj, generator) { const encoder = new TextEncoder() const body = new ReadableStream({ @@ -595,18 +637,7 @@ export function createProxyFetchHandler(client) { const completionID = `chatcmpl_${crypto.randomUUID().replace(/-/g, "")}` const now = Math.floor(Date.now() / 1000) - const chunks = [] - let resolve = null - let done = false - - function enqueue(value) { - chunks.push(value) - if (resolve) { - const r = resolve - resolve = null - r() - } - } + const queue = createSseQueue() async function* generateSse() { const runPromise = executePromptStreaming( @@ -622,7 +653,7 @@ export function createProxyFetchHandler(client) { model: model.id, choices: [{ index: 0, delta: { role: "assistant", content: delta }, finish_reason: null }], }) - enqueue(`data: ${chunk}\n\n`) + queue.enqueue(`data: ${chunk}\n\n`) }, ) .then((streamResult) => { @@ -638,7 +669,7 @@ export function createProxyFetchHandler(client) { total_tokens: streamResult.tokens.input + streamResult.tokens.output, }, }) - enqueue(`data: ${finalChunk}\n\ndata: [DONE]\n\n`) + queue.enqueue(`data: ${finalChunk}\n\ndata: [DONE]\n\n`) }) .catch(async (err) => { const streamError = err instanceof Error ? err.message : String(err) @@ -649,30 +680,13 @@ export function createProxyFetchHandler(client) { const errChunk = JSON.stringify({ error: { message: streamError, type: "server_error" }, }) - enqueue(`data: ${errChunk}\n\ndata: [DONE]\n\n`) + queue.enqueue(`data: ${errChunk}\n\ndata: [DONE]\n\n`) }) .finally(() => { - done = true - if (resolve) { - const r = resolve - resolve = null - r() - } + queue.finish() }) - while (true) { - while (chunks.length > 0) { - yield chunks.shift() - } - if (done) break - await new Promise((r) => { - resolve = r - }) - } - // Drain any remaining chunks - while (chunks.length > 0) { - yield chunks.shift() - } + yield* queue.generateChunks() await runPromise } @@ -739,25 +753,14 @@ export function createProxyFetchHandler(client) { const itemID = `msg_${crypto.randomUUID().replace(/-/g, "")}` const now = Math.floor(Date.now() / 1000) - const chunks = [] - let resolve = null - let done = false - - function enqueue(value) { - chunks.push(value) - if (resolve) { - const r = resolve - resolve = null - r() - } - } + const queue = createSseQueue() function sseEvent(eventType, data) { return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n` } async function* generateSse() { - enqueue( + queue.enqueue( sseEvent("response.created", { type: "response.created", response: { @@ -770,7 +773,7 @@ export function createProxyFetchHandler(client) { }, }), ) - enqueue( + queue.enqueue( sseEvent("response.output_item.added", { type: "response.output_item.added", output_index: 0, @@ -786,7 +789,7 @@ export function createProxyFetchHandler(client) { system, (delta) => { if (partIndex === 0) { - enqueue( + queue.enqueue( sseEvent("response.content_part.added", { type: "response.content_part.added", item_id: itemID, @@ -797,7 +800,7 @@ export function createProxyFetchHandler(client) { ) partIndex++ } - enqueue( + queue.enqueue( sseEvent("response.output_text.delta", { type: "response.output_text.delta", item_id: itemID, @@ -809,7 +812,7 @@ export function createProxyFetchHandler(client) { }, ) .then((streamResult) => { - enqueue( + queue.enqueue( sseEvent("response.output_text.done", { type: "response.output_text.done", item_id: itemID, @@ -818,14 +821,14 @@ export function createProxyFetchHandler(client) { text: "", }), ) - enqueue( + queue.enqueue( sseEvent("response.output_item.done", { type: "response.output_item.done", output_index: 0, item: { id: itemID, type: "message", status: "completed", role: "assistant" }, }), ) - enqueue( + queue.enqueue( sseEvent("response.completed", { type: "response.completed", response: { @@ -849,7 +852,7 @@ export function createProxyFetchHandler(client) { error: errMsg, requestedModel: body.model, }) - enqueue( + queue.enqueue( sseEvent("response.failed", { type: "response.failed", response: { @@ -863,26 +866,10 @@ export function createProxyFetchHandler(client) { ) }) .finally(() => { - done = true - if (resolve) { - const r = resolve - resolve = null - r() - } + queue.finish() }) - while (true) { - while (chunks.length > 0) { - yield chunks.shift() - } - if (done) break - await new Promise((r) => { - resolve = r - }) - } - while (chunks.length > 0) { - yield chunks.shift() - } + yield* queue.generateChunks() await runPromise } diff --git a/index.test.js b/index.test.js index 264f53c..f26b033 100644 --- a/index.test.js +++ b/index.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict" import { createProxyFetchHandler, + createSseQueue, toTextContent, normalizeMessages, normalizeResponseInput, @@ -762,3 +763,371 @@ describe("resolveModel", () => { assert.equal(model.modelID, "gpt-4o-mini") }) }) + +// --------------------------------------------------------------------------- +// Unit: createSseQueue +// --------------------------------------------------------------------------- +describe("createSseQueue", () => { + it("enqueue followed by generateChunks yields the value", async () => { + const queue = createSseQueue() + queue.enqueue("hello") + queue.finish() + const results = [] + for await (const chunk of queue.generateChunks()) { + results.push(chunk) + } + assert.deepEqual(results, ["hello"]) + }) + + it("multiple enqueues before finish yields all values in order", async () => { + const queue = createSseQueue() + queue.enqueue("a") + queue.enqueue("b") + queue.enqueue("c") + queue.finish() + const results = [] + for await (const chunk of queue.generateChunks()) { + results.push(chunk) + } + assert.deepEqual(results, ["a", "b", "c"]) + }) + + it("finish with no enqueues yields nothing", async () => { + const queue = createSseQueue() + queue.finish() + const results = [] + for await (const chunk of queue.generateChunks()) { + results.push(chunk) + } + assert.deepEqual(results, []) + }) + + it("enqueue after generateChunks starts still yields the value", async () => { + const queue = createSseQueue() + // Start consuming before anything is enqueued + const generatorPromise = (async () => { + const results = [] + for await (const chunk of queue.generateChunks()) { + results.push(chunk) + } + return results + })() + // Enqueue asynchronously + await Promise.resolve() + queue.enqueue("late") + queue.finish() + const results = await generatorPromise + assert.deepEqual(results, ["late"]) + }) +}) + +// --------------------------------------------------------------------------- +// Integration: GET /v1/models +// --------------------------------------------------------------------------- + +function createModelsClient(providers = []) { + return { + app: { log: async () => {} }, + config: { + providers: async () => ({ data: { providers } }), + }, + } +} + +test("GET /v1/models returns model list", async () => { + const client = createModelsClient([ + { + id: "openai", + models: { + "gpt-4o": { id: "gpt-4o", name: "GPT-4o" }, + "gpt-4o-mini": { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + }, + }, + { + id: "anthropic", + models: { + "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" }, + }, + }, + ]) + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/models") + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.equal(body.object, "list") + assert.ok(Array.isArray(body.data)) + assert.equal(body.data.length, 3) + + const ids = body.data.map((m) => m.id) + assert.ok(ids.includes("openai/gpt-4o")) + assert.ok(ids.includes("openai/gpt-4o-mini")) + assert.ok(ids.includes("anthropic/claude-3-5-sonnet")) + + const first = body.data[0] + assert.equal(first.object, "model") + assert.ok("owned_by" in first) + assert.ok("created" in first) +}) + +test("GET /v1/models returns empty list when no providers configured", async () => { + const handler = createProxyFetchHandler(createModelsClient([])) + const request = new Request("http://127.0.0.1:4010/v1/models") + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.deepEqual(body, { object: "list", data: [] }) +}) + +test("GET /v1/models returns 500 when providers call throws", async () => { + const client = { + app: { log: async () => {} }, + config: { + providers: async () => { + throw new Error("upstream failure") + }, + }, + } + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/models") + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 500) + assert.equal(body.error.type, "server_error") +}) + +// --------------------------------------------------------------------------- +// Integration: POST /v1/responses +// --------------------------------------------------------------------------- + +function createResponsesClient(responseContent = "The answer is 42.") { + return { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [ + { + id: "anthropic", + models: { "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" } }, + }, + ], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-resp-1" } }), + prompt: async () => ({ + data: { + parts: [{ type: "text", text: responseContent }], + info: { tokens: { input: 20, output: 8, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "end_turn" }, + }, + }), + }, + } +} + +test("POST /v1/responses returns a well-formed response object", async () => { + const handler = createProxyFetchHandler(createResponsesClient("Hello from Claude.")) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "anthropic/claude-3-5-sonnet", + input: "Say hello.", + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.equal(body.object, "response") + assert.equal(body.status, "completed") + assert.ok(body.id.startsWith("resp_")) + assert.equal(body.output_text, "Hello from Claude.") + assert.ok(Array.isArray(body.output)) + assert.equal(body.output[0].role, "assistant") + assert.equal(body.usage.input_tokens, 20) + assert.equal(body.usage.output_tokens, 8) + assert.equal(body.usage.total_tokens, 28) +}) + +test("POST /v1/responses missing model returns 400", async () => { + const handler = createProxyFetchHandler(createResponsesClient()) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ input: "hi" }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.ok(body.error.message.includes("model")) +}) + +test("POST /v1/responses empty input returns 400", async () => { + const handler = createProxyFetchHandler(createResponsesClient()) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "anthropic/claude-3-5-sonnet", input: " " }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.ok(body.error.message.includes("input")) +}) + +test("POST /v1/responses malformed JSON returns 400", async () => { + const handler = createProxyFetchHandler(createResponsesClient()) + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ bad json", + }) + + const response = await handler(request) + + assert.equal(response.status, 400) +}) + +test("POST /v1/responses unknown model returns 502", async () => { + const handler = createProxyFetchHandler(createModelsClient([])) // no providers + const request = new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "nonexistent", input: "hi" }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 502) + assert.ok(body.error.message.includes("nonexistent")) +}) + +test("POST /v1/responses instructions field is incorporated", 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-instr" } }), + 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/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "anthropic/claude-3-5-sonnet", + input: "What is 2+2?", + instructions: "You are a math tutor.", + }), + }) + + await handler(request) + assert.ok(capturedSystem?.includes("You are a math tutor.")) +}) + +test("POST /v1/responses stream: true returns SSE lifecycle events", 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) + + assert.equal(response.status, 200) + assert.ok(response.headers.get("content-type")?.includes("text/event-stream")) + + const text = await response.text() + assert.ok(text.includes("response.created")) + assert.ok(text.includes("response.output_text.delta")) + assert.ok(text.includes("The answer")) + assert.ok(text.includes(" is 42.")) + assert.ok(text.includes("response.completed")) +}) + +test("POST /v1/responses stream: true with session.error emits response.failed", async () => { + const events = [ + { + type: "session.error", + properties: { + sessionID: "sess-123", + error: { message: "Rate limit exceeded" }, + }, + }, + { 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: "hi", + }), + }) + + const response = await handler(request) + assert.equal(response.status, 200) + + const text = await response.text() + assert.ok(text.includes("response.failed") || text.includes("Rate limit exceeded")) +}) diff --git a/package.json b/package.json index 2edf778..5e0d820 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "opencode-llm-proxy", "version": "1.3.0", - "description": "OpenCode plugin that exposes an OpenAI-compatible HTTP proxy backed by your OpenCode providers", + "description": "OpenCode plugin that exposes an OpenAI-compatible HTTP proxy backed by any LLM provider configured in OpenCode", "main": "index.js", "type": "module", + "engines": { + "bun": ">=1.0.0" + }, "scripts": { "test": "node --test --experimental-test-coverage", "lint": "eslint .", From e42eee3abfa2e54c00cf8636f03fd3c3723780db Mon Sep 17 00:00:00 2001 From: KochC Date: Fri, 27 Mar 2026 17:07:37 +0100 Subject: [PATCH 4/6] feat: add Anthropic Messages API and Google Gemini API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /v1/messages — Anthropic Messages API with streaming (SSE) - POST /v1beta/models/:model:generateContent — Gemini non-streaming - POST /v1beta/models/:model:streamGenerateContent — Gemini NDJSON streaming - New helpers: normalizeAnthropicMessages, normalizeGeminiContents, extractGeminiSystemInstruction, mapFinishReasonToAnthropic/Gemini - 35 new tests (77 -> 112 total, all passing) - Update README to document all supported API formats Closes #38, #39 --- README.md | 80 +++++++- index.js | 368 +++++++++++++++++++++++++++++++++ index.test.js | 555 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 996 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 90a676b..f85a06f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # opencode-llm-proxy -An [OpenCode](https://opencode.ai) plugin that starts a local OpenAI-compatible HTTP server backed by your OpenCode providers. +An [OpenCode](https://opencode.ai) plugin that starts a local HTTP server backed by your OpenCode providers, with support for multiple LLM API formats: -Any tool or application that speaks the OpenAI Chat Completions or Responses API can use it — including LangChain, custom scripts, local frontends, etc. +- **OpenAI** Chat Completions (`POST /v1/chat/completions`) and Responses (`POST /v1/responses`) +- **Anthropic** Messages API (`POST /v1/messages`) +- **Google Gemini** API (`POST /v1beta/models/:model:generateContent`) + +Any tool or SDK that targets one of these APIs can point at the proxy without code changes. ## Quickstart @@ -92,7 +96,7 @@ curl http://127.0.0.1:4010/v1/models Returns all models from all providers configured in your OpenCode setup (e.g. `github-copilot/claude-sonnet-4.6`, `ollama/qwen3.5:9b`, etc.). -### Chat completions +### OpenAI Chat Completions ```bash curl http://127.0.0.1:4010/v1/chat/completions \ @@ -105,7 +109,7 @@ curl http://127.0.0.1:4010/v1/chat/completions \ }' ``` -Use the fully-qualified `provider/model` ID from `GET /v1/models`. +Use the fully-qualified `provider/model` ID from `GET /v1/models`. Supports `"stream": true` for SSE streaming. ### OpenAI Responses API @@ -118,6 +122,69 @@ curl http://127.0.0.1:4010/v1/responses \ }' ``` +Supports `"stream": true` for SSE streaming. + +### Anthropic Messages API + +Point the Anthropic SDK (or any client) at this proxy: + +```bash +curl http://127.0.0.1:4010/v1/messages \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-3-5-sonnet", + "max_tokens": 1024, + "system": "You are a helpful assistant.", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +Supports `"stream": true` for SSE streaming with standard Anthropic streaming events (`message_start`, `content_block_delta`, `message_stop`, etc.). + +To point the official Anthropic SDK at this proxy: + +```js +import Anthropic from "@anthropic-ai/sdk" + +const client = new Anthropic({ + baseURL: "http://127.0.0.1:4010", + apiKey: "unused", // or your OPENCODE_LLM_PROXY_TOKEN +}) +``` + +### Google Gemini API + +```bash +# Non-streaming +curl http://127.0.0.1:4010/v1beta/models/google/gemini-2.0-flash:generateContent \ + -H "Content-Type: application/json" \ + -d '{ + "contents": [{"role": "user", "parts": [{"text": "Hello!"}]}] + }' + +# Streaming (newline-delimited JSON) +curl http://127.0.0.1:4010/v1beta/models/google/gemini-2.0-flash:streamGenerateContent \ + -H "Content-Type: application/json" \ + -d '{ + "contents": [{"role": "user", "parts": [{"text": "Hello!"}]}] + }' +``` + +The model name in the URL path is resolved the same way as other endpoints (use `provider/model` or a bare model ID if unambiguous). + +To point the Google Generative AI SDK at this proxy, set the `baseUrl` option to `http://127.0.0.1:4010`. + +## Selecting a provider + +All endpoints accept an optional `x-opencode-provider` header to force a specific provider when the model ID is ambiguous: + +```bash +curl http://127.0.0.1:4010/v1/chat/completions \ + -H "x-opencode-provider: anthropic" \ + -H "Content-Type: application/json" \ + -d '{"model": "claude-3-5-sonnet", "messages": [...]}' +``` + ## Configuration All configuration is done through environment variables. No configuration file is needed. @@ -149,15 +216,14 @@ curl http://:4010/v1/models \ ## How it works -The plugin hooks into OpenCode at startup and spawns a Bun HTTP server. Incoming OpenAI-format requests are translated into OpenCode SDK calls (`client.session.create` + `client.session.prompt`), routed through whichever provider/model is requested, and the response is returned in OpenAI format. +The plugin hooks into OpenCode at startup and spawns a Bun HTTP server. Incoming requests (in OpenAI, Anthropic, or Gemini format) are translated into OpenCode SDK calls (`client.session.create` + `client.session.prompt`), routed through whichever provider/model is requested, and the response is returned in the matching API format. Each request creates a temporary OpenCode session, so prompts and responses appear in the OpenCode session list. ## Limitations -- Streaming (`"stream": true`) is not yet implemented — requests will return a 400 error. - Tool/function calling is not forwarded; all built-in OpenCode tools are disabled for proxy sessions. -- The proxy only handles `POST /v1/chat/completions` and `POST /v1/responses`. Other OpenAI endpoints are not implemented. +- Only text content is handled; image and file inputs are ignored. ## License diff --git a/index.js b/index.js index c9c1918..227f01e 100644 --- a/index.js +++ b/index.js @@ -569,6 +569,130 @@ function createModelResponse(models) { } } +// --------------------------------------------------------------------------- +// Anthropic Messages API helpers +// --------------------------------------------------------------------------- + +export function normalizeAnthropicMessages(messages) { + return messages + .map((message) => { + let content = "" + if (typeof message.content === "string") { + 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()) + .filter(Boolean) + .join("\n\n") + } + return { role: message.role, content } + }) + .filter((message) => message.content.length > 0) +} + +export function mapFinishReasonToAnthropic(finish) { + if (!finish) return "end_turn" + if (finish.includes("length")) return "max_tokens" + if (finish.includes("tool")) return "tool_use" + return "end_turn" +} + +function createAnthropicResponse(result, model) { + const tokensIn = result.completion.data.info?.tokens?.input ?? 0 + const tokensOut = result.completion.data.info?.tokens?.output ?? 0 + return { + id: `msg_${crypto.randomUUID().replace(/-/g, "")}`, + type: "message", + role: "assistant", + content: [{ type: "text", text: result.content }], + model: model.id, + stop_reason: mapFinishReasonToAnthropic(result.completion.data.info?.finish), + stop_sequence: null, + usage: { input_tokens: tokensIn, output_tokens: tokensOut }, + } +} + +function anthropicBadRequest(message, status = 400, request) { + return json( + { type: "error", error: { type: "invalid_request_error", message } }, + status, + {}, + request, + ) +} + +function anthropicInternalError(message, status = 500, request) { + return json( + { type: "error", error: { type: "api_error", message } }, + status, + {}, + request, + ) +} + +// --------------------------------------------------------------------------- +// Google Gemini API helpers +// --------------------------------------------------------------------------- + +export function normalizeGeminiContents(contents) { + if (!Array.isArray(contents)) return [] + return contents + .map((item) => { + 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() : "")) + .filter(Boolean) + .join("\n\n") + : "" + return { role, content } + }) + .filter((m) => m.content.length > 0) +} + +export function extractGeminiSystemInstruction(systemInstruction) { + if (!systemInstruction) return null + if (typeof systemInstruction === "string") return systemInstruction.trim() + if (Array.isArray(systemInstruction.parts)) { + return systemInstruction.parts + .map((part) => (typeof part?.text === "string" ? part.text.trim() : "")) + .filter(Boolean) + .join("\n\n") + } + return null +} + +export function mapFinishReasonToGemini(finish) { + if (!finish) return "STOP" + if (finish.includes("length")) return "MAX_TOKENS" + if (finish.includes("tool")) return "STOP" + return "STOP" +} + +function createGeminiResponse(content, finish, tokens) { + return { + candidates: [ + { + content: { role: "model", parts: [{ text: content }] }, + finishReason: mapFinishReasonToGemini(finish), + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: tokens?.input ?? 0, + candidatesTokenCount: tokens?.output ?? 0, + totalTokenCount: (tokens?.input ?? 0) + (tokens?.output ?? 0), + }, + } +} + +function geminiModelFromPath(pathname) { + // Matches /v1beta/models/some-model:generateContent or :streamGenerateContent + const match = pathname.match(/^\/v1beta\/models\/([^/:]+)(?::(?:generate|stream)(?:Content|GenerateContent))?$/) + return match ? match[1] : null +} + export function createProxyFetchHandler(client) { return async (request) => { const url = new URL(request.url) @@ -890,6 +1014,250 @@ export function createProxyFetchHandler(client) { } } + // ----------------------------------------------------------------------- + // Anthropic Messages API POST /v1/messages + // ----------------------------------------------------------------------- + + if (request.method === "POST" && url.pathname === "/v1/messages") { + let body + try { + body = await request.json() + } catch { + return anthropicBadRequest("Request body must be valid JSON.", 400, request) + } + + if (!body.model) { + return anthropicBadRequest("The 'model' field is required.", 400, request) + } + + if (!Array.isArray(body.messages) || body.messages.length === 0) { + return anthropicBadRequest("The 'messages' field must contain at least one message.", 400, request) + } + + const messages = normalizeAnthropicMessages(body.messages) + if (messages.length === 0) { + 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 + + const system = buildSystemPrompt(allMessages, { + temperature: body.temperature, + max_tokens: body.max_tokens, + }) + + let model + try { + const providerOverride = request.headers.get("x-opencode-provider") + model = await resolveModel(client, body.model, providerOverride) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await safeLog(client, "error", "Anthropic proxy call failed (model resolve)", { error: message, requestedModel: body.model }) + return anthropicBadRequest(message, 400, request) + } + + if (body.stream) { + const msgID = `msg_${crypto.randomUUID().replace(/-/g, "")}` + const queue = createSseQueue() + + function sseEvent(eventType, data) { + return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n` + } + + async function* generateSse() { + queue.enqueue(sseEvent("message_start", { + type: "message_start", + message: { + id: msgID, + type: "message", + role: "assistant", + content: [], + model: model.id, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + })) + queue.enqueue(sseEvent("content_block_start", { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })) + + const runPromise = executePromptStreaming( + client, + model, + messages, + system, + (delta) => { + queue.enqueue(sseEvent("content_block_delta", { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: delta }, + })) + }, + ) + .then((streamResult) => { + queue.enqueue(sseEvent("content_block_stop", { type: "content_block_stop", index: 0 })) + queue.enqueue(sseEvent("message_delta", { + type: "message_delta", + delta: { + stop_reason: mapFinishReasonToAnthropic(streamResult.finish), + stop_sequence: null, + }, + usage: { output_tokens: streamResult.tokens.output }, + })) + queue.enqueue(sseEvent("message_stop", { type: "message_stop" })) + }) + .catch(async (err) => { + const errMsg = err instanceof Error ? err.message : String(err) + await safeLog(client, "error", "Anthropic proxy streaming call failed", { error: errMsg, requestedModel: body.model }) + queue.enqueue(sseEvent("error", { type: "error", error: { type: "api_error", message: errMsg } })) + }) + .finally(() => { + queue.finish() + }) + + yield* queue.generateChunks() + await runPromise + } + + return sseResponse(corsHeaders(request), generateSse()) + } + + try { + const result = await executePrompt(client, body, model, messages, system) + return json(createAnthropicResponse(result, model), 200, {}, request) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await safeLog(client, "error", "Anthropic proxy call failed", { error: message, requestedModel: body.model }) + return anthropicInternalError(message, 500, request) + } + } + + // ----------------------------------------------------------------------- + // Google Gemini API POST /v1beta/models/:model:generateContent (non-streaming) + // POST /v1beta/models/:model:streamGenerateContent (streaming) + // ----------------------------------------------------------------------- + + const isGeminiNonStream = request.method === "POST" && url.pathname.endsWith(":generateContent") + const isGeminiStream = request.method === "POST" && url.pathname.endsWith(":streamGenerateContent") + + if (isGeminiNonStream || isGeminiStream) { + const geminiModelName = geminiModelFromPath(url.pathname) + if (!geminiModelName) { + return badRequest("Could not extract model name from URL.", 400, request) + } + + let body + try { + body = await request.json() + } catch { + return badRequest("Request body must be valid JSON.", 400, request) + } + + if (!Array.isArray(body.contents) || body.contents.length === 0) { + return badRequest("The 'contents' field must contain at least one item.", 400, request) + } + + const messages = normalizeGeminiContents(body.contents) + if (messages.length === 0) { + return badRequest("No text content was found in the supplied contents.", 400, request) + } + + const systemText = extractGeminiSystemInstruction(body.systemInstruction) + const systemMessages = systemText ? [{ role: "system", content: systemText }, ...messages] : messages + const system = buildSystemPrompt(systemMessages, { + temperature: body.generationConfig?.temperature, + max_tokens: body.generationConfig?.maxOutputTokens, + }) + + let model + try { + const providerOverride = request.headers.get("x-opencode-provider") + model = await resolveModel(client, geminiModelName, providerOverride) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + await safeLog(client, "error", "Gemini proxy call failed (model resolve)", { error: message, requestedModel: geminiModelName }) + return badRequest(message, 400, request) + } + + if (isGeminiStream) { + const queue = createSseQueue() + + async function* generateNdJson() { + const runPromise = executePromptStreaming( + client, + model, + messages, + system, + (delta) => { + const chunk = JSON.stringify(createGeminiResponse(delta, null, null)) + queue.enqueue(chunk + "\n") + }, + ) + .then((streamResult) => { + const finalChunk = JSON.stringify( + createGeminiResponse("", streamResult.finish, streamResult.tokens), + ) + queue.enqueue(finalChunk + "\n") + }) + .catch(async (err) => { + const errMsg = err instanceof Error ? err.message : String(err) + await safeLog(client, "error", "Gemini proxy streaming call failed", { error: errMsg, requestedModel: geminiModelName }) + const errChunk = JSON.stringify({ error: { code: 500, message: errMsg, status: "INTERNAL" } }) + queue.enqueue(errChunk + "\n") + }) + .finally(() => { + queue.finish() + }) + + yield* queue.generateChunks() + await runPromise + } + + const encoder = new TextEncoder() + const body_ = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of generateNdJson()) { + controller.enqueue(encoder.encode(chunk)) + } + } catch { + // errors surfaced via data + } finally { + controller.close() + } + }, + }) + + return new Response(body_, { + status: 200, + headers: { + "content-type": "application/json", + "cache-control": "no-cache", + connection: "keep-alive", + ...corsHeaders(request), + }, + }) + } + + try { + const result = await executePrompt(client, body, model, messages, system) + const finish = result.completion.data.info?.finish + const tokens = result.completion.data.info?.tokens + return json(createGeminiResponse(result.content, finish, tokens), 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 }) + return badRequest(message, 500, request) + } + } + return text("Not found", 404, request) } } diff --git a/index.test.js b/index.test.js index f26b033..f663ea0 100644 --- a/index.test.js +++ b/index.test.js @@ -12,6 +12,11 @@ import { extractAssistantText, mapFinishReason, resolveModel, + normalizeAnthropicMessages, + mapFinishReasonToAnthropic, + normalizeGeminiContents, + extractGeminiSystemInstruction, + mapFinishReasonToGemini, } from "./index.js" // --------------------------------------------------------------------------- @@ -1131,3 +1136,553 @@ test("POST /v1/responses stream: true with session.error emits response.failed", const text = await response.text() assert.ok(text.includes("response.failed") || text.includes("Rate limit exceeded")) }) + +// --------------------------------------------------------------------------- +// Unit: normalizeAnthropicMessages +// --------------------------------------------------------------------------- +describe("normalizeAnthropicMessages", () => { + it("passes through string content unchanged", () => { + const input = [{ role: "user", content: "hello" }] + assert.deepEqual(normalizeAnthropicMessages(input), [{ role: "user", content: "hello" }]) + }) + + it("trims whitespace from string content", () => { + const input = [{ role: "user", content: " hi " }] + assert.deepEqual(normalizeAnthropicMessages(input), [{ role: "user", content: "hi" }]) + }) + + it("joins text blocks from array content", () => { + const input = [ + { + role: "user", + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + ] + assert.deepEqual(normalizeAnthropicMessages(input), [{ role: "user", content: "first\n\nsecond" }]) + }) + + it("ignores non-text blocks in array content", () => { + const input = [ + { + role: "user", + content: [ + { type: "image", source: {} }, + { type: "text", text: "only this" }, + ], + }, + ] + assert.deepEqual(normalizeAnthropicMessages(input), [{ role: "user", content: "only this" }]) + }) + + it("drops messages with empty content", () => { + const input = [ + { role: "user", content: "" }, + { role: "assistant", content: "response" }, + ] + assert.deepEqual(normalizeAnthropicMessages(input), [{ role: "assistant", content: "response" }]) + }) +}) + +// --------------------------------------------------------------------------- +// Unit: mapFinishReasonToAnthropic +// --------------------------------------------------------------------------- +describe("mapFinishReasonToAnthropic", () => { + it("returns end_turn for undefined", () => { + assert.equal(mapFinishReasonToAnthropic(undefined), "end_turn") + }) + + it("returns end_turn for null", () => { + assert.equal(mapFinishReasonToAnthropic(null), "end_turn") + }) + + it("returns max_tokens when finish includes length", () => { + assert.equal(mapFinishReasonToAnthropic("max_length"), "max_tokens") + }) + + it("returns tool_use when finish includes tool", () => { + assert.equal(mapFinishReasonToAnthropic("tool_use"), "tool_use") + }) + + it("returns end_turn for unrecognised values", () => { + assert.equal(mapFinishReasonToAnthropic("stop"), "end_turn") + }) +}) + +// --------------------------------------------------------------------------- +// Unit: normalizeGeminiContents +// --------------------------------------------------------------------------- +describe("normalizeGeminiContents", () => { + it("returns empty array for non-array input", () => { + assert.deepEqual(normalizeGeminiContents(null), []) + assert.deepEqual(normalizeGeminiContents("string"), []) + }) + + it("converts user role and joins text parts", () => { + const contents = [{ role: "user", parts: [{ text: "hello" }] }] + assert.deepEqual(normalizeGeminiContents(contents), [{ role: "user", content: "hello" }]) + }) + + it("maps model role to assistant", () => { + const contents = [{ role: "model", parts: [{ text: "hi there" }] }] + assert.deepEqual(normalizeGeminiContents(contents), [{ role: "assistant", content: "hi there" }]) + }) + + it("joins multiple parts with double newline", () => { + const contents = [{ role: "user", parts: [{ text: "line one" }, { text: "line two" }] }] + assert.deepEqual(normalizeGeminiContents(contents), [{ role: "user", content: "line one\n\nline two" }]) + }) + + it("drops items with no text content", () => { + const contents = [ + { role: "user", parts: [{ text: "" }] }, + { role: "user", parts: [{ text: "kept" }] }, + ] + assert.deepEqual(normalizeGeminiContents(contents), [{ role: "user", content: "kept" }]) + }) +}) + +// --------------------------------------------------------------------------- +// Unit: extractGeminiSystemInstruction +// --------------------------------------------------------------------------- +describe("extractGeminiSystemInstruction", () => { + it("returns null for null/undefined input", () => { + assert.equal(extractGeminiSystemInstruction(null), null) + assert.equal(extractGeminiSystemInstruction(undefined), null) + }) + + it("returns trimmed string for string input", () => { + assert.equal(extractGeminiSystemInstruction(" be helpful "), "be helpful") + }) + + it("joins parts array", () => { + const si = { parts: [{ text: "be concise" }, { text: "and clear" }] } + assert.equal(extractGeminiSystemInstruction(si), "be concise\n\nand clear") + }) + + it("returns null for object without parts", () => { + assert.equal(extractGeminiSystemInstruction({ role: "system" }), null) + }) +}) + +// --------------------------------------------------------------------------- +// Unit: mapFinishReasonToGemini +// --------------------------------------------------------------------------- +describe("mapFinishReasonToGemini", () => { + it("returns STOP for undefined", () => { + assert.equal(mapFinishReasonToGemini(undefined), "STOP") + }) + + it("returns MAX_TOKENS when finish includes length", () => { + assert.equal(mapFinishReasonToGemini("max_length"), "MAX_TOKENS") + }) + + it("returns STOP for tool_use", () => { + assert.equal(mapFinishReasonToGemini("tool_use"), "STOP") + }) + + it("returns STOP for end_turn", () => { + assert.equal(mapFinishReasonToGemini("end_turn"), "STOP") + }) +}) + +// --------------------------------------------------------------------------- +// Integration: POST /v1/messages (Anthropic Messages API) +// --------------------------------------------------------------------------- + +function createAnthropicClient(responseContent = "Hello from Anthropic.") { + return { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [ + { + id: "anthropic", + models: { "claude-3-5-sonnet": { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" } }, + }, + ], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-ant-1" } }), + prompt: async () => ({ + data: { + parts: [{ type: "text", text: responseContent }], + info: { tokens: { input: 15, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "end_turn" }, + }, + }), + }, + } +} + +test("POST /v1/messages returns a well-formed Anthropic response", async () => { + const handler = createProxyFetchHandler(createAnthropicClient("Hi there!")) + 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", + max_tokens: 1024, + messages: [{ role: "user", content: "Say hello." }], + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.equal(body.type, "message") + assert.equal(body.role, "assistant") + assert.ok(body.id.startsWith("msg_")) + assert.ok(Array.isArray(body.content)) + assert.equal(body.content[0].type, "text") + assert.equal(body.content[0].text, "Hi there!") + assert.equal(body.stop_reason, "end_turn") + assert.equal(body.usage.input_tokens, 15) + assert.equal(body.usage.output_tokens, 10) +}) + +test("POST /v1/messages system string 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" } }), + 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: "You are a pirate.", + messages: [{ role: "user", content: "Hello." }], + }), + }) + + await handler(request) + assert.ok(capturedSystem?.includes("You are a pirate.")) +}) + +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", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.equal(body.type, "error") + assert.ok(body.error.type === "invalid_request_error") + assert.ok(body.error.message.includes("model")) +}) + +test("POST /v1/messages missing messages returns 400", async () => { + const handler = createProxyFetchHandler(createAnthropicClient()) + 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" }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.equal(body.type, "error") +}) + +test("POST /v1/messages malformed JSON returns 400", async () => { + const handler = createProxyFetchHandler(createAnthropicClient()) + const request = new Request("http://127.0.0.1:4010/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ bad json", + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.equal(body.type, "error") +}) + +test("POST /v1/messages stream: true returns Anthropic SSE events", async () => { + const events = [ + { + type: "message.part.updated", + properties: { + part: { sessionID: "sess-123", type: "text" }, + delta: "Hello", + }, + }, + { + type: "message.part.updated", + properties: { + part: { sessionID: "sess-123", type: "text" }, + delta: " world", + }, + }, + { type: "session.idle", properties: { sessionID: "sess-123" } }, + ] + + const handler = createProxyFetchHandler(createStreamingClient(events)) + 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: "hi" }], + }), + }) + + const response = await handler(request) + + assert.equal(response.status, 200) + assert.ok(response.headers.get("content-type")?.includes("text/event-stream")) + + const text = await response.text() + assert.ok(text.includes("message_start")) + assert.ok(text.includes("content_block_start")) + assert.ok(text.includes("content_block_delta")) + assert.ok(text.includes("Hello")) + assert.ok(text.includes(" world")) + assert.ok(text.includes("message_stop")) +}) + +test("POST /v1/messages stream: true with session.error emits SSE error event", async () => { + const events = [ + { + type: "session.error", + properties: { + sessionID: "sess-123", + error: { message: "Model overloaded" }, + }, + }, + { type: "session.idle", properties: { sessionID: "sess-123" } }, + ] + + const handler = createProxyFetchHandler(createStreamingClient(events)) + 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: "hi" }], + }), + }) + + const response = await handler(request) + assert.equal(response.status, 200) + + const text = await response.text() + assert.ok(text.includes("error") || text.includes("Model overloaded")) +}) + +// --------------------------------------------------------------------------- +// Integration: POST /v1beta/models/:model:generateContent (Gemini API) +// --------------------------------------------------------------------------- + +function createGeminiClient(responseContent = "Hello from Gemini.") { + return { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [ + { + id: "google", + models: { "gemini-2.0-flash": { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" } }, + }, + ], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-gem-1" } }), + prompt: async () => ({ + data: { + parts: [{ type: "text", text: responseContent }], + info: { tokens: { input: 12, output: 7, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "end_turn" }, + }, + }), + }, + } +} + +test("POST /v1beta/models/gemini-2.0-flash:generateContent returns Gemini response", async () => { + const handler = createProxyFetchHandler(createGeminiClient("Gemini says hi!")) + 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: "Say hi." }] }], + }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 200) + assert.ok(Array.isArray(body.candidates)) + assert.equal(body.candidates[0].content.role, "model") + assert.equal(body.candidates[0].content.parts[0].text, "Gemini says hi!") + assert.equal(body.candidates[0].finishReason, "STOP") + assert.equal(body.usageMetadata.promptTokenCount, 12) + assert.equal(body.usageMetadata.candidatesTokenCount, 7) + assert.equal(body.usageMetadata.totalTokenCount, 19) +}) + +test("POST /v1beta/models/:model:generateContent systemInstruction is included", async () => { + let capturedSystem = null + const client = { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [{ id: "google", models: { "gemini-2.0-flash": { id: "gemini-2.0-flash" } } }], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-gem-sys" } }), + 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/v1beta/models/gemini-2.0-flash:generateContent", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "Hello." }] }], + systemInstruction: { parts: [{ text: "You are a helpful assistant." }] }, + }), + }) + + await handler(request) + assert.ok(capturedSystem?.includes("You are a helpful assistant.")) +}) + +test("POST /v1beta/models/:model:generateContent missing contents returns 400", async () => { + const handler = createProxyFetchHandler(createGeminiClient()) + 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({ generationConfig: { maxOutputTokens: 100 } }), + }) + + const response = await handler(request) + const body = await response.json() + + assert.equal(response.status, 400) + assert.ok(body.error.message.includes("contents")) +}) + +test("POST /v1beta/models/:model:generateContent malformed JSON returns 400", async () => { + const handler = createProxyFetchHandler(createGeminiClient()) + 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: "{ not json", + }) + + const response = await handler(request) + + assert.equal(response.status, 400) +}) + +test("POST /v1beta/models/:model:streamGenerateContent returns NDJSON stream", async () => { + const events = [ + { + type: "message.part.updated", + properties: { + part: { sessionID: "sess-123", type: "text" }, + delta: "Gem", + }, + }, + { + type: "message.part.updated", + properties: { + part: { sessionID: "sess-123", type: "text" }, + delta: "ini", + }, + }, + { type: "session.idle", properties: { sessionID: "sess-123" } }, + ] + + // Use streaming client but swap provider to google + const streamingClient = createStreamingClient(events) + streamingClient.config = { + providers: async () => ({ + data: { + providers: [ + { id: "google", models: { "gemini-2.0-flash": { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" } } }, + ], + }, + }), + } + + const handler = createProxyFetchHandler(streamingClient) + const request = new Request( + "http://127.0.0.1:4010/v1beta/models/gemini-2.0-flash:streamGenerateContent", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "Stream this." }] }], + }), + }, + ) + + const response = await handler(request) + + assert.equal(response.status, 200) + assert.ok(response.headers.get("content-type")?.includes("application/json")) + + const text = await response.text() + // Should contain NDJSON lines with candidates + assert.ok(text.includes("candidates")) + assert.ok(text.includes("Gem")) + assert.ok(text.includes("ini")) +}) From 0e7a80cbebb3f4ca29921b685899d1cb59c75832 Mon Sep 17 00:00:00 2001 From: KochC Date: Fri, 27 Mar 2026 17:19:31 +0100 Subject: [PATCH 5/6] docs: rewrite README and expand package keywords for discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lead with value proposition, ASCII diagram, and feature table - Quickstart reduced to 4 steps; works in under 60 seconds - SDK examples for OpenAI, Anthropic, Gemini (JS+Python), LangChain - UI integration guides: Open WebUI, Chatbox, Continue, Zed - Reference section kept concise; full prose docs moved inline - package.json: sharper description, 20 keywords covering all search terms (openai-compatible, anthropic, gemini, ollama, langchain, open-webui, llm-proxy, ai-gateway, local-llm, github-copilot, model-router, …) --- README.md | 350 ++++++++++++++++++++++++++++++++++----------------- package.json | 19 ++- 2 files changed, 255 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index f85a06f..2077d16 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,53 @@ # opencode-llm-proxy -An [OpenCode](https://opencode.ai) plugin that starts a local HTTP server backed by your OpenCode providers, with support for multiple LLM API formats: +[![npm](https://img.shields.io/npm/v/opencode-llm-proxy)](https://www.npmjs.com/package/opencode-llm-proxy) +[![npm downloads](https://img.shields.io/npm/dm/opencode-llm-proxy)](https://www.npmjs.com/package/opencode-llm-proxy) +[![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) -- **OpenAI** Chat Completions (`POST /v1/chat/completions`) and Responses (`POST /v1/responses`) -- **Anthropic** Messages API (`POST /v1/messages`) -- **Google Gemini** API (`POST /v1beta/models/:model:generateContent`) +**One local endpoint. Every model you have access to. Any API format.** -Any tool or SDK that targets one of these APIs can point at the proxy without code changes. +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) + │ + ▼ http://127.0.0.1:4010 + opencode-llm-proxy + │ + ▼ OpenCode SDK + GitHub Copilot · Anthropic · Gemini · Ollama · OpenRouter · Bedrock · … +``` + +**Supported API formats — all with streaming:** + +| Format | Endpoint | +|---|---| +| OpenAI Chat Completions | `POST /v1/chat/completions` | +| OpenAI Responses API | `POST /v1/responses` | +| Anthropic Messages API | `POST /v1/messages` | +| Google Gemini | `POST /v1beta/models/:model:generateContent` | + +--- + +## Why + +Most LLM tools speak exactly one API dialect. OpenCode already manages connections to every provider you use. This proxy bridges the two — your tools keep working as-is, and you change which model they use in one place. + +**Common situations it solves:** + +- You have a **GitHub Copilot** subscription. Open WebUI, Chatbox, or a VS Code extension only accepts an OpenAI-compatible URL. Point them at the proxy — done. +- You run **Ollama** locally. Your Python scripts use the OpenAI SDK. Set `base_url` to the proxy and use your Ollama model IDs directly. +- 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. + +--- ## Quickstart ```bash -# 1. Install the npm package npm install opencode-llm-proxy - -# 2. Register the plugin in your opencode.json -# (or use one of the manual install methods below) ``` Add to `opencode.json`: @@ -26,11 +58,10 @@ Add to `opencode.json`: } ``` -Then start OpenCode — the proxy starts automatically: +Start OpenCode — the proxy starts automatically: ```bash opencode -# Proxy is now listening on http://127.0.0.1:4010 ``` Send a request: @@ -44,15 +75,17 @@ curl http://127.0.0.1:4010/v1/chat/completions \ }' ``` +--- + ## Install -### As an npm plugin (recommended) +### npm plugin (recommended) ```bash npm install opencode-llm-proxy ``` -Add to `opencode.json`: +Add to your global `~/.config/opencode/opencode.json` (works everywhere) or a project-level `opencode.json`: ```json { @@ -60,170 +93,263 @@ Add to `opencode.json`: } ``` -### As a global OpenCode plugin +### Copy the file -Copy `index.js` to your global plugin directory: +**Global** — loaded for every OpenCode session: ```bash -cp index.js ~/.config/opencode/plugins/openai-proxy.js +curl -o ~/.config/opencode/plugins/llm-proxy.js \ + https://raw.githubusercontent.com/KochC/opencode-llm-proxy/main/index.js ``` -The plugin is loaded automatically every time OpenCode starts. - -### As a project plugin - -Copy `index.js` to your project's plugin directory: +**Per-project** — loaded only in this directory: ```bash -cp index.js .opencode/plugins/openai-proxy.js +mkdir -p .opencode/plugins +curl -o .opencode/plugins/llm-proxy.js \ + https://raw.githubusercontent.com/KochC/opencode-llm-proxy/main/index.js ``` -## Usage +--- -Start OpenCode normally. The proxy server starts automatically in the background: +## Configuration -``` +| Variable | Default | Description | +|---|---|---| +| `OPENCODE_LLM_PROXY_HOST` | `127.0.0.1` | Bind address. `0.0.0.0` to expose on LAN or Docker. | +| `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. | + +```bash +OPENCODE_LLM_PROXY_HOST=0.0.0.0 \ +OPENCODE_LLM_PROXY_TOKEN=my-secret \ opencode ``` -The server listens on `http://127.0.0.1:4010` by default. +--- -### List available models +## Using with SDKs and tools -```bash -curl http://127.0.0.1:4010/v1/models -``` +### OpenAI SDK (JS/TS) -Returns all models from all providers configured in your OpenCode setup (e.g. `github-copilot/claude-sonnet-4.6`, `ollama/qwen3.5:9b`, etc.). +```javascript +import OpenAI from "openai" -### OpenAI Chat Completions +const client = new OpenAI({ + baseURL: "http://127.0.0.1:4010/v1", + apiKey: "unused", +}) -```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": "Write a haiku about OpenCode."} - ] - }' +const response = await client.chat.completions.create({ + model: "github-copilot/claude-sonnet-4.6", + messages: [{ role: "user", content: "Explain recursion." }], +}) ``` -Use the fully-qualified `provider/model` ID from `GET /v1/models`. Supports `"stream": true` for SSE streaming. +### OpenAI SDK (Python) -### OpenAI Responses API +```python +from openai import OpenAI -```bash -curl http://127.0.0.1:4010/v1/responses \ - -H "Content-Type: application/json" \ - -d '{ - "model": "github-copilot/claude-sonnet-4.6", - "input": [{"role": "user", "content": "Hello"}] - }' +client = OpenAI(base_url="http://127.0.0.1:4010/v1", api_key="unused") + +response = client.chat.completions.create( + model="ollama/qwen2.5-coder", + messages=[{"role": "user", "content": "Write a Python function to reverse a string."}], +) +print(response.choices[0].message.content) ``` -Supports `"stream": true` for SSE streaming. +### Anthropic SDK (Python) -### Anthropic Messages API +```python +import anthropic -Point the Anthropic SDK (or any client) at this proxy: +client = anthropic.Anthropic( + base_url="http://127.0.0.1:4010", + api_key="unused", +) -```bash -curl http://127.0.0.1:4010/v1/messages \ - -H "Content-Type: application/json" \ - -d '{ - "model": "anthropic/claude-3-5-sonnet", - "max_tokens": 1024, - "system": "You are a helpful assistant.", - "messages": [{"role": "user", "content": "Hello!"}] - }' +message = client.messages.create( + model="anthropic/claude-3-5-sonnet", + max_tokens=1024, + messages=[{"role": "user", "content": "What is the Pythagorean theorem?"}], +) +print(message.content[0].text) ``` -Supports `"stream": true` for SSE streaming with standard Anthropic streaming events (`message_start`, `content_block_delta`, `message_stop`, etc.). +### Anthropic SDK (JS/TS) -To point the official Anthropic SDK at this proxy: - -```js +```javascript import Anthropic from "@anthropic-ai/sdk" const client = new Anthropic({ baseURL: "http://127.0.0.1:4010", - apiKey: "unused", // or your OPENCODE_LLM_PROXY_TOKEN + apiKey: "unused", +}) + +const message = await client.messages.create({ + model: "anthropic/claude-opus-4", + max_tokens: 1024, + messages: [{ role: "user", content: "Explain async/await." }], }) ``` -### Google Gemini API +### Google Generative AI SDK (JS/TS) -```bash -# Non-streaming -curl http://127.0.0.1:4010/v1beta/models/google/gemini-2.0-flash:generateContent \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [{"role": "user", "parts": [{"text": "Hello!"}]}] - }' +```javascript +import { GoogleGenerativeAI } from "@google/generative-ai" -# Streaming (newline-delimited JSON) -curl http://127.0.0.1:4010/v1beta/models/google/gemini-2.0-flash:streamGenerateContent \ - -H "Content-Type: application/json" \ - -d '{ - "contents": [{"role": "user", "parts": [{"text": "Hello!"}]}] - }' +const genAI = new GoogleGenerativeAI("unused", { + baseUrl: "http://127.0.0.1:4010", +}) + +const model = genAI.getGenerativeModel({ model: "google/gemini-2.0-flash" }) +const result = await model.generateContent("What is machine learning?") +console.log(result.response.text()) ``` -The model name in the URL path is resolved the same way as other endpoints (use `provider/model` or a bare model ID if unambiguous). +### LangChain (Python) -To point the Google Generative AI SDK at this proxy, set the `baseUrl` option to `http://127.0.0.1:4010`. +```python +from langchain_openai import ChatOpenAI -## Selecting a provider +llm = ChatOpenAI( + model="anthropic/claude-3-5-sonnet", + openai_api_base="http://127.0.0.1:4010/v1", + openai_api_key="unused", +) -All endpoints accept an optional `x-opencode-provider` header to force a specific provider when the model ID is ambiguous: +response = llm.invoke("What are the SOLID principles?") +print(response.content) +``` -```bash -curl http://127.0.0.1:4010/v1/chat/completions \ - -H "x-opencode-provider: anthropic" \ - -H "Content-Type: application/json" \ - -d '{"model": "claude-3-5-sonnet", "messages": [...]}' +### Open WebUI + +1. Settings → Connections → OpenAI API +2. Set **API Base URL** to `http://127.0.0.1:4010/v1` +3. Leave API Key blank (or set to your `OPENCODE_LLM_PROXY_TOKEN`) +4. Save — all your OpenCode models appear in the model picker + +> Running Open WebUI in Docker? Use `http://host.docker.internal:4010/v1` and set `OPENCODE_LLM_PROXY_HOST=0.0.0.0`. + +### Chatbox + +Settings → AI Provider → OpenAI API → set **API Host** to `http://127.0.0.1:4010`. + +### Continue (VS Code / JetBrains) + +In `~/.continue/config.json`: + +```json +{ + "models": [ + { + "title": "Claude via OpenCode", + "provider": "openai", + "model": "anthropic/claude-3-5-sonnet", + "apiBase": "http://127.0.0.1:4010/v1", + "apiKey": "unused" + } + ] +} ``` -## Configuration +### Zed -All configuration is done through environment variables. No configuration file is needed. +In `~/.config/zed/settings.json`: -| Variable | Type | Default | Description | -|---|---|---|---| -| `OPENCODE_LLM_PROXY_HOST` | string | `127.0.0.1` | Bind address. Set to `0.0.0.0` to expose on LAN. | -| `OPENCODE_LLM_PROXY_PORT` | integer | `4010` | TCP port the proxy listens on. | -| `OPENCODE_LLM_PROXY_TOKEN` | string | _(unset)_ | Optional bearer token. When set, every request must include `Authorization: Bearer `. Unset means no authentication required. | -| `OPENCODE_LLM_PROXY_CORS_ORIGIN` | string | `*` | Value of the `Access-Control-Allow-Origin` response header. Use a specific origin (e.g. `https://app.example.com`) when browser clients send credentials. | +```json +{ + "language_models": { + "openai": { + "api_url": "http://127.0.0.1:4010/v1", + "available_models": [ + { + "name": "github-copilot/claude-sonnet-4.6", + "display_name": "Claude (OpenCode)", + "max_tokens": 8096 + } + ] + } + } +} +``` -The proxy adds CORS headers to all responses and handles `OPTIONS` preflight requests automatically. +--- -### LAN example +## Finding model IDs ```bash -export OPENCODE_LLM_PROXY_HOST=0.0.0.0 -export OPENCODE_LLM_PROXY_PORT=4010 -export OPENCODE_LLM_PROXY_TOKEN=my-secret-token -opencode +curl http://127.0.0.1:4010/v1/models | jq '.data[].id' +# "github-copilot/claude-sonnet-4.6" +# "anthropic/claude-3-5-sonnet" +# "ollama/qwen2.5-coder" +# ... ``` -Then from another machine: +Use `provider/model` for clarity. Bare model IDs (e.g. `gpt-4o`) work if unambiguous across your providers. -```bash -curl http://:4010/v1/models \ - -H "Authorization: Bearer my-secret-token" +To force a specific provider without changing the model string, add: + +``` +x-opencode-provider: anthropic +``` + +--- + +## API reference + +### GET /health +```json +{ "healthy": true, "service": "opencode-openai-proxy" } ``` +### GET /v1/models +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`. + +### POST /v1/responses +OpenAI Responses API. Required fields: `model`, `input`. Optional: `instructions`, `stream`, `max_output_tokens`. + +### POST /v1/messages +Anthropic Messages API. Required fields: `model`, `messages`. Optional: `system`, `max_tokens`, `stream`. + +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`. + +### POST /v1beta/models/:model:streamGenerateContent +Same as above, returns newline-delimited JSON stream. + +--- + ## How it works -The plugin hooks into OpenCode at startup and spawns a Bun HTTP server. Incoming requests (in OpenAI, Anthropic, or Gemini format) are translated into OpenCode SDK calls (`client.session.create` + `client.session.prompt`), routed through whichever provider/model is requested, and the response is returned in the matching API format. +Each request: + +1. Is authenticated if `OPENCODE_LLM_PROXY_TOKEN` is set +2. Has its model resolved — `provider/model`, bare model ID, or Gemini URL path +3. Creates a temporary OpenCode session (visible in the session list) +4. Sends the prompt via `client.session.prompt` / `client.session.promptAsync` +5. Returns the response in the same format as the request -Each request creates a temporary OpenCode session, so prompts and responses appear in the OpenCode session list. +Streaming uses OpenCode's `client.event.subscribe()` SSE stream. Text deltas are forwarded in real time. + +--- ## Limitations -- Tool/function calling is not forwarded; all built-in OpenCode tools are disabled for proxy sessions. -- Only text content is handled; image and file inputs are ignored. +- 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) + +--- ## License diff --git a/package.json b/package.json index e872da7..30bf2d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-llm-proxy", "version": "1.6.0", - "description": "OpenCode plugin that exposes an OpenAI-compatible HTTP proxy backed by any LLM provider configured in OpenCode", + "description": "Local AI gateway for OpenCode — use any model via OpenAI, Anthropic, or Gemini API format", "main": "index.js", "type": "module", "engines": { @@ -16,8 +16,23 @@ "opencode", "opencode-plugin", "openai", + "openai-compatible", + "anthropic", + "gemini", + "ollama", "proxy", - "llm" + "llm", + "ai", + "gateway", + "local-llm", + "github-copilot", + "langchain", + "open-webui", + "llm-proxy", + "ai-gateway", + "model-router", + "openrouter", + "bedrock" ], "author": "KochC", "license": "MIT", From a4a8688e64582c557d2b78cfa41529cf8aa92c7c Mon Sep 17 00:00:00 2001 From: KochC Date: Fri, 27 Mar 2026 17:25:13 +0100 Subject: [PATCH 6/6] fix: remove pretty-printing from JSON responses to reduce payload size --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 227f01e..d6ac27c 100644 --- a/index.js +++ b/index.js @@ -30,7 +30,7 @@ function corsHeaders(request) { } function json(data, status = 200, headers = {}, request) { - return new Response(JSON.stringify(data, null, 2), { + return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8",