opencode-plugboard 0.18.0 → 0.20.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/metrics.ts CHANGED
@@ -62,8 +62,8 @@ export interface Metrics {
62
62
  dropped(params: ReadonlyArray<string>): void
63
63
  /** A model call failed before any output and was retried on the next target of its alias. */
64
64
  fallback(app: string): void
65
- /** A call was refused with 429 by a concurrency cap (`concurrency`) or a requests-per-minute limit (`rate`). */
66
- limited(app: string, reason: "concurrency" | "rate"): void
65
+ /** A call was refused with 429 by a concurrency cap (`concurrency`), a requests-per-minute limit (`rate`) or a budget (`budget`). */
66
+ limited(app: string, reason: "concurrency" | "rate" | "budget"): void
67
67
  /** A call the response cache applied to: a stored answer replayed (`hit`) or the model run (`miss`). */
68
68
  cache(app: string, result: "hit" | "miss"): void
69
69
  /** One history sweep ended. */
@@ -110,7 +110,7 @@ export function createMetrics(): Metrics {
110
110
  })
111
111
  const limited = new Counter({
112
112
  name: "plugboard_limited_total",
113
- help: "Model calls refused with 429 by a concurrency cap or a requests-per-minute limit, by app and reason.",
113
+ help: "Model calls refused with 429 by a concurrency cap, a requests-per-minute limit or a budget, by app and reason.",
114
114
  labelNames: ["app", "reason"],
115
115
  registers: [registry],
116
116
  })
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
  }
@@ -25,6 +25,8 @@ import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.j
25
25
  import { reportServed, type ServedInfo } from "../fallback.js"
26
26
  import { reportCache } from "../cache.js"
27
27
  import { reportPolicy } from "../policy.js"
28
+ import { reportUsage } from "../usage-header.js"
29
+ import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
28
30
  import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
29
31
  import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
30
32
  import type { RouteHost } from "./route.js"
@@ -154,7 +156,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
154
156
  return out
155
157
  }
156
158
 
157
- export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
159
+ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
158
160
  const fail = (error: PlugError) => sendError(response, error, encodeError(error))
159
161
  // Abandoned requests (client gone, deadline passed) must never reach the model caller.
160
162
  if (scope.signal.aborted) throw scope.signal.reason
@@ -164,11 +166,15 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
164
166
  const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
165
167
  /** History record ids as the callers report them, in choice order, for the response header (comma separated). */
166
168
  const records: Array<string | undefined> = []
169
+ /** The session page links of the same records, in choice order (option `historyUrl`, src/session/link.ts). */
170
+ const links: Array<string | undefined> = []
167
171
  // Headers: the first choice to be served wins. Body model: only a single call echoes the served model.
168
172
  const served = reportServed(response, info, (s) => {
169
173
  if (calls.length === 1) codec.setModel(s.model)
170
174
  })
171
175
  const policy = reportPolicy(response)
176
+ // One header for all choices: each answered call adds its tokens and cost (`x-plugboard-usage`, #52).
177
+ const usageHeader = reportUsage(response, codec.stream)
172
178
  // Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
173
179
  const cache = reportCache(response)
174
180
  const open = (index: number) =>
@@ -176,13 +182,19 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
176
182
  host.caller.stream(calls[index]!, {
177
183
  app,
178
184
  signal: gscope.signal,
179
- onRecord: (recordID) => {
185
+ onRecord: (recordID, directory) => {
180
186
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
181
187
  records[index] = recordID
182
188
  response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
189
+ if (host.options.historyUrl !== undefined) {
190
+ links[index] = sessionUrl(host.options.historyUrl, recordID, directory)
191
+ info.sessionUrl = links.filter((link) => link !== undefined).join(",")
192
+ response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
193
+ }
183
194
  },
184
195
  onServed: served,
185
196
  onPolicy: policy,
197
+ onUsage: usageHeader,
186
198
  onCache: cache,
187
199
  noCache: calls.length > 1,
188
200
  }),
@@ -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,
@@ -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,
@@ -339,6 +341,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
339
341
  includeUsage: streamOptions.include_usage === true,
340
342
  ...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
341
343
  ...(format.instruction ? { jsonObject: true } : {}),
344
+ ...(generation.stop ? { stop: generation.stop } : {}),
342
345
  },
