opencode-plugboard 0.13.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.13.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.13.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.",
@@ -25,8 +25,12 @@
25
25
  "release": "bun .github/scripts/release.ts"
26
26
  },
27
27
  "dependencies": {
28
+ "@prometheus-io/client": "0.16.1",
28
29
  "escape-html": "1.0.3",
29
30
  "ipaddr.js": "2.5.0",
31
+ "lru-cache": "11.5.3",
32
+ "p-queue": "9.3.5",
33
+ "pino": "10.4.0",
30
34
  "safe-stable-stringify": "2.5.0"
31
35
  },
32
36
  "devDependencies": {
@@ -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 }