opencode-plugboard 0.18.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.
@@ -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 } : {}) }
@@ -46,10 +46,12 @@ import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
46
46
  import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
47
47
  import { reportCache } from "../cache.js"
48
48
  import { reportPolicy } from "../policy.js"
49
+ import { reportUsage } from "../usage-header.js"
49
50
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
50
51
  import { serveCompletions } from "../completions/route.js"
51
52
  import { serveResponses } from "../responses/route.js"
52
53
  import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
54
+ import { budgetFor, budgetStatus } from "../budgets.js"
53
55
  import type { UsageRecorder } from "../usage.js"
54
56
  import { DASHBOARD_ROUTES } from "../dashboard/page.js"
55
57
  import { serveDashboard } from "../dashboard/assets.js"
@@ -154,7 +156,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
154
156
  const path = url.pathname
155
157
  const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
156
158
  const gemini = matchGeminiRoute(request.method, path)
157
- 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)
158
162
  const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
159
163
  // Arbitrary client paths stay out of the log.
160
164
  info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
@@ -190,10 +194,19 @@ export function createOpenAiRoute(routeHost: RouteHost) {
190
194
 
191
195
  // Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
192
196
  // 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)
197
+ const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization)
194
198
  if (isPlugError(auth)) return fail(response, auth)
195
199
  info.app = auth.app
196
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
+
197
210
  if (!gemini && modelId === undefined && !known(route)) {
198
211
  return fail(response, {
199
212
  status: 404,
@@ -274,6 +287,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
274
287
  // An alias with fallbacks: report which target answered, and echo it as the response model.
275
288
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
276
289
  onPolicy: reportPolicy(response),
290
+ onUsage: reportUsage(response, exchange.stream),
277
291
  onCache: reportCache(response),
278
292
  }),
279
293
  scope,
@@ -318,6 +332,14 @@ export function createOpenAiRoute(routeHost: RouteHost) {
318
332
  app: who.admin ? app : who.app,
319
333
  })
320
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
+ }
321
343
  return sendJson(response, 200, report)
322
344
  }
