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.
@@ -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/rpc.ts CHANGED
@@ -26,6 +26,7 @@ export const UsageRpc = Rpc.define({
26
26
  from: { type: "string", description: "First UTC day, YYYY-MM-DD. Default: 29 days before `to`." },
27
27
  to: { type: "string", description: "Last UTC day, YYYY-MM-DD. Default: today." },
28
28
  app: { type: "string", description: "One app name. Default: every app." },
29
+ user: { type: "string", description: "One end user id. Each app's `users` then lists only that user. Default: every user." },
29
30
  },
30
31
  additionalProperties: false,
31
32
  },
@@ -37,7 +38,7 @@ export const UsageRpc = Rpc.define({
37
38
  errors: {
38
39
  invalid_query: {
39
40
  type: "object",
40
- properties: { param: { type: "string", description: "The query parameter that is wrong: from, to or app." } },
41
+ properties: { param: { type: "string", description: "The query parameter that is wrong: from, to, app or user." } },
41
42
  required: ["param"],
42
43
  additionalProperties: false,
43
44
  },
@@ -51,7 +52,7 @@ export const UsageRpc = Rpc.define({
51
52
  function queryOf(input: unknown): UsageQuery {
52
53
  const record: Record<string, unknown> = typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {}
53
54
  const text = (value: unknown) => (typeof value === "string" ? value : undefined)
54
- return { from: text(record.from), to: text(record.to), app: text(record.app) }
55
+ return { from: text(record.from), to: text(record.to), app: text(record.app), user: text(record.user) }
55
56
  }
56
57
 
57
58
  /** The handlers for `UsageRpc`, reading the given usage query (the generation's recorder). */
package/src/runtime.ts CHANGED
@@ -53,6 +53,7 @@ import { Cause, Effect, Stream } from "effect"
53
53
  import stableStringify from "safe-stable-stringify"
54
54
  import { raceAbort } from "./abort.js"
55
55
  import { cachedCaller, createCacheStore, type CacheStore } from "./cache.js"
56
+ import { personaOf } from "./models.js"
56
57
  import type { PlugboardOptions } from "./config.js"
57
58
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError } from "./contract.js"
58
59
  import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
@@ -62,6 +63,8 @@ import { ensureAppDir } from "./session/history.js"
62
63
  import type { HistoryPort } from "./session/host.js"
63
64
  import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
64
65
  import { createSessionStore, type SessionStore } from "./session/state.js"
66
+ import { createBudgetState, type BudgetState } from "./budgets.js"
67
+ import { createKeyState, type KeyState } from "./keys.js"
65
68
  import { createUsageState, type UsageState } from "./usage.js"
66
69
 
67
70
  export type Handler = (request: IncomingMessage, response: ServerResponse) => void
@@ -115,8 +118,8 @@ const BACKEND_POLL_MS = 25
115
118
  const BIND_RETRY_MS = 15_000
116
119
  const BIND_REMINDER_MS = 5 * 60_000
117
120
 
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
121
+ /** 14: the runtime app keys (src/keys.ts, #55) are cached here, so a key created or revoked through the admin routes holds across plugin generations. 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. */
122
+ const VERSION = 14
120
123
  const KEY = Symbol.for("opencode-plugboard.runtime")
121
124
 
122
125
  interface Listener {
@@ -180,6 +183,10 @@ interface Runtime {
180
183
  cache?: { readonly fingerprint: string; readonly store: CacheStore }
181
184
  /** The usage store's live days and retention clock (src/usage.ts). */
182
185
  readonly usage: UsageState
186
+ /** The budget warnings already logged (src/budgets.ts). */
187
+ readonly budgets: BudgetState
188
+ /** The runtime app keys (src/keys.ts), loaded from storage on the first authentication. */
189
+ readonly runtimeKeys: KeyState
183
190
  listener?: Listener
184
191
  /** Present while a fiber is subscribed to the server event bus. */
185
192
  pump?: Pump
@@ -193,6 +200,8 @@ const detachedMetrics = createMetrics()
193
200
  const detachedStartedAt = Date.now()
194
201
  const detachedLimits = createLimitState()
195
202
  const detachedUsage = createUsageState()
203
+ const detachedBudgets = createBudgetState()
204
+ const detachedKeys = createKeyState()
196
205
  let foreignVersionLogged = false
197
206
 
198
207
  /** The process runtime, created on first use; undefined when a different version already owns the process. */
@@ -210,6 +219,8 @@ function runtime(): Runtime | undefined {
210
219
  metrics: createMetrics(),
211
220
  limits: createLimitState(),
212
221
  usage: createUsageState(),
222
+ budgets: createBudgetState(),
223
+ runtimeKeys: createKeyState(),
213
224
  startedAt: Date.now(),
214
225
  }
215
226
  slot[KEY] = created
@@ -291,6 +302,12 @@ export const processLimits = (): LimitState => runtime()?.limits ?? detachedLimi
291
302
  /** The usage store's live days shared by every generation in the process (detached under a foreign-version runtime). */
292
303
  export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
293
304
 
305
+ /** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
306
+ export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
307
+
308
+ /** The runtime app keys' cache shared by every generation in the process (detached under a foreign-version runtime). */
309
+ export const processKeys = (): KeyState => runtime()?.runtimeKeys ?? detachedKeys
310
+
294
311
  /**
295
312
  * The options authentication reads: `keys` is the live key set, not the serving generation's own
296
313
  * (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
@@ -328,7 +345,7 @@ export function withResponseCache(caller: ModelCaller, options: PlugboardOptions
328
345
  if (rt) rt.cache = held
329
346
  }
330
347
  const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
331
- return cachedCaller(caller, config, held.store, count)
348
+ return cachedCaller(caller, config, held.store, count, (model) => personaOf(options, model) !== undefined)
332
349
  }
333
350
  return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
334
351
  }
@@ -21,7 +21,7 @@ import { raceAbort } from "../abort.js"
21
21
  import { APP_NAME_PATTERN, DEFAULT_APP, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
22
22
  import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
23
23
  import { log, logError } from "../log.js"
24
- import { checkAppModel } from "../models.js"
24
+ import { checkAppModel, type Persona } from "../models.js"
25
25
  import { RequestCollector } from "./events.js"
26
26
  import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
27
27
  import { describeError, type SessionPort } from "./host.js"
@@ -44,6 +44,11 @@ export interface SessionCallerDeps {
44
44
  * called right before `session.create`. An error ends the request without creating anything.
45
45
  */
46
46
  readonly ensureBackend?: (app: string, signal: AbortSignal) => Promise<PlugError | undefined>
47
+ /**
48
+ * What the requested name stands for beyond a plain model (`personaOf` in src/models.ts): a skill alias adds
49
+ * the skill's content to the system prompt, an agent runs its own system prompt and OpenCode agent.
50
+ */
51
+ readonly persona?: (model: string) => Persona | undefined
47
52
  }
48
53
 
49
54
  /** How long the request guard may outlive the response while the host has not reported the end of the execution. */
@@ -54,6 +59,8 @@ const failure = (status: number, message: string, code: string): PlugEvent => ({
54
59
  error: { status, kind: status >= 500 ? "server" : "invalid_request", message, code },
55
60
  })
56
61
 
62
+ const notFound = (message: string): PlugError => ({ status: 404, kind: "not_found", message, code: "model_not_found", param: "model" })
63
+
57
64
  const countMedia = (request: PlugRequest): number =>
58
65
  request.messages.reduce((sum, message) => sum + message.content.filter((part) => part.type === "media").length, 0)
59
66
 
@@ -123,7 +130,7 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
123
130
  const policy = appPolicy({ apps }, app)
124
131
  const outOfScope = checkAppModel(policy, called.model, called.ref)
125
132
  if (outOfScope) return yield note({ type: "error", error: outOfScope })
126
- const { request, stripped } = applyToolPolicy(called, policy)
133
+ let { request, stripped } = applyToolPolicy(called, policy)
127
134
  const tooMany = checkAppTools(request, app, policy)
128
135
  if (tooMany) return yield note({ type: "error", error: tooMany })
129
136
  if (stripped) {
@@ -166,23 +173,39 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
166
173
  if (unavailable) return yield note({ type: "error", error: unavailable })
167
174
  if (context.signal.aborted) return
168
175
 
176
+ // 3b. A persona (see `Persona`). A skill's content leads the system prompt; an agent's prompt replaces the
177
+ // caller's, and the session runs as that agent. Its permissions are still REQUEST_PERMISSIONS (deny all, then seeds).
178
+ const persona = deps.persona?.(called.model)
179
+ let prelude: ReadonlyArray<string> = []
180
+ if (persona?.kind === "skill") {
181
+ const content = await port.skill(directory, persona.skill)
182
+ if (content === undefined) return yield note({ type: "error", error: notFound(`Skill ${persona.skill} is not available`) })
183
+ prelude = [content]
184
+ } else if (persona?.kind === "agent") {
185
+ const agent = await port.agent(directory, persona.agent)
186
+ if (agent === undefined) return yield note({ type: "error", error: notFound(`Agent ${persona.agent} is not available`) })
187
+ request = { ...request, system: agent.system ? [agent.system] : [] }
188
+ }
189
+ if (context.signal.aborted) return
190
+
169
191
  collector = new RequestCollector({ stopTurn: interrupt, requestID })
170
192
  const created = await raceAbort(port.create({
171
193
  directory,
172
194
  model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
195
+ ...(persona?.kind === "agent" ? { agent: persona.agent } : {}),
173
196
  title: makeTitle(request),
174
197
  metadata: { plugboard: { app, model: request.model, requestID } },
175
198
  permissions: REQUEST_PERMISSIONS,
176
199
  }), context.signal)
177
200
  sessionID = String(created.id)
178
- sessions.register({ sessionID, app, requestID, request, collector })
201
+ sessions.register({ sessionID, app, requestID, request, collector, prelude })
179
202
 
180
203
  context.signal.addEventListener("abort", onAbort, { once: true })
181
204
  if (context.signal.aborted) return onAbort()
182
205
 
183
206
  // The history id is known from here on: tell the front-end before the first event.
184
207
  try {
185
- context.onRecord?.(sessionID)
208
+ context.onRecord?.(sessionID, directory)
186
209
  } catch (error) {
187
210
  logError("request.on_record_failed", { requestID, app, error: describeError(error) })
188
211
  }
@@ -123,7 +123,9 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
123
123
  return text("[image unavailable]")
124
124
  }
125
125
 
126
- const system = systemFor(request.system, appPolicy({ apps }, state.app)).map(text)
126
+ // Order: the skill's content (prelude), then the app's system policy over the caller's (or an agent's) prompt,
127
+ // then the tool-choice steering below.
128
+ const system = [...(state.prelude ?? []), ...systemFor(request.system, appPolicy({ apps }, state.app))].map(text)
127
129
  // The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
128
130
  const choice = request.toolChoice
129
131
  if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
@@ -61,10 +61,16 @@ const CONTROLS_KEEP_LAYOUT = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u20
61
61
  /** One display line: whitespace (including line breaks) collapsed to single spaces, then control and bidi characters removed. */
62
62
  export const oneLine = (text: string): string => text.replace(/\s+/g, " ").replace(CONTROLS, "").trim()
63
63
 
64
- /** Session title: the first user text, one line, control and bidi characters removed, cut to 60 characters. */
64
+ /**
65
+ * Session title: the end user as `[user] ` when the caller named one (#57), then the first user text, one
66
+ * line, control and bidi characters removed. The whole title is cut to 60 characters.
67
+ */
65
68
  export const makeTitle = (request: PlugRequest): string => {
69
+ const prefix = request.user === undefined ? "" : `[${request.user}] `
66
70
  const flat = oneLine(firstUserText(request))
67
- return flat.length > TITLE_LENGTH ? flat.slice(0, TITLE_LENGTH - 1) + "…" : flat
71
+ const room = TITLE_LENGTH - prefix.length
72
+ const text = flat.length > room ? flat.slice(0, room - 1) + "…" : flat
73
+ return (prefix + text).trimEnd()
68
74
  }
69
75
 
70
76
  /** Readable transcript stored as the session's prompt; images are listed by attachment number. */
@@ -24,12 +24,18 @@ export interface SessionPort {
24
24
  create(input: {
25
25
  readonly directory: string
26
26
  readonly model: { readonly providerID: string; readonly id: string; readonly variant?: string }
27
+ /** The OpenCode agent the session runs as (option `agents`); absent: the host's default agent. */
28
+ readonly agent?: string
27
29
  readonly title: string
28
30
  readonly metadata: { readonly [key: string]: Json }
29
31
  readonly permissions: ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }>
30
32
  }): Promise<{ id: string }>
31
33
  prompt(input: { readonly sessionID: string; readonly text: string; readonly files: ReadonlyArray<{ uri: string; name: string }> }): Promise<unknown>
32
34
  interrupt(sessionID: string): Promise<void>
35
+ /** The content of skill `id` in the Location at `directory`, or undefined when there is none. Briefly cached per directory. */
36
+ skill(directory: string, id: string): Promise<string | undefined>
37
+ /** The agent `id` in the Location at `directory` (its `system` prompt may be absent), or undefined when there is none. */
38
+ agent(directory: string, id: string): Promise<{ readonly system?: string } | undefined>
33
39
  }
34
40
 
35
41
  type HostSession = HostContext["session"]
@@ -45,6 +51,10 @@ const hostSessionID = (id: string): HostSessionID => id as HostSessionID
45
51
  type HostCreate = NonNullable<Parameters<HostSession["create"]>[0]>
46
52
  type HostModelRef = NonNullable<HostCreate["model"]>
47
53
  type HostDirectory = NonNullable<HostCreate["location"]>["directory"]
54
+ type HostAgent = NonNullable<HostCreate["agent"]>
55
+
56
+ /** How long a directory's skill list is reused: a skill edit is seen within this window (per plugin generation). */
57
+ const SKILL_CACHE_MS = 30_000
48
58
 
49
59
  /**
50
60
  * Branded model ref and directory. Same kind of cast as the session ID: the values are the
@@ -65,17 +75,33 @@ const hostDirectory = (directory: string): HostDirectory => directory as HostDir
65
75
  */
66
76
  export function createSessionPort(ctx: HostContext): SessionPort {
67
77
  const run = <A>(effect: Effect.Effect<A, unknown>): Promise<A> => Effect.runPromise(effect)
78
+ const skillsByDirectory = new Map<string, { readonly at: number; readonly skills: ReadonlyMap<string, string> }>()
68
79
  return {
69
80
  create: (input) =>
70
81
  run(
71
82
  ctx.session.create({
72
83
  model: hostModel(input.model),
84
+ ...(input.agent === undefined ? {} : { agent: input.agent as HostAgent }),
73
85
  title: input.title,
74
86
  metadata: input.metadata,
75
87
  permissions: input.permissions,
76
88
  location: { directory: hostDirectory(input.directory) },
77
89
  } satisfies HostCreate),
78
90
  ).then((created) => ({ id: String(created.id) })),
91
+ skill: async (directory, id) => {
92
+ const now = Date.now()
93
+ let held = skillsByDirectory.get(directory)
94
+ if (held === undefined || now - held.at >= SKILL_CACHE_MS) {
95
+ const listed = await run(ctx.skill.list({ location: { directory } }))
96
+ held = { at: now, skills: new Map(listed.data.map((skill): [string, string] => [skill.id, skill.content])) }
97
+ skillsByDirectory.set(directory, held)
98
+ }
99
+ return held.skills.get(id)
100
+ },
101
+ agent: async (directory, id) => {
102
+ const listed = await run(ctx.agent.list({ location: { directory } }))
103
+ return listed.data.find((agent) => agent.id === id)
104
+ },
79
105
  prompt: (input) =>
80
106
  run(
81
107
  ctx.session.prompt({
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Links from a response to the OpenCode session page of its request (#77). The host exposes no UI URL
3
+ * to a plugin, so option `historyUrl` is a template the operator fills in for how they reach OpenCode:
4
+ * `{id}` is the session id, `{directory}` the app directory (URL-encoded). With the option set, a
5
+ * response that carries `x-plugboard-session` also carries `x-plugboard-session-url` with the filled
6
+ * link, and the request log line gains `session_url=`.
7
+ *
8
+ * Pure: no node imports. The routes call `setSessionLink` from their `onRecord` callback, after they
9
+ * set the session header, so a link is only ever sent for a session that was named.
10
+ */
11
+
12
+ export const SESSION_URL_HEADER = "x-plugboard-session-url"
13
+
14
+ /** The template with `{id}` and `{directory}` filled (both URL-encoded). */
15
+ export function sessionUrl(template: string, recordID: string, directory = ""): string {
16
+ return template.replaceAll("{id}", encodeURIComponent(recordID)).replaceAll("{directory}", encodeURIComponent(directory))
17
+ }
18
+
19
+ /**
20
+ * Sets `x-plugboard-session-url` for one history record and the log field, when a template is set.
21
+ * Nothing happens without a template, so a route calls this unconditionally.
22
+ */
23
+ export function setSessionLink(
24
+ response: { setHeader(name: string, value: string): unknown },
25
+ info: { sessionUrl?: string },
26
+ template: string | undefined,
27
+ recordID: string,
28
+ directory?: string,
29
+ ): void {
30
+ if (template === undefined) return
31
+ info.sessionUrl = sessionUrl(template, recordID, directory)
32
+ response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
33
+ }
@@ -32,6 +32,8 @@ export interface RequestState {
32
32
  readonly requestID: string
33
33
  readonly request: PlugRequest
34
34
  readonly collector: EventSink
35
+ /** System parts placed before the app's and the caller's (a skill's content, option `skill:` alias). Empty or absent for a plain model. */
36
+ readonly prelude?: ReadonlyArray<string>
35
37
  }
36
38
 
37
39
  export interface Retirement {
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
+ }