opencode-plugboard 0.14.0 → 0.16.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.16.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.16.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
  },
@@ -35,10 +37,14 @@
35
37
  "@ai-sdk/anthropic": "4.0.78",
36
38
  "@ai-sdk/google": "4.0.93",
37
39
  "@ai-sdk/openai": "4.0.91",
40
+ "@ai-sdk/openai-compatible": "3.0.67",
38
41
  "@anthropic-ai/sdk": "0.133.0",
39
42
  "@commitlint/cli": "21.2.3",
40
43
  "@commitlint/config-conventional": "21.2.3",
41
44
  "@google/genai": "2.28.0",
45
+ "@langchain/anthropic": "1.5.12",
46
+ "@langchain/google-genai": "2.3.2",
47
+ "@langchain/openai": "1.6.2",
42
48
  "@opencode/plugin": "2.0.26",
43
49
  "@types/bun": "1.4.2",
44
50
  "@types/escape-html": "^1.0.4",
@@ -21,12 +21,14 @@ import {
21
21
  checkJsonContentType,
22
22
  checkOrigin,
23
23
  readJsonBody,
24
+ sendError,
24
25
  sendJson,
25
26
  sseWriter,
26
27
  type RequestScope,
27
28
  } from "../http.js"
28
29
  import { reportServed, type ServedInfo } from "../fallback.js"
29
30
  import { resolveModel } from "../models.js"
31
+ import { reportCache } from "../cache.js"
30
32
  import { reportPolicy } from "../policy.js"
31
33
  import { dropRejected } from "../openai/sampling.js"
32
34
  import type { RouteHost } from "../openai/route.js"
@@ -64,7 +66,7 @@ const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
64
66
 
65
67
  export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
66
68
  const { path, request, response, scope, info } = call
67
- const fail = (error: PlugError, close = false) => sendJson(response, error.status, encodeError(error), { close })
69
+ const fail = (error: PlugError, close = false) => sendError(response, error, encodeError(error), { close })
68
70
  if (request.method === "POST") info.route = `POST ${path}`
69
71
  response.setHeader("request-id", `req_${crypto.randomUUID().replaceAll("-", "")}`)
70
72
 
@@ -83,13 +85,14 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
83
85
  }
84
86
 
85
87
  async function serve(): Promise<void> {
86
- if (request.method !== "POST")
87
- return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
88
88
  const rejected = checkOrigin(request, host.options)
89
89
  if (rejected) return fail(rejected)
90
+ // Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
90
91
  const auth = authenticateRequest(host, request)
91
92
  if (isPlugError(auth)) return fail(auth)
92
93
  info.app = auth.app
94
+ if (request.method !== "POST")
95
+ return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
93
96
  if (path === COUNT_TOKENS)
94
97
  return fail({ status: 404, kind: "not_found", code: "not_found", message: "count_tokens is not supported by plugboard" })
95
98
  const unsupported = checkJsonContentType(request)
@@ -125,6 +128,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
125
128
  },
126
129
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
127
130
  onPolicy: reportPolicy(response),
131
+ onCache: reportCache(response),
128
132
  }),
129
133
  scope,
