opencode-plugboard 0.20.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 +30 -23
- package/package.json +10 -4
- package/src/anthropic/response.ts +5 -3
- package/src/anthropic/route.ts +6 -4
- package/src/cache.ts +16 -5
- package/src/completions/request.ts +13 -4
- package/src/completions/response.ts +52 -10
- package/src/config.ts +69 -3
- package/src/contract.ts +6 -0
- package/src/discovery.ts +27 -0
- package/src/gemini/request.ts +3 -2
- package/src/gemini/response.ts +20 -20
- package/src/gemini/route.ts +7 -3
- package/src/http.ts +65 -7
- package/src/openai/fanout.ts +19 -5
- package/src/openai/request.ts +4 -4
- package/src/openai/response.ts +99 -19
- package/src/openai/route.ts +21 -11
- package/src/policy.ts +16 -0
- package/src/responses/request.ts +1 -1
- package/src/responses/response.ts +13 -17
- package/src/responses/route.ts +4 -2
- package/src/runtime.ts +51 -7
- package/src/session/caller.ts +69 -7
- package/src/session/context.ts +16 -4
- package/src/session/events.ts +116 -12
- package/src/session/host.ts +14 -0
- package/src/session/seeds.ts +10 -0
- package/tui.ts +5 -4
package/src/http.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
8
8
|
import { raceAbort } from "./abort.js"
|
|
9
|
-
import { isLoopbackHost, type PlugboardOptions } from "./config.js"
|
|
9
|
+
import { BODY_LIMIT_DEFAULTS, type BodyLimits, isLoopbackHost, type PlugboardOptions } from "./config.js"
|
|
10
10
|
import type { PlugError, PlugEvent } from "./contract.js"
|
|
11
11
|
|
|
12
12
|
const MAX_BODY_BYTES = 32 * 1024 * 1024
|
|
@@ -19,16 +19,44 @@ const DEADLINE_GRACE_MS = 2_000
|
|
|
19
19
|
/** An SSE stream idle for this long gets a comment line, so a client with a header or idle timeout keeps waiting for a model that is still reasoning. */
|
|
20
20
|
const KEEPALIVE_MS = 15_000
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* A request body that cannot be accepted: too large, not JSON, not complete in time (408), or refused
|
|
24
|
+
* because the process has no room for it (503). `status` is the HTTP status to answer with; `code` and
|
|
25
|
+
* `retryAfterSeconds` go to the wire (`bodyPlugError`).
|
|
26
|
+
*/
|
|
23
27
|
export class BodyError extends Error {
|
|
24
28
|
constructor(
|
|
25
29
|
readonly status: number,
|
|
26
30
|
message: string,
|
|
31
|
+
readonly code?: string,
|
|
32
|
+
readonly retryAfterSeconds?: number,
|
|
27
33
|
) {
|
|
28
34
|
super(message)
|
|
29
35
|
}
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/** The `PlugError` a refused body answers with: `server_busy` (503) is a server kind, a slow body (408) a timeout. */
|
|
39
|
+
export function bodyPlugError(error: BodyError): PlugError {
|
|
40
|
+
const kind: PlugError["kind"] = error.status === 503 ? "server" : error.status === 408 ? "timeout" : "invalid_request"
|
|
41
|
+
return {
|
|
42
|
+
status: error.status,
|
|
43
|
+
kind,
|
|
44
|
+
message: error.message,
|
|
45
|
+
...(error.code === undefined ? {} : { code: error.code }),
|
|
46
|
+
...(error.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: error.retryAfterSeconds }),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Process-wide bytes of request bodies being read. On `globalThis` under a versioned key (I6), so every plugin generation shares one budget. */
|
|
51
|
+
interface BodyBudget {
|
|
52
|
+
inFlight: number
|
|
53
|
+
}
|
|
54
|
+
const BODY_BUDGET = Symbol.for("opencode-plugboard.body-budget/1")
|
|
55
|
+
const bodyBudget = (): BodyBudget => {
|
|
56
|
+
const slot = globalThis as Record<symbol, BodyBudget | undefined>
|
|
57
|
+
return (slot[BODY_BUDGET] ??= { inFlight: 0 })
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
/** Why a request's work was abandoned: the client went away, or the deadline passed. It is the abort reason of `RequestScope.signal`. */
|
|
33
61
|
export class RequestAborted extends Error {
|
|
34
62
|
constructor(readonly kind: "disconnected" | "timeout") {
|
|
@@ -105,21 +133,40 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
|
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
/**
|
|
108
|
-
* Reads the body as JSON (an empty body is `{}`). Rejects with `BodyError` (413, 400), the
|
|
109
|
-
* reason of `signal` or the stream's error. On rejection the rest of the body is discarded, not
|
|
136
|
+
* Reads the body as JSON (an empty body is `{}`). Rejects with `BodyError` (413, 400, 408 or 503), the
|
|
137
|
+
* abort reason of `signal` or the stream's error. On rejection the rest of the body is discarded, not
|
|
110
138
|
* buffered, so the response can still be delivered (see `finishRequest`).
|
|
139
|
+
*
|
|
140
|
+
* Bounded before it is buffered (#156, invariant I28). The bytes held by every body being read in the
|
|
141
|
+
* process stay within `limits.maxBytesInFlight`: a declared Content-Length is charged before the first
|
|
142
|
+
* byte is read, a body without one as it arrives, and a body that would go over is refused with 503
|
|
143
|
+
* `server_busy` (Retry-After 1). A body not complete within `limits.readTimeoutMs` is refused with 408.
|
|
111
144
|
*/
|
|
112
|
-
export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Promise<unknown> {
|
|
145
|
+
export function readJsonBody(request: IncomingMessage, signal: AbortSignal, limits: BodyLimits = BODY_LIMIT_DEFAULTS): Promise<unknown> {
|
|
113
146
|
return new Promise((resolve, reject) => {
|
|
114
|
-
const declared = Number(request.headers["content-length"])
|
|
115
|
-
if (
|
|
147
|
+
const declared = Math.max(0, Number(request.headers["content-length"]) || 0)
|
|
148
|
+
if (declared > MAX_BODY_BYTES) {
|
|
116
149
|
request.resume()
|
|
117
150
|
return reject(new BodyError(413, "Request body too large"))
|
|
118
151
|
}
|
|
119
152
|
if (signal.aborted) return reject(signal.reason)
|
|
153
|
+
const budget = bodyBudget()
|
|
120
154
|
const chunks: Buffer[] = []
|
|
121
155
|
let size = 0
|
|
156
|
+
// Bytes this read holds in the budget. Released on every exit: complete, refused or abandoned.
|
|
157
|
+
let held = 0
|
|
158
|
+
const charge = (bytes: number): boolean => {
|
|
159
|
+
if (budget.inFlight + bytes > limits.maxBytesInFlight) return false
|
|
160
|
+
budget.inFlight += bytes
|
|
161
|
+
held += bytes
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
const busy = () => new BodyError(503, "Too many request bodies are being read; retry shortly", "server_busy", 1)
|
|
165
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
122
166
|
const cleanup = () => {
|
|
167
|
+
clearTimeout(timer)
|
|
168
|
+
budget.inFlight -= held
|
|
169
|
+
held = 0
|
|
123
170
|
request.off("data", onData)
|
|
124
171
|
request.off("error", onError)
|
|
125
172
|
request.off("end", onEnd)
|
|
@@ -134,6 +181,7 @@ export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Pro
|
|
|
134
181
|
const onData = (chunk: Buffer) => {
|
|
135
182
|
size += chunk.length
|
|
136
183
|
if (size > MAX_BODY_BYTES) return stop(new BodyError(413, "Request body too large"))
|
|
184
|
+
if (size > held && !charge(size - held)) return stop(busy())
|
|
137
185
|
chunks.push(chunk)
|
|
138
186
|
}
|
|
139
187
|
const onError = (error: Error) => stop(error)
|
|
@@ -146,6 +194,16 @@ export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Pro
|
|
|
146
194
|
reject(new BodyError(400, "Request body is not valid JSON"))
|
|
147
195
|
}
|
|
148
196
|
}
|
|
197
|
+
// The declared length is charged before the first byte is read, so a refusal costs no buffering.
|
|
198
|
+
if (declared > 0 && !charge(declared)) {
|
|
199
|
+
request.resume()
|
|
200
|
+
return reject(busy())
|
|
201
|
+
}
|
|
202
|
+
timer = setTimeout(
|
|
203
|
+
() => stop(new BodyError(408, `Request body was not received within ${limits.readTimeoutMs} ms (option limits.bodyReadTimeoutMs)`, "body_read_timeout")),
|
|
204
|
+
limits.readTimeoutMs,
|
|
205
|
+
)
|
|
206
|
+
timer.unref?.()
|
|
149
207
|
request.on("data", onData)
|
|
150
208
|
request.on("error", onError)
|
|
151
209
|
request.on("end", onEnd)
|
package/src/openai/fanout.ts
CHANGED
|
@@ -24,11 +24,21 @@ import type { ServerResponse } from "node:http"
|
|
|
24
24
|
import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.js"
|
|
25
25
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
26
26
|
import { reportCache } from "../cache.js"
|
|
27
|
-
import { reportPolicy } from "../policy.js"
|
|
27
|
+
import { reportPolicy, reportServerTools } from "../policy.js"
|
|
28
28
|
import { reportUsage } from "../usage-header.js"
|
|
29
29
|
import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
|
|
30
30
|
import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
STRUCTURED_FALLBACK_HEADER,
|
|
33
|
+
chatUsageChunk,
|
|
34
|
+
collectCompletion,
|
|
35
|
+
encodeError,
|
|
36
|
+
streamCompletion,
|
|
37
|
+
type CollectedChoice,
|
|
38
|
+
type OpenAiExchange,
|
|
39
|
+
type StreamChoice,
|
|
40
|
+
type StreamResult,
|
|
41
|
+
} from "./response.js"
|
|
32
42
|
import type { RouteHost } from "./route.js"
|
|
33
43
|
|
|
34
44
|
const SESSION_HEADER = "x-plugboard-session"
|
|
@@ -40,7 +50,7 @@ export interface ChoiceCodec {
|
|
|
40
50
|
/** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
|
|
41
51
|
readonly idPrefix: string
|
|
42
52
|
/** One choice as a complete JSON body with `choices[0]` and `usage`. */
|
|
43
|
-
collect(events: AsyncIterable<PlugEvent>, index: number): Promise<
|
|
53
|
+
collect(events: AsyncIterable<PlugEvent>, index: number): Promise<CollectedChoice>
|
|
44
54
|
/** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
|
|
45
55
|
streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
|
|
46
56
|
/** The final chunk with the summed usage (`include_usage`). */
|
|
@@ -175,6 +185,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
|
|
|
175
185
|
const policy = reportPolicy(response)
|
|
176
186
|
// One header for all choices: each answered call adds its tokens and cost (`x-plugboard-usage`, #52).
|
|
177
187
|
const usageHeader = reportUsage(response, codec.stream)
|
|
188
|
+
// One header for all choices too: the server tool calls of every choice add up (`x-plugboard-server-tools`).
|
|
189
|
+
const serverToolsHeader = reportServerTools(response, codec.stream)
|
|
178
190
|
// Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
|
|
179
191
|
const cache = reportCache(response)
|
|
180
192
|
const open = (index: number) =>
|
|
@@ -195,6 +207,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
|
|
|
195
207
|
onServed: served,
|
|
196
208
|
onPolicy: policy,
|
|
197
209
|
onUsage: usageHeader,
|
|
210
|
+
onServerTools: serverToolsHeader,
|
|
198
211
|
onCache: cache,
|
|
199
212
|
noCache: calls.length > 1,
|
|
200
213
|
}),
|
|
@@ -202,8 +215,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
|
|
|
202
215
|
)
|
|
203
216
|
|
|
204
217
|
if (!codec.stream) {
|
|
205
|
-
const results = await new Promise<
|
|
206
|
-
const done:
|
|
218
|
+
const results = await new Promise<CollectedChoice[]>((resolve, reject) => {
|
|
219
|
+
const done: CollectedChoice[] = []
|
|
207
220
|
let left = calls.length
|
|
208
221
|
calls.forEach((_, index) => {
|
|
209
222
|
codec.collect(open(index), index).then(
|
|
@@ -223,6 +236,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
|
|
|
223
236
|
})
|
|
224
237
|
})
|
|
225
238
|
const first = results[0]!
|
|
239
|
+
if (results.some((result) => result.structuredFallback)) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
|
|
226
240
|
return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
|
|
227
241
|
}
|
|
228
242
|
|
package/src/openai/request.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface ParsedChat {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** Name of the forced tool that carries structured output. */
|
|
35
|
-
const RESPOND_TOOL = "respond"
|
|
35
|
+
export const RESPOND_TOOL = "respond"
|
|
36
36
|
|
|
37
37
|
/** The most stop sequences OpenAI accepts. */
|
|
38
38
|
const MAX_STOP = 4
|
|
@@ -196,14 +196,14 @@ const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError =
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/** System instruction for `json_object`: no schema, so the caller's prompt describes the keys (OpenAI semantics). */
|
|
199
|
-
const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
|
|
199
|
+
export const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
202
|
* `json_schema` -> the `respond` tool (`structured`; forced unless the caller also sends tools).
|
|
203
203
|
* `json_object` -> a system instruction and plain text content (`instruction`): there is no schema
|
|
204
204
|
* to force a tool with.
|
|
205
205
|
*/
|
|
206
|
-
const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
|
|
206
|
+
export const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
|
|
207
207
|
if (raw == null) return {}
|
|
208
208
|
if (!isRecord(raw)) return bad("response_format must be an object", "response_format")
|
|
209
209
|
if (raw.type === "text") return {}
|
|
@@ -339,7 +339,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
|
|
|
339
339
|
model: body.model,
|
|
340
340
|
stream: body.stream === true,
|
|
341
341
|
includeUsage: streamOptions.include_usage === true,
|
|
342
|
-
...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
342
|
+
...(respond ? { structuredTool: RESPOND_TOOL, structuredSchema: respond.parameters, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
343
343
|
...(format.instruction ? { jsonObject: true } : {}),
|
|
344
344
|
...(generation.stop ? { stop: generation.stop } : {}),
|
|
345
345
|
},
|
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. */
|
|
@@ -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`
|
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
|
|
23
23
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
24
24
|
import { serveAdmin } from "../admin/route.js"
|
|
25
|
-
import { DEFAULT_APP, authenticate, authenticateUsage, type PlugboardOptions } from "../config.js"
|
|
25
|
+
import { DEFAULT_APP, authenticate, authenticateUsage, bodyLimits, type PlugboardOptions } from "../config.js"
|
|
26
26
|
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
|
|
27
|
-
import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
|
|
27
|
+
import { buildDiscovery, buildStatus, readiness, renderDiscoveryHtml, sendText } from "../discovery.js"
|
|
28
28
|
import {
|
|
29
29
|
BodyError,
|
|
30
|
+
bodyPlugError,
|
|
30
31
|
RequestAborted,
|
|
31
32
|
beginRequest,
|
|
32
33
|
boundEvents,
|
|
@@ -46,7 +47,7 @@ import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
|
46
47
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
47
48
|
import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
|
|
48
49
|
import { reportCache } from "../cache.js"
|
|
49
|
-
import { reportPolicy } from "../policy.js"
|
|
50
|
+
import { reportPolicy, reportServerTools } from "../policy.js"
|
|
50
51
|
import { reportUsage } from "../usage-header.js"
|
|
51
52
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
52
53
|
import { serveCompletions } from "../completions/route.js"
|
|
@@ -61,7 +62,7 @@ import { bodyUser, noteEndUser } from "../user.js"
|
|
|
61
62
|
import { setSessionLink } from "../session/link.js"
|
|
62
63
|
import { chatCodec, serveChoices } from "./fanout.js"
|
|
63
64
|
import { parseChatRequest } from "./request.js"
|
|
64
|
-
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
65
|
+
import { STRUCTURED_FALLBACK_HEADER, collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
65
66
|
import { dropRejected } from "./sampling.js"
|
|
66
67
|
|
|
67
68
|
/** What the serving generation supplies per request. */
|
|
@@ -171,7 +172,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
171
172
|
if (googleShaped) googleErrors.add(response)
|
|
172
173
|
const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
173
174
|
// Arbitrary client paths stay out of the log.
|
|
174
|
-
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"
|
|
175
176
|
// Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
|
|
176
177
|
if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
|
|
177
178
|
|
|
@@ -180,6 +181,12 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
180
181
|
|
|
181
182
|
if (route === "GET /healthz") return sendJson(response, 200, { ok: true })
|
|
182
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
|
+
|
|
183
190
|
// First-run pages: reachable without a key, so what they show depends on the caller (`buildDiscovery`).
|
|
184
191
|
// With `keys` set, a request without credentials gets the minimal page; wrong credentials are a 401.
|
|
185
192
|
if (DISCOVERY_ROUTES.includes(route)) {
|
|
@@ -266,7 +273,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
266
273
|
const unsupported = checkJsonContentType(request)
|
|
267
274
|
if (unsupported) return fail(response, unsupported)
|
|
268
275
|
|
|
269
|
-
const body = await readJsonBody(request, scope.signal)
|
|
276
|
+
const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
|
|
270
277
|
noteEndUser(response, info, bodyUser(body))
|
|
271
278
|
if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
|
|
272
279
|
if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
|
|
@@ -304,6 +311,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
304
311
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
305
312
|
onPolicy: reportPolicy(response),
|
|
306
313
|
onUsage: reportUsage(response, exchange.stream),
|
|
314
|
+
onServerTools: reportServerTools(response, exchange.stream),
|
|
307
315
|
onCache: reportCache(response),
|
|
308
316
|
}),
|
|
309
317
|
scope,
|
|
@@ -329,6 +337,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
329
337
|
}
|
|
330
338
|
|
|
331
339
|
const result = await collectCompletion(events, exchange)
|
|
340
|
+
if (result.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
|
|
332
341
|
sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
|
|
333
342
|
}
|
|
334
343
|
|
|
@@ -387,8 +396,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
387
396
|
}
|
|
388
397
|
} else if (caught instanceof BodyError) {
|
|
389
398
|
outcome = `body_${caught.status}`
|
|
390
|
-
//
|
|
391
|
-
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)
|
|
392
401
|
} else {
|
|
393
402
|
// Unexpected: the message goes to the log only; the client gets a generic one.
|
|
394
403
|
outcome = "error"
|
|
@@ -414,8 +423,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
414
423
|
const ms = Date.now() - started
|
|
415
424
|
metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
|
|
416
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 }) })
|
|
417
|
-
// Probes and scrapes that succeed are not worth a line each.
|
|
418
|
-
|
|
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) {
|
|
419
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 }
|
|
420
430
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
421
431
|
else log("request", fields)
|
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
|
@@ -386,7 +386,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
|
|
|
386
386
|
truncation: "disabled",
|
|
387
387
|
user: typeof body.user === "string" ? body.user : null,
|
|
388
388
|
},
|
|
389
|
-
...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
389
|
+
...(respond ? { structuredTool: RESPOND_TOOL, structuredSchema: respond.parameters, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
390
390
|
},
|
|
391
391
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
392
392
|
}
|