@spinabot/brigade 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +208 -0
- package/assets/brigade-wordmark-on-black.png +0 -0
- package/assets/brigade-wordmark.png +0 -0
- package/brigade.mjs +96 -0
- package/dist/cli/chat-cmd.js +120 -0
- package/dist/cli/config-cmd.js +132 -0
- package/dist/cli/connect-cmd.js +447 -0
- package/dist/cli/doctor-cmd.js +317 -0
- package/dist/cli/gateway-cmd.js +92 -0
- package/dist/cli.js +287 -0
- package/dist/core/agent.js +1123 -0
- package/dist/core/config.js +80 -0
- package/dist/core/console-stream.js +188 -0
- package/dist/core/error-classifier.js +354 -0
- package/dist/core/event-logger.js +122 -0
- package/dist/core/model-caps.js +185 -0
- package/dist/core/provider-payload-mutators.js +517 -0
- package/dist/core/provider-quirks.js +285 -0
- package/dist/core/server.js +459 -0
- package/dist/core/smart-compaction.js +209 -0
- package/dist/core/system-prompt-defaults.js +88 -0
- package/dist/core/system-prompt-guidance.js +269 -0
- package/dist/core/system-prompt.js +884 -0
- package/dist/index.js +30 -0
- package/dist/integrations/ollama.js +140 -0
- package/dist/protocol.js +49 -0
- package/dist/providers/catalog.js +100 -0
- package/dist/providers/validate-key.js +197 -0
- package/dist/tui/client.js +263 -0
- package/dist/ui/brand-frames-cli.js +20 -0
- package/dist/ui/brand-frames.js +36 -0
- package/dist/ui/brand.js +402 -0
- package/dist/ui/chat.js +929 -0
- package/dist/ui/onboarding.js +400 -0
- package/dist/ui/theme.js +51 -0
- package/package.json +92 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-payload mutators — fire INSIDE Pi's `onPayload` hook to mutate
|
|
3
|
+
* the assembled provider-specific JSON right before it ships over HTTP.
|
|
4
|
+
*
|
|
5
|
+
* Pi's wrapping order is:
|
|
6
|
+
* session.prompt → Pi's auth wrapper → provider adapter → buildPayload
|
|
7
|
+
* → options.onPayload?.(payload, model) ← we hook here
|
|
8
|
+
* → HTTP request
|
|
9
|
+
*
|
|
10
|
+
* We wrap `session.agent.streamFn` (preserving Pi's existing auth wrapper)
|
|
11
|
+
* to inject our own `onPayload` callback. The callback delegates to any
|
|
12
|
+
* caller-supplied `onPayload` first, then walks the provider mutators in
|
|
13
|
+
* order. Each mutator is a pure-ish function gated on the active model;
|
|
14
|
+
* no-ops when the provider doesn't match.
|
|
15
|
+
*
|
|
16
|
+
* Three provider quirks are handled here:
|
|
17
|
+
* - OpenRouter Anthropic prompt-cache hints (cost win on long sessions)
|
|
18
|
+
* - Google Gemini thinking-config payload reformat (Pi's level → Google's enum)
|
|
19
|
+
* - SiliconFlow / Minimax thinking-mode normalization
|
|
20
|
+
*
|
|
21
|
+
* NOT a replacement for Pi's auth wrapper — strictly additive. The wrapped
|
|
22
|
+
* streamFn calls `originalStreamFn(model, context, options)` underneath.
|
|
23
|
+
*/
|
|
24
|
+
import { BRIGADE_CACHE_BOUNDARY } from "./system-prompt.js";
|
|
25
|
+
/* ─────────────────────────── 1. Anthropic system-prompt cache hints ─────────────────────────── */
|
|
26
|
+
/**
|
|
27
|
+
* Apply Anthropic prompt-cache markers to the system field. Three cases:
|
|
28
|
+
*
|
|
29
|
+
* A. SYSTEM PROMPT CONTAINS BRIGADE_CACHE_BOUNDARY MARKER
|
|
30
|
+
* Split the system text at the marker into two `{ type: "text" }`
|
|
31
|
+
* blocks. Apply `cache_control: { type: "ephemeral" }` to the FIRST
|
|
32
|
+
* block only (the static prefix). The SECOND block (dynamic suffix —
|
|
33
|
+
* runtime info, model id, etc.) gets no marker, so it doesn't break
|
|
34
|
+
* the cache when it varies per turn.
|
|
35
|
+
*
|
|
36
|
+
* B. NO MARKER, SYSTEM IS A STRING
|
|
37
|
+
* Convert to a single text block WITH cache_control. Whole prompt is
|
|
38
|
+
* cached. (This is the legacy behavior of `applyOpenRouterAnthropicCacheHints`.)
|
|
39
|
+
*
|
|
40
|
+
* C. NO MARKER, SYSTEM IS ALREADY AN ARRAY
|
|
41
|
+
* Mark the last text block with cache_control. (Same legacy behavior.)
|
|
42
|
+
*
|
|
43
|
+
* Then mark the LAST USER MESSAGE'S last content block with cache_control
|
|
44
|
+
* — this is the second of Anthropic's up-to-4 breakpoints (we use 2: system
|
|
45
|
+
* prefix + last user message).
|
|
46
|
+
*
|
|
47
|
+
* Only fires when `isAnthropicFlavored(model)` — direct Anthropic, OpenRouter
|
|
48
|
+
* routing to Claude, Anthropic-compat APIs (Bedrock, Vertex, Minimax). For
|
|
49
|
+
* non-Anthropic providers, see `stripCacheBoundaryFromPayload` below — the
|
|
50
|
+
* marker is stripped so the model never sees it.
|
|
51
|
+
*
|
|
52
|
+
* Replaces the older `applyOpenRouterAnthropicCacheHints`. The new version
|
|
53
|
+
* handles the boundary marker (which is what enables the static-vs-dynamic
|
|
54
|
+
* split that gives us the 5-10× cost win on multi-turn).
|
|
55
|
+
*/
|
|
56
|
+
export function applyAnthropicSystemCacheHints(payload, model) {
|
|
57
|
+
if (!isAnthropicFlavored(model))
|
|
58
|
+
return;
|
|
59
|
+
if (!payload || typeof payload !== "object")
|
|
60
|
+
return;
|
|
61
|
+
const p = payload;
|
|
62
|
+
const cacheMarker = { type: "ephemeral" };
|
|
63
|
+
// Mark the system field — string → array, array → split or mark last.
|
|
64
|
+
applyCacheToSystem(p, cacheMarker);
|
|
65
|
+
// Mark the last user message — second cache breakpoint.
|
|
66
|
+
applyCacheToLastUserMessage(p, cacheMarker);
|
|
67
|
+
}
|
|
68
|
+
function applyCacheToSystem(payload, cacheMarker) {
|
|
69
|
+
// Pi may pass system as either a top-level `system` string/array OR as
|
|
70
|
+
// a `messages` entry with role: "system". We handle both shapes.
|
|
71
|
+
if (typeof payload.system === "string") {
|
|
72
|
+
payload.system = splitOrWrap(payload.system, cacheMarker);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(payload.system)) {
|
|
76
|
+
payload.system = applyCacheToBlocks(payload.system, cacheMarker);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Fallback: mutate the messages array's system role entry.
|
|
80
|
+
const messages = payload.messages;
|
|
81
|
+
if (!Array.isArray(messages))
|
|
82
|
+
return;
|
|
83
|
+
for (const msg of messages) {
|
|
84
|
+
if (msg?.role !== "system" && msg?.role !== "developer")
|
|
85
|
+
continue;
|
|
86
|
+
if (typeof msg.content === "string") {
|
|
87
|
+
msg.content = splitOrWrap(msg.content, cacheMarker);
|
|
88
|
+
}
|
|
89
|
+
else if (Array.isArray(msg.content)) {
|
|
90
|
+
msg.content = applyCacheToBlocks(msg.content, cacheMarker);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Convert a system-prompt STRING into one or two `{type:"text"}` blocks.
|
|
96
|
+
* If the string contains the BRIGADE_CACHE_BOUNDARY marker, split into:
|
|
97
|
+
* - block 1: stable prefix WITH cache_control
|
|
98
|
+
* - block 2: dynamic suffix WITHOUT cache_control (changes per turn)
|
|
99
|
+
* Otherwise wrap the whole string in a single block with cache_control.
|
|
100
|
+
*/
|
|
101
|
+
function splitOrWrap(text, cacheMarker) {
|
|
102
|
+
const idx = text.indexOf(BRIGADE_CACHE_BOUNDARY);
|
|
103
|
+
if (idx === -1) {
|
|
104
|
+
return [{ type: "text", text, cache_control: cacheMarker }];
|
|
105
|
+
}
|
|
106
|
+
const stablePrefix = text.slice(0, idx).replace(/\s+$/, "");
|
|
107
|
+
const dynamicSuffix = text.slice(idx + BRIGADE_CACHE_BOUNDARY.length).replace(/^\s+/, "");
|
|
108
|
+
const blocks = [];
|
|
109
|
+
if (stablePrefix.length > 0) {
|
|
110
|
+
blocks.push({ type: "text", text: stablePrefix, cache_control: cacheMarker });
|
|
111
|
+
}
|
|
112
|
+
if (dynamicSuffix.length > 0) {
|
|
113
|
+
blocks.push({ type: "text", text: dynamicSuffix });
|
|
114
|
+
}
|
|
115
|
+
// If both halves trim to empty (degenerate input — caller passed a system
|
|
116
|
+
// prompt that's nothing but whitespace + a marker), return a single empty
|
|
117
|
+
// block WITHOUT cache_control. Anthropic rejects empty cache blocks
|
|
118
|
+
// (`{type:"text", text:"", cache_control:...}`) but accepts an empty
|
|
119
|
+
// non-cached block. Conservative fallback so the request still flies.
|
|
120
|
+
return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Walk an existing array of system blocks looking for the boundary marker.
|
|
124
|
+
* If a text block contains it, split that block in place. If no block
|
|
125
|
+
* contains it, mark the LAST non-thinking block with cache_control (legacy
|
|
126
|
+
* behavior — caches the whole prefix up to that point).
|
|
127
|
+
*/
|
|
128
|
+
function applyCacheToBlocks(blocks, cacheMarker) {
|
|
129
|
+
let foundBoundary = false;
|
|
130
|
+
const result = [];
|
|
131
|
+
for (const block of blocks) {
|
|
132
|
+
if (!foundBoundary &&
|
|
133
|
+
block &&
|
|
134
|
+
typeof block === "object" &&
|
|
135
|
+
block.type === "text" &&
|
|
136
|
+
typeof block.text === "string" &&
|
|
137
|
+
block.text.includes(BRIGADE_CACHE_BOUNDARY)) {
|
|
138
|
+
// Split this block at the boundary marker. Spread the source block's
|
|
139
|
+
// other fields (e.g. citations, custom keys) FIRST, then the new
|
|
140
|
+
// block's fields, then explicitly drop any inherited `cache_control`
|
|
141
|
+
// so the dynamic-suffix block (which `splitOrWrap` deliberately
|
|
142
|
+
// emits without cache_control) doesn't accidentally re-inherit one
|
|
143
|
+
// from the source. If the source block had `cache_control` already,
|
|
144
|
+
// silently letting it through would mark the suffix as cached even
|
|
145
|
+
// though it changes per turn — guaranteed cache miss every turn.
|
|
146
|
+
const split = splitOrWrap(block.text, cacheMarker);
|
|
147
|
+
for (const newBlock of split) {
|
|
148
|
+
const merged = { ...block, ...newBlock };
|
|
149
|
+
if (newBlock.cache_control === undefined) {
|
|
150
|
+
delete merged.cache_control;
|
|
151
|
+
}
|
|
152
|
+
result.push(merged);
|
|
153
|
+
}
|
|
154
|
+
foundBoundary = true;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
result.push(block);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// No boundary marker anywhere — mark the LAST non-thinking block.
|
|
161
|
+
if (!foundBoundary) {
|
|
162
|
+
for (let i = result.length - 1; i >= 0; i--) {
|
|
163
|
+
const block = result[i];
|
|
164
|
+
if (block && typeof block === "object" && block.type !== "thinking") {
|
|
165
|
+
if (block.cache_control === undefined) {
|
|
166
|
+
block.cache_control = cacheMarker;
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Apply cache_control to the LAST content block of the LAST user message.
|
|
176
|
+
* This is the second of Anthropic's 2 cache breakpoints (system + last
|
|
177
|
+
* user).
|
|
178
|
+
*
|
|
179
|
+
* IMPORTANT — Anthropic caps the request at 4 cache breakpoints total. Pi
|
|
180
|
+
* persists user messages across turns, so any cache_control we attached on
|
|
181
|
+
* a PREVIOUS turn is still on the older user/tool_result blocks. Without
|
|
182
|
+
* the sweep below, breakpoints accumulate turn-over-turn (turn N adds one
|
|
183
|
+
* to a new user msg; turn N+1 adds another while the old one is still set;
|
|
184
|
+
* etc.) and quickly exceed the cap → Anthropic 400. Best practice is "only
|
|
185
|
+
* the last user message holds the live breakpoint" — so we clear every
|
|
186
|
+
* older user-side breakpoint before applying the new one.
|
|
187
|
+
*
|
|
188
|
+
* Only applies if the message is a user message (skip if last message
|
|
189
|
+
* happens to be assistant — shouldn't happen in a healthy conversation
|
|
190
|
+
* but guard anyway).
|
|
191
|
+
*/
|
|
192
|
+
function applyCacheToLastUserMessage(payload, cacheMarker) {
|
|
193
|
+
const messages = payload.messages;
|
|
194
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
195
|
+
return;
|
|
196
|
+
// Sweep: clear cache_control from EVERY user/tool_result block in EVERY
|
|
197
|
+
// message except the last one. We restrict the sweep to user-side roles
|
|
198
|
+
// because Pi may set cache_control on assistant-emitted blocks for its
|
|
199
|
+
// own reasons (rare, but not ours to clobber).
|
|
200
|
+
for (let i = 0; i < messages.length - 1; i++) {
|
|
201
|
+
const msg = messages[i];
|
|
202
|
+
if (!msg || typeof msg !== "object")
|
|
203
|
+
continue;
|
|
204
|
+
if (msg.role !== "user")
|
|
205
|
+
continue;
|
|
206
|
+
const content = msg.content;
|
|
207
|
+
if (!Array.isArray(content))
|
|
208
|
+
continue;
|
|
209
|
+
for (const block of content) {
|
|
210
|
+
if (block &&
|
|
211
|
+
typeof block === "object" &&
|
|
212
|
+
(block.type === "text" ||
|
|
213
|
+
block.type === "image" ||
|
|
214
|
+
block.type === "tool_result") &&
|
|
215
|
+
block.cache_control !== undefined) {
|
|
216
|
+
delete block.cache_control;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const last = messages[messages.length - 1];
|
|
221
|
+
if (!last || typeof last !== "object" || last.role !== "user")
|
|
222
|
+
return;
|
|
223
|
+
if (Array.isArray(last.content) && last.content.length > 0) {
|
|
224
|
+
const lastBlock = last.content[last.content.length - 1];
|
|
225
|
+
if (lastBlock &&
|
|
226
|
+
typeof lastBlock === "object" &&
|
|
227
|
+
(lastBlock.type === "text" ||
|
|
228
|
+
lastBlock.type === "image" ||
|
|
229
|
+
lastBlock.type === "tool_result")) {
|
|
230
|
+
if (lastBlock.cache_control === undefined) {
|
|
231
|
+
lastBlock.cache_control = cacheMarker;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (typeof last.content === "string") {
|
|
236
|
+
// Convert string to array form so we can attach cache_control.
|
|
237
|
+
last.content = [
|
|
238
|
+
{ type: "text", text: last.content, cache_control: cacheMarker },
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Whether the model is Anthropic-flavored — direct Anthropic, OpenRouter→
|
|
244
|
+
* Claude, or any provider whose API field is `anthropic-messages`. Used to
|
|
245
|
+
* gate `applyAnthropicSystemCacheHints`.
|
|
246
|
+
*/
|
|
247
|
+
export function isAnthropicFlavored(model) {
|
|
248
|
+
if (!model)
|
|
249
|
+
return false;
|
|
250
|
+
const api = (model.api ?? "").toLowerCase();
|
|
251
|
+
if (api === "anthropic" || api === "anthropic-messages")
|
|
252
|
+
return true;
|
|
253
|
+
const provider = (model.provider ?? "").toLowerCase();
|
|
254
|
+
if (provider === "anthropic")
|
|
255
|
+
return true;
|
|
256
|
+
if (provider === "minimax" && api === "anthropic-messages")
|
|
257
|
+
return true;
|
|
258
|
+
if (provider === "openrouter" && /(?:^|\/)(?:anthropic|claude)/i.test(model.id ?? "")) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (provider === "bedrock" || provider === "vertex") {
|
|
262
|
+
return /claude|anthropic/i.test(model.id ?? "");
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Backwards-compat aliases. The old names (`isOpenRouterAnthropic`,
|
|
268
|
+
* `applyOpenRouterAnthropicCacheHints`) are too narrow now that the cache
|
|
269
|
+
* mutator handles direct Anthropic + Bedrock + Vertex + Minimax too. Kept
|
|
270
|
+
* exported under the old names so test files and any external callers
|
|
271
|
+
* continue to compile during the transition.
|
|
272
|
+
*
|
|
273
|
+
* @deprecated use `isAnthropicFlavored` / `applyAnthropicSystemCacheHints`.
|
|
274
|
+
*/
|
|
275
|
+
export const isOpenRouterAnthropic = isAnthropicFlavored;
|
|
276
|
+
export const applyOpenRouterAnthropicCacheHints = applyAnthropicSystemCacheHints;
|
|
277
|
+
/**
|
|
278
|
+
* Strip the BRIGADE_CACHE_BOUNDARY marker from any system field.
|
|
279
|
+
* Universal pre-pass — runs for ALL providers (Anthropic too) so the
|
|
280
|
+
* marker never escapes Brigade. For Anthropic, the marker has already
|
|
281
|
+
* been used by `applyAnthropicSystemCacheHints` to split the prompt;
|
|
282
|
+
* stripping leftovers is defensive in case the splitter missed one
|
|
283
|
+
* (multiple markers, marker in array form Pi assembled differently, etc).
|
|
284
|
+
*
|
|
285
|
+
* For non-Anthropic providers, this is the ONLY thing that touches the
|
|
286
|
+
* system field — strip the marker, leave the prompt as a single string
|
|
287
|
+
* the model can read.
|
|
288
|
+
*/
|
|
289
|
+
export function stripCacheBoundaryFromPayload(payload) {
|
|
290
|
+
if (!payload || typeof payload !== "object")
|
|
291
|
+
return;
|
|
292
|
+
const p = payload;
|
|
293
|
+
if (typeof p.system === "string") {
|
|
294
|
+
p.system = p.system.split(BRIGADE_CACHE_BOUNDARY).join("\n").trim();
|
|
295
|
+
}
|
|
296
|
+
else if (Array.isArray(p.system)) {
|
|
297
|
+
for (const block of p.system) {
|
|
298
|
+
if (block && typeof block === "object" && typeof block.text === "string") {
|
|
299
|
+
block.text = block.text.split(BRIGADE_CACHE_BOUNDARY).join("\n").trim();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const messages = p.messages;
|
|
304
|
+
if (Array.isArray(messages)) {
|
|
305
|
+
for (const msg of messages) {
|
|
306
|
+
if (msg?.role !== "system" && msg?.role !== "developer")
|
|
307
|
+
continue;
|
|
308
|
+
if (typeof msg.content === "string") {
|
|
309
|
+
msg.content = msg.content.split(BRIGADE_CACHE_BOUNDARY).join("\n").trim();
|
|
310
|
+
}
|
|
311
|
+
else if (Array.isArray(msg.content)) {
|
|
312
|
+
for (const block of msg.content) {
|
|
313
|
+
if (block && typeof block === "object" && typeof block.text === "string") {
|
|
314
|
+
block.text = block.text.split(BRIGADE_CACHE_BOUNDARY).join("\n").trim();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/* ─────────────────────────── 2. Google Gemini thinking payload ─────────────────────────── */
|
|
322
|
+
/**
|
|
323
|
+
* Gemini's GenerateContent API expects thinking config under
|
|
324
|
+
* `config.thinkingConfig.thinkingLevel` with values from a fixed enum:
|
|
325
|
+
* "MINIMAL" | "LOW" | "MEDIUM" | "HIGH". Pi's ThinkingLevel uses lowercase
|
|
326
|
+
* "minimal" | "low" | "medium" | "high" | "xhigh" (xhigh is Pi-only).
|
|
327
|
+
*
|
|
328
|
+
* The fix: write the uppercased value AND drop `xhigh` (which Gemini
|
|
329
|
+
* rejects as unknown).
|
|
330
|
+
*
|
|
331
|
+
* This is purely a payload-shape concern — Pi's adapter already builds the
|
|
332
|
+
* `config.thinkingConfig` object; we just normalize the level string.
|
|
333
|
+
*/
|
|
334
|
+
export function sanitizeGeminiThinkingPayload(payload, model) {
|
|
335
|
+
if (!isGoogleGeminiModel(model))
|
|
336
|
+
return;
|
|
337
|
+
if (!payload || typeof payload !== "object")
|
|
338
|
+
return;
|
|
339
|
+
const cfg = payload.config;
|
|
340
|
+
if (!cfg || typeof cfg !== "object")
|
|
341
|
+
return;
|
|
342
|
+
const tc = cfg.thinkingConfig;
|
|
343
|
+
if (!tc || typeof tc !== "object")
|
|
344
|
+
return;
|
|
345
|
+
const raw = tc.thinkingLevel ?? tc.thinking_level;
|
|
346
|
+
if (typeof raw !== "string")
|
|
347
|
+
return;
|
|
348
|
+
const mapped = mapPiThinkingToGemini(raw);
|
|
349
|
+
if (mapped === undefined) {
|
|
350
|
+
// Unknown level → drop the config entirely so Gemini uses its default
|
|
351
|
+
// instead of rejecting an invalid enum.
|
|
352
|
+
delete cfg.thinkingConfig;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
tc.thinkingLevel = mapped;
|
|
356
|
+
// Some Gemini variants want it under thinking_level (snake_case). Set both
|
|
357
|
+
// when one was supplied, so we don't silently lose the value if Pi later
|
|
358
|
+
// switches naming.
|
|
359
|
+
if ("thinking_level" in tc)
|
|
360
|
+
tc.thinking_level = mapped;
|
|
361
|
+
}
|
|
362
|
+
function mapPiThinkingToGemini(level) {
|
|
363
|
+
switch (level.toLowerCase()) {
|
|
364
|
+
case "off":
|
|
365
|
+
return undefined; // Gemini has no "off" — drop the config
|
|
366
|
+
case "minimal":
|
|
367
|
+
return "MINIMAL";
|
|
368
|
+
case "low":
|
|
369
|
+
return "LOW";
|
|
370
|
+
case "medium":
|
|
371
|
+
return "MEDIUM";
|
|
372
|
+
case "high":
|
|
373
|
+
case "xhigh": // xhigh isn't supported; bucket it as HIGH
|
|
374
|
+
return "HIGH";
|
|
375
|
+
default:
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
export function isGoogleGeminiModel(model) {
|
|
380
|
+
if (!model)
|
|
381
|
+
return false;
|
|
382
|
+
const api = (model.api ?? "").toLowerCase();
|
|
383
|
+
if (api === "google-generative-ai" || api === "google-gemini" || api === "google")
|
|
384
|
+
return true;
|
|
385
|
+
const provider = (model.provider ?? "").toLowerCase();
|
|
386
|
+
if (provider === "google" || provider === "gemini" || provider === "google-vertex" || provider === "vertex") {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
if (provider === "openrouter" && /gemini|google/i.test(model.id ?? ""))
|
|
390
|
+
return true;
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
/* ─────────────────────────── 3. SiliconFlow thinking-off normalization ─────────────────────────── */
|
|
394
|
+
/**
|
|
395
|
+
* SiliconFlow rejects `thinking: "off"` (Pi's value for "no reasoning") and
|
|
396
|
+
* wants `thinking: null` instead — this mutator does the swap.
|
|
397
|
+
*
|
|
398
|
+
* Pi sets `thinking: "off"` automatically when `session.thinkingLevel === "off"`
|
|
399
|
+
* for OpenAI-compatible providers; SiliconFlow shares the OpenAI shape
|
|
400
|
+
* but is stricter about this one field.
|
|
401
|
+
*/
|
|
402
|
+
export function normalizeSiliconFlowThinking(payload, model) {
|
|
403
|
+
if (!isSiliconFlowModel(model))
|
|
404
|
+
return;
|
|
405
|
+
if (!payload || typeof payload !== "object")
|
|
406
|
+
return;
|
|
407
|
+
const p = payload;
|
|
408
|
+
if (p.thinking === "off") {
|
|
409
|
+
p.thinking = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
export function isSiliconFlowModel(model) {
|
|
413
|
+
if (!model)
|
|
414
|
+
return false;
|
|
415
|
+
const provider = (model.provider ?? "").toLowerCase();
|
|
416
|
+
if (provider === "siliconflow" || provider === "silicon-flow")
|
|
417
|
+
return true;
|
|
418
|
+
const baseUrl = model.baseUrl ?? "";
|
|
419
|
+
if (typeof baseUrl === "string" && /siliconflow/i.test(baseUrl))
|
|
420
|
+
return true;
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
/* ─────────────────────────── 4. Minimax force-disable thinking ─────────────────────────── */
|
|
424
|
+
/**
|
|
425
|
+
* Minimax exposes an Anthropic-shaped Messages endpoint but ALWAYS streams
|
|
426
|
+
* reasoning back as visible deltas — even when the user wants thinking
|
|
427
|
+
* suppressed. The fix is to inject `thinking: { type: "disabled" }` into
|
|
428
|
+
* the payload when it's missing, so Minimax stops emitting reasoning deltas.
|
|
429
|
+
*
|
|
430
|
+
* Only applies when the underlying API is Anthropic Messages AND the
|
|
431
|
+
* provider is Minimax. Other Anthropic-compatible endpoints don't need this.
|
|
432
|
+
*/
|
|
433
|
+
export function disableMinimaxThinking(payload, model) {
|
|
434
|
+
if (!isMinimaxAnthropicModel(model))
|
|
435
|
+
return;
|
|
436
|
+
if (!payload || typeof payload !== "object")
|
|
437
|
+
return;
|
|
438
|
+
const p = payload;
|
|
439
|
+
// Don't overwrite an explicit thinking config the user set elsewhere.
|
|
440
|
+
if (p.thinking === undefined) {
|
|
441
|
+
p.thinking = { type: "disabled" };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
export function isMinimaxAnthropicModel(model) {
|
|
445
|
+
if (!model)
|
|
446
|
+
return false;
|
|
447
|
+
const api = (model.api ?? "").toLowerCase();
|
|
448
|
+
if (api !== "anthropic-messages" && api !== "anthropic")
|
|
449
|
+
return false;
|
|
450
|
+
const provider = (model.provider ?? "").toLowerCase();
|
|
451
|
+
return provider === "minimax" || provider === "minimax-portal";
|
|
452
|
+
}
|
|
453
|
+
/* ─────────────────────────── composition ─────────────────────────── */
|
|
454
|
+
/**
|
|
455
|
+
* Run every applicable mutator against the payload. Each mutator self-gates
|
|
456
|
+
* on the active model — no-ops when the provider doesn't match — so calling
|
|
457
|
+
* `applyAll` for every request is cheap.
|
|
458
|
+
*
|
|
459
|
+
* Order matters:
|
|
460
|
+
* 1. Anthropic system-prompt cache hints — splits at the boundary marker
|
|
461
|
+
* and applies cache_control. Must run BEFORE the universal strip
|
|
462
|
+
* (otherwise the boundary is gone before we can split on it).
|
|
463
|
+
* 2. Universal boundary strip — removes any leftover marker so it never
|
|
464
|
+
* reaches the model regardless of provider. Idempotent for Anthropic
|
|
465
|
+
* (the splitter already removed it for matched cases); load-bearing
|
|
466
|
+
* for non-Anthropic.
|
|
467
|
+
* 3. Provider-specific mutators (Gemini thinking, SiliconFlow, Minimax) —
|
|
468
|
+
* operate on fields other than `system`, no ordering interaction with
|
|
469
|
+
* the cache work above.
|
|
470
|
+
*
|
|
471
|
+
* Mutates `payload` in place. Returns nothing (caller hands `payload` back
|
|
472
|
+
* to Pi unchanged-by-reference; the in-place mutation is what Pi sends).
|
|
473
|
+
*/
|
|
474
|
+
export function applyAllPayloadMutations(payload, model) {
|
|
475
|
+
if (!model || !payload)
|
|
476
|
+
return;
|
|
477
|
+
applyAnthropicSystemCacheHints(payload, model);
|
|
478
|
+
stripCacheBoundaryFromPayload(payload);
|
|
479
|
+
sanitizeGeminiThinkingPayload(payload, model);
|
|
480
|
+
normalizeSiliconFlowThinking(payload, model);
|
|
481
|
+
disableMinimaxThinking(payload, model);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Wrap `session.agent.streamFn` so every request gets `applyAllPayloadMutations`
|
|
485
|
+
* fired against its assembled payload.
|
|
486
|
+
*
|
|
487
|
+
* SAFE TO CALL ONCE per session — preserves Pi's existing auth-aware streamFn
|
|
488
|
+
* by composing on top of it. Calling more than once would stack wrappers
|
|
489
|
+
* and fire mutations multiple times (idempotent for our current mutators
|
|
490
|
+
* but wasteful); buildAgent calls it exactly once after createAgentSession.
|
|
491
|
+
*
|
|
492
|
+
* If a caller (test, future feature) supplies its OWN `onPayload` in
|
|
493
|
+
* StreamOptions, we run THEIRS first and then our mutators on whatever
|
|
494
|
+
* they returned (or the original if they returned undefined). This keeps
|
|
495
|
+
* caller hooks composable.
|
|
496
|
+
*/
|
|
497
|
+
export function wrapStreamFnWithPayloadMutations(session) {
|
|
498
|
+
const original = session.agent.streamFn;
|
|
499
|
+
if (!original)
|
|
500
|
+
return;
|
|
501
|
+
const wrapped = (model, context, options) => {
|
|
502
|
+
const userOnPayload = options?.onPayload;
|
|
503
|
+
const augmented = {
|
|
504
|
+
...(options ?? {}),
|
|
505
|
+
onPayload: async (payload, m) => {
|
|
506
|
+
// User's hook gets first crack at mutating / replacing.
|
|
507
|
+
const userResult = userOnPayload ? await userOnPayload(payload, m) : undefined;
|
|
508
|
+
const next = userResult !== undefined ? userResult : payload;
|
|
509
|
+
applyAllPayloadMutations(next, m);
|
|
510
|
+
return next;
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
return original(model, context, augmented);
|
|
514
|
+
};
|
|
515
|
+
session.agent.streamFn = wrapped;
|
|
516
|
+
}
|
|
517
|
+
//# sourceMappingURL=provider-payload-mutators.js.map
|