opencode-plugboard 0.5.0 → 0.7.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 +5 -6
- package/package.json +1 -1
- package/src/anthropic/request.ts +343 -0
- package/src/anthropic/response.ts +221 -0
- package/src/anthropic/route.ts +148 -0
- package/src/config.ts +27 -5
- package/src/openai/route.ts +8 -4
- package/src/runtime.ts +43 -10
- package/src/session/caller.ts +5 -5
- package/src/session/events.ts +60 -12
- package/src/session/history.ts +18 -6
- package/src/session/media.ts +256 -23
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /v1/messages` (and a clear 404 for `POST /v1/messages/count_tokens`): the Anthropic
|
|
3
|
+
* Messages front-end's half of the route. `src/openai/route.ts` hands every request for these
|
|
4
|
+
* paths over before it does anything else, because this route differs from the OpenAI ones in
|
|
5
|
+
* three ways: errors use Anthropic's body shape from the first check on, `x-api-key` authenticates
|
|
6
|
+
* as well as `Authorization: Bearer`, and there is no `[DONE]`.
|
|
7
|
+
*
|
|
8
|
+
* Same flow as the other front-ends: origin check, authenticate, parse, resolve the model, call
|
|
9
|
+
* the ModelCaller, encode. Nothing is committed until the encoder emits its first event, so a
|
|
10
|
+
* failure before any output is a real HTTP error even when the client asked for a stream.
|
|
11
|
+
* `anthropic-version` and `anthropic-beta` are accepted and ignored.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
15
|
+
import { authenticate } from "../config.js"
|
|
16
|
+
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
17
|
+
import {
|
|
18
|
+
BodyError,
|
|
19
|
+
RequestAborted,
|
|
20
|
+
boundEvents,
|
|
21
|
+
checkJsonContentType,
|
|
22
|
+
checkOrigin,
|
|
23
|
+
readJsonBody,
|
|
24
|
+
sendJson,
|
|
25
|
+
sseWriter,
|
|
26
|
+
type RequestScope,
|
|
27
|
+
} from "../http.js"
|
|
28
|
+
import { resolveModel } from "../models.js"
|
|
29
|
+
import { dropRejected } from "../openai/sampling.js"
|
|
30
|
+
import type { RouteHost } from "../openai/route.js"
|
|
31
|
+
import { parseMessagesRequest } from "./request.js"
|
|
32
|
+
import { encodeError, runMessage, sseFrame } from "./response.js"
|
|
33
|
+
|
|
34
|
+
const SESSION_HEADER = "x-plugboard-session"
|
|
35
|
+
const DROPPED_HEADER = "x-plugboard-dropped-params"
|
|
36
|
+
const MESSAGES = "/v1/messages"
|
|
37
|
+
const COUNT_TOKENS = "/v1/messages/count_tokens"
|
|
38
|
+
|
|
39
|
+
/** Paths this front-end owns, whatever the method. */
|
|
40
|
+
export const isMessagesPath = (path: string) => path === MESSAGES || path === COUNT_TOKENS
|
|
41
|
+
|
|
42
|
+
export interface MessagesCall {
|
|
43
|
+
readonly path: string
|
|
44
|
+
readonly request: IncomingMessage
|
|
45
|
+
readonly response: ServerResponse
|
|
46
|
+
readonly scope: RequestScope
|
|
47
|
+
/** The route's log fields: the route name, the app, the resolved model, dropped parameters, and the outcome of a stream that failed after it committed. */
|
|
48
|
+
readonly info: { route: string; app?: string; model?: string; failed?: string; dropped?: string }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const header = (request: IncomingMessage, name: string): string | undefined => {
|
|
52
|
+
const value = request.headers[name]
|
|
53
|
+
return (Array.isArray(value) ? value[0] : value)?.trim() || undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
|
|
57
|
+
const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
|
|
58
|
+
const bearer = authenticate(host.options, request.headers.authorization)
|
|
59
|
+
const apiKey = header(request, "x-api-key")
|
|
60
|
+
return isPlugError(bearer) && apiKey ? authenticate(host.options, `Bearer ${apiKey}`) : bearer
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
|
|
64
|
+
const { path, request, response, scope, info } = call
|
|
65
|
+
const fail = (error: PlugError, close = false) => sendJson(response, error.status, encodeError(error), { close })
|
|
66
|
+
if (request.method === "POST") info.route = `POST ${path}`
|
|
67
|
+
response.setHeader("request-id", `req_${crypto.randomUUID().replaceAll("-", "")}`)
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await serve()
|
|
71
|
+
} catch (caught) {
|
|
72
|
+
// Answer in this route's error shape, then rethrow: the shared handler logs the outcome and
|
|
73
|
+
// finds the response already sent.
|
|
74
|
+
if (!response.headersSent) {
|
|
75
|
+
if (caught instanceof BodyError) fail({ status: caught.status, kind: "invalid_request", message: caught.message }, caught.status === 413)
|
|
76
|
+
else if (caught instanceof RequestAborted) {
|
|
77
|
+
if (caught.kind === "timeout") fail(scope.timeoutError, true)
|
|
78
|
+
} else fail({ status: 500, kind: "server", message: "Internal server error" })
|
|
79
|
+
}
|
|
80
|
+
throw caught
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function serve(): Promise<void> {
|
|
84
|
+
if (request.method !== "POST")
|
|
85
|
+
return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
|
|
86
|
+
const rejected = checkOrigin(request, host.options)
|
|
87
|
+
if (rejected) return fail(rejected)
|
|
88
|
+
const auth = authenticateRequest(host, request)
|
|
89
|
+
if (isPlugError(auth)) return fail(auth)
|
|
90
|
+
info.app = auth.app
|
|
91
|
+
if (path === COUNT_TOKENS)
|
|
92
|
+
return fail({ status: 404, kind: "not_found", code: "not_found", message: "count_tokens is not supported by plugboard" })
|
|
93
|
+
const unsupported = checkJsonContentType(request)
|
|
94
|
+
if (unsupported) return fail(unsupported)
|
|
95
|
+
|
|
96
|
+
const body = await readJsonBody(request, scope.signal)
|
|
97
|
+
const parsed = parseMessagesRequest(body, host.options)
|
|
98
|
+
if (isPlugError(parsed)) return fail(parsed)
|
|
99
|
+
const catalog = await scope.race(host.loadCatalog())
|
|
100
|
+
const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
|
|
101
|
+
if (isPlugError(ref)) return fail(ref)
|
|
102
|
+
info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
103
|
+
// Sampling parameters the model is known to reject are dropped; `top_k` has no neutral
|
|
104
|
+
// equivalent and is always dropped. Both are reported before any response bytes.
|
|
105
|
+
const sampled = host.options.dropUnsupportedParams ? dropRejected(ref, parsed.request.generation) : undefined
|
|
106
|
+
const dropped = [...(sampled?.dropped ?? []), ...(parsed.topKIgnored ? ["top_k"] : [])]
|
|
107
|
+
if (dropped.length > 0) {
|
|
108
|
+
info.dropped = dropped.join(",")
|
|
109
|
+
response.setHeader(DROPPED_HEADER, info.dropped)
|
|
110
|
+
}
|
|
111
|
+
const model: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
|
|
112
|
+
const { exchange } = parsed
|
|
113
|
+
|
|
114
|
+
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
115
|
+
if (scope.signal.aborted) throw scope.signal.reason
|
|
116
|
+
const events = boundEvents(
|
|
117
|
+
host.caller.stream(model, {
|
|
118
|
+
app: auth.app,
|
|
119
|
+
signal: scope.signal,
|
|
120
|
+
onRecord: (recordID) => {
|
|
121
|
+
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
122
|
+
response.setHeader(SESSION_HEADER, recordID)
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
scope,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if (exchange.stream) {
|
|
129
|
+
const sse = sseWriter(response, scope.signal)
|
|
130
|
+
const { failure, started } = await runMessage(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)))
|
|
131
|
+
if (!sse.started && !response.headersSent) {
|
|
132
|
+
if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
133
|
+
} else {
|
|
134
|
+
if (failure) {
|
|
135
|
+
info.failed = failure.code ?? failure.kind
|
|
136
|
+
// Committed by a keepalive before the encoder wrote anything: the encoder left the error for us.
|
|
137
|
+
if (!started && !response.writableEnded) sse.write(sseFrame(encodeError(failure)))
|
|
138
|
+
}
|
|
139
|
+
if (!response.writableEnded) response.end()
|
|
140
|
+
}
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const run = await runMessage(events, exchange, () => {})
|
|
145
|
+
if (run.failure || !run.message) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
146
|
+
sendJson(response, 200, run.message)
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
|
|
2
2
|
|
|
3
3
|
import type { PlugError } from "./contract.js"
|
|
4
|
+
import { parseAllowEntry, type RemoteImages } from "./session/media.js"
|
|
4
5
|
import type { HistoryLimits } from "./session/prune.js"
|
|
5
6
|
|
|
6
7
|
/** Plugin options, after defaults and validation (see `parseOptions`). */
|
|
@@ -14,8 +15,13 @@ export interface PlugboardOptions {
|
|
|
14
15
|
readonly aliases: Readonly<Record<string, string>>
|
|
15
16
|
readonly maxTools: number
|
|
16
17
|
readonly requestTimeoutMs: number
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network.
|
|
20
|
+
* `true` fetches public addresses only (loopback, private, link-local, CGNAT and ULA ranges are refused);
|
|
21
|
+
* `{ allow }` is on too, and lists the hosts (`name`, `*.suffix`), addresses or CIDR ranges that may be
|
|
22
|
+
* fetched even when they are not public.
|
|
23
|
+
*/
|
|
24
|
+
readonly remoteImages: RemoteImages
|
|
19
25
|
/** Prune old request sessions (see `src/session/prune.ts`). A limit that is unset is not applied; with neither, history is kept forever. Applies per app. */
|
|
20
26
|
readonly history: HistoryLimits
|
|
21
27
|
/** Drop sampling parameters the target model is known to reject (reported in `x-plugboard-dropped-params`). On by default; off forwards them as sent. */
|
|
@@ -68,6 +74,23 @@ function positiveInt(value: unknown, name: string, fallback: number, max = Numbe
|
|
|
68
74
|
return value
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
const REMOTE_IMAGES_ERROR =
|
|
78
|
+
"plugboard option `remoteImages` must be a boolean or `{ allow: [...] }` with host names, IP addresses or CIDR ranges that may be fetched even when they are not public"
|
|
79
|
+
|
|
80
|
+
function parseRemoteImages(value: unknown): RemoteImages {
|
|
81
|
+
if (value === undefined) return false
|
|
82
|
+
if (typeof value === "boolean") return value
|
|
83
|
+
if (!isRecord(value)) throw new Error(REMOTE_IMAGES_ERROR)
|
|
84
|
+
for (const key of Object.keys(value))
|
|
85
|
+
if (key !== "allow") throw new Error(`Unknown plugboard option \`remoteImages.${key}\` (known: allow)`)
|
|
86
|
+
const allow = value.allow ?? []
|
|
87
|
+
if (!Array.isArray(allow)) throw new Error(REMOTE_IMAGES_ERROR)
|
|
88
|
+
for (const entry of allow)
|
|
89
|
+
if (typeof entry !== "string" || parseAllowEntry(entry) === undefined)
|
|
90
|
+
throw new Error(`plugboard \`remoteImages.allow\` entry \`${String(entry)}\` must be a host name (\`images.internal\`, \`*.internal\`), an IP address or a CIDR range`)
|
|
91
|
+
return { allow: allow as string[] }
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
72
95
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
73
96
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -122,8 +145,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
122
145
|
}
|
|
123
146
|
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
124
147
|
|
|
125
|
-
|
|
126
|
-
throw new Error("plugboard option `remoteImages` must be a boolean")
|
|
148
|
+
const remoteImages = parseRemoteImages(input.remoteImages)
|
|
127
149
|
if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
|
|
128
150
|
throw new Error("plugboard option `dropUnsupportedParams` must be a boolean")
|
|
129
151
|
|
|
@@ -145,7 +167,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
145
167
|
aliases,
|
|
146
168
|
maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
|
|
147
169
|
requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
|
|
148
|
-
remoteImages
|
|
170
|
+
remoteImages,
|
|
149
171
|
history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
|
|
150
172
|
dropUnsupportedParams: input.dropUnsupportedParams !== false,
|
|
151
173
|
logFormat: input.logFormat === "json" ? "json" : "text",
|
package/src/openai/route.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
39
39
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
40
40
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
41
|
+
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
41
42
|
import { serveResponses } from "../responses/route.js"
|
|
42
43
|
import { processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
43
44
|
import { parseChatRequest } from "./request.js"
|
|
@@ -135,6 +136,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
135
136
|
const route = `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
136
137
|
// Arbitrary client paths stay out of the log.
|
|
137
138
|
info.route = modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
139
|
+
// Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
|
|
140
|
+
if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
|
|
138
141
|
|
|
139
142
|
const rejected = checkOrigin(request, host.options)
|
|
140
143
|
if (rejected) return fail(response, rejected)
|
|
@@ -157,6 +160,11 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
157
160
|
return sendText(response, 200, "text/html; charset=utf-8", renderDiscoveryHtml(doc), PAGE_HEADERS)
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
164
|
+
const auth = authenticate(host.options, request.headers.authorization)
|
|
165
|
+
if (isPlugError(auth)) return fail(response, auth)
|
|
166
|
+
info.app = auth.app
|
|
167
|
+
|
|
160
168
|
if (modelId === undefined && !known(route)) {
|
|
161
169
|
return fail(response, {
|
|
162
170
|
status: 404,
|
|
@@ -166,10 +174,6 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
166
174
|
})
|
|
167
175
|
}
|
|
168
176
|
|
|
169
|
-
const auth = authenticate(host.options, request.headers.authorization)
|
|
170
|
-
if (isPlugError(auth)) return fail(response, auth)
|
|
171
|
-
info.app = auth.app
|
|
172
|
-
|
|
173
177
|
if (route === "GET /v1/status") {
|
|
174
178
|
const catalog = await optionalCatalog(scope)
|
|
175
179
|
return sendJson(response, 200, buildStatus({ options: host.options, app: auth.app, runtime: runtimeStatus(), catalog }))
|
package/src/runtime.ts
CHANGED
|
@@ -100,8 +100,12 @@ const SWEEP_DEADLINE_MS = 60_000
|
|
|
100
100
|
const BACKEND_TIMEOUT_MS = 15_000
|
|
101
101
|
const BACKEND_POLL_MS = 25
|
|
102
102
|
|
|
103
|
-
/**
|
|
104
|
-
const
|
|
103
|
+
/** How often a listener that could not bind tries again, and how often it repeats the error while it still cannot (#87). */
|
|
104
|
+
const BIND_RETRY_MS = 15_000
|
|
105
|
+
const BIND_REMINDER_MS = 5 * 60_000
|
|
106
|
+
|
|
107
|
+
/** 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. */
|
|
108
|
+
const VERSION = 6
|
|
105
109
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
106
110
|
|
|
107
111
|
interface Listener {
|
|
@@ -111,6 +115,11 @@ interface Listener {
|
|
|
111
115
|
fingerprint: string
|
|
112
116
|
mismatchLogged: boolean
|
|
113
117
|
bound: boolean
|
|
118
|
+
/** Failed bind attempts since the last success: while this is above 0 nothing receives requests on the port. */
|
|
119
|
+
bindFailures: number
|
|
120
|
+
/** When the bind failure was last logged (epoch ms). */
|
|
121
|
+
bindLoggedAt: number
|
|
122
|
+
retryTimer?: ReturnType<typeof setTimeout>
|
|
114
123
|
graceTimer?: ReturnType<typeof setTimeout>
|
|
115
124
|
}
|
|
116
125
|
|
|
@@ -312,7 +321,7 @@ const unavailable = (response: ServerResponse, message: string) => {
|
|
|
312
321
|
}
|
|
313
322
|
|
|
314
323
|
function startListener(rt: Runtime, listen: { host: string; port: number }, fingerprint: string): Listener {
|
|
315
|
-
const listener: Listener = { server: createServer(), listen, fingerprint, mismatchLogged: false, bound: false }
|
|
324
|
+
const listener: Listener = { server: createServer(), listen, fingerprint, mismatchLogged: false, bound: false, bindFailures: 0, bindLoggedAt: 0 }
|
|
316
325
|
listener.server.on("request", (request, response) => {
|
|
317
326
|
const current = preferred(rt)
|
|
318
327
|
if (!current) return unavailable(response, "plugboard is reloading")
|
|
@@ -325,22 +334,39 @@ function startListener(rt: Runtime, listen: { host: string; port: number }, fing
|
|
|
325
334
|
})
|
|
326
335
|
listener.server.on("error", (error: NodeJS.ErrnoException) => {
|
|
327
336
|
if (listener.bound) return logError("listener.error", { code: error.code, error: error.message })
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
337
|
+
// Not bound: another process (or an older runtime) owns the port, so nothing here receives
|
|
338
|
+
// requests while clients may be sending their keys and prompts to whoever holds it. Say so
|
|
339
|
+
// loudly (error level, again every few minutes), keep trying, and mention it on every
|
|
340
|
+
// generation that registers meanwhile (`registerGeneration`).
|
|
341
|
+
listener.bindFailures++
|
|
342
|
+
if (Date.now() - listener.bindLoggedAt >= BIND_REMINDER_MS) {
|
|
343
|
+
listener.bindLoggedAt = Date.now()
|
|
344
|
+
logError(error.code === "EADDRINUSE" ? "listener.address_in_use" : "listener.failed", {
|
|
345
|
+
listen: `${listen.host}:${listen.port}`,
|
|
346
|
+
error: error.message,
|
|
347
|
+
status: "not serving: clients of this port reach another process",
|
|
348
|
+
action: "stop the process that holds the port or change `listen`; plugboard retries every 15s",
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
listener.retryTimer = setTimeout(() => {
|
|
352
|
+
listener.retryTimer = undefined
|
|
353
|
+
if (rt.listener === listener && !listener.bound) listener.server.listen(listen.port, listen.host)
|
|
354
|
+
}, BIND_RETRY_MS)
|
|
355
|
+
listener.retryTimer.unref?.()
|
|
333
356
|
})
|
|
334
|
-
listener.server.
|
|
357
|
+
listener.server.on("listening", () => {
|
|
335
358
|
listener.bound = true
|
|
336
|
-
log("listener.listening", { listen: `${listen.host}:${listen.port}
|
|
359
|
+
log("listener.listening", { listen: `${listen.host}:${listen.port}`, ...(listener.bindFailures > 0 ? { recoveredAfterFailedBinds: listener.bindFailures } : {}) })
|
|
360
|
+
listener.bindFailures = 0
|
|
337
361
|
})
|
|
362
|
+
listener.server.listen(listen.port, listen.host)
|
|
338
363
|
return listener
|
|
339
364
|
}
|
|
340
365
|
|
|
341
366
|
function closeListener(rt: Runtime, listener: Listener): void {
|
|
342
367
|
if (rt.listener !== listener) return
|
|
343
368
|
rt.listener = undefined
|
|
369
|
+
clearTimeout(listener.retryTimer)
|
|
344
370
|
try {
|
|
345
371
|
rt.pump?.stop()
|
|
346
372
|
} catch {}
|
|
@@ -404,6 +430,13 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
404
430
|
}
|
|
405
431
|
if (listener.graceTimer) clearTimeout(listener.graceTimer)
|
|
406
432
|
listener.graceTimer = undefined
|
|
433
|
+
if (listener.bindFailures > 0) {
|
|
434
|
+
logError("listener.not_serving", {
|
|
435
|
+
listen: `${listen.host}:${listen.port}`,
|
|
436
|
+
status: "another process holds the port; this generation serves nothing",
|
|
437
|
+
action: "stop that process or change `listen`",
|
|
438
|
+
})
|
|
439
|
+
}
|
|
407
440
|
const generation: Generation = {
|
|
408
441
|
directory,
|
|
409
442
|
handler: input.handler,
|
package/src/session/caller.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { log, logError } from "../log.js"
|
|
|
23
23
|
import { RequestCollector } from "./events.js"
|
|
24
24
|
import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
|
|
25
25
|
import { describeError, type SessionPort } from "./host.js"
|
|
26
|
-
import { ImageError, prepareImages } from "./media.js"
|
|
26
|
+
import { ImageError, prepareImages, type RemoteImages } from "./media.js"
|
|
27
27
|
import { REQUEST_PERMISSIONS, seedToolsFor } from "./seeds.js"
|
|
28
28
|
import type { SessionStore } from "./state.js"
|
|
29
29
|
|
|
@@ -33,8 +33,8 @@ export interface SessionCallerDeps {
|
|
|
33
33
|
readonly sessions: SessionStore
|
|
34
34
|
/** The seed pool size of the app directories (option `maxTools`); a request needing more tools fails before dispatch. */
|
|
35
35
|
readonly maxTools: number
|
|
36
|
-
/** Allow fetching http(s) image URLs (option `remoteImages
|
|
37
|
-
readonly remoteImages?:
|
|
36
|
+
/** Allow fetching http(s) image URLs (option `remoteImages`: off, public addresses only, or with an allowlist). */
|
|
37
|
+
readonly remoteImages?: RemoteImages
|
|
38
38
|
/**
|
|
39
39
|
* Make sure the backend of `app` can run request sessions (the app directory's plugin is loaded),
|
|
40
40
|
* called right before `session.create`. An error ends the request without creating anything.
|
|
@@ -85,7 +85,7 @@ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
|
85
85
|
|
|
86
86
|
export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
87
87
|
const { port, sessions, maxTools, ensureBackend } = deps
|
|
88
|
-
const remoteImages = deps.remoteImages
|
|
88
|
+
const remoteImages = deps.remoteImages ?? false
|
|
89
89
|
|
|
90
90
|
return {
|
|
91
91
|
async *stream(request: PlugRequest, context: CallContext): AsyncGenerator<PlugEvent> {
|
|
@@ -134,7 +134,7 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
|
134
134
|
if (unavailable) return yield note({ type: "error", error: unavailable })
|
|
135
135
|
if (context.signal.aborted) return
|
|
136
136
|
|
|
137
|
-
collector = new RequestCollector({ stopTurn: interrupt })
|
|
137
|
+
collector = new RequestCollector({ stopTurn: interrupt, requestID })
|
|
138
138
|
const created = await raceAbort(port.create({
|
|
139
139
|
directory,
|
|
140
140
|
model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
|
package/src/session/events.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { isPlugError, type PlugError, type PlugEvent, type PlugFinish, type PlugUsage } from "../contract.js"
|
|
15
|
+
import { logError } from "../log.js"
|
|
15
16
|
import type { BusEvent } from "./host.js"
|
|
16
17
|
import { FAILURE_EVENT } from "./state.js"
|
|
17
18
|
|
|
@@ -116,6 +117,8 @@ interface RawError {
|
|
|
116
117
|
readonly type?: string
|
|
117
118
|
readonly message?: string
|
|
118
119
|
readonly status?: number
|
|
120
|
+
/** Raw provider response. Read for classification and the log only; never copied to the caller. */
|
|
121
|
+
readonly response?: { readonly body?: string }
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
const total = (...parts: Array<number | undefined>): number | undefined =>
|
|
@@ -138,23 +141,56 @@ const toUsage = (tokens?: RawUsage): PlugUsage | undefined =>
|
|
|
138
141
|
}
|
|
139
142
|
: undefined
|
|
140
143
|
|
|
141
|
-
/**
|
|
144
|
+
/** Provider wording for "the prompt does not fit the model" (a subset of the host's own matcher; the host does not forward its classification). */
|
|
145
|
+
const CONTEXT_OVERFLOW =
|
|
146
|
+
/context[_ ](?:length|window)|maximum context|prompt (?:is )?too long|too many tokens|token limit|input length.*exceeds|exceeds the (?:available )?context|reduce the length of the messages/i
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Host `SessionError` (`{ type, message, status?, response? }`) to a `PlugError`. Provider and host
|
|
150
|
+
* error text can carry endpoints, account or organization ids, so it never reaches the caller:
|
|
151
|
+
* the caller gets a stable `code` and a fixed message per class. The classes a caller can act on
|
|
152
|
+
* stay distinct (rate limit, quota, context length, provider credentials, timeout, content filter).
|
|
153
|
+
* The raw text goes to the log only (`RequestCollector.failFromHost`).
|
|
154
|
+
*/
|
|
142
155
|
function toPlugError(error: RawError | undefined, fallback: string): PlugError {
|
|
143
156
|
const status = typeof error?.status === "number" && error.status >= 400 ? error.status : undefined
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
const type = error?.type ?? ""
|
|
158
|
+
// Our own constant (the session caller reports a prompt that could not start); the host never uses this type.
|
|
159
|
+
if (type === "prompt_failed") return { status: 502, kind: "server", code: "prompt_failed", message: error?.message || fallback }
|
|
160
|
+
if (type === "provider.rate-limit" || status === 429) {
|
|
161
|
+
return { status: 429, kind: "rate_limit", code: "rate_limited", message: "The model provider is rate limiting requests; retry later" }
|
|
162
|
+
}
|
|
163
|
+
if (type === "provider.quota") {
|
|
164
|
+
return { status: 429, kind: "rate_limit", code: "quota_exceeded", message: "The model provider's usage quota is exhausted" }
|
|
165
|
+
}
|
|
166
|
+
if ((type === "provider.invalid-request" || status === 400 || status === 413) && CONTEXT_OVERFLOW.test(`${error?.message ?? ""}\n${error?.response?.body ?? ""}`)) {
|
|
167
|
+
return { status: 400, kind: "invalid_request", code: "context_length_exceeded", param: "messages", message: "The request is longer than the model's context window" }
|
|
168
|
+
}
|
|
169
|
+
if (type === "provider.auth" || status === 401 || status === 403) {
|
|
170
|
+
const message = "The model provider rejected its credentials; the plugboard host needs to sign in again"
|
|
171
|
+
if (status === 401) return { status, kind: "authentication", code: "provider_auth_failed", message }
|
|
172
|
+
if (status === 403) return { status, kind: "permission", code: "provider_auth_failed", message }
|
|
173
|
+
return { status: 502, kind: "server", code: "provider_auth_failed", message }
|
|
174
|
+
}
|
|
175
|
+
if (type === "provider.content-filter") {
|
|
176
|
+
return { status: 400, kind: "invalid_request", code: "content_filter", message: "The model provider's content filter blocked the request" }
|
|
177
|
+
}
|
|
178
|
+
if (type === "provider.timeout" || status === 408 || status === 504) {
|
|
179
|
+
return { status: 504, kind: "timeout", code: "provider_timeout", message: "The model provider timed out" }
|
|
180
|
+
}
|
|
181
|
+
if (type.startsWith("provider.")) {
|
|
182
|
+
if (status === 404) return { status, kind: "not_found", code: "provider_rejected_request", message: "The model provider rejected the request" }
|
|
183
|
+
if (status !== undefined && status < 500) return { status, kind: "invalid_request", code: "provider_rejected_request", message: "The model provider rejected the request" }
|
|
184
|
+
return { status: status ?? 502, kind: "server", code: "provider_unavailable", message: "The model provider is unavailable or returned an error" }
|
|
185
|
+
}
|
|
186
|
+
return { status: 502, kind: "server", code: "model_run_failed", message: fallback }
|
|
153
187
|
}
|
|
154
188
|
|
|
155
189
|
export interface CollectorHooks {
|
|
156
190
|
/** The step ended with tool calls: stop the turn before the runner starts another model step. */
|
|
157
191
|
readonly stopTurn: () => void
|
|
192
|
+
/** The request's id, for the log line that carries a failure's raw host text. */
|
|
193
|
+
readonly requestID?: string
|
|
158
194
|
}
|
|
159
195
|
|
|
160
196
|
export class RequestCollector {
|
|
@@ -268,11 +304,11 @@ export class RequestCollector {
|
|
|
268
304
|
if (this.calls.size > 0 && error?.type === "aborted") return this.finishWith("tool-calls")
|
|
269
305
|
if (item.finish === "content-filter") return this.finishWith("content-filter")
|
|
270
306
|
if (error?.type === "aborted" && this.wasCancelled) return this.end()
|
|
271
|
-
return this.
|
|
307
|
+
return this.failFromHost(error, "Model step failed")
|
|
272
308
|
}
|
|
273
309
|
case "session.execution.failed":
|
|
274
310
|
this.executionDone = true
|
|
275
|
-
if (!this.finished) this.
|
|
311
|
+
if (!this.finished) this.failFromHost(item.error as RawError | undefined, "Session execution failed")
|
|
276
312
|
this.end()
|
|
277
313
|
return
|
|
278
314
|
case "session.execution.interrupted":
|
|
@@ -310,6 +346,18 @@ export class RequestCollector {
|
|
|
310
346
|
this.end()
|
|
311
347
|
}
|
|
312
348
|
|
|
349
|
+
/** Fail with the caller-safe mapping of a host error; the raw text is logged here and goes nowhere else. */
|
|
350
|
+
private failFromHost(raw: RawError | undefined, fallback: string): void {
|
|
351
|
+
if (this.finished) return
|
|
352
|
+
logError("model.failed", {
|
|
353
|
+
requestID: this.hooks.requestID,
|
|
354
|
+
type: raw?.type,
|
|
355
|
+
status: raw?.status,
|
|
356
|
+
message: raw?.message?.slice(0, 500),
|
|
357
|
+
})
|
|
358
|
+
this.fail(toPlugError(raw, fallback))
|
|
359
|
+
}
|
|
360
|
+
|
|
313
361
|
private fail(error: PlugError): void {
|
|
314
362
|
if (this.finished) return
|
|
315
363
|
this.emit({ type: "error", error })
|
package/src/session/history.ts
CHANGED
|
@@ -50,9 +50,20 @@ const firstUserText = (request: PlugRequest): string => {
|
|
|
50
50
|
return request.model
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Control characters that terminal and app UIs act on: C0 (ESC, BEL, CR, ...), DEL, C1 (8-bit CSI),
|
|
55
|
+
* and the bidirectional marks and overrides (ALM, LRM, RLM, U+202A-202E, U+2066-2069) that reorder text.
|
|
56
|
+
* `\t` and `\n` are included here; callers that keep layout use `LAYOUT_SAFE`.
|
|
57
|
+
*/
|
|
58
|
+
const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
|
|
59
|
+
const CONTROLS_KEEP_LAYOUT = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
|
|
60
|
+
|
|
61
|
+
/** One display line: whitespace (including line breaks) collapsed to single spaces, then control and bidi characters removed. */
|
|
62
|
+
export const oneLine = (text: string): string => text.replace(/\s+/g, " ").replace(CONTROLS, "").trim()
|
|
63
|
+
|
|
64
|
+
/** Session title: the first user text, one line, control and bidi characters removed, cut to 60 characters. */
|
|
54
65
|
export const makeTitle = (request: PlugRequest): string => {
|
|
55
|
-
const flat = firstUserText(request)
|
|
66
|
+
const flat = oneLine(firstUserText(request))
|
|
56
67
|
return flat.length > TITLE_LENGTH ? flat.slice(0, TITLE_LENGTH - 1) + "…" : flat
|
|
57
68
|
}
|
|
58
69
|
|
|
@@ -74,16 +85,17 @@ export function renderTranscript(request: PlugRequest): string {
|
|
|
74
85
|
lines.push(`[image ${++image}]`)
|
|
75
86
|
break
|
|
76
87
|
case "tool-call":
|
|
77
|
-
lines.push(`[tool call ${part.name} (${part.id})] ${typeof part.input === "string" ? part.input : JSON.stringify(part.input)}`)
|
|
88
|
+
lines.push(`[tool call ${oneLine(part.name)} (${oneLine(part.id)})] ${typeof part.input === "string" ? part.input : JSON.stringify(part.input)}`)
|
|
78
89
|
break
|
|
79
90
|
case "tool-result":
|
|
80
|
-
lines.push(`[tool result ${part.name} (${part.id})${part.isError ? " (error)" : ""}] ${part.result}`)
|
|
91
|
+
lines.push(`[tool result ${oneLine(part.name)} (${oneLine(part.id)})${part.isError ? " (error)" : ""}] ${part.result}`)
|
|
81
92
|
break
|
|
82
93
|
}
|
|
83
94
|
}
|
|
84
95
|
lines.push("")
|
|
85
96
|
}
|
|
86
|
-
if (request.tools.length > 0) lines.push("## tools", request.tools.map((tool) => tool.name).join(", "), "")
|
|
87
|
-
|
|
97
|
+
if (request.tools.length > 0) lines.push("## tools", request.tools.map((tool) => oneLine(tool.name)).join(", "), "")
|
|
98
|
+
// The stored text is display only (the model sees the structured request): body text keeps its line breaks and tabs, nothing else that a UI would act on.
|
|
99
|
+
const text = lines.join("\n").replace(CONTROLS_KEEP_LAYOUT, "").trim()
|
|
88
100
|
return text.length > 0 ? text : "(empty request)"
|
|
89
101
|
}
|