opencode-plugboard 0.4.1 → 0.6.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 +3 -4
- package/package.json +3 -3
- package/src/config.ts +38 -6
- package/src/contract.ts +21 -0
- package/src/discovery.ts +280 -0
- package/src/log.ts +56 -5
- package/src/metrics.ts +176 -0
- package/src/openai/response.ts +29 -1
- package/src/openai/route.ts +100 -12
- package/src/runtime.ts +103 -18
- package/src/session/caller.ts +5 -5
- package/src/session/events.ts +60 -12
- package/src/session/history.ts +18 -6
- package/src/session/host.ts +46 -2
- package/src/session/media.ts +256 -23
- package/src/session/state.ts +3 -0
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
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`).
|
|
5
|
+
*
|
|
6
|
+
* Labels are bounded by construction and carry ids only: `app` is a configured key name (or
|
|
7
|
+
* `_none` before authentication succeeded), `route` is one of the known routes or `unknown`,
|
|
8
|
+
* `status` a response code, `outcome` one of a fixed set, `param` a sampling parameter name from
|
|
9
|
+
* `src/openai/sampling.ts`. Never a model string, key, path or client-supplied text.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PlugUsage } from "./contract.js"
|
|
13
|
+
|
|
14
|
+
/** `app` label of a request that was not authenticated (app names cannot contain an underscore). */
|
|
15
|
+
export const NO_APP = "_none"
|
|
16
|
+
|
|
17
|
+
/** What a request ended as, for the `outcome` label. */
|
|
18
|
+
export type Outcome = "ok" | "rejected" | "failed" | "timeout" | "disconnected"
|
|
19
|
+
|
|
20
|
+
/** Collapses the request log's outcome string and the response status into the bounded `outcome` label. */
|
|
21
|
+
export function outcomeLabel(logOutcome: string, status: number): Outcome {
|
|
22
|
+
if (logOutcome === "timeout" || logOutcome === "disconnected") return logOutcome
|
|
23
|
+
if (logOutcome === "error") return "failed"
|
|
24
|
+
if (status >= 500) return "failed"
|
|
25
|
+
if (status >= 400) return "rejected"
|
|
26
|
+
return "ok"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Process-level values the registry does not own; read from the runtime at scrape time. */
|
|
30
|
+
export interface RuntimeSnapshot {
|
|
31
|
+
/** Accepted plugin generations currently serving. */
|
|
32
|
+
readonly generations: number
|
|
33
|
+
/** Whether the event pump is subscribed. */
|
|
34
|
+
readonly pumpRunning: boolean
|
|
35
|
+
/** Request guards outstanding (sessions the runtime still owns, including ones past their response). */
|
|
36
|
+
readonly guards: number
|
|
37
|
+
/** Runtime start, epoch ms. */
|
|
38
|
+
readonly startedAt: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Request duration buckets, seconds: model calls run from under a second to many minutes. */
|
|
42
|
+
const BUCKETS = [0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600]
|
|
43
|
+
|
|
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
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
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
|
+
export interface Metrics {
|
|
108
|
+
/** A request entered the handler. Pair with `requestFinished`. */
|
|
109
|
+
requestStarted(): void
|
|
110
|
+
requestFinished(input: { readonly app: string; readonly route: string; readonly status: number; readonly outcome: Outcome; readonly ms: number }): void
|
|
111
|
+
/** Token usage of one model call, as a non-overlapping breakdown (see `plugboard_tokens_total`). */
|
|
112
|
+
tokens(app: string, usage: PlugUsage): void
|
|
113
|
+
/** Sampling parameters dropped for one request. */
|
|
114
|
+
dropped(params: ReadonlyArray<string>): void
|
|
115
|
+
/** One history sweep ended. */
|
|
116
|
+
sweep(result: "ok" | "failed" | "timeout", removed: number): void
|
|
117
|
+
/** The exposition text. Called from a request that is itself in flight, which is not counted. */
|
|
118
|
+
render(snapshot: RuntimeSnapshot): string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createMetrics(): Metrics {
|
|
122
|
+
let inFlight = 0
|
|
123
|
+
const requests = new Counter("plugboard_requests_total", "Requests handled, by app, route, response status and outcome.", ["app", "route", "status", "outcome"])
|
|
124
|
+
const duration = new Histogram("plugboard_request_duration_seconds", "Time from request start to the end of the response, by app and route.", ["app", "route"])
|
|
125
|
+
const tokens = new Counter(
|
|
126
|
+
"plugboard_tokens_total",
|
|
127
|
+
"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.",
|
|
128
|
+
["app", "kind"],
|
|
129
|
+
)
|
|
130
|
+
const dropped = new Counter("plugboard_dropped_params_total", "Sampling parameters dropped because the target model rejects them, by parameter.", ["param"])
|
|
131
|
+
const sweeps = new Counter("plugboard_history_sweeps_total", "History sweeps that ended, by result.", ["result"])
|
|
132
|
+
const removed = new Counter("plugboard_history_sessions_removed_total", "Request sessions removed by history sweeps.", [])
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
requestStarted: () => void inFlight++,
|
|
136
|
+
requestFinished({ app, route, status, outcome, ms }) {
|
|
137
|
+
inFlight = Math.max(0, inFlight - 1)
|
|
138
|
+
requests.add([app, route, String(status), outcome])
|
|
139
|
+
duration.observe([app, route], ms / 1000)
|
|
140
|
+
},
|
|
141
|
+
tokens(app, usage) {
|
|
142
|
+
const cacheRead = usage.cacheReadTokens ?? 0
|
|
143
|
+
const cacheWrite = usage.cacheWriteTokens ?? 0
|
|
144
|
+
const reasoning = usage.reasoningTokens ?? 0
|
|
145
|
+
tokens.add([app, "input"], Math.max(0, (usage.inputTokens ?? 0) - cacheRead - cacheWrite))
|
|
146
|
+
tokens.add([app, "cache_read"], cacheRead)
|
|
147
|
+
tokens.add([app, "cache_write"], cacheWrite)
|
|
148
|
+
tokens.add([app, "output"], Math.max(0, (usage.outputTokens ?? 0) - reasoning))
|
|
149
|
+
tokens.add([app, "reasoning"], reasoning)
|
|
150
|
+
},
|
|
151
|
+
dropped(params) {
|
|
152
|
+
for (const param of params) dropped.add([param])
|
|
153
|
+
},
|
|
154
|
+
sweep(result, count) {
|
|
155
|
+
sweeps.add([result])
|
|
156
|
+
removed.add([], count)
|
|
157
|
+
},
|
|
158
|
+
render(snapshot) {
|
|
159
|
+
const lines = [
|
|
160
|
+
...requests.render(),
|
|
161
|
+
...duration.render(),
|
|
162
|
+
// The scrape itself is in flight; do not report it.
|
|
163
|
+
...gauge("plugboard_requests_in_flight", "Requests being handled right now, not counting this scrape.", Math.max(0, inFlight - 1)),
|
|
164
|
+
...tokens.render(),
|
|
165
|
+
...dropped.render(),
|
|
166
|
+
...sweeps.render(),
|
|
167
|
+
...removed.render(),
|
|
168
|
+
...gauge("plugboard_generations", "Accepted plugin generations (one per OpenCode Location) serving requests.", snapshot.generations),
|
|
169
|
+
...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),
|
|
170
|
+
...gauge("plugboard_session_guards", "Request guards outstanding: request sessions plugboard still owns, including ones whose response has ended.", snapshot.guards),
|
|
171
|
+
...gauge("plugboard_start_time_seconds", "Start of the plugboard runtime in this process, Unix time.", Math.floor(snapshot.startedAt / 1000)),
|
|
172
|
+
]
|
|
173
|
+
return lines.join("\n") + "\n"
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/openai/response.ts
CHANGED
|
@@ -63,6 +63,34 @@ export const encodeError = (error: PlugError): object => ({
|
|
|
63
63
|
error: { message: error.message, type: ERROR_TYPES[error.kind], param: error.param ?? null, code: error.code ?? null },
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
/** Epoch ms -> ISO 8601, or undefined for a value Date cannot represent. */
|
|
67
|
+
const isoDate = (ms: number | undefined): string | undefined => {
|
|
68
|
+
if (ms === undefined) return undefined
|
|
69
|
+
const date = new Date(ms)
|
|
70
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `plugboard` object of a model entry: what the catalog knows beyond the OpenAI fields. Absent
|
|
75
|
+
* facts are omitted. `cost` is USD per million tokens (an entry with `aboveContext` applies when
|
|
76
|
+
* the prompt exceeds that many tokens; the entry without it is the base price); `released` is ISO 8601.
|
|
77
|
+
*/
|
|
78
|
+
const encodePlugboard = (model: ListedModel["model"]) => ({
|
|
79
|
+
capabilities: model.capabilities ?? {},
|
|
80
|
+
contextLimit: model.contextLimit,
|
|
81
|
+
limit: model.limits,
|
|
82
|
+
modalities: model.modalities,
|
|
83
|
+
cost: model.cost?.map(({ input, output, cacheRead, cacheWrite, aboveContext }) => ({
|
|
84
|
+
input,
|
|
85
|
+
output,
|
|
86
|
+
cache: { read: cacheRead, write: cacheWrite },
|
|
87
|
+
...(aboveContext === undefined ? {} : { aboveContext }),
|
|
88
|
+
})),
|
|
89
|
+
status: model.status,
|
|
90
|
+
family: model.family,
|
|
91
|
+
released: isoDate(model.released),
|
|
92
|
+
})
|
|
93
|
+
|
|
66
94
|
/** `GET /v1/models` body. */
|
|
67
95
|
export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
|
|
68
96
|
object: "list",
|
|
@@ -71,7 +99,7 @@ export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => (
|
|
|
71
99
|
object: "model",
|
|
72
100
|
created: 0,
|
|
73
101
|
owned_by: ref.providerID,
|
|
74
|
-
plugboard:
|
|
102
|
+
plugboard: encodePlugboard(model),
|
|
75
103
|
})),
|
|
76
104
|
})
|
|
77
105
|
|
package/src/openai/route.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenAI front-end route table: `GET /healthz`, `GET /v1/models`, `GET /v1/models/{id}`,
|
|
3
3
|
* `POST /v1/chat/completions`, and the dispatch of `POST /v1/responses` to `src/responses/route.ts`.
|
|
4
|
+
* Observability and first-run routes (`src/discovery.ts`, `src/metrics.ts`): `GET /` and `GET /v1`
|
|
5
|
+
* (keyless discovery), the authenticated `GET /v1/status`, and `GET /metrics` (option `metrics`).
|
|
4
6
|
*
|
|
5
7
|
* The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
|
|
6
8
|
* the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
|
|
@@ -10,11 +12,16 @@
|
|
|
10
12
|
* Response header `x-plugboard-session: <id>` carries the id of the history record the call
|
|
11
13
|
* writes (the `ModelCaller` reports it through `CallContext.onRecord`). It is set before the
|
|
12
14
|
* response headers go out (JSON body or first SSE chunk); an id reported after that is ignored.
|
|
15
|
+
*
|
|
16
|
+
* Response header `x-plugboard-request-id: <id>` is set on every response, before anything else
|
|
17
|
+
* can fail. The same id is the `id` of the request's log line, and of every other log line emitted
|
|
18
|
+
* while the request is being handled (`withRequestId` in `src/log.ts`).
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
16
|
-
import { authenticate, type PlugboardOptions } from "../config.js"
|
|
22
|
+
import { DEFAULT_APP, authenticate, type PlugboardOptions } from "../config.js"
|
|
17
23
|
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
|
|
24
|
+
import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
|
|
18
25
|
import {
|
|
19
26
|
BodyError,
|
|
20
27
|
RequestAborted,
|
|
@@ -28,9 +35,11 @@ import {
|
|
|
28
35
|
sseWriter,
|
|
29
36
|
type RequestScope,
|
|
30
37
|
} from "../http.js"
|
|
31
|
-
import { errorMessage, log, logError } from "../log.js"
|
|
38
|
+
import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
39
|
+
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
32
40
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
33
41
|
import { serveResponses } from "../responses/route.js"
|
|
42
|
+
import { processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
34
43
|
import { parseChatRequest } from "./request.js"
|
|
35
44
|
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
36
45
|
import { dropRejected } from "./sampling.js"
|
|
@@ -41,12 +50,36 @@ export interface RouteHost {
|
|
|
41
50
|
readonly caller: ModelCaller
|
|
42
51
|
/** The serving Location's enabled models. */
|
|
43
52
|
loadCatalog(): Promise<ReadonlyArray<CatalogModel>>
|
|
53
|
+
/** Metrics registry; the process runtime's by default. Only written to when option `metrics` is on. */
|
|
54
|
+
readonly metrics?: Metrics
|
|
55
|
+
/** Runtime facts for `/v1/status` and `/metrics`; the process runtime's by default. */
|
|
56
|
+
readonly status?: () => RuntimeStatus
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
const SESSION_HEADER = "x-plugboard-session"
|
|
60
|
+
const REQUEST_ID_HEADER = "x-plugboard-request-id"
|
|
47
61
|
const DROPPED_HEADER = "x-plugboard-dropped-params"
|
|
48
62
|
const MODEL_PREFIX = "/v1/models/"
|
|
49
|
-
const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses"]
|
|
63
|
+
const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses", "GET /v1/status"]
|
|
64
|
+
/** Pages anyone may fetch; they decide for themselves how much to show (see `src/discovery.ts`). */
|
|
65
|
+
const DISCOVERY_ROUTES = ["GET /", "GET /v1"]
|
|
66
|
+
/** Allows a page's inline style and nothing else: no scripts, images, fonts or frames. */
|
|
67
|
+
const PAGE_HEADERS = { "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", "x-frame-options": "DENY", "referrer-policy": "no-referrer" }
|
|
68
|
+
|
|
69
|
+
/** Records the token usage of every finished model call (see `plugboard_tokens_total`); events pass through untouched. */
|
|
70
|
+
function metered(caller: ModelCaller, metrics: Metrics): ModelCaller {
|
|
71
|
+
return {
|
|
72
|
+
stream(request, context) {
|
|
73
|
+
const inner = caller.stream(request, context)
|
|
74
|
+
return (async function* () {
|
|
75
|
+
for await (const event of inner) {
|
|
76
|
+
if (event.type === "finish" && event.usage) metrics.tokens(context.app, event.usage)
|
|
77
|
+
yield event
|
|
78
|
+
}
|
|
79
|
+
})()
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
50
83
|
|
|
51
84
|
const decodeSegment = (raw: string): string => {
|
|
52
85
|
try {
|
|
@@ -67,10 +100,28 @@ interface RequestInfo {
|
|
|
67
100
|
dropped?: string
|
|
68
101
|
}
|
|
69
102
|
|
|
70
|
-
export function createOpenAiRoute(
|
|
103
|
+
export function createOpenAiRoute(routeHost: RouteHost) {
|
|
104
|
+
// Metrics are written only when the option is on: off means no bookkeeping, no wrapped event stream.
|
|
105
|
+
const metrics = routeHost.options.metrics ? (routeHost.metrics ?? processMetrics()) : undefined
|
|
106
|
+
const runtimeStatus = routeHost.status ?? processStatus
|
|
107
|
+
const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
|
|
108
|
+
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
109
|
+
const known = (route: string) => KNOWN_ROUTES.includes(route) || DISCOVERY_ROUTES.includes(route) || (metrics !== undefined && route === "GET /metrics")
|
|
110
|
+
|
|
71
111
|
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
72
112
|
sendJson(response, error.status, encodeError(error), { close })
|
|
73
113
|
|
|
114
|
+
/** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
|
|
115
|
+
async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
|
|
116
|
+
try {
|
|
117
|
+
return await scope.race(host.loadCatalog())
|
|
118
|
+
} catch (caught) {
|
|
119
|
+
if (caught instanceof RequestAborted) throw caught
|
|
120
|
+
logError("catalog.failed", { error: errorMessage(caught) })
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
74
125
|
/** The route proper. Throws `BodyError`, `RequestAborted` or an unexpected error; `handle` maps them. */
|
|
75
126
|
async function serve(request: IncomingMessage, response: ServerResponse, scope: RequestScope, info: RequestInfo): Promise<void> {
|
|
76
127
|
let url: URL
|
|
@@ -83,14 +134,35 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
83
134
|
const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
|
|
84
135
|
const route = `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
85
136
|
// Arbitrary client paths stay out of the log.
|
|
86
|
-
info.route = modelId !== undefined ||
|
|
137
|
+
info.route = modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
87
138
|
|
|
88
139
|
const rejected = checkOrigin(request, host.options)
|
|
89
140
|
if (rejected) return fail(response, rejected)
|
|
90
141
|
|
|
91
142
|
if (route === "GET /healthz") return sendJson(response, 200, { ok: true })
|
|
92
143
|
|
|
93
|
-
|
|
144
|
+
// First-run pages: reachable without a key, so what they show depends on the caller (`buildDiscovery`).
|
|
145
|
+
// With `keys` set, a request without credentials gets the minimal page; wrong credentials are a 401.
|
|
146
|
+
if (DISCOVERY_ROUTES.includes(route)) {
|
|
147
|
+
const keyed = Object.keys(host.options.keys).length > 0
|
|
148
|
+
let app: string | undefined = keyed ? undefined : DEFAULT_APP
|
|
149
|
+
if (keyed && request.headers.authorization !== undefined) {
|
|
150
|
+
const auth = authenticate(host.options, request.headers.authorization)
|
|
151
|
+
if (isPlugError(auth)) return fail(response, auth)
|
|
152
|
+
app = auth.app
|
|
153
|
+
}
|
|
154
|
+
info.app = app
|
|
155
|
+
const doc = buildDiscovery({ options: host.options, app, catalog: app === undefined ? undefined : await optionalCatalog(scope) })
|
|
156
|
+
if (route === "GET /v1") return sendJson(response, 200, doc)
|
|
157
|
+
return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
161
|
+
const auth = authenticate(host.options, request.headers.authorization)
|
|
162
|
+
if (isPlugError(auth)) return fail(response, auth)
|
|
163
|
+
info.app = auth.app
|
|
164
|
+
|
|
165
|
+
if (modelId === undefined && !known(route)) {
|
|
94
166
|
return fail(response, {
|
|
95
167
|
status: 404,
|
|
96
168
|
kind: "not_found",
|
|
@@ -99,9 +171,14 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
99
171
|
})
|
|
100
172
|
}
|
|
101
173
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
174
|
+
if (route === "GET /v1/status") {
|
|
175
|
+
const catalog = await optionalCatalog(scope)
|
|
176
|
+
return sendJson(response, 200, buildStatus({ options: host.options, app: auth.app, runtime: runtimeStatus(), catalog }))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (route === "GET /metrics" && metrics) {
|
|
180
|
+
return sendText(response, 200, "text/plain; version=0.0.4; charset=utf-8", metrics.render(runtimeStatus()))
|
|
181
|
+
}
|
|
105
182
|
|
|
106
183
|
if (route === "GET /v1/models" || modelId !== undefined) {
|
|
107
184
|
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
|
|
@@ -136,6 +213,7 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
136
213
|
if (sampled && sampled.dropped.length > 0) {
|
|
137
214
|
info.dropped = sampled.dropped.join(",")
|
|
138
215
|
response.setHeader(DROPPED_HEADER, info.dropped)
|
|
216
|
+
metrics?.dropped(sampled.dropped)
|
|
139
217
|
}
|
|
140
218
|
const call: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
|
|
141
219
|
const { exchange } = parsed
|
|
@@ -179,9 +257,16 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
179
257
|
|
|
180
258
|
/** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
|
|
181
259
|
return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
260
|
+
// Generated here, never taken from the client: the id is echoed and logged, so it must not be attacker-controlled text.
|
|
261
|
+
const id = crypto.randomUUID().replaceAll("-", "").slice(0, 12)
|
|
262
|
+
if (!response.headersSent) response.setHeader(REQUEST_ID_HEADER, id)
|
|
263
|
+
return withRequestId(id, () => run(request, response, id))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function run(request: IncomingMessage, response: ServerResponse, id: string): Promise<void> {
|
|
182
267
|
const started = Date.now()
|
|
183
|
-
const id = crypto.randomUUID().slice(0, 8)
|
|
184
268
|
const info: RequestInfo = { route: "unparsed" }
|
|
269
|
+
metrics?.requestStarted()
|
|
185
270
|
const scope = beginRequest(request, response, host.options.requestTimeoutMs)
|
|
186
271
|
let outcome = "ok"
|
|
187
272
|
let error: string | undefined
|
|
@@ -221,8 +306,11 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
221
306
|
try {
|
|
222
307
|
finishRequest(request, response)
|
|
223
308
|
} catch {}
|
|
224
|
-
|
|
225
|
-
|
|
309
|
+
const ms = Date.now() - started
|
|
310
|
+
metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
311
|
+
// Probes and scrapes that succeed are not worth a line each.
|
|
312
|
+
if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
|
|
313
|
+
const fields = { id, route: info.route, app: info.app, model: info.model, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
|
|
226
314
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
227
315
|
else log("request", fields)
|
|
228
316
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
* requests in flight fail with a 502 instead of waiting for their deadline;
|
|
20
20
|
* - the session store (src/session/state.ts), which outlives the listener and owns the request
|
|
21
21
|
* guards, their cleanup timers included;
|
|
22
|
-
*
|
|
22
|
+
* - the history sweep state: every registered request session is indexed in the host's plugin
|
|
23
23
|
* storage and, at most every `SWEEP_INTERVAL_MS`, a sweep prunes old ones past the `history`
|
|
24
|
-
* limits (src/session/prune.ts). Both start from request handling (the store's `register`)
|
|
24
|
+
* limits (src/session/prune.ts). Both start from request handling (the store's `register`);
|
|
25
|
+
* - the metrics registry (src/metrics.ts) and the runtime start time, so counters and uptime
|
|
26
|
+
* outlive any one generation. `processMetrics` / `processStatus` read them for `/metrics`,
|
|
27
|
+
* `/v1/status` and the discovery pages.
|
|
25
28
|
*
|
|
26
29
|
* Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
|
|
27
30
|
* rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
|
|
@@ -43,7 +46,8 @@
|
|
|
43
46
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
|
44
47
|
import { Cause, Effect, Stream } from "effect"
|
|
45
48
|
import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
|
|
46
|
-
import { errorMessage, log, logError } from "./log.js"
|
|
49
|
+
import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
|
|
50
|
+
import { createMetrics, type Metrics, type RuntimeSnapshot } from "./metrics.js"
|
|
47
51
|
import { ensureAppDir } from "./session/history.js"
|
|
48
52
|
import type { HistoryPort } from "./session/host.js"
|
|
49
53
|
import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
|
|
@@ -96,8 +100,12 @@ const SWEEP_DEADLINE_MS = 60_000
|
|
|
96
100
|
const BACKEND_TIMEOUT_MS = 15_000
|
|
97
101
|
const BACKEND_POLL_MS = 25
|
|
98
102
|
|
|
99
|
-
/**
|
|
100
|
-
const
|
|
103
|
+
/** How often a listener that could not bind tries again, and how often it repeats the error while it still cannot (#87). */
|
|
104
|
+
const BIND_RETRY_MS = 15_000
|
|
105
|
+
const BIND_REMINDER_MS = 5 * 60_000
|
|
106
|
+
|
|
107
|
+
/** 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. */
|
|
108
|
+
const VERSION = 6
|
|
101
109
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
102
110
|
|
|
103
111
|
interface Listener {
|
|
@@ -107,6 +115,11 @@ interface Listener {
|
|
|
107
115
|
fingerprint: string
|
|
108
116
|
mismatchLogged: boolean
|
|
109
117
|
bound: boolean
|
|
118
|
+
/** Failed bind attempts since the last success: while this is above 0 nothing receives requests on the port. */
|
|
119
|
+
bindFailures: number
|
|
120
|
+
/** When the bind failure was last logged (epoch ms). */
|
|
121
|
+
bindLoggedAt: number
|
|
122
|
+
retryTimer?: ReturnType<typeof setTimeout>
|
|
110
123
|
graceTimer?: ReturnType<typeof setTimeout>
|
|
111
124
|
}
|
|
112
125
|
|
|
@@ -140,6 +153,10 @@ interface Runtime {
|
|
|
140
153
|
readonly rejected: Map<string, number>
|
|
141
154
|
/** History sweep rate limit: no sweep starts before `nextAt` (epoch ms) or while one `running`. */
|
|
142
155
|
readonly sweep: { nextAt: number; running: boolean }
|
|
156
|
+
/** Process-wide counters, shared by every generation (see src/metrics.ts). */
|
|
157
|
+
readonly metrics: Metrics
|
|
158
|
+
/** When this runtime was created, epoch ms. */
|
|
159
|
+
readonly startedAt: number
|
|
143
160
|
listener?: Listener
|
|
144
161
|
/** Present while a fiber is subscribed to the server event bus. */
|
|
145
162
|
pump?: Pump
|
|
@@ -149,6 +166,8 @@ const REJECTED: Registration = { accepted: false, dispose() {}, confirmed: () =>
|
|
|
149
166
|
|
|
150
167
|
/** Used when a foreign-version runtime owns the process: never registered into, never dispatched to. */
|
|
151
168
|
const detachedSessions = createSessionStore()
|
|
169
|
+
const detachedMetrics = createMetrics()
|
|
170
|
+
const detachedStartedAt = Date.now()
|
|
152
171
|
let foreignVersionLogged = false
|
|
153
172
|
|
|
154
173
|
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
@@ -163,6 +182,8 @@ function runtime(): Runtime | undefined {
|
|
|
163
182
|
confirmed: new Set(),
|
|
164
183
|
rejected: new Map(),
|
|
165
184
|
sweep: { nextAt: 0, running: false },
|
|
185
|
+
metrics: createMetrics(),
|
|
186
|
+
startedAt: Date.now(),
|
|
166
187
|
}
|
|
167
188
|
slot[KEY] = created
|
|
168
189
|
return created
|
|
@@ -185,9 +206,12 @@ function trackSession(rt: Runtime, app: string, sessionID: string): void {
|
|
|
185
206
|
if (!history) return logError("history.index_skipped", { app, sessionID, reason: "no live generation" })
|
|
186
207
|
// No limits, no index: an unbounded index would grow opencode.db by a row per request forever (#85).
|
|
187
208
|
if (history.limits.maxSessions === undefined && history.limits.maxAgeDays === undefined) return
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
.
|
|
209
|
+
// Not about the request that happened to create the session: its log lines carry no request id.
|
|
210
|
+
void outsideRequest(() =>
|
|
211
|
+
recordSession(history.port, app, sessionID, Date.now())
|
|
212
|
+
.catch((error) => logError("history.index_failed", { app, sessionID, error: errorMessage(error) }))
|
|
213
|
+
.then(() => maybeSweep(rt, history)),
|
|
214
|
+
)
|
|
191
215
|
}
|
|
192
216
|
|
|
193
217
|
/**
|
|
@@ -212,10 +236,15 @@ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Pro
|
|
|
212
236
|
timer.unref?.()
|
|
213
237
|
}),
|
|
214
238
|
])
|
|
215
|
-
if (!result)
|
|
239
|
+
if (!result) {
|
|
240
|
+
rt.metrics.sweep("timeout", 0)
|
|
241
|
+
return logError("history.sweep_timeout", { limit: SWEEP_DEADLINE_MS })
|
|
242
|
+
}
|
|
216
243
|
if (result.more) rt.sweep.nextAt = Date.now() + SWEEP_BACKLOG_MS
|
|
244
|
+
rt.metrics.sweep("ok", result.removed)
|
|
217
245
|
log("history.swept", { ...result, durationMs: Date.now() - now })
|
|
218
246
|
} catch (error) {
|
|
247
|
+
rt.metrics.sweep("failed", 0)
|
|
219
248
|
logError("history.sweep_failed", { error: errorMessage(error) })
|
|
220
249
|
} finally {
|
|
221
250
|
clearTimeout(timer)
|
|
@@ -226,6 +255,34 @@ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Pro
|
|
|
226
255
|
/** The session store shared by every generation in the process. */
|
|
227
256
|
export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedSessions
|
|
228
257
|
|
|
258
|
+
/** The metrics registry shared by every generation in the process (a detached one under a foreign-version runtime). */
|
|
259
|
+
export const processMetrics = (): Metrics => runtime()?.metrics ?? detachedMetrics
|
|
260
|
+
|
|
261
|
+
/** What `/v1/status` and the discovery pages report about the process, besides the model catalog. */
|
|
262
|
+
export interface RuntimeStatus extends RuntimeSnapshot {
|
|
263
|
+
readonly runtimeVersion: number
|
|
264
|
+
/** The shared listener's address, and whether it is bound; absent before the first generation registered. */
|
|
265
|
+
readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
|
|
266
|
+
/** The serving generation's Location is a confirmed app directory (else the newest generation serves). */
|
|
267
|
+
readonly servingConfirmed: boolean
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A synchronous snapshot of the runtime: no host call, no filesystem. */
|
|
271
|
+
export function processStatus(): RuntimeStatus {
|
|
272
|
+
const rt = runtime()
|
|
273
|
+
if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false }
|
|
274
|
+
const serving = preferred(rt)
|
|
275
|
+
return {
|
|
276
|
+
runtimeVersion: rt.version,
|
|
277
|
+
generations: rt.generations.length,
|
|
278
|
+
pumpRunning: rt.pump !== undefined,
|
|
279
|
+
guards: rt.sessions.size(),
|
|
280
|
+
startedAt: rt.startedAt,
|
|
281
|
+
servingConfirmed: serving !== undefined && rt.confirmed.has(serving.directory),
|
|
282
|
+
...(rt.listener ? { listener: { host: rt.listener.listen.host, port: rt.listener.listen.port, bound: rt.listener.bound } } : {}),
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
229
286
|
/** The generation that serves requests and supplies the catalog: the newest confirmed app-directory one, else the newest. */
|
|
230
287
|
function preferred(rt: Runtime): Generation | undefined {
|
|
231
288
|
for (let index = rt.generations.length - 1; index >= 0; index--) if (rt.confirmed.has(rt.generations[index]!.directory)) return rt.generations[index]
|
|
@@ -264,7 +321,7 @@ const unavailable = (response: ServerResponse, message: string) => {
|
|
|
264
321
|
}
|
|
265
322
|
|
|
266
323
|
function startListener(rt: Runtime, listen: { host: string; port: number }, fingerprint: string): Listener {
|
|
267
|
-
const listener: Listener = { server: createServer(), listen, fingerprint, mismatchLogged: false, bound: false }
|
|
324
|
+
const listener: Listener = { server: createServer(), listen, fingerprint, mismatchLogged: false, bound: false, bindFailures: 0, bindLoggedAt: 0 }
|
|
268
325
|
listener.server.on("request", (request, response) => {
|
|
269
326
|
const current = preferred(rt)
|
|
270
327
|
if (!current) return unavailable(response, "plugboard is reloading")
|
|
@@ -277,22 +334,39 @@ function startListener(rt: Runtime, listen: { host: string; port: number }, fing
|
|
|
277
334
|
})
|
|
278
335
|
listener.server.on("error", (error: NodeJS.ErrnoException) => {
|
|
279
336
|
if (listener.bound) return logError("listener.error", { code: error.code, error: error.message })
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
337
|
+
// Not bound: another process (or an older runtime) owns the port, so nothing here receives
|
|
338
|
+
// requests while clients may be sending their keys and prompts to whoever holds it. Say so
|
|
339
|
+
// loudly (error level, again every few minutes), keep trying, and mention it on every
|
|
340
|
+
// generation that registers meanwhile (`registerGeneration`).
|
|
341
|
+
listener.bindFailures++
|
|
342
|
+
if (Date.now() - listener.bindLoggedAt >= BIND_REMINDER_MS) {
|
|
343
|
+
listener.bindLoggedAt = Date.now()
|
|
344
|
+
logError(error.code === "EADDRINUSE" ? "listener.address_in_use" : "listener.failed", {
|
|
345
|
+
listen: `${listen.host}:${listen.port}`,
|
|
346
|
+
error: error.message,
|
|
347
|
+
status: "not serving: clients of this port reach another process",
|
|
348
|
+
action: "stop the process that holds the port or change `listen`; plugboard retries every 15s",
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
listener.retryTimer = setTimeout(() => {
|
|
352
|
+
listener.retryTimer = undefined
|
|
353
|
+
if (rt.listener === listener && !listener.bound) listener.server.listen(listen.port, listen.host)
|
|
354
|
+
}, BIND_RETRY_MS)
|
|
355
|
+
listener.retryTimer.unref?.()
|
|
285
356
|
})
|
|
286
|
-
listener.server.
|
|
357
|
+
listener.server.on("listening", () => {
|
|
287
358
|
listener.bound = true
|
|
288
|
-
log("listener.listening", { listen: `${listen.host}:${listen.port}
|
|
359
|
+
log("listener.listening", { listen: `${listen.host}:${listen.port}`, ...(listener.bindFailures > 0 ? { recoveredAfterFailedBinds: listener.bindFailures } : {}) })
|
|
360
|
+
listener.bindFailures = 0
|
|
289
361
|
})
|
|
362
|
+
listener.server.listen(listen.port, listen.host)
|
|
290
363
|
return listener
|
|
291
364
|
}
|
|
292
365
|
|
|
293
366
|
function closeListener(rt: Runtime, listener: Listener): void {
|
|
294
367
|
if (rt.listener !== listener) return
|
|
295
368
|
rt.listener = undefined
|
|
369
|
+
clearTimeout(listener.retryTimer)
|
|
296
370
|
try {
|
|
297
371
|
rt.pump?.stop()
|
|
298
372
|
} catch {}
|
|
@@ -356,6 +430,13 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
356
430
|
}
|
|
357
431
|
if (listener.graceTimer) clearTimeout(listener.graceTimer)
|
|
358
432
|
listener.graceTimer = undefined
|
|
433
|
+
if (listener.bindFailures > 0) {
|
|
434
|
+
logError("listener.not_serving", {
|
|
435
|
+
listen: `${listen.host}:${listen.port}`,
|
|
436
|
+
status: "another process holds the port; this generation serves nothing",
|
|
437
|
+
action: "stop that process or change `listen`",
|
|
438
|
+
})
|
|
439
|
+
}
|
|
359
440
|
const generation: Generation = {
|
|
360
441
|
directory,
|
|
361
442
|
handler: input.handler,
|
|
@@ -367,6 +448,9 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
367
448
|
activated: rt.confirmed.has(directory),
|
|
368
449
|
}
|
|
369
450
|
rt.generations.push(generation)
|
|
451
|
+
// Accepted generations share the live options, so this is the live `logFormat` (log lines are process-wide).
|
|
452
|
+
const options = input.options as { readonly logFormat?: unknown } | null
|
|
453
|
+
setLogFormat(options?.logFormat === "json" ? "json" : "text")
|
|
370
454
|
return {
|
|
371
455
|
accepted: true,
|
|
372
456
|
confirmed: () => rt.confirmed.has(directory),
|
|
@@ -399,7 +483,8 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
399
483
|
export function ensurePump(subscribe: () => Stream.Stream<unknown, unknown, never>): void {
|
|
400
484
|
const rt = runtime()
|
|
401
485
|
if (!rt || rt.pump) return
|
|
402
|
-
|
|
486
|
+
// The pump serves every request; its log lines must not carry the id of the request that started it.
|
|
487
|
+
const fiber = outsideRequest(() => Effect.runFork(subscribe().pipe(Stream.runForEach((event) => Effect.sync(() => rt.sessions.dispatch(event))))))
|
|
403
488
|
const pump: Pump = {
|
|
404
489
|
stopped: false,
|
|
405
490
|
stop: () => {
|