opencode-plugboard 0.18.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 +32 -14
- package/package.json +1 -1
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/response.ts +8 -25
- package/src/anthropic/route.ts +13 -6
- package/src/budgets.ts +200 -0
- package/src/cache.ts +12 -2
- package/src/completions/request.ts +3 -0
- package/src/completions/response.ts +10 -1
- package/src/config.ts +183 -10
- package/src/contract.ts +17 -1
- package/src/discovery.ts +8 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/request.ts +1 -0
- package/src/gemini/response.ts +16 -1
- package/src/gemini/route.ts +5 -1
- package/src/keys.ts +172 -0
- package/src/log.ts +3 -0
- package/src/metrics.ts +3 -3
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +14 -2
- package/src/openai/request.ts +3 -0
- package/src/openai/response.ts +23 -5
- package/src/openai/route.ts +46 -7
- package/src/responses/request.ts +30 -14
- package/src/responses/route.ts +12 -5
- package/src/responses/store.ts +51 -0
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +20 -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/stop.ts +54 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +104 -5
- package/src/user.ts +55 -0
package/src/metrics.ts
CHANGED
|
@@ -62,8 +62,8 @@ export interface Metrics {
|
|
|
62
62
|
dropped(params: ReadonlyArray<string>): void
|
|
63
63
|
/** A model call failed before any output and was retried on the next target of its alias. */
|
|
64
64
|
fallback(app: string): void
|
|
65
|
-
/** A call was refused with 429 by a concurrency cap (`concurrency`)
|
|
66
|
-
limited(app: string, reason: "concurrency" | "rate"): void
|
|
65
|
+
/** A call was refused with 429 by a concurrency cap (`concurrency`), a requests-per-minute limit (`rate`) or a budget (`budget`). */
|
|
66
|
+
limited(app: string, reason: "concurrency" | "rate" | "budget"): void
|
|
67
67
|
/** A call the response cache applied to: a stored answer replayed (`hit`) or the model run (`miss`). */
|
|
68
68
|
cache(app: string, result: "hit" | "miss"): void
|
|
69
69
|
/** One history sweep ended. */
|
|
@@ -110,7 +110,7 @@ export function createMetrics(): Metrics {
|
|
|
110
110
|
})
|
|
111
111
|
const limited = new Counter({
|
|
112
112
|
name: "plugboard_limited_total",
|
|
113
|
-
help: "Model calls refused with 429 by a concurrency cap
|
|
113
|
+
help: "Model calls refused with 429 by a concurrency cap, a requests-per-minute limit or a budget, by app and reason.",
|
|
114
114
|
labelNames: ["app", "reason"],
|
|
115
115
|
registers: [registry],
|
|
116
116
|
})
|
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
|
@@ -25,6 +25,8 @@ import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.j
|
|
|
25
25
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
26
26
|
import { reportCache } from "../cache.js"
|
|
27
27
|
import { reportPolicy } from "../policy.js"
|
|
28
|
+
import { reportUsage } from "../usage-header.js"
|
|
29
|
+
import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
|
|
28
30
|
import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
29
31
|
import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
|
|
30
32
|
import type { RouteHost } from "./route.js"
|
|
@@ -154,7 +156,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
|
|
|
154
156
|
return out
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
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> {
|
|
158
160
|
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
159
161
|
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
160
162
|
if (scope.signal.aborted) throw scope.signal.reason
|
|
@@ -164,11 +166,15 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
164
166
|
const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
|
|
165
167
|
/** History record ids as the callers report them, in choice order, for the response header (comma separated). */
|
|
166
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> = []
|
|
167
171
|
// Headers: the first choice to be served wins. Body model: only a single call echoes the served model.
|
|
168
172
|
const served = reportServed(response, info, (s) => {
|
|
169
173
|
if (calls.length === 1) codec.setModel(s.model)
|
|
170
174
|
})
|
|
171
175
|
const policy = reportPolicy(response)
|
|
176
|
+
// One header for all choices: each answered call adds its tokens and cost (`x-plugboard-usage`, #52).
|
|
177
|
+
const usageHeader = reportUsage(response, codec.stream)
|
|
172
178
|
// Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
|
|
173
179
|
const cache = reportCache(response)
|
|
174
180
|
const open = (index: number) =>
|
|
@@ -176,13 +182,19 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
176
182
|
host.caller.stream(calls[index]!, {
|
|
177
183
|
app,
|
|
178
184
|
signal: gscope.signal,
|
|
179
|
-
onRecord: (recordID) => {
|
|
185
|
+
onRecord: (recordID, directory) => {
|
|
180
186
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
181
187
|
records[index] = recordID
|
|
182
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
|
+
}
|
|
183
194
|
},
|
|
184
195
|
onServed: served,
|
|
185
196
|
onPolicy: policy,
|
|
197
|
+
onUsage: usageHeader,
|
|
186
198
|
onCache: cache,
|
|
187
199
|
noCache: calls.length > 1,
|
|
188
200
|
}),
|
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,
|
|
@@ -339,6 +341,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
|
|
|
339
341
|
includeUsage: streamOptions.include_usage === true,
|
|
340
342
|
...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
341
343
|
...(format.instruction ? { jsonObject: true } : {}),
|
|
344
|
+
...(generation.stop ? { stop: generation.stop } : {}),
|
|
342
345
|
},
|
|
343
346
|
...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
|
|
344
347
|
}
|
package/src/openai/response.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
8
8
|
import type { ListedModel } from "../models.js"
|
|
9
|
+
import { StopHoldback, stripStopTail } from "../stop.js"
|
|
9
10
|
|
|
10
11
|
type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
11
12
|
|
|
@@ -33,6 +34,8 @@ export interface OpenAiExchange {
|
|
|
33
34
|
* stripped from the content (`unfence`).
|
|
34
35
|
*/
|
|
35
36
|
readonly jsonObject?: boolean
|
|
37
|
+
/** The request's `stop`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). Not applied to structured output. */
|
|
38
|
+
readonly stop?: ReadonlyArray<string>
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/** How a streamed exchange ended. Nothing was written when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
|
|
@@ -108,12 +111,12 @@ const encodePlugboard = (model: ListedModel["model"]) => ({
|
|
|
108
111
|
/** `GET /v1/models` body. */
|
|
109
112
|
export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
|
|
110
113
|
object: "list",
|
|
111
|
-
data: models.map(({ id, ref, model }) => ({
|
|
114
|
+
data: models.map(({ id, ref, model, skill, agent }) => ({
|
|
112
115
|
id,
|
|
113
116
|
object: "model",
|
|
114
117
|
created: 0,
|
|
115
118
|
owned_by: ref.providerID,
|
|
116
|
-
plugboard: encodePlugboard(model),
|
|
119
|
+
plugboard: { ...encodePlugboard(model), ...(skill === undefined ? {} : { skill }), ...(agent === undefined ? {} : { agent }) },
|
|
117
120
|
})),
|
|
118
121
|
})
|
|
119
122
|
|
|
@@ -219,6 +222,7 @@ export async function collectCompletion(
|
|
|
219
222
|
const error = failure ?? noFinish()
|
|
220
223
|
return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
|
|
221
224
|
}
|
|
225
|
+
if (finish === "stop" && calls.length === 0 && exchange.structuredTool === undefined) text = stripStopTail(text, exchange.stop ?? []).text
|
|
222
226
|
|
|
223
227
|
const answer = calls.find((call) => structured.isAnswer(exchange, call))
|
|
224
228
|
const others = calls.filter((call) => !structured.isAnswer(exchange, call))
|
|
@@ -309,6 +313,12 @@ export async function streamCompletion(
|
|
|
309
313
|
started = true
|
|
310
314
|
chunk({ role: "assistant", content: "" })
|
|
311
315
|
}
|
|
316
|
+
// A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
|
|
317
|
+
const holdback = exchange.structuredTool === undefined ? new StopHoldback(exchange.stop ?? []) : undefined
|
|
318
|
+
const send = (text: string) => {
|
|
319
|
+
const out = holdback ? holdback.push(text) : text
|
|
320
|
+
if (out.length > 0) chunk({ content: out })
|
|
321
|
+
}
|
|
312
322
|
// json_object: text that opens with a backtick may be a fenced answer, so it waits (as `pending`)
|
|
313
323
|
// until a tool call or the finish shows the whole text; anything else streams as it comes. A
|
|
314
324
|
// fenced answer therefore arrives as one chunk.
|
|
@@ -319,7 +329,7 @@ export async function streamCompletion(
|
|
|
319
329
|
const text = unfence(pending)
|
|
320
330
|
pending = ""
|
|
321
331
|
start()
|
|
322
|
-
|
|
332
|
+
send(text)
|
|
323
333
|
}
|
|
324
334
|
|
|
325
335
|
for await (const event of events) {
|
|
@@ -359,10 +369,14 @@ export async function streamCompletion(
|
|
|
359
369
|
}
|
|
360
370
|
continue
|
|
361
371
|
}
|
|
362
|
-
if (event.type !== "text-delta")
|
|
372
|
+
if (event.type !== "text-delta") {
|
|
373
|
+
flush()
|
|
374
|
+
const rest = holdback?.release() ?? ""
|
|
375
|
+
if (rest.length > 0) chunk({ content: rest })
|
|
376
|
+
}
|
|
363
377
|
start()
|
|
364
378
|
if (event.type === "text-delta") {
|
|
365
|
-
|
|
379
|
+
send(event.text)
|
|
366
380
|
} else {
|
|
367
381
|
// Tool calls arrive whole, so each goes out as one chunk with the next index.
|
|
368
382
|
chunk({ tool_calls: [{ index: calls++, id: event.id, type: "function", function: { name: event.name, arguments: args(event.input) } }] })
|
|
@@ -382,6 +396,10 @@ export async function streamCompletion(
|
|
|
382
396
|
}
|
|
383
397
|
flush()
|
|
384
398
|
start()
|
|
399
|
+
if (holdback) {
|
|
400
|
+
const rest = holdback.finish(finish === "stop" && calls === 0).text
|
|
401
|
+
if (rest.length > 0) chunk({ content: rest })
|
|
402
|
+
}
|
|
385
403
|
if (held && isStructured) chunk({ content: structured.content(held) })
|
|
386
404
|
chunk({}, finishReason(finish!, calls, isStructured))
|
|
387
405
|
if (choice) return { started, ...(usage ? { usage } : {}) }
|
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"
|
|
@@ -46,13 +47,18 @@ import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
|
46
47
|
import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
|
|
47
48
|
import { reportCache } from "../cache.js"
|
|
48
49
|
import { reportPolicy } from "../policy.js"
|
|
50
|
+
import { reportUsage } from "../usage-header.js"
|
|
49
51
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
50
52
|
import { serveCompletions } from "../completions/route.js"
|
|
51
53
|
import { serveResponses } from "../responses/route.js"
|
|
52
54
|
import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
55
|
+
import { budgetFor, budgetStatus } from "../budgets.js"
|
|
56
|
+
import type { KeyStore } from "../keys.js"
|
|
53
57
|
import type { UsageRecorder } from "../usage.js"
|
|
54
58
|
import { DASHBOARD_ROUTES } from "../dashboard/page.js"
|
|
55
59
|
import { serveDashboard } from "../dashboard/assets.js"
|
|
60
|
+
import { bodyUser, noteEndUser } from "../user.js"
|
|
61
|
+
import { setSessionLink } from "../session/link.js"
|
|
56
62
|
import { chatCodec, serveChoices } from "./fanout.js"
|
|
57
63
|
import { parseChatRequest } from "./request.js"
|
|
58
64
|
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
@@ -70,6 +76,8 @@ export interface RouteHost {
|
|
|
70
76
|
readonly status?: () => RuntimeStatus
|
|
71
77
|
/** Per-app usage (`src/usage.ts`): fed from the request log point, and served by `GET /v1/usage` when present. */
|
|
72
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
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
const SESSION_HEADER = "x-plugboard-session"
|
|
@@ -114,6 +122,8 @@ interface RequestInfo extends ServedInfo {
|
|
|
114
122
|
failed?: string
|
|
115
123
|
/** Sampling parameters dropped for this model (comma list), when any. */
|
|
116
124
|
dropped?: string
|
|
125
|
+
/** The end user the body named, when kept (src/user.ts). */
|
|
126
|
+
user?: string
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
export function createOpenAiRoute(routeHost: RouteHost) {
|
|
@@ -121,6 +131,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
121
131
|
const metrics = routeHost.options.metrics ? (routeHost.metrics ?? processMetrics()) : undefined
|
|
122
132
|
const runtimeStatus = routeHost.status ?? processStatus
|
|
123
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() : [])
|
|
124
136
|
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
125
137
|
const known = (route: string) =>
|
|
126
138
|
KNOWN_ROUTES.includes(route) ||
|
|
@@ -154,7 +166,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
154
166
|
const path = url.pathname
|
|
155
167
|
const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
|
|
156
168
|
const gemini = matchGeminiRoute(request.method, path)
|
|
157
|
-
|
|
169
|
+
// Any /v1beta path is Google's API, even one that matches no Gemini route (#150): its errors use Google's body.
|
|
170
|
+
const googleShaped = gemini !== undefined || path === "/v1beta" || path.startsWith("/v1beta/")
|
|
171
|
+
if (googleShaped) googleErrors.add(response)
|
|
158
172
|
const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
159
173
|
// Arbitrary client paths stay out of the log.
|
|
160
174
|
info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
@@ -172,7 +186,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
172
186
|
const keyed = Object.keys(host.options.keys).length > 0
|
|
173
187
|
let app: string | undefined = keyed ? undefined : DEFAULT_APP
|
|
174
188
|
if (keyed && request.headers.authorization !== undefined) {
|
|
175
|
-
const auth = authenticate(liveOptions(host.options), request.headers.authorization)
|
|
189
|
+
const auth = authenticate(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
|
|
176
190
|
if (isPlugError(auth)) return fail(response, auth)
|
|
177
191
|
app = auth.app
|
|
178
192
|
}
|
|
@@ -188,12 +202,25 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
188
202
|
// Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
|
|
189
203
|
if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
|
|
190
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
|
+
|
|
191
209
|
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
192
210
|
// Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
|
|
193
|
-
const auth = authenticate(liveOptions(host.options),
|
|
211
|
+
const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization, await runtimeDigests())
|
|
194
212
|
if (isPlugError(auth)) return fail(response, auth)
|
|
195
213
|
info.app = auth.app
|
|
196
214
|
|
|
215
|
+
// A /v1beta path no Gemini route matches: the AI SDK and LangChain send a model id with a slash and no `models/` prefix (#150).
|
|
216
|
+
if (googleShaped && !gemini)
|
|
217
|
+
return fail(response, {
|
|
218
|
+
status: 404,
|
|
219
|
+
kind: "not_found",
|
|
220
|
+
code: "not_found",
|
|
221
|
+
message: `No Gemini route for ${request.method} ${path}. Expected POST /v1beta/models/{model}:generateContent or POST /v1beta/models/{model}:streamGenerateContent; the path must start with /v1beta/models/. GET /v1beta/models lists the models.`,
|
|
222
|
+
})
|
|
223
|
+
|
|
197
224
|
if (!gemini && modelId === undefined && !known(route)) {
|
|
198
225
|
return fail(response, {
|
|
199
226
|
status: 404,
|
|
@@ -240,6 +267,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
240
267
|
if (unsupported) return fail(response, unsupported)
|
|
241
268
|
|
|
242
269
|
const body = await readJsonBody(request, scope.signal)
|
|
270
|
+
noteEndUser(response, info, bodyUser(body))
|
|
243
271
|
if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
|
|
244
272
|
if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
|
|
245
273
|
const parsed = parseChatRequest(body, host.options)
|
|
@@ -267,13 +295,15 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
267
295
|
host.caller.stream(call, {
|
|
268
296
|
app: auth.app,
|
|
269
297
|
signal: scope.signal,
|
|
270
|
-
onRecord: (recordID) => {
|
|
298
|
+
onRecord: (recordID, directory) => {
|
|
271
299
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
272
300
|
response.setHeader(SESSION_HEADER, recordID)
|
|
301
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
273
302
|
},
|
|
274
303
|
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
275
304
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
276
305
|
onPolicy: reportPolicy(response),
|
|
306
|
+
onUsage: reportUsage(response, exchange.stream),
|
|
277
307
|
onCache: reportCache(response),
|
|
278
308
|
}),
|
|
279
309
|
scope,
|
|
@@ -304,7 +334,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
304
334
|
|
|
305
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. */
|
|
306
336
|
async function serveUsage(request: IncomingMessage, response: ServerResponse, url: URL, usage: UsageRecorder, info: RequestInfo): Promise<void> {
|
|
307
|
-
const who = authenticateUsage(liveOptions(host.options), request.headers.authorization)
|
|
337
|
+
const who = authenticateUsage(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
|
|
308
338
|
if (isPlugError(who)) return fail(response, who)
|
|
309
339
|
const app = url.searchParams.get("app") ?? undefined
|
|
310
340
|
if (!who.admin) {
|
|
@@ -316,8 +346,17 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
316
346
|
from: url.searchParams.get("from") ?? undefined,
|
|
317
347
|
to: url.searchParams.get("to") ?? undefined,
|
|
318
348
|
app: who.admin ? app : who.app,
|
|
349
|
+
user: url.searchParams.get("user") || undefined,
|
|
319
350
|
})
|
|
320
351
|
if (isPlugError(report)) return fail(response, report)
|
|
352
|
+
// Each app's budget state, read from its current window rather than from the report's range (src/budgets.ts).
|
|
353
|
+
if (host.options.budgets) {
|
|
354
|
+
const at = Date.now()
|
|
355
|
+
for (const [name, entry] of Object.entries(report.apps)) {
|
|
356
|
+
const budget = budgetFor(host.options.budgets, name)
|
|
357
|
+
if (budget) entry.budgets = await budgetStatus(usage, budget, name, at)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
321
360
|
return sendJson(response, 200, report)
|
|
322
361
|
}
|
|
323
362
|
|
|
@@ -374,10 +413,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
374
413
|
} catch {}
|
|
375
414
|
const ms = Date.now() - started
|
|
376
415
|
metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
377
|
-
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 }) })
|
|
378
417
|
// Probes and scrapes that succeed are not worth a line each.
|
|
379
418
|
if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
|
|
380
|
-
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 }
|
|
381
420
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
382
421
|
else log("request", fields)
|
|
383
422
|
}
|
package/src/responses/request.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* OpenAI Responses request -> neutral `PlugRequest` plus the `ResponsesExchange` the response
|
|
3
3
|
* encoder needs.
|
|
4
4
|
*
|
|
5
|
-
* Pure function: no I/O.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Pure function: no I/O. The caller sends the whole conversation in `input` each time, which is
|
|
6
|
+
* how history already works. An `item_reference` is resolved through the `resolve` argument (the
|
|
7
|
+
* route passes the per-app store of recent answers, src/responses/store.ts; #147). Anything else that
|
|
8
|
+
* needs server-side state (`previous_response_id`, `conversation`, `background`) or a hosted tool
|
|
9
|
+
* is rejected with 400 `unsupported_parameter` naming the field. The route resolves `model` to a
|
|
9
10
|
* `ModelRef` afterwards, which is why the request omits `ref`.
|
|
10
11
|
*/
|
|
11
12
|
import type { PlugboardOptions } from "../config.js"
|
|
13
|
+
import { bodyUser, userField } from "../user.js"
|
|
12
14
|
import {
|
|
13
15
|
isPlugError,
|
|
14
16
|
type PlugError,
|
|
@@ -121,7 +123,23 @@ const assistantText = (content: unknown, param: string): string | PlugError => {
|
|
|
121
123
|
* assistant message, the shape the Chat Completions front-end produces, so a parallel-call turn
|
|
122
124
|
* is one message followed by its results.
|
|
123
125
|
*/
|
|
124
|
-
|
|
126
|
+
/** The output item an `item_reference` names, or undefined when the app's store does not hold it. */
|
|
127
|
+
export type ItemResolver = (itemId: string) => JsonObject | undefined
|
|
128
|
+
|
|
129
|
+
/** An `item_reference` is the output item of an earlier answer, decoded as that item (#147). */
|
|
130
|
+
const resolveReference = (item: JsonObject, param: string, resolve: ItemResolver | undefined): JsonObject | PlugError => {
|
|
131
|
+
if (typeof item.id !== "string" || item.id.length === 0) return bad("item_reference needs the id of an output item", `${param}.id`)
|
|
132
|
+
const found = resolve?.(item.id)
|
|
133
|
+
if (found === undefined)
|
|
134
|
+
return bad(
|
|
135
|
+
`item_reference "${item.id}" is not known to plugboard: it keeps the output of recent answers for an hour, per app. Send the earlier items in full, or send store: false (Vercel AI SDK: providerOptions.openai.store = false)`,
|
|
136
|
+
`${param}.id`,
|
|
137
|
+
"item_not_found",
|
|
138
|
+
)
|
|
139
|
+
return found
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const parseInput = (input: unknown, resolve: ItemResolver | undefined): { system: string[]; messages: PlugMessage[] } | PlugError => {
|
|
125
143
|
if (typeof input === "string") return { system: [], messages: [{ role: "user", content: [{ type: "text", text: input }] }] }
|
|
126
144
|
if (!Array.isArray(input) || input.length === 0) return bad("input must be a string or a non-empty array of items", "input")
|
|
127
145
|
|
|
@@ -134,9 +152,11 @@ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[]
|
|
|
134
152
|
pending = undefined
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
for (const [index,
|
|
155
|
+
for (const [index, given] of input.entries()) {
|
|
138
156
|
const param = `input[${index}]`
|
|
139
|
-
if (!isRecord(
|
|
157
|
+
if (!isRecord(given)) return bad("Each input item must be an object", param)
|
|
158
|
+
const item = given.type === "item_reference" ? resolveReference(given, param, resolve) : given
|
|
159
|
+
if (isPlugError(item)) return item
|
|
140
160
|
const type = item.type ?? (item.role !== undefined ? "message" : undefined)
|
|
141
161
|
switch (type) {
|
|
142
162
|
case "message": {
|
|
@@ -192,11 +212,6 @@ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[]
|
|
|
192
212
|
// Reasoning items carry no content plugboard can replay; they are dropped.
|
|
193
213
|
case "reasoning":
|
|
194
214
|
break
|
|
195
|
-
case "item_reference":
|
|
196
|
-
return unsupported(
|
|
197
|
-
"item_reference needs server-side state, which plugboard does not keep; send the full items instead (Vercel AI SDK: providerOptions.openai.store = false)",
|
|
198
|
-
`${param}.type`,
|
|
199
|
-
)
|
|
200
215
|
default:
|
|
201
216
|
return unsupported(`Unsupported input item type "${String(type)}"`, `${param}.type`)
|
|
202
217
|
}
|
|
@@ -296,7 +311,7 @@ const checkUnsupported = (body: JsonObject): PlugError | undefined => {
|
|
|
296
311
|
if (body.truncation != null && body.truncation !== "disabled") return unsupported('Only truncation "disabled" is supported', "truncation")
|
|
297
312
|
}
|
|
298
313
|
|
|
299
|
-
export function parseResponsesRequest(body: unknown, options: PlugboardOptions): ParsedResponses | PlugError {
|
|
314
|
+
export function parseResponsesRequest(body: unknown, options: PlugboardOptions, resolve?: ItemResolver): ParsedResponses | PlugError {
|
|
300
315
|
if (!isRecord(body)) return bad("Request body must be a JSON object")
|
|
301
316
|
if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
|
|
302
317
|
if (body.input == null) return bad("input is required", "input")
|
|
@@ -305,7 +320,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions):
|
|
|
305
320
|
if (body.instructions != null && typeof body.instructions !== "string") return bad("instructions must be a string", "instructions")
|
|
306
321
|
if (body.reasoning != null && !isRecord(body.reasoning)) return bad("reasoning must be an object", "reasoning")
|
|
307
322
|
|
|
308
|
-
const parsed = parseInput(body.input)
|
|
323
|
+
const parsed = parseInput(body.input, resolve)
|
|
309
324
|
if (isPlugError(parsed)) return parsed
|
|
310
325
|
const callerTools = parseTools(body.tools)
|
|
311
326
|
if (!Array.isArray(callerTools)) return callerTools
|
|
@@ -339,6 +354,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions):
|
|
|
339
354
|
const instructions = typeof body.instructions === "string" && body.instructions.length > 0 ? [body.instructions] : []
|
|
340
355
|
const system = [...instructions, ...parsed.system, ...(format.instruction ? [format.instruction] : [])]
|
|
341
356
|
const request: Omit<PlugRequest, "ref"> = {
|
|
357
|
+
...userField(bodyUser(body)),
|
|
342
358
|
model: body.model,
|
|
343
359
|
system,
|
|
344
360
|
messages: parsed.messages,
|
package/src/responses/route.ts
CHANGED
|
@@ -15,10 +15,13 @@ import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "
|
|
|
15
15
|
import { resolveModel } from "../models.js"
|
|
16
16
|
import { reportCache } from "../cache.js"
|
|
17
17
|
import { reportPolicy } from "../policy.js"
|
|
18
|
+
import { reportUsage } from "../usage-header.js"
|
|
19
|
+
import { setSessionLink } from "../session/link.js"
|
|
18
20
|
import { encodeError } from "../openai/response.js"
|
|
19
21
|
import type { RouteHost } from "../openai/route.js"
|
|
20
22
|
import { parseResponsesRequest } from "./request.js"
|
|
21
23
|
import { runResponse, sseFrame } from "./response.js"
|
|
24
|
+
import { findItem, rememberAnswer } from "./store.js"
|
|
22
25
|
|
|
23
26
|
const SESSION_HEADER = "x-plugboard-session"
|
|
24
27
|
|
|
@@ -34,7 +37,7 @@ export interface ResponsesCall {
|
|
|
34
37
|
export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
|
|
35
38
|
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
36
39
|
|
|
37
|
-
const parsed = parseResponsesRequest(body, host.options)
|
|
40
|
+
const parsed = parseResponsesRequest(body, host.options, (itemId) => findItem(app, itemId))
|
|
38
41
|
if (isPlugError(parsed)) return fail(parsed)
|
|
39
42
|
const catalog = await scope.race(host.loadCatalog())
|
|
40
43
|
const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
|
|
@@ -49,12 +52,14 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
49
52
|
host.caller.stream(call, {
|
|
50
53
|
app,
|
|
51
54
|
signal: scope.signal,
|
|
52
|
-
onRecord: (recordID) => {
|
|
55
|
+
onRecord: (recordID, directory) => {
|
|
53
56
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
54
57
|
response.setHeader(SESSION_HEADER, recordID)
|
|
58
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
55
59
|
},
|
|
56
60
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
57
61
|
onPolicy: reportPolicy(response),
|
|
62
|
+
onUsage: reportUsage(response, exchange.stream),
|
|
58
63
|
onCache: reportCache(response),
|
|
59
64
|
}),
|
|
60
65
|
scope,
|
|
@@ -62,11 +67,12 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
62
67
|
|
|
63
68
|
if (exchange.stream) {
|
|
64
69
|
const sse = sseWriter(response, scope.signal)
|
|
65
|
-
const
|
|
70
|
+
const run = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
|
|
71
|
+
if (run.response && !run.failure) rememberAnswer(app, run.response)
|
|
66
72
|
if (!sse.started && !response.headersSent) {
|
|
67
|
-
if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
73
|
+
if (!scope.signal.aborted || scope.timedOut) fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
68
74
|
} else {
|
|
69
|
-
if (failure) info.failed = failure.code ?? failure.kind
|
|
75
|
+
if (run.failure) info.failed = run.failure.code ?? run.failure.kind
|
|
70
76
|
if (!response.writableEnded) response.end()
|
|
71
77
|
}
|
|
72
78
|
return
|
|
@@ -74,5 +80,6 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
74
80
|
|
|
75
81
|
const run = await runResponse(events, exchange, () => {})
|
|
76
82
|
if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
83
|
+
rememberAnswer(app, run.response)
|
|
77
84
|
sendJson(response, 200, run.response)
|
|
78
85
|
}
|