opencode-plugboard 0.18.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 +32 -14
- package/package.json +1 -1
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/response.ts +8 -25
- package/src/anthropic/route.ts +13 -6
- package/src/budgets.ts +200 -0
- package/src/cache.ts +12 -2
- package/src/completions/request.ts +3 -0
- package/src/completions/response.ts +10 -1
- package/src/config.ts +183 -10
- package/src/contract.ts +17 -1
- package/src/discovery.ts +8 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/request.ts +1 -0
- package/src/gemini/response.ts +16 -1
- package/src/gemini/route.ts +5 -1
- package/src/keys.ts +172 -0
- package/src/log.ts +3 -0
- package/src/metrics.ts +3 -3
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +14 -2
- package/src/openai/request.ts +3 -0
- package/src/openai/response.ts +23 -5
- package/src/openai/route.ts +46 -7
- package/src/responses/request.ts +30 -14
- package/src/responses/route.ts +12 -5
- package/src/responses/store.ts +51 -0
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +20 -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/stop.ts +54 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +104 -5
- 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,14 +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"
|
|
27
|
+
import { withBudgets } from "./src/budgets.js"
|
|
26
28
|
import { withLimits } from "./src/limits.js"
|
|
27
29
|
import { createOpenAiRoute } from "./src/openai/route.js"
|
|
28
|
-
import { ensureBackend, ensurePump, loadServingCatalog, 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"
|
|
29
31
|
import { createSessionCaller } from "./src/session/caller.js"
|
|
30
32
|
import { installSessionHooks } from "./src/session/context.js"
|
|
31
33
|
import { createHistoryPort, createSessionPort, createStoragePort, loadCatalog } from "./src/session/host.js"
|
|
34
|
+
import { createKeyStore } from "./src/keys.js"
|
|
32
35
|
import { usageHandlers, UsageRpc } from "./src/rpc.js"
|
|
33
36
|
import { createUsageRecorder, withUsage } from "./src/usage.js"
|
|
34
37
|
|
|
@@ -58,6 +61,7 @@ export default Plugin.define({
|
|
|
58
61
|
sessions,
|
|
59
62
|
maxTools: options.maxTools,
|
|
60
63
|
apps: options.apps,
|
|
64
|
+
persona: (model: string) => personaOf(options, model),
|
|
61
65
|
remoteImages: options.remoteImages,
|
|
62
66
|
// The readiness gate the caller awaits before creating a session (fails closed: 503 `backend_unavailable`).
|
|
63
67
|
ensureBackend: (app: string, signal: AbortSignal) => ensureBackend(app, signal, bootLocation),
|
|
@@ -80,23 +84,35 @@ export default Plugin.define({
|
|
|
80
84
|
// Aliases written as a list retry the next target before the first output (src/fallback.ts).
|
|
81
85
|
// The response cache sits outside the fallbacks, so a hit never reaches a provider (src/cache.ts).
|
|
82
86
|
caller: withUsage(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
87
|
+
// Budgets sit outside the limiter, so a budget refusal never takes a slot (src/budgets.ts).
|
|
88
|
+
withBudgets(
|
|
89
|
+
withLimits(
|
|
90
|
+
withResponseCache(
|
|
91
|
+
withFallbacks(createSessionCaller(callerDeps), {
|
|
92
|
+
options,
|
|
93
|
+
loadCatalog: servingCatalog,
|
|
94
|
+
onFallback: (app) => {
|
|
95
|
+
if (options.metrics) processMetrics().fallback(app)
|
|
96
|
+
usage?.fallback(app)
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
86
99
|
options,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
),
|
|
101
|
+
() => limits,
|
|
102
|
+
processLimits(),
|
|
103
|
+
{
|
|
104
|
+
onLimited: (app, reason) => {
|
|
105
|
+
if (options.metrics) processMetrics().limited(app, reason)
|
|
106
|
+
usage?.limited(app)
|
|
91
107
|
},
|
|
92
|
-
}
|
|
93
|
-
options,
|
|
108
|
+
},
|
|
94
109
|
),
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
options.budgets,
|
|
111
|
+
usage,
|
|
112
|
+
processBudgets(),
|
|
97
113
|
{
|
|
98
|
-
|
|
99
|
-
if (options.metrics) processMetrics().limited(app,
|
|
114
|
+
onRefused: (app) => {
|
|
115
|
+
if (options.metrics) processMetrics().limited(app, "budget")
|
|
100
116
|
usage?.limited(app)
|
|
101
117
|
},
|
|
102
118
|
},
|
|
@@ -105,6 +121,8 @@ export default Plugin.define({
|
|
|
105
121
|
),
|
|
106
122
|
loadCatalog: servingCatalog,
|
|
107
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) }),
|
|
108
126
|
})
|
|
109
127
|
const registration = registerGeneration({
|
|
110
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,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* no-op `emit`. Thinking blocks are produced only when the request asked for thinking.
|
|
8
8
|
*/
|
|
9
9
|
import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
10
|
+
import { StopHoldback } from "../stop.js"
|
|
10
11
|
|
|
11
12
|
type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
12
13
|
|
|
@@ -76,10 +77,6 @@ const STOP_REASON: Record<Exclude<PlugFinish, "error">, string> = {
|
|
|
76
77
|
"content-filter": "refusal",
|
|
77
78
|
}
|
|
78
79
|
|
|
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
|
-
|
|
83
80
|
const noFinish = (): PlugError => ({ status: 500, kind: "server", message: "Model stream ended without a finish event" })
|
|
84
81
|
const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
|
|
85
82
|
|
|
@@ -127,10 +124,9 @@ export async function runMessage(
|
|
|
127
124
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
128
125
|
let usage: PlugUsage | undefined
|
|
129
126
|
let failure: PlugError | undefined
|
|
130
|
-
// Text is held back by the longest stop sequence
|
|
131
|
-
//
|
|
132
|
-
const holdback =
|
|
133
|
-
let held = ""
|
|
127
|
+
// Text is held back by the longest stop sequence, so a sequence at the end of the answer can be
|
|
128
|
+
// found and removed before it is sent (src/stop.ts). Without stop sequences nothing is held.
|
|
129
|
+
const holdback = new StopHoldback(exchange.stop ?? [])
|
|
134
130
|
|
|
135
131
|
const message = (extra: Record<string, unknown>) => ({
|
|
136
132
|
id,
|
|
@@ -162,20 +158,8 @@ export async function runMessage(
|
|
|
162
158
|
content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
|
|
163
159
|
open = undefined
|
|
164
160
|
}
|
|
165
|
-
const releaseHeld = () =>
|
|
166
|
-
|
|
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
|
-
}
|
|
161
|
+
const releaseHeld = () => delta("text", holdback.release())
|
|
162
|
+
const text = (chunk: string) => delta("text", holdback.push(chunk))
|
|
179
163
|
const delta = (kind: "text" | "thinking", text: string) => {
|
|
180
164
|
if (text.length === 0) return
|
|
181
165
|
start()
|
|
@@ -240,9 +224,8 @@ export async function runMessage(
|
|
|
240
224
|
return { failure, started }
|
|
241
225
|
}
|
|
242
226
|
// A plain stop with no tool calls that ends on a stop sequence: the sequence is not part of the answer.
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
releaseHeld()
|
|
227
|
+
const { text: rest, matched } = holdback.finish(finish === "stop" && calls === 0)
|
|
228
|
+
delta("text", rest)
|
|
246
229
|
start()
|
|
247
230
|
close()
|
|
248
231
|
ping()
|
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,
|
|
@@ -31,6 +32,8 @@ import { reportServed, type ServedInfo } from "../fallback.js"
|
|
|
31
32
|
import { resolveModel } from "../models.js"
|
|
32
33
|
import { reportCache } from "../cache.js"
|
|
33
34
|
import { reportPolicy } from "../policy.js"
|
|
35
|
+
import { reportUsage } from "../usage-header.js"
|
|
36
|
+
import { setSessionLink } from "../session/link.js"
|
|
34
37
|
import { dropRejected } from "../openai/sampling.js"
|
|
35
38
|
import type { RouteHost } from "../openai/route.js"
|
|
36
39
|
import { parseMessagesRequest } from "./request.js"
|
|
@@ -53,7 +56,7 @@ export interface MessagesCall {
|
|
|
53
56
|
readonly response: ServerResponse
|
|
54
57
|
readonly scope: RequestScope
|
|
55
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. */
|
|
56
|
-
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 }
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
const header = (request: IncomingMessage, name: string): string | undefined => {
|
|
@@ -62,11 +65,12 @@ const header = (request: IncomingMessage, name: string): string | undefined => {
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
/** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
|
|
65
|
-
const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
|
|
68
|
+
const authenticateRequest = async (host: RouteHost, request: IncomingMessage) => {
|
|
66
69
|
const options = liveOptions(host.options)
|
|
67
|
-
const
|
|
70
|
+
const runtime = host.keys ? await host.keys.digests() : []
|
|
71
|
+
const bearer = authenticate(options, request.headers.authorization, runtime)
|
|
68
72
|
const apiKey = header(request, "x-api-key")
|
|
69
|
-
return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}
|
|
73
|
+
return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`, runtime) : bearer
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
|
|
@@ -93,7 +97,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
93
97
|
const rejected = checkOrigin(request, host.options)
|
|
94
98
|
if (rejected) return fail(rejected)
|
|
95
99
|
// Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
|
|
96
|
-
const auth = authenticateRequest(host, request)
|
|
100
|
+
const auth = await authenticateRequest(host, request)
|
|
97
101
|
if (isPlugError(auth)) return fail(auth)
|
|
98
102
|
info.app = auth.app
|
|
99
103
|
if (request.method !== "POST")
|
|
@@ -102,6 +106,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
102
106
|
if (unsupported) return fail(unsupported)
|
|
103
107
|
|
|
104
108
|
const body = await readJsonBody(request, scope.signal)
|
|
109
|
+
noteEndUser(response, info, bodyUserId(body))
|
|
105
110
|
// count_tokens takes the message body without max_tokens; the parser needs one, so a placeholder stands in
|
|
106
111
|
// (the count never uses it). A max_tokens the client did send is still validated.
|
|
107
112
|
const sized = path === COUNT_TOKENS && isRecord(body) && body.max_tokens === undefined ? { ...body, max_tokens: Number.MAX_SAFE_INTEGER } : body
|
|
@@ -129,12 +134,14 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
129
134
|
host.caller.stream(model, {
|
|
130
135
|
app: auth.app,
|
|
131
136
|
signal: scope.signal,
|
|
132
|
-
onRecord: (recordID) => {
|
|
137
|
+
onRecord: (recordID, directory) => {
|
|
133
138
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
134
139
|
response.setHeader(SESSION_HEADER, recordID)
|
|
140
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
135
141
|
},
|
|
136
142
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
137
143
|
onPolicy: reportPolicy(response),
|
|
144
|
+
onUsage: reportUsage(response, exchange.stream),
|
|
138
145
|
onCache: reportCache(response),
|
|
139
146
|
}),
|
|
140
147
|
scope,
|
package/src/budgets.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-app budgets (option `budgets`): a `ModelCaller` wrapper that refuses a call with 429
|
|
3
|
+
* `budget_exceeded` once its app has used a ceiling for the current window.
|
|
4
|
+
*
|
|
5
|
+
* Windows are UTC: a calendar day, an ISO week (Monday start) and a calendar month. The check reads
|
|
6
|
+
* what the usage records already hold for the window (`UsageRecorder.spent`, src/usage.ts): requests
|
|
7
|
+
* on the model routes that were not refused by a limit or a budget, the tokens of answered calls and
|
|
8
|
+
* their cost at list prices. A call that crosses a ceiling is still served; the next one is refused.
|
|
9
|
+
* Calls in flight are not counted until they end, so a burst can pass a ceiling by the calls it
|
|
10
|
+
* admitted. A call that finds the records unreadable is refused with 503 `budget_unavailable` rather
|
|
11
|
+
* than let through.
|
|
12
|
+
*
|
|
13
|
+
* The check runs before anything else is taken: a refused call holds no concurrency slot and spends
|
|
14
|
+
* no requests-per-minute count. `index.ts` puts this wrapper outside `withLimits` for that reason.
|
|
15
|
+
* Crossing 80% of a ceiling logs `budget.warning` once per app, budget and window (and again in the
|
|
16
|
+
* next window). The warning state lives on the process runtime, so a plugin generation keeps it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Budget, BudgetMetric, BudgetWindow, Budgets } from "./config.js"
|
|
20
|
+
import type { CallContext, ModelCaller, PlugError, PlugEvent, PlugRequest } from "./contract.js"
|
|
21
|
+
import { errorMessage, log, logError, logWarn } from "./log.js"
|
|
22
|
+
import { totalTokens, utcDate, type BudgetStatus, type DayRecord, type UsageRecorder } from "./usage.js"
|
|
23
|
+
|
|
24
|
+
/** What the warnings already logged: `app|metric|window` -> the window's first day. The process-wide state (src/runtime.ts). */
|
|
25
|
+
export interface BudgetState {
|
|
26
|
+
readonly warned: Map<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const createBudgetState = (): BudgetState => ({ warned: new Map() })
|
|
30
|
+
|
|
31
|
+
export interface BudgetOptions {
|
|
32
|
+
/** Clock in epoch ms. Tests inject one; the default is `Date.now`. */
|
|
33
|
+
readonly now?: () => number
|
|
34
|
+
/** Called once for each refused call (metrics, usage). */
|
|
35
|
+
readonly onRefused?: (app: string) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DAY_MS = 86_400_000
|
|
39
|
+
const WARN_AT = 0.8
|
|
40
|
+
const METRICS: ReadonlyArray<BudgetMetric> = ["requests", "tokens", "costUsd"]
|
|
41
|
+
const WINDOWS: ReadonlyArray<BudgetWindow> = ["day", "week", "month"]
|
|
42
|
+
const LABEL: Record<BudgetMetric, string> = { requests: "requests", tokens: "tokens", costUsd: "cost (USD)" }
|
|
43
|
+
|
|
44
|
+
/** The window that holds `at`: its first UTC day (`start`) and the instant the next one begins (`resetsAt`, epoch ms). */
|
|
45
|
+
export function windowAt(window: BudgetWindow, at: number): { readonly start: string; readonly resetsAt: number } {
|
|
46
|
+
const now = new Date(at)
|
|
47
|
+
const year = now.getUTCFullYear()
|
|
48
|
+
const month = now.getUTCMonth()
|
|
49
|
+
const today = Date.UTC(year, month, now.getUTCDate())
|
|
50
|
+
if (window === "day") return { start: utcDate(today), resetsAt: today + DAY_MS }
|
|
51
|
+
if (window === "week") {
|
|
52
|
+
// getUTCDay counts from Sunday; Monday is 0 here.
|
|
53
|
+
const monday = today - ((now.getUTCDay() + 6) % 7) * DAY_MS
|
|
54
|
+
return { start: utcDate(monday), resetsAt: monday + 7 * DAY_MS }
|
|
55
|
+
}
|
|
56
|
+
return { start: utcDate(Date.UTC(year, month, 1)), resetsAt: Date.UTC(year, month + 1, 1) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The budget that applies to `app`: `perApp`, with the app's `apps` entry replacing each metric it sets. Undefined when neither sets a metric. */
|
|
60
|
+
export function budgetFor(budgets: Budgets, app: string): Budget | undefined {
|
|
61
|
+
const own = budgets.apps && Object.hasOwn(budgets.apps, app) ? budgets.apps[app] : undefined
|
|
62
|
+
const merged: Budget = { ...budgets.perApp, ...own }
|
|
63
|
+
return METRICS.some((metric) => merged[metric] !== undefined) ? merged : undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Ceiling {
|
|
67
|
+
readonly metric: BudgetMetric
|
|
68
|
+
readonly window: BudgetWindow
|
|
69
|
+
readonly limit: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every ceiling the budget sets, in a fixed order: metric, then window. */
|
|
73
|
+
function ceilingsOf(budget: Budget): Ceiling[] {
|
|
74
|
+
const out: Ceiling[] = []
|
|
75
|
+
for (const metric of METRICS)
|
|
76
|
+
for (const window of WINDOWS) {
|
|
77
|
+
const limit = budget[metric]?.[window]
|
|
78
|
+
if (limit !== undefined) out.push({ metric, window, limit })
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What a window's records count against a ceiling. Requests leave out refusals, which `limited` counts. */
|
|
84
|
+
function usedOf(day: DayRecord, metric: BudgetMetric): number {
|
|
85
|
+
if (metric === "requests") return day.requests - day.limited
|
|
86
|
+
if (metric === "tokens") return totalTokens(day.tokens)
|
|
87
|
+
return day.costUsd
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface Spent {
|
|
91
|
+
readonly day: DayRecord
|
|
92
|
+
readonly resetsAt: number
|
|
93
|
+
/** First UTC day of the window. */
|
|
94
|
+
readonly start: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The records of the window holding `at`, from its first day to today. Memoized per call, so each window is read once. */
|
|
98
|
+
function spentFor(usage: UsageRecorder, app: string, at: number): (window: BudgetWindow) => Promise<Spent> {
|
|
99
|
+
const seen = new Map<BudgetWindow, Promise<Spent>>()
|
|
100
|
+
return (window) => {
|
|
101
|
+
let found = seen.get(window)
|
|
102
|
+
if (!found) {
|
|
103
|
+
const { start, resetsAt } = windowAt(window, at)
|
|
104
|
+
seen.set(window, (found = usage.spent(app, start, utcDate(at)).then((day) => ({ day, resetsAt, start }))))
|
|
105
|
+
}
|
|
106
|
+
return found
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The state of every ceiling of `budget` for `app` now: what the current window has used, and when it resets. */
|
|
111
|
+
export async function budgetStatus(usage: UsageRecorder, budget: Budget, app: string, at: number): Promise<BudgetStatus[]> {
|
|
112
|
+
const spent = spentFor(usage, app, at)
|
|
113
|
+
const rows: BudgetStatus[] = []
|
|
114
|
+
for (const { metric, window, limit } of ceilingsOf(budget)) {
|
|
115
|
+
const current = await spent(window)
|
|
116
|
+
rows.push({ metric, window, limit, used: usedOf(current.day, metric), resetsAt: new Date(current.resetsAt).toISOString() })
|
|
117
|
+
}
|
|
118
|
+
return rows
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The refusal for a call now, or undefined. Logs the warnings the ceilings cross (once each per window). */
|
|
122
|
+
async function check(budget: Budget, app: string, usage: UsageRecorder, state: BudgetState, at: number): Promise<PlugError | undefined> {
|
|
123
|
+
const spent = spentFor(usage, app, at)
|
|
124
|
+
const exceeded: string[] = []
|
|
125
|
+
let resetsAt = 0
|
|
126
|
+
for (const { metric, window, limit } of ceilingsOf(budget)) {
|
|
127
|
+
const current = await spent(window)
|
|
128
|
+
const used = usedOf(current.day, metric)
|
|
129
|
+
if (used >= limit * WARN_AT) warnOnce(state, app, metric, window, current.start, { used, limit, resetsAt: current.resetsAt })
|
|
130
|
+
if (used >= limit) {
|
|
131
|
+
exceeded.push(`${LABEL[metric]} ${window}`)
|
|
132
|
+
resetsAt = Math.max(resetsAt, current.resetsAt)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (exceeded.length === 0) return undefined
|
|
136
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((resetsAt - at) / 1000))
|
|
137
|
+
log("request.budget_exceeded", { app, budgets: exceeded.join(", "), retryAfterSeconds })
|
|
138
|
+
return { status: 429, kind: "rate_limit", code: "budget_exceeded", message: `Budget for ${app} exceeded: ${exceeded.join(", ")}`, retryAfterSeconds }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function warnOnce(
|
|
142
|
+
state: BudgetState,
|
|
143
|
+
app: string,
|
|
144
|
+
metric: BudgetMetric,
|
|
145
|
+
window: BudgetWindow,
|
|
146
|
+
start: string,
|
|
147
|
+
figures: { readonly used: number; readonly limit: number; readonly resetsAt: number },
|
|
148
|
+
): void {
|
|
149
|
+
const key = `${app}|${metric}|${window}`
|
|
150
|
+
if (state.warned.get(key) === start) return
|
|
151
|
+
state.warned.set(key, start)
|
|
152
|
+
logWarn("budget.warning", { app, budget: metric, window, used: figures.used, limit: figures.limit, resetsAt: new Date(figures.resetsAt).toISOString() })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The budget caller: each call of an app with a budget is checked first. With no budget for the app,
|
|
157
|
+
* no usage recorder, or no budgets at all, the caller is returned as it is. `budgets` is the parsed option.
|
|
158
|
+
*/
|
|
159
|
+
export function withBudgets(
|
|
160
|
+
inner: ModelCaller,
|
|
161
|
+
budgets: Budgets | undefined,
|
|
162
|
+
usage: UsageRecorder | undefined,
|
|
163
|
+
state: BudgetState,
|
|
164
|
+
options: BudgetOptions = {},
|
|
165
|
+
): ModelCaller {
|
|
166
|
+
const now = options.now ?? Date.now
|
|
167
|
+
return {
|
|
168
|
+
stream(request, context) {
|
|
169
|
+
const budget = budgets === undefined ? undefined : budgetFor(budgets, context.app)
|
|
170
|
+
if (!usage || !budget) return inner.stream(request, context)
|
|
171
|
+
return admit(inner, budget, usage, state, now, options.onRefused, request, context)
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function* admit(
|
|
177
|
+
inner: ModelCaller,
|
|
178
|
+
budget: Budget,
|
|
179
|
+
usage: UsageRecorder,
|
|
180
|
+
state: BudgetState,
|
|
181
|
+
now: () => number,
|
|
182
|
+
onRefused: BudgetOptions["onRefused"],
|
|
183
|
+
request: PlugRequest,
|
|
184
|
+
context: CallContext,
|
|
185
|
+
): AsyncGenerator<PlugEvent> {
|
|
186
|
+
const app = context.app
|
|
187
|
+
let refusal: PlugError | undefined
|
|
188
|
+
try {
|
|
189
|
+
refusal = await check(budget, app, usage, state, now())
|
|
190
|
+
} catch (error) {
|
|
191
|
+
logError("budget.check_failed", { app, error: errorMessage(error) })
|
|
192
|
+
refusal = { status: 503, kind: "server", code: "budget_unavailable", message: "Could not read the usage records for the budget check" }
|
|
193
|
+
}
|
|
194
|
+
if (refusal) {
|
|
195
|
+
if (refusal.status === 429) onRefused?.(app)
|
|
196
|
+
yield { type: "error", error: refusal }
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
for await (const event of inner.stream(request, context)) yield event
|
|
200
|
+
}
|
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: [],
|
|
@@ -97,6 +99,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
|
|
|
97
99
|
includeUsage: streamOptions.include_usage === true,
|
|
98
100
|
perPrompt: n,
|
|
99
101
|
...(body.echo === true ? { echo: prompts } : {}),
|
|
102
|
+
...(generation.stop ? { stop: generation.stop } : {}),
|
|
100
103
|
},
|
|
101
104
|
...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
|
|
102
105
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
9
9
|
import type { ChoiceCodec } from "../openai/fanout.js"
|
|
10
10
|
import { encodeError, type StreamChoice, type StreamResult } from "../openai/response.js"
|
|
11
|
+
import { StopHoldback, stripStopTail } from "../stop.js"
|
|
11
12
|
|
|
12
13
|
/** What the encoders need besides the model events; the request decoder produces it, the model call never sees it. */
|
|
13
14
|
export interface CompletionsExchange {
|
|
@@ -20,6 +21,8 @@ export interface CompletionsExchange {
|
|
|
20
21
|
readonly perPrompt: number
|
|
21
22
|
/** `echo`: each prompt, written ahead of the text of its choices. */
|
|
22
23
|
readonly echo?: ReadonlyArray<string>
|
|
24
|
+
/** The request's `stop`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). */
|
|
25
|
+
readonly stop?: ReadonlyArray<string>
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
const FINISH_REASONS: Record<Exclude<PlugFinish, "error">, string> = {
|
|
@@ -65,6 +68,7 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
|
|
|
65
68
|
const error = failure ?? noFinish()
|
|
66
69
|
return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
|
|
67
70
|
}
|
|
71
|
+
if (finish === "stop") text = stripStopTail(text, exchange.stop ?? []).text
|
|
68
72
|
return {
|
|
69
73
|
status: 200,
|
|
70
74
|
body: {
|
|
@@ -106,6 +110,8 @@ async function streamText(
|
|
|
106
110
|
const prefix = prefixOf(exchange, choice.index)
|
|
107
111
|
if (prefix.length > 0) chunk(prefix)
|
|
108
112
|
}
|
|
113
|
+
// A stop sequence is held back until the finish decides it (src/stop.ts).
|
|
114
|
+
const holdback = new StopHoldback(exchange.stop ?? [])
|
|
109
115
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
110
116
|
let usage: PlugUsage | undefined
|
|
111
117
|
let failure: PlugError | undefined
|
|
@@ -120,12 +126,15 @@ async function streamText(
|
|
|
120
126
|
else finish = event.reason
|
|
121
127
|
} else if (event.type === "text-delta" && event.text.length > 0) {
|
|
122
128
|
start()
|
|
123
|
-
|
|
129
|
+
const out = holdback.push(event.text)
|
|
130
|
+
if (out.length > 0) chunk(out)
|
|
124
131
|
}
|
|
125
132
|
}
|
|
126
133
|
failure ??= finish ? undefined : noFinish()
|
|
127
134
|
if (failure) return { failure, started }
|
|
128
135
|
start()
|
|
136
|
+
const rest = holdback.finish(finish === "stop").text
|
|
137
|
+
if (rest.length > 0) chunk(rest)
|
|
129
138
|
chunk("", FINISH_REASONS[finish!])
|
|
130
139
|
return { started, ...(usage ? { usage } : {}) }
|
|
131
140
|
}
|