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/README.md +2 -2
- package/index.ts +19 -6
- package/package.json +5 -1
- package/src/anthropic/route.ts +2 -0
- package/src/cache.ts +165 -0
- package/src/config.ts +97 -1
- package/src/contract.ts +6 -0
- package/src/gemini/route.ts +2 -0
- package/src/limits.ts +144 -0
- package/src/log.ts +98 -31
- package/src/metrics.ts +100 -104
- package/src/models.ts +51 -3
- package/src/openai/fanout.ts +5 -0
- package/src/openai/route.ts +3 -1
- package/src/responses/route.ts +2 -0
- package/src/runtime.ts +51 -4
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
|
|
@@ -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
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
["app", "
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
142
|
-
|
|
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
|
|
149
|
-
tokens
|
|
150
|
-
tokens
|
|
151
|
-
tokens
|
|
152
|
-
tokens
|
|
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
|
|
158
|
+
for (const param of params) count(dropped, { param })
|
|
156
159
|
},
|
|
157
|
-
fallback: (app) => fallbacks
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
package/src/openai/fanout.ts
CHANGED
|
@@ -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
|
)
|
package/src/openai/route.ts
CHANGED
|
@@ -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,
|
|
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
|
)
|
package/src/responses/route.ts
CHANGED
|
@@ -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
|
)
|