opencode-plugboard 0.6.0 → 0.8.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,278 @@
1
+ /**
2
+ * Several choices for one request (`n` > 1 on Chat Completions, `n` or several prompts on legacy
3
+ * completions): one independent `ModelCaller` call per choice, run concurrently, each in its own
4
+ * session, merged into one response.
5
+ *
6
+ * Every choice is a full model call and a kept history session, which is why `maxN` caps them.
7
+ * The choices share nothing: no history, no seed, no ordering.
8
+ *
9
+ * Failure policy: if any choice fails, the request fails and the other choices are cancelled
10
+ * (their sessions are interrupted). A partial answer set would look like a complete one, and
11
+ * callers that sample n times for evaluation would silently average over fewer samples.
12
+ * - non-stream: the first failure becomes the HTTP error.
13
+ * - stream, nothing sent yet: the same HTTP error.
14
+ * - stream, already committed: an `error` frame and `[DONE]`, as for a single stream.
15
+ * Client disconnect and the request deadline cancel every choice (the group signal includes the
16
+ * request's).
17
+ *
18
+ * The wire encoders stay per-choice and come in through `ChoiceCodec`: Chat Completions here,
19
+ * legacy completions in `src/completions/response.ts`. This module owns what is shared: the
20
+ * group cancellation, the merge of JSON bodies and the interleaving of SSE chunks.
21
+ */
22
+
23
+ import type { ServerResponse } from "node:http"
24
+ import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.js"
25
+ import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
26
+ import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
27
+ import type { RouteHost } from "./route.js"
28
+
29
+ const SESSION_HEADER = "x-plugboard-session"
30
+
31
+ /** The per-choice wire encoder a front-end supplies. */
32
+ export interface ChoiceCodec {
33
+ readonly stream: boolean
34
+ readonly includeUsage: boolean
35
+ /** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
36
+ readonly idPrefix: string
37
+ /** One choice as a complete JSON body with `choices[0]` and `usage`. */
38
+ collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object }>
39
+ /** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
40
+ streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
41
+ /** The final chunk with the summed usage (`include_usage`). */
42
+ usageChunk(choice: Omit<StreamChoice, "index">, usage: PlugUsage): object
43
+ }
44
+
45
+ /** The Chat Completions encoder as a `ChoiceCodec`. */
46
+ export const chatCodec = (exchange: OpenAiExchange): ChoiceCodec => ({
47
+ stream: exchange.stream,
48
+ includeUsage: exchange.includeUsage,
49
+ idPrefix: "chatcmpl-",
50
+ collect: (events) => collectCompletion(events, exchange),
51
+ streamChoice: (events, write, choice) => streamCompletion(events, exchange, write, choice),
52
+ usageChunk: (choice, usage) => chatUsageChunk(exchange.model, choice.id, choice.created, usage),
53
+ })
54
+
55
+ export interface ChoicesCall {
56
+ readonly app: string
57
+ /** One resolved model call per choice, in choice order. Every entry is a full call with its own session. */
58
+ readonly calls: ReadonlyArray<PlugRequest>
59
+ readonly response: ServerResponse
60
+ readonly scope: RequestScope
61
+ /** The route's log fields: the outcome of a stream that failed after it committed. */
62
+ readonly info: { failed?: string }
63
+ readonly codec: ChoiceCodec
64
+ }
65
+
66
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
67
+
68
+ /** A queue that is an async iterable: the encoder of one choice reads what the interleaver feeds it. */
69
+ function channel<T>() {
70
+ const items: T[] = []
71
+ let closed = false
72
+ let wake: (() => void) | undefined
73
+ return {
74
+ push(item: T) {
75
+ items.push(item)
76
+ wake?.()
77
+ },
78
+ close() {
79
+ closed = true
80
+ wake?.()
81
+ },
82
+ async *[Symbol.asyncIterator](): AsyncGenerator<T> {
83
+ while (true) {
84
+ if (items.length > 0) {
85
+ yield items.shift()!
86
+ continue
87
+ }
88
+ if (closed) return
89
+ await new Promise<void>((resolve) => {
90
+ wake = resolve
91
+ })
92
+ wake = undefined
93
+ }
94
+ },
95
+ }
96
+ }
97
+
98
+ /** A scope whose signal also aborts when one choice fails; deadline and error stay the request's. */
99
+ function groupScope(scope: RequestScope, signal: AbortSignal): RequestScope {
100
+ return {
101
+ signal,
102
+ get timedOut() {
103
+ return scope.timedOut
104
+ },
105
+ timeoutError: scope.timeoutError,
106
+ race<T>(promise: Promise<T>): Promise<T> {
107
+ promise.catch(() => {})
108
+ if (signal.aborted) return Promise.reject(signal.reason)
109
+ return new Promise<T>((resolve, reject) => {
110
+ const onAbort = () => reject(signal.reason)
111
+ signal.addEventListener("abort", onAbort, { once: true })
112
+ promise.then(
113
+ (value) => {
114
+ signal.removeEventListener("abort", onAbort)
115
+ resolve(value)
116
+ },
117
+ (error) => {
118
+ signal.removeEventListener("abort", onAbort)
119
+ reject(error)
120
+ },
121
+ )
122
+ })
123
+ },
124
+ dispose() {},
125
+ }
126
+ }
127
+
128
+ /** Numbers add up, objects add up per key. */
129
+ const sumDeep = (a: unknown, b: unknown): unknown => {
130
+ if (typeof a === "number" && typeof b === "number") return a + b
131
+ if (isRecord(a) && isRecord(b)) return Object.fromEntries(Object.keys(a).map((key) => [key, sumDeep(a[key], b[key])]))
132
+ return a ?? b
133
+ }
134
+
135
+ /** The bodies of successful choices as one: `choices` in order with their `index`, `usage` summed, the rest from the first. */
136
+ function mergeBodies(bodies: ReadonlyArray<object>): object {
137
+ const [head] = bodies as ReadonlyArray<Record<string, unknown>>
138
+ const choices = (bodies as ReadonlyArray<{ choices: object[] }>).map((body, index) => ({ ...body.choices[0], index }))
139
+ const usage = (bodies as ReadonlyArray<{ usage?: unknown }>).map((body) => body.usage).reduce((total, next) => (total === undefined ? next : sumDeep(total, next)), undefined as unknown)
140
+ return { ...head, choices, ...(usage === undefined ? {} : { usage }) }
141
+ }
142
+
143
+ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
144
+ const out: { -readonly [K in keyof PlugUsage]: PlugUsage[K] } = {}
145
+ for (const key of ["inputTokens", "outputTokens", "reasoningTokens", "cacheReadTokens", "cacheWriteTokens"] as const) {
146
+ if (a[key] !== undefined || b[key] !== undefined) out[key] = (a[key] ?? 0) + (b[key] ?? 0)
147
+ }
148
+ return out
149
+ }
150
+
151
+ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
152
+ const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
153
+ // Abandoned requests (client gone, deadline passed) must never reach the model caller.
154
+ if (scope.signal.aborted) throw scope.signal.reason
155
+
156
+ const group = new AbortController()
157
+ const cancel = () => group.abort(new RequestAborted("disconnected"))
158
+ const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
159
+ /** History record ids as the callers report them, in choice order, for the response header (comma separated). */
160
+ const records: Array<string | undefined> = []
161
+ const open = (index: number) =>
162
+ boundEvents(
163
+ host.caller.stream(calls[index]!, {
164
+ app,
165
+ signal: gscope.signal,
166
+ onRecord: (recordID) => {
167
+ if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
168
+ records[index] = recordID
169
+ response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
170
+ },
171
+ }),
172
+ gscope,
173
+ )
174
+
175
+ if (!codec.stream) {
176
+ const results = await new Promise<Array<{ status: number; body: object }>>((resolve, reject) => {
177
+ const done: Array<{ status: number; body: object }> = []
178
+ let left = calls.length
179
+ calls.forEach((_, index) => {
180
+ codec.collect(open(index), index).then(
181
+ (result) => {
182
+ if (result.status !== 200) {
183
+ cancel()
184
+ return resolve([result])
185
+ }
186
+ done[index] = result
187
+ if (--left === 0) resolve(done)
188
+ },
189
+ (error) => {
190
+ cancel()
191
+ reject(error)
192
+ },
193
+ )
194
+ })
195
+ })
196
+ const first = results[0]!
197
+ return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)))
198
+ }
199
+
200
+ const sse = sseWriter(response, scope.signal)
201
+ const shared = { id: `${codec.idPrefix}${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`, created: Math.floor(Date.now() / 1000) }
202
+ const channels = calls.map(() => channel<PlugEvent>())
203
+ let failure: PlugError | undefined
204
+ let usage: PlugUsage | undefined
205
+ const runs = channels.map((events, index) =>
206
+ codec.streamChoice(events, sse.write, { ...shared, index }).then((result) => {
207
+ if (result.failure && !failure) {
208
+ failure = result.failure
209
+ cancel()
210
+ }
211
+ if (result.usage) usage = usage ? addUsage(usage, result.usage) : result.usage
212
+ }),
213
+ )
214
+
215
+ // The choice an event belongs to. Set just before it is yielded; the consumer reads it before it
216
+ // pulls again, so it cannot change in between.
217
+ let current = 0
218
+ /**
219
+ * The choices' events as one stream, so one `sse.paced` serves all: backpressure, and the
220
+ * keepalive commit at the first non-terminal event of any choice. At most one event per choice
221
+ * is pulled ahead. A choice's stream ending closes its channel.
222
+ */
223
+ async function* merged(): AsyncGenerator<PlugEvent> {
224
+ const iterators = calls.map((_, index) => open(index)[Symbol.asyncIterator]())
225
+ type Step = { index: number; result?: IteratorResult<PlugEvent>; error?: unknown; threw?: true }
226
+ const pending = new Map<number, Promise<Step>>()
227
+ const arm = (index: number) =>
228
+ pending.set(
229
+ index,
230
+ iterators[index]!.next().then(
231
+ (result): Step => ({ index, result }),
232
+ (error): Step => ({ index, error, threw: true }),
233
+ ),
234
+ )
235
+ try {
236
+ for (let index = 0; index < iterators.length; index++) arm(index)
237
+ while (pending.size > 0 && !failure) {
238
+ const step = await Promise.race(pending.values())
239
+ pending.delete(step.index)
240
+ if (step.threw) throw step.error
241
+ if (step.result!.done) {
242
+ channels[step.index]!.close()
243
+ continue
244
+ }
245
+ current = step.index
246
+ yield step.result!.value
247
+ arm(step.index)
248
+ }
249
+ } finally {
250
+ for (const iterator of iterators) void iterator.return?.(undefined)?.catch?.(() => {})
251
+ for (const events of channels) events.close()
252
+ }
253
+ }
254
+
255
+ try {
256
+ for await (const event of sse.paced(merged())) channels[current]!.push(event)
257
+ } catch (error) {
258
+ cancel()
259
+ await Promise.all(runs)
260
+ throw error
261
+ }
262
+ await Promise.all(runs)
263
+
264
+ if (!failure && !sse.started && !response.headersSent) failure = { status: 502, kind: "server", message: "No response from the model" }
265
+ if (failure) {
266
+ if (!sse.started && !response.headersSent) {
267
+ if (!scope.signal.aborted || scope.timedOut) fail(failure)
268
+ } else {
269
+ info.failed = failure.code ?? failure.kind
270
+ sse.write(`data: ${JSON.stringify(encodeError(failure))}\n\ndata: [DONE]\n\n`)
271
+ if (!response.writableEnded) response.end()
272
+ }
273
+ return
274
+ }
275
+ if (codec.includeUsage && usage) sse.write(`data: ${JSON.stringify(codec.usageChunk(shared, usage))}\n\n`)
276
+ sse.write("data: [DONE]\n\n")
277
+ if (!response.writableEnded) response.end()
278
+ }
@@ -25,6 +25,8 @@ export interface ParsedChat {
25
25
  readonly request: Omit<PlugRequest, "ref">
26
26
  /** Response presentation state for the encoders; never reaches the model call. */
27
27
  readonly exchange: OpenAiExchange
28
+ /** `n`: independent model calls whose answers become `choices` (1..`maxN`). The route fans out when it is above 1. */
29
+ readonly n: number
28
30
  readonly reasoningEffort?: string
29
31
  }
