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.
@@ -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
- /** Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network. */
18
- readonly remoteImages: boolean
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
- if (input.remoteImages !== undefined && typeof input.remoteImages !== "boolean")
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: input.remoteImages === true,
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",
@@ -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
- /** 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. */
104
- const VERSION = 5
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
- // Never bound: another process (or an older runtime) owns the port. Log once, stay inert.
329
- logError(error.code === "EADDRINUSE" ? "listener.address_in_use" : "listener.failed", {
330
- listen: `${listen.host}:${listen.port}`,
331
- error: error.message,
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.listen(listen.port, listen.host, () => {
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,
@@ -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?: boolean
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 === true
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 } : {}) },
@@ -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
- /** Host `SessionError` (`{ type, message, status?, response? }`) to a `PlugError`. */
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 message = error?.message || fallback
145
- const base = { message, ...(error?.type ? { code: error.type } : {}) }
146
- if (status === 401) return { status, kind: "authentication", ...base }
147
- if (status === 403) return { status, kind: "permission", ...base }
148
- if (status === 404) return { status, kind: "not_found", ...base }
149
- if (status === 429) return { status, kind: "rate_limit", ...base }
150
- if (status === 408 || status === 504) return { status, kind: "timeout", ...base }
151
- if (status !== undefined && status < 500) return { status, kind: "invalid_request", ...base }
152
- return { status: status ?? 502, kind: "server", ...base }
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.fail(toPlugError(error, "Model step failed"))
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.fail(toPlugError(item.error as RawError | undefined, "Session execution failed"))
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 })
@@ -50,9 +50,20 @@ const firstUserText = (request: PlugRequest): string => {
50
50
  return request.model
51
51
  }
52
52
 
53
- /** Session title: the first user text, whitespace-collapsed and cut to 60 characters. */
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).replace(/\s+/g, " ").trim()
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
- const text = lines.join("\n").trim()
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
  }