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.
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.15.0",
33
+ "package": "opencode-plugboard@0.17.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/index.ts CHANGED
@@ -25,10 +25,12 @@ import { errorMessage, logError } from "./src/log.js"
25
25
  import { withFallbacks } from "./src/fallback.js"
26
26
  import { withLimits } from "./src/limits.js"
27
27
  import { createOpenAiRoute } from "./src/openai/route.js"
28
- import { ensureBackend, ensurePump, loadServingCatalog, processLimits, processMetrics, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
28
+ import { ensureBackend, ensurePump, loadServingCatalog, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
29
29
  import { createSessionCaller } from "./src/session/caller.js"
30
30
  import { installSessionHooks } from "./src/session/context.js"
31
- import { createHistoryPort, createSessionPort, loadCatalog } from "./src/session/host.js"
31
+ import { createHistoryPort, createSessionPort, createStoragePort, loadCatalog } from "./src/session/host.js"
32
+ import { usageHandlers, UsageRpc } from "./src/rpc.js"
33
+ import { createUsageRecorder, withUsage } from "./src/usage.js"
32
34
 
33
35
  export default Plugin.define({
34
36
  id: "opencode-plugboard",
@@ -67,26 +69,42 @@ export default Plugin.define({
67
69
  return loadServingCatalog(ownCatalog)
68
70
  }
69
71
  const limits = options.limits
72
+ // Usage (src/usage.ts): built from closures only; it reads and writes storage from request handling.
73
+ const usage = options.usage.enabled
74
+ ? createUsageRecorder({ state: processUsage(), storage: createStoragePort(ctx), loadCatalog: servingCatalog, retentionDays: options.usage.retentionDays })
75
+ : undefined
70
76
  const handle = createOpenAiRoute({
71
77
  options,
72
- // Caps are outermost, so one request takes one slot even when it falls back (src/limits.ts).
78
+ // Usage is outermost, so it records the answer the client gets (src/usage.ts).
79
+ // Caps are outside the fallbacks, so one request takes one slot even when it falls back (src/limits.ts).
73
80
  // Aliases written as a list retry the next target before the first output (src/fallback.ts).
74
- // Limits are outermost (one slot per request, refused calls never reach anything); the response
75
- // cache sits outside the fallbacks, so a hit never reaches a provider (src/limits.ts, src/cache.ts).
76
- caller: withLimits(
77
- withResponseCache(
78
- withFallbacks(createSessionCaller(callerDeps), {
81
+ // The response cache sits outside the fallbacks, so a hit never reaches a provider (src/cache.ts).
82
+ caller: withUsage(
83
+ withLimits(
84
+ withResponseCache(
85
+ withFallbacks(createSessionCaller(callerDeps), {
86
+ options,
87
+ loadCatalog: servingCatalog,
88
+ onFallback: (app) => {
89
+ if (options.metrics) processMetrics().fallback(app)
90
+ usage?.fallback(app)
91
+ },
92
+ }),
79
93
  options,
80
- loadCatalog: servingCatalog,
81
- onFallback: options.metrics ? (app) => processMetrics().fallback(app) : undefined,
82
- }),
83
- options,
94
+ ),
95
+ () => limits,
96
+ processLimits(),
97
+ {
98
+ onLimited: (app, reason) => {
99
+ if (options.metrics) processMetrics().limited(app, reason)
100
+ usage?.limited(app)
101
+ },
102
+ },
84
103
  ),
85
- () => limits,
86
- processLimits(),
87
- { onLimited: options.metrics ? (app, reason) => processMetrics().limited(app, reason) : undefined },
104
+ usage,
88
105
  ),
89
106
  loadCatalog: servingCatalog,
107
+ usage,
90
108
  })
91
109
  const registration = registerGeneration({
92
110
  listen: options.listen,
@@ -113,6 +131,13 @@ export default Plugin.define({
113
131
  // ---- a rejected generation (different options or `listen`, foreign runtime) serves nothing and installs nothing.
114
132
  if (!registration.accepted) return
115
133
 
134
+ // ---- the usage RPC (src/rpc.ts): a synchronous registration; its handler reads the store from request time only.
135
+ // Its error channel is `unknown` in the host, so a failed registration is logged and the plugin keeps loading.
136
+ if (usage)
137
+ yield* ctx.rpc.register(UsageRpc, usageHandlers(usage)).pipe(
138
+ Effect.catch((error) => Effect.sync(() => logError("rpc.register_failed", { rpc: UsageRpc.id, error: errorMessage(error) }))),
139
+ )
140
+
116
141
  // ---- the context hook, the agent deny and the seed tools. Ownership of the Location is only
117
142
  // known after an asynchronous check (`ensureBackend`), so all three are no-ops until
118
143
  // `registration.confirmed()`: in real projects nothing is ever added or rewritten.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
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.",
@@ -15,7 +15,8 @@
15
15
  "homepage": "https://github.com/jjjermiah/opencode-plugboard#readme",
16
16
  "main": "./index.ts",
17
17
  "exports": {
18
- ".": "./index.ts"
18
+ ".": "./index.ts",
19
+ "./rpc": "./src/rpc.ts"
19
20
  },
20
21
  "files": ["index.ts", "src/", "README.md", "LICENSE"],
21
22
  "scripts": {
@@ -37,16 +38,21 @@
37
38
  "@ai-sdk/anthropic": "4.0.78",
38
39
  "@ai-sdk/google": "4.0.93",
39
40
  "@ai-sdk/openai": "4.0.91",
41
+ "@ai-sdk/openai-compatible": "3.0.67",
40
42
  "@anthropic-ai/sdk": "0.133.0",
41
43
  "@commitlint/cli": "21.2.3",
42
44
  "@commitlint/config-conventional": "21.2.3",
43
45
  "@google/genai": "2.28.0",
46
+ "@langchain/anthropic": "1.5.12",
47
+ "@langchain/google-genai": "2.3.2",
48
+ "@langchain/openai": "1.6.2",
44
49
  "@opencode/plugin": "2.0.26",
45
50
  "@types/bun": "1.4.2",
46
51
  "@types/escape-html": "^1.0.4",
47
52
  "@types/lcov-parse": "1.0.2",
48
53
  "@types/node": "^26.6.2",
49
54
  "ai": "7.0.137",
55
+ "ajv": "8.20.0",
50
56
  "conventional-commits-parser": "7.1.3",
51
57
  "effect": "4.0.0-rc.112",
52
58
  "eventsource-parser": "4.1.1",
@@ -336,7 +336,7 @@ export function parseMessagesRequest(body: unknown, options: PlugboardOptions):
336
336
  }
337
337
  return {
338
338
  request,
339
- exchange: { model: body.model, stream: body.stream === true, thinking: thinking.enabled },
339
+ exchange: { model: body.model, stream: body.stream === true, thinking: thinking.enabled, stop: generation.stop },
340
340
  ...(thinking.effort ? { reasoningEffort: thinking.effort } : {}),
341
341
  topKIgnored: body.top_k != null,
342
342
  }
@@ -17,6 +17,8 @@ export interface MessagesExchange {
17
17
  readonly stream: boolean
18
18
  /** `thinking` was enabled or adaptive: reasoning text is returned as thinking blocks. */
19
19
  readonly thinking: boolean
20
+ /** The request's `stop_sequences`. The host reports a stop without the sequence that caused it, so the end of the text is checked. */
21
+ readonly stop?: ReadonlyArray<string>
20
22
  }
21
23
 
22
24
  /** A stream event as emitted: `type` is also the SSE event name. */
@@ -74,6 +76,10 @@ const STOP_REASON: Record<Exclude<PlugFinish, "error">, string> = {
74
76
  "content-filter": "refusal",
75
77
  }
76
78
 
79
+ /** The longest stop sequence the text ends with, if any. */
80
+ const stopTail = (tail: string, stops: ReadonlyArray<string>): string | undefined =>
81
+ stops.filter((stop) => tail.endsWith(stop)).sort((a, b) => b.length - a.length)[0]
82
+
77
83
  const noFinish = (): PlugError => ({ status: 500, kind: "server", message: "Model stream ended without a finish event" })
78
84
  const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
79
85
 
@@ -121,6 +127,10 @@ export async function runMessage(
121
127
  let finish: Exclude<PlugFinish, "error"> | undefined
122
128
  let usage: PlugUsage | undefined
123
129
  let failure: PlugError | undefined
130
+ // Text is held back by the longest stop sequence (`holdback` characters), so a sequence at the end of
131
+ // the answer can be found and removed before it is sent. Without stop sequences nothing is held.
132
+ const holdback = Math.max(0, ...(exchange.stop ?? []).map((stop) => stop.length))
133
+ let held = ""
124
134
 
125
135
  const message = (extra: Record<string, unknown>) => ({
126
136
  id,
@@ -147,9 +157,25 @@ export async function runMessage(
147
157
  const close = () => {
148
158
  if (!open) return
149
159
  emit({ type: "content_block_stop", index: open.index })
160
+ // The signature is always empty: the host does not expose the provider's thinking signature (#146), so a
161
+ // replayed thinking block cannot carry the real one. Clients that replay thinking are told by the docs.
150
162
  content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
151
163
  open = undefined
152
164
  }
165
+ const releaseHeld = () => {
166
+ const out = held
167
+ held = ""
168
+ delta("text", out)
169
+ }
170
+ const text = (chunk: string) => {
171
+ if (holdback === 0) return delta("text", chunk)
172
+ held += chunk
173
+ if (held.length > holdback) {
174
+ const out = held.slice(0, held.length - holdback)
175
+ held = held.slice(out.length)
176
+ delta("text", out)
177
+ }
178
+ }
153
179
  const delta = (kind: "text" | "thinking", text: string) => {
154
180
  if (text.length === 0) return
155
181
  start()
@@ -172,6 +198,7 @@ export async function runMessage(
172
198
  }
173
199
  /** Tool calls arrive whole, so each goes out as one `input_json_delta` holding the full input. */
174
200
  const toolCall = (call: ToolCall) => {
201
+ releaseHeld()
175
202
  start()
176
203
  close()
177
204
  const index = nextIndex++
@@ -197,8 +224,9 @@ export async function runMessage(
197
224
  continue
198
225
  }
199
226
  if (event.type === "reasoning-delta") {
227
+ releaseHeld()
200
228
  if (exchange.thinking) delta("thinking", event.text)
201
- } else if (event.type === "text-delta") delta("text", event.text)
229
+ } else if (event.type === "text-delta") text(event.text)
202
230
  else toolCall(event)
203
231
  }
204
232
 
@@ -206,16 +234,23 @@ export async function runMessage(
206
234
  if (failure) {
207
235
  if (!started) return { failure, started }
208
236
  // The stream is already committed: close what is open and end with an `error` event (no `message_stop`).
237
+ releaseHeld()
209
238
  close()
210
239
  emit(encodeError(failure))
211
240
  return { failure, started }
212
241
  }
242
+ // A plain stop with no tool calls that ends on a stop sequence: the sequence is not part of the answer.
243
+ const matched = holdback > 0 && finish === "stop" && calls === 0 ? stopTail(held, exchange.stop ?? []) : undefined
244
+ if (matched !== undefined) held = held.slice(0, held.length - matched.length)
245
+ releaseHeld()
213
246
  start()
214
247
  close()
215
248
  ping()
216
249
  const reason = finish === "stop" && calls > 0 ? "tool-calls" : (finish ?? "stop")
217
- const final = message({ stop_reason: STOP_REASON[reason as Exclude<PlugFinish, "error">], usage: encodeUsage(usage) })
218
- emit({ type: "message_delta", delta: { stop_reason: final.stop_reason, stop_sequence: null }, usage: final.usage })
250
+ const stopReason = matched !== undefined ? "stop_sequence" : STOP_REASON[reason as Exclude<PlugFinish, "error">]
251
+ const stopSequence = matched ?? null
252
+ const final = message({ stop_reason: stopReason, stop_sequence: stopSequence, usage: encodeUsage(usage) })
253
+ emit({ type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: stopSequence }, usage: final.usage })
219
254
  emit({ type: "message_stop" })
220
255
  return { started, message: final }
221
256
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `POST /v1/messages` (and a clear 404 for `POST /v1/messages/count_tokens`): the Anthropic
2
+ * `POST /v1/messages` and `POST /v1/messages/count_tokens` (an estimate, see `tokens.ts`): the Anthropic
3
3
  * Messages front-end's half of the route. `src/openai/route.ts` hands every request for these
4
4
  * paths over before it does anything else, because this route differs from the OpenAI ones in
5
5
  * three ways: errors use Anthropic's body shape from the first check on, `x-api-key` authenticates
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { IncomingMessage, ServerResponse } from "node:http"
15
15
  import { authenticate } from "../config.js"
16
+ import { liveOptions } from "../runtime.js"
16
17
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
17
18
  import {
18
19
  BodyError,
@@ -21,6 +22,7 @@ import {
21
22
  checkJsonContentType,
22
23
  checkOrigin,
23
24
  readJsonBody,
25
+ sendError,
24
26
  sendJson,
25
27
  sseWriter,
26
28
  type RequestScope,
@@ -32,8 +34,11 @@ import { reportPolicy } from "../policy.js"
32
34
  import { dropRejected } from "../openai/sampling.js"
33
35
  import type { RouteHost } from "../openai/route.js"
34
36
  import { parseMessagesRequest } from "./request.js"
37
+ import { estimateTokens } from "./tokens.js"
35
38
  import { encodeError, runMessage, sseFrame } from "./response.js"
36
39
 
40
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
41
+
37
42
  const SESSION_HEADER = "x-plugboard-session"
38
43
  const DROPPED_HEADER = "x-plugboard-dropped-params"
39
44
  const MESSAGES = "/v1/messages"
@@ -58,14 +63,15 @@ const header = (request: IncomingMessage, name: string): string | undefined => {
58
63
 
59
64
  /** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
60
65
  const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
61
- const bearer = authenticate(host.options, request.headers.authorization)
66
+ const options = liveOptions(host.options)
67
+ const bearer = authenticate(options, request.headers.authorization)
62
68
  const apiKey = header(request, "x-api-key")
63
- return isPlugError(bearer) && apiKey ? authenticate(host.options, `Bearer ${apiKey}`) : bearer
69
+ return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`) : bearer
64
70
  }
65
71
 
66
72
  export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
67
73
  const { path, request, response, scope, info } = call
68
- const fail = (error: PlugError, close = false) => sendJson(response, error.status, encodeError(error), { close })
74
+ const fail = (error: PlugError, close = false) => sendError(response, error, encodeError(error), { close })
69
75
  if (request.method === "POST") info.route = `POST ${path}`
70
76
  response.setHeader("request-id", `req_${crypto.randomUUID().replaceAll("-", "")}`)
71
77
 
@@ -84,25 +90,28 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
84
90
  }
85
91
 
86
92
  async function serve(): Promise<void> {
87
- if (request.method !== "POST")
88
- return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
89
93
  const rejected = checkOrigin(request, host.options)
90
94
  if (rejected) return fail(rejected)
95
+ // Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
91
96
  const auth = authenticateRequest(host, request)
92
97
  if (isPlugError(auth)) return fail(auth)
93
98
  info.app = auth.app
94
- if (path === COUNT_TOKENS)
95
- return fail({ status: 404, kind: "not_found", code: "not_found", message: "count_tokens is not supported by plugboard" })
99
+ if (request.method !== "POST")
100
+ return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
96
101
  const unsupported = checkJsonContentType(request)
97
102
  if (unsupported) return fail(unsupported)
98
103
 
99
104
  const body = await readJsonBody(request, scope.signal)
100
- const parsed = parseMessagesRequest(body, host.options)
105
+ // count_tokens takes the message body without max_tokens; the parser needs one, so a placeholder stands in
106
+ // (the count never uses it). A max_tokens the client did send is still validated.
107
+ const sized = path === COUNT_TOKENS && isRecord(body) && body.max_tokens === undefined ? { ...body, max_tokens: Number.MAX_SAFE_INTEGER } : body
108
+ const parsed = parseMessagesRequest(sized, host.options)
101
109
  if (isPlugError(parsed)) return fail(parsed)
102
110
  const catalog = await scope.race(host.loadCatalog())
103
111
  const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
104
112
  if (isPlugError(ref)) return fail(ref)
105
113
  info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
114
+ if (path === COUNT_TOKENS) return sendJson(response, 200, { input_tokens: estimateTokens(parsed.request) })
106
115
  // Sampling parameters the model is known to reject are dropped; `top_k` has no neutral
107
116
  // equivalent and is always dropped. Both are reported before any response bytes.
108
117
  const sampled = host.options.dropUnsupportedParams ? dropRejected(ref, parsed.request.generation) : undefined
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Token counts for `count_tokens` (Anthropic #143) and `countTokens` (Gemini #144).
3
+ *
4
+ * plugboard has no tokenizer, and the host does not expose the provider's counter, so the count is
5
+ * an estimate, not the provider's exact number: characters / 4 over everything the model would
6
+ * read (system prompt, message text, tool-call and tool-result payloads, tool names, descriptions
7
+ * and JSON schemas), plus a fixed cost per image or file. Real tokenizers differ by model and
8
+ * language, so the number can be off either way. It is for sizing and budgeting, not billing.
9
+ */
10
+ import type { PlugPart, PlugRequest } from "../contract.js"
11
+
12
+ /** Tokens charged for each image or file part, whatever its size (the real cost depends on the size). */
13
+ export const MEDIA_TOKENS = 1600
14
+ const CHARS_PER_TOKEN = 4
15
+
16
+ const jsonLength = (value: unknown): number => (JSON.stringify(value) ?? "").length
17
+
18
+ const partChars = (part: PlugPart): number => {
19
+ switch (part.type) {
20
+ case "text":
21
+ return part.text.length
22
+ case "tool-call":
23
+ return part.name.length + jsonLength(part.input)
24
+ case "tool-result":
25
+ return part.name.length + part.result.length
26
+ case "media":
27
+ return 0
28
+ }
29
+ }
30
+
31
+ /** The estimated input tokens of a resolved request. Ceiling of characters / 4, plus `MEDIA_TOKENS` per media part. */
32
+ export function estimateTokens(request: Pick<PlugRequest, "system" | "messages" | "tools">): number {
33
+ let chars = request.system.join("").length
34
+ let media = 0
35
+ for (const message of request.messages)
36
+ for (const part of message.content) {
37
+ if (part.type === "media") media++
38
+ else chars += partChars(part)
39
+ }
40
+ for (const tool of request.tools) chars += tool.name.length + tool.description.length + jsonLength(tool.parameters)
41
+ return Math.ceil(chars / CHARS_PER_TOKEN) + media * MEDIA_TOKENS
42
+ }
@@ -44,7 +44,7 @@ const encodeUsage = (usage: PlugUsage) => {
44
44
  const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
45
45
 
46
46
  /** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
47
- async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object }> {
47
+ async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
48
48
  let text = ""
49
49
  let finish: Exclude<PlugFinish, "error"> | undefined
50
50
  let usage: PlugUsage | undefined
@@ -63,7 +63,7 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
63
63
  if (!failure && !finish) failure = noFinish()
64
64
  if (failure || !finish) {
65
65
  const error = failure ?? noFinish()
66
- return { status: error.status, body: encodeError(error) }
66
+ return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
67
67
  }
68
68
  return {
69
69
  status: 200,
@@ -12,7 +12,7 @@
12
12
  import type { ServerResponse } from "node:http"
13
13
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
14
14
  import type { ServedInfo } from "../fallback.js"
15
- import { sendJson, type RequestScope } from "../http.js"
15
+ import { sendError, type RequestScope } from "../http.js"
16
16
  import { resolveModel } from "../models.js"
17
17
  import { serveChoices } from "../openai/fanout.js"
18
18
  import { encodeError } from "../openai/response.js"
@@ -33,7 +33,7 @@ export interface CompletionsCall {
33
33
  }
34
34
 
35
35
  export async function serveCompletions(host: RouteHost, { app, body, response, scope, info }: CompletionsCall): Promise<void> {
36
- const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
36
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
37
37
 
38
38
  const parsed = parseCompletionsRequest(body, host.options)
39
39
  if (isPlugError(parsed)) return fail(parsed)
package/src/config.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { createHash, timingSafeEqual } from "node:crypto"
4
4
  import type { CacheConfig } from "./cache.js"
5
- import type { PlugError } from "./contract.js"
5
+ import { isPlugError, type PlugError } from "./contract.js"
6
6
  import { allowedBy, parseModelRef } from "./models.js"
7
7
  import { parseAllowEntry, type RemoteImages } from "./session/media.js"
8
8
  import type { HistoryLimits } from "./session/prune.js"
@@ -52,6 +52,16 @@ export interface PlugboardOptions {
52
52
  readonly limits?: Limits
53
53
  /** Opt-in exact-match response cache (see `src/cache.ts`). Absent: off. */
54
54
  readonly cache?: CacheConfig
55
+ /** Per-app usage statistics (see `src/usage.ts`): recorded and served by `GET /v1/usage` and the RPC. */
56
+ readonly usage: UsageConfig
57
+ /** A key that may read every app's usage on `GET /v1/usage`. Compared like app keys; must differ from every one of them. */
58
+ readonly adminKey?: string
59
+ }
60
+
61
+ /** Option `usage` after defaults: on, and days of history kept. */
62
+ export interface UsageConfig {
63
+ readonly enabled: boolean
64
+ readonly retentionDays: number
55
65
  }
56
66
 
57
67
  export type SystemMode = "prepend" | "append" | "replace"
@@ -99,7 +109,11 @@ const DEFAULTS = {
99
109
  requestTimeoutMs: 600_000,
100
110
  } as const
101
111
 
102
- const KNOWN = new Set(["listen", "keys", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "cache"])
112
+ const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "cache", "usage"])
113
+ const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
114
+ const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
115
+ /** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
116
+ const MAX_RETENTION_DAYS = 3650
103
117
  const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps"])
104
118
  const LIMIT_CAP_KNOWN = new Set(["maxConcurrent", "requestsPerMinute"])
105
119
  /** Largest cap accepted: far above any provider's limit, and keeps the error messages short. */
@@ -260,6 +274,18 @@ export function appPolicy(options: Pick<PlugboardOptions, "apps">, app: string):
260
274
  return Object.hasOwn(options.apps, app) ? options.apps[app] : undefined
261
275
  }
262
276
 
277
+ function parseUsage(value: unknown): UsageConfig {
278
+ if (value === undefined) return { ...USAGE_DEFAULTS }
279
+ if (!isRecord(value)) throw new Error("plugboard option `usage` must be an object")
280
+ for (const key of Object.keys(value))
281
+ if (!USAGE_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`usage.${key}\` (known: ${[...USAGE_KNOWN].join(", ")})`)
282
+ if (value.enabled !== undefined && typeof value.enabled !== "boolean") throw new Error("plugboard option `usage.enabled` must be a boolean")
283
+ return {
284
+ enabled: value.enabled !== false,
285
+ retentionDays: positiveInt(value.retentionDays, "usage.retentionDays", USAGE_DEFAULTS.retentionDays, MAX_RETENTION_DAYS),
286
+ }
287
+ }
288
+
263
289
  function parseCache(value: unknown, keys: Readonly<Record<string, string>>): CacheConfig | undefined {
264
290
  if (value === undefined) return undefined
265
291
  if (!isRecord(value)) throw new Error("plugboard option `cache` must be an object with `ttlSeconds`")
@@ -313,6 +339,10 @@ export function parseOptions(raw: unknown): PlugboardOptions {
313
339
  digestsOf(keys)
314
340
  if (keyEntries.length === 0 && !isLoopbackHost(host))
315
341
  throw new Error(`plugboard refuses to listen on non-loopback host \`${host}\` without \`keys\``)
342
+ if (input.adminKey !== undefined) {
343
+ if (typeof input.adminKey !== "string" || input.adminKey.length === 0) throw new Error("plugboard option `adminKey` must be a non-empty string (is its {env:VAR} set?)")
344
+ if (keyEntries.some(([, key]) => key === input.adminKey)) throw new Error("plugboard option `adminKey` must differ from every app key")
345
+ }
316
346
 
317
347
  const models = input.models ?? []
318
348
  if (!Array.isArray(models)) throw new Error("plugboard option `models` must be an array of strings")
@@ -366,6 +396,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
366
396
 
367
397
  const limits = parseLimits(input.limits, keys)
368
398
  const cache = parseCache(input.cache, keys)
399
+ const usage = parseUsage(input.usage)
369
400
 
370
401
  return {
371
402
  listen: { host, port: port ?? DEFAULTS.port },
@@ -385,6 +416,8 @@ export function parseOptions(raw: unknown): PlugboardOptions {
385
416
  modelIds: (input.modelIds as ModelIds | undefined) ?? "canonical",
386
417
  ...(limits === undefined ? {} : { limits }),
387
418
  ...(cache ? { cache } : {}),
419
+ usage,
420
+ ...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
388
421
  }
389
422
  }
390
423
 
@@ -410,6 +443,18 @@ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readon
410
443
  return digests
411
444
  }
412
445
 
446
+ /** Who may read `GET /v1/usage`: the admin key (every app), or one app (`default` when keyless). */
447
+ export type UsageScope = { readonly admin: true } | { readonly admin: false; readonly app: string }
448
+
449
+ /** The admin key is checked first; any other valid key (or keyless mode) is an app, as `authenticate` decides. */
450
+ export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined): UsageScope | PlugError {
451
+ const bearer = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
452
+ if (options.adminKey !== undefined && bearer !== undefined && timingSafeEqual(sha256(bearer), sha256(options.adminKey))) return { admin: true }
453
+ const auth = authenticate(options, authorization)
454
+ if (isPlugError(auth)) return auth
455
+ return { admin: false, app: auth.app }
456
+ }
457
+
413
458
  /** No keys -> app `default`; with keys every request needs `Authorization: Bearer <key>`. */
414
459
  export function authenticate(options: PlugboardOptions, authorization: string | undefined): { app: string } | PlugError {
415
460
  const entries = digestsOf(options.keys)
package/src/contract.ts CHANGED
@@ -153,7 +153,7 @@ export interface PlugError {
153
153
  readonly message: string
154
154
  readonly code?: string
155
155
  readonly param?: string
156
- /** Seconds to wait before a retry, for a `Retry-After` header. Set by the limiter (`src/limits.ts`); no route reads it yet. */
156
+ /** Seconds to wait before a retry, sent as the `Retry-After` header (`sendError` in `src/http.ts`). Set by the limiter (`src/limits.ts`). */
157
157
  readonly retryAfterSeconds?: number
158
158
  }
159
159
 
package/src/discovery.ts CHANGED
@@ -58,16 +58,34 @@ export interface Endpoint {
58
58
  readonly description: string
59
59
  }
60
60
 
61
- function endpoints(options: PlugboardOptions): Endpoint[] {
61
+ /**
62
+ * Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
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). `/v1/usage` and `/metrics` are added only when their options are on.
65
+ */
66
+ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
67
+ { method: "GET", path: "/", auth: false, description: "This page." },
68
+ { method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
69
+ { method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
70
+ { method: "GET", path: "/v1/models", auth: true, description: "Models this app may call (OpenAI shape)." },
71
+ { method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
72
+ { method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
73
+ { method: "POST", path: "/v1/completions", auth: true, description: "Legacy OpenAI completions." },
74
+ { method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
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)." },
77
+ { method: "GET", path: "/v1beta/models", auth: true, description: "Gemini model list." },
78
+ { method: "GET", path: "/v1beta/models/{model}", auth: true, description: "One Gemini model." },
79
+ { method: "POST", path: "/v1beta/models/{model}:generateContent", auth: true, description: "Gemini generateContent." },
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)." },
82
+ { method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
83
+ ]
84
+
85
+ function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
62
86
  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." },
87
+ ...ENDPOINTS,
88
+ ...(options.usage.enabled ? [{ method: "GET", path: "/v1/usage", auth: true, description: "Per-app usage by day: requests, tokens, cost, latency. The admin key reads every app." }] : []),
71
89
  ...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
72
90
  ]
73
91
  }