30
32
 
@@ -226,7 +228,16 @@ const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction
226
228
  return bad(`Unsupported response_format type "${String(raw.type)}"`, "response_format.type")
227
229
  }
228
230
 
229
- const parseGeneration = (body: JsonObject): PlugGeneration | PlugError => {
231
+ /** `n` is how many completions to return. Each one is a full model call (see `src/openai/fanout.ts`), so it is capped by the `maxN` option. */
232
+ export const parseChoiceCount = (body: JsonObject, options: PlugboardOptions): number | PlugError => {
233
+ if (body.n == null) return 1
234
+ if (typeof body.n !== "number" || !Number.isInteger(body.n) || body.n < 1) return bad("n must be a positive integer", "n")
235
+ if (body.n > options.maxN)
236
+ return bad(`n=${body.n} is above the limit of ${options.maxN} (option \`maxN\`; each choice is a full model call)`, "n", "too_many_choices")
237
+ return body.n
238
+ }
239
+
240
+ export const parseGeneration = (body: JsonObject): PlugGeneration | PlugError => {
230
241
  const out: { -readonly [K in keyof PlugGeneration]: PlugGeneration[K] } = {}
231
242
  const number = (key: string, min: number, max: number, integer: boolean): number | undefined | PlugError => {
232
243
  const value = body[key]
@@ -265,10 +276,8 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
265
276
  if (!Array.isArray(body.messages) || body.messages.length === 0) return bad("messages must be a non-empty array", "messages")
266
277
 
267
278
  // Parameters the session backend cannot honour are rejected, not ignored.
268
- if (body.n != null) {
269
- if (typeof body.n !== "number" || !Number.isInteger(body.n) || body.n < 1) return bad("n must be a positive integer", "n")
270
- if (body.n > 1) return bad("Only n=1 is supported", "n", "unsupported_parameter")
271
- }
279
+ const n = parseChoiceCount(body, options)
280
+ if (typeof n === "object") return n
272
281
  if (body.logprobs === true) return bad("logprobs is not supported", "logprobs", "unsupported_parameter")
273
282
  if (body.top_logprobs != null) return bad("top_logprobs is not supported", "top_logprobs", "unsupported_parameter")
274
283
 
@@ -323,6 +332,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
323
332
  const streamOptions = isRecord(body.stream_options) ? body.stream_options : {}
324
333
  return {
325
334
  request,
335
+ n,
326
336
  exchange: {
327
337
  model: body.model,
328
338
  stream: body.stream === true,
@@ -40,6 +40,20 @@ export interface StreamResult {
40
40
  readonly failure?: PlugError
41
41
  /** Whether any frame was written (the response is committed as a 200 SSE stream). */
42
42
  readonly started: boolean
43
+ /** The usage the model reported, when the exchange succeeded. */
44
+ readonly usage?: PlugUsage
45
+ }
46
+
47
+ /**
48
+ * One choice of a fanned-out stream (`n` > 1, see `src/openai/fanout.ts`): the chunks share the
49
+ * stream's `id` and `created` and carry this choice's `index`. The encoder then writes this
50
+ * choice's chunks only. The error frame, the usage chunk and `[DONE]` belong to the whole
51
+ * stream and are written by the fan-out.
52
+ */
53
+ export interface StreamChoice {
54
+ readonly id: string
55
+ readonly created: number
56
+ readonly index: number
43
57
  }
44
58
 
45
59
  const ERROR_TYPES: Record<PlugErrorKind, string> = {
@@ -246,6 +260,16 @@ export async function collectCompletion(
246
260
  }
247
261
  }
248
262
 
263
+ /** The final chunk of a stream with `include_usage`: no choices, the totals. */
264
+ export const chatUsageChunk = (model: string, id: string, created: number, usage: PlugUsage): object => ({
265
+ id,
266
+ object: "chat.completion.chunk",
267
+ created,
268
+ model,
269
+ choices: [],
270
+ usage: encodeUsage(usage),
271
+ })
272
+
249
273
  /**
250
274
  * Writes SSE frames via `write` (each frame is one complete `data: ...\n\n` string) and reports
251
275
  * how the exchange ended. A failure before the first frame is not written (the first wire-visible
@@ -256,9 +280,11 @@ export async function streamCompletion(
256
280
  events: AsyncIterable<PlugEvent>,
257
281
  exchange: OpenAiExchange,
258
282
  write: (chunk: string) => void,
283
+ choice?: StreamChoice,
259
284
  ): Promise<StreamResult> {
260
- const id = newId()
261
- const created = now()
285
+ const id = choice?.id ?? newId()
286
+ const created = choice?.created ?? now()
287
+ const index = choice?.index ?? 0
262
288
  const frame = (value: object) => write(`data: ${JSON.stringify(value)}\n\n`)
263
289
  const chunk = (delta: Record<string, unknown>, finish: string | null = null) =>
264
290
  frame({
@@ -266,7 +292,7 @@ export async function streamCompletion(
266
292
  object: "chat.completion.chunk",
267
293
  created,
268
294
  model: exchange.model,
269
- choices: [{ index: 0, delta, finish_reason: finish, logprobs: null }],
295
+ choices: [{ index, delta, finish_reason: finish, logprobs: null }],
270
296
  ...(exchange.includeUsage ? { usage: null } : {}),
271
297
  })
272
298
 
@@ -348,16 +374,18 @@ export async function streamCompletion(
348
374
  failure ??= finish ? (passthrough ? undefined : structured.failure(exchange, finish, answered)) : noFinish()
349
375
  if (failure) {
350
376
  if (!started) return { failure, started }
351
- frame(encodeError(failure))
352
- write("data: [DONE]\n\n")
377
+ if (!choice) {
378
+ frame(encodeError(failure))
379
+ write("data: [DONE]\n\n")
380
+ }
353
381
  return { failure, started }
354
382
  }
355
383
  flush()
356
384
  start()
357
385
  if (held && isStructured) chunk({ content: structured.content(held) })
358
386
  chunk({}, finishReason(finish!, calls, isStructured))
359
- if (exchange.includeUsage && usage)
360
- frame({ id, object: "chat.completion.chunk", created, model: exchange.model, choices: [], usage: encodeUsage(usage) })
387
+ if (choice) return { started, ...(usage ? { usage } : {}) }
388
+ if (exchange.includeUsage && usage) frame(chatUsageChunk(exchange.model, id, created, usage))
361
389
  write("data: [DONE]\n\n")
362
390
  return { started }
363
391
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * OpenAI front-end route table: `GET /healthz`, `GET /v1/models`, `GET /v1/models/{id}`,
3
- * `POST /v1/chat/completions`, and the dispatch of `POST /v1/responses` to `src/responses/route.ts`.
3
+ * `POST /v1/chat/completions` (`n` > 1 through `./fanout.ts`), and the dispatch of `POST /v1/responses`
4
+ * and `POST /v1/completions` to `src/responses/route.ts` and `src/completions/route.ts`.
4
5
  * Observability and first-run routes (`src/discovery.ts`, `src/metrics.ts`): `GET /` and `GET /v1`
5
6
  * (keyless discovery), the authenticated `GET /v1/status`, and `GET /metrics` (option `metrics`).
6
7
  *
@@ -38,8 +39,11 @@ import {
38
39
  import { errorMessage, log, logError, withRequestId } from "../log.js"
39
40
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
40
41
  import { listAvailableModels, resolveModel } from "../models.js"
42
+ import { isMessagesPath, serveMessages } from "../anthropic/route.js"
43
+ import { serveCompletions } from "../completions/route.js"
41
44
  import { serveResponses } from "../responses/route.js"
42
45
  import { processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
46
+ import { chatCodec, serveChoices } from "./fanout.js"
43
47
  import { parseChatRequest } from "./request.js"
44
48
  import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
45
49
  import { dropRejected } from "./sampling.js"
@@ -60,7 +64,7 @@ const SESSION_HEADER = "x-plugboard-session"
60
64
  const REQUEST_ID_HEADER = "x-plugboard-request-id"
61
65
  const DROPPED_HEADER = "x-plugboard-dropped-params"
62
66
  const MODEL_PREFIX = "/v1/models/"
63
- const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses", "GET /v1/status"]
67
+ const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/completions", "POST /v1/responses", "GET /v1/status"]
64
68
  /** Pages anyone may fetch; they decide for themselves how much to show (see `src/discovery.ts`). */
65
69
  const DISCOVERY_ROUTES = ["GET /", "GET /v1"]
66
70
  /** Allows a page's inline style and nothing else: no scripts, images, fonts or frames. */
@@ -135,6 +139,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
135
139
  const route = `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
136
140
  // Arbitrary client paths stay out of the log.
137
141
  info.route = modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
142
+ // Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
143
+ if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
138
144
 
139
145
  const rejected = checkOrigin(request, host.options)
140
146
  if (rejected) return fail(response, rejected)
@@ -202,6 +208,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
202
208
 
203
209
  const body = await readJsonBody(request, scope.signal)
204
210
  if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
211
+ if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
205
212
  const parsed = parseChatRequest(body, host.options)
206
213
  if (isPlugError(parsed)) return fail(response, parsed)
207
214
  const catalog = await scope.race(host.loadCatalog())
@@ -217,6 +224,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
217
224
  }
218
225
  const call: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
219
226
  const { exchange } = parsed
227
+ // n > 1: one independent call per choice, merged (src/openai/fanout.ts).
228
+ if (parsed.n > 1)
229
+ return serveChoices(host, { app: auth.app, calls: Array.from({ length: parsed.n }, () => call), response, scope, info, codec: chatCodec(exchange) })
220
230
 
221
231
  // Abandoned requests (client gone, deadline passed) must never reach the model caller.
222
232
  if (scope.signal.aborted) throw scope.signal.reason