opencode-plugboard 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
30
30
  {
31
31
  "plugins": [
32
32
  {
33
- "package": "opencode-plugboard@0.15.0",
33
+ "package": "opencode-plugboard@0.16.0",
34
34
  "options": {
35
35
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
36
36
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
@@ -37,10 +37,14 @@
37
37
  "@ai-sdk/anthropic": "4.0.78",
38
38
  "@ai-sdk/google": "4.0.93",
39
39
  "@ai-sdk/openai": "4.0.91",
40
+ "@ai-sdk/openai-compatible": "3.0.67",
40
41
  "@anthropic-ai/sdk": "0.133.0",
41
42
  "@commitlint/cli": "21.2.3",
42
43
  "@commitlint/config-conventional": "21.2.3",
43
44
  "@google/genai": "2.28.0",
45
+ "@langchain/anthropic": "1.5.12",
46
+ "@langchain/google-genai": "2.3.2",
47
+ "@langchain/openai": "1.6.2",
44
48
  "@opencode/plugin": "2.0.26",
45
49
  "@types/bun": "1.4.2",
46
50
  "@types/escape-html": "^1.0.4",
@@ -21,6 +21,7 @@ import {
21
21
  checkJsonContentType,
22
22
  checkOrigin,
23
23
  readJsonBody,
24
+ sendError,
24
25
  sendJson,
25
26
  sseWriter,
26
27
  type RequestScope,
@@ -65,7 +66,7 @@ const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
65
66
 
66
67
  export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
67
68
  const { path, request, response, scope, info } = call
68
- const fail = (error: PlugError, close = false) => sendJson(response, error.status, encodeError(error), { close })
69
+ const fail = (error: PlugError, close = false) => sendError(response, error, encodeError(error), { close })
69
70
  if (request.method === "POST") info.route = `POST ${path}`
70
71
  response.setHeader("request-id", `req_${crypto.randomUUID().replaceAll("-", "")}`)
71
72
 
@@ -84,13 +85,14 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
84
85
  }
85
86
 
86
87
  async function serve(): Promise<void> {
87
- if (request.method !== "POST")
88
- return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
89
88
  const rejected = checkOrigin(request, host.options)
90
89
  if (rejected) return fail(rejected)
90
+ // Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
91
91
  const auth = authenticateRequest(host, request)
92
92
  if (isPlugError(auth)) return fail(auth)
93
93
  info.app = auth.app
94
+ if (request.method !== "POST")
95
+ return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
94
96
  if (path === COUNT_TOKENS)
95
97
  return fail({ status: 404, kind: "not_found", code: "not_found", message: "count_tokens is not supported by plugboard" })
96
98
  const unsupported = checkJsonContentType(request)
@@ -44,7 +44,7 @@ const encodeUsage = (usage: PlugUsage) => {
44
44
  const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
45
45
 
46
46
  /** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
47
- async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object }> {
47
+ async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
48
48
  let text = ""
49
49
  let finish: Exclude<PlugFinish, "error"> | undefined
50
50
  let usage: PlugUsage | undefined
@@ -63,7 +63,7 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
63
63
  if (!failure && !finish) failure = noFinish()
64
64
  if (failure || !finish) {
65
65
  const error = failure ?? noFinish()
66
- return { status: error.status, body: encodeError(error) }
66
+ return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
67
67
  }
68
68
  return {
69
69
  status: 200,
@@ -12,7 +12,7 @@
12
12
  import type { ServerResponse } from "node:http"
13
13
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
14
14
  import type { ServedInfo } from "../fallback.js"
15
- import { sendJson, type RequestScope } from "../http.js"
15
+ import { sendError, type RequestScope } from "../http.js"
16
16
  import { resolveModel } from "../models.js"
17
17
  import { serveChoices } from "../openai/fanout.js"
18
18
  import { encodeError } from "../openai/response.js"
@@ -33,7 +33,7 @@ export interface CompletionsCall {
33
33
  }
34
34
 
35
35
  export async function serveCompletions(host: RouteHost, { app, body, response, scope, info }: CompletionsCall): Promise<void> {
36
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
36
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
37
37
 
38
38
  const parsed = parseCompletionsRequest(body, host.options)
39
39
  if (isPlugError(parsed)) return fail(parsed)
package/src/contract.ts CHANGED
@@ -153,7 +153,7 @@ export interface PlugError {
153
153
  readonly message: string
154
154
  readonly code?: string
155
155
  readonly param?: string
156
- /** Seconds to wait before a retry, for a `Retry-After` header. Set by the limiter (`src/limits.ts`); no route reads it yet. */
156
+ /** Seconds to wait before a retry, sent as the `Retry-After` header (`sendError` in `src/http.ts`). Set by the limiter (`src/limits.ts`). */
157
157
  readonly retryAfterSeconds?: number
158
158
  }
159
159
 
package/src/discovery.ts CHANGED
@@ -58,18 +58,30 @@ export interface Endpoint {
58
58
  readonly description: string
59
59
  }
60
60
 
61
- function endpoints(options: PlugboardOptions): Endpoint[] {
62
- return [
63
- { method: "GET", path: "/", auth: false, description: "This page." },
64
- { method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
65
- { method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
66
- { method: "GET", path: "/v1/models", auth: true, description: "Models this app may call." },
67
- { method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
68
- { method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
69
- { method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
70
- { method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
71
- ...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
72
- ]
61
+ /**
62
+ * Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
63
+ * `src/openai/route.ts` and the front-end routes; `test/http/discovery.test.ts` probes each served
64
+ * route and fails when this list omits one (#136). `/metrics` is added only when option `metrics` is on.
65
+ */
66
+ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
67
+ { method: "GET", path: "/", auth: false, description: "This page." },
68
+ { method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
69
+ { method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
70
+ { method: "GET", path: "/v1/models", auth: true, description: "Models this app may call (OpenAI shape)." },
71
+ { method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
72
+ { method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
73
+ { method: "POST", path: "/v1/completions", auth: true, description: "Legacy OpenAI completions." },
74
+ { method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
75
+ { method: "POST", path: "/v1/messages", auth: true, description: "Anthropic Messages (x-api-key or Bearer)." },
76
+ { method: "GET", path: "/v1beta/models", auth: true, description: "Gemini model list." },
77
+ { method: "GET", path: "/v1beta/models/{model}", auth: true, description: "One Gemini model." },
78
+ { method: "POST", path: "/v1beta/models/{model}:generateContent", auth: true, description: "Gemini generateContent." },
79
+ { method: "POST", path: "/v1beta/models/{model}:streamGenerateContent", auth: true, description: "Gemini streamGenerateContent, with ?alt=sse." },
80
+ { method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
81
+ ]
82
+
83
+ function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
84
+ return options.metrics ? [...ENDPOINTS, { method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : ENDPOINTS
73
85
  }
74
86
 
75
87
  type ModelsState = "ready" | "no_models_configured" | "none_available" | "catalog_unavailable"
@@ -17,7 +17,7 @@
17
17
  import type { IncomingMessage, ServerResponse } from "node:http"
18
18
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
19
19
  import { reportServed, type ServedInfo } from "../fallback.js"
20
- import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
20
+ import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
21
21
  import { listAvailableModels, resolveModel } from "../models.js"
22
22
  import { reportCache } from "../cache.js"
23
23
  import { reportPolicy } from "../policy.js"
@@ -64,10 +64,10 @@ export function matchGeminiRoute(method: string | undefined, path: string): Gemi
64
64
  const verb = colon > 0 ? rest.slice(colon + 1) : ""
65
65
  const model = decode(rest.slice(0, colon))
66
66
  if (!/^[A-Za-z]+$/.test(verb) || model === undefined) return undefined
67
- const label = `POST ${prefix.slice(0, -1)}/{model}:${verb}`
68
67
  if (verb === "generateContent" || verb === "streamGenerateContent")
69
- return { op: "generate", route: label, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
70
- return { op: "unsupported", route: label, verb }
68
+ return { op: "generate", route: `POST ${prefix.slice(0, -1)}/{model}:${verb}`, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
69
+ // The client's verb never becomes a metric or log label: every other verb is `unknown` (#135).
70
+ return { op: "unsupported", route: "unknown", verb }
71
71
  }
72
72
  return undefined
73
73
  }
@@ -95,7 +95,7 @@ export interface GeminiCall {
95
95
  }
96
96
 
97
97
  export async function serveGemini(host: RouteHost, { match, request, url, app, response, scope, info }: GeminiCall): Promise<void> {
98
- const fail = (error: PlugError) => sendJson(response, error.status, encodeGeminiError(error))
98
+ const fail = (error: PlugError) => sendError(response, error, encodeGeminiError(error))
99
99
 
100
100
  if (match.op === "unsupported")
101
101
  return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent and streamGenerateContent` })
package/src/http.ts CHANGED
@@ -165,18 +165,29 @@ export function finishRequest(request: IncomingMessage, response: ServerResponse
165
165
  response.once("close", () => clearTimeout(timer))
166
166
  }
167
167
 
168
- /** `close`: also tell the client to drop the connection (after an unread or abandoned body). */
169
- export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean }) => {
168
+ /**
169
+ * `close`: also tell the client to drop the connection (after an unread or abandoned body).
170
+ * `retryAfterSeconds`: sent as `Retry-After` (a rate-limit refusal, see `PlugError.retryAfterSeconds`).
171
+ */
172
+ export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean; retryAfterSeconds?: number }) => {
170
173
  if (response.headersSent || response.destroyed) return
171
174
  const body = JSON.stringify(payload)
172
175
  response.writeHead(status, {
173
176
  "content-type": "application/json",
174
177
  "content-length": Buffer.byteLength(body),
175
178
  ...(options?.close ? { connection: "close" } : {}),
179
+ ...(options?.retryAfterSeconds !== undefined ? { "retry-after": String(options.retryAfterSeconds) } : {}),
176
180
  })
177
181
  response.end(body)
178
182
  }
179
183
 
184
+ /**
185
+ * A `PlugError` answered in a front-end's own wire body, before any output. Every route's `fail` goes
186
+ * through here, so a refused call (`PlugError.retryAfterSeconds`) always carries `Retry-After`.
187
+ */
188
+ export const sendError = (response: ServerResponse, error: PlugError, body: unknown, options?: { close?: boolean }) =>
189
+ sendJson(response, error.status, body, { ...options, retryAfterSeconds: error.retryAfterSeconds })
190
+
180
191
  /**
181
192
  * SSE response writer. Headers go out with the first chunk, so nothing is committed until there
182
193
  * is something to send; chunks after the client is gone are dropped. `write` is synchronous (the
@@ -25,7 +25,7 @@ import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.j
25
25
  import { reportServed, type ServedInfo } from "../fallback.js"
26
26
  import { reportCache } from "../cache.js"
27
27
  import { reportPolicy } from "../policy.js"
28
- import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
28
+ import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
29
29
  import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
30
30
  import type { RouteHost } from "./route.js"
31
31
 
@@ -38,7 +38,7 @@ export interface ChoiceCodec {
38
38
  /** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
39
39
  readonly idPrefix: string
40
40
  /** One choice as a complete JSON body with `choices[0]` and `usage`. */
41
- collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object }>
41
+ collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }>
42
42
  /** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
43
43
  streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
44
44
  /** The final chunk with the summed usage (`include_usage`). */
@@ -155,7 +155,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
155
155
  }
156
156
 
157
157
  export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
158
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
158
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
159
159
  // Abandoned requests (client gone, deadline passed) must never reach the model caller.
160
160
  if (scope.signal.aborted) throw scope.signal.reason
161
161
 
@@ -190,8 +190,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
190
190
  )
191
191
 
192
192
  if (!codec.stream) {
193
- const results = await new Promise<Array<{ status: number; body: object }>>((resolve, reject) => {
194
- const done: Array<{ status: number; body: object }> = []
193
+ const results = await new Promise<Array<{ status: number; body: object; retryAfterSeconds?: number }>>((resolve, reject) => {
194
+ const done: Array<{ status: number; body: object; retryAfterSeconds?: number }> = []
195
195
  let left = calls.length
196
196
  calls.forEach((_, index) => {
197
197
  codec.collect(open(index), index).then(
@@ -211,7 +211,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
211
211
  })
212
212
  })
213
213
  const first = results[0]!
214
- return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)))
214
+ return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
215
215
  }
216
216
 
217
217
  const sse = sseWriter(response, scope.signal)
@@ -195,7 +195,7 @@ const now = () => Math.floor(Date.now() / 1000)
195
195
  export async function collectCompletion(
196
196
  events: AsyncIterable<PlugEvent>,
197
197
  exchange: OpenAiExchange,
198
- ): Promise<{ status: number; body: object }> {
198
+ ): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
199
199
  let text = ""
200
200
  const calls: ToolCall[] = []
201
201
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -217,7 +217,7 @@ export async function collectCompletion(
217
217
  if (!failure && !finish) failure = noFinish()
218
218
  if (failure || !finish) {
219
219
  const error = failure ?? noFinish()
220
- return { status: error.status, body: encodeError(error) }
220
+ return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
221
221
  }
222
222
 
223
223
  const answer = calls.find((call) => structured.isAnswer(exchange, call))
@@ -32,6 +32,7 @@ import {
32
32
  checkOrigin,
33
33
  finishRequest,
34
34
  readJsonBody,
35
+ sendError,
35
36
  sendJson,
36
37
  sseWriter,
37
38
  type RequestScope,
@@ -41,7 +42,7 @@ import { encodeGeminiError } from "../gemini/response.js"
41
42
  import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/route.js"
42
43
  import { errorMessage, log, logError, withRequestId } from "../log.js"
43
44
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
44
- import { listAvailableModels, resolveModel } from "../models.js"
45
+ import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
45
46
  import { reportCache } from "../cache.js"
46
47
  import { reportPolicy } from "../policy.js"
47
48
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
@@ -119,7 +120,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
119
120
  /** Responses to Gemini routes: errors there use Google's body. */
120
121
  const googleErrors = new WeakSet<ServerResponse>()
121
122
  const fail = (response: ServerResponse, error: PlugError, close = false) =>
122
- sendJson(response, error.status, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
123
+ sendError(response, error, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
123
124
 
124
125
  /** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
125
126
  async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
@@ -198,8 +199,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
198
199
  }
199
200
 
200
201
  if (route === "GET /v1/models" || modelId !== undefined) {
202
+ // `?capability=vision|tools` filters the list (#61). Any other value is refused before the catalog loads.
203
+ const requested = modelId === undefined ? url.searchParams.get("capability") : null
204
+ const capability = requested === null ? undefined : parseCapability(requested)
205
+ if (requested !== null && capability === undefined)
206
+ return fail(response, { status: 400, kind: "invalid_request", code: "invalid_capability", param: "capability", message: "capability must be vision or tools" })
201
207
  const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), auth.app)
202
- if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
208
+ if (modelId === undefined) return sendJson(response, 200, encodeModelList(capability ? filterByCapability(listed, capability) : listed))
203
209
  const item = listed.find((m) => m.id === modelId)
204
210
  if (!item) {
205
211
  return fail(response, {
@@ -277,7 +283,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
277
283
  }
278
284
 
279
285
  const result = await collectCompletion(events, exchange)
280
- sendJson(response, result.status, result.body)
286
+ sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
281
287
  }
282
288
 
283
289
  /** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
@@ -11,7 +11,7 @@
11
11
  import type { ServerResponse } from "node:http"
12
12
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
13
  import { reportServed, type ServedInfo } from "../fallback.js"
14
- import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
14
+ import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
15
15
  import { resolveModel } from "../models.js"
16
16
  import { reportCache } from "../cache.js"
17
17
  import { reportPolicy } from "../policy.js"
@@ -32,7 +32,7 @@ export interface ResponsesCall {
32
32
  }
33
33
 
34
34
  export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
35
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
35
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
36
36
 
37
37
  const parsed = parseResponsesRequest(body, host.options)
38
38
  if (isPlugError(parsed)) return fail(parsed)