opencode-plugboard 0.19.0 → 0.21.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 +35 -31
- package/dist/tui.js +334 -0
- package/index.ts +35 -23
- package/package.json +10 -4
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/response.ts +5 -3
- package/src/anthropic/route.ts +17 -10
- package/src/cache.ts +26 -5
- package/src/completions/request.ts +15 -4
- package/src/completions/response.ts +52 -10
- package/src/config.ts +155 -13
- package/src/contract.ts +9 -1
- package/src/discovery.ts +35 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/request.ts +3 -2
- package/src/gemini/response.ts +20 -20
- package/src/gemini/route.ts +10 -4
- package/src/http.ts +65 -7
- package/src/keys.ts +172 -0
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +29 -7
- package/src/openai/request.ts +6 -4
- package/src/openai/response.ts +101 -21
- package/src/openai/route.ts +44 -17
- package/src/policy.ts +16 -0
- package/src/responses/request.ts +3 -1
- package/src/responses/response.ts +13 -17
- package/src/responses/route.ts +7 -3
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +60 -7
- package/src/session/caller.ts +96 -11
- package/src/session/context.ts +18 -4
- package/src/session/events.ts +116 -12
- package/src/session/history.ts +8 -2
- package/src/session/host.ts +40 -0
- package/src/session/link.ts +33 -0
- package/src/session/seeds.ts +10 -0
- package/src/session/state.ts +2 -0
- package/src/usage.ts +61 -2
- package/src/user.ts +55 -0
- package/tui.ts +5 -4
package/src/config.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { createHash, timingSafeEqual } from "node:crypto"
|
|
4
4
|
import type { CacheConfig } from "./cache.js"
|
|
5
5
|
import { isPlugError, type PlugError } from "./contract.js"
|
|
6
|
-
import { allowedBy, parseModelRef } from "./models.js"
|
|
6
|
+
import { allowedBy, parseAliasTarget, parseModelRef, SKILL_PREFIX } from "./models.js"
|
|
7
7
|
import { parseAllowEntry, type RemoteImages } from "./session/media.js"
|
|
8
8
|
import type { HistoryLimits } from "./session/prune.js"
|
|
9
|
+
import { SEED_PREFIX } from "./session/seeds.js"
|
|
9
10
|
|
|
10
11
|
/** How `/v1/models` spells a model id: the canonical `provider/model#variant`, a UI-safe slug, or both. Resolution accepts either. */
|
|
11
12
|
export type ModelIds = "canonical" | "slugs" | "both"
|
|
@@ -21,6 +22,11 @@ export interface PlugboardOptions {
|
|
|
21
22
|
readonly models: ReadonlyArray<string>
|
|
22
23
|
/** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
|
|
23
24
|
readonly aliases: Readonly<Record<string, string>>
|
|
25
|
+
/**
|
|
26
|
+
* Agent name -> `provider/model[#variant]` (option `agents`): each is listed as `agent/<name>` and runs its
|
|
27
|
+
* OpenCode agent's system prompt on that model. The model must pass the allowlist.
|
|
28
|
+
*/
|
|
29
|
+
readonly agents: Readonly<Record<string, string>>
|
|
24
30
|
/**
|
|
25
31
|
* Alias -> the targets after the first, in order, for an alias written as an array. `aliases` holds the
|
|
26
32
|
* first target, so everything that resolves an alias keeps working; the model caller retries the rest
|
|
@@ -58,6 +64,8 @@ export interface PlugboardOptions {
|
|
|
58
64
|
readonly usage: UsageConfig
|
|
59
65
|
/** A key that may read every app's usage on `GET /v1/usage`. Compared like app keys; must differ from every one of them. */
|
|
60
66
|
readonly adminKey?: string
|
|
67
|
+
/** A template for the OpenCode session page of each request: `{id}`, and optional `{directory}` (see src/session/link.ts, #77). */
|
|
68
|
+
readonly historyUrl?: string
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
/** Option `usage` after defaults: on, and days of history kept. */
|
|
@@ -81,6 +89,13 @@ export interface AppPolicy {
|
|
|
81
89
|
readonly tools: boolean
|
|
82
90
|
/** Lowers the global `maxTools` for this app. */
|
|
83
91
|
readonly maxTools?: number
|
|
92
|
+
/**
|
|
93
|
+
* Exact OpenCode tool ids this app's sessions may run themselves (option `apps.<name>.serverTools`).
|
|
94
|
+
* Absent: none, the session denies every real tool. Trusted apps only, see docs/configuration.md.
|
|
95
|
+
*/
|
|
96
|
+
readonly serverTools?: ReadonlyArray<string>
|
|
97
|
+
/** Server-tool steps one request may run before the turn is stopped (option `maxServerToolSteps`). */
|
|
98
|
+
readonly maxServerToolSteps?: number
|
|
84
99
|
}
|
|
85
100
|
|
|
86
101
|
/** One set of caps. A field that is not set is unlimited. */
|
|
@@ -99,8 +114,24 @@ export interface Limits {
|
|
|
99
114
|
readonly maxConcurrent?: number
|
|
100
115
|
readonly perApp?: LimitCaps
|
|
101
116
|
readonly apps?: Readonly<Record<string, LimitCaps>>
|
|
117
|
+
/**
|
|
118
|
+
* Bytes of request bodies buffered at once, across every request in the process (`readJsonBody` in
|
|
119
|
+
* `src/http.ts`). A body that would go over it is refused with 503 `server_busy` before it is read. Not per app.
|
|
120
|
+
*/
|
|
121
|
+
readonly maxBodyBytesInFlight?: number
|
|
122
|
+
/** Milliseconds a request body may take to arrive in full; a slower one is refused with 408. */
|
|
123
|
+
readonly bodyReadTimeoutMs?: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The bounds `readJsonBody` applies before it buffers a request body (see `bodyLimits`). */
|
|
127
|
+
export interface BodyLimits {
|
|
128
|
+
readonly maxBytesInFlight: number
|
|
129
|
+
readonly readTimeoutMs: number
|
|
102
130
|
}
|
|
103
131
|
|
|
132
|
+
/** Defaults of `limits.maxBodyBytesInFlight` (128 MiB) and `limits.bodyReadTimeoutMs` (30 s). */
|
|
133
|
+
export const BODY_LIMIT_DEFAULTS: BodyLimits = { maxBytesInFlight: 128 * 1024 * 1024, readTimeoutMs: 30_000 }
|
|
134
|
+
|
|
104
135
|
/** What a budget counts: HTTP requests on the model routes (refusals by `limits` or budgets left out), tokens, or cost in USD. */
|
|
105
136
|
export type BudgetMetric = "requests" | "tokens" | "costUsd"
|
|
106
137
|
/** The windows a budget resets on: a UTC calendar day, an ISO week (Monday start) or a calendar month (UTC). */
|
|
@@ -131,6 +162,8 @@ export interface Budgets {
|
|
|
131
162
|
|
|
132
163
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
133
164
|
export const DEFAULT_APP = "default"
|
|
165
|
+
/** Server-tool steps per request when `maxServerToolSteps` is not set. */
|
|
166
|
+
export const DEFAULT_SERVER_TOOL_STEPS = 8
|
|
134
167
|
const DEFAULTS = {
|
|
135
168
|
host: "127.0.0.1",
|
|
136
169
|
port: 4010,
|
|
@@ -139,12 +172,12 @@ const DEFAULTS = {
|
|
|
139
172
|
requestTimeoutMs: 600_000,
|
|
140
173
|
} as const
|
|
141
174
|
|
|
142
|
-
const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage"])
|
|
175
|
+
const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "agents", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage", "historyUrl"])
|
|
143
176
|
const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
|
|
144
177
|
const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
|
|
145
178
|
/** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
|
|
146
179
|
const MAX_RETENTION_DAYS = 3650
|
|
147
|
-
const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps"])
|
|
180
|
+
const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps", "maxBodyBytesInFlight", "bodyReadTimeoutMs"])
|
|
148
181
|
const LIMIT_CAP_KNOWN = new Set(["maxConcurrent", "requestsPerMinute"])
|
|
149
182
|
/** Largest cap accepted: far above any provider's limit, and keeps the error messages short. */
|
|
150
183
|
const MAX_LIMIT = 1_000_000
|
|
@@ -217,7 +250,26 @@ function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
|
|
|
217
250
|
return [...merged]
|
|
218
251
|
}
|
|
219
252
|
|
|
220
|
-
const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools"])
|
|
253
|
+
const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools", "serverTools", "maxServerToolSteps"])
|
|
254
|
+
const SERVER_TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/
|
|
255
|
+
const MAX_SERVER_TOOLS = 32
|
|
256
|
+
const MAX_SERVER_TOOL_STEPS = 100
|
|
257
|
+
|
|
258
|
+
/** `apps.<name>.serverTools`: exact OpenCode tool ids. No wildcards, no duplicates, and not the seeds or CodeMode's `execute`. */
|
|
259
|
+
function parseServerTools(value: unknown, at: string): string[] {
|
|
260
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_SERVER_TOOLS)
|
|
261
|
+
throw new Error(`${at}.serverTools must be an array of 1 to ${MAX_SERVER_TOOLS} OpenCode tool ids`)
|
|
262
|
+
const tools: string[] = []
|
|
263
|
+
for (const tool of value) {
|
|
264
|
+
if (typeof tool !== "string" || !SERVER_TOOL_PATTERN.test(tool))
|
|
265
|
+
throw new Error(`${at}.serverTools entry \`${String(tool)}\` must be an exact OpenCode tool id (letters, digits, \`_\`, \`.\`, \`:\`, \`-\`; no wildcards)`)
|
|
266
|
+
if (tool === "execute") throw new Error(`${at}.serverTools cannot name \`execute\`: it is OpenCode's CodeMode tool and would run every tool it wraps`)
|
|
267
|
+
if (tool.startsWith(SEED_PREFIX)) throw new Error(`${at}.serverTools cannot name \`${tool}\`: plugboard's own seed tools are reserved`)
|
|
268
|
+
if (tools.includes(tool)) throw new Error(`${at}.serverTools lists \`${tool}\` twice`)
|
|
269
|
+
tools.push(tool)
|
|
270
|
+
}
|
|
271
|
+
return tools
|
|
272
|
+
}
|
|
221
273
|
const SYSTEM_MODES: ReadonlySet<string> = new Set(["prepend", "append", "replace"])
|
|
222
274
|
const MODEL_IDS: ReadonlySet<string> = new Set(["canonical", "slugs", "both"])
|
|
223
275
|
|
|
@@ -252,6 +304,12 @@ function parseApps(
|
|
|
252
304
|
if (value.systemMode !== undefined && value.system === undefined) throw new Error(`${at}.systemMode needs \`system\``)
|
|
253
305
|
if (value.tools !== undefined && typeof value.tools !== "boolean") throw new Error(`${at}.tools must be a boolean`)
|
|
254
306
|
const maxTools = value.maxTools === undefined ? undefined : positiveInt(value.maxTools, `apps.${app}.maxTools`, 0, context.maxTools)
|
|
307
|
+
if (value.serverTools !== undefined && app === DEFAULT_APP)
|
|
308
|
+
throw new Error(`${at}.serverTools needs a key: the keyless app cannot run OpenCode tools; name the app and give it a key`)
|
|
309
|
+
const serverTools = value.serverTools === undefined ? undefined : parseServerTools(value.serverTools, at)
|
|
310
|
+
if (value.maxServerToolSteps !== undefined && serverTools === undefined) throw new Error(`${at}.maxServerToolSteps needs \`serverTools\``)
|
|
311
|
+
const maxServerToolSteps =
|
|
312
|
+
value.maxServerToolSteps === undefined ? undefined : positiveInt(value.maxServerToolSteps, `apps.${app}.maxServerToolSteps`, DEFAULT_SERVER_TOOL_STEPS, MAX_SERVER_TOOL_STEPS)
|
|
255
313
|
|
|
256
314
|
const policy: AppPolicy = {
|
|
257
315
|
...(value.models === undefined ? {} : { models: value.models as string[] }),
|
|
@@ -259,9 +317,12 @@ function parseApps(
|
|
|
259
317
|
systemMode: (value.systemMode as SystemMode | undefined) ?? "prepend",
|
|
260
318
|
tools: value.tools !== false,
|
|
261
319
|
...(maxTools === undefined ? {} : { maxTools }),
|
|
320
|
+
...(serverTools === undefined ? {} : { serverTools }),
|
|
321
|
+
...(maxServerToolSteps === undefined ? {} : { maxServerToolSteps }),
|
|
262
322
|
}
|
|
263
323
|
// An app that only carries a key has no policy to look up.
|
|
264
|
-
if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined
|
|
324
|
+
if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined || serverTools !== undefined)
|
|
325
|
+
entries.push([app, policy])
|
|
265
326
|
}
|
|
266
327
|
return Object.fromEntries(entries)
|
|
267
328
|
}
|
|
@@ -296,6 +357,18 @@ function parseLimits(raw: unknown, keys: Readonly<Record<string, string>>): Limi
|
|
|
296
357
|
...(raw.maxConcurrent === undefined ? {} : { maxConcurrent: positiveInt(raw.maxConcurrent, "limits.maxConcurrent", 0, MAX_LIMIT) }),
|
|
297
358
|
...(raw.perApp === undefined ? {} : { perApp: parseCaps(raw.perApp, "limits.perApp") }),
|
|
298
359
|
...(appsRaw === undefined ? {} : { apps }),
|
|
360
|
+
...(raw.maxBodyBytesInFlight === undefined
|
|
361
|
+
? {}
|
|
362
|
+
: { maxBodyBytesInFlight: positiveInt(raw.maxBodyBytesInFlight, "limits.maxBodyBytesInFlight", 0, Number.MAX_SAFE_INTEGER) }),
|
|
363
|
+
...(raw.bodyReadTimeoutMs === undefined ? {} : { bodyReadTimeoutMs: positiveInt(raw.bodyReadTimeoutMs, "limits.bodyReadTimeoutMs", 0, MAX_TIMER_MS) }),
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The body limits in force for these options (`limits.maxBodyBytesInFlight`, `limits.bodyReadTimeoutMs`), or the defaults. */
|
|
368
|
+
export function bodyLimits(options: Pick<PlugboardOptions, "limits">): BodyLimits {
|
|
369
|
+
return {
|
|
370
|
+
maxBytesInFlight: options.limits?.maxBodyBytesInFlight ?? BODY_LIMIT_DEFAULTS.maxBytesInFlight,
|
|
371
|
+
readTimeoutMs: options.limits?.bodyReadTimeoutMs ?? BODY_LIMIT_DEFAULTS.readTimeoutMs,
|
|
299
372
|
}
|
|
300
373
|
}
|
|
301
374
|
|
|
@@ -396,6 +469,55 @@ function parseBudgets(raw: unknown, keys: Readonly<Record<string, string>>): Bud
|
|
|
396
469
|
return { ...(perApp === undefined ? {} : { perApp }), ...(raw.apps === undefined ? {} : { apps }) }
|
|
397
470
|
}
|
|
398
471
|
|
|
472
|
+
const AGENT_KNOWN = new Set(["name", "model"])
|
|
473
|
+
/** An OpenCode agent name: letters, digits, `_` and `-`, starting with a letter or digit. */
|
|
474
|
+
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Option `agents`: `{ name, model }` per OpenCode agent to expose. `model` is required and must pass the
|
|
478
|
+
* allowlist, so an agent's own configured model never runs outside `models` (the agent's model setting is
|
|
479
|
+
* not read). Names must be unique.
|
|
480
|
+
*/
|
|
481
|
+
function parseAgents(raw: unknown, models: ReadonlyArray<string>): Record<string, string> {
|
|
482
|
+
if (raw === undefined) return {}
|
|
483
|
+
if (!Array.isArray(raw)) throw new Error("plugboard option `agents` must be an array of { name, model } objects")
|
|
484
|
+
const entries: Array<[string, string]> = []
|
|
485
|
+
for (const item of raw) {
|
|
486
|
+
if (!isRecord(item)) throw new Error("plugboard option `agents` entries must be objects of the form { name, model }")
|
|
487
|
+
for (const key of Object.keys(item))
|
|
488
|
+
if (!AGENT_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`agents.${key}\` (known: ${[...AGENT_KNOWN].join(", ")})`)
|
|
489
|
+
const { name, model } = item
|
|
490
|
+
if (typeof name !== "string" || !AGENT_NAME.test(name)) throw new Error(`plugboard agent name \`${String(name)}\` must match ${AGENT_NAME} (an OpenCode agent name)`)
|
|
491
|
+
if (entries.some(([known]) => known === name)) throw new Error(`plugboard agent \`${name}\` is listed more than once in \`agents\``)
|
|
492
|
+
if (typeof model !== "string" || !ALIAS_TARGET.test(model))
|
|
493
|
+
throw new Error(`plugboard agent \`${name}\` must set \`model\` to \`provider/model\` or \`provider/model#variant\``)
|
|
494
|
+
if (!allowedBy(models as string[], parseModelRef(model)!))
|
|
495
|
+
throw new Error(`plugboard agent \`${name}\` model \`${model}\` is not allowed by the \`models\` option`)
|
|
496
|
+
entries.push([name, model])
|
|
497
|
+
}
|
|
498
|
+
return Object.fromEntries(entries)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Option `historyUrl` (#77): an http(s) URL template that must contain `{id}`. Its only other placeholder is
|
|
503
|
+
* `{directory}`, so a typo such as `{sesion}` fails at load instead of producing a link that is wrong.
|
|
504
|
+
*/
|
|
505
|
+
function parseHistoryUrl(value: unknown): string | undefined {
|
|
506
|
+
if (value === undefined) return undefined
|
|
507
|
+
const at = "plugboard option `historyUrl`"
|
|
508
|
+
if (typeof value !== "string" || !value.includes("{id}")) throw new Error(`${at} must be a URL template with {id} (for example http://host:4096/session/{id})`)
|
|
509
|
+
const unknown = (value.match(/\{[^{}]*\}/g) ?? []).find((placeholder) => placeholder !== "{id}" && placeholder !== "{directory}")
|
|
510
|
+
if (unknown !== undefined) throw new Error(`${at} has the unknown placeholder ${unknown} (known: {id}, {directory})`)
|
|
511
|
+
let url: URL
|
|
512
|
+
try {
|
|
513
|
+
url = new URL(value.replaceAll("{id}", "x").replaceAll("{directory}", "x"))
|
|
514
|
+
} catch {
|
|
515
|
+
throw new Error(`${at} must be an http(s) URL template`)
|
|
516
|
+
}
|
|
517
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${at} must be http or https`)
|
|
518
|
+
return value
|
|
519
|
+
}
|
|
520
|
+
|
|
399
521
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
400
522
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
401
523
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -452,6 +574,14 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
452
574
|
const chain = Array.isArray(target) ? target : [target]
|
|
453
575
|
if (chain.length === 0) throw new Error(`plugboard alias \`${alias}\` must list at least one target`)
|
|
454
576
|
for (const entry of chain) {
|
|
577
|
+
// A skill target binds one model and takes no fallbacks; like a list entry it must pass the allowlist at load.
|
|
578
|
+
if (typeof entry === "string" && entry.startsWith(SKILL_PREFIX)) {
|
|
579
|
+
const parsed = parseAliasTarget(entry)
|
|
580
|
+
if (parsed?.skill === undefined) throw new Error(`plugboard alias \`${alias}\` skill target \`${entry}\` must be \`skill:<id>@provider/model\` or \`skill:<id>@provider/model#variant\``)
|
|
581
|
+
if (Array.isArray(target)) throw new Error(`plugboard alias \`${alias}\` binds a skill, so it takes one target and no list of fallbacks`)
|
|
582
|
+
if (!allowedBy(models as string[], parsed.ref)) throw new Error(`plugboard alias \`${alias}\` target \`${entry}\` is not allowed by the \`models\` option`)
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
455
585
|
if (typeof entry !== "string" || !ALIAS_TARGET.test(entry))
|
|
456
586
|
throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\` (or a list of them)`)
|
|
457
587
|
// A list is new, so it is strict: a target the allowlist would refuse can never serve, so fail at load.
|
|
@@ -465,6 +595,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
465
595
|
}
|
|
466
596
|
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
467
597
|
const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
|
|
598
|
+
const agents = parseAgents(input.agents, models as string[])
|
|
468
599
|
|
|
469
600
|
const maxTools = positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024)
|
|
470
601
|
const apps = parseApps(input.apps, { keys, aliases, maxTools })
|
|
@@ -493,6 +624,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
493
624
|
if (budgets !== undefined && !usage.enabled) throw new Error("plugboard option `budgets` needs `usage` enabled: budgets are counted from the usage records")
|
|
494
625
|
if (budgets !== undefined && usage.retentionDays < widestWindowDays(budgets))
|
|
495
626
|
throw new Error(`plugboard option \`usage.retentionDays\` must be at least ${widestWindowDays(budgets)} for the budget windows in use (a month is 31 days, a week 7): older days are deleted and would stop counting`)
|
|
627
|
+
const historyUrl = parseHistoryUrl(input.historyUrl)
|
|
496
628
|
|
|
497
629
|
return {
|
|
498
630
|
listen: { host, port: port ?? DEFAULTS.port },
|
|
@@ -500,6 +632,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
500
632
|
apps,
|
|
501
633
|
models: models as string[],
|
|
502
634
|
aliases,
|
|
635
|
+
agents,
|
|
503
636
|
fallbacks,
|
|
504
637
|
maxTools,
|
|
505
638
|
maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
|
|
@@ -515,6 +648,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
515
648
|
usage,
|
|
516
649
|
...(budgets === undefined ? {} : { budgets }),
|
|
517
650
|
...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
|
|
651
|
+
...(historyUrl === undefined ? {} : { historyUrl }),
|
|
518
652
|
}
|
|
519
653
|
}
|
|
520
654
|
|
|
@@ -543,24 +677,32 @@ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readon
|
|
|
543
677
|
/** Who may read `GET /v1/usage`: the admin key (every app), or one app (`default` when keyless). */
|
|
544
678
|
export type UsageScope = { readonly admin: true } | { readonly admin: false; readonly app: string }
|
|
545
679
|
|
|
680
|
+
/** The digest of a runtime key (src/keys.ts), with its app: checked like a config key (#55). */
|
|
681
|
+
export type RuntimeDigest = readonly [app: string, digest: Buffer]
|
|
682
|
+
|
|
546
683
|
/** The admin key is checked first; any other valid key (or keyless mode) is an app, as `authenticate` decides. */
|
|
547
|
-
export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined): UsageScope | PlugError {
|
|
684
|
+
export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): UsageScope | PlugError {
|
|
548
685
|
const bearer = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
|
|
549
686
|
if (options.adminKey !== undefined && bearer !== undefined && timingSafeEqual(sha256(bearer), sha256(options.adminKey))) return { admin: true }
|
|
550
|
-
const auth = authenticate(options, authorization)
|
|
687
|
+
const auth = authenticate(options, authorization, runtime)
|
|
551
688
|
if (isPlugError(auth)) return auth
|
|
552
689
|
return { admin: false, app: auth.app }
|
|
553
690
|
}
|
|
554
691
|
|
|
555
|
-
/**
|
|
556
|
-
|
|
692
|
+
/**
|
|
693
|
+
* No config keys -> app `default` (keyless), as before. With config keys every request needs
|
|
694
|
+
* `Authorization: Bearer <key>`. A runtime key (#55) matches like a config key, so it names its app
|
|
695
|
+
* whenever it is presented. It never makes a keyless server require a key, so deleting the last
|
|
696
|
+
* runtime key cannot open or close access by itself.
|
|
697
|
+
*/
|
|
698
|
+
export function authenticate(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): { app: string } | PlugError {
|
|
557
699
|
const entries = digestsOf(options.keys)
|
|
558
|
-
if (entries.length === 0) return { app: DEFAULT_APP }
|
|
559
700
|
const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
|
|
560
|
-
if (
|
|
701
|
+
if (token === undefined) return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
|
|
561
702
|
const digest = sha256(token)
|
|
562
703
|
let app: string | undefined
|
|
563
704
|
// Every key is compared, no early exit.
|
|
564
|
-
for (const [name, expected] of entries) if (timingSafeEqual(digest, expected)) app = name
|
|
565
|
-
|
|
705
|
+
for (const [name, expected] of [...entries, ...runtime]) if (timingSafeEqual(digest, expected)) app = name
|
|
706
|
+
if (app !== undefined) return { app }
|
|
707
|
+
return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
|
|
566
708
|
}
|
package/src/contract.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface PlugRequest {
|
|
|
114
114
|
readonly tools: ReadonlyArray<PlugTool>
|
|
115
115
|
readonly toolChoice?: PlugToolChoice
|
|
116
116
|
readonly generation: PlugGeneration
|
|
117
|
+
/** The end user the caller named, when kept (src/user.ts, #57). Labels the session title and the usage counts. */
|
|
118
|
+
readonly user?: string
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
// ---------------------------------------------------------------- events
|
|
@@ -172,7 +174,7 @@ export interface CallContext {
|
|
|
172
174
|
/** Aborted on client disconnect or timeout. */
|
|
173
175
|
readonly signal: AbortSignal
|
|
174
176
|
/** Called once, before the first event, with the id of the history record this call writes (if any). */
|
|
175
|
-
readonly onRecord?: (recordID: string) => void
|
|
177
|
+
readonly onRecord?: (recordID: string, directory?: string) => void
|
|
176
178
|
/**
|
|
177
179
|
* Called once, before the first event, only for a request whose model is an alias with fallbacks
|
|
178
180
|
* (`src/fallback.ts`): which target answered and how many earlier targets failed first. A front-end
|
|
@@ -192,6 +194,12 @@ export interface CallContext {
|
|
|
192
194
|
* headers are open, so a stream (headers sent first) never gets it.
|
|
193
195
|
*/
|
|
194
196
|
readonly onUsage?: (totals: CallTotals) => void
|
|
197
|
+
/**
|
|
198
|
+
* Called once when the call ends, only if OpenCode ran server tools for it (option `apps.<name>.serverTools`),
|
|
199
|
+
* with how many. Front-ends set `x-plugboard-server-tools` from it while the headers are open, so a stream
|
|
200
|
+
* (headers sent first) never gets it, and a choice of `n` adds to the count of the others.
|
|
201
|
+
*/
|
|
202
|
+
readonly onServerTools?: (count: number) => void
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
/** One answered call's totals for the caller: `inputTokens` and `outputTokens` as the response body reports them (cache reads inside input, reasoning inside output), and `costUsd` when the model has a price. */
|
package/src/discovery.ts
CHANGED
|
@@ -67,6 +67,7 @@ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
|
67
67
|
{ method: "GET", path: "/", auth: false, description: "This page." },
|
|
68
68
|
{ method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
|
|
69
69
|
{ method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
|
|
70
|
+
{ method: "GET", path: "/readyz", auth: false, description: "Readiness: {\"ready\":true}, or 503 {\"ready\":false,\"reason\"} until the backend can serve." },
|
|
70
71
|
{ method: "GET", path: "/v1/models", auth: true, description: "Models this app may call (OpenAI shape)." },
|
|
71
72
|
{ method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
|
|
72
73
|
{ method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
|
|
@@ -92,6 +93,14 @@ function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
|
|
|
92
93
|
]
|
|
93
94
|
: []),
|
|
94
95
|
...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
|
|
96
|
+
// The admin key only (option `adminKey`); without it these paths are a 404 (src/admin/route.ts).
|
|
97
|
+
...(options.adminKey === undefined
|
|
98
|
+
? []
|
|
99
|
+
: [
|
|
100
|
+
{ method: "GET", path: "/v1/admin/keys", auth: true, description: "Admin: the config and runtime app keys, without key values." },
|
|
101
|
+
{ method: "POST", path: "/v1/admin/keys", auth: true, description: "Admin: a new app key for `{ app }`, returned once." },
|
|
102
|
+
{ method: "DELETE", path: "/v1/admin/keys/{keyId}", auth: true, description: "Admin: revoke a runtime app key." },
|
|
103
|
+
]),
|
|
95
104
|
]
|
|
96
105
|
}
|
|
97
106
|
|
|
@@ -276,6 +285,29 @@ ${sections.join("\n")}
|
|
|
276
285
|
`
|
|
277
286
|
}
|
|
278
287
|
|
|
288
|
+
/** The facts `/readyz` decides from (`RuntimeStatus` in src/runtime.ts). */
|
|
289
|
+
export interface ReadinessFacts {
|
|
290
|
+
readonly generations: number
|
|
291
|
+
readonly servingConfirmed: boolean
|
|
292
|
+
readonly pumpRunning: boolean
|
|
293
|
+
readonly catalogLoaded: boolean
|
|
294
|
+
readonly configError?: { readonly message: string }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `GET /readyz` (#160): whether the backend can serve a request. The reason names the first piece
|
|
299
|
+
* that is missing, in the order a person fixes them. A recorded options error comes first and is
|
|
300
|
+
* its own message (#159); nothing else from the config is shown.
|
|
301
|
+
*/
|
|
302
|
+
export function readiness(facts: ReadinessFacts): { ready: true } | { ready: false; reason: string } {
|
|
303
|
+
if (facts.configError) return { ready: false, reason: facts.configError.message }
|
|
304
|
+
if (facts.generations === 0) return { ready: false, reason: "no plugboard generation is live; the server is reloading or the plugin is not loaded" }
|
|
305
|
+
if (!facts.servingConfirmed) return { ready: false, reason: "no app directory has been used since start; one model request to an app confirms it" }
|
|
306
|
+
if (!facts.pumpRunning) return { ready: false, reason: "the event pump is not running; the next request to an app starts it" }
|
|
307
|
+
if (!facts.catalogLoaded) return { ready: false, reason: "the model catalog has not loaded yet" }
|
|
308
|
+
return { ready: true }
|
|
309
|
+
}
|
|
310
|
+
|
|
279
311
|
/** `GET /v1/status`: authenticated runtime facts. `models` is absent when the catalog could not be loaded. */
|
|
280
312
|
export function buildStatus(input: {
|
|
281
313
|
readonly options: PlugboardOptions
|
|
@@ -284,6 +316,7 @@ export function buildStatus(input: {
|
|
|
284
316
|
readonly runtimeVersion: number
|
|
285
317
|
readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
|
|
286
318
|
readonly servingConfirmed: boolean
|
|
319
|
+
readonly configError?: { readonly message: string; readonly at: number }
|
|
287
320
|
}
|
|
288
321
|
readonly catalog?: ReadonlyArray<CatalogModel>
|
|
289
322
|
readonly now?: number
|
|
@@ -303,5 +336,7 @@ export function buildStatus(input: {
|
|
|
303
336
|
sessionGuards: runtime.guards,
|
|
304
337
|
models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog, app).length } : { error: "catalog_unavailable" },
|
|
305
338
|
metrics: options.metrics,
|
|
339
|
+
// The plugin options error the last Location to load the plugin hit (#159); absent when options parse.
|
|
340
|
+
...(runtime.configError ? { configError: { message: runtime.configError.message, at: new Date(runtime.configError.at).toISOString() } } : {}),
|
|
306
341
|
}
|
|
307
342
|
}
|
package/src/fallback.ts
CHANGED
|
@@ -51,6 +51,8 @@ export const FALLBACKS_HEADER = "x-plugboard-fallbacks"
|
|
|
51
51
|
export interface ServedInfo {
|
|
52
52
|
served?: string
|
|
53
53
|
fallbacks?: number
|
|
54
|
+
/** The session page link of the request (option `historyUrl`, src/session/link.ts), for the log line. */
|
|
55
|
+
sessionUrl?: string
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -90,9 +92,9 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
|
|
|
90
92
|
let attempt = 0
|
|
91
93
|
for (;;) {
|
|
92
94
|
// The history id of this attempt: handed on only if the attempt is the one that answers (or the last).
|
|
93
|
-
let
|
|
95
|
+
let recorded: { id: string; directory?: string } | undefined
|
|
94
96
|
const next = attempt === 0 ? request : retarget(deps.options, request, ref)
|
|
95
|
-
const iterator = inner.stream(next, { ...context, onRecord: (id) => void (
|
|
97
|
+
const iterator = inner.stream(next, { ...context, onRecord: (id, directory) => void (recorded = { id, directory }) })[Symbol.asyncIterator]()
|
|
96
98
|
|
|
97
99
|
let first: IteratorResult<PlugEvent> | undefined
|
|
98
100
|
let thrown: { readonly error: unknown } | undefined
|
|
@@ -124,7 +126,7 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
// Answered, ran out of targets, or the request ended: this attempt's outcome is the response.
|
|
127
|
-
if (
|
|
129
|
+
if (recorded !== undefined) report(context, recorded)
|
|
128
130
|
if (thrown) throw thrown.error
|
|
129
131
|
if (first!.done) return
|
|
130
132
|
// An error is not an answer: a call that fails (non-retryable, or on its last target) reports no served model.
|
|
@@ -153,9 +155,9 @@ async function resolveRest(deps: FallbackDeps, request: PlugRequest, app: string
|
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
const report = (context: CallContext,
|
|
158
|
+
const report = (context: CallContext, recorded: { readonly id: string; readonly directory?: string }): void => {
|
|
157
159
|
try {
|
|
158
|
-
context.onRecord?.(
|
|
160
|
+
context.onRecord?.(recorded.id, recorded.directory)
|
|
159
161
|
} catch {}
|
|
160
162
|
}
|
|
161
163
|
|
package/src/gemini/request.ts
CHANGED
|
@@ -351,7 +351,7 @@ const parseGeneration = (config: JsonObject): PlugGeneration | PlugError => {
|
|
|
351
351
|
}
|
|
352
352
|
|
|
353
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 => {
|
|
354
|
+
const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; schema?: JsonObject; wrap?: boolean; instruction?: string } | PlugError => {
|
|
355
355
|
const mime = field(config, "responseMimeType")
|
|
356
356
|
const schema = field(config, "responseJsonSchema") ?? field(config, "responseSchema")
|
|
357
357
|
if (mime != null && typeof mime !== "string") return bad("responseMimeType must be a string", "generationConfig.responseMimeType")
|
|
@@ -364,6 +364,7 @@ const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; wrap?
|
|
|
364
364
|
const wrap = converted.type !== undefined && converted.type !== "object"
|
|
365
365
|
return {
|
|
366
366
|
wrap,
|
|
367
|
+
schema: converted,
|
|
367
368
|
structured: {
|
|
368
369
|
name: RESPOND_TOOL,
|
|
369
370
|
description: "Give your final answer by calling this tool with JSON matching its schema.",
|
|
@@ -437,7 +438,7 @@ export function parseGeminiRequest(model: string, stream: boolean, body: unknown
|
|
|
437
438
|
exchange: {
|
|
438
439
|
model,
|
|
439
440
|
stream,
|
|
440
|
-
...(respond ? { structuredTool: RESPOND_TOOL, ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
441
|
+
...(respond ? { structuredTool: RESPOND_TOOL, ...(format.schema ? { structuredSchema: format.schema } : {}), ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
441
442
|
...(format.instruction ? { jsonObject: true } : {}),
|
|
442
443
|
...(generation.stop ? { stop: generation.stop } : {}),
|
|
443
444
|
},
|
package/src/gemini/response.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
10
10
|
import type { ListedModel } from "../models.js"
|
|
11
|
-
import { unfence } from "../openai/response.js"
|
|
11
|
+
import { structuredMissing, textFallback, unfence } from "../openai/response.js"
|
|
12
12
|
import { StopHoldback } from "../stop.js"
|
|
13
13
|
import { WRAP_KEY } from "./request.js"
|
|
14
14
|
|
|
@@ -33,6 +33,8 @@ export interface GeminiExchange {
|
|
|
33
33
|
readonly jsonObject?: boolean
|
|
34
34
|
/** The request's `generationConfig.stopSequences`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). Not applied to structured output. */
|
|
35
35
|
readonly stop?: ReadonlyArray<string>
|
|
36
|
+
/** The caller's schema as sent (converted): a text answer is taken when the model skipped the call (`textFallback`, #173). */
|
|
37
|
+
readonly structuredSchema?: Readonly<Record<string, unknown>>
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
|
|
@@ -42,6 +44,8 @@ export interface GenerateRun {
|
|
|
42
44
|
readonly started: boolean
|
|
43
45
|
/** The aggregated response (non-streaming body). Absent on failure. */
|
|
44
46
|
readonly response?: object
|
|
47
|
+
/** The structured answer came from the final text, not the respond call (`textFallback`). */
|
|
48
|
+
readonly structuredFallback?: boolean
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
type Part = Record<string, unknown>
|
|
@@ -121,20 +125,6 @@ const structuredText = (exchange: GeminiExchange, call: ToolCall): string => {
|
|
|
121
125
|
return typeof input === "string" && !exchange.wrapped ? input : JSON.stringify(input ?? {})
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
/**
|
|
125
|
-
* Structured-output projection, as in the other front-ends: the text is exactly the first
|
|
126
|
-
* `respond` call; prose and other calls are suppressed. Without an answer the exchange fails,
|
|
127
|
-
* except when the model stopped for `length` or `content-filter`.
|
|
128
|
-
*/
|
|
129
|
-
const structuredFailure = (exchange: GeminiExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
|
|
130
|
-
exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
|
|
131
|
-
? undefined
|
|
132
|
-
: {
|
|
133
|
-
status: 502,
|
|
134
|
-
kind: "server",
|
|
135
|
-
code: "structured_output_missing",
|
|
136
|
-
message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
|
|
137
|
-
}
|
|
138
128
|
|
|
139
129
|
export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: GeminiExchange, emit: (chunk: object) => void): Promise<GenerateRun> {
|
|
140
130
|
const responseId = crypto.randomUUID().replaceAll("-", "").slice(0, 22)
|
|
@@ -145,11 +135,15 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
145
135
|
responseId,
|
|
146
136
|
})
|
|
147
137
|
|
|
138
|
+
// Only a non-streamed body needs the aggregated parts: a stream is already on the wire, so nothing grows (#153).
|
|
139
|
+
const aggregate = !exchange.stream
|
|
148
140
|
/** The aggregated parts: adjacent text merges into one part. */
|
|
149
141
|
const parts: Part[] = []
|
|
150
142
|
let started = false
|
|
151
143
|
let calls = 0
|
|
152
144
|
let answered = false
|
|
145
|
+
/** The text of a structured turn (not emitted): it is the answer only if the respond call never comes (#173). */
|
|
146
|
+
let collected = ""
|
|
153
147
|
/** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
|
|
154
148
|
let held: ToolCall | undefined
|
|
155
149
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
@@ -158,9 +152,11 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
158
152
|
|
|
159
153
|
const push = (part: Part) => {
|
|
160
154
|
started = true
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
155
|
+
if (aggregate) {
|
|
156
|
+
const last = parts.at(-1)
|
|
157
|
+
if (typeof part.text === "string" && last && typeof last.text === "string") last.text += part.text
|
|
158
|
+
else parts.push(part)
|
|
159
|
+
}
|
|
164
160
|
emit(envelope([part]))
|
|
165
161
|
}
|
|
166
162
|
// A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
|
|
@@ -212,6 +208,7 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
212
208
|
}
|
|
213
209
|
continue
|
|
214
210
|
}
|
|
211
|
+
if (event.type === "text-delta") collected += event.text
|
|
215
212
|
if (!(exchange.callerTools && event.type === "tool-call")) continue
|
|
216
213
|
}
|
|
217
214
|
if (exchange.jsonObject && event.type === "text-delta" && !passing) {
|
|
@@ -229,7 +226,9 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
229
226
|
}
|
|
230
227
|
|
|
231
228
|
const passthrough = exchange.callerTools === true && calls > 0
|
|
232
|
-
|
|
229
|
+
const fallback = exchange.structuredTool !== undefined && !passthrough && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
|
|
230
|
+
const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing(exchange.structuredTool)
|
|
231
|
+
failure ??= finish ? missing : noFinish()
|
|
233
232
|
if (failure) return { failure, started }
|
|
234
233
|
flush()
|
|
235
234
|
if (holdback) {
|
|
@@ -237,12 +236,13 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
237
236
|
if (rest.length > 0) push({ text: rest })
|
|
238
237
|
}
|
|
239
238
|
if (held && !passthrough) text(structuredText(exchange, held))
|
|
239
|
+
if (fallback !== undefined) text(fallback)
|
|
240
240
|
const candidate = { finishReason: FINISH_REASONS[finish!] }
|
|
241
241
|
const extra = usage ? { usageMetadata: encodeUsage(usage) } : {}
|
|
242
242
|
// The last chunk carries the finish reason and usage, with an empty text part as Google's does.
|
|
243
243
|
emit(envelope([{ text: "" }], extra, candidate))
|
|
244
244
|
started = true
|
|
245
|
-
return { started, response: envelope(parts.length > 0 ? parts : [{ text: "" }], extra, candidate) }
|
|
245
|
+
return { started, response: envelope(parts.length > 0 ? parts : [{ text: "" }], extra, candidate), ...(fallback !== undefined ? { structuredFallback: true } : {}) }
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/** SSE frame of one stream chunk (`alt=sse`): `data: <json>`, no event name and no `[DONE]`. */
|
package/src/gemini/route.ts
CHANGED
|
@@ -17,12 +17,15 @@
|
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
18
18
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
19
19
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
20
|
+
import { bodyLimits } from "../config.js"
|
|
20
21
|
import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
21
22
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
22
23
|
import { reportCache } from "../cache.js"
|
|
23
|
-
import { reportPolicy } from "../policy.js"
|
|
24
|
+
import { reportPolicy, reportServerTools } from "../policy.js"
|
|
24
25
|
import { reportUsage } from "../usage-header.js"
|
|
26
|
+
import { setSessionLink } from "../session/link.js"
|
|
25
27
|
import type { RouteHost } from "../openai/route.js"
|
|
28
|
+
import { STRUCTURED_FALLBACK_HEADER } from "../openai/response.js"
|
|
26
29
|
import { dropRejected } from "../openai/sampling.js"
|
|
27
30
|
import { estimateTokens } from "../anthropic/tokens.js"
|
|
28
31
|
import { parseGeminiRequest } from "./request.js"
|
|
@@ -111,7 +114,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
111
114
|
if (match.op === "count") {
|
|
112
115
|
const unsupported = checkJsonContentType(request)
|
|
113
116
|
if (unsupported) return fail(unsupported)
|
|
114
|
-
const parsed = parseGeminiRequest(match.model, false, countBody(await readJsonBody(request, scope.signal)), host.options)
|
|
117
|
+
const parsed = parseGeminiRequest(match.model, false, countBody(await readJsonBody(request, scope.signal, bodyLimits(host.options))), host.options)
|
|
115
118
|
if (isPlugError(parsed)) return fail(parsed)
|
|
116
119
|
const catalog = await scope.race(host.loadCatalog())
|
|
117
120
|
const ref = resolveModel(host.options, match.model, catalog, parsed.reasoningEffort)
|
|
@@ -134,7 +137,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
134
137
|
return fail({ status: 400, kind: "invalid_request", code: "unsupported_parameter", param: "alt", message: "streamGenerateContent needs the query parameter alt=sse" })
|
|
135
138
|
const unsupported = checkJsonContentType(request)
|
|
136
139
|
if (unsupported) return fail(unsupported)
|
|
137
|
-
const body = await readJsonBody(request, scope.signal)
|
|
140
|
+
const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
|
|
138
141
|
|
|
139
142
|
const parsed = parseGeminiRequest(match.model, match.stream, body, host.options)
|
|
140
143
|
if (isPlugError(parsed)) return fail(parsed)
|
|
@@ -157,13 +160,15 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
157
160
|
host.caller.stream(call, {
|
|
158
161
|
app,
|
|
159
162
|
signal: scope.signal,
|
|
160
|
-
onRecord: (recordID) => {
|
|
163
|
+
onRecord: (recordID, directory) => {
|
|
161
164
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
162
165
|
response.setHeader(SESSION_HEADER, recordID)
|
|
166
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
163
167
|
},
|
|
164
168
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
165
169
|
onPolicy: reportPolicy(response),
|
|
166
170
|
onUsage: reportUsage(response, match.stream),
|
|
171
|
+
onServerTools: reportServerTools(response, match.stream),
|
|
167
172
|
onCache: reportCache(response),
|
|
168
173
|
}),
|
|
169
174
|
scope,
|
|
@@ -187,5 +192,6 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
187
192
|
|
|
188
193
|
const run = await runGenerate(events, exchange, () => {})
|
|
189
194
|
if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
195
|
+
if (run.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
|
|
190
196
|
sendJson(response, 200, run.response)
|
|
191
197
|
}
|