@elliotsystems/eai 0.1.7 → 0.1.14

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.
@@ -24,6 +24,25 @@ import { configHook } from "./proxy-provider.js"
24
24
  import { formatScope, scopeForWorktree } from "./scope.js"
25
25
  import { applyElliotIdentity } from "./identity.js"
26
26
  import { resolveEndUserId, type SessionClient } from "./identity-headers.js"
27
+ import {
28
+ applyGuardrailDecision,
29
+ extractInstanceTeamId,
30
+ liveCompactionEnabled,
31
+ liveJevOutputValidationEnabled,
32
+ liveJevPolicyEnabled,
33
+ OUTPUT_VALIDATION_GUARDRAIL_NAME,
34
+ } from "./guardrail-sync.js"
35
+ import { stampSessionAffinity, type CompactionTelemetry } from "./session-affinity.js"
36
+ import { stripBlockedTurns } from "./blocked-turns.js"
37
+ import { readElliotConfig } from "./elliot-auth.js"
38
+ import {
39
+ compactMessages,
40
+ type AskerAnswer,
41
+ type AskerContext,
42
+ type CompactionMessage,
43
+ type ContentBlock,
44
+ type MessageRole,
45
+ } from "./compaction.js"
27
46
 
28
47
  // Structural mirrors of the host types (packages/plugin/src/index.ts @
29
48
  // v1.18.19). Declared locally so this package type-checks and unit-tests
@@ -72,10 +91,43 @@ export interface Hooks {
72
91
  input: { sessionID?: string; model: unknown },
73
92
  output: { system: string[] },
74
93
  ) => Promise<void>
94
+ /**
95
+ * Runs right before the session's message history is converted to the
96
+ * provider's wire format (session/prompt.ts, immediately before
97
+ * `MessageV2.toModelMessagesEffect`) — the one hook that sees, and can
98
+ * edit, exactly what the MODEL is about to be shown as this conversation's
99
+ * history. `output.messages` is the SAME array the caller goes on to use
100
+ * (session/plugin/index.ts's `trigger` never reassigns it), so a hook
101
+ * must mutate it in place; see blocked-turns.ts's own `stripBlockedTurns`.
102
+ */
103
+ "experimental.chat.messages.transform"?: (
104
+ input: Record<string, never>,
105
+ output: { messages: Array<{ info?: Record<string, unknown>; parts?: unknown[] }> },
106
+ ) => Promise<void>
75
107
  "chat.headers"?: (
76
108
  input: { sessionID: string; agent: unknown; model: unknown; provider: unknown; message: unknown },
77
109
  output: { headers: Record<string, string> },
78
110
  ) => Promise<void>
111
+ /**
112
+ * Runs on every outgoing request, before it is sent — the one hook with
113
+ * write access to `options`, the object `eai setup`'s static `metadata.
114
+ * guardrails` lands in (opencode/packages/opencode/src/session/llm/
115
+ * request.ts: `options = mergeOptions(base, model.options, ...)` is what
116
+ * this hook receives as `output.options`). See guardrail-sync.ts for why
117
+ * this is the CLI's real session-recovery mechanism: there is no response/
118
+ * error hook to retry FROM, so the fix is making the one attempt correct
119
+ * instead.
120
+ */
121
+ "chat.params"?: (
122
+ input: { sessionID: string; agent: unknown; model: unknown; provider: unknown; message: unknown },
123
+ output: {
124
+ temperature?: number
125
+ topP?: number
126
+ topK?: number
127
+ maxOutputTokens?: number
128
+ options: Record<string, unknown>
129
+ },
130
+ ) => Promise<void>
79
131
  config?: (input: any) => Promise<void>
80
132
  auth?: AuthHook
81
133
  dispose?: () => Promise<void>
