opencode-plugboard 0.9.1 → 0.12.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 +1 -1
- package/index.ts +15 -8
- package/package.json +15 -2
- package/src/abort.ts +26 -0
- package/src/anthropic/response.ts +2 -2
- package/src/anthropic/route.ts +3 -1
- package/src/completions/response.ts +3 -2
- package/src/completions/route.ts +2 -1
- package/src/config.ts +41 -11
- package/src/contract.ts +13 -0
- package/src/discovery.ts +6 -3
- package/src/fallback.ts +167 -0
- package/src/gemini/response.ts +2 -2
- package/src/gemini/route.ts +3 -1
- package/src/http.ts +5 -21
- package/src/metrics.ts +5 -0
- package/src/models.ts +20 -4
- package/src/openai/fanout.ts +11 -2
- package/src/openai/response.ts +2 -2
- package/src/openai/route.ts +5 -2
- package/src/responses/response.ts +11 -4
- package/src/responses/route.ts +4 -2
- package/src/runtime.ts +11 -40
- package/src/session/caller.ts +1 -11
- package/src/session/media.ts +60 -110
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.12.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"],
|
package/index.ts
CHANGED
|
@@ -22,8 +22,9 @@ import { Plugin } from "@opencode/plugin/effect"
|
|
|
22
22
|
import { Effect } from "effect"
|
|
23
23
|
import { parseOptions } from "./src/config.js"
|
|
24
24
|
import { errorMessage, logError } from "./src/log.js"
|
|
25
|
+
import { withFallbacks } from "./src/fallback.js"
|
|
25
26
|
import { createOpenAiRoute } from "./src/openai/route.js"
|
|
26
|
-
import { ensureBackend, ensurePump, loadServingCatalog, registerGeneration, sessionStore } from "./src/runtime.js"
|
|
27
|
+
import { ensureBackend, ensurePump, loadServingCatalog, processMetrics, registerGeneration, sessionStore } from "./src/runtime.js"
|
|
27
28
|
import { createSessionCaller } from "./src/session/caller.js"
|
|
28
29
|
import { installSessionHooks } from "./src/session/context.js"
|
|
29
30
|
import { createHistoryPort, createSessionPort, loadCatalog } from "./src/session/host.js"
|
|
@@ -57,15 +58,21 @@ export default Plugin.define({
|
|
|
57
58
|
// The readiness gate the caller awaits before creating a session (fails closed: 503 `backend_unavailable`).
|
|
58
59
|
ensureBackend: (app: string, signal: AbortSignal) => ensureBackend(app, signal, bootLocation),
|
|
59
60
|
}
|
|
61
|
+
// The catalog comes from the preferred accepted generation (a data-root Location when there is
|
|
62
|
+
// one), not from whichever Location registered last; this generation's own is the fallback.
|
|
63
|
+
const servingCatalog = async () => {
|
|
64
|
+
startPump()
|
|
65
|
+
return loadServingCatalog(ownCatalog)
|
|
66
|
+
}
|
|
60
67
|
const handle = createOpenAiRoute({
|
|
61
68
|
options,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
// Aliases written as a list retry the next target before the first output (src/fallback.ts).
|
|
70
|
+
caller: withFallbacks(createSessionCaller(callerDeps), {
|
|
71
|
+
options,
|
|
72
|
+
loadCatalog: servingCatalog,
|
|
73
|
+
onFallback: options.metrics ? (app) => processMetrics().fallback(app) : undefined,
|
|
74
|
+
}),
|
|
75
|
+
loadCatalog: servingCatalog,
|
|
69
76
|
})
|
|
70
77
|
const registration = registerGeneration({
|
|
71
78
|
listen: options.listen,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.",
|
|
@@ -19,13 +19,26 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": ["index.ts", "src/", "README.md", "LICENSE"],
|
|
21
21
|
"scripts": {
|
|
22
|
-
"typecheck": "tsc --noEmit",
|
|
22
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
23
|
+
"test": "bun test",
|
|
24
|
+
"smoke:pack": "bun .github/scripts/smoke-pack.ts",
|
|
23
25
|
"release": "bun .github/scripts/release.ts"
|
|
24
26
|
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"escape-html": "1.0.3",
|
|
29
|
+
"ipaddr.js": "2.5.0",
|
|
30
|
+
"safe-stable-stringify": "2.5.0"
|
|
31
|
+
},
|
|
25
32
|
"devDependencies": {
|
|
33
|
+
"@commitlint/cli": "21.2.3",
|
|
34
|
+
"@commitlint/config-conventional": "21.2.3",
|
|
26
35
|
"@opencode/plugin": "2.0.26",
|
|
36
|
+
"@types/bun": "1.4.2",
|
|
37
|
+
"@types/escape-html": "^1.0.4",
|
|
27
38
|
"@types/node": "^26.6.2",
|
|
39
|
+
"conventional-commits-parser": "7.1.3",
|
|
28
40
|
"effect": "4.0.0-rc.112",
|
|
41
|
+
"lefthook": "2.2.1",
|
|
29
42
|
"typescript": "^5.9.3"
|
|
30
43
|
},
|
|
31
44
|
"peerDependencies": {
|
package/src/abort.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The one "settle or reject when aborted" helper that request-side code races host calls against. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settles with `promise`, or rejects with `signal.reason` as soon as `signal` aborts. The promise
|
|
5
|
+
* itself keeps running (it cannot be cancelled): it is always observed, so a late rejection after
|
|
6
|
+
* the race was lost never becomes an unhandled rejection. A signal that is already aborted rejects
|
|
7
|
+
* at once.
|
|
8
|
+
*/
|
|
9
|
+
export function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
10
|
+
promise.catch(() => {})
|
|
11
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
12
|
+
return new Promise<T>((resolve, reject) => {
|
|
13
|
+
const onAbort = () => reject(signal.reason)
|
|
14
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
15
|
+
promise.then(
|
|
16
|
+
(value) => {
|
|
17
|
+
signal.removeEventListener("abort", onAbort)
|
|
18
|
+
resolve(value)
|
|
19
|
+
},
|
|
20
|
+
(error) => {
|
|
21
|
+
signal.removeEventListener("abort", onAbort)
|
|
22
|
+
reject(error)
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
@@ -12,8 +12,8 @@ type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
|
12
12
|
|
|
13
13
|
/** What the encoder needs besides the model events: Messages presentation state. The request decoder produces it. */
|
|
14
14
|
export interface MessagesExchange {
|
|
15
|
-
/** The model string the client sent; echoed in the message object. */
|
|
16
|
-
|
|
15
|
+
/** The model string the client sent; echoed in the message object. A request that used an alias with fallbacks echoes the target that answered (`CallContext.onServed`). */
|
|
16
|
+
model: string
|
|
17
17
|
readonly stream: boolean
|
|
18
18
|
/** `thinking` was enabled or adaptive: reasoning text is returned as thinking blocks. */
|
|
19
19
|
readonly thinking: boolean
|
package/src/anthropic/route.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
sseWriter,
|
|
26
26
|
type RequestScope,
|
|
27
27
|
} from "../http.js"
|
|
28
|
+
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
28
29
|
import { resolveModel } from "../models.js"
|
|
29
30
|
import { dropRejected } from "../openai/sampling.js"
|
|
30
31
|
import type { RouteHost } from "../openai/route.js"
|
|
@@ -45,7 +46,7 @@ export interface MessagesCall {
|
|
|
45
46
|
readonly response: ServerResponse
|
|
46
47
|
readonly scope: RequestScope
|
|
47
48
|
/** The route's log fields: the route name, the app, the resolved model, dropped parameters, and the outcome of a stream that failed after it committed. */
|
|
48
|
-
readonly info: { route: string; app?: string; model?: string; failed?: string; dropped?: string }
|
|
49
|
+
readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string }
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
const header = (request: IncomingMessage, name: string): string | undefined => {
|
|
@@ -121,6 +122,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
121
122
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
122
123
|
response.setHeader(SESSION_HEADER, recordID)
|
|
123
124
|
},
|
|
125
|
+
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
124
126
|
}),
|
|
125
127
|
scope,
|
|
126
128
|
)
|
|
@@ -11,8 +11,8 @@ import { encodeError, type StreamChoice, type StreamResult } from "../openai/res
|
|
|
11
11
|
|
|
12
12
|
/** What the encoders need besides the model events; the request decoder produces it, the model call never sees it. */
|
|
13
13
|
export interface CompletionsExchange {
|
|
14
|
-
/** The model string the client sent; echoed in every response object. */
|
|
15
|
-
|
|
14
|
+
/** The model string the client sent; echoed in every response object. A request that used an alias with fallbacks echoes the target that answered (`CallContext.onServed`). */
|
|
15
|
+
model: string
|
|
16
16
|
readonly stream: boolean
|
|
17
17
|
/** `stream_options.include_usage`: a final chunk with `choices: []` and the summed usage. */
|
|
18
18
|
readonly includeUsage: boolean
|
|
@@ -144,4 +144,5 @@ export const completionsCodec = (exchange: CompletionsExchange): ChoiceCodec =>
|
|
|
144
144
|
choices: [],
|
|
145
145
|
usage: encodeUsage(usage),
|
|
146
146
|
}),
|
|
147
|
+
setModel: (model) => void (exchange.model = model),
|
|
147
148
|
})
|
package/src/completions/route.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { ServerResponse } from "node:http"
|
|
13
13
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
14
|
+
import type { ServedInfo } from "../fallback.js"
|
|
14
15
|
import { sendJson, type RequestScope } from "../http.js"
|
|
15
16
|
import { resolveModel } from "../models.js"
|
|
16
17
|
import { serveChoices } from "../openai/fanout.js"
|
|
@@ -28,7 +29,7 @@ export interface CompletionsCall {
|
|
|
28
29
|
readonly response: ServerResponse
|
|
29
30
|
readonly scope: RequestScope
|
|
30
31
|
/** The route's log fields: the resolved model, the sampling parameters dropped, and the outcome of a stream that failed after it committed. */
|
|
31
|
-
readonly info: { model?: string; failed?: string; dropped?: string }
|
|
32
|
+
readonly info: ServedInfo & { model?: string; failed?: string; dropped?: string }
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export async function serveCompletions(host: RouteHost, { app, body, response, scope, info }: CompletionsCall): Promise<void> {
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
|
|
2
2
|
|
|
3
|
+
import { createHash, timingSafeEqual } from "node:crypto"
|
|
3
4
|
import type { PlugError } from "./contract.js"
|
|
5
|
+
import { allowedBy, parseModelRef } from "./models.js"
|
|
4
6
|
import { parseAllowEntry, type RemoteImages } from "./session/media.js"
|
|
5
7
|
import type { HistoryLimits } from "./session/prune.js"
|
|
6
8
|
|
|
@@ -13,6 +15,12 @@ export interface PlugboardOptions {
|
|
|
13
15
|
readonly models: ReadonlyArray<string>
|
|
14
16
|
/** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
|
|
15
17
|
readonly aliases: Readonly<Record<string, string>>
|
|
18
|
+
/**
|
|
19
|
+
* Alias -> the targets after the first, in order, for an alias written as an array. `aliases` holds the
|
|
20
|
+
* first target, so everything that resolves an alias keeps working; the model caller retries the rest
|
|
21
|
+
* (`src/fallback.ts`). Absent for an alias with one target. Every target passes the allowlist.
|
|
22
|
+
*/
|
|
23
|
+
readonly fallbacks: Readonly<Record<string, ReadonlyArray<string>>>
|
|
16
24
|
readonly maxTools: number
|
|
17
25
|
/** Most model calls one request may fan out to: `n` for chat completions, prompts x `n` for legacy completions. Each is a full call and a kept session. */
|
|
18
26
|
readonly maxN: number
|
|
@@ -125,6 +133,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
125
133
|
}
|
|
126
134
|
// Own-property construction: a key such as `__proto__` must stay a key, never reach the prototype.
|
|
127
135
|
const keys: Record<string, string> = Object.fromEntries(keyEntries)
|
|
136
|
+
digestsOf(keys)
|
|
128
137
|
if (keyEntries.length === 0 && !isLoopbackHost(host))
|
|
129
138
|
throw new Error(`plugboard refuses to listen on non-loopback host \`${host}\` without \`keys\``)
|
|
130
139
|
|
|
@@ -135,18 +144,28 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
135
144
|
throw new Error(`plugboard model \`${String(pattern)}\` must be \`provider/model\`, \`provider/model#variant\` or \`provider/*\``)
|
|
136
145
|
|
|
137
146
|
const aliasEntries: Array<[string, string]> = []
|
|
138
|
-
|
|
147
|
+
const fallbackEntries: Array<[string, string[]]> = []
|
|
148
|
+
if (input.aliases !== undefined && !isRecord(input.aliases)) throw new Error("plugboard option `aliases` must be an object of alias -> model or alias -> [model, ...]")
|
|
139
149
|
for (const [alias, target] of Object.entries(input.aliases ?? {})) {
|
|
140
150
|
if (alias.length === 0 || /[\s]/.test(alias)) throw new Error(`plugboard alias \`${alias}\` must be a non-empty string without whitespace`)
|
|
141
151
|
// Plain-object lookups downstream would hit the prototype accessor instead of the alias.
|
|
142
152
|
if (alias === "__proto__") throw new Error("plugboard alias `__proto__` is not allowed")
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
const chain = Array.isArray(target) ? target : [target]
|
|
154
|
+
if (chain.length === 0) throw new Error(`plugboard alias \`${alias}\` must list at least one target`)
|
|
155
|
+
for (const entry of chain) {
|
|
156
|
+
if (typeof entry !== "string" || !ALIAS_TARGET.test(entry))
|
|
157
|
+
throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\` (or a list of them)`)
|
|
158
|
+
// A list is new, so it is strict: a target the allowlist would refuse can never serve, so fail at load.
|
|
159
|
+
if (Array.isArray(target) && !allowedBy(models as string[], parseModelRef(entry)!))
|
|
160
|
+
throw new Error(`plugboard alias \`${alias}\` target \`${entry}\` is not allowed by the \`models\` option`)
|
|
161
|
+
}
|
|
145
162
|
if (CANONICAL_SHAPE.test(alias))
|
|
146
163
|
throw new Error(`plugboard alias \`${alias}\` is written like a \`provider/model\` id; aliases must not contain \`/\` (use a short name such as \`fast\`)`)
|
|
147
|
-
aliasEntries.push([alias,
|
|
164
|
+
aliasEntries.push([alias, chain[0] as string])
|
|
165
|
+
if (chain.length > 1) fallbackEntries.push([alias, chain.slice(1) as string[]])
|
|
148
166
|
}
|
|
149
167
|
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
168
|
+
const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
|
|
150
169
|
|
|
151
170
|
const remoteImages = parseRemoteImages(input.remoteImages)
|
|
152
171
|
if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
|
|
@@ -168,6 +187,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
168
187
|
keys,
|
|
169
188
|
models: models as string[],
|
|
170
189
|
aliases,
|
|
190
|
+
fallbacks,
|
|
171
191
|
maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
|
|
172
192
|
maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
|
|
173
193
|
requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
|
|
@@ -186,20 +206,30 @@ const UNAUTHORIZED: PlugError = {
|
|
|
186
206
|
code: "invalid_api_key",
|
|
187
207
|
}
|
|
188
208
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
209
|
+
const sha256 = (text: string): Buffer => createHash("sha256").update(text).digest()
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* SHA-256 digest of each key, computed once per `keys` object (at parse; lazily for an options
|
|
213
|
+
* object built without `parseOptions`). Comparing fixed-size digests means a wrong token's length
|
|
214
|
+
* and content do not change how long the comparison takes.
|
|
215
|
+
*/
|
|
216
|
+
const keyDigests = new WeakMap<object, ReadonlyArray<readonly [string, Buffer]>>()
|
|
217
|
+
|
|
218
|
+
function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readonly [string, Buffer]> {
|
|
219
|
+
let digests = keyDigests.get(keys)
|
|
220
|
+
if (!digests) keyDigests.set(keys, (digests = Object.entries(keys).map(([name, key]) => [name, sha256(key)] as const)))
|
|
221
|
+
return digests
|
|
194
222
|
}
|
|
195
223
|
|
|
196
224
|
/** No keys -> app `default`; with keys every request needs `Authorization: Bearer <key>`. */
|
|
197
225
|
export function authenticate(options: PlugboardOptions, authorization: string | undefined): { app: string } | PlugError {
|
|
198
|
-
const entries =
|
|
226
|
+
const entries = digestsOf(options.keys)
|
|
199
227
|
if (entries.length === 0) return { app: DEFAULT_APP }
|
|
200
228
|
const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
|
|
201
229
|
if (!token) return UNAUTHORIZED
|
|
230
|
+
const digest = sha256(token)
|
|
202
231
|
let app: string | undefined
|
|
203
|
-
|
|
232
|
+
// Every key is compared, no early exit.
|
|
233
|
+
for (const [name, expected] of entries) if (timingSafeEqual(digest, expected)) app = name
|
|
204
234
|
return app === undefined ? UNAUTHORIZED : { app }
|
|
205
235
|
}
|
package/src/contract.ts
CHANGED
|
@@ -168,6 +168,19 @@ export interface CallContext {
|
|
|
168
168
|
readonly signal: AbortSignal
|
|
169
169
|
/** Called once, before the first event, with the id of the history record this call writes (if any). */
|
|
170
170
|
readonly onRecord?: (recordID: string) => void
|
|
171
|
+
/**
|
|
172
|
+
* Called once, before the first event, only for a request whose model is an alias with fallbacks
|
|
173
|
+
* (`src/fallback.ts`): which target answered and how many earlier targets failed first. A front-end
|
|
174
|
+
* uses it for `x-plugboard-model` and the `model` it echoes. A call that fails on every target never
|
|
175
|
+
* reports it.
|
|
176
|
+
*/
|
|
177
|
+
readonly onServed?: (served: ServedModel) => void
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
|
|
181
|
+
export interface ServedModel {
|
|
182
|
+
readonly model: string
|
|
183
|
+
readonly fallbacks: number
|
|
171
184
|
}
|
|
172
185
|
|
|
173
186
|
/** The seam between the core and a backend (the session backend now, `ctx.llm.stream` possibly later). */
|
package/src/discovery.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ServerResponse } from "node:http"
|
|
19
|
+
import escapeText from "escape-html"
|
|
19
20
|
import type { PlugboardOptions } from "./config.js"
|
|
20
21
|
import type { CatalogModel } from "./contract.js"
|
|
21
22
|
import { listAvailableModels } from "./models.js"
|
|
@@ -40,8 +41,7 @@ export function sendText(response: ServerResponse, status: number, contentType:
|
|
|
40
41
|
response.end(body)
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
const escapeHtml = (value: unknown): string =>
|
|
44
|
-
String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char)
|
|
44
|
+
const escapeHtml = (value: unknown): string => escapeText(String(value))
|
|
45
45
|
|
|
46
46
|
/** `http://host:port` for a listen address; an IPv6 literal gets brackets. */
|
|
47
47
|
function origin(listen: PlugboardOptions["listen"]): string {
|
|
@@ -94,6 +94,8 @@ export interface Discovery {
|
|
|
94
94
|
readonly state: ModelsState
|
|
95
95
|
readonly allowlist: ReadonlyArray<string>
|
|
96
96
|
readonly aliases: Readonly<Record<string, string>>
|
|
97
|
+
/** Alias -> the targets tried after its first one, for aliases written as a list. Absent when there are none. */
|
|
98
|
+
readonly fallbacks?: Readonly<Record<string, ReadonlyArray<string>>>
|
|
97
99
|
/** Ids `GET /v1/models` lists (allowlist intersected with the catalog, plus aliases). */
|
|
98
100
|
readonly available?: number
|
|
99
101
|
readonly hint?: string
|
|
@@ -171,6 +173,7 @@ export function buildDiscovery({ options, app, catalog }: DiscoveryInput): Disco
|
|
|
171
173
|
state,
|
|
172
174
|
allowlist: options.models,
|
|
173
175
|
aliases: options.aliases,
|
|
176
|
+
...(Object.keys(options.fallbacks).length > 0 ? { fallbacks: options.fallbacks } : {}),
|
|
174
177
|
...(catalog ? { available: listed.length } : {}),
|
|
175
178
|
...(modelsHint ? { hint: modelsHint } : {}),
|
|
176
179
|
},
|
|
@@ -202,7 +205,7 @@ export function renderDiscoveryHtml(doc: Discovery): string {
|
|
|
202
205
|
if (models.available !== undefined) sections.push(`<p>${escapeHtml(models.available)} model ids available.</p>`)
|
|
203
206
|
sections.push(`<h3>Allowlist</h3>${models.allowlist.length > 0 ? list(models.allowlist) : "<p>Empty.</p>"}`)
|
|
204
207
|
const aliases = Object.entries(models.aliases)
|
|
205
|
-
if (aliases.length > 0) sections.push(`<h3>Aliases</h3>${list(aliases.map(([alias, target]) =>
|
|
208
|
+
if (aliases.length > 0) sections.push(`<h3>Aliases</h3>${list(aliases.map(([alias, target]) => [alias, target, ...(models.fallbacks?.[alias] ?? [])].join(" -> ")))}`)
|
|
206
209
|
}
|
|
207
210
|
if (doc.suggestedModels) {
|
|
208
211
|
sections.push(`<h2>Suggested <code>models</code> option</h2>${pre(`"models": ${JSON.stringify(doc.suggestedModels)}`)}`)
|
package/src/fallback.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordered model fallbacks: a `ModelCaller` wrapper, so every front-end gets them.
|
|
3
|
+
*
|
|
4
|
+
* An alias written as a list (`"fast": ["a/x", "b/y"]`) is a chain. The route resolves the first
|
|
5
|
+
* target as usual; this wrapper calls it, looks ahead to the first event only, and when that event
|
|
6
|
+
* is a failure of a retryable class (or the call throws, or ends with nothing) it calls the next
|
|
7
|
+
* target in a fresh call, so each attempt is its own session. Once any event has been handed on
|
|
8
|
+
* nothing is retried: a partly streamed answer is never restarted. Attempts are capped at the chain
|
|
9
|
+
* length, share the request's signal (so the deadline and a disconnect end the chain) and nothing is
|
|
10
|
+
* buffered beyond that first event.
|
|
11
|
+
*
|
|
12
|
+
* A call whose model is not an alias with fallbacks goes straight to the wrapped caller.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ServerResponse } from "node:http"
|
|
16
|
+
import type { PlugboardOptions } from "./config.js"
|
|
17
|
+
import type { CallContext, CatalogModel, ModelCaller, ModelRef, PlugError, PlugEvent, PlugRequest, ServedModel } from "./contract.js"
|
|
18
|
+
import { log } from "./log.js"
|
|
19
|
+
import { fallbackRefs, refToString } from "./models.js"
|
|
20
|
+
import { dropRejected } from "./openai/sampling.js"
|
|
21
|
+
|
|
22
|
+
export interface FallbackDeps {
|
|
23
|
+
readonly options: PlugboardOptions
|
|
24
|
+
/** The serving Location's enabled models; read only after a first failure, to skip targets that are not available. */
|
|
25
|
+
loadCatalog(): Promise<ReadonlyArray<CatalogModel>>
|
|
26
|
+
/** A call is about to be retried on the next target (metrics). */
|
|
27
|
+
readonly onFallback?: (app: string) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Failures that say something about this model or provider, not about the request: another target
|
|
32
|
+
* may well answer. Client errors (invalid request, context length, content filter) and plugboard's
|
|
33
|
+
* own failures (backend not ready, bad preparation) are not retried. `provider_auth_failed` is: a
|
|
34
|
+
* provider that lost its sign-in is as down as one that is rate limiting.
|
|
35
|
+
*/
|
|
36
|
+
const RETRYABLE = new Set([
|
|
37
|
+
"rate_limited",
|
|
38
|
+
"quota_exceeded",
|
|
39
|
+
"provider_unavailable",
|
|
40
|
+
"provider_timeout",
|
|
41
|
+
"provider_auth_failed",
|
|
42
|
+
"model_run_failed",
|
|
43
|
+
"prompt_failed",
|
|
44
|
+
"backend_failed",
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
export const MODEL_HEADER = "x-plugboard-model"
|
|
48
|
+
export const FALLBACKS_HEADER = "x-plugboard-fallbacks"
|
|
49
|
+
|
|
50
|
+
/** The route's log fields a served model fills in. */
|
|
51
|
+
export interface ServedInfo {
|
|
52
|
+
served?: string
|
|
53
|
+
fallbacks?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `CallContext.onServed` of a route: `x-plugboard-model` (and `x-plugboard-fallbacks` when targets
|
|
58
|
+
* failed first) while the headers are still open, and the log fields. The first call wins, so several
|
|
59
|
+
* choices of one request report the first answer. `then` runs for that first call (a single-call route
|
|
60
|
+
* echoes the served model in the body there).
|
|
61
|
+
*/
|
|
62
|
+
export function reportServed(response: ServerResponse, info: ServedInfo, then?: (served: ServedModel) => void): (served: ServedModel) => void {
|
|
63
|
+
return (served) => {
|
|
64
|
+
if (info.served !== undefined) return
|
|
65
|
+
info.served = served.model
|
|
66
|
+
info.fallbacks = served.fallbacks
|
|
67
|
+
if (!response.headersSent && !response.destroyed) {
|
|
68
|
+
response.setHeader(MODEL_HEADER, served.model)
|
|
69
|
+
if (served.fallbacks > 0) response.setHeader(FALLBACKS_HEADER, String(served.fallbacks))
|
|
70
|
+
}
|
|
71
|
+
then?.(served)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const isRetryable = (error: PlugError): boolean => error.code !== undefined && RETRYABLE.has(error.code)
|
|
76
|
+
|
|
77
|
+
/** The caller's events with a chain of fallbacks behind the request's model; see the file comment. */
|
|
78
|
+
export function withFallbacks(inner: ModelCaller, deps: FallbackDeps): ModelCaller {
|
|
79
|
+
return {
|
|
80
|
+
stream(request, context) {
|
|
81
|
+
if (!Object.hasOwn(deps.options.fallbacks, request.model)) return inner.stream(request, context)
|
|
82
|
+
return chain(inner, deps, request, context)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
|
|
88
|
+
let rest: ModelRef[] | undefined
|
|
89
|
+
let ref = request.ref
|
|
90
|
+
let attempt = 0
|
|
91
|
+
for (;;) {
|
|
92
|
+
// The history id of this attempt: handed on only if the attempt is the one that answers (or the last).
|
|
93
|
+
let recordID: string | undefined
|
|
94
|
+
const next = attempt === 0 ? request : retarget(deps.options, request, ref)
|
|
95
|
+
const iterator = inner.stream(next, { ...context, onRecord: (id) => void (recordID = id) })[Symbol.asyncIterator]()
|
|
96
|
+
|
|
97
|
+
let first: IteratorResult<PlugEvent> | undefined
|
|
98
|
+
let thrown: { readonly error: unknown } | undefined
|
|
99
|
+
try {
|
|
100
|
+
first = await iterator.next()
|
|
101
|
+
} catch (error) {
|
|
102
|
+
thrown = { error }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Why this attempt failed before any output, or undefined when it answered.
|
|
106
|
+
let reason: string | undefined
|
|
107
|
+
if (thrown) reason = "caller_error"
|
|
108
|
+
else if (first!.done) reason = "no_response"
|
|
109
|
+
else if (first!.value.type === "error" && isRetryable(first!.value.error)) reason = first!.value.error.code
|
|
110
|
+
if (context.signal.aborted) reason = undefined
|
|
111
|
+
|
|
112
|
+
if (reason !== undefined) {
|
|
113
|
+
rest ??= await resolveRest(deps, request)
|
|
114
|
+
const target = rest[attempt]
|
|
115
|
+
if (target) {
|
|
116
|
+
if (!thrown) await close(iterator)
|
|
117
|
+
log("model.fallback", { app: context.app, from: refToString(ref), to: refToString(target), reason, attempt: attempt + 1 })
|
|
118
|
+
deps.onFallback?.(context.app)
|
|
119
|
+
ref = target
|
|
120
|
+
attempt++
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
log("model.fallback_exhausted", { app: context.app, attempts: attempt + 1, reason })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Answered, ran out of targets, or the request ended: this attempt's outcome is the response.
|
|
127
|
+
if (recordID !== undefined) report(context, recordID)
|
|
128
|
+
if (thrown) throw thrown.error
|
|
129
|
+
if (first!.done) return
|
|
130
|
+
// An error is not an answer: a call that fails (non-retryable, or on its last target) reports no served model.
|
|
131
|
+
if (reason === undefined && first!.value.type !== "error") context.onServed?.({ model: refToString(ref), fallbacks: attempt })
|
|
132
|
+
try {
|
|
133
|
+
yield first!.value
|
|
134
|
+
for (let step = await iterator.next(); !step.done; step = await iterator.next()) yield step.value
|
|
135
|
+
} finally {
|
|
136
|
+
await close(iterator)
|
|
137
|
+
}
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The request for a later target: its ref, and the sampling parameters that target is known to reject dropped. */
|
|
143
|
+
function retarget(options: PlugboardOptions, request: PlugRequest, ref: ModelRef): PlugRequest {
|
|
144
|
+
return { ...request, ref, ...(options.dropUnsupportedParams ? { generation: dropRejected(ref, request.generation).generation } : {}) }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The fallbacks that are allowed and enabled. A catalog that cannot be loaded leaves none: the failure stands. */
|
|
148
|
+
async function resolveRest(deps: FallbackDeps, request: PlugRequest): Promise<ModelRef[]> {
|
|
149
|
+
try {
|
|
150
|
+
return fallbackRefs(deps.options, request.model, await deps.loadCatalog())
|
|
151
|
+
} catch {
|
|
152
|
+
return []
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const report = (context: CallContext, recordID: string): void => {
|
|
157
|
+
try {
|
|
158
|
+
context.onRecord?.(recordID)
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Ends an attempt's stream so its cleanup runs; it must never fail the request. */
|
|
163
|
+
async function close(iterator: AsyncIterator<PlugEvent>): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
await iterator.return?.()
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
package/src/gemini/response.ts
CHANGED
|
@@ -15,8 +15,8 @@ type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
|
15
15
|
|
|
16
16
|
/** What the encoder needs besides the model events: Gemini presentation state. The request decoder produces it. */
|
|
17
17
|
export interface GeminiExchange {
|
|
18
|
-
/** The model string from the URL; echoed as `modelVersion`. */
|
|
19
|
-
|
|
18
|
+
/** The model string from the URL; echoed as `modelVersion`. A request that used an alias with fallbacks echoes the target that answered (`CallContext.onServed`). */
|
|
19
|
+
model: string
|
|
20
20
|
readonly stream: boolean
|
|
21
21
|
/** Name of the forced tool that carries `responseSchema` output; its input becomes the text of the answer. */
|
|
22
22
|
readonly structuredTool?: string
|
package/src/gemini/route.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
18
18
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
19
|
+
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
19
20
|
import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
20
21
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
21
22
|
import type { RouteHost } from "../openai/route.js"
|
|
@@ -88,7 +89,7 @@ export interface GeminiCall {
|
|
|
88
89
|
readonly response: ServerResponse
|
|
89
90
|
readonly scope: RequestScope
|
|
90
91
|
/** The route's log fields: the resolved model, and the outcome of a stream that failed after it committed. */
|
|
91
|
-
readonly info: { model?: string; failed?: string; dropped?: string }
|
|
92
|
+
readonly info: ServedInfo & { model?: string; failed?: string; dropped?: string }
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
export async function serveGemini(host: RouteHost, { match, request, url, app, response, scope, info }: GeminiCall): Promise<void> {
|
|
@@ -137,6 +138,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
137
138
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
138
139
|
response.setHeader(SESSION_HEADER, recordID)
|
|
139
140
|
},
|
|
141
|
+
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
140
142
|
}),
|
|
141
143
|
scope,
|
|
142
144
|
)
|
package/src/http.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
8
|
+
import { raceAbort } from "./abort.js"
|
|
8
9
|
import { isLoopbackHost, type PlugboardOptions } from "./config.js"
|
|
9
10
|
import type { PlugError, PlugEvent } from "./contract.js"
|
|
10
11
|
|
|
@@ -49,9 +50,9 @@ export interface RequestScope {
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Establishes cancellation and the deadline. Call before the first `await` of a request: a socket
|
|
52
|
-
* that is already gone aborts immediately, so abandoned work never starts.
|
|
53
|
+
* that is already gone aborts immediately, so abandoned work never starts. `graceMs` is for tests.
|
|
53
54
|
*/
|
|
54
|
-
export function beginRequest(request: IncomingMessage, response: ServerResponse, timeoutMs: number): RequestScope {
|
|
55
|
+
export function beginRequest(request: IncomingMessage, response: ServerResponse, timeoutMs: number, graceMs = DEADLINE_GRACE_MS): RequestScope {
|
|
55
56
|
const controller = new AbortController()
|
|
56
57
|
const abort = (kind: RequestAborted["kind"]) => {
|
|
57
58
|
if (!controller.signal.aborted) controller.abort(new RequestAborted(kind))
|
|
@@ -68,7 +69,7 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
|
|
|
68
69
|
graceTimer = setTimeout(() => {
|
|
69
70
|
response.destroy()
|
|
70
71
|
request.socket?.destroy()
|
|
71
|
-
},
|
|
72
|
+
}, graceMs)
|
|
72
73
|
graceTimer.unref?.()
|
|
73
74
|
}
|
|
74
75
|
timer = setTimeout(onDeadline, Math.min(Math.max(timeoutMs, 1), MAX_TIMER_MS))
|
|
@@ -98,24 +99,7 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
|
|
|
98
99
|
code: "timeout",
|
|
99
100
|
message: `Request timed out after ${timeoutMs} ms (option requestTimeoutMs)`,
|
|
100
101
|
},
|
|
101
|
-
race
|
|
102
|
-
promise.catch(() => {})
|
|
103
|
-
if (signal.aborted) return Promise.reject(signal.reason)
|
|
104
|
-
return new Promise<T>((resolve, reject) => {
|
|
105
|
-
const onAbort = () => reject(signal.reason)
|
|
106
|
-
signal.addEventListener("abort", onAbort, { once: true })
|
|
107
|
-
promise.then(
|
|
108
|
-
(value) => {
|
|
109
|
-
signal.removeEventListener("abort", onAbort)
|
|
110
|
-
resolve(value)
|
|
111
|
-
},
|
|
112
|
-
(error) => {
|
|
113
|
-
signal.removeEventListener("abort", onAbort)
|
|
114
|
-
reject(error)
|
|
115
|
-
},
|
|
116
|
-
)
|
|
117
|
-
})
|
|
118
|
-
},
|
|
102
|
+
race: (promise) => raceAbort(promise, signal),
|
|
119
103
|
dispose,
|
|
120
104
|
}
|
|
121
105
|
}
|
package/src/metrics.ts
CHANGED
|
@@ -112,6 +112,8 @@ export interface Metrics {
|
|
|
112
112
|
tokens(app: string, usage: PlugUsage): void
|
|
113
113
|
/** Sampling parameters dropped for one request. */
|
|
114
114
|
dropped(params: ReadonlyArray<string>): void
|
|
115
|
+
/** A model call failed before any output and was retried on the next target of its alias. */
|
|
116
|
+
fallback(app: string): void
|
|
115
117
|
/** One history sweep ended. */
|
|
116
118
|
sweep(result: "ok" | "failed" | "timeout", removed: number): void
|
|
117
119
|
/** The exposition text. Called from a request that is itself in flight, which is not counted. */
|
|
@@ -128,6 +130,7 @@ export function createMetrics(): Metrics {
|
|
|
128
130
|
["app", "kind"],
|
|
129
131
|
)
|
|
130
132
|
const dropped = new Counter("plugboard_dropped_params_total", "Sampling parameters dropped because the target model rejects them, by parameter.", ["param"])
|
|
133
|
+
const fallbacks = new Counter("plugboard_fallbacks_total", "Model calls retried on the next target of an alias after a failure before any output, by app.", ["app"])
|
|
131
134
|
const sweeps = new Counter("plugboard_history_sweeps_total", "History sweeps that ended, by result.", ["result"])
|
|
132
135
|
const removed = new Counter("plugboard_history_sessions_removed_total", "Request sessions removed by history sweeps.", [])
|
|
133
136
|
|
|
@@ -151,6 +154,7 @@ export function createMetrics(): Metrics {
|
|
|
151
154
|
dropped(params) {
|
|
152
155
|
for (const param of params) dropped.add([param])
|
|
153
156
|
},
|
|
157
|
+
fallback: (app) => fallbacks.add([app]),
|
|
154
158
|
sweep(result, count) {
|
|
155
159
|
sweeps.add([result])
|
|
156
160
|
removed.add([], count)
|
|
@@ -163,6 +167,7 @@ export function createMetrics(): Metrics {
|
|
|
163
167
|
...gauge("plugboard_requests_in_flight", "Requests being handled right now, not counting this scrape.", Math.max(0, inFlight - 1)),
|
|
164
168
|
...tokens.render(),
|
|
165
169
|
...dropped.render(),
|
|
170
|
+
...fallbacks.render(),
|
|
166
171
|
...sweeps.render(),
|
|
167
172
|
...removed.render(),
|
|
168
173
|
...gauge("plugboard_generations", "Accepted plugin generations (one per OpenCode Location) serving requests.", snapshot.generations),
|
package/src/models.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { PlugboardOptions } from "./config.js"
|
|
|
4
4
|
import type { CatalogModel, ModelRef, PlugError } from "./contract.js"
|
|
5
5
|
|
|
6
6
|
/** `provider/model[#variant]`. The model id may contain slashes (`openrouter/vendor/model`). */
|
|
7
|
-
function parseModelRef(s: string): ModelRef | undefined {
|
|
7
|
+
export function parseModelRef(s: string): ModelRef | undefined {
|
|
8
8
|
if (typeof s !== "string") return undefined
|
|
9
9
|
const slash = s.indexOf("/")
|
|
10
10
|
if (slash <= 0) return undefined
|
|
@@ -16,19 +16,21 @@ function parseModelRef(s: string): ModelRef | undefined {
|
|
|
16
16
|
return { providerID: s.slice(0, slash), id, ...(variant ? { variant } : {}) }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function refToString(ref: ModelRef): string {
|
|
19
|
+
export function refToString(ref: ModelRef): string {
|
|
20
20
|
return `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** `provider/*` any model of the provider; `provider/model` any variant; `provider/model#variant` exactly. */
|
|
24
|
-
function
|
|
25
|
-
return
|
|
24
|
+
export function allowedBy(models: ReadonlyArray<string>, ref: ModelRef): boolean {
|
|
25
|
+
return models.some((pattern) => {
|
|
26
26
|
if (pattern.endsWith("/*")) return pattern.slice(0, -2) === ref.providerID
|
|
27
27
|
const p = parseModelRef(pattern)
|
|
28
28
|
return p !== undefined && p.providerID === ref.providerID && p.id === ref.id && (p.variant === undefined || p.variant === ref.variant)
|
|
29
29
|
})
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const allowed = (options: PlugboardOptions, ref: ModelRef): boolean => allowedBy(options.models, ref)
|
|
33
|
+
|
|
32
34
|
function find(catalog: ReadonlyArray<CatalogModel>, ref: ModelRef): CatalogModel | undefined {
|
|
33
35
|
const model = catalog.find((m) => m.providerID === ref.providerID && m.id === ref.id)
|
|
34
36
|
return model && (ref.variant === undefined || model.variants.includes(ref.variant)) ? model : undefined
|
|
@@ -98,3 +100,17 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
|
|
|
98
100
|
}
|
|
99
101
|
return data
|
|
100
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The targets an alias falls back to, in order: the fallbacks that are allowed and enabled on the server.
|
|
106
|
+
* Empty for a plain model id or an alias without fallbacks. The primary target is `resolveModel`'s answer.
|
|
107
|
+
*/
|
|
108
|
+
export function fallbackRefs(options: PlugboardOptions, alias: string, catalog: ReadonlyArray<CatalogModel>): ModelRef[] {
|
|
109
|
+
const targets = Object.hasOwn(options.fallbacks, alias) ? options.fallbacks[alias]! : []
|
|
110
|
+
const refs: ModelRef[] = []
|
|
111
|
+
for (const target of targets) {
|
|
112
|
+
const ref = parseModelRef(target)
|
|
113
|
+
if (ref && allowed(options, ref) && find(catalog, ref)) refs.push(ref)
|
|
114
|
+
}
|
|
115
|
+
return refs
|
|
116
|
+
}
|
package/src/openai/fanout.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { ServerResponse } from "node:http"
|
|
24
24
|
import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.js"
|
|
25
|
+
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
25
26
|
import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
26
27
|
import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
|
|
27
28
|
import type { RouteHost } from "./route.js"
|
|
@@ -40,6 +41,8 @@ export interface ChoiceCodec {
|
|
|
40
41
|
streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
|
|
41
42
|
/** The final chunk with the summed usage (`include_usage`). */
|
|
42
43
|
usageChunk(choice: Omit<StreamChoice, "index">, usage: PlugUsage): object
|
|
44
|
+
/** Echo the served model of an alias with fallbacks as the response model. Used for a single call only: with several choices the body keeps the requested model. */
|
|
45
|
+
setModel(model: string): void
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** The Chat Completions encoder as a `ChoiceCodec`. */
|
|
@@ -50,6 +53,7 @@ export const chatCodec = (exchange: OpenAiExchange): ChoiceCodec => ({
|
|
|
50
53
|
collect: (events) => collectCompletion(events, exchange),
|
|
51
54
|
streamChoice: (events, write, choice) => streamCompletion(events, exchange, write, choice),
|
|
52
55
|
usageChunk: (choice, usage) => chatUsageChunk(exchange.model, choice.id, choice.created, usage),
|
|
56
|
+
setModel: (model) => void (exchange.model = model),
|
|
53
57
|
})
|
|
54
58
|
|
|
55
59
|
export interface ChoicesCall {
|
|
@@ -58,8 +62,8 @@ export interface ChoicesCall {
|
|
|
58
62
|
readonly calls: ReadonlyArray<PlugRequest>
|
|
59
63
|
readonly response: ServerResponse
|
|
60
64
|
readonly scope: RequestScope
|
|
61
|
-
/** The route's log fields: the outcome of a stream that failed after it committed. */
|
|
62
|
-
readonly info: { failed?: string }
|
|
65
|
+
/** The route's log fields: the outcome of a stream that failed after it committed, and the first served model. */
|
|
66
|
+
readonly info: ServedInfo & { failed?: string }
|
|
63
67
|
readonly codec: ChoiceCodec
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -158,6 +162,10 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
158
162
|
const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
|
|
159
163
|
/** History record ids as the callers report them, in choice order, for the response header (comma separated). */
|
|
160
164
|
const records: Array<string | undefined> = []
|
|
165
|
+
// Headers: the first choice to be served wins. Body model: only a single call echoes the served model.
|
|
166
|
+
const served = reportServed(response, info, (s) => {
|
|
167
|
+
if (calls.length === 1) codec.setModel(s.model)
|
|
168
|
+
})
|
|
161
169
|
const open = (index: number) =>
|
|
162
170
|
boundEvents(
|
|
163
171
|
host.caller.stream(calls[index]!, {
|
|
@@ -168,6 +176,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
168
176
|
records[index] = recordID
|
|
169
177
|
response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
|
|
170
178
|
},
|
|
179
|
+
onServed: served,
|
|
171
180
|
}),
|
|
172
181
|
gscope,
|
|
173
182
|
)
|
package/src/openai/response.ts
CHANGED
|
@@ -14,8 +14,8 @@ type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
|
14
14
|
* presentation state. The request decoder produces it; the model call never sees it.
|
|
15
15
|
*/
|
|
16
16
|
export interface OpenAiExchange {
|
|
17
|
-
/** The model string the client sent; echoed in every response object. */
|
|
18
|
-
|
|
17
|
+
/** The model string the client sent; echoed in every response object. A request that used an alias with fallbacks echoes the target that answered (`CallContext.onServed`). */
|
|
18
|
+
model: string
|
|
19
19
|
readonly stream: boolean
|
|
20
20
|
/** `stream_options.include_usage`: the stream carries a usage field per chunk and a final usage chunk. */
|
|
21
21
|
readonly includeUsage: boolean
|
package/src/openai/route.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
sseWriter,
|
|
37
37
|
type RequestScope,
|
|
38
38
|
} from "../http.js"
|
|
39
|
+
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
39
40
|
import { encodeGeminiError } from "../gemini/response.js"
|
|
40
41
|
import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/route.js"
|
|
41
42
|
import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
@@ -96,7 +97,7 @@ const decodeSegment = (raw: string): string => {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/** What a request log line may carry: ids and outcomes only. */
|
|
99
|
-
interface RequestInfo {
|
|
100
|
+
interface RequestInfo extends ServedInfo {
|
|
100
101
|
route: string
|
|
101
102
|
app?: string
|
|
102
103
|
model?: string
|
|
@@ -246,6 +247,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
246
247
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
247
248
|
response.setHeader(SESSION_HEADER, recordID)
|
|
248
249
|
},
|
|
250
|
+
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
251
|
+
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
249
252
|
}),
|
|
250
253
|
scope,
|
|
251
254
|
)
|
|
@@ -328,7 +331,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
328
331
|
metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
329
332
|
// Probes and scrapes that succeed are not worth a line each.
|
|
330
333
|
if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
|
|
331
|
-
const fields = { id, route: info.route, app: info.app, model: info.model, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
|
|
334
|
+
const fields = { id, route: info.route, app: info.app, model: info.model, served: info.served, fallbacks: info.fallbacks, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
|
|
332
335
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
333
336
|
else log("request", fields)
|
|
334
337
|
}
|
|
@@ -12,8 +12,8 @@ type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
|
12
12
|
|
|
13
13
|
/** What the encoder needs besides the model events: Responses presentation state. The request decoder produces it. */
|
|
14
14
|
export interface ResponsesExchange {
|
|
15
|
-
/** The model string the client sent; echoed in every response object. */
|
|
16
|
-
|
|
15
|
+
/** The model string the client sent; echoed in every response object. A request that used an alias with fallbacks echoes the target that answered (`CallContext.onServed`). */
|
|
16
|
+
model: string
|
|
17
17
|
readonly stream: boolean
|
|
18
18
|
/** Request fields echoed into the response object (instructions, tools, temperature, ...). */
|
|
19
19
|
readonly envelope: Readonly<Record<string, unknown>>
|
|
@@ -79,10 +79,16 @@ const structuredFailure = (exchange: ResponsesExchange, finish: Exclude<PlugFini
|
|
|
79
79
|
message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* `committed`: whether the transport already committed a 200 stream before any event was emitted
|
|
84
|
+
* (the SSE writer does at the first reasoning event, #49). A failure then still opens the response
|
|
85
|
+
* and ends it with `response.failed`, instead of leaving the client an empty 200 stream.
|
|
86
|
+
*/
|
|
82
87
|
export async function runResponse(
|
|
83
88
|
events: AsyncIterable<PlugEvent>,
|
|
84
89
|
exchange: ResponsesExchange,
|
|
85
90
|
emit: (event: ResponsesStreamEvent) => void,
|
|
91
|
+
committed: () => boolean = () => false,
|
|
86
92
|
): Promise<ResponseRun> {
|
|
87
93
|
const id = newId("resp")
|
|
88
94
|
const createdAt = Math.floor(Date.now() / 1000)
|
|
@@ -203,14 +209,15 @@ export async function runResponse(
|
|
|
203
209
|
const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
|
|
204
210
|
failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
|
|
205
211
|
if (failure) {
|
|
206
|
-
if (!started) return { failure, started }
|
|
212
|
+
if (!started && !committed()) return { failure, started }
|
|
213
|
+
start()
|
|
207
214
|
closeMessage()
|
|
208
215
|
const response = envelope("failed", items, {
|
|
209
216
|
error: { code: failure.code ?? failure.kind, message: failure.message },
|
|
210
217
|
...(usage ? { usage: encodeUsage(usage) } : {}),
|
|
211
218
|
})
|
|
212
219
|
send("response.failed", { response })
|
|
213
|
-
return { failure, started, response }
|
|
220
|
+
return { failure, started: true, response }
|
|
214
221
|
}
|
|
215
222
|
start()
|
|
216
223
|
if (held && structuredAnswer) text(args(held.input))
|
package/src/responses/route.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ServerResponse } from "node:http"
|
|
12
12
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
13
|
+
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
13
14
|
import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
14
15
|
import { resolveModel } from "../models.js"
|
|
15
16
|
import { encodeError } from "../openai/response.js"
|
|
@@ -25,7 +26,7 @@ export interface ResponsesCall {
|
|
|
25
26
|
readonly response: ServerResponse
|
|
26
27
|
readonly scope: RequestScope
|
|
27
28
|
/** The route's log fields: the resolved model, and the outcome of a stream that failed after it committed. */
|
|
28
|
-
readonly info: { model?: string; failed?: string }
|
|
29
|
+
readonly info: ServedInfo & { model?: string; failed?: string }
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
|
|
@@ -50,13 +51,14 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
50
51
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
51
52
|
response.setHeader(SESSION_HEADER, recordID)
|
|
52
53
|
},
|
|
54
|
+
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
53
55
|
}),
|
|
54
56
|
scope,
|
|
55
57
|
)
|
|
56
58
|
|
|
57
59
|
if (exchange.stream) {
|
|
58
60
|
const sse = sseWriter(response, scope.signal)
|
|
59
|
-
const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)))
|
|
61
|
+
const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
|
|
60
62
|
if (!sse.started && !response.headersSent) {
|
|
61
63
|
if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
62
64
|
} else {
|
package/src/runtime.ts
CHANGED
|
@@ -44,7 +44,10 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
|
47
|
+
import { setTimeout as delay } from "node:timers/promises"
|
|
47
48
|
import { Cause, Effect, Stream } from "effect"
|
|
49
|
+
import stableStringify from "safe-stable-stringify"
|
|
50
|
+
import { raceAbort } from "./abort.js"
|
|
48
51
|
import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
|
|
49
52
|
import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
|
|
50
53
|
import { createMetrics, type Metrics, type RuntimeSnapshot } from "./metrics.js"
|
|
@@ -104,8 +107,8 @@ const BACKEND_POLL_MS = 25
|
|
|
104
107
|
const BIND_RETRY_MS = 15_000
|
|
105
108
|
const BIND_REMINDER_MS = 5 * 60_000
|
|
106
109
|
|
|
107
|
-
/** 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. */
|
|
108
|
-
const VERSION =
|
|
110
|
+
/** 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 = 7
|
|
109
112
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
110
113
|
|
|
111
114
|
interface Listener {
|
|
@@ -298,19 +301,9 @@ export function loadServingCatalog(fallback: () => Promise<ReadonlyArray<Catalog
|
|
|
298
301
|
return (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
|
|
299
302
|
}
|
|
300
303
|
|
|
301
|
-
/** JSON with object keys sorted at every level, so equal options compare equal whatever their key order. */
|
|
304
|
+
/** JSON with object keys sorted at every level, so equal options compare equal whatever their key order (undefined values are left out, as in `JSON.stringify`). */
|
|
302
305
|
function canonicalJson(value: unknown): string {
|
|
303
|
-
|
|
304
|
-
if (Array.isArray(item)) return item.map(canonical)
|
|
305
|
-
if (typeof item !== "object" || item === null) return item
|
|
306
|
-
return Object.fromEntries(
|
|
307
|
-
Object.entries(item)
|
|
308
|
-
.filter(([, entry]) => entry !== undefined)
|
|
309
|
-
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
310
|
-
.map(([key, entry]) => [key, canonical(entry)]),
|
|
311
|
-
)
|
|
312
|
-
}
|
|
313
|
-
return JSON.stringify(canonical(value))
|
|
306
|
+
return stableStringify(value) ?? ""
|
|
314
307
|
}
|
|
315
308
|
|
|
316
309
|
const unavailable = (response: ServerResponse, message: string) => {
|
|
@@ -512,30 +505,8 @@ export function ensurePump(subscribe: () => Stream.Stream<unknown, unknown, neve
|
|
|
512
505
|
|
|
513
506
|
const backendUnavailable = (message: string): PlugError => ({ status: 503, kind: "server", code: "backend_unavailable", message })
|
|
514
507
|
|
|
515
|
-
/**
|
|
516
|
-
|
|
517
|
-
return new Promise<T>((resolve, reject) => {
|
|
518
|
-
const onAbort = () => reject(new Error("aborted"))
|
|
519
|
-
if (signal.aborted) return onAbort()
|
|
520
|
-
signal.addEventListener("abort", onAbort, { once: true })
|
|
521
|
-
promise.then(
|
|
522
|
-
(value) => (signal.removeEventListener("abort", onAbort), resolve(value)),
|
|
523
|
-
(error) => (signal.removeEventListener("abort", onAbort), reject(error)),
|
|
524
|
-
)
|
|
525
|
-
})
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const sleep = (ms: number, signal: AbortSignal) =>
|
|
529
|
-
new Promise<void>((resolve) => {
|
|
530
|
-
if (signal.aborted) return resolve()
|
|
531
|
-
const timer = setTimeout(done, ms)
|
|
532
|
-
function done() {
|
|
533
|
-
clearTimeout(timer)
|
|
534
|
-
signal.removeEventListener("abort", done)
|
|
535
|
-
resolve()
|
|
536
|
-
}
|
|
537
|
-
signal.addEventListener("abort", done, { once: true })
|
|
538
|
-
})
|
|
508
|
+
/** Waits `ms`, or until `signal` aborts (which resolves, never rejects). */
|
|
509
|
+
const sleep = (ms: number, signal: AbortSignal): Promise<void> => delay(ms, undefined, { signal }).catch(() => {})
|
|
539
510
|
|
|
540
511
|
/** Rebuild the transforms of a generation set up before its directory was confirmed; shared by concurrent requests. */
|
|
541
512
|
function activate(generation: Generation): Promise<void> {
|
|
@@ -593,7 +564,7 @@ export async function ensureBackend(
|
|
|
593
564
|
if (!booted) {
|
|
594
565
|
booted = true
|
|
595
566
|
try {
|
|
596
|
-
await
|
|
567
|
+
await raceAbort(boot(directory), bounded)
|
|
597
568
|
} catch (error) {
|
|
598
569
|
if (bounded.aborted) continue
|
|
599
570
|
logError("backend.boot_failed", { app, error: errorMessage(error) })
|
|
@@ -603,7 +574,7 @@ export async function ensureBackend(
|
|
|
603
574
|
}
|
|
604
575
|
} else {
|
|
605
576
|
try {
|
|
606
|
-
await
|
|
577
|
+
await raceAbort(Promise.all(here.filter((generation) => !generation.activated).map(activate)), bounded)
|
|
607
578
|
} catch (error) {
|
|
608
579
|
if (bounded.aborted) continue
|
|
609
580
|
throw error
|
package/src/session/caller.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { randomUUID } from "node:crypto"
|
|
20
|
+
import { raceAbort } from "../abort.js"
|
|
20
21
|
import { APP_NAME_PATTERN, DEFAULT_APP } from "../config.js"
|
|
21
22
|
import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
|
|
22
23
|
import { log, logError } from "../log.js"
|
|
@@ -72,17 +73,6 @@ function checkPreparation(request: PlugRequest, images: number, maxTools: number
|
|
|
72
73
|
return undefined
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
/** Settles with `promise`, or rejects as soon as `signal` aborts. Request side only: the promise itself keeps running. */
|
|
76
|
-
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
77
|
-
promise.catch(() => {}) // a late rejection after the race was lost must not become unhandled
|
|
78
|
-
if (signal.aborted) return Promise.reject(signal.reason)
|
|
79
|
-
return new Promise<T>((resolve, reject) => {
|
|
80
|
-
const onAbort = () => reject(signal.reason)
|
|
81
|
-
signal.addEventListener("abort", onAbort, { once: true })
|
|
82
|
-
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort))
|
|
83
|
-
})
|
|
84
|
-
}
|
|
85
|
-
|
|
86
76
|
export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
87
77
|
const { port, sessions, maxTools, ensureBackend } = deps
|
|
88
78
|
const remoteImages = deps.remoteImages ?? false
|
package/src/session/media.ts
CHANGED
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
* server's own network (#84): the host is resolved first, every address it resolves to must be a
|
|
10
10
|
* public one (loopback, private, link-local, CGNAT, ULA, multicast, reserved and mapped or
|
|
11
11
|
* translated forms of those are refused) unless the operator's `remoteImages.allow` list names the
|
|
12
|
-
* host or the address, and the request then connects to the
|
|
13
|
-
* name, so a second DNS answer cannot redirect it (DNS
|
|
14
|
-
*
|
|
12
|
+
* host or the address, and the request then connects to the addresses that were checked (tried in
|
|
13
|
+
* order until one accepts), not to the name, so a second DNS answer cannot redirect it (DNS
|
|
14
|
+
* rebinding). Addresses are classified by ipaddr.js. The caller sees one generic error for every
|
|
15
|
+
* fetch failure, whatever the cause.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { lookup } from "node:dns/promises"
|
|
18
19
|
import { request as httpRequest, type IncomingMessage } from "node:http"
|
|
19
20
|
import { request as httpsRequest } from "node:https"
|
|
20
21
|
import { isIP } from "node:net"
|
|
22
|
+
import ipaddr from "ipaddr.js"
|
|
21
23
|
import type { PlugRequest } from "../contract.js"
|
|
22
24
|
import { log } from "../log.js"
|
|
23
25
|
|
|
@@ -49,127 +51,64 @@ export class ImageError extends Error {
|
|
|
49
51
|
|
|
50
52
|
// ---- addresses
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
readonly family: 4 | 6
|
|
54
|
-
readonly bytes: ReadonlyArray<number>
|
|
55
|
-
}
|
|
54
|
+
type Ip = ipaddr.IPv4 | ipaddr.IPv6
|
|
56
55
|
|
|
57
|
-
/**
|
|
56
|
+
/** Strict dotted IPv4 or any IPv6 text form (compressed, with an embedded IPv4 tail, with a zone). Odd IPv4 spellings (`0x7f.1`) never get here: a URL host is normalized by `new URL` first. */
|
|
58
57
|
function parseIp(text: string): Ip | undefined {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (dot !== -1) {
|
|
65
|
-
const colon = head.lastIndexOf(":")
|
|
66
|
-
const v4 = head.slice(colon + 1).split(".").map(Number)
|
|
67
|
-
head = `${head.slice(0, colon + 1)}${((v4[0]! << 8) | v4[1]!).toString(16)}:${((v4[2]! << 8) | v4[3]!).toString(16)}`
|
|
68
|
-
}
|
|
69
|
-
const [left = "", right = ""] = head.split("::")
|
|
70
|
-
const first = left === "" ? [] : left.split(":")
|
|
71
|
-
const last = head.includes("::") ? (right === "" ? [] : right.split(":")) : []
|
|
72
|
-
const groups = head.includes("::") ? [...first, ...Array<string>(8 - first.length - last.length).fill("0"), ...last] : head.split(":")
|
|
73
|
-
const bytes: number[] = []
|
|
74
|
-
for (const group of groups) {
|
|
75
|
-
const value = Number.parseInt(group, 16)
|
|
76
|
-
bytes.push(value >> 8, value & 0xff)
|
|
77
|
-
}
|
|
78
|
-
return { family, bytes }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface Cidr {
|
|
82
|
-
readonly ip: Ip
|
|
83
|
-
readonly bits: number
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function inCidr(ip: Ip, cidr: Cidr): boolean {
|
|
87
|
-
if (ip.family !== cidr.ip.family) return false
|
|
88
|
-
let left = cidr.bits
|
|
89
|
-
for (let i = 0; left > 0; i++, left -= 8) {
|
|
90
|
-
const mask = left >= 8 ? 0xff : (0xff << (8 - left)) & 0xff
|
|
91
|
-
if ((ip.bytes[i]! & mask) !== (cidr.ip.bytes[i]! & mask)) return false
|
|
58
|
+
if (isIP(text) === 0) return undefined
|
|
59
|
+
try {
|
|
60
|
+
return ipaddr.parse(text)
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined
|
|
92
63
|
}
|
|
93
|
-
return true
|
|
94
64
|
}
|
|
95
65
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const ip = parseIp(address)
|
|
99
|
-
if (!ip || extra !== undefined) return undefined
|
|
100
|
-
const max = ip.family === 4 ? 32 : 128
|
|
101
|
-
if (length === undefined) return { ip, bits: max }
|
|
102
|
-
const bits = Number(length)
|
|
103
|
-
return /^\d{1,3}$/.test(length) && bits <= max ? { ip, bits } : undefined
|
|
104
|
-
}
|
|
66
|
+
/** `address/bits` match; false for another family (ipaddr.js throws on one). */
|
|
67
|
+
const inNet = (ip: Ip, net: Ip, bits: number): boolean => ip.kind() === net.kind() && ip.match(net, bits)
|
|
105
68
|
|
|
106
|
-
const
|
|
69
|
+
const NAT64 = ipaddr.IPv6.parseCIDR("64:ff9b::/96")
|
|
70
|
+
/** `::/96`: unspecified, loopback and the deprecated IPv4-compatible form (`::7f00:1`), which ipaddr.js calls unicast. */
|
|
71
|
+
const IPV4_COMPATIBLE = ipaddr.IPv6.parseCIDR("::/96")
|
|
107
72
|
|
|
108
|
-
|
|
109
|
-
const BLOCKED_V4 = cidrs([
|
|
110
|
-
"0.0.0.0/8", // "this network"
|
|
111
|
-
"10.0.0.0/8",
|
|
112
|
-
"100.64.0.0/10", // CGNAT
|
|
113
|
-
"127.0.0.0/8", // loopback
|
|
114
|
-
"169.254.0.0/16", // link-local, cloud metadata
|
|
115
|
-
"172.16.0.0/12",
|
|
116
|
-
"192.0.0.0/24", // IETF protocol assignments
|
|
117
|
-
"192.0.2.0/24", // documentation
|
|
118
|
-
"192.168.0.0/16",
|
|
119
|
-
"198.18.0.0/15", // benchmarking
|
|
120
|
-
"198.51.100.0/24", // documentation
|
|
121
|
-
"203.0.113.0/24", // documentation
|
|
122
|
-
"224.0.0.0/4", // multicast
|
|
123
|
-
"240.0.0.0/4", // reserved, broadcast
|
|
124
|
-
])
|
|
73
|
+
const v4From = (high: number, low: number) => new ipaddr.IPv4([high >> 8, high & 0xff, low >> 8, low & 0xff])
|
|
125
74
|
|
|
126
75
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
76
|
+
* A public unicast address. ipaddr.js names the special ranges (loopback, private, link-local, CGNAT,
|
|
77
|
+
* multicast, reserved and documentation, ULA, Teredo, local-use NAT64, IPv4-mapped, ...); only
|
|
78
|
+
* `unicast` passes. NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) embed an IPv4 address, which is
|
|
79
|
+
* checked as IPv4 instead of being refused outright (a DNS64 network answers with NAT64 addresses
|
|
80
|
+
* for ordinary public hosts). An IPv4-mapped address is never a real answer for a public host.
|
|
130
81
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"64:ff9b:1::/48", // local-use NAT64
|
|
135
|
-
"100::/64", // discard
|
|
136
|
-
"2001::/32", // Teredo
|
|
137
|
-
"2001:db8::/32", // documentation
|
|
138
|
-
"fc00::/7", // unique local
|
|
139
|
-
"fe80::/10", // link-local
|
|
140
|
-
"fec0::/10", // site-local (deprecated)
|
|
141
|
-
"ff00::/8", // multicast
|
|
142
|
-
])
|
|
143
|
-
|
|
144
|
-
/** The IPv4 address embedded in a NAT64 or 6to4 address (or a mapped one), if it is one. */
|
|
145
|
-
function embeddedV4(ip: Ip): Ip | undefined {
|
|
146
|
-
if (ip.family !== 6) return undefined
|
|
147
|
-
const b = ip.bytes
|
|
148
|
-
const mapped = b.slice(0, 10).every((x) => x === 0) && b[10] === 0xff && b[11] === 0xff
|
|
149
|
-
const nat64 = b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b && b.slice(4, 12).every((x) => x === 0)
|
|
150
|
-
if (mapped || nat64) return { family: 4, bytes: b.slice(12, 16) }
|
|
151
|
-
if (b[0] === 0x20 && b[1] === 0x02) return { family: 4, bytes: b.slice(2, 6) }
|
|
152
|
-
return undefined
|
|
82
|
+
export function isPublicAddress(text: string): boolean {
|
|
83
|
+
const ip = parseIp(text)
|
|
84
|
+
return ip !== undefined && isPublic(ip)
|
|
153
85
|
}
|
|
154
86
|
|
|
155
87
|
function isPublic(ip: Ip): boolean {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
88
|
+
if (ip instanceof ipaddr.IPv4) return ip.range() === "unicast"
|
|
89
|
+
const range = ip.range()
|
|
90
|
+
const inner = range === "6to4" ? v4From(ip.parts[1]!, ip.parts[2]!) : ip.match(NAT64) ? v4From(ip.parts[6]!, ip.parts[7]!) : undefined
|
|
91
|
+
if (inner) return isPublic(inner)
|
|
92
|
+
return range === "unicast" && !ip.match(IPV4_COMPATIBLE)
|
|
159
93
|
}
|
|
160
94
|
|
|
161
95
|
// ---- allowlist
|
|
162
96
|
|
|
163
97
|
/** One `remoteImages.allow` entry: a host name (`images.internal`, or `*.internal` for subdomains), an IP address, or a CIDR range. */
|
|
164
|
-
type AllowEntry = { readonly kind: "host"; readonly name: string } | { readonly kind: "net"; readonly
|
|
98
|
+
type AllowEntry = { readonly kind: "host"; readonly name: string } | { readonly kind: "net"; readonly address: Ip; readonly bits: number }
|
|
165
99
|
|
|
166
100
|
const HOSTNAME = /^(?:\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/
|
|
167
101
|
|
|
168
102
|
/** Undefined when `entry` is none of the three forms. */
|
|
169
103
|
export function parseAllowEntry(entry: string): AllowEntry | undefined {
|
|
170
104
|
const text = entry.trim().toLowerCase()
|
|
171
|
-
const
|
|
172
|
-
|
|
105
|
+
const [address = "", length, extra] = text.split("/")
|
|
106
|
+
const ip = parseIp(address)
|
|
107
|
+
if (ip && extra === undefined) {
|
|
108
|
+
const max = ip instanceof ipaddr.IPv4 ? 32 : 128
|
|
109
|
+
if (length === undefined) return { kind: "net", address: ip, bits: max }
|
|
110
|
+
if (/^\d{1,3}$/.test(length) && Number(length) <= max) return { kind: "net", address: ip, bits: Number(length) }
|
|
111
|
+
}
|
|
173
112
|
return HOSTNAME.test(text) && !text.includes("/") ? { kind: "host", name: text } : undefined
|
|
174
113
|
}
|
|
175
114
|
|
|
@@ -190,11 +129,12 @@ function resolveAddresses(host: string, signal: AbortSignal): Promise<string[]>
|
|
|
190
129
|
}
|
|
191
130
|
|
|
192
131
|
/**
|
|
193
|
-
* Resolve the URL's host and
|
|
194
|
-
* to has to be allowed (a name that answers with one public and one private
|
|
195
|
-
* so
|
|
132
|
+
* Resolve the URL's host and return the addresses to connect to, in resolver order. Every address
|
|
133
|
+
* the name resolves to has to be allowed (a name that answers with one public and one private
|
|
134
|
+
* address is refused), so a pinned one cannot be a surprise. Throws the generic fetch error when
|
|
135
|
+
* anything is refused.
|
|
196
136
|
*/
|
|
197
|
-
async function
|
|
137
|
+
async function checkedAddresses(url: URL, allow: ReadonlyArray<AllowEntry>, signal: AbortSignal): Promise<string[]> {
|
|
198
138
|
const host = url.hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
|
199
139
|
const literal = parseIp(host)
|
|
200
140
|
const addresses = literal ? [host] : await resolveAddresses(host, signal)
|
|
@@ -202,14 +142,14 @@ async function pickAddress(url: URL, allow: ReadonlyArray<AllowEntry>, signal: A
|
|
|
202
142
|
const permitted = (address: string): boolean => {
|
|
203
143
|
const ip = parseIp(address)
|
|
204
144
|
if (!ip) return false
|
|
205
|
-
return nameAllowed || isPublic(ip) || allow.some((entry) => entry.kind === "net" &&
|
|
145
|
+
return nameAllowed || isPublic(ip) || allow.some((entry) => entry.kind === "net" && inNet(ip, entry.address, entry.bits))
|
|
206
146
|
}
|
|
207
147
|
if (addresses.length === 0 || !addresses.every(permitted)) {
|
|
208
148
|
// The log says which kind of refusal, never the host or the URL.
|
|
209
149
|
log("media.remote_refused", { reason: addresses.length === 0 ? "no_address" : "address_not_allowed" })
|
|
210
150
|
throw FETCH_FAILED()
|
|
211
151
|
}
|
|
212
|
-
return addresses
|
|
152
|
+
return addresses
|
|
213
153
|
}
|
|
214
154
|
|
|
215
155
|
/** GET `url` over a connection to `address` (already checked), keeping the URL's host for `Host` and TLS. No redirects are followed by the caller. */
|
|
@@ -239,6 +179,18 @@ function get(url: URL, address: string, signal: AbortSignal): Promise<IncomingMe
|
|
|
239
179
|
})
|
|
240
180
|
}
|
|
241
181
|
|
|
182
|
+
/** The first connection that succeeds, trying each checked address in order (#134); only a connection failure moves on, never a response. */
|
|
183
|
+
async function getFirst(url: URL, addresses: ReadonlyArray<string>, signal: AbortSignal): Promise<IncomingMessage> {
|
|
184
|
+
for (const address of addresses) {
|
|
185
|
+
try {
|
|
186
|
+
return await get(url, address, signal)
|
|
187
|
+
} catch {
|
|
188
|
+
if (signal.aborted) break
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw FETCH_FAILED()
|
|
192
|
+
}
|
|
193
|
+
|
|
242
194
|
// ---- preparation
|
|
243
195
|
|
|
244
196
|
async function toDataUrl(url: string, signal: AbortSignal, remoteImages: RemoteImages, budget: Budget): Promise<string> {
|
|
@@ -260,12 +212,10 @@ async function toDataUrl(url: string, signal: AbortSignal, remoteImages: RemoteI
|
|
|
260
212
|
throw FETCH_FAILED()
|
|
261
213
|
}
|
|
262
214
|
const allow = typeof remoteImages === "object" ? remoteImages.allow.flatMap((entry) => parseAllowEntry(entry) ?? []) : []
|
|
263
|
-
const
|
|
215
|
+
const addresses = await checkedAddresses(target, allow, bounded).catch((error: unknown) => {
|
|
264
216
|
throw error instanceof ImageError ? error : FETCH_FAILED()
|
|
265
217
|
})
|
|
266
|
-
const response = await
|
|
267
|
-
throw FETCH_FAILED()
|
|
268
|
-
})
|
|
218
|
+
const response = await getFirst(target, addresses, bounded)
|
|
269
219
|
// A redirect is never followed (it could point inside); anything but a plain success is a failed fetch.
|
|
270
220
|
if (response.statusCode === undefined || response.statusCode < 200 || response.statusCode > 299) {
|
|
271
221
|
response.destroy()
|