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/openai/response.ts
CHANGED
|
@@ -36,6 +36,20 @@ export interface OpenAiExchange {
|
|
|
36
36
|
readonly jsonObject?: boolean
|
|
37
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
38
|
readonly stop?: ReadonlyArray<string>
|
|
39
|
+
/** The caller's `json_schema`, checked against a text answer when the model skipped the respond call (`textFallback`). */
|
|
40
|
+
readonly structuredSchema?: Readonly<Record<string, unknown>>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Set on a complete answer that came from the final text, not the respond call (`textFallback`). */
|
|
44
|
+
export const STRUCTURED_FALLBACK_HEADER = "x-plugboard-structured-fallback"
|
|
45
|
+
|
|
46
|
+
/** One choice as a complete JSON body, with the status to send it with. */
|
|
47
|
+
export interface CollectedChoice {
|
|
48
|
+
readonly status: number
|
|
49
|
+
readonly body: object
|
|
50
|
+
readonly retryAfterSeconds?: number
|
|
51
|
+
/** The structured answer was taken from the text (the response carries `STRUCTURED_FALLBACK_HEADER`). */
|
|
52
|
+
readonly structuredFallback?: boolean
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
/** How a streamed exchange ended. Nothing was written when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
|
|
@@ -111,12 +125,12 @@ const encodePlugboard = (model: ListedModel["model"]) => ({
|
|
|
111
125
|
/** `GET /v1/models` body. */
|
|
112
126
|
export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
|
|
113
127
|
object: "list",
|
|
114
|
-
data: models.map(({ id, ref, model }) => ({
|
|
128
|
+
data: models.map(({ id, ref, model, skill, agent }) => ({
|
|
115
129
|
id,
|
|
116
130
|
object: "model",
|
|
117
131
|
created: 0,
|
|
118
132
|
owned_by: ref.providerID,
|
|
119
|
-
plugboard: encodePlugboard(model),
|
|
133
|
+
plugboard: { ...encodePlugboard(model), ...(skill === undefined ? {} : { skill }), ...(agent === undefined ? {} : { agent }) },
|
|
120
134
|
})),
|
|
121
135
|
})
|
|
122
136
|
|
|
@@ -132,7 +146,7 @@ const encodeUsage = (usage: PlugUsage) => {
|
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
|
|
135
|
-
const args = (input: unknown) => (typeof input === "string" ? input : JSON.stringify(input ?? {}))
|
|
149
|
+
export const args = (input: unknown) => (typeof input === "string" ? input : JSON.stringify(input ?? {}))
|
|
136
150
|
|
|
137
151
|
/**
|
|
138
152
|
* Final finish_reason. Tool calls force `tool_calls` even when the upstream says `stop`;
|
|
@@ -152,26 +166,88 @@ const noFinish = (): PlugError => ({
|
|
|
152
166
|
|
|
153
167
|
const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
|
|
154
168
|
|
|
169
|
+
/** The 502 for a structured turn that ended without its answer (the forced tool, or a valid JSON text). Shared with the legacy completions encoder. */
|
|
170
|
+
export const structuredMissing = (tool: string): PlugError => ({
|
|
171
|
+
status: 502,
|
|
172
|
+
kind: "server",
|
|
173
|
+
code: "structured_output_missing",
|
|
174
|
+
message: `The model finished without calling the \`${tool}\` tool, so there is no structured output`,
|
|
175
|
+
})
|
|
176
|
+
|
|
155
177
|
/**
|
|
156
178
|
* Structured-output projection, shared by the JSON and SSE encoders so both give the same body.
|
|
157
179
|
* In structured mode (`exchange.structuredTool` set) the content is exactly the arguments of
|
|
158
180
|
* the first call to that tool: prose, reasoning and any other tool call are suppressed, and
|
|
159
|
-
* later calls to it are ignored. Without an answer the exchange fails
|
|
160
|
-
*
|
|
181
|
+
* later calls to it are ignored. Without an answer the exchange fails with 502 (#172): a turn that
|
|
182
|
+
* ends with no structured output is never a 200 with empty content, whatever its finish reason.
|
|
161
183
|
*/
|
|
162
184
|
const structured = {
|
|
163
185
|
isAnswer: (exchange: OpenAiExchange, call: ToolCall) => exchange.structuredTool !== undefined && call.name === exchange.structuredTool,
|
|
164
186
|
content: (call: ToolCall) => args(call.input),
|
|
165
187
|
/** The failure for a finished structured exchange, if any. */
|
|
166
|
-
failure: (exchange: OpenAiExchange,
|
|
167
|
-
exchange.structuredTool === undefined || answered
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
188
|
+
failure: (exchange: OpenAiExchange, answered: boolean): PlugError | undefined =>
|
|
189
|
+
exchange.structuredTool === undefined || answered ? undefined : structuredMissing(exchange.structuredTool),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const isObjectValue = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
193
|
+
|
|
194
|
+
const hasOwn = (value: Record<string, unknown>, key: string) => Object.prototype.hasOwnProperty.call(value, key)
|
|
195
|
+
|
|
196
|
+
/** Whether a parsed JSON value has one JSON Schema type (`integer` is a whole number; an unknown type matches anything). */
|
|
197
|
+
const hasType = (value: unknown, type: unknown): boolean => {
|
|
198
|
+
switch (type) {
|
|
199
|
+
case "object":
|
|
200
|
+
return isObjectValue(value)
|
|
201
|
+
case "array":
|
|
202
|
+
return Array.isArray(value)
|
|
203
|
+
case "string":
|
|
204
|
+
return typeof value === "string"
|
|
205
|
+
case "number":
|
|
206
|
+
return typeof value === "number"
|
|
207
|
+
case "integer":
|
|
208
|
+
return Number.isInteger(value)
|
|
209
|
+
case "boolean":
|
|
210
|
+
return typeof value === "boolean"
|
|
211
|
+
case "null":
|
|
212
|
+
return value === null
|
|
213
|
+
default:
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The part of JSON Schema checked on a text answer. The runtime carries no schema library (ajv is a
|
|
220
|
+
* dev dependency), so this is deliberately small: the top-level `type` (one type or a list), the
|
|
221
|
+
* top-level `required` keys, and the `type` of each top-level property that is present. Anything
|
|
222
|
+
* else in the schema (enum, nested objects, formats, additionalProperties) is not checked.
|
|
223
|
+
*/
|
|
224
|
+
export function matchesSchema(value: unknown, schema: Readonly<Record<string, unknown>>): boolean {
|
|
225
|
+
const types = Array.isArray(schema.type) ? schema.type : schema.type === undefined ? undefined : [schema.type]
|
|
226
|
+
if (types !== undefined && !types.some((type) => hasType(value, type))) return false
|
|
227
|
+
if (!isObjectValue(value)) return true
|
|
228
|
+
const required = Array.isArray(schema.required) ? schema.required : []
|
|
229
|
+
if (!required.every((key) => typeof key === "string" && hasOwn(value, key))) return false
|
|
230
|
+
if (!isObjectValue(schema.properties)) return true
|
|
231
|
+
return Object.entries(schema.properties).every(([key, property]) => {
|
|
232
|
+
if (!hasOwn(value, key) || !isObjectValue(property) || property.type === undefined) return true
|
|
233
|
+
const propertyTypes = Array.isArray(property.type) ? property.type : [property.type]
|
|
234
|
+
return propertyTypes.some((type) => hasType(value[key], type))
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The answer of a structured turn that never called the respond tool, when its final text is the JSON
|
|
240
|
+
* the schema asks for (one code fence is stripped first). Undefined for any other text.
|
|
241
|
+
*/
|
|
242
|
+
export function textFallback(text: string, schema: Readonly<Record<string, unknown>>): string | undefined {
|
|
243
|
+
const candidate = unfence(text.trim())
|
|
244
|
+
let value: unknown
|
|
245
|
+
try {
|
|
246
|
+
value = JSON.parse(candidate)
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
return matchesSchema(value, schema) ? candidate : undefined
|
|
175
251
|
}
|
|
176
252
|
|
|
177
253
|
/** One markdown code fence (```json or a bare ```) around the whole text. */
|
|
@@ -195,10 +271,7 @@ export function unfence(text: string): string {
|
|
|
195
271
|
const newId = () => `chatcmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`
|
|
196
272
|
const now = () => Math.floor(Date.now() / 1000)
|
|
197
273
|
|
|
198
|
-
export async function collectCompletion(
|
|
199
|
-
events: AsyncIterable<PlugEvent>,
|
|
200
|
-
exchange: OpenAiExchange,
|
|
201
|
-
): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
|
|
274
|
+
export async function collectCompletion(events: AsyncIterable<PlugEvent>, exchange: OpenAiExchange): Promise<CollectedChoice> {
|
|
202
275
|
let text = ""
|
|
203
276
|
const calls: ToolCall[] = []
|
|
204
277
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
@@ -229,7 +302,8 @@ export async function collectCompletion(
|
|
|
229
302
|
// With the caller's tools offered, a call to one of them wins over the structured answer.
|
|
230
303
|
const passthrough = exchange.callerTools === true && others.length > 0
|
|
231
304
|
const isStructured = exchange.structuredTool !== undefined && !passthrough
|
|
232
|
-
const
|
|
305
|
+
const fallback = isStructured && answer === undefined && exchange.structuredSchema !== undefined ? textFallback(text, exchange.structuredSchema) : undefined
|
|
306
|
+
const missing = passthrough ? undefined : structured.failure(exchange, answer !== undefined || fallback !== undefined)
|
|
233
307
|
if (missing) return { status: missing.status, body: encodeError(missing) }
|
|
234
308
|
const toolCalls = isStructured ? [] : others
|
|
235
309
|
const message: Record<string, unknown> = {
|
|
@@ -237,7 +311,7 @@ export async function collectCompletion(
|
|
|
237
311
|
content: isStructured
|
|
238
312
|
? answer
|
|
239
313
|
? structured.content(answer)
|
|
240
|
-
: null
|
|
314
|
+
: (fallback ?? null)
|
|
241
315
|
: exchange.structuredTool === undefined && text.length > 0
|
|
242
316
|
? exchange.jsonObject
|
|
243
317
|
? unfence(text)
|
|
@@ -261,6 +335,7 @@ export async function collectCompletion(
|
|
|
261
335
|
choices: [{ index: 0, message, logprobs: null, finish_reason: finishReason(finish, toolCalls.length, isStructured) }],
|
|
262
336
|
...(usage ? { usage: encodeUsage(usage) } : {}),
|
|
263
337
|
},
|
|
338
|
+
...(fallback !== undefined ? { structuredFallback: true } : {}),
|
|
264
339
|
}
|
|
265
340
|
}
|
|
266
341
|
|
|
@@ -303,6 +378,8 @@ export async function streamCompletion(
|
|
|
303
378
|
let started = false
|
|
304
379
|
let calls = 0
|
|
305
380
|
let answered = false
|
|
381
|
+
/** The text of a structured turn (not streamed): it is the answer only if the respond call never comes. */
|
|
382
|
+
let collected = ""
|
|
306
383
|
/** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
|
|
307
384
|
let held: ToolCall | undefined
|
|
308
385
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
@@ -358,6 +435,7 @@ export async function streamCompletion(
|
|
|
358
435
|
}
|
|
359
436
|
continue
|
|
360
437
|
}
|
|
438
|
+
if (event.type === "text-delta") collected += event.text
|
|
361
439
|
if (!(exchange.callerTools && event.type === "tool-call")) continue
|
|
362
440
|
}
|
|
363
441
|
if (exchange.jsonObject && event.type === "text-delta" && !passing) {
|
|
@@ -385,7 +463,8 @@ export async function streamCompletion(
|
|
|
385
463
|
|
|
386
464
|
const passthrough = exchange.callerTools === true && calls > 0
|
|
387
465
|
const isStructured = exchange.structuredTool !== undefined && !passthrough
|
|
388
|
-
|
|
466
|
+
const fallback = isStructured && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
|
|
467
|
+
failure ??= finish ? (passthrough ? undefined : structured.failure(exchange, answered || fallback !== undefined)) : noFinish()
|
|
389
468
|
if (failure) {
|
|
390
469
|
if (!started) return { failure, started }
|
|
391
470
|
if (!choice) {
|
|
@@ -401,6 +480,7 @@ export async function streamCompletion(
|
|
|
401
480
|
if (rest.length > 0) chunk({ content: rest })
|
|
402
481
|
}
|
|
403
482
|
if (held && isStructured) chunk({ content: structured.content(held) })
|
|
483
|
+
if (fallback !== undefined) chunk({ content: fallback })
|
|
404
484
|
chunk({}, finishReason(finish!, calls, isStructured))
|
|
405
485
|
if (choice) return { started, ...(usage ? { usage } : {}) }
|
|
406
486
|
if (exchange.includeUsage && usage) frame(chatUsageChunk(exchange.model, id, created, usage))
|
package/src/openai/route.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenAI front-end route table: `GET /healthz`, `GET /v1/models`, `GET /v1/models/{id}`,
|
|
2
|
+
* OpenAI front-end route table: `GET /healthz`, `GET /readyz`, `GET /v1/models`, `GET /v1/models/{id}`,
|
|
3
3
|
* `POST /v1/chat/completions` (`n` > 1 through `./fanout.ts`), and the dispatch of `POST /v1/responses`
|
|
4
4
|
* and `POST /v1/completions` to `src/responses/route.ts` and `src/completions/route.ts`.
|
|
5
5
|
* Observability and first-run routes (`src/discovery.ts`, `src/metrics.ts`): `GET /` and `GET /v1`
|
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
24
|
-
import {
|
|
24
|
+
import { serveAdmin } from "../admin/route.js"
|
|
25
|
+
import { DEFAULT_APP, authenticate, authenticateUsage, bodyLimits, type PlugboardOptions } from "../config.js"
|
|
25
26
|
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
|
|
26
|
-
import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
|
|
27
|
+
import { buildDiscovery, buildStatus, readiness, renderDiscoveryHtml, sendText } from "../discovery.js"
|
|
27
28
|
import {
|
|
28
29
|
BodyError,
|
|
30
|
+
bodyPlugError,
|
|
29
31
|
RequestAborted,
|
|
30
32
|
beginRequest,
|
|
31
33
|
boundEvents,
|
|
@@ -45,19 +47,22 @@ import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
|
45
47
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
46
48
|
import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
|
|
47
49
|
import { reportCache } from "../cache.js"
|
|
48
|
-
import { reportPolicy } from "../policy.js"
|
|
50
|
+
import { reportPolicy, reportServerTools } from "../policy.js"
|
|
49
51
|
import { reportUsage } from "../usage-header.js"
|
|
50
52
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
51
53
|
import { serveCompletions } from "../completions/route.js"
|
|
52
54
|
import { serveResponses } from "../responses/route.js"
|
|
53
55
|
import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
54
56
|
import { budgetFor, budgetStatus } from "../budgets.js"
|
|
57
|
+
import type { KeyStore } from "../keys.js"
|
|
55
58
|
import type { UsageRecorder } from "../usage.js"
|
|
56
59
|
import { DASHBOARD_ROUTES } from "../dashboard/page.js"
|
|
57
60
|
import { serveDashboard } from "../dashboard/assets.js"
|
|
61
|
+
import { bodyUser, noteEndUser } from "../user.js"
|
|
62
|
+
import { setSessionLink } from "../session/link.js"
|
|
58
63
|
import { chatCodec, serveChoices } from "./fanout.js"
|
|
59
64
|
import { parseChatRequest } from "./request.js"
|
|
60
|
-
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
65
|
+
import { STRUCTURED_FALLBACK_HEADER, collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
61
66
|
import { dropRejected } from "./sampling.js"
|
|
62
67
|
|
|
63
68
|
/** What the serving generation supplies per request. */
|
|
@@ -72,6 +77,8 @@ export interface RouteHost {
|
|
|
72
77
|
readonly status?: () => RuntimeStatus
|
|
73
78
|
/** Per-app usage (`src/usage.ts`): fed from the request log point, and served by `GET /v1/usage` when present. */
|
|
74
79
|
readonly usage?: UsageRecorder
|
|
80
|
+
/** Runtime app keys (src/keys.ts, #55): their digests authenticate, and the admin routes (src/admin/route.ts) read them. */
|
|
81
|
+
readonly keys?: KeyStore
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
const SESSION_HEADER = "x-plugboard-session"
|
|
@@ -116,6 +123,8 @@ interface RequestInfo extends ServedInfo {
|
|
|
116
123
|
failed?: string
|
|
117
124
|
/** Sampling parameters dropped for this model (comma list), when any. */
|
|
118
125
|
dropped?: string
|
|
126
|
+
/** The end user the body named, when kept (src/user.ts). */
|
|
127
|
+
user?: string
|
|
119
128
|
}
|
|
120
129
|
|
|
121
130
|
export function createOpenAiRoute(routeHost: RouteHost) {
|
|
@@ -123,6 +132,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
123
132
|
const metrics = routeHost.options.metrics ? (routeHost.metrics ?? processMetrics()) : undefined
|
|
124
133
|
const runtimeStatus = routeHost.status ?? processStatus
|
|
125
134
|
const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
|
|
135
|
+
/** The runtime app keys that authenticate (loaded from storage on first use, #55). */
|
|
136
|
+
const runtimeDigests = async () => (host.keys ? await host.keys.digests() : [])
|
|
126
137
|
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
127
138
|
const known = (route: string) =>
|
|
128
139
|
KNOWN_ROUTES.includes(route) ||
|
|
@@ -161,7 +172,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
161
172
|
if (googleShaped) googleErrors.add(response)
|
|
162
173
|
const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
163
174
|
// Arbitrary client paths stay out of the log.
|
|
164
|
-
info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
175
|
+
info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" || route === "GET /readyz" ? route : "unknown"
|
|
165
176
|
// Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
|
|
166
177
|
if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
|
|
167
178
|
|
|
@@ -170,13 +181,19 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
170
181
|
|
|
171
182
|
if (route === "GET /healthz") return sendJson(response, 200, { ok: true })
|
|
172
183
|
|
|
184
|
+
// Readiness (#160): no key, like /healthz. 503 until a generation serves, the pump runs and the catalog has loaded.
|
|
185
|
+
if (route === "GET /readyz") {
|
|
186
|
+
const verdict = readiness(runtimeStatus())
|
|
187
|
+
return sendJson(response, verdict.ready ? 200 : 503, verdict)
|
|
188
|
+
}
|
|
189
|
+
|
|
173
190
|
// First-run pages: reachable without a key, so what they show depends on the caller (`buildDiscovery`).
|
|
174
191
|
// With `keys` set, a request without credentials gets the minimal page; wrong credentials are a 401.
|
|
175
192
|
if (DISCOVERY_ROUTES.includes(route)) {
|
|
176
193
|
const keyed = Object.keys(host.options.keys).length > 0
|
|
177
194
|
let app: string | undefined = keyed ? undefined : DEFAULT_APP
|
|
178
195
|
if (keyed && request.headers.authorization !== undefined) {
|
|
179
|
-
const auth = authenticate(liveOptions(host.options), request.headers.authorization)
|
|
196
|
+
const auth = authenticate(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
|
|
180
197
|
if (isPlugError(auth)) return fail(response, auth)
|
|
181
198
|
app = auth.app
|
|
182
199
|
}
|
|
@@ -192,9 +209,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
192
209
|
// Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
|
|
193
210
|
if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
|
|
194
211
|
|
|
212
|
+
// Runtime app keys (#55): the admin routes answer the admin key only, and are a 404 without `adminKey`.
|
|
213
|
+
if (path === "/v1/admin/keys" || path.startsWith("/v1/admin/keys/"))
|
|
214
|
+
return serveAdmin({ options: liveOptions(host.options), keys: host.keys }, { path, request, response, scope, info })
|
|
215
|
+
|
|
195
216
|
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
196
217
|
// 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)
|
|
218
|
+
const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization, await runtimeDigests())
|
|
198
219
|
if (isPlugError(auth)) return fail(response, auth)
|
|
199
220
|
info.app = auth.app
|
|
200
221
|
|
|
@@ -252,7 +273,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
252
273
|
const unsupported = checkJsonContentType(request)
|
|
253
274
|
if (unsupported) return fail(response, unsupported)
|
|
254
275
|
|
|
255
|
-
const body = await readJsonBody(request, scope.signal)
|
|
276
|
+
const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
|
|
277
|
+
noteEndUser(response, info, bodyUser(body))
|
|
256
278
|
if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
|
|
257
279
|
if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
|
|
258
280
|
const parsed = parseChatRequest(body, host.options)
|
|
@@ -280,14 +302,16 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
280
302
|
host.caller.stream(call, {
|
|
281
303
|
app: auth.app,
|
|
282
304
|
signal: scope.signal,
|
|
283
|
-
onRecord: (recordID) => {
|
|
305
|
+
onRecord: (recordID, directory) => {
|
|
284
306
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
285
307
|
response.setHeader(SESSION_HEADER, recordID)
|
|
308
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
286
309
|
},
|
|
287
310
|
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
288
311
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
289
312
|
onPolicy: reportPolicy(response),
|
|
290
313
|
onUsage: reportUsage(response, exchange.stream),
|
|
314
|
+
onServerTools: reportServerTools(response, exchange.stream),
|
|
291
315
|
onCache: reportCache(response),
|
|
292
316
|
}),
|
|
293
317
|
scope,
|
|
@@ -313,12 +337,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
313
337
|
}
|
|
314
338
|
|
|
315
339
|
const result = await collectCompletion(events, exchange)
|
|
340
|
+
if (result.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
|
|
316
341
|
sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
|
|
317
342
|
}
|
|
318
343
|
|
|
319
344
|
/** `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
345
|
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)
|
|
346
|
+
const who = authenticateUsage(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
|
|
322
347
|
if (isPlugError(who)) return fail(response, who)
|
|
323
348
|
const app = url.searchParams.get("app") ?? undefined
|
|
324
349
|
if (!who.admin) {
|
|
@@ -330,6 +355,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
330
355
|
from: url.searchParams.get("from") ?? undefined,
|
|
331
356
|
to: url.searchParams.get("to") ?? undefined,
|
|
332
357
|
app: who.admin ? app : who.app,
|
|
358
|
+
user: url.searchParams.get("user") || undefined,
|
|
333
359
|
})
|
|
334
360
|
if (isPlugError(report)) return fail(response, report)
|
|
335
361
|
// Each app's budget state, read from its current window rather than from the report's range (src/budgets.ts).
|
|
@@ -370,8 +396,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
370
396
|
}
|
|
371
397
|
} else if (caught instanceof BodyError) {
|
|
372
398
|
outcome = `body_${caught.status}`
|
|
373
|
-
//
|
|
374
|
-
fail(response,
|
|
399
|
+
// Every refusal but an invalid body (400) may leave the client still sending: answer, then close (see `finishRequest`).
|
|
400
|
+
fail(response, bodyPlugError(caught), caught.status !== 400)
|
|
375
401
|
} else {
|
|
376
402
|
// Unexpected: the message goes to the log only; the client gets a generic one.
|
|
377
403
|
outcome = "error"
|
|
@@ -396,10 +422,11 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
396
422
|
} catch {}
|
|
397
423
|
const ms = Date.now() - started
|
|
398
424
|
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 })
|
|
400
|
-
// Probes and scrapes that succeed are not worth a line each.
|
|
401
|
-
|
|
402
|
-
|
|
425
|
+
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 }) })
|
|
426
|
+
// Probes and scrapes that succeed are not worth a line each. A readiness probe is never logged: it repeats until the backend is ready.
|
|
427
|
+
const probe = info.route === "GET /readyz" || ((info.route === "GET /healthz" || info.route === "GET /metrics") && outcome === "ok")
|
|
428
|
+
if (!probe) {
|
|
429
|
+
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
430
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
404
431
|
else log("request", fields)
|
|
405
432
|
}
|
package/src/policy.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ServerResponse } from "node:http"
|
|
|
4
4
|
import type { PolicyNote } from "./contract.js"
|
|
5
5
|
|
|
6
6
|
export const POLICY_HEADER = "x-plugboard-policy"
|
|
7
|
+
export const SERVER_TOOLS_HEADER = "x-plugboard-server-tools"
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The `CallContext.onPolicy` of a route: `x-plugboard-policy` (the notes, comma separated) while the
|
|
@@ -15,3 +16,18 @@ export function reportPolicy(response: ServerResponse): (notes: ReadonlyArray<Po
|
|
|
15
16
|
response.setHeader(POLICY_HEADER, notes.join(","))
|
|
16
17
|
}
|
|
17
18
|
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The `CallContext.onServerTools` of a route: `x-plugboard-server-tools` with the number of server tool calls
|
|
22
|
+
* OpenCode ran for the answer, while the headers are open. Choices of `n` add up (each one reports once,
|
|
23
|
+
* when it ends, and the body is not sent before all of them). A stream gets no callback: its headers go first.
|
|
24
|
+
*/
|
|
25
|
+
export function reportServerTools(response: ServerResponse, stream: boolean): ((count: number) => void) | undefined {
|
|
26
|
+
if (stream) return undefined
|
|
27
|
+
let total = 0
|
|
28
|
+
return (count) => {
|
|
29
|
+
if (count <= 0 || response.headersSent || response.destroyed) return
|
|
30
|
+
total += count
|
|
31
|
+
response.setHeader(SERVER_TOOLS_HEADER, String(total))
|
|
32
|
+
}
|
|
33
|
+
}
|
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,
|
|
@@ -384,7 +386,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
|
|
|
384
386
|
truncation: "disabled",
|
|
385
387
|
user: typeof body.user === "string" ? body.user : null,
|
|
386
388
|
},
|
|
387
|
-
...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
389
|
+
...(respond ? { structuredTool: RESPOND_TOOL, structuredSchema: respond.parameters, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
388
390
|
},
|
|
389
391
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
390
392
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* call passes a no-op `emit`. Reasoning deltas are dropped (no reasoning items are produced).
|
|
8
8
|
*/
|
|
9
9
|
import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
10
|
+
import { structuredMissing, textFallback } from "../openai/response.js"
|
|
10
11
|
|
|
11
12
|
type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
12
13
|
|
|
@@ -25,6 +26,8 @@ export interface ResponsesExchange {
|
|
|
25
26
|
* answer is the message text only when the step called no caller tool.
|
|
26
27
|
*/
|
|
27
28
|
readonly callerTools?: boolean
|
|
29
|
+
/** The caller's `text.format` schema: a text answer is taken when the model skipped the respond call (`textFallback`, #173). */
|
|
30
|
+
readonly structuredSchema?: Readonly<Record<string, unknown>>
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/** A stream event as emitted: `type` is also the SSE event name. */
|
|
@@ -37,6 +40,8 @@ export interface ResponseRun {
|
|
|
37
40
|
readonly started: boolean
|
|
38
41
|
/** The final response object (completed, incomplete or failed once started). */
|
|
39
42
|
readonly response?: object
|
|
43
|
+
/** The structured answer came from the final text, not the respond call (`textFallback`). */
|
|
44
|
+
readonly structuredFallback?: boolean
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
type Item = Record<string, unknown>
|
|
@@ -63,21 +68,6 @@ const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Mod
|
|
|
63
68
|
/** SSE frame of one stream event: `event: <type>` then `data: <json>`. */
|
|
64
69
|
export const sseFrame = (event: ResponsesStreamEvent): string => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`
|
|
65
70
|
|
|
66
|
-
/**
|
|
67
|
-
* Structured-output projection, same rules as the Chat Completions front-end: in structured mode
|
|
68
|
-
* the message text is exactly the arguments of the first call to the `respond` tool; prose and
|
|
69
|
-
* other calls are suppressed. Without an answer the exchange fails, except when the model
|
|
70
|
-
* stopped for `length` or `content-filter`, which keep their status with empty output.
|
|
71
|
-
*/
|
|
72
|
-
const structuredFailure = (exchange: ResponsesExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
|
|
73
|
-
exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
|
|
74
|
-
? undefined
|
|
75
|
-
: {
|
|
76
|
-
status: 502,
|
|
77
|
-
kind: "server",
|
|
78
|
-
code: "structured_output_missing",
|
|
79
|
-
message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
|
|
80
|
-
}
|
|
81
71
|
|
|
82
72
|
/**
|
|
83
73
|
* `committed`: whether the transport already committed a 200 stream before any event was emitted
|
|
@@ -114,6 +104,8 @@ export async function runResponse(
|
|
|
114
104
|
let started = false
|
|
115
105
|
let calls = 0
|
|
116
106
|
let answered = false
|
|
107
|
+
/** The text of a structured turn (not emitted): it is the answer only if the respond call never comes (#173). */
|
|
108
|
+
let collected = ""
|
|
117
109
|
/** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
|
|
118
110
|
let held: ToolCall | undefined
|
|
119
111
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
@@ -199,6 +191,7 @@ export async function runResponse(
|
|
|
199
191
|
}
|
|
200
192
|
continue
|
|
201
193
|
}
|
|
194
|
+
if (event.type === "text-delta") collected += event.text
|
|
202
195
|
if (!(exchange.callerTools && event.type === "tool-call")) continue
|
|
203
196
|
}
|
|
204
197
|
if (event.type === "text-delta") text(event.text)
|
|
@@ -207,7 +200,9 @@ export async function runResponse(
|
|
|
207
200
|
|
|
208
201
|
const passthrough = exchange.callerTools === true && calls > 0
|
|
209
202
|
const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
|
|
210
|
-
|
|
203
|
+
const fallback = structuredAnswer && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
|
|
204
|
+
const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing(exchange.structuredTool)
|
|
205
|
+
failure ??= finish ? missing : noFinish()
|
|
211
206
|
if (failure) {
|
|
212
207
|
if (!started && !committed()) return { failure, started }
|
|
213
208
|
start()
|
|
@@ -221,6 +216,7 @@ export async function runResponse(
|
|
|
221
216
|
}
|
|
222
217
|
start()
|
|
223
218
|
if (held && structuredAnswer) text(args(held.input))
|
|
219
|
+
if (fallback !== undefined) text(fallback)
|
|
224
220
|
closeMessage()
|
|
225
221
|
const incomplete = finish === "length" ? "max_output_tokens" : finish === "content-filter" ? "content_filter" : undefined
|
|
226
222
|
const response = envelope(incomplete ? "incomplete" : "completed", items, {
|
|
@@ -228,5 +224,5 @@ export async function runResponse(
|
|
|
228
224
|
...(usage ? { usage: encodeUsage(usage) } : {}),
|
|
229
225
|
})
|
|
230
226
|
send(incomplete ? "response.incomplete" : "response.completed", { response })
|
|
231
|
-
return { started, response }
|
|
227
|
+
return { started, response, ...(fallback !== undefined ? { structuredFallback: true } : {}) }
|
|
232
228
|
}
|
package/src/responses/route.ts
CHANGED
|
@@ -14,9 +14,10 @@ import { reportServed, type ServedInfo } from "../fallback.js"
|
|
|
14
14
|
import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
15
15
|
import { resolveModel } from "../models.js"
|
|
16
16
|
import { reportCache } from "../cache.js"
|
|
17
|
-
import { reportPolicy } from "../policy.js"
|
|
17
|
+
import { reportPolicy, reportServerTools } from "../policy.js"
|
|
18
18
|
import { reportUsage } from "../usage-header.js"
|
|
19
|
-
import {
|
|
19
|
+
import { setSessionLink } from "../session/link.js"
|
|
20
|
+
import { STRUCTURED_FALLBACK_HEADER, encodeError } from "../openai/response.js"
|
|
20
21
|
import type { RouteHost } from "../openai/route.js"
|
|
21
22
|
import { parseResponsesRequest } from "./request.js"
|
|
22
23
|
import { runResponse, sseFrame } from "./response.js"
|
|
@@ -51,13 +52,15 @@ 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),
|
|
60
62
|
onUsage: reportUsage(response, exchange.stream),
|
|
63
|
+
onServerTools: reportServerTools(response, exchange.stream),
|
|
61
64
|
onCache: reportCache(response),
|
|
62
65
|
}),
|
|
63
66
|
scope,
|
|
@@ -79,5 +82,6 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
79
82
|
const run = await runResponse(events, exchange, () => {})
|
|
80
83
|
if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
81
84
|
rememberAnswer(app, run.response)
|
|
85
|
+
if (run.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
|
|
82
86
|
sendJson(response, 200, run.response)
|
|
83
87
|
}
|
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). */
|