opencode-plugboard 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
43
43
  {
44
44
  "plugins": [
45
45
  {
46
- "package": "opencode-plugboard@0.18.0",
46
+ "package": "opencode-plugboard@0.20.0",
47
47
  "options": {
48
48
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
49
49
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -390,7 +390,7 @@ A server shared by a Discord bot, n8n, Open WebUI and a coding agent, with most
390
390
  {
391
391
  "plugins": [
392
392
  {
393
- "package": "opencode-plugboard@0.18.0",
393
+ "package": "opencode-plugboard@0.20.0",
394
394
  "options": {
395
395
  // Reachable from the LAN. Non-loopback hosts refuse to start without keys.
396
396
  "listen": { "host": "10.0.1.50", "port": 4010 },
package/index.ts CHANGED
@@ -21,14 +21,17 @@
21
21
  import { Plugin } from "@opencode/plugin/effect"
22
22
  import { Effect } from "effect"
23
23
  import { parseOptions } from "./src/config.js"
24
+ import { personaOf } from "./src/models.js"
24
25
  import { errorMessage, logError } from "./src/log.js"
25
26
  import { withFallbacks } from "./src/fallback.js"
27
+ import { withBudgets } from "./src/budgets.js"
26
28
  import { withLimits } from "./src/limits.js"
27
29
  import { createOpenAiRoute } from "./src/openai/route.js"
28
- import { ensureBackend, ensurePump, loadServingCatalog, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
30
+ import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processKeys, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
29
31
  import { createSessionCaller } from "./src/session/caller.js"
30
32
  import { installSessionHooks } from "./src/session/context.js"
31
33
  import { createHistoryPort, createSessionPort, createStoragePort, loadCatalog } from "./src/session/host.js"
34
+ import { createKeyStore } from "./src/keys.js"
32
35
  import { usageHandlers, UsageRpc } from "./src/rpc.js"
33
36
  import { createUsageRecorder, withUsage } from "./src/usage.js"
34
37
 
@@ -58,6 +61,7 @@ export default Plugin.define({
58
61
  sessions,
59
62
  maxTools: options.maxTools,
60
63
  apps: options.apps,
64
+ persona: (model: string) => personaOf(options, model),
61
65
  remoteImages: options.remoteImages,
62
66
  // The readiness gate the caller awaits before creating a session (fails closed: 503 `backend_unavailable`).
63
67
  ensureBackend: (app: string, signal: AbortSignal) => ensureBackend(app, signal, bootLocation),
@@ -80,23 +84,35 @@ export default Plugin.define({
80
84
  // Aliases written as a list retry the next target before the first output (src/fallback.ts).
81
85
  // The response cache sits outside the fallbacks, so a hit never reaches a provider (src/cache.ts).
82
86
  caller: withUsage(
83
- withLimits(
84
- withResponseCache(
85
- withFallbacks(createSessionCaller(callerDeps), {
87
+ // Budgets sit outside the limiter, so a budget refusal never takes a slot (src/budgets.ts).
88
+ withBudgets(
89
+ withLimits(
90
+ withResponseCache(
91
+ withFallbacks(createSessionCaller(callerDeps), {
92
+ options,
93
+ loadCatalog: servingCatalog,
94
+ onFallback: (app) => {
95
+ if (options.metrics) processMetrics().fallback(app)
96
+ usage?.fallback(app)
97
+ },
98
+ }),
86
99
  options,
87
- loadCatalog: servingCatalog,
88
- onFallback: (app) => {
89
- if (options.metrics) processMetrics().fallback(app)
90
- usage?.fallback(app)
100
+ ),
101
+ () => limits,
102
+ processLimits(),
103
+ {
104
+ onLimited: (app, reason) => {
105
+ if (options.metrics) processMetrics().limited(app, reason)
106
+ usage?.limited(app)
91
107
  },
92
- }),
93
- options,
108
+ },
94
109
  ),
95
- () => limits,
96
- processLimits(),
110
+ options.budgets,
111
+ usage,
112
+ processBudgets(),
97
113
  {
98
- onLimited: (app, reason) => {
99
- if (options.metrics) processMetrics().limited(app, reason)
114
+ onRefused: (app) => {
115
+ if (options.metrics) processMetrics().limited(app, "budget")
100
116
  usage?.limited(app)
101
117
  },
102
118
  },
@@ -105,6 +121,8 @@ export default Plugin.define({
105
121
  ),
106
122
  loadCatalog: servingCatalog,
107
123
  usage,
124
+ // Runtime app keys (#55): read from storage on the first authentication, cached on the runtime.
125
+ keys: createKeyStore({ state: processKeys(), storage: createStoragePort(ctx) }),
108
126
  })
109
127
  const registration = registerGeneration({
110
128
  listen: options.listen,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
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.",
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Admin routes for runtime app keys (#55): `GET /v1/admin/keys` (keys without values),
3
+ * `POST /v1/admin/keys` with body `{ "app": "<name>" }` (a new random key, returned once) and
4
+ * `DELETE /v1/admin/keys/{keyId}` (revokes a runtime key; a config key is a 409).
5
+ *
6
+ * The routes exist only when option `adminKey` is set: without it every path here is a 404. Only the
7
+ * admin key passes; an app key, a wrong key or no key is a 401. src/openai/route.ts dispatches here
8
+ * after the origin check and before app authentication, so an app key never reaches them.
9
+ */
10
+
11
+ import type { IncomingMessage, ServerResponse } from "node:http"
12
+ import { APP_NAME_PATTERN, DEFAULT_APP, authenticateUsage, type PlugboardOptions } from "../config.js"
13
+ import { isPlugError, type PlugError } from "../contract.js"
14
+ import { checkJsonContentType, readJsonBody, sendError, sendJson, type RequestScope } from "../http.js"
15
+ import type { KeyStore } from "../keys.js"
16
+ import { log } from "../log.js"
17
+ import { encodeError } from "../openai/response.js"
18
+
19
+ export interface AdminHost {
20
+ /** The live options (`liveOptions`): the config keys, and `adminKey`. */
21
+ readonly options: PlugboardOptions
22
+ /** The runtime key store; absent means the admin routes are not served. */
23
+ readonly keys?: KeyStore
24
+ }
25
+
26
+ export interface AdminCall {
27
+ readonly path: string
28
+ readonly request: IncomingMessage
29
+ readonly response: ServerResponse
30
+ readonly scope: RequestScope
31
+ /** The route's log fields: the route name is set here, so the request log names the admin route. */
32
+ readonly info: { route: string; app?: string }
33
+ }
34
+
35
+ const KEYS = "/v1/admin/keys"
36
+ const ONE_KEY = /^\/v1\/admin\/keys\/([0-9a-f]{8})$/
37
+
38
+ export async function serveAdmin(host: AdminHost, call: AdminCall): Promise<void> {
39
+ const { path, request, response, scope, info } = call
40
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
41
+ const { keys } = host
42
+ if (host.options.adminKey === undefined || keys === undefined)
43
+ return fail({ status: 404, kind: "not_found", code: "not_found", message: `No route for ${request.method} ${path}` })
44
+
45
+ const who = authenticateUsage(host.options, request.headers.authorization, await keys.digests())
46
+ if (isPlugError(who) || !who.admin) return fail({ status: 401, kind: "authentication", code: "invalid_api_key", message: "Missing or invalid admin key" })
47
+
48
+ const id = ONE_KEY.exec(path)?.[1]
49
+ if (path === KEYS && request.method === "GET") {
50
+ info.route = "GET /v1/admin/keys"
51
+ return sendJson(response, 200, { keys: await keys.list(host.options.keys) })
52
+ }
53
+ if (path === KEYS && request.method === "POST") {
54
+ info.route = "POST /v1/admin/keys"
55
+ const unsupported = checkJsonContentType(request)
56
+ if (unsupported) return fail(unsupported)
57
+ const body = await readJsonBody(request, scope.signal)
58
+ const app = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as { app?: unknown }).app : undefined
59
+ if (typeof app !== "string" || app === DEFAULT_APP || !APP_NAME_PATTERN.test(app))
60
+ return fail({ status: 400, kind: "invalid_request", code: "invalid_app", param: "app", message: `\`app\` must match ${APP_NAME_PATTERN} and must not be \`${DEFAULT_APP}\`` })
61
+ if (Object.hasOwn(host.options.keys, app))
62
+ return fail({ status: 409, kind: "invalid_request", code: "app_has_config_key", param: "app", message: `App ${app} already has a key in the plugboard config; edit \`keys\` to change it` })
63
+ const created = await keys.create(app, host.options.keys)
64
+ log("admin.key_created", { id: created.id, app })
65
+ // The key value is shown once: a cache or a log of this response must not keep it.
66
+ response.setHeader("cache-control", "no-store")
67
+ return sendJson(response, 201, created)
68
+ }
69
+ if (id !== undefined && request.method === "DELETE") {
70
+ info.route = "DELETE /v1/admin/keys/{keyId}"
71
+ const result = await keys.revoke(id, host.options.keys)
72
+ if (result === "config")
73
+ return fail({ status: 409, kind: "invalid_request", code: "config_key", message: "This key is set in the plugboard config (`keys`). Remove it there and reload OpenCode." })
74
+ if (result === "not_found") return fail({ status: 404, kind: "not_found", code: "key_not_found", message: `No runtime key ${id}` })
75
+ log("admin.key_revoked", { id })
76
+ return sendJson(response, 200, { id, revoked: true })
77
+ }
78
+ if (path === KEYS || id !== undefined)
79
+ return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}` })
80
+ return fail({ status: 404, kind: "not_found", code: "not_found", message: `No route for ${request.method} ${path}` })
81
+ }
@@ -9,6 +9,7 @@
9
9
  * afterwards, which is why the request omits `ref`.
10
10
  */
11
11
  import type { PlugboardOptions } from "../config.js"
12
+ import { bodyUserId, userField } from "../user.js"
12
13
  import {
13
14
  isPlugError,
14
15
  type PlugError,
@@ -327,6 +328,7 @@ export function parseMessagesRequest(body: unknown, options: PlugboardOptions):
327
328
  return bad(`tool_choice.name: "${toolChoice.name}" is not in tools`)
328
329
 
329
330
  const request: Omit<PlugRequest, "ref"> = {
331
+ ...userField(bodyUserId(body)),
330
332
  model: body.model,
331
333
  system,
332
334
  messages,
@@ -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()
@@ -14,6 +14,7 @@
14
14
  import type { IncomingMessage, ServerResponse } from "node:http"
15
15
  import { authenticate } from "../config.js"
16
16
  import { liveOptions } from "../runtime.js"
17
+ import { bodyUserId, noteEndUser } from "../user.js"
17
18
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
18
19
  import {
19
20
  BodyError,
@@ -31,6 +32,8 @@ import { reportServed, type ServedInfo } from "../fallback.js"
31
32
  import { resolveModel } from "../models.js"
32
33
  import { reportCache } from "../cache.js"
33
34
  import { reportPolicy } from "../policy.js"
35
+ import { reportUsage } from "../usage-header.js"
36
+ import { setSessionLink } from "../session/link.js"
34
37
  import { dropRejected } from "../openai/sampling.js"
35
38
  import type { RouteHost } from "../openai/route.js"
36
39
  import { parseMessagesRequest } from "./request.js"
@@ -53,7 +56,7 @@ export interface MessagesCall {
53
56
  readonly response: ServerResponse
54
57
  readonly scope: RequestScope
55
58
  /** The route's log fields: the route name, the app, the resolved model, dropped parameters, and the outcome of a stream that failed after it committed. */
56
- readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string }
59
+ readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string; user?: string }
57
60
  }
58
61
 
59
62
  const header = (request: IncomingMessage, name: string): string | undefined => {
@@ -62,11 +65,12 @@ const header = (request: IncomingMessage, name: string): string | undefined => {
62
65
  }
63
66
 
64
67
  /** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
65
- const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
68
+ const authenticateRequest = async (host: RouteHost, request: IncomingMessage) => {
66
69
  const options = liveOptions(host.options)
67
- const bearer = authenticate(options, request.headers.authorization)
70
+ const runtime = host.keys ? await host.keys.digests() : []
71
+ const bearer = authenticate(options, request.headers.authorization, runtime)
68
72
  const apiKey = header(request, "x-api-key")
69
- return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`) : bearer
73
+ return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`, runtime) : bearer
70
74
  }
71
75
 
72
76
  export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
@@ -93,7 +97,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
93
97
  const rejected = checkOrigin(request, host.options)
94
98
  if (rejected) return fail(rejected)
95
99
  // Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
96
- const auth = authenticateRequest(host, request)
100
+ const auth = await authenticateRequest(host, request)
97
101
  if (isPlugError(auth)) return fail(auth)
98
102
  info.app = auth.app
99
103
  if (request.method !== "POST")
@@ -102,6 +106,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
102
106
  if (unsupported) return fail(unsupported)
103
107
 
104
108
  const body = await readJsonBody(request, scope.signal)
109
+ noteEndUser(response, info, bodyUserId(body))
105
110
  // count_tokens takes the message body without max_tokens; the parser needs one, so a placeholder stands in
106
111
  // (the count never uses it). A max_tokens the client did send is still validated.
107
112
  const sized = path === COUNT_TOKENS && isRecord(body) && body.max_tokens === undefined ? { ...body, max_tokens: Number.MAX_SAFE_INTEGER } : body
@@ -129,12 +134,14 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
129
134
  host.caller.stream(model, {
130
135
  app: auth.app,
131
136
  signal: scope.signal,
132
- onRecord: (recordID) => {
137
+ onRecord: (recordID, directory) => {
133
138
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
134
139
  response.setHeader(SESSION_HEADER, recordID)
140
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
135
141
  },
136
142
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
137
143
  onPolicy: reportPolicy(response),
144
+ onUsage: reportUsage(response, exchange.stream),
138
145
  onCache: reportCache(response),
139
146
  }),
140
147
  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
+ }
package/src/cache.ts CHANGED
@@ -25,6 +25,10 @@
25
25
  *
26
26
  * Fan-out (`n` above 1, prompt arrays) calls the caller once per choice with the same request, so
27
27
  * the fan-out sets `noCache`: every choice must be a fresh sample.
28
+ *
29
+ * A persona (a skill alias or an `agent/<name>` id, `bypass`) is never cached: its answer depends on
30
+ * the skill's content or the agent's prompt, which the key does not see, and a skill edit must not be
31
+ * masked by an old entry.
28
32
  */
29
33
 
30
34
  import { createHash } from "node:crypto"
@@ -115,11 +119,17 @@ function append(events: PlugEvent[], event: { readonly type: "text-delta" | "rea
115
119
  }
116
120
 
117
121
  /** Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. */
118
- export function cachedCaller(inner: ModelCaller, config: CacheConfig, store: CacheStore, count?: CacheCounter): ModelCaller {
122
+ export function cachedCaller(
123
+ inner: ModelCaller,
124
+ config: CacheConfig,
125
+ store: CacheStore,
126
+ count?: CacheCounter,
127
+ bypass?: (model: string) => boolean,
128
+ ): ModelCaller {
119
129
  const apps = config.apps ? new Set(config.apps) : undefined
120
130
  return {
121
131
  async *stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
122
- if (context.noCache || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
132
+ if (context.noCache || bypass?.(request.model) || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
123
133
  const key = cacheKey(context.app, request)
124
134
  const hit = store.get(key)
125
135
  if (hit) {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { PlugboardOptions } from "../config.js"
12
12
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
+ import { bodyUser, userField } from "../user.js"
13
14
  import { parseChoiceCount, parseGeneration } from "../openai/request.js"
14
15
  import type { CompletionsExchange } from "./response.js"
15
16
 
@@ -85,6 +86,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
85
86
  return {
86
87
  requests: prompts.map((prompt) => ({
87
88
  model: body.model as string,
89
+ ...userField(bodyUser(body)),
88
90
  system: suffix ? [SUFFIX_INSTRUCTION(suffix)] : [],
89
91
  messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
90
92
  tools: [],
@@ -97,6 +99,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
97
99
  includeUsage: streamOptions.include_usage === true,
98
100
  perPrompt: n,
99
101
  ...(body.echo === true ? { echo: prompts } : {}),
102
+ ...(generation.stop ? { stop: generation.stop } : {}),
100
103
  },
101
104
  ...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
102
105
  }
@@ -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
  }