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 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.11.0",
34
34
  "options": {
35
35
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
36
36
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
@@ -19,11 +19,20 @@
19
19
  },
20
20
  "files": ["index.ts", "src/", "README.md", "LICENSE"],
21
21
  "scripts": {
22
- "typecheck": "tsc --noEmit",
22
+ "typecheck": "tsc --noEmit && tsc --noEmit -p test",
23
+ "test": "bun test",
24
+ "smoke:pack": "bun .github/scripts/smoke-pack.ts",
23
25
  "release": "bun .github/scripts/release.ts"
24
26
  },
27
+ "dependencies": {
28
+ "escape-html": "1.0.3",
29
+ "ipaddr.js": "2.5.0",
30
+ "safe-stable-stringify": "2.5.0"
31
+ },
25
32
  "devDependencies": {
26
33
  "@opencode/plugin": "2.0.26",
34
+ "@types/bun": "1.4.2",
35
+ "@types/escape-html": "^1.0.4",
27
36
  "@types/node": "^26.6.2",
28
37
  "effect": "4.0.0-rc.112",
29
38
  "typescript": "^5.9.3"
package/src/abort.ts ADDED
@@ -0,0 +1,26 @@
1
+ /** The one "settle or reject when aborted" helper that request-side code races host calls against. */
2
+
3
+ /**
4
+ * Settles with `promise`, or rejects with `signal.reason` as soon as `signal` aborts. The promise
5
+ * itself keeps running (it cannot be cancelled): it is always observed, so a late rejection after
6
+ * the race was lost never becomes an unhandled rejection. A signal that is already aborted rejects
7
+ * at once.
8
+ */
9
+ export function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
10
+ promise.catch(() => {})
11
+ if (signal.aborted) return Promise.reject(signal.reason)
12
+ return new Promise<T>((resolve, reject) => {
13
+ const onAbort = () => reject(signal.reason)
14
+ signal.addEventListener("abort", onAbort, { once: true })
15
+ promise.then(
16
+ (value) => {
17
+ signal.removeEventListener("abort", onAbort)
18
+ resolve(value)
19
+ },
20
+ (error) => {
21
+ signal.removeEventListener("abort", onAbort)
22
+ reject(error)
23
+ },
24
+ )
25
+ })
26
+ }
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
2
2
 
3
+ import { createHash, timingSafeEqual } from "node:crypto"
3
4
  import type { PlugError } from "./contract.js"
4
5
  import { parseAllowEntry, type RemoteImages } from "./session/media.js"
5
6
  import type { HistoryLimits } from "./session/prune.js"
@@ -125,6 +126,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
125
126
  }
126
127
  // Own-property construction: a key such as `__proto__` must stay a key, never reach the prototype.
127
128
  const keys: Record<string, string> = Object.fromEntries(keyEntries)
129
+ digestsOf(keys)
128
130
  if (keyEntries.length === 0 && !isLoopbackHost(host))
129
131
  throw new Error(`plugboard refuses to listen on non-loopback host \`${host}\` without \`keys\``)
130
132
 
@@ -186,20 +188,30 @@ const UNAUTHORIZED: PlugError = {
186
188
  code: "invalid_api_key",
187
189
  }
188
190
 
189
- function same(a: string, b: string): boolean {
190
- let diff = a.length ^ b.length
191
- const length = Math.max(a.length, b.length)
192
- for (let i = 0; i < length; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0)
193
- return diff === 0
191
+ const sha256 = (text: string): Buffer => createHash("sha256").update(text).digest()
192
+
193
+ /**
194
+ * SHA-256 digest of each key, computed once per `keys` object (at parse; lazily for an options
195
+ * object built without `parseOptions`). Comparing fixed-size digests means a wrong token's length
196
+ * and content do not change how long the comparison takes.
197
+ */
198
+ const keyDigests = new WeakMap<object, ReadonlyArray<readonly [string, Buffer]>>()
199
+
200
+ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readonly [string, Buffer]> {
201
+ let digests = keyDigests.get(keys)
202
+ if (!digests) keyDigests.set(keys, (digests = Object.entries(keys).map(([name, key]) => [name, sha256(key)] as const)))
203
+ return digests
194
204
  }
195
205
 
196
206
  /** No keys -> app `default`; with keys every request needs `Authorization: Bearer <key>`. */
197
207
  export function authenticate(options: PlugboardOptions, authorization: string | undefined): { app: string } | PlugError {
198
- const entries = Object.entries(options.keys)
208
+ const entries = digestsOf(options.keys)
199
209
  if (entries.length === 0) return { app: DEFAULT_APP }
200
210
  const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
201
211
  if (!token) return UNAUTHORIZED
212
+ const digest = sha256(token)
202
213
  let app: string | undefined
203
- for (const [name, key] of entries) if (same(token, key)) app = name
214
+ // Every key is compared, no early exit.
215
+ for (const [name, expected] of entries) if (timingSafeEqual(digest, expected)) app = name
204
216
  return app === undefined ? UNAUTHORIZED : { app }
205
217
  }
package/src/discovery.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { ServerResponse } from "node:http"
19
+ import escapeText from "escape-html"
19
20
  import type { PlugboardOptions } from "./config.js"
20
21
  import type { CatalogModel } from "./contract.js"
21
22
  import { listAvailableModels } from "./models.js"
@@ -40,8 +41,7 @@ export function sendText(response: ServerResponse, status: number, contentType:
40
41
  response.end(body)
41
42
  }
42
43
 
43
- const escapeHtml = (value: unknown): string =>
44
- String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char] ?? char)
44
+ const escapeHtml = (value: unknown): string => escapeText(String(value))
45
45
 
46
46
  /** `http://host:port` for a listen address; an IPv6 literal gets brackets. */
47
47
  function origin(listen: PlugboardOptions["listen"]): string {
@@ -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
+ }