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.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
6
+ "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app: an OpenCode V2 plugin serving OpenAI, Anthropic and Gemini compatible APIs.",
7
7
  "keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions", "responses-api", "llm-gateway", "llm-proxy"],
8
8
  "repository": {
9
9
  "type": "git",
@@ -14,15 +14,26 @@
14
14
  },
15
15
  "homepage": "https://github.com/jjjermiah/opencode-plugboard#readme",
16
16
  "main": "./index.ts",
17
+ "types": "./index.ts",
17
18
  "exports": {
18
- ".": "./index.ts",
19
- "./rpc": "./src/rpc.ts"
19
+ ".": { "types": "./index.ts", "default": "./index.ts" },
20
+ "./rpc": { "types": "./rpc.ts", "default": "./rpc.ts" },
21
+ "./tui": { "types": "./tui.ts", "default": "./tui.ts" }
22
+ },
23
+ "files": ["index.ts", "rpc.ts", "tui.ts", "src/", "README.md", "LICENSE"],
24
+ "engines": {
25
+ "bun": ">=1.4.2"
20
26
  },
21
- "files": ["index.ts", "src/", "README.md", "LICENSE"],
22
27
  "scripts": {
23
- "typecheck": "tsc --noEmit && tsc --noEmit -p test",
28
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tui.json && tsc --noEmit -p test",
24
29
  "test": "bun test",
30
+ "bench": "bun test/bench/request.bench.ts",
31
+ "mutations": "bun .github/scripts/mutation-check.ts",
32
+ "flake-hunt": "bun .github/scripts/flake-hunt.ts",
33
+ "coverage-manifest": "bun .github/scripts/coverage-manifest.ts",
25
34
  "smoke:pack": "bun .github/scripts/smoke-pack.ts",
35
+ "docs:dev": "vitepress dev docs",
36
+ "docs:build": "vitepress build docs",
26
37
  "release": "bun .github/scripts/release.ts"
27
38
  },
28
39
  "dependencies": {
@@ -32,7 +43,8 @@
32
43
  "lru-cache": "11.5.3",
33
44
  "p-queue": "9.3.5",
34
45
  "pino": "10.4.0",
35
- "safe-stable-stringify": "2.5.0"
46
+ "safe-stable-stringify": "2.5.0",
47
+ "uplot": "1.6.32"
36
48
  },
37
49
  "devDependencies": {
38
50
  "@ai-sdk/anthropic": "4.0.78",
@@ -47,10 +59,13 @@
47
59
  "@langchain/google-genai": "2.3.2",
48
60
  "@langchain/openai": "1.6.2",
49
61
  "@opencode/plugin": "2.0.26",
62
+ "@opentui/core": "0.5.17",
63
+ "@opentui/solid": "0.5.17",
50
64
  "@types/bun": "1.4.2",
51
65
  "@types/escape-html": "^1.0.4",
52
66
  "@types/lcov-parse": "1.0.2",
53
67
  "@types/node": "^26.6.2",
68
+ "@types/semver": "7.8.0",
54
69
  "ai": "7.0.137",
55
70
  "ajv": "8.20.0",
56
71
  "conventional-commits-parser": "7.1.3",
@@ -60,11 +75,23 @@
60
75
  "fast-xml-parser": "5.11.2",
61
76
  "lcov-parse": "1.0.0",
62
77
  "lefthook": "2.2.1",
78
+ "mitata": "1.0.34",
63
79
  "openai": "7.32.0",
64
- "typescript": "^5.9.3"
80
+ "semver": "7.8.5",
81
+ "solid-js": "1.9.15",
82
+ "typescript": "^5.9.3",
83
+ "vitepress": "1.6.4"
65
84
  },
66
85
  "peerDependencies": {
67
86
  "@opencode/plugin": ">=2.0.26 <2.1",
68
- "effect": "4.0.0-rc.112"
87
+ "@opentui/core": ">=0.5.17",
88
+ "@opentui/solid": ">=0.5.17",
89
+ "effect": "4.0.0-rc.112",
90
+ "solid-js": ">=1.9.0"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@opentui/core": { "optional": true },
94
+ "@opentui/solid": { "optional": true },
95
+ "solid-js": { "optional": true }
69
96
  }
