opencode-plugboard 0.7.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 +3 -3
- package/package.json +1 -1
- 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 +9 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ client ──▶ plugboard listener ──▶ OpenCode session ──▶ provide
|
|
|
12
12
|
└──── reply; tool calls go back to the caller ◀──────────┘
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Tools like n8n, Open WebUI or a Discord bot only speak the OpenAI API, so each wants its own key or its own gateway. Plugboard serves OpenAI Chat Completions (`/v1/chat/completions`), the Responses API (`/v1/responses`), the Anthropic Messages API (`/v1/messages`) and `/v1/models` from inside OpenCode. OpenCode never runs your tools.
|
|
15
|
+
Tools like n8n, Open WebUI or a Discord bot only speak the OpenAI API, so each wants its own key or its own gateway. Plugboard serves OpenAI Chat Completions (`/v1/chat/completions`), the Responses API (`/v1/responses`), the Anthropic Messages API (`/v1/messages`), legacy completions (`/v1/completions`) and `/v1/models` from inside OpenCode. OpenCode never runs your tools.
|
|
16
16
|
|
|
17
17
|
## Quick start
|
|
18
18
|
|
|
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
|
|
|
30
30
|
{
|
|
31
31
|
"plugins": [
|
|
32
32
|
{
|
|
33
|
-
"package": "opencode-plugboard@0.
|
|
33
|
+
"package": "opencode-plugboard@0.8.0",
|
|
34
34
|
"options": {
|
|
35
35
|
"keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
|
|
36
36
|
"models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
|
|
@@ -77,7 +77,7 @@ Plugboard is early (0.x) and narrow on purpose.
|
|
|
77
77
|
|
|
78
78
|
- **V2 only.** OpenCode V2 has no OpenAI endpoint, and the V1 plugins that had one stopped working when the plugin API changed. V1 is not planned.
|
|
79
79
|
- **Two formats:** Chat Completions and Responses. `/v1/responses` is a stateless subset, see [docs/responses.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md). Anthropic Messages and Gemini front-ends are on the [roadmap](#roadmap).
|
|
80
|
-
- **Not planned:** embeddings, audio, image generation
|
|
80
|
+
- **Not planned:** embeddings, audio, image generation, `logprobs`.
|
|
81
81
|
- **No concurrency limit per app** yet ([#54](https://github.com/jjjermiah/opencode-plugboard/issues/54), [#86](https://github.com/jjjermiah/opencode-plugboard/issues/86)).
|
|
82
82
|
- **Not a gateway with its own provider keys.** It only reuses what OpenCode is logged in to.
|
|
83
83
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
|
|
@@ -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 }) },
|
|
@@ -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
|
+
}
|
package/src/openai/request.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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,
|
package/src/openai/response.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
352
|
-
|
|
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 (
|
|
360
|
-
|
|
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
|
}
|
package/src/openai/route.ts
CHANGED
|
@@ -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
|
|
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
|
*
|
|
@@ -39,8 +40,10 @@ 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"
|
|
41
42
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
43
|
+
import { serveCompletions } from "../completions/route.js"
|
|
42
44
|
import { serveResponses } from "../responses/route.js"
|
|
43
45
|
import { processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
|
|
46
|
+
import { chatCodec, serveChoices } from "./fanout.js"
|
|
44
47
|
import { parseChatRequest } from "./request.js"
|
|
45
48
|
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
46
49
|
import { dropRejected } from "./sampling.js"
|
|
@@ -61,7 +64,7 @@ const SESSION_HEADER = "x-plugboard-session"
|
|
|
61
64
|
const REQUEST_ID_HEADER = "x-plugboard-request-id"
|
|
62
65
|
const DROPPED_HEADER = "x-plugboard-dropped-params"
|
|
63
66
|
const MODEL_PREFIX = "/v1/models/"
|
|
64
|
-
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"]
|
|
65
68
|
/** Pages anyone may fetch; they decide for themselves how much to show (see `src/discovery.ts`). */
|
|
66
69
|
const DISCOVERY_ROUTES = ["GET /", "GET /v1"]
|
|
67
70
|
/** Allows a page's inline style and nothing else: no scripts, images, fonts or frames. */
|
|
@@ -205,6 +208,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
205
208
|
|
|
206
209
|
const body = await readJsonBody(request, scope.signal)
|
|
207
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 })
|
|
208
212
|
const parsed = parseChatRequest(body, host.options)
|
|
209
213
|
if (isPlugError(parsed)) return fail(response, parsed)
|
|
210
214
|
const catalog = await scope.race(host.loadCatalog())
|
|
@@ -220,6 +224,9 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
220
224
|
}
|
|
221
225
|
const call: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
|
|
222
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) })
|
|
223
230
|
|
|
224
231
|
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
225
232
|
if (scope.signal.aborted) throw scope.signal.reason
|