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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
36 changes: 34 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,14 +33,15 @@ jobs:
node-version: "22"

- name: Install dev dependencies
run: npm install
run: npm ci

- name: Run lint
run: npm run lint

test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
timeout-minutes: 15

strategy:
matrix:
Expand All @@ -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
24 changes: 24 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.DS_Store
node_modules/
.env
.env.*
!.env.example
!.env.*.example
promotion.md
docs/promotion-drafts/
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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

Expand All @@ -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

---
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/open-webui-docker/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
13 changes: 12 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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.
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export default [
URL: "readonly",
TextEncoder: "readonly",
ReadableStream: "readonly",
AbortController: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
},
},
rules: {
Expand Down
Loading