@@ -83,6 +135,256 @@ export interface Hooks {
83
135
 
84
136
  export type Plugin = (input: PluginInput, options?: Record<string, unknown>) => Promise<Hooks>
85
137
 
138
+ // ---------------------------------------------------- compaction adapter --
139
+ //
140
+ // Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
141
+ // pilot.md`, task T7). `compaction.ts` (T6) is deliberately transport- and
142
+ // host-shape-agnostic: it only knows its own `CompactionMessage`/
143
+ // `ContentBlock` shape. Everything below adapts OpenCode's native history
144
+ // entry shape (`{ info, parts }`, the same one `blocked-turns.ts` reads) to
145
+ // and from that shape, and supplies the `asker` that calls the T3 backend
146
+ // proxy. None of this runs unless `liveCompactionEnabled` resolves `true`,
147
+ // and the whole attempt is wrapped in a single try/catch in the hook below
148
+ // so a bad response, a network failure, or a bug in this adapter can never
149
+ // break or delay a chat turn -- it only ever falls back to whatever
150
+ // `stripBlockedTurns` already produced.
151
+
152
+ /** Per-request timeout for one `keepCall`/`keepResult` judge call. Several
153
+ * of these may run per turn (one per candidate tool round trip); kept
154
+ * short so a hung backend cannot meaningfully delay a chat turn even
155
+ * before the outer try/catch gives up on compaction entirely. */
156
+ const COMPACTION_ASKER_TIMEOUT_MS = 5_000
157
+
158
+ interface NativeToolPartState {
159
+ status?: unknown
160
+ input?: unknown
161
+ output?: unknown
162
+ error?: unknown
163
+ [k: string]: unknown
164
+ }
165
+
166
+ interface NativeToolPart {
167
+ type: "tool"
168
+ tool?: unknown
169
+ callID?: unknown
170
+ state?: NativeToolPartState
171
+ [k: string]: unknown
172
+ }
173
+
174
+ type NativePart = { type?: unknown; text?: unknown } & Record<string, unknown>
175
+
176
+ interface NativeMessage {
177
+ info?: Record<string, unknown>
178
+ parts?: unknown[]
179
+ }
180
+
181
+ function nativeMessageRole(info: Record<string, unknown> | undefined): MessageRole {
182
+ if (info?.["role"] === "assistant") return "assistant"
183
+ if (info?.["role"] === "system") return "system"
184
+ return "user"
185
+ }
186
+
187
+ interface CompactionAdapterState {
188
+ readonly compactionMessages: CompactionMessage[]
189
+ /** Original native tool part per `callID`, used on the way back to
190
+ * restore everything compaction never needed to know about (metadata,
191
+ * attachments, timing) rather than rebuilding it from scratch. */
192
+ readonly toolPartsByCallId: Map<string, NativeToolPart>
193
+ /** Parts compaction has no vocabulary for (reasoning, step-start, etc.),
194
+ * keyed by the message's own `info` object reference -- `compaction.ts`
195
+ * carries `info` through unchanged on every surviving `CompactionMessage`
196
+ * (see its own module docstring), so looking it back up by identity is
197
+ * reliable regardless of what compaction dropped or kept. */
198
+ readonly passthroughPartsByInfo: Map<object, unknown[]>
199
+ }
200
+
201
+ /** Converts OpenCode's native `{ info, parts }` history entries into T6's
202
+ * `CompactionMessage` shape. A native "tool" part (OpenCode pairs the call
203
+ * and its result into ONE part, unlike Claude-style `tool_use`/
204
+ * `tool_result` content blocks) becomes a `tool_use` block immediately
205
+ * followed, in the SAME message, by a `tool_result` block once the call has
206
+ * actually completed or errored -- `compaction.ts`'s pairing logic matches
207
+ * blocks by id regardless of which message(s) they live in, so this is a
208
+ * faithful, simpler-than-splitting-messages encoding. */
209
+ function toCompactionMessages(nativeMessages: readonly NativeMessage[]): CompactionAdapterState {
210
+ const compactionMessages: CompactionMessage[] = []
211
+ const toolPartsByCallId = new Map<string, NativeToolPart>()
212
+ const passthroughPartsByInfo = new Map<object, unknown[]>()
213
+
214
+ for (const native of nativeMessages) {
215
+ const info = native.info
216
+ const role = nativeMessageRole(info)
217
+ const blocks: ContentBlock[] = []
218
+ const passthrough: unknown[] = []
219
+
220
+ for (const rawPart of native.parts ?? []) {
221
+ const part = rawPart as NativePart
222
+ if (part.type === "text" && typeof part.text === "string") {
223
+ blocks.push({ type: "text", text: part.text })
224
+ continue
225
+ }
226
+ if (part.type === "tool" && typeof part.callID === "string") {
227
+ const toolPart = part as NativeToolPart
228
+ const callId = part.callID
229
+ toolPartsByCallId.set(callId, toolPart)
230
+ blocks.push({
231
+ type: "tool_use",
232
+ id: callId,
233
+ name: typeof toolPart.tool === "string" ? toolPart.tool : "unknown",
234
+ input: toolPart.state?.input,
235
+ })
236
+ const status = toolPart.state?.status
237
+ if (status === "completed" || status === "error") {
238
+ const output = toolPart.state?.output
239
+ const errorValue = toolPart.state?.error
240
+ const content =
241
+ typeof output === "string"
242
+ ? output
243
+ : typeof errorValue === "string"
244
+ ? errorValue
245
+ : JSON.stringify(output ?? errorValue ?? "")
246
+ blocks.push({ type: "tool_result", tool_use_id: callId, content })
247
+ }
248
+ continue
249
+ }
250
+ passthrough.push(rawPart)
251
+ }
252
+
253
+ if (passthrough.length > 0 && info) passthroughPartsByInfo.set(info, passthrough)
254
+ compactionMessages.push({ role, content: blocks, info: info as CompactionMessage["info"] })
255
+ }
256
+
257
+ return { compactionMessages, toolPartsByCallId, passthroughPartsByInfo }
258
+ }
259
+
260
+ /** Reverses `toCompactionMessages`, rebuilding native `{ info, parts }`
261
+ * entries from T6's output. A kept pair's native tool part is reused
262
+ * verbatim (preserving fields compaction never touched); a truncated
263
+ * pair's part is cloned with only `state.output` replaced by the short
264
+ * "omitted" note `compaction.ts` already produced; a dropped pair simply
265
+ * never gets a part emitted. */
266
+ function fromCompactionMessages(
267
+ compactionMessages: readonly CompactionMessage[],
268
+ toolPartsByCallId: ReadonlyMap<string, NativeToolPart>,
269
+ passthroughPartsByInfo: ReadonlyMap<object, unknown[]>,
270
+ ): NativeMessage[] {
271
+ return compactionMessages.map((message) => {
272
+ const parts: unknown[] = []
273
+
274
+ if (typeof message.content === "string") {
275
+ if (message.content) parts.push({ type: "text", text: message.content })
276
+ } else {
277
+ for (const block of message.content) {
278
+ if (block.type === "text") {
279
+ parts.push({ type: "text", text: block.text })
280
+ continue
281
+ }
282
+ if (block.type === "tool_use") {
283
+ const original = toolPartsByCallId.get(block.id)
284
+ if (original) {
285
+ parts.push({
286
+ ...original,
287
+ state: original.state ? { ...original.state, input: block.input } : original.state,
288
+ })
289
+ }
290
+ continue
291
+ }
292
+ if (block.type === "tool_result") {
293
+ // Merge into the tool part this same loop just pushed for the
294
+ // matching `tool_use` -- `compaction.ts` always keeps a call's
295
+ // block immediately before its result's within one message (see
296
+ // `toCompactionMessages`), so it is always the last entry here.
297
+ const lastIndex = parts.length - 1
298
+ const last = parts[lastIndex] as NativeToolPart | undefined
299
+ if (last && last.type === "tool" && last.callID === block.tool_use_id) {
300
+ parts[lastIndex] = { ...last, state: { ...last.state, output: block.content } }
301
+ }
302
+ continue
303
+ }
304
+ }
305
+ }
306
+
307
+ const passthrough = message.info ? passthroughPartsByInfo.get(message.info) : undefined
308
+ if (passthrough) parts.push(...passthrough)
309
+
310
+ return { info: message.info as Record<string, unknown> | undefined, parts }
311
+ })
312
+ }
313
+
314
+ /**
315
+ * The T3 proxy's own request/response contract, independent of whatever
316
+ * OpenRouter's Decisions API itself returns -- T3 treats both `state` and
317
+ * `questions` as opaque and passes `body` straight through, so this plugin
318
+ * (the only side that understands the payload) defines the convention: one
319
+ * question per `asker` call, answered as `{ answers: [{ keepCall,
320
+ * keepResult }] }`. Any other shape is treated as "could not get an answer"
321
+ * and throws, which the outer try/catch in the hook below turns into
322
+ * "skip compaction entirely for this turn" -- never a partial, wrongly-
323
+ * judged compaction.
324
+ */
325
+ function parseDecisionAnswer(body: unknown): AskerAnswer {
326
+ const record = body && typeof body === "object" ? (body as Record<string, unknown>) : null
327
+ const answers = record && Array.isArray(record["answers"]) ? record["answers"] : null
328
+ const first = answers && answers.length > 0 ? answers[0] : null
329
+ const firstRecord = first && typeof first === "object" ? (first as Record<string, unknown>) : null
330
+ const keepCall = firstRecord ? Number(firstRecord["keepCall"]) : NaN
331
+ const keepResult = firstRecord ? Number(firstRecord["keepResult"]) : NaN
332
+ if (!Number.isFinite(keepCall) || !Number.isFinite(keepResult)) {
333
+ throw new Error("compaction decisions response did not contain a usable answer")
334
+ }
335
+ return { keepCall, keepResult }
336
+ }
337
+
338
+ /** Builds the `Asker` the T7 wiring passes to `compactMessages`: one POST to
339
+ * the T3 endpoint (`POST /opencode/teams/{team_id}/compaction/decisions`)
340
+ * per candidate tool round trip, reusing the exact fetch/auth pattern
341
+ * `guardrail-sync.ts` already uses for its own backend call (bearer JWT,
342
+ * `~/.elliot/config.json`'s `backend_url`/`team_id`, an `AbortController`-
343
+ * based timeout). Throws on ANY failure -- offline, non-2xx, `available:
344
+ * false`, or an unparseable body -- by design: the caller (the hook below)
345
+ * wraps the whole compaction attempt in one try/catch, so a single bad
346
+ * answer here safely aborts compaction for this turn rather than risking a
347
+ * wrongly-judged partial result. */
348
+ function makeCompactionAsker(config: {
349
+ backendUrl: string
350
+ teamId: string
351
+ jwtToken: string
352
+ fetchImpl?: typeof fetch
353
+ }): (context: AskerContext) => Promise<AskerAnswer> {
354
+ const fetchImpl = config.fetchImpl ?? fetch
355
+ const base = config.backendUrl.replace(/\/+$/, "")
356
+
357
+ return async (context: AskerContext): Promise<AskerAnswer> => {
358
+ const controller = new AbortController()
359
+ const timer = setTimeout(() => controller.abort(), COMPACTION_ASKER_TIMEOUT_MS)
360
+ try {
361
+ const res = await fetchImpl(`${base}/opencode/teams/${encodeURIComponent(config.teamId)}/compaction/decisions`, {
362
+ method: "POST",
363
+ headers: {
364
+ Authorization: `Bearer ${config.jwtToken}`,
365
+ "Content-Type": "application/json",
366
+ },
367
+ body: JSON.stringify({
368
+ state: context.state,
369
+ questions: [
370
+ { id: context.id, toolName: context.toolName, input: context.input, resultSummary: context.resultSummary },
371
+ ],
372
+ }),
373
+ signal: controller.signal,
374
+ })
375
+ if (!res.ok) throw new Error(`backend returned ${res.status}`)
376
+ const payload: unknown = await res.json()
377
+ const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {}
378
+ if (record["available"] !== true) {
379
+ throw new Error(typeof record["reason"] === "string" ? record["reason"] : "compaction decisions unavailable")
380
+ }
381
+ return parseDecisionAnswer(record["body"])
382
+ } finally {
383
+ clearTimeout(timer)
384
+ }
385
+ }
386
+ }
387
+
86
388
  export const ElliotPlugin: Plugin = async (input, rawOptions) => {
87
389
  // Fail loudly at load on bad options — a plugin that limps with a typo'd
88
390
  // config is worse than one that says why it refused to start.
@@ -92,6 +394,47 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
92
394
  // the one source the model cannot influence.
93
395
  const worktree = input.worktree || input.directory
94
396
 
397
+ // Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
398
+ // pilot.md`, task T9). `experimental.chat.messages.transform` is triggered
399
+ // with an EMPTY `hookInput` (session/prompt.ts), so the compaction outcome
400
+ // it produces can't be correlated to a session id from inside that hook.
401
+ // `chat.params` -- which DOES get `hookInput.sessionID` and already stamps
402
+ // `metadata.session_id` via `stampSessionAffinity` -- fires immediately
403
+ // after, for the SAME turn (session/llm/request.ts builds `chat.params`
404
+ // from the same `msgs` the transform hook just finished mutating).
405
+ //
406
+ // A FIFO QUEUE, not a single mutable slot (code-review finding, post-T9):
407
+ // this plugin instance is a process-wide singleton, and both hooks are
408
+ // `async` -- if two sessions' turns are ever in flight concurrently (very
409
+ // plausible once a turn's compaction attempt awaits a live-flag check plus
410
+ // one network round trip per stale tool-call candidate), a single shared
411
+ // slot lets a slower session A's `transform` overwrite what a faster
412
+ // session B's `transform` just set, before B's own `chat.params` reads it
413
+ // -- silently mislabeling B's `LiteLLM_SpendLogs` row with A's telemetry
414
+ // (or vice versa), which directly undermines the before/after cost
415
+ // comparison T9 exists for. A FIFO queue fixes this without needing a
416
+ // session id in the transform hook at all: each session's own
417
+ // `transform`-then-`params` pair is internally ordered (params always
418
+ // fires after that same turn's transform), so across interleaved
419
+ // sessions the order in which `transform` calls finish and push still
420
+ // matches the order in which the corresponding `chat.params` calls fire
421
+ // and shift -- unlike a single slot, this doesn't require the two calls
422
+ // to be adjacent in time, only in queue order.
423
+ const pendingCompactionTelemetryQueue: CompactionTelemetry[] = []
424
+
425
+ // Wall-clock budget for the ENTIRE compaction attempt (code-review
426
+ // finding, post-T7): `compactMessages` awaits one `asker` call per
427
+ // non-pinned tool-call candidate, sequentially, each already bounded by
428
+ // `COMPACTION_ASKER_TIMEOUT_MS` -- but nothing previously bounded the
429
+ // SUM across many candidates. A turn with N stale candidates could add up
430
+ // to N * COMPACTION_ASKER_TIMEOUT_MS of latency before the outer
431
+ // try/catch below ever got a chance to fail open (that catch only fires
432
+ // on a thrown error, not on cumulative slowness), undercutting "this
433
+ // feature must never be able to break or delay a chat turn." This budget
434
+ // is enforced by racing the whole `compactMessages` call against a timer
435
+ // below, not by changing `compactMessages` itself.
436
+ const COMPACTION_TURN_BUDGET_MS = 15_000
437
+
95
438
  return {
96
439
  /**
97
440
  * One handler, two jobs — a JS object literal may carry only one
@@ -163,6 +506,94 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
163
506
  applyElliotIdentity(output.system)
164
507
  },
165
508
 
509
+ /**
510
+ * Keeps a Jev-blocked prompt from leaking into the MODEL's own view of
511
+ * this conversation's history on every LATER turn (see blocked-turns.ts's
512
+ * own module docstring for the full "why" — a benign follow-up was
513
+ * getting answered alongside an unprompted refusal about the earlier,
514
+ * never-actually-sent request). Purely a context-shaping step: the
515
+ * stored session history (what the UI's own transcript reads) is
516
+ * untouched, only what gets sent to the model next.
517
+ */
518
+ "experimental.chat.messages.transform": async (_hookInput, output) => {
519
+ const filtered = stripBlockedTurns(output.messages)
520
+ if (filtered !== output.messages) {
521
+ output.messages.length = 0
522
+ output.messages.push(...filtered)
523
+ }
524
+
525
+ // Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-
526
+ // compaction-pilot.md`, task T7). Runs strictly AFTER `stripBlockedTurns`
527
+ // above -- compaction must never see a message that was already known
528
+ // to be a blocked turn `stripBlockedTurns` would otherwise have
529
+ // removed. Opt-in (`liveCompactionEnabled`) and entirely fail-open: a
530
+ // `null`/`false` decision, or ANY error/timeout from the live check,
531
+ // the adapter, or the backend call the `asker` makes, leaves
532
+ // `output.messages` exactly as `stripBlockedTurns` left them above --
533
+ // this feature must never be able to break or delay a chat turn,
534
+ // mirroring guardrail-sync.ts's own fail-open posture (see its module
535
+ // docstring) rather than the Jev guardrails' own fail-closed one,
536
+ // since this flag only decides whether a token-reduction pass runs at
537
+ // all, not a security boundary.
538
+ // This turn's own outcome, pushed onto the FIFO queue (see its
539
+ // declaration above) exactly once below -- every path (flag off,
540
+ // missing config, success, timeout, or the catch's fail-open) pushes
541
+ // before returning, so `chat.params` always has exactly one entry per
542
+ // turn to shift, in the same relative order these pushes happened.
543
+ let telemetry: CompactionTelemetry = { applied: false }
544
+ try {
545
+ const compactionDecision = await liveCompactionEnabled()
546
+ if (compactionDecision !== true) return
547
+
548
+ const config = readElliotConfig()
549
+ if (!config?.backend_url || !config.team_id || !config.jwt_token) return
550
+
551
+ const { compactionMessages, toolPartsByCallId, passthroughPartsByInfo } = toCompactionMessages(
552
+ output.messages as unknown as NativeMessage[],
553
+ )
554
+
555
+ const asker = makeCompactionAsker({
556
+ backendUrl: config.backend_url,
557
+ teamId: config.team_id,
558
+ jwtToken: config.jwt_token,
559
+ })
560
+
561
+ // Raced against COMPACTION_TURN_BUDGET_MS (see its declaration
562
+ // above) -- a turn with many stale candidates can't accumulate
563
+ // unbounded latency across sequential asker calls. A timeout here
564
+ // throws into the same catch below as any other failure, so the
565
+ // fail-open behavior is identical either way.
566
+ const result = await Promise.race([
567
+ compactMessages(compactionMessages, { asker }),
568
+ new Promise<never>((_resolve, reject) => {
569
+ setTimeout(() => reject(new Error("compaction turn budget exceeded")), COMPACTION_TURN_BUDGET_MS)
570
+ }),
571
+ ])
572
+ const rebuilt = fromCompactionMessages(result.messages, toolPartsByCallId, passthroughPartsByInfo)
573
+
574
+ output.messages.length = 0
575
+ output.messages.push(...(rebuilt as typeof output.messages))
576
+
577
+ // T9 telemetry: surface T6's own already-computed stats verbatim --
578
+ // no new computation -- for `chat.params` to stamp into
579
+ // `metadata.compaction_applied`/`compaction_state_tokens`/
580
+ // `compaction_reduction_ratio` via `stampSessionAffinity`.
581
+ telemetry = {
582
+ applied: true,
583
+ stateTokens: result.stats.stateTokens,
584
+ reductionRatio: result.stats.reductionRatio,
585
+ }
586
+ } catch {
587
+ // Any failure anywhere in the attempt above (live-flag check,
588
+ // adapter, the asker's HTTP call, compactMessages itself, or the
589
+ // turn-budget timeout) -- do nothing. `output.messages` already
590
+ // reflects whatever `stripBlockedTurns` produced; leave it exactly
591
+ // as is. `telemetry` stays `{ applied: false }` (T9).
592
+ } finally {
593
+ pendingCompactionTelemetryQueue.push(telemetry)
594
+ }
595
+ },
596
+
166
597
  /**
167
598
  * Per-message LiteLLM `end_user` override for a TEAM-shared runtime. See
168
599
  * ./identity-headers.ts for the full rationale. A no-op (static header
@@ -175,6 +606,51 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
175
606
  if (userId) output.headers["x-litellm-end-user-id"] = userId
176
607
  },
177
608
 
609
+ /**
610
+ * The CLI's Jev guardrail session-recovery fix (see guardrail-sync.ts's
611
+ * own module docstring for the full "why this hook, not a retry" case).
612
+ * A `null` live decision (no team context, or the check itself failed —
613
+ * offline, backend down) changes nothing: whatever `eai setup` baked
614
+ * into this model's static `options.metadata.guardrails` is left
615
+ * exactly as it was, so this can only ever make a stale CLI config
616
+ * MORE correct, never less available.
617
+ *
618
+ * Also stamps `metadata.session_id` (see session-affinity.ts) so
619
+ * LiteLLM's `session_affinity` pre-call check can pin every turn of
620
+ * this session to the same upstream deployment — the mechanism that
621
+ * keeps DeepSeek's implicit prefix cache warm across a long agent
622
+ * session (deploy/litellm/config.yaml, `optional_pre_call_checks`).
623
+ *
624
+ * `instanceTeamId` (see guardrail-sync.ts's own docstring on
625
+ * `LiveCheckDeps`) is extracted from THIS request's own `options`
626
+ * rather than trusted from `~/.elliot/config.json` — on a shared-
627
+ * filesystem local-dev backend runtime, those two can disagree (a real
628
+ * person's own CLI login vs. the team this Web Chat request actually
629
+ * belongs to), and the live check must not apply one team's setting to
630
+ * another's request.
631
+ *
632
+ * Jev audit (2026-10, G2): both the pre-call (`jev-policy`) AND
633
+ * post-call (`jev-output-validation`) toggles are now live-synced here
634
+ * — previously only the pre-call flag was. `liveJevPolicyEnabled`/
635
+ * `liveJevOutputValidationEnabled` share one cached `GET /teams/{id}`
636
+ * fetch under the hood (see guardrail-sync.ts), so checking both here
637
+ * costs at most one network round trip, not two.
638
+ */
639
+ "chat.params": async (hookInput, output) => {
640
+ // T9: shift this turn's entry off the FIFO queue `experimental.chat.
641
+ // messages.transform` pushed for it (see the queue's declaration
642
+ // above). `shift()` on an empty queue (a turn that skipped the
643
+ // transform hook entirely) yields `undefined`, defaulted below --
644
+ // never a stale value from a different turn.
645
+ const compactionTelemetry = pendingCompactionTelemetryQueue.shift() ?? { applied: false }
646
+ stampSessionAffinity(output.options, hookInput.sessionID, compactionTelemetry)
647
+ const instanceTeamId = extractInstanceTeamId(output.options)
648
+ const preCallDecision = await liveJevPolicyEnabled({ instanceTeamId })
649
+ applyGuardrailDecision(output.options, preCallDecision)
650
+ const outputValidationDecision = await liveJevOutputValidationEnabled({ instanceTeamId })
651
+ applyGuardrailDecision(output.options, outputValidationDecision, OUTPUT_VALIDATION_GUARDRAIL_NAME)
652
+ },
653
+
178
654
  "permission.ask": async (permInput, output) => {
179
655
  if (permInput.type !== "bash") return
180
656
  const command = permInput.metadata?.["command"]
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Per-request LiteLLM session-affinity stamping.
3
+ *
4
+ * LiteLLM v1.101.0's `DeploymentAffinityCheck` — switched on by
5
+ * `optional_pre_call_checks: ["session_affinity"]` in
6
+ * deploy/litellm/config.yaml — pins consecutive turns of one conversation to
7
+ * the SAME upstream deployment, so DeepSeek's implicit prefix cache stays on
8
+ * the warm node. Verified in the v1.101.0 source
9
+ * (`deployment_affinity_check.py::_get_session_id_from_metadata_dict`): it
10
+ * keys on `metadata.session_id` in the REQUEST METADATA, NOT on the
11
+ * `x-session-affinity` / `x-opencode-session-id` HEADERS OpenCode already
12
+ * sends on every turn (session/llm/request.ts) — those are ignored for
13
+ * affinity in this version. Without this stamp, `session_affinity` in the
14
+ * router config would be dead config.
15
+ *
16
+ * This hook writes the id into `options.metadata.session_id` — the exact
17
+ * metadata channel the existing `metadata.guardrails` mechanism
18
+ * (guardrail-sync.ts) already proves reaches LiteLLM's request metadata end
19
+ * to end. Never throws, never fabricates: a missing session id or a
20
+ * non-object `metadata` simply leaves the request unstamped (the static,
21
+ * unchanged pre-existing request still goes out).
22
+ */
23
+
24
+ /**
25
+ * Fast Jev Compaction pilot (spec: `specs/2026-10-29-fast-jev-compaction-
26
+ * pilot.md`, task T9) telemetry handed to `stampSessionAffinity` alongside
27
+ * the session id, so a before/after cost comparison can later be built
28
+ * purely from `LiteLLM_SpendLogs` metadata filtered by `session_id` and
29
+ * `compaction_applied` (see `litellm_spend.py`'s existing per-request
30
+ * `prompt_tokens`/`completion_tokens` fields) -- no new computation here,
31
+ * these are T6's own `CompactionStats` fields, surfaced as-is.
32
+ */
33
+ export interface CompactionTelemetry {
34
+ readonly applied: boolean
35
+ readonly stateTokens?: number
36
+ readonly reductionRatio?: number
37
+ }
38
+
39
+ /**
40
+ * Stamp the OpenCode session id into a request's `metadata.session_id` so
41
+ * LiteLLM can route every turn of the same session to the same deployment.
42
+ * `options` is the same merged options object `chat.params` hands the caller
43
+ * (`opencode/.../session/llm/request.ts`), the one `metadata.guardrails`
44
+ * already travels in.
45
+ *
46
+ * Also stamps `metadata.compaction_applied` (T9) -- `false`, with
47
+ * `compaction_state_tokens`/`compaction_reduction_ratio` absent, whenever
48
+ * `compaction` is omitted or `compaction.applied` is `false` (flag off, or
49
+ * the compaction attempt in `experimental.chat.messages.transform` failed
50
+ * open); `true` plus both numeric fields when it actually ran and produced
51
+ * a result.
52
+ */
53
+ export function stampSessionAffinity(
54
+ options: Record<string, unknown> | null | undefined,
55
+ sessionID: string | undefined,
56
+ compaction?: CompactionTelemetry,
57
+ ): void {
58
+ if (!options || typeof options !== "object" || !sessionID) return
59
+ const metadata =
60
+ options.metadata && typeof options.metadata === "object" && !Array.isArray(options.metadata)
61
+ ? (options.metadata as Record<string, unknown>)
62
+ : (options.metadata = {} as Record<string, unknown>)
63
+ metadata["session_id"] = sessionID
64
+
65
+ metadata["compaction_applied"] = compaction?.applied ?? false
66
+ if (compaction?.applied) {
67
+ metadata["compaction_state_tokens"] = compaction.stateTokens
68
+ metadata["compaction_reduction_ratio"] = compaction.reductionRatio
69
+ } else {
70
+ delete metadata["compaction_state_tokens"]
71
+ delete metadata["compaction_reduction_ratio"]
72
+ }
73
+ }