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.
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/anthropic/request.ts +343 -0
- package/src/anthropic/response.ts +221 -0
- package/src/anthropic/route.ts +148 -0
- package/src/completions/request.ts +103 -0
- package/src/completions/response.ts +147 -0
- package/src/completions/route.ts +54 -0
- package/src/config.ts +5 -1
- package/src/openai/fanout.ts +278 -0
- package/src/openai/request.ts +15 -5
- package/src/openai/response.ts +35 -7
- package/src/openai/route.ts +12 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy OpenAI `POST /v1/completions` request -> neutral `PlugRequest`s plus the
|
|
3
|
+
* `CompletionsExchange` the encoders need.
|
|
4
|
+
*
|
|
5
|
+
* Pure function: no I/O. The OpenCode models are chat models, so a prompt becomes one user
|
|
6
|
+
* message and there is no system prompt, except for `suffix` (see `SUFFIX_INSTRUCTION`). A string
|
|
7
|
+
* prompt is one model call; a list of strings is one call per prompt, times `n` (capped by
|
|
8
|
+
* `maxN`). The route resolves `model` to a `ModelRef` afterwards, which is why the requests omit
|
|
9
|
+
* `ref`.
|
|
10
|
+
*/
|
|
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"
|
|
14
|
+
import type { CompletionsExchange } from "./response.js"
|
|
15
|
+
|
|
16
|
+
export interface ParsedCompletions {
|
|
17
|
+
/** One model call per prompt, minus the model ref the route resolves from `model`. The route runs each `n` times. */
|
|
18
|
+
readonly requests: ReadonlyArray<Omit<PlugRequest, "ref">>
|
|
19
|
+
readonly n: number
|
|
20
|
+
/** Response presentation state for the encoders; never reaches the model call. */
|
|
21
|
+
readonly exchange: CompletionsExchange
|
|
22
|
+
readonly reasoningEffort?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
26
|
+
|
|
27
|
+
const bad = (message: string, param?: string, code?: string): PlugError => ({
|
|
28
|
+
status: 400,
|
|
29
|
+
kind: "invalid_request",
|
|
30
|
+
message,
|
|
31
|
+
...(code ? { code } : {}),
|
|
32
|
+
...(param ? { param } : {}),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const unsupported = (message: string, param: string) => bad(message, param, "unsupported_parameter")
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `suffix` is the text that should follow the completion (insert mode). Chat models have no
|
|
39
|
+
* insert mode, so it is passed as an instruction: a best-effort mapping whose quality depends on
|
|
40
|
+
* the model, and the only case that adds a system prompt.
|
|
41
|
+
*/
|
|
42
|
+
const SUFFIX_INSTRUCTION = (suffix: string) =>
|
|
43
|
+
"Continue the user's text. Reply with only the text that belongs between the user's text and the following suffix, " +
|
|
44
|
+
"so that the user's text, your reply and the suffix read as one passage. Do not repeat either of them and add no commentary.\n\nSuffix:\n" +
|
|
45
|
+
suffix
|
|
46
|
+
|
|
47
|
+
/** `prompt`: a string, or a list of strings. Token ids (a list of numbers or of number lists) need a tokenizer and are rejected. */
|
|
48
|
+
const parsePrompts = (raw: unknown): string[] | PlugError => {
|
|
49
|
+
if (typeof raw === "string") return [raw]
|
|
50
|
+
if (!Array.isArray(raw) || raw.length === 0) return bad("prompt must be a string or a non-empty array of strings", "prompt")
|
|
51
|
+
if (raw.some((item) => typeof item === "number" || Array.isArray(item)))
|
|
52
|
+
return unsupported("Token-array prompts are not supported; send the prompt as text", "prompt")
|
|
53
|
+
if (!raw.every((item) => typeof item === "string")) return bad("prompt must be a string or an array of strings", "prompt")
|
|
54
|
+
return raw as string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseCompletionsRequest(body: unknown, options: PlugboardOptions): ParsedCompletions | PlugError {
|
|
58
|
+
if (!isRecord(body)) return bad("Request body must be a JSON object")
|
|
59
|
+
if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
|
|
60
|
+
if (body.prompt == null) return bad("prompt is required", "prompt")
|
|
61
|
+
|
|
62
|
+
if (body.logprobs != null) return unsupported("logprobs is not supported", "logprobs")
|
|
63
|
+
if (body.best_of != null) {
|
|
64
|
+
if (typeof body.best_of !== "number" || !Number.isInteger(body.best_of) || body.best_of < 1) return bad("best_of must be a positive integer", "best_of")
|
|
65
|
+
if (body.best_of > 1) return unsupported("best_of is not supported", "best_of")
|
|
66
|
+
}
|
|
67
|
+
if (body.echo != null && typeof body.echo !== "boolean") return bad("echo must be a boolean", "echo")
|
|
68
|
+
if (body.suffix != null && typeof body.suffix !== "string") return bad("suffix must be a string", "suffix")
|
|
69
|
+
|
|
70
|
+
const prompts = parsePrompts(body.prompt)
|
|
71
|
+
if (isPlugError(prompts)) return prompts
|
|
72
|
+
const n = parseChoiceCount(body, options)
|
|
73
|
+
if (isPlugError(n)) return n
|
|
74
|
+
if (prompts.length * n > options.maxN)
|
|
75
|
+
return bad(
|
|
76
|
+
`${prompts.length} prompt${prompts.length === 1 ? "" : "s"} with n=${n} make ${prompts.length * n} model calls, above the limit of ${options.maxN} (option \`maxN\`)`,
|
|
77
|
+
prompts.length > 1 ? "prompt" : "n",
|
|
78
|
+
"too_many_choices",
|
|
79
|
+
)
|
|
80
|
+
const generation = parseGeneration(body)
|
|
81
|
+
if (isPlugError(generation)) return generation
|
|
82
|
+
|
|
83
|
+
const suffix = typeof body.suffix === "string" && body.suffix.length > 0 ? body.suffix : undefined
|
|
84
|
+
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {}
|
|
85
|
+
return {
|
|
86
|
+
requests: prompts.map((prompt) => ({
|
|
87
|
+
model: body.model as string,
|
|
88
|
+
system: suffix ? [SUFFIX_INSTRUCTION(suffix)] : [],
|
|
89
|
+
messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
|
|
90
|
+
tools: [],
|
|
91
|
+
generation,
|
|
92
|
+
})),
|
|
93
|
+
n,
|
|
94
|
+
exchange: {
|
|
95
|
+
model: body.model,
|
|
96
|
+
stream: body.stream === true,
|
|
97
|
+
includeUsage: streamOptions.include_usage === true,
|
|
98
|
+
perPrompt: n,
|
|
99
|
+
...(body.echo === true ? { echo: prompts } : {}),
|
|
100
|
+
},
|
|
101
|
+
...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutral results -> the legacy `text_completion` wire objects: one JSON body per choice, or SSE
|
|
3
|
+
* chunks per choice, for `src/openai/fanout.ts` to merge.
|
|
4
|
+
*
|
|
5
|
+
* Reasoning deltas and tool calls are dropped: the legacy format has neither, and the request
|
|
6
|
+
* offers no tools. `logprobs` is always null.
|
|
7
|
+
*/
|
|
8
|
+
import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
9
|
+
import type { ChoiceCodec } from "../openai/fanout.js"
|
|
10
|
+
import { encodeError, type StreamChoice, type StreamResult } from "../openai/response.js"
|
|
11
|
+
|
|
12
|
+
/** What the encoders need besides the model events; the request decoder produces it, the model call never sees it. */
|
|
13
|
+
export interface CompletionsExchange {
|
|
14
|
+
/** The model string the client sent; echoed in every response object. */
|
|
15
|
+
readonly model: string
|
|
16
|
+
readonly stream: boolean
|
|
17
|
+
/** `stream_options.include_usage`: a final chunk with `choices: []` and the summed usage. */
|
|
18
|
+
readonly includeUsage: boolean
|
|
19
|
+
/** Choices per prompt. Choice `i` answers prompt `floor(i / perPrompt)`. */
|
|
20
|
+
readonly perPrompt: number
|
|
21
|
+
/** `echo`: each prompt, written ahead of the text of its choices. */
|
|
22
|
+
readonly echo?: ReadonlyArray<string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FINISH_REASONS: Record<Exclude<PlugFinish, "error">, string> = {
|
|
26
|
+
stop: "stop",
|
|
27
|
+
length: "length",
|
|
28
|
+
// The request offers no tools; should a model call one anyway, the turn simply stopped.
|
|
29
|
+
"tool-calls": "stop",
|
|
30
|
+
"content-filter": "content_filter",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const noFinish = (): PlugError => ({ status: 500, kind: "server", message: "Model stream ended without a finish event" })
|
|
34
|
+
const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
|
|
35
|
+
|
|
36
|
+
const now = () => Math.floor(Date.now() / 1000)
|
|
37
|
+
|
|
38
|
+
const encodeUsage = (usage: PlugUsage) => {
|
|
39
|
+
const prompt = usage.inputTokens ?? 0
|
|
40
|
+
const completion = usage.outputTokens ?? 0
|
|
41
|
+
return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
|
|
45
|
+
|
|
46
|
+
/** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
|
|
47
|
+
async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object }> {
|
|
48
|
+
let text = ""
|
|
49
|
+
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
50
|
+
let usage: PlugUsage | undefined
|
|
51
|
+
let failure: PlugError | undefined
|
|
52
|
+
for await (const event of events) {
|
|
53
|
+
if (event.type === "text-delta") text += event.text
|
|
54
|
+
else if (event.type === "finish") {
|
|
55
|
+
usage = event.usage ?? usage
|
|
56
|
+
if (event.reason === "error") failure ??= failedRun()
|
|
57
|
+
else finish = event.reason
|
|
58
|
+
} else if (event.type === "error") {
|
|
59
|
+
failure = event.error
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!failure && !finish) failure = noFinish()
|
|
64
|
+
if (failure || !finish) {
|
|
65
|
+
const error = failure ?? noFinish()
|
|
66
|
+
return { status: error.status, body: encodeError(error) }
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
status: 200,
|
|
70
|
+
body: {
|
|
71
|
+
id: `cmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`,
|
|
72
|
+
object: "text_completion",
|
|
73
|
+
created: now(),
|
|
74
|
+
model: exchange.model,
|
|
75
|
+
choices: [{ text: prefixOf(exchange, index) + text, index: 0, logprobs: null, finish_reason: FINISH_REASONS[finish] }],
|
|
76
|
+
...(usage ? { usage: encodeUsage(usage) } : {}),
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One choice of the shared stream. Writes this choice's text chunks and its finish chunk, never
|
|
83
|
+
* the error frame, usage chunk or `[DONE]` (see `StreamChoice`). Nothing is written before the
|
|
84
|
+
* first text, so a failure before it is still a real HTTP error.
|
|
85
|
+
*/
|
|
86
|
+
async function streamText(
|
|
87
|
+
events: AsyncIterable<PlugEvent>,
|
|
88
|
+
exchange: CompletionsExchange,
|
|
89
|
+
write: (chunk: string) => void,
|
|
90
|
+
choice: StreamChoice,
|
|
91
|
+
): Promise<StreamResult> {
|
|
92
|
+
const chunk = (text: string, finish: string | null = null) =>
|
|
93
|
+
write(
|
|
94
|
+
`data: ${JSON.stringify({
|
|
95
|
+
id: choice.id,
|
|
96
|
+
object: "text_completion",
|
|
97
|
+
created: choice.created,
|
|
98
|
+
model: exchange.model,
|
|
99
|
+
choices: [{ text, index: choice.index, logprobs: null, finish_reason: finish }],
|
|
100
|
+
})}\n\n`,
|
|
101
|
+
)
|
|
102
|
+
let started = false
|
|
103
|
+
const start = () => {
|
|
104
|
+
if (started) return
|
|
105
|
+
started = true
|
|
106
|
+
const prefix = prefixOf(exchange, choice.index)
|
|
107
|
+
if (prefix.length > 0) chunk(prefix)
|
|
108
|
+
}
|
|
109
|
+
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
110
|
+
let usage: PlugUsage | undefined
|
|
111
|
+
let failure: PlugError | undefined
|
|
112
|
+
for await (const event of events) {
|
|
113
|
+
if (event.type === "error") {
|
|
114
|
+
failure = event.error
|
|
115
|
+
break
|
|
116
|
+
}
|
|
117
|
+
if (event.type === "finish") {
|
|
118
|
+
usage = event.usage ?? usage
|
|
119
|
+
if (event.reason === "error") failure ??= failedRun()
|
|
120
|
+
else finish = event.reason
|
|
121
|
+
} else if (event.type === "text-delta" && event.text.length > 0) {
|
|
122
|
+
start()
|
|
123
|
+
chunk(event.text)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
failure ??= finish ? undefined : noFinish()
|
|
127
|
+
if (failure) return { failure, started }
|
|
128
|
+
start()
|
|
129
|
+
chunk("", FINISH_REASONS[finish!])
|
|
130
|
+
return { started, ...(usage ? { usage } : {}) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const completionsCodec = (exchange: CompletionsExchange): ChoiceCodec => ({
|
|
134
|
+
stream: exchange.stream,
|
|
135
|
+
includeUsage: exchange.includeUsage,
|
|
136
|
+
idPrefix: "cmpl-",
|
|
137
|
+
collect: (events, index) => collectText(events, exchange, index),
|
|
138
|
+
streamChoice: (events, write, choice) => streamText(events, exchange, write, choice),
|
|
139
|
+
usageChunk: (choice, usage) => ({
|
|
140
|
+
id: choice.id,
|
|
141
|
+
object: "text_completion",
|
|
142
|
+
created: choice.created,
|
|
143
|
+
model: exchange.model,
|
|
144
|
+
choices: [],
|
|
145
|
+
usage: encodeUsage(usage),
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /v1/completions`: the legacy completions front-end's half of the route.
|
|
3
|
+
* `src/openai/route.ts` has already checked origin, authenticated, and read the JSON body (the
|
|
4
|
+
* same handling and deadline as Chat Completions) and hands over here.
|
|
5
|
+
*
|
|
6
|
+
* Same flow as Chat Completions: parse, resolve the model, drop sampling parameters the model
|
|
7
|
+
* rejects, call the ModelCaller, encode. Every request goes through `serveChoices`, a single
|
|
8
|
+
* prompt with `n` 1 being the case of one choice. Nothing is committed until the first text, so a
|
|
9
|
+
* failure before it is a real HTTP error even when the client asked for a stream.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ServerResponse } from "node:http"
|
|
13
|
+
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
14
|
+
import { sendJson, type RequestScope } from "../http.js"
|
|
15
|
+
import { resolveModel } from "../models.js"
|
|
16
|
+
import { serveChoices } from "../openai/fanout.js"
|
|
17
|
+
import { encodeError } from "../openai/response.js"
|
|
18
|
+
import type { RouteHost } from "../openai/route.js"
|
|
19
|
+
import { dropRejected } from "../openai/sampling.js"
|
|
20
|
+
import { parseCompletionsRequest } from "./request.js"
|
|
21
|
+
import { completionsCodec } from "./response.js"
|
|
22
|
+
|
|
23
|
+
const DROPPED_HEADER = "x-plugboard-dropped-params"
|
|
24
|
+
|
|
25
|
+
export interface CompletionsCall {
|
|
26
|
+
readonly app: string
|
|
27
|
+
readonly body: unknown
|
|
28
|
+
readonly response: ServerResponse
|
|
29
|
+
readonly scope: RequestScope
|
|
30
|
+
/** The route's log fields: the resolved model, the sampling parameters dropped, and the outcome of a stream that failed after it committed. */
|
|
31
|
+
readonly info: { model?: string; failed?: string; dropped?: string }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function serveCompletions(host: RouteHost, { app, body, response, scope, info }: CompletionsCall): Promise<void> {
|
|
35
|
+
const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
|
|
36
|
+
|
|
37
|
+
const parsed = parseCompletionsRequest(body, host.options)
|
|
38
|
+
if (isPlugError(parsed)) return fail(parsed)
|
|
39
|
+
const catalog = await scope.race(host.loadCatalog())
|
|
40
|
+
const ref = resolveModel(host.options, parsed.requests[0]!.model, catalog, parsed.reasoningEffort)
|
|
41
|
+
if (isPlugError(ref)) return fail(ref)
|
|
42
|
+
info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
43
|
+
// Same sampling drop as Chat Completions: every prompt carries the same parameters.
|
|
44
|
+
const sampled = host.options.dropUnsupportedParams ? dropRejected(ref, parsed.requests[0]!.generation) : undefined
|
|
45
|
+
if (sampled && sampled.dropped.length > 0) {
|
|
46
|
+
info.dropped = sampled.dropped.join(",")
|
|
47
|
+
response.setHeader(DROPPED_HEADER, info.dropped)
|
|
48
|
+
}
|
|
49
|
+
// Choice `i` answers prompt `floor(i / n)`.
|
|
50
|
+
const calls: PlugRequest[] = parsed.requests.flatMap((request) =>
|
|
51
|
+
Array.from({ length: parsed.n }, (): PlugRequest => ({ ...request, ...(sampled ? { generation: sampled.generation } : {}), ref })),
|
|
52
|
+
)
|
|
53
|
+
return serveChoices(host, { app, calls, response, scope, info, codec: completionsCodec(parsed.exchange) })
|
|
54
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface PlugboardOptions {
|
|
|
14
14
|
/** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
|
|
15
15
|
readonly aliases: Readonly<Record<string, string>>
|
|
16
16
|
readonly maxTools: number
|
|
17
|
+
/** Most model calls one request may fan out to: `n` for chat completions, prompts x `n` for legacy completions. Each is a full call and a kept session. */
|
|
18
|
+
readonly maxN: number
|
|
17
19
|
readonly requestTimeoutMs: number
|
|
18
20
|
/**
|
|
19
21
|
* Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network.
|
|
@@ -38,10 +40,11 @@ const DEFAULTS = {
|
|
|
38
40
|
host: "127.0.0.1",
|
|
39
41
|
port: 4010,
|
|
40
42
|
maxTools: 32,
|
|
43
|
+
maxN: 1,
|
|
41
44
|
requestTimeoutMs: 600_000,
|
|
42
45
|
} as const
|
|
43
46
|
|
|
44
|
-
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
|
|
47
|
+
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
|
|
45
48
|
const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
|
|
46
49
|
/** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
|
|
47
50
|
const MAX_AGE_DAYS = 36_500
|
|
@@ -166,6 +169,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
166
169
|
models: models as string[],
|
|
167
170
|
aliases,
|
|
168
171
|
maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
|
|
172
|
+
maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
|
|
169
173
|
requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
|
|
170
174
|
remoteImages,
|
|
171
175
|
history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
|