opencode-plugboard 0.14.0 → 0.16.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 +7 -1
- package/src/anthropic/route.ts +7 -3
- package/src/cache.ts +165 -0
- package/src/completions/response.ts +2 -2
- package/src/completions/route.ts +2 -2
- package/src/config.ts +97 -1
- package/src/contract.ts +6 -0
- package/src/discovery.ts +24 -12
- package/src/gemini/route.ts +7 -5
- package/src/http.ts +13 -2
- package/src/limits.ts +144 -0
- package/src/metrics.ts +18 -0
- package/src/models.ts +51 -3
- package/src/openai/fanout.ts +11 -6
- package/src/openai/response.ts +2 -2
- package/src/openai/route.ts +12 -4
- package/src/responses/route.ts +4 -2
- 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.16.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.16.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.",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"@prometheus-io/client": "0.16.1",
|
|
29
29
|
"escape-html": "1.0.3",
|
|
30
30
|
"ipaddr.js": "2.5.0",
|
|
31
|
+
"lru-cache": "11.5.3",
|
|
32
|
+
"p-queue": "9.3.5",
|
|
31
33
|
"pino": "10.4.0",
|
|
32
34
|
"safe-stable-stringify": "2.5.0"
|
|
33
35
|
},
|
|
@@ -35,10 +37,14 @@
|
|
|
35
37
|
"@ai-sdk/anthropic": "4.0.78",
|
|
36
38
|
"@ai-sdk/google": "4.0.93",
|
|
37
39
|
"@ai-sdk/openai": "4.0.91",
|
|
40
|
+
"@ai-sdk/openai-compatible": "3.0.67",
|
|
38
41
|
"@anthropic-ai/sdk": "0.133.0",
|
|
39
42
|
"@commitlint/cli": "21.2.3",
|
|
40
43
|
"@commitlint/config-conventional": "21.2.3",
|
|
41
44
|
"@google/genai": "2.28.0",
|
|
45
|
+
"@langchain/anthropic": "1.5.12",
|
|
46
|
+
"@langchain/google-genai": "2.3.2",
|
|
47
|
+
"@langchain/openai": "1.6.2",
|
|
42
48
|
"@opencode/plugin": "2.0.26",
|
|
43
49
|
"@types/bun": "1.4.2",
|
|
44
50
|
"@types/escape-html": "^1.0.4",
|
package/src/anthropic/route.ts
CHANGED
|
@@ -21,12 +21,14 @@ import {
|
|
|
21
21
|
checkJsonContentType,
|
|
22
22
|
checkOrigin,
|
|
23
23
|
readJsonBody,
|
|
24
|
+
sendError,
|
|
24
25
|
sendJson,
|
|
25
26
|
sseWriter,
|
|
26
27
|
type RequestScope,
|
|
27
28
|
} from "../http.js"
|
|
28
29
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
29
30
|
import { resolveModel } from "../models.js"
|
|
31
|
+
import { reportCache } from "../cache.js"
|
|
30
32
|
import { reportPolicy } from "../policy.js"
|
|
31
33
|
import { dropRejected } from "../openai/sampling.js"
|
|
32
34
|
import type { RouteHost } from "../openai/route.js"
|
|
@@ -64,7 +66,7 @@ const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
|
|
|
64
66
|
|
|
65
67
|
export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
|
|
66
68
|
const { path, request, response, scope, info } = call
|
|
67
|
-
const fail = (error: PlugError, close = false) =>
|
|
69
|
+
const fail = (error: PlugError, close = false) => sendError(response, error, encodeError(error), { close })
|
|
68
70
|
if (request.method === "POST") info.route = `POST ${path}`
|
|
69
71
|
response.setHeader("request-id", `req_${crypto.randomUUID().replaceAll("-", "")}`)
|
|
70
72
|
|
|
@@ -83,13 +85,14 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
async function serve(): Promise<void> {
|
|
86
|
-
if (request.method !== "POST")
|
|
87
|
-
return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
|
|
88
88
|
const rejected = checkOrigin(request, host.options)
|
|
89
89
|
if (rejected) return fail(rejected)
|
|
90
|
+
// Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
|
|
90
91
|
const auth = authenticateRequest(host, request)
|
|
91
92
|
if (isPlugError(auth)) return fail(auth)
|
|
92
93
|
info.app = auth.app
|
|
94
|
+
if (request.method !== "POST")
|
|
95
|
+
return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
|
|
93
96
|
if (path === COUNT_TOKENS)
|
|
94
97
|
return fail({ status: 404, kind: "not_found", code: "not_found", message: "count_tokens is not supported by plugboard" })
|
|
95
98
|
const unsupported = checkJsonContentType(request)
|
|
@@ -125,6 +128,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
125
128
|
},
|
|
126
129
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
127
130
|
onPolicy: reportPolicy(response),
|
|
131
|
+
onCache: reportCache(response),
|
|
128
132
|
}),
|
|
129
133
|
scope,
|
|
130
134
|
)
|
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
|
+
}
|
|
@@ -44,7 +44,7 @@ const encodeUsage = (usage: PlugUsage) => {
|
|
|
44
44
|
const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
|
|
45
45
|
|
|
46
46
|
/** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
|
|
47
|
-
async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object }> {
|
|
47
|
+
async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
|
|
48
48
|
let text = ""
|
|
49
49
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
50
50
|
let usage: PlugUsage | undefined
|
|
@@ -63,7 +63,7 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
|
|
|
63
63
|
if (!failure && !finish) failure = noFinish()
|
|
64
64
|
if (failure || !finish) {
|
|
65
65
|
const error = failure ?? noFinish()
|
|
66
|
-
return { status: error.status, body: encodeError(error) }
|
|
66
|
+
return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
|
|
67
67
|
}
|
|
68
68
|
return {
|
|
69
69
|
status: 200,
|
package/src/completions/route.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { ServerResponse } from "node:http"
|
|
13
13
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
14
14
|
import type { ServedInfo } from "../fallback.js"
|
|
15
|
-
import {
|
|
15
|
+
import { sendError, type RequestScope } from "../http.js"
|
|
16
16
|
import { resolveModel } from "../models.js"
|
|
17
17
|
import { serveChoices } from "../openai/fanout.js"
|
|
18
18
|
import { encodeError } from "../openai/response.js"
|
|
@@ -33,7 +33,7 @@ export interface CompletionsCall {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export async function serveCompletions(host: RouteHost, { app, body, response, scope, info }: CompletionsCall): Promise<void> {
|
|
36
|
-
const fail = (error: PlugError) =>
|
|
36
|
+
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
37
37
|
|
|
38
38
|
const parsed = parseCompletionsRequest(body, host.options)
|
|
39
39
|
if (isPlugError(parsed)) return fail(parsed)
|
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, sent as the `Retry-After` header (`sendError` in `src/http.ts`). Set by the limiter (`src/limits.ts`). */
|
|
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/discovery.ts
CHANGED
|
@@ -58,18 +58,30 @@ export interface Endpoint {
|
|
|
58
58
|
readonly description: string
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
|
|
63
|
+
* `src/openai/route.ts` and the front-end routes; `test/http/discovery.test.ts` probes each served
|
|
64
|
+
* route and fails when this list omits one (#136). `/metrics` is added only when option `metrics` is on.
|
|
65
|
+
*/
|
|
66
|
+
export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
67
|
+
{ method: "GET", path: "/", auth: false, description: "This page." },
|
|
68
|
+
{ method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
|
|
69
|
+
{ method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
|
|
70
|
+
{ method: "GET", path: "/v1/models", auth: true, description: "Models this app may call (OpenAI shape)." },
|
|
71
|
+
{ method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
|
|
72
|
+
{ method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
|
|
73
|
+
{ method: "POST", path: "/v1/completions", auth: true, description: "Legacy OpenAI completions." },
|
|
74
|
+
{ method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
|
|
75
|
+
{ method: "POST", path: "/v1/messages", auth: true, description: "Anthropic Messages (x-api-key or Bearer)." },
|
|
76
|
+
{ method: "GET", path: "/v1beta/models", auth: true, description: "Gemini model list." },
|
|
77
|
+
{ method: "GET", path: "/v1beta/models/{model}", auth: true, description: "One Gemini model." },
|
|
78
|
+
{ method: "POST", path: "/v1beta/models/{model}:generateContent", auth: true, description: "Gemini generateContent." },
|
|
79
|
+
{ method: "POST", path: "/v1beta/models/{model}:streamGenerateContent", auth: true, description: "Gemini streamGenerateContent, with ?alt=sse." },
|
|
80
|
+
{ method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
|
|
84
|
+
return options.metrics ? [...ENDPOINTS, { method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : ENDPOINTS
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
type ModelsState = "ready" | "no_models_configured" | "none_available" | "catalog_unavailable"
|
package/src/gemini/route.ts
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
18
18
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
19
19
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
20
|
-
import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
20
|
+
import { boundEvents, checkJsonContentType, readJsonBody, sendError, 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"
|
|
@@ -63,10 +64,10 @@ export function matchGeminiRoute(method: string | undefined, path: string): Gemi
|
|
|
63
64
|
const verb = colon > 0 ? rest.slice(colon + 1) : ""
|
|
64
65
|
const model = decode(rest.slice(0, colon))
|
|
65
66
|
if (!/^[A-Za-z]+$/.test(verb) || model === undefined) return undefined
|
|
66
|
-
const label = `POST ${prefix.slice(0, -1)}/{model}:${verb}`
|
|
67
67
|
if (verb === "generateContent" || verb === "streamGenerateContent")
|
|
68
|
-
return { op: "generate", route:
|
|
69
|
-
|
|
68
|
+
return { op: "generate", route: `POST ${prefix.slice(0, -1)}/{model}:${verb}`, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
|
|
69
|
+
// The client's verb never becomes a metric or log label: every other verb is `unknown` (#135).
|
|
70
|
+
return { op: "unsupported", route: "unknown", verb }
|
|
70
71
|
}
|
|
71
72
|
return undefined
|
|
72
73
|
}
|
|
@@ -94,7 +95,7 @@ export interface GeminiCall {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
export async function serveGemini(host: RouteHost, { match, request, url, app, response, scope, info }: GeminiCall): Promise<void> {
|
|
97
|
-
const fail = (error: PlugError) =>
|
|
98
|
+
const fail = (error: PlugError) => sendError(response, error, encodeGeminiError(error))
|
|
98
99
|
|
|
99
100
|
if (match.op === "unsupported")
|
|
100
101
|
return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent and streamGenerateContent` })
|
|
@@ -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/http.ts
CHANGED
|
@@ -165,18 +165,29 @@ export function finishRequest(request: IncomingMessage, response: ServerResponse
|
|
|
165
165
|
response.once("close", () => clearTimeout(timer))
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
/**
|
|
169
|
-
|
|
168
|
+
/**
|
|
169
|
+
* `close`: also tell the client to drop the connection (after an unread or abandoned body).
|
|
170
|
+
* `retryAfterSeconds`: sent as `Retry-After` (a rate-limit refusal, see `PlugError.retryAfterSeconds`).
|
|
171
|
+
*/
|
|
172
|
+
export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean; retryAfterSeconds?: number }) => {
|
|
170
173
|
if (response.headersSent || response.destroyed) return
|
|
171
174
|
const body = JSON.stringify(payload)
|
|
172
175
|
response.writeHead(status, {
|
|
173
176
|
"content-type": "application/json",
|
|
174
177
|
"content-length": Buffer.byteLength(body),
|
|
175
178
|
...(options?.close ? { connection: "close" } : {}),
|
|
179
|
+
...(options?.retryAfterSeconds !== undefined ? { "retry-after": String(options.retryAfterSeconds) } : {}),
|
|
176
180
|
})
|
|
177
181
|
response.end(body)
|
|
178
182
|
}
|
|
179
183
|
|
|
184
|
+
/**
|
|
185
|
+
* A `PlugError` answered in a front-end's own wire body, before any output. Every route's `fail` goes
|
|
186
|
+
* through here, so a refused call (`PlugError.retryAfterSeconds`) always carries `Retry-After`.
|
|
187
|
+
*/
|
|
188
|
+
export const sendError = (response: ServerResponse, error: PlugError, body: unknown, options?: { close?: boolean }) =>
|
|
189
|
+
sendJson(response, error.status, body, { ...options, retryAfterSeconds: error.retryAfterSeconds })
|
|
190
|
+
|
|
180
191
|
/**
|
|
181
192
|
* SSE response writer. Headers go out with the first chunk, so nothing is committed until there
|
|
182
193
|
* is something to send; chunks after the client is gone are dropped. `write` is synchronous (the
|
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 }
|
package/src/metrics.ts
CHANGED
|
@@ -62,6 +62,10 @@ export interface Metrics {
|
|
|
62
62
|
dropped(params: ReadonlyArray<string>): void
|
|
63
63
|
/** A model call failed before any output and was retried on the next target of its alias. */
|
|
64
64
|
fallback(app: string): void
|
|
65
|
+
/** A call was refused with 429 by a concurrency cap (`concurrency`) or a requests-per-minute limit (`rate`). */
|
|
66
|
+
limited(app: string, reason: "concurrency" | "rate"): void
|
|
67
|
+
/** A call the response cache applied to: a stored answer replayed (`hit`) or the model run (`miss`). */
|
|
68
|
+
cache(app: string, result: "hit" | "miss"): void
|
|
65
69
|
/** One history sweep ended. */
|
|
66
70
|
sweep(result: "ok" | "failed" | "timeout", removed: number): void
|
|
67
71
|
/** The exposition text. Called from a request that is itself in flight, which is not counted. */
|
|
@@ -104,6 +108,18 @@ export function createMetrics(): Metrics {
|
|
|
104
108
|
labelNames: ["app"],
|
|
105
109
|
registers: [registry],
|
|
106
110
|
})
|
|
111
|
+
const limited = new Counter({
|
|
112
|
+
name: "plugboard_limited_total",
|
|
113
|
+
help: "Model calls refused with 429 by a concurrency cap or a requests-per-minute limit, by app and reason.",
|
|
114
|
+
labelNames: ["app", "reason"],
|
|
115
|
+
registers: [registry],
|
|
116
|
+
})
|
|
117
|
+
const cache = new Counter({
|
|
118
|
+
name: "plugboard_cache_requests_total",
|
|
119
|
+
help: "Model calls the response cache applied to, by app and result (hit or miss). Absent unless the `cache` option is set.",
|
|
120
|
+
labelNames: ["app", "result"],
|
|
121
|
+
registers: [registry],
|
|
122
|
+
})
|
|
107
123
|
const sweeps = new Counter({ name: "plugboard_history_sweeps_total", help: "History sweeps that ended, by result.", labelNames: ["result"], registers: [registry] })
|
|
108
124
|
const removed = new Counter({ name: "plugboard_history_sessions_removed_total", help: "Request sessions removed by history sweeps.", registers: [registry] })
|
|
109
125
|
const generations = new Gauge({ name: "plugboard_generations", help: "Accepted plugin generations (one per OpenCode Location) serving requests.", registers: [registry] })
|
|
@@ -142,6 +158,8 @@ export function createMetrics(): Metrics {
|
|
|
142
158
|
for (const param of params) count(dropped, { param })
|
|
143
159
|
},
|
|
144
160
|
fallback: (app) => count(fallbacks, { app }),
|
|
161
|
+
limited: (app, reason) => count(limited, { app, reason }),
|
|
162
|
+
cache: (app, result) => count(cache, { app, result }),
|
|
145
163
|
sweep(result, amount) {
|
|
146
164
|
count(sweeps, { result })
|
|
147
165
|
if (amount > 0 && Number.isFinite(amount)) removed.inc(amount)
|
package/src/models.ts
CHANGED
|
@@ -20,6 +20,33 @@ export function refToString(ref: ModelRef): string {
|
|
|
20
20
|
return `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A UI-safe spelling of a canonical id: `/` becomes `--` and `#` becomes `__`, so some chat UIs that mangle
|
|
25
|
+
* `/` and `#` keep it intact. `anthropic/claude-haiku-4-5#high` -> `anthropic--claude-haiku-4-5__high`.
|
|
26
|
+
*/
|
|
27
|
+
export function toSlug(id: string): string {
|
|
28
|
+
return id.replaceAll("/", "--").replaceAll("#", "__")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The canonical id a slug stands for, or undefined when `slug` is not one. The first `--` ends the provider, the
|
|
33
|
+
* first `__` starts the variant; a `--` inside the model id is read back as `/`. Ids where that does not round
|
|
34
|
+
* trip are not listed as slugs (see `listAvailableModels`), so they are never offered and never resolved this way.
|
|
35
|
+
*/
|
|
36
|
+
export function fromSlug(slug: string): string | undefined {
|
|
37
|
+
if (typeof slug !== "string" || /[\s/#*]/.test(slug)) return undefined
|
|
38
|
+
const sep = slug.indexOf("--")
|
|
39
|
+
if (sep <= 0) return undefined
|
|
40
|
+
const providerID = slug.slice(0, sep)
|
|
41
|
+
const rest = slug.slice(sep + 2)
|
|
42
|
+
const at = rest.indexOf("__")
|
|
43
|
+
const model = (at === -1 ? rest : rest.slice(0, at)).replaceAll("--", "/")
|
|
44
|
+
const variant = at === -1 ? undefined : rest.slice(at + 2)
|
|
45
|
+
if (variant !== undefined && (!variant || variant.includes("__"))) return undefined
|
|
46
|
+
const id = refToString({ providerID, id: model, ...(variant === undefined ? {} : { variant }) })
|
|
47
|
+
return parseModelRef(id) ? id : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
23
50
|
/** `provider/*` any model of the provider; `provider/model` any variant; `provider/model#variant` exactly. */
|
|
24
51
|
export function allowedBy(models: ReadonlyArray<string>, ref: ModelRef): boolean {
|
|
25
52
|
return models.some((pattern) => {
|
|
@@ -80,7 +107,8 @@ export function resolveModel(
|
|
|
80
107
|
code: "model_not_found",
|
|
81
108
|
param: "model",
|
|
82
109
|
}
|
|
83
|
-
|
|
110
|
+
// Aliases keep priority over slugs; a slug and a canonical id both resolve, whatever `modelIds` lists.
|
|
111
|
+
let ref = parseModelRef(own(options.aliases, model) ?? fromSlug(model) ?? model)
|
|
84
112
|
if (!ref) return notAvailable
|
|
85
113
|
if (!ref.variant && reasoningEffort && find(catalog, { ...ref, variant: reasoningEffort }))
|
|
86
114
|
ref = { ...ref, variant: reasoningEffort }
|
|
@@ -96,7 +124,9 @@ export interface ListedModel {
|
|
|
96
124
|
|
|
97
125
|
/**
|
|
98
126
|
* The allowlist intersected with the catalog, plus aliases whose target qualifies. Ids are unique except an alias may repeat one.
|
|
99
|
-
*
|
|
127
|
+
* Each model is listed as its canonical id, its slug, or both, per `options.modelIds`; a model whose slug does not
|
|
128
|
+
* round trip (see `fromSlug`) is left out in `slugs` mode. Aliases are listed as they are.
|
|
129
|
+
* With `app`, the list is narrowed to that app's `models` (see `inAppScope`), judged on the canonical id.
|
|
100
130
|
*/
|
|
101
131
|
export function listAvailableModels(options: PlugboardOptions, catalog: ReadonlyArray<CatalogModel>, app?: string): ListedModel[] {
|
|
102
132
|
const policy = app === undefined ? undefined : appPolicy(options, app)
|
|
@@ -110,7 +140,9 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
|
|
|
110
140
|
const id = refToString(ref)
|
|
111
141
|
if (seen.has(id) || !allowed(options, ref) || !inAppScope(policy, id, ref)) continue
|
|
112
142
|
seen.add(id)
|
|
113
|
-
data.push(entry(id, ref, model))
|
|
143
|
+
if (options.modelIds !== "slugs") data.push(entry(id, ref, model))
|
|
144
|
+
const slug = toSlug(id)
|
|
145
|
+
if (options.modelIds !== "canonical" && fromSlug(slug) === id) data.push(entry(slug, ref, model))
|
|
114
146
|
}
|
|
115
147
|
}
|
|
116
148
|
for (const [alias, target] of Object.entries(options.aliases)) {
|
|
@@ -121,6 +153,22 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
|
|
|
121
153
|
return data
|
|
122
154
|
}
|
|
123
155
|
|
|
156
|
+
/** Capabilities `GET /v1/models?capability=` can filter on: the flags the catalog reports (`CatalogModel.capabilities`). */
|
|
157
|
+
export type Capability = "vision" | "tools"
|
|
158
|
+
|
|
159
|
+
const CAPABILITY_FLAG: Readonly<Record<Capability, "image" | "tools">> = { vision: "image", tools: "tools" }
|
|
160
|
+
|
|
161
|
+
/** The capability a query value names, or undefined for anything else (`json` has no catalog flag, so it is not one). */
|
|
162
|
+
export function parseCapability(value: string): Capability | undefined {
|
|
163
|
+
return Object.hasOwn(CAPABILITY_FLAG, value) ? (value as Capability) : undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The listed models whose catalog entry sets that capability flag to true. Order is kept. */
|
|
167
|
+
export function filterByCapability(models: ReadonlyArray<ListedModel>, capability: Capability): ListedModel[] {
|
|
168
|
+
const flag = CAPABILITY_FLAG[capability]
|
|
169
|
+
return models.filter(({ model }) => model.capabilities?.[flag] === true)
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
/**
|
|
125
173
|
* The targets an alias falls back to, in order: the fallbacks that are allowed and enabled on the server.
|
|
126
174
|
* Empty for a plain model id or an alias without fallbacks. The primary target is `resolveModel`'s answer.
|
package/src/openai/fanout.ts
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
import type { ServerResponse } from "node:http"
|
|
24
24
|
import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.js"
|
|
25
25
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
26
|
+
import { reportCache } from "../cache.js"
|
|
26
27
|
import { reportPolicy } from "../policy.js"
|
|
27
|
-
import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
28
|
+
import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
28
29
|
import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
|
|
29
30
|
import type { RouteHost } from "./route.js"
|
|
30
31
|
|
|
@@ -37,7 +38,7 @@ export interface ChoiceCodec {
|
|
|
37
38
|
/** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
|
|
38
39
|
readonly idPrefix: string
|
|
39
40
|
/** One choice as a complete JSON body with `choices[0]` and `usage`. */
|
|
40
|
-
collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object }>
|
|
41
|
+
collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }>
|
|
41
42
|
/** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
|
|
42
43
|
streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
|
|
43
44
|
/** The final chunk with the summed usage (`include_usage`). */
|
|
@@ -154,7 +155,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
|
|
157
|
-
const fail = (error: PlugError) =>
|
|
158
|
+
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
158
159
|
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
159
160
|
if (scope.signal.aborted) throw scope.signal.reason
|
|
160
161
|
|
|
@@ -168,6 +169,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
168
169
|
if (calls.length === 1) codec.setModel(s.model)
|
|
169
170
|
})
|
|
170
171
|
const policy = reportPolicy(response)
|
|
172
|
+
// Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
|
|
173
|
+
const cache = reportCache(response)
|
|
171
174
|
const open = (index: number) =>
|
|
172
175
|
boundEvents(
|
|
173
176
|
host.caller.stream(calls[index]!, {
|
|
@@ -180,13 +183,15 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
180
183
|
},
|
|
181
184
|
onServed: served,
|
|
182
185
|
onPolicy: policy,
|
|
186
|
+
onCache: cache,
|
|
187
|
+
noCache: calls.length > 1,
|
|
183
188
|
}),
|
|
184
189
|
gscope,
|
|
185
190
|
)
|
|
186
191
|
|
|
187
192
|
if (!codec.stream) {
|
|
188
|
-
const results = await new Promise<Array<{ status: number; body: object }>>((resolve, reject) => {
|
|
189
|
-
const done: Array<{ status: number; body: object }> = []
|
|
193
|
+
const results = await new Promise<Array<{ status: number; body: object; retryAfterSeconds?: number }>>((resolve, reject) => {
|
|
194
|
+
const done: Array<{ status: number; body: object; retryAfterSeconds?: number }> = []
|
|
190
195
|
let left = calls.length
|
|
191
196
|
calls.forEach((_, index) => {
|
|
192
197
|
codec.collect(open(index), index).then(
|
|
@@ -206,7 +211,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
206
211
|
})
|
|
207
212
|
})
|
|
208
213
|
const first = results[0]!
|
|
209
|
-
return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)))
|
|
214
|
+
return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
|
|
210
215
|
}
|
|
211
216
|
|
|
212
217
|
const sse = sseWriter(response, scope.signal)
|
package/src/openai/response.ts
CHANGED
|
@@ -195,7 +195,7 @@ const now = () => Math.floor(Date.now() / 1000)
|
|
|
195
195
|
export async function collectCompletion(
|
|
196
196
|
events: AsyncIterable<PlugEvent>,
|
|
197
197
|
exchange: OpenAiExchange,
|
|
198
|
-
): Promise<{ status: number; body: object }> {
|
|
198
|
+
): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
|
|
199
199
|
let text = ""
|
|
200
200
|
const calls: ToolCall[] = []
|
|
201
201
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
@@ -217,7 +217,7 @@ export async function collectCompletion(
|
|
|
217
217
|
if (!failure && !finish) failure = noFinish()
|
|
218
218
|
if (failure || !finish) {
|
|
219
219
|
const error = failure ?? noFinish()
|
|
220
|
-
return { status: error.status, body: encodeError(error) }
|
|
220
|
+
return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
const answer = calls.find((call) => structured.isAnswer(exchange, call))
|
package/src/openai/route.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
checkOrigin,
|
|
33
33
|
finishRequest,
|
|
34
34
|
readJsonBody,
|
|
35
|
+
sendError,
|
|
35
36
|
sendJson,
|
|
36
37
|
sseWriter,
|
|
37
38
|
type RequestScope,
|
|
@@ -41,7 +42,8 @@ import { encodeGeminiError } from "../gemini/response.js"
|
|
|
41
42
|
import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/route.js"
|
|
42
43
|
import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
43
44
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
44
|
-
import { listAvailableModels, resolveModel } from "../models.js"
|
|
45
|
+
import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
|
|
46
|
+
import { reportCache } from "../cache.js"
|
|
45
47
|
import { reportPolicy } from "../policy.js"
|
|
46
48
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
47
49
|
import { serveCompletions } from "../completions/route.js"
|
|
@@ -118,7 +120,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
118
120
|
/** Responses to Gemini routes: errors there use Google's body. */
|
|
119
121
|
const googleErrors = new WeakSet<ServerResponse>()
|
|
120
122
|
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
121
|
-
|
|
123
|
+
sendError(response, error, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
|
|
122
124
|
|
|
123
125
|
/** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
|
|
124
126
|
async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
|
|
@@ -197,8 +199,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
if (route === "GET /v1/models" || modelId !== undefined) {
|
|
202
|
+
// `?capability=vision|tools` filters the list (#61). Any other value is refused before the catalog loads.
|
|
203
|
+
const requested = modelId === undefined ? url.searchParams.get("capability") : null
|
|
204
|
+
const capability = requested === null ? undefined : parseCapability(requested)
|
|
205
|
+
if (requested !== null && capability === undefined)
|
|
206
|
+
return fail(response, { status: 400, kind: "invalid_request", code: "invalid_capability", param: "capability", message: "capability must be vision or tools" })
|
|
200
207
|
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), auth.app)
|
|
201
|
-
if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
|
|
208
|
+
if (modelId === undefined) return sendJson(response, 200, encodeModelList(capability ? filterByCapability(listed, capability) : listed))
|
|
202
209
|
const item = listed.find((m) => m.id === modelId)
|
|
203
210
|
if (!item) {
|
|
204
211
|
return fail(response, {
|
|
@@ -251,6 +258,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
251
258
|
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
252
259
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
253
260
|
onPolicy: reportPolicy(response),
|
|
261
|
+
onCache: reportCache(response),
|
|
254
262
|
}),
|
|
255
263
|
scope,
|
|
256
264
|
)
|
|
@@ -275,7 +283,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
275
283
|
}
|
|
276
284
|
|
|
277
285
|
const result = await collectCompletion(events, exchange)
|
|
278
|
-
sendJson(response, result.status, result.body)
|
|
286
|
+
sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
|
|
279
287
|
}
|
|
280
288
|
|
|
281
289
|
/** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
|
package/src/responses/route.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
import type { ServerResponse } from "node:http"
|
|
12
12
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
13
13
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
14
|
-
import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
14
|
+
import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
15
15
|
import { resolveModel } from "../models.js"
|
|
16
|
+
import { reportCache } from "../cache.js"
|
|
16
17
|
import { reportPolicy } from "../policy.js"
|
|
17
18
|
import { encodeError } from "../openai/response.js"
|
|
18
19
|
import type { RouteHost } from "../openai/route.js"
|
|
@@ -31,7 +32,7 @@ export interface ResponsesCall {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
|
|
34
|
-
const fail = (error: PlugError) =>
|
|
35
|
+
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
35
36
|
|
|
36
37
|
const parsed = parseResponsesRequest(body, host.options)
|
|
37
38
|
if (isPlugError(parsed)) return fail(parsed)
|
|
@@ -54,6 +55,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
54
55
|
},
|
|
55
56
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
56
57
|
onPolicy: reportPolicy(response),
|
|
58
|
+
onCache: reportCache(response),
|
|
57
59
|
}),
|
|
58
60
|
scope,
|
|
59
61
|
)
|
package/src/runtime.ts
CHANGED
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
* limits (src/session/prune.ts). Both start from request handling (the store's `register`);
|
|
25
25
|
* - the metrics registry (src/metrics.ts) and the runtime start time, so counters and uptime
|
|
26
26
|
* outlive any one generation. `processMetrics` / `processStatus` read them for `/metrics`,
|
|
27
|
-
* `/v1/status` and the discovery pages
|
|
27
|
+
* `/v1/status` and the discovery pages;
|
|
28
|
+
* - the request limiter's queues and rate windows (src/limits.ts), so a cap holds across
|
|
29
|
+
* generations. `processLimits` reads them.
|
|
28
30
|
*
|
|
29
31
|
* Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
|
|
30
32
|
* rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
|
|
@@ -48,8 +50,11 @@ import { setTimeout as delay } from "node:timers/promises"
|
|
|
48
50
|
import { Cause, Effect, Stream } from "effect"
|
|
49
51
|
import stableStringify from "safe-stable-stringify"
|
|
50
52
|
import { raceAbort } from "./abort.js"
|
|
51
|
-
import {
|
|
53
|
+
import { cachedCaller, createCacheStore, type CacheStore } from "./cache.js"
|
|
54
|
+
import type { PlugboardOptions } from "./config.js"
|
|
55
|
+
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError } from "./contract.js"
|
|
52
56
|
import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
|
|
57
|
+
import { createLimitState, type LimitState } from "./limits.js"
|
|
53
58
|
import { createMetrics, type Metrics, type RuntimeSnapshot } from "./metrics.js"
|
|
54
59
|
import { ensureAppDir } from "./session/history.js"
|
|
55
60
|
import type { HistoryPort } from "./session/host.js"
|
|
@@ -107,8 +112,8 @@ const BACKEND_POLL_MS = 25
|
|
|
107
112
|
const BIND_RETRY_MS = 15_000
|
|
108
113
|
const BIND_REMINDER_MS = 5 * 60_000
|
|
109
114
|
|
|
110
|
-
/** 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
111
|
-
const VERSION =
|
|
115
|
+
/** 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
116
|
+
const VERSION = 10
|
|
112
117
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
113
118
|
|
|
114
119
|
interface Listener {
|
|
@@ -158,8 +163,12 @@ interface Runtime {
|
|
|
158
163
|
readonly sweep: { nextAt: number; running: boolean }
|
|
159
164
|
/** Process-wide counters, shared by every generation (see src/metrics.ts). */
|
|
160
165
|
readonly metrics: Metrics
|
|
166
|
+
/** Concurrency queues and requests-per-minute windows (see src/limits.ts). */
|
|
167
|
+
readonly limits: LimitState
|
|
161
168
|
/** When this runtime was created, epoch ms. */
|
|
162
169
|
readonly startedAt: number
|
|
170
|
+
/** The response cache store (src/cache.ts) and the fingerprint of the config and `apps` policy it was built for. */
|
|
171
|
+
cache?: { readonly fingerprint: string; readonly store: CacheStore }
|
|
163
172
|
listener?: Listener
|
|
164
173
|
/** Present while a fiber is subscribed to the server event bus. */
|
|
165
174
|
pump?: Pump
|
|
@@ -171,6 +180,7 @@ const REJECTED: Registration = { accepted: false, dispose() {}, confirmed: () =>
|
|
|
171
180
|
const detachedSessions = createSessionStore()
|
|
172
181
|
const detachedMetrics = createMetrics()
|
|
173
182
|
const detachedStartedAt = Date.now()
|
|
183
|
+
const detachedLimits = createLimitState()
|
|
174
184
|
let foreignVersionLogged = false
|
|
175
185
|
|
|
176
186
|
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
@@ -186,6 +196,7 @@ function runtime(): Runtime | undefined {
|
|
|
186
196
|
rejected: new Map(),
|
|
187
197
|
sweep: { nextAt: 0, running: false },
|
|
188
198
|
metrics: createMetrics(),
|
|
199
|
+
limits: createLimitState(),
|
|
189
200
|
startedAt: Date.now(),
|
|
190
201
|
}
|
|
191
202
|
slot[KEY] = created
|
|
@@ -261,6 +272,42 @@ export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedS
|
|
|
261
272
|
/** The metrics registry shared by every generation in the process (a detached one under a foreign-version runtime). */
|
|
262
273
|
export const processMetrics = (): Metrics => runtime()?.metrics ?? detachedMetrics
|
|
263
274
|
|
|
275
|
+
/** The request limiter's state shared by every generation in the process (detached under a foreign-version runtime). */
|
|
276
|
+
export const processLimits = (): LimitState => runtime()?.limits ?? detachedLimits
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Wraps `caller` with the response cache when option `cache` is set (a no-op without it). The store
|
|
280
|
+
* lives on the runtime, so it survives plugin generations. It is replaced when the cache option or
|
|
281
|
+
* any policy input (`apps`, `models`, `aliases`, `fallbacks`) differs from the one it was built for:
|
|
282
|
+
* the key does not cover policy, and a hit skips the session caller's scope checks. The store is
|
|
283
|
+
* resolved on the first call, not when the wrapper is built: a rejected generation is built too but
|
|
284
|
+
* never serves, and must not replace the live store. Under a foreign-version runtime the store is
|
|
285
|
+
* private to the wrapper.
|
|
286
|
+
*/
|
|
287
|
+
export function withResponseCache(caller: ModelCaller, options: PlugboardOptions): ModelCaller {
|
|
288
|
+
const config = options.cache
|
|
289
|
+
if (!config) return caller
|
|
290
|
+
let wrapped: ModelCaller | undefined
|
|
291
|
+
const build = (): ModelCaller => {
|
|
292
|
+
const rt = runtime()
|
|
293
|
+
const fingerprint = canonicalJson({
|
|
294
|
+
cache: config,
|
|
295
|
+
apps: options.apps,
|
|
296
|
+
models: options.models,
|
|
297
|
+
aliases: options.aliases,
|
|
298
|
+
fallbacks: options.fallbacks,
|
|
299
|
+
})
|
|
300
|
+
let held = rt?.cache
|
|
301
|
+
if (!held || held.fingerprint !== fingerprint) {
|
|
302
|
+
held = { fingerprint, store: createCacheStore({ ttlMs: config.ttlSeconds * 1000, maxEntries: config.maxEntries, maxBytes: config.maxBytes }) }
|
|
303
|
+
if (rt) rt.cache = held
|
|
304
|
+
}
|
|
305
|
+
const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
|
|
306
|
+
return cachedCaller(caller, config, held.store, count)
|
|
307
|
+
}
|
|
308
|
+
return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
|
|
309
|
+
}
|
|
310
|
+
|
|
264
311
|
/** What `/v1/status` and the discovery pages report about the process, besides the model catalog. */
|
|
265
312
|
export interface RuntimeStatus extends RuntimeSnapshot {
|
|
266
313
|
readonly runtimeVersion: number
|