opencode-plugboard 0.13.0 → 0.15.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 +19 -6
- package/package.json +5 -1
- package/src/anthropic/route.ts +2 -0
- package/src/cache.ts +165 -0
- package/src/config.ts +97 -1
- package/src/contract.ts +6 -0
- package/src/gemini/route.ts +2 -0
- package/src/limits.ts +144 -0
- package/src/log.ts +98 -31
- package/src/metrics.ts +100 -104
- package/src/models.ts +51 -3
- package/src/openai/fanout.ts +5 -0
- package/src/openai/route.ts +3 -1
- package/src/responses/route.ts +2 -0
- package/src/runtime.ts +51 -4
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
|
|
|
30
30
|
{
|
|
31
31
|
"plugins": [
|
|
32
32
|
{
|
|
33
|
-
"package": "opencode-plugboard@0.
|
|
33
|
+
"package": "opencode-plugboard@0.15.0",
|
|
34
34
|
"options": {
|
|
35
35
|
"keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
|
|
36
36
|
"models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
|
|
@@ -78,7 +78,7 @@ Plugboard is early (0.x) and narrow on purpose.
|
|
|
78
78
|
- **V2 only.** OpenCode V2 has no OpenAI endpoint, and the V1 plugins that had one stopped working when the plugin API changed. V1 is not planned.
|
|
79
79
|
- **Two formats:** Chat Completions and Responses. `/v1/responses` is a stateless subset, see [docs/responses.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md). Anthropic Messages and Gemini front-ends are on the [roadmap](#roadmap).
|
|
80
80
|
- **Not planned:** embeddings, audio, image generation, `logprobs`.
|
|
81
|
-
- **
|
|
81
|
+
- **Not a distributed gateway.** Limits, the response cache and metrics live in one OpenCode server process; two servers don't share them.
|
|
82
82
|
- **Not a gateway with its own provider keys.** It only reuses what OpenCode is logged in to.
|
|
83
83
|
|
|
84
84
|
How it differs from a standalone gateway:
|
package/index.ts
CHANGED
|
@@ -23,8 +23,9 @@ import { Effect } from "effect"
|
|
|
23
23
|
import { parseOptions } from "./src/config.js"
|
|
24
24
|
import { errorMessage, logError } from "./src/log.js"
|
|
25
25
|
import { withFallbacks } from "./src/fallback.js"
|
|
26
|
+
import { withLimits } from "./src/limits.js"
|
|
26
27
|
import { createOpenAiRoute } from "./src/openai/route.js"
|
|
27
|
-
import { ensureBackend, ensurePump, loadServingCatalog, processMetrics, registerGeneration, sessionStore } from "./src/runtime.js"
|
|
28
|
+
import { ensureBackend, ensurePump, loadServingCatalog, processLimits, processMetrics, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
|
|
28
29
|
import { createSessionCaller } from "./src/session/caller.js"
|
|
29
30
|
import { installSessionHooks } from "./src/session/context.js"
|
|
30
31
|
import { createHistoryPort, createSessionPort, loadCatalog } from "./src/session/host.js"
|
|
@@ -65,14 +66,26 @@ export default Plugin.define({
|
|
|
65
66
|
startPump()
|
|
66
67
|
return loadServingCatalog(ownCatalog)
|
|
67
68
|
}
|
|
69
|
+
const limits = options.limits
|
|
68
70
|
const handle = createOpenAiRoute({
|
|
69
71
|
options,
|
|
72
|
+
// Caps are outermost, so one request takes one slot even when it falls back (src/limits.ts).
|
|
70
73
|
// Aliases written as a list retry the next target before the first output (src/fallback.ts).
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
// Limits are outermost (one slot per request, refused calls never reach anything); the response
|
|
75
|
+
// cache sits outside the fallbacks, so a hit never reaches a provider (src/limits.ts, src/cache.ts).
|
|
76
|
+
caller: withLimits(
|
|
77
|
+
withResponseCache(
|
|
78
|
+
withFallbacks(createSessionCaller(callerDeps), {
|
|
79
|
+
options,
|
|
80
|
+
loadCatalog: servingCatalog,
|
|
81
|
+
onFallback: options.metrics ? (app) => processMetrics().fallback(app) : undefined,
|
|
82
|
+
}),
|
|
83
|
+
options,
|
|
84
|
+
),
|
|
85
|
+
() => limits,
|
|
86
|
+
processLimits(),
|
|
87
|
+
{ onLimited: options.metrics ? (app, reason) => processMetrics().limited(app, reason) : undefined },
|
|
88
|
+
),
|
|
76
89
|
loadCatalog: servingCatalog,
|
|
77
90
|
})
|
|
78
91
|
const registration = registerGeneration({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
|
|
@@ -25,8 +25,12 @@
|
|
|
25
25
|
"release": "bun .github/scripts/release.ts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"@prometheus-io/client": "0.16.1",
|
|
28
29
|
"escape-html": "1.0.3",
|
|
29
30
|
"ipaddr.js": "2.5.0",
|
|
31
|
+
"lru-cache": "11.5.3",
|
|
32
|
+
"p-queue": "9.3.5",
|
|
33
|
+
"pino": "10.4.0",
|
|
30
34
|
"safe-stable-stringify": "2.5.0"
|
|
31
35
|
},
|
|
32
36
|
"devDependencies": {
|
package/src/anthropic/route.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "../http.js"
|
|
28
28
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
29
29
|
import { resolveModel } from "../models.js"
|
|
30
|
+
import { reportCache } from "../cache.js"
|
|
30
31
|
import { reportPolicy } from "../policy.js"
|
|
31
32
|
import { dropRejected } from "../openai/sampling.js"
|
|
32
33
|
import type { RouteHost } from "../openai/route.js"
|
|
@@ -125,6 +126,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
125
126
|
},
|
|
126
127
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
127
128
|
onPolicy: reportPolicy(response),
|
|
129
|
+
onCache: reportCache(response),
|
|
128
130
|
}),
|
|
129
131
|
scope,
|
|
130
132
|
)
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in exact-match response cache, as a `ModelCaller` wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Pollers and bots repeat identical prompts and pay quota every time. With `cache` set, a
|
|
5
|
+
* completed answer is kept in memory, and the same request from the same app within the TTL is
|
|
6
|
+
* answered from it without creating a session or touching the provider.
|
|
7
|
+
*
|
|
8
|
+
* What is cached: a turn that streamed to the end and finished `stop` or `length`, with some text
|
|
9
|
+
* or reasoning and no tool call and no error. Everything else passes through untouched: tool calls
|
|
10
|
+
* need the caller's tool results, structured output arrives as a forced tool call (so it is not
|
|
11
|
+
* cached), and errors and aborted turns are never answers. A hit replays what the miss sent: the
|
|
12
|
+
* `PlugEvent` sequence (same-kind deltas coalesced), the `onServed` and `onPolicy` notes, and a
|
|
13
|
+
* `finish` with zero usage, because the call spent no tokens and `plugboard_tokens_total` must not
|
|
14
|
+
* count it. No history record is written on a hit.
|
|
15
|
+
*
|
|
16
|
+
* The key is a SHA-256 of the app and the resolved request: provider, model id, variant, system,
|
|
17
|
+
* messages, tools, tool choice and sampling parameters. The `model` string the caller sent (an
|
|
18
|
+
* alias, say) is not part of it, so two names for one model share entries. Apps never share
|
|
19
|
+
* entries. The store is process-wide (`src/runtime.ts`), so it survives plugin generations, is
|
|
20
|
+
* reset when the cache option or the `apps` policy changes, and is lost on restart.
|
|
21
|
+
*
|
|
22
|
+
* Policy that the key does not see (a system prompt part, a tool strip, a model scope) is applied
|
|
23
|
+
* after the key is built. That is safe because the store is reset whenever `apps` changes, so an
|
|
24
|
+
* entry always comes from the same policy as the request that replays it.
|
|
25
|
+
*
|
|
26
|
+
* Fan-out (`n` above 1, prompt arrays) calls the caller once per choice with the same request, so
|
|
27
|
+
* the fan-out sets `noCache`: every choice must be a fresh sample.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createHash } from "node:crypto"
|
|
31
|
+
import type { ServerResponse } from "node:http"
|
|
32
|
+
import { LRUCache } from "lru-cache"
|
|
33
|
+
import stableStringify from "safe-stable-stringify"
|
|
34
|
+
import type { CallContext, ModelCaller, PlugEvent, PlugFinish, PlugRequest, PolicyNote, ServedModel } from "./contract.js"
|
|
35
|
+
|
|
36
|
+
/** The `cache` plugin option after defaults and validation (see `parseOptions` in `src/config.ts`). */
|
|
37
|
+
export interface CacheConfig {
|
|
38
|
+
/** How long an entry is served after it was stored. */
|
|
39
|
+
readonly ttlSeconds: number
|
|
40
|
+
/** Most entries kept; the least recently used goes first. */
|
|
41
|
+
readonly maxEntries: number
|
|
42
|
+
/** Most bytes kept (the entries as JSON); the least recently used goes first. */
|
|
43
|
+
readonly maxBytes: number
|
|
44
|
+
/** Apps that use the cache. Absent: every app. */
|
|
45
|
+
readonly apps?: ReadonlyArray<string>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a miss produced, replayed by a hit. */
|
|
49
|
+
export interface Entry {
|
|
50
|
+
readonly events: ReadonlyArray<PlugEvent>
|
|
51
|
+
readonly served?: ServedModel
|
|
52
|
+
readonly policy?: ReadonlyArray<PolicyNote>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CacheStore {
|
|
56
|
+
get(key: string): Entry | undefined
|
|
57
|
+
set(key: string, entry: Entry): void
|
|
58
|
+
size(): number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CacheLimits {
|
|
62
|
+
readonly ttlMs: number
|
|
63
|
+
readonly maxEntries: number
|
|
64
|
+
readonly maxBytes: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Fixed per-entry overhead added to the payload size: the key, the entry object and the array. */
|
|
68
|
+
const ENTRY_OVERHEAD = 128
|
|
69
|
+
|
|
70
|
+
export function createCacheStore(limits: CacheLimits): CacheStore {
|
|
71
|
+
const lru = new LRUCache<string, Entry>({
|
|
72
|
+
max: limits.maxEntries,
|
|
73
|
+
maxSize: limits.maxBytes,
|
|
74
|
+
sizeCalculation: (entry) => Buffer.byteLength(JSON.stringify(entry)) + ENTRY_OVERHEAD,
|
|
75
|
+
ttl: limits.ttlMs,
|
|
76
|
+
})
|
|
77
|
+
return {
|
|
78
|
+
get: (key) => lru.get(key),
|
|
79
|
+
set: (key, entry) => void lru.set(key, entry),
|
|
80
|
+
size: () => lru.size,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The cache key of one call: the app and the resolved request, object keys sorted. */
|
|
85
|
+
export function cacheKey(app: string, request: PlugRequest): string {
|
|
86
|
+
const { ref } = request
|
|
87
|
+
const canonical = stableStringify([app, ref.providerID, ref.id, ref.variant ?? null, request.system, request.messages, request.tools, request.toolChoice ?? null, request.generation])
|
|
88
|
+
return createHash("sha256").update(canonical ?? "").digest("hex")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Called once per call the cache applies to. */
|
|
92
|
+
export type CacheCounter = (app: string, result: "hit" | "miss") => void
|
|
93
|
+
|
|
94
|
+
/** The response header that says whether the answer came from the cache. */
|
|
95
|
+
export const CACHE_HEADER = "x-plugboard-cache"
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `CallContext.onCache` of a route: `x-plugboard-cache: hit|miss` while the headers are still
|
|
99
|
+
* open. The first writer wins, so several choices of one request report once.
|
|
100
|
+
*/
|
|
101
|
+
export function reportCache(response: ServerResponse): (result: "hit" | "miss") => void {
|
|
102
|
+
return (result) => {
|
|
103
|
+
if (response.headersSent || response.destroyed || response.hasHeader(CACHE_HEADER)) return
|
|
104
|
+
response.setHeader(CACHE_HEADER, result)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const CACHEABLE_FINISH: ReadonlySet<PlugFinish> = new Set<PlugFinish>(["stop", "length"])
|
|
109
|
+
|
|
110
|
+
/** Appends a delta to the list, merging it into the previous event when that is the same kind. */
|
|
111
|
+
function append(events: PlugEvent[], event: { readonly type: "text-delta" | "reasoning-delta"; readonly text: string }): void {
|
|
112
|
+
const last = events.at(-1)
|
|
113
|
+
if (last && last.type === event.type) events[events.length - 1] = { type: event.type, text: (last as { text: string }).text + event.text }
|
|
114
|
+
else events.push({ type: event.type, text: event.text })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. */
|
|
118
|
+
export function cachedCaller(inner: ModelCaller, config: CacheConfig, store: CacheStore, count?: CacheCounter): ModelCaller {
|
|
119
|
+
const apps = config.apps ? new Set(config.apps) : undefined
|
|
120
|
+
return {
|
|
121
|
+
async *stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
|
|
122
|
+
if (context.noCache || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
|
|
123
|
+
const key = cacheKey(context.app, request)
|
|
124
|
+
const hit = store.get(key)
|
|
125
|
+
if (hit) {
|
|
126
|
+
count?.(context.app, "hit")
|
|
127
|
+
context.onCache?.("hit")
|
|
128
|
+
if (hit.served) context.onServed?.(hit.served)
|
|
129
|
+
if (hit.policy) context.onPolicy?.(hit.policy)
|
|
130
|
+
for (const event of hit.events) {
|
|
131
|
+
if (context.signal.aborted) return
|
|
132
|
+
yield event.type === "finish" ? { type: "finish", reason: event.reason, usage: { inputTokens: 0, outputTokens: 0 } } : event
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
count?.(context.app, "miss")
|
|
137
|
+
context.onCache?.("miss")
|
|
138
|
+
// What the route is told before the first event is kept for a later hit; the route still gets it.
|
|
139
|
+
let served: ServedModel | undefined
|
|
140
|
+
let policy: ReadonlyArray<PolicyNote> | undefined
|
|
141
|
+
const forward: CallContext = {
|
|
142
|
+
...context,
|
|
143
|
+
onServed: (value) => {
|
|
144
|
+
served ??= value
|
|
145
|
+
context.onServed?.(value)
|
|
146
|
+
},
|
|
147
|
+
onPolicy: (notes) => {
|
|
148
|
+
policy ??= [...notes]
|
|
149
|
+
context.onPolicy?.(notes)
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
const recorded: PlugEvent[] = []
|
|
153
|
+
let storable = true
|
|
154
|
+
for await (const event of inner.stream(request, forward)) {
|
|
155
|
+
if (event.type === "text-delta" || event.type === "reasoning-delta") append(recorded, event)
|
|
156
|
+
else if (event.type === "finish") {
|
|
157
|
+
// Stored before the event is handed on, so a consumer that stops reading at `finish` still fills the cache.
|
|
158
|
+
if (storable && CACHEABLE_FINISH.has(event.reason) && recorded.length > 0 && !context.signal.aborted)
|
|
159
|
+
store.set(key, { events: [...recorded, { type: "finish", reason: event.reason }], ...(served ? { served } : {}), ...(policy?.length ? { policy } : {}) })
|
|
160
|
+
} else storable = false
|
|
161
|
+
yield event
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
|
|
2
2
|
|
|
3
3
|
import { createHash, timingSafeEqual } from "node:crypto"
|
|
4
|
+
import type { CacheConfig } from "./cache.js"
|
|
4
5
|
import type { PlugError } from "./contract.js"
|
|
5
6
|
import { allowedBy, parseModelRef } from "./models.js"
|
|
6
7
|
import { parseAllowEntry, type RemoteImages } from "./session/media.js"
|
|
7
8
|
import type { HistoryLimits } from "./session/prune.js"
|
|
8
9
|
|
|
10
|
+
/** How `/v1/models` spells a model id: the canonical `provider/model#variant`, a UI-safe slug, or both. Resolution accepts either. */
|
|
11
|
+
export type ModelIds = "canonical" | "slugs" | "both"
|
|
12
|
+
|
|
9
13
|
/** Plugin options, after defaults and validation (see `parseOptions`). */
|
|
10
14
|
export interface PlugboardOptions {
|
|
11
15
|
readonly listen: { readonly host: string; readonly port: number }
|
|
@@ -42,6 +46,12 @@ export interface PlugboardOptions {
|
|
|
42
46
|
readonly logFormat: "text" | "json"
|
|
43
47
|
/** Serve `GET /metrics` (Prometheus text). Off by default; same authentication as `/v1`. */
|
|
44
48
|
readonly metrics: boolean
|
|
49
|
+
/** Ids listed by `/v1/models`: canonical, slugs (`provider--model__variant`) or both. See `toSlug` in `src/models.ts`. */
|
|
50
|
+
readonly modelIds: ModelIds
|
|
51
|
+
/** Concurrency caps and requests per minute per app (see `src/limits.ts`). Absent: unlimited. */
|
|
52
|
+
readonly limits?: Limits
|
|
53
|
+
/** Opt-in exact-match response cache (see `src/cache.ts`). Absent: off. */
|
|
54
|
+
readonly cache?: CacheConfig
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
export type SystemMode = "prepend" | "append" | "replace"
|
|
@@ -61,6 +71,24 @@ export interface AppPolicy {
|
|
|
61
71
|
readonly maxTools?: number
|
|
62
72
|
}
|
|
63
73
|
|
|
74
|
+
/** One set of caps. A field that is not set is unlimited. */
|
|
75
|
+
export interface LimitCaps {
|
|
76
|
+
/** Model calls in flight at once; each call holds one slot. */
|
|
77
|
+
readonly maxConcurrent?: number
|
|
78
|
+
/** Model calls started per minute (a fixed one-minute window). */
|
|
79
|
+
readonly requestsPerMinute?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Option `limits`. `maxConcurrent` caps all apps together; `perApp` caps every app; `apps.<name>`
|
|
84
|
+
* overrides `perApp` field by field for that app.
|
|
85
|
+
*/
|
|
86
|
+
export interface Limits {
|
|
87
|
+
readonly maxConcurrent?: number
|
|
88
|
+
readonly perApp?: LimitCaps
|
|
89
|
+
readonly apps?: Readonly<Record<string, LimitCaps>>
|
|
90
|
+
}
|
|
91
|
+
|
|
64
92
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
65
93
|
export const DEFAULT_APP = "default"
|
|
66
94
|
const DEFAULTS = {
|
|
@@ -71,8 +99,16 @@ const DEFAULTS = {
|
|
|
71
99
|
requestTimeoutMs: 600_000,
|
|
72
100
|
} as const
|
|
73
101
|
|
|
74
|
-
const KNOWN = new Set(["listen", "keys", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
|
|
102
|
+
const KNOWN = new Set(["listen", "keys", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "cache"])
|
|
103
|
+
const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps"])
|
|
104
|
+
const LIMIT_CAP_KNOWN = new Set(["maxConcurrent", "requestsPerMinute"])
|
|
105
|
+
/** Largest cap accepted: far above any provider's limit, and keeps the error messages short. */
|
|
106
|
+
const MAX_LIMIT = 1_000_000
|
|
75
107
|
const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
|
|
108
|
+
const CACHE_KNOWN = new Set(["ttlSeconds", "maxEntries", "maxBytes", "apps"])
|
|
109
|
+
const CACHE_DEFAULTS = { maxEntries: 1000, maxBytes: 64 * 1024 * 1024 } as const
|
|
110
|
+
/** One year: a longer TTL is a typo, and keeps the millisecond conversion a safe integer. */
|
|
111
|
+
const MAX_CACHE_TTL_SECONDS = 31_536_000
|
|
76
112
|
/** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
|
|
77
113
|
const MAX_AGE_DAYS = 36_500
|
|
78
114
|
const MODEL_PATTERN = /^[^/#*\s]+\/(\*|[^#*\s]+(#[^#*\s]+)?)$/
|
|
@@ -139,6 +175,7 @@ function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
|
|
|
139
175
|
|
|
140
176
|
const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools"])
|
|
141
177
|
const SYSTEM_MODES: ReadonlySet<string> = new Set(["prepend", "append", "replace"])
|
|
178
|
+
const MODEL_IDS: ReadonlySet<string> = new Set(["canonical", "slugs", "both"])
|
|
142
179
|
|
|
143
180
|
/** Validates `apps` (the `key` field was merged into `keys` already) into per-app policies. */
|
|
144
181
|
function parseApps(
|
|
@@ -185,11 +222,62 @@ function parseApps(
|
|
|
185
222
|
return Object.fromEntries(entries)
|
|
186
223
|
}
|
|
187
224
|
|
|
225
|
+
/** Validates one set of caps (`limits.perApp`, or one `limits.apps` entry): each cap is a positive integer. */
|
|
226
|
+
function parseCaps(raw: unknown, at: string): LimitCaps {
|
|
227
|
+
if (!isRecord(raw)) throw new Error(`plugboard option \`${at}\` must be an object`)
|
|
228
|
+
for (const key of Object.keys(raw))
|
|
229
|
+
if (!LIMIT_CAP_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`${at}.${key}\` (known: ${[...LIMIT_CAP_KNOWN].join(", ")})`)
|
|
230
|
+
return {
|
|
231
|
+
...(raw.maxConcurrent === undefined ? {} : { maxConcurrent: positiveInt(raw.maxConcurrent, `${at}.maxConcurrent`, 0, MAX_LIMIT) }),
|
|
232
|
+
...(raw.requestsPerMinute === undefined ? {} : { requestsPerMinute: positiveInt(raw.requestsPerMinute, `${at}.requestsPerMinute`, 0, MAX_LIMIT) }),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Validates `limits`. An `apps` entry must name a configured app: a key in `keys` (or `default`, only when there are no keys). */
|
|
237
|
+
function parseLimits(raw: unknown, keys: Readonly<Record<string, string>>): Limits | undefined {
|
|
238
|
+
if (raw === undefined) return undefined
|
|
239
|
+
if (!isRecord(raw)) throw new Error("plugboard option `limits` must be an object")
|
|
240
|
+
for (const key of Object.keys(raw))
|
|
241
|
+
if (!LIMITS_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`limits.${key}\` (known: ${[...LIMITS_KNOWN].join(", ")})`)
|
|
242
|
+
const appsRaw = raw.apps
|
|
243
|
+
if (appsRaw !== undefined && !isRecord(appsRaw)) throw new Error("plugboard option `limits.apps` must be an object of app name -> caps")
|
|
244
|
+
const apps: Record<string, LimitCaps> = {}
|
|
245
|
+
for (const [app, value] of Object.entries(appsRaw ?? {})) {
|
|
246
|
+
const at = `limits.apps.${app}`
|
|
247
|
+
const configured = app === DEFAULT_APP ? Object.keys(keys).length === 0 : Object.hasOwn(keys, app)
|
|
248
|
+
if (!configured) throw new Error(`plugboard option \`${at}\` must name a configured app (a key in \`keys\`; \`${DEFAULT_APP}\` only without keys)`)
|
|
249
|
+
apps[app] = parseCaps(value, at)
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
...(raw.maxConcurrent === undefined ? {} : { maxConcurrent: positiveInt(raw.maxConcurrent, "limits.maxConcurrent", 0, MAX_LIMIT) }),
|
|
253
|
+
...(raw.perApp === undefined ? {} : { perApp: parseCaps(raw.perApp, "limits.perApp") }),
|
|
254
|
+
...(appsRaw === undefined ? {} : { apps }),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
188
258
|
/** The policy configured for `app` (`default` for the keyless app), if any. Own-property lookup: an app name never reaches the prototype. */
|
|
189
259
|
export function appPolicy(options: Pick<PlugboardOptions, "apps">, app: string): AppPolicy | undefined {
|
|
190
260
|
return Object.hasOwn(options.apps, app) ? options.apps[app] : undefined
|
|
191
261
|
}
|
|
192
262
|
|
|
263
|
+
function parseCache(value: unknown, keys: Readonly<Record<string, string>>): CacheConfig | undefined {
|
|
264
|
+
if (value === undefined) return undefined
|
|
265
|
+
if (!isRecord(value)) throw new Error("plugboard option `cache` must be an object with `ttlSeconds`")
|
|
266
|
+
for (const key of Object.keys(value))
|
|
267
|
+
if (!CACHE_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`cache.${key}\` (known: ${[...CACHE_KNOWN].join(", ")})`)
|
|
268
|
+
if (value.ttlSeconds === undefined) throw new Error("plugboard option `cache.ttlSeconds` is required (how long an answer is served, in seconds)")
|
|
269
|
+
const ttlSeconds = positiveInt(value.ttlSeconds, "cache.ttlSeconds", 0, MAX_CACHE_TTL_SECONDS)
|
|
270
|
+
const maxEntries = positiveInt(value.maxEntries, "cache.maxEntries", CACHE_DEFAULTS.maxEntries)
|
|
271
|
+
const maxBytes = positiveInt(value.maxBytes, "cache.maxBytes", CACHE_DEFAULTS.maxBytes)
|
|
272
|
+
if (value.apps === undefined) return { ttlSeconds, maxEntries, maxBytes }
|
|
273
|
+
if (!Array.isArray(value.apps) || value.apps.length === 0) throw new Error("plugboard option `cache.apps` must be a non-empty array of app names")
|
|
274
|
+
// A name that can never authenticate would silently leave the cache off for the app the user meant.
|
|
275
|
+
const known = Object.keys(keys).length === 0 ? [DEFAULT_APP] : Object.keys(keys)
|
|
276
|
+
for (const app of value.apps)
|
|
277
|
+
if (typeof app !== "string" || !known.includes(app)) throw new Error(`plugboard \`cache.apps\` entry \`${String(app)}\` is not an app (known: ${known.join(", ")})`)
|
|
278
|
+
return { ttlSeconds, maxEntries, maxBytes, apps: [...new Set(value.apps as string[])] }
|
|
279
|
+
}
|
|
280
|
+
|
|
193
281
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
194
282
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
195
283
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -266,6 +354,8 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
266
354
|
if (input.logFormat !== undefined && input.logFormat !== "text" && input.logFormat !== "json")
|
|
267
355
|
throw new Error("plugboard option `logFormat` must be `text` or `json`")
|
|
268
356
|
if (input.metrics !== undefined && typeof input.metrics !== "boolean") throw new Error("plugboard option `metrics` must be a boolean")
|
|
357
|
+
if (input.modelIds !== undefined && (typeof input.modelIds !== "string" || !MODEL_IDS.has(input.modelIds)))
|
|
358
|
+
throw new Error("plugboard option `modelIds` must be `canonical`, `slugs` or `both`")
|
|
269
359
|
|
|
270
360
|
const historyInput = input.history
|
|
271
361
|
if (historyInput !== undefined && !isRecord(historyInput)) throw new Error("plugboard option `history` must be an object")
|
|
@@ -274,6 +364,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
274
364
|
const maxSessions = historyInput?.maxSessions === undefined ? undefined : positiveInt(historyInput.maxSessions, "history.maxSessions", 0)
|
|
275
365
|
const maxAgeDays = historyInput?.maxAgeDays === undefined ? undefined : positiveInt(historyInput.maxAgeDays, "history.maxAgeDays", 0, MAX_AGE_DAYS)
|
|
276
366
|
|
|
367
|
+
const limits = parseLimits(input.limits, keys)
|
|
368
|
+
const cache = parseCache(input.cache, keys)
|
|
369
|
+
|
|
277
370
|
return {
|
|
278
371
|
listen: { host, port: port ?? DEFAULTS.port },
|
|
279
372
|
keys,
|
|
@@ -289,6 +382,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
289
382
|
dropUnsupportedParams: input.dropUnsupportedParams !== false,
|
|
290
383
|
logFormat: input.logFormat === "json" ? "json" : "text",
|
|
291
384
|
metrics: input.metrics === true,
|
|
385
|
+
modelIds: (input.modelIds as ModelIds | undefined) ?? "canonical",
|
|
386
|
+
...(limits === undefined ? {} : { limits }),
|
|
387
|
+
...(cache ? { cache } : {}),
|
|
292
388
|
}
|
|
293
389
|
}
|
|
294
390
|
|
package/src/contract.ts
CHANGED
|
@@ -153,6 +153,8 @@ export interface PlugError {
|
|
|
153
153
|
readonly message: string
|
|
154
154
|
readonly code?: string
|
|
155
155
|
readonly param?: string
|
|
156
|
+
/** Seconds to wait before a retry, for a `Retry-After` header. Set by the limiter (`src/limits.ts`); no route reads it yet. */
|
|
157
|
+
readonly retryAfterSeconds?: number
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
/** The one discriminator for `PlugError` values returned in place of a result. */
|
|
@@ -180,6 +182,10 @@ export interface CallContext {
|
|
|
180
182
|
readonly onServed?: (served: ServedModel) => void
|
|
181
183
|
/** Called once, before the first event, with each change the app's policy made that the caller sent for. Front-ends report them in the `x-plugboard-policy` header. */
|
|
182
184
|
readonly onPolicy?: (notes: ReadonlyArray<PolicyNote>) => void
|
|
185
|
+
/** Called once, before the first event, when the response cache applies to the call: `hit` replays a stored answer (no session, no record), `miss` runs the model. Not called when the cache is off, skipped for the app, or `noCache` is set. */
|
|
186
|
+
readonly onCache?: (result: "hit" | "miss") => void
|
|
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
|
+
readonly noCache?: boolean
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
/** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
|
package/src/gemini/route.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
|
19
19
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
20
20
|
import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
21
21
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
22
|
+
import { reportCache } from "../cache.js"
|
|
22
23
|
import { reportPolicy } from "../policy.js"
|
|
23
24
|
import type { RouteHost } from "../openai/route.js"
|
|
24
25
|
import { dropRejected } from "../openai/sampling.js"
|
|
@@ -141,6 +142,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
141
142
|
},
|
|
142
143
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
143
144
|
onPolicy: reportPolicy(response),
|
|
145
|
+
onCache: reportCache(response),
|
|
144
146
|
}),
|
|
145
147
|
scope,
|
|
146
148
|
)
|
package/src/limits.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency caps and requests per minute per app (option `limits`): a `ModelCaller` wrapper
|
|
3
|
+
* that sits outside the fallback chain, so one request holds one slot however many targets it tries.
|
|
4
|
+
*
|
|
5
|
+
* Before the inner caller runs, a call takes a slot in each concurrency cap that applies to it: the
|
|
6
|
+
* global `maxConcurrent`, and its app's own cap (`apps.<name>` over `perApp`). Each cap is a
|
|
7
|
+
* `p-queue` whose concurrency is the cap, kept on the process runtime so it survives a plugin
|
|
8
|
+
* generation. Nothing waits in a queue: a call that finds a cap full, or its app past its requests
|
|
9
|
+
* per minute, is answered at once with one `error` event (429) and never reaches the model. Every
|
|
10
|
+
* check runs before any slot or window count is taken, so a refused call costs nothing.
|
|
11
|
+
*
|
|
12
|
+
* A slot is freed when the inner stream ends, throws, is closed by the consumer, or the request
|
|
13
|
+
* signal aborts (the abort frees it even if the inner stream ignores the signal). p-queue counts a
|
|
14
|
+
* slot free one microtask after its job resolves, so a call made in that same tick can still be
|
|
15
|
+
* refused; requests arrive from the I/O loop, so this does not happen in practice. The requests per
|
|
16
|
+
* minute limit is a fixed window of 60 s per app, counting the calls admitted in it.
|
|
17
|
+
*
|
|
18
|
+
* A call whose app has no cap that applies goes straight to the wrapped caller.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import PQueue from "p-queue"
|
|
22
|
+
import type { LimitCaps, Limits } from "./config.js"
|
|
23
|
+
import type { CallContext, ModelCaller, PlugError, PlugEvent, PlugRequest } from "./contract.js"
|
|
24
|
+
import { log } from "./log.js"
|
|
25
|
+
|
|
26
|
+
/** The process-wide queues and windows. Held on the runtime (`src/runtime.ts`), not on a generation. */
|
|
27
|
+
export interface LimitState {
|
|
28
|
+
/** One queue per capped scope: `*` for the global cap, an app name for that app's cap. Its concurrency is the cap. */
|
|
29
|
+
readonly queues: Map<string, PQueue>
|
|
30
|
+
/** The requests-per-minute window of each app: its start (epoch ms) and the calls admitted in it. */
|
|
31
|
+
readonly windows: Map<string, { start: number; count: number }>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const createLimitState = (): LimitState => ({ queues: new Map(), windows: new Map() })
|
|
35
|
+
|
|
36
|
+
export type LimitReason = "concurrency" | "rate"
|
|
37
|
+
|
|
38
|
+
export interface LimitOptions {
|
|
39
|
+
/** Clock in epoch ms. Tests inject one; the default is `Date.now`. */
|
|
40
|
+
readonly now?: () => number
|
|
41
|
+
/** Called once for each refused call (metrics). */
|
|
42
|
+
readonly onLimited?: (app: string, reason: LimitReason) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const WINDOW_MS = 60_000
|
|
46
|
+
/** Queue key of the global cap. Not an app name: names match `[a-z0-9-]+`. */
|
|
47
|
+
const GLOBAL = "*"
|
|
48
|
+
|
|
49
|
+
/** The caps that apply to `app`: `perApp`, with the app's `apps` entry overriding it field by field. */
|
|
50
|
+
export function capsFor(limits: Limits, app: string): LimitCaps {
|
|
51
|
+
const own = limits.apps && Object.hasOwn(limits.apps, app) ? limits.apps[app] : undefined
|
|
52
|
+
return { ...limits.perApp, ...own }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isCapped = (limits: Limits, app: string): boolean => {
|
|
56
|
+
const caps = capsFor(limits, app)
|
|
57
|
+
return limits.maxConcurrent !== undefined || caps.maxConcurrent !== undefined || caps.requestsPerMinute !== undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The caller's events, admitted under the configured caps; see the file comment. `getLimits` is read per call. */
|
|
61
|
+
export function withLimits(inner: ModelCaller, getLimits: () => Limits | undefined, state: LimitState, options: LimitOptions = {}): ModelCaller {
|
|
62
|
+
const now = options.now ?? Date.now
|
|
63
|
+
return {
|
|
64
|
+
stream(request, context) {
|
|
65
|
+
const limits = getLimits()
|
|
66
|
+
if (!limits || !isCapped(limits, context.app)) return inner.stream(request, context)
|
|
67
|
+
return admit(inner, limits, state, now, options.onLimited, request, context)
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function* admit(
|
|
73
|
+
inner: ModelCaller,
|
|
74
|
+
limits: Limits,
|
|
75
|
+
state: LimitState,
|
|
76
|
+
now: () => number,
|
|
77
|
+
onLimited: LimitOptions["onLimited"],
|
|
78
|
+
request: PlugRequest,
|
|
79
|
+
context: CallContext,
|
|
80
|
+
): AsyncGenerator<PlugEvent> {
|
|
81
|
+
const app = context.app
|
|
82
|
+
const caps = capsFor(limits, app)
|
|
83
|
+
const scopes: PQueue[] = []
|
|
84
|
+
if (limits.maxConcurrent !== undefined) scopes.push(queueFor(state, GLOBAL, limits.maxConcurrent))
|
|
85
|
+
if (caps.maxConcurrent !== undefined) scopes.push(queueFor(state, app, caps.maxConcurrent))
|
|
86
|
+
|
|
87
|
+
// Decide everything before taking anything: a refused call holds no slot and spends no window count.
|
|
88
|
+
const at = now()
|
|
89
|
+
const current = state.windows.get(app)
|
|
90
|
+
const live = current && at - current.start < WINDOW_MS ? current : undefined
|
|
91
|
+
let refusal: { readonly reason: LimitReason; readonly retryAfterSeconds: number } | undefined
|
|
92
|
+
if (scopes.some((queue) => queue.pending + queue.size >= queue.concurrency)) refusal = { reason: "concurrency", retryAfterSeconds: 1 }
|
|
93
|
+
else if (caps.requestsPerMinute !== undefined && live && live.count >= caps.requestsPerMinute)
|
|
94
|
+
refusal = { reason: "rate", retryAfterSeconds: Math.max(1, Math.ceil((live.start + WINDOW_MS - at) / 1000)) }
|
|
95
|
+
if (refusal) {
|
|
96
|
+
onLimited?.(app, refusal.reason)
|
|
97
|
+
log("request.limited", { app, reason: refusal.reason, retryAfterSeconds: refusal.retryAfterSeconds })
|
|
98
|
+
yield { type: "error", error: limitedError(refusal.reason, refusal.retryAfterSeconds) }
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (caps.requestsPerMinute !== undefined) {
|
|
103
|
+
if (live) live.count++
|
|
104
|
+
else state.windows.set(app, { start: at, count: 1 })
|
|
105
|
+
}
|
|
106
|
+
const frees = scopes.map(take)
|
|
107
|
+
let freed = false
|
|
108
|
+
const free = () => {
|
|
109
|
+
if (freed) return
|
|
110
|
+
freed = true
|
|
111
|
+
for (const release of frees) release()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const signal = context.signal
|
|
115
|
+
signal.addEventListener("abort", free, { once: true })
|
|
116
|
+
try {
|
|
117
|
+
for await (const event of inner.stream(request, context)) yield event
|
|
118
|
+
} finally {
|
|
119
|
+
signal.removeEventListener("abort", free)
|
|
120
|
+
free()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The queue of one capped scope, created on first use, with its concurrency set to the cap in force. */
|
|
125
|
+
function queueFor(state: LimitState, key: string, cap: number): PQueue {
|
|
126
|
+
let queue = state.queues.get(key)
|
|
127
|
+
if (!queue) state.queues.set(key, (queue = new PQueue({ concurrency: cap })))
|
|
128
|
+
queue.concurrency = cap
|
|
129
|
+
return queue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Takes a slot in a queue that has room (checked by the caller). The returned function frees it. */
|
|
133
|
+
function take(queue: PQueue): () => void {
|
|
134
|
+
let done = () => {}
|
|
135
|
+
// The job starts at once when a slot is free, so `done` is set before `add` returns.
|
|
136
|
+
void queue.add(() => new Promise<void>((resolve) => (done = resolve)))
|
|
137
|
+
return () => done()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A 429 with a neutral kind (`rate_limit`, the closest `PlugErrorKind`) and a code the front-ends pass through. */
|
|
141
|
+
const limitedError = (reason: LimitReason, retryAfterSeconds: number): PlugError =>
|
|
142
|
+
reason === "concurrency"
|
|
143
|
+
? { status: 429, kind: "rate_limit", code: "too_many_requests", message: "Too many concurrent requests", retryAfterSeconds }
|
|
144
|
+
: { status: 429, kind: "rate_limit", code: "rate_limit_exceeded", message: "Too many requests per minute", retryAfterSeconds }
|