opencode-plugboard 0.4.1 → 0.5.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 -3
- package/src/config.ts +11 -1
- 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 +96 -9
- package/src/runtime.ts +62 -10
- package/src/session/host.ts +46 -2
- package/src/session/state.ts +3 -0
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.5.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,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "
|
|
7
|
-
"keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions"],
|
|
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.",
|
|
7
|
+
"keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions", "responses-api", "llm-gateway", "llm-proxy"],
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/jjjermiah/opencode-plugboard.git"
|
package/src/config.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface PlugboardOptions {
|
|
|
20
20
|
readonly history: HistoryLimits
|
|
21
21
|
/** Drop sampling parameters the target model is known to reject (reported in `x-plugboard-dropped-params`). On by default; off forwards them as sent. */
|
|
22
22
|
readonly dropUnsupportedParams: boolean
|
|
23
|
+
/** Log line format: `text` (`key=value`, the default) or `json` (one object per line). Process-wide: the live generation's value applies. */
|
|
24
|
+
readonly logFormat: "text" | "json"
|
|
25
|
+
/** Serve `GET /metrics` (Prometheus text). Off by default; same authentication as `/v1`. */
|
|
26
|
+
readonly metrics: boolean
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
@@ -31,7 +35,7 @@ const DEFAULTS = {
|
|
|
31
35
|
requestTimeoutMs: 600_000,
|
|
32
36
|
} as const
|
|
33
37
|
|
|
34
|
-
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams"])
|
|
38
|
+
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
|
|
35
39
|
const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
|
|
36
40
|
/** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
|
|
37
41
|
const MAX_AGE_DAYS = 36_500
|
|
@@ -123,6 +127,10 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
123
127
|
if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
|
|
124
128
|
throw new Error("plugboard option `dropUnsupportedParams` must be a boolean")
|
|
125
129
|
|
|
130
|
+
if (input.logFormat !== undefined && input.logFormat !== "text" && input.logFormat !== "json")
|
|
131
|
+
throw new Error("plugboard option `logFormat` must be `text` or `json`")
|
|
132
|
+
if (input.metrics !== undefined && typeof input.metrics !== "boolean") throw new Error("plugboard option `metrics` must be a boolean")
|
|
133
|
+
|
|
126
134
|
const historyInput = input.history
|
|
127
135
|
if (historyInput !== undefined && !isRecord(historyInput)) throw new Error("plugboard option `history` must be an object")
|
|
128
136
|
for (const key of Object.keys(historyInput ?? {}))
|
|
@@ -140,6 +148,8 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
140
148
|
remoteImages: input.remoteImages === true,
|
|
141
149
|
history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
|
|
142
150
|
dropUnsupportedParams: input.dropUnsupportedParams !== false,
|
|
151
|
+
logFormat: input.logFormat === "json" ? "json" : "text",
|
|
152
|
+
metrics: input.metrics === true,
|
|
143
153
|
}
|
|
144
154
|
}
|
|
145
155
|
|
package/src/contract.ts
CHANGED
|
@@ -20,6 +20,18 @@ export interface ModelRef {
|
|
|
20
20
|
readonly variant?: string
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* One price entry, USD per million tokens (the host's `Model.Cost`). An entry with `aboveContext`
|
|
25
|
+
* applies once the prompt context exceeds that many tokens; the entry without it is the base price.
|
|
26
|
+
*/
|
|
27
|
+
export interface CatalogCost {
|
|
28
|
+
readonly input: number
|
|
29
|
+
readonly output: number
|
|
30
|
+
readonly cacheRead: number
|
|
31
|
+
readonly cacheWrite: number
|
|
32
|
+
readonly aboveContext?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
/** One enabled model as the server sees it: what model listing and the request resolver work from. */
|
|
24
36
|
export interface CatalogModel {
|
|
25
37
|
readonly providerID: string
|
|
@@ -28,6 +40,15 @@ export interface CatalogModel {
|
|
|
28
40
|
readonly variants: ReadonlyArray<string>
|
|
29
41
|
readonly capabilities?: { readonly tools?: boolean; readonly image?: boolean }
|
|
30
42
|
readonly contextLimit?: number
|
|
43
|
+
/** Token limits as the host reports them (`limit.{context,input,output}`). */
|
|
44
|
+
readonly limits?: { readonly context?: number; readonly input?: number; readonly output?: number }
|
|
45
|
+
/** Modalities the model accepts and produces (`capabilities.input` / `.output`, for example `text`, `image`). */
|
|
46
|
+
readonly modalities?: { readonly input: ReadonlyArray<string>; readonly output: ReadonlyArray<string> }
|
|
47
|
+
readonly cost?: ReadonlyArray<CatalogCost>
|
|
48
|
+
readonly status?: "alpha" | "beta" | "deprecated" | "active"
|
|
49
|
+
readonly family?: string
|
|
50
|
+
/** Release date, epoch milliseconds. */
|
|
51
|
+
readonly released?: number
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
// ---------------------------------------------------------------- request
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run discovery and status documents: `GET /v1` (JSON), `GET /` (small HTML page) and the
|
|
3
|
+
* authenticated `GET /v1/status`. Pure builders over the options, the catalog and a runtime
|
|
4
|
+
* snapshot; no I/O, no host calls. `src/openai/route.ts` loads the catalog and sends the result.
|
|
5
|
+
*
|
|
6
|
+
* Disclosure rule: the discovery pages are reachable without a key, so they only describe the
|
|
7
|
+
* configuration when that is safe. On a keyless endpoint (loopback only) everything is shown.
|
|
8
|
+
* When `keys` are configured, a caller without an `Authorization` header sees the address, the
|
|
9
|
+
* authentication requirement, the endpoint list and a curl example, and nothing about apps,
|
|
10
|
+
* allowlist, aliases or the catalog. A caller with a valid key additionally sees its own app name
|
|
11
|
+
* (never the other apps), the allowlist, the aliases and the catalog suggestion. Keys themselves
|
|
12
|
+
* never appear anywhere, and nothing here echoes a request header or path.
|
|
13
|
+
*
|
|
14
|
+
* The HTML page is built from the same document as the JSON, has no scripts or external assets,
|
|
15
|
+
* and every dynamic value goes through `escapeHtml`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ServerResponse } from "node:http"
|
|
19
|
+
import type { PlugboardOptions } from "./config.js"
|
|
20
|
+
import type { CatalogModel } from "./contract.js"
|
|
21
|
+
import { listAvailableModels } from "./models.js"
|
|
22
|
+
import type { RuntimeSnapshot } from "./metrics.js"
|
|
23
|
+
|
|
24
|
+
const NAME = "opencode-plugboard"
|
|
25
|
+
/** Providers listed in the catalog summary; the rest are counted, not listed. */
|
|
26
|
+
const MAX_PROVIDERS = 50
|
|
27
|
+
const EXAMPLES_PER_PROVIDER = 3
|
|
28
|
+
const PLACEHOLDER_MODEL = "provider/model"
|
|
29
|
+
|
|
30
|
+
/** A text response with the headers every page of this module shares. */
|
|
31
|
+
export function sendText(response: ServerResponse, status: number, contentType: string, body: string, headers: Record<string, string> = {}): void {
|
|
32
|
+
if (response.headersSent || response.destroyed) return
|
|
33
|
+
response.writeHead(status, {
|
|
34
|
+
"content-type": contentType,
|
|
35
|
+
"content-length": Buffer.byteLength(body),
|
|
36
|
+
"x-content-type-options": "nosniff",
|
|
37
|
+
"cache-control": "no-store",
|
|
38
|
+
...headers,
|
|
39
|
+
})
|
|
40
|
+
response.end(body)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const escapeHtml = (value: unknown): string =>
|
|
44
|
+
String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char)
|
|
45
|
+
|
|
46
|
+
/** `http://host:port` for a listen address; an IPv6 literal gets brackets. */
|
|
47
|
+
function origin(listen: PlugboardOptions["listen"]): string {
|
|
48
|
+
const host = listen.host.includes(":") && !listen.host.startsWith("[") ? `[${listen.host}]` : listen.host
|
|
49
|
+
return `http://${host}:${listen.port}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const shellQuote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
|
|
53
|
+
|
|
54
|
+
export interface Endpoint {
|
|
55
|
+
readonly method: string
|
|
56
|
+
readonly path: string
|
|
57
|
+
readonly auth: boolean
|
|
58
|
+
readonly description: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function endpoints(options: PlugboardOptions): Endpoint[] {
|
|
62
|
+
return [
|
|
63
|
+
{ method: "GET", path: "/", auth: false, description: "This page." },
|
|
64
|
+
{ method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
|
|
65
|
+
{ method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
|
|
66
|
+
{ method: "GET", path: "/v1/models", auth: true, description: "Models this app may call." },
|
|
67
|
+
{ method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
|
|
68
|
+
{ method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
|
|
69
|
+
{ method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
|
|
70
|
+
{ method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
|
|
71
|
+
...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ModelsState = "ready" | "no_models_configured" | "none_available" | "catalog_unavailable"
|
|
76
|
+
|
|
77
|
+
interface ProviderSummary {
|
|
78
|
+
readonly id: string
|
|
79
|
+
readonly models: number
|
|
80
|
+
readonly examples: ReadonlyArray<string>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The `GET /v1` document. Fields marked "detail" are present only when the caller may see the configuration. */
|
|
84
|
+
export interface Discovery {
|
|
85
|
+
readonly name: string
|
|
86
|
+
readonly baseUrl: string
|
|
87
|
+
readonly listen: { readonly host: string; readonly port: number }
|
|
88
|
+
readonly authentication: { readonly required: boolean; readonly scheme?: "Bearer" }
|
|
89
|
+
readonly hint?: string
|
|
90
|
+
/** detail: the caller's own app name, or `default` on a keyless endpoint. */
|
|
91
|
+
readonly apps?: ReadonlyArray<string>
|
|
92
|
+
/** detail */
|
|
93
|
+
readonly models?: {
|
|
94
|
+
readonly state: ModelsState
|
|
95
|
+
readonly allowlist: ReadonlyArray<string>
|
|
96
|
+
readonly aliases: Readonly<Record<string, string>>
|
|
97
|
+
/** Ids `GET /v1/models` lists (allowlist intersected with the catalog, plus aliases). */
|
|
98
|
+
readonly available?: number
|
|
99
|
+
readonly hint?: string
|
|
100
|
+
}
|
|
101
|
+
/** detail: a paste-ready `models` option built from the catalog (one wildcard per provider). */
|
|
102
|
+
readonly suggestedModels?: ReadonlyArray<string>
|
|
103
|
+
/** detail: what the catalog offers, by provider. */
|
|
104
|
+
readonly catalog?: { readonly providers: ReadonlyArray<ProviderSummary>; readonly moreProviders?: number }
|
|
105
|
+
readonly endpoints: ReadonlyArray<Endpoint>
|
|
106
|
+
readonly example: { readonly curl: string }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface DiscoveryInput {
|
|
110
|
+
readonly options: PlugboardOptions
|
|
111
|
+
/** The authenticated app; undefined when the caller sent no credentials (only possible when `keys` are set). */
|
|
112
|
+
readonly app?: string
|
|
113
|
+
/** The serving Location's catalog; undefined when it could not be loaded (or was not asked for). */
|
|
114
|
+
readonly catalog?: ReadonlyArray<CatalogModel>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function summarize(catalog: ReadonlyArray<CatalogModel>): { providers: ProviderSummary[]; more: number } {
|
|
118
|
+
const byProvider = new Map<string, string[]>()
|
|
119
|
+
for (const model of catalog) {
|
|
120
|
+
const ids = byProvider.get(model.providerID) ?? []
|
|
121
|
+
if (!ids.includes(model.id)) ids.push(model.id)
|
|
122
|
+
byProvider.set(model.providerID, ids)
|
|
123
|
+
}
|
|
124
|
+
const all = [...byProvider.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
125
|
+
return {
|
|
126
|
+
providers: all.slice(0, MAX_PROVIDERS).map(([id, ids]) => ({ id, models: ids.length, examples: ids.slice(0, EXAMPLES_PER_PROVIDER).map((model) => `${id}/${model}`) })),
|
|
127
|
+
more: Math.max(0, all.length - MAX_PROVIDERS),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildDiscovery({ options, app, catalog }: DiscoveryInput): Discovery {
|
|
132
|
+
const keyed = Object.keys(options.keys).length > 0
|
|
133
|
+
const base = `${origin(options.listen)}/v1`
|
|
134
|
+
const detail = !keyed || app !== undefined
|
|
135
|
+
const auth = keyed ? ({ required: true, scheme: "Bearer" } as const) : { required: false }
|
|
136
|
+
|
|
137
|
+
const listed = detail && catalog ? listAvailableModels(options, catalog) : []
|
|
138
|
+
const summary = detail && catalog ? summarize(catalog) : undefined
|
|
139
|
+
const suggested = summary?.providers.map((provider) => `${provider.id}/*`)
|
|
140
|
+
|
|
141
|
+
let state: ModelsState = "ready"
|
|
142
|
+
let modelsHint: string | undefined
|
|
143
|
+
const example = JSON.stringify(suggested && suggested.length > 0 ? { models: suggested } : { models: ["provider/*"] })
|
|
144
|
+
if (options.models.length === 0) {
|
|
145
|
+
state = "no_models_configured"
|
|
146
|
+
modelsHint = `The \`models\` option is empty, so every model call fails with 403 no_models_configured. Add an allowlist to the plugin options, for example ${example}`
|
|
147
|
+
} else if (!catalog) {
|
|
148
|
+
state = "catalog_unavailable"
|
|
149
|
+
modelsHint = "The model catalog could not be loaded from the OpenCode server."
|
|
150
|
+
} else if (listed.length === 0) {
|
|
151
|
+
state = "none_available"
|
|
152
|
+
modelsHint = "Nothing in the `models` allowlist matches a model the server offers. Check the provider and model ids against the catalog below."
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const first = listed[0]?.id ?? PLACEHOLDER_MODEL
|
|
156
|
+
const body = JSON.stringify({ model: first, messages: [{ role: "user", content: "Say hello." }] })
|
|
157
|
+
const curl = [`curl -s ${base}/chat/completions`, `-H "Content-Type: application/json"`, ...(keyed ? [`-H "Authorization: Bearer $PLUGBOARD_KEY"`] : []), `-d ${shellQuote(body)}`].join(" ")
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
name: NAME,
|
|
161
|
+
baseUrl: base,
|
|
162
|
+
listen: { host: options.listen.host, port: options.listen.port },
|
|
163
|
+
authentication: auth,
|
|
164
|
+
...(detail
|
|
165
|
+
? {}
|
|
166
|
+
: { hint: "Authentication is required. Send `Authorization: Bearer <key>` to see this app's configuration: the allowlist, aliases and a model suggestion." }),
|
|
167
|
+
...(detail ? { apps: [app ?? "default"] } : {}),
|
|
168
|
+
...(detail
|
|
169
|
+
? {
|
|
170
|
+
models: {
|
|
171
|
+
state,
|
|
172
|
+
allowlist: options.models,
|
|
173
|
+
aliases: options.aliases,
|
|
174
|
+
...(catalog ? { available: listed.length } : {}),
|
|
175
|
+
...(modelsHint ? { hint: modelsHint } : {}),
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
: {}),
|
|
179
|
+
...(suggested && suggested.length > 0 ? { suggestedModels: suggested } : {}),
|
|
180
|
+
...(summary ? { catalog: { providers: summary.providers, ...(summary.more > 0 ? { moreProviders: summary.more } : {}) } } : {}),
|
|
181
|
+
endpoints: endpoints(options),
|
|
182
|
+
example: { curl },
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pre = (text: string) => `<pre>${escapeHtml(text)}</pre>`
|
|
187
|
+
const list = (items: ReadonlyArray<string>) => `<ul>${items.map((item) => `<li><code>${escapeHtml(item)}</code></li>`).join("")}</ul>`
|
|
188
|
+
|
|
189
|
+
/** The `GET /` page: the discovery document as small, script-free HTML. */
|
|
190
|
+
export function renderDiscoveryHtml(doc: Discovery): string {
|
|
191
|
+
const sections: string[] = []
|
|
192
|
+
sections.push(`<p>Base URL: <code>${escapeHtml(doc.baseUrl)}</code></p>`)
|
|
193
|
+
sections.push(
|
|
194
|
+
`<p>Authentication: ${doc.authentication.required ? "required. Send <code>Authorization: Bearer <key></code>." : "none (keyless, loopback only)."}</p>`,
|
|
195
|
+
)
|
|
196
|
+
if (doc.hint) sections.push(`<p class="hint">${escapeHtml(doc.hint)}</p>`)
|
|
197
|
+
if (doc.apps) sections.push(`<h2>App</h2>${list(doc.apps)}`)
|
|
198
|
+
if (doc.models) {
|
|
199
|
+
const models = doc.models
|
|
200
|
+
sections.push(`<h2>Models</h2>`)
|
|
201
|
+
if (models.hint) sections.push(`<p class="hint">${escapeHtml(models.hint)}</p>`)
|
|
202
|
+
if (models.available !== undefined) sections.push(`<p>${escapeHtml(models.available)} model ids available.</p>`)
|
|
203
|
+
sections.push(`<h3>Allowlist</h3>${models.allowlist.length > 0 ? list(models.allowlist) : "<p>Empty.</p>"}`)
|
|
204
|
+
const aliases = Object.entries(models.aliases)
|
|
205
|
+
if (aliases.length > 0) sections.push(`<h3>Aliases</h3>${list(aliases.map(([alias, target]) => `${alias} -> ${target}`))}`)
|
|
206
|
+
}
|
|
207
|
+
if (doc.suggestedModels) {
|
|
208
|
+
sections.push(`<h2>Suggested <code>models</code> option</h2>${pre(`"models": ${JSON.stringify(doc.suggestedModels)}`)}`)
|
|
209
|
+
}
|
|
210
|
+
if (doc.catalog) {
|
|
211
|
+
const rows = doc.catalog.providers
|
|
212
|
+
.map(
|
|
213
|
+
(provider) =>
|
|
214
|
+
`<tr><td><code>${escapeHtml(provider.id)}</code></td><td>${escapeHtml(provider.models)}</td><td>${provider.examples.map((example) => `<code>${escapeHtml(example)}</code>`).join(" ")}</td></tr>`,
|
|
215
|
+
)
|
|
216
|
+
.join("")
|
|
217
|
+
const more = doc.catalog.moreProviders ? `<p>${escapeHtml(doc.catalog.moreProviders)} more providers not listed.</p>` : ""
|
|
218
|
+
sections.push(`<h2>Catalog</h2><table><tr><th>Provider</th><th>Models</th><th>Examples</th></tr>${rows}</table>${more}`)
|
|
219
|
+
}
|
|
220
|
+
sections.push(`<h2>Try it</h2>${pre(doc.example.curl)}`)
|
|
221
|
+
sections.push(
|
|
222
|
+
`<h2>Endpoints</h2><table><tr><th>Method</th><th>Path</th><th>Key</th><th></th></tr>${doc.endpoints
|
|
223
|
+
.map(
|
|
224
|
+
(endpoint) =>
|
|
225
|
+
`<tr><td>${escapeHtml(endpoint.method)}</td><td><code>${escapeHtml(endpoint.path)}</code></td><td>${endpoint.auth ? "yes" : "no"}</td><td>${escapeHtml(endpoint.description)}</td></tr>`,
|
|
226
|
+
)
|
|
227
|
+
.join("")}</table>`,
|
|
228
|
+
)
|
|
229
|
+
return `<!doctype html>
|
|
230
|
+
<html lang="en">
|
|
231
|
+
<head>
|
|
232
|
+
<meta charset="utf-8">
|
|
233
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
234
|
+
<title>${escapeHtml(doc.name)}</title>
|
|
235
|
+
<style>
|
|
236
|
+
body{font:16px/1.5 system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem;color:#1a1a1a}
|
|
237
|
+
code,pre{font-family:ui-monospace,monospace;font-size:.9em}
|
|
238
|
+
pre{background:#f4f4f4;padding:.75rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
|
|
239
|
+
table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:.25rem .5rem;text-align:left;vertical-align:top}
|
|
240
|
+
.hint{border-left:4px solid #c80;background:#fff7e6;padding:.5rem .75rem}
|
|
241
|
+
</style>
|
|
242
|
+
</head>
|
|
243
|
+
<body>
|
|
244
|
+
<h1>${escapeHtml(doc.name)}</h1>
|
|
245
|
+
${sections.join("\n")}
|
|
246
|
+
<p><a href="/v1">JSON version of this page</a></p>
|
|
247
|
+
</body>
|
|
248
|
+
</html>
|
|
249
|
+
`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `GET /v1/status`: authenticated runtime facts. `models` is absent when the catalog could not be loaded. */
|
|
253
|
+
export function buildStatus(input: {
|
|
254
|
+
readonly options: PlugboardOptions
|
|
255
|
+
readonly app: string
|
|
256
|
+
readonly runtime: RuntimeSnapshot & {
|
|
257
|
+
readonly runtimeVersion: number
|
|
258
|
+
readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
|
|
259
|
+
readonly servingConfirmed: boolean
|
|
260
|
+
}
|
|
261
|
+
readonly catalog?: ReadonlyArray<CatalogModel>
|
|
262
|
+
readonly now?: number
|
|
263
|
+
}): object {
|
|
264
|
+
const { options, app, runtime, catalog } = input
|
|
265
|
+
const now = input.now ?? Date.now()
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
app,
|
|
269
|
+
runtimeVersion: runtime.runtimeVersion,
|
|
270
|
+
startedAt: new Date(runtime.startedAt).toISOString(),
|
|
271
|
+
uptimeSeconds: Math.max(0, Math.floor((now - runtime.startedAt) / 1000)),
|
|
272
|
+
listen: { host: options.listen.host, port: options.listen.port, bound: runtime.listener?.bound ?? false },
|
|
273
|
+
generations: runtime.generations,
|
|
274
|
+
servingConfirmed: runtime.servingConfirmed,
|
|
275
|
+
eventPump: { running: runtime.pumpRunning },
|
|
276
|
+
sessionGuards: runtime.guards,
|
|
277
|
+
models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog).length } : { error: "catalog_unavailable" },
|
|
278
|
+
metrics: options.metrics,
|
|
279
|
+
}
|
|
280
|
+
}
|
package/src/log.ts
CHANGED
|
@@ -1,28 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tiny structured logger: one line per event
|
|
2
|
+
* Tiny structured logger: one line per event.
|
|
3
|
+
*
|
|
4
|
+
* text (default) `[opencode-plugboard] event key=value ...`
|
|
5
|
+
* json `{"time":"...","level":"info","service":"opencode-plugboard","event":"request","id":"...",...}`
|
|
3
6
|
*
|
|
4
7
|
* Callers pass only identifiers and outcomes (event name, request id, app, model, duration, an
|
|
5
8
|
* error message). Never pass API keys, request or response bodies, URLs or raw provider
|
|
6
9
|
* objects: nothing here redacts them.
|
|
10
|
+
*
|
|
11
|
+
* The format is process-wide (`setLogFormat`, set by the runtime from the live generation's
|
|
12
|
+
* `logFormat` option). A line logged while a request is being handled also carries that request's
|
|
13
|
+
* id (`id`, the value of the `x-plugboard-request-id` response header): `withRequestId` scopes it
|
|
14
|
+
* to the request's async chain (node:async_hooks), and `outsideRequest` detaches work that merely
|
|
15
|
+
* started from a request but is not about it (the history sweep, the event pump).
|
|
16
|
+
*
|
|
17
|
+
* Both settings sit on `globalThis`, because the module can be evaluated once per Location.
|
|
7
18
|
*/
|
|
8
19
|
|
|
20
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
21
|
+
|
|
9
22
|
export type LogFields = Readonly<Record<string, string | number | boolean | undefined>>
|
|
23
|
+
export type LogFormat = "text" | "json"
|
|
10
24
|
|
|
11
25
|
const PREFIX = "[opencode-plugboard]"
|
|
26
|
+
const SERVICE = "opencode-plugboard"
|
|
27
|
+
const KEY = Symbol.for("opencode-plugboard.log.1")
|
|
28
|
+
|
|
29
|
+
interface LogState {
|
|
30
|
+
format: LogFormat
|
|
31
|
+
readonly requests: AsyncLocalStorage<{ readonly id: string } | undefined>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function state(): LogState {
|
|
35
|
+
const slot = globalThis as Record<symbol, unknown>
|
|
36
|
+
return (slot[KEY] ??= { format: "text", requests: new AsyncLocalStorage() } satisfies LogState) as LogState
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Set the line format for the whole process. */
|
|
40
|
+
export function setLogFormat(format: LogFormat): void {
|
|
41
|
+
state().format = format
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Run `fn` so that every log line emitted from its async chain carries `id`. */
|
|
45
|
+
export const withRequestId = <T>(id: string, fn: () => T): T => state().requests.run({ id }, fn)
|
|
46
|
+
|
|
47
|
+
/** Run `fn` detached from the current request: its log lines carry no request id. */
|
|
48
|
+
export const outsideRequest = <T>(fn: () => T): T => state().requests.run(undefined, fn)
|
|
12
49
|
|
|
13
50
|
function formatValue(value: string | number | boolean): string {
|
|
14
51
|
return typeof value === "string" && !/^[\w.:/#-]+$/.test(value) ? JSON.stringify(value) : String(value)
|
|
15
52
|
}
|
|
16
53
|
|
|
17
|
-
|
|
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
|
+
}
|
|
18
69
|
let line = `${PREFIX} ${event}`
|
|
19
|
-
for (const [key, value] of
|
|
70
|
+
for (const [key, value] of entries) if (value !== undefined) line += ` ${key}=${formatValue(value)}`
|
|
20
71
|
return line
|
|
21
72
|
}
|
|
22
73
|
|
|
23
|
-
export const log = (event: string, fields?: LogFields): void => console.log(format(event, fields))
|
|
74
|
+
export const log = (event: string, fields?: LogFields): void => console.log(format("info", event, fields))
|
|
24
75
|
|
|
25
|
-
export const logError = (event: string, fields?: LogFields): void => console.error(format(event, fields))
|
|
76
|
+
export const logError = (event: string, fields?: LogFields): void => console.error(format("error", event, fields))
|
|
26
77
|
|
|
27
78
|
/** The message of a thrown value, without its stack or attached properties. */
|
|
28
79
|
export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
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,30 @@ 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
|
+
if (modelId === undefined && !known(route)) {
|
|
94
161
|
return fail(response, {
|
|
95
162
|
status: 404,
|
|
96
163
|
kind: "not_found",
|
|
@@ -103,6 +170,15 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
103
170
|
if (isPlugError(auth)) return fail(response, auth)
|
|
104
171
|
info.app = auth.app
|
|
105
172
|
|
|
173
|
+
if (route === "GET /v1/status") {
|
|
174
|
+
const catalog = await optionalCatalog(scope)
|
|
175
|
+
return sendJson(response, 200, buildStatus({ options: host.options, app: auth.app, runtime: runtimeStatus(), catalog }))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (route === "GET /metrics" && metrics) {
|
|
179
|
+
return sendText(response, 200, "text/plain; version=0.0.4; charset=utf-8", metrics.render(runtimeStatus()))
|
|
180
|
+
}
|
|
181
|
+
|
|
106
182
|
if (route === "GET /v1/models" || modelId !== undefined) {
|
|
107
183
|
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
|
|
108
184
|
if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
|
|
@@ -136,6 +212,7 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
136
212
|
if (sampled && sampled.dropped.length > 0) {
|
|
137
213
|
info.dropped = sampled.dropped.join(",")
|
|
138
214
|
response.setHeader(DROPPED_HEADER, info.dropped)
|
|
215
|
+
metrics?.dropped(sampled.dropped)
|
|
139
216
|
}
|
|
140
217
|
const call: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
|
|
141
218
|
const { exchange } = parsed
|
|
@@ -179,9 +256,16 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
179
256
|
|
|
180
257
|
/** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
|
|
181
258
|
return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
259
|
+
// Generated here, never taken from the client: the id is echoed and logged, so it must not be attacker-controlled text.
|
|
260
|
+
const id = crypto.randomUUID().replaceAll("-", "").slice(0, 12)
|
|
261
|
+
if (!response.headersSent) response.setHeader(REQUEST_ID_HEADER, id)
|
|
262
|
+
return withRequestId(id, () => run(request, response, id))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function run(request: IncomingMessage, response: ServerResponse, id: string): Promise<void> {
|
|
182
266
|
const started = Date.now()
|
|
183
|
-
const id = crypto.randomUUID().slice(0, 8)
|
|
184
267
|
const info: RequestInfo = { route: "unparsed" }
|
|
268
|
+
metrics?.requestStarted()
|
|
185
269
|
const scope = beginRequest(request, response, host.options.requestTimeoutMs)
|
|
186
270
|
let outcome = "ok"
|
|
187
271
|
let error: string | undefined
|
|
@@ -221,8 +305,11 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
221
305
|
try {
|
|
222
306
|
finishRequest(request, response)
|
|
223
307
|
} catch {}
|
|
224
|
-
|
|
225
|
-
|
|
308
|
+
const ms = Date.now() - started
|
|
309
|
+
metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
310
|
+
// Probes and scrapes that succeed are not worth a line each.
|
|
311
|
+
if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
|
|
312
|
+
const fields = { id, route: info.route, app: info.app, model: info.model, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
|
|
226
313
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
227
314
|
else log("request", fields)
|
|
228
315
|
}
|
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,8 @@ 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 VERSION =
|
|
103
|
+
/** 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. */
|
|
104
|
+
const VERSION = 5
|
|
101
105
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
102
106
|
|
|
103
107
|
interface Listener {
|
|
@@ -140,6 +144,10 @@ interface Runtime {
|
|
|
140
144
|
readonly rejected: Map<string, number>
|
|
141
145
|
/** History sweep rate limit: no sweep starts before `nextAt` (epoch ms) or while one `running`. */
|
|
142
146
|
readonly sweep: { nextAt: number; running: boolean }
|
|
147
|
+
/** Process-wide counters, shared by every generation (see src/metrics.ts). */
|
|
148
|
+
readonly metrics: Metrics
|
|
149
|
+
/** When this runtime was created, epoch ms. */
|
|
150
|
+
readonly startedAt: number
|
|
143
151
|
listener?: Listener
|
|
144
152
|
/** Present while a fiber is subscribed to the server event bus. */
|
|
145
153
|
pump?: Pump
|
|
@@ -149,6 +157,8 @@ const REJECTED: Registration = { accepted: false, dispose() {}, confirmed: () =>
|
|
|
149
157
|
|
|
150
158
|
/** Used when a foreign-version runtime owns the process: never registered into, never dispatched to. */
|
|
151
159
|
const detachedSessions = createSessionStore()
|
|
160
|
+
const detachedMetrics = createMetrics()
|
|
161
|
+
const detachedStartedAt = Date.now()
|
|
152
162
|
let foreignVersionLogged = false
|
|
153
163
|
|
|
154
164
|
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
@@ -163,6 +173,8 @@ function runtime(): Runtime | undefined {
|
|
|
163
173
|
confirmed: new Set(),
|
|
164
174
|
rejected: new Map(),
|
|
165
175
|
sweep: { nextAt: 0, running: false },
|
|
176
|
+
metrics: createMetrics(),
|
|
177
|
+
startedAt: Date.now(),
|
|
166
178
|
}
|
|
167
179
|
slot[KEY] = created
|
|
168
180
|
return created
|
|
@@ -185,9 +197,12 @@ function trackSession(rt: Runtime, app: string, sessionID: string): void {
|
|
|
185
197
|
if (!history) return logError("history.index_skipped", { app, sessionID, reason: "no live generation" })
|
|
186
198
|
// No limits, no index: an unbounded index would grow opencode.db by a row per request forever (#85).
|
|
187
199
|
if (history.limits.maxSessions === undefined && history.limits.maxAgeDays === undefined) return
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
.
|
|
200
|
+
// Not about the request that happened to create the session: its log lines carry no request id.
|
|
201
|
+
void outsideRequest(() =>
|
|
202
|
+
recordSession(history.port, app, sessionID, Date.now())
|
|
203
|
+
.catch((error) => logError("history.index_failed", { app, sessionID, error: errorMessage(error) }))
|
|
204
|
+
.then(() => maybeSweep(rt, history)),
|
|
205
|
+
)
|
|
191
206
|
}
|
|
192
207
|
|
|
193
208
|
/**
|
|
@@ -212,10 +227,15 @@ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Pro
|
|
|
212
227
|
timer.unref?.()
|
|
213
228
|
}),
|
|
214
229
|
])
|
|
215
|
-
if (!result)
|
|
230
|
+
if (!result) {
|
|
231
|
+
rt.metrics.sweep("timeout", 0)
|
|
232
|
+
return logError("history.sweep_timeout", { limit: SWEEP_DEADLINE_MS })
|
|
233
|
+
}
|
|
216
234
|
if (result.more) rt.sweep.nextAt = Date.now() + SWEEP_BACKLOG_MS
|
|
235
|
+
rt.metrics.sweep("ok", result.removed)
|
|
217
236
|
log("history.swept", { ...result, durationMs: Date.now() - now })
|
|
218
237
|
} catch (error) {
|
|
238
|
+
rt.metrics.sweep("failed", 0)
|
|
219
239
|
logError("history.sweep_failed", { error: errorMessage(error) })
|
|
220
240
|
} finally {
|
|
221
241
|
clearTimeout(timer)
|
|
@@ -226,6 +246,34 @@ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Pro
|
|
|
226
246
|
/** The session store shared by every generation in the process. */
|
|
227
247
|
export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedSessions
|
|
228
248
|
|
|
249
|
+
/** The metrics registry shared by every generation in the process (a detached one under a foreign-version runtime). */
|
|
250
|
+
export const processMetrics = (): Metrics => runtime()?.metrics ?? detachedMetrics
|
|
251
|
+
|
|
252
|
+
/** What `/v1/status` and the discovery pages report about the process, besides the model catalog. */
|
|
253
|
+
export interface RuntimeStatus extends RuntimeSnapshot {
|
|
254
|
+
readonly runtimeVersion: number
|
|
255
|
+
/** The shared listener's address, and whether it is bound; absent before the first generation registered. */
|
|
256
|
+
readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
|
|
257
|
+
/** The serving generation's Location is a confirmed app directory (else the newest generation serves). */
|
|
258
|
+
readonly servingConfirmed: boolean
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A synchronous snapshot of the runtime: no host call, no filesystem. */
|
|
262
|
+
export function processStatus(): RuntimeStatus {
|
|
263
|
+
const rt = runtime()
|
|
264
|
+
if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false }
|
|
265
|
+
const serving = preferred(rt)
|
|
266
|
+
return {
|
|
267
|
+
runtimeVersion: rt.version,
|
|
268
|
+
generations: rt.generations.length,
|
|
269
|
+
pumpRunning: rt.pump !== undefined,
|
|
270
|
+
guards: rt.sessions.size(),
|
|
271
|
+
startedAt: rt.startedAt,
|
|
272
|
+
servingConfirmed: serving !== undefined && rt.confirmed.has(serving.directory),
|
|
273
|
+
...(rt.listener ? { listener: { host: rt.listener.listen.host, port: rt.listener.listen.port, bound: rt.listener.bound } } : {}),
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
229
277
|
/** The generation that serves requests and supplies the catalog: the newest confirmed app-directory one, else the newest. */
|
|
230
278
|
function preferred(rt: Runtime): Generation | undefined {
|
|
231
279
|
for (let index = rt.generations.length - 1; index >= 0; index--) if (rt.confirmed.has(rt.generations[index]!.directory)) return rt.generations[index]
|
|
@@ -367,6 +415,9 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
367
415
|
activated: rt.confirmed.has(directory),
|
|
368
416
|
}
|
|
369
417
|
rt.generations.push(generation)
|
|
418
|
+
// Accepted generations share the live options, so this is the live `logFormat` (log lines are process-wide).
|
|
419
|
+
const options = input.options as { readonly logFormat?: unknown } | null
|
|
420
|
+
setLogFormat(options?.logFormat === "json" ? "json" : "text")
|
|
370
421
|
return {
|
|
371
422
|
accepted: true,
|
|
372
423
|
confirmed: () => rt.confirmed.has(directory),
|
|
@@ -399,7 +450,8 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
399
450
|
export function ensurePump(subscribe: () => Stream.Stream<unknown, unknown, never>): void {
|
|
400
451
|
const rt = runtime()
|
|
401
452
|
if (!rt || rt.pump) return
|
|
402
|
-
|
|
453
|
+
// The pump serves every request; its log lines must not carry the id of the request that started it.
|
|
454
|
+
const fiber = outsideRequest(() => Effect.runFork(subscribe().pipe(Stream.runForEach((event) => Effect.sync(() => rt.sessions.dispatch(event))))))
|
|
403
455
|
const pump: Pump = {
|
|
404
456
|
stopped: false,
|
|
405
457
|
stop: () => {
|
package/src/session/host.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { Plugin } from "@opencode/plugin/effect"
|
|
13
13
|
import type { SessionContext } from "@opencode/plugin/effect/session"
|
|
14
14
|
import { Effect } from "effect"
|
|
15
|
-
import type { CatalogModel } from "../contract.js"
|
|
15
|
+
import type { CatalogCost, CatalogModel } from "../contract.js"
|
|
16
16
|
|
|
17
17
|
export type HostContext = Plugin.Context
|
|
18
18
|
|
|
@@ -132,9 +132,39 @@ export function createHistoryPort(ctx: HostContext): HistoryPort {
|
|
|
132
132
|
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
133
133
|
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
|
|
134
134
|
|
|
135
|
+
const finite = (value: unknown): number | undefined => (typeof value === "number" && Number.isFinite(value) ? value : undefined)
|
|
136
|
+
const strings = (value: unknown): string[] | undefined =>
|
|
137
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : undefined
|
|
138
|
+
const STATUSES = new Set(["alpha", "beta", "deprecated", "active"])
|
|
139
|
+
|
|
140
|
+
/** Host `Model.Cost[]` (USD per million tokens, optional context tier) -> neutral price entries; malformed entries are skipped. */
|
|
141
|
+
function toCosts(raw: unknown): CatalogCost[] | undefined {
|
|
142
|
+
if (!Array.isArray(raw)) return undefined
|
|
143
|
+
const costs: CatalogCost[] = []
|
|
144
|
+
for (const item of raw) {
|
|
145
|
+
const cost = record(item)
|
|
146
|
+
const input = finite(cost?.input)
|
|
147
|
+
const output = finite(cost?.output)
|
|
148
|
+
if (!cost || input === undefined || output === undefined) continue
|
|
149
|
+
const cache = record(cost.cache)
|
|
150
|
+
const tier = record(cost.tier)
|
|
151
|
+
const size = tier?.type === "context" ? finite(tier.size) : undefined
|
|
152
|
+
costs.push({
|
|
153
|
+
input,
|
|
154
|
+
output,
|
|
155
|
+
cacheRead: finite(cache?.read) ?? 0,
|
|
156
|
+
cacheWrite: finite(cache?.write) ?? 0,
|
|
157
|
+
...(size === undefined ? {} : { aboveContext: size }),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
return costs
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
/**
|
|
136
164
|
* Reduces `ctx.model.list` data (V2 `Model.Info`, tolerant of missing fields) to the catalog.
|
|
137
165
|
* Models with `enabled: false` are dropped; `modelID` wins over `id`; variants may be `{ id }` or strings.
|
|
166
|
+
* Cost, limits, modalities, status, family and release date are carried as the host reports them
|
|
167
|
+
* (`Model.Info`, packages/schema/src/model.ts); anything absent or malformed is left out.
|
|
138
168
|
*/
|
|
139
169
|
function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
|
|
140
170
|
const catalog: CatalogModel[] = []
|
|
@@ -149,7 +179,13 @@ function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
|
|
|
149
179
|
.filter((v): v is string => typeof v === "string" && v.length > 0)
|
|
150
180
|
const caps = record(model.capabilities)
|
|
151
181
|
const input = caps && Array.isArray(caps.input) ? caps.input : undefined
|
|
152
|
-
const
|
|
182
|
+
const limit = record(model.limit)
|
|
183
|
+
const context = limit?.context
|
|
184
|
+
const limits = { context: finite(limit?.context), input: finite(limit?.input), output: finite(limit?.output) }
|
|
185
|
+
const modalityIn = strings(caps?.input)
|
|
186
|
+
const modalityOut = strings(caps?.output)
|
|
187
|
+
const cost = toCosts(model.cost)
|
|
188
|
+
const released = finite(record(model.time)?.released)
|
|
153
189
|
catalog.push({
|
|
154
190
|
providerID,
|
|
155
191
|
id,
|
|
@@ -164,6 +200,14 @@ function toCatalog(raw: ReadonlyArray<unknown>): CatalogModel[] {
|
|
|
164
200
|
}
|
|
165
201
|
: {}),
|
|
166
202
|
...(typeof context === "number" ? { contextLimit: context } : {}),
|
|
203
|
+
...(limits.context !== undefined || limits.input !== undefined || limits.output !== undefined
|
|
204
|
+
? { limits: Object.fromEntries(Object.entries(limits).filter(([, value]) => value !== undefined)) }
|
|
205
|
+
: {}),
|
|
206
|
+
...(modalityIn || modalityOut ? { modalities: { input: modalityIn ?? [], output: modalityOut ?? [] } } : {}),
|
|
207
|
+
...(cost && cost.length > 0 ? { cost } : {}),
|
|
208
|
+
...(typeof model.status === "string" && STATUSES.has(model.status) ? { status: model.status as NonNullable<CatalogModel["status"]> } : {}),
|
|
209
|
+
...(typeof model.family === "string" && model.family ? { family: model.family } : {}),
|
|
210
|
+
...(released !== undefined ? { released } : {}),
|
|
167
211
|
})
|
|
168
212
|
}
|
|
169
213
|
return catalog
|
package/src/session/state.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface SessionStore {
|
|
|
57
57
|
retire(sessionID: string, retirement: Retirement): void
|
|
58
58
|
/** Fail every registered request with `error`; returns how many were registered. */
|
|
59
59
|
failAll(error: PlugError): number
|
|
60
|
+
/** How many request guards are outstanding (metrics and status only). */
|
|
61
|
+
size(): number
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
const EXECUTION_END = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
|
|
@@ -140,5 +142,6 @@ export function createSessionStore(options: SessionStoreOptions = {}): SessionSt
|
|
|
140
142
|
}
|
|
141
143
|
return entries.size
|
|
142
144
|
},
|
|
145
|
+
size: () => entries.size,
|
|
143
146
|
}
|
|
144
147
|
}
|