opencode-plugboard 0.17.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 +458 -39
- package/index.ts +27 -14
- package/package.json +36 -9
- package/rpc.ts +3 -0
- 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/dashboard/app.css +209 -0
- package/src/dashboard/app.js +444 -0
- package/src/dashboard/assets.ts +72 -0
- package/src/dashboard/page.ts +87 -0
- package/src/discovery.ts +8 -2
- 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 +35 -4
- 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/tui-format.ts +217 -0
- package/src/tui.tsx +166 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +43 -3
- package/tui.ts +4 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves the usage dashboard (`src/dashboard/page.ts`): the page, the page's own script and
|
|
3
|
+
* stylesheet, and uPlot's browser build and stylesheet from its npm package. Files are read on the
|
|
4
|
+
* first request and kept in memory. Setup does no filesystem work (I3). The bytes never change
|
|
5
|
+
* while plugboard runs, so the ETag is their hash and a revalidation answers 304.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from "node:crypto"
|
|
8
|
+
import { readFile } from "node:fs/promises"
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
10
|
+
import { createRequire } from "node:module"
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
import { sendText } from "../discovery.js"
|
|
14
|
+
import { DASHBOARD_HEADERS, DASHBOARD_PAGE } from "./page.js"
|
|
15
|
+
|
|
16
|
+
const PAGE_PREFIX = "GET /dashboard/"
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const resolveFrom = createRequire(import.meta.url)
|
|
19
|
+
|
|
20
|
+
interface AssetFile {
|
|
21
|
+
readonly path: () => string
|
|
22
|
+
readonly contentType: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The asset named by the last segment of `/dashboard/<name>`. */
|
|
26
|
+
const ASSETS: Readonly<Record<string, AssetFile>> = {
|
|
27
|
+
"app.js": { path: () => join(here, "app.js"), contentType: "text/javascript; charset=utf-8" },
|
|
28
|
+
"app.css": { path: () => join(here, "app.css"), contentType: "text/css; charset=utf-8" },
|
|
29
|
+
"uplot.js": { path: () => resolveFrom.resolve("uplot/dist/uPlot.iife.min.js"), contentType: "text/javascript; charset=utf-8" },
|
|
30
|
+
"uplot.css": { path: () => resolveFrom.resolve("uplot/dist/uPlot.min.css"), contentType: "text/css; charset=utf-8" },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Asset {
|
|
34
|
+
readonly body: string
|
|
35
|
+
readonly contentType: string
|
|
36
|
+
readonly etag: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const loaded = new Map<string, Promise<Asset>>()
|
|
40
|
+
|
|
41
|
+
async function load(name: string): Promise<Asset> {
|
|
42
|
+
const file = ASSETS[name]
|
|
43
|
+
if (!file) throw new Error(`no dashboard asset ${name}`)
|
|
44
|
+
const body = await readFile(file.path(), "utf8")
|
|
45
|
+
const digest = createHash("sha256").update(body).digest("hex").slice(0, 32)
|
|
46
|
+
return { body, contentType: file.contentType, etag: `"${digest}"` }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The asset's bytes, read once per process. A failed read is not kept, so the next request tries again. */
|
|
50
|
+
function asset(name: string): Promise<Asset> {
|
|
51
|
+
let pending = loaded.get(name)
|
|
52
|
+
if (!pending) {
|
|
53
|
+
pending = load(name)
|
|
54
|
+
loaded.set(name, pending)
|
|
55
|
+
pending.catch(() => loaded.delete(name))
|
|
56
|
+
}
|
|
57
|
+
return pending
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Answers one dashboard route (`DASHBOARD_ROUTES`, already matched by the caller). */
|
|
61
|
+
export async function serveDashboard(request: IncomingMessage, response: ServerResponse, route: string): Promise<void> {
|
|
62
|
+
if (route === "GET /dashboard") return sendText(response, 200, "text/html; charset=utf-8", DASHBOARD_PAGE, DASHBOARD_HEADERS)
|
|
63
|
+
const found = await asset(route.slice(PAGE_PREFIX.length))
|
|
64
|
+
// Assets revalidate on every use: a plugboard upgrade changes them, and the ETag makes that check free.
|
|
65
|
+
const headers = { ...DASHBOARD_HEADERS, "cache-control": "no-cache", etag: found.etag }
|
|
66
|
+
if (request.headers["if-none-match"] === found.etag) {
|
|
67
|
+
if (!response.headersSent && !response.destroyed) response.writeHead(304, headers)
|
|
68
|
+
response.end()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
return sendText(response, 200, found.contentType, found.body, headers)
|
|
72
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage dashboard page (`GET /dashboard`, #141): static HTML, its headers and the routes that
|
|
3
|
+
* serve it. The page holds no data. It asks for a key, then reads `GET /v1/usage` from the same
|
|
4
|
+
* origin with that key (the data never reaches the page's HTML). No inline script or style: the
|
|
5
|
+
* script and stylesheets are plugboard's own files under `/dashboard/`, served by `src/dashboard/assets.ts`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Scripts and styles from this origin only; nothing else may load, frame or submit. */
|
|
9
|
+
export const DASHBOARD_CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
|
10
|
+
|
|
11
|
+
/** Headers of every dashboard response; the assets get them too. */
|
|
12
|
+
export const DASHBOARD_HEADERS: Readonly<Record<string, string>> = {
|
|
13
|
+
"content-security-policy": DASHBOARD_CSP,
|
|
14
|
+
"x-frame-options": "DENY",
|
|
15
|
+
"referrer-policy": "no-referrer",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Routes of the dashboard: the page and its four files. Served only when option `usage` is on. */
|
|
19
|
+
export const DASHBOARD_ROUTES: ReadonlySet<string> = new Set([
|
|
20
|
+
"GET /dashboard",
|
|
21
|
+
"GET /dashboard/app.js",
|
|
22
|
+
"GET /dashboard/app.css",
|
|
23
|
+
"GET /dashboard/uplot.js",
|
|
24
|
+
"GET /dashboard/uplot.css",
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
export const DASHBOARD_PAGE = `<!doctype html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8">
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
|
+
<meta name="referrer" content="no-referrer">
|
|
33
|
+
<title>plugboard usage</title>
|
|
34
|
+
<link rel="stylesheet" href="/dashboard/uplot.css">
|
|
35
|
+
<link rel="stylesheet" href="/dashboard/app.css">
|
|
36
|
+
<script src="/dashboard/uplot.js" defer></script>
|
|
37
|
+
<script src="/dashboard/app.js" defer></script>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<header class="top">
|
|
41
|
+
<h1>plugboard usage</h1>
|
|
42
|
+
<p class="muted">Requests, tokens and estimated cost per app, per UTC day. Cost uses the catalog's list prices.</p>
|
|
43
|
+
</header>
|
|
44
|
+
<main>
|
|
45
|
+
<section id="signin" class="card" hidden>
|
|
46
|
+
<form id="key-form">
|
|
47
|
+
<label for="key">API key</label>
|
|
48
|
+
<div class="row">
|
|
49
|
+
<input id="key" name="key" type="password" autocomplete="off" autocapitalize="off" spellcheck="false">
|
|
50
|
+
<button type="submit" class="primary">Show usage</button>
|
|
51
|
+
</div>
|
|
52
|
+
<p class="muted">The admin key shows every app. An app key shows only its own. The key is kept in this tab's session storage and sent only to this server.</p>
|
|
53
|
+
</form>
|
|
54
|
+
<p id="auth-error" class="error" role="alert" hidden></p>
|
|
55
|
+
</section>
|
|
56
|
+
<section id="panel" hidden>
|
|
57
|
+
<div class="toolbar">
|
|
58
|
+
<div class="ranges" role="group" aria-label="Range">
|
|
59
|
+
<button type="button" data-days="1">Today</button>
|
|
60
|
+
<button type="button" data-days="7">7 days</button>
|
|
61
|
+
<button type="button" data-days="30">30 days</button>
|
|
62
|
+
<button type="button" data-days="90">90 days</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="row">
|
|
65
|
+
<button type="button" id="refresh">Refresh</button>
|
|
66
|
+
<button type="button" id="sign-out">Sign out</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<p id="status" class="muted" aria-live="polite"></p>
|
|
70
|
+
<h2>Totals</h2>
|
|
71
|
+
<div id="totals" class="cards"></div>
|
|
72
|
+
<h2>Apps</h2>
|
|
73
|
+
<div class="scroll"><table id="apps"></table></div>
|
|
74
|
+
<h2>Requests per day</h2>
|
|
75
|
+
<div id="chart-requests" class="chart"></div>
|
|
76
|
+
<h2>Estimated cost per day (USD)</h2>
|
|
77
|
+
<div id="chart-cost" class="chart"></div>
|
|
78
|
+
<h2>Tokens per day</h2>
|
|
79
|
+
<div id="chart-tokens" class="chart"></div>
|
|
80
|
+
<h2>Models</h2>
|
|
81
|
+
<p id="models-note" class="muted"></p>
|
|
82
|
+
<div class="scroll"><table id="models"></table></div>
|
|
83
|
+
</section>
|
|
84
|
+
</main>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
87
|
+
`
|
package/src/discovery.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface Endpoint {
|
|
|
61
61
|
/**
|
|
62
62
|
* Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
|
|
63
63
|
* `src/openai/route.ts` and the front-end routes; `test/http/discovery.test.ts` probes each served
|
|
64
|
-
* route and fails when this list omits one (#136). `/v1/usage` and `/metrics` are added only when their options are on.
|
|
64
|
+
* route and fails when this list omits one (#136). `/v1/usage`, `/dashboard` and `/metrics` are added only when their options are on.
|
|
65
65
|
*/
|
|
66
66
|
export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
67
67
|
{ method: "GET", path: "/", auth: false, description: "This page." },
|
|
@@ -85,7 +85,12 @@ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
|
85
85
|
function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
|
|
86
86
|
return [
|
|
87
87
|
...ENDPOINTS,
|
|
88
|
-
...(options.usage.enabled
|
|
88
|
+
...(options.usage.enabled
|
|
89
|
+
? [
|
|
90
|
+
{ method: "GET", path: "/v1/usage", auth: true, description: "Per-app usage by day: requests, tokens, cost, latency. The admin key reads every app." },
|
|
91
|
+
{ method: "GET", path: "/dashboard", auth: false, description: "Usage dashboard page. Its data needs a key (`/v1/usage`)." },
|
|
92
|
+
]
|
|
93
|
+
: []),
|
|
89
94
|
...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
|
|
90
95
|
]
|
|
91
96
|
}
|
|
@@ -238,6 +243,7 @@ export function renderDiscoveryHtml(doc: Discovery): string {
|
|
|
238
243
|
const more = doc.catalog.moreProviders ? `<p>${escapeHtml(doc.catalog.moreProviders)} more providers not listed.</p>` : ""
|
|
239
244
|
sections.push(`<h2>Catalog</h2><table><tr><th>Provider</th><th>Models</th><th>Examples</th></tr>${rows}</table>${more}`)
|
|
240
245
|
}
|
|
246
|
+
if (doc.endpoints.some((endpoint) => endpoint.path === "/dashboard")) sections.push(`<p><a href="/dashboard">Usage dashboard</a></p>`)
|
|
241
247
|
sections.push(`<h2>Try it</h2>${pre(doc.example.curl)}`)
|
|
242
248
|
sections.push(
|
|
243
249
|
`<h2>Endpoints</h2><table><tr><th>Method</th><th>Path</th><th>Key</th><th></th></tr>${doc.endpoints
|
package/src/gemini/request.ts
CHANGED
|
@@ -439,6 +439,7 @@ export function parseGeminiRequest(model: string, stream: boolean, body: unknown
|
|
|
439
439
|
stream,
|
|
440
440
|
...(respond ? { structuredTool: RESPOND_TOOL, ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
441
441
|
...(format.instruction ? { jsonObject: true } : {}),
|
|
442
|
+
...(generation.stop ? { stop: generation.stop } : {}),
|
|
442
443
|
},
|
|
443
444
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
444
445
|
}
|
package/src/gemini/response.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
10
10
|
import type { ListedModel } from "../models.js"
|
|
11
11
|
import { unfence } from "../openai/response.js"
|
|
12
|
+
import { StopHoldback } from "../stop.js"
|
|
12
13
|
import { WRAP_KEY } from "./request.js"
|
|
13
14
|
|
|
14
15
|
type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
@@ -30,6 +31,8 @@ export interface GeminiExchange {
|
|
|
30
31
|
readonly callerTools?: boolean
|
|
31
32
|
/** `responseMimeType: application/json` without a schema: a single code fence around JSON text is stripped. */
|
|
32
33
|
readonly jsonObject?: boolean
|
|
34
|
+
/** The request's `generationConfig.stopSequences`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). Not applied to structured output. */
|
|
35
|
+
readonly stop?: ReadonlyArray<string>
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
|
|
@@ -160,10 +163,18 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
160
163
|
else parts.push(part)
|
|
161
164
|
emit(envelope([part]))
|
|
162
165
|
}
|
|
166
|
+
// A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
|
|
167
|
+
const holdback = exchange.structuredTool === undefined ? new StopHoldback(exchange.stop ?? []) : undefined
|
|
163
168
|
const text = (delta: string) => {
|
|
164
|
-
|
|
169
|
+
const out = holdback ? holdback.push(delta) : delta
|
|
170
|
+
if (out.length > 0) push({ text: out })
|
|
171
|
+
}
|
|
172
|
+
const releaseHeld = () => {
|
|
173
|
+
const rest = holdback?.release() ?? ""
|
|
174
|
+
if (rest.length > 0) push({ text: rest })
|
|
165
175
|
}
|
|
166
176
|
const toolCall = (call: ToolCall) => {
|
|
177
|
+
releaseHeld()
|
|
167
178
|
push({ functionCall: { name: call.name, args: argsOf(call.input) } })
|
|
168
179
|
calls++
|
|
169
180
|
}
|
|
@@ -221,6 +232,10 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
221
232
|
failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
|
|
222
233
|
if (failure) return { failure, started }
|
|
223
234
|
flush()
|
|
235
|
+
if (holdback) {
|
|
236
|
+
const rest = holdback.finish(finish === "stop" && calls === 0).text
|
|
237
|
+
if (rest.length > 0) push({ text: rest })
|
|
238
|
+
}
|
|
224
239
|
if (held && !passthrough) text(structuredText(exchange, held))
|
|
225
240
|
const candidate = { finishReason: FINISH_REASONS[finish!] }
|
|
226
241
|
const extra = usage ? { usageMetadata: encodeUsage(usage) } : {}
|
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
|
@@ -3,7 +3,8 @@
|
|
|
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`
|
|
6
|
-
* (keyless discovery), the authenticated `GET /v1/status`,
|
|
6
|
+
* (keyless discovery), the authenticated `GET /v1/status`, `GET /metrics` (option `metrics`), and the
|
|
7
|
+
* usage dashboard `GET /dashboard` with its assets (option `usage`, `src/dashboard/`).
|
|
7
8
|
*
|
|
8
9
|
* The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
|
|
9
10
|
* the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
|
|
@@ -45,11 +46,15 @@ import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
|
45
46
|
import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
|
|
46
47
|
import { reportCache } from "../cache.js"
|
|
47
48
|
import { reportPolicy } from "../policy.js"
|
|
49
|
+
import { reportUsage } from "../usage-header.js"
|
|
48
50
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
49
51
|
import { serveCompletions } from "../completions/route.js"
|
|
50
52
|
import { serveResponses } from "../responses/route.js"
|
|
51
53
|
import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
54
|
+
import { budgetFor, budgetStatus } from "../budgets.js"
|
|
52
55
|
import type { UsageRecorder } from "../usage.js"
|
|
56
|
+
import { DASHBOARD_ROUTES } from "../dashboard/page.js"
|
|
57
|
+
import { serveDashboard } from "../dashboard/assets.js"
|
|
53
58
|
import { chatCodec, serveChoices } from "./fanout.js"
|
|
54
59
|
import { parseChatRequest } from "./request.js"
|
|
55
60
|
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
@@ -120,7 +125,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
120
125
|
const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
|
|
121
126
|
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
122
127
|
const known = (route: string) =>
|
|
123
|
-
KNOWN_ROUTES.includes(route) ||
|
|
128
|
+
KNOWN_ROUTES.includes(route) ||
|
|
129
|
+
DISCOVERY_ROUTES.includes(route) ||
|
|
130
|
+
(metrics !== undefined && route === "GET /metrics") ||
|
|
131
|
+
(host.usage !== undefined && (route === "GET /v1/usage" || DASHBOARD_ROUTES.has(route)))
|
|
124
132
|
/** Responses to Gemini routes: errors there use Google's body. */
|
|
125
133
|
const googleErrors = new WeakSet<ServerResponse>()
|
|
126
134
|
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
@@ -148,7 +156,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
148
156
|
const path = url.pathname
|
|
149
157
|
const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
|
|
150
158
|
const gemini = matchGeminiRoute(request.method, path)
|
|
151
|
-
|
|
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)
|
|
152
162
|
const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
153
163
|
// Arbitrary client paths stay out of the log.
|
|
154
164
|
info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
@@ -176,15 +186,27 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
176
186
|
return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
|
|
177
187
|
}
|
|
178
188
|
|
|
189
|
+
// The usage dashboard (#141): a page and its files, keyless like the discovery pages. Its data comes from `GET /v1/usage`, behind a key.
|
|
190
|
+
if (host.usage && DASHBOARD_ROUTES.has(route)) return serveDashboard(request, response, route)
|
|
191
|
+
|
|
179
192
|
// Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
|
|
180
193
|
if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
|
|
181
194
|
|
|
182
195
|
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
183
196
|
// Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
|
|
184
|
-
const auth = authenticate(liveOptions(host.options),
|
|
197
|
+
const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization)
|
|
185
198
|
if (isPlugError(auth)) return fail(response, auth)
|
|
186
199
|
info.app = auth.app
|
|
187
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
|
+
|
|
188
210
|
if (!gemini && modelId === undefined && !known(route)) {
|
|
189
211
|
return fail(response, {
|
|
190
212
|
status: 404,
|
|
@@ -265,6 +287,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
265
287
|
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
266
288
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
267
289
|
onPolicy: reportPolicy(response),
|
|
290
|
+
onUsage: reportUsage(response, exchange.stream),
|
|
268
291
|
onCache: reportCache(response),
|
|
269
292
|
}),
|
|
270
293
|
scope,
|
|
@@ -309,6 +332,14 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
309
332
|
app: who.admin ? app : who.app,
|
|
310
333
|
})
|
|
311
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
|
+
}
|
|
312
343
|
return sendJson(response, 200, report)
|
|
313
344
|
}
|
|
314
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
|
}
|