opencode-plugboard 0.12.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 +2 -1
- package/package.json +13 -1
- package/src/anthropic/route.ts +2 -0
- package/src/config.ts +97 -4
- package/src/contract.ts +5 -0
- package/src/discovery.ts +2 -2
- package/src/fallback.ts +3 -3
- package/src/gemini/route.ts +3 -1
- package/src/models.ts +29 -7
- package/src/openai/fanout.ts +3 -0
- package/src/openai/route.ts +3 -1
- package/src/policy.ts +17 -0
- package/src/responses/route.ts +2 -0
- 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
|
@@ -54,6 +54,7 @@ export default Plugin.define({
|
|
|
54
54
|
port: createSessionPort(ctx),
|
|
55
55
|
sessions,
|
|
56
56
|
maxTools: options.maxTools,
|
|
57
|
+
apps: options.apps,
|
|
57
58
|
remoteImages: options.remoteImages,
|
|
58
59
|
// The readiness gate the caller awaits before creating a session (fails closed: 503 `backend_unavailable`).
|
|
59
60
|
ensureBackend: (app: string, signal: AbortSignal) => ensureBackend(app, signal, bootLocation),
|
|
@@ -102,7 +103,7 @@ export default Plugin.define({
|
|
|
102
103
|
// ---- the context hook, the agent deny and the seed tools. Ownership of the Location is only
|
|
103
104
|
// known after an asynchronous check (`ensureBackend`), so all three are no-ops until
|
|
104
105
|
// `registration.confirmed()`: in real projects nothing is ever added or rewritten.
|
|
105
|
-
yield* installSessionHooks(ctx, { maxTools: options.maxTools, sessions, active: registration.confirmed })
|
|
106
|
+
yield* installSessionHooks(ctx, { maxTools: options.maxTools, apps: options.apps, sessions, active: registration.confirmed })
|
|
106
107
|
registration.ready()
|
|
107
108
|
}),
|
|
108
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,15 +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",
|
|
33
37
|
"@commitlint/cli": "21.2.3",
|
|
34
38
|
"@commitlint/config-conventional": "21.2.3",
|
|
39
|
+
"@google/genai": "2.28.0",
|
|
35
40
|
"@opencode/plugin": "2.0.26",
|
|
36
41
|
"@types/bun": "1.4.2",
|
|
37
42
|
"@types/escape-html": "^1.0.4",
|
|
43
|
+
"@types/lcov-parse": "1.0.2",
|
|
38
44
|
"@types/node": "^26.6.2",
|
|
45
|
+
"ai": "7.0.137",
|
|
39
46
|
"conventional-commits-parser": "7.1.3",
|
|
40
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",
|
|
41
52
|
"lefthook": "2.2.1",
|
|
53
|
+
"openai": "7.32.0",
|
|
42
54
|
"typescript": "^5.9.3"
|
|
43
55
|
},
|
|
44
56
|
"peerDependencies": {
|
package/src/anthropic/route.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "../http.js"
|
|
28
28
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
29
29
|
import { resolveModel } from "../models.js"
|
|
30
|
+
import { reportPolicy } from "../policy.js"
|
|
30
31
|
import { dropRejected } from "../openai/sampling.js"
|
|
31
32
|
import type { RouteHost } from "../openai/route.js"
|
|
32
33
|
import { parseMessagesRequest } from "./request.js"
|
|
@@ -123,6 +124,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
123
124
|
response.setHeader(SESSION_HEADER, recordID)
|
|
124
125
|
},
|
|
125
126
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
127
|
+
onPolicy: reportPolicy(response),
|
|
126
128
|
}),
|
|
127
129
|
scope,
|
|
128
130
|
)
|
package/src/config.ts
CHANGED
|
@@ -9,8 +9,10 @@ import type { HistoryLimits } from "./session/prune.js"
|
|
|
9
9
|
/** Plugin options, after defaults and validation (see `parseOptions`). */
|
|
10
10
|
export interface PlugboardOptions {
|
|
11
11
|
readonly listen: { readonly host: string; readonly port: number }
|
|
12
|
-
/** 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. */
|
|
13
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>>
|
|
14
16
|
/** Allowlist: exact `provider/model`, `provider/model#variant`, or `provider/*` patterns. Empty = nothing allowed. */
|
|
15
17
|
readonly models: ReadonlyArray<string>
|
|
16
18
|
/** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
|
|
@@ -42,6 +44,23 @@ export interface PlugboardOptions {
|
|
|
42
44
|
readonly metrics: boolean
|
|
43
45
|
}
|
|
44
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
|
+
|
|
45
64
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
46
65
|
export const DEFAULT_APP = "default"
|
|
47
66
|
const DEFAULTS = {
|
|
@@ -52,7 +71,7 @@ const DEFAULTS = {
|
|
|
52
71
|
requestTimeoutMs: 600_000,
|
|
53
72
|
} as const
|
|
54
73
|
|
|
55
|
-
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"])
|
|
56
75
|
const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
|
|
57
76
|
/** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
|
|
58
77
|
const MAX_AGE_DAYS = 36_500
|
|
@@ -102,6 +121,75 @@ function parseRemoteImages(value: unknown): RemoteImages {
|
|
|
102
121
|
return { allow: allow as string[] }
|
|
103
122
|
}
|
|
104
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
|
+
|
|
105
193
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
106
194
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
107
195
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -119,8 +207,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
119
207
|
|
|
120
208
|
const keyEntries: Array<[string, string]> = []
|
|
121
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")
|
|
122
211
|
const appOfKey = new Map<string, string>()
|
|
123
|
-
for (const [app, key] of
|
|
212
|
+
for (const [app, key] of mergedKeys(input.keys, input.apps)) {
|
|
124
213
|
if (app === DEFAULT_APP) throw new Error(`plugboard key name \`${DEFAULT_APP}\` is reserved for keyless loopback use`)
|
|
125
214
|
if (!APP_NAME_PATTERN.test(app)) throw new Error(`plugboard key name \`${app}\` must match ${APP_NAME_PATTERN}`)
|
|
126
215
|
if (typeof key !== "string" || key.length === 0)
|
|
@@ -167,6 +256,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
167
256
|
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
168
257
|
const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
|
|
169
258
|
|
|
259
|
+
const maxTools = positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024)
|
|
260
|
+
const apps = parseApps(input.apps, { keys, aliases, maxTools })
|
|
261
|
+
|
|
170
262
|
const remoteImages = parseRemoteImages(input.remoteImages)
|
|
171
263
|
if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
|
|
172
264
|
throw new Error("plugboard option `dropUnsupportedParams` must be a boolean")
|
|
@@ -185,10 +277,11 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
185
277
|
return {
|
|
186
278
|
listen: { host, port: port ?? DEFAULTS.port },
|
|
187
279
|
keys,
|
|
280
|
+
apps,
|
|
188
281
|
models: models as string[],
|
|
189
282
|
aliases,
|
|
190
283
|
fallbacks,
|
|
191
|
-
maxTools
|
|
284
|
+
maxTools,
|
|
192
285
|
maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
|
|
193
286
|
requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
|
|
194
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
|
|
@@ -175,6 +178,8 @@ export interface CallContext {
|
|
|
175
178
|
* reports it.
|
|
176
179
|
*/
|
|
177
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
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
/** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
|
package/src/discovery.ts
CHANGED
|
@@ -136,7 +136,7 @@ export function buildDiscovery({ options, app, catalog }: DiscoveryInput): Disco
|
|
|
136
136
|
const detail = !keyed || app !== undefined
|
|
137
137
|
const auth = keyed ? ({ required: true, scheme: "Bearer" } as const) : { required: false }
|
|
138
138
|
|
|
139
|
-
const listed = detail && catalog ? listAvailableModels(options, catalog) : []
|
|
139
|
+
const listed = detail && catalog ? listAvailableModels(options, catalog, app) : []
|
|
140
140
|
const summary = detail && catalog ? summarize(catalog) : undefined
|
|
141
141
|
const suggested = summary?.providers.map((provider) => `${provider.id}/*`)
|
|
142
142
|
|
|
@@ -277,7 +277,7 @@ export function buildStatus(input: {
|
|
|
277
277
|
servingConfirmed: runtime.servingConfirmed,
|
|
278
278
|
eventPump: { running: runtime.pumpRunning },
|
|
279
279
|
sessionGuards: runtime.guards,
|
|
280
|
-
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" },
|
|
281
281
|
metrics: options.metrics,
|
|
282
282
|
}
|
|
283
283
|
}
|
package/src/fallback.ts
CHANGED
|
@@ -110,7 +110,7 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
|
|
|
110
110
|
if (context.signal.aborted) reason = undefined
|
|
111
111
|
|
|
112
112
|
if (reason !== undefined) {
|
|
113
|
-
rest ??= await resolveRest(deps, request)
|
|
113
|
+
rest ??= await resolveRest(deps, request, context.app)
|
|
114
114
|
const target = rest[attempt]
|
|
115
115
|
if (target) {
|
|
116
116
|
if (!thrown) await close(iterator)
|
|
@@ -145,9 +145,9 @@ function retarget(options: PlugboardOptions, request: PlugRequest, ref: ModelRef
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
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[]> {
|
|
148
|
+
async function resolveRest(deps: FallbackDeps, request: PlugRequest, app: string): Promise<ModelRef[]> {
|
|
149
149
|
try {
|
|
150
|
-
return fallbackRefs(deps.options, request.model, await deps.loadCatalog())
|
|
150
|
+
return fallbackRefs(deps.options, request.model, await deps.loadCatalog(), app)
|
|
151
151
|
} catch {
|
|
152
152
|
return []
|
|
153
153
|
}
|
package/src/gemini/route.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
|
19
19
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
20
20
|
import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
21
21
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
22
|
+
import { reportPolicy } from "../policy.js"
|
|
22
23
|
import type { RouteHost } from "../openai/route.js"
|
|
23
24
|
import { dropRejected } from "../openai/sampling.js"
|
|
24
25
|
import { parseGeminiRequest } from "./request.js"
|
|
@@ -99,7 +100,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
99
100
|
return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent and streamGenerateContent` })
|
|
100
101
|
|
|
101
102
|
if (match.op === "list" || match.op === "get") {
|
|
102
|
-
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
|
|
103
|
+
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), app)
|
|
103
104
|
if (match.op === "list") return sendJson(response, 200, encodeGeminiModelList(listed))
|
|
104
105
|
const item = listed.find((m) => m.id === match.model)
|
|
105
106
|
if (!item) return fail({ status: 404, kind: "not_found", code: "model_not_found", param: "model", message: `Model ${match.model} is not available` })
|
|
@@ -139,6 +140,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
139
140
|
response.setHeader(SESSION_HEADER, recordID)
|
|
140
141
|
},
|
|
141
142
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
143
|
+
onPolicy: reportPolicy(response),
|
|
142
144
|
}),
|
|
143
145
|
scope,
|
|
144
146
|
)
|
package/src/models.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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`). */
|
|
@@ -31,6 +31,22 @@ export function allowedBy(models: ReadonlyArray<string>, ref: ModelRef): boolean
|
|
|
31
31
|
|
|
32
32
|
const allowed = (options: PlugboardOptions, ref: ModelRef): boolean => allowedBy(options.models, ref)
|
|
33
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
|
+
|
|
34
50
|
function find(catalog: ReadonlyArray<CatalogModel>, ref: ModelRef): CatalogModel | undefined {
|
|
35
51
|
const model = catalog.find((m) => m.providerID === ref.providerID && m.id === ref.id)
|
|
36
52
|
return model && (ref.variant === undefined || model.variants.includes(ref.variant)) ? model : undefined
|
|
@@ -78,8 +94,12 @@ export interface ListedModel {
|
|
|
78
94
|
readonly model: CatalogModel
|
|
79
95
|
}
|
|
80
96
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
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)
|
|
83
103
|
const entry = (id: string, ref: ModelRef, model: CatalogModel): ListedModel => ({ id, ref, model })
|
|
84
104
|
const data: ListedModel[] = []
|
|
85
105
|
// The catalog can list a model more than once (and repeat variants); ids must be unique.
|
|
@@ -88,14 +108,14 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
|
|
|
88
108
|
const base = { providerID: model.providerID, id: model.id }
|
|
89
109
|
for (const ref of [base, ...model.variants.map((variant) => ({ ...base, variant }))]) {
|
|
90
110
|
const id = refToString(ref)
|
|
91
|
-
if (seen.has(id) || !allowed(options, ref)) continue
|
|
111
|
+
if (seen.has(id) || !allowed(options, ref) || !inAppScope(policy, id, ref)) continue
|
|
92
112
|
seen.add(id)
|
|
93
113
|
data.push(entry(id, ref, model))
|
|
94
114
|
}
|
|
95
115
|
}
|
|
96
116
|
for (const [alias, target] of Object.entries(options.aliases)) {
|
|
97
117
|
const ref = parseModelRef(target)
|
|
98
|
-
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
|
|
99
119
|
if (ref && model) data.push(entry(alias, ref, model))
|
|
100
120
|
}
|
|
101
121
|
return data
|
|
@@ -104,13 +124,15 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
|
|
|
104
124
|
/**
|
|
105
125
|
* The targets an alias falls back to, in order: the fallbacks that are allowed and enabled on the server.
|
|
106
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).
|
|
107
128
|
*/
|
|
108
|
-
export function fallbackRefs(options: PlugboardOptions, alias: string, catalog: ReadonlyArray<CatalogModel
|
|
129
|
+
export function fallbackRefs(options: PlugboardOptions, alias: string, catalog: ReadonlyArray<CatalogModel>, app?: string): ModelRef[] {
|
|
130
|
+
const policy = app === undefined ? undefined : appPolicy(options, app)
|
|
109
131
|
const targets = Object.hasOwn(options.fallbacks, alias) ? options.fallbacks[alias]! : []
|
|
110
132
|
const refs: ModelRef[] = []
|
|
111
133
|
for (const target of targets) {
|
|
112
134
|
const ref = parseModelRef(target)
|
|
113
|
-
if (ref && allowed(options, ref) && find(catalog, ref)) refs.push(ref)
|
|
135
|
+
if (ref && allowed(options, ref) && inAppScope(policy, alias, ref) && find(catalog, ref)) refs.push(ref)
|
|
114
136
|
}
|
|
115
137
|
return refs
|
|
116
138
|
}
|
package/src/openai/fanout.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import type { ServerResponse } from "node:http"
|
|
24
24
|
import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.js"
|
|
25
25
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
26
|
+
import { reportPolicy } from "../policy.js"
|
|
26
27
|
import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
27
28
|
import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
|
|
28
29
|
import type { RouteHost } from "./route.js"
|
|
@@ -166,6 +167,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
166
167
|
const served = reportServed(response, info, (s) => {
|
|
167
168
|
if (calls.length === 1) codec.setModel(s.model)
|
|
168
169
|
})
|
|
170
|
+
const policy = reportPolicy(response)
|
|
169
171
|
const open = (index: number) =>
|
|
170
172
|
boundEvents(
|
|
171
173
|
host.caller.stream(calls[index]!, {
|
|
@@ -177,6 +179,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
177
179
|
response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
|
|
178
180
|
},
|
|
179
181
|
onServed: served,
|
|
182
|
+
onPolicy: policy,
|
|
180
183
|
}),
|
|
181
184
|
gscope,
|
|
182
185
|
)
|
package/src/openai/route.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/ro
|
|
|
42
42
|
import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
43
43
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
44
44
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
45
|
+
import { reportPolicy } from "../policy.js"
|
|
45
46
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
46
47
|
import { serveCompletions } from "../completions/route.js"
|
|
47
48
|
import { serveResponses } from "../responses/route.js"
|
|
@@ -196,7 +197,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
if (route === "GET /v1/models" || modelId !== undefined) {
|
|
199
|
-
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
|
|
200
|
+
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), auth.app)
|
|
200
201
|
if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
|
|
201
202
|
const item = listed.find((m) => m.id === modelId)
|
|
202
203
|
if (!item) {
|
|
@@ -249,6 +250,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
249
250
|
},
|
|
250
251
|
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
251
252
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
253
|
+
onPolicy: reportPolicy(response),
|
|
252
254
|
}),
|
|
253
255
|
scope,
|
|
254
256
|
)
|
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
|
+
}
|
package/src/responses/route.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
|
13
13
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
14
14
|
import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
15
15
|
import { resolveModel } from "../models.js"
|
|
16
|
+
import { reportPolicy } from "../policy.js"
|
|
16
17
|
import { encodeError } from "../openai/response.js"
|
|
17
18
|
import type { RouteHost } from "../openai/route.js"
|
|
18
19
|
import { parseResponsesRequest } from "./request.js"
|
|
@@ -52,6 +53,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
52
53
|
response.setHeader(SESSION_HEADER, recordID)
|
|
53
54
|
},
|
|
54
55
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
56
|
+
onPolicy: reportPolicy(response),
|
|
55
57
|
}),
|
|
56
58
|
scope,
|
|
57
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
|