opencode-plugboard 0.14.0 → 0.15.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
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
30
30
  {
31
31
  "plugins": [
32
32
  {
33
- "package": "opencode-plugboard@0.14.0",
33
+ "package": "opencode-plugboard@0.15.0",
34
34
  "options": {
35
35
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
36
36
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -78,7 +78,7 @@ Plugboard is early (0.x) and narrow on purpose.
78
78
  - **V2 only.** OpenCode V2 has no OpenAI endpoint, and the V1 plugins that had one stopped working when the plugin API changed. V1 is not planned.
79
79
  - **Two formats:** Chat Completions and Responses. `/v1/responses` is a stateless subset, see [docs/responses.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md). Anthropic Messages and Gemini front-ends are on the [roadmap](#roadmap).
80
80
  - **Not planned:** embeddings, audio, image generation, `logprobs`.
81
- - **No concurrency limit per app** yet ([#54](https://github.com/jjjermiah/opencode-plugboard/issues/54), [#86](https://github.com/jjjermiah/opencode-plugboard/issues/86)).
81
+ - **Not a distributed gateway.** Limits, the response cache and metrics live in one OpenCode server process; two servers don't share them.
82
82
  - **Not a gateway with its own provider keys.** It only reuses what OpenCode is logged in to.
83
83
 
84
84
  How it differs from a standalone gateway:
package/index.ts CHANGED
@@ -23,8 +23,9 @@ import { Effect } from "effect"
23
23
  import { parseOptions } from "./src/config.js"
24
24
  import { errorMessage, logError } from "./src/log.js"
25
25
  import { withFallbacks } from "./src/fallback.js"
26
+ import { withLimits } from "./src/limits.js"
26
27
  import { createOpenAiRoute } from "./src/openai/route.js"
27
- import { ensureBackend, ensurePump, loadServingCatalog, processMetrics, registerGeneration, sessionStore } from "./src/runtime.js"
28
+ import { ensureBackend, ensurePump, loadServingCatalog, processLimits, processMetrics, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
28
29
  import { createSessionCaller } from "./src/session/caller.js"
29
30
  import { installSessionHooks } from "./src/session/context.js"
30
31
  import { createHistoryPort, createSessionPort, loadCatalog } from "./src/session/host.js"
@@ -65,14 +66,26 @@ export default Plugin.define({
65
66
  startPump()
66
67
  return loadServingCatalog(ownCatalog)
67
68
  }
69
+ const limits = options.limits
68
70
  const handle = createOpenAiRoute({
69
71
  options,
72
+ // Caps are outermost, so one request takes one slot even when it falls back (src/limits.ts).
70
73
  // Aliases written as a list retry the next target before the first output (src/fallback.ts).
71
- caller: withFallbacks(createSessionCaller(callerDeps), {
72
- options,
73
- loadCatalog: servingCatalog,
74
- onFallback: options.metrics ? (app) => processMetrics().fallback(app) : undefined,
75
- }),
74
+ // Limits are outermost (one slot per request, refused calls never reach anything); the response
75
+ // cache sits outside the fallbacks, so a hit never reaches a provider (src/limits.ts, src/cache.ts).
76
+ caller: withLimits(
77
+ withResponseCache(
78
+ withFallbacks(createSessionCaller(callerDeps), {
79
+ options,
80
+ loadCatalog: servingCatalog,
81
+ onFallback: options.metrics ? (app) => processMetrics().fallback(app) : undefined,
82
+ }),
83
+ options,
84
+ ),
85
+ () => limits,
86
+ processLimits(),
87
+ { onLimited: options.metrics ? (app, reason) => processMetrics().limited(app, reason) : undefined },
88
+ ),
76
89
  loadCatalog: servingCatalog,
77
90
  })
78
91
  const registration = registerGeneration({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.14.0",
3
+ "version": "0.15.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-compatible Chat Completions, Responses and model endpoints.",
@@ -28,6 +28,8 @@
28
28
  "@prometheus-io/client": "0.16.1",
29
29
  "escape-html": "1.0.3",
30
30
  "ipaddr.js": "2.5.0",
31
+ "lru-cache": "11.5.3",
32
+ "p-queue": "9.3.5",
31
33
  "pino": "10.4.0",
32
34
  "safe-stable-stringify": "2.5.0"
33
35
  },
@@ -27,6 +27,7 @@ import {
27
27
  } from "../http.js"
28
28
  import { reportServed, type ServedInfo } from "../fallback.js"
29
29
  import { resolveModel } from "../models.js"
30
+ import { reportCache } from "../cache.js"
30
31
  import { reportPolicy } from "../policy.js"
31
32
  import { dropRejected } from "../openai/sampling.js"
32
33
  import type { RouteHost } from "../openai/route.js"
@@ -125,6 +126,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
125
126
  },
126
127
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
127
128
  onPolicy: reportPolicy(response),
129
+ onCache: reportCache(response),
128
130
  }),
129
131
  scope,
130
132
  )
package/src/cache.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Opt-in exact-match response cache, as a `ModelCaller` wrapper.
3
+ *
4
+ * Pollers and bots repeat identical prompts and pay quota every time. With `cache` set, a
5
+ * completed answer is kept in memory, and the same request from the same app within the TTL is
6
+ * answered from it without creating a session or touching the provider.
7
+ *
8
+ * What is cached: a turn that streamed to the end and finished `stop` or `length`, with some text
9
+ * or reasoning and no tool call and no error. Everything else passes through untouched: tool calls
10
+ * need the caller's tool results, structured output arrives as a forced tool call (so it is not
11
+ * cached), and errors and aborted turns are never answers. A hit replays what the miss sent: the
12
+ * `PlugEvent` sequence (same-kind deltas coalesced), the `onServed` and `onPolicy` notes, and a
13
+ * `finish` with zero usage, because the call spent no tokens and `plugboard_tokens_total` must not
14
+ * count it. No history record is written on a hit.
15
+ *
16
+ * The key is a SHA-256 of the app and the resolved request: provider, model id, variant, system,
17
+ * messages, tools, tool choice and sampling parameters. The `model` string the caller sent (an
18
+ * alias, say) is not part of it, so two names for one model share entries. Apps never share
19
+ * entries. The store is process-wide (`src/runtime.ts`), so it survives plugin generations, is
20
+ * reset when the cache option or the `apps` policy changes, and is lost on restart.
21
+ *
22
+ * Policy that the key does not see (a system prompt part, a tool strip, a model scope) is applied
23
+ * after the key is built. That is safe because the store is reset whenever `apps` changes, so an
24
+ * entry always comes from the same policy as the request that replays it.
25
+ *
26
+ * Fan-out (`n` above 1, prompt arrays) calls the caller once per choice with the same request, so
27
+ * the fan-out sets `noCache`: every choice must be a fresh sample.
28
+ */
29
+
30
+ import { createHash } from "node:crypto"
31
+ import type { ServerResponse } from "node:http"
32
+ import { LRUCache } from "lru-cache"
33
+ import stableStringify from "safe-stable-stringify"
34
+ import type { CallContext, ModelCaller, PlugEvent, PlugFinish, PlugRequest, PolicyNote, ServedModel } from "./contract.js"
35
+
36
+ /** The `cache` plugin option after defaults and validation (see `parseOptions` in `src/config.ts`). */
37
+ export interface CacheConfig {
38
+ /** How long an entry is served after it was stored. */
39
+ readonly ttlSeconds: number
40
+ /** Most entries kept; the least recently used goes first. */
41
+ readonly maxEntries: number
42
+ /** Most bytes kept (the entries as JSON); the least recently used goes first. */
43
+ readonly maxBytes: number
44
+ /** Apps that use the cache. Absent: every app. */
45
+ readonly apps?: ReadonlyArray<string>
46
+ }
47
+
48
+ /** What a miss produced, replayed by a hit. */
49
+ export interface Entry {
50
+ readonly events: ReadonlyArray<PlugEvent>
51
+ readonly served?: ServedModel
52
+ readonly policy?: ReadonlyArray<PolicyNote>
53
+ }
54
+
55
+ export interface CacheStore {
56
+ get(key: string): Entry | undefined
57
+ set(key: string, entry: Entry): void
58
+ size(): number
59
+ }
60
+
61
+ export interface CacheLimits {
62
+ readonly ttlMs: number
63
+ readonly maxEntries: number
64
+ readonly maxBytes: number
65
+ }
66
+
67
+ /** Fixed per-entry overhead added to the payload size: the key, the entry object and the array. */
68
+ const ENTRY_OVERHEAD = 128
69
+
70
+ export function createCacheStore(limits: CacheLimits): CacheStore {
71
+ const lru = new LRUCache<string, Entry>({
72
+ max: limits.maxEntries,
73
+ maxSize: limits.maxBytes,
74
+ sizeCalculation: (entry) => Buffer.byteLength(JSON.stringify(entry)) + ENTRY_OVERHEAD,
75
+ ttl: limits.ttlMs,
76
+ })
77
+ return {
78
+ get: (key) => lru.get(key),
79
+ set: (key, entry) => void lru.set(key, entry),
80
+ size: () => lru.size,
81
+ }
82
+ }
83
+
84
+ /** The cache key of one call: the app and the resolved request, object keys sorted. */
85
+ export function cacheKey(app: string, request: PlugRequest): string {
86
+ const { ref } = request
87
+ const canonical = stableStringify([app, ref.providerID, ref.id, ref.variant ?? null, request.system, request.messages, request.tools, request.toolChoice ?? null, request.generation])
88
+ return createHash("sha256").update(canonical ?? "").digest("hex")
89
+ }
90
+
91
+ /** Called once per call the cache applies to. */
92
+ export type CacheCounter = (app: string, result: "hit" | "miss") => void
93
+
94
+ /** The response header that says whether the answer came from the cache. */
95
+ export const CACHE_HEADER = "x-plugboard-cache"
96
+
97
+ /**
98
+ * The `CallContext.onCache` of a route: `x-plugboard-cache: hit|miss` while the headers are still
99
+ * open. The first writer wins, so several choices of one request report once.
100
+ */
101
+ export function reportCache(response: ServerResponse): (result: "hit" | "miss") => void {
102
+ return (result) => {
103
+ if (response.headersSent || response.destroyed || response.hasHeader(CACHE_HEADER)) return
104
+ response.setHeader(CACHE_HEADER, result)
105
+ }
106
+ }
107
+
108
+ const CACHEABLE_FINISH: ReadonlySet<PlugFinish> = new Set<PlugFinish>(["stop", "length"])
109
+
110
+ /** Appends a delta to the list, merging it into the previous event when that is the same kind. */
111
+ function append(events: PlugEvent[], event: { readonly type: "text-delta" | "reasoning-delta"; readonly text: string }): void {
112
+ const last = events.at(-1)
113
+ if (last && last.type === event.type) events[events.length - 1] = { type: event.type, text: (last as { text: string }).text + event.text }
114
+ else events.push({ type: event.type, text: event.text })
115
+ }
116
+
117
+ /** 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 {
119
+ const apps = config.apps ? new Set(config.apps) : undefined
120
+ return {
121
+ async *stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
122
+ if (context.noCache || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
123
+ const key = cacheKey(context.app, request)
124
+ const hit = store.get(key)
125
+ if (hit) {
126
+ count?.(context.app, "hit")
127
+ context.onCache?.("hit")
128
+ if (hit.served) context.onServed?.(hit.served)
129
+ if (hit.policy) context.onPolicy?.(hit.policy)
130
+ for (const event of hit.events) {
131
+ if (context.signal.aborted) return
132
+ yield event.type === "finish" ? { type: "finish", reason: event.reason, usage: { inputTokens: 0, outputTokens: 0 } } : event
133
+ }
134
+ return
135
+ }
136
+ count?.(context.app, "miss")
137
+ context.onCache?.("miss")
138
+ // What the route is told before the first event is kept for a later hit; the route still gets it.
139
+ let served: ServedModel | undefined
140
+ let policy: ReadonlyArray<PolicyNote> | undefined
141
+ const forward: CallContext = {
142
+ ...context,
143
+ onServed: (value) => {
144
+ served ??= value
145
+ context.onServed?.(value)
146
+ },
147
+ onPolicy: (notes) => {
148
+ policy ??= [...notes]
149
+ context.onPolicy?.(notes)
150
+ },
151
+ }
152
+ const recorded: PlugEvent[] = []
153
+ let storable = true
154
+ for await (const event of inner.stream(request, forward)) {
155
+ if (event.type === "text-delta" || event.type === "reasoning-delta") append(recorded, event)
156
+ else if (event.type === "finish") {
157
+ // Stored before the event is handed on, so a consumer that stops reading at `finish` still fills the cache.
158
+ if (storable && CACHEABLE_FINISH.has(event.reason) && recorded.length > 0 && !context.signal.aborted)
159
+ store.set(key, { events: [...recorded, { type: "finish", reason: event.reason }], ...(served ? { served } : {}), ...(policy?.length ? { policy } : {}) })
160
+ } else storable = false
161
+ yield event
162
+ }
163
+ },
164
+ }
165
+ }
package/src/config.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  /** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
2
2
 
3
3
  import { createHash, timingSafeEqual } from "node:crypto"
4
+ import type { CacheConfig } from "./cache.js"
4
5
  import type { PlugError } from "./contract.js"
5
6
  import { allowedBy, parseModelRef } from "./models.js"
6
7
  import { parseAllowEntry, type RemoteImages } from "./session/media.js"
7
8
  import type { HistoryLimits } from "./session/prune.js"
8
9
 
10
+ /** How `/v1/models` spells a model id: the canonical `provider/model#variant`, a UI-safe slug, or both. Resolution accepts either. */
11
+ export type ModelIds = "canonical" | "slugs" | "both"
12
+
9
13
  /** Plugin options, after defaults and validation (see `parseOptions`). */
10
14
  export interface PlugboardOptions {
11
15
  readonly listen: { readonly host: string; readonly port: number }
@@ -42,6 +46,12 @@ export interface PlugboardOptions {
42
46
  readonly logFormat: "text" | "json"
43
47
  /** Serve `GET /metrics` (Prometheus text). Off by default; same authentication as `/v1`. */
44
48
  readonly metrics: boolean
49
+ /** Ids listed by `/v1/models`: canonical, slugs (`provider--model__variant`) or both. See `toSlug` in `src/models.ts`. */
50
+ readonly modelIds: ModelIds
51
+ /** Concurrency caps and requests per minute per app (see `src/limits.ts`). Absent: unlimited. */
52
+ readonly limits?: Limits
53
+ /** Opt-in exact-match response cache (see `src/cache.ts`). Absent: off. */
54
+ readonly cache?: CacheConfig
45
55
  }
46
56
 
47
57
  export type SystemMode = "prepend" | "append" | "replace"
@@ -61,6 +71,24 @@ export interface AppPolicy {
61
71
  readonly maxTools?: number
62
72
  }
63
73
 
74
+ /** One set of caps. A field that is not set is unlimited. */
75
+ export interface LimitCaps {
76
+ /** Model calls in flight at once; each call holds one slot. */
77
+ readonly maxConcurrent?: number
78
+ /** Model calls started per minute (a fixed one-minute window). */
79
+ readonly requestsPerMinute?: number
80
+ }
81
+
82
+ /**
83
+ * Option `limits`. `maxConcurrent` caps all apps together; `perApp` caps every app; `apps.<name>`
84
+ * overrides `perApp` field by field for that app.
85
+ */
86
+ export interface Limits {
87
+ readonly maxConcurrent?: number
88
+ readonly perApp?: LimitCaps
89
+ readonly apps?: Readonly<Record<string, LimitCaps>>
90
+ }
91
+
64
92
  export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
65
93
  export const DEFAULT_APP = "default"
66
94
  const DEFAULTS = {
@@ -71,8 +99,16 @@ const DEFAULTS = {
71
99
  requestTimeoutMs: 600_000,
72
100
  } as const
73
101
 
74
- const KNOWN = new Set(["listen", "keys", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
102
+ const KNOWN = new Set(["listen", "keys", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "cache"])
103
+ const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps"])
104
+ const LIMIT_CAP_KNOWN = new Set(["maxConcurrent", "requestsPerMinute"])
105
+ /** Largest cap accepted: far above any provider's limit, and keeps the error messages short. */
106
+ const MAX_LIMIT = 1_000_000
75
107
  const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
108
+ const CACHE_KNOWN = new Set(["ttlSeconds", "maxEntries", "maxBytes", "apps"])
109
+ const CACHE_DEFAULTS = { maxEntries: 1000, maxBytes: 64 * 1024 * 1024 } as const
110
+ /** One year: a longer TTL is a typo, and keeps the millisecond conversion a safe integer. */
111
+ const MAX_CACHE_TTL_SECONDS = 31_536_000
76
112
  /** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
77
113
  const MAX_AGE_DAYS = 36_500
78
114
  const MODEL_PATTERN = /^[^/#*\s]+\/(\*|[^#*\s]+(#[^#*\s]+)?)$/
@@ -139,6 +175,7 @@ function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
139
175
 
140
176
  const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools"])
141
177
  const SYSTEM_MODES: ReadonlySet<string> = new Set(["prepend", "append", "replace"])
178
+ const MODEL_IDS: ReadonlySet<string> = new Set(["canonical", "slugs", "both"])
142
179
 
143
180
  /** Validates `apps` (the `key` field was merged into `keys` already) into per-app policies. */
144
181
  function parseApps(
@@ -185,11 +222,62 @@ function parseApps(
185
222
  return Object.fromEntries(entries)
186
223
  }
187
224
 
225
+ /** Validates one set of caps (`limits.perApp`, or one `limits.apps` entry): each cap is a positive integer. */
226
+ function parseCaps(raw: unknown, at: string): LimitCaps {
227
+ if (!isRecord(raw)) throw new Error(`plugboard option \`${at}\` must be an object`)
228
+ for (const key of Object.keys(raw))
229
+ if (!LIMIT_CAP_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`${at}.${key}\` (known: ${[...LIMIT_CAP_KNOWN].join(", ")})`)
230
+ return {
231
+ ...(raw.maxConcurrent === undefined ? {} : { maxConcurrent: positiveInt(raw.maxConcurrent, `${at}.maxConcurrent`, 0, MAX_LIMIT) }),
232
+ ...(raw.requestsPerMinute === undefined ? {} : { requestsPerMinute: positiveInt(raw.requestsPerMinute, `${at}.requestsPerMinute`, 0, MAX_LIMIT) }),
233
+ }
234
+ }
235
+
236
+ /** Validates `limits`. An `apps` entry must name a configured app: a key in `keys` (or `default`, only when there are no keys). */
237
+ function parseLimits(raw: unknown, keys: Readonly<Record<string, string>>): Limits | undefined {
238
+ if (raw === undefined) return undefined
239
+ if (!isRecord(raw)) throw new Error("plugboard option `limits` must be an object")
240
+ for (const key of Object.keys(raw))
241
+ if (!LIMITS_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`limits.${key}\` (known: ${[...LIMITS_KNOWN].join(", ")})`)
242
+ const appsRaw = raw.apps
243
+ if (appsRaw !== undefined && !isRecord(appsRaw)) throw new Error("plugboard option `limits.apps` must be an object of app name -> caps")
244
+ const apps: Record<string, LimitCaps> = {}
245
+ for (const [app, value] of Object.entries(appsRaw ?? {})) {
246
+ const at = `limits.apps.${app}`
247
+ const configured = app === DEFAULT_APP ? Object.keys(keys).length === 0 : Object.hasOwn(keys, app)
248
+ if (!configured) throw new Error(`plugboard option \`${at}\` must name a configured app (a key in \`keys\`; \`${DEFAULT_APP}\` only without keys)`)
249
+ apps[app] = parseCaps(value, at)
250
+ }
251
+ return {
252
+ ...(raw.maxConcurrent === undefined ? {} : { maxConcurrent: positiveInt(raw.maxConcurrent, "limits.maxConcurrent", 0, MAX_LIMIT) }),
253
+ ...(raw.perApp === undefined ? {} : { perApp: parseCaps(raw.perApp, "limits.perApp") }),
254
+ ...(appsRaw === undefined ? {} : { apps }),
255
+ }
256
+ }
257
+
188
258
  /** The policy configured for `app` (`default` for the keyless app), if any. Own-property lookup: an app name never reaches the prototype. */
189
259
  export function appPolicy(options: Pick<PlugboardOptions, "apps">, app: string): AppPolicy | undefined {
190
260
  return Object.hasOwn(options.apps, app) ? options.apps[app] : undefined
191
261
  }
192
262
 
263
+ function parseCache(value: unknown, keys: Readonly<Record<string, string>>): CacheConfig | undefined {
264
+ if (value === undefined) return undefined
265
+ if (!isRecord(value)) throw new Error("plugboard option `cache` must be an object with `ttlSeconds`")
266
+ for (const key of Object.keys(value))
267
+ if (!CACHE_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`cache.${key}\` (known: ${[...CACHE_KNOWN].join(", ")})`)
268
+ if (value.ttlSeconds === undefined) throw new Error("plugboard option `cache.ttlSeconds` is required (how long an answer is served, in seconds)")
269
+ const ttlSeconds = positiveInt(value.ttlSeconds, "cache.ttlSeconds", 0, MAX_CACHE_TTL_SECONDS)
270
+ const maxEntries = positiveInt(value.maxEntries, "cache.maxEntries", CACHE_DEFAULTS.maxEntries)
271
+ const maxBytes = positiveInt(value.maxBytes, "cache.maxBytes", CACHE_DEFAULTS.maxBytes)
272
+ if (value.apps === undefined) return { ttlSeconds, maxEntries, maxBytes }
273
+ if (!Array.isArray(value.apps) || value.apps.length === 0) throw new Error("plugboard option `cache.apps` must be a non-empty array of app names")
274
+ // A name that can never authenticate would silently leave the cache off for the app the user meant.
275
+ const known = Object.keys(keys).length === 0 ? [DEFAULT_APP] : Object.keys(keys)
276
+ for (const app of value.apps)
277
+ if (typeof app !== "string" || !known.includes(app)) throw new Error(`plugboard \`cache.apps\` entry \`${String(app)}\` is not an app (known: ${known.join(", ")})`)
278
+ return { ttlSeconds, maxEntries, maxBytes, apps: [...new Set(value.apps as string[])] }
279
+ }
280
+
193
281
  /** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
194
282
  export function parseOptions(raw: unknown): PlugboardOptions {
195
283
  if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
@@ -266,6 +354,8 @@ export function parseOptions(raw: unknown): PlugboardOptions {
266
354
  if (input.logFormat !== undefined && input.logFormat !== "text" && input.logFormat !== "json")
267
355
  throw new Error("plugboard option `logFormat` must be `text` or `json`")
268
356
  if (input.metrics !== undefined && typeof input.metrics !== "boolean") throw new Error("plugboard option `metrics` must be a boolean")
357
+ if (input.modelIds !== undefined && (typeof input.modelIds !== "string" || !MODEL_IDS.has(input.modelIds)))
358
+ throw new Error("plugboard option `modelIds` must be `canonical`, `slugs` or `both`")
269
359
 
270
360
  const historyInput = input.history
271
361
  if (historyInput !== undefined && !isRecord(historyInput)) throw new Error("plugboard option `history` must be an object")
@@ -274,6 +364,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
274
364
  const maxSessions = historyInput?.maxSessions === undefined ? undefined : positiveInt(historyInput.maxSessions, "history.maxSessions", 0)
275
365
  const maxAgeDays = historyInput?.maxAgeDays === undefined ? undefined : positiveInt(historyInput.maxAgeDays, "history.maxAgeDays", 0, MAX_AGE_DAYS)
276
366
 
367
+ const limits = parseLimits(input.limits, keys)
368
+ const cache = parseCache(input.cache, keys)
369
+
277
370
  return {
278
371
  listen: { host, port: port ?? DEFAULTS.port },
279
372
  keys,
@@ -289,6 +382,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
289
382
  dropUnsupportedParams: input.dropUnsupportedParams !== false,
290
383
  logFormat: input.logFormat === "json" ? "json" : "text",
291
384
  metrics: input.metrics === true,
385
+ modelIds: (input.modelIds as ModelIds | undefined) ?? "canonical",
386
+ ...(limits === undefined ? {} : { limits }),
387
+ ...(cache ? { cache } : {}),
292
388
  }
293
389
  }
294
390
 
package/src/contract.ts CHANGED
@@ -153,6 +153,8 @@ export interface PlugError {
153
153
  readonly message: string
154
154
  readonly code?: string
155
155
  readonly param?: string
156
+ /** Seconds to wait before a retry, for a `Retry-After` header. Set by the limiter (`src/limits.ts`); no route reads it yet. */
157
+ readonly retryAfterSeconds?: number
156
158
  }
157
159
 
158
160
  /** The one discriminator for `PlugError` values returned in place of a result. */
@@ -180,6 +182,10 @@ export interface CallContext {
180
182
  readonly onServed?: (served: ServedModel) => void
181
183
  /** Called once, before the first event, with each change the app's policy made that the caller sent for. Front-ends report them in the `x-plugboard-policy` header. */
182
184
  readonly onPolicy?: (notes: ReadonlyArray<PolicyNote>) => void
185
+ /** Called once, before the first event, when the response cache applies to the call: `hit` replays a stored answer (no session, no record), `miss` runs the model. Not called when the cache is off, skipped for the app, or `noCache` is set. */
186
+ readonly onCache?: (result: "hit" | "miss") => void
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
+ readonly noCache?: boolean
183
189
  }
184
190
 
185
191
  /** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
@@ -19,6 +19,7 @@ import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
19
19
  import { reportServed, type ServedInfo } from "../fallback.js"
20
20
  import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
21
21
  import { listAvailableModels, resolveModel } from "../models.js"
22
+ import { reportCache } from "../cache.js"
22
23
  import { reportPolicy } from "../policy.js"
23
24
  import type { RouteHost } from "../openai/route.js"
24
25
  import { dropRejected } from "../openai/sampling.js"
@@ -141,6 +142,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
141
142
  },
142
143
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
143
144
  onPolicy: reportPolicy(response),
145
+ onCache: reportCache(response),
144
146
  }),
145
147
  scope,
146
148
  )
package/src/limits.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Concurrency caps and requests per minute per app (option `limits`): a `ModelCaller` wrapper
3
+ * that sits outside the fallback chain, so one request holds one slot however many targets it tries.
4
+ *
5
+ * Before the inner caller runs, a call takes a slot in each concurrency cap that applies to it: the
6
+ * global `maxConcurrent`, and its app's own cap (`apps.<name>` over `perApp`). Each cap is a
7
+ * `p-queue` whose concurrency is the cap, kept on the process runtime so it survives a plugin
8
+ * generation. Nothing waits in a queue: a call that finds a cap full, or its app past its requests
9
+ * per minute, is answered at once with one `error` event (429) and never reaches the model. Every
10
+ * check runs before any slot or window count is taken, so a refused call costs nothing.
11
+ *
12
+ * A slot is freed when the inner stream ends, throws, is closed by the consumer, or the request
13
+ * signal aborts (the abort frees it even if the inner stream ignores the signal). p-queue counts a
14
+ * slot free one microtask after its job resolves, so a call made in that same tick can still be
15
+ * refused; requests arrive from the I/O loop, so this does not happen in practice. The requests per
16
+ * minute limit is a fixed window of 60 s per app, counting the calls admitted in it.
17
+ *
18
+ * A call whose app has no cap that applies goes straight to the wrapped caller.
19
+ */
20
+
21
+ import PQueue from "p-queue"
22
+ import type { LimitCaps, Limits } from "./config.js"
23
+ import type { CallContext, ModelCaller, PlugError, PlugEvent, PlugRequest } from "./contract.js"
24
+ import { log } from "./log.js"
25
+
26
+ /** The process-wide queues and windows. Held on the runtime (`src/runtime.ts`), not on a generation. */
27
+ export interface LimitState {
28
+ /** One queue per capped scope: `*` for the global cap, an app name for that app's cap. Its concurrency is the cap. */
29
+ readonly queues: Map<string, PQueue>
30
+ /** The requests-per-minute window of each app: its start (epoch ms) and the calls admitted in it. */
31
+ readonly windows: Map<string, { start: number; count: number }>
32
+ }
33
+
34
+ export const createLimitState = (): LimitState => ({ queues: new Map(), windows: new Map() })
35
+
36
+ export type LimitReason = "concurrency" | "rate"
37
+
38
+ export interface LimitOptions {
39
+ /** Clock in epoch ms. Tests inject one; the default is `Date.now`. */
40
+ readonly now?: () => number
41
+ /** Called once for each refused call (metrics). */
42
+ readonly onLimited?: (app: string, reason: LimitReason) => void
43
+ }
44
+
45
+ const WINDOW_MS = 60_000
46
+ /** Queue key of the global cap. Not an app name: names match `[a-z0-9-]+`. */
47
+ const GLOBAL = "*"
48
+
49
+ /** The caps that apply to `app`: `perApp`, with the app's `apps` entry overriding it field by field. */
50
+ export function capsFor(limits: Limits, app: string): LimitCaps {
51
+ const own = limits.apps && Object.hasOwn(limits.apps, app) ? limits.apps[app] : undefined
52
+ return { ...limits.perApp, ...own }
53
+ }
54
+
55
+ const isCapped = (limits: Limits, app: string): boolean => {
56
+ const caps = capsFor(limits, app)
57
+ return limits.maxConcurrent !== undefined || caps.maxConcurrent !== undefined || caps.requestsPerMinute !== undefined
58
+ }
59
+
60
+ /** The caller's events, admitted under the configured caps; see the file comment. `getLimits` is read per call. */
61
+ export function withLimits(inner: ModelCaller, getLimits: () => Limits | undefined, state: LimitState, options: LimitOptions = {}): ModelCaller {
62
+ const now = options.now ?? Date.now
63
+ return {
64
+ stream(request, context) {
65
+ const limits = getLimits()
66
+ if (!limits || !isCapped(limits, context.app)) return inner.stream(request, context)
67
+ return admit(inner, limits, state, now, options.onLimited, request, context)
68
+ },
69
+ }
70
+ }
71
+
72
+ async function* admit(
73
+ inner: ModelCaller,
74
+ limits: Limits,
75
+ state: LimitState,
76
+ now: () => number,
77
+ onLimited: LimitOptions["onLimited"],
78
+ request: PlugRequest,
79
+ context: CallContext,
80
+ ): AsyncGenerator<PlugEvent> {
81
+ const app = context.app
82
+ const caps = capsFor(limits, app)
83
+ const scopes: PQueue[] = []
84
+ if (limits.maxConcurrent !== undefined) scopes.push(queueFor(state, GLOBAL, limits.maxConcurrent))
85
+ if (caps.maxConcurrent !== undefined) scopes.push(queueFor(state, app, caps.maxConcurrent))
86
+
87
+ // Decide everything before taking anything: a refused call holds no slot and spends no window count.
88
+ const at = now()
89
+ const current = state.windows.get(app)
90
+ const live = current && at - current.start < WINDOW_MS ? current : undefined
91
+ let refusal: { readonly reason: LimitReason; readonly retryAfterSeconds: number } | undefined
92
+ if (scopes.some((queue) => queue.pending + queue.size >= queue.concurrency)) refusal = { reason: "concurrency", retryAfterSeconds: 1 }
93
+ else if (caps.requestsPerMinute !== undefined && live && live.count >= caps.requestsPerMinute)
94
+ refusal = { reason: "rate", retryAfterSeconds: Math.max(1, Math.ceil((live.start + WINDOW_MS - at) / 1000)) }
95
+ if (refusal) {
96
+ onLimited?.(app, refusal.reason)
97
+ log("request.limited", { app, reason: refusal.reason, retryAfterSeconds: refusal.retryAfterSeconds })
98
+ yield { type: "error", error: limitedError(refusal.reason, refusal.retryAfterSeconds) }
99
+ return
100
+ }
101
+
102
+ if (caps.requestsPerMinute !== undefined) {
103
+ if (live) live.count++
104
+ else state.windows.set(app, { start: at, count: 1 })
105
+ }
106
+ const frees = scopes.map(take)
107
+ let freed = false
108
+ const free = () => {
109
+ if (freed) return
110
+ freed = true
111
+ for (const release of frees) release()
112
+ }
113
+
114
+ const signal = context.signal
115
+ signal.addEventListener("abort", free, { once: true })
116
+ try {
117
+ for await (const event of inner.stream(request, context)) yield event
118
+ } finally {
119
+ signal.removeEventListener("abort", free)
120
+ free()
121
+ }
122
+ }
123
+
124
+ /** The queue of one capped scope, created on first use, with its concurrency set to the cap in force. */
125
+ function queueFor(state: LimitState, key: string, cap: number): PQueue {
126
+ let queue = state.queues.get(key)
127
+ if (!queue) state.queues.set(key, (queue = new PQueue({ concurrency: cap })))
128
+ queue.concurrency = cap
129
+ return queue
130
+ }
131
+
132
+ /** Takes a slot in a queue that has room (checked by the caller). The returned function frees it. */
133
+ function take(queue: PQueue): () => void {
134
+ let done = () => {}
135
+ // The job starts at once when a slot is free, so `done` is set before `add` returns.
136
+ void queue.add(() => new Promise<void>((resolve) => (done = resolve)))
137
+ return () => done()
138
+ }
139
+
140
+ /** A 429 with a neutral kind (`rate_limit`, the closest `PlugErrorKind`) and a code the front-ends pass through. */
141
+ const limitedError = (reason: LimitReason, retryAfterSeconds: number): PlugError =>
142
+ reason === "concurrency"
143
+ ? { status: 429, kind: "rate_limit", code: "too_many_requests", message: "Too many concurrent requests", retryAfterSeconds }
144
+ : { status: 429, kind: "rate_limit", code: "rate_limit_exceeded", message: "Too many requests per minute", retryAfterSeconds }
package/src/metrics.ts CHANGED
@@ -62,6 +62,10 @@ export interface Metrics {
62
62
  dropped(params: ReadonlyArray<string>): void
63
63
  /** A model call failed before any output and was retried on the next target of its alias. */
64
64
  fallback(app: string): void
65
+ /** A call was refused with 429 by a concurrency cap (`concurrency`) or a requests-per-minute limit (`rate`). */
66
+ limited(app: string, reason: "concurrency" | "rate"): void
67
+ /** A call the response cache applied to: a stored answer replayed (`hit`) or the model run (`miss`). */
68
+ cache(app: string, result: "hit" | "miss"): void
65
69
  /** One history sweep ended. */
66
70
  sweep(result: "ok" | "failed" | "timeout", removed: number): void
67
71
  /** The exposition text. Called from a request that is itself in flight, which is not counted. */
@@ -104,6 +108,18 @@ export function createMetrics(): Metrics {
104
108
  labelNames: ["app"],
105
109
  registers: [registry],
106
110
  })
111
+ const limited = new Counter({
112
+ name: "plugboard_limited_total",
113
+ help: "Model calls refused with 429 by a concurrency cap or a requests-per-minute limit, by app and reason.",
114
+ labelNames: ["app", "reason"],
115
+ registers: [registry],
116
+ })
117
+ const cache = new Counter({
118
+ name: "plugboard_cache_requests_total",
119
+ help: "Model calls the response cache applied to, by app and result (hit or miss). Absent unless the `cache` option is set.",
120
+ labelNames: ["app", "result"],
121
+ registers: [registry],
122
+ })
107
123
  const sweeps = new Counter({ name: "plugboard_history_sweeps_total", help: "History sweeps that ended, by result.", labelNames: ["result"], registers: [registry] })
108
124
  const removed = new Counter({ name: "plugboard_history_sessions_removed_total", help: "Request sessions removed by history sweeps.", registers: [registry] })
109
125
  const generations = new Gauge({ name: "plugboard_generations", help: "Accepted plugin generations (one per OpenCode Location) serving requests.", registers: [registry] })
@@ -142,6 +158,8 @@ export function createMetrics(): Metrics {
142
158
  for (const param of params) count(dropped, { param })
143
159
  },
144
160
  fallback: (app) => count(fallbacks, { app }),
161
+ limited: (app, reason) => count(limited, { app, reason }),
162
+ cache: (app, result) => count(cache, { app, result }),
145
163
  sweep(result, amount) {
146
164
  count(sweeps, { result })
147
165
  if (amount > 0 && Number.isFinite(amount)) removed.inc(amount)
package/src/models.ts CHANGED
@@ -20,6 +20,33 @@ export function refToString(ref: ModelRef): string {
20
20
  return `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
21
21
  }
22
22
 
23
+ /**
24
+ * A UI-safe spelling of a canonical id: `/` becomes `--` and `#` becomes `__`, so some chat UIs that mangle
25
+ * `/` and `#` keep it intact. `anthropic/claude-haiku-4-5#high` -> `anthropic--claude-haiku-4-5__high`.
26
+ */
27
+ export function toSlug(id: string): string {
28
+ return id.replaceAll("/", "--").replaceAll("#", "__")
29
+ }
30
+
31
+ /**
32
+ * The canonical id a slug stands for, or undefined when `slug` is not one. The first `--` ends the provider, the
33
+ * first `__` starts the variant; a `--` inside the model id is read back as `/`. Ids where that does not round
34
+ * trip are not listed as slugs (see `listAvailableModels`), so they are never offered and never resolved this way.
35
+ */
36
+ export function fromSlug(slug: string): string | undefined {
37
+ if (typeof slug !== "string" || /[\s/#*]/.test(slug)) return undefined
38
+ const sep = slug.indexOf("--")
39
+ if (sep <= 0) return undefined
40
+ const providerID = slug.slice(0, sep)
41
+ const rest = slug.slice(sep + 2)
42
+ const at = rest.indexOf("__")
43
+ const model = (at === -1 ? rest : rest.slice(0, at)).replaceAll("--", "/")
44
+ const variant = at === -1 ? undefined : rest.slice(at + 2)
45
+ if (variant !== undefined && (!variant || variant.includes("__"))) return undefined
46
+ const id = refToString({ providerID, id: model, ...(variant === undefined ? {} : { variant }) })
47
+ return parseModelRef(id) ? id : undefined
48
+ }
49
+
23
50
  /** `provider/*` any model of the provider; `provider/model` any variant; `provider/model#variant` exactly. */
24
51
  export function allowedBy(models: ReadonlyArray<string>, ref: ModelRef): boolean {
25
52
  return models.some((pattern) => {
@@ -80,7 +107,8 @@ export function resolveModel(
80
107
  code: "model_not_found",
81
108
  param: "model",
82
109
  }
83
- let ref = parseModelRef(own(options.aliases, model) ?? model)
110
+ // Aliases keep priority over slugs; a slug and a canonical id both resolve, whatever `modelIds` lists.
111
+ let ref = parseModelRef(own(options.aliases, model) ?? fromSlug(model) ?? model)
84
112
  if (!ref) return notAvailable
85
113
  if (!ref.variant && reasoningEffort && find(catalog, { ...ref, variant: reasoningEffort }))
86
114
  ref = { ...ref, variant: reasoningEffort }
@@ -96,7 +124,9 @@ export interface ListedModel {
96
124
 
97
125
  /**
98
126
  * The allowlist intersected with the catalog, plus aliases whose target qualifies. Ids are unique except an alias may repeat one.
99
- * With `app`, the list is narrowed to that app's `models` (see `inAppScope`).
127
+ * Each model is listed as its canonical id, its slug, or both, per `options.modelIds`; a model whose slug does not
128
+ * round trip (see `fromSlug`) is left out in `slugs` mode. Aliases are listed as they are.
129
+ * With `app`, the list is narrowed to that app's `models` (see `inAppScope`), judged on the canonical id.
100
130
  */
101
131
  export function listAvailableModels(options: PlugboardOptions, catalog: ReadonlyArray<CatalogModel>, app?: string): ListedModel[] {
102
132
  const policy = app === undefined ? undefined : appPolicy(options, app)
@@ -110,7 +140,9 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
110
140
  const id = refToString(ref)
111
141
  if (seen.has(id) || !allowed(options, ref) || !inAppScope(policy, id, ref)) continue
112
142
  seen.add(id)
113
- data.push(entry(id, ref, model))
143
+ if (options.modelIds !== "slugs") data.push(entry(id, ref, model))
144
+ const slug = toSlug(id)
145
+ if (options.modelIds !== "canonical" && fromSlug(slug) === id) data.push(entry(slug, ref, model))
114
146
  }
115
147
  }
116
148
  for (const [alias, target] of Object.entries(options.aliases)) {
@@ -121,6 +153,22 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
121
153
  return data
122
154
  }
123
155
 
156
+ /** Capabilities `GET /v1/models?capability=` can filter on: the flags the catalog reports (`CatalogModel.capabilities`). */
157
+ export type Capability = "vision" | "tools"
158
+
159
+ const CAPABILITY_FLAG: Readonly<Record<Capability, "image" | "tools">> = { vision: "image", tools: "tools" }
160
+
161
+ /** The capability a query value names, or undefined for anything else (`json` has no catalog flag, so it is not one). */
162
+ export function parseCapability(value: string): Capability | undefined {
163
+ return Object.hasOwn(CAPABILITY_FLAG, value) ? (value as Capability) : undefined
164
+ }
165
+
166
+ /** The listed models whose catalog entry sets that capability flag to true. Order is kept. */
167
+ export function filterByCapability(models: ReadonlyArray<ListedModel>, capability: Capability): ListedModel[] {
168
+ const flag = CAPABILITY_FLAG[capability]
169
+ return models.filter(({ model }) => model.capabilities?.[flag] === true)
170
+ }
171
+
124
172
  /**
125
173
  * The targets an alias falls back to, in order: the fallbacks that are allowed and enabled on the server.
126
174
  * Empty for a plain model id or an alias without fallbacks. The primary target is `resolveModel`'s answer.
@@ -23,6 +23,7 @@
23
23
  import type { ServerResponse } from "node:http"
24
24
  import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.js"
25
25
  import { reportServed, type ServedInfo } from "../fallback.js"
26
+ import { reportCache } from "../cache.js"
26
27
  import { reportPolicy } from "../policy.js"
27
28
  import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
28
29
  import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
@@ -168,6 +169,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
168
169
  if (calls.length === 1) codec.setModel(s.model)
169
170
  })
170
171
  const policy = reportPolicy(response)
172
+ // Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
173
+ const cache = reportCache(response)
171
174
  const open = (index: number) =>
172
175
  boundEvents(
173
176
  host.caller.stream(calls[index]!, {
@@ -180,6 +183,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
180
183
  },
181
184
  onServed: served,
182
185
  onPolicy: policy,
186
+ onCache: cache,
187
+ noCache: calls.length > 1,
183
188
  }),
184
189
  gscope,
185
190
  )
@@ -42,6 +42,7 @@ import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/ro
42
42
  import { errorMessage, log, logError, withRequestId } from "../log.js"
43
43
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
44
44
  import { listAvailableModels, resolveModel } from "../models.js"
45
+ import { reportCache } from "../cache.js"
45
46
  import { reportPolicy } from "../policy.js"
46
47
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
47
48
  import { serveCompletions } from "../completions/route.js"
@@ -251,6 +252,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
251
252
  // An alias with fallbacks: report which target answered, and echo it as the response model.
252
253
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
253
254
  onPolicy: reportPolicy(response),
255
+ onCache: reportCache(response),
254
256
  }),
255
257
  scope,
256
258
  )
@@ -13,6 +13,7 @@ import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
13
  import { reportServed, type ServedInfo } from "../fallback.js"
14
14
  import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
15
15
  import { resolveModel } from "../models.js"
16
+ import { reportCache } from "../cache.js"
16
17
  import { reportPolicy } from "../policy.js"
17
18
  import { encodeError } from "../openai/response.js"
18
19
  import type { RouteHost } from "../openai/route.js"
@@ -54,6 +55,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
54
55
  },
55
56
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
56
57
  onPolicy: reportPolicy(response),
58
+ onCache: reportCache(response),
57
59
  }),
58
60
  scope,
59
61
  )
package/src/runtime.ts CHANGED
@@ -24,7 +24,9 @@
24
24
  * limits (src/session/prune.ts). Both start from request handling (the store's `register`);
25
25
  * - the metrics registry (src/metrics.ts) and the runtime start time, so counters and uptime
26
26
  * outlive any one generation. `processMetrics` / `processStatus` read them for `/metrics`,
27
- * `/v1/status` and the discovery pages.
27
+ * `/v1/status` and the discovery pages;
28
+ * - the request limiter's queues and rate windows (src/limits.ts), so a cap holds across
29
+ * generations. `processLimits` reads them.
28
30
  *
29
31
  * Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
30
32
  * rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
@@ -48,8 +50,11 @@ import { setTimeout as delay } from "node:timers/promises"
48
50
  import { Cause, Effect, Stream } from "effect"
49
51
  import stableStringify from "safe-stable-stringify"
50
52
  import { raceAbort } from "./abort.js"
51
- import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
53
+ import { cachedCaller, createCacheStore, type CacheStore } from "./cache.js"
54
+ import type { PlugboardOptions } from "./config.js"
55
+ import { isPlugError, type CatalogModel, type ModelCaller, type PlugError } from "./contract.js"
52
56
  import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
57
+ import { createLimitState, type LimitState } from "./limits.js"
53
58
  import { createMetrics, type Metrics, type RuntimeSnapshot } from "./metrics.js"
54
59
  import { ensureAppDir } from "./session/history.js"
55
60
  import type { HistoryPort } from "./session/host.js"
@@ -107,8 +112,8 @@ const BACKEND_POLL_MS = 25
107
112
  const BIND_RETRY_MS = 15_000
108
113
  const BIND_REMINDER_MS = 5 * 60_000
109
114
 
110
- /** 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. */
111
- const VERSION = 8
115
+ /** 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. */
116
+ const VERSION = 10
112
117
  const KEY = Symbol.for("opencode-plugboard.runtime")
113
118
 
114
119
  interface Listener {
@@ -158,8 +163,12 @@ interface Runtime {
158
163
  readonly sweep: { nextAt: number; running: boolean }
159
164
  /** Process-wide counters, shared by every generation (see src/metrics.ts). */
160
165
  readonly metrics: Metrics
166
+ /** Concurrency queues and requests-per-minute windows (see src/limits.ts). */
167
+ readonly limits: LimitState
161
168
  /** When this runtime was created, epoch ms. */
162
169
  readonly startedAt: number
170
+ /** The response cache store (src/cache.ts) and the fingerprint of the config and `apps` policy it was built for. */
171
+ cache?: { readonly fingerprint: string; readonly store: CacheStore }
163
172
  listener?: Listener
164
173
  /** Present while a fiber is subscribed to the server event bus. */
165
174
  pump?: Pump
@@ -171,6 +180,7 @@ const REJECTED: Registration = { accepted: false, dispose() {}, confirmed: () =>
171
180
  const detachedSessions = createSessionStore()
172
181
  const detachedMetrics = createMetrics()
173
182
  const detachedStartedAt = Date.now()
183
+ const detachedLimits = createLimitState()
174
184
  let foreignVersionLogged = false
175
185
 
176
186
  /** The process runtime, created on first use; undefined when a different version already owns the process. */
@@ -186,6 +196,7 @@ function runtime(): Runtime | undefined {
186
196
  rejected: new Map(),
187
197
  sweep: { nextAt: 0, running: false },
188
198
  metrics: createMetrics(),
199
+ limits: createLimitState(),
189
200
  startedAt: Date.now(),
190
201
  }
191
202
  slot[KEY] = created
@@ -261,6 +272,42 @@ export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedS
261
272
  /** The metrics registry shared by every generation in the process (a detached one under a foreign-version runtime). */
262
273
  export const processMetrics = (): Metrics => runtime()?.metrics ?? detachedMetrics
263
274
 
275
+ /** The request limiter's state shared by every generation in the process (detached under a foreign-version runtime). */
276
+ export const processLimits = (): LimitState => runtime()?.limits ?? detachedLimits
277
+
278
+ /**
279
+ * Wraps `caller` with the response cache when option `cache` is set (a no-op without it). The store
280
+ * lives on the runtime, so it survives plugin generations. It is replaced when the cache option or
281
+ * any policy input (`apps`, `models`, `aliases`, `fallbacks`) differs from the one it was built for:
282
+ * the key does not cover policy, and a hit skips the session caller's scope checks. The store is
283
+ * resolved on the first call, not when the wrapper is built: a rejected generation is built too but
284
+ * never serves, and must not replace the live store. Under a foreign-version runtime the store is
285
+ * private to the wrapper.
286
+ */
287
+ export function withResponseCache(caller: ModelCaller, options: PlugboardOptions): ModelCaller {
288
+ const config = options.cache
289
+ if (!config) return caller
290
+ let wrapped: ModelCaller | undefined
291
+ const build = (): ModelCaller => {
292
+ const rt = runtime()
293
+ const fingerprint = canonicalJson({
294
+ cache: config,
295
+ apps: options.apps,
296
+ models: options.models,
297
+ aliases: options.aliases,
298
+ fallbacks: options.fallbacks,
299
+ })
300
+ let held = rt?.cache
301
+ if (!held || held.fingerprint !== fingerprint) {
302
+ held = { fingerprint, store: createCacheStore({ ttlMs: config.ttlSeconds * 1000, maxEntries: config.maxEntries, maxBytes: config.maxBytes }) }
303
+ if (rt) rt.cache = held
304
+ }
305
+ const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
306
+ return cachedCaller(caller, config, held.store, count)
307
+ }
308
+ return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
309
+ }
310
+
264
311
  /** What `/v1/status` and the discovery pages report about the process, besides the model catalog. */
265
312
  export interface RuntimeStatus extends RuntimeSnapshot {
266
313
  readonly runtimeVersion: number