opencode-plugboard 0.19.0 → 0.21.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,81 @@
1
+ /**
2
+ * Admin routes for runtime app keys (#55): `GET /v1/admin/keys` (keys without values),
3
+ * `POST /v1/admin/keys` with body `{ "app": "<name>" }` (a new random key, returned once) and
4
+ * `DELETE /v1/admin/keys/{keyId}` (revokes a runtime key; a config key is a 409).
5
+ *
6
+ * The routes exist only when option `adminKey` is set: without it every path here is a 404. Only the
7
+ * admin key passes; an app key, a wrong key or no key is a 401. src/openai/route.ts dispatches here
8
+ * after the origin check and before app authentication, so an app key never reaches them.
9
+ */
10
+
11
+ import type { IncomingMessage, ServerResponse } from "node:http"
12
+ import { APP_NAME_PATTERN, DEFAULT_APP, authenticateUsage, type PlugboardOptions } from "../config.js"
13
+ import { isPlugError, type PlugError } from "../contract.js"
14
+ import { checkJsonContentType, readJsonBody, sendError, sendJson, type RequestScope } from "../http.js"
15
+ import type { KeyStore } from "../keys.js"
16
+ import { log } from "../log.js"
17
+ import { encodeError } from "../openai/response.js"
18
+
19
+ export interface AdminHost {
20
+ /** The live options (`liveOptions`): the config keys, and `adminKey`. */
21
+ readonly options: PlugboardOptions
22
+ /** The runtime key store; absent means the admin routes are not served. */
23
+ readonly keys?: KeyStore
24
+ }
25
+
26
+ export interface AdminCall {
27
+ readonly path: string
28
+ readonly request: IncomingMessage
29
+ readonly response: ServerResponse
30
+ readonly scope: RequestScope
31
+ /** The route's log fields: the route name is set here, so the request log names the admin route. */
32
+ readonly info: { route: string; app?: string }
33
+ }
34
+
35
+ const KEYS = "/v1/admin/keys"
36
+ const ONE_KEY = /^\/v1\/admin\/keys\/([0-9a-f]{8})$/
37
+
38
+ export async function serveAdmin(host: AdminHost, call: AdminCall): Promise<void> {
39
+ const { path, request, response, scope, info } = call
40
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
41
+ const { keys } = host
42
+ if (host.options.adminKey === undefined || keys === undefined)
43
+ return fail({ status: 404, kind: "not_found", code: "not_found", message: `No route for ${request.method} ${path}` })
44
+
45
+ const who = authenticateUsage(host.options, request.headers.authorization, await keys.digests())
46
+ if (isPlugError(who) || !who.admin) return fail({ status: 401, kind: "authentication", code: "invalid_api_key", message: "Missing or invalid admin key" })
47
+
48
+ const id = ONE_KEY.exec(path)?.[1]
49
+ if (path === KEYS && request.method === "GET") {
50
+ info.route = "GET /v1/admin/keys"
51
+ return sendJson(response, 200, { keys: await keys.list(host.options.keys) })
52
+ }
53
+ if (path === KEYS && request.method === "POST") {
54
+ info.route = "POST /v1/admin/keys"
55
+ const unsupported = checkJsonContentType(request)
56
+ if (unsupported) return fail(unsupported)
57
+ const body = await readJsonBody(request, scope.signal)
58
+ const app = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as { app?: unknown }).app : undefined
59
+ if (typeof app !== "string" || app === DEFAULT_APP || !APP_NAME_PATTERN.test(app))
60
+ return fail({ status: 400, kind: "invalid_request", code: "invalid_app", param: "app", message: `\`app\` must match ${APP_NAME_PATTERN} and must not be \`${DEFAULT_APP}\`` })
61
+ if (Object.hasOwn(host.options.keys, app))
62
+ return fail({ status: 409, kind: "invalid_request", code: "app_has_config_key", param: "app", message: `App ${app} already has a key in the plugboard config; edit \`keys\` to change it` })
63
+ const created = await keys.create(app, host.options.keys)
64
+ log("admin.key_created", { id: created.id, app })
65
+ // The key value is shown once: a cache or a log of this response must not keep it.
66
+ response.setHeader("cache-control", "no-store")
67
+ return sendJson(response, 201, created)
68
+ }
69
+ if (id !== undefined && request.method === "DELETE") {
70
+ info.route = "DELETE /v1/admin/keys/{keyId}"
71
+ const result = await keys.revoke(id, host.options.keys)
72
+ if (result === "config")
73
+ return fail({ status: 409, kind: "invalid_request", code: "config_key", message: "This key is set in the plugboard config (`keys`). Remove it there and reload OpenCode." })
74
+ if (result === "not_found") return fail({ status: 404, kind: "not_found", code: "key_not_found", message: `No runtime key ${id}` })
75
+ log("admin.key_revoked", { id })
76
+ return sendJson(response, 200, { id, revoked: true })
77
+ }
78
+ if (path === KEYS || id !== undefined)
79
+ return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}` })
80
+ return fail({ status: 404, kind: "not_found", code: "not_found", message: `No route for ${request.method} ${path}` })
81
+ }
@@ -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 { bodyUserId, userField } from "../user.js"
12
13
  import {
13
14
  isPlugError,
14
15
  type PlugError,
@@ -327,6 +328,7 @@ export function parseMessagesRequest(body: unknown, options: PlugboardOptions):
327
328
  return bad(`tool_choice.name: "${toolChoice.name}" is not in tools`)
328
329
 
329
330
  const request: Omit<PlugRequest, "ref"> = {
331
+ ...userField(bodyUserId(body)),
330
332
  model: body.model,
331
333
  system,
332
334
  messages,
@@ -114,6 +114,8 @@ export async function runMessage(
114
114
  emit: (event: MessagesStreamEvent) => void,
115
115
  ): Promise<MessageRun> {
116
116
  const id = newId("msg")
117
+ // Only a non-streamed body needs the aggregate: a stream is already on the wire, so nothing grows (#153).
118
+ const aggregate = !exchange.stream
117
119
  const content: Block[] = []
118
120
  /** Blocks opened so far: the next block's `index`. */
119
121
  let nextIndex = 0
@@ -155,7 +157,7 @@ export async function runMessage(
155
157
  emit({ type: "content_block_stop", index: open.index })
156
158
  // The signature is always empty: the host does not expose the provider's thinking signature (#146), so a
157
159
  // replayed thinking block cannot carry the real one. Clients that replay thinking are told by the docs.
158
- content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
160
+ if (aggregate) content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
159
161
  open = undefined
160
162
  }
161
163
  const releaseHeld = () => delta("text", holdback.release())
@@ -173,7 +175,7 @@ export async function runMessage(
173
175
  })
174
176
  ping()
175
177
  }
176
- open.text += text
178
+ if (aggregate) open.text += text
177
179
  emit({
178
180
  type: "content_block_delta",
179
181
  index: open.index,
@@ -192,7 +194,7 @@ export async function runMessage(
192
194
  ping()
193
195
  emit({ type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: JSON.stringify(input) } })
194
196
  emit({ type: "content_block_stop", index })
195
- content.push({ type: "tool_use", id: toolUseId, name: call.name, input })
197
+ if (aggregate) content.push({ type: "tool_use", id: toolUseId, name: call.name, input })
196
198
  calls++
197
199
  }
198
200
 
@@ -12,11 +12,13 @@
12
12
  */
13
13
 
14
14
  import type { IncomingMessage, ServerResponse } from "node:http"
15
- import { authenticate } from "../config.js"
15
+ import { authenticate, bodyLimits } from "../config.js"
16
16
  import { liveOptions } from "../runtime.js"
17
+ import { bodyUserId, noteEndUser } from "../user.js"
17
18
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
18
19
  import {
19
20
  BodyError,
21
+ bodyPlugError,
20
22
  RequestAborted,
21
23
  boundEvents,
22
24
  checkJsonContentType,
@@ -30,8 +32,9 @@ import {
30
32
  import { reportServed, type ServedInfo } from "../fallback.js"
31
33
  import { resolveModel } from "../models.js"
32
34
  import { reportCache } from "../cache.js"
33
- import { reportPolicy } from "../policy.js"
35
+ import { reportPolicy, reportServerTools } from "../policy.js"
34
36
  import { reportUsage } from "../usage-header.js"
37
+ import { setSessionLink } from "../session/link.js"
35
38
  import { dropRejected } from "../openai/sampling.js"
36
39
  import type { RouteHost } from "../openai/route.js"
37
40
  import { parseMessagesRequest } from "./request.js"
@@ -54,7 +57,7 @@ export interface MessagesCall {
54
57
  readonly response: ServerResponse
55
58
  readonly scope: RequestScope
56
59
  /** 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. */
57
- readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string }
60
+ readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string; user?: string }
58
61
  }
59
62
 
60
63
  const header = (request: IncomingMessage, name: string): string | undefined => {
@@ -63,11 +66,12 @@ const header = (request: IncomingMessage, name: string): string | undefined => {
63
66
  }
64
67
 
65
68
  /** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
66
- const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
69
+ const authenticateRequest = async (host: RouteHost, request: IncomingMessage) => {
67
70
  const options = liveOptions(host.options)
68
- const bearer = authenticate(options, request.headers.authorization)
71
+ const runtime = host.keys ? await host.keys.digests() : []
72
+ const bearer = authenticate(options, request.headers.authorization, runtime)
69
73
  const apiKey = header(request, "x-api-key")
70
- return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`) : bearer
74
+ return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`, runtime) : bearer
71
75
  }
72
76
 
73
77
  export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
@@ -82,7 +86,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
82
86
  // Answer in this route's error shape, then rethrow: the shared handler logs the outcome and
83
87
  // finds the response already sent.
84
88
  if (!response.headersSent) {
85
- if (caught instanceof BodyError) fail({ status: caught.status, kind: "invalid_request", message: caught.message }, caught.status === 413)
89
+ if (caught instanceof BodyError) fail(bodyPlugError(caught), caught.status !== 400)
86
90
  else if (caught instanceof RequestAborted) {
87
91
  if (caught.kind === "timeout") fail(scope.timeoutError, true)
88
92
  } else fail({ status: 500, kind: "server", message: "Internal server error" })
@@ -94,7 +98,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
94
98
  const rejected = checkOrigin(request, host.options)
95
99
  if (rejected) return fail(rejected)
96
100
  // Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
97
- const auth = authenticateRequest(host, request)
101
+ const auth = await authenticateRequest(host, request)
98
102
  if (isPlugError(auth)) return fail(auth)
99
103
  info.app = auth.app
100
104
  if (request.method !== "POST")
@@ -102,7 +106,8 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
102
106
  const unsupported = checkJsonContentType(request)
103
107
  if (unsupported) return fail(unsupported)
104
108
 
105
- const body = await readJsonBody(request, scope.signal)
109
+ const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
110
+ noteEndUser(response, info, bodyUserId(body))
106
111
  // count_tokens takes the message body without max_tokens; the parser needs one, so a placeholder stands in
107
112
  // (the count never uses it). A max_tokens the client did send is still validated.
108
113
  const sized = path === COUNT_TOKENS && isRecord(body) && body.max_tokens === undefined ? { ...body, max_tokens: Number.MAX_SAFE_INTEGER } : body
@@ -130,13 +135,15 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
130
135
  host.caller.stream(model, {
131
136
  app: auth.app,
132
137
  signal: scope.signal,
133
- onRecord: (recordID) => {
138
+ onRecord: (recordID, directory) => {
134
139
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
135
140
  response.setHeader(SESSION_HEADER, recordID)
141
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
136
142
  },
137
143
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
138
144
  onPolicy: reportPolicy(response),
139
145
  onUsage: reportUsage(response, exchange.stream),
146
+ onServerTools: reportServerTools(response, exchange.stream),
140
147
  onCache: reportCache(response),
141
148
  }),
142
149
  scope,
package/src/cache.ts CHANGED
@@ -25,6 +25,10 @@
25
25
  *
26
26
  * Fan-out (`n` above 1, prompt arrays) calls the caller once per choice with the same request, so
27
27
  * the fan-out sets `noCache`: every choice must be a fresh sample.
28
+ *
29
+ * A persona (a skill alias or an `agent/<name>` id, `bypass`) is never cached: its answer depends on
30
+ * the skill's content or the agent's prompt, which the key does not see, and a skill edit must not be
31
+ * masked by an old entry.
28
32
  */
29
33
 
30
34
  import { createHash } from "node:crypto"
@@ -114,12 +118,22 @@ function append(events: PlugEvent[], event: { readonly type: "text-delta" | "rea
114
118
  else events.push({ type: event.type, text: event.text })
115
119
  }
116
120
 
117
- /** Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. */
118
- export function cachedCaller(inner: ModelCaller, config: CacheConfig, store: CacheStore, count?: CacheCounter): ModelCaller {
121
+ /**
122
+ * Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. `bypass(model, app)`
123
+ * decides per call that the cache is not used: a persona model, or an app whose answers depend on live OpenCode
124
+ * tool output (`serverTools`, #82). Both conditions live in the one predicate the runtime passes.
125
+ */
126
+ export function cachedCaller(
127
+ inner: ModelCaller,
128
+ config: CacheConfig,
129
+ store: CacheStore,
130
+ count?: CacheCounter,
131
+ bypass?: (model: string, app: string) => boolean,
132
+ ): ModelCaller {
119
133
  const apps = config.apps ? new Set(config.apps) : undefined
120
134
  return {
121
135
  async *stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
122
- if (context.noCache || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
136
+ if (context.noCache || bypass?.(request.model, context.app) || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
123
137
  const key = cacheKey(context.app, request)
124
138
  const hit = store.get(key)
125
139
  if (hit) {
@@ -151,9 +165,16 @@ export function cachedCaller(inner: ModelCaller, config: CacheConfig, store: Cac
151
165
  }
152
166
  const recorded: PlugEvent[] = []
153
167
  let storable = true
168
+ // Text bytes seen so far. An answer over `maxBytes` could never be stored, so capture stops there and frees what it kept (#153).
169
+ let captured = 0
154
170
  for await (const event of inner.stream(request, forward)) {
155
- if (event.type === "text-delta" || event.type === "reasoning-delta") append(recorded, event)
156
- else if (event.type === "finish") {
171
+ if (event.type === "text-delta" || event.type === "reasoning-delta") {
172
+ captured += Buffer.byteLength(event.text)
173
+ if (captured > config.maxBytes) {
174
+ storable = false
175
+ recorded.length = 0
176
+ } else if (storable) append(recorded, event)
177
+ } else if (event.type === "finish") {
157
178
  // Stored before the event is handed on, so a consumer that stops reading at `finish` still fills the cache.
158
179
  if (storable && CACHEABLE_FINISH.has(event.reason) && recorded.length > 0 && !context.signal.aborted)
159
180
  store.set(key, { events: [...recorded, { type: "finish", reason: event.reason }], ...(served ? { served } : {}), ...(policy?.length ? { policy } : {}) })
@@ -9,8 +9,9 @@
9
9
  * `ref`.
10
10
  */
11
11
  import type { PlugboardOptions } from "../config.js"
12
- import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
- import { parseChoiceCount, parseGeneration } from "../openai/request.js"
12
+ import { isPlugError, type PlugError, type PlugRequest, type PlugToolChoice } from "../contract.js"
13
+ import { bodyUser, userField } from "../user.js"
14
+ import { RESPOND_TOOL, parseChoiceCount, parseGeneration, parseResponseFormat } from "../openai/request.js"
14
15
  import type { CompletionsExchange } from "./response.js"
15
16
 
16
17
  export interface ParsedCompletions {
@@ -79,15 +80,23 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
79
80
  )
80
81
  const generation = parseGeneration(body)
81
82
  if (isPlugError(generation)) return generation
83
+ // The same response_format as Chat Completions: json_schema is the forced `respond` tool, json_object an instruction.
84
+ const format = parseResponseFormat(body.response_format)
85
+ if (isPlugError(format)) return format
82
86
 
83
87
  const suffix = typeof body.suffix === "string" && body.suffix.length > 0 ? body.suffix : undefined
88
+ const system = [...(suffix ? [SUFFIX_INSTRUCTION(suffix)] : []), ...(format.instruction ? [format.instruction] : [])]
89
+ const tools = format.structured ? [format.structured] : []
90
+ const toolChoice: PlugToolChoice | undefined = format.structured ? { type: "tool", name: RESPOND_TOOL } : undefined
84
91
  const streamOptions = isRecord(body.stream_options) ? body.stream_options : {}
85
92
  return {
86
93
  requests: prompts.map((prompt) => ({
87
94
  model: body.model as string,
88
- system: suffix ? [SUFFIX_INSTRUCTION(suffix)] : [],
95
+ ...userField(bodyUser(body)),
96
+ system,
89
97
  messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
90
- tools: [],
98
+ tools,
99
+ ...(toolChoice ? { toolChoice } : {}),
91
100
  generation,
92
101
  })),
93
102
  n,
@@ -98,6 +107,8 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
98
107
  perPrompt: n,
99
108
  ...(body.echo === true ? { echo: prompts } : {}),
100
109
  ...(generation.stop ? { stop: generation.stop } : {}),
110
+ ...(format.structured ? { structuredTool: RESPOND_TOOL, structuredSchema: format.structured.parameters } : {}),
111
+ ...(format.instruction ? { jsonObject: true } : {}),
101
112
  },
102
113
  ...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
103
114
  }
@@ -5,9 +5,9 @@
5
5
  * Reasoning deltas and tool calls are dropped: the legacy format has neither, and the request
6
6
  * offers no tools. `logprobs` is always null.
7
7
  */
8
- import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
8
+ import { isPlugError, type PlugError, type PlugEvent, type PlugFinish, type PlugUsage } from "../contract.js"
9
9
  import type { ChoiceCodec } from "../openai/fanout.js"
10
- import { encodeError, type StreamChoice, type StreamResult } from "../openai/response.js"
10
+ import { args, encodeError, structuredMissing, textFallback, unfence, type CollectedChoice, type StreamChoice, type StreamResult } from "../openai/response.js"
11
11
  import { StopHoldback, stripStopTail } from "../stop.js"
12
12
 
13
13
  /** What the encoders need besides the model events; the request decoder produces it, the model call never sees it. */
@@ -23,6 +23,12 @@ export interface CompletionsExchange {
23
23
  readonly echo?: ReadonlyArray<string>
24
24
  /** The request's `stop`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). */
25
25
  readonly stop?: ReadonlyArray<string>
26
+ /** `response_format: json_schema`: the name of the forced tool whose input is the answer (`respond`). The answer is its arguments as text. */
27
+ readonly structuredTool?: string
28
+ /** The caller's `json_schema`: a text answer is taken when the model skipped the call and its text is that JSON (`textFallback`). */
29
+ readonly structuredSchema?: Readonly<Record<string, unknown>>
30
+ /** `response_format: json_object`: a single markdown fence around the answer is stripped (`unfence`). */
31
+ readonly jsonObject?: boolean
26
32
  }
27
33
 
28
34
  const FINISH_REASONS: Record<Exclude<PlugFinish, "error">, string> = {
@@ -46,15 +52,31 @@ const encodeUsage = (usage: PlugUsage) => {
46
52
 
47
53
  const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
48
54
 
49
- /** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
50
- async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
55
+ /** A choice's settled answer: its text, the finish reason and the usage. */
56
+ interface Settled {
57
+ readonly text: string
58
+ readonly reason: Exclude<PlugFinish, "error">
59
+ readonly usage?: PlugUsage
60
+ /** The structured answer came from the text (see `textFallback`), not the respond call. */
61
+ readonly fallback?: boolean
62
+ }
63
+
64
+ /**
65
+ * Reads one whole choice. A structured answer is the arguments of the `respond` call (the text is
66
+ * ignored); a json_object answer loses one code fence. Without the call a structured choice fails,
67
+ * as in Chat Completions (`structuredMissing`).
68
+ */
69
+ async function settle(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange): Promise<Settled | PlugError> {
51
70
  let text = ""
71
+ let answer: string | undefined
52
72
  let finish: Exclude<PlugFinish, "error"> | undefined
53
73
  let usage: PlugUsage | undefined
54
74
  let failure: PlugError | undefined
55
75
  for await (const event of events) {
56
76
  if (event.type === "text-delta") text += event.text
57
- else if (event.type === "finish") {
77
+ else if (event.type === "tool-call") {
78
+ if (exchange.structuredTool !== undefined && event.name === exchange.structuredTool) answer ??= args(event.input)
79
+ } else if (event.type === "finish") {
58
80
  usage = event.usage ?? usage
59
81
  if (event.reason === "error") failure ??= failedRun()
60
82
  else finish = event.reason
@@ -64,20 +86,31 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
64
86
  }
65
87
  }
66
88
  if (!failure && !finish) failure = noFinish()
67
- if (failure || !finish) {
68
- const error = failure ?? noFinish()
69
- return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
89
+ if (failure || !finish) return failure ?? noFinish()
90
+ if (exchange.structuredTool !== undefined) {
91
+ if (answer !== undefined) return { text: answer, reason: finish, usage }
92
+ const fallback = exchange.structuredSchema === undefined ? undefined : textFallback(text, exchange.structuredSchema)
93
+ if (fallback === undefined) return structuredMissing(exchange.structuredTool)
94
+ return { text: fallback, reason: finish, usage, fallback: true }
70
95
  }
71
96
  if (finish === "stop") text = stripStopTail(text, exchange.stop ?? []).text
97
+ return { text: exchange.jsonObject ? unfence(text) : text, reason: finish, usage }
98
+ }
99
+
100
+ /** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
101
+ async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<CollectedChoice> {
102
+ const settled = await settle(events, exchange)
103
+ if (isPlugError(settled)) return { status: settled.status, body: encodeError(settled), retryAfterSeconds: settled.retryAfterSeconds }
72
104
  return {
105
+ ...(settled.fallback ? { structuredFallback: true } : {}),
73
106
  status: 200,
74
107
  body: {
75
108
  id: `cmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`,
76
109
  object: "text_completion",
77
110
  created: now(),
78
111
  model: exchange.model,
79
- choices: [{ text: prefixOf(exchange, index) + text, index: 0, logprobs: null, finish_reason: FINISH_REASONS[finish] }],
80
- ...(usage ? { usage: encodeUsage(usage) } : {}),
112
+ choices: [{ text: prefixOf(exchange, index) + settled.text, index: 0, logprobs: null, finish_reason: FINISH_REASONS[settled.reason] }],
113
+ ...(settled.usage ? { usage: encodeUsage(settled.usage) } : {}),
81
114
  },
82
115
  }
83
116
  }
@@ -110,6 +143,15 @@ async function streamText(
110
143
  const prefix = prefixOf(exchange, choice.index)
111
144
  if (prefix.length > 0) chunk(prefix)
112
145
  }
146
+ if (exchange.structuredTool !== undefined || exchange.jsonObject) {
147
+ // A structured or json_object answer is only known at the end (the call, the fence), so it is written as one chunk.
148
+ const settled = await settle(events, exchange)
149
+ if (isPlugError(settled)) return { failure: settled, started: false }
150
+ start()
151
+ if (settled.text.length > 0) chunk(settled.text)
152
+ chunk("", FINISH_REASONS[settled.reason])
153
+ return { started, ...(settled.usage ? { usage: settled.usage } : {}) }
154
+ }
113
155
  // A stop sequence is held back until the finish decides it (src/stop.ts).
114
156
  const holdback = new StopHoldback(exchange.stop ?? [])
115
157
  let finish: Exclude<PlugFinish, "error"> | undefined