70
97
  }
package/rpc.ts ADDED
@@ -0,0 +1,3 @@
1
+ // The `./rpc` entrypoint, at the package root for the same reason as `tui.ts`: a plugin configured
2
+ // by local path is resolved as `<directory>/rpc`, without `exports`.
3
+ export * from "./src/rpc.ts"
@@ -7,6 +7,7 @@
7
7
  * no-op `emit`. Thinking blocks are produced only when the request asked for thinking.
8
8
  */
9
9
  import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
10
+ import { StopHoldback } from "../stop.js"
10
11
 
11
12
  type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
12
13
 
@@ -76,10 +77,6 @@ const STOP_REASON: Record<Exclude<PlugFinish, "error">, string> = {
76
77
  "content-filter": "refusal",
77
78
  }
78
79
 
79
- /** The longest stop sequence the text ends with, if any. */
80
- const stopTail = (tail: string, stops: ReadonlyArray<string>): string | undefined =>
81
- stops.filter((stop) => tail.endsWith(stop)).sort((a, b) => b.length - a.length)[0]
82
-
83
80
  const noFinish = (): PlugError => ({ status: 500, kind: "server", message: "Model stream ended without a finish event" })
84
81
  const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
85
82
 
@@ -127,10 +124,9 @@ export async function runMessage(
127
124
  let finish: Exclude<PlugFinish, "error"> | undefined
128
125
  let usage: PlugUsage | undefined
129
126
  let failure: PlugError | undefined
130
- // Text is held back by the longest stop sequence (`holdback` characters), so a sequence at the end of
131
- // the answer can be found and removed before it is sent. Without stop sequences nothing is held.
132
- const holdback = Math.max(0, ...(exchange.stop ?? []).map((stop) => stop.length))
133
- let held = ""
127
+ // Text is held back by the longest stop sequence, so a sequence at the end of the answer can be
128
+ // found and removed before it is sent (src/stop.ts). Without stop sequences nothing is held.
129
+ const holdback = new StopHoldback(exchange.stop ?? [])
134
130
 
135
131
  const message = (extra: Record<string, unknown>) => ({
136
132
  id,
@@ -162,20 +158,8 @@ export async function runMessage(
162
158
  content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
163
159
  open = undefined
164
160
  }
165
- const releaseHeld = () => {
166
- const out = held
167
- held = ""
168
- delta("text", out)
169
- }
170
- const text = (chunk: string) => {
171
- if (holdback === 0) return delta("text", chunk)
172
- held += chunk
173
- if (held.length > holdback) {
174
- const out = held.slice(0, held.length - holdback)
175
- held = held.slice(out.length)
176
- delta("text", out)
177
- }
178
- }
161
+ const releaseHeld = () => delta("text", holdback.release())
162
+ const text = (chunk: string) => delta("text", holdback.push(chunk))
179
163
  const delta = (kind: "text" | "thinking", text: string) => {
180
164
  if (text.length === 0) return
181
165
  start()
@@ -240,9 +224,8 @@ export async function runMessage(
240
224
  return { failure, started }
241
225
  }
242
226
  // A plain stop with no tool calls that ends on a stop sequence: the sequence is not part of the answer.
243
- const matched = holdback > 0 && finish === "stop" && calls === 0 ? stopTail(held, exchange.stop ?? []) : undefined
244
- if (matched !== undefined) held = held.slice(0, held.length - matched.length)
245
- releaseHeld()
227
+ const { text: rest, matched } = holdback.finish(finish === "stop" && calls === 0)
228
+ delta("text", rest)
246
229
  start()
247
230
  close()
248
231
  ping()
@@ -31,6 +31,7 @@ import { reportServed, type ServedInfo } from "../fallback.js"
31
31
  import { resolveModel } from "../models.js"
32
32
  import { reportCache } from "../cache.js"
33
33
  import { reportPolicy } from "../policy.js"
34
+ import { reportUsage } from "../usage-header.js"
34
35
  import { dropRejected } from "../openai/sampling.js"
35
36
  import type { RouteHost } from "../openai/route.js"
36
37
  import { parseMessagesRequest } from "./request.js"
@@ -135,6 +136,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
135
136
  },
136
137
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
137
138
  onPolicy: reportPolicy(response),
139
+ onUsage: reportUsage(response, exchange.stream),
138
140
  onCache: reportCache(response),
139
141
  }),
140
142
  scope,
package/src/budgets.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Per-app budgets (option `budgets`): a `ModelCaller` wrapper that refuses a call with 429
3
+ * `budget_exceeded` once its app has used a ceiling for the current window.
4
+ *
5
+ * Windows are UTC: a calendar day, an ISO week (Monday start) and a calendar month. The check reads
6
+ * what the usage records already hold for the window (`UsageRecorder.spent`, src/usage.ts): requests
7
+ * on the model routes that were not refused by a limit or a budget, the tokens of answered calls and
8
+ * their cost at list prices. A call that crosses a ceiling is still served; the next one is refused.
9
+ * Calls in flight are not counted until they end, so a burst can pass a ceiling by the calls it
10
+ * admitted. A call that finds the records unreadable is refused with 503 `budget_unavailable` rather
11
+ * than let through.
12
+ *
13
+ * The check runs before anything else is taken: a refused call holds no concurrency slot and spends
14
+ * no requests-per-minute count. `index.ts` puts this wrapper outside `withLimits` for that reason.
15
+ * Crossing 80% of a ceiling logs `budget.warning` once per app, budget and window (and again in the
16
+ * next window). The warning state lives on the process runtime, so a plugin generation keeps it.
17
+ */
18
+
19
+ import type { Budget, BudgetMetric, BudgetWindow, Budgets } from "./config.js"
20
+ import type { CallContext, ModelCaller, PlugError, PlugEvent, PlugRequest } from "./contract.js"
21
+ import { errorMessage, log, logError, logWarn } from "./log.js"
22
+ import { totalTokens, utcDate, type BudgetStatus, type DayRecord, type UsageRecorder } from "./usage.js"
23
+
24
+ /** What the warnings already logged: `app|metric|window` -> the window's first day. The process-wide state (src/runtime.ts). */
25
+ export interface BudgetState {
26
+ readonly warned: Map<string, string>
27
+ }
28
+
29
+ export const createBudgetState = (): BudgetState => ({ warned: new Map() })
30
+
31
+ export interface BudgetOptions {
32
+ /** Clock in epoch ms. Tests inject one; the default is `Date.now`. */
33
+ readonly now?: () => number
34
+ /** Called once for each refused call (metrics, usage). */
35
+ readonly onRefused?: (app: string) => void
36
+ }
37
+
38
+ const DAY_MS = 86_400_000
39
+ const WARN_AT = 0.8
40
+ const METRICS: ReadonlyArray<BudgetMetric> = ["requests", "tokens", "costUsd"]
41
+ const WINDOWS: ReadonlyArray<BudgetWindow> = ["day", "week", "month"]
42
+ const LABEL: Record<BudgetMetric, string> = { requests: "requests", tokens: "tokens", costUsd: "cost (USD)" }
43
+
44
+ /** The window that holds `at`: its first UTC day (`start`) and the instant the next one begins (`resetsAt`, epoch ms). */
45
+ export function windowAt(window: BudgetWindow, at: number): { readonly start: string; readonly resetsAt: number } {
46
+ const now = new Date(at)
47
+ const year = now.getUTCFullYear()
48
+ const month = now.getUTCMonth()
49
+ const today = Date.UTC(year, month, now.getUTCDate())
50
+ if (window === "day") return { start: utcDate(today), resetsAt: today + DAY_MS }
51
+ if (window === "week") {
52
+ // getUTCDay counts from Sunday; Monday is 0 here.
53
+ const monday = today - ((now.getUTCDay() + 6) % 7) * DAY_MS
54
+ return { start: utcDate(monday), resetsAt: monday + 7 * DAY_MS }
55
+ }
56
+ return { start: utcDate(Date.UTC(year, month, 1)), resetsAt: Date.UTC(year, month + 1, 1) }
57
+ }
58
+
59
+ /** The budget that applies to `app`: `perApp`, with the app's `apps` entry replacing each metric it sets. Undefined when neither sets a metric. */
60
+ export function budgetFor(budgets: Budgets, app: string): Budget | undefined {
61
+ const own = budgets.apps && Object.hasOwn(budgets.apps, app) ? budgets.apps[app] : undefined
62
+ const merged: Budget = { ...budgets.perApp, ...own }
63
+ return METRICS.some((metric) => merged[metric] !== undefined) ? merged : undefined
64
+ }
65
+
66
+ interface Ceiling {
67
+ readonly metric: BudgetMetric
68
+ readonly window: BudgetWindow
69
+ readonly limit: number
70
+ }
71
+
72
+ /** Every ceiling the budget sets, in a fixed order: metric, then window. */
73
+ function ceilingsOf(budget: Budget): Ceiling[] {
74
+ const out: Ceiling[] = []
75
+ for (const metric of METRICS)
76
+ for (const window of WINDOWS) {
77
+ const limit = budget[metric]?.[window]
78
+ if (limit !== undefined) out.push({ metric, window, limit })
79
+ }
80
+ return out
81
+ }
82
+
83
+ /** What a window's records count against a ceiling. Requests leave out refusals, which `limited` counts. */
84
+ function usedOf(day: DayRecord, metric: BudgetMetric): number {
85
+ if (metric === "requests") return day.requests - day.limited
86
+ if (metric === "tokens") return totalTokens(day.tokens)
87
+ return day.costUsd
88
+ }
89
+
90
+ interface Spent {
91
+ readonly day: DayRecord
92
+ readonly resetsAt: number
93
+ /** First UTC day of the window. */
94
+ readonly start: string
95
+ }
96
+
97
+ /** The records of the window holding `at`, from its first day to today. Memoized per call, so each window is read once. */
98
+ function spentFor(usage: UsageRecorder, app: string, at: number): (window: BudgetWindow) => Promise<Spent> {
99
+ const seen = new Map<BudgetWindow, Promise<Spent>>()
100
+ return (window) => {
101
+ let found = seen.get(window)
102
+ if (!found) {
103
+ const { start, resetsAt } = windowAt(window, at)
104
+ seen.set(window, (found = usage.spent(app, start, utcDate(at)).then((day) => ({ day, resetsAt, start }))))
105
+ }
106
+ return found
107
+ }
108
+ }
109
+
110
+ /** The state of every ceiling of `budget` for `app` now: what the current window has used, and when it resets. */
111
+ export async function budgetStatus(usage: UsageRecorder, budget: Budget, app: string, at: number): Promise<BudgetStatus[]> {
112
+ const spent = spentFor(usage, app, at)
113
+ const rows: BudgetStatus[] = []
114
+ for (const { metric, window, limit } of ceilingsOf(budget)) {
115
+ const current = await spent(window)
116
+ rows.push({ metric, window, limit, used: usedOf(current.day, metric), resetsAt: new Date(current.resetsAt).toISOString() })
117
+ }
118
+ return rows
119
+ }
120
+
121
+ /** The refusal for a call now, or undefined. Logs the warnings the ceilings cross (once each per window). */
122
+ async function check(budget: Budget, app: string, usage: UsageRecorder, state: BudgetState, at: number): Promise<PlugError | undefined> {
123
+ const spent = spentFor(usage, app, at)
124
+ const exceeded: string[] = []
125
+ let resetsAt = 0
126
+ for (const { metric, window, limit } of ceilingsOf(budget)) {
127
+ const current = await spent(window)
128
+ const used = usedOf(current.day, metric)
129
+ if (used >= limit * WARN_AT) warnOnce(state, app, metric, window, current.start, { used, limit, resetsAt: current.resetsAt })
130
+ if (used >= limit) {
131
+ exceeded.push(`${LABEL[metric]} ${window}`)
132
+ resetsAt = Math.max(resetsAt, current.resetsAt)
133
+ }
134
+ }
135
+ if (exceeded.length === 0) return undefined
136
+ const retryAfterSeconds = Math.max(1, Math.ceil((resetsAt - at) / 1000))
137
+ log("request.budget_exceeded", { app, budgets: exceeded.join(", "), retryAfterSeconds })
138
+ return { status: 429, kind: "rate_limit", code: "budget_exceeded", message: `Budget for ${app} exceeded: ${exceeded.join(", ")}`, retryAfterSeconds }
139
+ }
140
+
141
+ function warnOnce(
142
+ state: BudgetState,
143
+ app: string,
144
+ metric: BudgetMetric,
145
+ window: BudgetWindow,
146
+ start: string,
147
+ figures: { readonly used: number; readonly limit: number; readonly resetsAt: number },
148
+ ): void {
149
+ const key = `${app}|${metric}|${window}`
150
+ if (state.warned.get(key) === start) return
151
+ state.warned.set(key, start)
152
+ logWarn("budget.warning", { app, budget: metric, window, used: figures.used, limit: figures.limit, resetsAt: new Date(figures.resetsAt).toISOString() })
153
+ }
154
+
155
+ /**
156
+ * The budget caller: each call of an app with a budget is checked first. With no budget for the app,
157
+ * no usage recorder, or no budgets at all, the caller is returned as it is. `budgets` is the parsed option.
158
+ */
159
+ export function withBudgets(
160
+ inner: ModelCaller,
161
+ budgets: Budgets | undefined,
162
+ usage: UsageRecorder | undefined,
163
+ state: BudgetState,
164
+ options: BudgetOptions = {},
165
+ ): ModelCaller {
166
+ const now = options.now ?? Date.now
167
+ return {
168
+ stream(request, context) {
169
+ const budget = budgets === undefined ? undefined : budgetFor(budgets, context.app)
170
+ if (!usage || !budget) return inner.stream(request, context)
171
+ return admit(inner, budget, usage, state, now, options.onRefused, request, context)
172
+ },
173
+ }
174
+ }
175
+
176
+ async function* admit(
177
+ inner: ModelCaller,
178
+ budget: Budget,
179
+ usage: UsageRecorder,
180
+ state: BudgetState,
181
+ now: () => number,
182
+ onRefused: BudgetOptions["onRefused"],
183
+ request: PlugRequest,
184
+ context: CallContext,
185
+ ): AsyncGenerator<PlugEvent> {
186
+ const app = context.app
187
+ let refusal: PlugError | undefined
188
+ try {
189
+ refusal = await check(budget, app, usage, state, now())
190
+ } catch (error) {
191
+ logError("budget.check_failed", { app, error: errorMessage(error) })
192
+ refusal = { status: 503, kind: "server", code: "budget_unavailable", message: "Could not read the usage records for the budget check" }
193
+ }
194
+ if (refusal) {
195
+ if (refusal.status === 429) onRefused?.(app)
196
+ yield { type: "error", error: refusal }
197
+ return
198
+ }
199
+ for await (const event of inner.stream(request, context)) yield event
200
+ }
@@ -97,6 +97,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
97
97
  includeUsage: streamOptions.include_usage === true,
98
98
  perPrompt: n,
99
99
  ...(body.echo === true ? { echo: prompts } : {}),
100
+ ...(generation.stop ? { stop: generation.stop } : {}),
100
101
  },
