opencode-plugboard 0.19.0 → 0.21.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 +35 -31
- package/dist/tui.js +334 -0
- package/index.ts +35 -23
- package/package.json +10 -4
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/response.ts +5 -3
- package/src/anthropic/route.ts +17 -10
- package/src/cache.ts +26 -5
- package/src/completions/request.ts +15 -4
- package/src/completions/response.ts +52 -10
- package/src/config.ts +155 -13
- package/src/contract.ts +9 -1
- package/src/discovery.ts +35 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/request.ts +3 -2
- package/src/gemini/response.ts +20 -20
- package/src/gemini/route.ts +10 -4
- package/src/http.ts +65 -7
- package/src/keys.ts +172 -0
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +29 -7
- package/src/openai/request.ts +6 -4
- package/src/openai/response.ts +101 -21
- package/src/openai/route.ts +44 -17
- package/src/policy.ts +16 -0
- package/src/responses/request.ts +3 -1
- package/src/responses/response.ts +13 -17
- package/src/responses/route.ts +7 -3
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +60 -7
- package/src/session/caller.ts +96 -11
- package/src/session/context.ts +18 -4
- package/src/session/events.ts +116 -12
- package/src/session/history.ts +8 -2
- package/src/session/host.ts +40 -0
- package/src/session/link.ts +33 -0
- package/src/session/seeds.ts +10 -0
- package/src/session/state.ts +2 -0
- package/src/usage.ts +61 -2
- package/src/user.ts +55 -0
- package/tui.ts +5 -4
package/src/http.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
8
8
|
import { raceAbort } from "./abort.js"
|
|
9
|
-
import { isLoopbackHost, type PlugboardOptions } from "./config.js"
|
|
9
|
+
import { BODY_LIMIT_DEFAULTS, type BodyLimits, isLoopbackHost, type PlugboardOptions } from "./config.js"
|
|
10
10
|
import type { PlugError, PlugEvent } from "./contract.js"
|
|
11
11
|
|
|
12
12
|
const MAX_BODY_BYTES = 32 * 1024 * 1024
|
|
@@ -19,16 +19,44 @@ const DEADLINE_GRACE_MS = 2_000
|
|
|
19
19
|
/** An SSE stream idle for this long gets a comment line, so a client with a header or idle timeout keeps waiting for a model that is still reasoning. */
|
|
20
20
|
const KEEPALIVE_MS = 15_000
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* A request body that cannot be accepted: too large, not JSON, not complete in time (408), or refused
|
|
24
|
+
* because the process has no room for it (503). `status` is the HTTP status to answer with; `code` and
|
|
25
|
+
* `retryAfterSeconds` go to the wire (`bodyPlugError`).
|
|
26
|
+
*/
|
|
23
27
|
export class BodyError extends Error {
|
|
24
28
|
constructor(
|
|
25
29
|
readonly status: number,
|
|
26
30
|
message: string,
|
|
31
|
+
readonly code?: string,
|
|
32
|
+
readonly retryAfterSeconds?: number,
|
|
27
33
|
) {
|
|
28
34
|
super(message)
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/** The `PlugError` a refused body answers with: `server_busy` (503) is a server kind, a slow body (408) a timeout. */
|
|
39
|
+
export function bodyPlugError(error: BodyError): PlugError {
|
|
40
|
+
const kind: PlugError["kind"] = error.status === 503 ? "server" : error.status === 408 ? "timeout" : "invalid_request"
|
|
41
|
+
return {
|
|
42
|
+
status: error.status,
|
|
43
|
+
kind,
|
|
44
|
+
message: error.message,
|
|
45
|
+
...(error.code === undefined ? {} : { code: error.code }),
|
|
46
|
+
...(error.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: error.retryAfterSeconds }),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Process-wide bytes of request bodies being read. On `globalThis` under a versioned key (I6), so every plugin generation shares one budget. */
|
|
51
|
+
interface BodyBudget {
|
|
52
|
+
inFlight: number
|
|
53
|
+
}
|
|
54
|
+
const BODY_BUDGET = Symbol.for("opencode-plugboard.body-budget/1")
|
|
55
|
+
const bodyBudget = (): BodyBudget => {
|
|
56
|
+
const slot = globalThis as Record<symbol, BodyBudget | undefined>
|
|
57
|
+
return (slot[BODY_BUDGET] ??= { inFlight: 0 })
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
/** Why a request's work was abandoned: the client went away, or the deadline passed. It is the abort reason of `RequestScope.signal`. */
|
|
33
61
|
export class RequestAborted extends Error {
|
|
34
62
|
constructor(readonly kind: "disconnected" | "timeout") {
|
|
@@ -105,21 +133,40 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
/**
|
|
108
|
-
* Reads the body as JSON (an empty body is `{}`). Rejects with `BodyError` (413, 400), the
|
|
109
|
-
* reason of `signal` or the stream's error. On rejection the rest of the body is discarded, not
|
|
136
|
+
* Reads the body as JSON (an empty body is `{}`). Rejects with `BodyError` (413, 400, 408 or 503), the
|
|
137
|
+
* abort reason of `signal` or the stream's error. On rejection the rest of the body is discarded, not
|
|
110
138
|
* buffered, so the response can still be delivered (see `finishRequest`).
|
|
139
|
+
*
|
|
140
|
+
* Bounded before it is buffered (#156, invariant I28). The bytes held by every body being read in the
|
|
141
|
+
* process stay within `limits.maxBytesInFlight`: a declared Content-Length is charged before the first
|
|
142
|
+
* byte is read, a body without one as it arrives, and a body that would go over is refused with 503
|
|
143
|
+
* `server_busy` (Retry-After 1). A body not complete within `limits.readTimeoutMs` is refused with 408.
|
|
111
144
|
*/
|
|
112
|
-
export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Promise<unknown> {
|
|
145
|
+
export function readJsonBody(request: IncomingMessage, signal: AbortSignal, limits: BodyLimits = BODY_LIMIT_DEFAULTS): Promise<unknown> {
|
|
113
146
|
return new Promise((resolve, reject) => {
|
|
114
|
-
const declared = Number(request.headers["content-length"])
|
|
115
|
-
if (
|
|
147
|
+
const declared = Math.max(0, Number(request.headers["content-length"]) || 0)
|
|
148
|
+
if (declared > MAX_BODY_BYTES) {
|
|
116
149
|
request.resume()
|
|
117
150
|
return reject(new BodyError(413, "Request body too large"))
|
|
118
151
|
}
|
|
119
152
|
if (signal.aborted) return reject(signal.reason)
|
|
153
|
+
const budget = bodyBudget()
|
|
120
154
|
const chunks: Buffer[] = []
|
|
121
155
|
let size = 0
|
|
156
|
+
// Bytes this read holds in the budget. Released on every exit: complete, refused or abandoned.
|
|
157
|
+
let held = 0
|
|
158
|
+
const charge = (bytes: number): boolean => {
|
|
159
|
+
if (budget.inFlight + bytes > limits.maxBytesInFlight) return false
|
|
160
|
+
budget.inFlight += bytes
|
|
161
|
+
held += bytes
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
const busy = () => new BodyError(503, "Too many request bodies are being read; retry shortly", "server_busy", 1)
|
|
165
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
122
166
|
const cleanup = () => {
|
|
167
|
+
clearTimeout(timer)
|
|
168
|
+
budget.inFlight -= held
|
|
169
|
+
held = 0
|
|
123
170
|
request.off("data", onData)
|
|
124
171
|
request.off("error", onError)
|
|
125
172
|
request.off("end", onEnd)
|
|
@@ -134,6 +181,7 @@ export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Pro
|
|
|
134
181
|
const onData = (chunk: Buffer) => {
|
|
135
182
|
size += chunk.length
|
|
136
183
|
if (size > MAX_BODY_BYTES) return stop(new BodyError(413, "Request body too large"))
|
|
184
|
+
if (size > held && !charge(size - held)) return stop(busy())
|
|
137
185
|
chunks.push(chunk)
|
|
138
186
|
}
|
|
139
187
|
const onError = (error: Error) => stop(error)
|
|
@@ -146,6 +194,16 @@ export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Pro
|
|
|
146
194
|
reject(new BodyError(400, "Request body is not valid JSON"))
|
|
147
195
|
}
|
|
148
196
|
}
|
|
197
|
+
// The declared length is charged before the first byte is read, so a refusal costs no buffering.
|
|
198
|
+
if (declared > 0 && !charge(declared)) {
|
|
199
|
+
request.resume()
|
|
200
|
+
return reject(busy())
|
|
201
|
+
}
|
|
202
|
+
timer = setTimeout(
|
|
203
|
+
() => stop(new BodyError(408, `Request body was not received within ${limits.readTimeoutMs} ms (option limits.bodyReadTimeoutMs)`, "body_read_timeout")),
|
|
204
|
+
limits.readTimeoutMs,
|
|
205
|
+
)
|
|
206
|
+
timer.unref?.()
|
|
149
207
|
request.on("data", onData)
|
|
150
208
|
request.on("error", onError)
|
|
151
209
|
request.on("end", onEnd)
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime app keys (#55): keys created through `/v1/admin/keys` (src/admin/route.ts), kept beside
|
|
3
|
+
* the keys of the config.
|
|
4
|
+
*
|
|
5
|
+
* Each runtime key is one record in the plugin's host storage (`ctx.storage`, the table the history
|
|
6
|
+
* index and usage use) under `keys/<id>`, as JSON `{ app, digest, createdAt }`. The digest is the
|
|
7
|
+
* SHA-256 of the key, so the key value is never stored: it is returned once, by the create call.
|
|
8
|
+
* The id is the first 8 hex chars of that digest, the name `DELETE` takes; it is safe to show.
|
|
9
|
+
*
|
|
10
|
+
* The records are read lazily, from request handling, by the first request that authenticates (never
|
|
11
|
+
* from plugin setup, I3), and cached on the process runtime (`KeyState`), so the cache outlives a
|
|
12
|
+
* plugin generation. A failed read leaves the cache unloaded: runtime keys then fail closed (401) and
|
|
13
|
+
* the next request reads again. Create and revoke write storage first and the cache second, so a
|
|
14
|
+
* revoked key stops authenticating on the next request.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
18
|
+
import { APP_NAME_PATTERN } from "./config.js"
|
|
19
|
+
import { errorMessage, logError } from "./log.js"
|
|
20
|
+
import type { StoragePort } from "./session/host.js"
|
|
21
|
+
|
|
22
|
+
const PREFIX = "keys/"
|
|
23
|
+
const SCAN_PAGE = 200
|
|
24
|
+
/** A collision of two 32-bit ids is rare; a fresh key is drawn instead of sharing an id. */
|
|
25
|
+
const ID_ATTEMPTS = 5
|
|
26
|
+
|
|
27
|
+
export interface RuntimeKey {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly app: string
|
|
30
|
+
readonly digest: Buffer
|
|
31
|
+
readonly createdAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The live cache of runtime keys on the process runtime (src/runtime.ts). */
|
|
35
|
+
export interface KeyState {
|
|
36
|
+
loaded: boolean
|
|
37
|
+
readonly keys: Map<string, RuntimeKey>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const createKeyState = (): KeyState => ({ loaded: false, keys: new Map() })
|
|
41
|
+
|
|
42
|
+
/** SHA-256 of a key: what is stored and compared. */
|
|
43
|
+
export const keyDigest = (key: string): Buffer => createHash("sha256").update(key).digest()
|
|
44
|
+
|
|
45
|
+
/** The id of a key: the first 8 hex chars of its digest. */
|
|
46
|
+
export const keyIdOf = (digest: Buffer): string => digest.subarray(0, 4).toString("hex")
|
|
47
|
+
|
|
48
|
+
export interface KeyListing {
|
|
49
|
+
readonly id: string
|
|
50
|
+
readonly app: string
|
|
51
|
+
readonly source: "config" | "runtime"
|
|
52
|
+
readonly createdAt?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreatedKey {
|
|
56
|
+
readonly id: string
|
|
57
|
+
readonly app: string
|
|
58
|
+
/** The key value. Returned by `create` only; it is never stored or listed. */
|
|
59
|
+
readonly key: string
|
|
60
|
+
readonly createdAt: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface KeyStore {
|
|
64
|
+
/** The digests of the loaded runtime keys, as `[app, digest]`. Reads storage once; a failed read answers with what is loaded. */
|
|
65
|
+
digests(): Promise<ReadonlyArray<readonly [string, Buffer]>>
|
|
66
|
+
/** Config keys and runtime keys, without key values. */
|
|
67
|
+
list(config: Readonly<Record<string, string>>): Promise<ReadonlyArray<KeyListing>>
|
|
68
|
+
/** A new random key for `app`. The caller checks the app name and that the config has no key for it. */
|
|
69
|
+
create(app: string, config: Readonly<Record<string, string>>): Promise<CreatedKey>
|
|
70
|
+
/** `config` when the id names a config key (not revocable here), `not_found` when no runtime key has it. */
|
|
71
|
+
revoke(id: string, config: Readonly<Record<string, string>>): Promise<"revoked" | "config" | "not_found">
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface KeyStoreDeps {
|
|
75
|
+
readonly state: KeyState
|
|
76
|
+
readonly storage: StoragePort
|
|
77
|
+
/** Epoch ms; tests inject a clock. */
|
|
78
|
+
readonly now?: () => number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const configIds = (config: Readonly<Record<string, string>>): Set<string> => new Set(Object.values(config).map((key) => keyIdOf(keyDigest(key))))
|
|
82
|
+
|
|
83
|
+
/** A stored record, or undefined when the value is not one (logged; the key then never authenticates). */
|
|
84
|
+
function parseRecord(id: string, raw: string | undefined): RuntimeKey | undefined {
|
|
85
|
+
try {
|
|
86
|
+
const value = JSON.parse(raw ?? "") as { app?: unknown; digest?: unknown; createdAt?: unknown }
|
|
87
|
+
const digest = typeof value.digest === "string" && /^[0-9a-f]{64}$/.test(value.digest) ? Buffer.from(value.digest, "hex") : undefined
|
|
88
|
+
if (typeof value.app !== "string" || !APP_NAME_PATTERN.test(value.app) || digest === undefined || typeof value.createdAt !== "string") throw new Error("shape")
|
|
89
|
+
if (keyIdOf(digest) !== id) throw new Error("id")
|
|
90
|
+
return { id, app: value.app, digest, createdAt: value.createdAt }
|
|
91
|
+
} catch {
|
|
92
|
+
logError("keys.unreadable", { id })
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createKeyStore(deps: KeyStoreDeps): KeyStore {
|
|
98
|
+
const { state, storage } = deps
|
|
99
|
+
const now = deps.now ?? Date.now
|
|
100
|
+
let loading: Promise<void> | undefined
|
|
101
|
+
|
|
102
|
+
/** Reads every stored key once. Concurrent callers share one read. */
|
|
103
|
+
const ensure = (): Promise<void> => {
|
|
104
|
+
if (state.loaded) return Promise.resolve()
|
|
105
|
+
loading ??= (async () => {
|
|
106
|
+
const found = new Map<string, RuntimeKey>()
|
|
107
|
+
let after: string | undefined
|
|
108
|
+
for (;;) {
|
|
109
|
+
const page = await storage.scan({ prefix: PREFIX, ...(after === undefined ? {} : { after }), limit: SCAN_PAGE })
|
|
110
|
+
for (const key of page.keys) {
|
|
111
|
+
const record = parseRecord(key.slice(PREFIX.length), await storage.get(key))
|
|
112
|
+
if (record) found.set(record.id, record)
|
|
113
|
+
}
|
|
114
|
+
if (page.next === undefined) break
|
|
115
|
+
after = page.next
|
|
116
|
+
}
|
|
117
|
+
state.keys.clear()
|
|
118
|
+
for (const [id, record] of found) state.keys.set(id, record)
|
|
119
|
+
state.loaded = true
|
|
120
|
+
})().finally(() => {
|
|
121
|
+
loading = undefined
|
|
122
|
+
})
|
|
123
|
+
return loading
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
async digests() {
|
|
128
|
+
try {
|
|
129
|
+
await ensure()
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logError("keys.load_failed", { error: errorMessage(error) })
|
|
132
|
+
}
|
|
133
|
+
return [...state.keys.values()].map((record) => [record.app, record.digest] as const)
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async list(config) {
|
|
137
|
+
await ensure()
|
|
138
|
+
const runtime = [...state.keys.values()].sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
|
|
139
|
+
return [
|
|
140
|
+
...Object.entries(config).map(([app, key]) => ({ id: keyIdOf(keyDigest(key)), app, source: "config" as const })),
|
|
141
|
+
...runtime.map((record) => ({ id: record.id, app: record.app, source: "runtime" as const, createdAt: record.createdAt })),
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async create(app, config) {
|
|
146
|
+
await ensure()
|
|
147
|
+
const taken = configIds(config)
|
|
148
|
+
for (let attempt = 0; attempt < ID_ATTEMPTS; attempt++) {
|
|
149
|
+
const key = randomBytes(32).toString("base64url")
|
|
150
|
+
const digest = keyDigest(key)
|
|
151
|
+
const id = keyIdOf(digest)
|
|
152
|
+
if (taken.has(id) || state.keys.has(id)) continue
|
|
153
|
+
const createdAt = new Date(now()).toISOString()
|
|
154
|
+
// Storage first: a failed write creates no key, and the value is never returned for a key that does not exist.
|
|
155
|
+
await storage.put(PREFIX + id, JSON.stringify({ app, digest: digest.toString("hex"), createdAt }))
|
|
156
|
+
state.keys.set(id, { id, app, digest, createdAt })
|
|
157
|
+
return { id, app, key, createdAt }
|
|
158
|
+
}
|
|
159
|
+
throw new Error("could not draw an unused key id")
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async revoke(id, config) {
|
|
163
|
+
await ensure()
|
|
164
|
+
if (configIds(config).has(id)) return "config"
|
|
165
|
+
if (!state.keys.has(id)) return "not_found"
|
|
166
|
+
// Storage first: if the delete fails the key keeps working and the caller can retry.
|
|
167
|
+
await storage.drop(PREFIX + id)
|
|
168
|
+
state.keys.delete(id)
|
|
169
|
+
return "revoked"
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
}
|
package/src/models.ts
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import { appPolicy, type AppPolicy, type PlugboardOptions } from "./config.js"
|
|
4
4
|
import type { CatalogModel, ModelRef, PlugError } from "./contract.js"
|
|
5
5
|
|
|
6
|
+
/** Alias target prefix that binds a skill: `skill:<id>@provider/model[#variant]` (see `parseAliasTarget`). */
|
|
7
|
+
export const SKILL_PREFIX = "skill:"
|
|
8
|
+
/** Listed id prefix of an agent (option `agents`): `agent/<name>`. Aliases cannot contain `/`, so the two never collide. */
|
|
9
|
+
export const AGENT_PREFIX = "agent/"
|
|
10
|
+
const SKILL_TARGET = /^skill:([^@\s#*/]+)@([^@/#*\s]+\/[^@#*\s]+(#[^#*\s]+)?)$/
|
|
11
|
+
|
|
6
12
|
/** `provider/model[#variant]`. The model id may contain slashes (`openrouter/vendor/model`). */
|
|
7
13
|
export function parseModelRef(s: string): ModelRef | undefined {
|
|
8
14
|
if (typeof s !== "string") return undefined
|
|
@@ -58,6 +64,42 @@ export function allowedBy(models: ReadonlyArray<string>, ref: ModelRef): boolean
|
|
|
58
64
|
|
|
59
65
|
const allowed = (options: PlugboardOptions, ref: ModelRef): boolean => allowedBy(options.models, ref)
|
|
60
66
|
|
|
67
|
+
/** An alias target as written in `aliases`: `provider/model[#variant]`, or `skill:<id>@` in front of one. */
|
|
68
|
+
export interface AliasTarget {
|
|
69
|
+
readonly ref: ModelRef
|
|
70
|
+
/** The skill the alias binds, for a `skill:` target. */
|
|
71
|
+
readonly skill?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The model (and skill) an alias target names, or undefined when it is neither form. */
|
|
75
|
+
export function parseAliasTarget(target: string): AliasTarget | undefined {
|
|
76
|
+
if (!target.startsWith(SKILL_PREFIX)) {
|
|
77
|
+
const ref = parseModelRef(target)
|
|
78
|
+
return ref === undefined ? undefined : { ref }
|
|
79
|
+
}
|
|
80
|
+
const match = SKILL_TARGET.exec(target)
|
|
81
|
+
const ref = match ? parseModelRef(match[2]!) : undefined
|
|
82
|
+
return match && ref ? { ref, skill: match[1]! } : undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a request name stands for, beyond a plain model: a skill alias (its skill id) or an agent (its name
|
|
87
|
+
* in option `agents`). Undefined for plain model ids and plain aliases. The caller reads it to add the
|
|
88
|
+
* skill's content or the agent's system prompt to the session.
|
|
89
|
+
*/
|
|
90
|
+
export type Persona = { readonly kind: "skill"; readonly skill: string } | { readonly kind: "agent"; readonly agent: string }
|
|
91
|
+
|
|
92
|
+
export function personaOf(options: Pick<PlugboardOptions, "aliases" | "agents">, name: string): Persona | undefined {
|
|
93
|
+
const target = own(options.aliases, name)
|
|
94
|
+
if (target !== undefined) {
|
|
95
|
+
const skill = parseAliasTarget(target)?.skill
|
|
96
|
+
return skill === undefined ? undefined : { kind: "skill", skill }
|
|
97
|
+
}
|
|
98
|
+
if (!name.startsWith(AGENT_PREFIX)) return undefined
|
|
99
|
+
const agent = name.slice(AGENT_PREFIX.length)
|
|
100
|
+
return Object.hasOwn(options.agents, agent) ? { kind: "agent", agent } : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
61
103
|
/**
|
|
62
104
|
* Per-app narrowing (`apps.<name>.models`), applied on top of the global allowlist, never instead of it.
|
|
63
105
|
* `name` is what the caller sent (an alias or a model id). An entry that is an alias name admits that
|
|
@@ -108,7 +150,15 @@ export function resolveModel(
|
|
|
108
150
|
param: "model",
|
|
109
151
|
}
|
|
110
152
|
// Aliases keep priority over slugs; a slug and a canonical id both resolve, whatever `modelIds` lists.
|
|
111
|
-
|
|
153
|
+
// An agent runs on the model its `agents` entry names (the allowlist still applies below).
|
|
154
|
+
const target = own(options.aliases, model)
|
|
155
|
+
const persona = personaOf(options, model)
|
|
156
|
+
let ref =
|
|
157
|
+
target !== undefined
|
|
158
|
+
? parseAliasTarget(target)?.ref
|
|
159
|
+
: persona?.kind === "agent"
|
|
160
|
+
? parseModelRef(options.agents[persona.agent]!)
|
|
161
|
+
: parseModelRef(fromSlug(model) ?? model)
|
|
112
162
|
if (!ref) return notAvailable
|
|
113
163
|
if (!ref.variant && reasoningEffort && find(catalog, { ...ref, variant: reasoningEffort }))
|
|
114
164
|
ref = { ...ref, variant: reasoningEffort }
|
|
@@ -120,6 +170,10 @@ export interface ListedModel {
|
|
|
120
170
|
readonly id: string
|
|
121
171
|
readonly ref: ModelRef
|
|
122
172
|
readonly model: CatalogModel
|
|
173
|
+
/** The skill an alias binds (`skill:` target), listed as `plugboard.skill`. */
|
|
174
|
+
readonly skill?: string
|
|
175
|
+
/** The agent an `agent/<name>` id runs, listed as `plugboard.agent`. */
|
|
176
|
+
readonly agent?: string
|
|
123
177
|
}
|
|
124
178
|
|
|
125
179
|
/**
|
|
@@ -146,9 +200,16 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
|
|
|
146
200
|
}
|
|
147
201
|
}
|
|
148
202
|
for (const [alias, target] of Object.entries(options.aliases)) {
|
|
149
|
-
const
|
|
203
|
+
const parsed = parseAliasTarget(target)
|
|
204
|
+
const ref = parsed?.ref
|
|
150
205
|
const model = ref && allowed(options, ref) && inAppScope(policy, alias, ref) ? find(catalog, ref) : undefined
|
|
151
|
-
if (ref && model) data.push(entry(alias, ref, model))
|
|
206
|
+
if (ref && model) data.push({ ...entry(alias, ref, model), ...(parsed.skill === undefined ? {} : { skill: parsed.skill }) })
|
|
207
|
+
}
|
|
208
|
+
for (const [name, target] of Object.entries(options.agents)) {
|
|
209
|
+
const id = `${AGENT_PREFIX}${name}`
|
|
210
|
+
const ref = parseModelRef(target)
|
|
211
|
+
const model = ref && allowed(options, ref) && inAppScope(policy, id, ref) ? find(catalog, ref) : undefined
|
|
212
|
+
if (ref && model) data.push({ ...entry(id, ref, model), agent: name })
|
|
152
213
|
}
|
|
153
214
|
return data
|
|
154
215
|
}
|
package/src/openai/fanout.ts
CHANGED
|
@@ -24,10 +24,21 @@ 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
26
|
import { reportCache } from "../cache.js"
|
|
27
|
-
import { reportPolicy } from "../policy.js"
|
|
27
|
+
import { reportPolicy, reportServerTools } from "../policy.js"
|
|
28
28
|
import { reportUsage } from "../usage-header.js"
|
|
29
|
+
import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
|
|
29
30
|
import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
30
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
STRUCTURED_FALLBACK_HEADER,
|
|
33
|
+
chatUsageChunk,
|
|
34
|
+
collectCompletion,
|
|
35
|
+
encodeError,
|
|
36
|
+
streamCompletion,
|
|
37
|
+
type CollectedChoice,
|
|
38
|
+
type OpenAiExchange,
|
|
39
|
+
type StreamChoice,
|
|
40
|
+
type StreamResult,
|
|
41
|
+
} from "./response.js"
|
|
31
42
|
import type { RouteHost } from "./route.js"
|
|
32
43
|
|
|
33
44
|
const SESSION_HEADER = "x-plugboard-session"
|
|
@@ -39,7 +50,7 @@ export interface ChoiceCodec {
|
|
|
39
50
|
/** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
|
|
40
51
|
readonly idPrefix: string
|
|
41
52
|
/** One choice as a complete JSON body with `choices[0]` and `usage`. */
|
|
42
|
-
collect(events: AsyncIterable<PlugEvent>, index: number): Promise<
|
|
53
|
+
collect(events: AsyncIterable<PlugEvent>, index: number): Promise<CollectedChoice>
|
|
43
54
|
/** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
|
|
44
55
|
streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
|
|
45
56
|
/** The final chunk with the summed usage (`include_usage`). */
|
|
@@ -155,7 +166,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
|
|
|
155
166
|
return out
|
|
156
167
|
}
|
|
157
168
|
|
|
158
|
-
export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
|
|
169
|
+
export async function serveChoices(host: Pick<RouteHost, "caller" | "options">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
|
|
159
170
|
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
160
171
|
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
161
172
|
if (scope.signal.aborted) throw scope.signal.reason
|
|
@@ -165,6 +176,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
165
176
|
const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
|
|
166
177
|
/** History record ids as the callers report them, in choice order, for the response header (comma separated). */
|
|
167
178
|
const records: Array<string | undefined> = []
|
|
179
|
+
/** The session page links of the same records, in choice order (option `historyUrl`, src/session/link.ts). */
|
|
180
|
+
const links: Array<string | undefined> = []
|
|
168
181
|
// Headers: the first choice to be served wins. Body model: only a single call echoes the served model.
|
|
169
182
|
const served = reportServed(response, info, (s) => {
|
|
170
183
|
if (calls.length === 1) codec.setModel(s.model)
|
|
@@ -172,6 +185,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
172
185
|
const policy = reportPolicy(response)
|
|
173
186
|
// One header for all choices: each answered call adds its tokens and cost (`x-plugboard-usage`, #52).
|
|
174
187
|
const usageHeader = reportUsage(response, codec.stream)
|
|
188
|
+
// One header for all choices too: the server tool calls of every choice add up (`x-plugboard-server-tools`).
|
|
189
|
+
const serverToolsHeader = reportServerTools(response, codec.stream)
|
|
175
190
|
// Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
|
|
176
191
|
const cache = reportCache(response)
|
|
177
192
|
const open = (index: number) =>
|
|
@@ -179,14 +194,20 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
179
194
|
host.caller.stream(calls[index]!, {
|
|
180
195
|
app,
|
|
181
196
|
signal: gscope.signal,
|
|
182
|
-
onRecord: (recordID) => {
|
|
197
|
+
onRecord: (recordID, directory) => {
|
|
183
198
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
184
199
|
records[index] = recordID
|
|
185
200
|
response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
|
|
201
|
+
if (host.options.historyUrl !== undefined) {
|
|
202
|
+
links[index] = sessionUrl(host.options.historyUrl, recordID, directory)
|
|
203
|
+
info.sessionUrl = links.filter((link) => link !== undefined).join(",")
|
|
204
|
+
response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
|
|
205
|
+
}
|
|
186
206
|
},
|
|
187
207
|
onServed: served,
|
|
188
208
|
onPolicy: policy,
|
|
189
209
|
onUsage: usageHeader,
|
|
210
|
+
onServerTools: serverToolsHeader,
|
|
190
211
|
onCache: cache,
|
|
191
212
|
noCache: calls.length > 1,
|
|
192
213
|
}),
|
|
@@ -194,8 +215,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
194
215
|
)
|
|
195
216
|
|
|
196
217
|
if (!codec.stream) {
|
|
197
|
-
const results = await new Promise<
|
|
198
|
-
const done:
|
|
218
|
+
const results = await new Promise<CollectedChoice[]>((resolve, reject) => {
|
|
219
|
+
const done: CollectedChoice[] = []
|
|
199
220
|
let left = calls.length
|
|
200
221
|
calls.forEach((_, index) => {
|
|
201
222
|
codec.collect(open(index), index).then(
|
|
@@ -215,6 +236,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
215
236
|
})
|
|
216
237
|
})
|
|
217
238
|
const first = results[0]!
|
|
239
|
+
if (results.some((result) => result.structuredFallback)) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
|
|
218
240
|
return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
|
|
219
241
|
}
|
|
220
242
|
|
package/src/openai/request.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* the request omits `ref`.
|
|
7
7
|
*/
|
|
8
8
|
import type { PlugboardOptions } from "../config.js"
|
|
9
|
+
import { bodyUser, userField } from "../user.js"
|
|
9
10
|
import {
|
|
10
11
|
isPlugError,
|
|
11
12
|
type PlugError,
|
|
@@ -31,7 +32,7 @@ export interface ParsedChat {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/** Name of the forced tool that carries structured output. */
|
|
34
|
-
const RESPOND_TOOL = "respond"
|
|
35
|
+
export const RESPOND_TOOL = "respond"
|
|
35
36
|
|
|
36
37
|
/** The most stop sequences OpenAI accepts. */
|
|
37
38
|
const MAX_STOP = 4
|
|
@@ -195,14 +196,14 @@ const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError =
|
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
/** System instruction for `json_object`: no schema, so the caller's prompt describes the keys (OpenAI semantics). */
|
|
198
|
-
const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
|
|
199
|
+
export const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
|
|
199
200
|
|
|
200
201
|
/**
|
|
201
202
|
* `json_schema` -> the `respond` tool (`structured`; forced unless the caller also sends tools).
|
|
202
203
|
* `json_object` -> a system instruction and plain text content (`instruction`): there is no schema
|
|
203
204
|
* to force a tool with.
|
|
204
205
|
*/
|
|
205
|
-
const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
|
|
206
|
+
export const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
|
|
206
207
|
if (raw == null) return {}
|
|
207
208
|
if (!isRecord(raw)) return bad("response_format must be an object", "response_format")
|
|
208
209
|
if (raw.type === "text") return {}
|
|
@@ -322,6 +323,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
|
|
|
322
323
|
|
|
323
324
|
const forced: PlugToolChoice | undefined = respond ? (withCallerTools ? "required" : { type: "tool", name: RESPOND_TOOL }) : toolChoice
|
|
324
325
|
const request: Omit<PlugRequest, "ref"> = {
|
|
326
|
+
...userField(bodyUser(body)),
|
|
325
327
|
model: body.model,
|
|
326
328
|
system: format.instruction ? [...parsed.system, format.instruction] : parsed.system,
|
|
327
329
|
messages: parsed.messages,
|
|
@@ -337,7 +339,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
|
|
|
337
339
|
model: body.model,
|
|
338
340
|
stream: body.stream === true,
|
|
339
341
|
includeUsage: streamOptions.include_usage === true,
|
|
340
|
-
...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
342
|
+
...(respond ? { structuredTool: RESPOND_TOOL, structuredSchema: respond.parameters, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
341
343
|
...(format.instruction ? { jsonObject: true } : {}),
|
|
342
344
|
...(generation.stop ? { stop: generation.stop } : {}),
|
|
343
345
|
},
|