opencode-plugboard 0.17.0 → 0.19.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 +27 -14
- package/package.json +36 -9
- package/rpc.ts +3 -0
- package/src/anthropic/response.ts +8 -25
- package/src/anthropic/route.ts +2 -0
- package/src/budgets.ts +200 -0
- package/src/completions/request.ts +1 -0
- package/src/completions/response.ts +10 -1
- package/src/config.ts +98 -1
- package/src/contract.ts +14 -0
- 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 +8 -2
- package/src/gemini/request.ts +1 -0
- package/src/gemini/response.ts +16 -1
- package/src/gemini/route.ts +2 -0
- package/src/log.ts +3 -0
- package/src/metrics.ts +3 -3
- package/src/openai/fanout.ts +4 -0
- package/src/openai/request.ts +1 -0
- package/src/openai/response.ts +21 -3
- package/src/openai/route.ts +35 -4
- package/src/responses/request.ts +28 -14
- package/src/responses/route.ts +9 -4
- package/src/responses/store.ts +51 -0
- package/src/runtime.ts +10 -2
- package/src/stop.ts +54 -0
- package/src/tui-format.ts +217 -0
- package/src/tui.tsx +166 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +43 -3
- package/tui.ts +4 -0
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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,15 +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
|
-
"./rpc": "./
|
|
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"
|
|
20
26
|
},
|
|
21
|
-
"files": ["index.ts", "src/", "README.md", "LICENSE"],
|
|
22
27
|
"scripts": {
|
|
23
|
-
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
28
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.tui.json && tsc --noEmit -p test",
|
|
24
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",
|
|
25
34
|
"smoke:pack": "bun .github/scripts/smoke-pack.ts",
|
|
35
|
+
"docs:dev": "vitepress dev docs",
|
|
36
|
+
"docs:build": "vitepress build docs",
|
|
26
37
|
"release": "bun .github/scripts/release.ts"
|
|
27
38
|
},
|
|
28
39
|
"dependencies": {
|
|
@@ -32,7 +43,8 @@
|
|
|
32
43
|
"lru-cache": "11.5.3",
|
|
33
44
|
"p-queue": "9.3.5",
|
|
34
45
|
"pino": "10.4.0",
|
|
35
|
-
"safe-stable-stringify": "2.5.0"
|
|
46
|
+
"safe-stable-stringify": "2.5.0",
|
|
47
|
+
"uplot": "1.6.32"
|
|
36
48
|
},
|
|
37
49
|
"devDependencies": {
|
|
38
50
|
"@ai-sdk/anthropic": "4.0.78",
|
|
@@ -47,10 +59,13 @@
|
|
|
47
59
|
"@langchain/google-genai": "2.3.2",
|
|
48
60
|
"@langchain/openai": "1.6.2",
|
|
49
61
|
"@opencode/plugin": "2.0.26",
|
|
62
|
+
"@opentui/core": "0.5.17",
|
|
63
|
+
"@opentui/solid": "0.5.17",
|
|
50
64
|
"@types/bun": "1.4.2",
|
|
51
65
|
"@types/escape-html": "^1.0.4",
|
|
52
66
|
"@types/lcov-parse": "1.0.2",
|
|
53
67
|
"@types/node": "^26.6.2",
|
|
68
|
+
"@types/semver": "7.8.0",
|
|
54
69
|
"ai": "7.0.137",
|
|
55
70
|
"ajv": "8.20.0",
|
|
56
71
|
"conventional-commits-parser": "7.1.3",
|
|
@@ -60,11 +75,23 @@
|
|
|
60
75
|
"fast-xml-parser": "5.11.2",
|
|
61
76
|
"lcov-parse": "1.0.0",
|
|
62
77
|
"lefthook": "2.2.1",
|
|
78
|
+
"mitata": "1.0.34",
|
|
63
79
|
"openai": "7.32.0",
|
|
64
|
-
"
|
|
80
|
+
"semver": "7.8.5",
|
|
81
|
+
"solid-js": "1.9.15",
|
|
82
|
+
"typescript": "^5.9.3",
|
|
83
|
+
"vitepress": "1.6.4"
|
|
65
84
|
},
|
|
66
85
|
"peerDependencies": {
|
|
67
86
|
"@opencode/plugin": ">=2.0.26 <2.1",
|
|
68
|
-
"
|
|
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 }
|
|
69
96
|
}
|
|
70
97
|
}
|
package/rpc.ts
ADDED
|
@@ -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
|
@@ -31,6 +31,7 @@ import { reportServed, type ServedInfo } from "../fallback.js"
|
|
|
31
31
|
import { resolveModel } from "../models.js"
|
|
32
32
|
import { reportCache } from "../cache.js"
|
|
33
33
|
import { reportPolicy } from "../policy.js"
|
|
34
|
+
import { reportUsage } from "../usage-header.js"
|
|
34
35
|
import { dropRejected } from "../openai/sampling.js"
|
|
35
36
|
import type { RouteHost } from "../openai/route.js"
|
|
36
37
|
import { parseMessagesRequest } from "./request.js"
|
|
@@ -135,6 +136,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
135
136
|
},
|
|
136
137
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
137
138
|
onPolicy: reportPolicy(response),
|
|
139
|
+
onUsage: reportUsage(response, exchange.stream),
|
|
138
140
|
onCache: reportCache(response),
|
|
139
141
|
}),
|
|
140
142
|
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
|
+
}
|
|
@@ -97,6 +97,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
|
|
|
97
97
|
includeUsage: streamOptions.include_usage === true,
|
|
98
98
|
perPrompt: n,
|
|
99
99
|
...(body.echo === true ? { echo: prompts } : {}),
|
|
100
|
+
...(generation.stop ? { stop: generation.stop } : {}),
|
|
100
101
|
},
|
|
101
102
|
...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
|
|
102
103
|
}
|
|
@@ -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
|
}
|
package/src/config.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface PlugboardOptions {
|
|
|
50
50
|
readonly modelIds: ModelIds
|
|
51
51
|
/** Concurrency caps and requests per minute per app (see `src/limits.ts`). Absent: unlimited. */
|
|
52
52
|
readonly limits?: Limits
|
|
53
|
+
/** Per-app budgets (see `src/budgets.ts`): ceilings on requests, tokens and cost per UTC day, week or month. Needs `usage`. Absent: no budgets. */
|
|
54
|
+
readonly budgets?: Budgets
|
|
53
55
|
/** Opt-in exact-match response cache (see `src/cache.ts`). Absent: off. */
|
|
54
56
|
readonly cache?: CacheConfig
|
|
55
57
|
/** Per-app usage statistics (see `src/usage.ts`): recorded and served by `GET /v1/usage` and the RPC. */
|
|
@@ -99,6 +101,34 @@ export interface Limits {
|
|
|
99
101
|
readonly apps?: Readonly<Record<string, LimitCaps>>
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
/** What a budget counts: HTTP requests on the model routes (refusals by `limits` or budgets left out), tokens, or cost in USD. */
|
|
105
|
+
export type BudgetMetric = "requests" | "tokens" | "costUsd"
|
|
106
|
+
/** The windows a budget resets on: a UTC calendar day, an ISO week (Monday start) or a calendar month (UTC). */
|
|
107
|
+
export type BudgetWindow = "day" | "week" | "month"
|
|
108
|
+
|
|
109
|
+
/** Ceilings per window. A window that is not set has no ceiling. */
|
|
110
|
+
export interface BudgetWindows {
|
|
111
|
+
readonly day?: number
|
|
112
|
+
readonly week?: number
|
|
113
|
+
readonly month?: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One set of ceilings. A metric that is not set is unlimited. `costUsd` is an estimate at list prices. */
|
|
117
|
+
export interface Budget {
|
|
118
|
+
readonly requests?: BudgetWindows
|
|
119
|
+
readonly tokens?: BudgetWindows
|
|
120
|
+
readonly costUsd?: BudgetWindows
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Option `budgets`. `perApp` applies to every app; `apps.<name>` replaces `perApp` metric by metric for
|
|
125
|
+
* that app (its `requests` replaces `perApp.requests`, the other metrics still come from `perApp`).
|
|
126
|
+
*/
|
|
127
|
+
export interface Budgets {
|
|
128
|
+
readonly perApp?: Budget
|
|
129
|
+
readonly apps?: Readonly<Record<string, Budget>>
|
|
130
|
+
}
|
|
131
|
+
|
|
102
132
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
103
133
|
export const DEFAULT_APP = "default"
|
|
104
134
|
const DEFAULTS = {
|
|
@@ -109,7 +139,7 @@ const DEFAULTS = {
|
|
|
109
139
|
requestTimeoutMs: 600_000,
|
|
110
140
|
} as const
|
|
111
141
|
|
|
112
|
-
const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "cache", "usage"])
|
|
142
|
+
const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage"])
|
|
113
143
|
const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
|
|
114
144
|
const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
|
|
115
145
|
/** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
|
|
@@ -304,6 +334,68 @@ function parseCache(value: unknown, keys: Readonly<Record<string, string>>): Cac
|
|
|
304
334
|
return { ttlSeconds, maxEntries, maxBytes, apps: [...new Set(value.apps as string[])] }
|
|
305
335
|
}
|
|
306
336
|
|
|
337
|
+
const BUDGET_METRICS: ReadonlyArray<BudgetMetric> = ["requests", "tokens", "costUsd"]
|
|
338
|
+
const BUDGET_WINDOWS: ReadonlyArray<BudgetWindow> = ["day", "week", "month"]
|
|
339
|
+
/** The most days a window can span: a month is up to 31. Retention must keep at least that many, or a budget would count from pruned records. */
|
|
340
|
+
const WINDOW_DAYS: Readonly<Record<BudgetWindow, number>> = { day: 1, week: 7, month: 31 }
|
|
341
|
+
|
|
342
|
+
/** The longest window any ceiling of `budgets` uses, in days (0 without budgets). */
|
|
343
|
+
function widestWindowDays(budgets: Budgets | undefined): number {
|
|
344
|
+
let most = 0
|
|
345
|
+
for (const budget of [budgets?.perApp, ...Object.values(budgets?.apps ?? {})])
|
|
346
|
+
for (const metric of [budget?.requests, budget?.tokens, budget?.costUsd])
|
|
347
|
+
for (const window of Object.keys(metric ?? {}) as BudgetWindow[]) most = Math.max(most, WINDOW_DAYS[window])
|
|
348
|
+
return most
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Validates `budgets`: `{ perApp?, apps? }`, each a budget of `requests`, `tokens` and `costUsd`, each of
|
|
353
|
+
* those a map of window (`day`, `week`, `month`) to a positive limit. Counts are integers, costs any positive
|
|
354
|
+
* number of USD. An `apps` entry must name a configured app, as in `limits`.
|
|
355
|
+
*/
|
|
356
|
+
function parseBudgets(raw: unknown, keys: Readonly<Record<string, string>>): Budgets | undefined {
|
|
357
|
+
if (raw === undefined) return undefined
|
|
358
|
+
if (!isRecord(raw)) throw new Error("plugboard option `budgets` must be an object")
|
|
359
|
+
for (const key of Object.keys(raw))
|
|
360
|
+
if (key !== "perApp" && key !== "apps") throw new Error(`Unknown plugboard option \`budgets.${key}\` (known: perApp, apps)`)
|
|
361
|
+
|
|
362
|
+
const budget = (value: unknown, at: string): Budget => {
|
|
363
|
+
if (!isRecord(value)) throw new Error(`plugboard option \`${at}\` must be an object`)
|
|
364
|
+
for (const key of Object.keys(value))
|
|
365
|
+
if (!BUDGET_METRICS.includes(key as BudgetMetric)) throw new Error(`Unknown plugboard option \`${at}.${key}\` (known: ${BUDGET_METRICS.join(", ")})`)
|
|
366
|
+
const parsed: Record<string, Record<string, number>> = {}
|
|
367
|
+
for (const metric of BUDGET_METRICS) {
|
|
368
|
+
const windows = value[metric]
|
|
369
|
+
if (windows === undefined) continue
|
|
370
|
+
const field = `${at}.${metric}`
|
|
371
|
+
if (!isRecord(windows)) throw new Error(`plugboard option \`${field}\` must be an object of window (${BUDGET_WINDOWS.join(", ")}) to limit`)
|
|
372
|
+
const limits: Record<string, number> = {}
|
|
373
|
+
for (const window of Object.keys(windows)) {
|
|
374
|
+
if (!BUDGET_WINDOWS.includes(window as BudgetWindow)) throw new Error(`Unknown plugboard option \`${field}.${window}\` (known: ${BUDGET_WINDOWS.join(", ")})`)
|
|
375
|
+
const limit = windows[window]
|
|
376
|
+
if (limit === undefined) continue
|
|
377
|
+
if (metric === "costUsd") {
|
|
378
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) throw new Error(`plugboard option \`${field}.${window}\` must be a positive number of USD`)
|
|
379
|
+
limits[window] = limit
|
|
380
|
+
} else limits[window] = positiveInt(limit, `${field}.${window}`, 0, Number.MAX_SAFE_INTEGER)
|
|
381
|
+
}
|
|
382
|
+
parsed[metric] = limits
|
|
383
|
+
}
|
|
384
|
+
return parsed as Budget
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const perApp = raw.perApp === undefined ? undefined : budget(raw.perApp, "budgets.perApp")
|
|
388
|
+
if (raw.apps !== undefined && !isRecord(raw.apps)) throw new Error("plugboard option `budgets.apps` must be an object of app name -> budget")
|
|
389
|
+
const apps: Record<string, Budget> = {}
|
|
390
|
+
for (const [app, value] of Object.entries(isRecord(raw.apps) ? raw.apps : {})) {
|
|
391
|
+
const at = `budgets.apps.${app}`
|
|
392
|
+
const configured = app === DEFAULT_APP ? Object.keys(keys).length === 0 : Object.hasOwn(keys, app)
|
|
393
|
+
if (!configured) throw new Error(`plugboard option \`${at}\` must name a configured app (a key in \`keys\`; \`${DEFAULT_APP}\` only without keys)`)
|
|
394
|
+
apps[app] = budget(value, at)
|
|
395
|
+
}
|
|
396
|
+
return { ...(perApp === undefined ? {} : { perApp }), ...(raw.apps === undefined ? {} : { apps }) }
|
|
397
|
+
}
|
|
398
|
+
|
|
307
399
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
308
400
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
309
401
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -397,6 +489,10 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
397
489
|
const limits = parseLimits(input.limits, keys)
|
|
398
490
|
const cache = parseCache(input.cache, keys)
|
|
399
491
|
const usage = parseUsage(input.usage)
|
|
492
|
+
const budgets = parseBudgets(input.budgets, keys)
|
|
493
|
+
if (budgets !== undefined && !usage.enabled) throw new Error("plugboard option `budgets` needs `usage` enabled: budgets are counted from the usage records")
|
|
494
|
+
if (budgets !== undefined && usage.retentionDays < widestWindowDays(budgets))
|
|
495
|
+
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`)
|
|
400
496
|
|
|
401
497
|
return {
|
|
402
498
|
listen: { host, port: port ?? DEFAULTS.port },
|
|
@@ -417,6 +513,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
417
513
|
...(limits === undefined ? {} : { limits }),
|
|
418
514
|
...(cache ? { cache } : {}),
|
|
419
515
|
usage,
|
|
516
|
+
...(budgets === undefined ? {} : { budgets }),
|
|
420
517
|
...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
|
|
421
518
|
}
|
|
422
519
|
}
|
package/src/contract.ts
CHANGED
|
@@ -186,6 +186,20 @@ export interface CallContext {
|
|
|
186
186
|
readonly onCache?: (result: "hit" | "miss") => void
|
|
187
187
|
/** Bypass the response cache for this call. Fan-out sets it: each choice of `n` above 1 repeats one request and must be a fresh sample. */
|
|
188
188
|
readonly noCache?: boolean
|
|
189
|
+
/**
|
|
190
|
+
* Called once per answered call, before its finish event, when usage recording is on (`withUsage`,
|
|
191
|
+
* src/usage.ts): the call's tokens and USD cost. Front-ends set `x-plugboard-usage` from it while the
|
|
192
|
+
* headers are open, so a stream (headers sent first) never gets it.
|
|
193
|
+
*/
|
|
194
|
+
readonly onUsage?: (totals: CallTotals) => void
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One answered call's totals for the caller: `inputTokens` and `outputTokens` as the response body reports them (cache reads inside input, reasoning inside output), and `costUsd` when the model has a price. */
|
|
198
|
+
export interface CallTotals {
|
|
199
|
+
readonly inputTokens: number
|
|
200
|
+
readonly outputTokens: number
|
|
201
|
+
readonly cacheReadTokens: number
|
|
202
|
+
readonly costUsd?: number
|
|
189
203
|
}
|
|
190
204
|
|
|
191
205
|
/** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
|