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.
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)
package/src/keys.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Runtime app keys (#55): keys created through `/v1/admin/keys` (src/admin/route.ts), kept beside
3
+ * the keys of the config.
4
+ *
5
+ * Each runtime key is one record in the plugin's host storage (`ctx.storage`, the table the history
6
+ * index and usage use) under `keys/<id>`, as JSON `{ app, digest, createdAt }`. The digest is the
7
+ * SHA-256 of the key, so the key value is never stored: it is returned once, by the create call.
8
+ * The id is the first 8 hex chars of that digest, the name `DELETE` takes; it is safe to show.
9
+ *
10
+ * The records are read lazily, from request handling, by the first request that authenticates (never
11
+ * from plugin setup, I3), and cached on the process runtime (`KeyState`), so the cache outlives a
12
+ * plugin generation. A failed read leaves the cache unloaded: runtime keys then fail closed (401) and
13
+ * the next request reads again. Create and revoke write storage first and the cache second, so a
14
+ * revoked key stops authenticating on the next request.
15
+ */
16
+
17
+ import { createHash, randomBytes } from "node:crypto"
18
+ import { APP_NAME_PATTERN } from "./config.js"
19
+ import { errorMessage, logError } from "./log.js"
20
+ import type { StoragePort } from "./session/host.js"
21
+
22
+ const PREFIX = "keys/"
23
+ const SCAN_PAGE = 200
24
+ /** A collision of two 32-bit ids is rare; a fresh key is drawn instead of sharing an id. */
25
+ const ID_ATTEMPTS = 5
26
+
27
+ export interface RuntimeKey {
28
+ readonly id: string
29
+ readonly app: string
30
+ readonly digest: Buffer
31
+ readonly createdAt: string
32
+ }
33
+
34
+ /** The live cache of runtime keys on the process runtime (src/runtime.ts). */
35
+ export interface KeyState {
36
+ loaded: boolean
37
+ readonly keys: Map<string, RuntimeKey>
38
+ }
39
+
40
+ export const createKeyState = (): KeyState => ({ loaded: false, keys: new Map() })
41
+
42
+ /** SHA-256 of a key: what is stored and compared. */
43
+ export const keyDigest = (key: string): Buffer => createHash("sha256").update(key).digest()
44
+
45
+ /** The id of a key: the first 8 hex chars of its digest. */
46
+ export const keyIdOf = (digest: Buffer): string => digest.subarray(0, 4).toString("hex")
47
+
48
+ export interface KeyListing {
49
+ readonly id: string
50
+ readonly app: string
51
+ readonly source: "config" | "runtime"
52
+ readonly createdAt?: string
53
+ }
54
+
55
+ export interface CreatedKey {
56
+ readonly id: string
57
+ readonly app: string
58
+ /** The key value. Returned by `create` only; it is never stored or listed. */
59
+ readonly key: string
60
+ readonly createdAt: string
61
+ }
62
+
63
+ export interface KeyStore {
64
+ /** The digests of the loaded runtime keys, as `[app, digest]`. Reads storage once; a failed read answers with what is loaded. */
65
+ digests(): Promise<ReadonlyArray<readonly [string, Buffer]>>
66
+ /** Config keys and runtime keys, without key values. */
67
+ list(config: Readonly<Record<string, string>>): Promise<ReadonlyArray<KeyListing>>
68
+ /** A new random key for `app`. The caller checks the app name and that the config has no key for it. */
69
+ create(app: string, config: Readonly<Record<string, string>>): Promise<CreatedKey>
70
+ /** `config` when the id names a config key (not revocable here), `not_found` when no runtime key has it. */
71
+ revoke(id: string, config: Readonly<Record<string, string>>): Promise<"revoked" | "config" | "not_found">
72
+ }
73
+
74
+ export interface KeyStoreDeps {
75
+ readonly state: KeyState
76
+ readonly storage: StoragePort
77
+ /** Epoch ms; tests inject a clock. */
78
+ readonly now?: () => number
79
+ }
80
+
81
+ const configIds = (config: Readonly<Record<string, string>>): Set<string> => new Set(Object.values(config).map((key) => keyIdOf(keyDigest(key))))
82
+
83
+ /** A stored record, or undefined when the value is not one (logged; the key then never authenticates). */
84
+ function parseRecord(id: string, raw: string | undefined): RuntimeKey | undefined {
85
+ try {
86
+ const value = JSON.parse(raw ?? "") as { app?: unknown; digest?: unknown; createdAt?: unknown }
87
+ const digest = typeof value.digest === "string" && /^[0-9a-f]{64}$/.test(value.digest) ? Buffer.from(value.digest, "hex") : undefined
88
+ if (typeof value.app !== "string" || !APP_NAME_PATTERN.test(value.app) || digest === undefined || typeof value.createdAt !== "string") throw new Error("shape")
89
+ if (keyIdOf(digest) !== id) throw new Error("id")
90
+ return { id, app: value.app, digest, createdAt: value.createdAt }
91
+ } catch {
92
+ logError("keys.unreadable", { id })
93
+ return undefined
94
+ }
95
+ }
96
+
97
+ export function createKeyStore(deps: KeyStoreDeps): KeyStore {
98
+ const { state, storage } = deps
99
+ const now = deps.now ?? Date.now
100
+ let loading: Promise<void> | undefined
101
+
102
+ /** Reads every stored key once. Concurrent callers share one read. */
103
+ const ensure = (): Promise<void> => {
104
+ if (state.loaded) return Promise.resolve()
105
+ loading ??= (async () => {
106
+ const found = new Map<string, RuntimeKey>()
107
+ let after: string | undefined
108
+ for (;;) {
109
+ const page = await storage.scan({ prefix: PREFIX, ...(after === undefined ? {} : { after }), limit: SCAN_PAGE })
110
+ for (const key of page.keys) {
111
+ const record = parseRecord(key.slice(PREFIX.length), await storage.get(key))
112
+ if (record) found.set(record.id, record)
113
+ }
114
+ if (page.next === undefined) break
115
+ after = page.next
116
+ }
117
+ state.keys.clear()
118
+ for (const [id, record] of found) state.keys.set(id, record)
119
+ state.loaded = true
120
+ })().finally(() => {
121
+ loading = undefined
122
+ })
123
+ return loading
124
+ }
125
+
126
+ return {
127
+ async digests() {
128
+ try {
129
+ await ensure()
130
+ } catch (error) {
131
+ logError("keys.load_failed", { error: errorMessage(error) })
132
+ }
133
+ return [...state.keys.values()].map((record) => [record.app, record.digest] as const)
134
+ },
135
+
136
+ async list(config) {
137
+ await ensure()
138
+ const runtime = [...state.keys.values()].sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
139
+ return [
140
+ ...Object.entries(config).map(([app, key]) => ({ id: keyIdOf(keyDigest(key)), app, source: "config" as const })),
141
+ ...runtime.map((record) => ({ id: record.id, app: record.app, source: "runtime" as const, createdAt: record.createdAt })),
142
+ ]
143
+ },
144
+
145
+ async create(app, config) {
146
+ await ensure()
147
+ const taken = configIds(config)
148
+ for (let attempt = 0; attempt < ID_ATTEMPTS; attempt++) {
149
+ const key = randomBytes(32).toString("base64url")
150
+ const digest = keyDigest(key)
151
+ const id = keyIdOf(digest)
152
+ if (taken.has(id) || state.keys.has(id)) continue
153
+ const createdAt = new Date(now()).toISOString()
154
+ // Storage first: a failed write creates no key, and the value is never returned for a key that does not exist.
155
+ await storage.put(PREFIX + id, JSON.stringify({ app, digest: digest.toString("hex"), createdAt }))
156
+ state.keys.set(id, { id, app, digest, createdAt })
157
+ return { id, app, key, createdAt }
158
+ }
159
+ throw new Error("could not draw an unused key id")
160
+ },
161
+
162
+ async revoke(id, config) {
163
+ await ensure()
164
+ if (configIds(config).has(id)) return "config"
165
+ if (!state.keys.has(id)) return "not_found"
166
+ // Storage first: if the delete fails the key keeps working and the caller can retry.
167
+ await storage.drop(PREFIX + id)
168
+ state.keys.delete(id)
169
+ return "revoked"
170
+ },
171
+ }
172
+ }
package/src/models.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  import { appPolicy, type AppPolicy, type PlugboardOptions } from "./config.js"
4
4
  import type { CatalogModel, ModelRef, PlugError } from "./contract.js"
