opencode-plugboard 0.19.0 → 0.20.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 +2 -2
- package/index.ts +6 -1
- package/package.json +1 -1
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/route.ts +11 -6
- package/src/cache.ts +12 -2
- package/src/completions/request.ts +2 -0
- package/src/config.ts +86 -10
- package/src/contract.ts +3 -1
- package/src/discovery.ts +8 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/route.ts +3 -1
- package/src/keys.ts +172 -0
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +10 -2
- package/src/openai/request.ts +2 -0
- package/src/openai/response.ts +2 -2
- package/src/openai/route.ts +23 -6
- package/src/responses/request.ts +2 -0
- package/src/responses/route.ts +3 -1
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +12 -3
- package/src/session/caller.ts +27 -4
- package/src/session/context.ts +3 -1
- package/src/session/history.ts +8 -2
- package/src/session/host.ts +26 -0
- package/src/session/link.ts +33 -0
- package/src/session/state.ts +2 -0
- package/src/usage.ts +61 -2
- package/src/user.ts +55 -0
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
|
|
|
43
43
|
{
|
|
44
44
|
"plugins": [
|
|
45
45
|
{
|
|
46
|
-
"package": "opencode-plugboard@0.
|
|
46
|
+
"package": "opencode-plugboard@0.20.0",
|
|
47
47
|
"options": {
|
|
48
48
|
"keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
|
|
49
49
|
"models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
|
|
@@ -390,7 +390,7 @@ A server shared by a Discord bot, n8n, Open WebUI and a coding agent, with most
|
|
|
390
390
|
{
|
|
391
391
|
"plugins": [
|
|
392
392
|
{
|
|
393
|
-
"package": "opencode-plugboard@0.
|
|
393
|
+
"package": "opencode-plugboard@0.20.0",
|
|
394
394
|
"options": {
|
|
395
395
|
// Reachable from the LAN. Non-loopback hosts refuse to start without keys.
|
|
396
396
|
"listen": { "host": "10.0.1.50", "port": 4010 },
|
package/index.ts
CHANGED
|
@@ -21,15 +21,17 @@
|
|
|
21
21
|
import { Plugin } from "@opencode/plugin/effect"
|
|
22
22
|
import { Effect } from "effect"
|
|
23
23
|
import { parseOptions } from "./src/config.js"
|
|
24
|
+
import { personaOf } from "./src/models.js"
|
|
24
25
|
import { errorMessage, logError } from "./src/log.js"
|
|
25
26
|
import { withFallbacks } from "./src/fallback.js"
|
|
26
27
|
import { withBudgets } from "./src/budgets.js"
|
|
27
28
|
import { withLimits } from "./src/limits.js"
|
|
28
29
|
import { createOpenAiRoute } from "./src/openai/route.js"
|
|
29
|
-
import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
|
|
30
|
+
import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processKeys, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
|
|
30
31
|
import { createSessionCaller } from "./src/session/caller.js"
|
|
31
32
|
import { installSessionHooks } from "./src/session/context.js"
|
|
32
33
|
import { createHistoryPort, createSessionPort, createStoragePort, loadCatalog } from "./src/session/host.js"
|
|
34
|
+
import { createKeyStore } from "./src/keys.js"
|
|
33
35
|
import { usageHandlers, UsageRpc } from "./src/rpc.js"
|
|
34
36
|
import { createUsageRecorder, withUsage } from "./src/usage.js"
|
|
35
37
|
|
|
@@ -59,6 +61,7 @@ export default Plugin.define({
|
|
|
59
61
|
sessions,
|
|
60
62
|
maxTools: options.maxTools,
|
|
61
63
|
apps: options.apps,
|
|
64
|
+
persona: (model: string) => personaOf(options, model),
|
|
62
65
|
remoteImages: options.remoteImages,
|
|
63
66
|
// The readiness gate the caller awaits before creating a session (fails closed: 503 `backend_unavailable`).
|
|
64
67
|
ensureBackend: (app: string, signal: AbortSignal) => ensureBackend(app, signal, bootLocation),
|
|
@@ -118,6 +121,8 @@ export default Plugin.define({
|
|
|
118
121
|
),
|
|
119
122
|
loadCatalog: servingCatalog,
|
|
120
123
|
usage,
|
|
124
|
+
// Runtime app keys (#55): read from storage on the first authentication, cached on the runtime.
|
|
125
|
+
keys: createKeyStore({ state: processKeys(), storage: createStoragePort(ctx) }),
|
|
121
126
|
})
|
|
122
127
|
const registration = registerGeneration({
|
|
123
128
|
listen: options.listen,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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, Anthropic and Gemini compatible APIs.",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin routes for runtime app keys (#55): `GET /v1/admin/keys` (keys without values),
|
|
3
|
+
* `POST /v1/admin/keys` with body `{ "app": "<name>" }` (a new random key, returned once) and
|
|
4
|
+
* `DELETE /v1/admin/keys/{keyId}` (revokes a runtime key; a config key is a 409).
|
|
5
|
+
*
|
|
6
|
+
* The routes exist only when option `adminKey` is set: without it every path here is a 404. Only the
|
|
7
|
+
* admin key passes; an app key, a wrong key or no key is a 401. src/openai/route.ts dispatches here
|
|
8
|
+
* after the origin check and before app authentication, so an app key never reaches them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
12
|
+
import { APP_NAME_PATTERN, DEFAULT_APP, authenticateUsage, type PlugboardOptions } from "../config.js"
|
|
13
|
+
import { isPlugError, type PlugError } from "../contract.js"
|
|
14
|
+
import { checkJsonContentType, readJsonBody, sendError, sendJson, type RequestScope } from "../http.js"
|
|
15
|
+
import type { KeyStore } from "../keys.js"
|
|
16
|
+
import { log } from "../log.js"
|
|
17
|
+
import { encodeError } from "../openai/response.js"
|
|
18
|
+
|
|
19
|
+
export interface AdminHost {
|
|
20
|
+
/** The live options (`liveOptions`): the config keys, and `adminKey`. */
|
|
21
|
+
readonly options: PlugboardOptions
|
|
22
|
+
/** The runtime key store; absent means the admin routes are not served. */
|
|
23
|
+
readonly keys?: KeyStore
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AdminCall {
|
|
27
|
+
readonly path: string
|
|
28
|
+
readonly request: IncomingMessage
|
|
29
|
+
readonly response: ServerResponse
|
|
30
|
+
readonly scope: RequestScope
|
|
31
|
+
/** The route's log fields: the route name is set here, so the request log names the admin route. */
|
|
32
|
+
readonly info: { route: string; app?: string }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const KEYS = "/v1/admin/keys"
|
|
36
|
+
const ONE_KEY = /^\/v1\/admin\/keys\/([0-9a-f]{8})$/
|
|
37
|
+
|
|
38
|
+
export async function serveAdmin(host: AdminHost, call: AdminCall): Promise<void> {
|
|
39
|
+
const { path, request, response, scope, info } = call
|
|
40
|
+
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
41
|
+
const { keys } = host
|
|
42
|
+
if (host.options.adminKey === undefined || keys === undefined)
|
|
43
|
+
return fail({ status: 404, kind: "not_found", code: "not_found", message: `No route for ${request.method} ${path}` })
|
|
44
|
+
|
|
45
|
+
const who = authenticateUsage(host.options, request.headers.authorization, await keys.digests())
|
|
46
|
+
if (isPlugError(who) || !who.admin) return fail({ status: 401, kind: "authentication", code: "invalid_api_key", message: "Missing or invalid admin key" })
|
|
47
|
+
|
|
48
|
+
const id = ONE_KEY.exec(path)?.[1]
|
|
49
|
+
if (path === KEYS && request.method === "GET") {
|
|
50
|
+
info.route = "GET /v1/admin/keys"
|
|
51
|
+
return sendJson(response, 200, { keys: await keys.list(host.options.keys) })
|
|
52
|
+
}
|
|
53
|
+
if (path === KEYS && request.method === "POST") {
|
|
54
|
+
info.route = "POST /v1/admin/keys"
|
|
55
|
+
const unsupported = checkJsonContentType(request)
|
|
56
|
+
if (unsupported) return fail(unsupported)
|
|
57
|
+
const body = await readJsonBody(request, scope.signal)
|
|
58
|
+
const app = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as { app?: unknown }).app : undefined
|
|
59
|
+
if (typeof app !== "string" || app === DEFAULT_APP || !APP_NAME_PATTERN.test(app))
|
|
60
|
+
return fail({ status: 400, kind: "invalid_request", code: "invalid_app", param: "app", message: `\`app\` must match ${APP_NAME_PATTERN} and must not be \`${DEFAULT_APP}\`` })
|
|
61
|
+
if (Object.hasOwn(host.options.keys, app))
|
|
62
|
+
return fail({ status: 409, kind: "invalid_request", code: "app_has_config_key", param: "app", message: `App ${app} already has a key in the plugboard config; edit \`keys\` to change it` })
|
|
63
|
+
const created = await keys.create(app, host.options.keys)
|
|
64
|
+
log("admin.key_created", { id: created.id, app })
|
|
65
|
+
// The key value is shown once: a cache or a log of this response must not keep it.
|
|
66
|
+
response.setHeader("cache-control", "no-store")
|
|
67
|
+
return sendJson(response, 201, created)
|
|
68
|
+
}
|
|
69
|
+
if (id !== undefined && request.method === "DELETE") {
|
|
70
|
+
info.route = "DELETE /v1/admin/keys/{keyId}"
|
|
71
|
+
const result = await keys.revoke(id, host.options.keys)
|
|
72
|
+
if (result === "config")
|
|
73
|
+
return fail({ status: 409, kind: "invalid_request", code: "config_key", message: "This key is set in the plugboard config (`keys`). Remove it there and reload OpenCode." })
|
|
74
|
+
if (result === "not_found") return fail({ status: 404, kind: "not_found", code: "key_not_found", message: `No runtime key ${id}` })
|
|
75
|
+
log("admin.key_revoked", { id })
|
|
76
|
+
return sendJson(response, 200, { id, revoked: true })
|
|
77
|
+
}
|
|
78
|
+
if (path === KEYS || id !== undefined)
|
|
79
|
+
return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}` })
|
|
80
|
+
return fail({ status: 404, kind: "not_found", code: "not_found", message: `No route for ${request.method} ${path}` })
|
|
81
|
+
}
|
package/src/anthropic/request.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* afterwards, which is why the request omits `ref`.
|
|
10
10
|
*/
|
|
11
11
|
import type { PlugboardOptions } from "../config.js"
|
|
12
|
+
import { bodyUserId, userField } from "../user.js"
|
|
12
13
|
import {
|
|
13
14
|
isPlugError,
|
|
14
15
|
type PlugError,
|
|
@@ -327,6 +328,7 @@ export function parseMessagesRequest(body: unknown, options: PlugboardOptions):
|
|
|
327
328
|
return bad(`tool_choice.name: "${toolChoice.name}" is not in tools`)
|
|
328
329
|
|
|
329
330
|
const request: Omit<PlugRequest, "ref"> = {
|
|
331
|
+
...userField(bodyUserId(body)),
|
|
330
332
|
model: body.model,
|
|
331
333
|
system,
|
|
332
334
|
messages,
|
package/src/anthropic/route.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
15
15
|
import { authenticate } from "../config.js"
|
|
16
16
|
import { liveOptions } from "../runtime.js"
|
|
17
|
+
import { bodyUserId, noteEndUser } from "../user.js"
|
|
17
18
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
18
19
|
import {
|
|
19
20
|
BodyError,
|
|
@@ -32,6 +33,7 @@ import { resolveModel } from "../models.js"
|
|
|
32
33
|
import { reportCache } from "../cache.js"
|
|
33
34
|
import { reportPolicy } from "../policy.js"
|
|
34
35
|
import { reportUsage } from "../usage-header.js"
|
|
36
|
+
import { setSessionLink } from "../session/link.js"
|
|
35
37
|
import { dropRejected } from "../openai/sampling.js"
|
|
36
38
|
import type { RouteHost } from "../openai/route.js"
|
|
37
39
|
import { parseMessagesRequest } from "./request.js"
|
|
@@ -54,7 +56,7 @@ export interface MessagesCall {
|
|
|
54
56
|
readonly response: ServerResponse
|
|
55
57
|
readonly scope: RequestScope
|
|
56
58
|
/** The route's log fields: the route name, the app, the resolved model, dropped parameters, and the outcome of a stream that failed after it committed. */
|
|
57
|
-
readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string }
|
|
59
|
+
readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string; user?: string }
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
const header = (request: IncomingMessage, name: string): string | undefined => {
|
|
@@ -63,11 +65,12 @@ const header = (request: IncomingMessage, name: string): string | undefined => {
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
|
|
66
|
-
const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
|
|
68
|
+
const authenticateRequest = async (host: RouteHost, request: IncomingMessage) => {
|
|
67
69
|
const options = liveOptions(host.options)
|
|
68
|
-
const
|
|
70
|
+
const runtime = host.keys ? await host.keys.digests() : []
|
|
71
|
+
const bearer = authenticate(options, request.headers.authorization, runtime)
|
|
69
72
|
const apiKey = header(request, "x-api-key")
|
|
70
|
-
return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}
|
|
73
|
+
return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`, runtime) : bearer
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
|
|
@@ -94,7 +97,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
94
97
|
const rejected = checkOrigin(request, host.options)
|
|
95
98
|
if (rejected) return fail(rejected)
|
|
96
99
|
// Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
|
|
97
|
-
const auth = authenticateRequest(host, request)
|
|
100
|
+
const auth = await authenticateRequest(host, request)
|
|
98
101
|
if (isPlugError(auth)) return fail(auth)
|
|
99
102
|
info.app = auth.app
|
|
100
103
|
if (request.method !== "POST")
|
|
@@ -103,6 +106,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
103
106
|
if (unsupported) return fail(unsupported)
|
|
104
107
|
|
|
105
108
|
const body = await readJsonBody(request, scope.signal)
|
|
109
|
+
noteEndUser(response, info, bodyUserId(body))
|
|
106
110
|
// count_tokens takes the message body without max_tokens; the parser needs one, so a placeholder stands in
|
|
107
111
|
// (the count never uses it). A max_tokens the client did send is still validated.
|
|
108
112
|
const sized = path === COUNT_TOKENS && isRecord(body) && body.max_tokens === undefined ? { ...body, max_tokens: Number.MAX_SAFE_INTEGER } : body
|
|
@@ -130,9 +134,10 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
130
134
|
host.caller.stream(model, {
|
|
131
135
|
app: auth.app,
|
|
132
136
|
signal: scope.signal,
|
|
133
|
-
onRecord: (recordID) => {
|
|
137
|
+
onRecord: (recordID, directory) => {
|
|
134
138
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
135
139
|
response.setHeader(SESSION_HEADER, recordID)
|
|
140
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
136
141
|
},
|
|
137
142
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
138
143
|
onPolicy: reportPolicy(response),
|
package/src/cache.ts
CHANGED
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Fan-out (`n` above 1, prompt arrays) calls the caller once per choice with the same request, so
|
|
27
27
|
* the fan-out sets `noCache`: every choice must be a fresh sample.
|
|
28
|
+
*
|
|
29
|
+
* A persona (a skill alias or an `agent/<name>` id, `bypass`) is never cached: its answer depends on
|
|
30
|
+
* the skill's content or the agent's prompt, which the key does not see, and a skill edit must not be
|
|
31
|
+
* masked by an old entry.
|
|
28
32
|
*/
|
|
29
33
|
|
|
30
34
|
import { createHash } from "node:crypto"
|
|
@@ -115,11 +119,17 @@ function append(events: PlugEvent[], event: { readonly type: "text-delta" | "rea
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
/** Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. */
|
|
118
|
-
export function cachedCaller(
|
|
122
|
+
export function cachedCaller(
|
|
123
|
+
inner: ModelCaller,
|
|
124
|
+
config: CacheConfig,
|
|
125
|
+
store: CacheStore,
|
|
126
|
+
count?: CacheCounter,
|
|
127
|
+
bypass?: (model: string) => boolean,
|
|
128
|
+
): ModelCaller {
|
|
119
129
|
const apps = config.apps ? new Set(config.apps) : undefined
|
|
120
130
|
return {
|
|
121
131
|
async *stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
|
|
122
|
-
if (context.noCache || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
|
|
132
|
+
if (context.noCache || bypass?.(request.model) || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
|
|
123
133
|
const key = cacheKey(context.app, request)
|
|
124
134
|
const hit = store.get(key)
|
|
125
135
|
if (hit) {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { PlugboardOptions } from "../config.js"
|
|
12
12
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
13
|
+
import { bodyUser, userField } from "../user.js"
|
|
13
14
|
import { parseChoiceCount, parseGeneration } from "../openai/request.js"
|
|
14
15
|
import type { CompletionsExchange } from "./response.js"
|
|
15
16
|
|
|
@@ -85,6 +86,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
|
|
|
85
86
|
return {
|
|
86
87
|
requests: prompts.map((prompt) => ({
|
|
87
88
|
model: body.model as string,
|
|
89
|
+
...userField(bodyUser(body)),
|
|
88
90
|
system: suffix ? [SUFFIX_INSTRUCTION(suffix)] : [],
|
|
89
91
|
messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
|
|
90
92
|
tools: [],
|
package/src/config.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { createHash, timingSafeEqual } from "node:crypto"
|
|
4
4
|
import type { CacheConfig } from "./cache.js"
|
|
5
5
|
import { isPlugError, type PlugError } from "./contract.js"
|
|
6
|
-
import { allowedBy, parseModelRef } from "./models.js"
|
|
6
|
+
import { allowedBy, parseAliasTarget, parseModelRef, SKILL_PREFIX } from "./models.js"
|
|
7
7
|
import { parseAllowEntry, type RemoteImages } from "./session/media.js"
|
|
8
8
|
import type { HistoryLimits } from "./session/prune.js"
|
|
9
9
|
|
|
@@ -21,6 +21,11 @@ export interface PlugboardOptions {
|
|
|
21
21
|
readonly models: ReadonlyArray<string>
|
|
22
22
|
/** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
|
|
23
23
|
readonly aliases: Readonly<Record<string, string>>
|
|
24
|
+
/**
|
|
25
|
+
* Agent name -> `provider/model[#variant]` (option `agents`): each is listed as `agent/<name>` and runs its
|
|
26
|
+
* OpenCode agent's system prompt on that model. The model must pass the allowlist.
|
|
27
|
+
*/
|
|
28
|
+
readonly agents: Readonly<Record<string, string>>
|
|
24
29
|
/**
|
|
25
30
|
* Alias -> the targets after the first, in order, for an alias written as an array. `aliases` holds the
|
|
26
31
|
* first target, so everything that resolves an alias keeps working; the model caller retries the rest
|
|
@@ -58,6 +63,8 @@ export interface PlugboardOptions {
|
|
|
58
63
|
readonly usage: UsageConfig
|
|
59
64
|
/** A key that may read every app's usage on `GET /v1/usage`. Compared like app keys; must differ from every one of them. */
|
|
60
65
|
readonly adminKey?: string
|
|
66
|
+
/** A template for the OpenCode session page of each request: `{id}`, and optional `{directory}` (see src/session/link.ts, #77). */
|
|
67
|
+
readonly historyUrl?: string
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
/** Option `usage` after defaults: on, and days of history kept. */
|
|
@@ -139,7 +146,7 @@ const DEFAULTS = {
|
|
|
139
146
|
requestTimeoutMs: 600_000,
|
|
140
147
|
} as const
|
|
141
148
|
|
|
142
|
-
const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage"])
|
|
149
|
+
const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "agents", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage", "historyUrl"])
|
|
143
150
|
const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
|
|
144
151
|
const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
|
|
145
152
|
/** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
|
|
@@ -396,6 +403,55 @@ function parseBudgets(raw: unknown, keys: Readonly<Record<string, string>>): Bud
|
|
|
396
403
|
return { ...(perApp === undefined ? {} : { perApp }), ...(raw.apps === undefined ? {} : { apps }) }
|
|
397
404
|
}
|
|
398
405
|
|
|
406
|
+
const AGENT_KNOWN = new Set(["name", "model"])
|
|
407
|
+
/** An OpenCode agent name: letters, digits, `_` and `-`, starting with a letter or digit. */
|
|
408
|
+
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Option `agents`: `{ name, model }` per OpenCode agent to expose. `model` is required and must pass the
|
|
412
|
+
* allowlist, so an agent's own configured model never runs outside `models` (the agent's model setting is
|
|
413
|
+
* not read). Names must be unique.
|
|
414
|
+
*/
|
|
415
|
+
function parseAgents(raw: unknown, models: ReadonlyArray<string>): Record<string, string> {
|
|
416
|
+
if (raw === undefined) return {}
|
|
417
|
+
if (!Array.isArray(raw)) throw new Error("plugboard option `agents` must be an array of { name, model } objects")
|
|
418
|
+
const entries: Array<[string, string]> = []
|
|
419
|
+
for (const item of raw) {
|
|
420
|
+
if (!isRecord(item)) throw new Error("plugboard option `agents` entries must be objects of the form { name, model }")
|
|
421
|
+
for (const key of Object.keys(item))
|
|
422
|
+
if (!AGENT_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`agents.${key}\` (known: ${[...AGENT_KNOWN].join(", ")})`)
|
|
423
|
+
const { name, model } = item
|
|
424
|
+
if (typeof name !== "string" || !AGENT_NAME.test(name)) throw new Error(`plugboard agent name \`${String(name)}\` must match ${AGENT_NAME} (an OpenCode agent name)`)
|
|
425
|
+
if (entries.some(([known]) => known === name)) throw new Error(`plugboard agent \`${name}\` is listed more than once in \`agents\``)
|
|
426
|
+
if (typeof model !== "string" || !ALIAS_TARGET.test(model))
|
|
427
|
+
throw new Error(`plugboard agent \`${name}\` must set \`model\` to \`provider/model\` or \`provider/model#variant\``)
|
|
428
|
+
if (!allowedBy(models as string[], parseModelRef(model)!))
|
|
429
|
+
throw new Error(`plugboard agent \`${name}\` model \`${model}\` is not allowed by the \`models\` option`)
|
|
430
|
+
entries.push([name, model])
|
|
431
|
+
}
|
|
432
|
+
return Object.fromEntries(entries)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Option `historyUrl` (#77): an http(s) URL template that must contain `{id}`. Its only other placeholder is
|
|
437
|
+
* `{directory}`, so a typo such as `{sesion}` fails at load instead of producing a link that is wrong.
|
|
438
|
+
*/
|
|
439
|
+
function parseHistoryUrl(value: unknown): string | undefined {
|
|
440
|
+
if (value === undefined) return undefined
|
|
441
|
+
const at = "plugboard option `historyUrl`"
|
|
442
|
+
if (typeof value !== "string" || !value.includes("{id}")) throw new Error(`${at} must be a URL template with {id} (for example http://host:4096/session/{id})`)
|
|
443
|
+
const unknown = (value.match(/\{[^{}]*\}/g) ?? []).find((placeholder) => placeholder !== "{id}" && placeholder !== "{directory}")
|
|
444
|
+
if (unknown !== undefined) throw new Error(`${at} has the unknown placeholder ${unknown} (known: {id}, {directory})`)
|
|
445
|
+
let url: URL
|
|
446
|
+
try {
|
|
447
|
+
url = new URL(value.replaceAll("{id}", "x").replaceAll("{directory}", "x"))
|
|
448
|
+
} catch {
|
|
449
|
+
throw new Error(`${at} must be an http(s) URL template`)
|
|
450
|
+
}
|
|
451
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${at} must be http or https`)
|
|
452
|
+
return value
|
|
453
|
+
}
|
|
454
|
+
|
|
399
455
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
400
456
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
401
457
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -452,6 +508,14 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
452
508
|
const chain = Array.isArray(target) ? target : [target]
|
|
453
509
|
if (chain.length === 0) throw new Error(`plugboard alias \`${alias}\` must list at least one target`)
|
|
454
510
|
for (const entry of chain) {
|
|
511
|
+
// A skill target binds one model and takes no fallbacks; like a list entry it must pass the allowlist at load.
|
|
512
|
+
if (typeof entry === "string" && entry.startsWith(SKILL_PREFIX)) {
|
|
513
|
+
const parsed = parseAliasTarget(entry)
|
|
514
|
+
if (parsed?.skill === undefined) throw new Error(`plugboard alias \`${alias}\` skill target \`${entry}\` must be \`skill:<id>@provider/model\` or \`skill:<id>@provider/model#variant\``)
|
|
515
|
+
if (Array.isArray(target)) throw new Error(`plugboard alias \`${alias}\` binds a skill, so it takes one target and no list of fallbacks`)
|
|
516
|
+
if (!allowedBy(models as string[], parsed.ref)) throw new Error(`plugboard alias \`${alias}\` target \`${entry}\` is not allowed by the \`models\` option`)
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
455
519
|
if (typeof entry !== "string" || !ALIAS_TARGET.test(entry))
|
|
456
520
|
throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\` (or a list of them)`)
|
|
457
521
|
// A list is new, so it is strict: a target the allowlist would refuse can never serve, so fail at load.
|
|
@@ -465,6 +529,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
465
529
|
}
|
|
466
530
|
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
467
531
|
const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
|
|
532
|
+
const agents = parseAgents(input.agents, models as string[])
|
|
468
533
|
|
|
469
534
|
const maxTools = positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024)
|
|
470
535
|
const apps = parseApps(input.apps, { keys, aliases, maxTools })
|
|
@@ -493,6 +558,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
493
558
|
if (budgets !== undefined && !usage.enabled) throw new Error("plugboard option `budgets` needs `usage` enabled: budgets are counted from the usage records")
|
|
494
559
|
if (budgets !== undefined && usage.retentionDays < widestWindowDays(budgets))
|
|
495
560
|
throw new Error(`plugboard option \`usage.retentionDays\` must be at least ${widestWindowDays(budgets)} for the budget windows in use (a month is 31 days, a week 7): older days are deleted and would stop counting`)
|
|
561
|
+
const historyUrl = parseHistoryUrl(input.historyUrl)
|
|
496
562
|
|
|
497
563
|
return {
|
|
498
564
|
listen: { host, port: port ?? DEFAULTS.port },
|
|
@@ -500,6 +566,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
500
566
|
apps,
|
|
501
567
|
models: models as string[],
|
|
502
568
|
aliases,
|
|
569
|
+
agents,
|
|
503
570
|
fallbacks,
|
|
504
571
|
maxTools,
|
|
505
572
|
maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
|
|
@@ -515,6 +582,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
515
582
|
usage,
|
|
516
583
|
...(budgets === undefined ? {} : { budgets }),
|
|
517
584
|
...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
|
|
585
|
+
...(historyUrl === undefined ? {} : { historyUrl }),
|
|
518
586
|
}
|
|
519
587
|
}
|
|
520
588
|
|
|
@@ -543,24 +611,32 @@ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readon
|
|
|
543
611
|
/** Who may read `GET /v1/usage`: the admin key (every app), or one app (`default` when keyless). */
|
|
544
612
|
export type UsageScope = { readonly admin: true } | { readonly admin: false; readonly app: string }
|
|
545
613
|
|
|
614
|
+
/** The digest of a runtime key (src/keys.ts), with its app: checked like a config key (#55). */
|
|
615
|
+
export type RuntimeDigest = readonly [app: string, digest: Buffer]
|
|
616
|
+
|
|
546
617
|
/** The admin key is checked first; any other valid key (or keyless mode) is an app, as `authenticate` decides. */
|
|
547
|
-
export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined): UsageScope | PlugError {
|
|
618
|
+
export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): UsageScope | PlugError {
|
|
548
619
|
const bearer = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
|
|
549
620
|
if (options.adminKey !== undefined && bearer !== undefined && timingSafeEqual(sha256(bearer), sha256(options.adminKey))) return { admin: true }
|
|
550
|
-
const auth = authenticate(options, authorization)
|
|
621
|
+
const auth = authenticate(options, authorization, runtime)
|
|
551
622
|
if (isPlugError(auth)) return auth
|
|
552
623
|
return { admin: false, app: auth.app }
|
|
553
624
|
}
|
|
554
625
|
|
|
555
|
-
/**
|
|
556
|
-
|
|
626
|
+
/**
|
|
627
|
+
* No config keys -> app `default` (keyless), as before. With config keys every request needs
|
|
628
|
+
* `Authorization: Bearer <key>`. A runtime key (#55) matches like a config key, so it names its app
|
|
629
|
+
* whenever it is presented. It never makes a keyless server require a key, so deleting the last
|
|
630
|
+
* runtime key cannot open or close access by itself.
|
|
631
|
+
*/
|
|
632
|
+
export function authenticate(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): { app: string } | PlugError {
|
|
557
633
|
const entries = digestsOf(options.keys)
|
|
558
|
-
if (entries.length === 0) return { app: DEFAULT_APP }
|
|
559
634
|
const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
|
|
560
|
-
if (
|
|
635
|
+
if (token === undefined) return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
|
|
561
636
|
const digest = sha256(token)
|
|
562
637
|
let app: string | undefined
|
|
563
638
|
// Every key is compared, no early exit.
|
|
564
|
-
for (const [name, expected] of entries) if (timingSafeEqual(digest, expected)) app = name
|
|
565
|
-
|
|
639
|
+
for (const [name, expected] of [...entries, ...runtime]) if (timingSafeEqual(digest, expected)) app = name
|
|
640
|
+
if (app !== undefined) return { app }
|
|
641
|
+
return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
|
|
566
642
|
}
|
package/src/contract.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface PlugRequest {
|
|
|
114
114
|
readonly tools: ReadonlyArray<PlugTool>
|
|
115
115
|
readonly toolChoice?: PlugToolChoice
|
|
116
116
|
readonly generation: PlugGeneration
|
|
117
|
+
/** The end user the caller named, when kept (src/user.ts, #57). Labels the session title and the usage counts. */
|
|
118
|
+
readonly user?: string
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
// ---------------------------------------------------------------- events
|
|
@@ -172,7 +174,7 @@ export interface CallContext {
|
|
|
172
174
|
/** Aborted on client disconnect or timeout. */
|
|
173
175
|
readonly signal: AbortSignal
|
|
174
176
|
/** Called once, before the first event, with the id of the history record this call writes (if any). */
|
|
175
|
-
readonly onRecord?: (recordID: string) => void
|
|
177
|
+
readonly onRecord?: (recordID: string, directory?: string) => void
|
|
176
178
|
/**
|
|
177
179
|
* Called once, before the first event, only for a request whose model is an alias with fallbacks
|
|
178
180
|
* (`src/fallback.ts`): which target answered and how many earlier targets failed first. A front-end
|
package/src/discovery.ts
CHANGED
|
@@ -92,6 +92,14 @@ function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
|
|
|
92
92
|
]
|
|
93
93
|
: []),
|
|
94
94
|
...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
|
|
95
|
+
// The admin key only (option `adminKey`); without it these paths are a 404 (src/admin/route.ts).
|
|
96
|
+
...(options.adminKey === undefined
|
|
97
|
+
? []
|
|
98
|
+
: [
|
|
99
|
+
{ method: "GET", path: "/v1/admin/keys", auth: true, description: "Admin: the config and runtime app keys, without key values." },
|
|
100
|
+
{ method: "POST", path: "/v1/admin/keys", auth: true, description: "Admin: a new app key for `{ app }`, returned once." },
|
|
101
|
+
{ method: "DELETE", path: "/v1/admin/keys/{keyId}", auth: true, description: "Admin: revoke a runtime app key." },
|
|
102
|
+
]),
|
|
95
103
|
]
|
|
96
104
|
}
|
|
97
105
|
|
package/src/fallback.ts
CHANGED
|
@@ -51,6 +51,8 @@ export const FALLBACKS_HEADER = "x-plugboard-fallbacks"
|
|
|
51
51
|
export interface ServedInfo {
|
|
52
52
|
served?: string
|
|
53
53
|
fallbacks?: number
|
|
54
|
+
/** The session page link of the request (option `historyUrl`, src/session/link.ts), for the log line. */
|
|
55
|
+
sessionUrl?: string
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -90,9 +92,9 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
|
|
|
90
92
|
let attempt = 0
|
|
91
93
|
for (;;) {
|
|
92
94
|
// The history id of this attempt: handed on only if the attempt is the one that answers (or the last).
|
|
93
|
-
let
|
|
95
|
+
let recorded: { id: string; directory?: string } | undefined
|
|
94
96
|
const next = attempt === 0 ? request : retarget(deps.options, request, ref)
|
|
95
|
-
const iterator = inner.stream(next, { ...context, onRecord: (id) => void (
|
|
97
|
+
const iterator = inner.stream(next, { ...context, onRecord: (id, directory) => void (recorded = { id, directory }) })[Symbol.asyncIterator]()
|
|
96
98
|
|
|
97
99
|
let first: IteratorResult<PlugEvent> | undefined
|
|
98
100
|
let thrown: { readonly error: unknown } | undefined
|
|
@@ -124,7 +126,7 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
// Answered, ran out of targets, or the request ended: this attempt's outcome is the response.
|
|
127
|
-
if (
|
|
129
|
+
if (recorded !== undefined) report(context, recorded)
|
|
128
130
|
if (thrown) throw thrown.error
|
|
129
131
|
if (first!.done) return
|
|
130
132
|
// An error is not an answer: a call that fails (non-retryable, or on its last target) reports no served model.
|
|
@@ -153,9 +155,9 @@ async function resolveRest(deps: FallbackDeps, request: PlugRequest, app: string
|
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
const report = (context: CallContext,
|
|
158
|
+
const report = (context: CallContext, recorded: { readonly id: string; readonly directory?: string }): void => {
|
|
157
159
|
try {
|
|
158
|
-
context.onRecord?.(
|
|
160
|
+
context.onRecord?.(recorded.id, recorded.directory)
|
|
159
161
|
} catch {}
|
|
160
162
|
}
|
|
161
163
|
|
package/src/gemini/route.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { listAvailableModels, resolveModel } from "../models.js"
|
|
|
22
22
|
import { reportCache } from "../cache.js"
|
|
23
23
|
import { reportPolicy } from "../policy.js"
|
|
24
24
|
import { reportUsage } from "../usage-header.js"
|
|
25
|
+
import { setSessionLink } from "../session/link.js"
|
|
25
26
|
import type { RouteHost } from "../openai/route.js"
|
|
26
27
|
import { dropRejected } from "../openai/sampling.js"
|
|
27
28
|
import { estimateTokens } from "../anthropic/tokens.js"
|
|
@@ -157,9 +158,10 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
157
158
|
host.caller.stream(call, {
|
|
158
159
|
app,
|
|
159
160
|
signal: scope.signal,
|
|
160
|
-
onRecord: (recordID) => {
|
|
161
|
+
onRecord: (recordID, directory) => {
|
|
161
162
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
162
163
|
response.setHeader(SESSION_HEADER, recordID)
|
|
164
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
163
165
|
},
|
|
164
166
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
165
167
|
onPolicy: reportPolicy(response),
|