opencode-plugboard 0.4.0 → 0.5.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.
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * OpenAI front-end route table: `GET /healthz`, `GET /v1/models`, `GET /v1/models/{id}`,
3
3
  * `POST /v1/chat/completions`, and the dispatch of `POST /v1/responses` to `src/responses/route.ts`.
4
+ * Observability and first-run routes (`src/discovery.ts`, `src/metrics.ts`): `GET /` and `GET /v1`
5
+ * (keyless discovery), the authenticated `GET /v1/status`, and `GET /metrics` (option `metrics`).
4
6
  *
5
7
  * The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
6
8
  * the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
@@ -10,11 +12,16 @@
10
12
  * Response header `x-plugboard-session: <id>` carries the id of the history record the call
11
13
  * writes (the `ModelCaller` reports it through `CallContext.onRecord`). It is set before the
12
14
  * response headers go out (JSON body or first SSE chunk); an id reported after that is ignored.
15
+ *
16
+ * Response header `x-plugboard-request-id: <id>` is set on every response, before anything else
17
+ * can fail. The same id is the `id` of the request's log line, and of every other log line emitted
18
+ * while the request is being handled (`withRequestId` in `src/log.ts`).
13
19
  */
14
20
 
15
21
  import type { IncomingMessage, ServerResponse } from "node:http"
