opencode-plugboard 0.9.0 → 0.9.1

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 CHANGED
@@ -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.9.0",
33
+ "package": "opencode-plugboard@0.9.1",
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"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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,445 @@
1
+ /**
2
+ * Gemini `generateContent` / `streamGenerateContent` request -> neutral `PlugRequest` plus the
3
+ * `GeminiExchange` the response encoder needs.
4
+ *
5
+ * Pure function: no I/O. The model and the stream flag come from the URL, the rest from the body.
6
+ * Stateless like the other front-ends: the caller sends the whole conversation in `contents`.
7
+ * Gemini function calls carry no id, so calls get generated ids (`call_0`, `call_1`, ...) and a
8
+ * `functionResponse` is paired with the earliest unanswered call of the same name (or with the
9
+ * call that has its `id`, when the client sends one). Hosted tools (`googleSearch`,
10
+ * `codeExecution`, ...) and server-side state (`cachedContent`) are rejected with a 400.
11
+ *
12
+ * Field names are read in camelCase, the form every SDK sends, and in snake_case, which the REST
13
+ * API also accepts.
14
+ */
15
+ import type { PlugboardOptions } from "../config.js"
16
+ import {
17
+ isPlugError,
18
+ type PlugError,
19
+ type PlugGeneration,
20
+ type PlugMessage,
21
+ type PlugPart,
22
+ type PlugRequest,
23
+ type PlugTool,
24
+ type PlugToolChoice,
25
+ } from "../contract.js"
26
+ import type { GeminiExchange } from "./response.js"
27
+
28
+ type JsonObject = Record<string, unknown>
29
+
30
+ export interface ParsedGemini {
31
+ /** The model call, minus the model ref the route resolves from `request.model`. */
32
+ readonly request: Omit<PlugRequest, "ref">
33
+ /** Response presentation state for the encoder; never reaches the model call. */
34
+ readonly exchange: GeminiExchange
35
+ /** From `generationConfig.thinkingConfig`: picks the model variant of that name when the model has one. */
36
+ readonly reasoningEffort?: string
37
+ }
38
+
39
+ /** Name of the forced tool that carries structured output (same as the other front-ends). */
40
+ const RESPOND_TOOL = "respond"
41
+ /** Property that carries a non-object structured answer (an array or a scalar) through the object-only `respond` tool. */
42
+ export const WRAP_KEY = "value"
43
+
44
+ /** System instruction for `responseMimeType: application/json` without a schema. */
45
+ const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
46
+
47
+ const MAX_STOP = 5
48
+
49
+ const isRecord = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value)
50
+
51
+ const bad = (message: string, param?: string, code?: string): PlugError => ({
52
+ status: 400,
53
+ kind: "invalid_request",
54
+ message,
55
+ ...(code ? { code } : {}),
56
+ ...(param ? { param } : {}),
57
+ })
58
+
59
+ const unsupported = (message: string, param: string) => bad(message, param, "unsupported_parameter")
60
+
61
+ const snake = (name: string) => name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)
62
+
63
+ /** A field in camelCase, or snake_case when only that is present. */
64
+ const field = (object: JsonObject, name: string): unknown => object[name] ?? object[snake(name)]
65
+
66
+ // ---------------------------------------------------------------- schemas
67
+
68
+ /** `format` values that mean the same in JSON Schema; the rest (`int32`, `float`, `enum`, ...) are Gemini-only. */
69
+ const JSON_SCHEMA_FORMATS = new Set(["date-time", "date", "time", "email", "uri", "uuid", "hostname", "ipv4", "ipv6"])
70
+ /** Gemini types int64 limits as strings. */
71
+ const NUMERIC_STRING_KEYS = new Set(["minItems", "maxItems", "minLength", "maxLength", "minProperties", "maxProperties"])
72
+ const COPIED_KEYS = new Set(["description", "title", "enum", "required", "pattern", "default", "minimum", "maximum"])
73
+
74
+ /**
75
+ * Gemini `Schema` (the OpenAPI subset of `parameters` and `responseSchema`) -> JSON Schema:
76
+ * `OBJECT` becomes `object`, `nullable` becomes a `null` type, int64 strings become numbers, and
77
+ * keys JSON Schema does not have (`example`, `propertyOrdering`, Gemini-only formats) are dropped.
78
+ */
79
+ export function convertSchema(schema: unknown): unknown {
80
+ if (!isRecord(schema)) return schema
81
+ const out: JsonObject = {}
82
+ for (const [key, value] of Object.entries(schema)) {
83
+ if (key === "type") {
84
+ if (typeof value === "string") {
85
+ const type = value.toLowerCase()
86
+ if (type !== "type_unspecified") out.type = type
87
+ }
88
+ } else if (key === "format") {
89
+ if (typeof value === "string" && JSON_SCHEMA_FORMATS.has(value)) out.format = value
90
+ } else if (key === "properties") {
91
+ if (isRecord(value)) out.properties = Object.fromEntries(Object.entries(value).map(([name, child]) => [name, convertSchema(child)]))
92
+ } else if (key === "items") {
93
+ out.items = convertSchema(value)
94
+ } else if (key === "anyOf") {
95
+ if (Array.isArray(value)) out.anyOf = value.map(convertSchema)
96
+ } else if (key === "additionalProperties") {
97
+ out.additionalProperties = typeof value === "boolean" ? value : convertSchema(value)
98
+ } else if (NUMERIC_STRING_KEYS.has(key)) {
99
+ const n = typeof value === "string" ? Number(value) : value
100
+ if (typeof n === "number" && Number.isFinite(n)) out[key] = n
101
+ } else if (COPIED_KEYS.has(key)) {
102
+ out[key] = value
103
+ }
104
+ }
105
+ if (schema.nullable === true && typeof out.type === "string") out.type = [out.type, "null"]
106
+ return out
107
+ }
108
+
109
+ /** The parameters of a function declaration as a JSON Schema object (a tool with none takes no arguments). */
110
+ const toolParameters = (declaration: JsonObject): JsonObject | undefined => {
111
+ const json = field(declaration, "parametersJsonSchema")
112
+ if (isRecord(json)) return json
113
+ const schema = convertSchema(declaration.parameters)
114
+ if (!isRecord(schema)) return undefined
115
+ return schema.type === undefined || schema.type === "object" ? { type: "object", properties: {}, ...schema } : schema
116
+ }
117
+
118
+ // ---------------------------------------------------------------- contents
119
+
120
+ const dataUrl = (mimeType: string, data: string) => `data:${mimeType};base64,${data}`
121
+
122
+ const textOf = (value: unknown, param: string): string | PlugError => {
123
+ if (typeof value === "string") return value
124
+ if (!isRecord(value)) return bad("Expected a string or a content object with text parts", param)
125
+ if (typeof value.text === "string") return value.text
126
+ const parts = value.parts
127
+ if (!Array.isArray(parts)) return bad("Content needs a parts array", `${param}.parts`)
128
+ const texts: string[] = []
129
+ for (const [index, part] of parts.entries()) {
130
+ if (!isRecord(part) || typeof part.text !== "string") return unsupported("Only text parts are supported here", `${param}.parts[${index}]`)
131
+ texts.push(part.text)
132
+ }
133
+ return texts.join("\n")
134
+ }
135
+
136
+ /** `a` then `b`, with the text parts at the seam merged. */
137
+ const joinParts = (a: ReadonlyArray<PlugPart>, b: ReadonlyArray<PlugPart>): PlugPart[] => {
138
+ const tail = a.at(-1)
139
+ const head = b[0]
140
+ if (tail?.type === "text" && head?.type === "text") return [...a.slice(0, -1), { type: "text", text: tail.text + head.text }, ...b.slice(1)]
141
+ return [...a, ...b]
142
+ }
143
+
144
+ interface Call {
145
+ readonly id: string
146
+ readonly name: string
147
+ answered: boolean
148
+ }
149
+
150
+ /**
151
+ * `contents` -> neutral messages. A model turn is one assistant message (text and calls). A user
152
+ * turn is one tool message per `functionResponse` (first, so each directly follows the calls it
153
+ * answers) and then one user message with the rest.
154
+ */
155
+ const parseContents = (raw: unknown): PlugMessage[] | PlugError => {
156
+ if (!Array.isArray(raw) || raw.length === 0) return bad("contents must be a non-empty array", "contents")
157
+ const messages: PlugMessage[] = []
158
+ const calls: Call[] = []
159
+ const used = new Set<string>()
160
+ let counter = 0
161
+ const nextId = () => {
162
+ while (used.has(`call_${counter}`)) counter++
163
+ return `call_${counter}`
164
+ }
165
+
166
+ for (const [index, content] of raw.entries()) {
167
+ const param = `contents[${index}]`
168
+ if (!isRecord(content)) return bad("Each content must be an object", param)
169
+ const role = content.role ?? "user"
170
+ if (role !== "user" && role !== "model" && role !== "function") return bad(`Unsupported role "${String(role)}": use "user" or "model"`, `${param}.role`)
171
+ if (!Array.isArray(content.parts)) return bad("Each content needs a parts array", `${param}.parts`)
172
+
173
+ const parts: PlugPart[] = []
174
+ const results: PlugMessage[] = []
175
+ for (const [p, part] of content.parts.entries()) {
176
+ const at = `${param}.parts[${p}]`
177
+ if (!isRecord(part)) return bad("Each part must be an object", at)
178
+ const call = field(part, "functionCall")
179
+ const response = field(part, "functionResponse")
180
+ const inline = field(part, "inlineData")
181
+ const file = field(part, "fileData")
182
+ if (typeof part.text === "string") {
183
+ // Thought summaries from an earlier turn are not replayable context.
184
+ if (part.thought !== true && part.text.length > 0) parts.push({ type: "text", text: part.text })
185
+ } else if (call !== undefined) {
186
+ if (role !== "model") return bad("functionCall parts belong in a model turn", `${at}.functionCall`)
187
+ if (!isRecord(call) || typeof call.name !== "string" || call.name.length === 0) return bad("functionCall needs a name", `${at}.functionCall.name`)
188
+ const args = call.args ?? {}
189
+ if (!isRecord(args)) return bad("functionCall args must be an object", `${at}.functionCall.args`)
190
+ const id = typeof call.id === "string" && call.id.length > 0 && !used.has(call.id) ? call.id : nextId()
191
+ used.add(id)
192
+ calls.push({ id, name: call.name, answered: false })
193
+ parts.push({ type: "tool-call", id, name: call.name, input: args })
194
+ } else if (response !== undefined) {
195
+ if (role === "model") return bad("functionResponse parts belong in a user turn", `${at}.functionResponse`)
196
+ if (!isRecord(response) || typeof response.name !== "string" || response.name.length === 0)
197
+ return bad("functionResponse needs a name", `${at}.functionResponse.name`)
198
+ const wanted = typeof response.id === "string" ? response.id : undefined
199
+ const match =
200
+ (wanted !== undefined ? calls.find((c) => !c.answered && c.id === wanted) : undefined) ?? calls.find((c) => !c.answered && c.name === response.name)
201
+ if (!match)
202
+ return bad(`functionResponse "${response.name}" answers no earlier functionCall of that name (calls are paired by name and order)`, `${at}.functionResponse`)
203
+ match.answered = true
204
+ const body = response.response ?? {}
205
+ // The convention is {output: ...} for a result and {error: ...} for a failure.
206
+ const failed = isRecord(body) && body.error !== undefined && body.output === undefined
207
+ results.push({
208
+ role: "tool",
209
+ content: [{ type: "tool-result", id: match.id, name: match.name, result: JSON.stringify(body), ...(failed ? { isError: true } : {}) }],
210
+ })
211
+ } else if (inline !== undefined) {
212
+ if (role === "model") return unsupported("inlineData is not supported in a model turn", `${at}.inlineData`)
213
+ if (!isRecord(inline) || typeof inline.data !== "string" || typeof inline.mimeType !== "string")
214
+ return bad("inlineData needs mimeType and base64 data", `${at}.inlineData`)
215
+ parts.push({ type: "media", url: dataUrl(inline.mimeType, inline.data), mediaType: inline.mimeType })
216
+ } else if (file !== undefined) {
217
+ if (role === "model") return unsupported("fileData is not supported in a model turn", `${at}.fileData`)
218
+ const uri = isRecord(file) ? field(file, "fileUri") : undefined
219
+ if (typeof uri !== "string" || !/^https?:\/\//i.test(uri))
220
+ return unsupported("fileData needs an http(s) fileUri; Gemini file ids and gs:// URIs are not supported (send inlineData)", `${at}.fileData.fileUri`)
221
+ const mimeType = isRecord(file) ? field(file, "mimeType") : undefined
222
+ parts.push({ type: "media", url: uri, ...(typeof mimeType === "string" ? { mediaType: mimeType } : {}) })
223
+ } else if (isRecord(part) && Object.keys(part).every((key) => key === "thoughtSignature" || key === "thought_signature" || key === "thought")) {
224
+ // A signature-only or empty part carries nothing to replay.
225
+ } else {
226
+ const kind = Object.keys(part).find((key) => key !== "thoughtSignature" && key !== "thought_signature" && key !== "thought") ?? "unknown"
227
+ return unsupported(`Unsupported part "${kind}": plugboard handles text, inlineData, fileData (http), functionCall and functionResponse`, at)
228
+ }
229
+ }
230
+ if (role === "model") {
231
+ // A streamed turn replayed by an SDK arrives as one model content per chunk: they are one assistant message.
232
+ const last = messages.at(-1)
233
+ if (parts.length > 0 && last?.role === "assistant") messages[messages.length - 1] = { role: "assistant", content: joinParts(last.content, parts) }
234
+ else if (parts.length > 0) messages.push({ role: "assistant", content: parts })
235
+ } else {
236
+ messages.push(...results)
237
+ if (parts.length > 0) messages.push({ role: "user", content: parts })
238
+ }
239
+ }
240
+ const open = calls.find((call) => !call.answered)
241
+ if (open) return bad(`functionCall "${open.name}" has no functionResponse; send one for every call in the conversation`, "contents")
242
+ if (messages.length === 0) return bad("contents has no text, media or tool parts", "contents")
243
+ return messages
244
+ }
245
+
246
+ // ---------------------------------------------------------------- tools
247
+
248
+ /** Tool types that run on Google's side; plugboard only hands function calls back to the caller. */
249
+ const HOSTED = ["googleSearch", "googleSearchRetrieval", "codeExecution", "urlContext", "retrieval", "fileSearch", "googleMaps", "computerUse", "mcpServers", "enterpriseWebSearch"]
250
+
251
+ const parseTools = (raw: unknown): PlugTool[] | PlugError => {
252
+ if (raw == null) return []
253
+ if (!Array.isArray(raw)) return bad("tools must be an array", "tools")
254
+ const tools: PlugTool[] = []
255
+ const names = new Set<string>()
256
+ for (const [index, entry] of raw.entries()) {
257
+ const at = `tools[${index}]`
258
+ if (!isRecord(entry)) return bad("Each tool must be an object", at)
259
+ const hosted = HOSTED.find((key) => field(entry, key) !== undefined)
260
+ if (hosted)
261
+ return unsupported(`Unsupported tool "${hosted}": only functionDeclarations are supported (Google's built-in tools run on Google's servers)`, `${at}.${hosted}`)
262
+ const declarations = field(entry, "functionDeclarations")
263
+ if (declarations === undefined) {
264
+ const other = Object.keys(entry)[0]
265
+ if (other !== undefined) return unsupported(`Unsupported tool "${other}": only functionDeclarations are supported`, `${at}.${other}`)
266
+ continue
267
+ }
268
+ if (!Array.isArray(declarations)) return bad("functionDeclarations must be an array", `${at}.functionDeclarations`)
269
+ for (const [i, declaration] of declarations.entries()) {
270
+ const param = `${at}.functionDeclarations[${i}]`
271
+ if (!isRecord(declaration) || typeof declaration.name !== "string" || declaration.name.length === 0) return bad('Each function declaration needs a "name"', `${param}.name`)
272
+ if (names.has(declaration.name)) return bad(`Duplicate tool name "${declaration.name}"`, `${param}.name`)
273
+ if (declaration.description != null && typeof declaration.description !== "string") return bad("Tool description must be a string", `${param}.description`)
274
+ names.add(declaration.name)
275
+ tools.push({
276
+ name: declaration.name,
277
+ description: typeof declaration.description === "string" ? declaration.description : "",
278
+ parameters: toolParameters(declaration) ?? { type: "object", properties: {} },
279
+ })
280
+ }
281
+ }
282
+ return tools
283
+ }
284
+
285
+ /**
286
+ * `toolConfig.functionCallingConfig`: AUTO -> auto, NONE -> none, ANY -> required, and
287
+ * `allowedFunctionNames` narrows the tools offered (one name with ANY forces that tool).
288
+ */
289
+ const parseToolConfig = (raw: unknown, tools: PlugTool[]): { tools: PlugTool[]; toolChoice?: PlugToolChoice } | PlugError => {
290
+ if (raw == null) return { tools }
291
+ if (!isRecord(raw)) return bad("toolConfig must be an object", "toolConfig")
292
+ const config = field(raw, "functionCallingConfig")
293
+ if (config == null) return { tools }
294
+ if (!isRecord(config)) return bad("functionCallingConfig must be an object", "toolConfig.functionCallingConfig")
295
+ const mode = config.mode == null ? "AUTO" : typeof config.mode === "string" ? config.mode.toUpperCase() : undefined
296
+ if (mode === undefined || !["AUTO", "ANY", "NONE", "VALIDATED", "MODE_UNSPECIFIED"].includes(mode))
297
+ return bad(`Unsupported functionCallingConfig mode "${String(config.mode)}": use AUTO, ANY or NONE`, "toolConfig.functionCallingConfig.mode")
298
+ const allowed = field(config, "allowedFunctionNames")
299
+ if (allowed != null && (!Array.isArray(allowed) || !allowed.every((name) => typeof name === "string")))
300
+ return bad("allowedFunctionNames must be an array of strings", "toolConfig.functionCallingConfig.allowedFunctionNames")
301
+ let offered = tools
302
+ if (Array.isArray(allowed) && allowed.length > 0 && mode !== "NONE") {
303
+ const missing = allowed.find((name) => !tools.some((tool) => tool.name === name))
304
+ if (missing !== undefined) return bad(`allowedFunctionNames names "${missing}", which is not in tools`, "toolConfig.functionCallingConfig.allowedFunctionNames")
305
+ offered = tools.filter((tool) => allowed.includes(tool.name))
306
+ }
307
+ if (mode === "NONE") return { tools, toolChoice: "none" }
308
+ if (mode === "ANY") {
309
+ if (offered.length === 1 && Array.isArray(allowed) && allowed.length > 0) return { tools: offered, toolChoice: { type: "tool", name: offered[0]!.name } }
310
+ return { tools: offered, toolChoice: "required" }
311
+ }
312
+ return { tools: offered, toolChoice: "auto" }
313
+ }
314
+
315
+ // ---------------------------------------------------------------- generation
316
+
317
+ /** `thinkingConfig` -> the variant name to try: `thinkingLevel` as is, or a `thinkingBudget` bucketed into none/low/medium/high. */
318
+ const effortOf = (config: unknown): string | undefined => {
319
+ if (!isRecord(config)) return undefined
320
+ const level = field(config, "thinkingLevel")
321
+ if (typeof level === "string" && level.length > 0 && level.toUpperCase() !== "THINKING_LEVEL_UNSPECIFIED") return level.toLowerCase()
322
+ const budget = field(config, "thinkingBudget")
323
+ if (typeof budget !== "number" || !Number.isFinite(budget) || budget < 0) return undefined
324
+ return budget === 0 ? "none" : budget <= 1024 ? "low" : budget <= 8192 ? "medium" : "high"
325
+ }
326
+
327
+ const parseGeneration = (config: JsonObject): PlugGeneration | PlugError => {
328
+ const out: { -readonly [K in keyof PlugGeneration]: PlugGeneration[K] } = {}
329
+ for (const [key, to, min, max, integer] of [
330
+ ["temperature", "temperature", 0, 2, false],
331
+ ["topP", "topP", 0, 1, false],
332
+ ["presencePenalty", "presencePenalty", -2, 2, false],
333
+ ["frequencyPenalty", "frequencyPenalty", -2, 2, false],
334
+ ["maxOutputTokens", "maxTokens", 1, Number.MAX_SAFE_INTEGER, true],
335
+ ] as const) {
336
+ const value = field(config, key)
337
+ if (value == null) continue
338
+ const param = `generationConfig.${key}`
339
+ if (typeof value !== "number" || !Number.isFinite(value)) return bad(`${key} must be a number`, param)
340
+ if (integer && !Number.isInteger(value)) return bad(`${key} must be an integer`, param)
341
+ if (value < min || value > max) return bad(max === Number.MAX_SAFE_INTEGER ? `${key} must be at least ${min}` : `${key} must be between ${min} and ${max}`, param)
342
+ out[to] = value
343
+ }
344
+ const stop = field(config, "stopSequences")
345
+ if (stop != null) {
346
+ if (!Array.isArray(stop) || !stop.every((s) => typeof s === "string")) return bad("stopSequences must be an array of strings", "generationConfig.stopSequences")
347
+ if (stop.length > MAX_STOP) return bad(`stopSequences takes at most ${MAX_STOP} sequences`, "generationConfig.stopSequences")
348
+ if (stop.length > 0) out.stop = stop as string[]
349
+ }
350
+ return out
351
+ }
352
+
353
+ /** `responseMimeType` / `responseSchema` / `responseJsonSchema`: same outcomes as `response_format` in the other front-ends. */
354
+ const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; wrap?: boolean; instruction?: string } | PlugError => {
355
+ const mime = field(config, "responseMimeType")
356
+ const schema = field(config, "responseJsonSchema") ?? field(config, "responseSchema")
357
+ if (mime != null && typeof mime !== "string") return bad("responseMimeType must be a string", "generationConfig.responseMimeType")
358
+ if (mime !== undefined && mime !== null && mime !== "" && mime !== "text/plain" && mime !== "application/json")
359
+ return unsupported(`Unsupported responseMimeType "${mime}": use application/json or text/plain`, "generationConfig.responseMimeType")
360
+ if (schema == null) return mime === "application/json" ? { instruction: JSON_OBJECT_INSTRUCTION } : {}
361
+ if (!isRecord(schema)) return bad("responseSchema must be a schema object", "generationConfig.responseSchema")
362
+ const converted = field(config, "responseJsonSchema") !== undefined ? schema : (convertSchema(schema) as JsonObject)
363
+ // The `respond` tool takes an object; an array or scalar answer travels under one property and is unwrapped.
364
+ const wrap = converted.type !== undefined && converted.type !== "object"
365
+ return {
366
+ wrap,
367
+ structured: {
368
+ name: RESPOND_TOOL,
369
+ description: "Give your final answer by calling this tool with JSON matching its schema.",
370
+ parameters: wrap ? { type: "object", properties: { [WRAP_KEY]: converted }, required: [WRAP_KEY] } : converted,
371
+ },
372
+ }
373
+ }
374
+
375
+ /** Fields that need server-side state or features plugboard does not have. */
376
+ const checkUnsupported = (body: JsonObject, config: JsonObject): PlugError | undefined => {
377
+ if (field(body, "cachedContent") != null) return unsupported("cachedContent is not supported: plugboard is stateless, send the full contents", "cachedContent")
378
+ const count = field(config, "candidateCount")
379
+ if (count != null) {
380
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 1) return bad("candidateCount must be a positive integer", "generationConfig.candidateCount")
381
+ if (count > 1) return unsupported("Only candidateCount 1 is supported", "generationConfig.candidateCount")
382
+ }
383
+ if (field(config, "responseLogprobs") === true) return unsupported("responseLogprobs is not supported", "generationConfig.responseLogprobs")
384
+ const modalities = field(config, "responseModalities")
385
+ if (Array.isArray(modalities) && modalities.some((m) => String(m).toUpperCase() !== "TEXT"))
386
+ return unsupported("Only the TEXT response modality is supported", "generationConfig.responseModalities")
387
+ }
388
+
389
+ export function parseGeminiRequest(model: string, stream: boolean, body: unknown, options: PlugboardOptions): ParsedGemini | PlugError {
390
+ if (!isRecord(body)) return bad("Request body must be a JSON object")
391
+ const configRaw = field(body, "generationConfig")
392
+ if (configRaw != null && !isRecord(configRaw)) return bad("generationConfig must be an object", "generationConfig")
393
+ const config = isRecord(configRaw) ? configRaw : {}
394
+ const refused = checkUnsupported(body, config)
395
+ if (refused) return refused
396
+
397
+ const messages = parseContents(body.contents)
398
+ if (isPlugError(messages)) return messages
399
+ const instruction = field(body, "systemInstruction")
400
+ const system = instruction == null ? "" : textOf(instruction, "systemInstruction")
401
+ if (isPlugError(system)) return system
402
+ const declared = parseTools(body.tools)
403
+ if (!Array.isArray(declared)) return declared
404
+ const configured = parseToolConfig(field(body, "toolConfig"), declared)
405
+ if (isPlugError(configured)) return configured
406
+ const format = parseResponseFormat(config)
407
+ if (isPlugError(format)) return format
408
+ const generation = parseGeneration(config)
409
+ if (isPlugError(generation)) return generation
410
+
411
+ const callerTools = configured.tools
412
+ const toolChoice = configured.toolChoice
413
+ const respond = format.structured
414
+ if (respond && callerTools.some((tool) => tool.name === RESPOND_TOOL))
415
+ return bad(`A tool named "${RESPOND_TOOL}" collides with responseSchema, which plugboard answers through that name`, "tools", "reserved_tool_name")
416
+ if (respond && typeof toolChoice === "object")
417
+ return unsupported("Mode ANY with a single allowed function cannot be combined with responseSchema", "toolConfig.functionCallingConfig")
418
+ // Same rule as the other front-ends: with caller tools on offer the model may call one of them or
419
+ // `respond`, so the choice is `required`; mode NONE leaves only `respond`.
420
+ const withCallerTools = respond !== undefined && callerTools.length > 0 && toolChoice !== "none"
421
+ const tools = respond ? (withCallerTools ? [...callerTools, respond] : [respond]) : callerTools
422
+ if (tools.length > options.maxTools)
423
+ return bad(`Request has ${tools.length} tools; plugboard allows ${options.maxTools} (option \`maxTools\`)`, "tools", "too_many_tools")
424
+ const forced: PlugToolChoice | undefined = respond ? (withCallerTools ? "required" : { type: "tool", name: RESPOND_TOOL }) : toolChoice
425
+
426
+ const request: Omit<PlugRequest, "ref"> = {
427
+ model,
428
+ system: [...(system.length > 0 ? [system] : []), ...(format.instruction ? [format.instruction] : [])],
429
+ messages,
430
+ tools,
431
+ ...(forced && tools.length > 0 ? { toolChoice: forced } : {}),
432
+ generation,
433
+ }
434
+ const reasoningEffort = effortOf(field(config, "thinkingConfig"))
435
+ return {
436
+ request,
437
+ exchange: {
438
+ model,
439
+ stream,
440
+ ...(respond ? { structuredTool: RESPOND_TOOL, ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
441
+ ...(format.instruction ? { jsonObject: true } : {}),
442
+ },
443
+ ...(reasoningEffort ? { reasoningEffort } : {}),
444
+ }
445
+ }
@@ -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
+ }
@@ -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 }))