opencode-plugboard 0.15.0 → 0.17.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.
@@ -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` and `:streamGenerateContent?alt=sse` (and the
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`).
@@ -17,12 +17,13 @@
17
17
  import type { IncomingMessage, ServerResponse } from "node:http"
18
18
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
19
19
  import { reportServed, type ServedInfo } from "../fallback.js"
20
- import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
20
+ import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
21
21
  import { listAvailableModels, resolveModel } from "../models.js"
22
22
  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)
@@ -64,10 +70,11 @@ export function matchGeminiRoute(method: string | undefined, path: string): Gemi
64
70
  const verb = colon > 0 ? rest.slice(colon + 1) : ""
65
71
  const model = decode(rest.slice(0, colon))
66
72
  if (!/^[A-Za-z]+$/.test(verb) || model === undefined) return undefined
67
- const label = `POST ${prefix.slice(0, -1)}/{model}:${verb}`
68
73
  if (verb === "generateContent" || verb === "streamGenerateContent")
69
- return { op: "generate", route: label, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
70
- return { op: "unsupported", route: label, verb }
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 }
76
+ // The client's verb never becomes a metric or log label: every other verb is `unknown` (#135).
77
+ return { op: "unsupported", route: "unknown", verb }
71
78
  }
72
79
  return undefined
73
80
  }
@@ -95,10 +102,23 @@ export interface GeminiCall {
95
102
  }
96
103
 