130
134
  )
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
+ }
@@ -44,7 +44,7 @@ const encodeUsage = (usage: PlugUsage) => {
44
44
  const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
45
45
 
46
46
  /** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
47
- async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object }> {
47
+ async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
48
48
  let text = ""
49
49
  let finish: Exclude<PlugFinish, "error"> | undefined
50
50
  let usage: PlugUsage | undefined
@@ -63,7 +63,7 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
63
63
  if (!failure && !finish) failure = noFinish()
64
64
  if (failure || !finish) {
65
65
  const error = failure ?? noFinish()
66
- return { status: error.status, body: encodeError(error) }
66
+ return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
67
67
  }
68
68
  return {
69
69
  status: 200,
@@ -12,7 +12,7 @@
12
12
  import type { ServerResponse } from "node:http"
13
13
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
14
14
  import type { ServedInfo } from "../fallback.js"
15
- import { sendJson, type RequestScope } from "../http.js"
15
+ import { sendError, type RequestScope } from "../http.js"
16
16
  import { resolveModel } from "../models.js"
17
17
  import { serveChoices } from "../openai/fanout.js"
18
18
  import { encodeError } from "../openai/response.js"
@@ -33,7 +33,7 @@ export interface CompletionsCall {
33
33
  }
34
34
 
35
35
  export async function serveCompletions(host: RouteHost, { app, body, response, scope, info }: CompletionsCall): Promise<void> {
36
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
36
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
37
37
 
38
38
  const parsed = parseCompletionsRequest(body, host.options)
39
39
  if (isPlugError(parsed)) return fail(parsed)
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, sent as the `Retry-After` header (`sendError` in `src/http.ts`). Set by the limiter (`src/limits.ts`). */
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. */
package/src/discovery.ts CHANGED
@@ -58,18 +58,30 @@ export interface Endpoint {
58
58
  readonly description: string
59
59
  }
60
60
 
61
- function endpoints(options: PlugboardOptions): Endpoint[] {
62
- return [
63
- { method: "GET", path: "/", auth: false, description: "This page." },
64
- { method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
65
- { method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
66
- { method: "GET", path: "/v1/models", auth: true, description: "Models this app may call." },
67
- { method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
68
- { method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
69
- { method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
70
- { method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
71
- ...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
72
- ]
61
+ /**
62
+ * Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
63
+ * `src/openai/route.ts` and the front-end routes; `test/http/discovery.test.ts` probes each served
64
+ * route and fails when this list omits one (#136). `/metrics` is added only when option `metrics` is on.
65
+ */
66
+ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
67
+ { method: "GET", path: "/", auth: false, description: "This page." },
68
+ { method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
69
+ { method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
70
+ { method: "GET", path: "/v1/models", auth: true, description: "Models this app may call (OpenAI shape)." },
71
+ { method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
72
+ { method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
73
+ { method: "POST", path: "/v1/completions", auth: true, description: "Legacy OpenAI completions." },
74
+ { method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
75
+ { method: "POST", path: "/v1/messages", auth: true, description: "Anthropic Messages (x-api-key or Bearer)." },
76
+ { method: "GET", path: "/v1beta/models", auth: true, description: "Gemini model list." },
77
+ { method: "GET", path: "/v1beta/models/{model}", auth: true, description: "One Gemini model." },
78
+ { method: "POST", path: "/v1beta/models/{model}:generateContent", auth: true, description: "Gemini generateContent." },
79
+ { method: "POST", path: "/v1beta/models/{model}:streamGenerateContent", auth: true, description: "Gemini streamGenerateContent, with ?alt=sse." },
80
+ { method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
81
+ ]
82
+
83
+ function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
84
+ return options.metrics ? [...ENDPOINTS, { method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : ENDPOINTS
73
85
  }
74
86
 
75
87
  type ModelsState = "ready" | "no_models_configured" | "none_available" | "catalog_unavailable"
@@ -17,8 +17,9 @@
17
17
  import type { IncomingMessage, ServerResponse } from "node:http"
18
18
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
19
19
  import { reportServed, type ServedInfo } from "../fallback.js"
20
- import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
20
+ import { boundEvents, checkJsonContentType, readJsonBody, sendError, 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"
@@ -63,10 +64,10 @@ export function matchGeminiRoute(method: string | undefined, path: string): Gemi
63
64
  const verb = colon > 0 ? rest.slice(colon + 1) : ""
64
65
  const model = decode(rest.slice(0, colon))
65
66
  if (!/^[A-Za-z]+$/.test(verb) || model === undefined) return undefined
66
- const label = `POST ${prefix.slice(0, -1)}/{model}:${verb}`
67
67
  if (verb === "generateContent" || verb === "streamGenerateContent")
68
- return { op: "generate", route: label, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
69
- return { op: "unsupported", route: label, verb }
68
+ return { op: "generate", route: `POST ${prefix.slice(0, -1)}/{model}:${verb}`, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
69
+ // The client's verb never becomes a metric or log label: every other verb is `unknown` (#135).
70
+ return { op: "unsupported", route: "unknown", verb }
70
71
  }
71
72
  return undefined
72
73
  }
@@ -94,7 +95,7 @@ export interface GeminiCall {
94
95
  }
95
96
 
96
97
  export async function serveGemini(host: RouteHost, { match, request, url, app, response, scope, info }: GeminiCall): Promise<void> {
97
- const fail = (error: PlugError) => sendJson(response, error.status, encodeGeminiError(error))
98
+ const fail = (error: PlugError) => sendError(response, error, encodeGeminiError(error))
98
99
 
99
100
  if (match.op === "unsupported")
100
101
  return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent and streamGenerateContent` })
@@ -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/http.ts CHANGED
@@ -165,18 +165,29 @@ export function finishRequest(request: IncomingMessage, response: ServerResponse
165
165
  response.once("close", () => clearTimeout(timer))
166
166
  }
167
167
 
168
- /** `close`: also tell the client to drop the connection (after an unread or abandoned body). */
169
- export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean }) => {
168
+ /**
169
+ * `close`: also tell the client to drop the connection (after an unread or abandoned body).
170
+ * `retryAfterSeconds`: sent as `Retry-After` (a rate-limit refusal, see `PlugError.retryAfterSeconds`).
171
+ */
172
+ export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean; retryAfterSeconds?: number }) => {
170
173
  if (response.headersSent || response.destroyed) return
171
174
  const body = JSON.stringify(payload)
172
175
  response.writeHead(status, {
173
176
  "content-type": "application/json",
174
177
  "content-length": Buffer.byteLength(body),
175
178
  ...(options?.close ? { connection: "close" } : {}),
179
+ ...(options?.retryAfterSeconds !== undefined ? { "retry-after": String(options.retryAfterSeconds) } : {}),
176
180
  })
177
181
  response.end(body)
178
182
  }
179
183
 
184
+ /**
185
+ * A `PlugError` answered in a front-end's own wire body, before any output. Every route's `fail` goes
186
+ * through here, so a refused call (`PlugError.retryAfterSeconds`) always carries `Retry-After`.
187
+ */
188
+ export const sendError = (response: ServerResponse, error: PlugError, body: unknown, options?: { close?: boolean }) =>
189
+ sendJson(response, error.status, body, { ...options, retryAfterSeconds: error.retryAfterSeconds })
190
+
180
191
  /**
181
192
  * SSE response writer. Headers go out with the first chunk, so nothing is committed until there
182
193
  * is something to send; chunks after the client is gone are dropped. `write` is synchronous (the
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,8 +23,9 @@
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
- import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
28
+ import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
28
29
  import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
29
30
  import type { RouteHost } from "./route.js"
30
31
 
@@ -37,7 +38,7 @@ export interface ChoiceCodec {
37
38
  /** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
38
39
  readonly idPrefix: string
39
40
  /** One choice as a complete JSON body with `choices[0]` and `usage`. */
40
- collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object }>
41
+ collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }>
41
42
  /** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
42
43
  streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
43
44
  /** The final chunk with the summed usage (`include_usage`). */
@@ -154,7 +155,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
154
155
  }
155
156
 
156
157
  export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
157
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
158
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
158
159
  // Abandoned requests (client gone, deadline passed) must never reach the model caller.
159
160
  if (scope.signal.aborted) throw scope.signal.reason
160
161
 
@@ -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,13 +183,15 @@ 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
  )
186
191
 
187
192
  if (!codec.stream) {
188
- const results = await new Promise<Array<{ status: number; body: object }>>((resolve, reject) => {
189
- const done: Array<{ status: number; body: object }> = []
193
+ const results = await new Promise<Array<{ status: number; body: object; retryAfterSeconds?: number }>>((resolve, reject) => {
194
+ const done: Array<{ status: number; body: object; retryAfterSeconds?: number }> = []
190
195
  let left = calls.length
191
196
  calls.forEach((_, index) => {
192
197
  codec.collect(open(index), index).then(
@@ -206,7 +211,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
206
211
  })
207
212
  })
208
213
  const first = results[0]!
209
- return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)))
214
+ return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
210
215
  }
211
216
 
212
217
  const sse = sseWriter(response, scope.signal)
@@ -195,7 +195,7 @@ const now = () => Math.floor(Date.now() / 1000)
195
195
  export async function collectCompletion(
196
196
  events: AsyncIterable<PlugEvent>,
197
197
  exchange: OpenAiExchange,
198
- ): Promise<{ status: number; body: object }> {
198
+ ): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
199
199
  let text = ""
200
200
  const calls: ToolCall[] = []
201
201
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -217,7 +217,7 @@ export async function collectCompletion(
217
217
  if (!failure && !finish) failure = noFinish()
218
218
  if (failure || !finish) {
219
219
  const error = failure ?? noFinish()
220
- return { status: error.status, body: encodeError(error) }
220
+ return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
221
221
  }
222
222
 
223
223
  const answer = calls.find((call) => structured.isAnswer(exchange, call))
@@ -32,6 +32,7 @@ import {
32
32
  checkOrigin,
33
33
  finishRequest,
34
34
  readJsonBody,
35
+ sendError,
35
36
  sendJson,
36
37
  sseWriter,
37
38
  type RequestScope,
@@ -41,7 +42,8 @@ import { encodeGeminiError } from "../gemini/response.js"
41
42
  import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/route.js"
42
43
  import { errorMessage, log, logError, withRequestId } from "../log.js"
43
44
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
44
- import { listAvailableModels, resolveModel } from "../models.js"
45
+ import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
46
+ import { reportCache } from "../cache.js"
45
47
  import { reportPolicy } from "../policy.js"
46
48
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
47
49
  import { serveCompletions } from "../completions/route.js"
@@ -118,7 +120,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
118
120
  /** Responses to Gemini routes: errors there use Google's body. */
119
121
  const googleErrors = new WeakSet<ServerResponse>()
120
122
  const fail = (response: ServerResponse, error: PlugError, close = false) =>
121
- sendJson(response, error.status, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
123
+ sendError(response, error, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
122
124
 
123
125
  /** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
124
126
  async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
@@ -197,8 +199,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
197
199
  }
198
200
 
199
201
  if (route === "GET /v1/models" || modelId !== undefined) {
202
+ // `?capability=vision|tools` filters the list (#61). Any other value is refused before the catalog loads.
203
+ const requested = modelId === undefined ? url.searchParams.get("capability") : null
204
+ const capability = requested === null ? undefined : parseCapability(requested)
205
+ if (requested !== null && capability === undefined)
206
+ return fail(response, { status: 400, kind: "invalid_request", code: "invalid_capability", param: "capability", message: "capability must be vision or tools" })
200
207
  const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), auth.app)
201
- if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
208
+ if (modelId === undefined) return sendJson(response, 200, encodeModelList(capability ? filterByCapability(listed, capability) : listed))
202
209
  const item = listed.find((m) => m.id === modelId)
203
210
  if (!item) {
204
211
  return fail(response, {
@@ -251,6 +258,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
251
258
  // An alias with fallbacks: report which target answered, and echo it as the response model.
252
259
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
253
260
  onPolicy: reportPolicy(response),
261
+ onCache: reportCache(response),
254
262
  }),
255
263
  scope,
256
264
  )
@@ -275,7 +283,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
275
283
  }
276
284
 
277
285
  const result = await collectCompletion(events, exchange)
278
- sendJson(response, result.status, result.body)
286
+ sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
279
287
  }
280
288
 
281
289
  /** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
@@ -11,8 +11,9 @@
11
11
  import type { ServerResponse } from "node:http"
12
12
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
13
  import { reportServed, type ServedInfo } from "../fallback.js"
14
- import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
14
+ import { boundEvents, sendError, 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"
@@ -31,7 +32,7 @@ export interface ResponsesCall {
31
32
  }
32
33
 
33
34
  export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
34
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
35
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
35
36
 
36
37
  const parsed = parseResponsesRequest(body, host.options)
37
38
  if (isPlugError(parsed)) return fail(parsed)
@@ -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