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/log.ts CHANGED
@@ -1,39 +1,123 @@
1
1
  /**
2
- * Tiny structured logger: one line per event.
2
+ * Structured logger on pino: one line per event.
3
3
  *
4
4
  * text (default) `[opencode-plugboard] event key=value ...`
5
- * json `{"time":"...","level":"info","service":"opencode-plugboard","event":"request","id":"...",...}`
5
+ * json `{"level":"info","time":"...","service":"opencode-plugboard","event":"request","id":"...",...}`
6
6
  *
7
7
  * Callers pass only identifiers and outcomes (event name, request id, app, model, duration, an
8
8
  * error message). Never pass API keys, request or response bodies, URLs or raw provider
9
- * objects: nothing here redacts them.
9
+ * objects. As a backstop pino's `redact` censors the fields `authorization`, `key`, `apiKey`,
10
+ * `x-api-key` and `x-goog-api-key` (also under `headers`); it matches field names, not values,
11
+ * so it does not replace that rule.
12
+ *
13
+ * `json` is pino's own JSON with three settings: the level is its name (`"info"`, `"error"`, not
14
+ * pino's 30 and 50) so lines keep the shape they had before pino, the message key is `event`, and
15
+ * the base object holds only `service` (no `pid` or `hostname`). A field cannot overwrite
16
+ * `level`, `time`, `service` or `event`.
17
+ *
18
+ * There is no pino transport and no worker thread: pino writes synchronously to two small
19
+ * destinations, and the transport machinery (thread-stream) is never started, which keeps it
20
+ * safe inside the OpenCode host. Info lines go to `console.log` (stdout), error lines to
21
+ * `console.error` (stderr), as before. Text lines are made from the JSON line by `toText`;
22
+ * pino-pretty was not used because it prints multi-line, level-prefixed output, adds a dozen
23
+ * packages, and cannot produce the `event key=value` shape that log scrapers already match.
10
24
  *
11
25
  * The format is process-wide (`setLogFormat`, set by the runtime from the live generation's
12
26
  * `logFormat` option). A line logged while a request is being handled also carries that request's
13
27
  * id (`id`, the value of the `x-plugboard-request-id` response header): `withRequestId` scopes it
14
- * to the request's async chain (node:async_hooks), and `outsideRequest` detaches work that merely
15
- * started from a request but is not about it (the history sweep, the event pump).
28
+ * to the request's async chain (node:async_hooks) and pino's `mixin` reads it per line, and
29
+ * `outsideRequest` detaches work that merely started from a request but is not about it (the
30
+ * history sweep, the event pump). An explicit `id` field wins over the ambient one.
16
31
  *
17
- * Both settings sit on `globalThis`, because the module can be evaluated once per Location.
32
+ * The logger and both settings sit on `globalThis`, because the module can be evaluated once per
33
+ * Location. They are here and not on the process runtime (`src/runtime.ts`) because the runtime
34
+ * imports this module and must still be able to log when a foreign-version runtime owns the process.
18
35
  */
19
36
 
20
37
  import { AsyncLocalStorage } from "node:async_hooks"
38
+ import pino, { type Logger } from "pino"
21
39
 
22
40
  export type LogFields = Readonly<Record<string, string | number | boolean | undefined>>
23
41
  export type LogFormat = "text" | "json"
24
42
 
25
43
  const PREFIX = "[opencode-plugboard]"
26
44
  const SERVICE = "opencode-plugboard"
27
- const KEY = Symbol.for("opencode-plugboard.log.1")
45
+ const KEY = Symbol.for("opencode-plugboard.log.2")
46
+ /** Keys of the line itself; a caller's field of the same name is dropped. */
47
+ const ENVELOPE = new Set(["level", "time", "service", "event"])
48
+
49
+ /** Field names whose value is censored, at the top level and under `headers` (pino path syntax). */
50
+ const REDACTED = ["authorization", "Authorization", "x-api-key", "x-goog-api-key", "key", "apiKey"]
51
+ const REDACT_PATHS = REDACTED.flatMap((name) => {
52
+ const path = /^[A-Za-z_$][\w$]*$/.test(name) ? name : `["${name}"]`
53
+ return [path, `headers${path.startsWith("[") ? "" : "."}${path}`]
54
+ })
28
55
 