16
- import { authenticate, type PlugboardOptions } from "../config.js"
22
+ import { DEFAULT_APP, authenticate, type PlugboardOptions } from "../config.js"
17
23
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
24
+ import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
18
25
  import {
19
26
  BodyError,
20
27
  RequestAborted,
@@ -28,9 +35,11 @@ import {
28
35
  sseWriter,
29
36
  type RequestScope,
30
37
  } from "../http.js"
31
- import { errorMessage, log, logError } from "../log.js"
38
+ import { errorMessage, log, logError, withRequestId } from "../log.js"
39
+ import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
32
40
  import { listAvailableModels, resolveModel } from "../models.js"
33
41
  import { serveResponses } from "../responses/route.js"
42
+ import { processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
34
43
  import { parseChatRequest } from "./request.js"
35
44
  import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
36
45
  import { dropRejected } from "./sampling.js"
@@ -41,12 +50,36 @@ export interface RouteHost {
41
50
  readonly caller: ModelCaller
42
51
  /** The serving Location's enabled models. */
43
52
  loadCatalog(): Promise<ReadonlyArray<CatalogModel>>
53
+ /** Metrics registry; the process runtime's by default. Only written to when option `metrics` is on. */
54
+ readonly metrics?: Metrics
55
+ /** Runtime facts for `/v1/status` and `/metrics`; the process runtime's by default. */
56
+ readonly status?: () => RuntimeStatus
44
57
  }
45
58
 
46
59
  const SESSION_HEADER = "x-plugboard-session"
60
+ const REQUEST_ID_HEADER = "x-plugboard-request-id"
47
61
  const DROPPED_HEADER = "x-plugboard-dropped-params"
48
62
  const MODEL_PREFIX = "/v1/models/"
49
- const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses"]
63
+ const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses", "GET /v1/status"]
64
+ /** Pages anyone may fetch; they decide for themselves how much to show (see `src/discovery.ts`). */
65
+ const DISCOVERY_ROUTES = ["GET /", "GET /v1"]
66
+ /** Allows a page's inline style and nothing else: no scripts, images, fonts or frames. */
67
+ const PAGE_HEADERS = { "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", "x-frame-options": "DENY", "referrer-policy": "no-referrer" }
68
+
69
+ /** Records the token usage of every finished model call (see `plugboard_tokens_total`); events pass through untouched. */
70
+ function metered(caller: ModelCaller, metrics: Metrics): ModelCaller {
71
+ return {
72
+ stream(request, context) {
73
+ const inner = caller.stream(request, context)
74
+ return (async function* () {
75
+ for await (const event of inner) {
76
+ if (event.type === "finish" && event.usage) metrics.tokens(context.app, event.usage)
77
+ yield event
78
+ }
79
+ })()
80
+ },
81
+ }
82
+ }
50
83
 
51
84
  const decodeSegment = (raw: string): string => {
52
85
  try {
@@ -67,10 +100,28 @@ interface RequestInfo {
67
100
  dropped?: string
68
101
  }
69
102
 
70
- export function createOpenAiRoute(host: RouteHost) {
103
+ export function createOpenAiRoute(routeHost: RouteHost) {
104
+ // Metrics are written only when the option is on: off means no bookkeeping, no wrapped event stream.
105
+ const metrics = routeHost.options.metrics ? (routeHost.metrics ?? processMetrics()) : undefined
106
+ const runtimeStatus = routeHost.status ?? processStatus
107
+ const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
108
+ /** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
109
+ const known = (route: string) => KNOWN_ROUTES.includes(route) || DISCOVERY_ROUTES.includes(route) || (metrics !== undefined && route === "GET /metrics")
110
+
71
111
  const fail = (response: ServerResponse, error: PlugError, close = false) =>
72
112
  sendJson(response, error.status, encodeError(error), { close })
73
113
 
114
+ /** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
115
+ async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
116
+ try {
117
+ return await scope.race(host.loadCatalog())
118
+ } catch (caught) {
119
+ if (caught instanceof RequestAborted) throw caught
120
+ logError("catalog.failed", { error: errorMessage(caught) })
121
+ return undefined
122
+ }
123
+ }
124
+
74
125
  /** The route proper. Throws `BodyError`, `RequestAborted` or an unexpected error; `handle` maps them. */
75
126
  async function serve(request: IncomingMessage, response: ServerResponse, scope: RequestScope, info: RequestInfo): Promise<void> {
76
127
  let url: URL
@@ -83,14 +134,30 @@ export function createOpenAiRoute(host: RouteHost) {
83
134
  const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
84
135
  const route = `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
85
136
  // Arbitrary client paths stay out of the log.
86
- info.route = modelId !== undefined || KNOWN_ROUTES.includes(route) || route === "GET /healthz" ? route : "unknown"
137
+ info.route = modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
87
138
 
88
139
  const rejected = checkOrigin(request, host.options)
89
140
  if (rejected) return fail(response, rejected)
90
141
 
91
142
  if (route === "GET /healthz") return sendJson(response, 200, { ok: true })
92
143
 
93
- if (modelId === undefined && !KNOWN_ROUTES.includes(route)) {
144
+ // First-run pages: reachable without a key, so what they show depends on the caller (`buildDiscovery`).
145
+ // With `keys` set, a request without credentials gets the minimal page; wrong credentials are a 401.
146
+ if (DISCOVERY_ROUTES.includes(route)) {
147
+ const keyed = Object.keys(host.options.keys).length > 0
148
+ let app: string | undefined = keyed ? undefined : DEFAULT_APP
149
+ if (keyed && request.headers.authorization !== undefined) {
150
+ const auth = authenticate(host.options, request.headers.authorization)
151
+ if (isPlugError(auth)) return fail(response, auth)
152
+ app = auth.app
153
+ }
154
+ info.app = app
155
+ const doc = buildDiscovery({ options: host.options, app, catalog: app === undefined ? undefined : await optionalCatalog(scope) })
156
+ if (route === "GET /v1") return sendJson(response, 200, doc)
157
+ return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
158
+ }
159
+
160
+ if (modelId === undefined && !known(route)) {
94
161
  return fail(response, {
95
162
  status: 404,
96
163
  kind: "not_found",
@@ -103,6 +170,15 @@ export function createOpenAiRoute(host: RouteHost) {
103
170
  if (isPlugError(auth)) return fail(response, auth)
104
171
  info.app = auth.app
105
172
 
173
+ if (route === "GET /v1/status") {
174
+ const catalog = await optionalCatalog(scope)
175
+ return sendJson(response, 200, buildStatus({ options: host.options, app: auth.app, runtime: runtimeStatus(), catalog }))
176
+ }
177
+
178
+ if (route === "GET /metrics" && metrics) {
179
+ return sendText(response, 200, "text/plain; version=0.0.4; charset=utf-8", metrics.render(runtimeStatus()))
180
+ }
181
+
106
182
  if (route === "GET /v1/models" || modelId !== undefined) {
107
183
  const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
108
184
  if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
@@ -136,6 +212,7 @@ export function createOpenAiRoute(host: RouteHost) {
136
212
  if (sampled && sampled.dropped.length > 0) {
137
213
  info.dropped = sampled.dropped.join(",")
138
214
  response.setHeader(DROPPED_HEADER, info.dropped)
215
+ metrics?.dropped(sampled.dropped)
139
216
  }
140
217
  const call: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
141
218
  const { exchange } = parsed
@@ -179,9 +256,16 @@ export function createOpenAiRoute(host: RouteHost) {
179
256
 
180
257
  /** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
181
258
  return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
259
+ // Generated here, never taken from the client: the id is echoed and logged, so it must not be attacker-controlled text.
260
+ const id = crypto.randomUUID().replaceAll("-", "").slice(0, 12)
261
+ if (!response.headersSent) response.setHeader(REQUEST_ID_HEADER, id)
262
+ return withRequestId(id, () => run(request, response, id))
263
+ }
264
+
265
+ async function run(request: IncomingMessage, response: ServerResponse, id: string): Promise<void> {
182
266
  const started = Date.now()
183
- const id = crypto.randomUUID().slice(0, 8)
184
267
  const info: RequestInfo = { route: "unparsed" }
268
+ metrics?.requestStarted()
185
269
  const scope = beginRequest(request, response, host.options.requestTimeoutMs)
186
270
  let outcome = "ok"
187
271
  let error: string | undefined
@@ -221,8 +305,11 @@ export function createOpenAiRoute(host: RouteHost) {
221
305
  try {
222
306
  finishRequest(request, response)
223
307
  } catch {}
224
- if (info.route !== "GET /healthz" || outcome !== "ok") {
225
- const fields = { id, route: info.route, app: info.app, model: info.model, dropped: info.dropped, status: response.statusCode, ms: Date.now() - started, outcome, error }
308
+ const ms = Date.now() - started
309
+ metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
310
+ // Probes and scrapes that succeed are not worth a line each.
311
+ if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
312
+ const fields = { id, route: info.route, app: info.app, model: info.model, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
226
313
  if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
227
314
  else log("request", fields)
228
315
  }
package/src/runtime.ts CHANGED
@@ -19,9 +19,12 @@
19
19
  * requests in flight fail with a 502 instead of waiting for their deadline;
20
20
  * - the session store (src/session/state.ts), which outlives the listener and owns the request
21
21
  * guards, their cleanup timers included;
22
- * - the history sweep state: every registered request session is indexed in the host's plugin
22
+ * - the history sweep state: every registered request session is indexed in the host's plugin
23
23
  * storage and, at most every `SWEEP_INTERVAL_MS`, a sweep prunes old ones past the `history`
24
- * limits (src/session/prune.ts). Both start from request handling (the store's `register`).
24
+ * limits (src/session/prune.ts). Both start from request handling (the store's `register`);
25
+ * - the metrics registry (src/metrics.ts) and the runtime start time, so counters and uptime
26
+ * outlive any one generation. `processMetrics` / `processStatus` read them for `/metrics`,
27
+ * `/v1/status` and the discovery pages.
25
28
  *
26
29
  * Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
27
30
  * rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
@@ -43,7 +46,8 @@
43
46
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
44
47
  import { Cause, Effect, Stream } from "effect"
45
48
  import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
46
- import { errorMessage, log, logError } from "./log.js"
49
+ import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
50
+ import { createMetrics, type Metrics, type RuntimeSnapshot } from "./metrics.js"
47
51
  import { ensureAppDir } from "./session/history.js"
48
52
  import type { HistoryPort } from "./session/host.js"
49
53
  import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
@@ -96,8 +100,8 @@ const SWEEP_DEADLINE_MS = 60_000
96
100
  const BACKEND_TIMEOUT_MS = 15_000
97
101
  const BACKEND_POLL_MS = 25
98
102
 
99
- /** 4: adds the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
100
- const VERSION = 4
103
+ /** 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. */
104
+ const VERSION = 5
101
105
  const KEY = Symbol.for("opencode-plugboard.runtime")
102
106
 
103
107
  interface Listener {
@@ -140,6 +144,10 @@ interface Runtime {
140
144
  readonly rejected: Map<string, number>
141
145
  /** History sweep rate limit: no sweep starts before `nextAt` (epoch ms) or while one `running`. */
142
146
  readonly sweep: { nextAt: number; running: boolean }
147
+ /** Process-wide counters, shared by every generation (see src/metrics.ts). */
148
+ readonly metrics: Metrics
149
+ /** When this runtime was created, epoch ms. */
150
+ readonly startedAt: number
143
151
  listener?: Listener
144
152
  /** Present while a fiber is subscribed to the server event bus. */
145
153
  pump?: Pump
@@ -149,6 +157,8 @@ const REJECTED: Registration = { accepted: false, dispose() {}, confirmed: () =>
149
157
 
150
158
  /** Used when a foreign-version runtime owns the process: never registered into, never dispatched to. */
151
159
  const detachedSessions = createSessionStore()
160
+ const detachedMetrics = createMetrics()
161
+ const detachedStartedAt = Date.now()
152
162
  let foreignVersionLogged = false
153
163
 
154
164
  /** The process runtime, created on first use; undefined when a different version already owns the process. */
@@ -163,6 +173,8 @@ function runtime(): Runtime | undefined {
163
173
  confirmed: new Set(),
164
174
  rejected: new Map(),
165
175
  sweep: { nextAt: 0, running: false },
176
+ metrics: createMetrics(),
177
+ startedAt: Date.now(),
166
178
  }
167
179
  slot[KEY] = created
168
180
  return created
@@ -185,9 +197,12 @@ function trackSession(rt: Runtime, app: string, sessionID: string): void {
185
197
  if (!history) return logError("history.index_skipped", { app, sessionID, reason: "no live generation" })
186
198
  // No limits, no index: an unbounded index would grow opencode.db by a row per request forever (#85).
187
199
  if (history.limits.maxSessions === undefined && history.limits.maxAgeDays === undefined) return
188
- void recordSession(history.port, app, sessionID, Date.now())
189
- .catch((error) => logError("history.index_failed", { app, sessionID, error: errorMessage(error) }))
190
- .then(() => maybeSweep(rt, history))
200
+ // Not about the request that happened to create the session: its log lines carry no request id.
201
+ void outsideRequest(() =>
202
+ recordSession(history.port, app, sessionID, Date.now())
203
+ .catch((error) => logError("history.index_failed", { app, sessionID, error: errorMessage(error) }))
204
+ .then(() => maybeSweep(rt, history)),
205
+ )
191
206
  }
192
207
 
193
208
  /**
@@ -212,10 +227,15 @@ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Pro
212
227
  timer.unref?.()
213
228
  }),
214
229
  ])
215
- if (!result) return logError("history.sweep_timeout", { limit: SWEEP_DEADLINE_MS })
230
+ if (!result) {
231
+ rt.metrics.sweep("timeout", 0)
232
+ return logError("history.sweep_timeout", { limit: SWEEP_DEADLINE_MS })
233
+ }
216
234
  if (result.more) rt.sweep.nextAt = Date.now() + SWEEP_BACKLOG_MS
235
+ rt.metrics.sweep("ok", result.removed)
217
236
  log("history.swept", { ...result, durationMs: Date.now() - now })
218
237
  } catch (error) {
238
+ rt.metrics.sweep("failed", 0)
219
239
  logError("history.sweep_failed", { error: errorMessage(error) })
220
240
  } finally {
221
241
  clearTimeout(timer)
@@ -226,6 +246,34 @@ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Pro
226
246
  /** The session store shared by every generation in the process. */
227
247
  export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedSessions
228
248
 
249
+ /** The metrics registry shared by every generation in the process (a detached one under a foreign-version runtime). */
250
+ export const processMetrics = (): Metrics => runtime()?.metrics ?? detachedMetrics
251
+
252
+ /** What `/v1/status` and the discovery pages report about the process, besides the model catalog. */
253
+ export interface RuntimeStatus extends RuntimeSnapshot {
254
+ readonly runtimeVersion: number
255
+ /** The shared listener's address, and whether it is bound; absent before the first generation registered. */
256
+ readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
257
+ /** The serving generation's Location is a confirmed app directory (else the newest generation serves). */
258
+ readonly servingConfirmed: boolean
259
+ }
260
+
261
+ /** A synchronous snapshot of the runtime: no host call, no filesystem. */
262
+ export function processStatus(): RuntimeStatus {
263
+ const rt = runtime()
264
+ if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false }
265
+ const serving = preferred(rt)
266
+ return {
267
+ runtimeVersion: rt.version,
268
+ generations: rt.generations.length,
269
+ pumpRunning: rt.pump !== undefined,
270
+ guards: rt.sessions.size(),
271
+ startedAt: rt.startedAt,
272
+ servingConfirmed: serving !== undefined && rt.confirmed.has(serving.directory),
273
+ ...(rt.listener ? { listener: { host: rt.listener.listen.host, port: rt.listener.listen.port, bound: rt.listener.bound } } : {}),
274
+ }
275
+ }
276
+
229
277
  /** The generation that serves requests and supplies the catalog: the newest confirmed app-directory one, else the newest. */
230
278
  function preferred(rt: Runtime): Generation | undefined {
231
279
  for (let index = rt.generations.length - 1; index >= 0; index--) if (rt.confirmed.has(rt.generations[index]!.directory)) return rt.generations[index]
@@ -367,6 +415,9 @@ export function registerGeneration(input: GenerationInput): Registration {
367
415
  activated: rt.confirmed.has(directory),
368
416
  }
369
417
  rt.generations.push(generation)
418
+ // Accepted generations share the live options, so this is the live `logFormat` (log lines are process-wide).
419
+ const options = input.options as { readonly logFormat?: unknown } | null
420
+ setLogFormat(options?.logFormat === "json" ? "json" : "text")
370
421
  return {
371
422
  accepted: true,
372
423
  confirmed: () => rt.confirmed.has(directory),
@@ -399,7 +450,8 @@ export function registerGeneration(input: GenerationInput): Registration {
399
450
  export function ensurePump(subscribe: () => Stream.Stream<unknown, unknown, never>): void {
400
451
  const rt = runtime()
401
452
  if (!rt || rt.pump) return
402
- const fiber = Effect.runFork(subscribe().pipe(Stream.runForEach((event) => Effect.sync(() => rt.sessions.dispatch(event)))))
453
+ // The pump serves every request; its log lines must not carry the id of the request that started it.
454
+ const fiber = outsideRequest(() => Effect.runFork(subscribe().pipe(Stream.runForEach((event) => Effect.sync(() => rt.sessions.dispatch(event))))))
403
455
  const pump: Pump = {
404
456
  stopped: false,
405
457
  stop: () => {
@@ -12,7 +12,7 @@
12
12
  import type { Plugin } from "@opencode/plugin/effect"
13
13
  import type { SessionContext } from "@opencode/plugin/effect/session"
14
14
  import { Effect } from "effect"
15
- import type { CatalogModel } from "../contract.js"
15
+ import type { CatalogCost, CatalogModel } from "../contract.js"
16
16
 
17
17
  export type HostContext = Plugin.Context
18
18
 
@@ -132,9 +132,39 @@ export function createHistoryPort(ctx: HostContext): HistoryPort {
132
132
  const record = (value: unknown): Record<string, unknown> | undefined =>
133
133
  typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
134
134
 
135
+ const finite = (value: unknown): number | undefined => (typeof value === "number" && Number.isFinite(value) ? value : undefined)
136
+ const strings = (value: unknown): string[] | undefined =>
137
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : undefined
138
+ const STATUSES = new Set(["alpha", "beta", "deprecated", "active"])
139
+
140
+ /** Host `Model.Cost[]` (USD per million tokens, optional context tier) -> neutral price entries; malformed entries are skipped. */
141
+ function toCosts(raw: unknown): CatalogCost[] | undefined {
142
+ if (!Array.isArray(raw)) return undefined
143
+ const costs: CatalogCost[] = []
144
+ for (const item of raw) {
145
+ const cost = record(item)
146
+ const input = finite(cost?.input)
147
+ const output = finite(cost?.output)
148
+ if (!cost || input === undefined || output === undefined) continue
149
+ const cache = record(cost.cache)
150
+ const tier = record(cost.tier)
151
+ const size = tier?.type === "context" ? finite(tier.size) : undefined
152
+ costs.push({
153
+ input,
154
+ output,
155
+ cacheRead: finite(cache?.read) ?? 0,
156
+ cacheWrite: finite(cache?.write) ?? 0,
157
+ ...(size === undefined ? {} : { aboveContext: size }),
158
+ })
159
+ }
160
+ return costs
161
+ }
162
+
135
163
  /**
136
164
  * Reduces `ctx.model.list` data (V2 `Model.Info`, tolerant of missing fields) to the catalog.
137
165
  * Models with `enabled: false` are dropped; `modelID` wins over `id`; variants may be `{ id }` or strings.
166
+ * Cost, limits, modalities, status, family and release date are carried as the host reports them
167
+ * (`Model.Info`, packages/schema/src/model.ts); anything absent or malformed is left out.
138
168
  */
139
169
  function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
140
170
  const catalog: CatalogModel[] = []
@@ -149,7 +179,13 @@ function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
149
179
  .filter((v): v is string => typeof v === "string" && v.length > 0)
150
180
  const caps = record(model.capabilities)
151
181
  const input = caps && Array.isArray(caps.input) ? caps.input : undefined
152
- const context = record(model.limit)?.context
182
+ const limit = record(model.limit)
183
+ const context = limit?.context
184
+ const limits = { context: finite(limit?.context), input: finite(limit?.input), output: finite(limit?.output) }
185
+ const modalityIn = strings(caps?.input)
186
+ const modalityOut = strings(caps?.output)
187
+ const cost = toCosts(model.cost)
188
+ const released = finite(record(model.time)?.released)
153
189
  catalog.push({
154
190
  providerID,
155
191
  id,
@@ -164,6 +200,14 @@ function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
164
200
  }
165
201
  : {}),
166
202
  ...(typeof context === "number" ? { contextLimit: context } : {}),
203
+ ...(limits.context !== undefined || limits.input !== undefined || limits.output !== undefined
204
+ ? { limits: Object.fromEntries(Object.entries(limits).filter(([, value]) => value !== undefined)) }
205
+ : {}),
206
+ ...(modalityIn || modalityOut ? { modalities: { input: modalityIn ?? [], output: modalityOut ?? [] } } : {}),
207
+ ...(cost && cost.length > 0 ? { cost } : {}),
208
+ ...(typeof model.status === "string" && STATUSES.has(model.status) ? { status: model.status as NonNullable<CatalogModel["status"]> } : {}),
209
+ ...(typeof model.family === "string" && model.family ? { family: model.family } : {}),
210
+ ...(released !== undefined ? { released } : {}),
167
211
  })
168
212
  }
169
213
  return catalog
@@ -57,6 +57,8 @@ export interface SessionStore {
57
57
  retire(sessionID: string, retirement: Retirement): void
58
58
  /** Fail every registered request with `error`; returns how many were registered. */
59
59
  failAll(error: PlugError): number
60
+ /** How many request guards are outstanding (metrics and status only). */
61
+ size(): number
60
62
  }
61
63
 
62
64
  const EXECUTION_END = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
@@ -140,5 +142,6 @@ export function createSessionStore(options: SessionStoreOptions = {}): SessionSt
140
142
  }
141
143
  return entries.size
142
144
  },
145
+ size: () => entries.size,
143
146
  }
144
147
  }