opencode-plugboard 0.20.0 → 0.21.1

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/src/http.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { IncomingMessage, ServerResponse } from "node:http"
8
8
  import { raceAbort } from "./abort.js"
9
- import { isLoopbackHost, type PlugboardOptions } from "./config.js"
9
+ import { BODY_LIMIT_DEFAULTS, type BodyLimits, isLoopbackHost, type PlugboardOptions } from "./config.js"
10
10
  import type { PlugError, PlugEvent } from "./contract.js"
11
11
 
12
12
  const MAX_BODY_BYTES = 32 * 1024 * 1024
@@ -19,16 +19,44 @@ const DEADLINE_GRACE_MS = 2_000
19
19
  /** An SSE stream idle for this long gets a comment line, so a client with a header or idle timeout keeps waiting for a model that is still reasoning. */
20
20
  const KEEPALIVE_MS = 15_000
21
21
 
22
- /** A request body that cannot be accepted: too large, or not JSON. `status` is the HTTP status to answer with. */
22
+ /**
23
+ * A request body that cannot be accepted: too large, not JSON, not complete in time (408), or refused
24
+ * because the process has no room for it (503). `status` is the HTTP status to answer with; `code` and
25
+ * `retryAfterSeconds` go to the wire (`bodyPlugError`).
26
+ */
23
27
  export class BodyError extends Error {
24
28
  constructor(
25
29
  readonly status: number,
26
30
  message: string,
31
+ readonly code?: string,
32
+ readonly retryAfterSeconds?: number,
27
33
  ) {
28
34
  super(message)
29
35
  }
30
36
  }
31
37
 
38
+ /** The `PlugError` a refused body answers with: `server_busy` (503) is a server kind, a slow body (408) a timeout. */
39
+ export function bodyPlugError(error: BodyError): PlugError {
40
+ const kind: PlugError["kind"] = error.status === 503 ? "server" : error.status === 408 ? "timeout" : "invalid_request"
41
+ return {
42
+ status: error.status,
43
+ kind,
44
+ message: error.message,
45
+ ...(error.code === undefined ? {} : { code: error.code }),
46
+ ...(error.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: error.retryAfterSeconds }),
47
+ }
48
+ }
49
+
50
+ /** Process-wide bytes of request bodies being read. On `globalThis` under a versioned key (I6), so every plugin generation shares one budget. */
51
+ interface BodyBudget {
52
+ inFlight: number
53
+ }
54
+ const BODY_BUDGET = Symbol.for("opencode-plugboard.body-budget/1")
55
+ const bodyBudget = (): BodyBudget => {
56
+ const slot = globalThis as Record<symbol, BodyBudget | undefined>
57
+ return (slot[BODY_BUDGET] ??= { inFlight: 0 })
58
+ }
59
+
32
60
  /** Why a request's work was abandoned: the client went away, or the deadline passed. It is the abort reason of `RequestScope.signal`. */