343
346
  ...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
344
347
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
8
8
  import type { ListedModel } from "../models.js"
9
+ import { StopHoldback, stripStopTail } from "../stop.js"
9
10
 
10
11
  type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
11
12
 
@@ -33,6 +34,8 @@ export interface OpenAiExchange {
33
34
  * stripped from the content (`unfence`).
34
35
  */
35
36
  readonly jsonObject?: boolean
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
+ readonly stop?: ReadonlyArray<string>
36
39
  }
37
40
 
38
41
  /** How a streamed exchange ended. Nothing was written when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
@@ -108,12 +111,12 @@ const encodePlugboard = (model: ListedModel["model"]) => ({
108
111
  /** `GET /v1/models` body. */
109
112
  export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
110
113
  object: "list",
111
- data: models.map(({ id, ref, model }) => ({
114
+ data: models.map(({ id, ref, model, skill, agent }) => ({
112
115
  id,
113
116
  object: "model",
114
117
  created: 0,
115
118
  owned_by: ref.providerID,
116
- plugboard: encodePlugboard(model),
119
+ plugboard: { ...encodePlugboard(model), ...(skill === undefined ? {} : { skill }), ...(agent === undefined ? {} : { agent }) },
117
120
  })),
118
121
  })
119
122
 
@@ -219,6 +222,7 @@ export async function collectCompletion(
219
222
  const error = failure ?? noFinish()
220
223
  return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
221
224
  }
225
+ if (finish === "stop" && calls.length === 0 && exchange.structuredTool === undefined) text = stripStopTail(text, exchange.stop ?? []).text
222
226
 
223
227
  const answer = calls.find((call) => structured.isAnswer(exchange, call))
224
228
  const others = calls.filter((call) => !structured.isAnswer(exchange, call))
@@ -309,6 +313,12 @@ export async function streamCompletion(
309
313
  started = true
310
314
  chunk({ role: "assistant", content: "" })
311
315
  }
316
+ // A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
317
+ const holdback = exchange.structuredTool === undefined ? new StopHoldback(exchange.stop ?? []) : undefined
318
+ const send = (text: string) => {
319
+ const out = holdback ? holdback.push(text) : text
320
+ if (out.length > 0) chunk({ content: out })
321
+ }
312
322
  // json_object: text that opens with a backtick may be a fenced answer, so it waits (as `pending`)
313
323
  // until a tool call or the finish shows the whole text; anything else streams as it comes. A
314
324
  // fenced answer therefore arrives as one chunk.
@@ -319,7 +329,7 @@ export async function streamCompletion(
319
329
  const text = unfence(pending)
320
330
  pending = ""
321
331
  start()
322
- chunk({ content: text })
332
+ send(text)
323
333
  }
324
334
 
325
335
  for await (const event of events) {
@@ -359,10 +369,14 @@ export async function streamCompletion(
359
369
  }
360
370
  continue
361
371
  }
362
- if (event.type !== "text-delta") flush()
372
+ if (event.type !== "text-delta") {
373
+ flush()
374
+ const rest = holdback?.release() ?? ""
375
+ if (rest.length > 0) chunk({ content: rest })
376
+ }
363
377
  start()
364
378
  if (event.type === "text-delta") {
365
- if (event.text.length > 0) chunk({ content: event.text })
379
+ send(event.text)
366
380
  } else {
367
381
  // Tool calls arrive whole, so each goes out as one chunk with the next index.
368
382
  chunk({ tool_calls: [{ index: calls++, id: event.id, type: "function", function: { name: event.name, arguments: args(event.input) } }] })
@@ -382,6 +396,10 @@ export async function streamCompletion(
382
396
  }
383
397
  flush()
384
398
  start()
399
+ if (holdback) {
400
+ const rest = holdback.finish(finish === "stop" && calls === 0).text
401
+ if (rest.length > 0) chunk({ content: rest })
402
+ }
385
403
  if (held && isStructured) chunk({ content: structured.content(held) })
386
404
  chunk({}, finishReason(finish!, calls, isStructured))
387
405
  if (choice) return { started, ...(usage ? { usage } : {}) }
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { IncomingMessage, ServerResponse } from "node:http"
24
+ import { serveAdmin } from "../admin/route.js"
24
25
  import { DEFAULT_APP, authenticate, authenticateUsage, type PlugboardOptions } from "../config.js"
25
26
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
26
27
  import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
@@ -46,13 +47,18 @@ import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
46
47
  import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
47
48
  import { reportCache } from "../cache.js"
48
49
  import { reportPolicy } from "../policy.js"
50
+ import { reportUsage } from "../usage-header.js"
49
51
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
50
52
  import { serveCompletions } from "../completions/route.js"
51
53
  import { serveResponses } from "../responses/route.js"
52
54
  import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
55
+ import { budgetFor, budgetStatus } from "../budgets.js"
56
+ import type { KeyStore } from "../keys.js"
53
57
  import type { UsageRecorder } from "../usage.js"
54
58
  import { DASHBOARD_ROUTES } from "../dashboard/page.js"
55
59
  import { serveDashboard } from "../dashboard/assets.js"
60
+ import { bodyUser, noteEndUser } from "../user.js"
61
+ import { setSessionLink } from "../session/link.js"
56
62
  import { chatCodec, serveChoices } from "./fanout.js"
57
63
  import { parseChatRequest } from "./request.js"
58
64
  import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
@@ -70,6 +76,8 @@ export interface RouteHost {
70
76
  readonly status?: () => RuntimeStatus
71
77
  /** Per-app usage (`src/usage.ts`): fed from the request log point, and served by `GET /v1/usage` when present. */
72
78
  readonly usage?: UsageRecorder
79
+ /** Runtime app keys (src/keys.ts, #55): their digests authenticate, and the admin routes (src/admin/route.ts) read them. */
80
+ readonly keys?: KeyStore
73
81
  }
74
82
 
75
83
  const SESSION_HEADER = "x-plugboard-session"
@@ -114,6 +122,8 @@ interface RequestInfo extends ServedInfo {
114
122
  failed?: string
115
123
  /** Sampling parameters dropped for this model (comma list), when any. */
116
124
  dropped?: string
125
+ /** The end user the body named, when kept (src/user.ts). */
126
+ user?: string
117
127
  }
118
128
 
119
129
  export function createOpenAiRoute(routeHost: RouteHost) {
@@ -121,6 +131,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
121
131
  const metrics = routeHost.options.metrics ? (routeHost.metrics ?? processMetrics()) : undefined
122
132
  const runtimeStatus = routeHost.status ?? processStatus
123
133
  const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
134
+ /** The runtime app keys that authenticate (loaded from storage on first use, #55). */
135
+ const runtimeDigests = async () => (host.keys ? await host.keys.digests() : [])
124
136
  /** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
125
137
  const known = (route: string) =>
126
138
  KNOWN_ROUTES.includes(route) ||
@@ -154,7 +166,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
154
166
  const path = url.pathname
155
167
  const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
156
168
  const gemini = matchGeminiRoute(request.method, path)
157
- if (gemini) googleErrors.add(response)
169
+ // Any /v1beta path is Google's API, even one that matches no Gemini route (#150): its errors use Google's body.
170
+ const googleShaped = gemini !== undefined || path === "/v1beta" || path.startsWith("/v1beta/")
171
+ if (googleShaped) googleErrors.add(response)
158
172
  const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
159
173
  // Arbitrary client paths stay out of the log.
160
174
  info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
@@ -172,7 +186,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
172
186
  const keyed = Object.keys(host.options.keys).length > 0
173
187
  let app: string | undefined = keyed ? undefined : DEFAULT_APP
174
188
  if (keyed && request.headers.authorization !== undefined) {
175
- const auth = authenticate(liveOptions(host.options), request.headers.authorization)
189
+ const auth = authenticate(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
176
190
  if (isPlugError(auth)) return fail(response, auth)
177
191
  app = auth.app
178
192
  }
@@ -188,12 +202,25 @@ export function createOpenAiRoute(routeHost: RouteHost) {
188
202
  // Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
189
203
  if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
190
204
 
205
+ // Runtime app keys (#55): the admin routes answer the admin key only, and are a 404 without `adminKey`.
206
+ if (path === "/v1/admin/keys" || path.startsWith("/v1/admin/keys/"))
207
+ return serveAdmin({ options: liveOptions(host.options), keys: host.keys }, { path, request, response, scope, info })
208
+
191
209
  // Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
192
210
  // Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
193
- const auth = authenticate(liveOptions(host.options), gemini ? geminiAuthorization(request, url) : request.headers.authorization)
211
+ const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization, await runtimeDigests())
194
212
  if (isPlugError(auth)) return fail(response, auth)
195
213
  info.app = auth.app
196
214
 
215
+ // A /v1beta path no Gemini route matches: the AI SDK and LangChain send a model id with a slash and no `models/` prefix (#150).
216
+ if (googleShaped && !gemini)
217
+ return fail(response, {
218
+ status: 404,
219
+ kind: "not_found",
220
+ code: "not_found",
221
+ message: `No Gemini route for ${request.method} ${path}. Expected POST /v1beta/models/{model}:generateContent or POST /v1beta/models/{model}:streamGenerateContent; the path must start with /v1beta/models/. GET /v1beta/models lists the models.`,
222
+ })
223
+
197
224
  if (!gemini && modelId === undefined && !known(route)) {
198
225
  return fail(response, {
199
226
  status: 404,
@@ -240,6 +267,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
240
267
  if (unsupported) return fail(response, unsupported)
241
268
 
242
269
  const body = await readJsonBody(request, scope.signal)
270
+ noteEndUser(response, info, bodyUser(body))
243
271
  if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
244
272
  if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
245
273
  const parsed = parseChatRequest(body, host.options)
@@ -267,13 +295,15 @@ export function createOpenAiRoute(routeHost: RouteHost) {
267
295
  host.caller.stream(call, {
268
296
  app: auth.app,
269
297
  signal: scope.signal,
270
- onRecord: (recordID) => {
298
+ onRecord: (recordID, directory) => {
271
299
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
272
300
  response.setHeader(SESSION_HEADER, recordID)
301
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
273
302
  },
274
303
  // An alias with fallbacks: report which target answered, and echo it as the response model.
275
304
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
276
305
  onPolicy: reportPolicy(response),
306
+ onUsage: reportUsage(response, exchange.stream),
277
307
  onCache: reportCache(response),
278
308
  }),
279
309
  scope,
@@ -304,7 +334,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
304
334
 
305
335
  /** `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. */
306
336
  async function serveUsage(request: IncomingMessage, response: ServerResponse, url: URL, usage: UsageRecorder, info: RequestInfo): Promise<void> {
307
- const who = authenticateUsage(liveOptions(host.options), request.headers.authorization)
337
+ const who = authenticateUsage(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
308
338
  if (isPlugError(who)) return fail(response, who)
309
339
  const app = url.searchParams.get("app") ?? undefined
310
340
  if (!who.admin) {
@@ -316,8 +346,17 @@ export function createOpenAiRoute(routeHost: RouteHost) {
316
346
  from: url.searchParams.get("from") ?? undefined,
317
347
  to: url.searchParams.get("to") ?? undefined,
318
348
  app: who.admin ? app : who.app,
349
+ user: url.searchParams.get("user") || undefined,
319
350
  })
320
351
  if (isPlugError(report)) return fail(response, report)
352
+ // Each app's budget state, read from its current window rather than from the report's range (src/budgets.ts).
353
+ if (host.options.budgets) {
354
+ const at = Date.now()
355
+ for (const [name, entry] of Object.entries(report.apps)) {
356
+ const budget = budgetFor(host.options.budgets, name)
357
+ if (budget) entry.budgets = await budgetStatus(usage, budget, name, at)
358
+ }
359
+ }
321
360
  return sendJson(response, 200, report)
322
361
  }
323
362
 
@@ -374,10 +413,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
374
413
  } catch {}
375
414
  const ms = Date.now() - started
376
415
  metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
377
- if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
416
+ 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 }) })
378
417
  // Probes and scrapes that succeed are not worth a line each.
