opencode-plugboard 0.19.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.
@@ -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. */
@@ -111,12 +125,12 @@ const encodePlugboard = (model: ListedModel["model"]) => ({
111
125
  /** `GET /v1/models` body. */
112
126
  export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
113
127
  object: "list",
114
- data: models.map(({ id, ref, model }) => ({
128
+ data: models.map(({ id, ref, model, skill, agent }) => ({
115
129
  id,
116
130
  object: "model",
117
131
  created: 0,
118
132
  owned_by: ref.providerID,
119
- plugboard: encodePlugboard(model),
133
+ plugboard: { ...encodePlugboard(model), ...(skill === undefined ? {} : { skill }), ...(agent === undefined ? {} : { agent }) },
120
134
  })),
121
135
  })
122
136
 
@@ -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,26 +166,88 @@ 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),
190
+ }
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
+ const candidate = unfence(text.trim())
244
+ let value: unknown
245
+ try {
246
+ value = JSON.parse(candidate)
247
+ } catch {
248
+ return undefined
249
+ }
250
+ return matchesSchema(value, schema) ? candidate : undefined
175
251
  }
176
252
 
177
253
  /** One markdown code fence (```json or a bare ```) around the whole text. */
@@ -195,10 +271,7 @@ export function unfence(text: string): string {
195
271
  const newId = () => `chatcmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`
196
272
  const now = () => Math.floor(Date.now() / 1000)
197
273
 
198
- export async function collectCompletion(
199
- events: AsyncIterable<PlugEvent>,
200
- exchange: OpenAiExchange,
201
- ): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
274
+ export async function collectCompletion(events: AsyncIterable<PlugEvent>, exchange: OpenAiExchange): Promise<CollectedChoice> {
202
275
  let text = ""
203
276
  const calls: ToolCall[] = []
204
277
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -229,7 +302,8 @@ export async function collectCompletion(
229
302
  // With the caller's tools offered, a call to one of them wins over the structured answer.
230
303
  const passthrough = exchange.callerTools === true && others.length > 0
231
304
  const isStructured = exchange.structuredTool !== undefined && !passthrough
232
- const missing = passthrough ? undefined : structured.failure(exchange, finish, answer !== undefined)
305
+ const fallback = isStructured && answer === undefined && exchange.structuredSchema !== undefined ? textFallback(text, exchange.structuredSchema) : undefined
306
+ const missing = passthrough ? undefined : structured.failure(exchange, answer !== undefined || fallback !== undefined)
233
307
  if (missing) return { status: missing.status, body: encodeError(missing) }
234
308
  const toolCalls = isStructured ? [] : others
235
309
  const message: Record<string, unknown> = {
@@ -237,7 +311,7 @@ export async function collectCompletion(
237
311
  content: isStructured
238
312
  ? answer
239
313
  ? structured.content(answer)
240
- : null
314
+ : (fallback ?? null)
241
315
  : exchange.structuredTool === undefined && text.length > 0
242
316
  ? exchange.jsonObject
243
317
  ? unfence(text)
@@ -261,6 +335,7 @@ export async function collectCompletion(
261
335
  choices: [{ index: 0, message, logprobs: null, finish_reason: finishReason(finish, toolCalls.length, isStructured) }],
262
336
  ...(usage ? { usage: encodeUsage(usage) } : {}),
263
337
  },
338
+ ...(fallback !== undefined ? { structuredFallback: true } : {}),
264
339
  }
265
340
  }
266
341
 
@@ -303,6 +378,8 @@ export async function streamCompletion(
303
378
  let started = false
304
379
  let calls = 0
305
380
  let answered = false
381
+ /** The text of a structured turn (not streamed): it is the answer only if the respond call never comes. */
382
+ let collected = ""
306
383
  /** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
307
384
  let held: ToolCall | undefined
308
385
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -358,6 +435,7 @@ export async function streamCompletion(
358
435
  }
359
436
  continue
360
437
  }
438
+ if (event.type === "text-delta") collected += event.text
361
439
  if (!(exchange.callerTools && event.type === "tool-call")) continue
362
440
  }
363
441
  if (exchange.jsonObject && event.type === "text-delta" && !passing) {
@@ -385,7 +463,8 @@ export async function streamCompletion(
385
463
 
386
464
  const passthrough = exchange.callerTools === true && calls > 0
387
465
  const isStructured = exchange.structuredTool !== undefined && !passthrough
388
- failure ??= finish ? (passthrough ? undefined : structured.failure(exchange, finish, answered)) : noFinish()
466
+ const fallback = isStructured && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
467
+ failure ??= finish ? (passthrough ? undefined : structured.failure(exchange, answered || fallback !== undefined)) : noFinish()
389
468
  if (failure) {
390
469
  if (!started) return { failure, started }
391
470
  if (!choice) {
@@ -401,6 +480,7 @@ export async function streamCompletion(
401
480
  if (rest.length > 0) chunk({ content: rest })
402
481
  }
403
482
  if (held && isStructured) chunk({ content: structured.content(held) })
483
+ if (fallback !== undefined) chunk({ content: fallback })
404
484
  chunk({}, finishReason(finish!, calls, isStructured))
405
485
  if (choice) return { started, ...(usage ? { usage } : {}) }
406
486
  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`
@@ -21,11 +21,13 @@
21
21
  */
22
22
 
23
23
  import type { IncomingMessage, ServerResponse } from "node:http"
24
- import { DEFAULT_APP, authenticate, authenticateUsage, type PlugboardOptions } from "../config.js"
24
+ import { serveAdmin } from "../admin/route.js"
25
+ import { DEFAULT_APP, authenticate, authenticateUsage, bodyLimits, type PlugboardOptions } from "../config.js"
25
26
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
26
- import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
27
+ import { buildDiscovery, buildStatus, readiness, renderDiscoveryHtml, sendText } from "../discovery.js"
27
28
  import {
28
29
  BodyError,
30
+ bodyPlugError,
29
31
  RequestAborted,
30
32
  beginRequest,
31
33
  boundEvents,
@@ -45,19 +47,22 @@ import { errorMessage, log, logError, withRequestId } from "../log.js"
45
47
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
46
48
  import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
47
49
  import { reportCache } from "../cache.js"
48
- import { reportPolicy } from "../policy.js"
50
+ import { reportPolicy, reportServerTools } from "../policy.js"
49
51
  import { reportUsage } from "../usage-header.js"
50
52
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
51
53
  import { serveCompletions } from "../completions/route.js"
52
54
  import { serveResponses } from "../responses/route.js"
53
55
  import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
54
56
  import { budgetFor, budgetStatus } from "../budgets.js"
57
+ import type { KeyStore } from "../keys.js"
55
58
  import type { UsageRecorder } from "../usage.js"
56
59
  import { DASHBOARD_ROUTES } from "../dashboard/page.js"
57
60
  import { serveDashboard } from "../dashboard/assets.js"
61
+ import { bodyUser, noteEndUser } from "../user.js"
62
+ import { setSessionLink } from "../session/link.js"
58
63
  import { chatCodec, serveChoices } from "./fanout.js"
59
64
  import { parseChatRequest } from "./request.js"
60
- import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
65
+ import { STRUCTURED_FALLBACK_HEADER, collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
61
66
  import { dropRejected } from "./sampling.js"
62
67
 
63
68
  /** What the serving generation supplies per request. */
@@ -72,6 +77,8 @@ export interface RouteHost {
72
77
  readonly status?: () => RuntimeStatus
73
78
  /** Per-app usage (`src/usage.ts`): fed from the request log point, and served by `GET /v1/usage` when present. */
74
79
  readonly usage?: UsageRecorder
80
+ /** Runtime app keys (src/keys.ts, #55): their digests authenticate, and the admin routes (src/admin/route.ts) read them. */
81
+ readonly keys?: KeyStore
75
82
  }
76
83
 
77
84
  const SESSION_HEADER = "x-plugboard-session"
@@ -116,6 +123,8 @@ interface RequestInfo extends ServedInfo {
116
123
  failed?: string
117
124
  /** Sampling parameters dropped for this model (comma list), when any. */
118
125
  dropped?: string
126
+ /** The end user the body named, when kept (src/user.ts). */
127
+ user?: string
119
128
  }
120
129
 
121
130
  export function createOpenAiRoute(routeHost: RouteHost) {
@@ -123,6 +132,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
123
132
  const metrics = routeHost.options.metrics ? (routeHost.metrics ?? processMetrics()) : undefined
124
133
  const runtimeStatus = routeHost.status ?? processStatus
125
134
  const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
135
+ /** The runtime app keys that authenticate (loaded from storage on first use, #55). */
136
+ const runtimeDigests = async () => (host.keys ? await host.keys.digests() : [])
126
137
  /** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
127
138
  const known = (route: string) =>
128
139
  KNOWN_ROUTES.includes(route) ||
@@ -161,7 +172,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
161
172
  if (googleShaped) googleErrors.add(response)
162
173
  const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
163
174
  // Arbitrary client paths stay out of the log.
164
- 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"
165
176
  // Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
166
177
  if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
167
178
 
@@ -170,13 +181,19 @@ export function createOpenAiRoute(routeHost: RouteHost) {
170
181
 
171
182
  if (route === "GET /healthz") return sendJson(response, 200, { ok: true })
172
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
+
173
190
  // First-run pages: reachable without a key, so what they show depends on the caller (`buildDiscovery`).
174
191
  // With `keys` set, a request without credentials gets the minimal page; wrong credentials are a 401.
175
192
  if (DISCOVERY_ROUTES.includes(route)) {
176
193
  const keyed = Object.keys(host.options.keys).length > 0
177
194
  let app: string | undefined = keyed ? undefined : DEFAULT_APP
178
195
  if (keyed && request.headers.authorization !== undefined) {
179
- const auth = authenticate(liveOptions(host.options), request.headers.authorization)
196
+ const auth = authenticate(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
180
197
  if (isPlugError(auth)) return fail(response, auth)
181
198
  app = auth.app
182
199
  }
@@ -192,9 +209,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
192
209
  // Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
193
210
  if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
194
211
 
212
+ // Runtime app keys (#55): the admin routes answer the admin key only, and are a 404 without `adminKey`.
213
+ if (path === "/v1/admin/keys" || path.startsWith("/v1/admin/keys/"))
214
+ return serveAdmin({ options: liveOptions(host.options), keys: host.keys }, { path, request, response, scope, info })
215
+
195
216
  // Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
196
217
  // Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
197
- const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization)
218
+ const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization, await runtimeDigests())
198
219
  if (isPlugError(auth)) return fail(response, auth)
199
220
  info.app = auth.app
200
221
 
@@ -252,7 +273,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
252
273
  const unsupported = checkJsonContentType(request)
253
274
  if (unsupported) return fail(response, unsupported)
254
275
 
255
- const body = await readJsonBody(request, scope.signal)
276
+ const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
277
+ noteEndUser(response, info, bodyUser(body))
256
278
  if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
257
279
  if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
258
280
  const parsed = parseChatRequest(body, host.options)
@@ -280,14 +302,16 @@ export function createOpenAiRoute(routeHost: RouteHost) {
280
302
  host.caller.stream(call, {
281
303
  app: auth.app,
282
304
  signal: scope.signal,
283
- onRecord: (recordID) => {
305
+ onRecord: (recordID, directory) => {
284
306
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
285
307
  response.setHeader(SESSION_HEADER, recordID)
308
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
286
309
  },
287
310
  // An alias with fallbacks: report which target answered, and echo it as the response model.
288
311
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
289
312
  onPolicy: reportPolicy(response),
290
313
  onUsage: reportUsage(response, exchange.stream),
314
+ onServerTools: reportServerTools(response, exchange.stream),
291
315
  onCache: reportCache(response),
292
316
  }),
293
317
  scope,
@@ -313,12 +337,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
313
337
  }
314
338
 
315
339
  const result = await collectCompletion(events, exchange)
340
+ if (result.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
316
341
  sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
317
342
  }
318
343
 
319
344
  /** `GET /v1/usage?from=&to=&app=`. An app key may name only its own app (another one is a 403); the admin key may name any app or none. */
320
345
  async function serveUsage(request: IncomingMessage, response: ServerResponse, url: URL, usage: UsageRecorder, info: RequestInfo): Promise<void> {
321
- const who = authenticateUsage(liveOptions(host.options), request.headers.authorization)
346
+ const who = authenticateUsage(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
322
347
  if (isPlugError(who)) return fail(response, who)
323
348
  const app = url.searchParams.get("app") ?? undefined
324
349
  if (!who.admin) {
@@ -330,6 +355,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
330
355
  from: url.searchParams.get("from") ?? undefined,
331
356
  to: url.searchParams.get("to") ?? undefined,
332
357
  app: who.admin ? app : who.app,
358
+ user: url.searchParams.get("user") || undefined,
333
359
  })
334
360
  if (isPlugError(report)) return fail(response, report)
335
361
  // Each app's budget state, read from its current window rather than from the report's range (src/budgets.ts).
@@ -370,8 +396,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
370
396
  }
371
397
  } else if (caught instanceof BodyError) {
372
398
  outcome = `body_${caught.status}`
373
- // 413: the client may still be sending; answer, then close (see `finishRequest`).
374
- 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)
375
401
  } else {
376
402
  // Unexpected: the message goes to the log only; the client gets a generic one.
377
403
  outcome = "error"
@@ -396,10 +422,11 @@ export function createOpenAiRoute(routeHost: RouteHost) {
396
422
  } catch {}
397
423
  const ms = Date.now() - started
398
424
  metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
399
- if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
400
- // Probes and scrapes that succeed are not worth a line each.
401
- if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
402
- const fields = { id, route: info.route, app: info.app, model: info.model, served: info.served, fallbacks: info.fallbacks, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
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 }) })
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) {
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 }
403
430
  if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
404
431
  else log("request", fields)
405
432
  }
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
+ }
@@ -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 { bodyUser, userField } from "../user.js"
13
14
  import {
14
15
  isPlugError,
15
16
  type PlugError,
@@ -353,6 +354,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
353
354
  const instructions = typeof body.instructions === "string" && body.instructions.length > 0 ? [body.instructions] : []
354
355
  const system = [...instructions, ...parsed.system, ...(format.instruction ? [format.instruction] : [])]
355
356
  const request: Omit<PlugRequest, "ref"> = {
357
+ ...userField(bodyUser(body)),
356
358
  model: body.model,
357
359
  system,
358
360
  messages: parsed.messages,
@@ -384,7 +386,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
384
386
  truncation: "disabled",
385
387
  user: typeof body.user === "string" ? body.user : null,
386
388
  },
387
- ...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
389
+ ...(respond ? { structuredTool: RESPOND_TOOL, structuredSchema: respond.parameters, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
388
390
  },
389
391
  ...(reasoningEffort ? { reasoningEffort } : {}),
390
392
  }
@@ -7,6 +7,7 @@
7
7
  * call passes a no-op `emit`. Reasoning deltas are dropped (no reasoning items are produced).
8
8
  */
9
9
  import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
10
+ import { structuredMissing, textFallback } from "../openai/response.js"
10
11
 
11
12
  type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
12
13
 
@@ -25,6 +26,8 @@ export interface ResponsesExchange {
25
26
  * answer is the message text only when the step called no caller tool.
26
27
  */
27
28
  readonly callerTools?: boolean
29
+ /** The caller's `text.format` schema: a text answer is taken when the model skipped the respond call (`textFallback`, #173). */
30
+ readonly structuredSchema?: Readonly<Record<string, unknown>>
28
31
  }
29
32
 
30
33
  /** A stream event as emitted: `type` is also the SSE event name. */
@@ -37,6 +40,8 @@ export interface ResponseRun {
37
40
  readonly started: boolean
38
41
  /** The final response object (completed, incomplete or failed once started). */
39
42
  readonly response?: object
43
+ /** The structured answer came from the final text, not the respond call (`textFallback`). */
44
+ readonly structuredFallback?: boolean
40
45
  }
41
46
 
42
47
  type Item = Record<string, unknown>
@@ -63,21 +68,6 @@ const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Mod
63
68
  /** SSE frame of one stream event: `event: <type>` then `data: <json>`. */
64
69
  export const sseFrame = (event: ResponsesStreamEvent): string => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`
65
70
 
66
- /**
67
- * Structured-output projection, same rules as the Chat Completions front-end: in structured mode
68
- * the message text is exactly the arguments of the first call to the `respond` tool; prose and
69
- * other calls are suppressed. Without an answer the exchange fails, except when the model
70
- * stopped for `length` or `content-filter`, which keep their status with empty output.
71
- */
72
- const structuredFailure = (exchange: ResponsesExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
73
- exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
74
- ? undefined
75
- : {
76
- status: 502,
77
- kind: "server",
78
- code: "structured_output_missing",
79
- message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
80
- }
81
71
 
82
72
  /**
83
73
  * `committed`: whether the transport already committed a 200 stream before any event was emitted
@@ -114,6 +104,8 @@ export async function runResponse(
114
104
  let started = false
115
105
  let calls = 0
116
106
  let answered = false
107
+ /** The text of a structured turn (not emitted): it is the answer only if the respond call never comes (#173). */
108
+ let collected = ""
117
109
  /** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
118
110
  let held: ToolCall | undefined
119
111
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -199,6 +191,7 @@ export async function runResponse(
199
191
  }
200
192
  continue
201
193
  }
194
+ if (event.type === "text-delta") collected += event.text
202
195
  if (!(exchange.callerTools && event.type === "tool-call")) continue
203
196
  }
204
197
  if (event.type === "text-delta") text(event.text)
@@ -207,7 +200,9 @@ export async function runResponse(
207
200
 
208
201
  const passthrough = exchange.callerTools === true && calls > 0
209
202
  const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
210
- failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
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)
205
+ failure ??= finish ? missing : noFinish()
211
206
  if (failure) {
212
207
  if (!started && !committed()) return { failure, started }
213
208
  start()
@@ -221,6 +216,7 @@ export async function runResponse(
221
216
  }
222
217
  start()
223
218
  if (held && structuredAnswer) text(args(held.input))
219
+ if (fallback !== undefined) text(fallback)
224
220
  closeMessage()
225
221
  const incomplete = finish === "length" ? "max_output_tokens" : finish === "content-filter" ? "content_filter" : undefined
226
222
  const response = envelope(incomplete ? "incomplete" : "completed", items, {
@@ -228,5 +224,5 @@ export async function runResponse(
228
224
  ...(usage ? { usage: encodeUsage(usage) } : {}),
229
225
  })
230
226
  send(incomplete ? "response.incomplete" : "response.completed", { response })
231
- return { started, response }
227
+ return { started, response, ...(fallback !== undefined ? { structuredFallback: true } : {}) }
232
228
  }
@@ -14,9 +14,10 @@ import { reportServed, type ServedInfo } from "../fallback.js"
14
14
  import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
15
15
  import { resolveModel } from "../models.js"
16
16
  import { reportCache } from "../cache.js"
17
- import { reportPolicy } from "../policy.js"
17
+ import { reportPolicy, reportServerTools } from "../policy.js"
18
18
  import { reportUsage } from "../usage-header.js"
19
- import { encodeError } from "../openai/response.js"
19
+ import { setSessionLink } from "../session/link.js"
20
+ import { STRUCTURED_FALLBACK_HEADER, encodeError } from "../openai/response.js"
20
21
  import type { RouteHost } from "../openai/route.js"
21
22
  import { parseResponsesRequest } from "./request.js"
22
23
  import { runResponse, sseFrame } from "./response.js"
@@ -51,13 +52,15 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
51
52
  host.caller.stream(call, {
52
53
  app,
53
54
  signal: scope.signal,
54
- onRecord: (recordID) => {
55
+ onRecord: (recordID, directory) => {
55
56
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
56
57
  response.setHeader(SESSION_HEADER, recordID)
58
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
57
59
  },
58
60
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
59
61
  onPolicy: reportPolicy(response),
60
62
  onUsage: reportUsage(response, exchange.stream),
63
+ onServerTools: reportServerTools(response, exchange.stream),
61
64
  onCache: reportCache(response),
62
65
  }),
63
66
  scope,
@@ -79,5 +82,6 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
79
82
  const run = await runResponse(events, exchange, () => {})
80
83
  if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
81
84
  rememberAnswer(app, run.response)
85
+ if (run.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
82
86
  sendJson(response, 200, run.response)
83
87
  }
package/src/rpc.ts CHANGED
@@ -26,6 +26,7 @@ export const UsageRpc = Rpc.define({
26
26
  from: { type: "string", description: "First UTC day, YYYY-MM-DD. Default: 29 days before `to`." },
27
27
  to: { type: "string", description: "Last UTC day, YYYY-MM-DD. Default: today." },
28
28
  app: { type: "string", description: "One app name. Default: every app." },
29
+ user: { type: "string", description: "One end user id. Each app's `users` then lists only that user. Default: every user." },
29
30
  },
30
31
  additionalProperties: false,
31
32
  },
@@ -37,7 +38,7 @@ export const UsageRpc = Rpc.define({
37
38
  errors: {
38
39
  invalid_query: {
39
40
  type: "object",
40
- properties: { param: { type: "string", description: "The query parameter that is wrong: from, to or app." } },
41
+ properties: { param: { type: "string", description: "The query parameter that is wrong: from, to, app or user." } },
41
42
  required: ["param"],
42
43
  additionalProperties: false,
43
44
  },
@@ -51,7 +52,7 @@ export const UsageRpc = Rpc.define({
51
52
  function queryOf(input: unknown): UsageQuery {
52
53
  const record: Record<string, unknown> = typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {}
53
54
  const text = (value: unknown) => (typeof value === "string" ? value : undefined)
54
- return { from: text(record.from), to: text(record.to), app: text(record.app) }
55
+ return { from: text(record.from), to: text(record.to), app: text(record.app), user: text(record.user) }
55
56
  }
56
57
 
57
58
  /** The handlers for `UsageRpc`, reading the given usage query (the generation's recorder). */