opencode-plugboard 0.20.0 → 0.21.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 +35 -31
- package/dist/tui.js +334 -0
- package/index.ts +30 -23
- package/package.json +10 -4
- package/src/anthropic/response.ts +5 -3
- package/src/anthropic/route.ts +6 -4
- package/src/cache.ts +16 -5
- package/src/completions/request.ts +13 -4
- package/src/completions/response.ts +52 -10
- package/src/config.ts +69 -3
- package/src/contract.ts +6 -0
- package/src/discovery.ts +27 -0
- package/src/gemini/request.ts +3 -2
- package/src/gemini/response.ts +20 -20
- package/src/gemini/route.ts +7 -3
- package/src/http.ts +65 -7
- package/src/openai/fanout.ts +19 -5
- package/src/openai/request.ts +4 -4
- package/src/openai/response.ts +108 -19
- package/src/openai/route.ts +21 -11
- package/src/policy.ts +16 -0
- package/src/responses/request.ts +1 -1
- package/src/responses/response.ts +13 -17
- package/src/responses/route.ts +4 -2
- package/src/runtime.ts +51 -7
- package/src/session/caller.ts +69 -7
- package/src/session/context.ts +16 -4
- package/src/session/events.ts +116 -12
- package/src/session/host.ts +14 -0
- package/src/session/seeds.ts +10 -0
- package/tui.ts +5 -4
package/src/anthropic/route.ts
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
15
|
-
import { authenticate } from "../config.js"
|
|
15
|
+
import { authenticate, bodyLimits } from "../config.js"
|
|
16
16
|
import { liveOptions } from "../runtime.js"
|
|
17
17
|
import { bodyUserId, noteEndUser } from "../user.js"
|
|
18
18
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
19
19
|
import {
|
|
20
20
|
BodyError,
|
|
21
|
+
bodyPlugError,
|
|
21
22
|
RequestAborted,
|
|
22
23
|
boundEvents,
|
|
23
24
|
checkJsonContentType,
|
|
@@ -31,7 +32,7 @@ import {
|
|
|
31
32
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
32
33
|
import { resolveModel } from "../models.js"
|
|
33
34
|
import { reportCache } from "../cache.js"
|
|
34
|
-
import { reportPolicy } from "../policy.js"
|
|
35
|
+
import { reportPolicy, reportServerTools } from "../policy.js"
|
|
35
36
|
import { reportUsage } from "../usage-header.js"
|
|
36
37
|
import { setSessionLink } from "../session/link.js"
|
|
37
38
|
import { dropRejected } from "../openai/sampling.js"
|
|
@@ -85,7 +86,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
85
86
|
// Answer in this route's error shape, then rethrow: the shared handler logs the outcome and
|
|
86
87
|
// finds the response already sent.
|
|
87
88
|
if (!response.headersSent) {
|
|
88
|
-
if (caught instanceof BodyError) fail(
|
|
89
|
+
if (caught instanceof BodyError) fail(bodyPlugError(caught), caught.status !== 400)
|
|
89
90
|
else if (caught instanceof RequestAborted) {
|
|
90
91
|
if (caught.kind === "timeout") fail(scope.timeoutError, true)
|
|
91
92
|
} else fail({ status: 500, kind: "server", message: "Internal server error" })
|
|
@@ -105,7 +106,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
105
106
|
const unsupported = checkJsonContentType(request)
|
|
106
107
|
if (unsupported) return fail(unsupported)
|
|
107
108
|
|
|
108
|
-
const body = await readJsonBody(request, scope.signal)
|
|
109
|
+
const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
|
|
109
110
|
noteEndUser(response, info, bodyUserId(body))
|
|
110
111
|
// count_tokens takes the message body without max_tokens; the parser needs one, so a placeholder stands in
|
|
111
112
|
// (the count never uses it). A max_tokens the client did send is still validated.
|
|
@@ -142,6 +143,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
142
143
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
143
144
|
onPolicy: reportPolicy(response),
|
|
144
145
|
onUsage: reportUsage(response, exchange.stream),
|
|
146
|
+
onServerTools: reportServerTools(response, exchange.stream),
|
|
145
147
|
onCache: reportCache(response),
|
|
146
148
|
}),
|
|
147
149
|
scope,
|
package/src/cache.ts
CHANGED
|
@@ -118,18 +118,22 @@ function append(events: PlugEvent[], event: { readonly type: "text-delta" | "rea
|
|
|
118
118
|
else events.push({ type: event.type, text: event.text })
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/**
|
|
121
|
+
/**
|
|
122
|
+
* Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. `bypass(model, app)`
|
|
123
|
+
* decides per call that the cache is not used: a persona model, or an app whose answers depend on live OpenCode
|
|
124
|
+
* tool output (`serverTools`, #82). Both conditions live in the one predicate the runtime passes.
|
|
125
|
+
*/
|
|
122
126
|
export function cachedCaller(
|
|
123
127
|
inner: ModelCaller,
|
|
124
128
|
config: CacheConfig,
|
|
125
129
|
store: CacheStore,
|
|
126
130
|
count?: CacheCounter,
|
|
127
|
-
bypass?: (model: string) => boolean,
|
|
131
|
+
bypass?: (model: string, app: string) => boolean,
|
|
128
132
|
): ModelCaller {
|
|
129
133
|
const apps = config.apps ? new Set(config.apps) : undefined
|
|
130
134
|
return {
|
|
131
135
|
async *stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
|
|
132
|
-
if (context.noCache || bypass?.(request.model) || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
|
|
136
|
+
if (context.noCache || bypass?.(request.model, context.app) || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
|
|
133
137
|
const key = cacheKey(context.app, request)
|
|
134
138
|
const hit = store.get(key)
|
|
135
139
|
if (hit) {
|
|
@@ -161,9 +165,16 @@ export function cachedCaller(
|
|
|
161
165
|
}
|
|
162
166
|
const recorded: PlugEvent[] = []
|
|
163
167
|
let storable = true
|
|
168
|
+
// Text bytes seen so far. An answer over `maxBytes` could never be stored, so capture stops there and frees what it kept (#153).
|
|
169
|
+
let captured = 0
|
|
164
170
|
for await (const event of inner.stream(request, forward)) {
|
|
165
|
-
if (event.type === "text-delta" || event.type === "reasoning-delta")
|
|
166
|
-
|
|
171
|
+
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
|
172
|
+
captured += Buffer.byteLength(event.text)
|
|
173
|
+
if (captured > config.maxBytes) {
|
|
174
|
+
storable = false
|
|
175
|
+
recorded.length = 0
|
|
176
|
+
} else if (storable) append(recorded, event)
|
|
177
|
+
} else if (event.type === "finish") {
|
|
167
178
|
// Stored before the event is handed on, so a consumer that stops reading at `finish` still fills the cache.
|
|
168
179
|
if (storable && CACHEABLE_FINISH.has(event.reason) && recorded.length > 0 && !context.signal.aborted)
|
|
169
180
|
store.set(key, { events: [...recorded, { type: "finish", reason: event.reason }], ...(served ? { served } : {}), ...(policy?.length ? { policy } : {}) })
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* `ref`.
|
|
10
10
|
*/
|
|
11
11
|
import type { PlugboardOptions } from "../config.js"
|
|
12
|
-
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
12
|
+
import { isPlugError, type PlugError, type PlugRequest, type PlugToolChoice } from "../contract.js"
|
|
13
13
|
import { bodyUser, userField } from "../user.js"
|
|
14
|
-
import { parseChoiceCount, parseGeneration } from "../openai/request.js"
|
|
14
|
+
import { RESPOND_TOOL, parseChoiceCount, parseGeneration, parseResponseFormat } from "../openai/request.js"
|
|
15
15
|
import type { CompletionsExchange } from "./response.js"
|
|
16
16
|
|
|
17
17
|
export interface ParsedCompletions {
|
|
@@ -80,16 +80,23 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
|
|
|
80
80
|
)
|
|
81
81
|
const generation = parseGeneration(body)
|
|
82
82
|
if (isPlugError(generation)) return generation
|
|
83
|
+
// The same response_format as Chat Completions: json_schema is the forced `respond` tool, json_object an instruction.
|
|
84
|
+
const format = parseResponseFormat(body.response_format)
|
|
85
|
+
if (isPlugError(format)) return format
|
|
83
86
|
|
|
84
87
|
const suffix = typeof body.suffix === "string" && body.suffix.length > 0 ? body.suffix : undefined
|
|
88
|
+
const system = [...(suffix ? [SUFFIX_INSTRUCTION(suffix)] : []), ...(format.instruction ? [format.instruction] : [])]
|
|
89
|
+
const tools = format.structured ? [format.structured] : []
|
|
90
|
+
const toolChoice: PlugToolChoice | undefined = format.structured ? { type: "tool", name: RESPOND_TOOL } : undefined
|
|
85
91
|
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {}
|
|
86
92
|
return {
|
|
87
93
|
requests: prompts.map((prompt) => ({
|
|
88
94
|
model: body.model as string,
|
|
89
95
|
...userField(bodyUser(body)),
|
|
90
|
-
system
|
|
96
|
+
system,
|
|
91
97
|
messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
|
|
92
|
-
tools
|
|
98
|
+
tools,
|
|
99
|
+
...(toolChoice ? { toolChoice } : {}),
|
|
93
100
|
generation,
|
|
94
101
|
})),
|
|
95
102
|
n,
|
|
@@ -100,6 +107,8 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
|
|
|
100
107
|
perPrompt: n,
|
|
101
108
|
...(body.echo === true ? { echo: prompts } : {}),
|
|
102
109
|
...(generation.stop ? { stop: generation.stop } : {}),
|
|
110
|
+
...(format.structured ? { structuredTool: RESPOND_TOOL, structuredSchema: format.structured.parameters } : {}),
|
|
111
|
+
...(format.instruction ? { jsonObject: true } : {}),
|
|
103
112
|
},
|
|
104
113
|
...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
|
|
105
114
|
}
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* Reasoning deltas and tool calls are dropped: the legacy format has neither, and the request
|
|
6
6
|
* offers no tools. `logprobs` is always null.
|
|
7
7
|
*/
|
|
8
|
-
import type
|
|
8
|
+
import { isPlugError, type PlugError, type PlugEvent, type PlugFinish, type PlugUsage } from "../contract.js"
|
|
9
9
|
import type { ChoiceCodec } from "../openai/fanout.js"
|
|
10
|
-
import { encodeError, type StreamChoice, type StreamResult } from "../openai/response.js"
|
|
10
|
+
import { args, encodeError, structuredMissing, textFallback, unfence, type CollectedChoice, type StreamChoice, type StreamResult } from "../openai/response.js"
|
|
11
11
|
import { StopHoldback, stripStopTail } from "../stop.js"
|
|
12
12
|
|
|
13
13
|
/** What the encoders need besides the model events; the request decoder produces it, the model call never sees it. */
|
|
@@ -23,6 +23,12 @@ export interface CompletionsExchange {
|
|
|
23
23
|
readonly echo?: ReadonlyArray<string>
|
|
24
24
|
/** The request's `stop`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). */
|
|
25
25
|
readonly stop?: ReadonlyArray<string>
|
|
26
|
+
/** `response_format: json_schema`: the name of the forced tool whose input is the answer (`respond`). The answer is its arguments as text. */
|
|
27
|
+
readonly structuredTool?: string
|
|
28
|
+
/** The caller's `json_schema`: a text answer is taken when the model skipped the call and its text is that JSON (`textFallback`). */
|
|
29
|
+
readonly structuredSchema?: Readonly<Record<string, unknown>>
|
|
30
|
+
/** `response_format: json_object`: a single markdown fence around the answer is stripped (`unfence`). */
|
|
31
|
+
readonly jsonObject?: boolean
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
const FINISH_REASONS: Record<Exclude<PlugFinish, "error">, string> = {
|
|
@@ -46,15 +52,31 @@ const encodeUsage = (usage: PlugUsage) => {
|
|
|
46
52
|
|
|
47
53
|
const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
|
|
48
54
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
55
|
+
/** A choice's settled answer: its text, the finish reason and the usage. */
|
|
56
|
+
interface Settled {
|
|
57
|
+
readonly text: string
|
|
58
|
+
readonly reason: Exclude<PlugFinish, "error">
|
|
59
|
+
readonly usage?: PlugUsage
|
|
60
|
+
/** The structured answer came from the text (see `textFallback`), not the respond call. */
|
|
61
|
+
readonly fallback?: boolean
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reads one whole choice. A structured answer is the arguments of the `respond` call (the text is
|
|
66
|
+
* ignored); a json_object answer loses one code fence. Without the call a structured choice fails,
|
|
67
|
+
* as in Chat Completions (`structuredMissing`).
|
|
68
|
+
*/
|
|
69
|
+
async function settle(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange): Promise<Settled | PlugError> {
|
|
51
70
|
let text = ""
|
|
71
|
+
let answer: string | undefined
|
|
52
72
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
53
73
|
let usage: PlugUsage | undefined
|
|
54
74
|
let failure: PlugError | undefined
|
|
55
75
|
for await (const event of events) {
|
|
56
76
|
if (event.type === "text-delta") text += event.text
|
|
57
|
-
else if (event.type === "
|
|
77
|
+
else if (event.type === "tool-call") {
|
|
78
|
+
if (exchange.structuredTool !== undefined && event.name === exchange.structuredTool) answer ??= args(event.input)
|
|
79
|
+
} else if (event.type === "finish") {
|
|
58
80
|
usage = event.usage ?? usage
|
|
59
81
|
if (event.reason === "error") failure ??= failedRun()
|
|
60
82
|
else finish = event.reason
|
|
@@ -64,20 +86,31 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
|
|
|
64
86
|
}
|
|
65
87
|
}
|
|
66
88
|
if (!failure && !finish) failure = noFinish()
|
|
67
|
-
if (failure || !finish)
|
|
68
|
-
|
|
69
|
-
return {
|
|
89
|
+
if (failure || !finish) return failure ?? noFinish()
|
|
90
|
+
if (exchange.structuredTool !== undefined) {
|
|
91
|
+
if (answer !== undefined) return { text: answer, reason: finish, usage }
|
|
92
|
+
const fallback = exchange.structuredSchema === undefined ? undefined : textFallback(text, exchange.structuredSchema)
|
|
93
|
+
if (fallback === undefined) return structuredMissing(exchange.structuredTool)
|
|
94
|
+
return { text: fallback, reason: finish, usage, fallback: true }
|
|
70
95
|
}
|
|
71
96
|
if (finish === "stop") text = stripStopTail(text, exchange.stop ?? []).text
|
|
97
|
+
return { text: exchange.jsonObject ? unfence(text) : text, reason: finish, usage }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
|
|
101
|
+
async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<CollectedChoice> {
|
|
102
|
+
const settled = await settle(events, exchange)
|
|
103
|
+
if (isPlugError(settled)) return { status: settled.status, body: encodeError(settled), retryAfterSeconds: settled.retryAfterSeconds }
|
|
72
104
|
return {
|
|
105
|
+
...(settled.fallback ? { structuredFallback: true } : {}),
|
|
73
106
|
status: 200,
|
|
74
107
|
body: {
|
|
75
108
|
id: `cmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`,
|
|
76
109
|
object: "text_completion",
|
|
77
110
|
created: now(),
|
|
78
111
|
model: exchange.model,
|
|
79
|
-
choices: [{ text: prefixOf(exchange, index) + text, index: 0, logprobs: null, finish_reason: FINISH_REASONS[
|
|
80
|
-
...(usage ? { usage: encodeUsage(usage) } : {}),
|
|
112
|
+
choices: [{ text: prefixOf(exchange, index) + settled.text, index: 0, logprobs: null, finish_reason: FINISH_REASONS[settled.reason] }],
|
|
113
|
+
...(settled.usage ? { usage: encodeUsage(settled.usage) } : {}),
|
|
81
114
|
},
|
|
82
115
|
}
|
|
83
116
|
}
|
|
@@ -110,6 +143,15 @@ async function streamText(
|
|
|
110
143
|
const prefix = prefixOf(exchange, choice.index)
|
|
111
144
|
if (prefix.length > 0) chunk(prefix)
|
|
112
145
|
}
|
|
146
|
+
if (exchange.structuredTool !== undefined || exchange.jsonObject) {
|
|
147
|
+
// A structured or json_object answer is only known at the end (the call, the fence), so it is written as one chunk.
|
|
148
|
+
const settled = await settle(events, exchange)
|
|
149
|
+
if (isPlugError(settled)) return { failure: settled, started: false }
|
|
150
|
+
start()
|
|
151
|
+
if (settled.text.length > 0) chunk(settled.text)
|
|
152
|
+
chunk("", FINISH_REASONS[settled.reason])
|
|
153
|
+
return { started, ...(settled.usage ? { usage: settled.usage } : {}) }
|
|
154
|
+
}
|
|
113
155
|
// A stop sequence is held back until the finish decides it (src/stop.ts).
|
|
114
156
|
const holdback = new StopHoldback(exchange.stop ?? [])
|
|
115
157
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
package/src/config.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { isPlugError, type PlugError } from "./contract.js"
|
|
|
6
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"
|
|
@@ -88,6 +89,13 @@ export interface AppPolicy {
|
|
|
88
89
|
readonly tools: boolean
|
|
89
90
|
/** Lowers the global `maxTools` for this app. */
|
|
90
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
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
/** One set of caps. A field that is not set is unlimited. */
|
|
@@ -106,8 +114,24 @@ export interface Limits {
|
|
|
106
114
|
readonly maxConcurrent?: number
|
|
107
115
|
readonly perApp?: LimitCaps
|
|
108
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
|
|
109
124
|
}
|
|
110
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
|
|
130
|
+
}
|
|
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
|
+
|
|
111
135
|
/** What a budget counts: HTTP requests on the model routes (refusals by `limits` or budgets left out), tokens, or cost in USD. */
|
|
112
136
|
export type BudgetMetric = "requests" | "tokens" | "costUsd"
|
|
113
137
|
/** The windows a budget resets on: a UTC calendar day, an ISO week (Monday start) or a calendar month (UTC). */
|
|
@@ -138,6 +162,8 @@ export interface Budgets {
|
|
|
138
162
|
|
|
139
163
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
140
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
|
|
141
167
|
const DEFAULTS = {
|
|
142
168
|
host: "127.0.0.1",
|
|
143
169
|
port: 4010,
|
|
@@ -151,7 +177,7 @@ const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
|
|
|
151
177
|
const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
|
|
152
178
|
/** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
|
|
153
179
|
const MAX_RETENTION_DAYS = 3650
|
|
154
|
-
const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps"])
|
|
180
|
+
const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps", "maxBodyBytesInFlight", "bodyReadTimeoutMs"])
|
|
155
181
|
const LIMIT_CAP_KNOWN = new Set(["maxConcurrent", "requestsPerMinute"])
|
|
156
182
|
/** Largest cap accepted: far above any provider's limit, and keeps the error messages short. */
|
|
157
183
|
const MAX_LIMIT = 1_000_000
|
|
@@ -224,7 +250,26 @@ function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
|
|
|
224
250
|
return [...merged]
|
|
225
251
|
}
|
|
226
252
|
|
|
227
|
-
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
|
+
}
|
|
228
273
|
const SYSTEM_MODES: ReadonlySet<string> = new Set(["prepend", "append", "replace"])
|
|
229
274
|
const MODEL_IDS: ReadonlySet<string> = new Set(["canonical", "slugs", "both"])
|
|
230
275
|
|
|
@@ -259,6 +304,12 @@ function parseApps(
|
|
|
259
304
|
if (value.systemMode !== undefined && value.system === undefined) throw new Error(`${at}.systemMode needs \`system\``)
|
|
260
305
|
if (value.tools !== undefined && typeof value.tools !== "boolean") throw new Error(`${at}.tools must be a boolean`)
|
|
261
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)
|
|
262
313
|
|
|
263
314
|
const policy: AppPolicy = {
|
|
264
315
|
...(value.models === undefined ? {} : { models: value.models as string[] }),
|
|
@@ -266,9 +317,12 @@ function parseApps(
|
|
|
266
317
|
systemMode: (value.systemMode as SystemMode | undefined) ?? "prepend",
|
|
267
318
|
tools: value.tools !== false,
|
|
268
319
|
...(maxTools === undefined ? {} : { maxTools }),
|
|
320
|
+
...(serverTools === undefined ? {} : { serverTools }),
|
|
321
|
+
...(maxServerToolSteps === undefined ? {} : { maxServerToolSteps }),
|
|
269
322
|
}
|
|
270
323
|
// An app that only carries a key has no policy to look up.
|
|
271
|
-
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])
|
|
272
326
|
}
|
|
273
327
|
return Object.fromEntries(entries)
|
|
274
328
|
}
|
|
@@ -303,6 +357,18 @@ function parseLimits(raw: unknown, keys: Readonly<Record<string, string>>): Limi
|
|
|
303
357
|
...(raw.maxConcurrent === undefined ? {} : { maxConcurrent: positiveInt(raw.maxConcurrent, "limits.maxConcurrent", 0, MAX_LIMIT) }),
|
|
304
358
|
...(raw.perApp === undefined ? {} : { perApp: parseCaps(raw.perApp, "limits.perApp") }),
|
|
305
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,
|
|
306
372
|
}
|
|
307
373
|
}
|
|
308
374
|
|
package/src/contract.ts
CHANGED
|
@@ -194,6 +194,12 @@ export interface CallContext {
|
|
|
194
194
|
* headers are open, so a stream (headers sent first) never gets it.
|
|
195
195
|
*/
|
|
196
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
|
|
197
203
|
}
|
|
198
204
|
|
|
199
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." },
|
|
@@ -284,6 +285,29 @@ ${sections.join("\n")}
|
|
|
284
285
|
`
|
|
285
286
|
}
|
|
286
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
|
+
|
|
287
311
|
/** `GET /v1/status`: authenticated runtime facts. `models` is absent when the catalog could not be loaded. */
|
|
288
312
|
export function buildStatus(input: {
|
|
289
313
|
readonly options: PlugboardOptions
|
|
@@ -292,6 +316,7 @@ export function buildStatus(input: {
|
|
|
292
316
|
readonly runtimeVersion: number
|
|
293
317
|
readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
|
|
294
318
|
readonly servingConfirmed: boolean
|
|
319
|
+
readonly configError?: { readonly message: string; readonly at: number }
|
|
295
320
|
}
|
|
296
321
|
readonly catalog?: ReadonlyArray<CatalogModel>
|
|
297
322
|
readonly now?: number
|
|
@@ -311,5 +336,7 @@ export function buildStatus(input: {
|
|
|
311
336
|
sessionGuards: runtime.guards,
|
|
312
337
|
models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog, app).length } : { error: "catalog_unavailable" },
|
|
313
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() } } : {}),
|
|
314
341
|
}
|
|
315
342
|
}
|
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,13 +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"
|
|
25
26
|
import { setSessionLink } from "../session/link.js"
|
|
26
27
|
import type { RouteHost } from "../openai/route.js"
|
|
28
|
+
import { STRUCTURED_FALLBACK_HEADER } from "../openai/response.js"
|
|
27
29
|
import { dropRejected } from "../openai/sampling.js"
|
|
28
30
|
import { estimateTokens } from "../anthropic/tokens.js"
|
|
29
31
|
import { parseGeminiRequest } from "./request.js"
|
|
@@ -112,7 +114,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
112
114
|
if (match.op === "count") {
|
|
113
115
|
const unsupported = checkJsonContentType(request)
|
|
114
116
|
if (unsupported) return fail(unsupported)
|
|
115
|
-
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)
|
|
116
118
|
if (isPlugError(parsed)) return fail(parsed)
|
|
117
119
|
const catalog = await scope.race(host.loadCatalog())
|
|
118
120
|
const ref = resolveModel(host.options, match.model, catalog, parsed.reasoningEffort)
|
|
@@ -135,7 +137,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
135
137
|
return fail({ status: 400, kind: "invalid_request", code: "unsupported_parameter", param: "alt", message: "streamGenerateContent needs the query parameter alt=sse" })
|
|
136
138
|
const unsupported = checkJsonContentType(request)
|
|
137
139
|
if (unsupported) return fail(unsupported)
|
|
138
|
-
const body = await readJsonBody(request, scope.signal)
|
|
140
|
+
const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
|
|
139
141
|
|
|
140
142
|
const parsed = parseGeminiRequest(match.model, match.stream, body, host.options)
|
|
141
143
|
if (isPlugError(parsed)) return fail(parsed)
|
|
@@ -166,6 +168,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
166
168
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
167
169
|
onPolicy: reportPolicy(response),
|
|
168
170
|
onUsage: reportUsage(response, match.stream),
|
|
171
|
+
onServerTools: reportServerTools(response, match.stream),
|
|
169
172
|
onCache: reportCache(response),
|
|
170
173
|
}),
|
|
171
174
|
scope,
|
|
@@ -189,5 +192,6 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
189
192
|
|
|
190
193
|
const run = await runGenerate(events, exchange, () => {})
|
|
191
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")
|
|
192
196
|
sendJson(response, 200, run.response)
|
|
193
197
|
}
|