opencode-plugboard 0.8.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 +3 -3
- package/package.json +1 -1
- package/src/gemini/request.ts +445 -0
- package/src/gemini/response.ts +234 -0
- package/src/gemini/route.ts +163 -0
- package/src/openai/route.ts +14 -6
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/jjjermiah/opencode-plugboard/blob/main/LICENSE)
|
|
5
5
|
[](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml)
|
|
6
6
|
|
|
7
|
-
An OpenCode V2 plugin that turns your OpenCode server into an OpenAI- and
|
|
7
|
+
An OpenCode V2 plugin that turns your OpenCode server into an OpenAI-, Anthropic- and Gemini-compatible API, so every other app reuses the provider logins you already made in OpenCode. Point Claude Code at a GPT model, or n8n at your Claude subscription.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
client ──▶ plugboard listener ──▶ OpenCode session ──▶ provider
|
|
@@ -12,7 +12,7 @@ client ──▶ plugboard listener ──▶ OpenCode session ──▶ provide
|
|
|
12
12
|
└──── reply; tool calls go back to the caller ◀──────────┘
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Tools like n8n, Open WebUI or a Discord bot only speak the OpenAI API, so each wants its own key or its own gateway. Plugboard serves OpenAI Chat Completions (`/v1/chat/completions`), the Responses API (`/v1/responses`), the Anthropic Messages API (`/v1/messages`), legacy completions (`/v1/completions`) and `/v1/models` from inside OpenCode. OpenCode never runs your tools.
|
|
15
|
+
Tools like n8n, Open WebUI or a Discord bot only speak the OpenAI API, so each wants its own key or its own gateway. Plugboard serves OpenAI Chat Completions (`/v1/chat/completions`), the Responses API (`/v1/responses`), the Anthropic Messages API (`/v1/messages`), the Gemini API (`/v1beta/models/{model}:generateContent`), legacy completions (`/v1/completions`) and `/v1/models` from inside OpenCode. OpenCode never runs your tools.
|
|
16
16
|
|
|
17
17
|
## Quick start
|
|
18
18
|
|
|
@@ -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.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.
|
|
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
|
+
}
|
package/src/openai/route.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
sseWriter,
|
|
37
37
|
type RequestScope,
|
|
38
38
|
} from "../http.js"
|
|
39
|
+
import { encodeGeminiError } from "../gemini/response.js"
|
|
40
|
+
import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/route.js"
|
|
39
41
|
import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
40
42
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
41
43
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
@@ -111,9 +113,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
111
113
|
const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
|
|
112
114
|
/** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
|
|
113
115
|
const known = (route: string) => KNOWN_ROUTES.includes(route) || DISCOVERY_ROUTES.includes(route) || (metrics !== undefined && route === "GET /metrics")
|
|
114
|
-
|
|
116
|
+
/** Responses to Gemini routes: errors there use Google's body. */
|
|
117
|
+
const googleErrors = new WeakSet<ServerResponse>()
|
|
115
118
|
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
116
|
-
sendJson(response, error.status, encodeError(error), { close })
|
|
119
|
+
sendJson(response, error.status, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
|
|
117
120
|
|
|
118
121
|
/** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
|
|
119
122
|
async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
|
|
@@ -136,9 +139,11 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
136
139
|
}
|
|
137
140
|
const path = url.pathname
|
|
138
141
|
const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
|
|
139
|
-
const
|
|
142
|
+
const gemini = matchGeminiRoute(request.method, path)
|
|
143
|
+
if (gemini) googleErrors.add(response)
|
|
144
|
+
const route = gemini?.route ?? `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
|
|
140
145
|
// Arbitrary client paths stay out of the log.
|
|
141
|
-
info.route = modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
146
|
+
info.route = gemini || modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
|
|
142
147
|
// Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
|
|
143
148
|
if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
|
|
144
149
|
|
|
@@ -164,11 +169,12 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
// Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
|
|
167
|
-
|
|
172
|
+
// Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
|
|
173
|
+
const auth = authenticate(host.options, gemini ? geminiAuthorization(request, url) : request.headers.authorization)
|
|
168
174
|
if (isPlugError(auth)) return fail(response, auth)
|
|
169
175
|
info.app = auth.app
|
|
170
176
|
|
|
171
|
-
if (modelId === undefined && !known(route)) {
|
|
177
|
+
if (!gemini && modelId === undefined && !known(route)) {
|
|
172
178
|
return fail(response, {
|
|
173
179
|
status: 404,
|
|
174
180
|
kind: "not_found",
|
|
@@ -177,6 +183,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
177
183
|
})
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
if (gemini) return serveGemini(host, { match: gemini, request, url, app: auth.app, response, scope, info })
|
|
187
|
+
|
|
180
188
|
if (route === "GET /v1/status") {
|
|
181
189
|
const catalog = await optionalCatalog(scope)
|
|
182
190
|
return sendJson(response, 200, buildStatus({ options: host.options, app: auth.app, runtime: runtimeStatus(), catalog }))
|