33
61
  export class RequestAborted extends Error {
34
62
  constructor(readonly kind: "disconnected" | "timeout") {
@@ -105,21 +133,40 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
105
133
  }
106
134
 
107
135
  /**
108
- * Reads the body as JSON (an empty body is `{}`). Rejects with `BodyError` (413, 400), the abort
109
- * reason of `signal` or the stream's error. On rejection the rest of the body is discarded, not
136
+ * Reads the body as JSON (an empty body is `{}`). Rejects with `BodyError` (413, 400, 408 or 503), the
137
+ * abort reason of `signal` or the stream's error. On rejection the rest of the body is discarded, not
110
138
  * buffered, so the response can still be delivered (see `finishRequest`).
139
+ *
140
+ * Bounded before it is buffered (#156, invariant I28). The bytes held by every body being read in the
141
+ * process stay within `limits.maxBytesInFlight`: a declared Content-Length is charged before the first
142
+ * byte is read, a body without one as it arrives, and a body that would go over is refused with 503
143
+ * `server_busy` (Retry-After 1). A body not complete within `limits.readTimeoutMs` is refused with 408.
111
144
  */
112
- export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Promise<unknown> {
145
+ export function readJsonBody(request: IncomingMessage, signal: AbortSignal, limits: BodyLimits = BODY_LIMIT_DEFAULTS): Promise<unknown> {
113
146
  return new Promise((resolve, reject) => {
114
- const declared = Number(request.headers["content-length"])
115
- if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
147
+ const declared = Math.max(0, Number(request.headers["content-length"]) || 0)
148
+ if (declared > MAX_BODY_BYTES) {
116
149
  request.resume()
117
150
  return reject(new BodyError(413, "Request body too large"))
118
151
  }
119
152
  if (signal.aborted) return reject(signal.reason)
153
+ const budget = bodyBudget()
120
154
  const chunks: Buffer[] = []
121
155
  let size = 0
156
+ // Bytes this read holds in the budget. Released on every exit: complete, refused or abandoned.
157
+ let held = 0
158
+ const charge = (bytes: number): boolean => {
159
+ if (budget.inFlight + bytes > limits.maxBytesInFlight) return false
160
+ budget.inFlight += bytes
161
+ held += bytes
162
+ return true
163
+ }
164
+ const busy = () => new BodyError(503, "Too many request bodies are being read; retry shortly", "server_busy", 1)
165
+ let timer: ReturnType<typeof setTimeout> | undefined
122
166
  const cleanup = () => {
167
+ clearTimeout(timer)
168
+ budget.inFlight -= held
169
+ held = 0
123
170
  request.off("data", onData)
124
171
  request.off("error", onError)
125
172
  request.off("end", onEnd)
@@ -134,6 +181,7 @@ export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Pro
134
181
  const onData = (chunk: Buffer) => {
135
182
  size += chunk.length
136
183
  if (size > MAX_BODY_BYTES) return stop(new BodyError(413, "Request body too large"))
184
+ if (size > held && !charge(size - held)) return stop(busy())
137
185
  chunks.push(chunk)
138
186
  }
139
187
  const onError = (error: Error) => stop(error)
@@ -146,6 +194,16 @@ export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Pro
146
194
  reject(new BodyError(400, "Request body is not valid JSON"))
147
195
  }
148
196
  }
197
+ // The declared length is charged before the first byte is read, so a refusal costs no buffering.
198
+ if (declared > 0 && !charge(declared)) {
199
+ request.resume()
200
+ return reject(busy())
201
+ }
202
+ timer = setTimeout(
203
+ () => stop(new BodyError(408, `Request body was not received within ${limits.readTimeoutMs} ms (option limits.bodyReadTimeoutMs)`, "body_read_timeout")),
204
+ limits.readTimeoutMs,
205
+ )
206
+ timer.unref?.()
149
207
  request.on("data", onData)
150
208
  request.on("error", onError)
151
209
  request.on("end", onEnd)
@@ -24,11 +24,21 @@ import type { ServerResponse } from "node:http"
24
24
  import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.js"
25
25
  import { reportServed, type ServedInfo } from "../fallback.js"
26
26
  import { reportCache } from "../cache.js"
27
- import { reportPolicy } from "../policy.js"
27
+ import { reportPolicy, reportServerTools } from "../policy.js"
28
28
  import { reportUsage } from "../usage-header.js"
29
29
  import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
30
30
  import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
31
- import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
31
+ import {
32
+ STRUCTURED_FALLBACK_HEADER,
33
+ chatUsageChunk,
34
+ collectCompletion,
35
+ encodeError,
36
+ streamCompletion,
37
+ type CollectedChoice,
38
+ type OpenAiExchange,
39
+ type StreamChoice,
40
+ type StreamResult,
41
+ } from "./response.js"
32
42
  import type { RouteHost } from "./route.js"
33
43
 
34
44
  const SESSION_HEADER = "x-plugboard-session"
@@ -40,7 +50,7 @@ export interface ChoiceCodec {
40
50
  /** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
41
51
  readonly idPrefix: string
42
52
  /** One choice as a complete JSON body with `choices[0]` and `usage`. */
43
- collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }>
53
+ collect(events: AsyncIterable<PlugEvent>, index: number): Promise<CollectedChoice>
44
54
  /** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
45
55
  streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
46
56
  /** The final chunk with the summed usage (`include_usage`). */
@@ -175,6 +185,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
175
185
  const policy = reportPolicy(response)
176
186
  // One header for all choices: each answered call adds its tokens and cost (`x-plugboard-usage`, #52).
177
187
  const usageHeader = reportUsage(response, codec.stream)
188
+ // One header for all choices too: the server tool calls of every choice add up (`x-plugboard-server-tools`).
189
+ const serverToolsHeader = reportServerTools(response, codec.stream)
178
190
  // Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
179
191
  const cache = reportCache(response)
180
192
  const open = (index: number) =>
@@ -195,6 +207,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
195
207
  onServed: served,
196
208
  onPolicy: policy,
197
209
  onUsage: usageHeader,
210
+ onServerTools: serverToolsHeader,
198
211
  onCache: cache,
199
212
  noCache: calls.length > 1,
200
213
  }),
@@ -202,8 +215,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
202
215
  )
203
216
 
204
217
  if (!codec.stream) {
205
- const results = await new Promise<Array<{ status: number; body: object; retryAfterSeconds?: number }>>((resolve, reject) => {
206
- const done: Array<{ status: number; body: object; retryAfterSeconds?: number }> = []
218
+ const results = await new Promise<CollectedChoice[]>((resolve, reject) => {
219
+ const done: CollectedChoice[] = []
207
220
  let left = calls.length
208
221
  calls.forEach((_, index) => {
209
222
  codec.collect(open(index), index).then(
@@ -223,6 +236,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
223
236
  })
224
237
  })
225
238
  const first = results[0]!
239
+ if (results.some((result) => result.structuredFallback)) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
226
240
  return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
227
241
  }
228
242
 
@@ -32,7 +32,7 @@ export interface ParsedChat {
32
32
  }
33
33
 
34
34
  /** Name of the forced tool that carries structured output. */
35
- const RESPOND_TOOL = "respond"
35
+ export const RESPOND_TOOL = "respond"
36
36
 
37
37
  /** The most stop sequences OpenAI accepts. */
38
38
  const MAX_STOP = 4
@@ -196,14 +196,14 @@ const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError =
196
196
  }
197
197
 
198
198
  /** System instruction for `json_object`: no schema, so the caller's prompt describes the keys (OpenAI semantics). */
199
- const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
199
+ export const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
200
200
 
201
201
  /**
202
202
  * `json_schema` -> the `respond` tool (`structured`; forced unless the caller also sends tools).
203
203
  * `json_object` -> a system instruction and plain text content (`instruction`): there is no schema
204
204
  * to force a tool with.
205
205
  */
206
- const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
206
+ export const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
207
207
  if (raw == null) return {}
208
208
  if (!isRecord(raw)) return bad("response_format must be an object", "response_format")
209
209
  if (raw.type === "text") return {}
@@ -339,7 +339,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
339
339
  model: body.model,
340
340
  stream: body.stream === true,
341
341
  includeUsage: streamOptions.include_usage === true,
342
- ...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
342
+ ...(respond ? { structuredTool: RESPOND_TOOL, structuredSchema: respond.parameters, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
343
343
  ...(format.instruction ? { jsonObject: true } : {}),
344
344
  ...(generation.stop ? { stop: generation.stop } : {}),
345
345
  },
@@ -36,6 +36,20 @@ export interface OpenAiExchange {
36
36
  readonly jsonObject?: boolean
37
37
  /** The request's `stop`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). Not applied to structured output. */
38
38
  readonly stop?: ReadonlyArray<string>
39
+ /** The caller's `json_schema`, checked against a text answer when the model skipped the respond call (`textFallback`). */
40
+ readonly structuredSchema?: Readonly<Record<string, unknown>>
41
+ }
42
+
43
+ /** Set on a complete answer that came from the final text, not the respond call (`textFallback`). */
44
+ export const STRUCTURED_FALLBACK_HEADER = "x-plugboard-structured-fallback"
45
+
46
+ /** One choice as a complete JSON body, with the status to send it with. */
47
+ export interface CollectedChoice {
48
+ readonly status: number
49
+ readonly body: object
50
+ readonly retryAfterSeconds?: number
51
+ /** The structured answer was taken from the text (the response carries `STRUCTURED_FALLBACK_HEADER`). */
52
+ readonly structuredFallback?: boolean
39
53
  }
40
54
 
41
55
  /** How a streamed exchange ended. Nothing was written when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
@@ -132,7 +146,7 @@ const encodeUsage = (usage: PlugUsage) => {
132
146
  }
133
147
  }
134
148
 
135
- const args = (input: unknown) => (typeof input === "string" ? input : JSON.stringify(input ?? {}))
149
+ export const args = (input: unknown) => (typeof input === "string" ? input : JSON.stringify(input ?? {}))
136
150
 
137
151
  /**
138
152
  * Final finish_reason. Tool calls force `tool_calls` even when the upstream says `stop`;
@@ -152,28 +166,99 @@ const noFinish = (): PlugError => ({
152
166
 
153
167
  const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
154
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 => ({
171
+ status: 502,
172
+ kind: "server",
173
+ code: "structured_output_missing",
174
+ message: `The model finished without calling the \`${tool}\` tool, so there is no structured output`,
175
+ })
176
+
155
177
  /**
156
178
  * Structured-output projection, shared by the JSON and SSE encoders so both give the same body.
157
179
  * In structured mode (`exchange.structuredTool` set) the content is exactly the arguments of
158
180
  * the first call to that tool: prose, reasoning and any other tool call are suppressed, and
159
- * later calls to it are ignored. Without an answer the exchange fails, except when the model
160
- * stopped for `length` or `content-filter`, which keep their finish reasons with empty content.
181
+ * later calls to it are ignored. Without an answer the exchange fails with 502 (#172): a turn that
182
+ * ends with no structured output is never a 200 with empty content, whatever its finish reason.
161
183
  */
162
184
  const structured = {
163
185
  isAnswer: (exchange: OpenAiExchange, call: ToolCall) => exchange.structuredTool !== undefined && call.name === exchange.structuredTool,
164
186
  content: (call: ToolCall) => args(call.input),
165
187
  /** The failure for a finished structured exchange, if any. */
166
- failure: (exchange: OpenAiExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
167
- exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
168
- ? undefined
169
- : {
170
- status: 502,
171
- kind: "server",
172
- code: "structured_output_missing",
173
- message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
174
- },
188
+ failure: (exchange: OpenAiExchange, answered: boolean): PlugError | undefined =>
189
+ exchange.structuredTool === undefined || answered ? undefined : structuredMissing(exchange.structuredTool),
175
190
  }
176
191
 
192
+ const isObjectValue = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
193
+
194
+ const hasOwn = (value: Record<string, unknown>, key: string) => Object.prototype.hasOwnProperty.call(value, key)
195
+
196
+ /** Whether a parsed JSON value has one JSON Schema type (`integer` is a whole number; an unknown type matches anything). */
197
+ const hasType = (value: unknown, type: unknown): boolean => {
198
+ switch (type) {
199
+ case "object":
200
+ return isObjectValue(value)
201
+ case "array":
202
+ return Array.isArray(value)
203
+ case "string":
204
+ return typeof value === "string"
205
+ case "number":
206
+ return typeof value === "number"
207
+ case "integer":
208
+ return Number.isInteger(value)
209
+ case "boolean":
210
+ return typeof value === "boolean"
211
+ case "null":
212
+ return value === null
213
+ default:
214
+ return true
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The part of JSON Schema checked on a text answer. The runtime carries no schema library (ajv is a
220
+ * dev dependency), so this is deliberately small: the top-level `type` (one type or a list), the
221
+ * top-level `required` keys, and the `type` of each top-level property that is present. Anything
222
+ * else in the schema (enum, nested objects, formats, additionalProperties) is not checked.
223
+ */
224
+ export function matchesSchema(value: unknown, schema: Readonly<Record<string, unknown>>): boolean {
225
+ const types = Array.isArray(schema.type) ? schema.type : schema.type === undefined ? undefined : [schema.type]
226
+ if (types !== undefined && !types.some((type) => hasType(value, type))) return false
227
+ if (!isObjectValue(value)) return true
228
+ const required = Array.isArray(schema.required) ? schema.required : []
229
+ if (!required.every((key) => typeof key === "string" && hasOwn(value, key))) return false
230
+ if (!isObjectValue(schema.properties)) return true
231
+ return Object.entries(schema.properties).every(([key, property]) => {
232
+ if (!hasOwn(value, key) || !isObjectValue(property) || property.type === undefined) return true
233
+ const propertyTypes = Array.isArray(property.type) ? property.type : [property.type]
234
+ return propertyTypes.some((type) => hasType(value[key], type))
235
+ })
236
+ }
237
+
238
+ /**
239
+ * The answer of a structured turn that never called the respond tool, when its final text is the JSON
240
+ * the schema asks for (one code fence is stripped first). Undefined for any other text.
241
+ */
242
+ export function textFallback(text: string, schema: Readonly<Record<string, unknown>>): string | undefined {
243
+ // The whole text (fenced or not), else the one fenced block inside prose: Claude, told not to call
244
+ // tools, skips `respond` and answers "Here is the JSON: ```json {...}```" (found by the live suite).
245
+ const fenced = [...text.matchAll(FENCE_ANYWHERE)].map((match) => match[1]!.trim())
246
+ const candidates = [unfence(text.trim()), ...(fenced.length === 1 ? fenced : [])]
247
+ for (const candidate of candidates) {
248
+ let value: unknown
249
+ try {
250
+ value = JSON.parse(candidate)
251
+ } catch {
252
+ continue
253
+ }
254
+ if (matchesSchema(value, schema)) return candidate
255
+ }
256
+ return undefined
257
+ }
258
+
259
+ /** Every fenced code block (```json or a bare ```) anywhere in the text. */
260
+ const FENCE_ANYWHERE = /```[ \t]*(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n?[ \t]*```/gi
261
+
177
262
  /** One markdown code fence (```json or a bare ```) around the whole text. */
178
263
  const FENCED = /^```[ \t]*(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n?[ \t]*```$/i
179
264
 
@@ -195,10 +280,7 @@ export function unfence(text: string): string {
195
280
  const newId = () => `chatcmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`
196
281
  const now = () => Math.floor(Date.now() / 1000)
197
282
 
198
- export async function collectCompletion(
199
- events: AsyncIterable<PlugEvent>,
200
- exchange: OpenAiExchange,
201
- ): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
283
+ export async function collectCompletion(events: AsyncIterable<PlugEvent>, exchange: OpenAiExchange): Promise<CollectedChoice> {
202
284
  let text = ""
203
285
  const calls: ToolCall[] = []
204
286
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -229,7 +311,8 @@ export async function collectCompletion(
229
311
  // With the caller's tools offered, a call to one of them wins over the structured answer.
230
312
  const passthrough = exchange.callerTools === true && others.length > 0
231
313
  const isStructured = exchange.structuredTool !== undefined && !passthrough
232
- const missing = passthrough ? undefined : structured.failure(exchange, finish, answer !== undefined)
314
+ const fallback = isStructured && answer === undefined && exchange.structuredSchema !== undefined ? textFallback(text, exchange.structuredSchema) : undefined
315
+ const missing = passthrough ? undefined : structured.failure(exchange, answer !== undefined || fallback !== undefined)
233
316
  if (missing) return { status: missing.status, body: encodeError(missing) }
234
317
  const toolCalls = isStructured ? [] : others
235
318
  const message: Record<string, unknown> = {
@@ -237,7 +320,7 @@ export async function collectCompletion(
237
320
  content: isStructured
238
321
  ? answer
239
322
  ? structured.content(answer)
240
- : null
323
+ : (fallback ?? null)
241
324
  : exchange.structuredTool === undefined && text.length > 0
242
325
  ? exchange.jsonObject
243
326
  ? unfence(text)
@@ -261,6 +344,7 @@ export async function collectCompletion(
261
344
  choices: [{ index: 0, message, logprobs: null, finish_reason: finishReason(finish, toolCalls.length, isStructured) }],
262
345
  ...(usage ? { usage: encodeUsage(usage) } : {}),
263
346
  },
347
+ ...(fallback !== undefined ? { structuredFallback: true } : {}),
264
348
  }
265
349
  }
266
350
 
@@ -303,6 +387,8 @@ export async function streamCompletion(
303
387
  let started = false
304
388
  let calls = 0
305
389
  let answered = false
390
+ /** The text of a structured turn (not streamed): it is the answer only if the respond call never comes. */
391
+ let collected = ""
306
392
  /** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
307
393
  let held: ToolCall | undefined
308
394
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -358,6 +444,7 @@ export async function streamCompletion(
358
444
  }
359
445
  continue
360
446
  }
447
+ if (event.type === "text-delta") collected += event.text
361
448
  if (!(exchange.callerTools && event.type === "tool-call")) continue
362
449
  }
363
450
  if (exchange.jsonObject && event.type === "text-delta" && !passing) {
@@ -385,7 +472,8 @@ export async function streamCompletion(
385
472
 
386
473
  const passthrough = exchange.callerTools === true && calls > 0
387
474
  const isStructured = exchange.structuredTool !== undefined && !passthrough
388
- failure ??= finish ? (passthrough ? undefined : structured.failure(exchange, finish, answered)) : noFinish()
475
+ const fallback = isStructured && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
476
+ failure ??= finish ? (passthrough ? undefined : structured.failure(exchange, answered || fallback !== undefined)) : noFinish()
389
477
  if (failure) {
390
478
  if (!started) return { failure, started }
391
479
  if (!choice) {
@@ -401,6 +489,7 @@ export async function streamCompletion(
401
489
  if (rest.length > 0) chunk({ content: rest })
402
490
  }
403
491
  if (held && isStructured) chunk({ content: structured.content(held) })
492
+ if (fallback !== undefined) chunk({ content: fallback })
404
493
  chunk({}, finishReason(finish!, calls, isStructured))
405
494
  if (choice) return { started, ...(usage ? { usage } : {}) }
406
495
  if (exchange.includeUsage && usage) frame(chatUsageChunk(exchange.model, id, created, usage))
@@ -1,5 +1,5 @@
1
1
  /**
2
- * OpenAI front-end route table: `GET /healthz`, `GET /v1/models`, `GET /v1/models/{id}`,
2
+ * OpenAI front-end route table: `GET /healthz`, `GET /readyz`, `GET /v1/models`, `GET /v1/models/{id}`,
3
3
  * `POST /v1/chat/completions` (`n` > 1 through `./fanout.ts`), and the dispatch of `POST /v1/responses`
4
4
  * and `POST /v1/completions` to `src/responses/route.ts` and `src/completions/route.ts`.
5
5
  * Observability and first-run routes (`src/discovery.ts`, `src/metrics.ts`): `GET /` and `GET /v1`
@@ -22,11 +22,12 @@
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, type PlugboardOptions } from "../config.js"
25
+ import { DEFAULT_APP, authenticate, authenticateUsage, bodyLimits, type PlugboardOptions } from "../config.js"
26
26
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
27
- import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
27
+ import { buildDiscovery, buildStatus, readiness, renderDiscoveryHtml, sendText } from "../discovery.js"
28
28
  import {
29
29
  BodyError,
30
+ bodyPlugError,
30
31
  RequestAborted,
31
32
  beginRequest,
32
33
  boundEvents,
@@ -46,7 +47,7 @@ import { errorMessage, log, logError, withRequestId } from "../log.js"
46
47
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
47
48
  import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
48
49
  import { reportCache } from "../cache.js"
49
- import { reportPolicy } from "../policy.js"
50
+ import { reportPolicy, reportServerTools } from "../policy.js"
50
51
  import { reportUsage } from "../usage-header.js"
51
52
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
52
53
  import { serveCompletions } from "../completions/route.js"
@@ -61,7 +62,7 @@ import { bodyUser, noteEndUser } from "../user.js"
61
62
  import { setSessionLink } from "../session/link.js"
62
63
  import { chatCodec, serveChoices } from "./fanout.js"
63
64
  import { parseChatRequest } from "./request.js"
64
- import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
65
+ import { STRUCTURED_FALLBACK_HEADER, collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
65
66
  import { dropRejected } from "./sampling.js"
66
67
 
67
68
  /** What the serving generation supplies per request. */
@@ -171,7 +172,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
171
172
  if (googleShaped) googleErrors.add(response)
172
173
  const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
173
174
  // Arbitrary client paths stay out of the log.
174
- info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
175
+ info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" || route === "GET /readyz" ? route : "unknown"
175
176
  // Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
176
177
  if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
177
178
 
@@ -180,6 +181,12 @@ export function createOpenAiRoute(routeHost: RouteHost) {
180
181
 
181
182
  if (route === "GET /healthz") return sendJson(response, 200, { ok: true })
182
183
 
184
+ // Readiness (#160): no key, like /healthz. 503 until a generation serves, the pump runs and the catalog has loaded.
185
+ if (route === "GET /readyz") {
186
+ const verdict = readiness(runtimeStatus())
187
+ return sendJson(response, verdict.ready ? 200 : 503, verdict)
188
+ }
189
+
183
190
  // First-run pages: reachable without a key, so what they show depends on the caller (`buildDiscovery`).
184
191
  // With `keys` set, a request without credentials gets the minimal page; wrong credentials are a 401.
185
192
  if (DISCOVERY_ROUTES.includes(route)) {
@@ -266,7 +273,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
266
273
  const unsupported = checkJsonContentType(request)
267
274
  if (unsupported) return fail(response, unsupported)
268
275
 
269
- const body = await readJsonBody(request, scope.signal)
276
+ const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
270
277
  noteEndUser(response, info, bodyUser(body))
271
278
  if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
272
279
  if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
@@ -304,6 +311,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
304
311
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
305
312
  onPolicy: reportPolicy(response),
306
313
  onUsage: reportUsage(response, exchange.stream),
314
+ onServerTools: reportServerTools(response, exchange.stream),
307
315
  onCache: reportCache(response),
308
316
  }),
309
317
  scope,
@@ -329,6 +337,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
329
337
  }
330
338
 
331
339
  const result = await collectCompletion(events, exchange)
340
+ if (result.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
332
341
  sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
333
342
  }
334
343
 
@@ -387,8 +396,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
387
396
  }
388
397
  } else if (caught instanceof BodyError) {
389
398
  outcome = `body_${caught.status}`
390
- // 413: the client may still be sending; answer, then close (see `finishRequest`).
391
- fail(response, { status: caught.status, kind: "invalid_request", message: caught.message }, caught.status === 413)
399
+ // Every refusal but an invalid body (400) may leave the client still sending: answer, then close (see `finishRequest`).
400
+ fail(response, bodyPlugError(caught), caught.status !== 400)
392
401
  } else {
393
402
  // Unexpected: the message goes to the log only; the client gets a generic one.
394
403
  outcome = "error"
@@ -414,8 +423,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
414
423
  const ms = Date.now() - started
415
424
  metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
416
425
  if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms, ...(info.user === undefined ? {} : { user: info.user }) })
417
- // Probes and scrapes that succeed are not worth a line each.
418
- if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
426
+ // Probes and scrapes that succeed are not worth a line each. A readiness probe is never logged: it repeats until the backend is ready.
427
+ const probe = info.route === "GET /readyz" || ((info.route === "GET /healthz" || info.route === "GET /metrics") && outcome === "ok")
428
+ if (!probe) {
419
429
  const fields = { id, route: info.route, app: info.app, user: info.user, session_url: info.sessionUrl, model: info.model, served: info.served, fallbacks: info.fallbacks, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
420
430
  if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
421
431
  else log("request", fields)
package/src/policy.ts CHANGED
@@ -4,6 +4,7 @@ import type { ServerResponse } from "node:http"
4
4
  import type { PolicyNote } from "./contract.js"
5
5
 
6
6
  export const POLICY_HEADER = "x-plugboard-policy"
7
+ export const SERVER_TOOLS_HEADER = "x-plugboard-server-tools"
7
8
 
8
9
  /**
9
10
  * The `CallContext.onPolicy` of a route: `x-plugboard-policy` (the notes, comma separated) while the
@@ -15,3 +16,18 @@ export function reportPolicy(response: ServerResponse): (notes: ReadonlyArray<Po
15
16
  response.setHeader(POLICY_HEADER, notes.join(","))
16
17
  }
17
18
  }
19
+
20
+ /**
21
+ * The `CallContext.onServerTools` of a route: `x-plugboard-server-tools` with the number of server tool calls
22
+ * OpenCode ran for the answer, while the headers are open. Choices of `n` add up (each one reports once,
23
+ * when it ends, and the body is not sent before all of them). A stream gets no callback: its headers go first.
24
+ */
25
+ export function reportServerTools(response: ServerResponse, stream: boolean): ((count: number) => void) | undefined {
26
+ if (stream) return undefined
27
+ let total = 0
28
+ return (count) => {
29
+ if (count <= 0 || response.headersSent || response.destroyed) return
30
+ total += count
31
+ response.setHeader(SERVER_TOOLS_HEADER, String(total))
32
+ }
33
+ }
@@ -386,7 +386,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
386
386
  truncation: "disabled",
387
387
  user: typeof body.user === "string" ? body.user : null,
388
388
  },
389
- ...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
389
+ ...(respond ? { structuredTool: RESPOND_TOOL, structuredSchema: respond.parameters, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
390
390
  },
391
391
  ...(reasoningEffort ? { reasoningEffort } : {}),
392
392
  }