5
5
 
6
+ /** Alias target prefix that binds a skill: `skill:<id>@provider/model[#variant]` (see `parseAliasTarget`). */
7
+ export const SKILL_PREFIX = "skill:"
8
+ /** Listed id prefix of an agent (option `agents`): `agent/<name>`. Aliases cannot contain `/`, so the two never collide. */
9
+ export const AGENT_PREFIX = "agent/"
10
+ const SKILL_TARGET = /^skill:([^@\s#*/]+)@([^@/#*\s]+\/[^@#*\s]+(#[^#*\s]+)?)$/
11
+
6
12
  /** `provider/model[#variant]`. The model id may contain slashes (`openrouter/vendor/model`). */
7
13
  export function parseModelRef(s: string): ModelRef | undefined {
8
14
  if (typeof s !== "string") return undefined
@@ -58,6 +64,42 @@ export function allowedBy(models: ReadonlyArray<string>, ref: ModelRef): boolean
58
64
 
59
65
  const allowed = (options: PlugboardOptions, ref: ModelRef): boolean => allowedBy(options.models, ref)
60
66
 
67
+ /** An alias target as written in `aliases`: `provider/model[#variant]`, or `skill:<id>@` in front of one. */
68
+ export interface AliasTarget {
69
+ readonly ref: ModelRef
70
+ /** The skill the alias binds, for a `skill:` target. */
71
+ readonly skill?: string
72
+ }
73
+
74
+ /** The model (and skill) an alias target names, or undefined when it is neither form. */
75
+ export function parseAliasTarget(target: string): AliasTarget | undefined {
76
+ if (!target.startsWith(SKILL_PREFIX)) {
77
+ const ref = parseModelRef(target)
78
+ return ref === undefined ? undefined : { ref }
79
+ }
80
+ const match = SKILL_TARGET.exec(target)
81
+ const ref = match ? parseModelRef(match[2]!) : undefined
82
+ return match && ref ? { ref, skill: match[1]! } : undefined
83
+ }
84
+
85
+ /**
86
+ * What a request name stands for, beyond a plain model: a skill alias (its skill id) or an agent (its name
87
+ * in option `agents`). Undefined for plain model ids and plain aliases. The caller reads it to add the
88
+ * skill's content or the agent's system prompt to the session.
89
+ */
90
+ export type Persona = { readonly kind: "skill"; readonly skill: string } | { readonly kind: "agent"; readonly agent: string }
91
+
92
+ export function personaOf(options: Pick<PlugboardOptions, "aliases" | "agents">, name: string): Persona | undefined {
93
+ const target = own(options.aliases, name)
94
+ if (target !== undefined) {
95
+ const skill = parseAliasTarget(target)?.skill
96
+ return skill === undefined ? undefined : { kind: "skill", skill }
97
+ }
98
+ if (!name.startsWith(AGENT_PREFIX)) return undefined
99
+ const agent = name.slice(AGENT_PREFIX.length)
100
+ return Object.hasOwn(options.agents, agent) ? { kind: "agent", agent } : undefined
101
+ }
102
+
61
103
  /**
62
104
  * Per-app narrowing (`apps.<name>.models`), applied on top of the global allowlist, never instead of it.
63
105
  * `name` is what the caller sent (an alias or a model id). An entry that is an alias name admits that
@@ -108,7 +150,15 @@ export function resolveModel(
108
150
  param: "model",
109
151
  }
110
152
  // Aliases keep priority over slugs; a slug and a canonical id both resolve, whatever `modelIds` lists.
111
- let ref = parseModelRef(own(options.aliases, model) ?? fromSlug(model) ?? model)
153
+ // An agent runs on the model its `agents` entry names (the allowlist still applies below).
154
+ const target = own(options.aliases, model)
155
+ const persona = personaOf(options, model)
156
+ let ref =
157
+ target !== undefined
158
+ ? parseAliasTarget(target)?.ref
159
+ : persona?.kind === "agent"
160
+ ? parseModelRef(options.agents[persona.agent]!)
161
+ : parseModelRef(fromSlug(model) ?? model)
112
162
  if (!ref) return notAvailable
113
163
  if (!ref.variant && reasoningEffort && find(catalog, { ...ref, variant: reasoningEffort }))
114
164
  ref = { ...ref, variant: reasoningEffort }
@@ -120,6 +170,10 @@ export interface ListedModel {
120
170
  readonly id: string
121
171
  readonly ref: ModelRef
122
172
  readonly model: CatalogModel
173
+ /** The skill an alias binds (`skill:` target), listed as `plugboard.skill`. */
174
+ readonly skill?: string
175
+ /** The agent an `agent/<name>` id runs, listed as `plugboard.agent`. */
176
+ readonly agent?: string
123
177
  }
124
178
 
125
179
  /**
@@ -146,9 +200,16 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
146
200
  }
147
201
  }
148
202
  for (const [alias, target] of Object.entries(options.aliases)) {
149
- const ref = parseModelRef(target)
203
+ const parsed = parseAliasTarget(target)
204
+ const ref = parsed?.ref
150
205
  const model = ref && allowed(options, ref) && inAppScope(policy, alias, ref) ? find(catalog, ref) : undefined
151
- if (ref && model) data.push(entry(alias, ref, model))
206
+ if (ref && model) data.push({ ...entry(alias, ref, model), ...(parsed.skill === undefined ? {} : { skill: parsed.skill }) })
207
+ }
208
+ for (const [name, target] of Object.entries(options.agents)) {
209
+ const id = `${AGENT_PREFIX}${name}`
210
+ const ref = parseModelRef(target)
211
+ const model = ref && allowed(options, ref) && inAppScope(policy, id, ref) ? find(catalog, ref) : undefined
212
+ if (ref && model) data.push({ ...entry(id, ref, model), agent: name })
152
213
  }
153
214
  return data
154
215
  }
@@ -24,10 +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
+ import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
29
30
  import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
30
- 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"
31
42
  import type { RouteHost } from "./route.js"
32
43
 
33
44
  const SESSION_HEADER = "x-plugboard-session"
@@ -39,7 +50,7 @@ export interface ChoiceCodec {
39
50
  /** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
40
51
  readonly idPrefix: string
41
52
  /** One choice as a complete JSON body with `choices[0]` and `usage`. */
42
- collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }>
53
+ collect(events: AsyncIterable<PlugEvent>, index: number): Promise<CollectedChoice>
43
54
  /** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
44
55
  streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
45
56
  /** The final chunk with the summed usage (`include_usage`). */
@@ -155,7 +166,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
155
166
  return out
156
167
  }
