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
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
|
-
//
|
|
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
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app
|
|
6
|
+
"description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app: an OpenCode V2 plugin serving OpenAI, Anthropic and Gemini compatible APIs.",
|
|
7
7
|
"keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions", "responses-api", "llm-gateway", "llm-proxy"],
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
@@ -14,14 +14,26 @@
|
|
|
14
14
|
},
|
|
15
15
|
"homepage": "https://github.com/jjjermiah/opencode-plugboard#readme",
|
|
16
16
|
"main": "./index.ts",
|
|
17
|
+
"types": "./index.ts",
|
|
17
18
|
"exports": {
|
|
18
|
-
".": "./index.ts"
|
|
19
|
+
".": { "types": "./index.ts", "default": "./index.ts" },
|
|
20
|
+
"./rpc": { "types": "./rpc.ts", "default": "./rpc.ts" },
|
|
21
|
+
"./tui": { "types": "./tui.ts", "default": "./tui.ts" }
|
|
22
|
+
},
|
|
23
|
+
"files": ["index.ts", "rpc.ts", "tui.ts", "src/", "README.md", "LICENSE"],
|
|
24
|
+
"engines": {
|
|
25
|
+
"bun": ">=1.4.2"
|
|
19
26
|
},
|
|
20
|
-
"files": ["index.ts", "src/", "README.md", "LICENSE"],
|
|
21
27
|
"scripts": {
|
|
22
|
-
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
28
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tui.json && tsc --noEmit -p test",
|
|
23
29
|
"test": "bun test",
|
|
30
|
+
"bench": "bun test/bench/request.bench.ts",
|
|
31
|
+
"mutations": "bun .github/scripts/mutation-check.ts",
|
|
32
|
+
"flake-hunt": "bun .github/scripts/flake-hunt.ts",
|
|
33
|
+
"coverage-manifest": "bun .github/scripts/coverage-manifest.ts",
|
|
24
34
|
"smoke:pack": "bun .github/scripts/smoke-pack.ts",
|
|
35
|
+
"docs:dev": "vitepress dev docs",
|
|
36
|
+
"docs:build": "vitepress build docs",
|
|
25
37
|
"release": "bun .github/scripts/release.ts"
|
|
26
38
|
},
|
|
27
39
|
"dependencies": {
|
|
@@ -31,7 +43,8 @@
|
|
|
31
43
|
"lru-cache": "11.5.3",
|
|
32
44
|
"p-queue": "9.3.5",
|
|
33
45
|
"pino": "10.4.0",
|
|
34
|
-
"safe-stable-stringify": "2.5.0"
|
|
46
|
+
"safe-stable-stringify": "2.5.0",
|
|
47
|
+
"uplot": "1.6.32"
|
|
35
48
|
},
|
|
36
49
|
"devDependencies": {
|
|
37
50
|
"@ai-sdk/anthropic": "4.0.78",
|
|
@@ -46,11 +59,15 @@
|
|
|
46
59
|
"@langchain/google-genai": "2.3.2",
|
|
47
60
|
"@langchain/openai": "1.6.2",
|
|
48
61
|
"@opencode/plugin": "2.0.26",
|
|
62
|
+
"@opentui/core": "0.5.17",
|
|
63
|
+
"@opentui/solid": "0.5.17",
|
|
49
64
|
"@types/bun": "1.4.2",
|
|
50
65
|
"@types/escape-html": "^1.0.4",
|
|
51
66
|
"@types/lcov-parse": "1.0.2",
|
|
52
67
|
"@types/node": "^26.6.2",
|
|
68
|
+
"@types/semver": "7.8.0",
|
|
53
69
|
"ai": "7.0.137",
|
|
70
|
+
"ajv": "8.20.0",
|
|
54
71
|
"conventional-commits-parser": "7.1.3",
|
|
55
72
|
"effect": "4.0.0-rc.112",
|
|
56
73
|
"eventsource-parser": "4.1.1",
|
|
@@ -58,11 +75,23 @@
|
|
|
58
75
|
"fast-xml-parser": "5.11.2",
|
|
59
76
|
"lcov-parse": "1.0.0",
|
|
60
77
|
"lefthook": "2.2.1",
|
|
78
|
+
"mitata": "1.0.34",
|
|
61
79
|
"openai": "7.32.0",
|
|
62
|
-
"
|
|
80
|
+
"semver": "7.8.5",
|
|
81
|
+
"solid-js": "1.9.15",
|
|
82
|
+
"typescript": "^5.9.3",
|
|
83
|
+
"vitepress": "1.6.4"
|
|
63
84
|
},
|
|
64
85
|
"peerDependencies": {
|
|
65
86
|
"@opencode/plugin": ">=2.0.26 <2.1",
|
|
66
|
-
"
|
|
87
|
+
"@opentui/core": ">=0.5.17",
|
|
88
|
+
"@opentui/solid": ">=0.5.17",
|
|
89
|
+
"effect": "4.0.0-rc.112",
|
|
90
|
+
"solid-js": ">=1.9.0"
|
|
91
|
+
},
|
|
92
|
+
"peerDependenciesMeta": {
|
|
93
|
+
"@opentui/core": { "optional": true },
|
|
94
|
+
"@opentui/solid": { "optional": true },
|
|
95
|
+
"solid-js": { "optional": true }
|
|
67
96
|
}
|
|
68
97
|
}
|
package/rpc.ts
ADDED
package/src/anthropic/request.ts
CHANGED
|
@@ -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")
|
|
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
|
|
218
|
-
|
|
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
|
}
|
package/src/anthropic/route.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `POST /v1/messages`
|
|
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,
|
|
@@ -33,8 +34,11 @@ import { reportPolicy } from "../policy.js"
|
|
|
33
34
|
import { dropRejected } from "../openai/sampling.js"
|
|
34
35
|
import type { RouteHost } from "../openai/route.js"
|
|
35
36
|
import { parseMessagesRequest } from "./request.js"
|
|
37
|
+
import { estimateTokens } from "./tokens.js"
|
|
36
38
|
import { encodeError, runMessage, sseFrame } from "./response.js"
|
|
37
39
|
|
|
40
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
41
|
+
|
|
38
42
|
const SESSION_HEADER = "x-plugboard-session"
|
|
39
43
|
const DROPPED_HEADER = "x-plugboard-dropped-params"
|
|
40
44
|
const MESSAGES = "/v1/messages"
|
|
@@ -59,9 +63,10 @@ const header = (request: IncomingMessage, name: string): string | undefined => {
|
|
|
59
63
|
|
|
60
64
|
/** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
|
|
61
65
|
const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
|
|
62
|
-
const
|
|
66
|
+
const options = liveOptions(host.options)
|
|
67
|
+
const bearer = authenticate(options, request.headers.authorization)
|
|
63
68
|
const apiKey = header(request, "x-api-key")
|
|
64
|
-
return isPlugError(bearer) && apiKey ? authenticate(
|
|
69
|
+
return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`) : bearer
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
|
|
@@ -93,18 +98,20 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
93
98
|
info.app = auth.app
|
|
94
99
|
if (request.method !== "POST")
|
|
95
100
|
return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
|
|
96
|
-
if (path === COUNT_TOKENS)
|
|
97
|
-
return fail({ status: 404, kind: "not_found", code: "not_found", message: "count_tokens is not supported by plugboard" })
|
|
98
101
|
const unsupported = checkJsonContentType(request)
|
|
99
102
|
if (unsupported) return fail(unsupported)
|
|
100
103
|
|
|
101
104
|
const body = await readJsonBody(request, scope.signal)
|
|
102
|
-
|
|
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)
|
|
103
109
|
if (isPlugError(parsed)) return fail(parsed)
|
|
104
110
|
const catalog = await scope.race(host.loadCatalog())
|
|
105
111
|
const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
|
|
106
112
|
if (isPlugError(ref)) return fail(ref)
|
|
107
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) })
|
|
108
115
|
// Sampling parameters the model is known to reject are dropped; `top_k` has no neutral
|
|
109
116
|
// equivalent and is always dropped. Both are reported before any response bytes.
|
|
110
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
|
+
}
|
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
|
|
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)
|