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 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.12.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.12.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": {
@@ -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 Object.entries(input.keys ?? {})) {
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: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
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
  }
@@ -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 { PlugboardOptions } from "./config.js"
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
- /** The allowlist intersected with the catalog, plus aliases whose target qualifies. Ids are unique except an alias may repeat one. */
82
- export function listAvailableModels(options: PlugboardOptions, catalog: ReadonlyArray<CatalogModel>): ListedModel[] {
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>): ModelRef[] {
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
  }
@@ -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
  )
@@ -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
+ }
@@ -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
  )
@@ -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(request: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
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
@@ -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