157
168
 
158
- export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
169
+ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
159
170
  const fail = (error: PlugError) => sendError(response, error, encodeError(error))
160
171
  // Abandoned requests (client gone, deadline passed) must never reach the model caller.
161
172
  if (scope.signal.aborted) throw scope.signal.reason
@@ -165,6 +176,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
165
176
  const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
166
177
  /** History record ids as the callers report them, in choice order, for the response header (comma separated). */
167
178
  const records: Array<string | undefined> = []
179
+ /** The session page links of the same records, in choice order (option `historyUrl`, src/session/link.ts). */
180
+ const links: Array<string | undefined> = []
168
181
  // Headers: the first choice to be served wins. Body model: only a single call echoes the served model.
169
182
  const served = reportServed(response, info, (s) => {
170
183
  if (calls.length === 1) codec.setModel(s.model)
@@ -172,6 +185,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
172
185
  const policy = reportPolicy(response)
173
186
  // One header for all choices: each answered call adds its tokens and cost (`x-plugboard-usage`, #52).
174
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)
175
190
  // Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
176
191
  const cache = reportCache(response)
177
192
  const open = (index: number) =>
@@ -179,14 +194,20 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
179
194
  host.caller.stream(calls[index]!, {
180
195
  app,
181
196
  signal: gscope.signal,
182
- onRecord: (recordID) => {
197
+ onRecord: (recordID, directory) => {
183
198
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
184
199
  records[index] = recordID
185
200
  response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
201
+ if (host.options.historyUrl !== undefined) {
202
+ links[index] = sessionUrl(host.options.historyUrl, recordID, directory)
203
+ info.sessionUrl = links.filter((link) => link !== undefined).join(",")
204
+ response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
205
+ }
186
206
  },
187
207
  onServed: served,
188
208
  onPolicy: policy,
189
209
  onUsage: usageHeader,
210
+ onServerTools: serverToolsHeader,
190
211
  onCache: cache,
191
212
  noCache: calls.length > 1,
192
213
  }),
@@ -194,8 +215,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
194
215
  )
195
216
 
196
217
  if (!codec.stream) {
197
- const results = await new Promise<Array<{ status: number; body: object; retryAfterSeconds?: number }>>((resolve, reject) => {
198
- const done: Array<{ status: number; body: object; retryAfterSeconds?: number }> = []
218
+ const results = await new Promise<CollectedChoice[]>((resolve, reject) => {
219
+ const done: CollectedChoice[] = []
199
220
  let left = calls.length
200
221
  calls.forEach((_, index) => {
201
222
  codec.collect(open(index), index).then(
@@ -215,6 +236,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
215
236
  })
216
237
  })
217
238
  const first = results[0]!
239
+ if (results.some((result) => result.structuredFallback)) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
218
240
  return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
219
241
  }
220
242
 
@@ -6,6 +6,7 @@
6
6
  * the request omits `ref`.
7
7
  */
8
8
  import type { PlugboardOptions } from "../config.js"
9
+ import { bodyUser, userField } from "../user.js"
9
10
  import {
10
11
  isPlugError,
11
12
  type PlugError,
@@ -31,7 +32,7 @@ export interface ParsedChat {
31
32
  }
32
33
 
33
34
  /** Name of the forced tool that carries structured output. */
34
- const RESPOND_TOOL = "respond"
35
+ export const RESPOND_TOOL = "respond"
35
36
 
36
37
  /** The most stop sequences OpenAI accepts. */
37
38
  const MAX_STOP = 4
@@ -195,14 +196,14 @@ const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError =
195
196
  }
196
197
 
197
198
  /** System instruction for `json_object`: no schema, so the caller's prompt describes the keys (OpenAI semantics). */
198
- 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."
199
200
 
200
201
  /**
201
202
  * `json_schema` -> the `respond` tool (`structured`; forced unless the caller also sends tools).
202
203
  * `json_object` -> a system instruction and plain text content (`instruction`): there is no schema
203
204
  * to force a tool with.
204
205
  */
205
- const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
206
+ export const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
206
207
  if (raw == null) return {}
207
208
  if (!isRecord(raw)) return bad("response_format must be an object", "response_format")
208
209
  if (raw.type === "text") return {}
@@ -322,6 +323,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
322
323
 
323
324
  const forced: PlugToolChoice | undefined = respond ? (withCallerTools ? "required" : { type: "tool", name: RESPOND_TOOL }) : toolChoice
324
325
  const request: Omit<PlugRequest, "ref"> = {
326
+ ...userField(bodyUser(body)),
325
327
  model: body.model,
326
328
  system: format.instruction ? [...parsed.system, format.instruction] : parsed.system,
327
329
  messages: parsed.messages,
@@ -337,7 +339,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
337
339
  model: body.model,
338
340
  stream: body.stream === true,
339
341
  includeUsage: streamOptions.include_usage === true,
340
- ...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
342
+ ...(respond ? { structuredTool: RESPOND_TOOL, structuredSchema: respond.parameters, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
341
343
  ...(format.instruction ? { jsonObject: true } : {}),
342
344
  ...(generation.stop ? { stop: generation.stop } : {}),
343
345
  },