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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ curl -o .opencode/plugins/llm-proxy.js \
| `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_TOOL_BRIDGE_MAX_QUEUE` | `32` | Maximum tool-calling requests waiting for a bridge slot, from 0 to 10,000; excess requests receive `429`. |
| `OPENCODE_LLM_PROXY_KEEP_SESSIONS` | `false` | Set to `true` to retain temporary OpenCode sessions; otherwise they are deleted after use. |
| `OPENCODE_LLM_PROXY_KEEP_SESSIONS` | `false` | Set to `true` to retain temporary OpenCode sessions; otherwise they are deleted after use. Kept sessions are still swept after 24h idle (see [Session lifecycle](#session-lifecycle-safe-teardown)). |
| `OPENCODE_LLM_PROXY_MODEL_ALIASES` | `{}` | JSON object mapping aliases to a model ID string or ordered array of fallback model IDs. |
| `OPENCODE_LLM_PROXY_METRICS_ENABLED` | `false` | Set to `true` to expose the authenticated Prometheus endpoint at `GET /metrics`. |
| `OPENCODE_LLM_PROXY_REMOTE_MEDIA_ENABLED` | `false` | Set to `true` to fetch remote media URLs and convert them to embedded data URLs. Leave disabled unless required. |
Expand All @@ -206,6 +206,15 @@ Use `x-opencode-variant` to select an OpenCode model variant for a request. The

Generation `temperature`, top-p (`top_p`/`topP`), and top-k (`topK`) values are validated and applied through the plugin's `chat.params` hook. Maximum-token fields (`max_tokens`, `max_completion_tokens`, `max_output_tokens`, and Gemini `maxOutputTokens`) are accepted where clients require them, but the current OpenCode SDK cannot enforce them. OpenAI and Anthropic requests reject unsupported controls (`stop`, `seed`, `frequency_penalty`, `presence_penalty`, `logprobs`, and `n`) with `400` instead of silently ignoring them.

### Session lifecycle (safe teardown)

Each proxied request runs in a throwaway OpenCode session titled `Proxy: <model-id>` (**"Proxy: " is a reserved title prefix** — do not name your own sessions with it). When a request completes normally, the session is deleted immediately. When a request is abandoned mid-turn (client disconnect, timeout, or error), the session is **intentionally left in place**: deleting it immediately races the OpenCode server's final persist of the aborted turn, which surfaces as `FOREIGN KEY constraint failed` errors in the server log (observed at thousands per day under bulk clients that cancel slow streams, e.g. RAG pipelines with aggressive timeouts).

Leaked sessions are reaped by a background sweep at plugin start and every 6 hours: `Proxy:`-titled sessions idle for more than 24 hours are deleted (long-idle deletes are race-free). Notes:

- The first start after upgrading to this behavior will burn down any previously leaked backlog in one pass.
- With `OPENCODE_LLM_PROXY_KEEP_SESSIONS=true`, kept sessions carry the same title and are subject to the same 24h reap — if you need a longer inspection window, inspect within a day or copy what you need.

```bash
OPENCODE_LLM_PROXY_HOST=0.0.0.0 \
OPENCODE_LLM_PROXY_TOKEN=my-secret \
Expand Down
114 changes: 106 additions & 8 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -621,15 +621,99 @@
}
}

async function deleteSession(client, sessionID, keepSessions) {
// Bound the best-effort delete: client.session.delete() accepts no abort signal
// in older SDK shapes, and on the happy path it runs INSIDE the request's
// finally — a hung SDK connection there would block the response forever and
// hold the concurrency slot (observed as a full limiter wedge: /models keeps
// answering while every completion hangs). Race it with a short timer; on
// timeout give up — sweepStaleProxySessions reaps the session later anyway.
// Read per-call (not at module load) so tests and runtime config changes apply.
function deleteTimeoutMs() {
return integerEnv("OPENCODE_LLM_PROXY_DELETE_TIMEOUT_MS", 5000, { min: 100, max: 60000 })
}

async function deleteSession(client, sessionID, keepSessions, signal) {
if (keepSessions || !sessionID || typeof client.session.delete !== "function") return
const attempt = client.session.delete({ path: { id: sessionID }, signal })
try {
await client.session.delete({ path: { id: sessionID } })
await Promise.race([
attempt,
new Promise((_, reject) => {
const timer = setTimeout(() => reject(new Error("session delete timed out")), deleteTimeoutMs())
timer.unref?.()
signal?.addEventListener("abort", () => {
clearTimeout(timer)
reject(new Error("session delete aborted"))
}, { once: true })
}),
])
} catch {
// Best-effort cleanup for compatibility with older OpenCode clients.
// Best-effort cleanup: gave up (timeout/abort) — the stale-session sweep reaps it.
}
}

// Safe teardown: sessions abandoned mid-turn (client abort, error, timeout) are
// intentionally leaked at abandon time and reaped by this sweep once they are
// long idle. Deleting them immediately races the server's final persist of the
// aborted turn: the delete commits while the turn's last message insert is still
// in flight, and the insert then fails with
// "FOREIGN KEY constraint failed: insert into message" against the deleted
// session row. Under bulk clients that cancel slow streams (RAG pipelines with
// aggressive timeouts, retry loops), this produced thousands of constraint
// errors per day. Deleting only settled sessions mirrors how long-lived SDK
// consumers (e.g. chat bots that keep one session per conversation) avoid the
// race entirely.
//
// NOTE: "Proxy: " is a reserved title prefix — this plugin titles its throwaway
// sessions `Proxy: <model-id>` at creation. Do not name real sessions with this
// prefix: idle ones get reaped here. Internal helper — exported for tests.
export async function sweepStaleProxySessions(client, maxAgeMs = 24 * 60 * 60 * 1000) {
if (typeof client.session.list !== "function") return
let sessions
try {
const result = await client.session.list()
sessions = result.data ?? []
} catch (error) {
await safeLog(client, "warn", "OpenAI proxy stale-session sweep could not list sessions", {
error: error instanceof Error ? error.message : String(error),
})
return
}
const cutoff = Date.now() - maxAgeMs
let reaped = 0
for (const s of sessions) {
if (!s?.title?.startsWith("Proxy:")) continue
const updated = Number(s.time_updated ?? s.timeUpdated ?? 0)
// Fail safe: only treat as a stale ms-epoch timestamp. Rejects seconds-epoch
// values, NaN, and missing fields (those sessions are left for the next sweep
// rather than risk mass-deleting fresh ones on a units mismatch).
if (!(updated > 1e12) || updated >= cutoff) continue
try {
await client.session.delete({ path: { id: s.id } })
reaped++
} catch {
// skip; a later sweep retries
}
}
if (reaped > 0) {
await safeLog(client, "info", "OpenAI proxy stale-session sweep", { reaped })
}
}

const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000

// Run the sweep at startup and every SWEEP_INTERVAL_MS so long-lived processes
// still reap leaked sessions. The timer is unref'd: it never keeps the process
// alive on its own.
function scheduleSessionSweeps(client) {
void sweepStaleProxySessions(client).catch(() => {})
const timer = setInterval(() => {

Check failure on line 710 in index.js

View workflow job for this annotation

GitHub Actions / Lint

'setInterval' is not defined
void sweepStaleProxySessions(client).catch(() => {})
}, SWEEP_INTERVAL_MS)
timer.unref?.()
return timer
}

function setGenerationControls(sessionID, controls) {
if (!sessionID || !controls || Object.keys(controls).length === 0) return
const state = getState()
Expand Down Expand Up @@ -665,6 +749,7 @@

const tools = await getDisabledTools(client)
let sessionID
let settled = false // safe teardown: only delete sessions whose turn fully settled
try {
const session = await client.session.create({ body: { title: `Proxy: ${model.id}` }, signal: options.signal })
sessionID = session.data.id
Expand All @@ -687,10 +772,14 @@

if (!content && completion.data.info?.error) throw new Error(completion.data.info.error.message ?? "Model call failed.")

settled = true
return { content, structured, toolCalls: [], completion, request: _request, sessionID }
} finally {
clearGenerationControls(sessionID)
await deleteSession(client, sessionID, options.keepSessions)
// Deleting an aborted/errored session here races the server's final persist
// of the aborted turn (FOREIGN KEY constraint failures under bulk cancels).
// Leak it instead; sweepStaleProxySessions reaps it once long idle.
if (settled) await deleteSession(client, sessionID, options.keepSessions, options.signal)
}
}

Expand Down Expand Up @@ -1276,8 +1365,10 @@
}
}
}
} catch (error) {

Check failure on line 1368 in index.js

View workflow job for this annotation

GitHub Actions / Lint

Unnecessary catch clause
await deleteSession(client, sessionID, options.keepSessions)
// Safe teardown: no delete on abort/error — it races the server's final
// persist of the aborted turn (FOREIGN KEY failures under bulk cancels).
// The session leaks inert; sweepStaleProxySessions reaps it once long idle.
throw error
} finally {
removeAbortListener()
Expand All @@ -1297,18 +1388,18 @@
}))

if (errorMessage && toolCalls.length === 0) {
await deleteSession(client, sessionID, options.keepSessions)
// Safe teardown: leak-on-error (was: delete then throw).
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.
let assistantEntry
try {

Check failure on line 1398 in index.js

View workflow job for this annotation

GitHub Actions / Lint

Unnecessary try/catch wrapper
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)
// Safe teardown: leak-on-error (was: delete then throw).
throw error
}
const assistantInfo = assistantEntry?.info
Expand All @@ -1330,7 +1421,10 @@
finish: toolCalls.length > 0 ? "tool_calls" : assistantInfo?.finish,
structured: assistantInfo?.structured,
}
await deleteSession(client, sessionID, options.keepSessions)
// Safe teardown: only delete when the turn completed without a server-side
// error (errorMessage with partial tool calls still lands here — the turn did
// not settle cleanly, so leak it for the sweep instead).
if (!errorMessage) await deleteSession(client, sessionID, options.keepSessions, options.signal)
return result
}

Expand Down Expand Up @@ -2754,6 +2848,10 @@
state.started = true
state.server = server

// Safe teardown: reap stale leaked "Proxy:" sessions now and periodically
// (fire-and-forget; never blocks or fails startup).
state.sweepTimer = scheduleSessionSweeps(client)

await safeLog(client, "info", "OpenAI proxy server started", {
hostname,
port,
Expand Down
4 changes: 3 additions & 1 deletion index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1173,7 +1173,9 @@
}))

assert.equal(response.status, 200)
assert.deepEqual(deleted, [{ path: { id: "sess-resp-1" } }])
assert.equal(deleted.length, 1)
assert.equal(deleted[0].path.id, "sess-resp-1")
assert.ok(deleted[0].signal instanceof AbortSignal, "delete carries the request signal")

Check failure on line 1178 in index.test.js

View workflow job for this annotation

GitHub Actions / Lint

'AbortSignal' is not defined
})

test("structured output schema is forwarded and structured data is extracted", async () => {
Expand Down
Loading
Loading