379
418
  if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
380
- 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 }
419
+ 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 }
381
420
  if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
382
421
  else log("request", fields)
383
422
  }
@@ -2,13 +2,15 @@
2
2
  * OpenAI Responses request -> neutral `PlugRequest` plus the `ResponsesExchange` the response
3
3
  * encoder needs.
4
4
  *
5
- * Pure function: no I/O. Stateless subset: the caller sends the whole conversation in `input`
6
- * each time, which is how history already works. Anything that needs server-side state
7
- * (`previous_response_id`, `conversation`, `item_reference`, `background`) or a hosted tool is
8
- * rejected with 400 `unsupported_parameter` naming the field. The route resolves `model` to a
5
+ * Pure function: no I/O. The caller sends the whole conversation in `input` each time, which is
6
+ * how history already works. An `item_reference` is resolved through the `resolve` argument (the
7
+ * route passes the per-app store of recent answers, src/responses/store.ts; #147). Anything else that
8
+ * needs server-side state (`previous_response_id`, `conversation`, `background`) or a hosted tool
9
+ * is rejected with 400 `unsupported_parameter` naming the field. The route resolves `model` to a
9
10
  * `ModelRef` afterwards, which is why the request omits `ref`.
10
11
  */
11
12
  import type { PlugboardOptions } from "../config.js"
13
+ import { bodyUser, userField } from "../user.js"
12
14
  import {
13
15
  isPlugError,
14
16
  type PlugError,
@@ -121,7 +123,23 @@ const assistantText = (content: unknown, param: string): string | PlugError => {
121
123
  * assistant message, the shape the Chat Completions front-end produces, so a parallel-call turn
122
124
  * is one message followed by its results.
123
125
  */
124
- const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[] } | PlugError => {
126
+ /** The output item an `item_reference` names, or undefined when the app's store does not hold it. */
127
+ export type ItemResolver = (itemId: string) => JsonObject | undefined
128
+
129
+ /** An `item_reference` is the output item of an earlier answer, decoded as that item (#147). */
130
+ const resolveReference = (item: JsonObject, param: string, resolve: ItemResolver | undefined): JsonObject | PlugError => {
131
+ if (typeof item.id !== "string" || item.id.length === 0) return bad("item_reference needs the id of an output item", `${param}.id`)
132
+ const found = resolve?.(item.id)
133
+ if (found === undefined)
134
+ return bad(
135
+ `item_reference "${item.id}" is not known to plugboard: it keeps the output of recent answers for an hour, per app. Send the earlier items in full, or send store: false (Vercel AI SDK: providerOptions.openai.store = false)`,
136
+ `${param}.id`,
137
+ "item_not_found",
138
+ )
139
+ return found
140
+ }
141
+
142
+ const parseInput = (input: unknown, resolve: ItemResolver | undefined): { system: string[]; messages: PlugMessage[] } | PlugError => {
125
143
  if (typeof input === "string") return { system: [], messages: [{ role: "user", content: [{ type: "text", text: input }] }] }
126
144
  if (!Array.isArray(input) || input.length === 0) return bad("input must be a string or a non-empty array of items", "input")
127
145
 
@@ -134,9 +152,11 @@ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[]
134
152
  pending = undefined
135
153
  }
136
154
 
137
- for (const [index, item] of input.entries()) {
155
+ for (const [index, given] of input.entries()) {
138
156
  const param = `input[${index}]`
139
- if (!isRecord(item)) return bad("Each input item must be an object", param)
157
+ if (!isRecord(given)) return bad("Each input item must be an object", param)
158
+ const item = given.type === "item_reference" ? resolveReference(given, param, resolve) : given
159
+ if (isPlugError(item)) return item
140
160
  const type = item.type ?? (item.role !== undefined ? "message" : undefined)
141
161
  switch (type) {
142
162
  case "message": {
@@ -192,11 +212,6 @@ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[]
192
212
  // Reasoning items carry no content plugboard can replay; they are dropped.
193
213
  case "reasoning":
194
214
  break
195
- case "item_reference":
196
- return unsupported(
197
- "item_reference needs server-side state, which plugboard does not keep; send the full items instead (Vercel AI SDK: providerOptions.openai.store = false)",
198
- `${param}.type`,
199
- )
200
215
  default:
201
216
  return unsupported(`Unsupported input item type "${String(type)}"`, `${param}.type`)
202
217
  }
@@ -296,7 +311,7 @@ const checkUnsupported = (body: JsonObject): PlugError | undefined => {
296
311
  if (body.truncation != null && body.truncation !== "disabled") return unsupported('Only truncation "disabled" is supported', "truncation")
297
312
  }
298
313
 
299
- export function parseResponsesRequest(body: unknown, options: PlugboardOptions): ParsedResponses | PlugError {
314
+ export function parseResponsesRequest(body: unknown, options: PlugboardOptions, resolve?: ItemResolver): ParsedResponses | PlugError {
300
315
  if (!isRecord(body)) return bad("Request body must be a JSON object")
301
316
  if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
302
317
  if (body.input == null) return bad("input is required", "input")
@@ -305,7 +320,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions):
305
320
  if (body.instructions != null && typeof body.instructions !== "string") return bad("instructions must be a string", "instructions")
306
321
  if (body.reasoning != null && !isRecord(body.reasoning)) return bad("reasoning must be an object", "reasoning")
307
322
 
308
- const parsed = parseInput(body.input)
323
+ const parsed = parseInput(body.input, resolve)
309
324
  if (isPlugError(parsed)) return parsed
310
325
  const callerTools = parseTools(body.tools)
311
326
  if (!Array.isArray(callerTools)) return callerTools
@@ -339,6 +354,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions):
339
354
  const instructions = typeof body.instructions === "string" && body.instructions.length > 0 ? [body.instructions] : []
340
355
  const system = [...instructions, ...parsed.system, ...(format.instruction ? [format.instruction] : [])]
341
356
  const request: Omit<PlugRequest, "ref"> = {
357
+ ...userField(bodyUser(body)),
342
358
  model: body.model,
343
359
  system,
344
360
  messages: parsed.messages,
@@ -15,10 +15,13 @@ import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "
15
15
  import { resolveModel } from "../models.js"
16
16
  import { reportCache } from "../cache.js"
17
17
  import { reportPolicy } from "../policy.js"
18
+ import { reportUsage } from "../usage-header.js"
19
+ import { setSessionLink } from "../session/link.js"
18
20
  import { encodeError } from "../openai/response.js"
19
21
  import type { RouteHost } from "../openai/route.js"
20
22
  import { parseResponsesRequest } from "./request.js"
21
23
  import { runResponse, sseFrame } from "./response.js"
24
+ import { findItem, rememberAnswer } from "./store.js"
22
25
 
23
26
  const SESSION_HEADER = "x-plugboard-session"
24
27
 
@@ -34,7 +37,7 @@ export interface ResponsesCall {
34
37
  export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
35
38
  const fail = (error: PlugError) => sendError(response, error, encodeError(error))
36
39
 
37
- const parsed = parseResponsesRequest(body, host.options)
40
+ const parsed = parseResponsesRequest(body, host.options, (itemId) => findItem(app, itemId))
38
41
  if (isPlugError(parsed)) return fail(parsed)
39
42
  const catalog = await scope.race(host.loadCatalog())
40
43
  const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
@@ -49,12 +52,14 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
49
52
  host.caller.stream(call, {
50
53
  app,
51
54
  signal: scope.signal,
52
- onRecord: (recordID) => {
55
+ onRecord: (recordID, directory) => {
53
56
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
54
57
  response.setHeader(SESSION_HEADER, recordID)
58
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
55
59
  },
56
60
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
57
61
  onPolicy: reportPolicy(response),
62
+ onUsage: reportUsage(response, exchange.stream),
58
63
  onCache: reportCache(response),
59
64
  }),
60
65
  scope,
@@ -62,11 +67,12 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
62
67
 
63
68
  if (exchange.stream) {
64
69
  const sse = sseWriter(response, scope.signal)
65
- const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
70
+ const run = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
71
+ if (run.response && !run.failure) rememberAnswer(app, run.response)
66
72
  if (!sse.started && !response.headersSent) {
67
- if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
73
+ if (!scope.signal.aborted || scope.timedOut) fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
68
74
  } else {
69
- if (failure) info.failed = failure.code ?? failure.kind
75
+ if (run.failure) info.failed = run.failure.code ?? run.failure.kind
70
76
  if (!response.writableEnded) response.end()
71
77
  }
72
78
  return
@@ -74,5 +80,6 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
74
80
 
75
81
  const run = await runResponse(events, exchange, () => {})
76
82
  if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
83
+ rememberAnswer(app, run.response)
77
84
  sendJson(response, 200, run.response)
78
85
  }