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/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
- /** 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 = 7
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