diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9cd0b3f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + target-branch: dev + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + target-branch: dev + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65673fc..971cf97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,18 @@ on: - main - dev +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint: name: Lint runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout @@ -25,7 +33,7 @@ jobs: node-version: "22" - name: Install dev dependencies - run: npm install + run: npm ci - name: Run lint run: npm run lint @@ -33,6 +41,7 @@ jobs: test: name: Test (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: matrix: @@ -48,7 +57,30 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install dev dependencies - run: npm install + run: npm ci - name: Run tests run: npm test + + package: + name: Audit and package + runs-on: ubuntu-latest + timeout-minutes: 10 + + 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 ci + + - name: Audit dependencies + run: npm audit + + - name: Check package contents + run: npm pack --dry-run diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 29672f4..1e70a34 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,10 +11,18 @@ on: description: "Git ref (tag or SHA) to publish" required: true +permissions: + contents: read + +concurrency: + group: publish-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.event.workflow_run.head_sha }} + cancel-in-progress: false + jobs: test: name: Test before publish runs-on: ubuntu-latest + timeout-minutes: 15 if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} steps: @@ -28,12 +36,16 @@ jobs: with: node-version: "22" + - name: Install dev dependencies + run: npm ci + - name: Run tests run: npm test publish: name: Publish to npm runs-on: ubuntu-latest + timeout-minutes: 15 needs: test permissions: contents: read @@ -65,6 +77,18 @@ jobs: node-version: "22" registry-url: "https://registry.npmjs.org" + - name: Install dependencies + if: steps.check.outputs.skip == 'false' + run: npm ci + + - name: Audit dependencies + if: steps.check.outputs.skip == 'false' + run: npm audit + + - name: Check package contents + if: steps.check.outputs.skip == 'false' + run: npm pack --dry-run + - name: Publish if: steps.check.outputs.skip == 'false' run: npm publish --provenance --access public diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b07ff4d..2af7fd9 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -9,9 +9,14 @@ permissions: contents: write pull-requests: write +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: true + jobs: release-please: runs-on: ubuntu-latest + timeout-minutes: 10 outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} diff --git a/.gitignore b/.gitignore index 880d319..e233f05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ .DS_Store node_modules/ +.env +.env.* +!.env.example +!.env.*.example promotion.md docs/promotion-drafts/ diff --git a/README.md b/README.md index 541a4a8..97c0cb7 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ Start OpenCode — the proxy starts automatically: opencode ``` +This package is an OpenCode plugin, not a standalone server. It intentionally has no `npm start` command; load it through OpenCode as shown above. + Send a request: ```bash @@ -177,9 +179,23 @@ curl -o .opencode/plugins/llm-proxy.js \ |---|---|---| | `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. | +| `OPENCODE_LLM_PROXY_TOKEN` | _(unset)_ | Single accepted bearer token. A token is required when binding beyond loopback. | +| `OPENCODE_LLM_PROXY_TOKENS` | `[]` | JSON array of additional accepted bearer-token strings. | +| `OPENCODE_LLM_PROXY_CORS_ORIGINS` | `[]` | JSON array of allowed browser origins. Browser cross-origin requests are denied by default; use `"*"` explicitly to allow all. | +| `OPENCODE_LLM_PROXY_CORS_ORIGIN` | _(unset)_ | Legacy single origin appended to the CORS allowlist. | +| `OPENCODE_LLM_PROXY_ALLOW_PRIVATE_NETWORK` | `false` | Set to `true` to allow browser Private Network Access preflights. | +| `OPENCODE_LLM_PROXY_REQUEST_TIMEOUT_MS` | `120000` | Total request timeout, from 1 to 3,600,000 ms. | +| `OPENCODE_LLM_PROXY_MAX_REQUEST_BYTES` | `1048576` | Maximum JSON request body and embedded data-URL size, up to 100 MiB. | +| `OPENCODE_LLM_PROXY_MAX_CONCURRENT_REQUESTS` | `8` | Maximum active POST requests. | +| `OPENCODE_LLM_PROXY_MAX_QUEUED_REQUESTS` | `32` | Maximum POST requests waiting for capacity; excess requests receive `503`. | | `OPENCODE_LLM_PROXY_TOOL_BRIDGE_POOL_SIZE` | `8` | Max concurrent in-flight requests using [tool calling](#tool-calling). | +| `OPENCODE_LLM_PROXY_TOOL_BRIDGE_ACQUIRE_TIMEOUT_MS` | `10000` | Maximum wait for a tool-bridge slot, from 1 to 3,600,000 ms. | +| `OPENCODE_LLM_PROXY_KEEP_SESSIONS` | `false` | Set to `true` to retain temporary OpenCode sessions; otherwise they are deleted after use. | +| `OPENCODE_LLM_PROXY_MODEL_ALIASES` | `{}` | JSON object mapping aliases to a model ID string or ordered array of fallback model IDs. | + +Use `x-opencode-variant` to select an OpenCode model variant for a request. The proxy accepts multimodal image, document, and file inputs in each API's native content shape, using embedded data URLs and validating model capabilities. Structured JSON output is supported through OpenAI `response_format.json_schema`, Responses API `text.format.schema`, and Gemini `generationConfig.responseSchema`. + +Generation `temperature`, `top_p`/`topP`, and `topK` values are validated and applied through the plugin's `chat.params` hook. Unsupported controls (`stop`, `seed`, `frequency_penalty`, `presence_penalty`, `logprobs`, and `n`) are rejected with `400` instead of being silently ignored. ```bash OPENCODE_LLM_PROXY_HOST=0.0.0.0 \ @@ -491,9 +507,9 @@ Same as above, returns newline-delimited JSON stream. Each request: -1. Is authenticated if `OPENCODE_LLM_PROXY_TOKEN` is set +1. Is authenticated if either token setting is configured; non-loopback binding requires a token 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) +3. Creates a temporary OpenCode session and deletes it after use unless `OPENCODE_LLM_PROXY_KEEP_SESSIONS=true` 4. Sends the prompt via `client.session.prompt` / `client.session.promptAsync` 5. Returns the response in the same format as the request @@ -503,9 +519,9 @@ Streaming uses OpenCode's `client.event.subscribe()` SSE stream. Text deltas are ## Limitations -- Text only — image, audio, and file inputs are ignored +- Media support depends on the selected model's advertised image, audio, video, and PDF/file capabilities - No cross-request session state — send full conversation history on every request -- Temperature and max tokens are advisory (passed as system prompt hints) +- `temperature`, `top_p`/`topP`, and `topK` are applied through OpenCode's plugin hook. Maximum-token controls are accepted for client compatibility but cannot be enforced by the current OpenCode SDK. - Tool calling supports parallel calls in a single turn — see [Tool calling](#tool-calling) above --- diff --git a/docs/examples/open-webui-docker/docker-compose.yml b/docs/examples/open-webui-docker/docker-compose.yml index 14ee811..7134ed5 100644 --- a/docs/examples/open-webui-docker/docker-compose.yml +++ b/docs/examples/open-webui-docker/docker-compose.yml @@ -1,6 +1,6 @@ services: open-webui: - image: ghcr.io/open-webui/open-webui:main + image: ghcr.io/open-webui/open-webui:v0.11.0 container_name: open-webui ports: - "3000:8080" diff --git a/docs/security.md b/docs/security.md index 6e11488..52ab6a5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -8,7 +8,7 @@ By default the proxy binds to `127.0.0.1`, so only processes on the same machine ## Use a bearer token when exposing beyond localhost -If you bind to a network interface (`OPENCODE_LLM_PROXY_HOST=0.0.0.0`) for LAN or Docker use, always set a token: +If you bind to a network interface (`OPENCODE_LLM_PROXY_HOST=0.0.0.0`) for LAN or Docker use, the proxy requires at least one token and refuses to start without one: ```bash OPENCODE_LLM_PROXY_HOST=0.0.0.0 \ @@ -18,6 +18,8 @@ opencode Every request must then send `Authorization: Bearer some-long-random-token`. Use a long, random value and rotate it if it may have leaked. +For rotation or multiple clients, `OPENCODE_LLM_PROXY_TOKENS` accepts a JSON array of non-empty token strings. It can be used alongside the single `OPENCODE_LLM_PROXY_TOKEN` value. + ## Do not expose the proxy to the public internet The proxy is designed for localhost and trusted LANs. Do not port-forward it, place it on a public IP, or put it behind a public reverse proxy. A token is not a substitute for network isolation. @@ -42,10 +44,19 @@ The whole point of the proxy is reuse of your OpenCode providers. Anyone who can Never log the `Authorization` header or the token value in application logs, reverse-proxy logs, or debugging output. Scrub them from any shared traces or issue reports. +## Browser and resource controls + +Browser origins are denied by default. Configure an explicit JSON allowlist with `OPENCODE_LLM_PROXY_CORS_ORIGINS`; avoid `"*"` on network-exposed installations. Browser Private Network Access is also denied unless `OPENCODE_LLM_PROXY_ALLOW_PRIVATE_NETWORK=true`. + +The proxy enforces request timeouts, request/media size limits, active-request and queue limits, and tool-bridge acquisition timeouts. Tune the corresponding variables documented in the README for your host capacity. Temporary OpenCode sessions are deleted after requests by default; enable `OPENCODE_LLM_PROXY_KEEP_SESSIONS` only when retained sessions are needed for diagnostics. + +Multimodal inputs are accepted only through supported content shapes and URL schemes and are checked against model capabilities. Structured-output schemas and generation controls are validated, and unsupported controls are rejected rather than silently accepted. + ## Checklist - [ ] Localhost binding unless network access is genuinely required - [ ] `OPENCODE_LLM_PROXY_TOKEN` set whenever bound to a network interface +- [ ] Browser origins explicitly allowlisted when browser access is needed - [ ] Not reachable from the public internet - [ ] Firewall restricts inbound access to known hosts - [ ] Tool-using clients are trusted and reviewed diff --git a/eslint.config.js b/eslint.config.js index b887cf9..8445b7f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,6 +19,9 @@ export default [ URL: "readonly", TextEncoder: "readonly", ReadableStream: "readonly", + AbortController: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", }, }, rules: { diff --git a/index.js b/index.js index d55980c..ed58c51 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,6 @@ import { fileURLToPath } from "node:url" +import { Buffer } from "node:buffer" +import { timingSafeEqual } from "node:crypto" const STATE_KEY = "__opencodeOpenAIProxyState" const BRIDGE_SCRIPT_PATH = fileURLToPath(new URL("./mcp-tool-bridge.js", import.meta.url)) @@ -10,44 +12,126 @@ function getState() { return globalThis[STATE_KEY] } -function corsHeaders(request) { - const configuredOrigin = process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN ?? "*" - const requestedHeaders = request?.headers.get("access-control-request-headers") - const requestedMethod = request?.headers.get("access-control-request-method") +const DEFAULTS = Object.freeze({ + requestTimeoutMs: 120000, + maxRequestBytes: 1024 * 1024, + maxConcurrentRequests: 8, + maxQueuedRequests: 32, + bridgeAcquireTimeoutMs: 10000, +}) + +class ProxyError extends Error { + constructor(message, status = 500, code = "server_error") { + super(message) + this.name = "ProxyError" + this.status = status + this.code = code + } +} + +function integerEnv(name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { + const raw = process.env[name] + if (raw === undefined || raw.trim() === "") return fallback + const value = Number(raw) + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new ProxyError(`${name} must be an integer between ${min} and ${max}.`, 500, "invalid_config") + } + return value +} + +function jsonArrayEnv(name) { + const raw = process.env[name] + if (!raw?.trim()) return [] + try { + const value = JSON.parse(raw) + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry.trim())) throw new Error() + return value.map((entry) => entry.trim()) + } catch { + throw new ProxyError(`${name} must be a JSON array of non-empty strings.`, 500, "invalid_config") + } +} + +function objectEnv(name) { + const raw = process.env[name] + if (!raw?.trim()) return {} + try { + const value = JSON.parse(raw) + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error() + return value + } catch { + throw new ProxyError(`${name} must be a JSON object.`, 500, "invalid_config") + } +} + +function loadConfig() { + const legacyToken = process.env.OPENCODE_LLM_PROXY_TOKEN?.trim() + const configuredOrigin = process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN?.trim() + const origins = jsonArrayEnv("OPENCODE_LLM_PROXY_CORS_ORIGINS") + if (configuredOrigin) origins.push(configuredOrigin) + return { + tokens: [...new Set([legacyToken, ...jsonArrayEnv("OPENCODE_LLM_PROXY_TOKENS")].filter(Boolean))], + corsOrigins: [...new Set(origins)], + allowPrivateNetwork: process.env.OPENCODE_LLM_PROXY_ALLOW_PRIVATE_NETWORK === "true", + requestTimeoutMs: integerEnv("OPENCODE_LLM_PROXY_REQUEST_TIMEOUT_MS", DEFAULTS.requestTimeoutMs, { min: 1, max: 3600000 }), + maxRequestBytes: integerEnv("OPENCODE_LLM_PROXY_MAX_REQUEST_BYTES", DEFAULTS.maxRequestBytes, { min: 1, max: 100 * 1024 * 1024 }), + maxConcurrentRequests: integerEnv("OPENCODE_LLM_PROXY_MAX_CONCURRENT_REQUESTS", DEFAULTS.maxConcurrentRequests, { min: 1, max: 1000 }), + maxQueuedRequests: integerEnv("OPENCODE_LLM_PROXY_MAX_QUEUED_REQUESTS", DEFAULTS.maxQueuedRequests, { min: 0, max: 10000 }), + bridgeAcquireTimeoutMs: integerEnv("OPENCODE_LLM_PROXY_TOOL_BRIDGE_ACQUIRE_TIMEOUT_MS", DEFAULTS.bridgeAcquireTimeoutMs, { min: 1, max: 3600000 }), + keepSessions: process.env.OPENCODE_LLM_PROXY_KEEP_SESSIONS === "true", + aliases: objectEnv("OPENCODE_LLM_PROXY_MODEL_ALIASES"), + } +} + +function commonHeaders(request, config) { + return { + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "x-frame-options": "DENY", + "content-security-policy": "default-src 'none'; frame-ancestors 'none'", + "x-request-id": request?.headers.get("x-request-id")?.slice(0, 128) || crypto.randomUUID(), + ...corsHeaders(request, config), + } +} + +function corsHeaders(request, config = loadConfig()) { const requestedPrivateNetwork = request?.headers.get("access-control-request-private-network") - const requestOrigin = request?.headers.get("origin") ?? "" - const allowOrigin = configuredOrigin === "*" ? "*" : (requestOrigin === configuredOrigin ? requestOrigin : configuredOrigin) + const requestOrigin = request?.headers.get("origin") + if (!requestOrigin) return {} + const allowed = config.corsOrigins.includes("*") || config.corsOrigins.includes(requestOrigin) + if (!allowed) return { vary: "origin, access-control-request-method, access-control-request-headers" } const headers = { vary: "origin, access-control-request-method, access-control-request-headers", - "access-control-allow-origin": allowOrigin, - "access-control-allow-headers": requestedHeaders ?? "authorization, content-type, x-opencode-provider", - "access-control-allow-methods": requestedMethod ?? "GET, POST, OPTIONS", + "access-control-allow-origin": config.corsOrigins.includes("*") ? "*" : requestOrigin, + "access-control-allow-headers": "authorization, content-type, x-opencode-provider, x-opencode-variant, x-request-id", + "access-control-allow-methods": "GET, POST, OPTIONS", "access-control-max-age": "86400", } - if (requestedPrivateNetwork === "true") { + if (requestedPrivateNetwork === "true" && config.allowPrivateNetwork) { headers["access-control-allow-private-network"] = "true" } return headers } -function json(data, status = 200, headers = {}, request) { +function json(data, status = 200, headers = {}, request, config) { return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", - ...corsHeaders(request), + ...commonHeaders(request, config ?? loadConfig()), ...headers, }, }) } -function text(message, status = 200, request) { +function text(message, status = 200, request, config) { return new Response(message, { status, - headers: corsHeaders(request), + headers: commonHeaders(request, config ?? loadConfig()), }) } @@ -100,10 +184,133 @@ function getBearerToken(request) { return header.slice(prefix.length).trim() } -function isAuthorized(request) { - const configured = process.env.OPENCODE_LLM_PROXY_TOKEN - if (!configured) return true - return getBearerToken(request) === configured +function tokensEqual(left, right) { + const a = Buffer.from(left) + const b = Buffer.from(right) + return a.length === b.length && timingSafeEqual(a, b) +} + +function isAuthorized(request, config = loadConfig()) { + if (config.tokens.length === 0) return true + const supplied = getBearerToken(request) + return Boolean(supplied && config.tokens.some((token) => tokensEqual(supplied, token))) +} + +function isPlainObject(value) { + return Boolean(value && typeof value === "object" && !Array.isArray(value)) +} + +async function readJsonBody(request, maxBytes, signal) { + const declared = Number(request.headers.get("content-length")) + if (Number.isFinite(declared) && declared > maxBytes) { + throw new ProxyError("Request body is too large.", 413, "request_too_large") + } + if (!request.body) throw new ProxyError("Request body must be valid JSON.", 400, "invalid_json") + const reader = request.body.getReader() + const onAbort = () => reader.cancel(signal.reason).catch(() => {}) + signal?.addEventListener("abort", onAbort, { once: true }) + const read = () => new Promise((resolve, reject) => { + const abort = () => { + cleanup() + reject(signal.reason) + } + const cleanup = () => signal?.removeEventListener("abort", abort) + signal?.addEventListener("abort", abort, { once: true }) + reader.read().then((value) => { + cleanup() + resolve(value) + }, (error) => { + cleanup() + reject(error) + }) + }) + const chunks = [] + let size = 0 + try { + while (true) { + const { value, done } = await read() + if (done) break + size += value.byteLength + if (size > maxBytes) { + await reader.cancel() + throw new ProxyError("Request body is too large.", 413, "request_too_large") + } + chunks.push(value) + } + const body = JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8")) + if (!isPlainObject(body)) throw new ProxyError("Request body must be a JSON object.", 400, "invalid_json") + return body + } catch (error) { + if (error instanceof ProxyError) throw error + throw new ProxyError("Request body must be valid JSON.", 400, "invalid_json") + } finally { + signal?.removeEventListener("abort", onAbort) + reader.releaseLock() + } +} + +function createRequestSignal(request, timeoutMs) { + const controller = new AbortController() + const onAbort = () => controller.abort(new ProxyError("Request was cancelled.", 499, "cancelled")) + request.signal?.addEventListener("abort", onAbort, { once: true }) + const timer = setTimeout(() => controller.abort(new ProxyError("Upstream request timed out.", 504, "timeout")), timeoutMs) + timer.unref?.() + return { + signal: controller.signal, + abort: (reason) => controller.abort(reason), + finish() { + clearTimeout(timer) + request.signal?.removeEventListener("abort", onAbort) + }, + } +} + +function getRequestLimiter(config) { + const state = getState() + const key = `${config.maxConcurrentRequests}:${config.maxQueuedRequests}` + if (!state.requestLimiter || state.requestLimiter.key !== key) { + state.requestLimiter = { key, active: 0, waiters: [] } + } + return state.requestLimiter +} + +async function acquireRequestSlot(config, signal) { + const limiter = getRequestLimiter(config) + if (limiter.active < config.maxConcurrentRequests) { + limiter.active++ + return () => releaseRequestSlot(limiter) + } + if (limiter.waiters.length >= config.maxQueuedRequests) { + throw new ProxyError("The proxy is busy. Try again later.", 503, "overloaded") + } + return new Promise((resolve, reject) => { + const waiter = { active: true } + const cleanup = () => signal?.removeEventListener("abort", onAbort) + const onAbort = () => { + if (!waiter.active) return + waiter.active = false + cleanup() + reject(signal.reason ?? new ProxyError("Request was cancelled.", 499, "cancelled")) + } + waiter.resolve = () => { + if (!waiter.active) return false + waiter.active = false + cleanup() + limiter.active++ + resolve(() => releaseRequestSlot(limiter)) + return true + } + limiter.waiters.push(waiter) + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +function releaseRequestSlot(limiter) { + limiter.active = Math.max(0, limiter.active - 1) + while (limiter.waiters.length > 0) { + const waiter = limiter.waiters.shift() + if (waiter.resolve()) return + } } export function toTextContent(content) { @@ -117,10 +324,12 @@ export function toTextContent(content) { } export function normalizeMessages(messages) { + if (!Array.isArray(messages)) return [] const toolNameByCallId = new Map() return messages .map((message) => { + if (!isPlainObject(message) || typeof message.role !== "string") return null if (message.role === "assistant" && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { const baseText = toTextContent(message.content).trim() const callsText = message.tool_calls @@ -145,7 +354,7 @@ export function normalizeMessages(messages) { content: toTextContent(message.content).trim(), } }) - .filter((message) => message.content.length > 0) + .filter((message) => message && message.content.length > 0) } export function normalizeResponseInput(input) { @@ -213,7 +422,7 @@ export function normalizeResponseInput(input) { .filter((message) => message.content.length > 0) } -export function buildSystemPrompt(messages, request) { +export function buildSystemPrompt(messages, _request) { const systemMessages = messages .filter((message) => message.role === "system" || message.role === "developer") .map((message) => message.content) @@ -223,14 +432,6 @@ export function buildSystemPrompt(messages, request) { "Return only the assistant's reply content.", ] - if (typeof request.temperature === "number") { - hints.push(`Requested temperature: ${request.temperature}`) - } - - if (typeof request.max_completion_tokens === "number" || typeof request.max_tokens === "number") { - hints.push(`Requested max output tokens: ${request.max_completion_tokens ?? request.max_tokens}`) - } - return [...systemMessages, ...hints].join("\n\n").trim() } @@ -248,7 +449,7 @@ export function buildPrompt(messages) { } const transcript = chatMessages - .map((message) => `${message.role.toUpperCase()}:\n${message.content}`) + .map((message) => `${String(message.role).toUpperCase()}:\n${message.content}`) .join("\n\n") return [ @@ -267,16 +468,84 @@ export function extractAssistantText(parts) { .trim() } -async function executePrompt(client, request, model, messages, system, callerTools = []) { +function dataUrlSize(url) { + if (typeof url !== "string" || !url.startsWith("data:")) return 0 + const comma = url.indexOf(",") + if (comma === -1) return Number.POSITIVE_INFINITY + const metadata = url.slice(0, comma) + const payload = url.slice(comma + 1) + return metadata.endsWith(";base64") ? Math.ceil(payload.length * 0.75) : Buffer.byteLength(decodeURIComponent(payload)) +} + +function validateFilePart(part, model, maxBytes) { + if (!part?.mime || !part?.url) throw new ProxyError("Invalid file or image content part.", 400, "invalid_media") + if (!part.url.startsWith("data:")) { + throw new ProxyError("Only embedded data URLs are supported for media.", 400, "invalid_media") + } + if (dataUrlSize(part.url) > maxBytes) throw new ProxyError("Embedded media is too large.", 413, "request_too_large") + const kind = part.mime === "application/pdf" ? "pdf" : part.mime.split("/", 1)[0] + const input = model.capabilities?.input + if (input && kind in input && !input[kind]) { + throw new ProxyError(`Model '${model.id}' does not support ${kind} input.`, 400, "unsupported_media") + } +} + +function promptParts(messages, media, model, maxBytes) { + const parts = [{ type: "text", text: buildPrompt(messages) }] + for (const part of media ?? []) { + validateFilePart(part, model, maxBytes) + parts.push({ type: "file", mime: part.mime, url: part.url, ...(part.filename ? { filename: part.filename } : {}) }) + } + return parts +} + +function structuredFormat(request) { + const openAI = request.response_format?.json_schema?.schema ?? request.text?.format?.schema + const gemini = request.generationConfig?.responseSchema + const schema = openAI ?? gemini + if (!schema) return undefined + if (!isPlainObject(schema)) throw new ProxyError("Structured output schema must be a JSON object.", 400, "invalid_schema") + return { type: "json_schema", schema } +} + +function validateUnsupportedControls(request) { + const unsupported = ["stop", "seed", "frequency_penalty", "presence_penalty", "logprobs", "n"] + .filter((name) => request[name] !== undefined) + if (unsupported.length > 0) { + throw new ProxyError(`Unsupported generation controls: ${unsupported.join(", ")}.`, 400, "unsupported_parameter") + } +} + +async function deleteSession(client, sessionID, keepSessions) { + if (keepSessions || !sessionID || typeof client.session.delete !== "function") return + try { + await client.session.delete({ path: { id: sessionID } }) + } catch { + // Best-effort cleanup for compatibility with older OpenCode clients. + } +} + +function setGenerationControls(sessionID, controls) { + if (!sessionID || !controls || Object.keys(controls).length === 0) return + const state = getState() + state.generationControls ??= new Map() + state.generationControls.set(sessionID, controls) +} + +function clearGenerationControls(sessionID) { + getState().generationControls?.delete(sessionID) +} + +async function executePrompt(client, _request, model, messages, system, callerTools = [], options = {}) { if (Array.isArray(callerTools) && callerTools.length > 0) { // Tool-aware path: must watch the event stream (via runAgentTurn) rather than // block on session.prompt, so we can intercept a proposed tool call instead of // letting OpenCode's agent loop run to a final text answer. - const result = await runAgentTurn(client, model, messages, system, callerTools, () => {}) + const result = await runAgentTurn(client, model, messages, system, callerTools, () => {}, options) return { content: result.content, toolCalls: result.toolCalls, - request, + request: _request, sessionID: result.sessionID, completion: { data: { @@ -290,54 +559,45 @@ async function executePrompt(client, request, model, messages, system, callerToo } const tools = await getDisabledTools(client) - const session = await client.session.create({ - body: { - title: `Proxy: ${model.id}`, - }, - }) - - const prompt = buildPrompt(messages) - - const completion = await client.session.prompt({ - path: { id: session.data.id }, - body: { - model: { - providerID: model.providerID, - modelID: model.modelID, + let sessionID + try { + const session = await client.session.create({ body: { title: `Proxy: ${model.id}` }, signal: options.signal }) + sessionID = session.data.id + setGenerationControls(sessionID, options.controls) + const completion = await client.session.prompt({ + path: { id: sessionID }, + signal: options.signal, + body: { + model: { providerID: model.providerID, modelID: model.modelID }, + system, + tools, + parts: promptParts(messages, options.media, model, options.maxRequestBytes ?? DEFAULTS.maxRequestBytes), + ...(options.format ? { format: options.format } : {}), + ...(options.variant ? { variant: options.variant } : {}), }, - system, - tools, - parts: [ - { - type: "text", - text: prompt, - }, - ], - }, - }) + }) - const content = extractAssistantText(completion.data.parts ?? []) + const structured = completion.data.info?.structured + const content = structured === undefined ? extractAssistantText(completion.data.parts ?? []) : JSON.stringify(structured) - if (!content && completion.data.info?.error) { - throw new Error(completion.data.info.error.message ?? "Model call failed.") - } + if (!content && completion.data.info?.error) throw new Error(completion.data.info.error.message ?? "Model call failed.") - return { - content, - toolCalls: [], - completion, - request, - sessionID: session.data.id, + return { content, structured, toolCalls: [], completion, request: _request, sessionID } + } finally { + clearGenerationControls(sessionID) + await deleteSession(client, sessionID, options.keepSessions) } } -async function executePromptStreaming(client, model, messages, system, onChunk, callerTools = []) { - const result = await runAgentTurn(client, model, messages, system, callerTools, onChunk) +async function executePromptStreaming(client, model, messages, system, onChunk, callerTools = [], options = {}) { + const result = await runAgentTurn(client, model, messages, system, callerTools, onChunk, options) return { sessionID: result.sessionID, tokens: result.tokens, finish: result.finish, toolCalls: result.toolCalls, + content: result.structured === undefined ? result.content : JSON.stringify(result.structured), + structured: result.structured, } } @@ -529,24 +789,47 @@ function getToolBridgeState() { return state.toolBridge } -async function acquireBridgeSlot() { +async function acquireBridgeSlot(options = {}) { const bridgeState = getToolBridgeState() if (bridgeState.freeSlots.length > 0) { return bridgeState.freeSlots.shift() } - return new Promise((resolve) => { - bridgeState.waiters.push(resolve) + return new Promise((resolve, reject) => { + const waiter = { active: true } + const timeout = setTimeout(() => { + if (!waiter.active) return + waiter.active = false + reject(new ProxyError("Timed out waiting for tool capacity.", 503, "tool_capacity_timeout")) + }, options.timeoutMs ?? DEFAULTS.bridgeAcquireTimeoutMs) + timeout.unref?.() + const onAbort = () => { + if (!waiter.active) return + waiter.active = false + clearTimeout(timeout) + reject(options.signal.reason ?? new ProxyError("Request was cancelled.", 499, "cancelled")) + } + waiter.resolve = (slot) => { + if (!waiter.active) return false + waiter.active = false + clearTimeout(timeout) + options.signal?.removeEventListener("abort", onAbort) + resolve(slot) + return true + } + bridgeState.waiters.push(waiter) + options.signal?.addEventListener("abort", onAbort, { once: true }) }) } function releaseBridgeSlot(slotName) { const bridgeState = getToolBridgeState() if (bridgeState.waiters.length > 0) { - const resolve = bridgeState.waiters.shift() - resolve(slotName) - } else { - bridgeState.freeSlots.push(slotName) + while (bridgeState.waiters.length > 0) { + const waiter = bridgeState.waiters.shift() + if (waiter.resolve(slotName)) return + } } + if (!bridgeState.freeSlots.includes(slotName)) bridgeState.freeSlots.push(slotName) } export function sanitizeToolName(name, seen = new Set()) { @@ -665,8 +948,8 @@ export function applyGeminiToolChoice(tools, toolConfig) { return tools } -export async function registerToolBridge(client, tools) { - const slotName = await acquireBridgeSlot() +export async function registerToolBridge(client, tools, options = {}) { + const slotName = await acquireBridgeSlot(options) try { const seen = new Set() const nameMap = new Map() // full bridge tool ID ("_") -> original caller-facing name @@ -715,7 +998,10 @@ export async function registerToolBridge(client, tools) { } export function releaseToolBridge(bridge) { - if (bridge) releaseBridgeSlot(bridge.slotName) + if (bridge && !bridge.released) { + bridge.released = true + releaseBridgeSlot(bridge.slotName) + } } // Builds the per-turn `tools` map sent to OpenCode: the caller's bridge tools enabled, @@ -742,34 +1028,22 @@ export function buildToolsMap(baseTools, bridge) { return toolsMap } -async function runAgentTurn(client, model, messages, system, callerTools, onChunk) { +async function runAgentTurn(client, model, messages, system, callerTools, onChunk, options = {}) { const baseTools = await getDisabledTools(client) let toolsMap = baseTools let bridge = null if (Array.isArray(callerTools) && callerTools.length > 0) { - bridge = await registerToolBridge(client, callerTools) + bridge = await registerToolBridge(client, callerTools, { + signal: options.signal, + timeoutMs: options.bridgeAcquireTimeoutMs, + }) toolsMap = buildToolsMap(baseTools, bridge) } - const session = await client.session.create({ body: { title: `Proxy: ${model.id}` } }) - const sessionID = session.data.id - const prompt = buildPrompt(messages) + let sessionID const toolIDSet = bridge ? new Set(bridge.toolIDs) : null - // 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: toolsMap, - parts: [{ type: "text", text: prompt }], - }, - }) - let errorMessage = null let content = "" // Tool calls collected live off the event stream, keyed by callID so parallel calls @@ -805,6 +1079,25 @@ async function runAgentTurn(client, model, messages, system, callerTools, onChun } try { + const session = await client.session.create({ body: { title: `Proxy: ${model.id}` }, signal: options.signal }) + sessionID = session.data.id + setGenerationControls(sessionID, options.controls) + const onAbort = () => client.session.abort?.({ path: { id: sessionID } }).catch(() => {}) + options.signal?.addEventListener("abort", onAbort, { once: true }) + // Subscribe before prompting so no events are missed. + const { stream } = await client.event.subscribe({ signal: options.signal }) + await client.session.promptAsync({ + path: { id: sessionID }, + signal: options.signal, + body: { + model: { providerID: model.providerID, modelID: model.modelID }, + system, + tools: toolsMap, + parts: promptParts(messages, options.media, model, options.maxRequestBytes ?? DEFAULTS.maxRequestBytes), + ...(options.format ? { format: options.format } : {}), + ...(options.variant ? { variant: options.variant } : {}), + }, + }) for await (const event of stream) { if (event.type === "message.part.delta") { // Real incremental token deltas arrive here, as flat properties (sessionID, @@ -820,7 +1113,7 @@ async function runAgentTurn(client, model, messages, system, callerTools, onChun props.delta.length > 0 ) { content += props.delta - onChunk?.(props.delta) + await onChunk?.(props.delta) } } else if (event.type === "message.part.updated") { const part = event.properties?.part @@ -853,7 +1146,7 @@ async function runAgentTurn(client, model, messages, system, callerTools, onChun break } } else if (event.type === "session.error") { - if (!event.properties?.sessionID || event.properties.sessionID === sessionID) { + if (event.properties?.sessionID === sessionID) { errorMessage = event.properties?.error?.message ?? "Model call failed." } break @@ -863,7 +1156,12 @@ async function runAgentTurn(client, model, messages, system, callerTools, onChun } } } + options.signal?.removeEventListener("abort", onAbort) + } catch (error) { + await deleteSession(client, sessionID, options.keepSessions) + throw error } finally { + clearGenerationControls(sessionID) releaseToolBridge(bridge) } @@ -874,13 +1172,20 @@ async function runAgentTurn(client, model, messages, system, callerTools, onChun })) if (errorMessage && toolCalls.length === 0) { + await deleteSession(client, sessionID, options.keepSessions) throw new Error(errorMessage) } // Each list item is { info: Message, parts: Part[] } - matching the shape // client.session.prompt() (the non-tool-calling path) already returns directly. - const messagesResult = await client.session.messages({ path: { id: sessionID } }) - const assistantEntry = (messagesResult.data ?? []).filter((m) => m.info?.role === "assistant").at(-1) + let assistantEntry + try { + const messagesResult = await client.session.messages({ path: { id: sessionID }, signal: options.signal }) + assistantEntry = (messagesResult.data ?? []).filter((m) => m.info?.role === "assistant").at(-1) + } catch (error) { + await deleteSession(client, sessionID, options.keepSessions) + throw error + } const assistantInfo = assistantEntry?.info // Fallback for turns where message.part.delta never fired (observed for some @@ -890,14 +1195,18 @@ async function runAgentTurn(client, model, messages, system, callerTools, onChun if (!content && toolCalls.length === 0) { content = extractAssistantText(assistantEntry?.parts ?? []) } + if (!content && assistantInfo?.structured !== undefined) content = JSON.stringify(assistantInfo.structured) - return { + const result = { sessionID, content, toolCalls, tokens: assistantInfo?.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, finish: toolCalls.length > 0 ? "tool_calls" : assistantInfo?.finish, + structured: assistantInfo?.structured, } + await deleteSession(client, sessionID, options.keepSessions) + return result } async function listModels(client) { @@ -912,6 +1221,11 @@ async function listModels(client) { providerID: provider.id, modelID: model.id, name: model.name ?? model.id, + capabilities: model.capabilities, + limit: model.limit, + cost: model.cost, + status: model.status, + variants: model.variants, })) }) } @@ -948,6 +1262,49 @@ export async function resolveModel(client, requestedModel, providerOverride) { throw new Error(`Unknown model '${requestedModel}'. Call GET /v1/models to inspect available IDs.`) } +async function resolveModelCandidates(client, requestedModel, providerOverride, aliases = {}) { + const configured = aliases[requestedModel] + const targets = typeof configured === "string" ? [configured] : configured + if (configured !== undefined && (!Array.isArray(targets) || targets.length === 0 || targets.some((target) => typeof target !== "string"))) { + throw new ProxyError(`Model alias '${requestedModel}' is invalid.`, 500, "invalid_config") + } + const ids = targets ?? [requestedModel] + const models = [] + for (const id of ids) models.push(await resolveModel(client, id, providerOverride)) + return models +} + +function isRetryableError(error) { + if (error instanceof ProxyError && error.status < 500) return false + return !error?.message?.toLowerCase().includes("invalid") +} + +async function executeWithFallback(candidates, operation) { + let lastError + for (const candidate of candidates) { + try { + return { result: await operation(candidate), model: candidate } + } catch (error) { + lastError = error + if (!isRetryableError(error)) throw error + } + } + throw lastError +} + +async function executeStreamingWithFallback(candidates, operation, hasOutput) { + let lastError + for (const candidate of candidates) { + try { + return { result: await operation(candidate), model: candidate } + } catch (error) { + lastError = error + if (hasOutput() || !isRetryableError(error)) throw error + } + } + throw lastError +} + export function createSseQueue() { const chunks = [] let resolve = null @@ -990,7 +1347,7 @@ export function createSseQueue() { return { enqueue, finish, generateChunks } } -function sseResponse(corsHeadersObj, generator) { +function streamResponse(headers, generator, options = {}) { const encoder = new TextEncoder() const body = new ReadableStream({ async start(controller) { @@ -1002,21 +1359,39 @@ function sseResponse(corsHeadersObj, generator) { // Stream errors are surfaced via SSE data before this point. } finally { controller.close() + options.onDone?.() } }, + cancel(reason) { + options.onCancel?.(reason) + options.onDone?.() + }, }) return new Response(body, { status: 200, headers: { "content-type": "text/event-stream; charset=utf-8", - "cache-control": "no-cache", + "cache-control": "no-store", connection: "keep-alive", - ...corsHeadersObj, + ...headers, }, }) } +function sseResponse(headers, generator, options) { + return streamResponse(headers, generator, options) +} + +function once(callback) { + let called = false + return () => { + if (called) return + called = true + callback?.() + } +} + function createModelResponse(models) { return { object: "list", @@ -1026,6 +1401,14 @@ function createModelResponse(models) { created: 0, owned_by: model.providerID, root: model.id, + x_opencode: { + name: model.name, + status: model.status, + capabilities: model.capabilities, + limits: model.limit, + variants: model.variants, + cost: model.cost, + }, })), } } @@ -1173,6 +1556,79 @@ export function normalizeGeminiContents(contents) { .filter((m) => m.content.length > 0) } +function openAIMedia(messages) { + const media = [] + for (const message of messages ?? []) { + for (const part of Array.isArray(message?.content) ? message.content : []) { + if (part?.type === "image_url") { + const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url + if (url) media.push({ type: "file", mime: /^data:([^;,]+)/.exec(url)?.[1] ?? "image/*", url }) + } else if (part?.type === "input_image" && (part.image_url || part.file_data)) { + const url = part.image_url ?? part.file_data + media.push({ type: "file", mime: /^data:([^;,]+)/.exec(url)?.[1] ?? "image/*", url }) + } else if (part?.type === "input_file" && (part.file_data || part.file_url)) { + const url = part.file_data ?? part.file_url + media.push({ type: "file", mime: part.mime_type ?? /^data:([^;,]+)/.exec(url)?.[1] ?? "application/octet-stream", url, filename: part.filename }) + } + } + } + return media +} + +function anthropicMedia(messages) { + const media = [] + for (const message of messages ?? []) { + for (const block of Array.isArray(message?.content) ? message.content : []) { + if (!block || !["image", "document"].includes(block.type)) continue + const source = block.source + if (source?.type === "base64" && source.media_type && source.data) { + media.push({ type: "file", mime: source.media_type, url: `data:${source.media_type};base64,${source.data}` }) + } else if (source?.type === "url" && source.url) { + media.push({ type: "file", mime: block.type === "image" ? "image/*" : "application/pdf", url: source.url }) + } + } + } + return media +} + +function geminiMedia(contents) { + const media = [] + for (const item of contents ?? []) { + for (const part of item?.parts ?? []) { + const inline = part?.inlineData ?? part?.inline_data + const file = part?.fileData ?? part?.file_data + if (inline?.mimeType && inline.data) { + media.push({ type: "file", mime: inline.mimeType, url: `data:${inline.mimeType};base64,${inline.data}` }) + } else if (file?.mimeType && file.fileUri) { + media.push({ type: "file", mime: file.mimeType, url: file.fileUri }) + } + } + } + return media +} + +function generationControls(body) { + const source = body.generationConfig ?? body + const controls = {} + if (source.temperature !== undefined) { + if (typeof source.temperature !== "number" || source.temperature < 0 || source.temperature > 2) { + throw new ProxyError("'temperature' must be a number between 0 and 2.", 400, "invalid_parameter") + } + controls.temperature = source.temperature + } + const topP = source.top_p ?? source.topP + if (topP !== undefined) { + if (typeof topP !== "number" || topP < 0 || topP > 1) throw new ProxyError("'top_p' must be between 0 and 1.", 400, "invalid_parameter") + controls.topP = topP + } + const topK = source.topK + if (topK !== undefined) { + if (!Number.isInteger(topK) || topK < 1) throw new ProxyError("'topK' must be a positive integer.", 400, "invalid_parameter") + controls.topK = topK + } + return controls +} + export function extractGeminiSystemInstruction(systemInstruction) { if (!systemInstruction) return null if (typeof systemInstruction === "string") return systemInstruction.trim() @@ -1217,22 +1673,68 @@ function createGeminiResponse(content, finish, tokens, toolCalls) { 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 + const match = pathname.match(/^\/v1beta\/models\/(.+):(?:generateContent|streamGenerateContent)$/) + return match ? decodeURIComponent(match[1]) : null } export function createProxyFetchHandler(client) { + const config = loadConfig() return async (request) => { const url = new URL(request.url) + const origin = request.headers.get("origin") if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: corsHeaders(request) }) + const method = request.headers.get("access-control-request-method") + const requestedHeaders = (request.headers.get("access-control-request-headers") ?? "") + .split(",").map((value) => value.trim().toLowerCase()).filter(Boolean) + const allowedHeaders = new Set(["authorization", "content-type", "x-opencode-provider", "x-opencode-variant", "x-request-id"]) + const allowedOrigin = origin && (config.corsOrigins.includes("*") || config.corsOrigins.includes(origin)) + if (!allowedOrigin || (method && !["GET", "POST", "OPTIONS"].includes(method)) || requestedHeaders.some((value) => !allowedHeaders.has(value))) { + return text("CORS preflight rejected", 403, request, config) + } + return new Response(null, { status: 204, headers: commonHeaders(request, config) }) + } + + if (origin && !config.corsOrigins.includes("*") && !config.corsOrigins.includes(origin)) { + return text("Origin not allowed", 403, request, config) } - if (!isAuthorized(request)) { + if (!isAuthorized(request, config)) { return unauthorized(request) } + const started = Date.now() + const context = createRequestSignal(request, config.requestTimeoutMs) + let releaseSlot = () => {} + let deferredCleanup = false + if (request.method === "POST") { + try { + releaseSlot = await acquireRequestSlot(config, context.signal) + } catch (error) { + context.finish() + const status = error instanceof ProxyError ? error.status : 503 + return badRequest(status === 503 ? "The proxy is busy. Try again later." : "Request was cancelled.", status, request) + } + } + + const options = { + signal: context.signal, + maxRequestBytes: config.maxRequestBytes, + bridgeAcquireTimeoutMs: config.bridgeAcquireTimeoutMs, + keepSessions: config.keepSessions, + } + const streamCleanup = once(() => { + releaseSlot() + context.finish() + safeLog(client, "info", "Proxy stream completed", { + method: request.method, + path: url.pathname, + durationMs: Date.now() - started, + }) + }) + + try { + if (request.method === "GET" && url.pathname === "/health") { return json({ healthy: true, service: "opencode-openai-proxy" }, 200, {}, request) } @@ -1252,9 +1754,9 @@ export function createProxyFetchHandler(client) { if (request.method === "POST" && url.pathname === "/v1/chat/completions") { let body try { - body = await request.json() - } catch { - return badRequest("Request body must be valid JSON.", 400, request) + body = await readJsonBody(request, config.maxRequestBytes, context.signal) + } catch (error) { + return badRequest(error.message, error.status ?? 400, request) } if (!body.model) { @@ -1266,39 +1768,52 @@ export function createProxyFetchHandler(client) { } const messages = normalizeMessages(body.messages) - if (messages.length === 0) { + const media = openAIMedia(body.messages) + if (messages.length === 0 && media.length === 0) { return badRequest("No text content was found in the supplied messages.", 400, request) } - let model + let candidates try { + validateUnsupportedControls(body) const providerOverride = request.headers.get("x-opencode-provider") - model = await resolveModel(client, body.model, providerOverride) + candidates = await resolveModelCandidates(client, body.model, providerOverride, config.aliases) } 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) + return badRequest(error instanceof ProxyError ? message : "The requested model is unavailable.", error.status ?? 400, request) } const system = buildSystemPrompt(messages, body) const callerTools = applyOpenAIToolChoice(parseOpenAITools(body), body.tool_choice) + let requestOptions + try { + const format = structuredFormat(body) + if (format && callerTools.length > 0) throw new ProxyError("Structured output cannot be combined with tools.", 400, "invalid_request") + requestOptions = { ...options, media, format, controls: generationControls(body), variant: request.headers.get("x-opencode-variant") ?? undefined } + } catch (error) { + return badRequest(error.message, error.status ?? 400, request) + } + let model = candidates[0] if (body.stream) { const completionID = `chatcmpl_${crypto.randomUUID().replace(/-/g, "")}` const now = Math.floor(Date.now() / 1000) const queue = createSseQueue() + let emitted = false async function* generateSse() { - const runPromise = executePromptStreaming( + const runPromise = executeStreamingWithFallback(candidates, (candidate) => executePromptStreaming( client, - model, + candidate, messages, system, (delta) => { + emitted = true const chunk = JSON.stringify({ id: completionID, object: "chat.completion.chunk", @@ -1309,8 +1824,15 @@ export function createProxyFetchHandler(client) { queue.enqueue(`data: ${chunk}\n\n`) }, callerTools, - ) - .then((streamResult) => { + requestOptions, + ), () => emitted) + .then(({ result: streamResult, model: selectedModel }) => { + model = selectedModel + if (!emitted && streamResult.content && !(streamResult.toolCalls?.length > 0)) { + emitted = true + const chunk = JSON.stringify({ id: completionID, object: "chat.completion.chunk", created: now, model: model.id, choices: [{ index: 0, delta: { role: "assistant", content: streamResult.content }, finish_reason: null }] }) + queue.enqueue(`data: ${chunk}\n\n`) + } const toolCalls = streamResult.toolCalls ?? [] if (toolCalls.length > 0) { const toolCallChunk = JSON.stringify({ @@ -1367,7 +1889,7 @@ export function createProxyFetchHandler(client) { requestedModel: body.model, }) const errChunk = JSON.stringify({ - error: { message: streamError, type: "server_error" }, + error: { message: "Upstream request failed.", type: "server_error" }, }) queue.enqueue(`data: ${errChunk}\n\ndata: [DONE]\n\n`) }) @@ -1380,28 +1902,34 @@ export function createProxyFetchHandler(client) { await runPromise } - return sseResponse(corsHeaders(request), generateSse()) + deferredCleanup = true + return sseResponse(commonHeaders(request, config), generateSse(), { + onCancel: (reason) => context.abort(reason), + onDone: streamCleanup, + }) } try { - const result = await executePrompt(client, body, model, messages, system, callerTools) - return json(createChatCompletionResponse(result, model), 200, {}, request) + const executed = await executeWithFallback(candidates, (candidate) => + executePrompt(client, body, candidate, messages, system, callerTools, requestOptions)) + model = executed.model + return json(createChatCompletionResponse(executed.result, model), 200, {}, request) } 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) + return badRequest(error instanceof ProxyError ? message : "Upstream request failed.", error.status ?? 502, request) } } if (request.method === "POST" && url.pathname === "/v1/responses") { let body try { - body = await request.json() - } catch { - return badRequest("Request body must be valid JSON.", 400, request) + body = await readJsonBody(request, config.maxRequestBytes, context.signal) + } catch (error) { + return badRequest(error.message, error.status ?? 400, request) } if (!body.model) { @@ -1409,7 +1937,8 @@ export function createProxyFetchHandler(client) { } const messages = normalizeResponseInput(body.input) - if (messages.length === 0) { + const media = openAIMedia(Array.isArray(body.input) ? body.input : []) + if (messages.length === 0 && media.length === 0) { return badRequest("The 'input' field must contain at least one text message.", 400, request) } @@ -1425,18 +1954,28 @@ export function createProxyFetchHandler(client) { }) const callerTools = applyOpenAIToolChoice(parseOpenAITools(body), body.tool_choice) - let model + let candidates try { + validateUnsupportedControls(body) const providerOverride = request.headers.get("x-opencode-provider") - model = await resolveModel(client, body.model, providerOverride) + candidates = await resolveModelCandidates(client, body.model, providerOverride, config.aliases) } 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) + return badRequest(error instanceof ProxyError ? message : "The requested model is unavailable.", error.status ?? 400, request) + } + let requestOptions + try { + const format = structuredFormat(body) + if (format && callerTools.length > 0) throw new ProxyError("Structured output cannot be combined with tools.", 400, "invalid_request") + requestOptions = { ...options, media, format, controls: generationControls(body), variant: request.headers.get("x-opencode-variant") ?? body.reasoning?.effort ?? undefined } + } catch (error) { + return badRequest(error.message, error.status ?? 400, request) } + let model = candidates[0] if (body.stream) { const responseID = `resp_${crypto.randomUUID().replace(/-/g, "")}` @@ -1444,6 +1983,7 @@ export function createProxyFetchHandler(client) { const now = Math.floor(Date.now() / 1000) const queue = createSseQueue() + let emitted = false function sseEvent(eventType, data) { return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n` @@ -1463,25 +2003,25 @@ export function createProxyFetchHandler(client) { }, }), ) - queue.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 // Accumulate delta tokens so we can populate `text` on output_text.done and content_part.done per the // OpenAI Responses API SSE spec (https://platform.openai.com/docs/api-reference/responses-streaming). let accumulatedText = "" - const runPromise = executePromptStreaming( + const runPromise = executeStreamingWithFallback(candidates, (candidate) => executePromptStreaming( client, - model, + candidate, messages, system, (delta) => { + emitted = true if (partIndex === 0) { + queue.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: [] }, + }), + ) queue.enqueue( sseEvent("response.content_part.added", { type: "response.content_part.added", @@ -1505,15 +2045,25 @@ export function createProxyFetchHandler(client) { ) }, callerTools, - ) - .then((streamResult) => { + requestOptions, + ), () => emitted) + .then(({ result: streamResult, model: selectedModel }) => { + model = selectedModel + if (!emitted && streamResult.content && !(streamResult.toolCalls?.length > 0)) { + accumulatedText = streamResult.content + emitted = true + queue.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: [] } })) + queue.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: [] } })) + queue.enqueue(sseEvent("response.output_text.delta", { type: "response.output_text.delta", item_id: itemID, output_index: 0, content_index: 0, delta: accumulatedText })) + partIndex = 1 + } const toolCalls = streamResult.toolCalls ?? [] if (toolCalls.length > 0) { // Each parallel tool call is its own output item with a distinct output_index. toolCalls.forEach((call, index) => { const args = JSON.stringify(call.arguments ?? {}) const callItemID = `fc_${crypto.randomUUID().replace(/-/g, "")}` - const outputIndex = index + 1 + const outputIndex = index queue.enqueue( sseEvent("response.output_item.added", { type: "response.output_item.added", @@ -1640,7 +2190,7 @@ export function createProxyFetchHandler(client) { object: "response", created_at: now, status: "failed", - error: { message: errMsg, code: "server_error" }, + error: { message: "Upstream request failed.", code: "server_error" }, }, }), ) @@ -1654,19 +2204,25 @@ export function createProxyFetchHandler(client) { await runPromise } - return sseResponse(corsHeaders(request), generateSse()) + deferredCleanup = true + return sseResponse(commonHeaders(request, config), generateSse(), { + onCancel: (reason) => context.abort(reason), + onDone: streamCleanup, + }) } try { - const result = await executePrompt(client, body, model, messages, system, callerTools) - return json(createResponsesApiResponse(result, model), 200, {}, request) + const executed = await executeWithFallback(candidates, (candidate) => + executePrompt(client, body, candidate, messages, system, callerTools, requestOptions)) + model = executed.model + return json(createResponsesApiResponse(executed.result, model), 200, {}, request) } 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) + return badRequest(error instanceof ProxyError ? message : "Upstream request failed.", error.status ?? 502, request) } } @@ -1677,9 +2233,9 @@ export function createProxyFetchHandler(client) { 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) + body = await readJsonBody(request, config.maxRequestBytes, context.signal) + } catch (error) { + return anthropicBadRequest(error.message, error.status ?? 400, request) } if (!body.model) { @@ -1691,7 +2247,8 @@ export function createProxyFetchHandler(client) { } const messages = normalizeAnthropicMessages(body.messages) - if (messages.length === 0) { + const media = anthropicMedia(body.messages) + if (messages.length === 0 && media.length === 0) { return anthropicBadRequest("No text content was found in the supplied messages.", 400, request) } @@ -1709,19 +2266,28 @@ export function createProxyFetchHandler(client) { }) const callerTools = applyAnthropicToolChoice(parseAnthropicTools(body), body.tool_choice) - let model + let candidates try { + validateUnsupportedControls(body) const providerOverride = request.headers.get("x-opencode-provider") - model = await resolveModel(client, body.model, providerOverride) + candidates = await resolveModelCandidates(client, body.model, providerOverride, config.aliases) } 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) + return anthropicBadRequest(error instanceof ProxyError ? message : "The requested model is unavailable.", error.status ?? 400, request) + } + let requestOptions + try { + requestOptions = { ...options, media, controls: generationControls(body), variant: request.headers.get("x-opencode-variant") ?? undefined } + } catch (error) { + return anthropicBadRequest(error.message, error.status ?? 400, request) } + let model = candidates[0] if (body.stream) { const msgID = `msg_${crypto.randomUUID().replace(/-/g, "")}` const queue = createSseQueue() + let emitted = false function sseEvent(eventType, data) { return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n` @@ -1743,12 +2309,13 @@ export function createProxyFetchHandler(client) { })) let textBlockStarted = false - const runPromise = executePromptStreaming( + const runPromise = executeStreamingWithFallback(candidates, (candidate) => executePromptStreaming( client, - model, + candidate, messages, system, (delta) => { + emitted = true if (!textBlockStarted) { queue.enqueue(sseEvent("content_block_start", { type: "content_block_start", @@ -1764,8 +2331,16 @@ export function createProxyFetchHandler(client) { })) }, callerTools, - ) - .then((streamResult) => { + requestOptions, + ), () => emitted) + .then(({ result: streamResult, model: selectedModel }) => { + model = selectedModel + if (!emitted && streamResult.content && !(streamResult.toolCalls?.length > 0)) { + emitted = true + textBlockStarted = true + queue.enqueue(sseEvent("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })) + queue.enqueue(sseEvent("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: streamResult.content } })) + } const toolCalls = streamResult.toolCalls ?? [] if (toolCalls.length > 0) { if (textBlockStarted) { @@ -1819,7 +2394,7 @@ export function createProxyFetchHandler(client) { .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 } })) + queue.enqueue(sseEvent("error", { type: "error", error: { type: "api_error", message: "Upstream request failed." } })) }) .finally(() => { queue.finish() @@ -1829,16 +2404,22 @@ export function createProxyFetchHandler(client) { await runPromise } - return sseResponse(corsHeaders(request), generateSse()) + deferredCleanup = true + return sseResponse(commonHeaders(request, config), generateSse(), { + onCancel: (reason) => context.abort(reason), + onDone: streamCleanup, + }) } try { - const result = await executePrompt(client, body, model, messages, system, callerTools) - return json(createAnthropicResponse(result, model), 200, {}, request) + const executed = await executeWithFallback(candidates, (candidate) => + executePrompt(client, body, candidate, messages, system, callerTools, requestOptions)) + model = executed.model + return json(createAnthropicResponse(executed.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) + return anthropicInternalError(error instanceof ProxyError ? message : "Upstream request failed.", error.status ?? 502, request) } } @@ -1858,9 +2439,9 @@ export function createProxyFetchHandler(client) { let body try { - body = await request.json() - } catch { - return badRequest("Request body must be valid JSON.", 400, request) + body = await readJsonBody(request, config.maxRequestBytes, context.signal) + } catch (error) { + return badRequest(error.message, error.status ?? 400, request) } if (!Array.isArray(body.contents) || body.contents.length === 0) { @@ -1868,7 +2449,8 @@ export function createProxyFetchHandler(client) { } const messages = normalizeGeminiContents(body.contents) - if (messages.length === 0) { + const media = geminiMedia(body.contents) + if (messages.length === 0 && media.length === 0) { return badRequest("No text content was found in the supplied contents.", 400, request) } @@ -1880,32 +2462,44 @@ export function createProxyFetchHandler(client) { }) const callerTools = applyGeminiToolChoice(parseGeminiTools(body), body.toolConfig) - let model + let candidates try { const providerOverride = request.headers.get("x-opencode-provider") - model = await resolveModel(client, geminiModelName, providerOverride) + candidates = await resolveModelCandidates(client, geminiModelName, providerOverride, config.aliases) } 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) + return badRequest(error instanceof ProxyError ? message : "The requested model is unavailable.", error.status ?? 400, request) + } + let requestOptions + try { + requestOptions = { ...options, media, format: structuredFormat(body), controls: generationControls(body), variant: request.headers.get("x-opencode-variant") ?? undefined } + } catch (error) { + return badRequest(error.message, error.status ?? 400, request) } - if (isGeminiStream) { const queue = createSseQueue() + let emitted = false async function* generateNdJson() { - const runPromise = executePromptStreaming( + const runPromise = executeStreamingWithFallback(candidates, (candidate) => executePromptStreaming( client, - model, + candidate, messages, system, (delta) => { + emitted = true const chunk = JSON.stringify(createGeminiResponse(delta, null, null)) queue.enqueue(chunk + "\n") }, callerTools, - ) - .then((streamResult) => { + requestOptions, + ), () => emitted) + .then(({ result: streamResult }) => { + if (!emitted && streamResult.content && !(streamResult.toolCalls?.length > 0)) { + emitted = true + queue.enqueue(JSON.stringify(createGeminiResponse(streamResult.content, null, null)) + "\n") + } const toolCalls = streamResult.toolCalls ?? [] const finalChunk = JSON.stringify( toolCalls.length > 0 @@ -1917,7 +2511,7 @@ export function createProxyFetchHandler(client) { .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" } }) + const errChunk = JSON.stringify({ error: { code: 502, message: "Upstream request failed.", status: "UNAVAILABLE" } }) queue.enqueue(errChunk + "\n") }) .finally(() => { @@ -1928,45 +2522,42 @@ export function createProxyFetchHandler(client) { 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), - }, + deferredCleanup = true + return streamResponse({ + ...commonHeaders(request, config), + "content-type": "application/x-ndjson; charset=utf-8", + }, generateNdJson(), { + onCancel: (reason) => context.abort(reason), + onDone: streamCleanup, }) } try { - const result = await executePrompt(client, body, model, messages, system, callerTools) + const executed = await executeWithFallback(candidates, (candidate) => + executePrompt(client, body, candidate, messages, system, callerTools, requestOptions)) + const result = executed.result const finish = result.completion.data.info?.finish const tokens = result.completion.data.info?.tokens return json(createGeminiResponse(result.content, finish, tokens, result.toolCalls), 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 badRequest(error instanceof ProxyError ? message : "Upstream request failed.", error.status ?? 502, request) } } - return text("Not found", 404, request) + return text("Not found", 404, request, config) + } finally { + if (!deferredCleanup) { + releaseSlot() + context.finish() + safeLog(client, "info", "Proxy request completed", { + method: request.method, + path: url.pathname, + durationMs: Date.now() - started, + }) + } + } } } @@ -1976,10 +2567,23 @@ export const OpenAIProxyPlugin = async ({ client }) => { return {} } - state.started = true - const hostname = process.env.OPENCODE_LLM_PROXY_HOST ?? "127.0.0.1" const port = Number.parseInt(process.env.OPENCODE_LLM_PROXY_PORT ?? "4010", 10) + let config + try { + config = loadConfig() + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ProxyError("Proxy port must be between 1 and 65535.", 500, "invalid_config") + const normalizedHost = hostname.replace(/^\[|\]$/g, "") + const loopback = normalizedHost === "localhost" || normalizedHost === "::1" || normalizedHost.startsWith("127.") || normalizedHost.startsWith("::ffff:127.") + if (!loopback && config.tokens.length === 0) { + throw new ProxyError("A bearer token is required when binding beyond loopback.", 500, "invalid_config") + } + } catch (error) { + await safeLog(client, "warn", "OpenAI proxy configuration is invalid", { + error: error instanceof Error ? error.message : String(error), + }) + return {} + } let server try { @@ -1998,6 +2602,7 @@ export const OpenAIProxyPlugin = async ({ client }) => { return {} } + state.started = true state.server = server await safeLog(client, "info", "OpenAI proxy server started", { @@ -2006,5 +2611,13 @@ export const OpenAIProxyPlugin = async ({ client }) => { protected: Boolean(process.env.OPENCODE_LLM_PROXY_TOKEN), }) - return {} + return { + "chat.params": async (input, output) => { + const controls = getState().generationControls?.get(input.sessionID) + if (!controls) return + if (controls.temperature !== undefined) output.temperature = controls.temperature + if (controls.topP !== undefined) output.topP = controls.topP + if (controls.topK !== undefined) output.topK = controls.topK + }, + } } diff --git a/index.test.js b/index.test.js index 49d5130..826c05f 100644 --- a/index.test.js +++ b/index.test.js @@ -39,8 +39,9 @@ import { dispatch, parseTools, runStdioServer } from "./mcp-tool-bridge.js" // shell or CI (e.g. OPENCODE_LLM_PROXY_TOKEN) must not change test outcomes. // Tests that need these set do so explicitly inside the test body. beforeEach(() => { - delete process.env.OPENCODE_LLM_PROXY_TOKEN - delete process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN + for (const name of Object.keys(process.env)) { + if (name.startsWith("OPENCODE_LLM_PROXY_")) delete process.env[name] + } }) // --------------------------------------------------------------------------- @@ -122,6 +123,8 @@ function parseSseStream(text) { } test("OPTIONS preflight returns CORS headers", async () => { + process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN = "https://app.example.com" + process.env.OPENCODE_LLM_PROXY_ALLOW_PRIVATE_NETWORK = "true" const handler = createProxyFetchHandler(createClient()) const request = new Request("http://127.0.0.1:4010/v1/models", { method: "OPTIONS", @@ -136,17 +139,18 @@ test("OPTIONS preflight returns CORS headers", async () => { const response = await handler(request) assert.equal(response.status, 204) - assert.equal(response.headers.get("access-control-allow-origin"), "*") - assert.equal(response.headers.get("access-control-allow-methods"), "POST") + assert.equal(response.headers.get("access-control-allow-origin"), "https://app.example.com") + assert.equal(response.headers.get("access-control-allow-methods"), "GET, POST, OPTIONS") assert.equal( response.headers.get("access-control-allow-headers"), - "authorization, content-type, x-opencode-provider", + "authorization, content-type, x-opencode-provider, x-opencode-variant, x-request-id", ) assert.equal(response.headers.get("access-control-allow-private-network"), "true") assert.equal(response.headers.get("access-control-max-age"), "86400") }) -test("health response includes CORS headers", async () => { +test("health response includes CORS headers for an allowed origin", async () => { + process.env.OPENCODE_LLM_PROXY_CORS_ORIGINS = '["*"]' const handler = createProxyFetchHandler(createClient()) const request = new Request("http://127.0.0.1:4010/health", { headers: { @@ -169,7 +173,7 @@ test("configured origin is returned for normal requests", async () => { const handler = createProxyFetchHandler(createClient()) const request = new Request("http://127.0.0.1:4010/health", { headers: { - Origin: "https://app.example.com", + Origin: "https://console.example.com", }, }) @@ -192,14 +196,36 @@ test("disallowed origin does not receive its own origin back", async () => { 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") + assert.equal(response.status, 403) + assert.equal(response.headers.get("access-control-allow-origin"), null) } finally { delete process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN } }) +test("invalid generation controls return 400 instead of throwing", async () => { + const handler = createProxyFetchHandler(createResponsesClient()) + const response = await handler(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: "hi", temperature: 99 }), + })) + assert.equal(response.status, 400) +}) + +test("remote media URLs are rejected to prevent SSRF", async () => { + const handler = createProxyFetchHandler(createResponsesClient()) + const response = await handler(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: [{ role: "user", content: [{ type: "input_image", image_url: "http://127.0.0.1/private" }] }], + }), + })) + assert.equal(response.status, 400) +}) + 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") @@ -207,11 +233,10 @@ test("request with no Origin header is handled gracefully", async () => { 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"), "*") + assert.equal(response.headers.get("access-control-allow-origin"), null) }) -test("OPTIONS preflight for disallowed origin returns configured origin, not request origin", async () => { +test("OPTIONS preflight for a disallowed origin returns 403 without allow-origin", async () => { process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN = "https://allowed.example.com" try { @@ -226,9 +251,8 @@ test("OPTIONS preflight for disallowed origin returns configured origin, not req 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") + assert.equal(response.status, 403) + assert.equal(response.headers.get("access-control-allow-origin"), null) } finally { delete process.env.OPENCODE_LLM_PROXY_CORS_ORIGIN } @@ -300,6 +324,18 @@ test("no token configured allows all requests through", async () => { assert.equal(response.status, 200) }) +test("any configured bearer token is accepted", async () => { + process.env.OPENCODE_LLM_PROXY_TOKENS = '["first-token","second-token"]' + const handler = createProxyFetchHandler(createClient()) + + for (const token of ["first-token", "second-token"]) { + const response = await handler(new Request("http://127.0.0.1:4010/health", { + headers: { Authorization: `Bearer ${token}` }, + })) + assert.equal(response.status, 200) + } +}) + // --------------------------------------------------------------------------- // Integration: /v1/chat/completions error handling // --------------------------------------------------------------------------- @@ -319,6 +355,44 @@ test("malformed JSON body returns 400", async () => { assert.equal(body.error.type, "invalid_request_error") }) +test("top-level null JSON returns 400", async () => { + const handler = createProxyFetchHandler(createClient()) + const response = await handler(new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "null", + })) + + assert.equal(response.status, 400) + assert.match((await response.json()).error.message, /JSON object/) +}) + +test("request body over the configured byte limit returns 413", async () => { + process.env.OPENCODE_LLM_PROXY_MAX_REQUEST_BYTES = "32" + const handler = createProxyFetchHandler(createClient()) + const response = await handler(new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "x", messages: [{ role: "user", content: "a".repeat(40) }] }), + })) + + assert.equal(response.status, 413) +}) + +test("responses include no-store and browser security headers", async () => { + const response = await createProxyFetchHandler(createClient())( + new Request("http://127.0.0.1:4010/health", { headers: { "x-request-id": "request-123" } }), + ) + + assert.equal(response.headers.get("cache-control"), "no-store") + assert.equal(response.headers.get("pragma"), "no-cache") + assert.equal(response.headers.get("x-content-type-options"), "nosniff") + assert.equal(response.headers.get("x-frame-options"), "DENY") + assert.equal(response.headers.get("referrer-policy"), "no-referrer") + assert.equal(response.headers.get("content-security-policy"), "default-src 'none'; frame-ancestors 'none'") + assert.equal(response.headers.get("x-request-id"), "request-123") +}) + test("missing model field returns 400", async () => { const handler = createProxyFetchHandler(createClient()) const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { @@ -393,7 +467,7 @@ test("stream: true returns SSE response", async () => { assert.ok(text.includes("[DONE]")) }) -test("stream: true with unknown model returns 502", async () => { +test("stream: true with unknown model returns a safe 400", async () => { const handler = createProxyFetchHandler(createClient()) // no providers const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { method: "POST", @@ -408,8 +482,8 @@ test("stream: true with unknown model returns 502", async () => { const response = await handler(request) const body = await response.json() - assert.equal(response.status, 502) - assert.ok(body.error.message.includes("nonexistent-model")) + assert.equal(response.status, 400) + assert.equal(body.error.message, "The requested model is unavailable.") }) test("stream: true propagates session.error into the SSE stream", async () => { @@ -444,7 +518,7 @@ test("stream: true propagates session.error into the SSE stream", async () => { assert.ok(text.includes("[DONE]")) }) -test("unknown model returns 502", async () => { +test("unknown model returns a safe 400", async () => { const handler = createProxyFetchHandler(createClient()) // client returns no providers const request = new Request("http://127.0.0.1:4010/v1/chat/completions", { method: "POST", @@ -458,8 +532,8 @@ test("unknown model returns 502", async () => { const response = await handler(request) const body = await response.json() - assert.equal(response.status, 502) - assert.ok(body.error.message.includes("nonexistent-model")) + assert.equal(response.status, 400) + assert.equal(body.error.message, "The requested model is unavailable.") }) test("unknown route returns 404", async () => { @@ -615,19 +689,10 @@ describe("buildSystemPrompt", () => { assert.ok(result.includes("Return only the assistant")) }) - it("appends temperature hint when provided", () => { - const result = buildSystemPrompt([], { temperature: 0.7 }) - assert.ok(result.includes("0.7")) - }) - - it("appends max_completion_tokens hint when provided", () => { - const result = buildSystemPrompt([], { max_completion_tokens: 512 }) - assert.ok(result.includes("512")) - }) - - it("appends max_tokens hint when provided", () => { - const result = buildSystemPrompt([], { max_tokens: 256 }) - assert.ok(result.includes("256")) + it("does not turn generation controls into prompt hints", () => { + const baseline = buildSystemPrompt([], {}) + assert.equal(buildSystemPrompt([], { temperature: 0.7 }), baseline) + assert.equal(buildSystemPrompt([], { max_completion_tokens: 512, max_tokens: 256 }), baseline) }) it("ignores non-system roles", () => { @@ -932,6 +997,30 @@ test("GET /v1/models returns empty list when no providers configured", async () assert.deepEqual(body, { object: "list", data: [] }) }) +test("GET /v1/models exposes rich OpenCode model metadata", async () => { + const metadata = { + capabilities: { input: { text: true, image: true }, output: { text: true } }, + limit: { context: 128000, output: 4096 }, + cost: { input: 1, output: 2 }, + status: "active", + variants: { fast: { temperature: 0.2 } }, + } + const response = await createProxyFetchHandler(createModelsClient([ + { id: "openai", models: { "gpt-rich": { id: "gpt-rich", name: "Rich Model", ...metadata } } }, + ]))(new Request("http://127.0.0.1:4010/v1/models")) + const model = (await response.json()).data[0] + + assert.equal(model.root, "openai/gpt-rich") + assert.deepEqual(model.x_opencode, { + name: "Rich Model", + status: metadata.status, + capabilities: metadata.capabilities, + limits: metadata.limit, + variants: metadata.variants, + cost: metadata.cost, + }) +}) + test("GET /v1/models returns 500 when providers call throws", async () => { const client = { app: { log: async () => {} }, @@ -1009,6 +1098,96 @@ test("POST /v1/responses returns a well-formed response object", async () => { assert.equal(body.usage.total_tokens, 28) }) +test("completed sessions are deleted when the client supports deletion", async () => { + const client = createResponsesClient() + const deleted = [] + client.session.delete = async (request) => deleted.push(request) + + const response = await createProxyFetchHandler(client)(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: "hi" }), + })) + + assert.equal(response.status, 200) + assert.deepEqual(deleted, [{ path: { id: "sess-resp-1" } }]) +}) + +test("structured output schema is forwarded and structured data is extracted", async () => { + const client = createResponsesClient() + let promptBody + client.session.prompt = async ({ body }) => { + promptBody = body + return { + data: { + parts: [{ type: "text", text: "ignored" }], + info: { structured: { answer: 42 }, tokens: { input: 1, output: 1 }, finish: "stop" }, + }, + } + } + const schema = { type: "object", properties: { answer: { type: "number" } }, required: ["answer"] } + const response = await createProxyFetchHandler(client)(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: "answer", + text: { format: { type: "json_schema", schema } }, + }), + })) + const body = await response.json() + + assert.deepEqual(promptBody.format, { type: "json_schema", schema }) + assert.equal(body.output_text, '{"answer":42}') +}) + +test("OpenAI image content is forwarded as an OpenCode file part", async () => { + const client = createResponsesClient() + let parts + client.session.prompt = async ({ body }) => { + parts = body.parts + return { data: { parts: [{ type: "text", text: "seen" }], info: { tokens: {}, finish: "stop" } } } + } + const image = "data:image/png;base64,aGVsbG8=" + const response = await createProxyFetchHandler(client)(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: [{ role: "user", content: [{ type: "input_text", text: "describe" }, { type: "input_image", image_url: image }] }], + }), + })) + + assert.equal(response.status, 200) + assert.deepEqual(parts[1], { type: "file", mime: "image/png", url: image }) +}) + +test("model aliases fall back to the next target after an upstream failure", async () => { + process.env.OPENCODE_LLM_PROXY_MODEL_ALIASES = JSON.stringify({ smart: ["openai/first", "openai/second"] }) + const attempted = [] + const client = createResponsesClient("fallback worked") + client.config.providers = async () => ({ data: { providers: [{ id: "openai", models: { + first: { id: "first" }, + second: { id: "second" }, + } }] } }) + client.session.prompt = async ({ body }) => { + attempted.push(body.model.modelID) + if (body.model.modelID === "first") throw new Error("temporary upstream failure") + return { data: { parts: [{ type: "text", text: "fallback worked" }], info: { tokens: {}, finish: "stop" } } } + } + + const response = await createProxyFetchHandler(client)(new Request("http://127.0.0.1:4010/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "smart", input: "hi" }), + })) + const body = await response.json() + + assert.equal(response.status, 200) + assert.deepEqual(attempted, ["first", "second"]) + assert.equal(body.model, "openai/second") +}) + 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", { @@ -1052,7 +1231,7 @@ test("POST /v1/responses malformed JSON returns 400", async () => { assert.equal(response.status, 400) }) -test("POST /v1/responses unknown model returns 502", async () => { +test("POST /v1/responses unknown model returns a safe 400", async () => { const handler = createProxyFetchHandler(createModelsClient([])) // no providers const request = new Request("http://127.0.0.1:4010/v1/responses", { method: "POST", @@ -1063,8 +1242,8 @@ test("POST /v1/responses unknown model returns 502", async () => { const response = await handler(request) const body = await response.json() - assert.equal(response.status, 502) - assert.ok(body.error.message.includes("nonexistent")) + assert.equal(response.status, 400) + assert.equal(body.error.message, "The requested model is unavailable.") }) test("POST /v1/responses instructions field is incorporated", async () => { @@ -1900,7 +2079,7 @@ test("POST /v1beta/models/:model:streamGenerateContent returns NDJSON stream", a const response = await handler(request) assert.equal(response.status, 200) - assert.ok(response.headers.get("content-type")?.includes("application/json")) + assert.ok(response.headers.get("content-type")?.includes("application/x-ndjson")) const text = await response.text() // Should contain NDJSON lines with candidates @@ -2511,6 +2690,29 @@ describe("buildToolsMap / registerToolBridge slot isolation", () => { assert.ok(bridge.slotName) after(() => releaseToolBridge(bridge)) }) + + it("releases the bridge slot when event subscription fails", async () => { + const state = globalThis.__opencodeOpenAIProxyState + state.toolBridge = { freeSlots: ["px_tools_0"], waiters: [], slotToolIDs: new Map() } + const client = createToolCallClient({ toolName: "flaky", toolArgs: {} }) + client.event.subscribe = async () => { + throw new Error("subscribe failed") + } + client.session.delete = async () => {} + + const response = await createProxyFetchHandler(client)(new Request("http://127.0.0.1:4010/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-4o", + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "flaky" } }], + }), + })) + + assert.equal(response.status, 502) + assert.deepEqual(state.toolBridge.freeSlots, ["px_tools_0"]) + }) }) test("POST /v1beta/models/:model:generateContent returns a functionCall part", async () => { @@ -2849,7 +3051,7 @@ test("POST /v1/responses stream emits parallel function_call items with distinct assert.equal(doneEvents.length, 2) assert.deepEqual( doneEvents.map((e) => e.data.output_index), - [1, 2], + [0, 1], ) assert.deepEqual( doneEvents.map((e) => e.data.item.name), @@ -2989,7 +3191,7 @@ describe("mcp-tool-bridge parseTools", () => { describe("mcp-tool-bridge dispatch", () => { it("responds to initialize with server info and the requested protocol version", () => { - const response = dispatch({ id: 1, method: "initialize", params: { protocolVersion: "2025-01-01" } }) + const response = dispatch({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-01-01" } }) assert.equal(response.jsonrpc, "2.0") assert.equal(response.id, 1) assert.equal(response.result.protocolVersion, "2025-01-01") @@ -2998,7 +3200,7 @@ describe("mcp-tool-bridge dispatch", () => { }) it("falls back to the default protocol version when none is supplied", () => { - const response = dispatch({ id: 1, method: "initialize" }) + const response = dispatch({ jsonrpc: "2.0", id: 1, method: "initialize" }) assert.equal(response.result.protocolVersion, "2024-11-05") }) @@ -3007,7 +3209,7 @@ describe("mcp-tool-bridge dispatch", () => { { name: "get_weather", description: "Get weather", parameters: { type: "object", properties: { city: {} } } }, { name: "no_desc" }, ] - const response = dispatch({ id: 2, method: "tools/list" }, tools) + const response = dispatch({ jsonrpc: "2.0", id: 2, method: "tools/list" }, tools) assert.deepEqual(response.result.tools, [ { name: "get_weather", description: "Get weather", inputSchema: { type: "object", properties: { city: {} } } }, { name: "no_desc", description: "", inputSchema: { type: "object", properties: {} } }, @@ -3015,37 +3217,45 @@ describe("mcp-tool-bridge dispatch", () => { }) it("returns an empty tools list when no tools are configured", () => { - const response = dispatch({ id: 3, method: "tools/list" }) + const response = dispatch({ jsonrpc: "2.0", id: 3, method: "tools/list" }) assert.deepEqual(response.result.tools, []) }) it("responds to ping with an empty result", () => { - const response = dispatch({ id: 4, method: "ping" }) + const response = dispatch({ jsonrpc: "2.0", id: 4, method: "ping" }) assert.deepEqual(response.result, {}) }) it("returns a placeholder text content for tools/call", () => { - const response = dispatch({ id: 5, method: "tools/call", params: { name: "x" } }) + const response = dispatch({ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "x" } }) assert.equal(response.result.content[0].type, "text") assert.match(response.result.content[0].text, /intercepted by opencode-llm-proxy/) }) it("returns null (no response) for notifications/initialized", () => { - assert.equal(dispatch({ method: "notifications/initialized" }), null) + assert.equal(dispatch({ jsonrpc: "2.0", method: "notifications/initialized" }), null) }) it("returns null for a request without an id", () => { - assert.equal(dispatch({ method: "ping" }), null) + assert.equal(dispatch({ jsonrpc: "2.0", method: "ping" }), null) }) it("returns a JSON-RPC method-not-found error for unknown methods", () => { - const response = dispatch({ id: 6, method: "does/not/exist" }) + const response = dispatch({ jsonrpc: "2.0", id: 6, method: "does/not/exist" }) assert.equal(response.error.code, -32601) assert.match(response.error.message, /Method not found: does\/not\/exist/) }) it("does not emit an error response for an unknown method without an id", () => { - assert.equal(dispatch({ method: "does/not/exist" }), null) + assert.equal(dispatch({ jsonrpc: "2.0", method: "does/not/exist" }), null) + }) + + it("rejects requests without jsonrpc 2.0", () => { + assert.deepEqual(dispatch({ id: 1, method: "ping" }), { + jsonrpc: "2.0", + id: null, + error: { code: -32600, message: "Invalid Request" }, + }) }) }) @@ -3088,9 +3298,10 @@ describe("mcp-tool-bridge runStdioServer", () => { .filter(Boolean) .map((line) => JSON.parse(line)) - assert.equal(lines.length, 2) + assert.equal(lines.length, 3) assert.deepEqual(lines[0], { jsonrpc: "2.0", id: 1, result: {} }) assert.equal(lines[1].result.tools[0].name, "get_weather") + assert.deepEqual(lines[2], { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }) assert.match(readError(), /failed to parse message/) assert.equal(ended, true) }) @@ -3151,7 +3362,7 @@ describe("OpenAIProxyPlugin", () => { try { const result = await OpenAIProxyPlugin({ client: createClient() }) - assert.deepEqual(result, {}) + assert.equal(typeof result["chat.params"], "function") assert.equal(calls.length, 1) assert.equal(calls[0].hostname, "127.0.0.1") assert.equal(calls[0].port, 4999) @@ -3166,6 +3377,41 @@ describe("OpenAIProxyPlugin", () => { ) }) + it("chat.params applies captured generation controls", async () => { + let hooks + let releasePrompt + const promptStarted = new Promise((resolve) => { + releasePrompt = resolve + }) + await withMockedBun( + () => ({}), + async () => { + hooks = await OpenAIProxyPlugin({ client: createClient() }) + const client = createResponsesClient() + client.session.prompt = async () => { + releasePrompt() + await delay(20) + return { data: { parts: [{ type: "text", text: "ok" }], info: { tokens: {}, finish: "stop" } } } + } + const responsePromise = createProxyFetchHandler(client)(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: "hi", + temperature: 0.4, + top_p: 0.8, + }), + })) + await promptStarted + const output = {} + await hooks["chat.params"]({ sessionID: "sess-resp-1" }, output) + assert.deepEqual(output, { temperature: 0.4, topP: 0.8 }) + await responsePromise + }, + ) + }) + it("does not start a second server when already started", async () => { let served = false @@ -3197,4 +3443,40 @@ describe("OpenAIProxyPlugin", () => { }, ) }) + + it("retries startup after Bun.serve fails", async () => { + let attempts = 0 + await withMockedBun( + () => { + attempts++ + if (attempts === 1) throw new Error("temporary failure") + return { started: true } + }, + async () => { + assert.deepEqual(await OpenAIProxyPlugin({ client: createClient() }), {}) + const hooks = await OpenAIProxyPlugin({ client: createClient() }) + assert.equal(attempts, 2) + assert.equal(typeof hooks["chat.params"], "function") + }, + ) + }) + + it("refuses a non-loopback bind without a token", async () => { + let served = false + await withMockedBun( + () => { + served = true + return {} + }, + async () => { + process.env.OPENCODE_LLM_PROXY_HOST = "0.0.0.0" + try { + assert.deepEqual(await OpenAIProxyPlugin({ client: createClient() }), {}) + assert.equal(served, false) + } finally { + delete process.env.OPENCODE_LLM_PROXY_HOST + } + }, + ) + }) }) diff --git a/mcp-tool-bridge.js b/mcp-tool-bridge.js index 4ea1853..6afc207 100644 --- a/mcp-tool-bridge.js +++ b/mcp-tool-bridge.js @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { Buffer } from "node:buffer" + // Minimal MCP (Model Context Protocol) stdio server used internally by opencode-llm-proxy // to expose a proxy caller's OpenAI/Anthropic/Gemini tool schemas to OpenCode as if they // were real MCP tools. @@ -37,7 +39,14 @@ function error(id, code, message) { // returns the response object to send, or null when no response is expected // (notifications, or requests without an id). export function dispatch(message, tools = []) { + if (!message || typeof message !== "object" || Array.isArray(message)) { + return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request" } } + } + const { id, method, params } = message ?? {} + if (message.jsonrpc !== "2.0" || typeof method !== "string") { + return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request" } } + } switch (method) { case "initialize": @@ -85,31 +94,75 @@ export function runStdioServer(tools, options = {}) { const output = options.output ?? process.stdout const errorOutput = options.errorOutput ?? process.stderr const onEnd = options.onEnd ?? (() => process.exit(0)) + const configuredMax = Number(options.maxFrameBytes ?? process.env.OPENCODE_LLM_PROXY_BRIDGE_MAX_FRAME_BYTES) + const maxFrameBytes = Number.isSafeInteger(configuredMax) && configuredMax > 0 ? configuredMax : 1024 * 1024 function send(message) { output.write(JSON.stringify(message) + "\n") } + function sendError(code, message) { + send({ jsonrpc: "2.0", id: null, error: { code, message } }) + } + + function processFrame(frame) { + const line = frame.trim() + if (!line) return + let message + try { + message = JSON.parse(line) + } catch (err) { + sendError(-32700, "Parse error") + errorOutput.write(`opencode-llm-proxy bridge: failed to parse message: ${err}\n`) + return + } + const response = dispatch(message, tools) + if (response) send(response) + } + let buffer = "" + let bufferBytes = 0 + let oversized = false input.setEncoding("utf8") input.on("data", (chunk) => { - buffer += chunk - let newlineIndex - while ((newlineIndex = buffer.indexOf("\n")) !== -1) { - const line = buffer.slice(0, newlineIndex).trim() - buffer = buffer.slice(newlineIndex + 1) - if (!line) continue - try { - const message = JSON.parse(line) - const response = dispatch(message, tools) - if (response) send(response) - } catch (err) { - errorOutput.write(`opencode-llm-proxy bridge: failed to parse message: ${err}\n`) + let start = 0 + for (let newlineIndex; (newlineIndex = chunk.indexOf("\n", start)) !== -1; start = newlineIndex + 1) { + const part = chunk.slice(start, newlineIndex) + if (!oversized) { + const partBytes = Buffer.byteLength(part) + if (bufferBytes + partBytes > maxFrameBytes) { + oversized = true + buffer = "" + bufferBytes = 0 + sendError(-32600, "Invalid Request") + } else { + processFrame(buffer + part) + } + } + buffer = "" + bufferBytes = 0 + oversized = false + } + + const part = chunk.slice(start) + if (!oversized) { + const partBytes = Buffer.byteLength(part) + if (bufferBytes + partBytes > maxFrameBytes) { + oversized = true + buffer = "" + bufferBytes = 0 + sendError(-32600, "Invalid Request") + } else { + buffer += part + bufferBytes += partBytes } } }) - input.on("end", onEnd) + input.on("end", () => { + if (!oversized) processFrame(buffer) + onEnd() + }) } // Only start the stdio server when executed directly (`node mcp-tool-bridge.js`), diff --git a/package-lock.json b/package-lock.json index 3e04aa4..15791a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,12 @@ "@eslint/js": "^10.0.1", "eslint": "^10.1.0" }, + "engines": { + "bun": ">=1.0.0", + "node": ">=20.19.0" + }, "peerDependencies": { - "opencode-ai": "*" + "opencode-ai": ">=1.0.0 <2" } }, "node_modules/@eslint-community/eslint-utils": { @@ -230,14 +234,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/cross-spawn": { diff --git a/package.json b/package.json index 658803e..0025c68 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,19 @@ "main": "index.js", "type": "module", "engines": { + "node": ">=20.19.0", "bun": ">=1.0.0" }, + "files": [ + "index.js", + "mcp-tool-bridge.js", + "README.md", + "LICENSE", + "docs/" + ], "scripts": { "test": "node --test --experimental-test-coverage", - "lint": "eslint .", - "start": "node index.js" + "lint": "eslint ." }, "keywords": [ "opencode", @@ -49,7 +56,7 @@ "url": "https://github.com/KochC/opencode-llm-proxy.git" }, "peerDependencies": { - "opencode-ai": "*" + "opencode-ai": ">=1.0.0 <2" }, "devDependencies": { "@eslint/js": "^10.0.1",