29
56
  interface LogState {
30
57
  format: LogFormat
31
58
  readonly requests: AsyncLocalStorage<{ readonly id: string } | undefined>
59
+ readonly logger: Logger
60
+ }
61
+
62
+ /** A pino line (JSON, newline ended) as one line of the console: raw for `json`, `event key=value` for `text`. */
63
+ function toText(line: string): string {
64
+ let record: Record<string, unknown>
65
+ try {
66
+ record = JSON.parse(line) as Record<string, unknown>
67
+ } catch {
68
+ return line
69
+ }
70
+ let text = `${PREFIX} ${String(record.event)}`
71
+ for (const [key, value] of Object.entries(record)) {
72
+ if (ENVELOPE.has(key) || value === undefined) continue
73
+ text += ` ${key}=${formatValue(value)}`
74
+ }
75
+ return text
76
+ }
77
+
78
+ function formatValue(value: unknown): string {
79
+ if (typeof value === "string") return /^[\w.:/#-]+$/.test(value) ? value : JSON.stringify(value)
80
+ if (typeof value === "number" || typeof value === "boolean") return String(value)
81
+ return JSON.stringify(value)
82
+ }
83
+
84
+ function createState(): LogState {
85
+ const requests = new AsyncLocalStorage<{ readonly id: string } | undefined>()
86
+ const state = { format: "text" as LogFormat, requests } as { format: LogFormat; requests: typeof requests; logger: Logger }
87
+ // `console` is read at write time, not captured, so spies and host patches of it keep working.
88
+ const destination = (channel: "log" | "error") => ({
89
+ write(line: string) {
90
+ const text = line.endsWith("\n") ? line.slice(0, -1) : line
91
+ console[channel](state.format === "json" ? text : toText(text))
92
+ },
93
+ })
94
+ state.logger = pino(
95
+ {
96
+ level: "info",
97
+ base: { service: SERVICE },
98
+ timestamp: pino.stdTimeFunctions.isoTime,
99
+ formatters: { level: (label) => ({ level: label }) },
100
+ mixin: () => {
101
+ const id = requests.getStore()?.id
102
+ return id === undefined ? {} : { id }
103
+ },
104
+ redact: { paths: REDACT_PATHS, censor: "[redacted]" },
105
+ },
106
+ // dedupe: a record goes to the highest-level destination it reaches, so errors only to stderr.
107
+ pino.multistream(
108
+ [
109
+ { level: "info", stream: destination("log") },
110
+ { level: "error", stream: destination("error") },
111
+ ],
112
+ { dedupe: true },
113
+ ),
114
+ )
115
+ return state
32
116
  }
33
117
 
34
118
  function state(): LogState {
35
119
  const slot = globalThis as Record<symbol, unknown>
36
- return (slot[KEY] ??= { format: "text", requests: new AsyncLocalStorage() } satisfies LogState) as LogState
120
+ return (slot[KEY] ??= createState()) as LogState
37
121
  }
38
122
 
39
123
  /** Set the line format for the whole process. */
@@ -47,33 +131,16 @@ export const withRequestId = <T>(id: string, fn: () => T): T => state().requests
47
131
  /** Run `fn` detached from the current request: its log lines carry no request id. */
48
132
  export const outsideRequest = <T>(fn: () => T): T => state().requests.run(undefined, fn)
49
133
 
50
- function formatValue(value: string | number | boolean): string {
51
- return typeof value === "string" && !/^[\w.:/#-]+$/.test(value) ? JSON.stringify(value) : String(value)
52
- }
53
-
54
- /** The fields with the ambient request id added (an explicit `id` field wins). */
55
- function withAmbientId(fields: LogFields | undefined): Array<[string, string | number | boolean | undefined]> {
56
- const entries = Object.entries(fields ?? {})
57
- const ambient = state().requests.getStore()?.id
58
- return ambient !== undefined && !entries.some(([key]) => key === "id") ? [["id", ambient], ...entries] : entries
59
- }
60
-
61
- function format(level: "info" | "error", event: string, fields: LogFields | undefined): string {
62
- const entries = withAmbientId(fields)
63
- if (state().format === "json") {
64
- const line: Record<string, unknown> = { time: new Date().toISOString(), level, service: SERVICE, event }
65
- // The envelope keys cannot be overwritten by a field of the same name.
66
- for (const [key, value] of entries) if (value !== undefined && !(key in line)) line[key] = value
67
- return JSON.stringify(line)
68
- }
69
- let line = `${PREFIX} ${event}`
70
- for (const [key, value] of entries) if (value !== undefined) line += ` ${key}=${formatValue(value)}`
134
+ /** The fields without the keys of the line envelope (a field cannot overwrite them), with `event` first. */
135
+ function record(event: string, fields: LogFields | undefined): Record<string, unknown> {
136
+ const line: Record<string, unknown> = { event }
137
+ for (const [key, value] of Object.entries(fields ?? {})) if (value !== undefined && !ENVELOPE.has(key)) line[key] = value
71
138
  return line
72
139
  }
73
140
 
74
- export const log = (event: string, fields?: LogFields): void => console.log(format("info", event, fields))
141
+ export const log = (event: string, fields?: LogFields): void => state().logger.info(record(event, fields))
75
142
 
76
- export const logError = (event: string, fields?: LogFields): void => console.error(format("error", event, fields))
143
+ export const logError = (event: string, fields?: LogFields): void => state().logger.error(record(event, fields))
77
144
 
78
145
  /** The message of a thrown value, without its stack or attached properties. */
79
146
  export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
package/src/metrics.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Process metrics in the Prometheus text exposition format (version 0.0.4), hand-rolled: no
3
- * dependency, no I/O. One registry lives in the process runtime (`src/runtime.ts`), so counts
4
- * survive a plugin generation being replaced. `GET /metrics` renders it (opt-in, `metrics: true`).
2
+ * Process metrics in the Prometheus text exposition format (version 0.0.4), on @prometheus-io/client. No
3
+ * I/O. `createMetrics` makes one `Registry` of its own (the default global registry is never
4
+ * touched, and the default process metrics are never collected); the one the server uses lives
5
+ * in the process runtime (`src/runtime.ts`), so counts survive a plugin generation being
6
+ * replaced. `GET /metrics` renders it (opt-in, `metrics: true`).
5
7
  *
6
8
  * Labels are bounded by construction and carry ids only: `app` is a configured key name (or
7
9
  * `_none` before authentication succeeded), `route` is one of the known routes or `unknown`,
@@ -9,6 +11,7 @@
9
11
  * `src/openai/sampling.ts`. Never a model string, key, path or client-supplied text.
10
12
  */
11
13
 
14
+ import { Counter, Gauge, Histogram, Registry } from "@prometheus-io/client"
12
15
  import type { PlugUsage } from "./contract.js"
13
16
 
14
17
  /** `app` label of a request that was not authenticated (app names cannot contain an underscore). */
@@ -41,70 +44,15 @@ export interface RuntimeSnapshot {
41
44
  /** Request duration buckets, seconds: model calls run from under a second to many minutes. */
42
45
  const BUCKETS = [0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600]
43
46
 
44
- const escapeLabel = (value: string): string => value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"')
45
- const escapeHelp = (value: string): string => value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n")
46
- const labelText = (names: ReadonlyArray<string>, values: ReadonlyArray<string>): string =>
47
- names.length === 0 ? "" : `{${names.map((name, index) => `${name}="${escapeLabel(values[index] ?? "")}"`).join(",")}}`
48
-
49
- class Counter {
50
- private readonly series = new Map<string, { values: string[]; total: number }>()
51
- constructor(
52
- readonly name: string,
53
- readonly help: string,
54
- private readonly labels: ReadonlyArray<string>,
55
- ) {}
56
- add(values: ReadonlyArray<string>, amount = 1): void {
57
- if (!(amount > 0) || !Number.isFinite(amount)) return
58
- const key = values.join("\u0000")
59
- const entry = this.series.get(key)
60
- if (entry) entry.total += amount
61
- else this.series.set(key, { values: [...values], total: amount })
62
- }
63
- render(): string[] {
64
- const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} counter`]
65
- for (const { values, total } of this.series.values()) lines.push(`${this.name}${labelText(this.labels, values)} ${total}`)
66
- return lines
67
- }
68
- }
69
-
70
- class Histogram {
71
- private readonly series = new Map<string, { values: string[]; counts: number[]; sum: number; count: number }>()
72
- constructor(
73
- readonly name: string,
74
- readonly help: string,
75
- private readonly labels: ReadonlyArray<string>,
76
- ) {}
77
- observe(values: ReadonlyArray<string>, seconds: number): void {
78
- if (!(seconds >= 0) || !Number.isFinite(seconds)) return
79
- const key = values.join("\u0000")
80
- let entry = this.series.get(key)
81
- if (!entry) this.series.set(key, (entry = { values: [...values], counts: BUCKETS.map(() => 0), sum: 0, count: 0 }))
82
- BUCKETS.forEach((bound, index) => {
83
- if (seconds <= bound) entry.counts[index]! += 1
84
- })
85
- entry.sum += seconds
86
- entry.count += 1
87
- }
88
- render(): string[] {
89
- const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} histogram`]
90
- for (const { values, counts, sum, count } of this.series.values()) {
91
- const names = [...this.labels, "le"]
92
- BUCKETS.forEach((bound, index) => lines.push(`${this.name}_bucket${labelText(names, [...values, String(bound)])} ${counts[index]}`))
93
- lines.push(`${this.name}_bucket${labelText(names, [...values, "+Inf"])} ${count}`)
94
- lines.push(`${this.name}_sum${labelText(this.labels, values)} ${sum}`)
95
- lines.push(`${this.name}_count${labelText(this.labels, values)} ${count}`)
96
- }
97
- return lines
98
- }
47
+ /** A counter that ignores zero, negative and non-finite amounts (@prometheus-io/client throws on the last two and would write a zero series for the first). */
48
+ const count = <L extends string>(counter: Counter<L>, labels: Record<L, string>, amount = 1): void => {
49
+ // `Record<L, string>` is what `LabelValues<L>` means for a non-empty label list; its conditional type cannot resolve for a generic `L`.
50
+ if (amount > 0 && Number.isFinite(amount)) (counter as Counter<string>).inc(labels, amount)
99
51
  }
100
52
 
101
- const gauge = (name: string, help: string, value: number): string[] => [
102
- `# HELP ${name} ${escapeHelp(help)}`,
103
- `# TYPE ${name} gauge`,
104
- `${name} ${value}`,
105
- ]
106
-
107
53
  export interface Metrics {
54
+ /** The `Content-Type` of `render`'s text, as the registry reports it. */
55
+ readonly contentType: string
108
56
  /** A request entered the handler. Pair with `requestFinished`. */
109
57
  requestStarted(): void
110
58
  requestFinished(input: { readonly app: string; readonly route: string; readonly status: number; readonly outcome: Outcome; readonly ms: number }): void
@@ -114,68 +62,116 @@ export interface Metrics {
114
62
  dropped(params: ReadonlyArray<string>): void
115
63
  /** A model call failed before any output and was retried on the next target of its alias. */
116
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
117
69
  /** One history sweep ended. */
118
70
  sweep(result: "ok" | "failed" | "timeout", removed: number): void
119
71
  /** The exposition text. Called from a request that is itself in flight, which is not counted. */
120
- render(snapshot: RuntimeSnapshot): string
72
+ render(snapshot: RuntimeSnapshot): Promise<string>
121
73
  }
122
74
 
123
75
  export function createMetrics(): Metrics {
124
76
  let inFlight = 0
125
- const requests = new Counter("plugboard_requests_total", "Requests handled, by app, route, response status and outcome.", ["app", "route", "status", "outcome"])
126
- const duration = new Histogram("plugboard_request_duration_seconds", "Time from request start to the end of the response, by app and route.", ["app", "route"])
127
- const tokens = new Counter(
128
- "plugboard_tokens_total",
129
- "Tokens of completed model calls, by app and kind. Kinds do not overlap: input is uncached prompt tokens, cache_read and cache_write are cached prompt tokens, output excludes reasoning.",
130
- ["app", "kind"],
131
- )
132
- const dropped = new Counter("plugboard_dropped_params_total", "Sampling parameters dropped because the target model rejects them, by parameter.", ["param"])
133
- const fallbacks = new Counter("plugboard_fallbacks_total", "Model calls retried on the next target of an alias after a failure before any output, by app.", ["app"])
134
- const sweeps = new Counter("plugboard_history_sweeps_total", "History sweeps that ended, by result.", ["result"])
135
- const removed = new Counter("plugboard_history_sessions_removed_total", "Request sessions removed by history sweeps.", [])
77
+ const registry = new Registry()
78
+ // Registration order is the order of the exposition text.
79
+ const requests = new Counter({
80
+ name: "plugboard_requests_total",
81
+ help: "Requests handled, by app, route, response status and outcome.",
82
+ labelNames: ["app", "route", "status", "outcome"],
83
+ registers: [registry],
84
+ })
85
+ const duration = new Histogram({
86
+ name: "plugboard_request_duration_seconds",
87
+ help: "Time from request start to the end of the response, by app and route.",
88
+ labelNames: ["app", "route"],
89
+ buckets: BUCKETS,
90
+ registers: [registry],
91
+ })
92
+ const inFlightGauge = new Gauge({ name: "plugboard_requests_in_flight", help: "Requests being handled right now, not counting this scrape.", registers: [registry] })
93
+ const tokens = new Counter({
94
+ name: "plugboard_tokens_total",
95
+ help: "Tokens of completed model calls, by app and kind. Kinds do not overlap: input is uncached prompt tokens, cache_read and cache_write are cached prompt tokens, output excludes reasoning.",
96
+ labelNames: ["app", "kind"],
97
+ registers: [registry],
98
+ })
99
+ const dropped = new Counter({
100
+ name: "plugboard_dropped_params_total",
101
+ help: "Sampling parameters dropped because the target model rejects them, by parameter.",
102
+ labelNames: ["param"],
103
+ registers: [registry],
104
+ })
105
+ const fallbacks = new Counter({
106
+ name: "plugboard_fallbacks_total",
107
+ help: "Model calls retried on the next target of an alias after a failure before any output, by app.",
108
+ labelNames: ["app"],
109
+ registers: [registry],
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
+ })
123
+ const sweeps = new Counter({ name: "plugboard_history_sweeps_total", help: "History sweeps that ended, by result.", labelNames: ["result"], registers: [registry] })
124
+ const removed = new Counter({ name: "plugboard_history_sessions_removed_total", help: "Request sessions removed by history sweeps.", registers: [registry] })
125
+ const generations = new Gauge({ name: "plugboard_generations", help: "Accepted plugin generations (one per OpenCode Location) serving requests.", registers: [registry] })
126
+ const pumpUp = new Gauge({
127
+ name: "plugboard_event_pump_up",
128
+ help: "1 while the event pump is subscribed to the OpenCode event bus, else 0 (it starts with the first request).",
129
+ registers: [registry],
130
+ })
131
+ const guards = new Gauge({
132
+ name: "plugboard_session_guards",
133
+ help: "Request guards outstanding: request sessions plugboard still owns, including ones whose response has ended.",
134
+ registers: [registry],
135
+ })
136
+ const startTime = new Gauge({ name: "plugboard_start_time_seconds", help: "Start of the plugboard runtime in this process, Unix time.", registers: [registry] })
136
137
 
137
138
  return {
139
+ contentType: registry.contentType,
138
140
  requestStarted: () => void inFlight++,
139
141
  requestFinished({ app, route, status, outcome, ms }) {
140
142
  inFlight = Math.max(0, inFlight - 1)
141
- requests.add([app, route, String(status), outcome])
142
- duration.observe([app, route], ms / 1000)
143
+ count(requests, { app, route, status: String(status), outcome })
144
+ const seconds = ms / 1000
145
+ if (seconds >= 0 && Number.isFinite(seconds)) duration.observe({ app, route }, seconds)
143
146
  },
144
147
  tokens(app, usage) {
145
148
  const cacheRead = usage.cacheReadTokens ?? 0
146
149
  const cacheWrite = usage.cacheWriteTokens ?? 0
147
150
  const reasoning = usage.reasoningTokens ?? 0
148
- tokens.add([app, "input"], Math.max(0, (usage.inputTokens ?? 0) - cacheRead - cacheWrite))
149
- tokens.add([app, "cache_read"], cacheRead)
150
- tokens.add([app, "cache_write"], cacheWrite)
151
- tokens.add([app, "output"], Math.max(0, (usage.outputTokens ?? 0) - reasoning))
152
- tokens.add([app, "reasoning"], reasoning)
151
+ count(tokens, { app, kind: "input" }, Math.max(0, (usage.inputTokens ?? 0) - cacheRead - cacheWrite))
152
+ count(tokens, { app, kind: "cache_read" }, cacheRead)
153
+ count(tokens, { app, kind: "cache_write" }, cacheWrite)
154
+ count(tokens, { app, kind: "output" }, Math.max(0, (usage.outputTokens ?? 0) - reasoning))
155
+ count(tokens, { app, kind: "reasoning" }, reasoning)
153
156
  },
154
157
  dropped(params) {
155
- for (const param of params) dropped.add([param])
158
+ for (const param of params) count(dropped, { param })
156
159
  },
157
- fallback: (app) => fallbacks.add([app]),
158
- sweep(result, count) {
159
- sweeps.add([result])
160
- removed.add([], count)
160
+ fallback: (app) => count(fallbacks, { app }),
161
+ limited: (app, reason) => count(limited, { app, reason }),
162
+ cache: (app, result) => count(cache, { app, result }),
163
+ sweep(result, amount) {
164
+ count(sweeps, { result })
165
+ if (amount > 0 && Number.isFinite(amount)) removed.inc(amount)
161
166
  },
162
167
  render(snapshot) {
163
- const lines = [
164
- ...requests.render(),
165
- ...duration.render(),
166
- // The scrape itself is in flight; do not report it.
167
- ...gauge("plugboard_requests_in_flight", "Requests being handled right now, not counting this scrape.", Math.max(0, inFlight - 1)),
168
- ...tokens.render(),
169
- ...dropped.render(),
170
- ...fallbacks.render(),
171
- ...sweeps.render(),
172
- ...removed.render(),
173
- ...gauge("plugboard_generations", "Accepted plugin generations (one per OpenCode Location) serving requests.", snapshot.generations),
174
- ...gauge("plugboard_event_pump_up", "1 while the event pump is subscribed to the OpenCode event bus, else 0 (it starts with the first request).", snapshot.pumpRunning ? 1 : 0),
175
- ...gauge("plugboard_session_guards", "Request guards outstanding: request sessions plugboard still owns, including ones whose response has ended.", snapshot.guards),
176
- ...gauge("plugboard_start_time_seconds", "Start of the plugboard runtime in this process, Unix time.", Math.floor(snapshot.startedAt / 1000)),
177
- ]
178
- return lines.join("\n") + "\n"
168
+ // The scrape itself is in flight; do not report it.
169
+ inFlightGauge.set(Math.max(0, inFlight - 1))
170
+ generations.set(snapshot.generations)
171
+ pumpUp.set(snapshot.pumpRunning ? 1 : 0)
172
+ guards.set(snapshot.guards)
173
+ startTime.set(Math.floor(snapshot.startedAt / 1000))
174
+ return registry.metrics()
179
175
  },
180
176
  }
181
177
  }
package/src/models.ts CHANGED
@@ -20,6 +20,33 @@ export function refToString(ref: ModelRef): string {
20
20
  return `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
21
21
  }
22
22
 
23
+ /**
24
+ * A UI-safe spelling of a canonical id: `/` becomes `--` and `#` becomes `__`, so some chat UIs that mangle
25
+ * `/` and `#` keep it intact. `anthropic/claude-haiku-4-5#high` -> `anthropic--claude-haiku-4-5__high`.
26
+ */
27
+ export function toSlug(id: string): string {
28
+ return id.replaceAll("/", "--").replaceAll("#", "__")
29
+ }
30
+
31
+ /**
32
+ * The canonical id a slug stands for, or undefined when `slug` is not one. The first `--` ends the provider, the
33
+ * first `__` starts the variant; a `--` inside the model id is read back as `/`. Ids where that does not round
34
+ * trip are not listed as slugs (see `listAvailableModels`), so they are never offered and never resolved this way.
35
+ */
36
+ export function fromSlug(slug: string): string | undefined {
37
+ if (typeof slug !== "string" || /[\s/#*]/.test(slug)) return undefined
38
+ const sep = slug.indexOf("--")
39
+ if (sep <= 0) return undefined
40
+ const providerID = slug.slice(0, sep)
41
+ const rest = slug.slice(sep + 2)
42
+ const at = rest.indexOf("__")
43
+ const model = (at === -1 ? rest : rest.slice(0, at)).replaceAll("--", "/")
44
+ const variant = at === -1 ? undefined : rest.slice(at + 2)
45
+ if (variant !== undefined && (!variant || variant.includes("__"))) return undefined
46
+ const id = refToString({ providerID, id: model, ...(variant === undefined ? {} : { variant }) })
47
+ return parseModelRef(id) ? id : undefined
48
+ }
49
+
23
50
  /** `provider/*` any model of the provider; `provider/model` any variant; `provider/model#variant` exactly. */
24
51
  export function allowedBy(models: ReadonlyArray<string>, ref: ModelRef): boolean {
25
52
  return models.some((pattern) => {
@@ -80,7 +107,8 @@ export function resolveModel(
80
107
  code: "model_not_found",
81
108
  param: "model",
82
109
  }
83
- let ref = parseModelRef(own(options.aliases, model) ?? model)
110
+ // Aliases keep priority over slugs; a slug and a canonical id both resolve, whatever `modelIds` lists.
111
+ let ref = parseModelRef(own(options.aliases, model) ?? fromSlug(model) ?? model)
84
112
  if (!ref) return notAvailable
85
113
  if (!ref.variant && reasoningEffort && find(catalog, { ...ref, variant: reasoningEffort }))
86
114
  ref = { ...ref, variant: reasoningEffort }
@@ -96,7 +124,9 @@ export interface ListedModel {
96
124
 
97
125
  /**
98
126
  * The allowlist intersected with the catalog, plus aliases whose target qualifies. Ids are unique except an alias may repeat one.
99
- * With `app`, the list is narrowed to that app's `models` (see `inAppScope`).
127
+ * Each model is listed as its canonical id, its slug, or both, per `options.modelIds`; a model whose slug does not
128
+ * round trip (see `fromSlug`) is left out in `slugs` mode. Aliases are listed as they are.
129
+ * With `app`, the list is narrowed to that app's `models` (see `inAppScope`), judged on the canonical id.
100
130
  */
101
131
  export function listAvailableModels(options: PlugboardOptions, catalog: ReadonlyArray<CatalogModel>, app?: string): ListedModel[] {
102
132
  const policy = app === undefined ? undefined : appPolicy(options, app)
@@ -110,7 +140,9 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
110
140
  const id = refToString(ref)
111
141
  if (seen.has(id) || !allowed(options, ref) || !inAppScope(policy, id, ref)) continue
112
142
  seen.add(id)
113
- data.push(entry(id, ref, model))
143
+ if (options.modelIds !== "slugs") data.push(entry(id, ref, model))
144
+ const slug = toSlug(id)
145
+ if (options.modelIds !== "canonical" && fromSlug(slug) === id) data.push(entry(slug, ref, model))
114
146
  }
115
147
  }
116
148
  for (const [alias, target] of Object.entries(options.aliases)) {
@@ -121,6 +153,22 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
121
153
  return data
122
154
  }
123
155
 
156
+ /** Capabilities `GET /v1/models?capability=` can filter on: the flags the catalog reports (`CatalogModel.capabilities`). */
157
+ export type Capability = "vision" | "tools"
158
+
159
+ const CAPABILITY_FLAG: Readonly<Record<Capability, "image" | "tools">> = { vision: "image", tools: "tools" }
160
+
161
+ /** The capability a query value names, or undefined for anything else (`json` has no catalog flag, so it is not one). */
162
+ export function parseCapability(value: string): Capability | undefined {
163
+ return Object.hasOwn(CAPABILITY_FLAG, value) ? (value as Capability) : undefined
164
+ }
165
+
166
+ /** The listed models whose catalog entry sets that capability flag to true. Order is kept. */
167
+ export function filterByCapability(models: ReadonlyArray<ListedModel>, capability: Capability): ListedModel[] {
168
+ const flag = CAPABILITY_FLAG[capability]
169
+ return models.filter(({ model }) => model.capabilities?.[flag] === true)
170
+ }
171
+
124
172
  /**
125
173
  * The targets an alias falls back to, in order: the fallbacks that are allowed and enabled on the server.
126
174
  * Empty for a plain model id or an alias without fallbacks. The primary target is `resolveModel`'s answer.
@@ -23,6 +23,7 @@
23
23
  import type { ServerResponse } from "node:http"
24
24
  import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.js"
25
25
  import { reportServed, type ServedInfo } from "../fallback.js"
26
+ import { reportCache } from "../cache.js"
26
27
  import { reportPolicy } from "../policy.js"
27
28
  import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
28
29
  import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
@@ -168,6 +169,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
168
169
  if (calls.length === 1) codec.setModel(s.model)
169
170
  })
170
171
  const policy = reportPolicy(response)
172
+ // Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
173
+ const cache = reportCache(response)
171
174
  const open = (index: number) =>
172
175
  boundEvents(
173
176
  host.caller.stream(calls[index]!, {
@@ -180,6 +183,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
180
183
  },
181
184
  onServed: served,
182
185
  onPolicy: policy,
186
+ onCache: cache,
187
+ noCache: calls.length > 1,
183
188
  }),
184
189
  gscope,
185
190
  )
@@ -42,6 +42,7 @@ import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/ro
42
42
  import { errorMessage, log, logError, withRequestId } from "../log.js"
43
43
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
44
44
  import { listAvailableModels, resolveModel } from "../models.js"
45
+ import { reportCache } from "../cache.js"
45
46
  import { reportPolicy } from "../policy.js"
46
47
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
47
48
  import { serveCompletions } from "../completions/route.js"
@@ -193,7 +194,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
193
194
  }
194
195
 
195
196
  if (route === "GET /metrics" && metrics) {
196
- return sendText(response, 200, "text/plain; version=0.0.4; charset=utf-8", metrics.render(runtimeStatus()))
197
+ return sendText(response, 200, metrics.contentType, await metrics.render(runtimeStatus()))
197
198
  }
198
199
 
199
200
  if (route === "GET /v1/models" || modelId !== undefined) {
@@ -251,6 +252,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
251
252
  // An alias with fallbacks: report which target answered, and echo it as the response model.
252
253
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
253
254
  onPolicy: reportPolicy(response),
255
+ onCache: reportCache(response),
254
256
  }),
255
257
  scope,
256
258
  )
@@ -13,6 +13,7 @@ import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
13
  import { reportServed, type ServedInfo } from "../fallback.js"
14
14
  import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
15
15
  import { resolveModel } from "../models.js"
16
+ import { reportCache } from "../cache.js"
16
17
  import { reportPolicy } from "../policy.js"
17
18
  import { encodeError } from "../openai/response.js"
18
19
  import type { RouteHost } from "../openai/route.js"
@@ -54,6 +55,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
54
55
  },
55
56
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
56
57
  onPolicy: reportPolicy(response),
58
+ onCache: reportCache(response),
57
59
  }),
58
60
  scope,
59
61
  )