opencode-plugboard 0.21.2 → 0.23.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
@@ -3,6 +3,10 @@
3
3
  [![npm](https://img.shields.io/npm/v/opencode-plugboard)](https://www.npmjs.com/package/opencode-plugboard)
4
4
  [![license](https://img.shields.io/npm/l/opencode-plugboard)](https://github.com/jjjermiah/opencode-plugboard/blob/main/LICENSE)
5
5
  [![CI](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml/badge.svg)](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/jjjermiah/opencode-plugboard/badge)](https://scorecard.dev/viewer/?uri=github.com/jjjermiah/opencode-plugboard)
7
+ <!-- OpenSSF Best Practices: register at https://www.bestpractices.dev first (answers in docs/best-practices.md). Then replace <id> with the project id and uncomment the next line:
8
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/<id>/badge)](https://www.bestpractices.dev/projects/<id>)
9
+ -->
6
10
 
7
11
  Plugboard is an OpenCode V2 plugin. It turns your OpenCode server into one HTTP API that OpenAI, Anthropic and Gemini compatible apps can call with their own key.
8
12
 
@@ -43,7 +47,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
43
47
  {
44
48
  "plugins": [
45
49
  {
46
- "package": "opencode-plugboard@0.21.2",
50
+ "package": "opencode-plugboard@0.22.0",
47
51
  "options": {
48
52
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
49
53
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -125,7 +129,7 @@ Nothing is exposed by accident. A model is callable only if `models` lists it, a
125
129
  A model outside the list gets a 404:
126
130
 
127
131
  ```json
128
- {"error": {"message": "Model openai/gpt-5 is not available", "code": "model_not_found", "type": "not_found_error", "param": null}}
132
+ {"error": {"message": "Model openai/gpt-5 is not available", "code": "model_not_found", "type": "not_found_error", "param": "model"}}
129
133
  ```
130
134
 
131
135
  #### Per-app policy
@@ -229,7 +233,7 @@ retry-after: 12
229
233
  {"error": {"message": "Too many requests per minute", "code": "rate_limit_exceeded", "type": "rate_limit_error", "param": null}}
230
234
  ```
231
235
 
232
- A full concurrency cap returns `too_many_requests` with `retry-after: 1`. The limits live in the server process, so a restart clears them.
236
+ A full concurrency cap returns `too_many_requests` with `retry-after: 1`. The `code` is on the OpenAI and Gemini routes; the Anthropic `/v1/messages` error has a `type` and a `message` only (see [Anthropic Messages](https://jjjermiah.github.io/opencode-plugboard/messages)). The limits live in the server process, so a restart clears them.
233
237
 
234
238
  #### Response cache
235
239
 
@@ -390,7 +394,7 @@ A server shared by a Discord bot, n8n, Open WebUI and a coding agent, with most
390
394
  {
391
395
  "plugins": [
392
396
  {
393
- "package": "opencode-plugboard@0.21.2",
397
+ "package": "opencode-plugboard@0.22.0",
394
398
  "options": {
395
399
  // Reachable from the LAN. Non-loopback hosts refuse to start without keys.
396
400
  "listen": { "host": "10.0.1.50", "port": 4010 },
@@ -460,6 +464,17 @@ One page per client, with the config, a chat, a tool call, structured output and
460
464
  | pydantic-ai | [pydantic-ai][client-pydantic-ai] | pass with `OpenAIChatModel` |
461
465
  | Open WebUI | [open-webui][client-open-webui] | pass over its HTTP API; no browser, no structured output in its UI |
462
466
  | n8n | [n8n][client-n8n] | pass with "Use Responses API" off |
467
+ | Claude Code | [claude-code][client-claude-code] | pass: text and a tool turn through `/v1/messages`; needs `maxTools` above 36 |
468
+ | Codex CLI | [codex-cli][client-codex-cli] | docs only, not run yet (Responses API) |
469
+ | Continue | [continue][client-continue] | docs only, not run yet |
470
+ | Kilo Code | [kilo-code][client-kilo-code] | docs only, not run yet |
471
+ | LibreChat | [librechat][client-librechat] | docs only, not run yet |
472
+ | Zed | [zed][client-zed] | docs only, not run yet |
473
+ | OpenAI Agents SDK (Python) | [openai-agents][client-openai-agents] | pass on Responses and Chat Completions: text, stream, tools, structured output |
474
+ | LiteLLM (as a client) | [litellm-client][client-litellm] | pass: text, stream, tools |
475
+ | Instructor | [instructor][client-instructor] | pass: TOOLS, JSON_SCHEMA and partial streaming modes |
476
+ | DSPy | [dspy][client-dspy] | pass: Predict, ChainOfThought, typed output |
477
+ | Aider | [aider][client-aider] | pass: one scripted `--message` run |
463
478
  | Anything else | [any-app][client-any-app] | base URL, key, model id |
464
479
 
465
480
  Each run drove the client's own SDK or API against a dev server at an earlier commit: a plain chat, a tool round trip, streaming, an image and structured output on `fast`, a Claude model and a ChatGPT model. A pass is not a clean sweep, and the notes say where it was not. The latest full run is in [compat/results/release-candidate.md][release-candidate].
@@ -538,4 +553,15 @@ Not affiliated with or endorsed by OpenAI, Anthropic, Google, GitHub or Microsof
538
553
  [client-pydantic-ai]: https://jjjermiah.github.io/opencode-plugboard/clients/pydantic-ai
539
554
  [client-open-webui]: https://jjjermiah.github.io/opencode-plugboard/clients/open-webui
540
555
  [client-n8n]: https://jjjermiah.github.io/opencode-plugboard/clients/n8n
556
+ [client-claude-code]: https://jjjermiah.github.io/opencode-plugboard/clients/claude-code
557
+ [client-codex-cli]: https://jjjermiah.github.io/opencode-plugboard/clients/codex-cli
558
+ [client-continue]: https://jjjermiah.github.io/opencode-plugboard/clients/continue
559
+ [client-kilo-code]: https://jjjermiah.github.io/opencode-plugboard/clients/kilo-code
560
+ [client-librechat]: https://jjjermiah.github.io/opencode-plugboard/clients/librechat
561
+ [client-zed]: https://jjjermiah.github.io/opencode-plugboard/clients/zed
562
+ [client-openai-agents]: https://jjjermiah.github.io/opencode-plugboard/clients/openai-agents
563
+ [client-litellm]: https://jjjermiah.github.io/opencode-plugboard/clients/litellm-client
564
+ [client-instructor]: https://jjjermiah.github.io/opencode-plugboard/clients/instructor
565
+ [client-dspy]: https://jjjermiah.github.io/opencode-plugboard/clients/dspy
566
+ [client-aider]: https://jjjermiah.github.io/opencode-plugboard/clients/aider
541
567
  [client-any-app]: https://jjjermiah.github.io/opencode-plugboard/clients/any-app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.21.2",
3
+ "version": "0.23.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, Anthropic and Gemini compatible APIs.",
@@ -32,6 +32,7 @@
32
32
  "flake-hunt": "bun .github/scripts/flake-hunt.ts",
33
33
  "coverage-manifest": "bun .github/scripts/coverage-manifest.ts",
34
34
  "live-matrix": "bun test/live/matrix.ts",
35
+ "compat:python": "uv run --project compat/py python compat/py/clients/run_all.py",
35
36
  "build:tui": "bun .github/scripts/build-tui.ts",
36
37
  "prepack": "bun run build:tui",
37
38
  "smoke:pack": "bun .github/scripts/smoke-pack.ts",
@@ -9,6 +9,7 @@
9
9
  * afterwards, which is why the request omits `ref`.
10
10
  */
11
11
  import type { PlugboardOptions } from "../config.js"
12
+ import { imageProblem } from "../image.js"
12
13
  import { bodyUserId, userField } from "../user.js"
13
14
  import {
14
15
  isPlugError,
@@ -54,7 +55,10 @@ const mediaPart = (block: JsonObject, at: string, what: "image" | "document"): P
54
55
  if (source.type === "base64") {
55
56
  if (typeof source.media_type !== "string" || !MEDIA_TYPE.test(source.media_type)) return bad(`${at}.source.media_type: must be a media type such as image/png`)
56
57
  if (typeof source.data !== "string") return bad(`${at}.source.data: must be a base64 string`)
57
- return { type: "media", url: `data:${source.media_type};base64,${source.data}`, mediaType: source.media_type }
58
+ const url = `data:${source.media_type};base64,${source.data}`
59
+ const problem = what === "image" ? imageProblem(url) : undefined
60
+ if (problem) return bad(`${at}.source.data: ${problem}`)
61
+ return { type: "media", url, mediaType: source.media_type }
58
62
  }
59
63
  if (source.type === "url") {
60
64
  if (typeof source.url !== "string" || source.url.length === 0) return bad(`${at}.source.url: must be a non-empty string`)
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { IncomingMessage, ServerResponse } from "node:http"
15
- import { authenticate, bodyLimits } from "../config.js"
15
+ import { authenticate, bodyLimits, historyTemplate } from "../config.js"
16
16
  import { liveOptions } from "../runtime.js"
17
17
  import { bodyUserId, noteEndUser } from "../user.js"
18
18
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
@@ -138,7 +138,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
138
138
  onRecord: (recordID, directory) => {
139
139
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
140
140
  response.setHeader(SESSION_HEADER, recordID)
141
- setSessionLink(response, info, host.options.historyUrl, recordID, directory)
141
+ setSessionLink(response, info, historyTemplate(host.options, auth.app), recordID, directory)
142
142
  },
143
143
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
144
144
  onPolicy: reportPolicy(response),
@@ -59,6 +59,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
59
59
  if (!isRecord(body)) return bad("Request body must be a JSON object")
60
60
  if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
61
61
  if (body.prompt == null) return bad("prompt is required", "prompt")
62
+ if (body.stream != null && typeof body.stream !== "boolean") return bad("stream must be a boolean", "stream")
62
63
 
63
64
  if (body.logprobs != null) return unsupported("logprobs is not supported", "logprobs")
64
65
  if (body.best_of != null) {
@@ -90,7 +90,7 @@ async function settle(events: AsyncIterable<PlugEvent>, exchange: CompletionsExc
90
90
  if (exchange.structuredTool !== undefined) {
91
91
  if (answer !== undefined) return { text: answer, reason: finish, usage }
92
92
  const fallback = exchange.structuredSchema === undefined ? undefined : textFallback(text, exchange.structuredSchema)
93
- if (fallback === undefined) return structuredMissing(exchange.structuredTool)
93
+ if (fallback === undefined) return structuredMissing()
94
94
  return { text: fallback, reason: finish, usage, fallback: true }
95
95
  }
96
96
  if (finish === "stop") text = stripStopTail(text, exchange.stop ?? []).text
package/src/config.ts CHANGED
@@ -68,6 +68,16 @@ export interface PlugboardOptions {
68
68
  readonly historyUrl?: string
69
69
  }
70
70
 
71
+ /**
72
+ * The session link template that applies to `app` (#280): its own `apps.<name>.historyUrl` when set, `undefined`
73
+ * when that is `false`, else the global `historyUrl`. Routes call this with the authenticated app.
74
+ */
75
+ export function historyTemplate(options: Pick<PlugboardOptions, "apps" | "historyUrl">, app: string): string | undefined {
76
+ const own = appPolicy(options, app)?.historyUrl
77
+ if (own === false) return undefined
78
+ return own ?? options.historyUrl
79
+ }
80
+
71
81
  /** Option `usage` after defaults: on, and days of history kept. */
72
82
  export interface UsageConfig {
73
83
  readonly enabled: boolean
@@ -96,6 +106,11 @@ export interface AppPolicy {
96
106
  readonly serverTools?: ReadonlyArray<string>
97
107
  /** Server-tool steps one request may run before the turn is stopped (option `maxServerToolSteps`). */
98
108
  readonly maxServerToolSteps?: number
109
+ /**
110
+ * This app's session link template (option `apps.<name>.historyUrl`), or `false` for none. Absent: the global
111
+ * `historyUrl`. Look the effective template up with `historyTemplate`.
112
+ */
113
+ readonly historyUrl?: string | false
99
114
  }
100
115
 
101
116
  /** One set of caps. A field that is not set is unlimited. */
@@ -250,7 +265,7 @@ function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
250
265
  return [...merged]
251
266
  }
252
267
 
253
- const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools", "serverTools", "maxServerToolSteps"])
268
+ const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools", "serverTools", "maxServerToolSteps", "historyUrl"])
254
269
  const SERVER_TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/
255
270
  const MAX_SERVER_TOOLS = 32
256
271
  const MAX_SERVER_TOOL_STEPS = 100
@@ -311,6 +326,8 @@ function parseApps(
311
326
  const maxServerToolSteps =
312
327
  value.maxServerToolSteps === undefined ? undefined : positiveInt(value.maxServerToolSteps, `apps.${app}.maxServerToolSteps`, DEFAULT_SERVER_TOOL_STEPS, MAX_SERVER_TOOL_STEPS)
313
328
 
329
+ // `false` turns the link off for this app; a string is its own template (validated like the global one).
330
+ const historyUrl = value.historyUrl === false ? false : parseHistoryUrl(value.historyUrl, `plugboard option \`apps.${app}.historyUrl\``)
314
331
  const policy: AppPolicy = {
315
332
  ...(value.models === undefined ? {} : { models: value.models as string[] }),
316
333
  ...(value.system === undefined ? {} : { system: value.system as string }),
@@ -319,9 +336,10 @@ function parseApps(
319
336
  ...(maxTools === undefined ? {} : { maxTools }),
320
337
  ...(serverTools === undefined ? {} : { serverTools }),
321
338
  ...(maxServerToolSteps === undefined ? {} : { maxServerToolSteps }),
339
+ ...(historyUrl === undefined ? {} : { historyUrl }),
322
340
  }
323
341
  // An app that only carries a key has no policy to look up.
324
- if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined || serverTools !== undefined)
342
+ if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined || serverTools !== undefined || historyUrl !== undefined)
325
343
  entries.push([app, policy])
326
344
  }
327
345
  return Object.fromEntries(entries)
@@ -502,9 +520,8 @@ function parseAgents(raw: unknown, models: ReadonlyArray<string>): Record<string
502
520
  * Option `historyUrl` (#77): an http(s) URL template that must contain `{id}`. Its only other placeholder is
503
521
  * `{directory}`, so a typo such as `{sesion}` fails at load instead of producing a link that is wrong.
504
522
  */
505
- function parseHistoryUrl(value: unknown): string | undefined {
523
+ function parseHistoryUrl(value: unknown, at = "plugboard option `historyUrl`"): string | undefined {
506
524
  if (value === undefined) return undefined
507
- const at = "plugboard option `historyUrl`"
508
525
  if (typeof value !== "string" || !value.includes("{id}")) throw new Error(`${at} must be a URL template with {id} (for example http://host:4096/session/{id})`)
509
526
  const unknown = (value.match(/\{[^{}]*\}/g) ?? []).find((placeholder) => placeholder !== "{id}" && placeholder !== "{directory}")
510
527
  if (unknown !== undefined) throw new Error(`${at} has the unknown placeholder ${unknown} (known: {id}, {directory})`)
package/src/contract.ts CHANGED
@@ -176,10 +176,9 @@ export interface CallContext {
176
176
  /** Called once, before the first event, with the id of the history record this call writes (if any). */
177
177
  readonly onRecord?: (recordID: string, directory?: string) => void
178
178
  /**
179
- * Called once, before the first event, only for a request whose model is an alias with fallbacks
180
- * (`src/fallback.ts`): which target answered and how many earlier targets failed first. A front-end
181
- * uses it for `x-plugboard-model` and the `model` it echoes. A call that fails on every target never
182
- * reports it.
179
+ * Called once, before the first event, for every answered call (`src/fallback.ts`): which target answered
180
+ * and how many earlier targets failed first. A front-end uses it for `x-plugboard-model`, and the `model`
181
+ * it echoes when `chain` is set. A call that fails never reports it.
183
182
  */
184
183
  readonly onServed?: (served: ServedModel) => void
185
184
  /** Called once, before the first event, with each change the app's policy made that the caller sent for. Front-ends report them in the `x-plugboard-policy` header. */
@@ -210,10 +209,14 @@ export interface CallTotals {
210
209
  readonly costUsd?: number
211
210
  }
212
211
 
213
- /** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
212
+ /**
213
+ * The target that answered: `provider/model[#variant]`, and the targets tried before it. `chain` is set
214
+ * when the request named a list alias, the only case where the response body echoes the target.
215
+ */
214
216
  export interface ServedModel {
215
217
  readonly model: string
216
218
  readonly fallbacks: number
219
+ readonly chain?: boolean
217
220
  }
218
221
 
219
222
  /** The seam between the core and a backend (the session backend now, `ctx.llm.stream` possibly later). */
package/src/fallback.ts CHANGED
@@ -56,10 +56,11 @@ export interface ServedInfo {
56
56
  }
57
57
 
58
58
  /**
59
- * The `CallContext.onServed` of a route: `x-plugboard-model` (and `x-plugboard-fallbacks` when targets
60
- * failed first) while the headers are still open, and the log fields. The first call wins, so several
61
- * choices of one request report the first answer. `then` runs for that first call (a single-call route
62
- * echoes the served model in the body there).
59
+ * The `CallContext.onServed` of a route: `x-plugboard-model` on every answer (alias or canonical id), and
60
+ * `x-plugboard-fallbacks` when targets failed first, while the headers are still open, plus the log fields.
61
+ * The first call wins, so several choices of one request report the first answer. `then` runs only for a
62
+ * list alias (`served.chain`): a single-call route echoes the target in the body for those, and keeps the
63
+ * model the caller sent for every other request.
63
64
  */
64
65
  export function reportServed(response: ServerResponse, info: ServedInfo, then?: (served: ServedModel) => void): (served: ServedModel) => void {
65
66
  return (served) => {
@@ -70,7 +71,7 @@ export function reportServed(response: ServerResponse, info: ServedInfo, then?:
70
71
  response.setHeader(MODEL_HEADER, served.model)
71
72
  if (served.fallbacks > 0) response.setHeader(FALLBACKS_HEADER, String(served.fallbacks))
72
73
  }
73
- then?.(served)
74
+ if (served.chain) then?.(served)
74
75
  }
75
76
  }
76
77
 
@@ -80,12 +81,27 @@ export const isRetryable = (error: PlugError): boolean => error.code !== undefin
80
81
  export function withFallbacks(inner: ModelCaller, deps: FallbackDeps): ModelCaller {
81
82
  return {
82
83
  stream(request, context) {
83
- if (!Object.hasOwn(deps.options.fallbacks, request.model)) return inner.stream(request, context)
84
+ if (!Object.hasOwn(deps.options.fallbacks, request.model)) return reportPlain(inner.stream(request, context), context, request.ref)
84
85
  return chain(inner, deps, request, context)
85
86
  },
86
87
  }
87
88
  }
88
89
 
90
+ /**
91
+ * A call that is not a list alias: no retry, but the answer still names its model (`x-plugboard-model`, #279).
92
+ * Reported before the first non-error event, so a failed call reports nothing, as in `chain`.
93
+ */
94
+ async function* reportPlain(events: AsyncIterable<PlugEvent>, context: CallContext, ref: ModelRef): AsyncGenerator<PlugEvent> {
95
+ let reported = false
96
+ for await (const event of events) {
97
+ if (!reported && event.type !== "error") {
98
+ reported = true
99
+ context.onServed?.({ model: refToString(ref), fallbacks: 0 })
100
+ }
101
+ yield event
102
+ }
103
+ }
104
+
89
105
  async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
90
106
  let rest: ModelRef[] | undefined
91
107
  let ref = request.ref
@@ -130,7 +146,7 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
130
146
  if (thrown) throw thrown.error
131
147
  if (first!.done) return
132
148
  // An error is not an answer: a call that fails (non-retryable, or on its last target) reports no served model.
133
- if (reason === undefined && first!.value.type !== "error") context.onServed?.({ model: refToString(ref), fallbacks: attempt })
149
+ if (reason === undefined && first!.value.type !== "error") context.onServed?.({ model: refToString(ref), fallbacks: attempt, chain: true })
134
150
  try {
135
151
  yield first!.value
136
152
  for (let step = await iterator.next(); !step.done; step = await iterator.next()) yield step.value
@@ -13,6 +13,7 @@
13
13
  * API also accepts.
14
14
  */
15
15
  import type { PlugboardOptions } from "../config.js"
16
+ import { imageProblem } from "../image.js"
16
17
  import {
17
18
  isPlugError,
18
19
  type PlugError,
@@ -212,7 +213,10 @@ const parseContents = (raw: unknown): PlugMessage[] | PlugError => {
212
213
  if (role === "model") return unsupported("inlineData is not supported in a model turn", `${at}.inlineData`)
213
214
  if (!isRecord(inline) || typeof inline.data !== "string" || typeof inline.mimeType !== "string")
214
215
  return bad("inlineData needs mimeType and base64 data", `${at}.inlineData`)
215
- parts.push({ type: "media", url: dataUrl(inline.mimeType, inline.data), mediaType: inline.mimeType })
216
+ const url = dataUrl(inline.mimeType, inline.data)
217
+ const problem = inline.mimeType.toLowerCase().startsWith("image/") ? imageProblem(url) : undefined
218
+ if (problem) return bad(problem, `${at}.inlineData`, "invalid_image")
219
+ parts.push({ type: "media", url, mediaType: inline.mimeType })
216
220
  } else if (file !== undefined) {
217
221
  if (role === "model") return unsupported("fileData is not supported in a model turn", `${at}.fileData`)
218
222
  const uri = isRecord(file) ? field(file, "fileUri") : undefined
@@ -227,7 +227,7 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
227
227
 
228
228
  const passthrough = exchange.callerTools === true && calls > 0
229
229
  const fallback = exchange.structuredTool !== undefined && !passthrough && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
230
- const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing(exchange.structuredTool)
230
+ const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing()
231
231
  failure ??= finish ? missing : noFinish()
232
232
  if (failure) return { failure, started }
233
233
  flush()
@@ -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 { bodyLimits } from "../config.js"
20
+ import { bodyLimits, historyTemplate } from "../config.js"
21
21
  import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
22
22
  import { listAvailableModels, resolveModel } from "../models.js"
23
23
  import { reportCache } from "../cache.js"
@@ -163,7 +163,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
163
163
  onRecord: (recordID, directory) => {
164
164
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
165
165
  response.setHeader(SESSION_HEADER, recordID)
166
- setSessionLink(response, info, host.options.historyUrl, recordID, directory)
166
+ setSessionLink(response, info, historyTemplate(host.options, app), recordID, directory)
167
167
  },
168
168
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
169
169
  onPolicy: reportPolicy(response),
package/src/image.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The check every image part of a request gets in its decoder (#286), so a caller's bad image is a 400
3
+ * before any session, preparation or budget work. Only `data:` URLs are checked here: a `data:` image must
4
+ * carry an `image/*` media type, well-formed base64, and bytes that start with a PNG, JPEG, GIF or WebP
5
+ * signature (the formats the providers take). Any other URL is not checked here; remote images are checked
6
+ * when they are fetched (src/session/media.ts).
7
+ *
8
+ * Pure: no node imports. `Buffer` is the global, and only the first 12 decoded bytes are read.
9
+ */
10
+
11
+ const DATA_URL = /^data:([^,]*);base64,(.*)$/s
12
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/
13
+
14
+ /** The image signatures plugboard accepts, checked on the first bytes of the decoded data. */
15
+ const SIGNATURES: ReadonlyArray<(head: Buffer) => boolean> = [
16
+ (head) => head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47,
17
+ (head) => head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff,
18
+ (head) => head.subarray(0, 4).toString("latin1") === "GIF8",
19
+ (head) => head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP",
20
+ ]
21
+
22
+ /**
23
+ * The message for a file part (`file_data`, Chat Completions or Responses `input_file`) that cannot be sent (#288).
24
+ * Only a PDF given as a `data:application/pdf;base64,` URL is accepted: the compatibility matrix sends one on
25
+ * OpenAI Chat. A bare base64 string has no media type, and any other type is refused here instead of failing
26
+ * later in backend preparation.
27
+ */
28
+ export function pdfProblem(url: string): string | undefined {
29
+ const match = /^data:application\/pdf;base64,(.*)$/is.exec(url)
30
+ if (!match) return "File data must be a PDF data URL (data:application/pdf;base64,...); other file types are not supported"
31
+ const data = match[1]!
32
+ if (data.length === 0 || !BASE64.test(data) || data.length % 4 === 1) return "File data is not valid base64"
33
+ return Buffer.from(data.slice(0, 8), "base64").subarray(0, 4).toString("latin1") === "%PDF" ? undefined : "File data is not a PDF"
34
+ }
35
+
36
+ /** The message for a `data:` image URL that cannot be sent to a provider, or undefined when it can (or is not a data URL). */
37
+ export function imageProblem(url: string): string | undefined {
38
+ if (!url.startsWith("data:")) return undefined
39
+ const match = DATA_URL.exec(url)
40
+ if (!match) return "Image data URL must be base64 encoded, for example data:image/png;base64,..."
41
+ const mediaType = match[1]!.split(";")[0]!.trim().toLowerCase()
42
+ if (!mediaType.startsWith("image/")) return "Image data URL must have an image/* media type"
43
+ const data = match[2]!
44
+ if (data.length === 0) return "Image data URL has no data"
45
+ if (!BASE64.test(data) || data.length % 4 === 1) return "Image data URL is not valid base64"
46
+ // 16 base64 characters decode to the 12 bytes the signatures need.
47
+ const head = Buffer.from(data.slice(0, 16), "base64")
48
+ return SIGNATURES.some((matches) => matches(head)) ? undefined : "Image data URL is not a PNG, JPEG, GIF or WebP image"
49
+ }
@@ -26,6 +26,7 @@ import { reportServed, type ServedInfo } from "../fallback.js"
26
26
  import { reportCache } from "../cache.js"
27
27
  import { reportPolicy, reportServerTools } from "../policy.js"
28
28
  import { reportUsage } from "../usage-header.js"
29
+ import { historyTemplate } from "../config.js"
29
30
  import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
30
31
  import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
31
32
  import {
@@ -198,8 +199,9 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
198
199
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
199
200
  records[index] = recordID
200
201
  response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
201
- if (host.options.historyUrl !== undefined) {
202
- links[index] = sessionUrl(host.options.historyUrl, recordID, directory)
202
+ const template = historyTemplate(host.options, app)
203
+ if (template !== undefined) {
204
+ links[index] = sessionUrl(template, recordID, directory)
203
205
  info.sessionUrl = links.filter((link) => link !== undefined).join(",")
204
206
  response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
205
207
  }
@@ -6,6 +6,7 @@
6
6
  * the request omits `ref`.
7
7
  */
8
8
  import type { PlugboardOptions } from "../config.js"
9
+ import { imageProblem, pdfProblem } from "../image.js"
9
10
  import { bodyUser, userField } from "../user.js"
10
11
  import {
11
12
  isPlugError,
@@ -80,10 +81,13 @@ const userParts = (content: unknown, param: string): PlugPart[] | PlugError => {
80
81
  if (item.type === "text" && typeof item.text === "string") parts.push({ type: "text", text: item.text })
81
82
  else if (item.type === "image_url" && isRecord(item.image_url) && typeof item.image_url.url === "string") {
82
83
  const url = item.image_url.url
84
+ const problem = imageProblem(url)
85
+ if (problem) return bad(problem, `${at}.image_url.url`, "invalid_image")
83
86
  parts.push({ type: "media", url, ...(dataUrlType(url) ? { mediaType: dataUrlType(url) } : {}) })
84
87
  } else if (item.type === "file" && isRecord(item.file) && typeof item.file.file_data === "string") {
85
- const data = item.file.file_data
86
- const url = data.startsWith("data:") ? data : `data:application/octet-stream;base64,${data}`
88
+ const url = item.file.file_data
89
+ const problem = pdfProblem(url)
90
+ if (problem) return bad(problem, `${at}.file.file_data`, "invalid_file")
87
91
  parts.push({ type: "media", url, mediaType: dataUrlType(url) })
88
92
  } else return bad(`Unsupported content part type "${String(item.type)}"`, `${at}.type`, "unsupported_parameter")
89
93
  }
@@ -279,6 +283,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
279
283
  // Parameters the session backend cannot honour are rejected, not ignored.
280
284
  const n = parseChoiceCount(body, options)
281
285
  if (typeof n === "object") return n
286
+ if (body.stream != null && typeof body.stream !== "boolean") return bad("stream must be a boolean", "stream")
282
287
  if (body.logprobs === true) return bad("logprobs is not supported", "logprobs", "unsupported_parameter")
283
288
  if (body.top_logprobs != null) return bad("top_logprobs is not supported", "top_logprobs", "unsupported_parameter")
284
289
 
@@ -166,12 +166,15 @@ const noFinish = (): PlugError => ({
166
166
 
167
167
  const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
168
168
 
169
- /** The 502 for a structured turn that ended without its answer (the forced tool, or a valid JSON text). Shared with the legacy completions encoder. */
170
- export const structuredMissing = (tool: string): PlugError => ({
169
+ /**
170
+ * The 502 for a structured turn that ended without its answer (no forced-tool call, and no text that is the
171
+ * schema's JSON). Shared by every front-end. The message names no internal tool: the caller never sees one (#282).
172
+ */
173
+ export const structuredMissing = (): PlugError => ({
171
174
  status: 502,
172
175
  kind: "server",
173
176
  code: "structured_output_missing",
174
- message: `The model finished without calling the \`${tool}\` tool, so there is no structured output`,
177
+ message: "The model did not return JSON matching the response schema. Say the expected JSON, with its keys and types, in the prompt.",
175
178
  })
176
179
 
177
180
  /**
@@ -186,7 +189,7 @@ const structured = {
186
189
  content: (call: ToolCall) => args(call.input),
187
190
  /** The failure for a finished structured exchange, if any. */
188
191
  failure: (exchange: OpenAiExchange, answered: boolean): PlugError | undefined =>
189
- exchange.structuredTool === undefined || answered ? undefined : structuredMissing(exchange.structuredTool),
192
+ exchange.structuredTool === undefined || answered ? undefined : structuredMissing(),
190
193
  }
191
194
 
192
195
  const isObjectValue = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
@@ -22,7 +22,7 @@
22
22
 
23
23
  import type { IncomingMessage, ServerResponse } from "node:http"
24
24
  import { serveAdmin } from "../admin/route.js"
25
- import { DEFAULT_APP, authenticate, authenticateUsage, bodyLimits, type PlugboardOptions } from "../config.js"
25
+ import { DEFAULT_APP, authenticate, authenticateUsage, bodyLimits, historyTemplate, type PlugboardOptions } from "../config.js"
26
26
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
27
27
  import { buildDiscovery, buildStatus, readiness, renderDiscoveryHtml, sendText } from "../discovery.js"
28
28
  import {
@@ -209,6 +209,15 @@ export function createOpenAiRoute(routeHost: RouteHost) {
209
209
  // Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
210
210
  if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
211
211
 
212
+ // `/metrics` with `adminKey` set: the admin key alone (#285). Its series name every app, so an app key may not read them.
213
+ // Without `adminKey` it keeps the `/v1` authentication, and any app key sees every app's series (documented).
214
+ if (route === "GET /metrics" && metrics && host.options.adminKey !== undefined) {
215
+ const who = authenticateUsage(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
216
+ if (isPlugError(who)) return fail(response, who)
217
+ if (!who.admin) return fail(response, { status: 403, kind: "permission", code: "admin_key_required", message: "Metrics need the admin key (option `adminKey`)" })
218
+ return sendText(response, 200, metrics.contentType, await metrics.render(runtimeStatus()))
219
+ }
220
+
212
221
  // Runtime app keys (#55): the admin routes answer the admin key only, and are a 404 without `adminKey`.
213
222
  if (path === "/v1/admin/keys" || path.startsWith("/v1/admin/keys/"))
214
223
  return serveAdmin({ options: liveOptions(host.options), keys: host.keys }, { path, request, response, scope, info })
@@ -305,7 +314,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
305
314
  onRecord: (recordID, directory) => {
306
315
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
307
316
  response.setHeader(SESSION_HEADER, recordID)
308
- setSessionLink(response, info, host.options.historyUrl, recordID, directory)
317
+ setSessionLink(response, info, historyTemplate(host.options, auth.app), recordID, directory)
309
318
  },
310
319
  // An alias with fallbacks: report which target answered, and echo it as the response model.
311
320
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
@@ -10,6 +10,7 @@
10
10
  * `ModelRef` afterwards, which is why the request omits `ref`.
11
11
  */
12
12
  import type { PlugboardOptions } from "../config.js"
13
+ import { imageProblem, pdfProblem } from "../image.js"
13
14
  import { bodyUser, userField } from "../user.js"
14
15
  import {
15
16
  isPlugError,
@@ -88,12 +89,16 @@ const userParts = (content: unknown, param: string): PlugPart[] | PlugError => {
88
89
  const url = typeof item.image_url === "string" ? item.image_url : isRecord(item.image_url) ? item.image_url.url : undefined
89
90
  if (typeof url !== "string")
90
91
  return unsupported(item.file_id != null ? "input_image.file_id is not supported (stateless); send image_url" : "input_image needs an image_url", `${at}.image_url`)
92
+ const problem = imageProblem(url)
93
+ if (problem) return bad(problem, `${at}.image_url`, "invalid_image")
91
94
  parts.push({ type: "media", url, ...(dataUrlType(url) ? { mediaType: dataUrlType(url) } : {}) })
92
95
  } else if (item.type === "input_file") {
93
96
  if (typeof item.file_data === "string") {
94
97
  const data = item.file_data
95
98
  const filename = typeof item.filename === "string" ? item.filename.toLowerCase() : ""
96
99
  const url = data.startsWith("data:") ? data : `data:${filename.endsWith(".pdf") ? "application/pdf" : "application/octet-stream"};base64,${data}`
100
+ const problem = pdfProblem(url)
101
+ if (problem) return bad(problem, `${at}.file_data`, "invalid_file")
97
102
  parts.push({ type: "media", url, mediaType: dataUrlType(url) })
98
103
  } else if (typeof item.file_url === "string") {
99
104
  parts.push({ type: "media", url: item.file_url })
@@ -246,6 +251,13 @@ const parseTools = (raw: unknown): PlugTool[] | PlugError => {
246
251
  return tools
247
252
  }
248
253
 
254
+ /**
255
+ * A caller's tool as echoed on the response object. A function tool always carries `strict` (#289, the OpenAI
256
+ * FunctionTool schema requires it): the caller's boolean, or false when it was absent or null. Other entries are
257
+ * echoed as sent.
258
+ */
259
+ const echoTool = (tool: unknown): unknown => (isRecord(tool) && tool.type === "function" ? { ...tool, strict: tool.strict === true } : tool)
260
+
249
261
  const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError => {
250
262
  if (raw == null) return undefined
251
263
  if (raw === "auto" || raw === "none" || raw === "required") return raw
@@ -319,6 +331,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
319
331
  if (refused) return refused
320
332
  if (body.instructions != null && typeof body.instructions !== "string") return bad("instructions must be a string", "instructions")
321
333
  if (body.reasoning != null && !isRecord(body.reasoning)) return bad("reasoning must be an object", "reasoning")
334
+ if (body.stream != null && typeof body.stream !== "boolean") return bad("stream must be a boolean", "stream")
322
335
 
323
336
  const parsed = parseInput(body.input, resolve)
324
337
  if (isPlugError(parsed)) return parsed
@@ -381,7 +394,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
381
394
  temperature: generation.temperature ?? null,
382
395
  text: { format: isRecord(text?.format) ? text.format : { type: "text" } },
383
396
  tool_choice: body.tool_choice ?? "auto",
384
- tools: Array.isArray(body.tools) ? body.tools : [],
397
+ tools: Array.isArray(body.tools) ? body.tools.map(echoTool) : [],
385
398
  top_p: generation.topP ?? null,
386
399
  truncation: "disabled",
387
400
  user: typeof body.user === "string" ? body.user : null,
@@ -201,7 +201,7 @@ export async function runResponse(
201
201
  const passthrough = exchange.callerTools === true && calls > 0
202
202
  const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
203
203
  const fallback = structuredAnswer && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
204
- const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing(exchange.structuredTool)
204
+ const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing()
205
205
  failure ??= finish ? missing : noFinish()
206
206
  if (failure) {
207
207
  if (!started && !committed()) return { failure, started }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { ServerResponse } from "node:http"
12
12
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
+ import { historyTemplate } from "../config.js"
13
14
  import { reportServed, type ServedInfo } from "../fallback.js"
14
15
  import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
15
16
  import { resolveModel } from "../models.js"
@@ -55,7 +56,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
55
56
  onRecord: (recordID, directory) => {
56
57
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
57
58
  response.setHeader(SESSION_HEADER, recordID)
58
- setSessionLink(response, info, host.options.historyUrl, recordID, directory)
59
+ setSessionLink(response, info, historyTemplate(host.options, app), recordID, directory)
59
60
  },
60
61
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
61
62
  onPolicy: reportPolicy(response),
@@ -68,7 +68,9 @@ const countMedia = (request: PlugRequest): number =>
68
68
  function checkPreparation(request: PlugRequest, images: number, maxTools: number): PlugError | undefined {
69
69
  const wanted = countMedia(request)
70
70
  if (images !== wanted) {
71
- return { status: 500, kind: "server", code: "backend_preparation_failed", message: `Prepared ${images} attachment(s) for a request with ${wanted}` }
71
+ // The counts are for the log only; the caller gets the generic message (#288).
72
+ log("request.preparation_failed", { images, wanted })
73
+ return { status: 500, kind: "server", code: "backend_preparation_failed", message: "Could not prepare the model request" }
72
74
  }
73
75
  const tools = seedToolsFor(request).length
74
76
  if (tools > maxTools) {
@@ -10,6 +10,7 @@
10
10
  import { Effect } from "effect"
11
11
  import { appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
12
12
  import type { PlugError, PlugMessage, PlugRequest } from "../contract.js"
13
+ import { log } from "../log.js"
13
14
  import type { ContextHookEvent, HostContext, HostPart, HostToolDescriptor } from "./host.js"
14
15
  import { SEED_PREFIX, SEED_RESULT, seedToolsFor } from "./seeds.js"
15
16
  import { FAILURE_EVENT, type SessionStore } from "./state.js"
@@ -108,7 +109,8 @@ function systemFor(caller: ReadonlyArray<string>, policy: AppPolicy | undefined)
108
109
  }
109
110
  }
110
111
 
111
- const preparationFailed = (message: string): PlugError => ({ status: 500, kind: "server", code: "backend_preparation_failed", message })
112
+ /** The one 500 message for a request plugboard could not prepare. What was missing is logged, never sent: it names host internals (#288). */
113
+ const preparationFailed = (): PlugError => ({ status: 500, kind: "server", code: "backend_preparation_failed", message: "Could not prepare the model request" })
112
114
 
113
115
  /**
114
116
  * `session.hook("context")` body: swap the request session's system, messages, tools and
@@ -151,11 +153,8 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
151
153
  // The caller validates both before dispatch; this only fires if the host disagrees. The request is
152
154
  // already rewritten (no real tool can run); fail it instead of answering from a truncated prompt.
153
155
  if (missingMedia > 0 || missingTools > 0) {
154
- const detail = [missingMedia > 0 ? `${missingMedia} attachment(s) not delivered by the host` : "", missingTools > 0 ? `${missingTools} tool(s) without a seed tool` : ""]
155
- state.collector.push({
156
- type: FAILURE_EVENT,
157
- error: preparationFailed(`Could not prepare the model request: ${detail.filter(Boolean).join(", ")}`),
158
- })
156
+ log("request.preparation_failed", { missing_media: missingMedia, missing_tools: missingTools })
157
+ state.collector.push({ type: FAILURE_EVENT, error: preparationFailed() })
159
158
  }
160
159
  }
161
160