97
104
  export async function serveGemini(host: RouteHost, { match, request, url, app, response, scope, info }: GeminiCall): Promise<void> {
98
- const fail = (error: PlugError) => sendJson(response, error.status, encodeGeminiError(error))
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 streamGenerateContent` })
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/http.ts CHANGED
@@ -165,18 +165,29 @@ export function finishRequest(request: IncomingMessage, response: ServerResponse
165
165
  response.once("close", () => clearTimeout(timer))
166
166
  }
167
167
 
168
- /** `close`: also tell the client to drop the connection (after an unread or abandoned body). */
169
- export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean }) => {
168
+ /**
169
+ * `close`: also tell the client to drop the connection (after an unread or abandoned body).
170
+ * `retryAfterSeconds`: sent as `Retry-After` (a rate-limit refusal, see `PlugError.retryAfterSeconds`).
171
+ */
172
+ export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean; retryAfterSeconds?: number }) => {
170
173
  if (response.headersSent || response.destroyed) return
171
174
  const body = JSON.stringify(payload)
172
175
  response.writeHead(status, {
173
176
  "content-type": "application/json",
174
177
  "content-length": Buffer.byteLength(body),
175
178
  ...(options?.close ? { connection: "close" } : {}),
179
+ ...(options?.retryAfterSeconds !== undefined ? { "retry-after": String(options.retryAfterSeconds) } : {}),
176
180
  })
177
181
  response.end(body)
178
182
  }
179
183
 
184
+ /**
185
+ * A `PlugError` answered in a front-end's own wire body, before any output. Every route's `fail` goes
186
+ * through here, so a refused call (`PlugError.retryAfterSeconds`) always carries `Retry-After`.
187
+ */
188
+ export const sendError = (response: ServerResponse, error: PlugError, body: unknown, options?: { close?: boolean }) =>
189
+ sendJson(response, error.status, body, { ...options, retryAfterSeconds: error.retryAfterSeconds })
190
+
180
191
  /**
181
192
  * SSE response writer. Headers go out with the first chunk, so nothing is committed until there
182
193
  * is something to send; chunks after the client is gone are dropped. `write` is synchronous (the
@@ -25,7 +25,7 @@ import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.j
25
25
  import { reportServed, type ServedInfo } from "../fallback.js"
26
26
  import { reportCache } from "../cache.js"
27
27
  import { reportPolicy } from "../policy.js"
28
- import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
28
+ import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
29
29
  import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
30
30
  import type { RouteHost } from "./route.js"
31
31
 
@@ -38,7 +38,7 @@ export interface ChoiceCodec {
38
38
  /** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
39
39
  readonly idPrefix: string
40
40
  /** One choice as a complete JSON body with `choices[0]` and `usage`. */
41
- collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object }>
41
+ collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }>
42
42
  /** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
43
43
  streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
44
44
  /** The final chunk with the summed usage (`include_usage`). */
@@ -155,7 +155,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
155
155
  }
156
156
 
157
157
  export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
158
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
158
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
159
159
  // Abandoned requests (client gone, deadline passed) must never reach the model caller.
160
160
  if (scope.signal.aborted) throw scope.signal.reason
161
161
 
@@ -190,8 +190,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
190
190
  )
191
191
 
192
192
  if (!codec.stream) {
193
- const results = await new Promise<Array<{ status: number; body: object }>>((resolve, reject) => {
194
- const done: Array<{ status: number; body: object }> = []
193
+ const results = await new Promise<Array<{ status: number; body: object; retryAfterSeconds?: number }>>((resolve, reject) => {
194
+ const done: Array<{ status: number; body: object; retryAfterSeconds?: number }> = []
195
195
  let left = calls.length
196
196
  calls.forEach((_, index) => {
197
197
  codec.collect(open(index), index).then(
@@ -211,7 +211,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
211
211
  })
212
212
  })
213
213
  const first = results[0]!
214
- return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)))
214
+ return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
215
215
  }
216
216
 
217
217
  const sse = sseWriter(response, scope.signal)
@@ -195,7 +195,7 @@ const now = () => Math.floor(Date.now() / 1000)
195
195
  export async function collectCompletion(
196
196
  events: AsyncIterable<PlugEvent>,
197
197
  exchange: OpenAiExchange,
198
- ): Promise<{ status: number; body: object }> {
198
+ ): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
199
199
  let text = ""
200
200
  const calls: ToolCall[] = []
201
201
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -217,7 +217,7 @@ export async function collectCompletion(
217
217
  if (!failure && !finish) failure = noFinish()
218
218
  if (failure || !finish) {
219
219
  const error = failure ?? noFinish()
220
- return { status: error.status, body: encodeError(error) }
220
+ return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
221
221
  }
222
222
 
223
223
  const answer = calls.find((call) => structured.isAnswer(exchange, call))
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import type { IncomingMessage, ServerResponse } from "node:http"
23
- import { DEFAULT_APP, authenticate, type PlugboardOptions } from "../config.js"
23
+ import { DEFAULT_APP, authenticate, authenticateUsage, type PlugboardOptions } from "../config.js"
24
24
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
25
25
  import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
26
26
  import {
@@ -32,6 +32,7 @@ import {
32
32
  checkOrigin,
33
33
  finishRequest,
34
34
  readJsonBody,
35
+ sendError,
35
36
  sendJson,
36
37
  sseWriter,
37
38
  type RequestScope,
@@ -41,13 +42,14 @@ import { encodeGeminiError } from "../gemini/response.js"
41
42
  import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/route.js"
42
43
  import { errorMessage, log, logError, withRequestId } from "../log.js"
43
44
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
44
- import { listAvailableModels, resolveModel } from "../models.js"
45
+ import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
45
46
  import { reportCache } from "../cache.js"
46
47
  import { reportPolicy } from "../policy.js"
47
48
  import { isMessagesPath, serveMessages } from "../anthropic/route.js"
48
49
  import { serveCompletions } from "../completions/route.js"
49
50
  import { serveResponses } from "../responses/route.js"
50
- import { processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
51
+ import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
52
+ import type { UsageRecorder } from "../usage.js"
51
53
  import { chatCodec, serveChoices } from "./fanout.js"
52
54
  import { parseChatRequest } from "./request.js"
53
55
  import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
@@ -63,6 +65,8 @@ export interface RouteHost {
63
65
  readonly metrics?: Metrics
64
66
  /** Runtime facts for `/v1/status` and `/metrics`; the process runtime's by default. */
65
67
  readonly status?: () => RuntimeStatus
68
+ /** Per-app usage (`src/usage.ts`): fed from the request log point, and served by `GET /v1/usage` when present. */
69
+ readonly usage?: UsageRecorder
66
70
  }
67
71
 
68
72
  const SESSION_HEADER = "x-plugboard-session"
@@ -115,11 +119,12 @@ export function createOpenAiRoute(routeHost: RouteHost) {
115
119
  const runtimeStatus = routeHost.status ?? processStatus
116
120
  const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
117
121
  /** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
118
- const known = (route: string) => KNOWN_ROUTES.includes(route) || DISCOVERY_ROUTES.includes(route) || (metrics !== undefined && route === "GET /metrics")
122
+ const known = (route: string) =>
123
+ KNOWN_ROUTES.includes(route) || DISCOVERY_ROUTES.includes(route) || (metrics !== undefined && route === "GET /metrics") || (host.usage !== undefined && route === "GET /v1/usage")
119
124
  /** Responses to Gemini routes: errors there use Google's body. */
120
125
  const googleErrors = new WeakSet<ServerResponse>()
121
126
  const fail = (response: ServerResponse, error: PlugError, close = false) =>
122
- sendJson(response, error.status, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
127
+ sendError(response, error, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
123
128
 
124
129
  /** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
125
130
  async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
@@ -161,7 +166,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
161
166
  const keyed = Object.keys(host.options.keys).length > 0
162
167
  let app: string | undefined = keyed ? undefined : DEFAULT_APP
163
168
  if (keyed && request.headers.authorization !== undefined) {
164
- const auth = authenticate(host.options, request.headers.authorization)
169
+ const auth = authenticate(liveOptions(host.options), request.headers.authorization)
165
170
  if (isPlugError(auth)) return fail(response, auth)
166
171
  app = auth.app
167
172
  }
@@ -171,9 +176,12 @@ export function createOpenAiRoute(routeHost: RouteHost) {
171
176
  return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
172
177
  }
173
178
 
179
+ // Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
180
+ if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
181
+
174
182
  // Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
175
183
  // Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
176
- const auth = authenticate(host.options, gemini ? geminiAuthorization(request, url) : request.headers.authorization)
184
+ const auth = authenticate(liveOptions(host.options), gemini ? geminiAuthorization(request, url) : request.headers.authorization)
177
185
  if (isPlugError(auth)) return fail(response, auth)
178
186
  info.app = auth.app
179
187
 
@@ -198,8 +206,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
198
206
  }
199
207
 
200
208
  if (route === "GET /v1/models" || modelId !== undefined) {
209
+ // `?capability=vision|tools` filters the list (#61). Any other value is refused before the catalog loads.
210
+ const requested = modelId === undefined ? url.searchParams.get("capability") : null
211
+ const capability = requested === null ? undefined : parseCapability(requested)
212
+ if (requested !== null && capability === undefined)
213
+ return fail(response, { status: 400, kind: "invalid_request", code: "invalid_capability", param: "capability", message: "capability must be vision or tools" })
201
214
  const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), auth.app)
202
- if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
215
+ if (modelId === undefined) return sendJson(response, 200, encodeModelList(capability ? filterByCapability(listed, capability) : listed))
203
216
  const item = listed.find((m) => m.id === modelId)
204
217
  if (!item) {
205
218
  return fail(response, {
@@ -277,7 +290,26 @@ export function createOpenAiRoute(routeHost: RouteHost) {
277
290
  }
278
291
 
279
292
  const result = await collectCompletion(events, exchange)
280
- sendJson(response, result.status, result.body)
293
+ sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
294
+ }
295
+
296
+ /** `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. */
297
+ async function serveUsage(request: IncomingMessage, response: ServerResponse, url: URL, usage: UsageRecorder, info: RequestInfo): Promise<void> {
298
+ const who = authenticateUsage(liveOptions(host.options), request.headers.authorization)
299
+ if (isPlugError(who)) return fail(response, who)
300
+ const app = url.searchParams.get("app") ?? undefined
301
+ if (!who.admin) {
302
+ if (app !== undefined && app !== who.app)
303
+ return fail(response, { status: 403, kind: "permission", code: "app_forbidden", message: "This key cannot read the usage of another app" })
304
+ info.app = who.app
305
+ }
306
+ const report = await usage.query({
307
+ from: url.searchParams.get("from") ?? undefined,
308
+ to: url.searchParams.get("to") ?? undefined,
309
+ app: who.admin ? app : who.app,
310
+ })
311
+ if (isPlugError(report)) return fail(response, report)
312
+ return sendJson(response, 200, report)
281
313
  }
282
314
 
283
315
  /** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
@@ -333,6 +365,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
333
365
  } catch {}
334
366
  const ms = Date.now() - started
335
367
  metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
368
+ if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
336
369
  // Probes and scrapes that succeed are not worth a line each.
337
370
  if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
338
371
  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 }
@@ -11,7 +11,7 @@
11
11
  import type { ServerResponse } from "node:http"
12
12
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
13
  import { reportServed, type ServedInfo } from "../fallback.js"
14
- import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
14
+ import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
15
15
  import { resolveModel } from "../models.js"
16
16
  import { reportCache } from "../cache.js"
17
17
  import { reportPolicy } from "../policy.js"
@@ -32,7 +32,7 @@ export interface ResponsesCall {
32
32
  }
33
33
 
34
34
  export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
35
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
35
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
36
36
 
37
37
  const parsed = parseResponsesRequest(body, host.options)
38
38
  if (isPlugError(parsed)) return fail(parsed)
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
- * - the request limiter's queues and rate windows (src/limits.ts), so a cap holds across
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 = 10
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
- if (listener.fingerprint !== fingerprint) {
461
- if (rt.generations.length > 0) {
462
- if (!listener.mismatchLogged) {
463
- listener.mismatchLogged = true
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
@@ -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