opencode-plugboard 0.20.0 → 0.21.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.
@@ -12,12 +12,13 @@
12
12
  */
13
13
 
14
14
  import type { IncomingMessage, ServerResponse } from "node:http"
15
- import { authenticate } from "../config.js"
15
+ import { authenticate, bodyLimits } 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"
19
19
  import {
20
20
  BodyError,
21
+ bodyPlugError,
21
22
  RequestAborted,
22
23
  boundEvents,
23
24
  checkJsonContentType,
@@ -31,7 +32,7 @@ import {
31
32
  import { reportServed, type ServedInfo } from "../fallback.js"
32
33
  import { resolveModel } from "../models.js"
33
34
  import { reportCache } from "../cache.js"
34
- import { reportPolicy } from "../policy.js"
35
+ import { reportPolicy, reportServerTools } from "../policy.js"
35
36
  import { reportUsage } from "../usage-header.js"
36
37
  import { setSessionLink } from "../session/link.js"
37
38
  import { dropRejected } from "../openai/sampling.js"
@@ -85,7 +86,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
85
86
  // Answer in this route's error shape, then rethrow: the shared handler logs the outcome and
86
87
  // finds the response already sent.
87
88
  if (!response.headersSent) {
88
- if (caught instanceof BodyError) fail({ status: caught.status, kind: "invalid_request", message: caught.message }, caught.status === 413)
89
+ if (caught instanceof BodyError) fail(bodyPlugError(caught), caught.status !== 400)
89
90
  else if (caught instanceof RequestAborted) {
90
91
  if (caught.kind === "timeout") fail(scope.timeoutError, true)
91
92
  } else fail({ status: 500, kind: "server", message: "Internal server error" })
@@ -105,7 +106,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
105
106
  const unsupported = checkJsonContentType(request)
106
107
  if (unsupported) return fail(unsupported)
107
108
 
108
- const body = await readJsonBody(request, scope.signal)
109
+ const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
109
110
  noteEndUser(response, info, bodyUserId(body))
110
111
  // count_tokens takes the message body without max_tokens; the parser needs one, so a placeholder stands in
111
112
  // (the count never uses it). A max_tokens the client did send is still validated.
@@ -142,6 +143,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
142
143
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
143
144
  onPolicy: reportPolicy(response),
144
145
  onUsage: reportUsage(response, exchange.stream),
146
+ onServerTools: reportServerTools(response, exchange.stream),
145
147
  onCache: reportCache(response),
146
148
  }),
147
149
  scope,
package/src/cache.ts CHANGED
@@ -118,18 +118,22 @@ function append(events: PlugEvent[], event: { readonly type: "text-delta" | "rea
118
118
  else events.push({ type: event.type, text: event.text })
119
119
  }
120
120
 
121
- /** Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. */
121
+ /**
122
+ * Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. `bypass(model, app)`
123
+ * decides per call that the cache is not used: a persona model, or an app whose answers depend on live OpenCode
124
+ * tool output (`serverTools`, #82). Both conditions live in the one predicate the runtime passes.
125
+ */
122
126
  export function cachedCaller(
123
127
  inner: ModelCaller,
124
128
  config: CacheConfig,
125
129
  store: CacheStore,
126
130
  count?: CacheCounter,
127
- bypass?: (model: string) => boolean,
131
+ bypass?: (model: string, app: string) => boolean,
128
132
  ): ModelCaller {
129
133
  const apps = config.apps ? new Set(config.apps) : undefined
130
134
  return {
131
135
  async *stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
132
- if (context.noCache || bypass?.(request.model) || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
136
+ if (context.noCache || bypass?.(request.model, context.app) || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
133
137
  const key = cacheKey(context.app, request)
134
138
  const hit = store.get(key)
135
139
  if (hit) {
@@ -161,9 +165,16 @@ export function cachedCaller(
161
165
  }
162
166
  const recorded: PlugEvent[] = []
163
167
  let storable = true
168
+ // Text bytes seen so far. An answer over `maxBytes` could never be stored, so capture stops there and frees what it kept (#153).
169
+ let captured = 0
164
170
  for await (const event of inner.stream(request, forward)) {
165
- if (event.type === "text-delta" || event.type === "reasoning-delta") append(recorded, event)
166
- else if (event.type === "finish") {
171
+ if (event.type === "text-delta" || event.type === "reasoning-delta") {
172
+ captured += Buffer.byteLength(event.text)
173
+ if (captured > config.maxBytes) {
174
+ storable = false
175
+ recorded.length = 0
176
+ } else if (storable) append(recorded, event)
177
+ } else if (event.type === "finish") {
167
178
  // Stored before the event is handed on, so a consumer that stops reading at `finish` still fills the cache.
168
179
  if (storable && CACHEABLE_FINISH.has(event.reason) && recorded.length > 0 && !context.signal.aborted)
169
180
  store.set(key, { events: [...recorded, { type: "finish", reason: event.reason }], ...(served ? { served } : {}), ...(policy?.length ? { policy } : {}) })
@@ -9,9 +9,9 @@
9
9
  * `ref`.
10
10
  */
11
11
  import type { PlugboardOptions } from "../config.js"
12
- import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
12
+ import { isPlugError, type PlugError, type PlugRequest, type PlugToolChoice } from "../contract.js"
13
13
  import { bodyUser, userField } from "../user.js"
14
- import { parseChoiceCount, parseGeneration } from "../openai/request.js"
14
+ import { RESPOND_TOOL, parseChoiceCount, parseGeneration, parseResponseFormat } from "../openai/request.js"
15
15
  import type { CompletionsExchange } from "./response.js"
16
16
 
17
17
  export interface ParsedCompletions {
@@ -80,16 +80,23 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
80
80
  )
81
81
  const generation = parseGeneration(body)
82
82
  if (isPlugError(generation)) return generation
83
+ // The same response_format as Chat Completions: json_schema is the forced `respond` tool, json_object an instruction.
84
+ const format = parseResponseFormat(body.response_format)
85
+ if (isPlugError(format)) return format
83
86
 
84
87
  const suffix = typeof body.suffix === "string" && body.suffix.length > 0 ? body.suffix : undefined
88
+ const system = [...(suffix ? [SUFFIX_INSTRUCTION(suffix)] : []), ...(format.instruction ? [format.instruction] : [])]
89
+ const tools = format.structured ? [format.structured] : []
90
+ const toolChoice: PlugToolChoice | undefined = format.structured ? { type: "tool", name: RESPOND_TOOL } : undefined
85
91
  const streamOptions = isRecord(body.stream_options) ? body.stream_options : {}
86
92
  return {
87
93
  requests: prompts.map((prompt) => ({
88
94
  model: body.model as string,
89
95
  ...userField(bodyUser(body)),
90
- system: suffix ? [SUFFIX_INSTRUCTION(suffix)] : [],
96
+ system,
91
97
  messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
92
- tools: [],
98
+ tools,
99
+ ...(toolChoice ? { toolChoice } : {}),
93
100
  generation,
94
101
  })),
95
102
  n,
@@ -100,6 +107,8 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
100
107
  perPrompt: n,
101
108
  ...(body.echo === true ? { echo: prompts } : {}),
102
109
  ...(generation.stop ? { stop: generation.stop } : {}),
110
+ ...(format.structured ? { structuredTool: RESPOND_TOOL, structuredSchema: format.structured.parameters } : {}),
111
+ ...(format.instruction ? { jsonObject: true } : {}),
103
112
  },
104
113
  ...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
105
114
  }
@@ -5,9 +5,9 @@
5
5
  * Reasoning deltas and tool calls are dropped: the legacy format has neither, and the request
6
6
  * offers no tools. `logprobs` is always null.
7
7
  */
8
- import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
8
+ import { isPlugError, type PlugError, type PlugEvent, type PlugFinish, type PlugUsage } from "../contract.js"
9
9
  import type { ChoiceCodec } from "../openai/fanout.js"
10
- import { encodeError, type StreamChoice, type StreamResult } from "../openai/response.js"
10
+ import { args, encodeError, structuredMissing, textFallback, unfence, type CollectedChoice, type StreamChoice, type StreamResult } from "../openai/response.js"
11
11
  import { StopHoldback, stripStopTail } from "../stop.js"
12
12
 
13
13
  /** What the encoders need besides the model events; the request decoder produces it, the model call never sees it. */
@@ -23,6 +23,12 @@ export interface CompletionsExchange {
23
23
  readonly echo?: ReadonlyArray<string>
24
24
  /** The request's `stop`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). */
25
25
  readonly stop?: ReadonlyArray<string>
26
+ /** `response_format: json_schema`: the name of the forced tool whose input is the answer (`respond`). The answer is its arguments as text. */
27
+ readonly structuredTool?: string
28
+ /** The caller's `json_schema`: a text answer is taken when the model skipped the call and its text is that JSON (`textFallback`). */
29
+ readonly structuredSchema?: Readonly<Record<string, unknown>>
30
+ /** `response_format: json_object`: a single markdown fence around the answer is stripped (`unfence`). */
31
+ readonly jsonObject?: boolean
26
32
  }
27
33
 
28
34
  const FINISH_REASONS: Record<Exclude<PlugFinish, "error">, string> = {
@@ -46,15 +52,31 @@ const encodeUsage = (usage: PlugUsage) => {
46
52
 
47
53
  const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
48
54
 
49
- /** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
50
- async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
55
+ /** A choice's settled answer: its text, the finish reason and the usage. */
56
+ interface Settled {
57
+ readonly text: string
58
+ readonly reason: Exclude<PlugFinish, "error">
59
+ readonly usage?: PlugUsage
60
+ /** The structured answer came from the text (see `textFallback`), not the respond call. */
61
+ readonly fallback?: boolean
62
+ }
63
+
64
+ /**
65
+ * Reads one whole choice. A structured answer is the arguments of the `respond` call (the text is
66
+ * ignored); a json_object answer loses one code fence. Without the call a structured choice fails,
67
+ * as in Chat Completions (`structuredMissing`).
68
+ */
69
+ async function settle(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange): Promise<Settled | PlugError> {
51
70
  let text = ""
71
+ let answer: string | undefined
52
72
  let finish: Exclude<PlugFinish, "error"> | undefined
53
73
  let usage: PlugUsage | undefined
54
74
  let failure: PlugError | undefined
55
75
  for await (const event of events) {
56
76
  if (event.type === "text-delta") text += event.text
57
- else if (event.type === "finish") {
77
+ else if (event.type === "tool-call") {
78
+ if (exchange.structuredTool !== undefined && event.name === exchange.structuredTool) answer ??= args(event.input)
79
+ } else if (event.type === "finish") {
58
80
  usage = event.usage ?? usage
59
81
  if (event.reason === "error") failure ??= failedRun()
60
82
  else finish = event.reason
@@ -64,20 +86,31 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
64
86
  }
65
87
  }
66
88
  if (!failure && !finish) failure = noFinish()
67
- if (failure || !finish) {
68
- const error = failure ?? noFinish()
69
- return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
89
+ if (failure || !finish) return failure ?? noFinish()
90
+ if (exchange.structuredTool !== undefined) {
91
+ if (answer !== undefined) return { text: answer, reason: finish, usage }
92
+ const fallback = exchange.structuredSchema === undefined ? undefined : textFallback(text, exchange.structuredSchema)
93
+ if (fallback === undefined) return structuredMissing(exchange.structuredTool)
94
+ return { text: fallback, reason: finish, usage, fallback: true }
70
95
  }
71
96
  if (finish === "stop") text = stripStopTail(text, exchange.stop ?? []).text
97
+ return { text: exchange.jsonObject ? unfence(text) : text, reason: finish, usage }
98
+ }
99
+
100
+ /** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
101
+ async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<CollectedChoice> {
102
+ const settled = await settle(events, exchange)
103
+ if (isPlugError(settled)) return { status: settled.status, body: encodeError(settled), retryAfterSeconds: settled.retryAfterSeconds }
72
104
  return {
105
+ ...(settled.fallback ? { structuredFallback: true } : {}),
73
106
  status: 200,
74
107
  body: {
75
108
  id: `cmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`,
76
109
  object: "text_completion",
77
110
  created: now(),
78
111
  model: exchange.model,
79
- choices: [{ text: prefixOf(exchange, index) + text, index: 0, logprobs: null, finish_reason: FINISH_REASONS[finish] }],
80
- ...(usage ? { usage: encodeUsage(usage) } : {}),
112
+ choices: [{ text: prefixOf(exchange, index) + settled.text, index: 0, logprobs: null, finish_reason: FINISH_REASONS[settled.reason] }],
113
+ ...(settled.usage ? { usage: encodeUsage(settled.usage) } : {}),
81
114
  },
82
115
  }
83
116
  }
@@ -110,6 +143,15 @@ async function streamText(
110
143
  const prefix = prefixOf(exchange, choice.index)
111
144
  if (prefix.length > 0) chunk(prefix)
112
145
  }
146
+ if (exchange.structuredTool !== undefined || exchange.jsonObject) {
147
+ // A structured or json_object answer is only known at the end (the call, the fence), so it is written as one chunk.
148
+ const settled = await settle(events, exchange)
149
+ if (isPlugError(settled)) return { failure: settled, started: false }
150
+ start()
151
+ if (settled.text.length > 0) chunk(settled.text)
152
+ chunk("", FINISH_REASONS[settled.reason])
153
+ return { started, ...(settled.usage ? { usage: settled.usage } : {}) }
154
+ }
113
155
  // A stop sequence is held back until the finish decides it (src/stop.ts).
114
156
  const holdback = new StopHoldback(exchange.stop ?? [])
115
157
  let finish: Exclude<PlugFinish, "error"> | undefined
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ import { isPlugError, type PlugError } from "./contract.js"
6
6
  import { allowedBy, parseAliasTarget, parseModelRef, SKILL_PREFIX } from "./models.js"
7
7
  import { parseAllowEntry, type RemoteImages } from "./session/media.js"
8
8
  import type { HistoryLimits } from "./session/prune.js"
9
+ import { SEED_PREFIX } from "./session/seeds.js"
9
10
 
10
11
  /** How `/v1/models` spells a model id: the canonical `provider/model#variant`, a UI-safe slug, or both. Resolution accepts either. */
11
12
  export type ModelIds = "canonical" | "slugs" | "both"
@@ -88,6 +89,13 @@ export interface AppPolicy {
88
89
  readonly tools: boolean
89
90
  /** Lowers the global `maxTools` for this app. */
90
91
  readonly maxTools?: number
92
+ /**
93
+ * Exact OpenCode tool ids this app's sessions may run themselves (option `apps.<name>.serverTools`).
94
+ * Absent: none, the session denies every real tool. Trusted apps only, see docs/configuration.md.
95
+ */
96
+ readonly serverTools?: ReadonlyArray<string>
97
+ /** Server-tool steps one request may run before the turn is stopped (option `maxServerToolSteps`). */
98
+ readonly maxServerToolSteps?: number
91
99
  }
92
100
 
93
101
  /** One set of caps. A field that is not set is unlimited. */
@@ -106,8 +114,24 @@ export interface Limits {
106
114
  readonly maxConcurrent?: number
107
115
  readonly perApp?: LimitCaps
108
116
  readonly apps?: Readonly<Record<string, LimitCaps>>
117
+ /**
118
+ * Bytes of request bodies buffered at once, across every request in the process (`readJsonBody` in
119
+ * `src/http.ts`). A body that would go over it is refused with 503 `server_busy` before it is read. Not per app.
120
+ */
121
+ readonly maxBodyBytesInFlight?: number
122
+ /** Milliseconds a request body may take to arrive in full; a slower one is refused with 408. */
123
+ readonly bodyReadTimeoutMs?: number
109
124
  }
110
125
 
126
+ /** The bounds `readJsonBody` applies before it buffers a request body (see `bodyLimits`). */
127
+ export interface BodyLimits {
128
+ readonly maxBytesInFlight: number
129
+ readonly readTimeoutMs: number
130
+ }
131
+
132
+ /** Defaults of `limits.maxBodyBytesInFlight` (128 MiB) and `limits.bodyReadTimeoutMs` (30 s). */
133
+ export const BODY_LIMIT_DEFAULTS: BodyLimits = { maxBytesInFlight: 128 * 1024 * 1024, readTimeoutMs: 30_000 }
134
+
111
135
  /** What a budget counts: HTTP requests on the model routes (refusals by `limits` or budgets left out), tokens, or cost in USD. */
112
136
  export type BudgetMetric = "requests" | "tokens" | "costUsd"
113
137
  /** The windows a budget resets on: a UTC calendar day, an ISO week (Monday start) or a calendar month (UTC). */
@@ -138,6 +162,8 @@ export interface Budgets {
138
162
 
139
163
  export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
140
164
  export const DEFAULT_APP = "default"
165
+ /** Server-tool steps per request when `maxServerToolSteps` is not set. */
166
+ export const DEFAULT_SERVER_TOOL_STEPS = 8
141
167
  const DEFAULTS = {
142
168
  host: "127.0.0.1",
143
169
  port: 4010,
@@ -151,7 +177,7 @@ const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
151
177
  const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
152
178
  /** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
153
179
  const MAX_RETENTION_DAYS = 3650
154
- const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps"])
180
+ const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps", "maxBodyBytesInFlight", "bodyReadTimeoutMs"])
155
181
  const LIMIT_CAP_KNOWN = new Set(["maxConcurrent", "requestsPerMinute"])
156
182
  /** Largest cap accepted: far above any provider's limit, and keeps the error messages short. */
157
183
  const MAX_LIMIT = 1_000_000
@@ -224,7 +250,26 @@ function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
224
250
  return [...merged]
225
251
  }
226
252
 
227
- const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools"])
253
+ const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools", "serverTools", "maxServerToolSteps"])
254
+ const SERVER_TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/
255
+ const MAX_SERVER_TOOLS = 32
256
+ const MAX_SERVER_TOOL_STEPS = 100
257
+
258
+ /** `apps.<name>.serverTools`: exact OpenCode tool ids. No wildcards, no duplicates, and not the seeds or CodeMode's `execute`. */
259
+ function parseServerTools(value: unknown, at: string): string[] {
260
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_SERVER_TOOLS)
261
+ throw new Error(`${at}.serverTools must be an array of 1 to ${MAX_SERVER_TOOLS} OpenCode tool ids`)
262
+ const tools: string[] = []
263
+ for (const tool of value) {
264
+ if (typeof tool !== "string" || !SERVER_TOOL_PATTERN.test(tool))
265
+ throw new Error(`${at}.serverTools entry \`${String(tool)}\` must be an exact OpenCode tool id (letters, digits, \`_\`, \`.\`, \`:\`, \`-\`; no wildcards)`)
266
+ if (tool === "execute") throw new Error(`${at}.serverTools cannot name \`execute\`: it is OpenCode's CodeMode tool and would run every tool it wraps`)
267
+ if (tool.startsWith(SEED_PREFIX)) throw new Error(`${at}.serverTools cannot name \`${tool}\`: plugboard's own seed tools are reserved`)
268
+ if (tools.includes(tool)) throw new Error(`${at}.serverTools lists \`${tool}\` twice`)
269
+ tools.push(tool)
270
+ }
271
+ return tools
272
+ }
228
273
  const SYSTEM_MODES: ReadonlySet<string> = new Set(["prepend", "append", "replace"])
229
274
  const MODEL_IDS: ReadonlySet<string> = new Set(["canonical", "slugs", "both"])
230
275
 
@@ -259,6 +304,12 @@ function parseApps(
259
304
  if (value.systemMode !== undefined && value.system === undefined) throw new Error(`${at}.systemMode needs \`system\``)
260
305
  if (value.tools !== undefined && typeof value.tools !== "boolean") throw new Error(`${at}.tools must be a boolean`)
261
306
  const maxTools = value.maxTools === undefined ? undefined : positiveInt(value.maxTools, `apps.${app}.maxTools`, 0, context.maxTools)
307
+ if (value.serverTools !== undefined && app === DEFAULT_APP)
308
+ throw new Error(`${at}.serverTools needs a key: the keyless app cannot run OpenCode tools; name the app and give it a key`)
309
+ const serverTools = value.serverTools === undefined ? undefined : parseServerTools(value.serverTools, at)
310
+ if (value.maxServerToolSteps !== undefined && serverTools === undefined) throw new Error(`${at}.maxServerToolSteps needs \`serverTools\``)
311
+ const maxServerToolSteps =
312
+ value.maxServerToolSteps === undefined ? undefined : positiveInt(value.maxServerToolSteps, `apps.${app}.maxServerToolSteps`, DEFAULT_SERVER_TOOL_STEPS, MAX_SERVER_TOOL_STEPS)
262
313
 
263
314
  const policy: AppPolicy = {
264
315
  ...(value.models === undefined ? {} : { models: value.models as string[] }),
@@ -266,9 +317,12 @@ function parseApps(
266
317
  systemMode: (value.systemMode as SystemMode | undefined) ?? "prepend",
267
318
  tools: value.tools !== false,
268
319
  ...(maxTools === undefined ? {} : { maxTools }),
320
+ ...(serverTools === undefined ? {} : { serverTools }),
321
+ ...(maxServerToolSteps === undefined ? {} : { maxServerToolSteps }),
269
322
  }
270
323
  // An app that only carries a key has no policy to look up.
271
- if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined) entries.push([app, policy])
324
+ if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined || serverTools !== undefined)
325
+ entries.push([app, policy])
272
326
  }
273
327
  return Object.fromEntries(entries)
274
328
  }
@@ -303,6 +357,18 @@ function parseLimits(raw: unknown, keys: Readonly<Record<string, string>>): Limi
303
357
  ...(raw.maxConcurrent === undefined ? {} : { maxConcurrent: positiveInt(raw.maxConcurrent, "limits.maxConcurrent", 0, MAX_LIMIT) }),
304
358
  ...(raw.perApp === undefined ? {} : { perApp: parseCaps(raw.perApp, "limits.perApp") }),
305
359
  ...(appsRaw === undefined ? {} : { apps }),
360
+ ...(raw.maxBodyBytesInFlight === undefined
361
+ ? {}
362
+ : { maxBodyBytesInFlight: positiveInt(raw.maxBodyBytesInFlight, "limits.maxBodyBytesInFlight", 0, Number.MAX_SAFE_INTEGER) }),
363
+ ...(raw.bodyReadTimeoutMs === undefined ? {} : { bodyReadTimeoutMs: positiveInt(raw.bodyReadTimeoutMs, "limits.bodyReadTimeoutMs", 0, MAX_TIMER_MS) }),
364
+ }
365
+ }
366
+
367
+ /** The body limits in force for these options (`limits.maxBodyBytesInFlight`, `limits.bodyReadTimeoutMs`), or the defaults. */
368
+ export function bodyLimits(options: Pick<PlugboardOptions, "limits">): BodyLimits {
369
+ return {
370
+ maxBytesInFlight: options.limits?.maxBodyBytesInFlight ?? BODY_LIMIT_DEFAULTS.maxBytesInFlight,
371
+ readTimeoutMs: options.limits?.bodyReadTimeoutMs ?? BODY_LIMIT_DEFAULTS.readTimeoutMs,
306
372
  }
307
373
  }
308
374
 
package/src/contract.ts CHANGED
@@ -194,6 +194,12 @@ export interface CallContext {
194
194
  * headers are open, so a stream (headers sent first) never gets it.
195
195
  */
196
196
  readonly onUsage?: (totals: CallTotals) => void
197
+ /**
198
+ * Called once when the call ends, only if OpenCode ran server tools for it (option `apps.<name>.serverTools`),
199
+ * with how many. Front-ends set `x-plugboard-server-tools` from it while the headers are open, so a stream
200
+ * (headers sent first) never gets it, and a choice of `n` adds to the count of the others.
201
+ */
202
+ readonly onServerTools?: (count: number) => void
197
203
  }
198
204
 
199
205
  /** One answered call's totals for the caller: `inputTokens` and `outputTokens` as the response body reports them (cache reads inside input, reasoning inside output), and `costUsd` when the model has a price. */
package/src/discovery.ts CHANGED
@@ -67,6 +67,7 @@ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
67
67
  { method: "GET", path: "/", auth: false, description: "This page." },
68
68
  { method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
69
69
  { method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
70
+ { method: "GET", path: "/readyz", auth: false, description: "Readiness: {\"ready\":true}, or 503 {\"ready\":false,\"reason\"} until the backend can serve." },
70
71
  { method: "GET", path: "/v1/models", auth: true, description: "Models this app may call (OpenAI shape)." },
71
72
  { method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
72
73
  { method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
@@ -284,6 +285,29 @@ ${sections.join("\n")}
284
285
  `
285
286
  }
286
287
 
288
+ /** The facts `/readyz` decides from (`RuntimeStatus` in src/runtime.ts). */
289
+ export interface ReadinessFacts {
290
+ readonly generations: number
291
+ readonly servingConfirmed: boolean
292
+ readonly pumpRunning: boolean
293
+ readonly catalogLoaded: boolean
294
+ readonly configError?: { readonly message: string }
295
+ }
296
+
297
+ /**
298
+ * `GET /readyz` (#160): whether the backend can serve a request. The reason names the first piece
299
+ * that is missing, in the order a person fixes them. A recorded options error comes first and is
300
+ * its own message (#159); nothing else from the config is shown.
301
+ */
302
+ export function readiness(facts: ReadinessFacts): { ready: true } | { ready: false; reason: string } {
303
+ if (facts.configError) return { ready: false, reason: facts.configError.message }
304
+ if (facts.generations === 0) return { ready: false, reason: "no plugboard generation is live; the server is reloading or the plugin is not loaded" }
305
+ if (!facts.servingConfirmed) return { ready: false, reason: "no app directory has been used since start; one model request to an app confirms it" }
306
+ if (!facts.pumpRunning) return { ready: false, reason: "the event pump is not running; the next request to an app starts it" }
307
+ if (!facts.catalogLoaded) return { ready: false, reason: "the model catalog has not loaded yet" }
308
+ return { ready: true }
309
+ }
310
+
287
311
  /** `GET /v1/status`: authenticated runtime facts. `models` is absent when the catalog could not be loaded. */
288
312
  export function buildStatus(input: {
289
313
  readonly options: PlugboardOptions
@@ -292,6 +316,7 @@ export function buildStatus(input: {
292
316
  readonly runtimeVersion: number
293
317
  readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
294
318
  readonly servingConfirmed: boolean
319
+ readonly configError?: { readonly message: string; readonly at: number }
295
320
  }
296
321
  readonly catalog?: ReadonlyArray<CatalogModel>
297
322
  readonly now?: number
@@ -311,5 +336,7 @@ export function buildStatus(input: {
311
336
  sessionGuards: runtime.guards,
312
337
  models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog, app).length } : { error: "catalog_unavailable" },
313
338
  metrics: options.metrics,
339
+ // The plugin options error the last Location to load the plugin hit (#159); absent when options parse.
340
+ ...(runtime.configError ? { configError: { message: runtime.configError.message, at: new Date(runtime.configError.at).toISOString() } } : {}),
314
341
  }
315
342
  }
@@ -351,7 +351,7 @@ const parseGeneration = (config: JsonObject): PlugGeneration | PlugError => {
351
351
  }
352
352
 
353
353
  /** `responseMimeType` / `responseSchema` / `responseJsonSchema`: same outcomes as `response_format` in the other front-ends. */
354
- const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; wrap?: boolean; instruction?: string } | PlugError => {
354
+ const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; schema?: JsonObject; wrap?: boolean; instruction?: string } | PlugError => {
355
355
  const mime = field(config, "responseMimeType")
356
356
  const schema = field(config, "responseJsonSchema") ?? field(config, "responseSchema")
357
357
  if (mime != null && typeof mime !== "string") return bad("responseMimeType must be a string", "generationConfig.responseMimeType")
@@ -364,6 +364,7 @@ const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; wrap?
364
364
  const wrap = converted.type !== undefined && converted.type !== "object"
365
365
  return {
366
366
  wrap,
367
+ schema: converted,
367
368
  structured: {
368
369
  name: RESPOND_TOOL,
369
370
  description: "Give your final answer by calling this tool with JSON matching its schema.",
@@ -437,7 +438,7 @@ export function parseGeminiRequest(model: string, stream: boolean, body: unknown
437
438
  exchange: {
438
439
  model,
439
440
  stream,
440
- ...(respond ? { structuredTool: RESPOND_TOOL, ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
441
+ ...(respond ? { structuredTool: RESPOND_TOOL, ...(format.schema ? { structuredSchema: format.schema } : {}), ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
441
442
  ...(format.instruction ? { jsonObject: true } : {}),
442
443
  ...(generation.stop ? { stop: generation.stop } : {}),
443
444
  },
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
10
10
  import type { ListedModel } from "../models.js"
11
- import { unfence } from "../openai/response.js"
11
+ import { structuredMissing, textFallback, unfence } from "../openai/response.js"
12
12
  import { StopHoldback } from "../stop.js"
13
13
  import { WRAP_KEY } from "./request.js"
14
14
 
@@ -33,6 +33,8 @@ export interface GeminiExchange {
33
33
  readonly jsonObject?: boolean
34
34
  /** The request's `generationConfig.stopSequences`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). Not applied to structured output. */
35
35
  readonly stop?: ReadonlyArray<string>
36
+ /** The caller's schema as sent (converted): a text answer is taken when the model skipped the call (`textFallback`, #173). */
37
+ readonly structuredSchema?: Readonly<Record<string, unknown>>
36
38
  }
37
39
 
38
40
  /** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
@@ -42,6 +44,8 @@ export interface GenerateRun {
42
44
  readonly started: boolean
43
45
  /** The aggregated response (non-streaming body). Absent on failure. */
44
46
  readonly response?: object
47
+ /** The structured answer came from the final text, not the respond call (`textFallback`). */
48
+ readonly structuredFallback?: boolean
45
49
  }
46
50
 
47
51
  type Part = Record<string, unknown>
@@ -121,20 +125,6 @@ const structuredText = (exchange: GeminiExchange, call: ToolCall): string => {
121
125
  return typeof input === "string" && !exchange.wrapped ? input : JSON.stringify(input ?? {})
122
126
  }
123
127
 
124
- /**
125
- * Structured-output projection, as in the other front-ends: the text is exactly the first
126
- * `respond` call; prose and other calls are suppressed. Without an answer the exchange fails,
127
- * except when the model stopped for `length` or `content-filter`.
128
- */
129
- const structuredFailure = (exchange: GeminiExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
130
- exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
131
- ? undefined
132
- : {
133
- status: 502,
134
- kind: "server",
135
- code: "structured_output_missing",
136
- message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
137
- }
138
128
 
139
129
  export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: GeminiExchange, emit: (chunk: object) => void): Promise<GenerateRun> {
140
130
  const responseId = crypto.randomUUID().replaceAll("-", "").slice(0, 22)
@@ -145,11 +135,15 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
145
135
  responseId,
146
136
  })
147
137
 
138
+ // Only a non-streamed body needs the aggregated parts: a stream is already on the wire, so nothing grows (#153).
139
+ const aggregate = !exchange.stream
148
140
  /** The aggregated parts: adjacent text merges into one part. */
149
141
  const parts: Part[] = []
150
142
  let started = false
151
143
  let calls = 0
152
144
  let answered = false
145
+ /** The text of a structured turn (not emitted): it is the answer only if the respond call never comes (#173). */
146
+ let collected = ""
153
147
  /** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
154
148
  let held: ToolCall | undefined
155
149
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -158,9 +152,11 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
158
152
 
159
153
  const push = (part: Part) => {
160
154
  started = true
161
- const last = parts.at(-1)
162
- if (typeof part.text === "string" && last && typeof last.text === "string") last.text += part.text
163
- else parts.push(part)
155
+ if (aggregate) {
156
+ const last = parts.at(-1)
157
+ if (typeof part.text === "string" && last && typeof last.text === "string") last.text += part.text
158
+ else parts.push(part)
159
+ }
164
160
  emit(envelope([part]))
165
161
  }
166
162
  // A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
@@ -212,6 +208,7 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
212
208
  }
213
209
  continue
214
210
  }
211
+ if (event.type === "text-delta") collected += event.text
215
212
  if (!(exchange.callerTools && event.type === "tool-call")) continue
216
213
  }
217
214
  if (exchange.jsonObject && event.type === "text-delta" && !passing) {
@@ -229,7 +226,9 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
229
226
  }
230
227
 
231
228
  const passthrough = exchange.callerTools === true && calls > 0
232
- failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
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)
231
+ failure ??= finish ? missing : noFinish()
233
232
  if (failure) return { failure, started }
234
233
  flush()
235
234
  if (holdback) {
@@ -237,12 +236,13 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
237
236
  if (rest.length > 0) push({ text: rest })
238
237
  }
239
238
  if (held && !passthrough) text(structuredText(exchange, held))
239
+ if (fallback !== undefined) text(fallback)
240
240
  const candidate = { finishReason: FINISH_REASONS[finish!] }
241
241
  const extra = usage ? { usageMetadata: encodeUsage(usage) } : {}
242
242
  // The last chunk carries the finish reason and usage, with an empty text part as Google's does.
243
243
  emit(envelope([{ text: "" }], extra, candidate))
244
244
  started = true
245
- return { started, response: envelope(parts.length > 0 ? parts : [{ text: "" }], extra, candidate) }
245
+ return { started, response: envelope(parts.length > 0 ? parts : [{ text: "" }], extra, candidate), ...(fallback !== undefined ? { structuredFallback: true } : {}) }
246
246
  }
247
247
 
248
248
  /** SSE frame of one stream chunk (`alt=sse`): `data: <json>`, no event name and no `[DONE]`. */
@@ -17,13 +17,15 @@
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
21
  import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
21
22
  import { listAvailableModels, resolveModel } from "../models.js"
22
23
  import { reportCache } from "../cache.js"
23
- import { reportPolicy } from "../policy.js"
24
+ import { reportPolicy, reportServerTools } from "../policy.js"
24
25
  import { reportUsage } from "../usage-header.js"
25
26
  import { setSessionLink } from "../session/link.js"
26
27
  import type { RouteHost } from "../openai/route.js"
28
+ import { STRUCTURED_FALLBACK_HEADER } from "../openai/response.js"
27
29
  import { dropRejected } from "../openai/sampling.js"
28
30
  import { estimateTokens } from "../anthropic/tokens.js"
29
31
  import { parseGeminiRequest } from "./request.js"
@@ -112,7 +114,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
112
114
  if (match.op === "count") {
113
115
  const unsupported = checkJsonContentType(request)
114
116
  if (unsupported) return fail(unsupported)
115
- const parsed = parseGeminiRequest(match.model, false, countBody(await readJsonBody(request, scope.signal)), host.options)
117
+ const parsed = parseGeminiRequest(match.model, false, countBody(await readJsonBody(request, scope.signal, bodyLimits(host.options))), host.options)
116
118
  if (isPlugError(parsed)) return fail(parsed)
117
119
  const catalog = await scope.race(host.loadCatalog())
118
120
  const ref = resolveModel(host.options, match.model, catalog, parsed.reasoningEffort)
@@ -135,7 +137,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
135
137
  return fail({ status: 400, kind: "invalid_request", code: "unsupported_parameter", param: "alt", message: "streamGenerateContent needs the query parameter alt=sse" })
136
138
  const unsupported = checkJsonContentType(request)
137
139
  if (unsupported) return fail(unsupported)
138
- const body = await readJsonBody(request, scope.signal)
140
+ const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
139
141
 
140
142
  const parsed = parseGeminiRequest(match.model, match.stream, body, host.options)
141
143
  if (isPlugError(parsed)) return fail(parsed)
@@ -166,6 +168,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
166
168
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
167
169
  onPolicy: reportPolicy(response),
168
170
  onUsage: reportUsage(response, match.stream),
171
+ onServerTools: reportServerTools(response, match.stream),
169
172
  onCache: reportCache(response),
170
173
  }),
171
174
  scope,
@@ -189,5 +192,6 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
189
192
 
190
193
  const run = await runGenerate(events, exchange, () => {})
191
194
  if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
195
+ if (run.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
192
196
  sendJson(response, 200, run.response)
193
197
  }