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.
- package/README.md +35 -31
- package/dist/tui.js +334 -0
- package/index.ts +35 -23
- package/package.json +10 -4
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/response.ts +5 -3
- package/src/anthropic/route.ts +17 -10
- package/src/cache.ts +26 -5
- package/src/completions/request.ts +15 -4
- package/src/completions/response.ts +52 -10
- package/src/config.ts +155 -13
- package/src/contract.ts +9 -1
- package/src/discovery.ts +35 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/request.ts +3 -2
- package/src/gemini/response.ts +20 -20
- package/src/gemini/route.ts +10 -4
- package/src/http.ts +65 -7
- package/src/keys.ts +172 -0
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +29 -7
- package/src/openai/request.ts +6 -4
- package/src/openai/response.ts +101 -21
- package/src/openai/route.ts +44 -17
- package/src/policy.ts +16 -0
- package/src/responses/request.ts +3 -1
- package/src/responses/response.ts +13 -17
- package/src/responses/route.ts +7 -3
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +60 -7
- package/src/session/caller.ts +96 -11
- package/src/session/context.ts +18 -4
- package/src/session/events.ts +116 -12
- package/src/session/history.ts +8 -2
- package/src/session/host.ts +40 -0
- package/src/session/link.ts +33 -0
- package/src/session/seeds.ts +10 -0
- package/src/session/state.ts +2 -0
- package/src/usage.ts +61 -2
- package/src/user.ts +55 -0
- package/tui.ts +5 -4
|
@@ -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
|
+
}
|
package/src/anthropic/request.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* afterwards, which is why the request omits `ref`.
|
|
10
10
|
*/
|
|
11
11
|
import type { PlugboardOptions } from "../config.js"
|
|
12
|
+
import { 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
|
|
package/src/anthropic/route.ts
CHANGED
|
@@ -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
|
|
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}
|
|
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(
|
|
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
|
-
/**
|
|
118
|
-
|
|
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")
|
|
156
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
50
|
-
|
|
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 === "
|
|
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
|
-
|
|
69
|
-
return {
|
|
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[
|
|
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
|