From b3295e6fad046e3d299e77f42fe63211b16ee46f Mon Sep 17 00:00:00 2001 From: KochC Date: Fri, 27 Mar 2026 15:44:50 +0100 Subject: [PATCH 1/2] 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/2] 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")) })