opencode-plugboard 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +11 -2
- package/src/abort.ts +26 -0
- package/src/config.ts +19 -7
- package/src/discovery.ts +2 -2
- package/src/gemini/request.ts +445 -0
- package/src/gemini/response.ts +234 -0
- package/src/gemini/route.ts +163 -0
- package/src/http.ts +5 -21
- package/src/openai/route.ts +14 -6
- package/src/responses/response.ts +9 -2
- package/src/responses/route.ts +1 -1
- package/src/runtime.ts +11 -40
- package/src/session/caller.ts +1 -11
- package/src/session/media.ts +60 -110
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.
|
|
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.
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 =
|
|
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
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
}
|