101
102
  ...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
102
103
  }
@@ -8,6 +8,7 @@
8
8
  import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
9
9
  import type { ChoiceCodec } from "../openai/fanout.js"
10
10
  import { encodeError, type StreamChoice, type StreamResult } from "../openai/response.js"
11
+ import { StopHoldback, stripStopTail } from "../stop.js"
11
12
 
12
13
  /** What the encoders need besides the model events; the request decoder produces it, the model call never sees it. */
13
14
  export interface CompletionsExchange {
@@ -20,6 +21,8 @@ export interface CompletionsExchange {
20
21
  readonly perPrompt: number
21
22
  /** `echo`: each prompt, written ahead of the text of its choices. */
22
23
  readonly echo?: ReadonlyArray<string>
24
+ /** The request's `stop`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). */
25
+ readonly stop?: ReadonlyArray<string>
23
26
  }
24
27
 
25
28
  const FINISH_REASONS: Record<Exclude<PlugFinish, "error">, string> = {
@@ -65,6 +68,7 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
65
68
  const error = failure ?? noFinish()
66
69
  return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
67
70
  }
71
+ if (finish === "stop") text = stripStopTail(text, exchange.stop ?? []).text
68
72
  return {
69
73
  status: 200,
70
74
  body: {
@@ -106,6 +110,8 @@ async function streamText(
106
110
  const prefix = prefixOf(exchange, choice.index)
107
111
  if (prefix.length > 0) chunk(prefix)
108
112
  }
113
+ // A stop sequence is held back until the finish decides it (src/stop.ts).
114
+ const holdback = new StopHoldback(exchange.stop ?? [])
109
115
  let finish: Exclude<PlugFinish, "error"> | undefined
110
116
  let usage: PlugUsage | undefined
111
117
  let failure: PlugError | undefined
@@ -120,12 +126,15 @@ async function streamText(
120
126
  else finish = event.reason
121
127
  } else if (event.type === "text-delta" && event.text.length > 0) {
122
128
  start()
123
- chunk(event.text)
129
+ const out = holdback.push(event.text)
130
+ if (out.length > 0) chunk(out)
124
131
  }
125
132
  }
126
133
  failure ??= finish ? undefined : noFinish()
127
134
  if (failure) return { failure, started }
128
135
  start()
136
+ const rest = holdback.finish(finish === "stop").text
137
+ if (rest.length > 0) chunk(rest)
129
138
  chunk("", FINISH_REASONS[finish!])
130
139
  return { started, ...(usage ? { usage } : {}) }
131
140
  }
package/src/config.ts CHANGED
@@ -50,6 +50,8 @@ export interface PlugboardOptions {
50
50
  readonly modelIds: ModelIds
51
51
  /** Concurrency caps and requests per minute per app (see `src/limits.ts`). Absent: unlimited. */
52
52
  readonly limits?: Limits
53
+ /** Per-app budgets (see `src/budgets.ts`): ceilings on requests, tokens and cost per UTC day, week or month. Needs `usage`. Absent: no budgets. */
54
+ readonly budgets?: Budgets
53
55
  /** Opt-in exact-match response cache (see `src/cache.ts`). Absent: off. */
54
56
  readonly cache?: CacheConfig
55
57
  /** Per-app usage statistics (see `src/usage.ts`): recorded and served by `GET /v1/usage` and the RPC. */
@@ -99,6 +101,34 @@ export interface Limits {
99
101
  readonly apps?: Readonly<Record<string, LimitCaps>>
100
102
  }
101
103
 
104
+ /** What a budget counts: HTTP requests on the model routes (refusals by `limits` or budgets left out), tokens, or cost in USD. */
105
+ export type BudgetMetric = "requests" | "tokens" | "costUsd"
106
+ /** The windows a budget resets on: a UTC calendar day, an ISO week (Monday start) or a calendar month (UTC). */
107
+ export type BudgetWindow = "day" | "week" | "month"
108
+
109
+ /** Ceilings per window. A window that is not set has no ceiling. */
110
+ export interface BudgetWindows {
111
+ readonly day?: number
112
+ readonly week?: number
113
+ readonly month?: number
114
+ }
115
+
116
+ /** One set of ceilings. A metric that is not set is unlimited. `costUsd` is an estimate at list prices. */
117
+ export interface Budget {
118
+ readonly requests?: BudgetWindows
119
+ readonly tokens?: BudgetWindows
120
+ readonly costUsd?: BudgetWindows
121
+ }
122
+
123
+ /**
124
+ * Option `budgets`. `perApp` applies to every app; `apps.<name>` replaces `perApp` metric by metric for
125
+ * that app (its `requests` replaces `perApp.requests`, the other metrics still come from `perApp`).
126
+ */
127
+ export interface Budgets {
128
+ readonly perApp?: Budget
129
+ readonly apps?: Readonly<Record<string, Budget>>
130
+ }
131
+
102
132
  export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
103
133
  export const DEFAULT_APP = "default"
104
134
  const DEFAULTS = {
@@ -109,7 +139,7 @@ const DEFAULTS = {
109
139
  requestTimeoutMs: 600_000,
110
140
  } as const
111
141
 
112
- const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "cache", "usage"])
142
+ const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage"])
113
143
  const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
114
144
  const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
115
145
  /** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
@@ -304,6 +334,68 @@ function parseCache(value: unknown, keys: Readonly<Record<string, string>>): Cac
304
334
  return { ttlSeconds, maxEntries, maxBytes, apps: [...new Set(value.apps as string[])] }
305
335
  }
306
336
 
337
+ const BUDGET_METRICS: ReadonlyArray<BudgetMetric> = ["requests", "tokens", "costUsd"]
338
+ const BUDGET_WINDOWS: ReadonlyArray<BudgetWindow> = ["day", "week", "month"]
339
+ /** The most days a window can span: a month is up to 31. Retention must keep at least that many, or a budget would count from pruned records. */
340
+ const WINDOW_DAYS: Readonly<Record<BudgetWindow, number>> = { day: 1, week: 7, month: 31 }
341
+
342
+ /** The longest window any ceiling of `budgets` uses, in days (0 without budgets). */
343
+ function widestWindowDays(budgets: Budgets | undefined): number {
344
+ let most = 0
345
+ for (const budget of [budgets?.perApp, ...Object.values(budgets?.apps ?? {})])
346
+ for (const metric of [budget?.requests, budget?.tokens, budget?.costUsd])
347
+ for (const window of Object.keys(metric ?? {}) as BudgetWindow[]) most = Math.max(most, WINDOW_DAYS[window])
348
+ return most
349
+ }
350
+
351
+ /**
352
+ * Validates `budgets`: `{ perApp?, apps? }`, each a budget of `requests`, `tokens` and `costUsd`, each of
353
+ * those a map of window (`day`, `week`, `month`) to a positive limit. Counts are integers, costs any positive
354
+ * number of USD. An `apps` entry must name a configured app, as in `limits`.
355
+ */
356
+ function parseBudgets(raw: unknown, keys: Readonly<Record<string, string>>): Budgets | undefined {
357
+ if (raw === undefined) return undefined
358
+ if (!isRecord(raw)) throw new Error("plugboard option `budgets` must be an object")
359
+ for (const key of Object.keys(raw))
360
+ if (key !== "perApp" && key !== "apps") throw new Error(`Unknown plugboard option \`budgets.${key}\` (known: perApp, apps)`)
361
+
362
+ const budget = (value: unknown, at: string): Budget => {
363
+ if (!isRecord(value)) throw new Error(`plugboard option \`${at}\` must be an object`)
364
+ for (const key of Object.keys(value))
365
+ if (!BUDGET_METRICS.includes(key as BudgetMetric)) throw new Error(`Unknown plugboard option \`${at}.${key}\` (known: ${BUDGET_METRICS.join(", ")})`)
366
+ const parsed: Record<string, Record<string, number>> = {}
367
+ for (const metric of BUDGET_METRICS) {
368
+ const windows = value[metric]
369
+ if (windows === undefined) continue
370
+ const field = `${at}.${metric}`
371
+ if (!isRecord(windows)) throw new Error(`plugboard option \`${field}\` must be an object of window (${BUDGET_WINDOWS.join(", ")}) to limit`)
372
+ const limits: Record<string, number> = {}
373
+ for (const window of Object.keys(windows)) {
374
+ if (!BUDGET_WINDOWS.includes(window as BudgetWindow)) throw new Error(`Unknown plugboard option \`${field}.${window}\` (known: ${BUDGET_WINDOWS.join(", ")})`)
375
+ const limit = windows[window]
376
+ if (limit === undefined) continue
377
+ if (metric === "costUsd") {
378
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) throw new Error(`plugboard option \`${field}.${window}\` must be a positive number of USD`)
379
+ limits[window] = limit
380
+ } else limits[window] = positiveInt(limit, `${field}.${window}`, 0, Number.MAX_SAFE_INTEGER)
381
+ }
382
+ parsed[metric] = limits
383
+ }
384
+ return parsed as Budget
385
+ }
386
+
387
+ const perApp = raw.perApp === undefined ? undefined : budget(raw.perApp, "budgets.perApp")
388
+ if (raw.apps !== undefined && !isRecord(raw.apps)) throw new Error("plugboard option `budgets.apps` must be an object of app name -> budget")
389
+ const apps: Record<string, Budget> = {}
390
+ for (const [app, value] of Object.entries(isRecord(raw.apps) ? raw.apps : {})) {
391
+ const at = `budgets.apps.${app}`
392
+ const configured = app === DEFAULT_APP ? Object.keys(keys).length === 0 : Object.hasOwn(keys, app)
393
+ if (!configured) throw new Error(`plugboard option \`${at}\` must name a configured app (a key in \`keys\`; \`${DEFAULT_APP}\` only without keys)`)
394
+ apps[app] = budget(value, at)
395
+ }
396
+ return { ...(perApp === undefined ? {} : { perApp }), ...(raw.apps === undefined ? {} : { apps }) }
397
+ }
398
+
307
399
  /** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
308
400
  export function parseOptions(raw: unknown): PlugboardOptions {
309
401
  if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
@@ -397,6 +489,10 @@ export function parseOptions(raw: unknown): PlugboardOptions {
397
489
  const limits = parseLimits(input.limits, keys)
398
490
  const cache = parseCache(input.cache, keys)
399
491
  const usage = parseUsage(input.usage)
492
+ const budgets = parseBudgets(input.budgets, keys)
493
+ if (budgets !== undefined && !usage.enabled) throw new Error("plugboard option `budgets` needs `usage` enabled: budgets are counted from the usage records")
494
+ if (budgets !== undefined && usage.retentionDays < widestWindowDays(budgets))
495
+ throw new Error(`plugboard option \`usage.retentionDays\` must be at least ${widestWindowDays(budgets)} for the budget windows in use (a month is 31 days, a week 7): older days are deleted and would stop counting`)
400
496
 
401
497
  return {
402
498
  listen: { host, port: port ?? DEFAULTS.port },
@@ -417,6 +513,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
417
513
  ...(limits === undefined ? {} : { limits }),
418
514
  ...(cache ? { cache } : {}),
419
515
  usage,
516
+ ...(budgets === undefined ? {} : { budgets }),
420
517
  ...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
421
518
  }
422
519
  }
package/src/contract.ts CHANGED
@@ -186,6 +186,20 @@ export interface CallContext {
186
186
  readonly onCache?: (result: "hit" | "miss") => void
187
187
  /** Bypass the response cache for this call. Fan-out sets it: each choice of `n` above 1 repeats one request and must be a fresh sample. */
188
188
  readonly noCache?: boolean
189
+ /**
190
+ * Called once per answered call, before its finish event, when usage recording is on (`withUsage`,
191
+ * src/usage.ts): the call's tokens and USD cost. Front-ends set `x-plugboard-usage` from it while the
192
+ * headers are open, so a stream (headers sent first) never gets it.
193
+ */
194
+ readonly onUsage?: (totals: CallTotals) => void
195
+ }
196
+
197
+ /** One answered call's totals for the caller: `inputTokens` and `outputTokens` as the response body reports them (cache reads inside input, reasoning inside output), and `costUsd` when the model has a price. */
198
+ export interface CallTotals {
199
+ readonly inputTokens: number
200
+ readonly outputTokens: number
201
+ readonly cacheReadTokens: number
202
+ readonly costUsd?: number
189
203
  }
190
204
 
191
205
  /** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */