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.
@@ -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
- }, DEADLINE_GRACE_MS)
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<T>(promise: Promise<T>): Promise<T> {
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
  }
@@ -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 route = `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
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
- const auth = authenticate(host.options, request.headers.authorization)
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))
@@ -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 = 6
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
- const canonical = (item: unknown): unknown => {
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
- /** Rejects when `signal` aborts; the underlying promise is left running (its late result or error is swallowed). */
516
- function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
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 abortable(boot(directory), bounded)
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 abortable(Promise.all(here.filter((generation) => !generation.activated).map(activate)), bounded)
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