opencode-plugboard 0.21.2 → 0.23.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 +30 -4
- package/package.json +2 -1
- package/src/anthropic/request.ts +5 -1
- package/src/anthropic/route.ts +2 -2
- package/src/completions/request.ts +1 -0
- package/src/completions/response.ts +1 -1
- package/src/config.ts +21 -4
- package/src/contract.ts +8 -5
- package/src/fallback.ts +23 -7
- package/src/gemini/request.ts +5 -1
- package/src/gemini/response.ts +1 -1
- package/src/gemini/route.ts +2 -2
- package/src/image.ts +49 -0
- package/src/openai/fanout.ts +4 -2
- package/src/openai/request.ts +7 -2
- package/src/openai/response.ts +7 -4
- package/src/openai/route.ts +11 -2
- package/src/responses/request.ts +14 -1
- package/src/responses/response.ts +1 -1
- package/src/responses/route.ts +2 -1
- package/src/session/caller.ts +3 -1
- package/src/session/context.ts +5 -6
package/README.md
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/opencode-plugboard)
|
|
4
4
|
[](https://github.com/jjjermiah/opencode-plugboard/blob/main/LICENSE)
|
|
5
5
|
[](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/jjjermiah/opencode-plugboard)
|
|
7
|
+
<!-- OpenSSF Best Practices: register at https://www.bestpractices.dev first (answers in docs/best-practices.md). Then replace <id> with the project id and uncomment the next line:
|
|
8
|
+
[](https://www.bestpractices.dev/projects/<id>)
|
|
9
|
+
-->
|
|
6
10
|
|
|
7
11
|
Plugboard is an OpenCode V2 plugin. It turns your OpenCode server into one HTTP API that OpenAI, Anthropic and Gemini compatible apps can call with their own key.
|
|
8
12
|
|
|
@@ -43,7 +47,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
|
|
|
43
47
|
{
|
|
44
48
|
"plugins": [
|
|
45
49
|
{
|
|
46
|
-
"package": "opencode-plugboard@0.
|
|
50
|
+
"package": "opencode-plugboard@0.22.0",
|
|
47
51
|
"options": {
|
|
48
52
|
"keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
|
|
49
53
|
"models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
|
|
@@ -125,7 +129,7 @@ Nothing is exposed by accident. A model is callable only if `models` lists it, a
|
|
|
125
129
|
A model outside the list gets a 404:
|
|
126
130
|
|
|
127
131
|
```json
|
|
128
|
-
{"error": {"message": "Model openai/gpt-5 is not available", "code": "model_not_found", "type": "not_found_error", "param":
|
|
132
|
+
{"error": {"message": "Model openai/gpt-5 is not available", "code": "model_not_found", "type": "not_found_error", "param": "model"}}
|
|
129
133
|
```
|
|
130
134
|
|
|
131
135
|
#### Per-app policy
|
|
@@ -229,7 +233,7 @@ retry-after: 12
|
|
|
229
233
|
{"error": {"message": "Too many requests per minute", "code": "rate_limit_exceeded", "type": "rate_limit_error", "param": null}}
|
|
230
234
|
```
|
|
231
235
|
|
|
232
|
-
A full concurrency cap returns `too_many_requests` with `retry-after: 1`. The limits live in the server process, so a restart clears them.
|
|
236
|
+
A full concurrency cap returns `too_many_requests` with `retry-after: 1`. The `code` is on the OpenAI and Gemini routes; the Anthropic `/v1/messages` error has a `type` and a `message` only (see [Anthropic Messages](https://jjjermiah.github.io/opencode-plugboard/messages)). The limits live in the server process, so a restart clears them.
|
|
233
237
|
|
|
234
238
|
#### Response cache
|
|
235
239
|
|
|
@@ -390,7 +394,7 @@ A server shared by a Discord bot, n8n, Open WebUI and a coding agent, with most
|
|
|
390
394
|
{
|
|
391
395
|
"plugins": [
|
|
392
396
|
{
|
|
393
|
-
"package": "opencode-plugboard@0.
|
|
397
|
+
"package": "opencode-plugboard@0.22.0",
|
|
394
398
|
"options": {
|
|
395
399
|
// Reachable from the LAN. Non-loopback hosts refuse to start without keys.
|
|
396
400
|
"listen": { "host": "10.0.1.50", "port": 4010 },
|
|
@@ -460,6 +464,17 @@ One page per client, with the config, a chat, a tool call, structured output and
|
|
|
460
464
|
| pydantic-ai | [pydantic-ai][client-pydantic-ai] | pass with `OpenAIChatModel` |
|
|
461
465
|
| Open WebUI | [open-webui][client-open-webui] | pass over its HTTP API; no browser, no structured output in its UI |
|
|
462
466
|
| n8n | [n8n][client-n8n] | pass with "Use Responses API" off |
|
|
467
|
+
| Claude Code | [claude-code][client-claude-code] | pass: text and a tool turn through `/v1/messages`; needs `maxTools` above 36 |
|
|
468
|
+
| Codex CLI | [codex-cli][client-codex-cli] | docs only, not run yet (Responses API) |
|
|
469
|
+
| Continue | [continue][client-continue] | docs only, not run yet |
|
|
470
|
+
| Kilo Code | [kilo-code][client-kilo-code] | docs only, not run yet |
|
|
471
|
+
| LibreChat | [librechat][client-librechat] | docs only, not run yet |
|
|
472
|
+
| Zed | [zed][client-zed] | docs only, not run yet |
|
|
473
|
+
| OpenAI Agents SDK (Python) | [openai-agents][client-openai-agents] | pass on Responses and Chat Completions: text, stream, tools, structured output |
|
|
474
|
+
| LiteLLM (as a client) | [litellm-client][client-litellm] | pass: text, stream, tools |
|
|
475
|
+
| Instructor | [instructor][client-instructor] | pass: TOOLS, JSON_SCHEMA and partial streaming modes |
|
|
476
|
+
| DSPy | [dspy][client-dspy] | pass: Predict, ChainOfThought, typed output |
|
|
477
|
+
| Aider | [aider][client-aider] | pass: one scripted `--message` run |
|
|
463
478
|
| Anything else | [any-app][client-any-app] | base URL, key, model id |
|
|
464
479
|
|
|
465
480
|
Each run drove the client's own SDK or API against a dev server at an earlier commit: a plain chat, a tool round trip, streaming, an image and structured output on `fast`, a Claude model and a ChatGPT model. A pass is not a clean sweep, and the notes say where it was not. The latest full run is in [compat/results/release-candidate.md][release-candidate].
|
|
@@ -538,4 +553,15 @@ Not affiliated with or endorsed by OpenAI, Anthropic, Google, GitHub or Microsof
|
|
|
538
553
|
[client-pydantic-ai]: https://jjjermiah.github.io/opencode-plugboard/clients/pydantic-ai
|
|
539
554
|
[client-open-webui]: https://jjjermiah.github.io/opencode-plugboard/clients/open-webui
|
|
540
555
|
[client-n8n]: https://jjjermiah.github.io/opencode-plugboard/clients/n8n
|
|
556
|
+
[client-claude-code]: https://jjjermiah.github.io/opencode-plugboard/clients/claude-code
|
|
557
|
+
[client-codex-cli]: https://jjjermiah.github.io/opencode-plugboard/clients/codex-cli
|
|
558
|
+
[client-continue]: https://jjjermiah.github.io/opencode-plugboard/clients/continue
|
|
559
|
+
[client-kilo-code]: https://jjjermiah.github.io/opencode-plugboard/clients/kilo-code
|
|
560
|
+
[client-librechat]: https://jjjermiah.github.io/opencode-plugboard/clients/librechat
|
|
561
|
+
[client-zed]: https://jjjermiah.github.io/opencode-plugboard/clients/zed
|
|
562
|
+
[client-openai-agents]: https://jjjermiah.github.io/opencode-plugboard/clients/openai-agents
|
|
563
|
+
[client-litellm]: https://jjjermiah.github.io/opencode-plugboard/clients/litellm-client
|
|
564
|
+
[client-instructor]: https://jjjermiah.github.io/opencode-plugboard/clients/instructor
|
|
565
|
+
[client-dspy]: https://jjjermiah.github.io/opencode-plugboard/clients/dspy
|
|
566
|
+
[client-aider]: https://jjjermiah.github.io/opencode-plugboard/clients/aider
|
|
541
567
|
[client-any-app]: https://jjjermiah.github.io/opencode-plugboard/clients/any-app
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app: an OpenCode V2 plugin serving OpenAI, Anthropic and Gemini compatible APIs.",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"flake-hunt": "bun .github/scripts/flake-hunt.ts",
|
|
33
33
|
"coverage-manifest": "bun .github/scripts/coverage-manifest.ts",
|
|
34
34
|
"live-matrix": "bun test/live/matrix.ts",
|
|
35
|
+
"compat:python": "uv run --project compat/py python compat/py/clients/run_all.py",
|
|
35
36
|
"build:tui": "bun .github/scripts/build-tui.ts",
|
|
36
37
|
"prepack": "bun run build:tui",
|
|
37
38
|
"smoke:pack": "bun .github/scripts/smoke-pack.ts",
|
package/src/anthropic/request.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* afterwards, which is why the request omits `ref`.
|
|
10
10
|
*/
|
|
11
11
|
import type { PlugboardOptions } from "../config.js"
|
|
12
|
+
import { imageProblem } from "../image.js"
|
|
12
13
|
import { bodyUserId, userField } from "../user.js"
|
|
13
14
|
import {
|
|
14
15
|
isPlugError,
|
|
@@ -54,7 +55,10 @@ const mediaPart = (block: JsonObject, at: string, what: "image" | "document"): P
|
|
|
54
55
|
if (source.type === "base64") {
|
|
55
56
|
if (typeof source.media_type !== "string" || !MEDIA_TYPE.test(source.media_type)) return bad(`${at}.source.media_type: must be a media type such as image/png`)
|
|
56
57
|
if (typeof source.data !== "string") return bad(`${at}.source.data: must be a base64 string`)
|
|
57
|
-
|
|
58
|
+
const url = `data:${source.media_type};base64,${source.data}`
|
|
59
|
+
const problem = what === "image" ? imageProblem(url) : undefined
|
|
60
|
+
if (problem) return bad(`${at}.source.data: ${problem}`)
|
|
61
|
+
return { type: "media", url, mediaType: source.media_type }
|
|
58
62
|
}
|
|
59
63
|
if (source.type === "url") {
|
|
60
64
|
if (typeof source.url !== "string" || source.url.length === 0) return bad(`${at}.source.url: must be a non-empty string`)
|
package/src/anthropic/route.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
15
|
-
import { authenticate, bodyLimits } from "../config.js"
|
|
15
|
+
import { authenticate, bodyLimits, historyTemplate } from "../config.js"
|
|
16
16
|
import { liveOptions } from "../runtime.js"
|
|
17
17
|
import { bodyUserId, noteEndUser } from "../user.js"
|
|
18
18
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
@@ -138,7 +138,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
138
138
|
onRecord: (recordID, directory) => {
|
|
139
139
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
140
140
|
response.setHeader(SESSION_HEADER, recordID)
|
|
141
|
-
setSessionLink(response, info, host.options.
|
|
141
|
+
setSessionLink(response, info, historyTemplate(host.options, auth.app), recordID, directory)
|
|
142
142
|
},
|
|
143
143
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
144
144
|
onPolicy: reportPolicy(response),
|
|
@@ -59,6 +59,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
|
|
|
59
59
|
if (!isRecord(body)) return bad("Request body must be a JSON object")
|
|
60
60
|
if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
|
|
61
61
|
if (body.prompt == null) return bad("prompt is required", "prompt")
|
|
62
|
+
if (body.stream != null && typeof body.stream !== "boolean") return bad("stream must be a boolean", "stream")
|
|
62
63
|
|
|
63
64
|
if (body.logprobs != null) return unsupported("logprobs is not supported", "logprobs")
|
|
64
65
|
if (body.best_of != null) {
|
|
@@ -90,7 +90,7 @@ async function settle(events: AsyncIterable<PlugEvent>, exchange: CompletionsExc
|
|
|
90
90
|
if (exchange.structuredTool !== undefined) {
|
|
91
91
|
if (answer !== undefined) return { text: answer, reason: finish, usage }
|
|
92
92
|
const fallback = exchange.structuredSchema === undefined ? undefined : textFallback(text, exchange.structuredSchema)
|
|
93
|
-
if (fallback === undefined) return structuredMissing(
|
|
93
|
+
if (fallback === undefined) return structuredMissing()
|
|
94
94
|
return { text: fallback, reason: finish, usage, fallback: true }
|
|
95
95
|
}
|
|
96
96
|
if (finish === "stop") text = stripStopTail(text, exchange.stop ?? []).text
|
package/src/config.ts
CHANGED
|
@@ -68,6 +68,16 @@ export interface PlugboardOptions {
|
|
|
68
68
|
readonly historyUrl?: string
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The session link template that applies to `app` (#280): its own `apps.<name>.historyUrl` when set, `undefined`
|
|
73
|
+
* when that is `false`, else the global `historyUrl`. Routes call this with the authenticated app.
|
|
74
|
+
*/
|
|
75
|
+
export function historyTemplate(options: Pick<PlugboardOptions, "apps" | "historyUrl">, app: string): string | undefined {
|
|
76
|
+
const own = appPolicy(options, app)?.historyUrl
|
|
77
|
+
if (own === false) return undefined
|
|
78
|
+
return own ?? options.historyUrl
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
/** Option `usage` after defaults: on, and days of history kept. */
|
|
72
82
|
export interface UsageConfig {
|
|
73
83
|
readonly enabled: boolean
|
|
@@ -96,6 +106,11 @@ export interface AppPolicy {
|
|
|
96
106
|
readonly serverTools?: ReadonlyArray<string>
|
|
97
107
|
/** Server-tool steps one request may run before the turn is stopped (option `maxServerToolSteps`). */
|
|
98
108
|
readonly maxServerToolSteps?: number
|
|
109
|
+
/**
|
|
110
|
+
* This app's session link template (option `apps.<name>.historyUrl`), or `false` for none. Absent: the global
|
|
111
|
+
* `historyUrl`. Look the effective template up with `historyTemplate`.
|
|
112
|
+
*/
|
|
113
|
+
readonly historyUrl?: string | false
|
|
99
114
|
}
|
|
100
115
|
|
|
101
116
|
/** One set of caps. A field that is not set is unlimited. */
|
|
@@ -250,7 +265,7 @@ function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
|
|
|
250
265
|
return [...merged]
|
|
251
266
|
}
|
|
252
267
|
|
|
253
|
-
const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools", "serverTools", "maxServerToolSteps"])
|
|
268
|
+
const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools", "serverTools", "maxServerToolSteps", "historyUrl"])
|
|
254
269
|
const SERVER_TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/
|
|
255
270
|
const MAX_SERVER_TOOLS = 32
|
|
256
271
|
const MAX_SERVER_TOOL_STEPS = 100
|
|
@@ -311,6 +326,8 @@ function parseApps(
|
|
|
311
326
|
const maxServerToolSteps =
|
|
312
327
|
value.maxServerToolSteps === undefined ? undefined : positiveInt(value.maxServerToolSteps, `apps.${app}.maxServerToolSteps`, DEFAULT_SERVER_TOOL_STEPS, MAX_SERVER_TOOL_STEPS)
|
|
313
328
|
|
|
329
|
+
// `false` turns the link off for this app; a string is its own template (validated like the global one).
|
|
330
|
+
const historyUrl = value.historyUrl === false ? false : parseHistoryUrl(value.historyUrl, `plugboard option \`apps.${app}.historyUrl\``)
|
|
314
331
|
const policy: AppPolicy = {
|
|
315
332
|
...(value.models === undefined ? {} : { models: value.models as string[] }),
|
|
316
333
|
...(value.system === undefined ? {} : { system: value.system as string }),
|
|
@@ -319,9 +336,10 @@ function parseApps(
|
|
|
319
336
|
...(maxTools === undefined ? {} : { maxTools }),
|
|
320
337
|
...(serverTools === undefined ? {} : { serverTools }),
|
|
321
338
|
...(maxServerToolSteps === undefined ? {} : { maxServerToolSteps }),
|
|
339
|
+
...(historyUrl === undefined ? {} : { historyUrl }),
|
|
322
340
|
}
|
|
323
341
|
// An app that only carries a key has no policy to look up.
|
|
324
|
-
if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined || serverTools !== undefined)
|
|
342
|
+
if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined || serverTools !== undefined || historyUrl !== undefined)
|
|
325
343
|
entries.push([app, policy])
|
|
326
344
|
}
|
|
327
345
|
return Object.fromEntries(entries)
|
|
@@ -502,9 +520,8 @@ function parseAgents(raw: unknown, models: ReadonlyArray<string>): Record<string
|
|
|
502
520
|
* Option `historyUrl` (#77): an http(s) URL template that must contain `{id}`. Its only other placeholder is
|
|
503
521
|
* `{directory}`, so a typo such as `{sesion}` fails at load instead of producing a link that is wrong.
|
|
504
522
|
*/
|
|
505
|
-
function parseHistoryUrl(value: unknown): string | undefined {
|
|
523
|
+
function parseHistoryUrl(value: unknown, at = "plugboard option `historyUrl`"): string | undefined {
|
|
506
524
|
if (value === undefined) return undefined
|
|
507
|
-
const at = "plugboard option `historyUrl`"
|
|
508
525
|
if (typeof value !== "string" || !value.includes("{id}")) throw new Error(`${at} must be a URL template with {id} (for example http://host:4096/session/{id})`)
|
|
509
526
|
const unknown = (value.match(/\{[^{}]*\}/g) ?? []).find((placeholder) => placeholder !== "{id}" && placeholder !== "{directory}")
|
|
510
527
|
if (unknown !== undefined) throw new Error(`${at} has the unknown placeholder ${unknown} (known: {id}, {directory})`)
|
package/src/contract.ts
CHANGED
|
@@ -176,10 +176,9 @@ export interface CallContext {
|
|
|
176
176
|
/** Called once, before the first event, with the id of the history record this call writes (if any). */
|
|
177
177
|
readonly onRecord?: (recordID: string, directory?: string) => void
|
|
178
178
|
/**
|
|
179
|
-
* Called once, before the first event,
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* reports it.
|
|
179
|
+
* Called once, before the first event, for every answered call (`src/fallback.ts`): which target answered
|
|
180
|
+
* and how many earlier targets failed first. A front-end uses it for `x-plugboard-model`, and the `model`
|
|
181
|
+
* it echoes when `chain` is set. A call that fails never reports it.
|
|
183
182
|
*/
|
|
184
183
|
readonly onServed?: (served: ServedModel) => void
|
|
185
184
|
/** Called once, before the first event, with each change the app's policy made that the caller sent for. Front-ends report them in the `x-plugboard-policy` header. */
|
|
@@ -210,10 +209,14 @@ export interface CallTotals {
|
|
|
210
209
|
readonly costUsd?: number
|
|
211
210
|
}
|
|
212
211
|
|
|
213
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* The target that answered: `provider/model[#variant]`, and the targets tried before it. `chain` is set
|
|
214
|
+
* when the request named a list alias, the only case where the response body echoes the target.
|
|
215
|
+
*/
|
|
214
216
|
export interface ServedModel {
|
|
215
217
|
readonly model: string
|
|
216
218
|
readonly fallbacks: number
|
|
219
|
+
readonly chain?: boolean
|
|
217
220
|
}
|
|
218
221
|
|
|
219
222
|
/** The seam between the core and a backend (the session backend now, `ctx.llm.stream` possibly later). */
|
package/src/fallback.ts
CHANGED
|
@@ -56,10 +56,11 @@ export interface ServedInfo {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* The `CallContext.onServed` of a route: `x-plugboard-model` (
|
|
60
|
-
* failed first
|
|
61
|
-
* choices of one request report the first answer. `then` runs for
|
|
62
|
-
* echoes the
|
|
59
|
+
* The `CallContext.onServed` of a route: `x-plugboard-model` on every answer (alias or canonical id), and
|
|
60
|
+
* `x-plugboard-fallbacks` when targets failed first, while the headers are still open, plus the log fields.
|
|
61
|
+
* The first call wins, so several choices of one request report the first answer. `then` runs only for a
|
|
62
|
+
* list alias (`served.chain`): a single-call route echoes the target in the body for those, and keeps the
|
|
63
|
+
* model the caller sent for every other request.
|
|
63
64
|
*/
|
|
64
65
|
export function reportServed(response: ServerResponse, info: ServedInfo, then?: (served: ServedModel) => void): (served: ServedModel) => void {
|
|
65
66
|
return (served) => {
|
|
@@ -70,7 +71,7 @@ export function reportServed(response: ServerResponse, info: ServedInfo, then?:
|
|
|
70
71
|
response.setHeader(MODEL_HEADER, served.model)
|
|
71
72
|
if (served.fallbacks > 0) response.setHeader(FALLBACKS_HEADER, String(served.fallbacks))
|
|
72
73
|
}
|
|
73
|
-
then?.(served)
|
|
74
|
+
if (served.chain) then?.(served)
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -80,12 +81,27 @@ export const isRetryable = (error: PlugError): boolean => error.code !== undefin
|
|
|
80
81
|
export function withFallbacks(inner: ModelCaller, deps: FallbackDeps): ModelCaller {
|
|
81
82
|
return {
|
|
82
83
|
stream(request, context) {
|
|
83
|
-
if (!Object.hasOwn(deps.options.fallbacks, request.model)) return inner.stream(request, context)
|
|
84
|
+
if (!Object.hasOwn(deps.options.fallbacks, request.model)) return reportPlain(inner.stream(request, context), context, request.ref)
|
|
84
85
|
return chain(inner, deps, request, context)
|
|
85
86
|
},
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A call that is not a list alias: no retry, but the answer still names its model (`x-plugboard-model`, #279).
|
|
92
|
+
* Reported before the first non-error event, so a failed call reports nothing, as in `chain`.
|
|
93
|
+
*/
|
|
94
|
+
async function* reportPlain(events: AsyncIterable<PlugEvent>, context: CallContext, ref: ModelRef): AsyncGenerator<PlugEvent> {
|
|
95
|
+
let reported = false
|
|
96
|
+
for await (const event of events) {
|
|
97
|
+
if (!reported && event.type !== "error") {
|
|
98
|
+
reported = true
|
|
99
|
+
context.onServed?.({ model: refToString(ref), fallbacks: 0 })
|
|
100
|
+
}
|
|
101
|
+
yield event
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
|
|
90
106
|
let rest: ModelRef[] | undefined
|
|
91
107
|
let ref = request.ref
|
|
@@ -130,7 +146,7 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
|
|
|
130
146
|
if (thrown) throw thrown.error
|
|
131
147
|
if (first!.done) return
|
|
132
148
|
// An error is not an answer: a call that fails (non-retryable, or on its last target) reports no served model.
|
|
133
|
-
if (reason === undefined && first!.value.type !== "error") context.onServed?.({ model: refToString(ref), fallbacks: attempt })
|
|
149
|
+
if (reason === undefined && first!.value.type !== "error") context.onServed?.({ model: refToString(ref), fallbacks: attempt, chain: true })
|
|
134
150
|
try {
|
|
135
151
|
yield first!.value
|
|
136
152
|
for (let step = await iterator.next(); !step.done; step = await iterator.next()) yield step.value
|
package/src/gemini/request.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* API also accepts.
|
|
14
14
|
*/
|
|
15
15
|
import type { PlugboardOptions } from "../config.js"
|
|
16
|
+
import { imageProblem } from "../image.js"
|
|
16
17
|
import {
|
|
17
18
|
isPlugError,
|
|
18
19
|
type PlugError,
|
|
@@ -212,7 +213,10 @@ const parseContents = (raw: unknown): PlugMessage[] | PlugError => {
|
|
|
212
213
|
if (role === "model") return unsupported("inlineData is not supported in a model turn", `${at}.inlineData`)
|
|
213
214
|
if (!isRecord(inline) || typeof inline.data !== "string" || typeof inline.mimeType !== "string")
|
|
214
215
|
return bad("inlineData needs mimeType and base64 data", `${at}.inlineData`)
|
|
215
|
-
|
|
216
|
+
const url = dataUrl(inline.mimeType, inline.data)
|
|
217
|
+
const problem = inline.mimeType.toLowerCase().startsWith("image/") ? imageProblem(url) : undefined
|
|
218
|
+
if (problem) return bad(problem, `${at}.inlineData`, "invalid_image")
|
|
219
|
+
parts.push({ type: "media", url, mediaType: inline.mimeType })
|
|
216
220
|
} else if (file !== undefined) {
|
|
217
221
|
if (role === "model") return unsupported("fileData is not supported in a model turn", `${at}.fileData`)
|
|
218
222
|
const uri = isRecord(file) ? field(file, "fileUri") : undefined
|
package/src/gemini/response.ts
CHANGED
|
@@ -227,7 +227,7 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
227
227
|
|
|
228
228
|
const passthrough = exchange.callerTools === true && calls > 0
|
|
229
229
|
const fallback = exchange.structuredTool !== undefined && !passthrough && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
|
|
230
|
-
const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing(
|
|
230
|
+
const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing()
|
|
231
231
|
failure ??= finish ? missing : noFinish()
|
|
232
232
|
if (failure) return { failure, started }
|
|
233
233
|
flush()
|
package/src/gemini/route.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
18
18
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
19
19
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
20
|
-
import { bodyLimits } from "../config.js"
|
|
20
|
+
import { bodyLimits, historyTemplate } from "../config.js"
|
|
21
21
|
import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
22
22
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
23
23
|
import { reportCache } from "../cache.js"
|
|
@@ -163,7 +163,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
163
163
|
onRecord: (recordID, directory) => {
|
|
164
164
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
165
165
|
response.setHeader(SESSION_HEADER, recordID)
|
|
166
|
-
setSessionLink(response, info, host.options
|
|
166
|
+
setSessionLink(response, info, historyTemplate(host.options, app), recordID, directory)
|
|
167
167
|
},
|
|
168
168
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
169
169
|
onPolicy: reportPolicy(response),
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check every image part of a request gets in its decoder (#286), so a caller's bad image is a 400
|
|
3
|
+
* before any session, preparation or budget work. Only `data:` URLs are checked here: a `data:` image must
|
|
4
|
+
* carry an `image/*` media type, well-formed base64, and bytes that start with a PNG, JPEG, GIF or WebP
|
|
5
|
+
* signature (the formats the providers take). Any other URL is not checked here; remote images are checked
|
|
6
|
+
* when they are fetched (src/session/media.ts).
|
|
7
|
+
*
|
|
8
|
+
* Pure: no node imports. `Buffer` is the global, and only the first 12 decoded bytes are read.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DATA_URL = /^data:([^,]*);base64,(.*)$/s
|
|
12
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/
|
|
13
|
+
|
|
14
|
+
/** The image signatures plugboard accepts, checked on the first bytes of the decoded data. */
|
|
15
|
+
const SIGNATURES: ReadonlyArray<(head: Buffer) => boolean> = [
|
|
16
|
+
(head) => head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47,
|
|
17
|
+
(head) => head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff,
|
|
18
|
+
(head) => head.subarray(0, 4).toString("latin1") === "GIF8",
|
|
19
|
+
(head) => head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The message for a file part (`file_data`, Chat Completions or Responses `input_file`) that cannot be sent (#288).
|
|
24
|
+
* Only a PDF given as a `data:application/pdf;base64,` URL is accepted: the compatibility matrix sends one on
|
|
25
|
+
* OpenAI Chat. A bare base64 string has no media type, and any other type is refused here instead of failing
|
|
26
|
+
* later in backend preparation.
|
|
27
|
+
*/
|
|
28
|
+
export function pdfProblem(url: string): string | undefined {
|
|
29
|
+
const match = /^data:application\/pdf;base64,(.*)$/is.exec(url)
|
|
30
|
+
if (!match) return "File data must be a PDF data URL (data:application/pdf;base64,...); other file types are not supported"
|
|
31
|
+
const data = match[1]!
|
|
32
|
+
if (data.length === 0 || !BASE64.test(data) || data.length % 4 === 1) return "File data is not valid base64"
|
|
33
|
+
return Buffer.from(data.slice(0, 8), "base64").subarray(0, 4).toString("latin1") === "%PDF" ? undefined : "File data is not a PDF"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The message for a `data:` image URL that cannot be sent to a provider, or undefined when it can (or is not a data URL). */
|
|
37
|
+
export function imageProblem(url: string): string | undefined {
|
|
38
|
+
if (!url.startsWith("data:")) return undefined
|
|
39
|
+
const match = DATA_URL.exec(url)
|
|
40
|
+
if (!match) return "Image data URL must be base64 encoded, for example data:image/png;base64,..."
|
|
41
|
+
const mediaType = match[1]!.split(";")[0]!.trim().toLowerCase()
|
|
42
|
+
if (!mediaType.startsWith("image/")) return "Image data URL must have an image/* media type"
|
|
43
|
+
const data = match[2]!
|
|
44
|
+
if (data.length === 0) return "Image data URL has no data"
|
|
45
|
+
if (!BASE64.test(data) || data.length % 4 === 1) return "Image data URL is not valid base64"
|
|
46
|
+
// 16 base64 characters decode to the 12 bytes the signatures need.
|
|
47
|
+
const head = Buffer.from(data.slice(0, 16), "base64")
|
|
48
|
+
return SIGNATURES.some((matches) => matches(head)) ? undefined : "Image data URL is not a PNG, JPEG, GIF or WebP image"
|
|
49
|
+
}
|
package/src/openai/fanout.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { reportServed, type ServedInfo } from "../fallback.js"
|
|
|
26
26
|
import { reportCache } from "../cache.js"
|
|
27
27
|
import { reportPolicy, reportServerTools } from "../policy.js"
|
|
28
28
|
import { reportUsage } from "../usage-header.js"
|
|
29
|
+
import { historyTemplate } from "../config.js"
|
|
29
30
|
import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
|
|
30
31
|
import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
31
32
|
import {
|
|
@@ -198,8 +199,9 @@ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">,
|
|
|
198
199
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
199
200
|
records[index] = recordID
|
|
200
201
|
response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
const template = historyTemplate(host.options, app)
|
|
203
|
+
if (template !== undefined) {
|
|
204
|
+
links[index] = sessionUrl(template, recordID, directory)
|
|
203
205
|
info.sessionUrl = links.filter((link) => link !== undefined).join(",")
|
|
204
206
|
response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
|
|
205
207
|
}
|
package/src/openai/request.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* the request omits `ref`.
|
|
7
7
|
*/
|
|
8
8
|
import type { PlugboardOptions } from "../config.js"
|
|
9
|
+
import { imageProblem, pdfProblem } from "../image.js"
|
|
9
10
|
import { bodyUser, userField } from "../user.js"
|
|
10
11
|
import {
|
|
11
12
|
isPlugError,
|
|
@@ -80,10 +81,13 @@ const userParts = (content: unknown, param: string): PlugPart[] | PlugError => {
|
|
|
80
81
|
if (item.type === "text" && typeof item.text === "string") parts.push({ type: "text", text: item.text })
|
|
81
82
|
else if (item.type === "image_url" && isRecord(item.image_url) && typeof item.image_url.url === "string") {
|
|
82
83
|
const url = item.image_url.url
|
|
84
|
+
const problem = imageProblem(url)
|
|
85
|
+
if (problem) return bad(problem, `${at}.image_url.url`, "invalid_image")
|
|
83
86
|
parts.push({ type: "media", url, ...(dataUrlType(url) ? { mediaType: dataUrlType(url) } : {}) })
|
|
84
87
|
} else if (item.type === "file" && isRecord(item.file) && typeof item.file.file_data === "string") {
|
|
85
|
-
const
|
|
86
|
-
const
|
|
88
|
+
const url = item.file.file_data
|
|
89
|
+
const problem = pdfProblem(url)
|
|
90
|
+
if (problem) return bad(problem, `${at}.file.file_data`, "invalid_file")
|
|
87
91
|
parts.push({ type: "media", url, mediaType: dataUrlType(url) })
|
|
88
92
|
} else return bad(`Unsupported content part type "${String(item.type)}"`, `${at}.type`, "unsupported_parameter")
|
|
89
93
|
}
|
|
@@ -279,6 +283,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
|
|
|
279
283
|
// Parameters the session backend cannot honour are rejected, not ignored.
|
|
280
284
|
const n = parseChoiceCount(body, options)
|
|
281
285
|
if (typeof n === "object") return n
|
|
286
|
+
if (body.stream != null && typeof body.stream !== "boolean") return bad("stream must be a boolean", "stream")
|
|
282
287
|
if (body.logprobs === true) return bad("logprobs is not supported", "logprobs", "unsupported_parameter")
|
|
283
288
|
if (body.top_logprobs != null) return bad("top_logprobs is not supported", "top_logprobs", "unsupported_parameter")
|
|
284
289
|
|
package/src/openai/response.ts
CHANGED
|
@@ -166,12 +166,15 @@ const noFinish = (): PlugError => ({
|
|
|
166
166
|
|
|
167
167
|
const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
|
|
168
168
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
169
|
+
/**
|
|
170
|
+
* The 502 for a structured turn that ended without its answer (no forced-tool call, and no text that is the
|
|
171
|
+
* schema's JSON). Shared by every front-end. The message names no internal tool: the caller never sees one (#282).
|
|
172
|
+
*/
|
|
173
|
+
export const structuredMissing = (): PlugError => ({
|
|
171
174
|
status: 502,
|
|
172
175
|
kind: "server",
|
|
173
176
|
code: "structured_output_missing",
|
|
174
|
-
message:
|
|
177
|
+
message: "The model did not return JSON matching the response schema. Say the expected JSON, with its keys and types, in the prompt.",
|
|
175
178
|
})
|
|
176
179
|
|
|
177
180
|
/**
|
|
@@ -186,7 +189,7 @@ const structured = {
|
|
|
186
189
|
content: (call: ToolCall) => args(call.input),
|
|
187
190
|
/** The failure for a finished structured exchange, if any. */
|
|
188
191
|
failure: (exchange: OpenAiExchange, answered: boolean): PlugError | undefined =>
|
|
189
|
-
exchange.structuredTool === undefined || answered ? undefined : structuredMissing(
|
|
192
|
+
exchange.structuredTool === undefined || answered ? undefined : structuredMissing(),
|
|
190
193
|
}
|
|
191
194
|
|
|
192
195
|
const isObjectValue = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
package/src/openai/route.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
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, bodyLimits, type PlugboardOptions } from "../config.js"
|
|
25
|
+
import { DEFAULT_APP, authenticate, authenticateUsage, bodyLimits, historyTemplate, type PlugboardOptions } from "../config.js"
|
|
26
26
|
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
|
|
27
27
|
import { buildDiscovery, buildStatus, readiness, renderDiscoveryHtml, sendText } from "../discovery.js"
|
|
28
28
|
import {
|
|
@@ -209,6 +209,15 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
209
209
|
// Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
|
|
210
210
|
if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
|
|
211
211
|
|
|
212
|
+
// `/metrics` with `adminKey` set: the admin key alone (#285). Its series name every app, so an app key may not read them.
|
|
213
|
+
// Without `adminKey` it keeps the `/v1` authentication, and any app key sees every app's series (documented).
|
|
214
|
+
if (route === "GET /metrics" && metrics && host.options.adminKey !== undefined) {
|
|
215
|
+
const who = authenticateUsage(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
|
|
216
|
+
if (isPlugError(who)) return fail(response, who)
|
|
217
|
+
if (!who.admin) return fail(response, { status: 403, kind: "permission", code: "admin_key_required", message: "Metrics need the admin key (option `adminKey`)" })
|
|
218
|
+
return sendText(response, 200, metrics.contentType, await metrics.render(runtimeStatus()))
|
|
219
|
+
}
|
|
220
|
+
|
|
212
221
|
// Runtime app keys (#55): the admin routes answer the admin key only, and are a 404 without `adminKey`.
|
|
213
222
|
if (path === "/v1/admin/keys" || path.startsWith("/v1/admin/keys/"))
|
|
214
223
|
return serveAdmin({ options: liveOptions(host.options), keys: host.keys }, { path, request, response, scope, info })
|
|
@@ -305,7 +314,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
305
314
|
onRecord: (recordID, directory) => {
|
|
306
315
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
307
316
|
response.setHeader(SESSION_HEADER, recordID)
|
|
308
|
-
setSessionLink(response, info, host.options.
|
|
317
|
+
setSessionLink(response, info, historyTemplate(host.options, auth.app), recordID, directory)
|
|
309
318
|
},
|
|
310
319
|
// An alias with fallbacks: report which target answered, and echo it as the response model.
|
|
311
320
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
package/src/responses/request.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `ModelRef` afterwards, which is why the request omits `ref`.
|
|
11
11
|
*/
|
|
12
12
|
import type { PlugboardOptions } from "../config.js"
|
|
13
|
+
import { imageProblem, pdfProblem } from "../image.js"
|
|
13
14
|
import { bodyUser, userField } from "../user.js"
|
|
14
15
|
import {
|
|
15
16
|
isPlugError,
|
|
@@ -88,12 +89,16 @@ const userParts = (content: unknown, param: string): PlugPart[] | PlugError => {
|
|
|
88
89
|
const url = typeof item.image_url === "string" ? item.image_url : isRecord(item.image_url) ? item.image_url.url : undefined
|
|
89
90
|
if (typeof url !== "string")
|
|
90
91
|
return unsupported(item.file_id != null ? "input_image.file_id is not supported (stateless); send image_url" : "input_image needs an image_url", `${at}.image_url`)
|
|
92
|
+
const problem = imageProblem(url)
|
|
93
|
+
if (problem) return bad(problem, `${at}.image_url`, "invalid_image")
|
|
91
94
|
parts.push({ type: "media", url, ...(dataUrlType(url) ? { mediaType: dataUrlType(url) } : {}) })
|
|
92
95
|
} else if (item.type === "input_file") {
|
|
93
96
|
if (typeof item.file_data === "string") {
|
|
94
97
|
const data = item.file_data
|
|
95
98
|
const filename = typeof item.filename === "string" ? item.filename.toLowerCase() : ""
|
|
96
99
|
const url = data.startsWith("data:") ? data : `data:${filename.endsWith(".pdf") ? "application/pdf" : "application/octet-stream"};base64,${data}`
|
|
100
|
+
const problem = pdfProblem(url)
|
|
101
|
+
if (problem) return bad(problem, `${at}.file_data`, "invalid_file")
|
|
97
102
|
parts.push({ type: "media", url, mediaType: dataUrlType(url) })
|
|
98
103
|
} else if (typeof item.file_url === "string") {
|
|
99
104
|
parts.push({ type: "media", url: item.file_url })
|
|
@@ -246,6 +251,13 @@ const parseTools = (raw: unknown): PlugTool[] | PlugError => {
|
|
|
246
251
|
return tools
|
|
247
252
|
}
|
|
248
253
|
|
|
254
|
+
/**
|
|
255
|
+
* A caller's tool as echoed on the response object. A function tool always carries `strict` (#289, the OpenAI
|
|
256
|
+
* FunctionTool schema requires it): the caller's boolean, or false when it was absent or null. Other entries are
|
|
257
|
+
* echoed as sent.
|
|
258
|
+
*/
|
|
259
|
+
const echoTool = (tool: unknown): unknown => (isRecord(tool) && tool.type === "function" ? { ...tool, strict: tool.strict === true } : tool)
|
|
260
|
+
|
|
249
261
|
const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError => {
|
|
250
262
|
if (raw == null) return undefined
|
|
251
263
|
if (raw === "auto" || raw === "none" || raw === "required") return raw
|
|
@@ -319,6 +331,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
|
|
|
319
331
|
if (refused) return refused
|
|
320
332
|
if (body.instructions != null && typeof body.instructions !== "string") return bad("instructions must be a string", "instructions")
|
|
321
333
|
if (body.reasoning != null && !isRecord(body.reasoning)) return bad("reasoning must be an object", "reasoning")
|
|
334
|
+
if (body.stream != null && typeof body.stream !== "boolean") return bad("stream must be a boolean", "stream")
|
|
322
335
|
|
|
323
336
|
const parsed = parseInput(body.input, resolve)
|
|
324
337
|
if (isPlugError(parsed)) return parsed
|
|
@@ -381,7 +394,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
|
|
|
381
394
|
temperature: generation.temperature ?? null,
|
|
382
395
|
text: { format: isRecord(text?.format) ? text.format : { type: "text" } },
|
|
383
396
|
tool_choice: body.tool_choice ?? "auto",
|
|
384
|
-
tools: Array.isArray(body.tools) ? body.tools : [],
|
|
397
|
+
tools: Array.isArray(body.tools) ? body.tools.map(echoTool) : [],
|
|
385
398
|
top_p: generation.topP ?? null,
|
|
386
399
|
truncation: "disabled",
|
|
387
400
|
user: typeof body.user === "string" ? body.user : null,
|
|
@@ -201,7 +201,7 @@ export async function runResponse(
|
|
|
201
201
|
const passthrough = exchange.callerTools === true && calls > 0
|
|
202
202
|
const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
|
|
203
203
|
const fallback = structuredAnswer && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
|
|
204
|
-
const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing(
|
|
204
|
+
const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing()
|
|
205
205
|
failure ??= finish ? missing : noFinish()
|
|
206
206
|
if (failure) {
|
|
207
207
|
if (!started && !committed()) return { failure, started }
|
package/src/responses/route.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { ServerResponse } from "node:http"
|
|
12
12
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
13
|
+
import { historyTemplate } from "../config.js"
|
|
13
14
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
14
15
|
import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
15
16
|
import { resolveModel } from "../models.js"
|
|
@@ -55,7 +56,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
55
56
|
onRecord: (recordID, directory) => {
|
|
56
57
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
57
58
|
response.setHeader(SESSION_HEADER, recordID)
|
|
58
|
-
setSessionLink(response, info, host.options
|
|
59
|
+
setSessionLink(response, info, historyTemplate(host.options, app), recordID, directory)
|
|
59
60
|
},
|
|
60
61
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
61
62
|
onPolicy: reportPolicy(response),
|
package/src/session/caller.ts
CHANGED
|
@@ -68,7 +68,9 @@ const countMedia = (request: PlugRequest): number =>
|
|
|
68
68
|
function checkPreparation(request: PlugRequest, images: number, maxTools: number): PlugError | undefined {
|
|
69
69
|
const wanted = countMedia(request)
|
|
70
70
|
if (images !== wanted) {
|
|
71
|
-
|
|
71
|
+
// The counts are for the log only; the caller gets the generic message (#288).
|
|
72
|
+
log("request.preparation_failed", { images, wanted })
|
|
73
|
+
return { status: 500, kind: "server", code: "backend_preparation_failed", message: "Could not prepare the model request" }
|
|
72
74
|
}
|
|
73
75
|
const tools = seedToolsFor(request).length
|
|
74
76
|
if (tools > maxTools) {
|
package/src/session/context.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { Effect } from "effect"
|
|
11
11
|
import { appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
|
|
12
12
|
import type { PlugError, PlugMessage, PlugRequest } from "../contract.js"
|
|
13
|
+
import { log } from "../log.js"
|
|
13
14
|
import type { ContextHookEvent, HostContext, HostPart, HostToolDescriptor } from "./host.js"
|
|
14
15
|
import { SEED_PREFIX, SEED_RESULT, seedToolsFor } from "./seeds.js"
|
|
15
16
|
import { FAILURE_EVENT, type SessionStore } from "./state.js"
|
|
@@ -108,7 +109,8 @@ function systemFor(caller: ReadonlyArray<string>, policy: AppPolicy | undefined)
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
|
|
112
|
+
/** The one 500 message for a request plugboard could not prepare. What was missing is logged, never sent: it names host internals (#288). */
|
|
113
|
+
const preparationFailed = (): PlugError => ({ status: 500, kind: "server", code: "backend_preparation_failed", message: "Could not prepare the model request" })
|
|
112
114
|
|
|
113
115
|
/**
|
|
114
116
|
* `session.hook("context")` body: swap the request session's system, messages, tools and
|
|
@@ -151,11 +153,8 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
|
|
|
151
153
|
// The caller validates both before dispatch; this only fires if the host disagrees. The request is
|
|
152
154
|
// already rewritten (no real tool can run); fail it instead of answering from a truncated prompt.
|
|
153
155
|
if (missingMedia > 0 || missingTools > 0) {
|
|
154
|
-
|
|
155
|
-
state.collector.push({
|
|
156
|
-
type: FAILURE_EVENT,
|
|
157
|
-
error: preparationFailed(`Could not prepare the model request: ${detail.filter(Boolean).join(", ")}`),
|
|
158
|
-
})
|
|
156
|
+
log("request.preparation_failed", { missing_media: missingMedia, missing_tools: missingTools })
|
|
157
|
+
state.collector.push({ type: FAILURE_EVENT, error: preparationFailed() })
|
|
159
158
|
}
|
|
160
159
|
}
|
|
161
160
|
|