opencode-plugboard 0.9.0 → 0.11.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 +1 -1
- package/package.json +11 -2
- package/src/abort.ts +26 -0
- package/src/config.ts +19 -7
- package/src/discovery.ts +2 -2
- package/src/gemini/request.ts +445 -0
- package/src/gemini/response.ts +234 -0
- package/src/gemini/route.ts +163 -0
- package/src/http.ts +5 -21
- package/src/openai/route.ts +14 -6
- package/src/responses/response.ts +9 -2
- package/src/responses/route.ts +1 -1
- package/src/runtime.ts +11 -40
- package/src/session/caller.ts +1 -11
- package/src/session/media.ts +60 -110
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutral results -> Gemini wire objects: `GenerateContentResponse` (one JSON body, or the chunks
|
|
3
|
+
* of a stream), the model list, and Google-shaped error bodies.
|
|
4
|
+
*
|
|
5
|
+
* One state machine (`runGenerate`) serves both: it emits a chunk per piece of output as it goes
|
|
6
|
+
* and returns the aggregated response, so the JSON body and the stream cannot disagree. A
|
|
7
|
+
* non-streaming call passes a no-op `emit`. Reasoning deltas are dropped (no thought parts).
|
|
8
|
+
*/
|
|
9
|
+
import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
10
|
+
import type { ListedModel } from "../models.js"
|
|
11
|
+
import { unfence } from "../openai/response.js"
|
|
12
|
+
import { WRAP_KEY } from "./request.js"
|
|
13
|
+
|
|
14
|
+
type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
15
|
+
|
|
16
|
+
/** What the encoder needs besides the model events: Gemini presentation state. The request decoder produces it. */
|
|
17
|
+
export interface GeminiExchange {
|
|
18
|
+
/** The model string from the URL; echoed as `modelVersion`. */
|
|
19
|
+
readonly model: string
|
|
20
|
+
readonly stream: boolean
|
|
21
|
+
/** Name of the forced tool that carries `responseSchema` output; its input becomes the text of the answer. */
|
|
22
|
+
readonly structuredTool?: string
|
|
23
|
+
/** The schema was not an object, so the answer sits under one property of the tool input and is unwrapped. */
|
|
24
|
+
readonly wrapped?: boolean
|
|
25
|
+
/**
|
|
26
|
+
* The caller's own tools are offered next to the structured tool. A call to any of them is the
|
|
27
|
+
* response (function calls, prose and the structured answer dropped); the structured answer is
|
|
28
|
+
* the text only when the step called no caller tool.
|
|
29
|
+
*/
|
|
30
|
+
readonly callerTools?: boolean
|
|
31
|
+
/** `responseMimeType: application/json` without a schema: a single code fence around JSON text is stripped. */
|
|
32
|
+
readonly jsonObject?: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
|
|
36
|
+
export interface GenerateRun {
|
|
37
|
+
readonly failure?: PlugError
|
|
38
|
+
/** Whether any chunk was emitted (a streamed response is committed as a 200 SSE stream). */
|
|
39
|
+
readonly started: boolean
|
|
40
|
+
/** The aggregated response (non-streaming body). Absent on failure. */
|
|
41
|
+
readonly response?: object
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Part = Record<string, unknown>
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------- errors, models
|
|
47
|
+
|
|
48
|
+
const STATUSES: Record<PlugErrorKind, string> = {
|
|
49
|
+
invalid_request: "INVALID_ARGUMENT",
|
|
50
|
+
authentication: "UNAUTHENTICATED",
|
|
51
|
+
permission: "PERMISSION_DENIED",
|
|
52
|
+
not_found: "NOT_FOUND",
|
|
53
|
+
rate_limit: "RESOURCE_EXHAUSTED",
|
|
54
|
+
server: "INTERNAL",
|
|
55
|
+
timeout: "DEADLINE_EXCEEDED",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Google's `{error:{code,message,status,details?}}`; `code` is the HTTP status. */
|
|
59
|
+
export const encodeGeminiError = (error: PlugError): object => ({
|
|
60
|
+
error: {
|
|
61
|
+
code: error.status,
|
|
62
|
+
message: error.message,
|
|
63
|
+
status: error.status === 503 ? "UNAVAILABLE" : STATUSES[error.kind],
|
|
64
|
+
...(error.param
|
|
65
|
+
? { details: [{ "@type": "type.googleapis.com/google.rpc.BadRequest", fieldViolations: [{ field: error.param, description: error.message }] }] }
|
|
66
|
+
: {}),
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const encodeModel = ({ id, ref, model }: ListedModel): object => ({
|
|
71
|
+
name: `models/${id}`,
|
|
72
|
+
version: "plugboard",
|
|
73
|
+
displayName: model.name ?? id,
|
|
74
|
+
description: `${ref.providerID} model served by opencode-plugboard`,
|
|
75
|
+
...(model.contextLimit !== undefined ? { inputTokenLimit: model.contextLimit } : {}),
|
|
76
|
+
supportedGenerationMethods: ["generateContent", "streamGenerateContent"],
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
/** `GET /v1beta/models` body (everything on one page). */
|
|
80
|
+
export const encodeGeminiModelList = (models: ReadonlyArray<ListedModel>): object => ({ models: models.map(encodeModel) })
|
|
81
|
+
|
|
82
|
+
/** `GET /v1beta/models/{model}` body. */
|
|
83
|
+
export const encodeGeminiModel = encodeModel
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------- responses
|
|
86
|
+
|
|
87
|
+
const FINISH_REASONS: Record<Exclude<PlugFinish, "error">, string> = {
|
|
88
|
+
stop: "STOP",
|
|
89
|
+
// Gemini reports STOP when the model ends on a function call.
|
|
90
|
+
"tool-calls": "STOP",
|
|
91
|
+
length: "MAX_TOKENS",
|
|
92
|
+
"content-filter": "SAFETY",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Gemini's candidate count excludes thoughts, which have their own field; the total is the sum. */
|
|
96
|
+
const encodeUsage = (usage: PlugUsage) => {
|
|
97
|
+
const input = usage.inputTokens ?? 0
|
|
98
|
+
const output = usage.outputTokens ?? 0
|
|
99
|
+
const thoughts = usage.reasoningTokens ?? 0
|
|
100
|
+
return {
|
|
101
|
+
promptTokenCount: input,
|
|
102
|
+
candidatesTokenCount: Math.max(output - thoughts, 0),
|
|
103
|
+
totalTokenCount: input + output,
|
|
104
|
+
...(usage.cacheReadTokens ? { cachedContentTokenCount: usage.cacheReadTokens } : {}),
|
|
105
|
+
...(thoughts > 0 ? { thoughtsTokenCount: thoughts } : {}),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const noFinish = (): PlugError => ({ status: 500, kind: "server", message: "Model stream ended without a finish event" })
|
|
110
|
+
const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
|
|
111
|
+
|
|
112
|
+
const argsOf = (input: unknown): Record<string, unknown> =>
|
|
113
|
+
typeof input === "object" && input !== null && !Array.isArray(input) ? (input as Record<string, unknown>) : { value: input }
|
|
114
|
+
|
|
115
|
+
/** The text of a structured answer: the tool input as JSON, or its wrapped property. */
|
|
116
|
+
const structuredText = (exchange: GeminiExchange, call: ToolCall): string => {
|
|
117
|
+
const input = exchange.wrapped && typeof call.input === "object" && call.input !== null ? (call.input as Record<string, unknown>)[WRAP_KEY] : call.input
|
|
118
|
+
return typeof input === "string" && !exchange.wrapped ? input : JSON.stringify(input ?? {})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Structured-output projection, as in the other front-ends: the text is exactly the first
|
|
123
|
+
* `respond` call; prose and other calls are suppressed. Without an answer the exchange fails,
|
|
124
|
+
* except when the model stopped for `length` or `content-filter`.
|
|
125
|
+
*/
|
|
126
|
+
const structuredFailure = (exchange: GeminiExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
|
|
127
|
+
exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
|
|
128
|
+
? undefined
|
|
129
|
+
: {
|
|
130
|
+
status: 502,
|
|
131
|
+
kind: "server",
|
|
132
|
+
code: "structured_output_missing",
|
|
133
|
+
message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: GeminiExchange, emit: (chunk: object) => void): Promise<GenerateRun> {
|
|
137
|
+
const responseId = crypto.randomUUID().replaceAll("-", "").slice(0, 22)
|
|
138
|
+
const envelope = (parts: Part[], extra: Record<string, unknown> = {}, candidate: Record<string, unknown> = {}) => ({
|
|
139
|
+
candidates: [{ content: { role: "model", parts }, ...candidate, index: 0 }],
|
|
140
|
+
...extra,
|
|
141
|
+
modelVersion: exchange.model,
|
|
142
|
+
responseId,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
/** The aggregated parts: adjacent text merges into one part. */
|
|
146
|
+
const parts: Part[] = []
|
|
147
|
+
let started = false
|
|
148
|
+
let calls = 0
|
|
149
|
+
let answered = false
|
|
150
|
+
/** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
|
|
151
|
+
let held: ToolCall | undefined
|
|
152
|
+
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
153
|
+
let usage: PlugUsage | undefined
|
|
154
|
+
let failure: PlugError | undefined
|
|
155
|
+
|
|
156
|
+
const push = (part: Part) => {
|
|
157
|
+
started = true
|
|
158
|
+
const last = parts.at(-1)
|
|
159
|
+
if (typeof part.text === "string" && last && typeof last.text === "string") last.text += part.text
|
|
160
|
+
else parts.push(part)
|
|
161
|
+
emit(envelope([part]))
|
|
162
|
+
}
|
|
163
|
+
const text = (delta: string) => {
|
|
164
|
+
if (delta.length > 0) push({ text: delta })
|
|
165
|
+
}
|
|
166
|
+
const toolCall = (call: ToolCall) => {
|
|
167
|
+
push({ functionCall: { name: call.name, args: argsOf(call.input) } })
|
|
168
|
+
calls++
|
|
169
|
+
}
|
|
170
|
+
// json_object: text that opens with a backtick may be a fenced answer, so it waits until a tool
|
|
171
|
+
// call or the finish shows the whole text; anything else streams as it comes.
|
|
172
|
+
let pending = ""
|
|
173
|
+
let passing = false
|
|
174
|
+
const flush = () => {
|
|
175
|
+
if (pending.length === 0) return
|
|
176
|
+
const out = unfence(pending)
|
|
177
|
+
pending = ""
|
|
178
|
+
text(out)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for await (const event of events) {
|
|
182
|
+
if (event.type === "error") {
|
|
183
|
+
failure = event.error
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
if (event.type === "finish") {
|
|
187
|
+
usage = event.usage ?? usage
|
|
188
|
+
if (event.reason === "error") failure ??= failedRun()
|
|
189
|
+
else finish = event.reason
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (event.type === "reasoning-delta") continue
|
|
193
|
+
if (exchange.structuredTool !== undefined) {
|
|
194
|
+
// Structured mode: only the first answer is content; prose and other tool calls are
|
|
195
|
+
// suppressed, except the caller's own tools when they are offered.
|
|
196
|
+
if (event.type === "tool-call" && event.name === exchange.structuredTool) {
|
|
197
|
+
if (!answered) {
|
|
198
|
+
answered = true
|
|
199
|
+
if (exchange.callerTools) held = event
|
|
200
|
+
else text(structuredText(exchange, event))
|
|
201
|
+
}
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (!(exchange.callerTools && event.type === "tool-call")) continue
|
|
205
|
+
}
|
|
206
|
+
if (exchange.jsonObject && event.type === "text-delta" && !passing) {
|
|
207
|
+
pending += event.text
|
|
208
|
+
const lead = pending.trimStart()
|
|
209
|
+
if (lead.length > 0 && !lead.startsWith("`")) {
|
|
210
|
+
passing = true
|
|
211
|
+
flush()
|
|
212
|
+
}
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (event.type !== "text-delta") flush()
|
|
216
|
+
if (event.type === "text-delta") text(event.text)
|
|
217
|
+
else toolCall(event)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const passthrough = exchange.callerTools === true && calls > 0
|
|
221
|
+
failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
|
|
222
|
+
if (failure) return { failure, started }
|
|
223
|
+
flush()
|
|
224
|
+
if (held && !passthrough) text(structuredText(exchange, held))
|
|
225
|
+
const candidate = { finishReason: FINISH_REASONS[finish!] }
|
|
226
|
+
const extra = usage ? { usageMetadata: encodeUsage(usage) } : {}
|
|
227
|
+
// The last chunk carries the finish reason and usage, with an empty text part as Google's does.
|
|
228
|
+
emit(envelope([{ text: "" }], extra, candidate))
|
|
229
|
+
started = true
|
|
230
|
+
return { started, response: envelope(parts.length > 0 ? parts : [{ text: "" }], extra, candidate) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** SSE frame of one stream chunk (`alt=sse`): `data: <json>`, no event name and no `[DONE]`. */
|
|
234
|
+
export const sseChunk = (chunk: object): string => `data: ${JSON.stringify(chunk)}\n\n`
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Gemini front-end's half of the route: `GET /v1beta/models`, `GET /v1beta/models/{model}`,
|
|
3
|
+
* `POST /v1beta/models/{model}:generateContent` and `:streamGenerateContent?alt=sse` (and the
|
|
4
|
+
* `POST /v1/models/{model}:...` spelling). `src/openai/route.ts` matches the path
|
|
5
|
+
* (`matchGeminiRoute`), checks the origin, authenticates (`geminiAuthorization` supplies the
|
|
6
|
+
* credential) and hands over here; errors on these routes use Google's body (`encodeGeminiError`).
|
|
7
|
+
*
|
|
8
|
+
* The `{model}` segment is an alias or `provider/model[#variant]`, percent-decoded. Ids contain `/`
|
|
9
|
+
* and `#`: a literal `/` works, a `#` must be sent as `%23` (SDKs build the URL as a string, so
|
|
10
|
+
* `#` is cut as a fragment there; use an alias).
|
|
11
|
+
*
|
|
12
|
+
* Same flow as the other front-ends: parse, resolve the model, call the ModelCaller, encode. Nothing
|
|
13
|
+
* is committed until the encoder emits its first chunk, so a failure before any output is a real
|
|
14
|
+
* HTTP error even for a stream.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
18
|
+
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
19
|
+
import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
20
|
+
import { listAvailableModels, resolveModel } from "../models.js"
|
|
21
|
+
import type { RouteHost } from "../openai/route.js"
|
|
22
|
+
import { dropRejected } from "../openai/sampling.js"
|
|
23
|
+
import { parseGeminiRequest } from "./request.js"
|
|
24
|
+
import { encodeGeminiError, encodeGeminiModel, encodeGeminiModelList, runGenerate, sseChunk } from "./response.js"
|
|
25
|
+
|
|
26
|
+
const SESSION_HEADER = "x-plugboard-session"
|
|
27
|
+
const DROPPED_HEADER = "x-plugboard-dropped-params"
|
|
28
|
+
|
|
29
|
+
export type GeminiMatch =
|
|
30
|
+
| { readonly op: "list"; readonly route: string }
|
|
31
|
+
| { readonly op: "get"; readonly route: string; readonly model: string }
|
|
32
|
+
| { readonly op: "generate"; readonly route: string; readonly model: string; readonly stream: boolean }
|
|
33
|
+
/** A method on a model that plugboard does not serve (`countTokens`, `embedContent`, ...). */
|
|
34
|
+
| { readonly op: "unsupported"; readonly route: string; readonly verb: string }
|
|
35
|
+
|
|
36
|
+
const decode = (raw: string): string | undefined => {
|
|
37
|
+
try {
|
|
38
|
+
return decodeURIComponent(raw)
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Recognises the Gemini paths. `GET /v1/models...` stays with the OpenAI routes: only `POST`
|
|
46
|
+
* with a `:method` suffix is claimed under `/v1/`. Logs get the `route` label, never the path.
|
|
47
|
+
*/
|
|
48
|
+
export function matchGeminiRoute(method: string | undefined, path: string): GeminiMatch | undefined {
|
|
49
|
+
if (path === "/v1beta/models") return method === "GET" ? { op: "list", route: "GET /v1beta/models" } : undefined
|
|
50
|
+
for (const prefix of ["/v1beta/models/", "/v1/models/"]) {
|
|
51
|
+
if (!path.startsWith(prefix) || path.length === prefix.length) continue
|
|
52
|
+
const rest = path.slice(prefix.length)
|
|
53
|
+
if (method === "GET") {
|
|
54
|
+
if (prefix === "/v1/models/") return undefined
|
|
55
|
+
const model = decode(rest)
|
|
56
|
+
return model === undefined ? undefined : { op: "get", route: "GET /v1beta/models/{model}", model }
|
|
57
|
+
}
|
|
58
|
+
if (method !== "POST") return undefined
|
|
59
|
+
// The method is after the last colon; ids may contain colons themselves (`llama3:8b`).
|
|
60
|
+
const colon = rest.lastIndexOf(":")
|
|
61
|
+
const verb = colon > 0 ? rest.slice(colon + 1) : ""
|
|
62
|
+
const model = decode(rest.slice(0, colon))
|
|
63
|
+
if (!/^[A-Za-z]+$/.test(verb) || model === undefined) return undefined
|
|
64
|
+
const label = `POST ${prefix.slice(0, -1)}/{model}:${verb}`
|
|
65
|
+
if (verb === "generateContent" || verb === "streamGenerateContent")
|
|
66
|
+
return { op: "generate", route: label, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
|
|
67
|
+
return { op: "unsupported", route: label, verb }
|
|
68
|
+
}
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The credential in the form `authenticate` reads: `Authorization` as sent, else the Gemini key
|
|
74
|
+
* as `x-goog-api-key` or the `key` query parameter. Query strings end up in proxy and access
|
|
75
|
+
* logs; plugboard never logs the URL, but prefer the header.
|
|
76
|
+
*/
|
|
77
|
+
export function geminiAuthorization(request: IncomingMessage, url: URL): string | undefined {
|
|
78
|
+
if (request.headers.authorization !== undefined) return request.headers.authorization
|
|
79
|
+
const key = request.headers["x-goog-api-key"] ?? url.searchParams.get("key")
|
|
80
|
+
return typeof key === "string" && key.length > 0 ? `Bearer ${key}` : undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GeminiCall {
|
|
84
|
+
readonly match: GeminiMatch
|
|
85
|
+
readonly request: IncomingMessage
|
|
86
|
+
readonly url: URL
|
|
87
|
+
readonly app: string
|
|
88
|
+
readonly response: ServerResponse
|
|
89
|
+
readonly scope: RequestScope
|
|
90
|
+
/** The route's log fields: the resolved model, and the outcome of a stream that failed after it committed. */
|
|
91
|
+
readonly info: { model?: string; failed?: string; dropped?: string }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function serveGemini(host: RouteHost, { match, request, url, app, response, scope, info }: GeminiCall): Promise<void> {
|
|
95
|
+
const fail = (error: PlugError) => sendJson(response, error.status, encodeGeminiError(error))
|
|
96
|
+
|
|
97
|
+
if (match.op === "unsupported")
|
|
98
|
+
return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent and streamGenerateContent` })
|
|
99
|
+
|
|
100
|
+
if (match.op === "list" || match.op === "get") {
|
|
101
|
+
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
|
|
102
|
+
if (match.op === "list") return sendJson(response, 200, encodeGeminiModelList(listed))
|
|
103
|
+
const item = listed.find((m) => m.id === match.model)
|
|
104
|
+
if (!item) return fail({ status: 404, kind: "not_found", code: "model_not_found", param: "model", message: `Model ${match.model} is not available` })
|
|
105
|
+
info.model = item.id
|
|
106
|
+
return sendJson(response, 200, encodeGeminiModel(item))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (match.stream && url.searchParams.get("alt") !== "sse")
|
|
110
|
+
return fail({ status: 400, kind: "invalid_request", code: "unsupported_parameter", param: "alt", message: "streamGenerateContent needs the query parameter alt=sse" })
|
|
111
|
+
const unsupported = checkJsonContentType(request)
|
|
112
|
+
if (unsupported) return fail(unsupported)
|
|
113
|
+
const body = await readJsonBody(request, scope.signal)
|
|
114
|
+
|
|
115
|
+
const parsed = parseGeminiRequest(match.model, match.stream, body, host.options)
|
|
116
|
+
if (isPlugError(parsed)) return fail(parsed)
|
|
117
|
+
const catalog = await scope.race(host.loadCatalog())
|
|
118
|
+
const ref = resolveModel(host.options, match.model, catalog, parsed.reasoningEffort)
|
|
119
|
+
if (isPlugError(ref)) return fail(ref)
|
|
120
|
+
info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
121
|
+
// Sampling parameters the model is known to reject are dropped and reported, before any response bytes.
|
|
122
|
+
const sampled = host.options.dropUnsupportedParams ? dropRejected(ref, parsed.request.generation) : undefined
|
|
123
|
+
if (sampled && sampled.dropped.length > 0) {
|
|
124
|
+
info.dropped = sampled.dropped.join(",")
|
|
125
|
+
response.setHeader(DROPPED_HEADER, info.dropped)
|
|
126
|
+
}
|
|
127
|
+
const call: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
|
|
128
|
+
const { exchange } = parsed
|
|
129
|
+
|
|
130
|
+
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
131
|
+
if (scope.signal.aborted) throw scope.signal.reason
|
|
132
|
+
const events = boundEvents(
|
|
133
|
+
host.caller.stream(call, {
|
|
134
|
+
app,
|
|
135
|
+
signal: scope.signal,
|
|
136
|
+
onRecord: (recordID) => {
|
|
137
|
+
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
138
|
+
response.setHeader(SESSION_HEADER, recordID)
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
scope,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if (exchange.stream) {
|
|
145
|
+
const sse = sseWriter(response, scope.signal)
|
|
146
|
+
const { failure } = await runGenerate(sse.paced(events), exchange, (chunk) => sse.write(sseChunk(chunk)))
|
|
147
|
+
if (!sse.started && !response.headersSent) {
|
|
148
|
+
if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
149
|
+
} else {
|
|
150
|
+
if (failure) {
|
|
151
|
+
info.failed = failure.code ?? failure.kind
|
|
152
|
+
// Google ends a failed stream with one error object.
|
|
153
|
+
sse.write(sseChunk(encodeGeminiError(failure)))
|
|
154
|
+
}
|
|
155
|
+
if (!response.writableEnded) response.end()
|
|
156
|
+
}
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const run = await runGenerate(events, exchange, () => {})
|
|
161
|
+
if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
162
|
+
sendJson(response, 200, run.response)
|
|
163
|
+
}
|
package/src/http.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
8
|
+
import { raceAbort } from "./abort.js"
|
|
8
9
|
import { isLoopbackHost, type PlugboardOptions } from "./config.js"
|
|
9
10
|
import type { PlugError, PlugEvent } from "./contract.js"
|
|
10
11
|
|
|
@@ -49,9 +50,9 @@ export interface RequestScope {
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Establishes cancellation and the deadline. Call before the first `await` of a request: a socket
|
|
52
|
-
* that is already gone aborts immediately, so abandoned work never starts.
|
|
53
|
+
* that is already gone aborts immediately, so abandoned work never starts. `graceMs` is for tests.
|
|
53
54
|
*/
|
|
54
|
-
export function beginRequest(request: IncomingMessage, response: ServerResponse, timeoutMs: number): RequestScope {
|
|
55
|
+
export function beginRequest(request: IncomingMessage, response: ServerResponse, timeoutMs: number, graceMs = DEADLINE_GRACE_MS): RequestScope {
|
|
55
56
|
const controller = new AbortController()
|
|
56
57
|
const abort = (kind: RequestAborted["kind"]) => {
|
|
57
58
|
if (!controller.signal.aborted) controller.abort(new RequestAborted(kind))
|
|
@@ -68,7 +69,7 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
|
|
|
68
69
|
graceTimer = setTimeout(() => {
|
|
69
70
|
response.destroy()
|
|
70
71
|
request.socket?.destroy()
|
|
71
|
-
},
|
|
72
|
+
}, graceMs)
|
|
72
73
|
graceTimer.unref?.()
|
|
73
74
|
}
|
|
74
75
|
timer = setTimeout(onDeadline, Math.min(Math.max(timeoutMs, 1), MAX_TIMER_MS))
|
|
@@ -98,24 +99,7 @@ export function beginRequest(request: IncomingMessage, response: ServerResponse,
|
|
|
98
99
|
code: "timeout",
|
|
99
100
|
message: `Request timed out after ${timeoutMs} ms (option requestTimeoutMs)`,
|
|
100
101
|
},
|
|
101
|
-
race
|
|
102
|
-
promise.catch(() => {})
|
|
103
|
-
if (signal.aborted) return Promise.reject(signal.reason)
|
|
104
|
-
return new Promise<T>((resolve, reject) => {
|
|
105
|
-
const onAbort = () => reject(signal.reason)
|
|
106
|
-
signal.addEventListener("abort", onAbort, { once: true })
|
|
107
|
-
promise.then(
|
|
108
|
-
(value) => {
|
|
109
|
-
signal.removeEventListener("abort", onAbort)
|
|
110
|
-
resolve(value)
|
|
111
|
-
},
|
|
112
|
-
(error) => {
|
|
113
|
-
signal.removeEventListener("abort", onAbort)
|
|
114
|
-
reject(error)
|
|
115
|
-
},
|
|
116
|
-
)
|
|
117
|
-
})
|
|
118
|
-
},
|
|
102
|
+
race: (promise) => raceAbort(promise, signal),
|
|
119
103
|
dispose,
|
|
120
104
|
}
|
|
121
105
|
}
|
package/src/openai/route.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
sseWriter,
|
|
37
37
|
type RequestScope,
|
|
38
38
|
} from "../http.js"
|
|
39
|
+
import { encodeGeminiError } from "../gemini/response.js"
|
|
40
|
+
import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/route.js"
|
|
39
41
|
import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
40
42
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
41
43
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
@@ -111,9 +113,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
111
113
|
const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
|
|
112
114
|
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
113
115
|
const known = (route: string) => KNOWN_ROUTES.includes(route) || DISCOVERY_ROUTES.includes(route) || (metrics !== undefined && route === "GET /metrics")
|
|
114
|
-
|
|
116
|
+
/** Responses to Gemini routes: errors there use Google's body. */
|
|
117
|
+
const googleErrors = new WeakSet<ServerResponse>()
|
|
115
118
|
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
116
|
-
sendJson(response, error.status, encodeError(error), { close })
|
|
119
|
+
sendJson(response, error.status, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
|
|
117
120
|
|
|
118
121
|
/** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
|
|
119
122
|
async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
|
|
@@ -136,9 +139,11 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
136
139
|
}
|
|
137
140
|
const path = url.pathname
|
|
138
141
|
const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
|
|
139
|
-
const
|
|
142
|
+
const gemini = matchGeminiRoute(request.method, path)
|
|
143
|
+
if (gemini) googleErrors.add(response)
|
|
144
|
+
const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
140
145
|
// Arbitrary client paths stay out of the log.
|
|
141
|
-
info.route = modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
146
|
+
info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
142
147
|
// Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
|
|
143
148
|
if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
|
|
144
149
|
|
|
@@ -164,11 +169,12 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
167
|
-
|
|
172
|
+
// Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
|
|
173
|
+
const auth = authenticate(host.options, gemini ? geminiAuthorization(request, url) : request.headers.authorization)
|
|
168
174
|
if (isPlugError(auth)) return fail(response, auth)
|
|
169
175
|
info.app = auth.app
|
|
170
176
|
|
|
171
|
-
if (modelId === undefined && !known(route)) {
|
|
177
|
+
if (!gemini && modelId === undefined && !known(route)) {
|
|
172
178
|
return fail(response, {
|
|
173
179
|
status: 404,
|
|
174
180
|
kind: "not_found",
|
|
@@ -177,6 +183,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
177
183
|
})
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
if (gemini) return serveGemini(host, { match: gemini, request, url, app: auth.app, response, scope, info })
|
|
187
|
+
|
|
180
188
|
if (route === "GET /v1/status") {
|
|
181
189
|
const catalog = await optionalCatalog(scope)
|
|
182
190
|
return sendJson(response, 200, buildStatus({ options: host.options, app: auth.app, runtime: runtimeStatus(), catalog }))
|
|
@@ -79,10 +79,16 @@ const structuredFailure = (exchange: ResponsesExchange, finish: Exclude<PlugFini
|
|
|
79
79
|
message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* `committed`: whether the transport already committed a 200 stream before any event was emitted
|
|
84
|
+
* (the SSE writer does at the first reasoning event, #49). A failure then still opens the response
|
|
85
|
+
* and ends it with `response.failed`, instead of leaving the client an empty 200 stream.
|
|
86
|
+
*/
|
|
82
87
|
export async function runResponse(
|
|
83
88
|
events: AsyncIterable<PlugEvent>,
|
|
84
89
|
exchange: ResponsesExchange,
|
|
85
90
|
emit: (event: ResponsesStreamEvent) => void,
|
|
91
|
+
committed: () => boolean = () => false,
|
|
86
92
|
): Promise<ResponseRun> {
|
|
87
93
|
const id = newId("resp")
|
|
88
94
|
const createdAt = Math.floor(Date.now() / 1000)
|
|
@@ -203,14 +209,15 @@ export async function runResponse(
|
|
|
203
209
|
const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
|
|
204
210
|
failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
|
|
205
211
|
if (failure) {
|
|
206
|
-
if (!started) return { failure, started }
|
|
212
|
+
if (!started && !committed()) return { failure, started }
|
|
213
|
+
start()
|
|
207
214
|
closeMessage()
|
|
208
215
|
const response = envelope("failed", items, {
|
|
209
216
|
error: { code: failure.code ?? failure.kind, message: failure.message },
|
|
210
217
|
...(usage ? { usage: encodeUsage(usage) } : {}),
|
|
211
218
|
})
|
|
212
219
|
send("response.failed", { response })
|
|
213
|
-
return { failure, started, response }
|
|
220
|
+
return { failure, started: true, response }
|
|
214
221
|
}
|
|
215
222
|
start()
|
|
216
223
|
if (held && structuredAnswer) text(args(held.input))
|
package/src/responses/route.ts
CHANGED
|
@@ -56,7 +56,7 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
|
|
|
56
56
|
|
|
57
57
|
if (exchange.stream) {
|
|
58
58
|
const sse = sseWriter(response, scope.signal)
|
|
59
|
-
const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)))
|
|
59
|
+
const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)), () => sse.started)
|
|
60
60
|
if (!sse.started && !response.headersSent) {
|
|
61
61
|
if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
62
62
|
} else {
|
package/src/runtime.ts
CHANGED
|
@@ -44,7 +44,10 @@
|
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
46
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
|
47
|
+
import { setTimeout as delay } from "node:timers/promises"
|
|
47
48
|
import { Cause, Effect, Stream } from "effect"
|
|
49
|
+
import stableStringify from "safe-stable-stringify"
|
|
50
|
+
import { raceAbort } from "./abort.js"
|
|
48
51
|
import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
|
|
49
52
|
import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
|
|
50
53
|
import { createMetrics, type Metrics, type RuntimeSnapshot } from "./metrics.js"
|
|
@@ -104,8 +107,8 @@ const BACKEND_POLL_MS = 25
|
|
|
104
107
|
const BIND_RETRY_MS = 15_000
|
|
105
108
|
const BIND_REMINDER_MS = 5 * 60_000
|
|
106
109
|
|
|
107
|
-
/** 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
108
|
-
const VERSION =
|
|
110
|
+
/** 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
111
|
+
const VERSION = 7
|
|
109
112
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
110
113
|
|
|
111
114
|
interface Listener {
|
|
@@ -298,19 +301,9 @@ export function loadServingCatalog(fallback: () => Promise<ReadonlyArray<Catalog
|
|
|
298
301
|
return (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
|
|
299
302
|
}
|
|
300
303
|
|
|
301
|
-
/** JSON with object keys sorted at every level, so equal options compare equal whatever their key order. */
|
|
304
|
+
/** JSON with object keys sorted at every level, so equal options compare equal whatever their key order (undefined values are left out, as in `JSON.stringify`). */
|
|
302
305
|
function canonicalJson(value: unknown): string {
|
|
303
|
-
|
|
304
|
-
if (Array.isArray(item)) return item.map(canonical)
|
|
305
|
-
if (typeof item !== "object" || item === null) return item
|
|
306
|
-
return Object.fromEntries(
|
|
307
|
-
Object.entries(item)
|
|
308
|
-
.filter(([, entry]) => entry !== undefined)
|
|
309
|
-
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
310
|
-
.map(([key, entry]) => [key, canonical(entry)]),
|
|
311
|
-
)
|
|
312
|
-
}
|
|
313
|
-
return JSON.stringify(canonical(value))
|
|
306
|
+
return stableStringify(value) ?? ""
|
|
314
307
|
}
|
|
315
308
|
|
|
316
309
|
const unavailable = (response: ServerResponse, message: string) => {
|
|
@@ -512,30 +505,8 @@ export function ensurePump(subscribe: () => Stream.Stream<unknown, unknown, neve
|
|
|
512
505
|
|
|
513
506
|
const backendUnavailable = (message: string): PlugError => ({ status: 503, kind: "server", code: "backend_unavailable", message })
|
|
514
507
|
|
|
515
|
-
/**
|
|
516
|
-
|
|
517
|
-
return new Promise<T>((resolve, reject) => {
|
|
518
|
-
const onAbort = () => reject(new Error("aborted"))
|
|
519
|
-
if (signal.aborted) return onAbort()
|
|
520
|
-
signal.addEventListener("abort", onAbort, { once: true })
|
|
521
|
-
promise.then(
|
|
522
|
-
(value) => (signal.removeEventListener("abort", onAbort), resolve(value)),
|
|
523
|
-
(error) => (signal.removeEventListener("abort", onAbort), reject(error)),
|
|
524
|
-
)
|
|
525
|
-
})
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const sleep = (ms: number, signal: AbortSignal) =>
|
|
529
|
-
new Promise<void>((resolve) => {
|
|
530
|
-
if (signal.aborted) return resolve()
|
|
531
|
-
const timer = setTimeout(done, ms)
|
|
532
|
-
function done() {
|
|
533
|
-
clearTimeout(timer)
|
|
534
|
-
signal.removeEventListener("abort", done)
|
|
535
|
-
resolve()
|
|
536
|
-
}
|
|
537
|
-
signal.addEventListener("abort", done, { once: true })
|
|
538
|
-
})
|
|
508
|
+
/** Waits `ms`, or until `signal` aborts (which resolves, never rejects). */
|
|
509
|
+
const sleep = (ms: number, signal: AbortSignal): Promise<void> => delay(ms, undefined, { signal }).catch(() => {})
|
|
539
510
|
|
|
540
511
|
/** Rebuild the transforms of a generation set up before its directory was confirmed; shared by concurrent requests. */
|
|
541
512
|
function activate(generation: Generation): Promise<void> {
|
|
@@ -593,7 +564,7 @@ export async function ensureBackend(
|
|
|
593
564
|
if (!booted) {
|
|
594
565
|
booted = true
|
|
595
566
|
try {
|
|
596
|
-
await
|
|
567
|
+
await raceAbort(boot(directory), bounded)
|
|
597
568
|
} catch (error) {
|
|
598
569
|
if (bounded.aborted) continue
|
|
599
570
|
logError("backend.boot_failed", { app, error: errorMessage(error) })
|
|
@@ -603,7 +574,7 @@ export async function ensureBackend(
|
|
|
603
574
|
}
|
|
604
575
|
} else {
|
|
605
576
|
try {
|
|
606
|
-
await
|
|
577
|
+
await raceAbort(Promise.all(here.filter((generation) => !generation.activated).map(activate)), bounded)
|
|
607
578
|
} catch (error) {
|
|
608
579
|
if (bounded.aborted) continue
|
|
609
580
|
throw error
|