323
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
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The output items of recent Responses answers, so a later request can refer to them with an
3
+ * `item_reference` (#147). The Vercel AI SDK keeps `store: true` by default and sends the earlier
4
+ * assistant turn as references to the items plugboard returned, so without this store its
5
+ * multi-turn chat is refused.
6
+ *
7
+ * Bounded and per app: an app's store holds up to 1000 answers for an hour, and an app reads only
8
+ * its own answers. Nothing is written to disk. The store lives on `globalThis` under a versioned
9
+ * key, as the other process-wide state does (docs/architecture.md, I6), so it outlives a plugin
10
+ * generation that is replaced. A reference to an item that was evicted, expired or never returned
11
+ * is a 400 that suggests `store: false`; it is never a guess.
12
+ */
13
+ import { LRUCache } from "lru-cache"
14
+
15
+ type Item = Readonly<Record<string, unknown>>
16
+
17
+ const TTL_MS = 60 * 60 * 1000
18
+ const MAX_ANSWERS = 1000
19
+ const SLOT = Symbol.for("opencode-plugboard.responses-items/1")
20
+
21
+ const holder = globalThis as unknown as Record<symbol, Map<string, LRUCache<string, ReadonlyArray<Item>>> | undefined>
22
+
23
+ /** The store of one app: answer id -> its output items. */
24
+ function storeOf(app: string): LRUCache<string, ReadonlyArray<Item>> {
25
+ const apps = (holder[SLOT] ??= new Map())
26
+ let store = apps.get(app)
27
+ if (store === undefined) {
28
+ store = new LRUCache<string, ReadonlyArray<Item>>({ max: MAX_ANSWERS, ttl: TTL_MS })
29
+ apps.set(app, store)
30
+ }
31
+ return store
32
+ }
33
+
34
+ const isItem = (value: unknown): value is Item => typeof value === "object" && value !== null && typeof (value as Item).id === "string"
35
+
36
+ /** Keeps the output items of a completed or incomplete answer (a failed one has no items to replay). */
37
+ export function rememberAnswer(app: string, response: object): void {
38
+ const { id, output } = response as { id?: unknown; output?: unknown }
39
+ if (typeof id !== "string" || !Array.isArray(output)) return
40
+ const items = output.filter(isItem)
41
+ if (items.length > 0) storeOf(app).set(id, items)
42
+ }
43
+
44
+ /** The output item with this id that the app's recent answers returned, if still held. */
45
+ export function findItem(app: string, itemId: string): Item | undefined {
46
+ for (const items of storeOf(app).values()) {
47
+ const found = items.find((item) => item.id === itemId)
48
+ if (found !== undefined) return found
49
+ }
50
+ return undefined
51
+ }
package/src/runtime.ts CHANGED
@@ -62,6 +62,7 @@ import { ensureAppDir } from "./session/history.js"
62
62
  import type { HistoryPort } from "./session/host.js"
63
63
  import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
64
64
  import { createSessionStore, type SessionStore } from "./session/state.js"
65
+ import { createBudgetState, type BudgetState } from "./budgets.js"
65
66
  import { createUsageState, type UsageState } from "./usage.js"
66
67
 
67
68
  export type Handler = (request: IncomingMessage, response: ServerResponse) => void
@@ -115,8 +116,8 @@ const BACKEND_POLL_MS = 25
115
116
  const BIND_RETRY_MS = 15_000
116
117
  const BIND_REMINDER_MS = 5 * 60_000
117
118
 
118
- /** 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
119
- const VERSION = 12
119
+ /** 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
120
+ const VERSION = 13
120
121
  const KEY = Symbol.for("opencode-plugboard.runtime")
121
122
 
122
123
  interface Listener {
@@ -180,6 +181,8 @@ interface Runtime {
180
181
  cache?: { readonly fingerprint: string; readonly store: CacheStore }
181
182
  /** The usage store's live days and retention clock (src/usage.ts). */
182
183
  readonly usage: UsageState
184
+ /** The budget warnings already logged (src/budgets.ts). */
185
+ readonly budgets: BudgetState
183
186
  listener?: Listener
184
187
  /** Present while a fiber is subscribed to the server event bus. */
185
188
  pump?: Pump
@@ -193,6 +196,7 @@ const detachedMetrics = createMetrics()
193
196
  const detachedStartedAt = Date.now()
194
197
  const detachedLimits = createLimitState()
195
198
  const detachedUsage = createUsageState()
199
+ const detachedBudgets = createBudgetState()
196
200
  let foreignVersionLogged = false
197
201
 
198
202
  /** The process runtime, created on first use; undefined when a different version already owns the process. */
@@ -210,6 +214,7 @@ function runtime(): Runtime | undefined {
210
214
  metrics: createMetrics(),
211
215
  limits: createLimitState(),
212
216
  usage: createUsageState(),
217
+ budgets: createBudgetState(),
213
218
  startedAt: Date.now(),
214
219
  }
215
220
  slot[KEY] = created
@@ -291,6 +296,9 @@ export const processLimits = (): LimitState => runtime()?.limits ?? detachedLimi
291
296
  /** The usage store's live days shared by every generation in the process (detached under a foreign-version runtime). */
292
297
  export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
293
298
 
299
+ /** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
300
+ export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
301
+
294
302
  /**
295
303
  * The options authentication reads: `keys` is the live key set, not the serving generation's own
296
304
  * (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
package/src/stop.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Caller stop sequences on the answer text (#145, #149). A provider ends the answer at a sequence
3
+ * but does not always return it, and Anthropic, OpenAI and Gemini all leave it out of the answer
4
+ * when the stop is the one that ended it. The host reports only `stop`, so the end of a plain
5
+ * stop's text is checked for a sequence, which is removed and reported. A streamed text keeps its
6
+ * last characters back (`StopHoldback`) until the finish decides whether they end in a sequence.
7
+ */
8
+
9
+ /** The longest of `stops` that `tail` ends with, if any. */
10
+ export const stopTail = (tail: string, stops: ReadonlyArray<string>): string | undefined =>
11
+ stops.filter((stop) => tail.endsWith(stop)).sort((a, b) => b.length - a.length)[0]
12
+
13
+ /** Streamed text with its last `size` characters (the longest sequence) held back. Without sequences nothing is held. */
14
+ export class StopHoldback {
15
+ private held = ""
16
+ private readonly size: number
17
+
18
+ constructor(private readonly stops: ReadonlyArray<string>) {
19
+ this.size = Math.max(0, ...stops.map((stop) => stop.length))
20
+ }
21
+
22
+ /** Adds a chunk and returns the text that can be sent now: everything except the last `size` characters. */
23
+ push(chunk: string): string {
24
+ if (this.size === 0) return chunk
25
+ this.held += chunk
26
+ const out = this.held.slice(0, Math.max(0, this.held.length - this.size))
27
+ this.held = this.held.slice(out.length)
28
+ return out
29
+ }
30
+
31
+ /** Everything still held, unchanged. Used before a tool call or reasoning, which cannot be a stop. */
32
+ release(): string {
33
+ const out = this.held
34
+ this.held = ""
35
+ return out
36
+ }
37
+
38
+ /**
39
+ * The end of the text. `plainStop` is true when the model stopped for `stop` with no tool call:
40
+ * then a sequence the held text ends with is removed and returned as `matched`.
41
+ */
42
+ finish(plainStop: boolean): { readonly text: string; readonly matched?: string } {
43
+ const matched = plainStop && this.size > 0 ? stopTail(this.held, this.stops) : undefined
44
+ const text = matched === undefined ? this.held : this.held.slice(0, this.held.length - matched.length)
45
+ this.held = ""
46
+ return matched === undefined ? { text } : { text, matched }
47
+ }
48
+ }
49
+
50
+ /** A complete text (no streaming): the text without the sequence it ends with, if any. */
51
+ export const stripStopTail = (text: string, stops: ReadonlyArray<string>): { readonly text: string; readonly matched?: string } => {
52
+ const matched = stopTail(text, stops)
53
+ return matched === undefined ? { text } : { text: text.slice(0, text.length - matched.length), matched }
54
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `x-plugboard-usage` (#52): the tokens and USD cost of the model calls a response answered, so a
3
+ * caller sees what one request cost without asking `GET /v1/usage`. It is set only while usage
4
+ * recording is on (`withUsage`, src/usage.ts), and only on a non-streaming response: a stream
5
+ * sends its headers with its first event, before the usage is known, so it has no such header.
6
+ */
7
+ import type { ServerResponse } from "node:http"
8
+ import type { CallTotals } from "./contract.js"
9
+
10
+ export const USAGE_HEADER = "x-plugboard-usage"
11
+
12
+ /** The totals of one response, summed over its calls. `costUsd` is undefined when any call had no price. */
13
+ interface Totals {
14
+ readonly inputTokens: number
15
+ readonly outputTokens: number
16
+ readonly cacheReadTokens: number
17
+ readonly costUsd?: number
18
+ }
19
+
20
+ /** `input=<n>; output=<n>; cache_read=<n>; cost_usd=<x.xxxxxx>`; the cost part is left out for an unpriced model. */
21
+ export function formatUsage(totals: Totals): string {
22
+ const parts = [`input=${totals.inputTokens}`, `output=${totals.outputTokens}`, `cache_read=${totals.cacheReadTokens}`]
23
+ if (totals.costUsd !== undefined) parts.push(`cost_usd=${totals.costUsd.toFixed(6)}`)
24
+ return parts.join("; ")
25
+ }
26
+
27
+ /**
28
+ * The `CallContext.onUsage` of one response: each answered call adds its totals and the header
29
+ * is rewritten while the headers are still open, so the fan-out's `n` choices sum into one value.
30
+ * `undefined` for a stream (see the module comment).
31
+ */
32
+ export function reportUsage(response: ServerResponse, stream: boolean): ((call: CallTotals) => void) | undefined {
33
+ if (stream) return undefined
34
+ let totals: Totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0 }
35
+ return (call) => {
36
+ if (response.headersSent || response.destroyed) return
37
+ const costUsd = totals.costUsd === undefined || call.costUsd === undefined ? undefined : totals.costUsd + call.costUsd
38
+ totals = {
39
+ inputTokens: totals.inputTokens + call.inputTokens,
40
+ outputTokens: totals.outputTokens + call.outputTokens,
41
+ cacheReadTokens: totals.cacheReadTokens + call.cacheReadTokens,
42
+ ...(costUsd === undefined ? {} : { costUsd }),
43
+ }
44
+ response.setHeader(USAGE_HEADER, formatUsage(totals))
45
+ }
46
+ }