opencode-plugboard 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
30
30
  {
31
31
  "plugins": [
32
32
  {
33
- "package": "opencode-plugboard@0.13.0",
33
+ "package": "opencode-plugboard@0.14.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"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
@@ -25,8 +25,10 @@
25
25
  "release": "bun .github/scripts/release.ts"
26
26
  },
27
27
  "dependencies": {
28
+ "@prometheus-io/client": "0.16.1",
28
29
  "escape-html": "1.0.3",
29
30
  "ipaddr.js": "2.5.0",
31
+ "pino": "10.4.0",
30
32
  "safe-stable-stringify": "2.5.0"
31
33
  },
32
34
  "devDependencies": {
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
@@ -117,65 +65,95 @@ export interface Metrics {
117
65
  /** One history sweep ended. */
118
66
  sweep(result: "ok" | "failed" | "timeout", removed: number): void
119
67
  /** The exposition text. Called from a request that is itself in flight, which is not counted. */
120
- render(snapshot: RuntimeSnapshot): string
68
+ render(snapshot: RuntimeSnapshot): Promise<string>
121
69
  }
122
70
 
123
71
  export function createMetrics(): Metrics {
124
72
  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.", [])
73
+ const registry = new Registry()
74
+ // Registration order is the order of the exposition text.
75
+ const requests = new Counter({
76
+ name: "plugboard_requests_total",
77
+ help: "Requests handled, by app, route, response status and outcome.",
78
+ labelNames: ["app", "route", "status", "outcome"],
79
+ registers: [registry],
80
+ })
81
+ const duration = new Histogram({
82
+ name: "plugboard_request_duration_seconds",
83
+ help: "Time from request start to the end of the response, by app and route.",
84
+ labelNames: ["app", "route"],
85
+ buckets: BUCKETS,
86
+ registers: [registry],
87
+ })
88
+ const inFlightGauge = new Gauge({ name: "plugboard_requests_in_flight", help: "Requests being handled right now, not counting this scrape.", registers: [registry] })
89
+ const tokens = new Counter({
90
+ name: "plugboard_tokens_total",
91
+ 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.",
92
+ labelNames: ["app", "kind"],
93
+ registers: [registry],
94
+ })
95
+ const dropped = new Counter({
96
+ name: "plugboard_dropped_params_total",
97
+ help: "Sampling parameters dropped because the target model rejects them, by parameter.",
98
+ labelNames: ["param"],
99
+ registers: [registry],
100
+ })
101
+ const fallbacks = new Counter({
102
+ name: "plugboard_fallbacks_total",
103
+ help: "Model calls retried on the next target of an alias after a failure before any output, by app.",
104
+ labelNames: ["app"],
105
+ registers: [registry],
106
+ })
107
+ const sweeps = new Counter({ name: "plugboard_history_sweeps_total", help: "History sweeps that ended, by result.", labelNames: ["result"], registers: [registry] })
108
+ const removed = new Counter({ name: "plugboard_history_sessions_removed_total", help: "Request sessions removed by history sweeps.", registers: [registry] })
109
+ const generations = new Gauge({ name: "plugboard_generations", help: "Accepted plugin generations (one per OpenCode Location) serving requests.", registers: [registry] })
110
+ const pumpUp = new Gauge({
111
+ name: "plugboard_event_pump_up",
112
+ help: "1 while the event pump is subscribed to the OpenCode event bus, else 0 (it starts with the first request).",
113
+ registers: [registry],
114
+ })
115
+ const guards = new Gauge({
116
+ name: "plugboard_session_guards",
117
+ help: "Request guards outstanding: request sessions plugboard still owns, including ones whose response has ended.",
118
+ registers: [registry],
119
+ })
120
+ const startTime = new Gauge({ name: "plugboard_start_time_seconds", help: "Start of the plugboard runtime in this process, Unix time.", registers: [registry] })
136
121
 
137
122
  return {
123
+ contentType: registry.contentType,
138
124
  requestStarted: () => void inFlight++,
139
125
  requestFinished({ app, route, status, outcome, ms }) {
140
126
  inFlight = Math.max(0, inFlight - 1)
141
- requests.add([app, route, String(status), outcome])
142
- duration.observe([app, route], ms / 1000)
127
+ count(requests, { app, route, status: String(status), outcome })
128
+ const seconds = ms / 1000
129
+ if (seconds >= 0 && Number.isFinite(seconds)) duration.observe({ app, route }, seconds)
143
130
  },
144
131
  tokens(app, usage) {
145
132
  const cacheRead = usage.cacheReadTokens ?? 0
146
133
  const cacheWrite = usage.cacheWriteTokens ?? 0
147
134
  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)
135
+ count(tokens, { app, kind: "input" }, Math.max(0, (usage.inputTokens ?? 0) - cacheRead - cacheWrite))
136
+ count(tokens, { app, kind: "cache_read" }, cacheRead)
137
+ count(tokens, { app, kind: "cache_write" }, cacheWrite)
138
+ count(tokens, { app, kind: "output" }, Math.max(0, (usage.outputTokens ?? 0) - reasoning))
139
+ count(tokens, { app, kind: "reasoning" }, reasoning)
153
140
  },
154
141
  dropped(params) {
155
- for (const param of params) dropped.add([param])
142
+ for (const param of params) count(dropped, { param })
156
143
  },
157
- fallback: (app) => fallbacks.add([app]),
158
- sweep(result, count) {
159
- sweeps.add([result])
160
- removed.add([], count)
144
+ fallback: (app) => count(fallbacks, { app }),
145
+ sweep(result, amount) {
146
+ count(sweeps, { result })
147
+ if (amount > 0 && Number.isFinite(amount)) removed.inc(amount)
161
148
  },
162
149
  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"
150
+ // The scrape itself is in flight; do not report it.
151
+ inFlightGauge.set(Math.max(0, inFlight - 1))
152
+ generations.set(snapshot.generations)
153
+ pumpUp.set(snapshot.pumpRunning ? 1 : 0)
154
+ guards.set(snapshot.guards)
155
+ startTime.set(Math.floor(snapshot.startedAt / 1000))
156
+ return registry.metrics()
179
157
  },
180
158
  }
181
159
  }
@@ -193,7 +193,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
193
193
  }
194
194
 
195
195
  if (route === "GET /metrics" && metrics) {
196
- return sendText(response, 200, "text/plain; version=0.0.4; charset=utf-8", metrics.render(runtimeStatus()))
196
+ return sendText(response, 200, metrics.contentType, await metrics.render(runtimeStatus()))
197
197
  }
198
198
 
199
199
  if (route === "GET /v1/models" || modelId !== undefined) {
package/src/runtime.ts CHANGED
@@ -107,8 +107,8 @@ const BACKEND_POLL_MS = 25
107
107
  const BIND_RETRY_MS = 15_000
108
108
  const BIND_REMINDER_MS = 5 * 60_000
109
109
 
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
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
112
112
  const KEY = Symbol.for("opencode-plugboard.runtime")
113
113
 
114
114
  interface Listener {