opencode-plugboard 0.11.0 → 0.13.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 +17 -9
- package/package.json +17 -1
- package/src/anthropic/response.ts +2 -2
- package/src/anthropic/route.ts +5 -1
- package/src/completions/response.ts +3 -2
- package/src/completions/route.ts +2 -1
- package/src/config.ts +119 -8
- package/src/contract.ts +18 -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 +6 -2
- package/src/metrics.ts +5 -0
- package/src/models.ts +47 -9
- package/src/openai/fanout.ts +14 -2
- package/src/openai/response.ts +2 -2
- package/src/openai/route.ts +8 -3
- package/src/policy.ts +17 -0
- package/src/responses/response.ts +2 -2
- package/src/responses/route.ts +5 -1
- package/src/session/caller.ts +44 -2
- package/src/session/context.ts +22 -5
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.13.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"
|
|
@@ -53,19 +54,26 @@ export default Plugin.define({
|
|
|
53
54
|
port: createSessionPort(ctx),
|
|
54
55
|
sessions,
|
|
55
56
|
maxTools: options.maxTools,
|
|
57
|
+
apps: options.apps,
|
|
56
58
|
remoteImages: options.remoteImages,
|
|
57
59
|
// The readiness gate the caller awaits before creating a session (fails closed: 503 `backend_unavailable`).
|
|
58
60
|
ensureBackend: (app: string, signal: AbortSignal) => ensureBackend(app, signal, bootLocation),
|
|
59
61
|
}
|
|
62
|
+
// The catalog comes from the preferred accepted generation (a data-root Location when there is
|
|
63
|
+
// one), not from whichever Location registered last; this generation's own is the fallback.
|
|
64
|
+
const servingCatalog = async () => {
|
|
65
|
+
startPump()
|
|
66
|
+
return loadServingCatalog(ownCatalog)
|
|
67
|
+
}
|
|
60
68
|
const handle = createOpenAiRoute({
|
|
61
69
|
options,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
// Aliases written as a list retry the next target before the first output (src/fallback.ts).
|
|
71
|
+
caller: withFallbacks(createSessionCaller(callerDeps), {
|
|
72
|
+
options,
|
|
73
|
+
loadCatalog: servingCatalog,
|
|
74
|
+
onFallback: options.metrics ? (app) => processMetrics().fallback(app) : undefined,
|
|
75
|
+
}),
|
|
76
|
+
loadCatalog: servingCatalog,
|
|
69
77
|
})
|
|
70
78
|
const registration = registerGeneration({
|
|
71
79
|
listen: options.listen,
|
|
@@ -95,7 +103,7 @@ export default Plugin.define({
|
|
|
95
103
|
// ---- the context hook, the agent deny and the seed tools. Ownership of the Location is only
|
|
96
104
|
// known after an asynchronous check (`ensureBackend`), so all three are no-ops until
|
|
97
105
|
// `registration.confirmed()`: in real projects nothing is ever added or rewritten.
|
|
98
|
-
yield* installSessionHooks(ctx, { maxTools: options.maxTools, sessions, active: registration.confirmed })
|
|
106
|
+
yield* installSessionHooks(ctx, { maxTools: options.maxTools, apps: options.apps, sessions, active: registration.confirmed })
|
|
99
107
|
registration.ready()
|
|
100
108
|
}),
|
|
101
109
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.",
|
|
@@ -30,11 +30,27 @@
|
|
|
30
30
|
"safe-stable-stringify": "2.5.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
+
"@ai-sdk/anthropic": "4.0.78",
|
|
34
|
+
"@ai-sdk/google": "4.0.93",
|
|
35
|
+
"@ai-sdk/openai": "4.0.91",
|
|
36
|
+
"@anthropic-ai/sdk": "0.133.0",
|
|
37
|
+
"@commitlint/cli": "21.2.3",
|
|
38
|
+
"@commitlint/config-conventional": "21.2.3",
|
|
39
|
+
"@google/genai": "2.28.0",
|
|
33
40
|
"@opencode/plugin": "2.0.26",
|
|
34
41
|
"@types/bun": "1.4.2",
|
|
35
42
|
"@types/escape-html": "^1.0.4",
|
|
43
|
+
"@types/lcov-parse": "1.0.2",
|
|
36
44
|
"@types/node": "^26.6.2",
|
|
45
|
+
"ai": "7.0.137",
|
|
46
|
+
"conventional-commits-parser": "7.1.3",
|
|
37
47
|
"effect": "4.0.0-rc.112",
|
|
48
|
+
"eventsource-parser": "4.1.1",
|
|
49
|
+
"fast-check": "4.10.2",
|
|
50
|
+
"fast-xml-parser": "5.11.2",
|
|
51
|
+
"lcov-parse": "1.0.0",
|
|
52
|
+
"lefthook": "2.2.1",
|
|
53
|
+
"openai": "7.32.0",
|
|
38
54
|
"typescript": "^5.9.3"
|
|
39
55
|
},
|
|
40
56
|
"peerDependencies": {
|
|
@@ -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,7 +25,9 @@ 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"
|
|
30
|
+
import { reportPolicy } from "../policy.js"
|
|
29
31
|
import { dropRejected } from "../openai/sampling.js"
|
|
30
32
|
import type { RouteHost } from "../openai/route.js"
|
|
31
33
|
import { parseMessagesRequest } from "./request.js"
|
|
@@ -45,7 +47,7 @@ export interface MessagesCall {
|
|
|
45
47
|
readonly response: ServerResponse
|
|
46
48
|
readonly scope: RequestScope
|
|
47
49
|
/** 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 }
|
|
50
|
+
readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string }
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
const header = (request: IncomingMessage, name: string): string | undefined => {
|
|
@@ -121,6 +123,8 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
121
123
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
122
124
|
response.setHeader(SESSION_HEADER, recordID)
|
|
123
125
|
},
|
|
126
|
+
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
127
|
+
onPolicy: reportPolicy(response),
|
|
124
128
|
}),
|
|
125
129
|
scope,
|
|
126
130
|
)
|
|
@@ -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
|
@@ -2,18 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
import { createHash, timingSafeEqual } from "node:crypto"
|
|
4
4
|
import type { PlugError } from "./contract.js"
|
|
5
|
+
import { allowedBy, parseModelRef } from "./models.js"
|
|
5
6
|
import { parseAllowEntry, type RemoteImages } from "./session/media.js"
|
|
6
7
|
import type { HistoryLimits } from "./session/prune.js"
|
|
7
8
|
|
|
8
9
|
/** Plugin options, after defaults and validation (see `parseOptions`). */
|
|
9
10
|
export interface PlugboardOptions {
|
|
10
11
|
readonly listen: { readonly host: string; readonly port: number }
|
|
11
|
-
/** App name -> API key. App names match APP_NAME_PATTERN; "default" is reserved. */
|
|
12
|
+
/** App name -> API key, from `keys` and `apps.<name>.key` merged. App names match APP_NAME_PATTERN; "default" is reserved. */
|
|
12
13
|
readonly keys: Readonly<Record<string, string>>
|
|
14
|
+
/** Per-app policy (`apps`); only apps that set a policy field appear. Look one up with `appPolicy`. */
|
|
15
|
+
readonly apps: Readonly<Record<string, AppPolicy>>
|
|
13
16
|
/** Allowlist: exact `provider/model`, `provider/model#variant`, or `provider/*` patterns. Empty = nothing allowed. */
|
|
14
17
|
readonly models: ReadonlyArray<string>
|
|
15
18
|
/** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
|
|
16
19
|
readonly aliases: Readonly<Record<string, string>>
|
|
20
|
+
/**
|
|
21
|
+
* Alias -> the targets after the first, in order, for an alias written as an array. `aliases` holds the
|
|
22
|
+
* first target, so everything that resolves an alias keeps working; the model caller retries the rest
|
|
23
|
+
* (`src/fallback.ts`). Absent for an alias with one target. Every target passes the allowlist.
|
|
24
|
+
*/
|
|
25
|
+
readonly fallbacks: Readonly<Record<string, ReadonlyArray<string>>>
|
|
17
26
|
readonly maxTools: number
|
|
18
27
|
/** 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. */
|
|
19
28
|
readonly maxN: number
|
|
@@ -35,6 +44,23 @@ export interface PlugboardOptions {
|
|
|
35
44
|
readonly metrics: boolean
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
export type SystemMode = "prepend" | "append" | "replace"
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What the operator fixes for one app, whatever the caller sends (option `apps`). `models` only narrows the
|
|
51
|
+
* global allowlist: entries are `provider/model[#variant]`, `provider/*` or alias names.
|
|
52
|
+
*/
|
|
53
|
+
export interface AppPolicy {
|
|
54
|
+
readonly models?: ReadonlyArray<string>
|
|
55
|
+
/** Text added to the caller's system prompt, or replacing it (`systemMode`). */
|
|
56
|
+
readonly system?: string
|
|
57
|
+
readonly systemMode: SystemMode
|
|
58
|
+
/** false: the caller's tools are dropped before the model sees them. */
|
|
59
|
+
readonly tools: boolean
|
|
60
|
+
/** Lowers the global `maxTools` for this app. */
|
|
61
|
+
readonly maxTools?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
38
64
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
39
65
|
export const DEFAULT_APP = "default"
|
|
40
66
|
const DEFAULTS = {
|
|
@@ -45,7 +71,7 @@ const DEFAULTS = {
|
|
|
45
71
|
requestTimeoutMs: 600_000,
|
|
46
72
|
} as const
|
|
47
73
|
|
|
48
|
-
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
|
|
74
|
+
const KNOWN = new Set(["listen", "keys", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
|
|
49
75
|
const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
|
|
50
76
|
/** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
|
|
51
77
|
const MAX_AGE_DAYS = 36_500
|
|
@@ -95,6 +121,75 @@ function parseRemoteImages(value: unknown): RemoteImages {
|
|
|
95
121
|
return { allow: allow as string[] }
|
|
96
122
|
}
|
|
97
123
|
|
|
124
|
+
/**
|
|
125
|
+
* `keys` and the `key` of each `apps` entry as one list. An app named in both must carry the same
|
|
126
|
+
* value: two sources that disagree are a config mistake, not a precedence rule. Values are validated
|
|
127
|
+
* by the caller of this function (non-empty strings, unique).
|
|
128
|
+
*/
|
|
129
|
+
function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
|
|
130
|
+
const merged = new Map<string, unknown>(Object.entries(isRecord(keys) ? keys : {}))
|
|
131
|
+
for (const [app, policy] of Object.entries(isRecord(apps) ? apps : {})) {
|
|
132
|
+
if (!isRecord(policy) || policy.key === undefined) continue
|
|
133
|
+
if (merged.has(app) && merged.get(app) !== policy.key)
|
|
134
|
+
throw new Error(`plugboard app \`${app}\` has a different key in \`keys\` and in \`apps.${app}.key\`; set it in one place or make them equal`)
|
|
135
|
+
merged.set(app, policy.key)
|
|
136
|
+
}
|
|
137
|
+
return [...merged]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools"])
|
|
141
|
+
const SYSTEM_MODES: ReadonlySet<string> = new Set(["prepend", "append", "replace"])
|
|
142
|
+
|
|
143
|
+
/** Validates `apps` (the `key` field was merged into `keys` already) into per-app policies. */
|
|
144
|
+
function parseApps(
|
|
145
|
+
raw: unknown,
|
|
146
|
+
context: { keys: Readonly<Record<string, string>>; aliases: Readonly<Record<string, string>>; maxTools: number },
|
|
147
|
+
): Record<string, AppPolicy> {
|
|
148
|
+
const entries: Array<[string, AppPolicy]> = []
|
|
149
|
+
for (const [app, value] of Object.entries(isRecord(raw) ? raw : {})) {
|
|
150
|
+
const at = `plugboard \`apps.${app}\``
|
|
151
|
+
if (app !== DEFAULT_APP && !APP_NAME_PATTERN.test(app)) throw new Error(`plugboard app name \`${app}\` must match ${APP_NAME_PATTERN} (or be \`${DEFAULT_APP}\`)`)
|
|
152
|
+
if (!isRecord(value)) throw new Error(`${at} must be an object`)
|
|
153
|
+
for (const field of Object.keys(value))
|
|
154
|
+
if (!APP_KNOWN.has(field)) throw new Error(`Unknown plugboard option \`apps.${app}.${field}\` (known: ${[...APP_KNOWN].join(", ")})`)
|
|
155
|
+
const keyed = Object.keys(context.keys).length > 0
|
|
156
|
+
if (app === DEFAULT_APP && (keyed || value.key !== undefined))
|
|
157
|
+
throw new Error(`${at} configures the keyless app and cannot be used together with \`keys\`; name the app and give it a key instead`)
|
|
158
|
+
if (app !== DEFAULT_APP && !Object.hasOwn(context.keys, app)) throw new Error(`${at} has no key: add \`key\` there or an entry in \`keys\``)
|
|
159
|
+
|
|
160
|
+
if (value.models !== undefined) {
|
|
161
|
+
if (!Array.isArray(value.models) || value.models.length === 0)
|
|
162
|
+
throw new Error(`${at}.models must be a non-empty array (omit it to inherit the global \`models\`)`)
|
|
163
|
+
for (const entry of value.models)
|
|
164
|
+
if (typeof entry !== "string" || !(MODEL_PATTERN.test(entry) || Object.hasOwn(context.aliases, entry)))
|
|
165
|
+
throw new Error(`${at}.models entry \`${String(entry)}\` must be \`provider/model\`, \`provider/model#variant\`, \`provider/*\` or an alias name`)
|
|
166
|
+
}
|
|
167
|
+
if (value.system !== undefined && (typeof value.system !== "string" || value.system.trim().length === 0))
|
|
168
|
+
throw new Error(`${at}.system must be a non-empty string`)
|
|
169
|
+
if (value.systemMode !== undefined && (typeof value.systemMode !== "string" || !SYSTEM_MODES.has(value.systemMode)))
|
|
170
|
+
throw new Error(`${at}.systemMode must be \`prepend\`, \`append\` or \`replace\``)
|
|
171
|
+
if (value.systemMode !== undefined && value.system === undefined) throw new Error(`${at}.systemMode needs \`system\``)
|
|
172
|
+
if (value.tools !== undefined && typeof value.tools !== "boolean") throw new Error(`${at}.tools must be a boolean`)
|
|
173
|
+
const maxTools = value.maxTools === undefined ? undefined : positiveInt(value.maxTools, `apps.${app}.maxTools`, 0, context.maxTools)
|
|
174
|
+
|
|
175
|
+
const policy: AppPolicy = {
|
|
176
|
+
...(value.models === undefined ? {} : { models: value.models as string[] }),
|
|
177
|
+
...(value.system === undefined ? {} : { system: value.system as string }),
|
|
178
|
+
systemMode: (value.systemMode as SystemMode | undefined) ?? "prepend",
|
|
179
|
+
tools: value.tools !== false,
|
|
180
|
+
...(maxTools === undefined ? {} : { maxTools }),
|
|
181
|
+
}
|
|
182
|
+
// An app that only carries a key has no policy to look up.
|
|
183
|
+
if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined) entries.push([app, policy])
|
|
184
|
+
}
|
|
185
|
+
return Object.fromEntries(entries)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The policy configured for `app` (`default` for the keyless app), if any. Own-property lookup: an app name never reaches the prototype. */
|
|
189
|
+
export function appPolicy(options: Pick<PlugboardOptions, "apps">, app: string): AppPolicy | undefined {
|
|
190
|
+
return Object.hasOwn(options.apps, app) ? options.apps[app] : undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
98
193
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
99
194
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
100
195
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -112,8 +207,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
112
207
|
|
|
113
208
|
const keyEntries: Array<[string, string]> = []
|
|
114
209
|
if (input.keys !== undefined && !isRecord(input.keys)) throw new Error("plugboard option `keys` must be an object of app name -> key")
|
|
210
|
+
if (input.apps !== undefined && !isRecord(input.apps)) throw new Error("plugboard option `apps` must be an object of app name -> policy")
|
|
115
211
|
const appOfKey = new Map<string, string>()
|
|
116
|
-
for (const [app, key] of
|
|
212
|
+
for (const [app, key] of mergedKeys(input.keys, input.apps)) {
|
|
117
213
|
if (app === DEFAULT_APP) throw new Error(`plugboard key name \`${DEFAULT_APP}\` is reserved for keyless loopback use`)
|
|
118
214
|
if (!APP_NAME_PATTERN.test(app)) throw new Error(`plugboard key name \`${app}\` must match ${APP_NAME_PATTERN}`)
|
|
119
215
|
if (typeof key !== "string" || key.length === 0)
|
|
@@ -137,18 +233,31 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
137
233
|
throw new Error(`plugboard model \`${String(pattern)}\` must be \`provider/model\`, \`provider/model#variant\` or \`provider/*\``)
|
|
138
234
|
|
|
139
235
|
const aliasEntries: Array<[string, string]> = []
|
|
140
|
-
|
|
236
|
+
const fallbackEntries: Array<[string, string[]]> = []
|
|
237
|
+
if (input.aliases !== undefined && !isRecord(input.aliases)) throw new Error("plugboard option `aliases` must be an object of alias -> model or alias -> [model, ...]")
|
|
141
238
|
for (const [alias, target] of Object.entries(input.aliases ?? {})) {
|
|
142
239
|
if (alias.length === 0 || /[\s]/.test(alias)) throw new Error(`plugboard alias \`${alias}\` must be a non-empty string without whitespace`)
|
|
143
240
|
// Plain-object lookups downstream would hit the prototype accessor instead of the alias.
|
|
144
241
|
if (alias === "__proto__") throw new Error("plugboard alias `__proto__` is not allowed")
|
|
145
|
-
|
|
146
|
-
|
|
242
|
+
const chain = Array.isArray(target) ? target : [target]
|
|
243
|
+
if (chain.length === 0) throw new Error(`plugboard alias \`${alias}\` must list at least one target`)
|
|
244
|
+
for (const entry of chain) {
|
|
245
|
+
if (typeof entry !== "string" || !ALIAS_TARGET.test(entry))
|
|
246
|
+
throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\` (or a list of them)`)
|
|
247
|
+
// A list is new, so it is strict: a target the allowlist would refuse can never serve, so fail at load.
|
|
248
|
+
if (Array.isArray(target) && !allowedBy(models as string[], parseModelRef(entry)!))
|
|
249
|
+
throw new Error(`plugboard alias \`${alias}\` target \`${entry}\` is not allowed by the \`models\` option`)
|
|
250
|
+
}
|
|
147
251
|
if (CANONICAL_SHAPE.test(alias))
|
|
148
252
|
throw new Error(`plugboard alias \`${alias}\` is written like a \`provider/model\` id; aliases must not contain \`/\` (use a short name such as \`fast\`)`)
|
|
149
|
-
aliasEntries.push([alias,
|
|
253
|
+
aliasEntries.push([alias, chain[0] as string])
|
|
254
|
+
if (chain.length > 1) fallbackEntries.push([alias, chain.slice(1) as string[]])
|
|
150
255
|
}
|
|
151
256
|
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
257
|
+
const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
|
|
258
|
+
|
|
259
|
+
const maxTools = positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024)
|
|
260
|
+
const apps = parseApps(input.apps, { keys, aliases, maxTools })
|
|
152
261
|
|
|
153
262
|
const remoteImages = parseRemoteImages(input.remoteImages)
|
|
154
263
|
if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
|
|
@@ -168,9 +277,11 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
168
277
|
return {
|
|
169
278
|
listen: { host, port: port ?? DEFAULTS.port },
|
|
170
279
|
keys,
|
|
280
|
+
apps,
|
|
171
281
|
models: models as string[],
|
|
172
282
|
aliases,
|
|
173
|
-
|
|
283
|
+
fallbacks,
|
|
284
|
+
maxTools,
|
|
174
285
|
maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
|
|
175
286
|
requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
|
|
176
287
|
remoteImages,
|
package/src/contract.ts
CHANGED
|
@@ -161,6 +161,9 @@ export const isPlugError = (value: unknown): value is PlugError =>
|
|
|
161
161
|
|
|
162
162
|
// ---------------------------------------------------------------- caller
|
|
163
163
|
|
|
164
|
+
/** What an app's policy (option `apps`) changed in a request in a way the caller should be told about. */
|
|
165
|
+
export type PolicyNote = "tools-stripped"
|
|
166
|
+
|
|
164
167
|
export interface CallContext {
|
|
165
168
|
/** App name (key name, or the default app). Decides the history directory. */
|
|
166
169
|
readonly app: string
|
|
@@ -168,6 +171,21 @@ export interface CallContext {
|
|
|
168
171
|
readonly signal: AbortSignal
|
|
169
172
|
/** Called once, before the first event, with the id of the history record this call writes (if any). */
|
|
170
173
|
readonly onRecord?: (recordID: string) => void
|
|
174
|
+
/**
|
|
175
|
+
* Called once, before the first event, only for a request whose model is an alias with fallbacks
|
|
176
|
+
* (`src/fallback.ts`): which target answered and how many earlier targets failed first. A front-end
|
|
177
|
+
* uses it for `x-plugboard-model` and the `model` it echoes. A call that fails on every target never
|
|
178
|
+
* reports it.
|
|
179
|
+
*/
|
|
180
|
+
readonly onServed?: (served: ServedModel) => void
|
|
181
|
+
/** 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
|
+
readonly onPolicy?: (notes: ReadonlyArray<PolicyNote>) => void
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
|
|
186
|
+
export interface ServedModel {
|
|
187
|
+
readonly model: string
|
|
188
|
+
readonly fallbacks: number
|
|
171
189
|
}
|
|
172
190
|
|
|
173
191
|
/** The seam between the core and a backend (the session backend now, `ctx.llm.stream` possibly later). */
|
package/src/discovery.ts
CHANGED
|
@@ -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
|
|
@@ -134,7 +136,7 @@ export function buildDiscovery({ options, app, catalog }: DiscoveryInput): Disco
|
|
|
134
136
|
const detail = !keyed || app !== undefined
|
|
135
137
|
const auth = keyed ? ({ required: true, scheme: "Bearer" } as const) : { required: false }
|
|
136
138
|
|
|
137
|
-
const listed = detail && catalog ? listAvailableModels(options, catalog) : []
|
|
139
|
+
const listed = detail && catalog ? listAvailableModels(options, catalog, app) : []
|
|
138
140
|
const summary = detail && catalog ? summarize(catalog) : undefined
|
|
139
141
|
const suggested = summary?.providers.map((provider) => `${provider.id}/*`)
|
|
140
142
|
|
|
@@ -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)}`)}`)
|
|
@@ -274,7 +277,7 @@ export function buildStatus(input: {
|
|
|
274
277
|
servingConfirmed: runtime.servingConfirmed,
|
|
275
278
|
eventPump: { running: runtime.pumpRunning },
|
|
276
279
|
sessionGuards: runtime.guards,
|
|
277
|
-
models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog).length } : { error: "catalog_unavailable" },
|
|
280
|
+
models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog, app).length } : { error: "catalog_unavailable" },
|
|
278
281
|
metrics: options.metrics,
|
|
279
282
|
}
|
|
280
283
|
}
|
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, context.app)
|
|
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, app: string): Promise<ModelRef[]> {
|
|
149
|
+
try {
|
|
150
|
+
return fallbackRefs(deps.options, request.model, await deps.loadCatalog(), app)
|
|
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,8 +16,10 @@
|
|
|
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"
|
|
22
|
+
import { reportPolicy } from "../policy.js"
|
|
21
23
|
import type { RouteHost } from "../openai/route.js"
|
|
22
24
|
import { dropRejected } from "../openai/sampling.js"
|
|
23
25
|
import { parseGeminiRequest } from "./request.js"
|
|
@@ -88,7 +90,7 @@ export interface GeminiCall {
|
|
|
88
90
|
readonly response: ServerResponse
|
|
89
91
|
readonly scope: RequestScope
|
|
90
92
|
/** 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 }
|
|
93
|
+
readonly info: ServedInfo & { model?: string; failed?: string; dropped?: string }
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
export async function serveGemini(host: RouteHost, { match, request, url, app, response, scope, info }: GeminiCall): Promise<void> {
|
|
@@ -98,7 +100,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
98
100
|
return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent and streamGenerateContent` })
|
|
99
101
|
|
|
100
102
|
if (match.op === "list" || match.op === "get") {
|
|
101
|
-
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
|
|
103
|
+
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), app)
|
|
102
104
|
if (match.op === "list") return sendJson(response, 200, encodeGeminiModelList(listed))
|
|
103
105
|
const item = listed.find((m) => m.id === match.model)
|
|
104
106
|
if (!item) return fail({ status: 404, kind: "not_found", code: "model_not_found", param: "model", message: `Model ${match.model} is not available` })
|
|
@@ -137,6 +139,8 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
137
139
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
138
140
|
response.setHeader(SESSION_HEADER, recordID)
|
|
139
141
|
},
|
|
142
|
+
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
143
|
+
onPolicy: reportPolicy(response),
|
|
140
144
|
}),
|
|
141
145
|
scope,
|
|
142
146
|
)
|
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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/** Model refs and the allowlist/alias policy over a neutral catalog. Protocol-neutral: front-ends format the results. */
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { appPolicy, type AppPolicy, 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,37 @@ 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
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-app narrowing (`apps.<name>.models`), applied on top of the global allowlist, never instead of it.
|
|
36
|
+
* `name` is what the caller sent (an alias or a model id). An entry that is an alias name admits that
|
|
37
|
+
* name; a pattern entry admits every resolved model it matches, whichever name reached it. No `models`
|
|
38
|
+
* on the app means no narrowing. Alias names never match as patterns: they are not `provider/model` shaped.
|
|
39
|
+
*/
|
|
40
|
+
function inAppScope(policy: AppPolicy | undefined, name: string, ref: ModelRef): boolean {
|
|
41
|
+
return policy?.models === undefined || policy.models.includes(name) || allowedBy(policy.models, ref)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 404 `model_not_found` when `app` may not use the (already resolved) model; the same error as an unknown model. */
|
|
45
|
+
export function checkAppModel(policy: AppPolicy | undefined, name: string, ref: ModelRef): PlugError | undefined {
|
|
46
|
+
if (inAppScope(policy, name, ref)) return undefined
|
|
47
|
+
return { status: 404, message: `Model ${name} is not available`, kind: "not_found", code: "model_not_found", param: "model" }
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
function find(catalog: ReadonlyArray<CatalogModel>, ref: ModelRef): CatalogModel | undefined {
|
|
33
51
|
const model = catalog.find((m) => m.providerID === ref.providerID && m.id === ref.id)
|
|
34
52
|
return model && (ref.variant === undefined || model.variants.includes(ref.variant)) ? model : undefined
|
|
@@ -76,8 +94,12 @@ export interface ListedModel {
|
|
|
76
94
|
readonly model: CatalogModel
|
|
77
95
|
}
|
|
78
96
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The allowlist intersected with the catalog, plus aliases whose target qualifies. Ids are unique except an alias may repeat one.
|
|
99
|
+
* With `app`, the list is narrowed to that app's `models` (see `inAppScope`).
|
|
100
|
+
*/
|
|
101
|
+
export function listAvailableModels(options: PlugboardOptions, catalog: ReadonlyArray<CatalogModel>, app?: string): ListedModel[] {
|
|
102
|
+
const policy = app === undefined ? undefined : appPolicy(options, app)
|
|
81
103
|
const entry = (id: string, ref: ModelRef, model: CatalogModel): ListedModel => ({ id, ref, model })
|
|
82
104
|
const data: ListedModel[] = []
|
|
83
105
|
// The catalog can list a model more than once (and repeat variants); ids must be unique.
|
|
@@ -86,15 +108,31 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
|
|
|
86
108
|
const base = { providerID: model.providerID, id: model.id }
|
|
87
109
|
for (const ref of [base, ...model.variants.map((variant) => ({ ...base, variant }))]) {
|
|
88
110
|
const id = refToString(ref)
|
|
89
|
-
if (seen.has(id) || !allowed(options, ref)) continue
|
|
111
|
+
if (seen.has(id) || !allowed(options, ref) || !inAppScope(policy, id, ref)) continue
|
|
90
112
|
seen.add(id)
|
|
91
113
|
data.push(entry(id, ref, model))
|
|
92
114
|
}
|
|
93
115
|
}
|
|
94
116
|
for (const [alias, target] of Object.entries(options.aliases)) {
|
|
95
117
|
const ref = parseModelRef(target)
|
|
96
|
-
const model = ref && allowed(options, ref) ? find(catalog, ref) : undefined
|
|
118
|
+
const model = ref && allowed(options, ref) && inAppScope(policy, alias, ref) ? find(catalog, ref) : undefined
|
|
97
119
|
if (ref && model) data.push(entry(alias, ref, model))
|
|
98
120
|
}
|
|
99
121
|
return data
|
|
100
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The targets an alias falls back to, in order: the fallbacks that are allowed and enabled on the server.
|
|
126
|
+
* Empty for a plain model id or an alias without fallbacks. The primary target is `resolveModel`'s answer.
|
|
127
|
+
* With `app`, a target outside that app's `models` is skipped (an app scoped to the alias name admits every target).
|
|
128
|
+
*/
|
|
129
|
+
export function fallbackRefs(options: PlugboardOptions, alias: string, catalog: ReadonlyArray<CatalogModel>, app?: string): ModelRef[] {
|
|
130
|
+
const policy = app === undefined ? undefined : appPolicy(options, app)
|
|
131
|
+
const targets = Object.hasOwn(options.fallbacks, alias) ? options.fallbacks[alias]! : []
|
|
132
|
+
const refs: ModelRef[] = []
|
|
133
|
+
for (const target of targets) {
|
|
134
|
+
const ref = parseModelRef(target)
|
|
135
|
+
if (ref && allowed(options, ref) && inAppScope(policy, alias, ref) && find(catalog, ref)) refs.push(ref)
|
|
136
|
+
}
|
|
137
|
+
return refs
|
|
138
|
+
}
|
package/src/openai/fanout.ts
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
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"
|
|
26
|
+
import { reportPolicy } from "../policy.js"
|
|
25
27
|
import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
26
28
|
import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
|
|
27
29
|
import type { RouteHost } from "./route.js"
|
|
@@ -40,6 +42,8 @@ export interface ChoiceCodec {
|
|
|
40
42
|
streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
|
|
41
43
|
/** The final chunk with the summed usage (`include_usage`). */
|
|
42
44
|
usageChunk(choice: Omit<StreamChoice, "index">, usage: PlugUsage): object
|
|
45
|
+
/** 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. */
|
|
46
|
+
setModel(model: string): void
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/** The Chat Completions encoder as a `ChoiceCodec`. */
|
|
@@ -50,6 +54,7 @@ export const chatCodec = (exchange: OpenAiExchange): ChoiceCodec => ({
|
|
|
50
54
|
collect: (events) => collectCompletion(events, exchange),
|
|
51
55
|
streamChoice: (events, write, choice) => streamCompletion(events, exchange, write, choice),
|
|
52
56
|
usageChunk: (choice, usage) => chatUsageChunk(exchange.model, choice.id, choice.created, usage),
|
|
57
|
+
setModel: (model) => void (exchange.model = model),
|
|
53
58
|
})
|
|
54
59
|
|
|
55
60
|
export interface ChoicesCall {
|
|
@@ -58,8 +63,8 @@ export interface ChoicesCall {
|
|
|
58
63
|
readonly calls: ReadonlyArray<PlugRequest>
|
|
59
64
|
readonly response: ServerResponse
|
|
60
65
|
readonly scope: RequestScope
|
|
61
|
-
/** The route's log fields: the outcome of a stream that failed after it committed. */
|
|
62
|
-
readonly info: { failed?: string }
|
|
66
|
+
/** The route's log fields: the outcome of a stream that failed after it committed, and the first served model. */
|
|
67
|
+
readonly info: ServedInfo & { failed?: string }
|
|
63
68
|
readonly codec: ChoiceCodec
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -158,6 +163,11 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
158
163
|
const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
|
|
159
164
|
/** History record ids as the callers report them, in choice order, for the response header (comma separated). */
|
|
160
165
|
const records: Array<string | undefined> = []
|
|
166
|
+
// Headers: the first choice to be served wins. Body model: only a single call echoes the served model.
|
|
167
|
+
const served = reportServed(response, info, (s) => {
|
|
168
|
+
if (calls.length === 1) codec.setModel(s.model)
|
|
169
|
+
})
|
|
170
|
+
const policy = reportPolicy(response)
|
|
161
171
|
const open = (index: number) =>
|
|
162
172
|
boundEvents(
|
|
163
173
|
host.caller.stream(calls[index]!, {
|
|
@@ -168,6 +178,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
168
178
|
records[index] = recordID
|
|
169
179
|
response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
|
|
170
180
|
},
|
|
181
|
+
onServed: served,
|
|
182
|
+
onPolicy: policy,
|
|
171
183
|
}),
|
|
172
184
|
gscope,
|
|
173
185
|
)
|
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,11 +36,13 @@ 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"
|
|
42
43
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
43
44
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
45
|
+
import { reportPolicy } from "../policy.js"
|
|
44
46
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
45
47
|
import { serveCompletions } from "../completions/route.js"
|
|
46
48
|
import { serveResponses } from "../responses/route.js"
|
|
@@ -96,7 +98,7 @@ const decodeSegment = (raw: string): string => {
|
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
/** What a request log line may carry: ids and outcomes only. */
|
|
99
|
-
interface RequestInfo {
|
|
101
|
+
interface RequestInfo extends ServedInfo {
|
|
100
102
|
route: string
|
|
101
103
|
app?: string
|
|
102
104
|
model?: string
|
|
@@ -195,7 +197,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
if (route === "GET /v1/models" || modelId !== undefined) {
|
|
198
|
-
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
|
|
200
|
+
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), auth.app)
|
|
199
201
|
if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
|
|
200
202
|
const item = listed.find((m) => m.id === modelId)
|
|
201
203
|
if (!item) {
|
|
@@ -246,6 +248,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
246
248
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
247
249
|
response.setHeader(SESSION_HEADER, recordID)
|
|
248
250
|
},
|
|
251
|
+
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
252
|
+
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
253
|
+
onPolicy: reportPolicy(response),
|
|
249
254
|
}),
|
|
250
255
|
scope,
|
|
251
256
|
)
|
|
@@ -328,7 +333,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
328
333
|
metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
329
334
|
// Probes and scrapes that succeed are not worth a line each.
|
|
330
335
|
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 }
|
|
336
|
+
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
337
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
333
338
|
else log("request", fields)
|
|
334
339
|
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The response side of per-app policy (option `apps`): what a route tells the caller about changes the policy made. */
|
|
2
|
+
|
|
3
|
+
import type { ServerResponse } from "node:http"
|
|
4
|
+
import type { PolicyNote } from "./contract.js"
|
|
5
|
+
|
|
6
|
+
export const POLICY_HEADER = "x-plugboard-policy"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `CallContext.onPolicy` of a route: `x-plugboard-policy` (the notes, comma separated) while the
|
|
10
|
+
* headers are still open. The first writer wins, so several choices of one request report once.
|
|
11
|
+
*/
|
|
12
|
+
export function reportPolicy(response: ServerResponse): (notes: ReadonlyArray<PolicyNote>) => void {
|
|
13
|
+
return (notes) => {
|
|
14
|
+
if (notes.length === 0 || response.headersSent || response.destroyed || response.hasHeader(POLICY_HEADER)) return
|
|
15
|
+
response.setHeader(POLICY_HEADER, notes.join(","))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -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>>
|
package/src/responses/route.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
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"
|
|
16
|
+
import { reportPolicy } from "../policy.js"
|
|
15
17
|
import { encodeError } from "../openai/response.js"
|
|
16
18
|
import type { RouteHost } from "../openai/route.js"
|
|
17
19
|
import { parseResponsesRequest } from "./request.js"
|
|
@@ -25,7 +27,7 @@ export interface ResponsesCall {
|
|
|
25
27
|
readonly response: ServerResponse
|
|
26
28
|
readonly scope: RequestScope
|
|
27
29
|
/** 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 }
|
|
30
|
+
readonly info: ServedInfo & { model?: string; failed?: string }
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
|
|
@@ -50,6 +52,8 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
50
52
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
51
53
|
response.setHeader(SESSION_HEADER, recordID)
|
|
52
54
|
},
|
|
55
|
+
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
56
|
+
onPolicy: reportPolicy(response),
|
|
53
57
|
}),
|
|
54
58
|
scope,
|
|
55
59
|
)
|
package/src/session/caller.ts
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
|
|
19
19
|
import { randomUUID } from "node:crypto"
|
|
20
20
|
import { raceAbort } from "../abort.js"
|
|
21
|
-
import { APP_NAME_PATTERN, DEFAULT_APP } from "../config.js"
|
|
21
|
+
import { APP_NAME_PATTERN, DEFAULT_APP, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
|
|
22
22
|
import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
|
|
23
23
|
import { log, logError } from "../log.js"
|
|
24
|
+
import { checkAppModel } from "../models.js"
|
|
24
25
|
import { RequestCollector } from "./events.js"
|
|
25
26
|
import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
|
|
26
27
|
import { describeError, type SessionPort } from "./host.js"
|
|
@@ -34,6 +35,8 @@ export interface SessionCallerDeps {
|
|
|
34
35
|
readonly sessions: SessionStore
|
|
35
36
|
/** The seed pool size of the app directories (option `maxTools`); a request needing more tools fails before dispatch. */
|
|
36
37
|
readonly maxTools: number
|
|
38
|
+
/** Per-app policy (option `apps`): model scope, tools off, a lower tool cap. The system prompt part is applied by the context hook. */
|
|
39
|
+
readonly apps?: PlugboardOptions["apps"]
|
|
37
40
|
/** Allow fetching http(s) image URLs (option `remoteImages`: off, public addresses only, or with an allowlist). */
|
|
38
41
|
readonly remoteImages?: RemoteImages
|
|
39
42
|
/**
|
|
@@ -73,12 +76,36 @@ function checkPreparation(request: PlugRequest, images: number, maxTools: number
|
|
|
73
76
|
return undefined
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The app's tool policy applied to the request: `tools: false` drops every tool (and a tool choice that
|
|
81
|
+
* would refer to them). Returns the request the session is built from, and whether anything was dropped.
|
|
82
|
+
* Note that a `response_format` of `json_schema` is answered through a tool, so it is dropped too.
|
|
83
|
+
*/
|
|
84
|
+
function applyToolPolicy(request: PlugRequest, policy: AppPolicy | undefined): { request: PlugRequest; stripped: boolean } {
|
|
85
|
+
if (policy?.tools !== false) return { request, stripped: false }
|
|
86
|
+
const { toolChoice: _dropped, ...rest } = request
|
|
87
|
+
return { request: { ...rest, tools: [] }, stripped: request.tools.length > 0 }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An app's own `maxTools`, checked on top of the global one (which `checkPreparation` and the request decoders enforce). */
|
|
91
|
+
function checkAppTools(request: PlugRequest, app: string, policy: AppPolicy | undefined): PlugError | undefined {
|
|
92
|
+
const max = policy?.maxTools
|
|
93
|
+
if (max === undefined || request.tools.length <= max) return undefined
|
|
94
|
+
return {
|
|
95
|
+
status: 400,
|
|
96
|
+
kind: "invalid_request",
|
|
97
|
+
code: "too_many_tools",
|
|
98
|
+
param: "tools",
|
|
99
|
+
message: `Request has ${request.tools.length} tools; app ${app} allows ${max} (option \`apps.${app}.maxTools\`)`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
76
102
|
export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
77
103
|
const { port, sessions, maxTools, ensureBackend } = deps
|
|
104
|
+
const apps = deps.apps ?? {}
|
|
78
105
|
const remoteImages = deps.remoteImages ?? false
|
|
79
106
|
|
|
80
107
|
return {
|
|
81
|
-
async *stream(
|
|
108
|
+
async *stream(called: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
|
|
82
109
|
const app = APP_NAME_PATTERN.test(context.app) || context.app === DEFAULT_APP ? context.app : undefined
|
|
83
110
|
if (!app) return yield failure(400, `Invalid app name: ${context.app}`, "invalid_app")
|
|
84
111
|
if (context.signal.aborted) return
|
|
@@ -92,6 +119,21 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
|
92
119
|
return event
|
|
93
120
|
}
|
|
94
121
|
|
|
122
|
+
// The app's policy: model scope first (a model outside it is as unknown as one off the allowlist), then tools.
|
|
123
|
+
const policy = appPolicy({ apps }, app)
|
|
124
|
+
const outOfScope = checkAppModel(policy, called.model, called.ref)
|
|
125
|
+
if (outOfScope) return yield note({ type: "error", error: outOfScope })
|
|
126
|
+
const { request, stripped } = applyToolPolicy(called, policy)
|
|
127
|
+
const tooMany = checkAppTools(request, app, policy)
|
|
128
|
+
if (tooMany) return yield note({ type: "error", error: tooMany })
|
|
129
|
+
if (stripped) {
|
|
130
|
+
try {
|
|
131
|
+
context.onPolicy?.(["tools-stripped"])
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logError("request.on_policy_failed", { app, error: describeError(error) })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
95
137
|
let sessionID: string | undefined
|
|
96
138
|
let collector: RequestCollector | undefined
|
|
97
139
|
let promptStarted = false
|
package/src/session/context.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Effect } from "effect"
|
|
11
|
+
import { appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
|
|
11
12
|
import type { PlugError, PlugMessage, PlugRequest } from "../contract.js"
|
|
12
13
|
import type { ContextHookEvent, HostContext, HostPart, HostToolDescriptor } from "./host.js"
|
|
13
14
|
import { SEED_PREFIX, SEED_RESULT, seedToolsFor } from "./seeds.js"
|
|
@@ -80,13 +81,29 @@ function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number
|
|
|
80
81
|
return tools.length - next.size
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The caller's system prompts with the app's `system` applied: before them (default), after them, or
|
|
86
|
+
* instead of them (`replace` also drops instructions the front-end put there, such as the JSON-mode one).
|
|
87
|
+
*/
|
|
88
|
+
function systemFor(caller: ReadonlyArray<string>, policy: AppPolicy | undefined): ReadonlyArray<string> {
|
|
89
|
+
if (policy?.system === undefined) return caller
|
|
90
|
+
switch (policy.systemMode) {
|
|
91
|
+
case "prepend":
|
|
92
|
+
return [policy.system, ...caller]
|
|
93
|
+
case "append":
|
|
94
|
+
return [...caller, policy.system]
|
|
95
|
+
case "replace":
|
|
96
|
+
return [policy.system]
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
const preparationFailed = (message: string): PlugError => ({ status: 500, kind: "server", code: "backend_preparation_failed", message })
|
|
84
101
|
|
|
85
102
|
/**
|
|
86
103
|
* `session.hook("context")` body: swap the request session's system, messages, tools and
|
|
87
|
-
* generation options for the caller's.
|
|
104
|
+
* generation options for the caller's, with the app's system prompt (option `apps`) applied.
|
|
88
105
|
*/
|
|
89
|
-
function applyContext(event: unknown, sessions: SessionStore): void {
|
|
106
|
+
function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOptions["apps"]): void {
|
|
90
107
|
// Assumed invariant: the host passes its mutable `SessionContext` (checked structurally by `HostContextMatches`).
|
|
91
108
|
const session = event as ContextHookEvent
|
|
92
109
|
const state = sessions.lookup(String(session.sessionID))
|
|
@@ -106,7 +123,7 @@ function applyContext(event: unknown, sessions: SessionStore): void {
|
|
|
106
123
|
return text("[image unavailable]")
|
|
107
124
|
}
|
|
108
125
|
|
|
109
|
-
const system = request.system.map(text)
|
|
126
|
+
const system = systemFor(request.system, appPolicy({ apps }, state.app)).map(text)
|
|
110
127
|
// The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
|
|
111
128
|
const choice = request.toolChoice
|
|
112
129
|
if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
|
|
@@ -144,11 +161,11 @@ function applyContext(event: unknown, sessions: SessionStore): void {
|
|
|
144
161
|
*/
|
|
145
162
|
export function installSessionHooks(
|
|
146
163
|
ctx: HostContext,
|
|
147
|
-
options: { readonly maxTools: number; readonly sessions: SessionStore; readonly active: () => boolean },
|
|
164
|
+
options: { readonly maxTools: number; readonly sessions: SessionStore; readonly active: () => boolean; readonly apps?: PlugboardOptions["apps"] },
|
|
148
165
|
) {
|
|
149
166
|
return Effect.gen(function* () {
|
|
150
167
|
// Swap in the caller's system/messages/tools for plugboard request sessions only.
|
|
151
|
-
yield* ctx.session.hook("context", (event) => Effect.sync(() => (options.active() ? applyContext(event, options.sessions) : undefined)))
|
|
168
|
+
yield* ctx.session.hook("context", (event) => Effect.sync(() => (options.active() ? applyContext(event, options.sessions, options.apps ?? {}) : undefined)))
|
|
152
169
|
|
|
153
170
|
yield* ctx.agent.transform((editor) => {
|
|
154
171
|
if (!options.active()) return
|