opencode-plugboard 0.18.0 → 0.19.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 +27 -14
- package/package.json +1 -1
- package/src/anthropic/response.ts +8 -25
- package/src/anthropic/route.ts +2 -0
- package/src/budgets.ts +200 -0
- package/src/completions/request.ts +1 -0
- package/src/completions/response.ts +10 -1
- package/src/config.ts +98 -1
- package/src/contract.ts +14 -0
- package/src/gemini/request.ts +1 -0
- package/src/gemini/response.ts +16 -1
- package/src/gemini/route.ts +2 -0
- package/src/log.ts +3 -0
- package/src/metrics.ts +3 -3
- package/src/openai/fanout.ts +4 -0
- package/src/openai/request.ts +1 -0
- package/src/openai/response.ts +21 -3
- package/src/openai/route.ts +24 -2
- package/src/responses/request.ts +28 -14
- package/src/responses/route.ts +9 -4
- package/src/responses/store.ts +51 -0
- package/src/runtime.ts +10 -2
- package/src/stop.ts +54 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +43 -3
package/src/gemini/route.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, s
|
|
|
21
21
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
22
22
|
import { reportCache } from "../cache.js"
|
|
23
23
|
import { reportPolicy } from "../policy.js"
|
|
24
|
+
import { reportUsage } from "../usage-header.js"
|
|
24
25
|
import type { RouteHost } from "../openai/route.js"
|
|
25
26
|
import { dropRejected } from "../openai/sampling.js"
|
|
26
27
|
import { estimateTokens } from "../anthropic/tokens.js"
|
|
@@ -162,6 +163,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
162
163
|
},
|
|
163
164
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
164
165
|
onPolicy: reportPolicy(response),
|
|
166
|
+
onUsage: reportUsage(response, match.stream),
|
|
165
167
|
onCache: reportCache(response),
|
|
166
168
|
}),
|
|
167
169
|
scope,
|
package/src/log.ts
CHANGED
|
@@ -142,5 +142,8 @@ export const log = (event: string, fields?: LogFields): void => state().logger.i
|
|
|
142
142
|
|
|
143
143
|
export const logError = (event: string, fields?: LogFields): void => state().logger.error(record(event, fields))
|
|
144
144
|
|
|
145
|
+
/** A line for something that needs attention but did not fail a request (a budget at 80%). Info-level destination, `warn` in the level field. */
|
|
146
|
+
export const logWarn = (event: string, fields?: LogFields): void => state().logger.warn(record(event, fields))
|
|
147
|
+
|
|
145
148
|
/** The message of a thrown value, without its stack or attached properties. */
|
|
146
149
|
export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
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/openai/fanout.ts
CHANGED
|
@@ -25,6 +25,7 @@ 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"
|
|
28
29
|
import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
29
30
|
import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
|
|
30
31
|
import type { RouteHost } from "./route.js"
|
|
@@ -169,6 +170,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
169
170
|
if (calls.length === 1) codec.setModel(s.model)
|
|
170
171
|
})
|
|
171
172
|
const policy = reportPolicy(response)
|
|
173
|
+
// One header for all choices: each answered call adds its tokens and cost (`x-plugboard-usage`, #52).
|
|
174
|
+
const usageHeader = reportUsage(response, codec.stream)
|
|
172
175
|
// Each choice is a fresh sample: the response cache never serves or stores fan-out calls.
|
|
173
176
|
const cache = reportCache(response)
|
|
174
177
|
const open = (index: number) =>
|
|
@@ -183,6 +186,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
183
186
|
},
|
|
184
187
|
onServed: served,
|
|
185
188
|
onPolicy: policy,
|
|
189
|
+
onUsage: usageHeader,
|
|
186
190
|
onCache: cache,
|
|
187
191
|
noCache: calls.length > 1,
|
|
188
192
|
}),
|
package/src/openai/request.ts
CHANGED
|
@@ -339,6 +339,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
|
|
|
339
339
|
includeUsage: streamOptions.include_usage === true,
|
|
340
340
|
...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
341
341
|
...(format.instruction ? { jsonObject: true } : {}),
|
|
342
|
+
...(generation.stop ? { stop: generation.stop } : {}),
|
|
342
343
|
},
|
|
343
344
|
...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
|
|
344
345
|
}
|
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. */
|
|
@@ -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
|
@@ -46,10 +46,12 @@ import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
|
46
46
|
import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
|
|
47
47
|
import { reportCache } from "../cache.js"
|
|
48
48
|
import { reportPolicy } from "../policy.js"
|
|
49
|
+
import { reportUsage } from "../usage-header.js"
|
|
49
50
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
50
51
|
import { serveCompletions } from "../completions/route.js"
|
|
51
52
|
import { serveResponses } from "../responses/route.js"
|
|
52
53
|
import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
54
|
+
import { budgetFor, budgetStatus } from "../budgets.js"
|
|
53
55
|
import type { UsageRecorder } from "../usage.js"
|
|
54
56
|
import { DASHBOARD_ROUTES } from "../dashboard/page.js"
|
|
55
57
|
import { serveDashboard } from "../dashboard/assets.js"
|
|
@@ -154,7 +156,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
154
156
|
const path = url.pathname
|
|
155
157
|
const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
|
|
156
158
|
const gemini = matchGeminiRoute(request.method, path)
|
|
157
|
-
|
|
159
|
+
// Any /v1beta path is Google's API, even one that matches no Gemini route (#150): its errors use Google's body.
|
|
160
|
+
const googleShaped = gemini !== undefined || path === "/v1beta" || path.startsWith("/v1beta/")
|
|
161
|
+
if (googleShaped) googleErrors.add(response)
|
|
158
162
|
const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
159
163
|
// Arbitrary client paths stay out of the log.
|
|
160
164
|
info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
@@ -190,10 +194,19 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
190
194
|
|
|
191
195
|
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
192
196
|
// Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
|
|
193
|
-
const auth = authenticate(liveOptions(host.options),
|
|
197
|
+
const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization)
|
|
194
198
|
if (isPlugError(auth)) return fail(response, auth)
|
|
195
199
|
info.app = auth.app
|
|
196
200
|
|
|
201
|
+
// A /v1beta path no Gemini route matches: the AI SDK and LangChain send a model id with a slash and no `models/` prefix (#150).
|
|
202
|
+
if (googleShaped && !gemini)
|
|
203
|
+
return fail(response, {
|
|
204
|
+
status: 404,
|
|
205
|
+
kind: "not_found",
|
|
206
|
+
code: "not_found",
|
|
207
|
+
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.`,
|
|
208
|
+
})
|
|
209
|
+
|
|
197
210
|
if (!gemini && modelId === undefined && !known(route)) {
|
|
198
211
|
return fail(response, {
|
|
199
212
|
status: 404,
|
|
@@ -274,6 +287,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
274
287
|
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
275
288
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
276
289
|
onPolicy: reportPolicy(response),
|
|
290
|
+
onUsage: reportUsage(response, exchange.stream),
|
|
277
291
|
onCache: reportCache(response),
|
|
278
292
|
}),
|
|
279
293
|
scope,
|
|
@@ -318,6 +332,14 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
318
332
|
app: who.admin ? app : who.app,
|
|
319
333
|
})
|
|
320
334
|
if (isPlugError(report)) return fail(response, report)
|
|
335
|
+
// Each app's budget state, read from its current window rather than from the report's range (src/budgets.ts).
|
|
336
|
+
if (host.options.budgets) {
|
|
337
|
+
const at = Date.now()
|
|
338
|
+
for (const [name, entry] of Object.entries(report.apps)) {
|
|
339
|
+
const budget = budgetFor(host.options.budgets, name)
|
|
340
|
+
if (budget) entry.budgets = await budgetStatus(usage, budget, name, at)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
321
343
|
return sendJson(response, 200, report)
|
|
322
344
|
}
|
|
323
345
|
|
package/src/responses/request.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
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"
|
|
@@ -121,7 +122,23 @@ const assistantText = (content: unknown, param: string): string | PlugError => {
|
|
|
121
122
|
* assistant message, the shape the Chat Completions front-end produces, so a parallel-call turn
|
|
122
123
|
* is one message followed by its results.
|
|
123
124
|
*/
|
|
124
|
-
|
|
125
|
+
/** The output item an `item_reference` names, or undefined when the app's store does not hold it. */
|
|
126
|
+
export type ItemResolver = (itemId: string) => JsonObject | undefined
|
|
127
|
+
|
|
128
|
+
/** An `item_reference` is the output item of an earlier answer, decoded as that item (#147). */
|
|
129
|
+
const resolveReference = (item: JsonObject, param: string, resolve: ItemResolver | undefined): JsonObject | PlugError => {
|
|
130
|
+
if (typeof item.id !== "string" || item.id.length === 0) return bad("item_reference needs the id of an output item", `${param}.id`)
|
|
131
|
+
const found = resolve?.(item.id)
|
|
132
|
+
if (found === undefined)
|
|
133
|
+
return bad(
|
|
134
|
+
`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)`,
|
|
135
|
+
`${param}.id`,
|
|
136
|
+
"item_not_found",
|
|
137
|
+
)
|
|
138
|
+
return found
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parseInput = (input: unknown, resolve: ItemResolver | undefined): { system: string[]; messages: PlugMessage[] } | PlugError => {
|
|
125
142
|
if (typeof input === "string") return { system: [], messages: [{ role: "user", content: [{ type: "text", text: input }] }] }
|
|
126
143
|
if (!Array.isArray(input) || input.length === 0) return bad("input must be a string or a non-empty array of items", "input")
|
|
127
144
|
|
|
@@ -134,9 +151,11 @@ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[]
|
|
|
134
151
|
pending = undefined
|
|
135
152
|
}
|
|
136
153
|
|
|
137
|
-
for (const [index,
|
|
154
|
+
for (const [index, given] of input.entries()) {
|
|
138
155
|
const param = `input[${index}]`
|
|
139
|
-
if (!isRecord(
|
|
156
|
+
if (!isRecord(given)) return bad("Each input item must be an object", param)
|
|
157
|
+
const item = given.type === "item_reference" ? resolveReference(given, param, resolve) : given
|
|
158
|
+
if (isPlugError(item)) return item
|
|
140
159
|
const type = item.type ?? (item.role !== undefined ? "message" : undefined)
|
|
141
160
|
switch (type) {
|
|
142
161
|
case "message": {
|
|
@@ -192,11 +211,6 @@ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[]
|
|
|
192
211
|
// Reasoning items carry no content plugboard can replay; they are dropped.
|
|
193
212
|
case "reasoning":
|
|
194
213
|
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
214
|
default:
|
|
201
215
|
return unsupported(`Unsupported input item type "${String(type)}"`, `${param}.type`)
|
|
202
216
|
}
|
|
@@ -296,7 +310,7 @@ const checkUnsupported = (body: JsonObject): PlugError | undefined => {
|
|
|
296
310
|
if (body.truncation != null && body.truncation !== "disabled") return unsupported('Only truncation "disabled" is supported', "truncation")
|
|
297
311
|
}
|
|
298
312
|
|
|
299
|
-
export function parseResponsesRequest(body: unknown, options: PlugboardOptions): ParsedResponses | PlugError {
|
|
313
|
+
export function parseResponsesRequest(body: unknown, options: PlugboardOptions, resolve?: ItemResolver): ParsedResponses | PlugError {
|
|
300
314
|
if (!isRecord(body)) return bad("Request body must be a JSON object")
|
|
301
315
|
if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
|
|
302
316
|
if (body.input == null) return bad("input is required", "input")
|
|
@@ -305,7 +319,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions):
|
|
|
305
319
|
if (body.instructions != null && typeof body.instructions !== "string") return bad("instructions must be a string", "instructions")
|
|
306
320
|
if (body.reasoning != null && !isRecord(body.reasoning)) return bad("reasoning must be an object", "reasoning")
|
|
307
321
|
|
|
308
|
-
const parsed = parseInput(body.input)
|
|
322
|
+
const parsed = parseInput(body.input, resolve)
|
|
309
323
|
if (isPlugError(parsed)) return parsed
|
|
310
324
|
const callerTools = parseTools(body.tools)
|
|
311
325
|
if (!Array.isArray(callerTools)) return callerTools
|
package/src/responses/route.ts
CHANGED
|
@@ -15,10 +15,12 @@ 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"
|
|
18
19
|
import { encodeError } from "../openai/response.js"
|
|
19
20
|
import type { RouteHost } from "../openai/route.js"
|
|
20
21
|
import { parseResponsesRequest } from "./request.js"
|
|
21
22
|
import { runResponse, sseFrame } from "./response.js"
|
|
23
|
+
import { findItem, rememberAnswer } from "./store.js"
|
|
22
24
|
|
|
23
25
|
const SESSION_HEADER = "x-plugboard-session"
|
|
24
26
|
|
|
@@ -34,7 +36,7 @@ export interface ResponsesCall {
|
|
|
34
36
|
export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
|
|
35
37
|
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
36
38
|
|
|
37
|
-
const parsed = parseResponsesRequest(body, host.options)
|
|
39
|
+
const parsed = parseResponsesRequest(body, host.options, (itemId) => findItem(app, itemId))
|
|
38
40
|
if (isPlugError(parsed)) return fail(parsed)
|
|
39
41
|
const catalog = await scope.race(host.loadCatalog())
|
|
40
42
|
const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
|
|
@@ -55,6 +57,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
55
57
|
},
|
|
56
58
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
57
59
|
onPolicy: reportPolicy(response),
|
|
60
|
+
onUsage: reportUsage(response, exchange.stream),
|
|
58
61
|
onCache: reportCache(response),
|
|
59
62
|
}),
|
|
60
63
|
scope,
|
|
@@ -62,11 +65,12 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
62
65
|
|
|
63
66
|
if (exchange.stream) {
|
|
64
67
|
const sse = sseWriter(response, scope.signal)
|
|
65
|
-
const
|
|
68
|
+
const run = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
|
|
69
|
+
if (run.response && !run.failure) rememberAnswer(app, run.response)
|
|
66
70
|
if (!sse.started && !response.headersSent) {
|
|
67
|
-
if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
71
|
+
if (!scope.signal.aborted || scope.timedOut) fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
68
72
|
} else {
|
|
69
|
-
if (failure) info.failed = failure.code ?? failure.kind
|
|
73
|
+
if (run.failure) info.failed = run.failure.code ?? run.failure.kind
|
|
70
74
|
if (!response.writableEnded) response.end()
|
|
71
75
|
}
|
|
72
76
|
return
|
|
@@ -74,5 +78,6 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
74
78
|
|
|
75
79
|
const run = await runResponse(events, exchange, () => {})
|
|
76
80
|
if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
81
|
+
rememberAnswer(app, run.response)
|
|
77
82
|
sendJson(response, 200, run.response)
|
|
78
83
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The output items of recent Responses answers, so a later request can refer to them with an
|
|
3
|
+
* `item_reference` (#147). The Vercel AI SDK keeps `store: true` by default and sends the earlier
|
|
4
|
+
* assistant turn as references to the items plugboard returned, so without this store its
|
|
5
|
+
* multi-turn chat is refused.
|
|
6
|
+
*
|
|
7
|
+
* Bounded and per app: an app's store holds up to 1000 answers for an hour, and an app reads only
|
|
8
|
+
* its own answers. Nothing is written to disk. The store lives on `globalThis` under a versioned
|
|
9
|
+
* key, as the other process-wide state does (docs/architecture.md, I6), so it outlives a plugin
|
|
10
|
+
* generation that is replaced. A reference to an item that was evicted, expired or never returned
|
|
11
|
+
* is a 400 that suggests `store: false`; it is never a guess.
|
|
12
|
+
*/
|
|
13
|
+
import { LRUCache } from "lru-cache"
|
|
14
|
+
|
|
15
|
+
type Item = Readonly<Record<string, unknown>>
|
|
16
|
+
|
|
17
|
+
const TTL_MS = 60 * 60 * 1000
|
|
18
|
+
const MAX_ANSWERS = 1000
|
|
19
|
+
const SLOT = Symbol.for("opencode-plugboard.responses-items/1")
|
|
20
|
+
|
|
21
|
+
const holder = globalThis as unknown as Record<symbol, Map<string, LRUCache<string, ReadonlyArray<Item>>> | undefined>
|
|
22
|
+
|
|
23
|
+
/** The store of one app: answer id -> its output items. */
|
|
24
|
+
function storeOf(app: string): LRUCache<string, ReadonlyArray<Item>> {
|
|
25
|
+
const apps = (holder[SLOT] ??= new Map())
|
|
26
|
+
let store = apps.get(app)
|
|
27
|
+
if (store === undefined) {
|
|
28
|
+
store = new LRUCache<string, ReadonlyArray<Item>>({ max: MAX_ANSWERS, ttl: TTL_MS })
|
|
29
|
+
apps.set(app, store)
|
|
30
|
+
}
|
|
31
|
+
return store
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isItem = (value: unknown): value is Item => typeof value === "object" && value !== null && typeof (value as Item).id === "string"
|
|
35
|
+
|
|
36
|
+
/** Keeps the output items of a completed or incomplete answer (a failed one has no items to replay). */
|
|
37
|
+
export function rememberAnswer(app: string, response: object): void {
|
|
38
|
+
const { id, output } = response as { id?: unknown; output?: unknown }
|
|
39
|
+
if (typeof id !== "string" || !Array.isArray(output)) return
|
|
40
|
+
const items = output.filter(isItem)
|
|
41
|
+
if (items.length > 0) storeOf(app).set(id, items)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The output item with this id that the app's recent answers returned, if still held. */
|
|
45
|
+
export function findItem(app: string, itemId: string): Item | undefined {
|
|
46
|
+
for (const items of storeOf(app).values()) {
|
|
47
|
+
const found = items.find((item) => item.id === itemId)
|
|
48
|
+
if (found !== undefined) return found
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { ensureAppDir } from "./session/history.js"
|
|
|
62
62
|
import type { HistoryPort } from "./session/host.js"
|
|
63
63
|
import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
|
|
64
64
|
import { createSessionStore, type SessionStore } from "./session/state.js"
|
|
65
|
+
import { createBudgetState, type BudgetState } from "./budgets.js"
|
|
65
66
|
import { createUsageState, type UsageState } from "./usage.js"
|
|
66
67
|
|
|
67
68
|
export type Handler = (request: IncomingMessage, response: ServerResponse) => void
|
|
@@ -115,8 +116,8 @@ const BACKEND_POLL_MS = 25
|
|
|
115
116
|
const BIND_RETRY_MS = 15_000
|
|
116
117
|
const BIND_REMINDER_MS = 5 * 60_000
|
|
117
118
|
|
|
118
|
-
/** 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
119
|
-
const VERSION =
|
|
119
|
+
/** 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
120
|
+
const VERSION = 13
|
|
120
121
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
121
122
|
|
|
122
123
|
interface Listener {
|
|
@@ -180,6 +181,8 @@ interface Runtime {
|
|
|
180
181
|
cache?: { readonly fingerprint: string; readonly store: CacheStore }
|
|
181
182
|
/** The usage store's live days and retention clock (src/usage.ts). */
|
|
182
183
|
readonly usage: UsageState
|
|
184
|
+
/** The budget warnings already logged (src/budgets.ts). */
|
|
185
|
+
readonly budgets: BudgetState
|
|
183
186
|
listener?: Listener
|
|
184
187
|
/** Present while a fiber is subscribed to the server event bus. */
|
|
185
188
|
pump?: Pump
|
|
@@ -193,6 +196,7 @@ const detachedMetrics = createMetrics()
|
|
|
193
196
|
const detachedStartedAt = Date.now()
|
|
194
197
|
const detachedLimits = createLimitState()
|
|
195
198
|
const detachedUsage = createUsageState()
|
|
199
|
+
const detachedBudgets = createBudgetState()
|
|
196
200
|
let foreignVersionLogged = false
|
|
197
201
|
|
|
198
202
|
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
@@ -210,6 +214,7 @@ function runtime(): Runtime | undefined {
|
|
|
210
214
|
metrics: createMetrics(),
|
|
211
215
|
limits: createLimitState(),
|
|
212
216
|
usage: createUsageState(),
|
|
217
|
+
budgets: createBudgetState(),
|
|
213
218
|
startedAt: Date.now(),
|
|
214
219
|
}
|
|
215
220
|
slot[KEY] = created
|
|
@@ -291,6 +296,9 @@ export const processLimits = (): LimitState => runtime()?.limits ?? detachedLimi
|
|
|
291
296
|
/** The usage store's live days shared by every generation in the process (detached under a foreign-version runtime). */
|
|
292
297
|
export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
|
|
293
298
|
|
|
299
|
+
/** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
|
|
300
|
+
export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
|
|
301
|
+
|
|
294
302
|
/**
|
|
295
303
|
* The options authentication reads: `keys` is the live key set, not the serving generation's own
|
|
296
304
|
* (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
|
package/src/stop.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller stop sequences on the answer text (#145, #149). A provider ends the answer at a sequence
|
|
3
|
+
* but does not always return it, and Anthropic, OpenAI and Gemini all leave it out of the answer
|
|
4
|
+
* when the stop is the one that ended it. The host reports only `stop`, so the end of a plain
|
|
5
|
+
* stop's text is checked for a sequence, which is removed and reported. A streamed text keeps its
|
|
6
|
+
* last characters back (`StopHoldback`) until the finish decides whether they end in a sequence.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The longest of `stops` that `tail` ends with, if any. */
|
|
10
|
+
export const stopTail = (tail: string, stops: ReadonlyArray<string>): string | undefined =>
|
|
11
|
+
stops.filter((stop) => tail.endsWith(stop)).sort((a, b) => b.length - a.length)[0]
|
|
12
|
+
|
|
13
|
+
/** Streamed text with its last `size` characters (the longest sequence) held back. Without sequences nothing is held. */
|
|
14
|
+
export class StopHoldback {
|
|
15
|
+
private held = ""
|
|
16
|
+
private readonly size: number
|
|
17
|
+
|
|
18
|
+
constructor(private readonly stops: ReadonlyArray<string>) {
|
|
19
|
+
this.size = Math.max(0, ...stops.map((stop) => stop.length))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Adds a chunk and returns the text that can be sent now: everything except the last `size` characters. */
|
|
23
|
+
push(chunk: string): string {
|
|
24
|
+
if (this.size === 0) return chunk
|
|
25
|
+
this.held += chunk
|
|
26
|
+
const out = this.held.slice(0, Math.max(0, this.held.length - this.size))
|
|
27
|
+
this.held = this.held.slice(out.length)
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Everything still held, unchanged. Used before a tool call or reasoning, which cannot be a stop. */
|
|
32
|
+
release(): string {
|
|
33
|
+
const out = this.held
|
|
34
|
+
this.held = ""
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The end of the text. `plainStop` is true when the model stopped for `stop` with no tool call:
|
|
40
|
+
* then a sequence the held text ends with is removed and returned as `matched`.
|
|
41
|
+
*/
|
|
42
|
+
finish(plainStop: boolean): { readonly text: string; readonly matched?: string } {
|
|
43
|
+
const matched = plainStop && this.size > 0 ? stopTail(this.held, this.stops) : undefined
|
|
44
|
+
const text = matched === undefined ? this.held : this.held.slice(0, this.held.length - matched.length)
|
|
45
|
+
this.held = ""
|
|
46
|
+
return matched === undefined ? { text } : { text, matched }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A complete text (no streaming): the text without the sequence it ends with, if any. */
|
|
51
|
+
export const stripStopTail = (text: string, stops: ReadonlyArray<string>): { readonly text: string; readonly matched?: string } => {
|
|
52
|
+
const matched = stopTail(text, stops)
|
|
53
|
+
return matched === undefined ? { text } : { text: text.slice(0, text.length - matched.length), matched }
|
|
54
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `x-plugboard-usage` (#52): the tokens and USD cost of the model calls a response answered, so a
|
|
3
|
+
* caller sees what one request cost without asking `GET /v1/usage`. It is set only while usage
|
|
4
|
+
* recording is on (`withUsage`, src/usage.ts), and only on a non-streaming response: a stream
|
|
5
|
+
* sends its headers with its first event, before the usage is known, so it has no such header.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerResponse } from "node:http"
|
|
8
|
+
import type { CallTotals } from "./contract.js"
|
|
9
|
+
|
|
10
|
+
export const USAGE_HEADER = "x-plugboard-usage"
|
|
11
|
+
|
|
12
|
+
/** The totals of one response, summed over its calls. `costUsd` is undefined when any call had no price. */
|
|
13
|
+
interface Totals {
|
|
14
|
+
readonly inputTokens: number
|
|
15
|
+
readonly outputTokens: number
|
|
16
|
+
readonly cacheReadTokens: number
|
|
17
|
+
readonly costUsd?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `input=<n>; output=<n>; cache_read=<n>; cost_usd=<x.xxxxxx>`; the cost part is left out for an unpriced model. */
|
|
21
|
+
export function formatUsage(totals: Totals): string {
|
|
22
|
+
const parts = [`input=${totals.inputTokens}`, `output=${totals.outputTokens}`, `cache_read=${totals.cacheReadTokens}`]
|
|
23
|
+
if (totals.costUsd !== undefined) parts.push(`cost_usd=${totals.costUsd.toFixed(6)}`)
|
|
24
|
+
return parts.join("; ")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `CallContext.onUsage` of one response: each answered call adds its totals and the header
|
|
29
|
+
* is rewritten while the headers are still open, so the fan-out's `n` choices sum into one value.
|
|
30
|
+
* `undefined` for a stream (see the module comment).
|
|
31
|
+
*/
|
|
32
|
+
export function reportUsage(response: ServerResponse, stream: boolean): ((call: CallTotals) => void) | undefined {
|
|
33
|
+
if (stream) return undefined
|
|
34
|
+
let totals: Totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0 }
|
|
35
|
+
return (call) => {
|
|
36
|
+
if (response.headersSent || response.destroyed) return
|
|
37
|
+
const costUsd = totals.costUsd === undefined || call.costUsd === undefined ? undefined : totals.costUsd + call.costUsd
|
|
38
|
+
totals = {
|
|
39
|
+
inputTokens: totals.inputTokens + call.inputTokens,
|
|
40
|
+
outputTokens: totals.outputTokens + call.outputTokens,
|
|
41
|
+
cacheReadTokens: totals.cacheReadTokens + call.cacheReadTokens,
|
|
42
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
43
|
+
}
|
|
44
|
+
response.setHeader(USAGE_HEADER, formatUsage(totals))
|
|
45
|
+
}
|
|
46
|
+
}
|