opencode-plugboard 0.16.0 → 0.18.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 +458 -39
- package/index.ts +40 -15
- package/package.json +37 -8
- package/rpc.ts +3 -0
- package/src/anthropic/request.ts +1 -1
- package/src/anthropic/response.ts +38 -3
- package/src/anthropic/route.ts +13 -6
- package/src/anthropic/tokens.ts +42 -0
- package/src/config.ts +47 -2
- package/src/dashboard/app.css +209 -0
- package/src/dashboard/app.js +444 -0
- package/src/dashboard/assets.ts +72 -0
- package/src/dashboard/page.ts +87 -0
- package/src/discovery.ts +14 -2
- package/src/gemini/route.ts +22 -2
- package/src/openai/route.ts +42 -6
- package/src/rpc.ts +65 -0
- package/src/runtime.ts +62 -12
- package/src/session/host.ts +24 -0
- package/src/tui-format.ts +217 -0
- package/src/tui.tsx +166 -0
- package/src/usage.ts +672 -0
- package/tui.ts +4 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves the usage dashboard (`src/dashboard/page.ts`): the page, the page's own script and
|
|
3
|
+
* stylesheet, and uPlot's browser build and stylesheet from its npm package. Files are read on the
|
|
4
|
+
* first request and kept in memory. Setup does no filesystem work (I3). The bytes never change
|
|
5
|
+
* while plugboard runs, so the ETag is their hash and a revalidation answers 304.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto"
|
|
8
|
+
import { readFile } from "node:fs/promises"
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
10
|
+
import { createRequire } from "node:module"
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
import { sendText } from "../discovery.js"
|
|
14
|
+
import { DASHBOARD_HEADERS, DASHBOARD_PAGE } from "./page.js"
|
|
15
|
+
|
|
16
|
+
const PAGE_PREFIX = "GET /dashboard/"
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const resolveFrom = createRequire(import.meta.url)
|
|
19
|
+
|
|
20
|
+
interface AssetFile {
|
|
21
|
+
readonly path: () => string
|
|
22
|
+
readonly contentType: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The asset named by the last segment of `/dashboard/<name>`. */
|
|
26
|
+
const ASSETS: Readonly<Record<string, AssetFile>> = {
|
|
27
|
+
"app.js": { path: () => join(here, "app.js"), contentType: "text/javascript; charset=utf-8" },
|
|
28
|
+
"app.css": { path: () => join(here, "app.css"), contentType: "text/css; charset=utf-8" },
|
|
29
|
+
"uplot.js": { path: () => resolveFrom.resolve("uplot/dist/uPlot.iife.min.js"), contentType: "text/javascript; charset=utf-8" },
|
|
30
|
+
"uplot.css": { path: () => resolveFrom.resolve("uplot/dist/uPlot.min.css"), contentType: "text/css; charset=utf-8" },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Asset {
|
|
34
|
+
readonly body: string
|
|
35
|
+
readonly contentType: string
|
|
36
|
+
readonly etag: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const loaded = new Map<string, Promise<Asset>>()
|
|
40
|
+
|
|
41
|
+
async function load(name: string): Promise<Asset> {
|
|
42
|
+
const file = ASSETS[name]
|
|
43
|
+
if (!file) throw new Error(`no dashboard asset ${name}`)
|
|
44
|
+
const body = await readFile(file.path(), "utf8")
|
|
45
|
+
const digest = createHash("sha256").update(body).digest("hex").slice(0, 32)
|
|
46
|
+
return { body, contentType: file.contentType, etag: `"${digest}"` }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The asset's bytes, read once per process. A failed read is not kept, so the next request tries again. */
|
|
50
|
+
function asset(name: string): Promise<Asset> {
|
|
51
|
+
let pending = loaded.get(name)
|
|
52
|
+
if (!pending) {
|
|
53
|
+
pending = load(name)
|
|
54
|
+
loaded.set(name, pending)
|
|
55
|
+
pending.catch(() => loaded.delete(name))
|
|
56
|
+
}
|
|
57
|
+
return pending
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Answers one dashboard route (`DASHBOARD_ROUTES`, already matched by the caller). */
|
|
61
|
+
export async function serveDashboard(request: IncomingMessage, response: ServerResponse, route: string): Promise<void> {
|
|
62
|
+
if (route === "GET /dashboard") return sendText(response, 200, "text/html; charset=utf-8", DASHBOARD_PAGE, DASHBOARD_HEADERS)
|
|
63
|
+
const found = await asset(route.slice(PAGE_PREFIX.length))
|
|
64
|
+
// Assets revalidate on every use: a plugboard upgrade changes them, and the ETag makes that check free.
|
|
65
|
+
const headers = { ...DASHBOARD_HEADERS, "cache-control": "no-cache", etag: found.etag }
|
|
66
|
+
if (request.headers["if-none-match"] === found.etag) {
|
|
67
|
+
if (!response.headersSent && !response.destroyed) response.writeHead(304, headers)
|
|
68
|
+
response.end()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
return sendText(response, 200, found.contentType, found.body, headers)
|
|
72
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage dashboard page (`GET /dashboard`, #141): static HTML, its headers and the routes that
|
|
3
|
+
* serve it. The page holds no data. It asks for a key, then reads `GET /v1/usage` from the same
|
|
4
|
+
* origin with that key (the data never reaches the page's HTML). No inline script or style: the
|
|
5
|
+
* script and stylesheets are plugboard's own files under `/dashboard/`, served by `src/dashboard/assets.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Scripts and styles from this origin only; nothing else may load, frame or submit. */
|
|
9
|
+
export const DASHBOARD_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
10
|
+
|
|
11
|
+
/** Headers of every dashboard response; the assets get them too. */
|
|
12
|
+
export const DASHBOARD_HEADERS: Readonly<Record<string, string>> = {
|
|
13
|
+
"content-security-policy": DASHBOARD_CSP,
|
|
14
|
+
"x-frame-options": "DENY",
|
|
15
|
+
"referrer-policy": "no-referrer",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Routes of the dashboard: the page and its four files. Served only when option `usage` is on. */
|
|
19
|
+
export const DASHBOARD_ROUTES: ReadonlySet<string> = new Set([
|
|
20
|
+
"GET /dashboard",
|
|
21
|
+
"GET /dashboard/app.js",
|
|
22
|
+
"GET /dashboard/app.css",
|
|
23
|
+
"GET /dashboard/uplot.js",
|
|
24
|
+
"GET /dashboard/uplot.css",
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
export const DASHBOARD_PAGE = `<!doctype html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
|
+
<meta name="referrer" content="no-referrer">
|
|
33
|
+
<title>plugboard usage</title>
|
|
34
|
+
<link rel="stylesheet" href="/dashboard/uplot.css">
|
|
35
|
+
<link rel="stylesheet" href="/dashboard/app.css">
|
|
36
|
+
<script src="/dashboard/uplot.js" defer></script>
|
|
37
|
+
<script src="/dashboard/app.js" defer></script>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<header class="top">
|
|
41
|
+
<h1>plugboard usage</h1>
|
|
42
|
+
<p class="muted">Requests, tokens and estimated cost per app, per UTC day. Cost uses the catalog's list prices.</p>
|
|
43
|
+
</header>
|
|
44
|
+
<main>
|
|
45
|
+
<section id="signin" class="card" hidden>
|
|
46
|
+
<form id="key-form">
|
|
47
|
+
<label for="key">API key</label>
|
|
48
|
+
<div class="row">
|
|
49
|
+
<input id="key" name="key" type="password" autocomplete="off" autocapitalize="off" spellcheck="false">
|
|
50
|
+
<button type="submit" class="primary">Show usage</button>
|
|
51
|
+
</div>
|
|
52
|
+
<p class="muted">The admin key shows every app. An app key shows only its own. The key is kept in this tab's session storage and sent only to this server.</p>
|
|
53
|
+
</form>
|
|
54
|
+
<p id="auth-error" class="error" role="alert" hidden></p>
|
|
55
|
+
</section>
|
|
56
|
+
<section id="panel" hidden>
|
|
57
|
+
<div class="toolbar">
|
|
58
|
+
<div class="ranges" role="group" aria-label="Range">
|
|
59
|
+
<button type="button" data-days="1">Today</button>
|
|
60
|
+
<button type="button" data-days="7">7 days</button>
|
|
61
|
+
<button type="button" data-days="30">30 days</button>
|
|
62
|
+
<button type="button" data-days="90">90 days</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="row">
|
|
65
|
+
<button type="button" id="refresh">Refresh</button>
|
|
66
|
+
<button type="button" id="sign-out">Sign out</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<p id="status" class="muted" aria-live="polite"></p>
|
|
70
|
+
<h2>Totals</h2>
|
|
71
|
+
<div id="totals" class="cards"></div>
|
|
72
|
+
<h2>Apps</h2>
|
|
73
|
+
<div class="scroll"><table id="apps"></table></div>
|
|
74
|
+
<h2>Requests per day</h2>
|
|
75
|
+
<div id="chart-requests" class="chart"></div>
|
|
76
|
+
<h2>Estimated cost per day (USD)</h2>
|
|
77
|
+
<div id="chart-cost" class="chart"></div>
|
|
78
|
+
<h2>Tokens per day</h2>
|
|
79
|
+
<div id="chart-tokens" class="chart"></div>
|
|
80
|
+
<h2>Models</h2>
|
|
81
|
+
<p id="models-note" class="muted"></p>
|
|
82
|
+
<div class="scroll"><table id="models"></table></div>
|
|
83
|
+
</section>
|
|
84
|
+
</main>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
87
|
+
`
|
package/src/discovery.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface Endpoint {
|
|
|
61
61
|
/**
|
|
62
62
|
* Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
|
|
63
63
|
* `src/openai/route.ts` and the front-end routes; `test/http/discovery.test.ts` probes each served
|
|
64
|
-
* route and fails when this list omits one (#136). `/metrics`
|
|
64
|
+
* route and fails when this list omits one (#136). `/v1/usage`, `/dashboard` and `/metrics` are added only when their options are on.
|
|
65
65
|
*/
|
|
66
66
|
export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
67
67
|
{ method: "GET", path: "/", auth: false, description: "This page." },
|
|
@@ -73,15 +73,26 @@ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
|
73
73
|
{ method: "POST", path: "/v1/completions", auth: true, description: "Legacy OpenAI completions." },
|
|
74
74
|
{ method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
|
|
75
75
|
{ method: "POST", path: "/v1/messages", auth: true, description: "Anthropic Messages (x-api-key or Bearer)." },
|
|
76
|
+
{ method: "POST", path: "/v1/messages/count_tokens", auth: true, description: "Anthropic token count (an estimate, not the provider's exact count)." },
|
|
76
77
|
{ method: "GET", path: "/v1beta/models", auth: true, description: "Gemini model list." },
|
|
77
78
|
{ method: "GET", path: "/v1beta/models/{model}", auth: true, description: "One Gemini model." },
|
|
78
79
|
{ method: "POST", path: "/v1beta/models/{model}:generateContent", auth: true, description: "Gemini generateContent." },
|
|
79
80
|
{ method: "POST", path: "/v1beta/models/{model}:streamGenerateContent", auth: true, description: "Gemini streamGenerateContent, with ?alt=sse." },
|
|
81
|
+
{ method: "POST", path: "/v1beta/models/{model}:countTokens", auth: true, description: "Gemini token count (an estimate)." },
|
|
80
82
|
{ method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
|
|
81
83
|
]
|
|
82
84
|
|
|
83
85
|
function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
|
|
84
|
-
return
|
|
86
|
+
return [
|
|
87
|
+
...ENDPOINTS,
|
|
88
|
+
...(options.usage.enabled
|
|
89
|
+
? [
|
|
90
|
+
{ method: "GET", path: "/v1/usage", auth: true, description: "Per-app usage by day: requests, tokens, cost, latency. The admin key reads every app." },
|
|
91
|
+
{ method: "GET", path: "/dashboard", auth: false, description: "Usage dashboard page. Its data needs a key (`/v1/usage`)." },
|
|
92
|
+
]
|
|
93
|
+
: []),
|
|
94
|
+
...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
|
|
95
|
+
]
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
type ModelsState = "ready" | "no_models_configured" | "none_available" | "catalog_unavailable"
|
|
@@ -232,6 +243,7 @@ export function renderDiscoveryHtml(doc: Discovery): string {
|
|
|
232
243
|
const more = doc.catalog.moreProviders ? `<p>${escapeHtml(doc.catalog.moreProviders)} more providers not listed.</p>` : ""
|
|
233
244
|
sections.push(`<h2>Catalog</h2><table><tr><th>Provider</th><th>Models</th><th>Examples</th></tr>${rows}</table>${more}`)
|
|
234
245
|
}
|
|
246
|
+
if (doc.endpoints.some((endpoint) => endpoint.path === "/dashboard")) sections.push(`<p><a href="/dashboard">Usage dashboard</a></p>`)
|
|
235
247
|
sections.push(`<h2>Try it</h2>${pre(doc.example.curl)}`)
|
|
236
248
|
sections.push(
|
|
237
249
|
`<h2>Endpoints</h2><table><tr><th>Method</th><th>Path</th><th>Key</th><th></th></tr>${doc.endpoints
|
package/src/gemini/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Gemini front-end's half of the route: `GET /v1beta/models`, `GET /v1beta/models/{model}`,
|
|
3
|
-
* `POST /v1beta/models/{model}:generateContent
|
|
3
|
+
* `POST /v1beta/models/{model}:generateContent`, `:streamGenerateContent?alt=sse` and `:countTokens` (an estimate, #144) (and the
|
|
4
4
|
* `POST /v1/models/{model}:...` spelling). `src/openai/route.ts` matches the path
|
|
5
5
|
* (`matchGeminiRoute`), checks the origin, authenticates (`geminiAuthorization` supplies the
|
|
6
6
|
* credential) and hands over here; errors on these routes use Google's body (`encodeGeminiError`).
|
|
@@ -23,6 +23,7 @@ import { reportCache } from "../cache.js"
|
|
|
23
23
|
import { reportPolicy } from "../policy.js"
|
|
24
24
|
import type { RouteHost } from "../openai/route.js"
|
|
25
25
|
import { dropRejected } from "../openai/sampling.js"
|
|
26
|
+
import { estimateTokens } from "../anthropic/tokens.js"
|
|
26
27
|
import { parseGeminiRequest } from "./request.js"
|
|
27
28
|
import { encodeGeminiError, encodeGeminiModel, encodeGeminiModelList, runGenerate, sseChunk } from "./response.js"
|
|
28
29
|
|
|
@@ -33,9 +34,14 @@ export type GeminiMatch =
|
|
|
33
34
|
| { readonly op: "list"; readonly route: string }
|
|
34
35
|
| { readonly op: "get"; readonly route: string; readonly model: string }
|
|
35
36
|
| { readonly op: "generate"; readonly route: string; readonly model: string; readonly stream: boolean }
|
|
37
|
+
| { readonly op: "count"; readonly route: string; readonly model: string }
|
|
36
38
|
/** A method on a model that plugboard does not serve (`countTokens`, `embedContent`, ...). */
|
|
37
39
|
| { readonly op: "unsupported"; readonly route: string; readonly verb: string }
|
|
38
40
|
|
|
41
|
+
/** `countTokens` takes the request's contents at the top level, or wrapped as `generateContentRequest`. */
|
|
42
|
+
const countBody = (body: unknown): unknown =>
|
|
43
|
+
typeof body === "object" && body !== null && "generateContentRequest" in body ? (body as { generateContentRequest: unknown }).generateContentRequest : body
|
|
44
|
+
|
|
39
45
|
const decode = (raw: string): string | undefined => {
|
|
40
46
|
try {
|
|
41
47
|
return decodeURIComponent(raw)
|
|
@@ -66,6 +72,7 @@ export function matchGeminiRoute(method: string | undefined, path: string): Gemi
|
|
|
66
72
|
if (!/^[A-Za-z]+$/.test(verb) || model === undefined) return undefined
|
|
67
73
|
if (verb === "generateContent" || verb === "streamGenerateContent")
|
|
68
74
|
return { op: "generate", route: `POST ${prefix.slice(0, -1)}/{model}:${verb}`, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
|
|
75
|
+
if (verb === "countTokens") return { op: "count", route: `POST ${prefix.slice(0, -1)}/{model}:countTokens`, model: model.startsWith("models/") ? model.slice(7) : model }
|
|
69
76
|
// The client's verb never becomes a metric or log label: every other verb is `unknown` (#135).
|
|
70
77
|
return { op: "unsupported", route: "unknown", verb }
|
|
71
78
|
}
|
|
@@ -98,7 +105,20 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
98
105
|
const fail = (error: PlugError) => sendError(response, error, encodeGeminiError(error))
|
|
99
106
|
|
|
100
107
|
if (match.op === "unsupported")
|
|
101
|
-
return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent and
|
|
108
|
+
return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent, streamGenerateContent and countTokens` })
|
|
109
|
+
|
|
110
|
+
if (match.op === "count") {
|
|
111
|
+
const unsupported = checkJsonContentType(request)
|
|
112
|
+
if (unsupported) return fail(unsupported)
|
|
113
|
+
const parsed = parseGeminiRequest(match.model, false, countBody(await readJsonBody(request, scope.signal)), host.options)
|
|
114
|
+
if (isPlugError(parsed)) return fail(parsed)
|
|
115
|
+
const catalog = await scope.race(host.loadCatalog())
|
|
116
|
+
const ref = resolveModel(host.options, match.model, catalog, parsed.reasoningEffort)
|
|
117
|
+
if (isPlugError(ref)) return fail(ref)
|
|
118
|
+
info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
119
|
+
// An estimate, not the provider's count (see src/anthropic/tokens.ts).
|
|
120
|
+
return sendJson(response, 200, { totalTokens: estimateTokens(parsed.request) })
|
|
121
|
+
}
|
|
102
122
|
|
|
103
123
|
if (match.op === "list" || match.op === "get") {
|
|
104
124
|
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), app)
|
package/src/openai/route.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* `POST /v1/chat/completions` (`n` > 1 through `./fanout.ts`), and the dispatch of `POST /v1/responses`
|
|
4
4
|
* and `POST /v1/completions` to `src/responses/route.ts` and `src/completions/route.ts`.
|
|
5
5
|
* Observability and first-run routes (`src/discovery.ts`, `src/metrics.ts`): `GET /` and `GET /v1`
|
|
6
|
-
* (keyless discovery), the authenticated `GET /v1/status`,
|
|
6
|
+
* (keyless discovery), the authenticated `GET /v1/status`, `GET /metrics` (option `metrics`), and the
|
|
7
|
+
* usage dashboard `GET /dashboard` with its assets (option `usage`, `src/dashboard/`).
|
|
7
8
|
*
|
|
8
9
|
* The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
|
|
9
10
|
* the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
23
|
-
import { DEFAULT_APP, authenticate, type PlugboardOptions } from "../config.js"
|
|
24
|
+
import { DEFAULT_APP, authenticate, authenticateUsage, type PlugboardOptions } from "../config.js"
|
|
24
25
|
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
|
|
25
26
|
import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
|
|
26
27
|
import {
|
|
@@ -48,7 +49,10 @@ import { reportPolicy } from "../policy.js"
|
|
|
48
49
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
49
50
|
import { serveCompletions } from "../completions/route.js"
|
|
50
51
|
import { serveResponses } from "../responses/route.js"
|
|
51
|
-
import { processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
52
|
+
import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
53
|
+
import type { UsageRecorder } from "../usage.js"
|
|
54
|
+
import { DASHBOARD_ROUTES } from "../dashboard/page.js"
|
|
55
|
+
import { serveDashboard } from "../dashboard/assets.js"
|
|
52
56
|
import { chatCodec, serveChoices } from "./fanout.js"
|
|
53
57
|
import { parseChatRequest } from "./request.js"
|
|
54
58
|
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
@@ -64,6 +68,8 @@ export interface RouteHost {
|
|
|
64
68
|
readonly metrics?: Metrics
|
|
65
69
|
/** Runtime facts for `/v1/status` and `/metrics`; the process runtime's by default. */
|
|
66
70
|
readonly status?: () => RuntimeStatus
|
|
71
|
+
/** Per-app usage (`src/usage.ts`): fed from the request log point, and served by `GET /v1/usage` when present. */
|
|
72
|
+
readonly usage?: UsageRecorder
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
const SESSION_HEADER = "x-plugboard-session"
|
|
@@ -116,7 +122,11 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
116
122
|
const runtimeStatus = routeHost.status ?? processStatus
|
|
117
123
|
const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
|
|
118
124
|
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
119
|
-
const known = (route: string) =>
|
|
125
|
+
const known = (route: string) =>
|
|
126
|
+
KNOWN_ROUTES.includes(route) ||
|
|
127
|
+
DISCOVERY_ROUTES.includes(route) ||
|
|
128
|
+
(metrics !== undefined && route === "GET /metrics") ||
|
|
129
|
+
(host.usage !== undefined && (route === "GET /v1/usage" || DASHBOARD_ROUTES.has(route)))
|
|
120
130
|
/** Responses to Gemini routes: errors there use Google's body. */
|
|
121
131
|
const googleErrors = new WeakSet<ServerResponse>()
|
|
122
132
|
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
@@ -162,7 +172,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
162
172
|
const keyed = Object.keys(host.options.keys).length > 0
|
|
163
173
|
let app: string | undefined = keyed ? undefined : DEFAULT_APP
|
|
164
174
|
if (keyed && request.headers.authorization !== undefined) {
|
|
165
|
-
const auth = authenticate(host.options, request.headers.authorization)
|
|
175
|
+
const auth = authenticate(liveOptions(host.options), request.headers.authorization)
|
|
166
176
|
if (isPlugError(auth)) return fail(response, auth)
|
|
167
177
|
app = auth.app
|
|
168
178
|
}
|
|
@@ -172,9 +182,15 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
172
182
|
return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
// The usage dashboard (#141): a page and its files, keyless like the discovery pages. Its data comes from `GET /v1/usage`, behind a key.
|
|
186
|
+
if (host.usage && DASHBOARD_ROUTES.has(route)) return serveDashboard(request, response, route)
|
|
187
|
+
|
|
188
|
+
// Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
|
|
189
|
+
if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
|
|
190
|
+
|
|
175
191
|
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
176
192
|
// Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
|
|
177
|
-
const auth = authenticate(host.options, gemini ? geminiAuthorization(request, url) : request.headers.authorization)
|
|
193
|
+
const auth = authenticate(liveOptions(host.options), gemini ? geminiAuthorization(request, url) : request.headers.authorization)
|
|
178
194
|
if (isPlugError(auth)) return fail(response, auth)
|
|
179
195
|
info.app = auth.app
|
|
180
196
|
|
|
@@ -286,6 +302,25 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
286
302
|
sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
|
|
287
303
|
}
|
|
288
304
|
|
|
305
|
+
/** `GET /v1/usage?from=&to=&app=`. An app key may name only its own app (another one is a 403); the admin key may name any app or none. */
|
|
306
|
+
async function serveUsage(request: IncomingMessage, response: ServerResponse, url: URL, usage: UsageRecorder, info: RequestInfo): Promise<void> {
|
|
307
|
+
const who = authenticateUsage(liveOptions(host.options), request.headers.authorization)
|
|
308
|
+
if (isPlugError(who)) return fail(response, who)
|
|
309
|
+
const app = url.searchParams.get("app") ?? undefined
|
|
310
|
+
if (!who.admin) {
|
|
311
|
+
if (app !== undefined && app !== who.app)
|
|
312
|
+
return fail(response, { status: 403, kind: "permission", code: "app_forbidden", message: "This key cannot read the usage of another app" })
|
|
313
|
+
info.app = who.app
|
|
314
|
+
}
|
|
315
|
+
const report = await usage.query({
|
|
316
|
+
from: url.searchParams.get("from") ?? undefined,
|
|
317
|
+
to: url.searchParams.get("to") ?? undefined,
|
|
318
|
+
app: who.admin ? app : who.app,
|
|
319
|
+
})
|
|
320
|
+
if (isPlugError(report)) return fail(response, report)
|
|
321
|
+
return sendJson(response, 200, report)
|
|
322
|
+
}
|
|
323
|
+
|
|
289
324
|
/** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
|
|
290
325
|
return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
291
326
|
// Generated here, never taken from the client: the id is echoed and logged, so it must not be attacker-controlled text.
|
|
@@ -339,6 +374,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
339
374
|
} catch {}
|
|
340
375
|
const ms = Date.now() - started
|
|
341
376
|
metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
377
|
+
if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
342
378
|
// Probes and scrapes that succeed are not worth a line each.
|
|
343
379
|
if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
|
|
344
380
|
const fields = { id, route: info.route, app: info.app, model: info.model, served: info.served, fallbacks: info.fallbacks, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage RPC, `opencode-plugboard.usage` (package entry `./rpc`): the report `GET /v1/usage` serves,
|
|
3
|
+
* for OpenCode's own clients. A TUI plugin reads it with `client.rpc(UsageRpc).get({ ... })` over the
|
|
4
|
+
* server's password-authenticated API, so the method sees every app.
|
|
5
|
+
*
|
|
6
|
+
* The server plugin registers the handler in every accepted generation (`index.ts`). Registration is
|
|
7
|
+
* synchronous and touches no storage; the handler reads the same store as the route, from request
|
|
8
|
+
* time only. Input and output are JSON Schema (the host's `Rpc.define`): the input is the query
|
|
9
|
+
* (`from`, `to`, `app`, all optional), the output is the `UsageReport` as documented in `src/usage.ts`.
|
|
10
|
+
* A bad query is the declared error `invalid_query` with `{ param }`, naming the parameter.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
14
|
+
import type { RpcHandlers } from "@opencode/plugin/effect/rpc"
|
|
15
|
+
import { Effect } from "effect"
|
|
16
|
+
import { isPlugError, type PlugError } from "./contract.js"
|
|
17
|
+
import type { UsageQuery, UsageReport } from "./usage.js"
|
|
18
|
+
|
|
19
|
+
export const UsageRpc = Rpc.define({
|
|
20
|
+
id: "opencode-plugboard.usage",
|
|
21
|
+
methods: {
|
|
22
|
+
get: {
|
|
23
|
+
input: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
from: { type: "string", description: "First UTC day, YYYY-MM-DD. Default: 29 days before `to`." },
|
|
27
|
+
to: { type: "string", description: "Last UTC day, YYYY-MM-DD. Default: today." },
|
|
28
|
+
app: { type: "string", description: "One app name. Default: every app." },
|
|
29
|
+
},
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
},
|
|
32
|
+
output: {
|
|
33
|
+
type: "object",
|
|
34
|
+
description: "A UsageReport (see src/usage.ts).",
|
|
35
|
+
additionalProperties: true,
|
|
36
|
+
},
|
|
37
|
+
errors: {
|
|
38
|
+
invalid_query: {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: { param: { type: "string", description: "The query parameter that is wrong: from, to or app." } },
|
|
41
|
+
required: ["param"],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
events: {},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** The query fields of an RPC input. The input was validated against JSON Schema by the host; anything else is ignored. */
|
|
51
|
+
function queryOf(input: unknown): UsageQuery {
|
|
52
|
+
const record: Record<string, unknown> = typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {}
|
|
53
|
+
const text = (value: unknown) => (typeof value === "string" ? value : undefined)
|
|
54
|
+
return { from: text(record.from), to: text(record.to), app: text(record.app) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The handlers for `UsageRpc`, reading the given usage query (the generation's recorder). */
|
|
58
|
+
export function usageHandlers(usage: { query(input: UsageQuery): Promise<UsageReport | PlugError> }): RpcHandlers<typeof UsageRpc> {
|
|
59
|
+
return {
|
|
60
|
+
get: (input, context) =>
|
|
61
|
+
Effect.promise(() => usage.query(queryOf(input))).pipe(
|
|
62
|
+
Effect.flatMap((result) => (isPlugError(result) ? Effect.fail(context.error("invalid_query", result.message, { param: result.param ?? "query" })) : Effect.succeed(result))),
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
* - the metrics registry (src/metrics.ts) and the runtime start time, so counters and uptime
|
|
26
26
|
* outlive any one generation. `processMetrics` / `processStatus` read them for `/metrics`,
|
|
27
27
|
* `/v1/status` and the discovery pages;
|
|
28
|
-
|
|
29
|
-
* generations. `processLimits` reads them
|
|
28
|
+
* - the request limiter's queues and rate windows (src/limits.ts), so a cap holds across
|
|
29
|
+
* generations. `processLimits` reads them;
|
|
30
|
+
* - the usage store's live days (src/usage.ts), so the in-memory aggregates survive a generation.
|
|
31
|
+
* `processUsage` reads them.
|
|
30
32
|
*
|
|
31
33
|
* Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
|
|
32
34
|
* rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
|
|
@@ -60,6 +62,7 @@ import { ensureAppDir } from "./session/history.js"
|
|
|
60
62
|
import type { HistoryPort } from "./session/host.js"
|
|
61
63
|
import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
|
|
62
64
|
import { createSessionStore, type SessionStore } from "./session/state.js"
|
|
65
|
+
import { createUsageState, type UsageState } from "./usage.js"
|
|
63
66
|
|
|
64
67
|
export type Handler = (request: IncomingMessage, response: ServerResponse) => void
|
|
65
68
|
|
|
@@ -112,8 +115,8 @@ const BACKEND_POLL_MS = 25
|
|
|
112
115
|
const BIND_RETRY_MS = 15_000
|
|
113
116
|
const BIND_REMINDER_MS = 5 * 60_000
|
|
114
117
|
|
|
115
|
-
/** 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
116
|
-
const VERSION =
|
|
118
|
+
/** 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
119
|
+
const VERSION = 12
|
|
117
120
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
118
121
|
|
|
119
122
|
interface Listener {
|
|
@@ -167,8 +170,16 @@ interface Runtime {
|
|
|
167
170
|
readonly limits: LimitState
|
|
168
171
|
/** When this runtime was created, epoch ms. */
|
|
169
172
|
readonly startedAt: number
|
|
173
|
+
/**
|
|
174
|
+
* The app keys authentication checks (app name -> key), once a generation has registered. Replaced
|
|
175
|
+
* when a newer generation only revokes or rotates keys (`registerGeneration`, #90). Absent means
|
|
176
|
+
* the handler's own options are used.
|
|
177
|
+
*/
|
|
178
|
+
keys?: Readonly<Record<string, string>>
|
|
170
179
|
/** The response cache store (src/cache.ts) and the fingerprint of the config and `apps` policy it was built for. */
|
|
171
180
|
cache?: { readonly fingerprint: string; readonly store: CacheStore }
|
|
181
|
+
/** The usage store's live days and retention clock (src/usage.ts). */
|
|
182
|
+
readonly usage: UsageState
|
|
172
183
|
listener?: Listener
|
|
173
184
|
/** Present while a fiber is subscribed to the server event bus. */
|
|
174
185
|
pump?: Pump
|
|
@@ -181,6 +192,7 @@ const detachedSessions = createSessionStore()
|
|
|
181
192
|
const detachedMetrics = createMetrics()
|
|
182
193
|
const detachedStartedAt = Date.now()
|
|
183
194
|
const detachedLimits = createLimitState()
|
|
195
|
+
const detachedUsage = createUsageState()
|
|
184
196
|
let foreignVersionLogged = false
|
|
185
197
|
|
|
186
198
|
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
@@ -197,6 +209,7 @@ function runtime(): Runtime | undefined {
|
|
|
197
209
|
sweep: { nextAt: 0, running: false },
|
|
198
210
|
metrics: createMetrics(),
|
|
199
211
|
limits: createLimitState(),
|
|
212
|
+
usage: createUsageState(),
|
|
200
213
|
startedAt: Date.now(),
|
|
201
214
|
}
|
|
202
215
|
slot[KEY] = created
|
|
@@ -275,6 +288,18 @@ export const processMetrics = (): Metrics => runtime()?.metrics ?? detachedMetri
|
|
|
275
288
|
/** The request limiter's state shared by every generation in the process (detached under a foreign-version runtime). */
|
|
276
289
|
export const processLimits = (): LimitState => runtime()?.limits ?? detachedLimits
|
|
277
290
|
|
|
291
|
+
/** The usage store's live days shared by every generation in the process (detached under a foreign-version runtime). */
|
|
292
|
+
export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The options authentication reads: `keys` is the live key set, not the serving generation's own
|
|
296
|
+
* (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
|
|
297
|
+
*/
|
|
298
|
+
export function liveOptions(options: PlugboardOptions): PlugboardOptions {
|
|
299
|
+
const keys = runtime()?.keys
|
|
300
|
+
return keys === undefined || keys === options.keys ? options : { ...options, keys }
|
|
301
|
+
}
|
|
302
|
+
|
|
278
303
|
/**
|
|
279
304
|
* Wraps `caller` with the response cache when option `cache` is set (a no-op without it). The store
|
|
280
305
|
* lives on the runtime, so it survives plugin generations. It is replaced when the cache option or
|
|
@@ -423,6 +448,12 @@ function closeListener(rt: Runtime, listener: Listener): void {
|
|
|
423
448
|
* generation's options are adopted. A different `listen` needs a server restart and is rejected.
|
|
424
449
|
* A rejected generation is not in the generation list: it never serves, supplies no catalog, and
|
|
425
450
|
* the caller must not install hooks or seeds for it.
|
|
451
|
+
*
|
|
452
|
+
* The key set (`keys`) has one more rule (#90). While generations are live, a newer generation whose
|
|
453
|
+
* keys only revoke or rotate those of apps the listener already has (no app added, at least one
|
|
454
|
+
* left) replaces `Runtime.keys` at once, so a Location still on an older generation stops accepting
|
|
455
|
+
* a removed key. Any other key change is rejected like any other option difference. The listener's
|
|
456
|
+
* `fingerprint` covers the options without `keys`.
|
|
426
457
|
*/
|
|
427
458
|
export function registerGeneration(input: GenerationInput): Registration {
|
|
428
459
|
const rt = runtime()
|
|
@@ -444,7 +475,8 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
444
475
|
},
|
|
445
476
|
}
|
|
446
477
|
}
|
|
447
|
-
const fingerprint = canonicalJson(input.options)
|
|
478
|
+
const fingerprint = canonicalJson({ ...(input.options as Record<string, unknown>), keys: undefined })
|
|
479
|
+
const keys = (input.options as { readonly keys?: Readonly<Record<string, string>> } | null)?.keys ?? {}
|
|
448
480
|
const listener = (rt.listener ??= startListener(rt, listen, fingerprint))
|
|
449
481
|
if (listener.listen.host !== listen.host || listener.listen.port !== listen.port) {
|
|
450
482
|
if (!listener.mismatchLogged) {
|
|
@@ -457,16 +489,28 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
457
489
|
}
|
|
458
490
|
return reject()
|
|
459
491
|
}
|
|
460
|
-
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
logError("listener.options_mismatch", { action: "this Location is rejected; global config is authoritative" })
|
|
465
|
-
}
|
|
466
|
-
return reject()
|
|
492
|
+
const rejectOptions = (): Registration => {
|
|
493
|
+
if (!listener.mismatchLogged) {
|
|
494
|
+
listener.mismatchLogged = true
|
|
495
|
+
logError("listener.options_mismatch", { action: "this Location is rejected; global config is authoritative" })
|
|
467
496
|
}
|
|
497
|
+
return reject()
|
|
498
|
+
}
|
|
499
|
+
if (listener.fingerprint !== fingerprint) {
|
|
500
|
+
if (rt.generations.length > 0) return rejectOptions()
|
|
468
501
|
listener.fingerprint = fingerprint
|
|
469
502
|
listener.mismatchLogged = false
|
|
503
|
+
rt.keys = keys
|
|
504
|
+
} else if (canonicalJson(keys) !== canonicalJson(rt.keys ?? {})) {
|
|
505
|
+
const live = rt.keys ?? {}
|
|
506
|
+
if (rt.generations.length === 0) rt.keys = keys
|
|
507
|
+
else if (revokesOrRotatesOnly(live, keys)) {
|
|
508
|
+
log("listener.keys_updated", {
|
|
509
|
+
removed: Object.keys(live).filter((app) => !Object.hasOwn(keys, app)).length,
|
|
510
|
+
rotated: Object.keys(keys).filter((app) => Object.hasOwn(live, app) && live[app] !== keys[app]).length,
|
|
511
|
+
})
|
|
512
|
+
rt.keys = keys
|
|
513
|
+
} else return rejectOptions()
|
|
470
514
|
}
|
|
471
515
|
if (listener.graceTimer) clearTimeout(listener.graceTimer)
|
|
472
516
|
listener.graceTimer = undefined
|
|
@@ -510,6 +554,12 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
510
554
|
}
|
|
511
555
|
}
|
|
512
556
|
|
|
557
|
+
/** #90: `next` names no app that `live` lacks, and names at least one app: it only removes or changes keys. */
|
|
558
|
+
function revokesOrRotatesOnly(live: Readonly<Record<string, string>>, next: Readonly<Record<string, string>>): boolean {
|
|
559
|
+
const apps = Object.keys(next)
|
|
560
|
+
return apps.length > 0 && apps.every((app) => Object.hasOwn(live, app))
|
|
561
|
+
}
|
|
562
|
+
|
|
513
563
|
/**
|
|
514
564
|
* Start the process-wide event pump if it is not running. The bus is server-wide, so the store
|
|
515
565
|
* drops everything that is not a registered request session before it is buffered. The pump is
|
package/src/session/host.ts
CHANGED
|
@@ -129,6 +129,30 @@ export function createHistoryPort(ctx: HostContext): HistoryPort {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* `ctx.storage` as a Promise port for string values (the usage store keeps one JSON string per app
|
|
134
|
+
* and day). Same host table and namespace as `HistoryPort`; the keys never overlap (`usage/` vs `history/`).
|
|
135
|
+
*/
|
|
136
|
+
export interface StoragePort {
|
|
137
|
+
get(key: string): Promise<string | undefined>
|
|
138
|
+
put(key: string, value: string): Promise<void>
|
|
139
|
+
drop(key: string): Promise<void>
|
|
140
|
+
scan(input: { readonly prefix: string; readonly after?: string; readonly limit: number }): Promise<{ readonly keys: ReadonlyArray<string>; readonly next?: string }>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createStoragePort(ctx: HostContext): StoragePort {
|
|
144
|
+
return {
|
|
145
|
+
get: (key) => Effect.runPromise(ctx.storage.get(key)).then((value) => (typeof value === "string" ? value : undefined)),
|
|
146
|
+
put: (key, value) => Effect.runPromise(ctx.storage.set(key, value)),
|
|
147
|
+
drop: (key) => Effect.runPromise(ctx.storage.remove(key)),
|
|
148
|
+
scan: (input) =>
|
|
149
|
+
Effect.runPromise(ctx.storage.scan(input)).then((result) => ({
|
|
150
|
+
keys: result.entries.map((entry) => entry.key),
|
|
151
|
+
...(result.next === undefined ? {} : { next: result.next }),
|
|
152
|
+
})),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
133
157
|
typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
|
|
134
158
|
|