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 +1 -1
- package/package.json +3 -1
- package/src/log.ts +98 -31
- package/src/metrics.ts +82 -104
- package/src/openai/route.ts +1 -1
- package/src/runtime.ts +2 -2
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.
|
|
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.
|
|
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
|
-
*
|
|
2
|
+
* Structured logger on pino: one line per event.
|
|
3
3
|
*
|
|
4
4
|
* text (default) `[opencode-plugboard] event key=value ...`
|
|
5
|
-
* json `{"
|
|
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
|
|
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)
|
|
15
|
-
* started from a request but is not about it (the
|
|
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
|
-
*
|
|
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.
|
|
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] ??=
|
|
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
|
-
|
|
51
|
-
|
|
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 =>
|
|
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 =>
|
|
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),
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
["app", "
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
142
|
-
|
|
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
|
|
149
|
-
tokens
|
|
150
|
-
tokens
|
|
151
|
-
tokens
|
|
152
|
-
tokens
|
|
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
|
|
142
|
+
for (const param of params) count(dropped, { param })
|
|
156
143
|
},
|
|
157
|
-
fallback: (app) => fallbacks
|
|
158
|
-
sweep(result,
|
|
159
|
-
sweeps
|
|
160
|
-
removed.
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
}
|
package/src/openai/route.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
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 {
|