opencode-plugboard 0.17.0 → 0.19.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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Serves the usage dashboard (`src/dashboard/page.ts`): the page, the page's own script and
3
+ * stylesheet, and uPlot's browser build and stylesheet from its npm package. Files are read on the
4
+ * first request and kept in memory. Setup does no filesystem work (I3). The bytes never change
5
+ * while plugboard runs, so the ETag is their hash and a revalidation answers 304.
6
+ */
7
+ import { createHash } from "node:crypto"
8
+ import { readFile } from "node:fs/promises"
9
+ import type { IncomingMessage, ServerResponse } from "node:http"
10
+ import { createRequire } from "node:module"
11
+ import { dirname, join } from "node:path"
12
+ import { fileURLToPath } from "node:url"
13
+ import { sendText } from "../discovery.js"
14
+ import { DASHBOARD_HEADERS, DASHBOARD_PAGE } from "./page.js"
15
+
16
+ const PAGE_PREFIX = "GET /dashboard/"
17
+ const here = dirname(fileURLToPath(import.meta.url))
18
+ const resolveFrom = createRequire(import.meta.url)
19
+
20
+ interface AssetFile {
21
+ readonly path: () => string
22
+ readonly contentType: string
23
+ }
24
+
25
+ /** The asset named by the last segment of `/dashboard/<name>`. */
26
+ const ASSETS: Readonly<Record<string, AssetFile>> = {
27
+ "app.js": { path: () => join(here, "app.js"), contentType: "text/javascript; charset=utf-8" },
28
+ "app.css": { path: () => join(here, "app.css"), contentType: "text/css; charset=utf-8" },
29
+ "uplot.js": { path: () => resolveFrom.resolve("uplot/dist/uPlot.iife.min.js"), contentType: "text/javascript; charset=utf-8" },
30
+ "uplot.css": { path: () => resolveFrom.resolve("uplot/dist/uPlot.min.css"), contentType: "text/css; charset=utf-8" },
31
+ }
32
+
33
+ interface Asset {
34
+ readonly body: string
35
+ readonly contentType: string
36
+ readonly etag: string
37
+ }
38
+
39
+ const loaded = new Map<string, Promise<Asset>>()
40
+
41
+ async function load(name: string): Promise<Asset> {
42
+ const file = ASSETS[name]
43
+ if (!file) throw new Error(`no dashboard asset ${name}`)
44
+ const body = await readFile(file.path(), "utf8")
45
+ const digest = createHash("sha256").update(body).digest("hex").slice(0, 32)
46
+ return { body, contentType: file.contentType, etag: `"${digest}"` }
47
+ }
48
+
49
+ /** The asset's bytes, read once per process. A failed read is not kept, so the next request tries again. */
50
+ function asset(name: string): Promise<Asset> {
51
+ let pending = loaded.get(name)
52
+ if (!pending) {
53
+ pending = load(name)
54
+ loaded.set(name, pending)
55
+ pending.catch(() => loaded.delete(name))
56
+ }
57
+ return pending
58
+ }
59
+
60
+ /** Answers one dashboard route (`DASHBOARD_ROUTES`, already matched by the caller). */
61
+ export async function serveDashboard(request: IncomingMessage, response: ServerResponse, route: string): Promise<void> {
62
+ if (route === "GET /dashboard") return sendText(response, 200, "text/html; charset=utf-8", DASHBOARD_PAGE, DASHBOARD_HEADERS)
63
+ const found = await asset(route.slice(PAGE_PREFIX.length))
64
+ // Assets revalidate on every use: a plugboard upgrade changes them, and the ETag makes that check free.
65
+ const headers = { ...DASHBOARD_HEADERS, "cache-control": "no-cache", etag: found.etag }
66
+ if (request.headers["if-none-match"] === found.etag) {
67
+ if (!response.headersSent && !response.destroyed) response.writeHead(304, headers)
68
+ response.end()
69
+ return
70
+ }
71
+ return sendText(response, 200, found.contentType, found.body, headers)
72
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The usage dashboard page (`GET /dashboard`, #141): static HTML, its headers and the routes that
3
+ * serve it. The page holds no data. It asks for a key, then reads `GET /v1/usage` from the same
4
+ * origin with that key (the data never reaches the page's HTML). No inline script or style: the
5
+ * script and stylesheets are plugboard's own files under `/dashboard/`, served by `src/dashboard/assets.ts`.
6
+ */
7
+
8
+ /** Scripts and styles from this origin only; nothing else may load, frame or submit. */
9
+ export const DASHBOARD_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
10
+
11
+ /** Headers of every dashboard response; the assets get them too. */
12
+ export const DASHBOARD_HEADERS: Readonly<Record<string, string>> = {
13
+ "content-security-policy": DASHBOARD_CSP,
14
+ "x-frame-options": "DENY",
15
+ "referrer-policy": "no-referrer",
16
+ }
17
+
18
+ /** Routes of the dashboard: the page and its four files. Served only when option `usage` is on. */
19
+ export const DASHBOARD_ROUTES: ReadonlySet<string> = new Set([
20
+ "GET /dashboard",
21
+ "GET /dashboard/app.js",
22
+ "GET /dashboard/app.css",
23
+ "GET /dashboard/uplot.js",
24
+ "GET /dashboard/uplot.css",
25
+ ])
26
+
27
+ export const DASHBOARD_PAGE = `<!doctype html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1">
32
+ <meta name="referrer" content="no-referrer">
33
+ <title>plugboard usage</title>
34
+ <link rel="stylesheet" href="/dashboard/uplot.css">
35
+ <link rel="stylesheet" href="/dashboard/app.css">
36
+ <script src="/dashboard/uplot.js" defer></script>
37
+ <script src="/dashboard/app.js" defer></script>
38
+ </head>
39
+ <body>
40
+ <header class="top">
41
+ <h1>plugboard usage</h1>
42
+ <p class="muted">Requests, tokens and estimated cost per app, per UTC day. Cost uses the catalog's list prices.</p>
43
+ </header>
44
+ <main>
45
+ <section id="signin" class="card" hidden>
46
+ <form id="key-form">
47
+ <label for="key">API key</label>
48
+ <div class="row">
49
+ <input id="key" name="key" type="password" autocomplete="off" autocapitalize="off" spellcheck="false">
50
+ <button type="submit" class="primary">Show usage</button>
51
+ </div>
52
+ <p class="muted">The admin key shows every app. An app key shows only its own. The key is kept in this tab's session storage and sent only to this server.</p>
53
+ </form>
54
+ <p id="auth-error" class="error" role="alert" hidden></p>
55
+ </section>
56
+ <section id="panel" hidden>
57
+ <div class="toolbar">
58
+ <div class="ranges" role="group" aria-label="Range">
59
+ <button type="button" data-days="1">Today</button>
60
+ <button type="button" data-days="7">7 days</button>
61
+ <button type="button" data-days="30">30 days</button>
62
+ <button type="button" data-days="90">90 days</button>
63
+ </div>
64
+ <div class="row">
65
+ <button type="button" id="refresh">Refresh</button>
66
+ <button type="button" id="sign-out">Sign out</button>
67
+ </div>
68
+ </div>
69
+ <p id="status" class="muted" aria-live="polite"></p>
70
+ <h2>Totals</h2>
71
+ <div id="totals" class="cards"></div>
72
+ <h2>Apps</h2>
73
+ <div class="scroll"><table id="apps"></table></div>
74
+ <h2>Requests per day</h2>
75
+ <div id="chart-requests" class="chart"></div>
76
+ <h2>Estimated cost per day (USD)</h2>
77
+ <div id="chart-cost" class="chart"></div>
78
+ <h2>Tokens per day</h2>
79
+ <div id="chart-tokens" class="chart"></div>
80
+ <h2>Models</h2>
81
+ <p id="models-note" class="muted"></p>
82
+ <div class="scroll"><table id="models"></table></div>
83
+ </section>
84
+ </main>
85
+ </body>
86
+ </html>
87
+ `
package/src/discovery.ts CHANGED
@@ -61,7 +61,7 @@ export interface Endpoint {
61
61
  /**
62
62
  * Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
63
63
  * `src/openai/route.ts` and the front-end routes; `test/http/discovery.test.ts` probes each served
64
- * route and fails when this list omits one (#136). `/v1/usage` and `/metrics` are added only when their options are on.
64
+ * route and fails when this list omits one (#136). `/v1/usage`, `/dashboard` and `/metrics` are added only when their options are on.
65
65
  */
66
66
  export const ENDPOINTS: ReadonlyArray<Endpoint> = [
67
67
  { method: "GET", path: "/", auth: false, description: "This page." },
@@ -85,7 +85,12 @@ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
85
85
  function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
86
86
  return [
87
87
  ...ENDPOINTS,
88
- ...(options.usage.enabled ? [{ method: "GET", path: "/v1/usage", auth: true, description: "Per-app usage by day: requests, tokens, cost, latency. The admin key reads every app." }] : []),
88
+ ...(options.usage.enabled
89
+ ? [
90
+ { method: "GET", path: "/v1/usage", auth: true, description: "Per-app usage by day: requests, tokens, cost, latency. The admin key reads every app." },
91
+ { method: "GET", path: "/dashboard", auth: false, description: "Usage dashboard page. Its data needs a key (`/v1/usage`)." },
92
+ ]
93
+ : []),
89
94
  ...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
90
95
  ]
91
96
  }
@@ -238,6 +243,7 @@ export function renderDiscoveryHtml(doc: Discovery): string {
238
243
  const more = doc.catalog.moreProviders ? `<p>${escapeHtml(doc.catalog.moreProviders)} more providers not listed.</p>` : ""
239
244
  sections.push(`<h2>Catalog</h2><table><tr><th>Provider</th><th>Models</th><th>Examples</th></tr>${rows}</table>${more}`)
240
245
  }
246
+ if (doc.endpoints.some((endpoint) => endpoint.path === "/dashboard")) sections.push(`<p><a href="/dashboard">Usage dashboard</a></p>`)
241
247
  sections.push(`<h2>Try it</h2>${pre(doc.example.curl)}`)
242
248
  sections.push(
243
249
  `<h2>Endpoints</h2><table><tr><th>Method</th><th>Path</th><th>Key</th><th></th></tr>${doc.endpoints
@@ -439,6 +439,7 @@ export function parseGeminiRequest(model: string, stream: boolean, body: unknown
439
439
  stream,
440
440
  ...(respond ? { structuredTool: RESPOND_TOOL, ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
441
441
  ...(format.instruction ? { jsonObject: true } : {}),
442
+ ...(generation.stop ? { stop: generation.stop } : {}),
442
443
  },
443
444
  ...(reasoningEffort ? { reasoningEffort } : {}),
444
445
  }
@@ -9,6 +9,7 @@
9
9
  import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
10
10
  import type { ListedModel } from "../models.js"
11
11
  import { unfence } from "../openai/response.js"
12
+ import { StopHoldback } from "../stop.js"
12
13
  import { WRAP_KEY } from "./request.js"
13
14
 
14
15
  type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
@@ -30,6 +31,8 @@ export interface GeminiExchange {
30
31
  readonly callerTools?: boolean
31
32
  /** `responseMimeType: application/json` without a schema: a single code fence around JSON text is stripped. */
32
33
  readonly jsonObject?: boolean
34
+ /** The request's `generationConfig.stopSequences`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). Not applied to structured output. */
35
+ readonly stop?: ReadonlyArray<string>
33
36
  }
34
37
 
35
38
  /** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
@@ -160,10 +163,18 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
160
163
  else parts.push(part)
161
164
  emit(envelope([part]))
162
165
  }
166
+ // A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
167
+ const holdback = exchange.structuredTool === undefined ? new StopHoldback(exchange.stop ?? []) : undefined
163
168
  const text = (delta: string) => {
164
- if (delta.length > 0) push({ text: delta })
169
+ const out = holdback ? holdback.push(delta) : delta
170
+ if (out.length > 0) push({ text: out })
171
+ }
172
+ const releaseHeld = () => {
173
+ const rest = holdback?.release() ?? ""
174
+ if (rest.length > 0) push({ text: rest })
165
175
  }
166
176
  const toolCall = (call: ToolCall) => {
177
+ releaseHeld()
167
178
  push({ functionCall: { name: call.name, args: argsOf(call.input) } })
168
179
  calls++
169
180
  }
@@ -221,6 +232,10 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
221
232
  failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
222
233
  if (failure) return { failure, started }
223
234
  flush()
235
+ if (holdback) {
236
+ const rest = holdback.finish(finish === "stop" && calls === 0).text
237
+ if (rest.length > 0) push({ text: rest })
238
+ }
224
239
  if (held && !passthrough) text(structuredText(exchange, held))
225
240
  const candidate = { finishReason: FINISH_REASONS[finish!] }
226
241
  const extra = usage ? { usageMetadata: encodeUsage(usage) } : {}
@@ -21,6 +21,7 @@ import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, s
21
21
  import { listAvailableModels, resolveModel } from "../models.js"
22
22
  import { reportCache } from "../cache.js"
23
23
  import { reportPolicy } from "../policy.js"
24
+ import { reportUsage } from "../usage-header.js"
24
25
  import type { RouteHost } from "../openai/route.js"
25
26
  import { dropRejected } from "../openai/sampling.js"
26
27
  import { estimateTokens } from "../anthropic/tokens.js"
@@ -162,6 +163,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
162
163
  },
163
164
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
164
165
  onPolicy: reportPolicy(response),
166
+ onUsage: reportUsage(response, match.stream),
165
167
  onCache: reportCache(response),
166
168
  }),
167
169
  scope,
package/src/log.ts CHANGED
@@ -142,5 +142,8 @@ export const log = (event: string, fields?: LogFields): void => state().logger.i
142
142
 
143
143
  export const logError = (event: string, fields?: LogFields): void => state().logger.error(record(event, fields))
144
144
 
145
+ /** A line for something that needs attention but did not fail a request (a budget at 80%). Info-level destination, `warn` in the level field. */
146
+ export const logWarn = (event: string, fields?: LogFields): void => state().logger.warn(record(event, fields))
147
+
145
148
  /** The message of a thrown value, without its stack or attached properties. */
146
149
  export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
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
  })
@@ -25,6 +25,7 @@ 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"
28
29
  import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
29
30
  import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
30
31
  import type { RouteHost } from "./route.js"
@@ -169,6 +170,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
169
170
  if (calls.length === 1) codec.setModel(s.model)
170
171
  })
171
172
  const policy = reportPolicy(response)
173
+ // One header for all choices: each answered call adds its tokens and cost (`x-plugboard-usage`, #52).
174
+ const usageHeader = reportUsage(response, codec.stream)
172
175
  // Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
173
176
  const cache = reportCache(response)
174
177
  const open = (index: number) =>
@@ -183,6 +186,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
183
186
  },
184
187
  onServed: served,
185
188
  onPolicy: policy,
189
+ onUsage: usageHeader,
186
190
  onCache: cache,
187
191
  noCache: calls.length > 1,
188
192
  }),
@@ -339,6 +339,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
339
339
  includeUsage: streamOptions.include_usage === true,
340
340
  ...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
341
341
  ...(format.instruction ? { jsonObject: true } : {}),
342
+ ...(generation.stop ? { stop: generation.stop } : {}),
342
343
  },
343
344
  ...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
344
345
  }
@@ -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. */
@@ -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 } : {}) }
@@ -3,7 +3,8 @@
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`
6
- * (keyless discovery), the authenticated `GET /v1/status`, and `GET /metrics` (option `metrics`).
6
+ * (keyless discovery), the authenticated `GET /v1/status`, `GET /metrics` (option `metrics`), and the
7
+ * usage dashboard `GET /dashboard` with its assets (option `usage`, `src/dashboard/`).
7
8
  *
8
9
  * The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
9
10
  * the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
@@ -45,11 +46,15 @@ import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
45
46
  import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
46
47
  import { reportCache } from "../cache.js"
47
48
  import { reportPolicy } from "../policy.js"
49
+ import { reportUsage } from "../usage-header.js"
48
50
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
49
51
  import { serveCompletions } from "../completions/route.js"
50
52
  import { serveResponses } from "../responses/route.js"
51
53
  import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
54
+ import { budgetFor, budgetStatus } from "../budgets.js"
52
55
  import type { UsageRecorder } from "../usage.js"
56
+ import { DASHBOARD_ROUTES } from "../dashboard/page.js"
57
+ import { serveDashboard } from "../dashboard/assets.js"
53
58
  import { chatCodec, serveChoices } from "./fanout.js"
54
59
  import { parseChatRequest } from "./request.js"
55
60
  import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
@@ -120,7 +125,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
120
125
  const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
121
126
  /** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
122
127
  const known = (route: string) =>
123
- KNOWN_ROUTES.includes(route) || DISCOVERY_ROUTES.includes(route) || (metrics !== undefined && route === "GET /metrics") || (host.usage !== undefined && route === "GET /v1/usage")
128
+ KNOWN_ROUTES.includes(route) ||
129
+ DISCOVERY_ROUTES.includes(route) ||
130
+ (metrics !== undefined && route === "GET /metrics") ||
131
+ (host.usage !== undefined && (route === "GET /v1/usage" || DASHBOARD_ROUTES.has(route)))
124
132
  /** Responses to Gemini routes: errors there use Google's body. */
125
133
  const googleErrors = new WeakSet<ServerResponse>()
126
134
  const fail = (response: ServerResponse, error: PlugError, close = false) =>
@@ -148,7 +156,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
148
156
  const path = url.pathname
149
157
  const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
150
158
  const gemini = matchGeminiRoute(request.method, path)
151
- if (gemini) googleErrors.add(response)
159
+ // Any /v1beta path is Google's API, even one that matches no Gemini route (#150): its errors use Google's body.
160
+ const googleShaped = gemini !== undefined || path === "/v1beta" || path.startsWith("/v1beta/")
161
+ if (googleShaped) googleErrors.add(response)
152
162
  const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
153
163
  // Arbitrary client paths stay out of the log.
154
164
  info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
@@ -176,15 +186,27 @@ export function createOpenAiRoute(routeHost: RouteHost) {
176
186
  return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
177
187
  }
178
188
 
189
+ // The usage dashboard (#141): a page and its files, keyless like the discovery pages. Its data comes from `GET /v1/usage`, behind a key.
190
+ if (host.usage && DASHBOARD_ROUTES.has(route)) return serveDashboard(request, response, route)
191
+
179
192
  // Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
180
193
  if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
181
194
 
182
195
  // Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
183
196
  // Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
184
- const auth = authenticate(liveOptions(host.options), gemini ? geminiAuthorization(request, url) : request.headers.authorization)
197
+ const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization)
185
198
  if (isPlugError(auth)) return fail(response, auth)
186
199
  info.app = auth.app
187
200
 
201
+ // A /v1beta path no Gemini route matches: the AI SDK and LangChain send a model id with a slash and no `models/` prefix (#150).
202
+ if (googleShaped && !gemini)
203
+ return fail(response, {
204
+ status: 404,
205
+ kind: "not_found",
206
+ code: "not_found",
207
+ 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.`,
208
+ })
209
+
188
210
  if (!gemini && modelId === undefined && !known(route)) {
189
211
  return fail(response, {
190
212
  status: 404,
@@ -265,6 +287,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
265
287
  // An alias with fallbacks: report which target answered, and echo it as the response model.
266
288
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
267
289
  onPolicy: reportPolicy(response),
290
+ onUsage: reportUsage(response, exchange.stream),
268
291
  onCache: reportCache(response),
269
292
  }),
270
293
  scope,
@@ -309,6 +332,14 @@ export function createOpenAiRoute(routeHost: RouteHost) {
309
332
  app: who.admin ? app : who.app,
310
333
  })
311
334
  if (isPlugError(report)) return fail(response, report)
335
+ // Each app's budget state, read from its current window rather than from the report's range (src/budgets.ts).
336
+ if (host.options.budgets) {
337
+ const at = Date.now()
338
+ for (const [name, entry] of Object.entries(report.apps)) {
339
+ const budget = budgetFor(host.options.budgets, name)
340
+ if (budget) entry.budgets = await budgetStatus(usage, budget, name, at)
341
+ }
342
+ }
312
343
  return sendJson(response, 200, report)
313
344
  }
314
345
 
@@ -2,10 +2,11 @@
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"
@@ -121,7 +122,23 @@ const assistantText = (content: unknown, param: string): string | PlugError => {
121
122
  * assistant message, the shape the Chat Completions front-end produces, so a parallel-call turn
122
123
  * is one message followed by its results.
123
124
  */
124
- const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[] } | PlugError => {
125
+ /** The output item an `item_reference` names, or undefined when the app's store does not hold it. */
126
+ export type ItemResolver = (itemId: string) => JsonObject | undefined
127
+
128
+ /** An `item_reference` is the output item of an earlier answer, decoded as that item (#147). */
129
+ const resolveReference = (item: JsonObject, param: string, resolve: ItemResolver | undefined): JsonObject | PlugError => {
130
+ if (typeof item.id !== "string" || item.id.length === 0) return bad("item_reference needs the id of an output item", `${param}.id`)
131
+ const found = resolve?.(item.id)
132
+ if (found === undefined)
133
+ return bad(
134
+ `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)`,
135
+ `${param}.id`,
136
+ "item_not_found",
137
+ )
138
+ return found
139
+ }
140
+
141
+ const parseInput = (input: unknown, resolve: ItemResolver | undefined): { system: string[]; messages: PlugMessage[] } | PlugError => {
125
142
  if (typeof input === "string") return { system: [], messages: [{ role: "user", content: [{ type: "text", text: input }] }] }
126
143
  if (!Array.isArray(input) || input.length === 0) return bad("input must be a string or a non-empty array of items", "input")
127
144
 
@@ -134,9 +151,11 @@ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[]
134
151
  pending = undefined
135
152
  }
136
153
 
137
- for (const [index, item] of input.entries()) {
154
+ for (const [index, given] of input.entries()) {
138
155
  const param = `input[${index}]`
139
- if (!isRecord(item)) return bad("Each input item must be an object", param)
156
+ if (!isRecord(given)) return bad("Each input item must be an object", param)
157
+ const item = given.type === "item_reference" ? resolveReference(given, param, resolve) : given
158
+ if (isPlugError(item)) return item
140
159
  const type = item.type ?? (item.role !== undefined ? "message" : undefined)
141
160
  switch (type) {
142
161
  case "message": {
@@ -192,11 +211,6 @@ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[]
192
211
  // Reasoning items carry no content plugboard can replay; they are dropped.
193
212
  case "reasoning":
194
213
  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
214
  default:
201
215
  return unsupported(`Unsupported input item type "${String(type)}"`, `${param}.type`)
202
216
  }
@@ -296,7 +310,7 @@ const checkUnsupported = (body: JsonObject): PlugError | undefined => {
296
310
  if (body.truncation != null && body.truncation !== "disabled") return unsupported('Only truncation "disabled" is supported', "truncation")
297
311
  }
298
312
 
299
- export function parseResponsesRequest(body: unknown, options: PlugboardOptions): ParsedResponses | PlugError {
313
+ export function parseResponsesRequest(body: unknown, options: PlugboardOptions, resolve?: ItemResolver): ParsedResponses | PlugError {
300
314
  if (!isRecord(body)) return bad("Request body must be a JSON object")
301
315
  if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
302
316
  if (body.input == null) return bad("input is required", "input")
@@ -305,7 +319,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions):
305
319
  if (body.instructions != null && typeof body.instructions !== "string") return bad("instructions must be a string", "instructions")
306
320
  if (body.reasoning != null && !isRecord(body.reasoning)) return bad("reasoning must be an object", "reasoning")
307
321
 
308
- const parsed = parseInput(body.input)
322
+ const parsed = parseInput(body.input, resolve)
309
323
  if (isPlugError(parsed)) return parsed
310
324
  const callerTools = parseTools(body.tools)
311
325
  if (!Array.isArray(callerTools)) return callerTools
@@ -15,10 +15,12 @@ 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"
18
19
  import { encodeError } from "../openai/response.js"
19
20
  import type { RouteHost } from "../openai/route.js"
20
21
  import { parseResponsesRequest } from "./request.js"
21
22
  import { runResponse, sseFrame } from "./response.js"
23
+ import { findItem, rememberAnswer } from "./store.js"
22
24
 
23
25
  const SESSION_HEADER = "x-plugboard-session"
24
26
 
@@ -34,7 +36,7 @@ export interface ResponsesCall {
34
36
  export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
35
37
  const fail = (error: PlugError) => sendError(response, error, encodeError(error))
36
38
 
37
- const parsed = parseResponsesRequest(body, host.options)
39
+ const parsed = parseResponsesRequest(body, host.options, (itemId) => findItem(app, itemId))
38
40
  if (isPlugError(parsed)) return fail(parsed)
39
41
  const catalog = await scope.race(host.loadCatalog())
40
42
  const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
@@ -55,6 +57,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
55
57
  },
56
58
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
57
59
  onPolicy: reportPolicy(response),
60
+ onUsage: reportUsage(response, exchange.stream),
58
61
  onCache: reportCache(response),
59
62
  }),
60
63
  scope,
@@ -62,11 +65,12 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
62
65
 
63
66
  if (exchange.stream) {
64
67
  const sse = sseWriter(response, scope.signal)
65
- const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
68
+ const run = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
69
+ if (run.response && !run.failure) rememberAnswer(app, run.response)
66
70
  if (!sse.started && !response.headersSent) {
67
- if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
71
+ if (!scope.signal.aborted || scope.timedOut) fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
68
72
  } else {
69
- if (failure) info.failed = failure.code ?? failure.kind
73
+ if (run.failure) info.failed = run.failure.code ?? run.failure.kind
70
74
  if (!response.writableEnded) response.end()
71
75
  }
72
76
  return
@@ -74,5 +78,6 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
74
78
 
75
79
  const run = await runResponse(events, exchange, () => {})
76
80
  if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
81
+ rememberAnswer(app, run.response)
77
82
  sendJson(response, 200, run.response)
78
83
  }