opencode-plugboard 0.19.0 → 0.20.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 +2 -2
- package/index.ts +6 -1
- package/package.json +1 -1
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/route.ts +11 -6
- package/src/cache.ts +12 -2
- package/src/completions/request.ts +2 -0
- package/src/config.ts +86 -10
- package/src/contract.ts +3 -1
- package/src/discovery.ts +8 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/route.ts +3 -1
- package/src/keys.ts +172 -0
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +10 -2
- package/src/openai/request.ts +2 -0
- package/src/openai/response.ts +2 -2
- package/src/openai/route.ts +23 -6
- package/src/responses/request.ts +2 -0
- package/src/responses/route.ts +3 -1
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +12 -3
- package/src/session/caller.ts +27 -4
- package/src/session/context.ts +3 -1
- package/src/session/history.ts +8 -2
- package/src/session/host.ts +26 -0
- package/src/session/link.ts +33 -0
- package/src/session/state.ts +2 -0
- package/src/usage.ts +61 -2
- package/src/user.ts +55 -0
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
|
@@ -26,6 +26,7 @@ import { reportServed, type ServedInfo } from "../fallback.js"
|
|
|
26
26
|
import { reportCache } from "../cache.js"
|
|
27
27
|
import { reportPolicy } 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
31
|
import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
|
|
31
32
|
import type { RouteHost } from "./route.js"
|
|
@@ -155,7 +156,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
|
|
|
155
156
|
return out
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
|
|
159
|
+
export async function serveChoices(host: Pick<RouteHost, "caller" | "options">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
|
|
159
160
|
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
160
161
|
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
161
162
|
if (scope.signal.aborted) throw scope.signal.reason
|
|
@@ -165,6 +166,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
165
166
|
const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
|
|
166
167
|
/** History record ids as the callers report them, in choice order, for the response header (comma separated). */
|
|
167
168
|
const records: Array<string | undefined> = []
|
|
169
|
+
/** The session page links of the same records, in choice order (option `historyUrl`, src/session/link.ts). */
|
|
170
|
+
const links: Array<string | undefined> = []
|
|
168
171
|
// Headers: the first choice to be served wins. Body model: only a single call echoes the served model.
|
|
169
172
|
const served = reportServed(response, info, (s) => {
|
|
170
173
|
if (calls.length === 1) codec.setModel(s.model)
|
|
@@ -179,10 +182,15 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
179
182
|
host.caller.stream(calls[index]!, {
|
|
180
183
|
app,
|
|
181
184
|
signal: gscope.signal,
|
|
182
|
-
onRecord: (recordID) => {
|
|
185
|
+
onRecord: (recordID, directory) => {
|
|
183
186
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
184
187
|
records[index] = recordID
|
|
185
188
|
response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
|
|
189
|
+
if (host.options.historyUrl !== undefined) {
|
|
190
|
+
links[index] = sessionUrl(host.options.historyUrl, recordID, directory)
|
|
191
|
+
info.sessionUrl = links.filter((link) => link !== undefined).join(",")
|
|
192
|
+
response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
|
|
193
|
+
}
|
|
186
194
|
},
|
|
187
195
|
onServed: served,
|
|
188
196
|
onPolicy: policy,
|
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,
|
|
@@ -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,
|
package/src/openai/response.ts
CHANGED
|
@@ -111,12 +111,12 @@ const encodePlugboard = (model: ListedModel["model"]) => ({
|
|
|
111
111
|
/** `GET /v1/models` body. */
|
|
112
112
|
export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
|
|
113
113
|
object: "list",
|
|
114
|
-
data: models.map(({ id, ref, model }) => ({
|
|
114
|
+
data: models.map(({ id, ref, model, skill, agent }) => ({
|
|
115
115
|
id,
|
|
116
116
|
object: "model",
|
|
117
117
|
created: 0,
|
|
118
118
|
owned_by: ref.providerID,
|
|
119
|
-
plugboard: encodePlugboard(model),
|
|
119
|
+
plugboard: { ...encodePlugboard(model), ...(skill === undefined ? {} : { skill }), ...(agent === undefined ? {} : { agent }) },
|
|
120
120
|
})),
|
|
121
121
|
})
|
|
122
122
|
|
package/src/openai/route.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
24
|
+
import { serveAdmin } from "../admin/route.js"
|
|
24
25
|
import { DEFAULT_APP, authenticate, authenticateUsage, type PlugboardOptions } from "../config.js"
|
|
25
26
|
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
|
|
26
27
|
import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
|
|
@@ -52,9 +53,12 @@ import { serveCompletions } from "../completions/route.js"
|
|
|
52
53
|
import { serveResponses } from "../responses/route.js"
|
|
53
54
|
import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
54
55
|
import { budgetFor, budgetStatus } from "../budgets.js"
|
|
56
|
+
import type { KeyStore } from "../keys.js"
|
|
55
57
|
import type { UsageRecorder } from "../usage.js"
|
|
56
58
|
import { DASHBOARD_ROUTES } from "../dashboard/page.js"
|
|
57
59
|
import { serveDashboard } from "../dashboard/assets.js"
|
|
60
|
+
import { bodyUser, noteEndUser } from "../user.js"
|
|
61
|
+
import { setSessionLink } from "../session/link.js"
|
|
58
62
|
import { chatCodec, serveChoices } from "./fanout.js"
|
|
59
63
|
import { parseChatRequest } from "./request.js"
|
|
60
64
|
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
@@ -72,6 +76,8 @@ export interface RouteHost {
|
|
|
72
76
|
readonly status?: () => RuntimeStatus
|
|
73
77
|
/** Per-app usage (`src/usage.ts`): fed from the request log point, and served by `GET /v1/usage` when present. */
|
|
74
78
|
readonly usage?: UsageRecorder
|
|
79
|
+
/** Runtime app keys (src/keys.ts, #55): their digests authenticate, and the admin routes (src/admin/route.ts) read them. */
|
|
80
|
+
readonly keys?: KeyStore
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
const SESSION_HEADER = "x-plugboard-session"
|
|
@@ -116,6 +122,8 @@ interface RequestInfo extends ServedInfo {
|
|
|
116
122
|
failed?: string
|
|
117
123
|
/** Sampling parameters dropped for this model (comma list), when any. */
|
|
118
124
|
dropped?: string
|
|
125
|
+
/** The end user the body named, when kept (src/user.ts). */
|
|
126
|
+
user?: string
|
|
119
127
|
}
|
|
120
128
|
|
|
121
129
|
export function createOpenAiRoute(routeHost: RouteHost) {
|
|
@@ -123,6 +131,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
123
131
|
const metrics = routeHost.options.metrics ? (routeHost.metrics ?? processMetrics()) : undefined
|
|
124
132
|
const runtimeStatus = routeHost.status ?? processStatus
|
|
125
133
|
const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
|
|
134
|
+
/** The runtime app keys that authenticate (loaded from storage on first use, #55). */
|
|
135
|
+
const runtimeDigests = async () => (host.keys ? await host.keys.digests() : [])
|
|
126
136
|
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
127
137
|
const known = (route: string) =>
|
|
128
138
|
KNOWN_ROUTES.includes(route) ||
|
|
@@ -176,7 +186,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
176
186
|
const keyed = Object.keys(host.options.keys).length > 0
|
|
177
187
|
let app: string | undefined = keyed ? undefined : DEFAULT_APP
|
|
178
188
|
if (keyed && request.headers.authorization !== undefined) {
|
|
179
|
-
const auth = authenticate(liveOptions(host.options), request.headers.authorization)
|
|
189
|
+
const auth = authenticate(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
|
|
180
190
|
if (isPlugError(auth)) return fail(response, auth)
|
|
181
191
|
app = auth.app
|
|
182
192
|
}
|
|
@@ -192,9 +202,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
192
202
|
// Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
|
|
193
203
|
if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
|
|
194
204
|
|
|
205
|
+
// Runtime app keys (#55): the admin routes answer the admin key only, and are a 404 without `adminKey`.
|
|
206
|
+
if (path === "/v1/admin/keys" || path.startsWith("/v1/admin/keys/"))
|
|
207
|
+
return serveAdmin({ options: liveOptions(host.options), keys: host.keys }, { path, request, response, scope, info })
|
|
208
|
+
|
|
195
209
|
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
196
210
|
// Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
|
|
197
|
-
const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization)
|
|
211
|
+
const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization, await runtimeDigests())
|
|
198
212
|
if (isPlugError(auth)) return fail(response, auth)
|
|
199
213
|
info.app = auth.app
|
|
200
214
|
|
|
@@ -253,6 +267,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
253
267
|
if (unsupported) return fail(response, unsupported)
|
|
254
268
|
|
|
255
269
|
const body = await readJsonBody(request, scope.signal)
|
|
270
|
+
noteEndUser(response, info, bodyUser(body))
|
|
256
271
|
if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
|
|
257
272
|
if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
|
|
258
273
|
const parsed = parseChatRequest(body, host.options)
|
|
@@ -280,9 +295,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
280
295
|
host.caller.stream(call, {
|
|
281
296
|
app: auth.app,
|
|
282
297
|
signal: scope.signal,
|
|
283
|
-
onRecord: (recordID) => {
|
|
298
|
+
onRecord: (recordID, directory) => {
|
|
284
299
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
285
300
|
response.setHeader(SESSION_HEADER, recordID)
|
|
301
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
286
302
|
},
|
|
287
303
|
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
288
304
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
@@ -318,7 +334,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
318
334
|
|
|
319
335
|
/** `GET /v1/usage?from=&to=&app=`. An app key may name only its own app (another one is a 403); the admin key may name any app or none. */
|
|
320
336
|
async function serveUsage(request: IncomingMessage, response: ServerResponse, url: URL, usage: UsageRecorder, info: RequestInfo): Promise<void> {
|
|
321
|
-
const who = authenticateUsage(liveOptions(host.options), request.headers.authorization)
|
|
337
|
+
const who = authenticateUsage(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
|
|
322
338
|
if (isPlugError(who)) return fail(response, who)
|
|
323
339
|
const app = url.searchParams.get("app") ?? undefined
|
|
324
340
|
if (!who.admin) {
|
|
@@ -330,6 +346,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
330
346
|
from: url.searchParams.get("from") ?? undefined,
|
|
331
347
|
to: url.searchParams.get("to") ?? undefined,
|
|
332
348
|
app: who.admin ? app : who.app,
|
|
349
|
+
user: url.searchParams.get("user") || undefined,
|
|
333
350
|
})
|
|
334
351
|
if (isPlugError(report)) return fail(response, report)
|
|
335
352
|
// Each app's budget state, read from its current window rather than from the report's range (src/budgets.ts).
|
|
@@ -396,10 +413,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
396
413
|
} catch {}
|
|
397
414
|
const ms = Date.now() - started
|
|
398
415
|
metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
399
|
-
if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
416
|
+
if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms, ...(info.user === undefined ? {} : { user: info.user }) })
|
|
400
417
|
// Probes and scrapes that succeed are not worth a line each.
|
|
401
418
|
if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
|
|
402
|
-
const fields = { id, route: info.route, app: info.app, model: info.model, served: info.served, fallbacks: info.fallbacks, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
|
|
419
|
+
const fields = { id, route: info.route, app: info.app, user: info.user, session_url: info.sessionUrl, model: info.model, served: info.served, fallbacks: info.fallbacks, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
|
|
403
420
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
404
421
|
else log("request", fields)
|
|
405
422
|
}
|
package/src/responses/request.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `ModelRef` afterwards, which is why the request omits `ref`.
|
|
11
11
|
*/
|
|
12
12
|
import type { PlugboardOptions } from "../config.js"
|
|
13
|
+
import { bodyUser, userField } from "../user.js"
|
|
13
14
|
import {
|
|
14
15
|
isPlugError,
|
|
15
16
|
type PlugError,
|
|
@@ -353,6 +354,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
|
|
|
353
354
|
const instructions = typeof body.instructions === "string" && body.instructions.length > 0 ? [body.instructions] : []
|
|
354
355
|
const system = [...instructions, ...parsed.system, ...(format.instruction ? [format.instruction] : [])]
|
|
355
356
|
const request: Omit<PlugRequest, "ref"> = {
|
|
357
|
+
...userField(bodyUser(body)),
|
|
356
358
|
model: body.model,
|
|
357
359
|
system,
|
|
358
360
|
messages: parsed.messages,
|
package/src/responses/route.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveModel } from "../models.js"
|
|
|
16
16
|
import { reportCache } from "../cache.js"
|
|
17
17
|
import { reportPolicy } from "../policy.js"
|
|
18
18
|
import { reportUsage } from "../usage-header.js"
|
|
19
|
+
import { setSessionLink } from "../session/link.js"
|
|
19
20
|
import { encodeError } from "../openai/response.js"
|
|
20
21
|
import type { RouteHost } from "../openai/route.js"
|
|
21
22
|
import { parseResponsesRequest } from "./request.js"
|
|
@@ -51,9 +52,10 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
51
52
|
host.caller.stream(call, {
|
|
52
53
|
app,
|
|
53
54
|
signal: scope.signal,
|
|
54
|
-
onRecord: (recordID) => {
|
|
55
|
+
onRecord: (recordID, directory) => {
|
|
55
56
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
56
57
|
response.setHeader(SESSION_HEADER, recordID)
|
|
58
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
57
59
|
},
|
|
58
60
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
59
61
|
onPolicy: reportPolicy(response),
|
package/src/rpc.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const UsageRpc = Rpc.define({
|
|
|
26
26
|
from: { type: "string", description: "First UTC day, YYYY-MM-DD. Default: 29 days before `to`." },
|
|
27
27
|
to: { type: "string", description: "Last UTC day, YYYY-MM-DD. Default: today." },
|
|
28
28
|
app: { type: "string", description: "One app name. Default: every app." },
|
|
29
|
+
user: { type: "string", description: "One end user id. Each app's `users` then lists only that user. Default: every user." },
|
|
29
30
|
},
|
|
30
31
|
additionalProperties: false,
|
|
31
32
|
},
|
|
@@ -37,7 +38,7 @@ export const UsageRpc = Rpc.define({
|
|
|
37
38
|
errors: {
|
|
38
39
|
invalid_query: {
|
|
39
40
|
type: "object",
|
|
40
|
-
properties: { param: { type: "string", description: "The query parameter that is wrong: from, to or
|
|
41
|
+
properties: { param: { type: "string", description: "The query parameter that is wrong: from, to, app or user." } },
|
|
41
42
|
required: ["param"],
|
|
42
43
|
additionalProperties: false,
|
|
43
44
|
},
|
|
@@ -51,7 +52,7 @@ export const UsageRpc = Rpc.define({
|
|
|
51
52
|
function queryOf(input: unknown): UsageQuery {
|
|
52
53
|
const record: Record<string, unknown> = typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {}
|
|
53
54
|
const text = (value: unknown) => (typeof value === "string" ? value : undefined)
|
|
54
|
-
return { from: text(record.from), to: text(record.to), app: text(record.app) }
|
|
55
|
+
return { from: text(record.from), to: text(record.to), app: text(record.app), user: text(record.user) }
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/** The handlers for `UsageRpc`, reading the given usage query (the generation's recorder). */
|
package/src/runtime.ts
CHANGED
|
@@ -53,6 +53,7 @@ import { Cause, Effect, Stream } from "effect"
|
|
|
53
53
|
import stableStringify from "safe-stable-stringify"
|
|
54
54
|
import { raceAbort } from "./abort.js"
|
|
55
55
|
import { cachedCaller, createCacheStore, type CacheStore } from "./cache.js"
|
|
56
|
+
import { personaOf } from "./models.js"
|
|
56
57
|
import type { PlugboardOptions } from "./config.js"
|
|
57
58
|
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError } from "./contract.js"
|
|
58
59
|
import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
|
|
@@ -63,6 +64,7 @@ import type { HistoryPort } from "./session/host.js"
|
|
|
63
64
|
import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
|
|
64
65
|
import { createSessionStore, type SessionStore } from "./session/state.js"
|
|
65
66
|
import { createBudgetState, type BudgetState } from "./budgets.js"
|
|
67
|
+
import { createKeyState, type KeyState } from "./keys.js"
|
|
66
68
|
import { createUsageState, type UsageState } from "./usage.js"
|
|
67
69
|
|
|
68
70
|
export type Handler = (request: IncomingMessage, response: ServerResponse) => void
|
|
@@ -116,8 +118,8 @@ const BACKEND_POLL_MS = 25
|
|
|
116
118
|
const BIND_RETRY_MS = 15_000
|
|
117
119
|
const BIND_REMINDER_MS = 5 * 60_000
|
|
118
120
|
|
|
119
|
-
/** 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
120
|
-
const VERSION =
|
|
121
|
+
/** 14: the runtime app keys (src/keys.ts, #55) are cached here, so a key created or revoked through the admin routes holds across plugin generations. 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
122
|
+
const VERSION = 14
|
|
121
123
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
122
124
|
|
|
123
125
|
interface Listener {
|
|
@@ -183,6 +185,8 @@ interface Runtime {
|
|
|
183
185
|
readonly usage: UsageState
|
|
184
186
|
/** The budget warnings already logged (src/budgets.ts). */
|
|
185
187
|
readonly budgets: BudgetState
|
|
188
|
+
/** The runtime app keys (src/keys.ts), loaded from storage on the first authentication. */
|
|
189
|
+
readonly runtimeKeys: KeyState
|
|
186
190
|
listener?: Listener
|
|
187
191
|
/** Present while a fiber is subscribed to the server event bus. */
|
|
188
192
|
pump?: Pump
|
|
@@ -197,6 +201,7 @@ const detachedStartedAt = Date.now()
|
|
|
197
201
|
const detachedLimits = createLimitState()
|
|
198
202
|
const detachedUsage = createUsageState()
|
|
199
203
|
const detachedBudgets = createBudgetState()
|
|
204
|
+
const detachedKeys = createKeyState()
|
|
200
205
|
let foreignVersionLogged = false
|
|
201
206
|
|
|
202
207
|
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
@@ -215,6 +220,7 @@ function runtime(): Runtime | undefined {
|
|
|
215
220
|
limits: createLimitState(),
|
|
216
221
|
usage: createUsageState(),
|
|
217
222
|
budgets: createBudgetState(),
|
|
223
|
+
runtimeKeys: createKeyState(),
|
|
218
224
|
startedAt: Date.now(),
|
|
219
225
|
}
|
|
220
226
|
slot[KEY] = created
|
|
@@ -299,6 +305,9 @@ export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
|
|
|
299
305
|
/** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
|
|
300
306
|
export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
|
|
301
307
|
|
|
308
|
+
/** The runtime app keys' cache shared by every generation in the process (detached under a foreign-version runtime). */
|
|
309
|
+
export const processKeys = (): KeyState => runtime()?.runtimeKeys ?? detachedKeys
|
|
310
|
+
|
|
302
311
|
/**
|
|
303
312
|
* The options authentication reads: `keys` is the live key set, not the serving generation's own
|
|
304
313
|
* (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
|
|
@@ -336,7 +345,7 @@ export function withResponseCache(caller: ModelCaller, options: PlugboardOptions
|
|
|
336
345
|
if (rt) rt.cache = held
|
|
337
346
|
}
|
|
338
347
|
const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
|
|
339
|
-
return cachedCaller(caller, config, held.store, count)
|
|
348
|
+
return cachedCaller(caller, config, held.store, count, (model) => personaOf(options, model) !== undefined)
|
|
340
349
|
}
|
|
341
350
|
return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
|
|
342
351
|
}
|